From 96961f23f5f8dba5bac6111379dcdc0870b275f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Mon, 13 Jul 2026 10:12:00 +0200 Subject: [PATCH 001/326] docs: new root as prior history lost (orphan + GC); ~215 commits not recoverable --- .gitignore | 7 + 2026/2026-06-29.md | 4 + 2026/2026-06-30.md | 4 + 2026/2026-07-01.md | 3 + 2026/2026-07-02.md | 3 + 2026/2026-07-07.md | 3 + 2026/2026-07-08.md | 6 + ...26-07-09--amount-ladder-partial-results.md | 47 + 2026/2026-07-09--bonfire-ground-zero.md | 46 + 2026/2026-07-09--castopod-content.md | 34 + ...26-07-09--exchange-snapshot-and-restore.md | 201 ++ 2026/2026-07-09--ipv6-15d6.md | 34 + 2026/2026-07-09--keys-wire.md | 17 + 2026/2026-07-09--koopa-prime-jellyfin.md | 43 + 2026/2026-07-09--merchant-bank-link.md | 8 + 2026/2026-07-09.md | 6 + 2026/2026-07-10--admin-log-origin-forgejo.md | 40 + 2026/2026-07-10--bonfire-federate.md | 60 + 2026/2026-07-10--forgejo-rootless.md | 90 + ...2026-07-10--forgejo-theme-exchange-dark.md | 27 + 2026/2026-07-10.md | 21 + 2026/2026-07-11--bonfire-branding-feeds.md | 25 + 2026/2026-07-11--bonfire-gitbot-systemd.md | 26 + 2026/2026-07-11--bonfire-logo-not-served.md | 19 + 2026/2026-07-11.md | 5 + 2026/2026-07-13--castopod-boot-branding.md | 31 + 2026/README.md | 3 + README.md | 86 + SECRETS.md | 34 + benchmarks/README.md | 10 + benchmarks/amount-ladder/README.md | 90 + benchmarks/amount-ladder/run.sh | 375 +++ .../amount-ladder/sample-load-inside.sh | 26 + configs/README.md | 19 + configs/bank-landing/README.md | 250 ++ configs/bank-landing/index.html | 2055 +++++++++++++++++ configs/bank-landing/nginx-cors-stats.conf | 6 + configs/bank-landing/nginx-landing.conf | 71 + configs/bank-landing/og-goa-shop.png | Bin 0 -> 557173 bytes configs/bank-landing/privacy.html | 137 ++ configs/bank-landing/qr-logo.png | Bin 0 -> 8563 bytes configs/bank-landing/qr-logo.svg | 1 + configs/bank-landing/qr-payto.png | Bin 0 -> 602 bytes .../bank-landing/qr-product-beacon-badge.png | Bin 0 -> 608 bytes .../qr-product-blue-or-red-pill.png | Bin 0 -> 617 bytes configs/bank-landing/qr-product-comet-cap.png | Bin 0 -> 524 bytes .../qr-product-eclipse-shades.png | Bin 0 -> 609 bytes .../bank-landing/qr-product-nebula-coffee.png | Bin 0 -> 604 bytes .../bank-landing/qr-product-orbit-sticker.png | Bin 0 -> 597 bytes .../bank-landing/qr-product-rainbow-pill.png | Bin 0 -> 599 bytes configs/bank-landing/qr-product-relay-pin.png | Bin 0 -> 528 bytes .../bank-landing/qr-product-shuttle-pass.png | Bin 0 -> 609 bytes .../bank-landing/qr-product-star-chart.png | Bin 0 -> 527 bytes .../qr-product-voidwave-playlist.png | Bin 0 -> 602 bytes configs/bank-landing/qrcode.min.js | 1 + configs/bank-landing/shop-pay.css | 348 +++ configs/bank-landing/shop-pay.js | 410 ++++ configs/bank-landing/terms.html | 75 + configs/bonfire/README.md | 28 + configs/bonfire/assets/img/favicon.svg | 90 + configs/bonfire/assets/img/logo.png | Bin 0 -> 98601 bytes configs/bonfire/assets/img/logo.svg | 90 + configs/bonfire/compose.yml | 55 + .../container-koopa-bonfire-db.service | 21 + .../bonfire/container-koopa-bonfire.service | 21 + configs/bonfire/gitbot-mirror.service | 18 + configs/bonfire/public-feeds.md | 11 + configs/bonfire/theme-hacktivism.json | 29 + configs/caddy/Caddyfile | 185 ++ configs/caddy/Caddyfile.taler-host | 24 + configs/caddy/README.md | 9 + configs/caddy/git.hacktivism.ch.caddy | 15 + configs/castopod/README.md | 21 + .../assets/css/theme-hacktivism-overlay.css | 64 + configs/castopod/assets/img/favicon.svg | 90 + configs/castopod/assets/img/logo.png | Bin 0 -> 98601 bytes configs/castopod/assets/img/logo.svg | 90 + configs/castopod/compose.yml | 81 + .../container-koopa-castopod-mariadb.service | 21 + .../container-koopa-castopod-redis.service | 21 + .../castopod/container-koopa-castopod.service | 21 + configs/exchange-landing/README.md | 23 + configs/exchange-landing/index.html | 603 +++++ configs/exchange-landing/nginx-landing.conf | 24 + configs/exchange-landing/og-goa-shop.png | Bin 0 -> 557173 bytes configs/exchange-landing/qr-logo.png | Bin 0 -> 8563 bytes configs/exchange-landing/qr-logo.svg | 1 + configs/firewalld/public-ports.md | 61 + configs/forgejo/README.md | 60 + configs/forgejo/app.ini.example | 79 + .../forgejo/assets/css/theme-hacktivism.css | 275 +++ configs/forgejo/assets/img/favicon.svg | 90 + configs/forgejo/assets/img/logo.png | Bin 0 -> 98601 bytes configs/forgejo/assets/img/logo.svg | 90 + configs/forgejo/compose.yml | 86 + configs/merchant-landing/README.md | 42 + configs/merchant-landing/index.html | 1016 ++++++++ configs/merchant-landing/nginx-landing.conf | 24 + configs/merchant-landing/og-goa-shop.png | Bin 0 -> 557173 bytes configs/merchant-landing/qr-logo.png | Bin 0 -> 8563 bytes configs/merchant-landing/qr-logo.svg | 1 + configs/merchant-landing/qr-payto.png | Bin 0 -> 602 bytes .../qr-product-beacon-badge.png | Bin 0 -> 608 bytes .../qr-product-blue-or-red-pill.png | Bin 0 -> 617 bytes .../merchant-landing/qr-product-comet-cap.png | Bin 0 -> 524 bytes .../qr-product-eclipse-shades.png | Bin 0 -> 609 bytes .../qr-product-nebula-coffee.png | Bin 0 -> 604 bytes .../qr-product-orbit-sticker.png | Bin 0 -> 597 bytes .../qr-product-rainbow-pill.png | Bin 0 -> 599 bytes .../merchant-landing/qr-product-relay-pin.png | Bin 0 -> 528 bytes .../qr-product-shuttle-pass.png | Bin 0 -> 609 bytes .../qr-product-star-chart.png | Bin 0 -> 527 bytes .../qr-product-voidwave-playlist.png | Bin 0 -> 602 bytes configs/merchant-landing/qrcode.min.js | 1 + configs/merchant-landing/shop-pay.css | 348 +++ configs/merchant-landing/shop-pay.js | 410 ++++ configs/ports.md | 25 + configs/prime/README.md | 16 + configs/prime/compose.yml | 47 + configs/shared/goa-stats-snippet.html | 39 + configs/shared/goa-stats.css | 86 + configs/shared/goa-stats.js | 130 ++ .../systemd/caddy.service.d-no-resume.conf | 3 + configs/systemd/http-proxy.socket | 11 + configs/systemd/https-proxy.socket | 11 + configs/systemd/https-proxy@.service | 5 + configs/taler-exchange-ansible/Containerfile | 26 + configs/taler-exchange-ansible/README.md | 33 + .../deploy-hacktivism-goa.sh | 25 + .../inventories-default.snippet | 6 + configs/taler-exchange-ansible/public.yml | 31 + .../run-container-koopa.sh | 45 + configs/taler-exchange/README.md | 44 + .../taler-exchange/conf.d-exchange-coins.conf | 331 +++ .../taler-exchange/exchange-overrides.conf | 52 + .../taler-exchange.conf.includes.txt | 3 + .../terms-src/no-privacy-v0.en.rst | 15 + .../terms-src/no-terms-v0.en.rst | 16 + configs/taler-hacktivism-bank/README.md | 98 + .../taler-hacktivism-bank/bank-overrides.conf | 39 + .../libeufin-bank.conf.includes.txt | 7 + configs/taler-hacktivism/README.md | 63 + configs/taler-hacktivism/conf.d-merchant.conf | 7 + .../demo-instance-goa-demo-cp4zqk.md | 63 + .../taler-hacktivism/merchant-overrides.conf | 71 + .../nginx-taler-merchant.conf | 50 + .../taler-merchant.conf.includes.txt | 5 + .../terms-src/merchant-tos-dual-v0.en.rst | 43 + configs/tops/Caddyfile.snippet | 29 + configs/tops/README.md | 73 + configs/tops/compose.yml | 61 + configs/tops/container-koopa-tops.service | 35 + configs/tops/nginx-default.conf | 47 + configs/tor/Containerfile | 10 + configs/tor/README.md | 53 + configs/tor/container-koopa-tor-relay.service | 28 + configs/tor/migrate-identity.sh | 39 + configs/tor/torrc | 16 + configs/tor/torrc.minimal | 192 ++ configs/tor/torrc.sample | 257 +++ host/README.md | 16 + host/caddy/Caddyfile | 185 ++ host/caddy/README.md | 19 + host/firewalld/README.md | 18 + host/firewalld/list-all.txt | 19 + host/network/README.md | 10 + host/overview/LIVE.md | 104 + host/overview/services.md | 100 + host/overview/services.txt | 24 + host/systemd/README.md | 13 + host/systemd/caddy.service.d-no-resume.conf | 3 + host/systemd/http-proxy.socket | 11 + host/systemd/https-proxy.socket | 11 + host/systemd/https-proxy@.service | 5 + host/tor/README.md | 27 + ops/README.md | 5 + ops/ROOT_HYGIENE.md | 11 + scripts/README.md | 47 + scripts/bonfire/README.md | 13 + scripts/bonfire/apply-branding.exs | 56 + scripts/bonfire/apply-branding.sh | 55 + scripts/bonfire/gitbot-mirror.py | 240 ++ scripts/bonfire/install-systemd.sh | 29 + scripts/bonfire/publish-outbox-to-public.sql | 28 + scripts/caddy/README.md | 15 + scripts/castopod/README.md | 33 + scripts/castopod/apply-branding.sh | 137 ++ scripts/castopod/install-systemd.sh | 41 + scripts/castopod/lib.sh | 116 + scripts/castopod/status.sh | 21 + scripts/castopod/up.sh | 32 + scripts/monitoring/countries.txt | 249 ++ scripts/monitoring/tor_inbound_connects.sh | 8 + .../tor_show_relays--koopa+firecuda.sh | 2 + scripts/monitoring/tor_show_relays.sh | 108 + scripts/monitoring/tor_stats_per_country.sh | 83 + scripts/taler-bank/README.md | 71 + .../taler-bank/auto-confirm-withdrawals.sh | 143 ++ scripts/taler-bank/check_bank-health.sh | 48 + scripts/taler-bank/credit-account.sh | 96 + scripts/taler-bank/demo-withdraw-api.py | 316 +++ .../taler-bank/install-demo-withdraw-api.sh | 80 + scripts/taler-bank/landing-stats-install.sh | 78 + scripts/taler-bank/landing-stats.sh | 612 +++++ scripts/taler-bank/make-demo-withdraw-qr.sh | 112 + scripts/taler-bank/refresh-demo-withdraw.sh | 54 + scripts/taler-bank/start_bank.sh | 109 + .../start_base_services_for_taler_bank.sh | 148 ++ scripts/taler-dns/README.md | 48 + scripts/taler-dns/bake-hosts-into-images.sh | 116 + scripts/taler-dns/pin-container-hosts.sh | 82 + .../pin-hacktivism-hosts.in-container.sh | 31 + .../taler-dns/pin-hacktivism-hosts.service | 16 + scripts/taler-exchange/archive/README.md | 35 + .../archive/exchange-bootstrap.sh | 451 ++++ .../taler-exchange/check_exchange-health.sh | 88 + .../taler-exchange/ensure_exchange_helpers.sh | 75 + scripts/taler-exchange/install_no_terms.sh | 263 +++ .../taler-exchange/install_swiss_privacy.sh | 168 ++ .../taler-exchange/landing-stats-exchange.sh | 300 +++ .../offline-sign-upload-keys.sh | 62 + .../start_base_services_for_taler_exchange.sh | 148 ++ scripts/taler-exchange/start_exchange.sh | 104 + scripts/taler-exchange/start_wire_helpers.sh | 43 + .../taler-exchange/wire-enable-and-upload.sh | 91 + scripts/taler-landing/deploy-landings.sh | 72 + scripts/taler-merchant/README.md | 56 + scripts/taler-merchant/certbot_renew.sh | 5 + .../taler-merchant/check_merchant-health.sh | 222 ++ .../taler-merchant/ensure_merchant_helpers.sh | 100 + scripts/taler-merchant/install_dual_terms.sh | 232 ++ .../taler-merchant/install_swiss_privacy.sh | 195 ++ .../taler-merchant/landing-stats-merchant.sh | 439 ++++ scripts/taler-merchant/setup_credit_facade.sh | 91 + .../start_base_services_for_taler.sh | 133 ++ scripts/taler-merchant/start_merchant.sh | 126 + .../stats--merchant-payments.sh | 33 + .../taler-hacktivism-email-helper.sh | 19 + .../taler-hacktivism-sms-helper-wrapper.sh | 5 + .../taler-merchant-wirewatch-supervise.sh | 26 + scripts/taler-monitoring/README.md | 100 + scripts/taler-monitoring/TESTS.md | 165 ++ scripts/taler-monitoring/check_e2e.sh | 1220 ++++++++++ scripts/taler-monitoring/check_inside.sh | 141 ++ scripts/taler-monitoring/check_sanity.sh | 281 +++ scripts/taler-monitoring/check_server.sh | 128 + scripts/taler-monitoring/check_urls.sh | 636 +++++ scripts/taler-monitoring/check_versions.sh | 469 ++++ scripts/taler-monitoring/harness-live.txt | 7 + scripts/taler-monitoring/harness-tests.txt | 41 + scripts/taler-monitoring/lib.sh | 536 +++++ scripts/taler-monitoring/taler-monitoring.sh | 198 ++ scripts/taler-sanity/README.md | 31 + .../taler-sanity/check_exchange-wirewatch.sh | 92 + scripts/taler-sanity/check_helpers-running.sh | 81 + scripts/taler-sanity/check_merchant-delays.sh | 72 + scripts/taler-sanity/check_settlement.sh | 119 + scripts/taler-sanity/check_stack-health.sh | 73 + scripts/taler-sanity/lib.sh | 60 + scripts/taler-sanity/run_all.sh | 22 + scripts/taler-shared/install_privacy_docs.sh | 74 + scripts/taler-shared/mem-snapshot.sh | 130 ++ scripts/taler-shared/terms-style.css.fragment | 37 + .../taler-wallet-cli/goa-deb-withdraw-pay.sh | 32 + .../goa-posix-withdraw-pay.sh | 41 + .../run-amount-ladder-bench.sh | 3 + .../run-extreme-amount-tests.sh | 350 +++ scripts/taler-wallet-cli/withdraw-qr1-goa.sh | 54 + 268 files changed, 24161 insertions(+) create mode 100644 .gitignore create mode 100644 2026/2026-06-29.md create mode 100644 2026/2026-06-30.md create mode 100644 2026/2026-07-01.md create mode 100644 2026/2026-07-02.md create mode 100644 2026/2026-07-07.md create mode 100644 2026/2026-07-08.md create mode 100644 2026/2026-07-09--amount-ladder-partial-results.md create mode 100644 2026/2026-07-09--bonfire-ground-zero.md create mode 100644 2026/2026-07-09--castopod-content.md create mode 100644 2026/2026-07-09--exchange-snapshot-and-restore.md create mode 100644 2026/2026-07-09--ipv6-15d6.md create mode 100644 2026/2026-07-09--keys-wire.md create mode 100644 2026/2026-07-09--koopa-prime-jellyfin.md create mode 100644 2026/2026-07-09--merchant-bank-link.md create mode 100644 2026/2026-07-09.md create mode 100644 2026/2026-07-10--admin-log-origin-forgejo.md create mode 100644 2026/2026-07-10--bonfire-federate.md create mode 100644 2026/2026-07-10--forgejo-rootless.md create mode 100644 2026/2026-07-10--forgejo-theme-exchange-dark.md create mode 100644 2026/2026-07-10.md create mode 100644 2026/2026-07-11--bonfire-branding-feeds.md create mode 100644 2026/2026-07-11--bonfire-gitbot-systemd.md create mode 100644 2026/2026-07-11--bonfire-logo-not-served.md create mode 100644 2026/2026-07-11.md create mode 100644 2026/2026-07-13--castopod-boot-branding.md create mode 100644 2026/README.md create mode 100644 README.md create mode 100644 SECRETS.md create mode 100644 benchmarks/README.md create mode 100644 benchmarks/amount-ladder/README.md create mode 100755 benchmarks/amount-ladder/run.sh create mode 100755 benchmarks/amount-ladder/sample-load-inside.sh create mode 100644 configs/README.md create mode 100644 configs/bank-landing/README.md create mode 100644 configs/bank-landing/index.html create mode 100644 configs/bank-landing/nginx-cors-stats.conf create mode 100644 configs/bank-landing/nginx-landing.conf create mode 100644 configs/bank-landing/og-goa-shop.png create mode 100644 configs/bank-landing/privacy.html create mode 100644 configs/bank-landing/qr-logo.png create mode 100644 configs/bank-landing/qr-logo.svg create mode 100644 configs/bank-landing/qr-payto.png create mode 100644 configs/bank-landing/qr-product-beacon-badge.png create mode 100644 configs/bank-landing/qr-product-blue-or-red-pill.png create mode 100644 configs/bank-landing/qr-product-comet-cap.png create mode 100644 configs/bank-landing/qr-product-eclipse-shades.png create mode 100644 configs/bank-landing/qr-product-nebula-coffee.png create mode 100644 configs/bank-landing/qr-product-orbit-sticker.png create mode 100644 configs/bank-landing/qr-product-rainbow-pill.png create mode 100644 configs/bank-landing/qr-product-relay-pin.png create mode 100644 configs/bank-landing/qr-product-shuttle-pass.png create mode 100644 configs/bank-landing/qr-product-star-chart.png create mode 100644 configs/bank-landing/qr-product-voidwave-playlist.png create mode 100644 configs/bank-landing/qrcode.min.js create mode 100644 configs/bank-landing/shop-pay.css create mode 100644 configs/bank-landing/shop-pay.js create mode 100644 configs/bank-landing/terms.html create mode 100644 configs/bonfire/README.md create mode 100644 configs/bonfire/assets/img/favicon.svg create mode 100644 configs/bonfire/assets/img/logo.png create mode 100644 configs/bonfire/assets/img/logo.svg create mode 100644 configs/bonfire/compose.yml create mode 100644 configs/bonfire/container-koopa-bonfire-db.service create mode 100644 configs/bonfire/container-koopa-bonfire.service create mode 100644 configs/bonfire/gitbot-mirror.service create mode 100644 configs/bonfire/public-feeds.md create mode 100644 configs/bonfire/theme-hacktivism.json create mode 100644 configs/caddy/Caddyfile create mode 100644 configs/caddy/Caddyfile.taler-host create mode 100644 configs/caddy/README.md create mode 100644 configs/caddy/git.hacktivism.ch.caddy create mode 100644 configs/castopod/README.md create mode 100644 configs/castopod/assets/css/theme-hacktivism-overlay.css create mode 100644 configs/castopod/assets/img/favicon.svg create mode 100644 configs/castopod/assets/img/logo.png create mode 100644 configs/castopod/assets/img/logo.svg create mode 100644 configs/castopod/compose.yml create mode 100644 configs/castopod/container-koopa-castopod-mariadb.service create mode 100644 configs/castopod/container-koopa-castopod-redis.service create mode 100644 configs/castopod/container-koopa-castopod.service create mode 100644 configs/exchange-landing/README.md create mode 100644 configs/exchange-landing/index.html create mode 100644 configs/exchange-landing/nginx-landing.conf create mode 100644 configs/exchange-landing/og-goa-shop.png create mode 100644 configs/exchange-landing/qr-logo.png create mode 100644 configs/exchange-landing/qr-logo.svg create mode 100644 configs/firewalld/public-ports.md create mode 100644 configs/forgejo/README.md create mode 100644 configs/forgejo/app.ini.example create mode 100644 configs/forgejo/assets/css/theme-hacktivism.css create mode 100644 configs/forgejo/assets/img/favicon.svg create mode 100644 configs/forgejo/assets/img/logo.png create mode 100644 configs/forgejo/assets/img/logo.svg create mode 100644 configs/forgejo/compose.yml create mode 100644 configs/merchant-landing/README.md create mode 100644 configs/merchant-landing/index.html create mode 100644 configs/merchant-landing/nginx-landing.conf create mode 100644 configs/merchant-landing/og-goa-shop.png create mode 100644 configs/merchant-landing/qr-logo.png create mode 100644 configs/merchant-landing/qr-logo.svg create mode 100644 configs/merchant-landing/qr-payto.png create mode 100644 configs/merchant-landing/qr-product-beacon-badge.png create mode 100644 configs/merchant-landing/qr-product-blue-or-red-pill.png create mode 100644 configs/merchant-landing/qr-product-comet-cap.png create mode 100644 configs/merchant-landing/qr-product-eclipse-shades.png create mode 100644 configs/merchant-landing/qr-product-nebula-coffee.png create mode 100644 configs/merchant-landing/qr-product-orbit-sticker.png create mode 100644 configs/merchant-landing/qr-product-rainbow-pill.png create mode 100644 configs/merchant-landing/qr-product-relay-pin.png create mode 100644 configs/merchant-landing/qr-product-shuttle-pass.png create mode 100644 configs/merchant-landing/qr-product-star-chart.png create mode 100644 configs/merchant-landing/qr-product-voidwave-playlist.png create mode 100644 configs/merchant-landing/qrcode.min.js create mode 100644 configs/merchant-landing/shop-pay.css create mode 100644 configs/merchant-landing/shop-pay.js create mode 100644 configs/ports.md create mode 100644 configs/prime/README.md create mode 100644 configs/prime/compose.yml create mode 100644 configs/shared/goa-stats-snippet.html create mode 100644 configs/shared/goa-stats.css create mode 100644 configs/shared/goa-stats.js create mode 100644 configs/systemd/caddy.service.d-no-resume.conf create mode 100644 configs/systemd/http-proxy.socket create mode 100644 configs/systemd/https-proxy.socket create mode 100644 configs/systemd/https-proxy@.service create mode 100644 configs/taler-exchange-ansible/Containerfile create mode 100644 configs/taler-exchange-ansible/README.md create mode 100755 configs/taler-exchange-ansible/deploy-hacktivism-goa.sh create mode 100644 configs/taler-exchange-ansible/inventories-default.snippet create mode 100644 configs/taler-exchange-ansible/public.yml create mode 100755 configs/taler-exchange-ansible/run-container-koopa.sh create mode 100644 configs/taler-exchange/README.md create mode 100644 configs/taler-exchange/conf.d-exchange-coins.conf create mode 100644 configs/taler-exchange/exchange-overrides.conf create mode 100644 configs/taler-exchange/taler-exchange.conf.includes.txt create mode 100644 configs/taler-exchange/terms-src/no-privacy-v0.en.rst create mode 100644 configs/taler-exchange/terms-src/no-terms-v0.en.rst create mode 100644 configs/taler-hacktivism-bank/README.md create mode 100644 configs/taler-hacktivism-bank/bank-overrides.conf create mode 100644 configs/taler-hacktivism-bank/libeufin-bank.conf.includes.txt create mode 100644 configs/taler-hacktivism/README.md create mode 100644 configs/taler-hacktivism/conf.d-merchant.conf create mode 100644 configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md create mode 100644 configs/taler-hacktivism/merchant-overrides.conf create mode 100644 configs/taler-hacktivism/nginx-taler-merchant.conf create mode 100644 configs/taler-hacktivism/taler-merchant.conf.includes.txt create mode 100644 configs/taler-hacktivism/terms-src/merchant-tos-dual-v0.en.rst create mode 100644 configs/tops/Caddyfile.snippet create mode 100644 configs/tops/README.md create mode 100644 configs/tops/compose.yml create mode 100644 configs/tops/container-koopa-tops.service create mode 100644 configs/tops/nginx-default.conf create mode 100644 configs/tor/Containerfile create mode 100644 configs/tor/README.md create mode 100644 configs/tor/container-koopa-tor-relay.service create mode 100755 configs/tor/migrate-identity.sh create mode 100644 configs/tor/torrc create mode 100644 configs/tor/torrc.minimal create mode 100644 configs/tor/torrc.sample create mode 100644 host/README.md create mode 100644 host/caddy/Caddyfile create mode 100644 host/caddy/README.md create mode 100644 host/firewalld/README.md create mode 100644 host/firewalld/list-all.txt create mode 100644 host/network/README.md create mode 100644 host/overview/LIVE.md create mode 100644 host/overview/services.md create mode 100644 host/overview/services.txt create mode 100644 host/systemd/README.md create mode 100644 host/systemd/caddy.service.d-no-resume.conf create mode 100644 host/systemd/http-proxy.socket create mode 100644 host/systemd/https-proxy.socket create mode 100644 host/systemd/https-proxy@.service create mode 100644 host/tor/README.md create mode 100644 ops/README.md create mode 100644 ops/ROOT_HYGIENE.md create mode 100644 scripts/README.md create mode 100644 scripts/bonfire/README.md create mode 100644 scripts/bonfire/apply-branding.exs create mode 100755 scripts/bonfire/apply-branding.sh create mode 100755 scripts/bonfire/gitbot-mirror.py create mode 100755 scripts/bonfire/install-systemd.sh create mode 100644 scripts/bonfire/publish-outbox-to-public.sql create mode 100644 scripts/caddy/README.md create mode 100644 scripts/castopod/README.md create mode 100755 scripts/castopod/apply-branding.sh create mode 100755 scripts/castopod/install-systemd.sh create mode 100644 scripts/castopod/lib.sh create mode 100644 scripts/castopod/status.sh create mode 100644 scripts/castopod/up.sh create mode 100755 scripts/monitoring/countries.txt create mode 100755 scripts/monitoring/tor_inbound_connects.sh create mode 100755 scripts/monitoring/tor_show_relays--koopa+firecuda.sh create mode 100755 scripts/monitoring/tor_show_relays.sh create mode 100755 scripts/monitoring/tor_stats_per_country.sh create mode 100644 scripts/taler-bank/README.md create mode 100755 scripts/taler-bank/auto-confirm-withdrawals.sh create mode 100755 scripts/taler-bank/check_bank-health.sh create mode 100755 scripts/taler-bank/credit-account.sh create mode 100755 scripts/taler-bank/demo-withdraw-api.py create mode 100755 scripts/taler-bank/install-demo-withdraw-api.sh create mode 100644 scripts/taler-bank/landing-stats-install.sh create mode 100755 scripts/taler-bank/landing-stats.sh create mode 100755 scripts/taler-bank/make-demo-withdraw-qr.sh create mode 100755 scripts/taler-bank/refresh-demo-withdraw.sh create mode 100755 scripts/taler-bank/start_bank.sh create mode 100755 scripts/taler-bank/start_base_services_for_taler_bank.sh create mode 100644 scripts/taler-dns/README.md create mode 100644 scripts/taler-dns/bake-hosts-into-images.sh create mode 100755 scripts/taler-dns/pin-container-hosts.sh create mode 100644 scripts/taler-dns/pin-hacktivism-hosts.in-container.sh create mode 100644 scripts/taler-dns/pin-hacktivism-hosts.service create mode 100644 scripts/taler-exchange/archive/README.md create mode 100755 scripts/taler-exchange/archive/exchange-bootstrap.sh create mode 100755 scripts/taler-exchange/check_exchange-health.sh create mode 100755 scripts/taler-exchange/ensure_exchange_helpers.sh create mode 100755 scripts/taler-exchange/install_no_terms.sh create mode 100644 scripts/taler-exchange/install_swiss_privacy.sh create mode 100644 scripts/taler-exchange/landing-stats-exchange.sh create mode 100755 scripts/taler-exchange/offline-sign-upload-keys.sh create mode 100755 scripts/taler-exchange/start_base_services_for_taler_exchange.sh create mode 100755 scripts/taler-exchange/start_exchange.sh create mode 100755 scripts/taler-exchange/start_wire_helpers.sh create mode 100755 scripts/taler-exchange/wire-enable-and-upload.sh create mode 100644 scripts/taler-landing/deploy-landings.sh create mode 100644 scripts/taler-merchant/README.md create mode 100755 scripts/taler-merchant/certbot_renew.sh create mode 100755 scripts/taler-merchant/check_merchant-health.sh create mode 100755 scripts/taler-merchant/ensure_merchant_helpers.sh create mode 100644 scripts/taler-merchant/install_dual_terms.sh create mode 100644 scripts/taler-merchant/install_swiss_privacy.sh create mode 100644 scripts/taler-merchant/landing-stats-merchant.sh create mode 100755 scripts/taler-merchant/setup_credit_facade.sh create mode 100755 scripts/taler-merchant/start_base_services_for_taler.sh create mode 100755 scripts/taler-merchant/start_merchant.sh create mode 100755 scripts/taler-merchant/stats--merchant-payments.sh create mode 100755 scripts/taler-merchant/taler-hacktivism-email-helper.sh create mode 100755 scripts/taler-merchant/taler-hacktivism-sms-helper-wrapper.sh create mode 100755 scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh create mode 100644 scripts/taler-monitoring/README.md create mode 100644 scripts/taler-monitoring/TESTS.md create mode 100755 scripts/taler-monitoring/check_e2e.sh create mode 100755 scripts/taler-monitoring/check_inside.sh create mode 100755 scripts/taler-monitoring/check_sanity.sh create mode 100755 scripts/taler-monitoring/check_server.sh create mode 100755 scripts/taler-monitoring/check_urls.sh create mode 100755 scripts/taler-monitoring/check_versions.sh create mode 100644 scripts/taler-monitoring/harness-live.txt create mode 100644 scripts/taler-monitoring/harness-tests.txt create mode 100755 scripts/taler-monitoring/lib.sh create mode 100755 scripts/taler-monitoring/taler-monitoring.sh create mode 100644 scripts/taler-sanity/README.md create mode 100644 scripts/taler-sanity/check_exchange-wirewatch.sh create mode 100755 scripts/taler-sanity/check_helpers-running.sh create mode 100644 scripts/taler-sanity/check_merchant-delays.sh create mode 100644 scripts/taler-sanity/check_settlement.sh create mode 100644 scripts/taler-sanity/check_stack-health.sh create mode 100644 scripts/taler-sanity/lib.sh create mode 100644 scripts/taler-sanity/run_all.sh create mode 100644 scripts/taler-shared/install_privacy_docs.sh create mode 100644 scripts/taler-shared/mem-snapshot.sh create mode 100644 scripts/taler-shared/terms-style.css.fragment create mode 100755 scripts/taler-wallet-cli/goa-deb-withdraw-pay.sh create mode 100755 scripts/taler-wallet-cli/goa-posix-withdraw-pay.sh create mode 100755 scripts/taler-wallet-cli/run-amount-ladder-bench.sh create mode 100755 scripts/taler-wallet-cli/run-extreme-amount-tests.sh create mode 100755 scripts/taler-wallet-cli/withdraw-qr1-goa.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5516c4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.bak +*.pyc +__pycache__/ +.DS_Store +*.swp +*~ +.tmp/ diff --git a/2026/2026-06-29.md b/2026/2026-06-29.md new file mode 100644 index 0000000..e6bf974 --- /dev/null +++ b/2026/2026-06-29.md @@ -0,0 +1,4 @@ +# 2026-06-29 + +- koopa-admin-log started +- merchant start/health; container TLS :8081 + certbot :8082 diff --git a/2026/2026-06-30.md b/2026/2026-06-30.md new file mode 100644 index 0000000..caa594b --- /dev/null +++ b/2026/2026-06-30.md @@ -0,0 +1,4 @@ +# 2026-06-30 + +- separate exchange container reverted → conf.d drop-in +- exchange start/health + GOA zz- drop-in diff --git a/2026/2026-07-01.md b/2026/2026-07-01.md new file mode 100644 index 0000000..8bc350c --- /dev/null +++ b/2026/2026-07-01.md @@ -0,0 +1,3 @@ +# 2026-07-01 + +- bank start scripts + GOA bank-overrides diff --git a/2026/2026-07-02.md b/2026/2026-07-02.md new file mode 100644 index 0000000..339dd5e --- /dev/null +++ b/2026/2026-07-02.md @@ -0,0 +1,3 @@ +# 2026-07-02 + +- exchange ensure-helpers; bank demo withdraw/credit helpers diff --git a/2026/2026-07-07.md b/2026/2026-07-07.md new file mode 100644 index 0000000..3aab877 --- /dev/null +++ b/2026/2026-07-07.md @@ -0,0 +1,3 @@ +# 2026-07-07 + +- taler.hacktivism.ch LE (certbot), notBefore ~20:44 CEST / notAfter ~2026-10-05 diff --git a/2026/2026-07-08.md b/2026/2026-07-08.md new file mode 100644 index 0000000..d2329bc --- /dev/null +++ b/2026/2026-07-08.md @@ -0,0 +1,6 @@ +# 2026-07-08 + +- Caddy live: taler LE certs (from 07.07.) → /etc/caddy/certs; :443 then 8085/8443 then 9000/9001 + ACME webroot +- firewalld 9000/9001/80; drop obsolete 443/8082 +- exchange GOA overrides (no KUDOS) +- exchange.hacktivism.ch LE via Caddy ACME ~21:36 CEST diff --git a/2026/2026-07-09--amount-ladder-partial-results.md b/2026/2026-07-09--amount-ladder-partial-results.md new file mode 100644 index 0000000..df357c7 --- /dev/null +++ b/2026/2026-07-09--amount-ladder-partial-results.md @@ -0,0 +1,47 @@ +# Amount ladder bench — partial results (interrupted 2026-07-09) + + +## Setup +| Item | Value | +|------|-------| +| Bank user | `bench-fat` | +| Password file (koopa host) | `/root/bank-bench-fat-password.txt` | +| Admin credit | GOA:100000 | +| Withdraw | GOA:25000 → wallet available GOA:25000 | +| Merchant instance | `goa-demo-cp4zqk` | +| Free-amount template | `goa-free` | +| Fixed template (legacy) | `goa` amount GOA:5 fixed-order | +| Withdraw wall time | ~393 s (confirm path issues) | + +## Payments (all failed — template URL 404) + +| # | Amount | handle (s) | rud (s) | total (s) | status | loadavg | +|--:|--------|----------:|--------:|----------:|--------|--------:| +| 1 | `GOA:0.000001` | 0.209 | 0.168 | **0.377** | error | 2.17 | +| 2 | `GOA:0.001` | 0.234 | 0.17 | **0.404** | error | 2.06 | +| 3 | `GOA:0.05` | 0.203 | 0.168 | **0.371** | error | 2.21 | +| 4 | `GOA:0.5` | 0.199 | 0.169 | **0.369** | error | 2.17 | +| 5 | `GOA:1` | 0.204 | 0.167 | **0.37** | error | 2.07 | +| 6 | `GOA:3` | 0.201 | 0.168 | **0.37** | error | 2.22 | +| 7 | `GOA:7` | 0.198 | 0.168 | **0.366** | error | 2.18 | +| 8 | `GOA:10` | 0.286 | 0.17 | **0.456** | error | 2.23 | +| 9 | `GOA:25` | 0.204 | 0.167 | **0.372** | error | 2.21 | +| 10 | `GOA:50` | 0.204 | 0.167 | **0.372** | error | 2.41 | +| 11 | `GOA:100` | 0.279 | 0.166 | **0.445** | error | 2.27 | +| 12 | `GOA:250` | 0.294 | 0.168 | **0.462** | error | 2.23 | +| 13 | `GOA:500` | 0.21 | 0.165 | **0.375** | error | 2.12 | +| 14 | `GOA:1000` | 0.202 | 0.169 | **0.37** | error | 2.33 | +| 15 | `GOA:2500` | 0.198 | 0.164 | **0.362** | error | 2.31 | +| 16 | `GOA:5000` | 0.201 | 0.167 | **0.368** | error | 2.43 | + +### Failure +Wallet requested: +``` +GET https://taler.hacktivism.ch/instances/goa-demo-cp4zqk/goa-free/templates/ +→ 404 +``` +URI used: `taler://pay-template/taler.hacktivism.ch/instances/goa-demo-cp4zqk/goa-free/?amount=…&summary=…` +Fix next: correct pay-template path / merchant public template endpoint. + +## Planned amount ladder (not all run) +`GOA:0.000001 0.001 0.05 0.5 1 3 7 10 25 50 100 250 500 1000 2500 5000` diff --git a/2026/2026-07-09--bonfire-ground-zero.md b/2026/2026-07-09--bonfire-ground-zero.md new file mode 100644 index 0000000..12402f8 --- /dev/null +++ b/2026/2026-07-09--bonfire-ground-zero.md @@ -0,0 +1,46 @@ +# Bonfire ground zero + gitbot (2026-07-09) + +## Stack + +| Item | Value | +|------|--------| +| Path | `/home/hernani/koopa-bonfire/` | +| Containers | `koopa-bonfire`, `koopa-bonfire-db` | +| Port | **9021** → Caddy `bonfire.hacktivism.ch` | +| Image | `bonfirenetworks/bonfire:1.0.5-social-amd64` | +| Env | secrets **64** chars; `DB_MIGRATE_INDEXES_CONCURRENTLY=false` | + +Ground zero: wiped volume + dir, clean migrate (import_me `20200828094944` applied, ~109 migrations, identity tables present). + +## Accounts + +| Login | Role | +|-------|------| +| `foss` / `foss@bonfire.hacktivism.ch` | operator account | +| character `gitbot` | same account (multi-profile) | + +Passwords: `/home/hernani/koopa-bonfire/users.env` (mode 600). + +Public: https://bonfire.hacktivism.ch/ + +## Gitbot (commit mirror) + +- Live: `~/koopa-bonfire/bin/gitbot-mirror.py` +- Mirror in repo: `scripts/bonfire/gitbot-mirror.py` +- State: `~/koopa-bonfire/gitbot-state.json` +- Poll Forgejo every **300s** (`gitbot-mirror.service`); log `~/koopa-bonfire/gitbot.log` +- No webhook — API only + +```bash +systemctl --user status gitbot-mirror.service +tail -20 ~/koopa-bonfire/gitbot.log +python3 ~/koopa-bonfire/bin/gitbot-mirror.py +``` + +More: `2026/2026-07-11--bonfire-gitbot-systemd.md`. + +## Lessons + +- First boot needs **≥64-byte** `SECRET_KEY_BASE`. +- Migrations: **`DB_MIGRATE_INDEXES_CONCURRENTLY=false`** on empty DB (avoids concurrent index in txn). +- Partial DB → wipe volume and ground zero rather than patch half-migrated state. diff --git a/2026/2026-07-09--castopod-content.md b/2026/2026-07-09--castopod-content.md new file mode 100644 index 0000000..a59749e --- /dev/null +++ b/2026/2026-07-09--castopod-content.md @@ -0,0 +1,34 @@ +# Castopod — admin note (2026-07-09) + +Site: **https://castopod.hacktivism.ch/** +Admin: https://castopod.hacktivism.ch/cp-admin + +## Stack + +| Item | Value | +|------|--------| +| Live root | `~/koopa-castopod/` | +| Compose | `podman-compose` (mirror: `configs/castopod/compose.yml`) | +| Containers | `koopa-castopod`, `koopa-castopod-mariadb`, `koopa-castopod-redis` | +| Host port | **9020** → Caddy | + +Secrets (not in this repo): `~/koopa-castopod/.env`, `~/koopa-castopod/users.env` (mode 600). +Also: `koopa-admin-secrets/…/koopa-castopod/`. + +## Accounts + +| User | Role | +|------|------| +| `admin` | instance superadmin | +| `ngi` | podcaster | + +## Ops + +```bash +cd ~/koopa-castopod && set -a && source .env && set +a && podman-compose up -d +# scripts: scripts/castopod/{up,status,lib}.sh +``` + +Podcast content is managed in Castopod, not in admin-log. + +Boot/branding: `2026/2026-07-13--castopod-boot-branding.md`, `configs/castopod/`, `scripts/castopod/`. diff --git a/2026/2026-07-09--exchange-snapshot-and-restore.md b/2026/2026-07-09--exchange-snapshot-and-restore.md new file mode 100644 index 0000000..f2ff730 --- /dev/null +++ b/2026/2026-07-09--exchange-snapshot-and-restore.md @@ -0,0 +1,201 @@ +# Exchange snapshot + restore (GOA) — 2026-07-09 + +**Scope:** local on host **koopa** only. No registry, no upload, no public internet publish. + +Container was **stopped** then **committed/saved**. Live name: `taler-hacktivism-exchange-ansible`. + +--- + +## 1. What was stored (local files) + +Directory (hernani on koopa): + +```text +/home/hernani/images/taler-stack-20260709-snapshot/ +``` + +| File | Role | Size (approx.) | +|------|------|----------------| +| `taler-hacktivism-exchange-ansible-live-20260709-snapshot.tar` | full exchange image archive | **~1.6 G** | +| `taler-hacktivism-live-20260709-snapshot.tar` | merchant archive (partial earlier run) | ~2.4 G | +| `SHA256SUMS` | checksums for completed saves | | + +**Podman images (local store only):** + +| Image | Tag | Notes | +|-------|-----|--------| +| `localhost/taler-hacktivism-exchange-ansible-live` | `20260709-snapshot` | committed from stopped container | +| `localhost/taler-hacktivism-exchange-ansible-live` | `9011` | previous live tag (still present) | +| `localhost/taler-hacktivism-live` | `20260709-snapshot` | merchant (if commit kept) | +| `localhost/taler-bank-hacktivism-live` | `20260709-snapshot` | bank (if commit kept) | + +Verify: + +```bash +# as hernani@koopa +ls -lh /home/hernani/images/taler-stack-20260709-snapshot/ +sha256sum -c /home/hernani/images/taler-stack-20260709-snapshot/SHA256SUMS +podman images | grep exchange +podman ps -a --filter name=taler-hacktivism-exchange-ansible +``` + +Expected container state after stop: + +```text +taler-hacktivism-exchange-ansible Exited (…) localhost/taler-hacktivism-exchange-ansible-live:9011 0.0.0.0:9011->9011/tcp +``` + +--- + +## 2. How the snapshot was made + +```bash +# as hernani@koopa — local only +podman stop taler-hacktivism-exchange-ansible + +TAG=20260709-snapshot +OUT=/home/hernani/images/taler-stack-$TAG +mkdir -p "$OUT" + +podman commit taler-hacktivism-exchange-ansible \ + localhost/taler-hacktivism-exchange-ansible-live:$TAG + +podman save -o "$OUT/taler-hacktivism-exchange-ansible-live-$TAG.tar" \ + localhost/taler-hacktivism-exchange-ansible-live:$TAG + +sha256sum "$OUT/taler-hacktivism-exchange-ansible-live-$TAG.tar" | tee -a "$OUT/SHA256SUMS" +``` + +`-p` on commit pauses the container if still running; after `stop` it is optional. + +--- + +## 3. Restore from image (same host) + +### A. From local image tag (fast) + +```bash +podman rm -f taler-hacktivism-exchange-ansible # only if replacing dead/exited + +podman run -d --name taler-hacktivism-exchange-ansible \ + --network pasta \ + -p 9011:9011 \ + --label org.hacktivism.service=taler-exchange \ + --label org.hacktivism.host_port=9011 \ + --label org.hacktivism.site=exchange.hacktivism.ch \ + --label org.hacktivism.currency=GOA \ + --label org.hacktivism.managed_by=koopa-admin \ + localhost/taler-hacktivism-exchange-ansible-live:20260709-snapshot \ + sleep infinity +``` + +### B. From tar (if image store was wiped) + +```bash +podman load -i /home/hernani/images/taler-stack-20260709-snapshot/taler-hacktivism-exchange-ansible-live-20260709-snapshot.tar +# then same podman run as above +``` + +### C. Start services (manual model, no systemd) + +Inside container as **root**, then app user: + +```bash +podman exec -u root -it taler-hacktivism-exchange-ansible bash +# 1) base: postgres, runtime dirs, secmods, wire helpers +/root/start_base_services_for_taler_exchange.sh --no-shell +# 2) httpd +runuser -u taler-exchange-httpd -- /usr/local/bin/start_exchange.sh --restart +# 3) health +/usr/local/bin/check_exchange-health.sh +``` + +Host / public checks: + +```bash +curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9011/config +curl -sS -o /dev/null -w "%{http_code}\n" https://exchange.hacktivism.ch/config +curl -sS -o /dev/null -w "%{http_code}\n" https://exchange.hacktivism.ch/keys +``` + +Caddy already proxies `exchange.hacktivism.ch` → host `:9011` (see `configs/caddy/`, `configs/ports.md`). + +--- + +## 4. GOA configuration (authoritative tree) + +**Do not** edit package `taler-exchange.conf` defaults by hand. Site config lives in overrides + coins (merchant pattern). + +| In container | In admin-log repo | +|--------------|-------------------| +| `/etc/taler-exchange/exchange-overrides.conf` | `configs/taler-exchange/exchange-overrides.conf` | +| `/etc/taler-exchange/conf.d/exchange-coins.conf` | `configs/taler-exchange/conf.d-exchange-coins.conf` | +| secrets (wire gateway) | **not** in admin-log → `koopa-admin-secrets` | +| start scripts | `scripts/taler-exchange/` | + +GOA essentials in overrides: + +- `CURRENCY = GOA`, `BASE_URL = https://exchange.hacktivism.ch/` +- `SERVE = tcp`, `PORT = 9011` +- `[currency-goa]` ENABLED + unit names (Yotta…Atomic) +- `[exchange-account-1]` payto `x-taler-bank/bank.hacktivism.ch/exchange` +- Terms stubs: `TERMS_ETAG` / `PRIVACY_ETAG` = `no-terms-v0` / `no-privacy-v0` + +Deploy overrides from the local machine (example): + +```bash +# from laptop, secrets never committed to admin-log +scp configs/taler-exchange/exchange-overrides.conf \ + hernani@koopa:/tmp/exchange-overrides.conf +scp configs/taler-exchange/conf.d-exchange-coins.conf \ + hernani@koopa:/tmp/exchange-coins.conf +ssh hernani@koopa 'podman cp /tmp/exchange-overrides.conf taler-hacktivism-exchange-ansible:/etc/taler-exchange/exchange-overrides.conf + podman cp /tmp/exchange-coins.conf taler-hacktivism-exchange-ansible:/etc/taler-exchange/conf.d/exchange-coins.conf' +``` + +Greenfield (empty Debian + packages) only: +`scripts/taler-exchange/archive/exchange-bootstrap.sh` **as root inside** container — generates a **new** master key; not the same as restoring this snapshot. + +After config change: + +```bash +runuser -u taler-exchange-httpd -- /usr/local/bin/start_exchange.sh --restart +# optional offline keys / wire: +# scripts/taler-exchange/wire-enable-and-upload.sh +# scripts/taler-exchange/offline-sign-upload-keys.sh +# /usr/local/bin/install_no_terms.sh +``` + +--- + +## 5. Ansible + +There is **no** Ansible playbook in `koopa-admin-log` / this monorepo path for the exchange. +Operational model is: **podman** + **manual start scripts** + **GOA overrides** as above. + +If an external Ansible tree is added later, it should only: + +1. `podman load` / ensure image tag +2. `podman run` with pasta + `9011:9011` + labels above +3. copy GOA configs from `configs/taler-exchange/` +4. run `start_base` → `start_exchange` +5. never push images to a public registry without explicit request + +--- + +## 6. Related docs + +| Doc | Content | +|-----|---------| +| `configs/taler-exchange/README.md` | GOA site notes, terms, ports | +| `scripts/taler-exchange/archive/README.md` | bootstrap vs daily start | +| `configs/ports.md` | 9011 pasta | +| `ops/ROOT_HYGIENE.md` | secrets layout | + +--- + +## 7. Session note (2026-07-09) + +- Exchange **stopped** on request (`Exited 137`). +- Snapshot **local only** under `/home/hernani/images/…`. +- Known pre-stop issue: wirewatch had died after Postgres ownership/outage → bank confirms OK but wallet “Withdraw money from bank…” could hang until wirewatch imports credits. After restore, always verify **wirewatch** is live (`check_exchange-health.sh`). diff --git a/2026/2026-07-09--ipv6-15d6.md b/2026/2026-07-09--ipv6-15d6.md new file mode 100644 index 0000000..b2647cf --- /dev/null +++ b/2026/2026-07-09--ipv6-15d6.md @@ -0,0 +1,34 @@ +# 2026-07-09 — IPv6 `:15d6` reachability + +## Symptom + +`https://exchange.hacktivism.ch/config` failed with `ERR_ADDRESS_UNREACHABLE` +when clients preferred AAAA `2a02:168:53a8:0:d584:9e34:13cf:15d6`. +IPv4 `212.51.151.254` worked (HTTP 200). + +## Path comparison + +| | IPv4 | IPv6 | +|--|------|------| +| Public endpoint | WAN IP DNAT | Host global `:15d6` | +| VeciGate | dstnat 443→9001 | forward 443 (no NAT) | +| Host listener | Caddy `:9001` | `https-proxy.socket` `:443` → 9001 | +| firewalld needed | 9001 | **443** (+ 80 for ACME) | + +## Fix applied + +```bash +# on koopa (root) +firewall-cmd --permanent --zone=public --add-port=443/tcp +firewall-cmd --reload +``` + +VeciGate: confirmed IPv6 filter allow 443; added explicit allow **80** for ACME. + +## Verify + +```bash +curl -6 -sS -o /dev/null -w '%{http_code} %{remote_ip}\n' \ + https://exchange.hacktivism.ch/config +# 200 2a02:168:53a8:0:d584:9e34:13cf:15d6 +``` diff --git a/2026/2026-07-09--keys-wire.md b/2026/2026-07-09--keys-wire.md new file mode 100644 index 0000000..5ee24a4 --- /dev/null +++ b/2026/2026-07-09--keys-wire.md @@ -0,0 +1,17 @@ +# 2026-07-09 — `/keys` + Wire + Merchant GOA + +## Done + +1. **Exchange `/keys`:** was suspended (no wire accounts → then no online signing keys). + - `enable-account` + `wire-fee` + `global-fee` → offline **upload** (local `BASE_URL=http://127.0.0.1:9011/`) + - `download` → `sign` → `upload` for denoms + signkeys + - Result: `GET /keys` **200**, ~1 MB, currency GOA, master `TW6K5FXF…` +2. **Merchant health:** `check_merchant-health.sh` includes exchange `/keys` + MASTER_KEY match → **ALL CRITICAL PASSED** (GOA via `host.containers.internal:9011` fallback). +3. **Wire helpers:** secret `exchange-accountcredentials-1.secret.conf` needs group `taler-exchange-db` mode 640; then aggregator/closer/wirewatch/transfer OK. +4. **Smoke:** local+public `/keys` 200; bank wire-gateway config as `exchange` → 200 on `:9012`; bank SPA public earlier (LE cert). Full phone Withdraw is manual (wallet app). +5. **Admin-log:** docs diagram keys/wire; scripts `wire-enable-and-upload.sh`, `offline-sign-upload-keys.sh`, `start_wire_helpers.sh`; merchant overrides GOA; ports 9012 noted. + +## Manual withdraw (phone) + +1. https://bank.hacktivism.ch/ → login `explorer` (pw host `/root/bank-explorer-password.txt`) +2. Withdraw GOA → open in Taler Wallet (exchange.hacktivism.ch `/keys` must stay 200) diff --git a/2026/2026-07-09--koopa-prime-jellyfin.md b/2026/2026-07-09--koopa-prime-jellyfin.md new file mode 100644 index 0000000..2a71180 --- /dev/null +++ b/2026/2026-07-09--koopa-prime-jellyfin.md @@ -0,0 +1,43 @@ +# koopa-prime — Jellyfin + qBittorrent (2026-07-09) + +## Containers (podman-compose) + +Path: `/home/hernani/koopa-prime/` + +| Container | Role | Host port | +|-----------|------|-----------| +| `koopa-prime-jellyfin` | Video library (Jellyfin) | **9022** | +| `koopa-prime-qbittorrent` | BitTorrent client | **9023** WebUI, **6881** BT | + +## Media wiring + +| Host path | Jellyfin | qBittorrent | +|-----------|----------|-------------| +| `/home/hernani/Downloads` | `/media/downloads` **ro** (library) | `/downloads` **rw** (save path) | + +Downloads land in the same folder Jellyfin scans. + +## Auth (login required) + +- **Jellyfin:** admin account created via Startup API; library access needs login. +- **qBittorrent:** WebUI password required (401 without session). + +Credentials: `~/koopa-prime/users.env` (mode 600). + +## Public / Caddy + +| Host | Backend | +|------|---------| +| `prime.hacktivism.ch` | `127.0.0.1:9022` | +| `torrent.prime.hacktivism.ch` | `127.0.0.1:9023` (needs DNS CNAME) | + +See session notes for Caddy snippets. BT port **6881** may need firewall/VeciGate if peers should connect. + +## Ops + +```bash +cd ~/koopa-prime +podman-compose ps +podman-compose logs -f jellyfin +podman-compose up -d +``` diff --git a/2026/2026-07-09--merchant-bank-link.md b/2026/2026-07-09--merchant-bank-link.md new file mode 100644 index 0000000..31a417a --- /dev/null +++ b/2026/2026-07-09--merchant-bank-link.md @@ -0,0 +1,8 @@ +# 2026-07-09 — Merchant demo + bank link + +1. Disabled mandatory SMS/email TAN on merchant (`MANDATORY_TAN_CHANNELS` empty). +2. Self-provisioned instance `goa-demo-cp4zqk` via `POST /instances` (HTTP 200, no challenge). +3. Created bank user `goa-demo-cp4zqk`, credited **GOA:5000**. +4. Linked payto to merchant: `POST .../private/accounts` → active wire account. + +See `configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md`. diff --git a/2026/2026-07-09.md b/2026/2026-07-09.md new file mode 100644 index 0000000..3c96245 --- /dev/null +++ b/2026/2026-07-09.md @@ -0,0 +1,6 @@ +# 2026-07-09 + +- bank.hacktivism.ch LE ~05:43 CEST +- public intros :9013–9015 + landing-stats; merchant stats fail-closed +- dual-currency terms + Swiss FADP privacy (merchant/exchange) +- taler-monitoring (+ versions vs deb.taler.net) diff --git a/2026/2026-07-10--admin-log-origin-forgejo.md b/2026/2026-07-10--admin-log-origin-forgejo.md new file mode 100644 index 0000000..48340e6 --- /dev/null +++ b/2026/2026-07-10--admin-log-origin-forgejo.md @@ -0,0 +1,40 @@ +# koopa-admin-log origin on git.hacktivism.ch (2026-07-10) + +## Remote + +| Item | Value | +|------|--------| +| Host | **Forgejo rootless** `https://git.hacktivism.ch/` | +| Repo | **https://git.hacktivism.ch/hernani/koopa-admin-log** | +| Visibility | **public** (ops log only; **no secret values**) | +| Default branch | `main` | +| HTTPS clone | `https://git.hacktivism.ch/hernani/koopa-admin-log.git` | +| SSH clone | `ssh://git@git.hacktivism.ch:9200/hernani/koopa-admin-log.git` | + +## Local setup + +```bash +cd /path/to/koopa-admin-log +git remote add origin https://git.hacktivism.ch/hernani/koopa-admin-log.git +# or SSH (port 9200): +# git remote add origin ssh://git@git.hacktivism.ch:9200/hernani/koopa-admin-log.git + +git push -u origin main +``` + +Auth: Forgejo user **`hernani`** (admin; registration disabled site-wide). +Credentials: `koopa-admin-secrets/…/koopa-forgejo/users.env` — not in this repo. + +## Related + +- Forgejo install: `2026-07-10--forgejo-rootless.md` +- Secrets sibling: **not** hosted here (private material stays in `koopa-admin-secrets` only) + +## Network (SSH :9200) + +Public git-SSH requires: + +1. VeciGate WAN/hairpin + IPv6 forward **9200** → koopa +2. koopa firewalld **9200/tcp** + +See `2026-07-10--forgejo-rootless.md` (Network exposure) and `../vecigate-admin-log/docs/CHANGELOG.md`. diff --git a/2026/2026-07-10--bonfire-federate.md b/2026/2026-07-10--bonfire-federate.md new file mode 100644 index 0000000..e24068e --- /dev/null +++ b/2026/2026-07-10--bonfire-federate.md @@ -0,0 +1,60 @@ +# Bonfire — FEDERATE + public guest access (2026-07-10) + +Follow-up to ground zero: `2026-07-09--bonfire-ground-zero.md`. + +## Change + +| Item | Before | After | +|------|--------|--------| +| `.env` `FEDERATE` | unset | **`true`** | +| Container | recreated | `podman-compose up -d --force-recreate web` | +| `printenv FEDERATE` (web) | — | `true` | +| `HOSTNAME` / compose | `bonfire.hacktivism.ch` | unchanged | +| Port | **9021** → Caddy | unchanged | + +Only new env key vs ground-zero `.env`: **`FEDERATE`**. + +Live: `/home/hernani/koopa-bonfire/.env` (mode 600). +Mirror: `koopa-admin-secrets/koopa/home-hernani/koopa-bonfire/.env`. +Compose mirror: `configs/bonfire/compose.yml`. + +## Intent + +- Allow federation-related runtime paths (`FEDERATE` consumed in Bonfire `runtime.exs` as `true|yes|1`). +- Keep **guest-readable** public surfaces (no login required for explore / profiles that are already public). +- Operator stance: **federation enablement is accepted as OK** even if the UI banner lags. + +## Observed behaviour (after recreate) + +| Check | Result | +|-------|--------| +| Local `http://127.0.0.1:9021/` | 200; guest UI (Explore / Log in) | +| Guest UI banner | still may show **“Federation disabled”** | +| App RPC (earlier probe) | `activity_pub` instance sometimes still `federating: false`, `hostname: "localhost"` despite env | +| Public profiles | guest-readable when already public | +| Front / local feed | can be sparse depending on boundaries / feed window | + +So: **env is set and container healthy**; full ActivityPub “federating=true” in Application config may still need admin UI / deeper config if outbound federation is required later. + +## Ops + +```bash +cd ~/koopa-bonfire +grep ^FEDERATE= .env +podman exec koopa-bonfire printenv FEDERATE HOSTNAME +podman ps --filter name=koopa-bonfire +curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9021/ +``` + +Recreate web after env change: + +```bash +cd ~/koopa-bonfire +set -a && source .env && set +a +podman-compose up -d --force-recreate web +``` + +## Secrets / log rules + +- Values only in **`koopa-admin-secrets`**, not in this repo. +- Daylog major bullet only (see `2026-07-10.md`). diff --git a/2026/2026-07-10--forgejo-rootless.md b/2026/2026-07-10--forgejo-rootless.md new file mode 100644 index 0000000..ebfc2cf --- /dev/null +++ b/2026/2026-07-10--forgejo-rootless.md @@ -0,0 +1,90 @@ +# Forgejo rootless — git.hacktivism.ch (2026-07-10) + +## Emphasis: rootless + +Forgejo is installed **rootless end-to-end**: + +1. **Image:** `codeberg.org/forgejo/forgejo:11-rootless` (process runs as UID/GID **1000**, not container root). +2. **Podman:** started as user **hernani** (rootless podman), **not** rootful. +3. **Userns:** `userns_mode: keep-id` so container 1000 maps to host hernani. +4. **Bind mounts:** `./data` → `/var/lib/gitea`, `./config` → `/etc/gitea` under `~/koopa-forgejo/`. + +Do **not** switch to the non-rootless `forgejo:11` image under rootless podman: that image refuses to run as mapped root (`Forgejo is not supposed to be run as root`). + +## Stack + +| Item | Value | +|------|--------| +| Path | `/home/hernani/koopa-forgejo/` | +| Containers | `koopa-forgejo` (**rootless**), `koopa-forgejo-db` (postgres:16-alpine) | +| HTTP | host **9024** → Caddy `git.hacktivism.ch` | +| SSH (git) | host **9200** → container 2222 (direct; not via Caddy) | +| ROOT_URL | `https://git.hacktivism.ch/` | +| Landing | explore (public browse OK) | +| LE cert | automatic via Caddy (`CN=git.hacktivism.ch`) | + +Compose + redacted `app.ini`: `configs/forgejo/`. +Caddy site block: `configs/caddy/git.hacktivism.ch.caddy` (+ full `configs/caddy/Caddyfile`). + +## Access policy + +| Setting | Value | +|---------|--------| +| `DISABLE_REGISTRATION` | **true** — no free self-signup | +| `SHOW_REGISTRATION_BUTTON` | **false** | +| OpenID signup/signin | **false** | +| Public site | readable without login (`REQUIRE_SIGNIN_VIEW=false`) | +| Accounts | admin-created only (`forgejo admin user create`) | + +Reserved username `admin` cannot be used; first admin: **`hernani`**. + +Passwords: `~/koopa-forgejo/users.env` + DB/secrets in `.env` (mode 600). +Mirror: `koopa-admin-secrets/koopa/home-hernani/koopa-forgejo/`. + +## Start / status + +```bash +cd ~/koopa-forgejo +set -a && source .env && set +a +podman-compose up -d +podman ps --filter name=koopa-forgejo +curl -sS http://127.0.0.1:9024/api/healthz +curl -sS https://git.hacktivism.ch/api/healthz +``` + +## Caddy (host root) + +Live `/etc/caddy/Caddyfile` matches `configs/caddy/Caddyfile` (applied 2026-07-10; `http://git.` in ACME list; SSH note **9200**). + +Site block live in `/etc/caddy/Caddyfile` (mirror under `configs/caddy/`). +Prefer including `http://git.hacktivism.ch` in the shared ACME/redirect host list. + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` + +## Network exposure (2026-07-10) + +| Layer | Rule | +|-------|------| +| **VeciGate IPv4** | WAN **9200** → `192.168.100.95:9200`; hairpin LAN/LAN2 → public A:9200 | +| **VeciGate IPv6** | WAN→LAN forward **tcp/9200** | +| **koopa firewalld** | `public` **9200/tcp** (verified 2026-07-10: LAN + hairpin + `ssh -T`) | + +Docs: `../vecigate-admin-log` (`ip/firewall/nat/`, `ipv6/firewall/filter/`, `docs/CHANGELOG.md`); +host ports: `configs/firewalld/public-ports.md`. + +```bash +# host +sudo firewall-cmd --permanent --add-port=9200/tcp && sudo firewall-cmd --reload +# client +ssh -p 9200 -T git@git.hacktivism.ch +``` + +## Notes + +- DNS: `git.hacktivism.ch` → koopa (same family as other `*.hacktivism.ch`). +- Git over SSH: `ssh://git@git.hacktivism.ch:9200/…` +- Clone URLs advertise `SSH_PORT=9200`; container still listens on internal **2222**. +- Admin-log origin: `hernani/koopa-admin-log` on this Forgejo — see `2026-07-10--admin-log-origin-forgejo.md`. diff --git a/2026/2026-07-10--forgejo-theme-exchange-dark.md b/2026/2026-07-10--forgejo-theme-exchange-dark.md new file mode 100644 index 0000000..f255abe --- /dev/null +++ b/2026/2026-07-10--forgejo-theme-exchange-dark.md @@ -0,0 +1,27 @@ +# 2026-07-10 — Forgejo global theme (exchange-dark) + +## Goal + +Make https://git.hacktivism.ch look closer to https://exchange.hacktivism.ch: warm dark layout, gold + teal accents, readable text. + +## Done on koopa + +- Custom theme file `theme-hacktivism.css` (full variable set from forgejo-dark + exchange palette). +- Installed under `~/koopa-forgejo/data/custom/public/assets/css/` (and gitea/custom mirror). +- Logo/favicon under `…/assets/img/`. +- `DEFAULT_THEME = hacktivism` in app.ini + compose `FORGEJO__ui__*`. +- Forced DB: `UPDATE "user" SET theme = 'hacktivism'` (logged-in preference was still `forgejo-auto`). + +## Readability + +- Body/text cream on `#1a1410`; muted secondary not below ~`#c9b8a0`. +- Gold primary buttons with dark label text. +- Explicit overrides for menus, forms, code, labels, messages, footer. + +## Admin-log + +Source of truth: `configs/forgejo/assets/` + `configs/forgejo/README.md`. + +## Note + +Browser cache may hold old CSS (`Cache-Control: max-age=21600`). Hard-reload or private window if UI looks stale. diff --git a/2026/2026-07-10.md b/2026/2026-07-10.md new file mode 100644 index 0000000..95c9812 --- /dev/null +++ b/2026/2026-07-10.md @@ -0,0 +1,21 @@ +# 2026-07-10 + +- landing mem snapshot fix (pipeline → 0 B) +- Caddy redir / → /intro/ (three sites) +- SECRETS.md paths only; no secret material in admin-log +- **Bonfire** `FEDERATE=true` (+ web recreate); guest public profiles OK; UI may still say federation disabled + → topic: `2026-07-10--bonfire-federate.md`; secrets: `koopa-admin-secrets/…/koopa-bonfire/` +- **Forgejo rootless** on `git.hacktivism.ch` (HTTP **9024**, git-SSH **9200**); no free registration; admin `hernani` + → topic: `2026-07-10--forgejo-rootless.md`; secrets: `koopa-admin-secrets/…/koopa-forgejo/` +- **git-SSH :9200** exposed + **verified**: VeciGate DNAT/hairpin + IPv6; firewalld **9200/tcp** (LAN/hairpin/`ssh -T` OK) + → vecigate-admin-log CHANGELOG 2026-07-10; `configs/firewalld/public-ports.md` +- **admin-log origin** → Forgejo `hernani/koopa-admin-log` on git.hacktivism.ch (public; SSH :9200) + → topic: `2026-07-10--admin-log-origin-forgejo.md` +- admin-log: castopod/prime compose mirrors; firewalld list-all +9200; Caddyfile.taler-host marked legacy +- admin-log: Tor relay mirror documented (`configs/tor/`, host/tor) +- Caddyfile applied on host: git. in ACME/redirect list, SSH note :9200; live matches configs/caddy mirror (verified) +- Bonfire: republish foss outbox → Local users + public internet feeds; guest `/feed/local` shows posts +- Reflect live koopa state in admin-log: `host/overview/LIVE.md`, exchange name **taler-hacktivism-exchange-ansible**, ansible mirror +- configs/: rename mirrors to live container names (`taler-hacktivism`, `taler-hacktivism-bank`; image banking) +- **Forgejo branding**: theme `hacktivism` (exchange-dark) + Kamek-inspired logo (blue robe, flying on wand); compose DEFAULT_THEME + → `configs/forgejo/` diff --git a/2026/2026-07-11--bonfire-branding-feeds.md b/2026/2026-07-11--bonfire-branding-feeds.md new file mode 100644 index 0000000..6e808bf --- /dev/null +++ b/2026/2026-07-11--bonfire-branding-feeds.md @@ -0,0 +1,25 @@ +# Bonfire branding + public feeds (2026-07-11) + +After `2026/2026-07-11--bonfire-gitbot-systemd.md`. Logo: `2026/2026-07-11--bonfire-logo-not-served.md`. + +## Branding + +- hacktivism magician logo (same as git.hacktivism.ch). +- Deploy: `scripts/bonfire/apply-branding.sh` +- Logo URL: `/images/hacktivism-logo.svg` (compose bind-mount). + +**Official:** [Admin Tools — Instance Settings](https://docs.bonfirenetworks.org/admin-tools.html) (name/icon in UI). +Instance theme via `bonfire remote` matches [Settings System](https://docs.bonfirenetworks.org/settings_system.html) (`scope: :instance`). +No official doc for custom static files in podman — bind-mount workaround only. + +## Public feed (ops) + +Gitbot activity can stay in the user outbox; guests on `/` may see a stale feed until republished. + +**Official:** [Feed structure](https://docs.bonfirenetworks.org/feed_structure.html) (`FeedPublish`). +No official “republish outbox to local feed” procedure — ops SQL + gitbot workaround. + +- Continuous: `gitbot-mirror.py` republishes outbox → local + internet every cycle. +- One-off: `scripts/bonfire/publish-outbox-to-public.sql`. + +Check: https://bonfire.hacktivism.ch/feed/local diff --git a/2026/2026-07-11--bonfire-gitbot-systemd.md b/2026/2026-07-11--bonfire-gitbot-systemd.md new file mode 100644 index 0000000..5abce7b --- /dev/null +++ b/2026/2026-07-11--bonfire-gitbot-systemd.md @@ -0,0 +1,26 @@ +# Bonfire gitbot + boot (2026-07-11) + +After `2026/2026-07-09--bonfire-ground-zero.md`. + +## Problems + +- Bare hostnames in commit text crash `createPost` (`URI.parse/1`). +- No webhook; bot polls git.ngi-0.eu every 300s. +- Stack did not start on boot. + +## Fix + +- `gitbot-mirror.py`: prefix bare hosts, fallback post, skip after 3 fails. +- User systemd: `gitbot-mirror.service`, `container-koopa-bonfire-*.service` (`podman-compose up -d`). +- Install: `scripts/bonfire/install-systemd.sh` + +**Official:** [Hosting guide](https://docs.bonfirenetworks.org/deploy.html) recommends Co-op Cloud; we run podman-compose on koopa (ops choice, not upstream path). +GraphQL `createPost`: [API reference](https://docs.bonfirenetworks.org/api-reference.html) — no official gitbot mirror doc; custom script. + +## Ops + +```bash +systemctl --user status container-koopa-bonfire-db.service container-koopa-bonfire.service gitbot-mirror.service +tail -30 ~/koopa-bonfire/gitbot.log +python3 ~/koopa-bonfire/bin/gitbot-mirror.py +``` \ No newline at end of file diff --git a/2026/2026-07-11--bonfire-logo-not-served.md b/2026/2026-07-11--bonfire-logo-not-served.md new file mode 100644 index 0000000..efa27e4 --- /dev/null +++ b/2026/2026-07-11--bonfire-logo-not-served.md @@ -0,0 +1,19 @@ +# Bonfire logo not served (2026-07-11) + +After `scripts/bonfire/apply-branding.sh` the sidebar showed a broken image. + +## Symptom + +- `instance_icon` was `/images/hacktivism-logo.svg` but the file returned 404 +- `data/branding/` on disk is not a public URL; `/branding/` is not served by Bonfire + +## Official docs + +[Admin Tools — Instance Settings](https://docs.bonfirenetworks.org/admin-tools.html) covers instance name and icon in the admin UI only. + +No official guide for custom static files in Docker/podman. We use a bind-mount workaround. + +## Fix + +`compose.yml` mounts `data/branding/logo.svg` into `priv/static/images/hacktivism-logo.svg`. +Recreate web: `podman-compose up -d --no-deps --force-recreate web` \ No newline at end of file diff --git a/2026/2026-07-11.md b/2026/2026-07-11.md new file mode 100644 index 0000000..60875ad --- /dev/null +++ b/2026/2026-07-11.md @@ -0,0 +1,5 @@ +# 2026-07-11 + +- Bonfire gitbot + boot systemd → `2026/2026-07-11--bonfire-gitbot-systemd.md` +- Bonfire logo not served → `2026/2026-07-11--bonfire-logo-not-served.md` +- Bonfire branding + public feeds → `2026/2026-07-11--bonfire-branding-feeds.md` \ No newline at end of file diff --git a/2026/2026-07-13--castopod-boot-branding.md b/2026/2026-07-13--castopod-boot-branding.md new file mode 100644 index 0000000..c6e27b3 --- /dev/null +++ b/2026/2026-07-13--castopod-boot-branding.md @@ -0,0 +1,31 @@ +# Castopod — boot + branding (2026-07-13) + +Site: **https://castopod.hacktivism.ch/** · `~/koopa-castopod/` · Caddy **:9020** + +## Boot + +User systemd + linger (like Bonfire): + +```bash +scripts/castopod/install-systemd.sh +systemctl --user status container-koopa-castopod{,-mariadb,-redis}.service +``` + +Units: `configs/castopod/container-koopa-castopod*.service` + +## Branding + +Official settings: https://docs.castopod.org/main/en/user-guide/instance/settings/ +Docker: https://docs.castopod.org/main/en/getting-started/docker/ + +| Item | Host choice | +|------|-------------| +| Site icon | Magician logo (`configs/castopod/assets/img/`) | +| Theme | Castopod accent **amber** | +| Optional CSS | `assets/css/theme-hacktivism-overlay.css` (not upstream) | + +```bash +scripts/castopod/apply-branding.sh +``` + +Stack/accounts: `2026/2026-07-09--castopod-content.md`. diff --git a/2026/README.md b/2026/README.md new file mode 100644 index 0000000..6d32a14 --- /dev/null +++ b/2026/README.md @@ -0,0 +1,3 @@ +# Day logs 2026 + +Major ops only. One file per work day; written the same day (not backfilled). diff --git a/README.md b/README.md new file mode 100644 index 0000000..c165035 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# koopa-admin-log + +Ops log and config mirror for host **koopa** (openSUSE Tumbleweed). + +**Day logs:** `2026/` (major only, written same day). Topic notes: `YYYY-MM-DD--topic.md`. + +**Secrets:** not in this repo — see **`SECRETS.md`** and sibling **`koopa-admin-secrets`**. + +## Git origin + +| | | +|--|--| +| **origin** | `https://git.hacktivism.ch/hernani/koopa-admin-log.git` | +| Web | https://git.hacktivism.ch/hernani/koopa-admin-log | +| SSH | `ssh://git@git.hacktivism.ch:9200/hernani/koopa-admin-log.git` | + +Hosted on **Forgejo rootless** (this host). See `2026/2026-07-10--admin-log-origin-forgejo.md`. + +## Layout + +``` +host/ # openSUSE host-level config + overview + overview/services.md # graphical service map + systemd/ firewalld/ caddy/ network/ +configs/ # mirrored app configs (caddy, taler, forgejo, …) + taler-hacktivism/ # merchant container taler-hacktivism + taler-hacktivism-bank/ # bank container (image …-banking) + taler-exchange/ # exchange conf (inside ansible container) + taler-exchange-ansible/ # container taler-hacktivism-exchange-ansible + forgejo/ # rootless git.hacktivism.ch + castopod/ bonfire/ prime/ # compose mirrors (no secret values) + tops/ # koopa-tops-ng1…ng3 + autostart unit + tor/ # KoopaRelay ORPort 8080 +scripts/ # merchant, exchange, monitoring helpers + taler-merchant/ # merchant-model start scripts + taler-exchange/ # same model; archive/ = greenfield bootstrap +2026/ # day logs + dated topic notes +``` + +## Live containers (podman) + +**Authoritative snapshot of host koopa:** [`host/overview/LIVE.md`](host/overview/LIVE.md) (refreshed from live). + +| Name (podman) | Role | Host port | +|---------------|------|-----------| +| `taler-hacktivism` | merchant | **9010** (+ landing **9015**) | +| **`taler-hacktivism-exchange-ansible`** | exchange (Ansible/systemd) | **9011** (+ landing **9014**) | +| `taler-hacktivism-bank` | libeufin-bank (GOA) | **9012** (+ landing **9013**) | +| `koopa-castopod` (+ mariadb/redis) | Castopod | **9020** | +| `koopa-bonfire` (+ postgres) | Bonfire | **9021** | +| `koopa-prime-jellyfin` | Jellyfin | **9022** | +| `koopa-prime-qbittorrent` | qBittorrent | **9023** | +| `koopa-forgejo` (+ postgres) | Forgejo rootless | **9024** + SSH **9200** | +| `koopa-tops-ng1` … `ng3` | static nginx | **9090**–**9092** | + +Start model (Taler merchant/bank): **root** `start_base_services_*` → `/usr/local/bin/start_*.sh`. +Start model (exchange): `~/ansible-taler-exchange/` → `run-container-koopa.sh` + `deploy-hacktivism-goa.sh`. +Start model (user apps): `hernani` → `cd ~/koopa-* && podman-compose up -d`. +Start model (tops): user unit `container-koopa-tops.service` (linger). + +Public hosts: `taler.` / `exchange.` / `bank.` / `castopod.` / `bonfire.` / `prime.` / `bt.` / **`git.hacktivism.ch`** / `tops.ng1`–`ng3`.hacktivism.ch +(Caddy → 9010–9015 / 9020–**9024** / **9090–9092**; git-SSH host **9200** direct). + +Service **details**: `configs/taler-hacktivism*`, `configs/taler-exchange*`, `configs/forgejo/`, `configs/tops/`, `scripts/*`. +Host-wide picture: **`host/overview/services.md`**. +Port table: **`configs/ports.md`**. +Router: **`../vecigate-admin-log`**. + +**Benchmarks** (drive from the local machine, measure on koopa): **`benchmarks/`** +→ amount ladder: `benchmarks/amount-ladder/`. + +**Exchange snapshot/restore (GOA, local images only):** +→ `2026/2026-07-09--exchange-snapshot-and-restore.md` + +**Exchange `/keys` + Wire + bank account (diagram):** +`configs/taler-exchange/README.md` (section “/keys, Wire and Account”). + +**Castopod (stack/accounts/boot — not podcast content):** +`2026/2026-07-09--castopod-content.md` → https://castopod.hacktivism.ch/ + +**Bonfire FEDERATE + guest public (2026-07-10):** +`2026/2026-07-10--bonfire-federate.md` → https://bonfire.hacktivism.ch/ +(ground zero: `2026/2026-07-09--bonfire-ground-zero.md`) + +**Forgejo rootless (git.hacktivism.ch, no free registration, SSH :9200):** +`2026/2026-07-10--forgejo-rootless.md` → https://git.hacktivism.ch/ diff --git a/SECRETS.md b/SECRETS.md new file mode 100644 index 0000000..cf70fa3 --- /dev/null +++ b/SECRETS.md @@ -0,0 +1,34 @@ +# Secrets — not stored in this repo + +Live passwords/keys live in sibling **`koopa-admin-secrets`**. + +## Map + +### Taler — host `/root` + +| What | Mirror (`koopa-admin-secrets/…`) | Live | +|------|----------------------------------|------| +| Bank admin/exchange/explorer/demo/bench | `koopa/host-root/taler-bank/*.txt` | `/root/bank-*-password.txt` | +| Merchant instance goa-demo | `koopa/host-root/taler-merchant/…` | `/root/merchant-*-password.txt` | +| Exchange ATTRIBUTE_ENCRYPTION_KEY | `containers/taler-exchange/secrets/exchange-attribute-encryption.secret.conf` | container `/etc/taler-exchange/secrets/` | +| Exchange wire / DB | `…/*.secret.conf` (+ `.example` templates) | same under container | + +### Apps — `/home/hernani/` + +| What | Mirror | Live | +|------|--------|------| +| Castopod | `koopa/home-hernani/koopa-castopod/{.env,users.env}` (+ compose mirror in admin-log `configs/castopod/`) | `~/koopa-castopod/` | +| Bonfire (`FEDERATE=true`) | `koopa/home-hernani/koopa-bonfire/{.env,users.env,compose.yml}` | `~/koopa-bonfire/` | +| 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/` | + +## Rules + +- **koopa-admin-log**: paths only; `@inline-secret@` in configs, never password/key material. +- Pull/deploy: `koopa-admin-secrets/scripts/{pull,deploy}-from-koopa.sh` +- Full inventory: `koopa-admin-secrets/MAP.md` + +## Public (OK here) + +- `MASTER_PUBLIC_KEY` / merchant `MASTER_KEY` (exchange public master key) +- Port maps, unit ladders, non-secret overrides diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..9f618bf --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,10 @@ +# Benchmarks (koopa Taler stack) + +Run **from the local machine** against public endpoints. +**Measure load inside** the three Podman containers on koopa via `ssh hernani@koopa` + `podman exec` (no root/screen required for metrics). + +| Benchmark | What it does | +|-----------|----------------| +| [`amount-ladder/`](amount-ladder/) | Free-amount template payments across a wide GOA ladder; wall times + host/container CPU/RSS samples | + +Secrets: sibling repo **`koopa-admin-secrets`** (never committed here). diff --git a/benchmarks/amount-ladder/README.md b/benchmarks/amount-ladder/README.md new file mode 100644 index 0000000..666006d --- /dev/null +++ b/benchmarks/amount-ladder/README.md @@ -0,0 +1,90 @@ +# Amount-ladder payment benchmark + +## Model + +| Side | Machine | Role | +|------|---------|------| +| **Drive (external)** | Local machine (this tree) | `taler-wallet-cli`, orchestration, CSV/RESULTS | +| **SUT (internal)** | koopa containers | merchant / exchange / bank under load | +| **Measure (internal)** | `ssh hernani@koopa` | `loadavg`, `ps` in each container | + +``` +[ Local-machine wallet-cli ] --HTTPS--> taler.hacktivism.ch / exchange / bank + | ^ + | ssh sample_load | pasta :9010/:9011/:9012 + v | +[ hernani@koopa ] --podman exec--> process CPU/RSS snapshots +``` + +## Prerequisites + +1. Stack healthy (`check_*-health.sh` all green; public `/config` + exchange `/keys` 200). +2. Local wallet CLI (default path in script, override `MONO=`). +3. Secrets in **`../koopa-admin-secrets/koopa/host-root/`**: + - `taler-bank/bank-admin-password.txt` + - `taler-bank/bank-bench-fat-password.txt` (created if missing) + - `taler-merchant/merchant-goa-demo-cp4zqk-password.txt` +4. SSH: `hernani@koopa` BatchMode, `podman` without su. + +## Amount ladder (~20 payments: small / medium / large) + +| Band | Amounts (GOA) | +|------|----------------| +| **small** | `0.000001` `0.00001` `0.0001` `0.001` `0.01` `0.05` `0.1` `0.5` | +| **medium** | `1` `2` `5` `10` `25` `50` `100` | +| **large** | `250` `500` `1000` `2500` `5000` | + +Default list (20 steps): + +```text +GOA:0.000001 GOA:0.00001 GOA:0.0001 GOA:0.001 GOA:0.01 GOA:0.05 GOA:0.1 GOA:0.5 +GOA:1 GOA:2 GOA:5 GOA:10 GOA:25 GOA:50 GOA:100 +GOA:250 GOA:500 GOA:1000 GOA:2500 GOA:5000 +``` + +Σ ladder ≈ **GOA:9433** → default withdraw **GOA:50000**, admin credit **GOA:200000**. + +Override: `AMOUNTS='GOA:1 GOA:10' ./run.sh` + +## Pay-template URI (important) + +Do **not** put a trailing slash after the template id (wallet then 404s). + +```text +taler://pay-template/taler.hacktivism.ch/instances/goa-demo-cp4zqk/goa-free?amount=GOA:1 +``` + +- Template `goa-free` is free-amount with fixed summary `"pay"` → pass **only** `amount=` (no `summary=` or merchant 409). +- HTTP GET probe: + `https://taler.hacktivism.ch/instances/goa-demo-cp4zqk/templates/goa-free` → 200. + +## Run + +```bash +cd koopa-admin-log/benchmarks/amount-ladder +./run.sh +# short: +AMOUNTS='GOA:0.05 GOA:1 GOA:10' WITHDRAW_AMT=GOA:100 ./run.sh +``` + +Outputs under `../../.tmp/amount-bench-/`: + +| File | Content | +|------|---------| +| `payments.csv` | per-amount timings + status | +| `RESULTS.md` | table summary | +| `koopa-load-*.out` | **inside** process samples | +| `run.log` | timeline | + +## Columns (RESULTS) + +- **handle**: wall time of `wallet handle-uri` +- **run-until-done**: wallet background after handle +- **total**: sum +- **loadavg**: koopa 1‑min load after payment +- **status**: `paid` / `error` / `insufficient` / … + +## Related + +- Partial earlier run (wrong URI): `2026/2026-07-09--amount-ladder-partial-results.md` +- Instance notes: `configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md` diff --git a/benchmarks/amount-ladder/run.sh b/benchmarks/amount-ladder/run.sh new file mode 100755 index 0000000..b8e337d --- /dev/null +++ b/benchmarks/amount-ladder/run.sh @@ -0,0 +1,375 @@ +#!/bin/bash +# Amount-ladder benchmark — drive from the local machine, measure on koopa (inside containers). +# See README.md in this directory. +set -eu + +BENCH_DIR=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "$BENCH_DIR/../.." && pwd) +SECRETS="${SECRETS_DIR:-}" +if [ -z "$SECRETS" ]; then + if [ -d "$ROOT/../koopa-admin-secrets" ]; then + SECRETS=$(cd "$ROOT/../koopa-admin-secrets" && pwd) + else + SECRETS="$ROOT/../koopa-admin-secrets" + fi +fi + +SCRATCH="${ROOT}/.tmp/amount-bench-$(date -u +%Y%m%d-%H%M%S)" +mkdir -p "$SCRATCH" +chmod 700 "$SCRATCH" + +MONO="${MONO:-/Users/newkamek/src/taler/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs}" +WDB="$SCRATCH/wallet.sqlite3" +EX="${EXCHANGE_URL:-https://exchange.hacktivism.ch/}" +KOOPA="${KOOPA_HOST:-koopa}" +INST="${MERCHANT_INSTANCE:-goa-demo-cp4zqk}" +TPL="${TEMPLATE_ID:-goa-free}" +MER_HOST="${MER_HOST:-taler.hacktivism.ch}" +FAT_USER="${FAT_USER:-bench-fat}" +# ~20 payments: small → medium → large (atomic/tiny through multi-kilo) +# Override: AMOUNTS='GOA:1 GOA:10' ./run.sh +AMOUNTS="${AMOUNTS:-GOA:0.000001 GOA:0.00001 GOA:0.0001 GOA:0.001 GOA:0.01 GOA:0.05 GOA:0.1 GOA:0.5 GOA:1 GOA:2 GOA:5 GOA:10 GOA:25 GOA:50 GOA:100 GOA:250 GOA:500 GOA:1000 GOA:2500 GOA:5000}" +# sum of default ladder ≈ GOA:9433 → withdraw headroom for multi-coin + fees +WITHDRAW_AMT="${WITHDRAW_AMT:-GOA:50000}" +CREDIT_AMT="${CREDIT_AMT:-GOA:200000}" + +BANK_ADMIN_PW_FILE="${BANK_ADMIN_PW_FILE:-$SECRETS/koopa/host-root/taler-bank/bank-admin-password.txt}" +BANK_FAT_PW_FILE="${BANK_FAT_PW_FILE:-$SECRETS/koopa/host-root/taler-bank/bank-bench-fat-password.txt}" +MER_PW_FILE="${MER_PW_FILE:-$SECRETS/koopa/host-root/taler-merchant/merchant-${INST}-password.txt}" + +wcli() { node "$MONO" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"; } +log() { echo "$(date -u +%H:%M:%S) $*" | tee -a "$SCRATCH/run.log"; } +now() { python3 -c 'import time;print(time.time())'; } +die() { log "FAIL $*"; exit 1; } + +[ -f "$MONO" ] || die "wallet cli missing: $MONO" +[ -f "$BANK_ADMIN_PW_FILE" ] || die "missing $BANK_ADMIN_PW_FILE" +[ -f "$MER_PW_FILE" ] || die "missing $MER_PW_FILE" + +ADMIN_PW=$(tr -d '\n' <"$BANK_ADMIN_PW_FILE") +MER_PW=$(tr -d '\n' <"$MER_PW_FILE") +if [ -f "$BANK_FAT_PW_FILE" ]; then + FAT_PW=$(tr -d '\n' <"$BANK_FAT_PW_FILE") +else + FAT_PW=$(openssl rand -hex 12) + mkdir -p "$(dirname "$BANK_FAT_PW_FILE")" + printf '%s\n' "$FAT_PW" >"$BANK_FAT_PW_FILE" + chmod 600 "$BANK_FAT_PW_FILE" + log "generated fat password -> $BANK_FAT_PW_FILE (secrets repo)" +fi + +# --- remote: run short script as hernani, capture stdout --- +run_koopa() { + local id="$1" + shift + local script_local="$SCRATCH/remote-$id.sh" + local mark="BENCH_${id}_OK" + { + echo '#!/bin/bash' + echo 'set +e' + printf '%s\n' "$@" + echo "echo $mark" + } >"$script_local" + scp -o BatchMode=yes -q "$script_local" "${KOOPA}:/tmp/bench-run-$id.sh" + ssh -o BatchMode=yes "$KOOPA" "bash /tmp/bench-run-$id.sh; rm -f /tmp/bench-run-$id.sh" \ + >"$SCRATCH/koopa-$id.out" 2>&1 || true + if ! grep -q "$mark" "$SCRATCH/koopa-$id.out" 2>/dev/null; then + log "WARN run_koopa $id incomplete (see koopa-$id.out)" + return 1 + fi + return 0 +} + +# Measure **inside** containers (and host loadavg) +sample_load() { + local tag="$1" + scp -o BatchMode=yes -q "$BENCH_DIR/sample-load-inside.sh" "${KOOPA}:/tmp/sample-load-inside.sh" + ssh -o BatchMode=yes "$KOOPA" "bash /tmp/sample-load-inside.sh $(printf %q "$tag"); rm -f /tmp/sample-load-inside.sh" \ + >"$SCRATCH/koopa-load-$tag.out" 2>&1 || true +} + +log "scratch=$SCRATCH" +log "AMOUNTS=$AMOUNTS" + +# --- preflight (external, public HTTPS) --- +for url in \ + "https://taler.hacktivism.ch/config" \ + "https://exchange.hacktivism.ch/keys" \ + "https://taler.hacktivism.ch/instances/${INST}/templates/${TPL}" +do + code=$(curl -sk -o /dev/null -w '%{http_code}' -m 10 "$url" || echo err) + log "preflight $url -> $code" + [ "$code" = "200" ] || die "preflight failed $url" +done + +# --- bank setup on koopa (API localhost, orchestrated from the local machine) --- +log "=== bank setup $FAT_USER credit=$CREDIT_AMT withdraw=$WITHDRAW_AMT ===" +{ + echo '#!/bin/bash' + echo 'set -euo pipefail' + echo "export ADMIN_PW=$(printf %q "$ADMIN_PW")" + echo "export FAT_PW=$(printf %q "$FAT_PW")" + echo "export FAT_USER=$(printf %q "$FAT_USER")" + echo "export CREDIT_AMT=$(printf %q "$CREDIT_AMT")" + echo "export WITHDRAW_AMT=$(printf %q "$WITHDRAW_AMT")" + cat <<'EOS' +BANK=http://127.0.0.1:9012 +AT=$(curl -sS -u "admin:${ADMIN_PW}" -H "Content-Type: application/json" -d '{"scope":"readwrite"}' \ + "$BANK/accounts/admin/token" | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])") +echo admin_tok_len=${#AT} +curl -sS -o /tmp/acc.json -w "create:%{http_code}\n" -X POST -H "Authorization: Bearer $AT" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"${FAT_USER}\",\"password\":\"${FAT_PW}\",\"name\":\"Bench Fat\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"GOA:0\"}" \ + "$BANK/accounts" || true +echo create_body=$(head -c 160 /tmp/acc.json) +python3 - <<'PY' +import json, os +A="0123456789ABCDEFGHJKMNPQRSTVWXYZ" +def c32(b): + v=bits=0; o=[] + for x in b: + v=(v<<8)|x; bits+=8 + while bits>=5: + bits-=5; o.append(A[(v>>bits)&31]) + if bits: o.append(A[(v<<(5-bits))&31]) + return "".join(o) +uid=c32(os.urandom(32)) +user=os.environ["FAT_USER"] +amt=os.environ["CREDIT_AMT"] +payto=f"payto://x-taler-bank/bank.hacktivism.ch/{user}?receiver-name={user}&message=bench-credit" +json.dump({"payto_uri":payto,"amount":amt,"request_uid":uid}, open("/tmp/tx.json","w")) +print("credit", amt, "->", user, "uid", uid) +PY +curl -sS -o /tmp/cred.json -w "credit:%{http_code}\n" -X POST -H "Authorization: Bearer $AT" \ + -H "Content-Type: application/json" -d @/tmp/tx.json "$BANK/accounts/admin/transactions" +echo credit_body=$(head -c 200 /tmp/cred.json) +BT=$(curl -sS -u "${FAT_USER}:${FAT_PW}" -H "Content-Type: application/json" -d '{"scope":"readwrite"}' \ + "$BANK/accounts/${FAT_USER}/token" | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])") +echo fat_tok_len=${#BT} +curl -sS -H "Authorization: Bearer $BT" "$BANK/accounts/${FAT_USER}" -o /tmp/fatbal.json +echo fat_account=$(head -c 280 /tmp/fatbal.json) +curl -sS -o /tmp/wd.json -w "wd:%{http_code}\n" -X POST -H "Authorization: Bearer $BT" \ + -H "Content-Type: application/json" -d "{\"amount\":\"${WITHDRAW_AMT}\"}" \ + "$BANK/accounts/${FAT_USER}/withdrawals" +python3 - <<'PY' +import json +d=json.load(open("/tmp/wd.json")) +print("wd_keys", list(d.keys())[:20]) +uri=d.get("taler_withdraw_uri") or d.get("withdrawal_uri") or "" +wid=d.get("withdrawal_id") or d.get("id") or "" +if not uri: + for v in d.values(): + if isinstance(v, str) and v.startswith("taler"): + uri=v +open("/tmp/bench-wuri.txt","w").write(uri) +open("/tmp/bench-wid.txt","w").write(wid) +print("WURI", uri) +print("WID", wid) +assert uri and wid, d +PY +# cleanup setup temps on host (not durable) +rm -f /tmp/acc.json /tmp/tx.json /tmp/cred.json /tmp/fatbal.json /tmp/wd.json +echo BENCH_banksetup_OK +EOS +} >"$SCRATCH/remote-banksetup.sh" +scp -o BatchMode=yes -q "$SCRATCH/remote-banksetup.sh" "${KOOPA}:/tmp/bench-run-banksetup.sh" +ssh -o BatchMode=yes "$KOOPA" 'bash /tmp/bench-run-banksetup.sh; rm -f /tmp/bench-run-banksetup.sh' \ + | tee "$SCRATCH/koopa-banksetup.out" +grep -q BENCH_banksetup_OK "$SCRATCH/koopa-banksetup.out" || die "bank setup failed" +scp -o BatchMode=yes -q "${KOOPA}:/tmp/bench-wuri.txt" "${KOOPA}:/tmp/bench-wid.txt" "$SCRATCH/" +ssh -o BatchMode=yes "$KOOPA" 'rm -f /tmp/bench-wuri.txt /tmp/bench-wid.txt' || true +WURI=$(tr -d '\n' <"$SCRATCH/bench-wuri.txt") +WID=$(tr -d '\n' <"$SCRATCH/bench-wid.txt") +[ -n "$WURI" ] || die "missing withdraw URI" +log "WURI=$WURI WID=$WID" + +log "=== load baseline (internal) ===" +sample_load baseline + +log "=== wallet: exchange + withdraw (external) ===" +wcli exchanges add "$EX" 2>&1 | tee "$SCRATCH/ex-add.out" || true +wcli exchanges update "$EX" 2>&1 | tee "$SCRATCH/ex-upd.out" || true +wcli exchanges tos "$EX" 2>&1 | tee "$SCRATCH/ex-tos.out" || true +wcli exchanges accept-tos "$EX" 2>&1 | tee "$SCRATCH/ex-accept.out" || true + +TW0=$(now) +# 1) Wallet selects exchange (status → selected). Must happen BEFORE bank confirm. +wcli withdraw accept-uri --exchange "$EX" "$WURI" 2>&1 | tee "$SCRATCH/wd-accept.out" || true + +# 2) Wait until bank sees selection, then confirm once (libeufin rejects confirm if still unselected). +log "=== bank: wait selected → confirm ===" +run_koopa wdconfirm \ + "export FAT_USER=$(printf %q "$FAT_USER")" \ + "export FAT_PW=$(printf %q "$FAT_PW")" \ + "export WID=$(printf %q "$WID")" \ + 'BANK=http://127.0.0.1:9012' \ + 'BT=$(curl -sS -u "${FAT_USER}:${FAT_PW}" -H "Content-Type: application/json" -d "{\"scope\":\"readwrite\"}" "$BANK/accounts/${FAT_USER}/token" | python3 -c "import sys,json;print(json.load(sys.stdin).get(\"access_token\",\"\"))")' \ + 'echo WID=$WID tok_len=${#BT}' \ + 'st=""' \ + 'for i in $(seq 1 60); do + curl -sS "$BANK/taler-integration/withdrawal-operation/${WID}" -o /tmp/wdst.json || true + st=$(python3 -c "import json;print(json.load(open(\"/tmp/wdst.json\")).get(\"status\",\"\"))" 2>/dev/null || true) + echo "wait_select #$i status=$st" + case "$st" in selected|confirmed) break ;; esac + sleep 1 + done' \ + 'case "$st" in + selected) + echo confirming... + curl -sS -o /tmp/wdconf.out -w "confirm_http:%{http_code}\n" -X POST \ + -H "Authorization: Bearer $BT" -H "Content-Type: application/json" -d "{}" \ + "$BANK/accounts/${FAT_USER}/withdrawals/${WID}/confirm" + head -c 120 /tmp/wdconf.out; echo + ;; + confirmed) echo already_confirmed ;; + *) echo "WARN never selected (last=$st)" ;; + esac' \ + 'for i in $(seq 1 30); do + curl -sS "$BANK/taler-integration/withdrawal-operation/${WID}" -o /tmp/wdst.json || true + python3 -c "import json;d=json.load(open(\"/tmp/wdst.json\"));print(\"post\",d.get(\"status\"),\"transfer\",d.get(\"transfer_done\"))" + st=$(python3 -c "import json;print(json.load(open(\"/tmp/wdst.json\")).get(\"status\",\"\"))" 2>/dev/null || true) + td=$(python3 -c "import json;print(json.load(open(\"/tmp/wdst.json\")).get(\"transfer_done\"))" 2>/dev/null || true) + case "$st" in confirmed) break ;; esac + [ "$td" = "True" ] || [ "$td" = "true" ] && break + sleep 1 + done + rm -f /tmp/wdst.json /tmp/wdconf.out' || true + +# 3) Wallet finishes planchets (may take minutes for large withdraws) +log "=== wallet: run-until-done after confirm ===" +for round in $(seq 1 30); do + log "withdraw run-until-done round $round" + # timeout each round so we do not hang forever on long-poll + set +e + # 120s max per round + perl -e 'alarm shift; exec @ARGV' 120 \ + node "$MONO" --wallet-db="$WDB" --no-throttle --skip-defaults run-until-done \ + 2>&1 | tee -a "$SCRATCH/wd-rud.out" + set -e + wcli balance 2>&1 | tee "$SCRATCH/bal-wd-$round.out" + if python3 - "$SCRATCH/bal-wd-$round.out" <<'PY' +import re,sys +t=open(sys.argv[1]).read() +vals=[float(x) for x in re.findall(r"GOA:([0-9.]+)", t)] +sys.exit(0 if vals and max(vals) >= 100 else 1) +PY + then + log "withdraw OK" + break + fi + sleep 1 +done +TW1=$(now) +python3 -c "print('withdraw_wall_s', round(float('$TW1')-float('$TW0'), 3))" | tee "$SCRATCH/withdraw-time.txt" +wcli balance 2>&1 | tee "$SCRATCH/balance-before-pays.out" +sample_load after-withdraw + +CSV="$SCRATCH/payments.csv" +echo "idx,amount,handle_s,rud_s,total_s,status,order_id,loadavg,notes" >"$CSV" + +idx=0 +for AMT in $AMOUNTS; do + idx=$((idx + 1)) + # no trailing slash; amount only (template has fixed summary) + URI="taler://pay-template/${MER_HOST}/instances/${INST}/${TPL}?amount=${AMT}" + echo "$URI" >"$SCRATCH/uri-$idx.txt" + log "=== pay #$idx $AMT ===" + log "uri=$URI" + + load_before=$(ssh -o BatchMode=yes "$KOOPA" 'cut -d" " -f1 /proc/loadavg' 2>/dev/null || echo "?") + sample_load "pay$idx" & + SPID=$! + + T0=$(now) + set +e + wcli handle-uri --yes "$URI" 2>&1 | tee "$SCRATCH/pay-$idx-handle.out" + T1=$(now) + wcli run-until-done 2>&1 | tee "$SCRATCH/pay-$idx-rud.out" + T2=$(now) + set -e + + handle_s=$(python3 -c "print(round(float('$T1')-float('$T0'),3))") + rud_s=$(python3 -c "print(round(float('$T2')-float('$T1'),3))") + total_s=$(python3 -c "print(round(float('$T2')-float('$T0'),3))") + wait "$SPID" 2>/dev/null || true + load_after=$(ssh -o BatchMode=yes "$KOOPA" 'cut -d" " -f1 /proc/loadavg' 2>/dev/null || echo "?") + + run_koopa "ord$idx" \ + "export MPW=$(printf %q "$MER_PW")" \ + "export INST=$(printf %q "$INST")" \ + "export AMT=$(printf %q "$AMT")" \ + 'AUTH="Authorization: Bearer secret-token:${MPW}"' \ + 'curl -sk -H "$AUTH" "https://127.0.0.1:9010/instances/${INST}/private/orders?paid=YES&delta=-30" -o /tmp/ol.json' \ + 'python3 - </dev/null || true)' \ + 'if [ -n "$OID" ]; then + curl -sk -H "$AUTH" "https://127.0.0.1:9010/instances/${INST}/private/orders/${OID}" -o /tmp/od.json + python3 -c "import json;d=json.load(open(\"/tmp/od.json\"));print(\"DETAIL paid\",d.get(\"paid\"),\"status\",d.get(\"order_status\"),\"amount\",(d.get(\"contract_terms\")or{}).get(\"amount\"))" + fi + rm -f /tmp/ol.json /tmp/od.json /tmp/oid.txt' || true + + status=unknown + if grep -qE 'DETAIL paid True|DETAIL paid true' "$SCRATCH/koopa-ord$idx.out" 2>/dev/null; then + status=paid + elif grep -qiE 'insufficient balance' "$SCRATCH/pay-$idx-handle.out" 2>/dev/null; then + status=insufficient + elif grep -qiE 'error|fail|invalid|404|409|Unexpected HTTP' "$SCRATCH/pay-$idx-handle.out" 2>/dev/null; then + status=error + elif grep -qi 'paid' "$SCRATCH/pay-$idx-rud.out" 2>/dev/null; then + status=maybe + fi + oid=$(awk '/^ORDER /{print $2; exit}' "$SCRATCH/koopa-ord$idx.out" 2>/dev/null || true) + notes="load ${load_before}->${load_after}" + echo "$idx,$AMT,$handle_s,$rud_s,$total_s,$status,$oid,$load_after,$notes" >>"$CSV" + log "pay #$idx $AMT total=${total_s}s status=$status oid=$oid load=$load_after" + wcli balance 2>&1 | tee "$SCRATCH/bal-after-$idx.out" || true +done + +sample_load final +wcli balance 2>&1 | tee "$SCRATCH/balance-final.out" || true + +python3 - "$CSV" "$SCRATCH" <<'PY' | tee "$SCRATCH/RESULTS.md" +import csv, sys +csv_path, scratch = sys.argv[1], sys.argv[2] +rows = list(csv.DictReader(open(csv_path))) +print("# Amount ladder payment benchmark") +print() +print(f"- Scratch: `{scratch}`") +print(f"- Drive: Local-machine wallet-cli (external) · Measure: koopa podman (internal)") +print(f"- Template: `goa-free` on `goa-demo-cp4zqk`") +print(f"- URI: `taler://pay-template/…/instances/…/goa-free?amount=…` (no trailing slash, no summary)") +print() +print("| # | Amount | handle (s) | run-until-done (s) | **total (s)** | status | order | loadavg |") +print("|--:|--------|----------:|-------------------:|--------------:|--------|-------|--------:|") +for r in rows: + print(f"| {r['idx']} | `{r['amount']}` | {r['handle_s']} | {r['rud_s']} | **{r['total_s']}** | {r['status']} | `{r.get('order_id','')}` | {r.get('loadavg','')} |") +print() +paid = [r for r in rows if r["status"] == "paid"] +print(f"Paid **{len(paid)}/{len(rows)}**.") +if paid: + totals = [float(r["total_s"]) for r in paid if r["total_s"]] + if totals: + print(f"**Min** {min(totals):.3f}s · **Max** {max(totals):.3f}s · **Avg** {sum(totals)/len(totals):.3f}s") +print() +print("Inside samples: `koopa-load-*.out` in scratch.") +PY + +# durable copy of last RESULTS into benchmarks tree (no secrets) +cp "$SCRATCH/RESULTS.md" "$BENCH_DIR/LAST_RESULTS.md" +cp "$CSV" "$BENCH_DIR/LAST_payments.csv" +log "DONE -> $SCRATCH/RESULTS.md (also benchmarks/amount-ladder/LAST_*)" +cat "$SCRATCH/RESULTS.md" diff --git a/benchmarks/amount-ladder/sample-load-inside.sh b/benchmarks/amount-ladder/sample-load-inside.sh new file mode 100755 index 0000000..0895f7e --- /dev/null +++ b/benchmarks/amount-ladder/sample-load-inside.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Sample host + three-container CPU/RSS from koopa (run as hernani via ssh). +# Usage (local): ssh hernani@koopa 'bash -s' < sample-load-inside.sh +# Or: ./sample-load-inside.sh # if executed ON koopa as hernani +set -eu +tag="${1:-manual}" +echo "=== sample_load tag=$tag $(date -u +%FT%TZ) ===" +echo "=== loadavg ===" +cat /proc/loadavg +echo "=== mem ===" +free -h 2>/dev/null || true +echo "=== top host cpu ===" +ps -eo pid,pcpu,pmem,rss,etime,comm --sort=-pcpu 2>/dev/null | head -15 +echo "=== exchange (taler-hacktivism-exchange-ansible) ===" +podman exec taler-hacktivism-exchange-ansible \ + ps -eo pid,pcpu,pmem,rss,etime,args 2>/dev/null \ + | grep -E 'taler-exchange|postgres -D|PID' | head -25 || true +echo "=== merchant (taler-hacktivism) ===" +podman exec taler-hacktivism \ + ps -eo pid,pcpu,pmem,rss,etime,args 2>/dev/null \ + | grep -E 'taler-merchant|nginx|postgres -D' | head -25 || true +echo "=== bank (taler-bank-hacktivism) ===" +podman exec taler-bank-hacktivism \ + ps -eo pid,pcpu,pmem,rss,etime,args 2>/dev/null \ + | grep -E 'java|libeufin|postgres -D' | head -15 || true +echo "=== sample_load done ===" diff --git a/configs/README.md b/configs/README.md new file mode 100644 index 0000000..82ae124 --- /dev/null +++ b/configs/README.md @@ -0,0 +1,19 @@ +# Config mirrors (from host koopa) + +Directories are named to match **live podman container names** where possible. + +| Directory | Live container | Image (typical) | +|-----------|----------------|-----------------| +| `taler-hacktivism/` | `taler-hacktivism` | `taler-hacktivism-live:landing` | +| `taler-hacktivism-bank/` | `taler-hacktivism-bank` | **`taler-hacktivism-banking:live`** | +| `taler-exchange/` | conf inside exchange container | (see exchange-ansible) | +| `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` | +| `caddy/` `firewalld/` `systemd/` | host services | | +| `tor/` | **`koopa-tor-relay`** (podman host net) | `localhost/koopa-tor-relay:latest` | + +**Authoritative running inventory:** `host/overview/LIVE.md`. + +Secrets never live here — `SECRETS.md` / `koopa-admin-secrets`. diff --git a/configs/bank-landing/README.md b/configs/bank-landing/README.md new file mode 100644 index 0000000..d59e83a --- /dev/null +++ b/configs/bank-landing/README.md @@ -0,0 +1,250 @@ +# Bank landing (`bank.hacktivism.ch`) + +English intro for **GOA exploration** (badge: **Intergalactic · GOA · no IBAN**): +put coins in a GNU Taler wallet, optionally open your own bank account, and +point merchants at `taler.hacktivism.ch`. + +| Public URL | What | +|------------|------| +| `https://bank.hacktivism.ch/` · `/intro/` | Landing (nginx in bank container → host **9013**, Caddy) | +| `https://bank.hacktivism.ch/intro/stats.json` | **Live stats** (written *inside* bank container) | +| `https://bank.hacktivism.ch/intro/demo-withdraw.json` | Mint **shared-pool** (`explorer`) withdraw URI | +| `https://bank.hacktivism.ch/intro/auto-account.json` | Auto-create **personal** bank user (credentials once; balance **GOA:0**) | +| `https://bank.hacktivism.ch/webui/` | libeufin-bank SPA (register · login · withdraw) when libeufin is up on **9012** | +| `https://exchange.hacktivism.ch/` | Exchange (ToS `/terms`, keys `/keys`) | +| `https://taler.hacktivism.ch/` | Merchant backend | + +## Page layout (top → bottom) + +1. Hero (badge, short shared-account line) +2. **Get the wallet** — Play / F-Droid / iOS / web hub + **Debian/Ubuntu · Command line** (apt) + **Unix / POSIX · from source** (build monorepo or run `.mjs`) +3. **Withdraw GOA** — compact QR_Taler, open CTA, live pool balance note, generate-own-account link +4. **GOA shop · samples** — always visible (2 products + merchant link) +5. **How the shared account works** — collapsed fold (click to expand) +6. **Optional · own bank account** — collapsed fold: auto-create account + credentials + QR_Taler for bank UI +7. **GOA flow** stats +8. **Performance** +9. Footer + +## User flow (documented on the page) + +### Primary path (works without your own bank account) + +1. **Install wallet** — Android (Play / F-Droid), iOS, Ubuntu Touch (via Waydroid + Android wallet), web (Chrome/Firefox/Opera on taler.net). +2. **One QR / link** — live `taler://withdraw/…` from the **community-shared** bank pool (`explorer`). + Minted via `GET /intro/demo-withdraw.json` (or fallback `withdraw.uri`). + Auto-confirm finishes the bank side so coins reach the wallet. +3. **Surprising balance note** — shows live `balance_explorer` from `stats.json` + (“pool holds GOA:… (shared, but also yours)”). + +QR encoding matches merchant shop popup (**QR_Taler**): animated `#0042B3` conic +ring, off-DOM PNG, center `qr-logo.svg` (`shop-pay.css` classes). Label text: +**Scan in wallet**. + +### Optional: personal bank account (auto-created) + +In the optional fold: + +1. User clicks **Create my bank account**. +2. Browser calls `GET /intro/auto-account.json` (no form fields). +3. Server registers a new libeufin user via public `POST /accounts`. +4. UI shows **username / password** once (“created for you”; + not stored for recovery). +5. Username shape: **`goa-account-`** (shown to the user). + Password embeds **`pleasechangeme`** with random chars around it. +6. QR_Taler + link open `/webui/` so the user can log in with those credentials. + +Balance **starts at zero** — not the shared `explorer` pool. + +#### Deploy status — own bank account (**missing** until verified) + +The **own bank account** path is **not complete** unless all of the following +are live on `taler-hacktivism-bank`. Treat this as **missing** until checked: + +| Check | Expected | +|-------|----------| +| Landing HTML | Optional fold has **Create my bank account** | +| API process | `python3 /usr/local/bin/demo-withdraw-api.py` on **127.0.0.1:19096** | +| Nginx | `location = /intro/auto-account.json` → `:19096` | +| Public | `curl -sS https://bank.hacktivism.ch/intro/auto-account.json` returns JSON with `username` / `password` / `balance: "GOA:0"` | +| WebUI login | Credentials work at `https://bank.hacktivism.ch/webui/` | + +Install helper: `scripts/taler-bank/install-demo-withdraw-api.sh` +(or manual copy of `demo-withdraw-api.py` + `nginx-landing.conf` + restart API). + +### How the shared pool works + +1. Shared account `explorer` is pre-funded with GOA. +2. Each demo withdraw is a **one-shot** op from that pool (not a personal login). +3. Wallet accepts the URI; **auto-confirm only for `explorer`** (see + `auto-confirm-withdrawals.sh`). +4. Pool balance is communal; amounts you withdraw are yours in the wallet. + +### Helpers + +| Script | Role | +|--------|------| +| `scripts/taler-bank/demo-withdraw-api.py` | Loopback HTTP **:19096** — `/demo-withdraw.json` + `/auto-account.json` | +| `scripts/taler-bank/install-demo-withdraw-api.sh` | Copy into bank container, nginx locations, start API + auto-confirm loop | +| `scripts/taler-bank/auto-confirm-withdrawals.sh` | Confirm **explorer-only** withdrawals when status=`selected` | +| `scripts/taler-bank/refresh-demo-withdraw.sh` | Refresh static `withdraw.uri` (no python) | +| `configs/bank-landing/nginx-landing.conf` | `/intro/demo-withdraw.json`, `/intro/auto-account.json` proxies | + +Needs **python3** in the bank container for `demo-withdraw-api.py`. + +### CLI sketch (fresh wallet DB) + +```bash +# equivalent one-step in CLI terms: +taler-wallet-cli exchanges add https://exchange.hacktivism.ch/ +taler-wallet-cli exchanges accept-tos https://exchange.hacktivism.ch/ +# then withdraw from the exchange (amount chosen in wallet UI) +``` + +--- + +## Live stats (in bank container) + +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. + +| Piece | Path / role | +|-------|-------------| +| Generator (in-container) | `/usr/local/bin/landing-stats.sh` | +| Source in admin-log | `scripts/taler-bank/landing-stats.sh` | +| Host installer | `scripts/taler-bank/landing-stats-install.sh` | +| Output | `/var/www/bank-landing/stats.json` → `https://bank.hacktivism.ch/intro/stats.json` | +| Landing UI | `index.html` section **GOA flow · live** | + +### What the page shows + +- **Bank accounts** — registered accounts (libeufin `GET /accounts`, admin) +- **Wallets involved** — unique reserve pubs from Taler withdrawal debits +- **Withdraws / bank credits** — flow amounts (exchange mirror skipped where noted) +- **Last withdraws** — recent list with amount, time (**CEST** / `Europe/Zurich`) +- **Last 24h / 7d** — amount +- **`balance_explorer`** — live shared-pool balance (shown on withdraw step) +- Performance probes + container RSS (bottom of page) + +### Scan depth (must stay deep enough) + +| 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 | + +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) — +that is normal (e.g. fresh auto-accounts), not a stats failure. + +### `stats.json` shape (abridged) + +```json +{ + "ok": true, + "currency": "GOA", + "generated_at": "2026-07-10T20:35+02:00", + "balance_explorer": "GOA:960", + "bank_accounts": { "total": 56, "users": 55 }, + "wallets": { "unique_reserves": 51 }, + "withdraws": { + "count": 51, + "last_24h": { "amount": "GOA:…" }, + "last_7d": { "amount": "GOA:…" } + }, + "flow": { + "incoming": { "amount": "GOA:…", "count": 68 }, + "withdraw": { "amount": "GOA:…", "count": 51 } + }, + "source": "in-container landing-stats.sh" +} +``` + +### Install + refresh (on koopa host) + +```bash +# from laptop: copy scripts, then on koopa: +cd /path/to/koopa-admin-log/scripts/taler-bank + +# copy into container + one-shot run +sudo ./landing-stats-install.sh + +# every minute inside the container (* * * * *) +sudo ./landing-stats-install.sh --cron +# optional: LANDING_STATS_CRON='*/5 * * * *' ./landing-stats-install.sh --cron + +# later: only re-run +sudo ./landing-stats-install.sh --run-only +``` + +Requirements **inside** the container: + +- `curl`, `awk` (mawk OK), `sed`, `date` — **no python** +- libeufin-bank listening on loopback (script auto-detects `PORT` / 9012 / 8080) +- `/root/bank-explorer-password.txt` (or `BANK_PASS=…` on the `podman exec`) +- writable `/var/www/bank-landing/` (same tree nginx uses for `/intro/`) + +`make-demo-withdraw-qr.sh` also calls `landing-stats.sh` after refreshing the demo URI, +when the binary is already installed at `/usr/local/bin/landing-stats.sh`. + +### Manual one-liner (debug) + +```bash +podman exec -e LANDING_DIR=/var/www/bank-landing taler-hacktivism-bank \ + /usr/local/bin/landing-stats.sh +curl -sS https://bank.hacktivism.ch/intro/stats.json | python3 -m json.tool +``` + +--- + +## Host / container files + +Landing root (nginx in bank container), typically under something like +`/var/www/bank-landing/` or the container path mapped for **9013**: + +| File | Role | +|------|------| +| `index.html` | landing (wallet, shared withdraw, folds, stats) + Open Graph meta | +| `og-goa-shop.png` | **link preview** image (1200×630) for chats / social (OG + Twitter) | +| `shop-pay.css` / `shop-pay.js` | GOA shop popup + **QR_Taler** frame styles | +| `qr-logo.svg` | center logo on QR_Taler frames | +| `qrcode.min.js` | QR encode helper | +| `stats.json` | **live stats** (from in-container script) | +| `nginx-landing.conf` | bank container nginx (**9013**) | +| `withdraw.uri` | current demo `taler://withdraw/…` (optional) | +| `withdraw.amount` | e.g. `GOA:10` (optional) | +| `withdraw-watch.ids` | ids for auto-confirm helper (optional) | + +## Deploy landing HTML + helper API + +```bash +# landing assets +podman cp configs/bank-landing/index.html \ + taler-hacktivism-bank:/var/www/bank-landing/index.html +podman cp configs/bank-landing/og-goa-shop.png \ + taler-hacktivism-bank:/var/www/bank-landing/og-goa-shop.png +podman cp configs/bank-landing/shop-pay.js \ + taler-hacktivism-bank:/var/www/bank-landing/shop-pay.js +# … shop-pay.css, qr-logo.svg, qrcode.min.js as needed + +# demo-withdraw + auto-account API (python3 in container) +./scripts/taler-bank/install-demo-withdraw-api.sh +# or manually: +podman cp scripts/taler-bank/demo-withdraw-api.py \ + taler-hacktivism-bank:/usr/local/bin/demo-withdraw-api.py +podman cp configs/bank-landing/nginx-landing.conf \ + taler-hacktivism-bank:/etc/nginx/sites-available/bank-landing +# restart: python3 /usr/local/bin/demo-withdraw-api.py on 127.0.0.1:19096 +# nginx -s reload; auto-confirm-withdrawals.sh --loop +``` + +### Demo QR refresh (in container, no python) + +```bash +podman exec taler-hacktivism-bank /usr/local/bin/refresh-demo-withdraw.sh +# source: scripts/taler-bank/refresh-demo-withdraw.sh +``` + +Older host script (needs python3): `scripts/taler-bank/make-demo-withdraw-qr.sh` diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html new file mode 100644 index 0000000..5dfebb3 --- /dev/null +++ b/configs/bank-landing/index.html @@ -0,0 +1,2055 @@ + + + + + + GOA Exploration Bank + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ stack· + libeufin-bank 1.6.6 + · + libeufin-common 1.6.6 +

+
+
+ Intergalactic · GOA · no IBAN +

GOA Exploration Bank

+

+ Get your GOA in one step from the + shared bank account into your wallet — no registration. +

+
+ + +
+

1 Get the wallet

+
+ + + + +
+
+ + +
+

2 Withdraw GOA

+

One step · shared pool · no registration

+
+
+
+
+ +
+
Scan in wallet
+ Preparing withdraw… +
+ + Open in wallet → + + +

+ Community pool balance: + sits in the shared bank account + (everyone draws from the same pool — not your personal wallet). + Need more? Withdraw again from this pool, or create an own account + and fund it separately. +

+
+
+ + + + +
+ + +
+ + +
+ + +
+ + + + +
+

Performance

+

+ Live HTTP probes + container memory (RSS) +

+
+
+ /config + +
+
+ /taler-integration/config + +
+
+ /webui/ + +
+
+ Loadavg + +
+
+ Container + +
+
+ PostgreSQL + +
+
+ Java / libeufin + +
+
+ Nginx + +
+
+
+

Top processes (RSS)

+
+
+

Memory from /proc + cgroup inside podman container.

+
+ + +
+ + + + + diff --git a/configs/bank-landing/nginx-cors-stats.conf b/configs/bank-landing/nginx-cors-stats.conf new file mode 100644 index 0000000..6442861 --- /dev/null +++ b/configs/bank-landing/nginx-cors-stats.conf @@ -0,0 +1,6 @@ +# Snippet: CORS so exchange/merchant landings can fetch stats.json +# Include inside bank landing server { } or merge into bank-landing site. +# location for stats only: +# include /etc/nginx/snippets/goa-stats-cors.conf; (if placed as snippet) + +# Applied as extra location on :9013 bank-landing: diff --git a/configs/bank-landing/nginx-landing.conf b/configs/bank-landing/nginx-landing.conf new file mode 100644 index 0000000..251a713 --- /dev/null +++ b/configs/bank-landing/nginx-landing.conf @@ -0,0 +1,71 @@ +# Bank landing — :9013 (behind Caddy :443 — never put 9013 in redirects) +server { + listen 9013; + listen [::]:9013; + server_name bank.hacktivism.ch _; + root /var/www/bank-landing; + index index.html; + + # Critical behind reverse_proxy: no :9013 in Location headers + absolute_redirect off; + port_in_redirect off; + + location = / { + return 302 /intro/; + } + location = /intro { + return 302 /intro/; + } + # Terms (same dark palette as merchant/exchange terms pages) + location = /terms { + alias /var/www/bank-landing/terms.html; + default_type text/html; + add_header Cache-Control "no-store" always; + } + location = /terms/ { + return 302 /terms; + } + location = /privacy { + alias /var/www/bank-landing/privacy.html; + default_type text/html; + add_header Cache-Control "no-store" always; + } + location = /privacy/ { + return 302 /privacy; + } + # Fresh community-pool withdraw for one-click wallet funding + location = /intro/demo-withdraw.json { + proxy_pass http://127.0.0.1:19096/demo-withdraw.json; + proxy_http_version 1.1; + proxy_set_header Host $host; + add_header Cache-Control "no-store" always; + add_header Access-Control-Allow-Origin * always; + } + # Auto-create personal bank account (credentials returned once; balance GOA:0) + location = /intro/auto-account.json { + proxy_pass http://127.0.0.1:19096/auto-account.json; + proxy_http_version 1.1; + proxy_set_header Host $host; + add_header Cache-Control "no-store" always; + add_header Access-Control-Allow-Origin * always; + } + location /intro/ { + alias /var/www/bank-landing/; + } + location = /intro/stats.json { + alias /var/www/bank-landing/stats.json; + default_type application/json; + add_header Access-Control-Allow-Origin * always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + add_header Cache-Control "no-store" always; + } + location = /stats.json { + alias /var/www/bank-landing/stats.json; + default_type application/json; + add_header Access-Control-Allow-Origin * always; + add_header Cache-Control "no-store" always; + } + location / { + try_files $uri $uri/ =404; + } +} diff --git a/configs/bank-landing/og-goa-shop.png b/configs/bank-landing/og-goa-shop.png new file mode 100644 index 0000000000000000000000000000000000000000..aeaad870078fe2e89faa409718d3c3430aa112ba GIT binary patch literal 557173 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
7VAoh{5UoAiYM9&Ly$g4 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
I|8CJr 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

969O}CVy_Ydn!e9OM_wfAc8Q%NS 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

gzxgCdSd*5D& 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�kcEyv=#;$;UV{ ze~8VERhAYnBc&vYCAqZ-57xK@GeSmt4#3Ri&78hlQ#w+X|QyZLM*ee<=y?8N%7UH)#a54XdJ9Urgp zF7C%yJt$_em!ywyMT&n#pxu4l-8Keew*5r+w{m*i&~c@-6&QCh&_nV%rKMIFw?2G z%ER>B?R=3c(jyo~^ou-wS?24zpxLAZ7-MmfVAW>$MuU(3#82|YpZjUVRD(bL-k-91 zbBRXOKnsf<^bigfmj*1aLucv~GM?g{O9I@#z>jq@C@=T@;oCi zG!k@_(C)OEo}Ho7?vNyLFn*jPO$YS)18!}u(CznF@2+EQjfL!H9A@6+2IruyhWpv*adUYk6%2!dk;d|l>VcDVn5fqMP z!A1#bKT(w~SQ>m)gq<2O<&J31^uP!5@qzrSKF5Gp=^A+-R7ki zU*zG(KSZN5ABrCgLd4m6%exmZa{AOsTJ09;AVo=yE0`r-cm}BnAXYEu-~78jpx5p3 z;pd*^xsN=fU9rem`^IZnba^vRbRX z5S4aRIghLTs(#&S9+YPp#@2>x`T+X{slI!cCN*No2?|glyvIR2+|g__@!n&MAyOKp zWAa{#a|4=j2iH&e^oKsgeTO@^evharu`WXuDXAVwZY-+M7qmVoIH4oXz4<0fOSfn= z5}XhSC((jJyUK=w7ZBz;5-*C%uOh)=@xmg!KnZ~q3gdF3ShC*V3gA_7UwIxX+%_6X zu}+2cNlJ+g!a;>@lqb(KW@l$uTV2IuIZ;ESVujhtk zZfL9%Xc?ZKaY5%NjupA_NaxVah;x_EbM5*invDb(vZIJ}6GZ`D2{rPJf`-4WBmf*> zJZQo_<3MXo<_yl~C>g5ZmTA065(hAg0BnU+*U|a6ueq{^<5s2+)}5C+v#R16R-kV! z)1c1OD*EYB2n1FH9l7egn0WosjEm~ieXv7r_kQoBReKS2yVv5Z;l4Ac&`NOr+}kvg z23ji+5?$B~&bcr}Opf*7y`t-4F5Xz@`t_SkO|?l9L6Ublb?guiojS^3D`k1{1|qWP zIKmr2Zb8T>_*4j-OVLV(D$LG-*C-hUi3I|Hs$|fhvPG3ai6inL74$ZjuugFL_#sx; z)>v8VvfkTbwdXm0>?B$$%%I1>^sz1_QZYJ;Pz2VPGZt$tgN+TAH*WCgGauv3>65(t z)>X6&wp$6*?8kP$o6l*dxv{_bGX!Xd|1SPc`HqNP&pqyU3~8>;uUl9(tzr?zg?K&1-*9&wc>gI@c}N)n^KWtF{LEl0z9 z@nikw4uHL#=ITM_@O}s<_5STgGHB8@_R8iOzyF~rH+)$2W1Mp|8VvvjgTW}4v%k?B z{oBxB+IDX`Fg}Y27Ze6{1hSRy;-qIf?%3ZBK(hUT4E2L19lP&gnE+IbgDShB z(YL#RP8x$!d|Mvy<=9C8Zl{0O$8j$Zt3Hu;qK59p>Y4-q)&D*cO{o@;+HxpF@CNZC z=euq88%#9cCZX0lJ^K>4s)JWMfn39=QHUG{%*c&x6g83SyUGP?B|%!qFLi`d5CLr=R;M*Kgh6%^$uAnPE0*;d6r!1}OzvH+kdI0{`Uq-{xn2 z>NHm`-{AW{bQm+ozzT9XAZf>(Jb9c$v$G^g8(m zMIAa*bIi;qA?3h1g!DoG!+WIEC=}Vil9?`6I2s+r-24&dXXa?PJLovBqWFWsfR&Xc zHa6DT+}tG34c0ik7f3D94T(%*64k8q!n`e3k>e2Af(lH^G(FBKA4(GEf0Bmo4QK6$gD+))3Apm)o2&K0> ztB^v5%7No+RE?Qd0?nmpF9epAtfeoNlalLWXtB+5phFBI+iT=KA$TraK*e`e(n+`Ezga&fDi`B%u=vM zz?h1Yh&T?qRaQA7DN)KX=&vGV%#-(@=A)0C!uGcikpL;lb3&k035;{Z+9Qm?ILMGF z-QfJCt6aEvfmXX+X!#UuB@sBvUP@fSOZHAumB>Y70Y?rZ1YJatB=4m{CXW94CQ(x% zMWIWWXS7-^;v_C?Srf{6GajkM;PZ(S`g9!fr1Sd=2l^w z74t`gp}`~2N}`0q8XNQ#RZf=o0f$rp^vO*|vzef^3e3vNUMe2E7a;6id>4lA_t1Q+ zLh{x04Pe%=s`<#OT(Hc_RO7OWvahF<*I0?W*_7q?6Q}(SOpTq6z3U)W$t)?>SEU5r zSz3*l`Ppf@TN{9OPs!<$e+vpqG-D{o!n@BaP&8EXZ90o ztloH=L^(E=uM^7-Nu+U+$2uEWg(AYcAoeZ?!n3w~oo2Je*)u0u>us>IvBfKIyv=mG!?D9N2q`&yLsDb}wqMou*(1MD9N z+IC!dg0X*(vb{Y_^zhhPw4@GRjX_r1W6Nl5hVQkC~=<57jZ zqDUn>YZ6$rE4o`)3G#l~xRW;iYhPc48NRCChm;qs6=|L$ z8!^j+F2^5#m@oa(&vD|+8D9ULly(L1^nV#ax(wZUMJA_OR<(OT1L z#z90F@KWKW1ZhJGinK^2h#C?ddxQujs|5<_BcxSe6{{;1qDmO1)LJbTJ9TN$GH6el;Dhmd%U^v&q0}%yFuB;YG$;>RD+vuKpPa?#7H(!BQ zt`M)6MV@07H5;)IiYihi8>fmq%iyfKvdR=#Z-?T&^?Q7O(7HPurP|*{1l?Rb}mTui-V`Br~7^Y`MNrbf?B^AzDyl@4GQfN7O7qr-fsI;xh zYOw*t5_AUx7H-_+lb`xDZ+-P^y!gTkw2vRbJXfYOy znL~kmSgWR1k+Evrmva5}L*9xAVotT!XJo~`d#!M)>T6IHq!gmi#*ql&>2x}*uC8IU zB^Hh-@pOAPIQ!VceCflF({`JnJzmFT#*;{ecM@X_MtEclMj3>aB#kzU%U#}h>n(KD zB#Ij3#)1kUMOoD~=vi~AN{*7E#iSaX#|2*{-& z?WYLk5K>nx*=D1GR05NElwuh1z70I|An#2{qAka^a?&fnR8nG6<4#HytSos~qRv?l zC-)Uo%kTPD?5EH7ioLVj^-7J)l4*$ttfAG2IW#*%Bho}V%yH`qz|R%IO{oBdrO-ah z2F$f%W+TtBlSg^t>`}6<6?`@z_X0$C4`rmujm05|k=Q&(>L{%9Sac*Y#-mh%F$%5Q zNF`}BX6SCL6JtYqJ0T4?)Tb%HWSbL77dUg;WAXvU8DcG&pPA;y!YWxWxcbt^Lv){4Vjocw8=wFVcKKdbaG^y_seYITx>z%TY zs{T!yNBdmkwlRmc`Ea}cb|;dfE1PQT47|xek1&F9CdNbFvoCG=V2&RJ6vk^VQLKxh>;0az2N&E?mY*$ zyvC%zPda8CaJeT&@5n8TIj9r*HT+I&yXAUnTAk^zEq8bikn48Z#D!)T7S?VH>|WGL z{N!^_q%!UXHp($;X&=wFTCjYX4kN1mc<&2izW~!h2X@&A>e07hHzxG-j-StXD^t*% zvqUn8Z(CzQK;|7Tl5Chh_dWX|zVr)Ur!#+u@BQfuq#NsWS`CD=m^?)YO{AhACMqP| z+##7m>%%PNF`cO?jvk*zHyf<1Y;gYNS6IAym5r4ZHdj_Lxe1XqA!)Xzh?+?eK@y~S z9x9{>hjErfCpahQ5Bi9PqBDDlnYlUUXJ%+NW46*htD7rqUEN}9bBpfQ8rJj?-Vtj_ zv)N>})eg>>mH{BafDAe;NmHS;ra9H3GaJ*KibfXU&INE!)y{dh+I|egyX(l*!i-_}9R`9R zq<|D-s)Rt{_@jUOZ}^oz|EuUokXb{XTbv8k;5IjVT)cRh)2B~{bXMn~h<{q|v3ZJj zf+%i7rg-tiS6EqH<0pUO3!FZ48d;1YtW*$awwBlhC&l7I`dza@-#A`-?MHy3)o5c}(AbgHTmUBmi^vtM zMNt5GKAZ_#q?aKaIERijskNx4WMg9$CsVX;lbMVx9S}#M5?>h4(h&~e+$a-$xL0?k zrdVEDB{LZbO}vI%y+uAeeVR{y{4qp-i&k3@;lbuaItX#+Cd_3m!kTZT#(FsS&RcA5 zZZI`9Lv9?2jKdmOtn;o|3w;6F1u^#;8>?7P1)&&9&~9{CQk=}lTn`xqmRg=>fT!7N zV67_{W)>;+@O8V(jJ{zCA;Ufw0B+g;nuRveZmZ^b83QS+i&L=U%6F#PC-2_6arf8$ z$=3*fy26$WEN+Cu*_`RAIp*hQNYeq{1tI>Rp9rBsvZzv&(Fs1!$g_--hiCZj{`z0T zSx35biOi;SItkY12%$i#pgE;9%7btLM3dxLXV8hp7>5m{Dw5_DnUDF+zwWE;ygrNEgWcOZ2{?m{sNtu@|eA!A2{pj2)Kge>3&_n$n??4iT7rxLC$Ec4d6 z8=U#lg8{2`;G_rV@IJ#@L%TDJYqVI~*rZCu9-Aij+%LOZZ7eHh=yDYH&~4sAUADv+ zGjS{?`LeYy$GW+j&ocSEu5Rvsbr@J*!#=>OT-e?4y27z1Im#!>nFeYWTg}^~b@1 z8EU;$#*`}@yz%ofwoQ34{>fdP6#LEj+)q0QU^J1ASCQMqHff|0WDbMBR9c`ur$&~kDPA!T3-2(S{m&hX$_|LM|>+$lR{(yWCsxet>@j$F1 zaGuTHAc$)wl168W<1=$iH`_Spxq0&@7tg=R;^GZfmzPMpeKM0HWGEHgY`2U12Z6+9 zL3_$LgOnQU3?7ShIep`3Hana-eU_P{vxq1mOH*!LUtn`}mCgPjqy)MgsXS3b(Q0?l zkpwC6N|Q^4j1*B*(`-vRbCT&p5mU2@c3W_0wnd|9X|z(5?j!sb0)xgP4EPL8CP{PH zXl$YM6y5#?t>h3-e&Q+Ke*Feg3fe*;QybC-H~VyCjLY(Bxw6ILTSZ))oEQwRUg`Rh zI`X04V~xFs^QXot3Gd#JkXr=>Yf+``DY@Yey?2am9U?F)+f z)`deLRAD$8ODl=Fe&qtsfA8CT_!D2C-`_$2d77ajO&rCnudieBjFTr%5GN7+UcY!2 z4wD-qoiIpqw2JUTbMDQzSzB4mQ0dupn zMdaJn7-yveIZRP1fii1z;y9w!Zj)tMU=r$tOc_>sH)(YQPI@GuoI%)9AI_1)5d$+o zh`9ELPa>2%seQG~N0&EM9t1#f2>?Z-AL1ABGSVOf#0nqGbgXjQ)%>(NNc zU@;G?is)4mvcTU(xrO(-c^qD-0{E5~=a`;qGdGo5SBq@HaFvf%mhIx)x9w9xsGpHyEm70yG(VW3J&qlY$3$MM* zD{s7vNP2wana9vNqTB1CbkIt2CREFGcybY{f(1Y&=w&5B;Uq@5}EC=5`q|v863lyI|$biOJ{QPjIwMtuFsQ(w9*R>y9APj^nbA=d_a_zMtRU zOFyd0W~o>>2l?^A@POU!qg?$;-?hHZ9!$aPAl;t~MDFK#D5V0EUj&c4)LGr_{FA;f z;G=Nel04-{gHhW6tok-H6h12*jWTzCKa-|ps)&h3(-3@k?;`Kx2=acIK?k|kZKofK z{a<$^vRnNBj=!BSydl~eUV*v*;a=C8bngeHp4PuRXj__Klr%ZjWz^912aID;DU7_>aPS_Cgu18XZV?4{sq=HSGjQE64}Op zq!FW)V31j)R6$rh)*L!=gz3XaNT%Cl*?{G%S9$&N%iOqeovp1dSvNxiT0$#MNTQ@* z9|1V94(kACkPFYm!QL#kE5Vv9)?I!J+4s%Clm^<8NdOo2wWzlg;l%zBxhsgTGDo5*#NLiXX zV!AQIRMe&=rw}G$v%i6l9X+|uwbc#&=-cmbWc~;uouz$wlc`pV-trok-aZeTeWq1} zO>>kGA&E^=L6O+IzC`xYM)9JF02{oM7L&jkdHLo1QoxzD9(o4|w zkTnKMdE-@ePRGHlgI&7(ene}l==r!gQ95O!7MTKPa;91he)#?G(V0EQ@iX^ht;I>m zjX?m-W{b_uO|D(N#?hn4kV=r}DFT6%kY;^6f_|0-O{~OmP511G@p{4b2^CD8D|84YIFA)MLTIdWRnY-_NC!`Cj`xKkpI1atjIp6=XwX^I zU_Dw!Na@L4hKLktnxmAa-JWJ`WsQYfw{TK&_RMkq{@?%Gbhp-6x^CM^Ace@wkaIo}aG4by^ zj>8_;y;t}3UfGj-jo(f_gdq5%2L%>y_dflY-#eZmRTc@b&Vmb?zaxyX9qxVm_o8C% zmwv;f*`P;WvZ|8|^LUFlW4Lkc0yjNh=aEM z(j2#KoCg4}#_vJY{=fd+UJJr|!T{PKeRI4o2cl0-L;xn=<3VVs^?pvG%$5#|A0D{< zcca(NqTvOeDBwBiKef}Vqv zhRkF^%fw3#pE}0z6Gu@Itgo$d{hb9ix3=h~Dc&pMB&OBSpgkhi$aa%dT0}z-PsPj} zPMAFuap-8o(Gv-W4n<70GZNJ&_Lc-ef+i9%4VoN2^Z>1BhIV6yrkJHEIwT??sBtyP z;|$xn%1vJ57H_b$waMud$2c@~A1}YY!W0oJZ(ru-rK{-7k*El3OsJ-X$6JSRAyyC( zz%1|Gj?~%GxA7y-&4)@4<@YaLP4q}%QDum6Alj%(L$@RMKpGN(@6ht!%z zyT#3$H`&n=qH#=NhzQsTNgMY_!fAKHT@_hz*k0_BSArJ~= zB#4v*(ctZCH+kjN*J(66c%kve6Kjc+Wi>X1_X<^LiUp=rkmA@M5<2L^#+aOLx6iG$6)s=7&dl^Q^K&zS z@s)?vbFDPiWuLBw{m0{fIT#3A>PD6Q z*!O*L8-CRRz>9-?cThj`prnTV^!@!#KRsw>)}H+pVg#VP8+Ug*ILf3HQtv~t74LlQ zmDoF@ZAXS%;s1>64r1tUK*7K&nTe&W$@p1@{zz$<-~E?k`w#aXw7uF+46AysW?c*7 zj{3WgahL?|2`NX?+U^AG*$=&MH=*_09oz9S-s}1$N?R8wtoP~QWjFUf7CS~U3Z%EL z`7-jIAC|Qp|8D%eA#JLt>RIBjzC@yj@6g?FqAT6I3AAA;hgAsCl9lc{&wlQckVKrn z{4T;-BH_`oB8eMJ%}sIm$We@eD_5`Z?LT>wrJF1CH+v`>+_gB;SOg6n!~am{L>B#q-R^N0<_GG?}9z z4k4KaT6mfW8-vrq=}ZU2(Hf?aU`&r|t5*;Xj!mE72Y>t`uYT(qTMH@u)eWXp6J>)l zD5S&-gAg(Z`MSVh2}0OVum2>A6cXvdj#IuXKxg<4RUpj*zxGo+@W2CXZmy$M%<`>U^wSjSJV-gzb}F1tYXbn_d%MImWkNd4+%eC*R;d`k9{~yK#xg zTcqm~Yek&2u~xCV+2gx^`aDU}M(ZXf4@EnS4-8=Ii%NpNs5V$8VS4bw;RqG|LJDC3 zK!OwR$*rM1+vGEIaKeyTLpzSi`x&{*X*DA(F+Y0#M{IR_blS(r ztVY-}T~go;$l`vz4D2f<3vpXtFtvPNcc3_MFheKF^1qv+5AW^~s zwIGQS0;#A{RjK;+_3zcY_rBYoc6V#7IWu$kLuSsIbFR6%z4z&!DsP;-_g-r@nIpR~ z{^LJJT5Gxa{54*={}K9+5DX zybt;L0%)b$xEFHg?ZVR*@|rZ`Q-#xe1CaY@0%ab(6E}nn5|ix%6h?9V+8(2_psFq7 z(TF7n z%Pi!48`w8it%Heqvgt~BS)fRB-OxLTOxq^)h}v3#Ta|Ze8RIC|z1F>N6~W;$b13C? z8%zM+iNxC6etg{Jyx2pZ#PK)@B-^y@A|Tr-*Jx63R~Q%rM}-W3Lc& zJgF;saMC@Jj=+ud-X@GN8R8nDo78$$ILSi8Q`kiZF_W&>Ms=T;Xp~wNTZ1{x-^xef zq|cLV^2D}FP9$t#MZGm^s-f07hc*T;fr@UF4<1{k8|7oMEz)^LIuWe9pbb+p`wTD1 z8a)J-bs1iH?tvC?X~#!*!E16ZsmMs_U6bh4SFVLz6?g9337|(&1OquHY7O4$e*X&1 zpoXly{dTWVb^_Nua~!sENF?^uj?OsHszH{CjD);|Bji)RMTJ~Eq7|qj)>UPLEf_et zyv|dU1TZANHRcv-l|^sk$q+^}wKe1YU9LWVldrw@3R0Hbymp<#okR9^_7D;7-o4Au z{7-+6m%sip$H&JQr6{yvM;9m)PcBg^f>nZdg2A9LfKWunkvNA(a7H0YGglTbaQ)Ub zZol|EBUAF~tFQ6)gZs>;rzq_i6(vPcfbpmugPT~&t2^vmy~@pZ@AKmOcX{!a;r+M9 z?3o!O{RSgFVpsOLqHb_y{0^?{-r}&l#+Y5k>`}0bVT{Lw&Q4Y1IL)umcH?>M}2$m#5i z`Rt5|Eh+XUR6Aq-^pF2dZoTg|-}|u-a`M_MlscTuS5Ef1`>^Jxe&*-VYKPs)K2T67 z5Dd--W(X42K&4awJ0b`YbRLcKf;J0SidX15IMthp761?8wCx7AVy!Q4P`&SN7N>H|9TxyIeW8!L1%0y7kq7{@{ zhkQ1oK@=qhSJjM1C0;`%6;8v!^<5s+5BSQPpU0Ug&exzk^Vy8?WWwRWUJR{h*?b-2 zNDb@btJb}Bj>$&vQa+xfNeq8$t{@%sq?C&N7_1PDj;Hggz&!FECm!86`(mE{ zDuP!_n2Jw%Y4u*vrlhXUxUy?_@#X=_)gU93DnV;ZsZgR(qVaeX1=e{4g*L4tQj5a- zFm|;GPP3|>Gu|2FBxGC!z!coQ|9~I=@gHY2Df!;t`F&i!aRp@*)%+9#I1fe{#0h0N z#yPOwF*2HWzvF6fzD@S{n}7SKcDj%R2VaD$>TL{j$tsKuJbvn$(GS^Iz-e~Yu&UP7$m4H>&N`M053%#KiS6(coh#h z@gYxSv9!@@;}0JzGTLYEEKIZ{SV;;{|NdolUoGGcIJZP*t0rm=KsDi+na9ER47&W8 z>FCq+ZwQ6Yy{Vhj=^T=8+MgIB$zz|Aw)Mf7Wem9KbUL^=x6d$3CuDchEiSSxFgdj7 zsDM+LJ7r@Zyn9bSFqmwEf`w>ddF27yUAVsAWYNd66ZEGZN^7;FZGQ6}VZ zAka9)**HEl);i|4Vze{i{qK7(Q(mz5RB`ub>S@SyGhXq(XLTcCU}vyEW$O z3wzvp*A;HwzQXO>`yA{k_D#h#skpIolUrABbA9|g2V7@|>x{XIVh>~jOh86JiD1x_ z4e;t}55`ew2TVadz?b(qRxk6?^p|;k`X%npPWb7+_XWQCYY!NmKF`%+%;9@qxLR;FBrt_=I+Q1Q*n1meKwapZgVV+_=HR z2giK=^PlI#AO0|t$%OfA7Jxo$Lnu`QyB|J$7?U^eqm0Hm8&CEOo4qJUU0ak=l;a8W zSH@{D&TT)le8TW_2)Jv%`OT)T3OJ4Z+S@&Dq#!u;WXhVTE#2dM76g|36~ z`s<(j5~pXL{k=mxV{GlI#Zwr?NR5IqE-`sK9wRFHd{i^0Old-*_PJwZ3Oo+0JqHI9 zUO#!AU;4@~u=m`UO4m$JPngf9jLH$$Za#<8j?K9kS-%5#htY<^Yd1JKn{zfhLGK>0 zv%k;V_mBAx{_DTUtru?d9Up!V$8Wqr;XISWL%#f54|wg>hfH>EV6?$m&tx>h+HhRG zDMu&_&ik-0h-q>GTG0pwtY@M$x-f`!U_7G&{GQ=dpYY3H`1`oMDukS!K4dmKL0rwX zo7Y3aeQUR7PMkMoMSg#9;(4G5EG*ydcOn0n!c)*c2uO5D`mc>fYY)z~y0s=eD^HH0 zLVhrL7WWBBtV$nFD+R_Q!(11AR zvnk3LOflj!pZo+z4}XELedUXM_7fk*RSJ*6x)5USr)TVr3P8}_v0D~Ae{DCiuWGz^ zu?@jN??P{5Ymd>I@o0pc)hIE53z|h#Xqyls)$k^fEgT}-+CST*9!WZP>f6NdO!|^< zY_ygHuxXuh_DcG!ndfe`)B7^C$5lC=H(6&MH^b;60NbOid)rTows1GxyL7Q7EMDUtvv^T7WIMNhuO(4i>TtF zpobcAF7Qj`45^-A8hXBBxJpCs5>Rd>d>!hp7e3p#${F~Pj>rw6jIIG}`U9gM-k6wI z{V=y~elopOFnA^a0Du5VL_t)xeyjSRL-iYAzc$;5R*n39+-aS6jX~271I*h$o6QD4 zZ|l;no7QQZxvi-2LoBZRqopjp4f}K1yP~tX{iE}13qD>HTWr`osZP*%m9>hXs0f8F zn7ayU;pEPJPR`Ew%1dA4&Kq}dRWLqx#uIk;b{jU3b3QQeRI`r6e_MZ`R955#pgLbI_BlydWqTe3{B1cWQ0`(RtvVY9NgOH#(Q>n?mZ)}zMwd` zGUeLA8(b^j;)`<5wZ$px4>##y#H;7_98uP2a`m229D_`RG{>YE; zZJ+%N&)m>gK4l~C0cDcTpS093ujEdrCY(Hc0X~M<#=n>5yCZ~HC8nWL?Q^>Z$AC`rSc@>79dmfF!|Y^_ z2ggU@P>;zjdpDow<@*o#cmC90;NkE8L4M@hKgOO{{PHhm{zRRA_PGAsbG-SDZ!nv~WK=M^ew(kKoba#z$Nw?^#=rQ_@ehCE z-QE0t`wKd*Zib5~wOJScbP{yO2WuydCI4A^j zZHI5n-r}cz_3!b}oS}D1PR<^1diDTs=UlyhmHoZFj?R{cYFu~(Y&CfU^Eeso;r`@= z@r{m?JnO-N$ZU@EKBXe#zYQsT65&FRq$JzQdnS{8cK7!J=r0~?Ypxs~@{7Ot%V@3n z)W<(XQ54j^ZcP@L%kxR?wpI>D?t@HBxh_GJ@(6w?8MqUDV`V%GP6w194tJ={;Y|{xj(6GoPM$XbISHmLB z0onxM_TR6E@5^Gt8gERZL|;;ASUtU(D4!OCz~CF166V+4sT=rkfIDg42dn6za^*-z6oClq<60sni1YPre|l|yzw0Ge)qe$d+#2vf8#Y~ z(=*0piO~gCYUa!sAC7qUhhOC8drPj~t~ofIGZ{^}u`}k~yYJ)^@A?QI-2Yat@FE4z z0ax+tgN(tKfp)JVn@6?s6=lNdPFe`O2ajcnmlK@7Po>`C$i2+H`ODm`zrhz?{tCbH z8(-nhD>ZU7VaFXHQ-!Mqyu#LJIHxGe5m&G8QRp#859Ual zs&ecfUS)QA%%A(QALC13{1QL-gFnEH8#j30``^dm)hoPt=MG0l$DEv;hVXu6u-3)H zA~m+Q*s8`l%RArs4wOM;um|Nk-l`G6##lQI{{|ul1`Va8W{$u|HzjbuN z)ptBc=?|!nEWVyGuWQ07qXs7`h7rcOZbFVGaXy4s$Gx)Dieg`*5hnXP99L6*>T^HC z=ic}V<&7OGQ*nBF%-QsWc{SzM&D-oB>@lzA6e{OmZN8?Ya0F3;*qVcb1L~VMxPSME z`$xy@AHeRFeO^Dk%m3lu{}1_>fAmNBCw})2@#br>(nK zo!uR-T{&#n??d@SFH$z~{0G4iau+5*H*I*Sfpq6dJKmxmo0v7tb=et&X}@>|@vA}I zIYtY3FYN5>u)Dj%d^SbA199BDbBB+9_`}TS6|T0_b%ibhsH=R)2Nxvc(Fk>2g?w+K zsHUe#almhV^(B7pS3b`_`v?Ci-ucdVarcd{BerhC+Xx2;s1!CF*JGUmc_l^%D2HNL`&Mp8~ z!|PK&^r~-Fge0##rP-$aOyNJX(woRUR`_Yta!t2;kDx(Q!MqR33i;A}SuKIDQ z>tuS~vJIk}*Vnm}K-DzhQGDXk@V=D)%X=bF)g4c5yO$%)?K5>_G>hbupgra}74KVM zt(r_L*0TZfyDDyu5@(sM{%7lLOUXL;_V?8DFW#b> z7^E;wo1GPGU5Buw+BWM*I`u(BLJo$gy?T|vqWHcTMkfgSR}OjcUGJp!j9=Ixix z*r~2_FjFY2n9Zk=fg)fvS}Uw_RP_v3Tgsgst`%cWk7k^m%n@(f{6C^X{*&0}%Yq;J zbLVL)&4wz;mndz}YX(Nsgvd%WN5h8Kw%V(}lgruEzIop2z2(_23SwC0WU|A(`$v58 zGoR(dAO0|3f8|v^_OXxh>%aMVl!Ux6`v-d*9Ub$PuY8qvzVjVixpIZ7szS(Z#^5#D zP}LPDwq|xZr5ueIk5fJ?ur@GtQ`nza$7p9lF)sP#U;i~e|M}1HsZW23&wSfwxpnI~ zKJeama(Z&g{Rj8C|L`HlN2f?#BNFm>R8_@fXP*~u-RA1S0rh+a;<$U~ZARrNgcQX+ z+~B>zUyNj6a@r3>HE+Pu&pqg2#xx+fa zT8FrBlA54E8%$B6wLx@XT$tT4^-jUhfAMqt?B{=jc`0ynms2;Rnx1lUdW5xeu3fvv z;gthyH4XWAd_P;@rV4%tSJEPQU4t`Rf9?e;YdF6Dkkg0v(R*X|uO2c-@qha-{~AB> z8?W%4?|L8ad(U+ai(QNuoE>2+$Goz*TH%~$J_{%HCMV^n9EU?cyr2v4#!?%h(luXs z>rH|-pW0R2f9q||=2N`&Om-$bck_BoR^Qz_l9P1E%f4yZW(>Bf zT{>MdMLcBTCaS9pwfR%KEGtPlPEvnhNX(#TdQXs`>xcAYp&h%sJ50tU^V2g@!ei@d zlroIRC3QW+=nzgCGJpvVA9Bb=Hj+_L+Zx2+ouwQ(e*Zu5L+HZrJ>ULG?%#bI?;XV` zgqZ8-R0s}7iR(@fZOYpx5qNPbgmg~Kh^m@{@#vz!YK4l7evjotAN>~o)=&La{_DT> z6a4f46_|N}-j?eCelQzACu=A)3`WSAS659J5;8Mc=-WVkoB%DF2Q|O0M*@Bvp z7{r7_F=D8-m+is|FT0Z^)ir&R+0Qy-D6cB3m@WP5(%ebFLROO|M^JM;o4tRdmM)o! zDK}9=l3Q`_runWOby>wGTUEEAu)U$rbNzYMbCpu4bl`^wPRYi;zKg8&o%au<#z~G5 z2;uPCs^?9r#4PG!69${e^4B!aNs07;BKNBIpWU3MGZ2b(k+L=fqyr|98KuS`iutU@)**+RHf2n>>X(4=n|UvuiXtrwFZY>;x-VX? z5-{IfqwrDjQj+ET8DL3%^A(8(Y%n^fvnjvx`@WB}>5PX*M||cppP{mj-~7$bQxpZW z`JCO|9j46q`q#hC_3PJp;e{92-{0r#>@4QH3NkIr5p`{u*N&>5QdKo&IS!$HO2;VL zU@%xuBk-NQJ?z}^lYj5$`K4d|4LpVC*=JfcC)3YgF zpeV=e?H{tgJHgu-yOSOM#$Wq8oE#r>`?>4bdKTDW-9<1T6;HY&F5HjJZ@J@!7oAeZY(%k=;i4qE}z z0W7$ho7b;lyyC&bJJ=)3srT$&IpEfN-^0&+`AhurFa0X-xp9*ZyznkQ@ZR@u{qQRL z`+Mw84v@-HpqZd?1mLc=jyZ~xvor2Lc*yK@#vAwFrl4|l0R5FmA=tVKIc_nystO0g z5E;vfFBNrQt#r_(T+OH$as2Qt-u1#E|LVW|hp@Kd?BqVXJ7oZ%;+pS#jaKml?X><# z%*mI@*L#n~P}dbjq3}va##e#Z=%BE3>eGk(&<}hMwwm#~zUQ;tzxNgiG67LKT6jVC@CQB1)lgL64L)51v$hwA`B=ow;c* z2n+p2^J)k{>7;V$F?G^sgE^G4{(QV(k*owC7OvhBc*zfpcpA3KK8CTnpNB~`spuR; z+JwWFv5k`)H*tbD<0-yVYu!XqIp>;uiVOEVE`h6T-Y-G%a$H-0!b&)%xDDqBPZ~-- zw%C$IVfW)H^H|NH6W3agX7j%+3`h59$IhP5g;MrZv6}0BYqu-Wj}2 zV7WqEK|K#SwUj8FvmsxQH)x|7O@hZ!E5Uk?rv@pSwd^9}@JW`|*bV?Mk|1)uoTCzwuWeEG{?rZnNW z^m1HsP#EstyU+3QF}H8u=J4>4(P%_fRZOQ-&>ne$N* zZUi zLEf6r`P#`bzxegfW4~B&uzSExIbmFs98C5Zl_jMqKmw>++nVWY#>w=Avue(1HN%Wc zR8esF!WE8=kC>i3pq@>c&!>!xV(-cUhgYv4XuMYxYJ#^GjO(6Y-IwYTJtvvV5^QIT z_K2M_DfhT}?K)*K;_id{Opi~gsu}a6hupk=i>oiZlUMGY@%b-(1-|%YuI%n|b#Irw z(TJVWus0buEEg*^y4}}1BoxWXL)j3Z} zRGyP=mbPi&Ia-Cex=C&|MHgd8dA0j>ZgO=iI+(^*vdx%Sc^!tI6Ze7C2eL@;Nc9mF zY>`5|kGY3h#i?7@yc4?}+Qo5(fvHXNUiDd5iWa=e8@M!YO6r^?W3H_aO(doiad)K>|))mKRCjsrpLOJKvm)_v_efHyg*9UF` zcbLhPiaC|koL0w_^;a2}2aJn-N=88e!vPMj9L`t-i=)C}nbuQ|j~;M*e22I1zscSE zZ*l*@10Fm$=H#SeURjVGl;3CU$M|VUHT9?pqz)iJWgXUOoCPOPS03v?)dk`W-h~AB z-nISUy^AargHi=l4r`^MdNaQ21=h zX49ew$-B$Ka5k&>_{TrS``-6{UVZ%yMxzm@XB8t;@aa!~is|%>S6_QIDuID9ymzq6 zd{*)5tFLnJ-aT&KyvgC=A!SiAt84(JOz@<68%{hEk&po1m{0*tNc4_rDC=HeT2Ikv zM!Wl*&gXpY%P;YjufN9gzxfqz-MYbxFTBX!?hb`gm_jodk9ctZ9zXq4Kf%xb^iOhp z{E+?KU0gM%FhTKXPt2m@MSBKq(6;Un!(Kx1;(N+l+vcQz2}w~5-|3-{f-FR;`B=B} zY6)8 z;n}n4DdH_6j>42299-dGe~;ZsNZ4N29%YL5P>o(&&SR)<4_?K{fy@owZs;&!o+}i_Xp9aHFdZA78DU_y~9|ye>#b`VZ8M>4TryRPFlT<4mc6lG+xn!g(jjiV(o}1UM zQJtOOtq!MHJCD{OPn=g8ZA!*FyJ*6kcX2$KnA2z^$w`jcjUL= zTML-9*LUla10d~vEulcSb4?cOvCN^r&05jeSVJ~W|C$J9r)Zq#V@yvx0$e9&zj`Xe;2i?JfXa;9!A$)zAboFpPE($}T8m@b8>Wlm^ z!J$O+VTkl8ys9%9)}0}n9&G_MVl$roj3Wpzx69T`1*)re-6ZI#0ugx&iW8e=p`6;&f_Ef%y|$A z9!OJwcfnKo?3T1Q$bx#1!iIAOWx6M^Fj0Z2m&Tq}8gyjRg#nt4x?1(;2$ZeDYJDptcpSzwrj;sGyoxh&al!pfClq*^HN7 zdWox7uX64BHFkCn(6*vLQB@V*C`!UfQ=$wudG|!)tTgL)Fb;^Ga*YKhdk5?tUdKAe zH{Q6`W%jDQnI-l$MasC{r+2 z=rPY+N#nl>3yy1KIyxsA=;0!1*3wqLN(`#e~VN;rZbFnvcm6Aljd+1cAciQuAvAF?kfD8S;=X+)c~v7_F`XY%xhn3@Vc!^!M(pkFv%9m$XfzIBh7UkpIIU6P zV>0i&Ep2-q{TeC~Qbb6WhYJaZ6QwYi=Fl1yQEe5z_Yu7cCr)Kflz;_n}bS zXQ70xGvty}I;;n#&;fk%!4XyzLHA7w`9*^zz)g0^}GAc{FfcHXe9SGUB;7Litz+d0bI+BtaJC##pcTJy}3Tq{J-O=?m9oZte?g9$ddc=2pu0|O!NFe zLw5K!0QZoMxhz+gQYwbT#&^b0)%2WBR8p@V>vDcA$VRnt6%eRzq$vg~e#Hpg+X^UuFj?I@mA{e_=e3i(vq~JS;Xu+w#6!RiSJS%aQSgZ`Oc*DZFLqyZ-(Ce%Pd}x~}W7h;WQbN~K*PN!!a9v*Ob?Fxt2u2GtU0Ae{uRaH12!UwGl zIYe~KRTG#*>6FMeQ71}clxAcMhqrFAGZ~?zVp1qx{l-hY{PnN$#v8BU?VSCc5Vj>Q zB&kj(=V-NtTlRD@12joggwNE^~=XS zOO+^bA#aVEGb%L2)qUj3F23^kX^pKcC-WK3J8Em0ot@y1k08N9nNtf1eA-MvC?$+1 z;{e>4g2~Q=@y-OTbdxhQd8SRwS0&-zX^_2d)Fu;GCM`kFB<3G--eL;HmHl1z zcXp{O$Ly@)Y(8UJ&#|uN!GjrDAbnulq&%RWhBbmpd842#4f{I>ltoEV7L?-=<55Xr z%9uMzz`KxOUd5v{(%?HcQ@r2)?MjRWZ|_y@x=suo3cElPoEh(FM|Vq#2xf_Hbd20U zOZ&vIqxRWZLjr)AqC>PYF_lPUU=dD<4hL>{lqk-oQ;N}ucf8|Wy#3m1eEp3#n4X<7 z+1=#>-|}I8*FXA$oXskxXD0y+_6{wILI*v)^0s+*V+l6$ZrO2x$)_3sOGU$6NeE?B z+9O&aG6ET4l%v#f|A;3PDikF;hO1jd`*tn!F6fm4iEt3W79aKDg0d*M^*+u2;}f4m zw8q&A6WP4Bu9?m(50B^k?9cuZ|IJ_j@0rezD9c?O3Z)Fbk8k9Xzl1r`FI`(nlMdr( zfE%TvEv@K=Gc|B69i;X=BPt^VEJnX3Q8+n%zcImdb7^R3VmY8 zvidFqfX*wkO+MQ~5`!|?^xe7qa{IOq_67N7VCa&Fqro>M$H1m7>R?XG9V6@h?z7Jt zux*p7q$NMu1PyUz7?3PdsF;qYsHHEpBF3wG&NJDDfQ+@Vu4zGCDfICDGFscZnzn4A zbXs1koF)k_-y7G)zd8AUA2xvV~`P-0P-Z z#B5}*koyoyybUNd)Q)x6w{nc<@g&qdB1NrH(k?>h)xQ?x*e4baNR0uh$(p~Ld5~cZ zzJt2=K(Op35)<+lT2m+;^1eNsBQnKm#awF4j%DZCi;PDHcs0V_uefvnjQg*jqQ}R` zNFfCjMln)`kySBir;ed$!6-6%AETq!JdRgy4bB?8bJV^LMh*nFmD5NS09jmoz ztkpONIFGjiwL)CX+u%Lk1|!5pFS2(!TeGAE-k)x{TtK3T1bNJ6Q*J(YllQ#u-Q2r(ze5u*3d5)< zd2sZQgM$M;{NWGdYR}tu@8V+EmDYl_mZB&ajYc?MGo77s@{Rj^C&*PvV~M2MNW5(`u=^;I1d`fZljoW71_sK29@uI z4JMF|tJfi0kx?F$qml|Q8e>X|$(XVzakXd1T%nc#`ipZo>u|Qli;E{!24F0lKn7Ze z0}e2nVmw0Y0;4oqX$q|oF~P7;Ot$D43BZU)OLC}~b4f1`;@1&mP~t-fy-x4+_}a!g zmrQmhOm=qJ_a5s#)>~Xv<6Vt7+b~o@o}&=PuS|h9MGVOgiP(!mQ4|F_MH}c4TIIbU zt|lBv6@effb1L~wq8J`+=`f-s=sAJ8mrj>UA!>%CtG5h;R*wh=Dy~64h)9-lL4>8?U{F zFGhUdkNh5dRY#^<@S@pz#;7cDO5@4*)*grw|MV#`Ad%#VK!f;(nH5g=D^YHYp$1hW z+Tc_XAZ{-hFEO!t9f4mJ!7CaoR7JK(o-#!#n9ioCm`oZZB%9X?l*c>4y1<10u1|l2 zZ~x>+_<#PfKf!Bv?s0JSCY1|}U%%tF#K~=yt_}96oM0Du#=7T;LWQ0*1s`&^qUFMjayw@w3i4eQAe`(wzR?F1#u$;vHP;YR;{8$ukP?XyfK%@ zfC3!j!Vj1Rs(9GjeW}PDxj>NOKVv!tYu){I!{6RF8Pwv z|C?LbI|07Q_>n;+0%^gjyxZae+{~51jWTiPjp;> zUARLRikr8tGuBX5bIeH4lC|0r*S^i+j+q?i?jwOco&kr zhkZpgz+FNU+Xel}8XV^@U4^V2N8vl?Lvq!?SkUov-yIiTqLG^N4#r?pm5s5?`}DZW zWq9|N$u1di#%N|oGd}U@&v51N8ZUk0^;S6(kI@S2Ys%6vo6gzW+2MoV@&Wj=@a9`@ zgYp!m!PYIK)ELA5{)E|7`0`6%=hd%&l`H#u?CtMyczDQ_D_7au-{)ZeD!aS;>=tD> z8J6g}Q5I`!tgWeMbB@l=IC}7a6SS`R`*HvU7Y39|OQ4AH=MTwx9p3V5^$3Dofe(pE< z!skCvX(WWtdds*RGpikc`@j9`?2LE$_V4?Cj*cJTl%*UQ#5iWPL;Dhy1=Tho%o!g; zL2Kiss1^bc1>iEKsP=QTR8%qaQu5*0Lp(0U@NY#M^O3}kBSJk*Zi z3Ecl|h_Mtt&*hZB!p#7kRiAAWW;iS}wi=`7LV=e|=C$`{KUeYymEwY$H$dZo?jJ|p zyr)wsX_Ex!*9=xPo%p%fHU+_}oN{0Q>4duGavc^#8?&L8Wf`DhU$ehI;&6Y=bb1V9 zL75P8XPu^ykUz^9gEE5B7LQN?#%M&l2AmQ?SZne;d1vrm$3-^M@+DE_4b}#uLEGTn z6BNz~&P5?wLLw_0@>4h~5yT=U#}1)`39JeX7a#u)r|PtRx9%G9Y$)1Wlr%OHi-)wi z`9_h6+GU)tjqfB%XV|!TGEI4ON>0jCVV&iBzxR8os+#F^N?}TB7xRUPFdmP>vF9RK zTQeSyc>nv~k3#eEtKR^nC<=qMY3=aLPNrNr*yr|*8$7swhiZDt+0k9@ztx@?Iv(#~ zijtxzF{W&2{UQ$QDr{ZF#JM))HSu9?8Dl84W@mpArbOjpdjk70!x8gSw3H6!QymXL(Wao#8b<({i1WasLyj&IBPk@=4tuMQ08Pp+5*c5V zc;|xsAKM+AGXM|Mz+Shd#*ujT@ZJ&X~_@M1#>mH_`n)EhCVoFz8?nrW5}n zGgo|ov2+}d2#5>JD>1lqG9dxD)(X6K0ccPKN`>+2#bvCcNEq%yZSos)Ir8ZM1+3h7b8{L9&u+vJpPqc-ARB8j&v zos*i?!Y@!X$mkMbo~yZQV?I|mZ1KCUw{q1z>pQzq2UgTuFP#+Imp;;-We3YY&N6R41$mnxgTN80!0`)_V%)#4kasCh(uSOPMPF%-(+ zjf-BpK&QWQeF9V{8<-``Le>nlMinK#4*5~UI4~xn`xd1F14^gU>{J1ga=OJ_UWBlX z-~~?!E*DBc(Jm(=%yNSSn zG(sAhx6evx7z3%XF=PyMXmg^>$9@kC5>GnvJZ(skOBXhyMu};ancP|Rq(P0#R(M20 zX#UyR8PDIo%@6+IKf>8;M&&G{a*V4gyz>-AfvV~tW*wL&wXH$HyWaIq%JG<&UwMVu zvLwu**{M|?cKIW#ttn=FJj=WEEbR9f~z)3nYQ&MoXgZ{tu1 zVJ3*DZDkM)wJKu&dz5k^r;$-86GPu^NU*MS2;oDb*WH0pA^E$~syXV~O8~q2Q(K`? zlEFpd0?4Do35cY0>YjYHX>zSJdrHjLb>5QpN~=uMZi#@qk1VM|Pf$wZe2up?q8&zS zl%&)fE*|m|LAj`gX;E!hIV~ull){M(VSz%(d4+D)#dK1(N3gMuTIs-iRw`q6Z`iMw z$~wt@SE_R&cVg}4xK)G;~f~df9M->5-MS-8AuqYX$F?cl# z%t05zfx}#P4G`>=3K2e$m85lGDr%*1vl>@dP!3LWBk-F{vyZqDTUGK%me$l4gKUy3Uk4{B0e>~@N$GdRPmu}*4;mp zBy|Yk+b(a>a^PxNc-gx8Zc^Ik(3!lO4$5V0wpDEw2}eTr8Bcu>Y8jAK6eVRDD8BP~ z6~lqr=MCkX8me!E1cqsmi|uHjtZ2Qs;2G8$uT;ilYC%%C zI0-A!cRL26Y`0COoD?}fCK;uq=k*LO$tb`&?-K7Zrij3iYrrP!DojzZyEn#+G}GA` zqw$#ftU?z;p$)YHKd-|W)1XWUEfMYFSaxVJ0q+-yjL~T12*Dum!FUco!j)#yCK6o7n3LJ}@4i_@5o`TXd z%5%dcdEdn6`IKT%clPL^GCr5nrW1rx;=Wg=m-FrCa zscemCg)*9IK1W0tjYiC-bHsa$Hdw+KymIA`ckxbMfBg+kPEI1L%V4y@S-5g|l{d7( zxr#!0z)?0JZwpuxK}Ka18WTWV2%hoCJVC1v4lKclP=HY(MA*5yVWc3sJ?fdK!Ap2a z{kF6Xs@=CG01Vy>5>LzPgVw3!jI0Kn2ro)eJ>lE9M+Dr&UkW?-yd{OX>1gOCnv6$- z=xC(Hc^t!`lo(7CKBO8JjgM=fBqmkbOEX7QQe;b0;Kbs@H;_~a%TpN95eAQIZzi55 zpW8X6vyzs@EGn?h+8&p9NP))sn1_pCc#8@SjL`nTcr+^PTOutFH#sXaY&Wz+026b@ z1VP1pMB@|arLvVVG8S~qB^Cg}CpJXlq}P$5qXHvNWz2ZhI_^}59j6*S!%IBqBn~mH z$#j=z6!Gq~G_+fyj-m*s#n#qiin0-;wH7CeLL02Dc=+G}ld|C9ojaVKO*uY3;r8v@ zeEWBN4`2H7=Xv9eHyMozUVi!OeE7rPikA_yxuYmXpadx_qFm5_67x#w2x7GzIJng5 zI97ce-^#|NEri44K}m&DHZrn2UO9vsEd^dhX0Eh3hQmpQf)$U6x}|l&5ov9WDHL`( z4SS(8xS0M#0mWp3^)+rbMTzI=-W^`JeU0l^b~%3QEVksy3=?R%Me zM;{~zx|i?hY@0kIm1TSQCN`VaA*sJ}j@rizY)Xajzj%`BGB71iRkN<1LnMxi$n?DS zebRlq5pI`@6_X*Y+5=|JMr%!E_K7q?rri#pnd~rwDwJo4HY-& zwoyGJL;02-z6NL*?ASi#TlB#Fi_EJbA#IX4tSi*~-HV7~{`=Z?EU8yx#Ks&{KAo!A z^VSXxihes*fq@I*c%2+1Z4%)juqhvW40uRIf|n?bhmedi&;=Ks)wwcohMNq1dP%iVH2P3cdVlJ?p z38tD&S|sI=^EmH8o3ME&fFvrxfw>S3#S50c5~~Kjmx1e5Z2!>MY8^(4^vz_5DQTIE z$%7XnMifrssZ%X+zSqL$fuxG2t!ahF+_%Pmm5<#3JCSHrA+z-*8u6)xc4P!^T z>%-$@V66tEwrTk^<|vE_35vbv>}<-1Kk^Zrc#e)wA~QwB&{P+4{{XeOc;)eU zoU?Hqv3Or|u(ylcy2+d3IXOAON{zx`%osfyGn(uo)}dl3tB&4Z=POL1V}7cTZ>I$r zv-K|iPeDAwUN_Ur!{Wf2LT8Dgxz7p>%;)fq9b{4k3AU zC}~wpA>H8=FA4m&+)0uSevq)%Af^ea2m*3z70SyjZGSjSJjg!m5h3$cI;AX(8P|dg zP4aYU90ZMylK~ovaQKDF0KLZB60foGBoNh@+Rf_IVH^dH!*<|(P4TMLMdIWWy3FRzy+o&g+8n&P?0Ke?!6N}=i)(q~ z^lIWmgG?0xwsE9Hyy;{`i4HJ{b3=#I^rF6s(ufn3mjzJreC@eN-o5iWS3E`w);g4$ zpj1JrN1RP7j%PKylRcaZ^(wTYuBvcgO{u8sDM}l>){J&0oSd9+c6P>#?|26kr-!!&eWLQ)ok7#~fJ}2U7 z!!V7Xg`8OE2wGt>`z`?8N(F{`?Xw697simCJ5Ua>8cT`u0jx#^0C`?}+-%0)xa8s8 zJAC{DALMsz1zLr1t#v#B_!3a(A$CsV zfDadB*Yzh*_ZjK_zO6K9>*_SqJBC*SNjF(ejp6OL-s%90%k0noJAz)Hky2Q#6D}i0 z_#}rlK%!5&-w>X#gd)_DtLlE2J{z)oJGSzQHZ2Rm8zS^9)UH?O1=;0Y*9*H%DWryE zn~QOX2r@+e8S0zPy-DAc;E*!3d#q|U+IAC7`;AdLazTAc;-11kAVx5^4HOSjWDVGE zGw4@-crb2(*qy`JSPouwpScOGav69v#9~?%LbMH9yB?5RfA0_&x9;7}xkffQD73xI zhs$g>!#RhwwxK2_Bd|Mx3C1(aQT$ZW{pq^Ip3+)(K&wsUwvLY4sSA;6EhS3%=!Mb2 zNa@9(s04QC)j1w6qpaI6&qF1KY;A9Dk!Evlj$@7sxpfXxXm)l+C>4^XCr`bP384e) zBPRSNxWv6`k^SWT4jyjbygC6V0Z^g|y+|wxiEOOU&J+$?#_=4LaPNbS=D@jVpi4Mm zFeKXzA&N*mAu5Kn1o^e?&jk`T;;1q{vx%#H%cK$=b={1P$FdDoH{XT%8$pc*0PI~J zj@No(t-Pq>t5rALx)_b9=5r9w-tL6L6kNM{jiaMuX0th>4QHoQv@v)e4+uynwmBd3 zvV~L35`%GG&nODTt=rGBv$Mn9dv~zjQ;TO(jG652a{ta9CMKN<6Z*yI66Y!)jNRac zAj?}dERU5+<1@^PIByB-jBnQlr2xI4FfQEJlVeIC&}IW7&X0VpcrxH#I}iGEaRVb> ztqE$A^|@I(kL%vNU`#9kLl@DJx;phhWpbzBW^#E{Q{-uxdrF6OU1LD$kjE>wyIaSu zVbeIK#WlK5ARVTV+~xUEpDEBA5Npx8KtyA00Ea5;d2l>s|N0@$*Ep-g34?JDnAa7f zoe9&EDdSPeyn<0#;A+d+=?RBd4#TLk4lkZ+KE)Wtbovl4F*MP8jMmf{2+)hXY5S3w zp9nztCSi8do6_1dxnHi)k5%fI07^+b9Xi?g4P&+M^v=Fh@!cT-gpGPx1blQn&9t@{ z?>T<(kQd)^gM=9}`u(tButlkS(rye;U_HmxN zp2wkl9;dKRfL}veqGhA^#zNnI&j2iGzHAKr%(`wH(*9gmy=8R7j!#3CLg#G1ltedgxPJ@g-prJ9-y{;ouuR@qhM3i*{Bz2PrZd;c1TX%toi>=ZveLSa`M(UX7 z{2T3~WT`BB?q;FDvHhx9FglaBEXE=4kBeb^5{?B=$L=aEF>)q)pjGNy=onPGyH=?< zCcGaydOPH(60aK2L=q;Sz$+Kn5Ft4`EkG5~esKwyO9|VPQj)U-yPIz7!Fg7JtF~Er zC`YCa(_7s-a_^3#uA!b1-8V#&o&x$ubFCl)A)>3JP6tb~fkgjpz8l zw|tn>>5K;tA2QiHK$#M2J+`W`)*@EWqA{W|C=5CMrhepZ*>jc}gHI|2l_4G;I-`iUND=|+R^(Hq~gV7kAw_>6J z8rY>uWx6_T+JR)GHae{exlM-9&XK7iBKu`EkQqcWUOQib!_Rphp(5L;A8Af39j0kCwt zQnXN6#%>B3ejAov0+W)`D=Ey`t9Tk=q^$Y~O!){>#b*W}E8Le@R3gN1kHrly{0@(M zLwX#gm3)waCxqBY3>)Y;JV-2(1TYWm8zkd>GsFE^F*ffhopopX5Sajs@O8e^l;gnq^W)=Ms18xP63v*tE0 z5^}%fpnfxjx|+~8Be?Rk&ErR9EIw`JX_9-c6F|$zX_-15UwdoVlMZKj*7{0?@KR5h z^Cmbtj!$QN=_{}BJHGAXO!js-y?>9L(M~uW7UALX3D<7i;OO2x@Q&S`2@fAUWdGoR zgS|c8e*10eI_R=jcCX>8n%V4(+1VN5JZi3Sgxrdq+Vn$rq}A;^{gjfD`B0yvt12?E zAT~+$`!k(lFA1Dd_>8TcVfYQ}D=_wA&MR>!nW3ZI=|$tjL?@(2k2EL8#~hs;b22>* zwoNa*_;Ita()yoLB{U*V4!TT!%fOo=VEfy~mohZfk`WWJuqj=2ca zcAIDBpKtQ~t@1u?K>kV3T&&x$ZoN%OV9-$1*jCTEAY*X#vp2X%BqnihjEdM0k!Y#( zwv`^eZ~gOEfkx|S>8&BON$A`5gE)B-h9^<~ohw-=YWaQ3>NgYze38(|Wpc@r=j8*f z`~Km6ZSU*9BTGh`zd!qXsCSbx;w2oto)-1Rd}GTdL)-+VMLH6gk`_)czH?H7_c8Y0 zH`z(Dwr+304Poc)c+O5(i%g%+jgf)XhE-jxJno!9>78wxB|CvA^X5rp{oxStlw}!D z-Ek-tLid!CkRvEjgiX%kiwl^7l#544CAaZvNjo+}PLz23d&a_&1U@C!gOBXN#MUAL zTRu~my>txA?larGA2it|o;AgK)6$)b5@juM47Yr&M!lt7uU(MMfM~=^lgN2pdE7wl z`HDxx;T7m&#I%|-oz-YlFm=Mcqf^E^`@Hgv*Lm%=H}T4#i-LI_JkGY7;jF_uM`bPZ z%2L;L{AZhW(K%0DS6J_`m1kZ}5vhY>FQ9$R+*-VyQ7DDc1-6y>*Q; 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

qHPVWR)mld=^&xi zZ8JzytkooGM%?N$KevSTpouY>t(~6B@+f1B&R9NhfGpGKEW=8d@v+@mL`0G_)x@oB zdOO<$X;d3StJPtC;SdJsrwK+Fq;i1UAkDCepcT*4i4}Bo9?V;7?E@PNqBtTm8m%?+ z^9u;mbqq`aX__L1Bv#4?u?{hF3NBe%yRbtT57Oh`U0J~^D$BisX8nzE`o(@ zFd&Kqnb9BxhgXl1B`Hak(QdUEBneVVRu3Nb!*8%&QSHpPkwTCpjse~4_faZl`M^<^ zb$3T&B}A`KMM`Jd zNsUFdyBu9wB}r3cB#>6nPZI`dhEy>^NPm6A+`=-8%gc^2m1Rywiy+N3bMwnsu>e}Q zwISym=<9(!Z48~oB_h*8TZ47!YOq4!qR$D0bV;{j;m*HITTm_9%Lfp`XGyit8}#Y6 zVp5&b>9mniE6;2ztU_m+cBf0+X``fa>vE8g*o?)M1FXg>Uw=vJ=HBAc3LqE^`m{Rp z9GRQL_!xy+dv6daTpU0xNixIgfg?zzFxGhBs!)<$7z0u0BJc2|X`T-vuVJA(gwLyF zg+iArzK8wQUP4uXUOPCe5Pj?t8e6DMKWUzI|_Dhi0$tG%T^vSk7+B zC>*1%OOMp^DX*R)ovn~QmSTv_Bb<5M_~ZrF+F`R~$fA%pPe2mOCZYUW%18FD=jDhx zmnXV0Rq(2)UkH}@U`hcL*4^^_xYt;7&2F9J`?dVDXzyfq#3DmS?ejhC5A}FkqXd{L zz+YB={8F^!6)Un1&Q_QG+VtF-kVvVVuw`A)a;-CGl}{6ta`8cgP+mB7g1(TJRHr@@ zp0hF=qw^q$gT%YIDHm%+Dkm5j>n#4lMh?i*7L#QdG?BLm8sn_u#z+P`DN4Hdds?SN zLL!a944l7%0fbZx5(659h>+4f!=x##NMN*PU@h86l<}5wB`~;*6%rh9VGXJkG0+)A zEntDAl|!`SdA8Pf=(LtGsRQ%`f;1V>YCEP}l4d@wv$OhH3|4D5=0c$℞q{+;*(i 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 ~L0HX}AQ#%0EW@T5eJMEb@Okbe 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 Vy;2o~7%Tt)002ovPDHLkV1j3=oB{v< literal 0 HcmV?d00001 diff --git a/configs/bank-landing/privacy.html b/configs/bank-landing/privacy.html new file mode 100644 index 0000000..a7a3ae5 --- /dev/null +++ b/configs/bank-landing/privacy.html @@ -0,0 +1,137 @@ + + + + + + Privacy · GOA Bank · Swiss FADP + + + +

+

Privacy notice · GOA Exploration Bank

+

+ This notice describes personal data processing for the self-hosted bank at + bank.hacktivism.ch under the Swiss Federal Act on Data Protection + (FADP / revDSG, in force since 1 Sep 2023). It is an + explorational service, not a licensed Swiss bank. +

+ +

1. Controller

+

Operators of the hacktivism.ch GNU Taler stack (GOA exploration deployment). + Contact via the operational channels published for this host. No separate DPO + is appointed for this experimental service.

+ +

2. Categories of data retained

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DataExamplesTypical retention
Account identifiersUsername, account serial, registration timestampFor account lifetime + up to 12 months after deletion or archive wipe
Authentication secretsPassword hashes / tokens (not plaintext passwords)While account exists; tokens until expiry or revoke
Ledger / transactionsCredits, debits, amounts (GOA), subjects, counterparty account names, timestampsOperational retention for the service lifetime; may be wiped on stack reset
Balances & limitsAccount balance, debt limit, conversion flagsWhile account exists
Withdrawal operationsWithdrawal IDs, amounts, status (pending/selected/confirmed), reserve pub when knownUntil completed/aborted + short operational logs (days–weeks)
Technical logsHTTP access logs (IP, User-Agent, path, status), application logsTypically days to a few weeks (rotation); not used for marketing
Landing / demo artefactsPublic stats.json aggregates, demo withdraw URI stateStats overwritten continuously; demo URI until used or rotated
+

Not retained as payment content: wallet coin private keys (never sent to the bank). + Full browser history or device contacts are not collected by this bank service.

+ +

3. Purposes

+
    +
  • Operating bank accounts and GOA transfers for exploration of GNU Taler
  • +
  • Authenticating users and preventing abuse
  • +
  • Enabling withdrawals to the GOA exchange
  • +
  • Security, debugging, and capacity monitoring
  • +
+ +

4. Legal basis (Swiss FADP)

+

Processing is necessary to provide the service requested by the user (account / withdraw) + and for overriding private interests of secure operation of an experimental public stack + (Art. 6 and 31 FADP principles: lawfulness, proportionality, purpose limitation).

+ +

5. Recipients & transfers

+
    +
  • GOA exchange (exchange.hacktivism.ch): reserve/wire-related data required by the protocol
  • +
  • Infrastructure operators of this host (hosting, backups) under operational control
  • +
  • No sale of personal data. No intentional transfer outside Switzerland/EEA for this service; + infrastructure may use standard CDN/DNS resolvers
  • +
+ +

6. Your rights (FADP)

+

Subject to legal limits: right to information/access, rectification, deletion, and to object + to processing. Contact the operators. You may lodge a complaint with the Swiss Federal Data + Protection and Information Commissioner (FDPIC / EDÖB).

+ +

7. Security

+

TLS in transit; access control on bank API; experimental service — no certified ISMS. + Do not store sensitive personal data in transaction subjects.

+ +

Related

+
+
bank-pp-swiss-v0 · Swiss FADP (revDSG)
+ + diff --git a/configs/bank-landing/qr-logo.png b/configs/bank-landing/qr-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aeffa23d3ec9d66f1041cc50d28cd2037e0e4e56 GIT binary patch literal 8563 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 tCI$W<00960e6}m<00006Nkl \ No newline at end of file diff --git a/configs/bank-landing/qr-payto.png b/configs/bank-landing/qr-payto.png new file mode 100644 index 0000000000000000000000000000000000000000..2be70b216a7c4217c137a36be9082e29dbd96445 GIT binary patch literal 602 zcmV-g0;T`6pHR9J=W znO&;GKnR6}SwNRGTc0J(0s>w=M>FXG?PihjQ2$G^d#yDmG~Zge9~N5NMe`7+PIKjElG9Py<* zQ-;`k{MF@=-~aBn{IAM25?Wph`7ymd!RTlGt;?AWx&&@9=h&+~Km1djdqTe!p;>91 zUwNR_=ibUZ8|)JgD5SVOTz7oc=H3?8IfJjfIWy@ya4$<1P$Ec?J$W--T5#Hjv=%oG z-}1QwH=lTlKvv)xx$gNPvjItb1zAUrw! ztu)W#gtQPoP$IFvRp*|^u*MCgvWqD#^QSgPSh2GS486r&`lmFfSsLQb^@Xd9&`KS6 zSVprTHzG^U{7{;EWnG~rG*lid?r6djLSnG3AxpBq)#jXo_X)IjeNmmCwU+0%Pc|!6 z>g>RCl;3C(n9NY9&VHgg&&nDz0X8ebSZly}L1l2b3$C%;oiUT8);RU6HkW7x o{ufh-3pPF79r)+#$G^+}0Nz87Dqcs^`~Uy|07*qoM6N<$g1Q|al>h($ literal 0 HcmV?d00001 diff --git a/configs/bank-landing/qr-product-beacon-badge.png b/configs/bank-landing/qr-product-beacon-badge.png new file mode 100644 index 0000000000000000000000000000000000000000..e01486e1d4bf246337f606534b9367414aa743ac GIT binary patch literal 608 zcmV-m0-ybfP)6lkhO)lT_@rSaE7zk76V2pgx27&6<8Cp%EU-+L|F+AH(@o^%aUJ1bo2g~ zoQIoWudh~OtybCVJHFC$iLmk|iiMKn|8jFUBQHztHQJ0t+xceZ_oGQGp|;Y5QxWi}kwWFS2v&FE4+MZGp%WU+KBKiRHnC z)6LQ8-^n>7ju=bC<{q1yS%c$l#4asfq3C$|xgXf&U_KQ7!fy-kJztS~ECk0xW>}D uHj(rNxaT+dWbTYey;c?ApN}8^F8>2hr2G}wjM)kR0000K@4h2>CNJYWw$L-xdsPSxW2Imynqxy5^rR`|&UHZgG{kU(GHr z?xokbW7TQ~UL%ZH81a7LUc42q0A=72N34xY%S|6f#3d8=Sc?=zy4)KgPRGq6#P~Tg zlZd<0g8U%9m8k#vJD1Df_4RxE|5&c8BhbRfh%&VHs=w#*NC^F{K$p1cBR{9}urn%1BudQWk_5cZPw=*Sg?jazFAnGOH-+a3`7h|Q<5rH>2>GlXbyAJC9nl0 zw&siCDfn!~l9jY3qpbPvbgs^(C{d{Rtu_q_I2!Vlm`}(NY0n>r^L`(z*rPdX-ER&io;uv>q}bL?igA=ZeY?E)sg6nxJ_wl|t>joY4p>wJZ0 z5^4_Paom!DSGH)sPlixjtKa?kbUvj7qq9XXfB3Dx1Y8A4!-Z{?W#K-1n*8nCA{n@I zi$Ik%)mI)gw&(vd2lLqqicecCt*!VD=bx`1|1SRn$~GG&R3v|P00000NkvXXu0mjf DjZz@D literal 0 HcmV?d00001 diff --git a/configs/bank-landing/qr-product-comet-cap.png b/configs/bank-landing/qr-product-comet-cap.png new file mode 100644 index 0000000000000000000000000000000000000000..1bea33faab97352ea6d20aa40e804f3d9def064a GIT binary patch literal 524 zcmV+n0`vWeP)>M8>MJN$^?6# z(}9}aG$aIh1gVkpdpVy%aF48`)Q#!a-X{Z5inE#)HUHv45wfdAO>@hW3{1Kv%s2Aj z;p<)BrKmx);re{I9NYuv&op7R;bk<9+WvZ$gsh;XozqzXb=~bv526Pn?<}v>L&rNZ zk%LGO(J~o)$T30{uI_DiRfsUE+cjZqMYqMVA8(n6LB!t5>GN&_i6>tXgH%KvJKid0 zRBYFT?7LR+9O~vJqYOk5GUs-DfE&|Bj!e`b^W)z5(s-@agzOWHIq4g1%Qiw3mdo8} z6>ZCQJX;OcfFXDGL6GE4P7(Gs$aZ_H`|$~AYS7u?CVPg{M*LYOYOufE%0o&fZOgK- zOrzQxvrh;^MjnG|kb16pj7|;G-2MtW=~5EzBe;9U|2vWx{4jp-EBF9}fN)Ps1=scf O0000fa{IYls-|^$N zWwvgM#f4Q{6Zi^pTo%xMAJ;l!Vg< zL!?NU>!wyX)(9?5h^9;wZQPmP&AGc$T=fC6WLH!?f`j;0eMqekXQi(<=PyLr#o(&? zfLyQq>CKlC+N&%>C0(leyWX6Y`SDjZN?E99@O`VlroYCgTq-ku0QW2foDcA7ms;%p zT%~|LHRc1nI3IuFuQ{)_uF`s|P^a(q=O_-?!Xt`UV~TU7{HZx-H)KoxinOl&!hHg- z%AE57Mq~~;=Pv*1%@Iw`uu7LzWEpdP0B0+7B^7ix~TlMJ3s1JRGFFRmKCo**Tc3H^{x5~+3f(H_bbLTskG)5 z%l-ZSr7vG;QM}^Kv7Y*-aE>+WX^WWa5{#pJ7>+dm4bu?w-00000NkvXXu0mjfa5pm9 literal 0 HcmV?d00001 diff --git a/configs/bank-landing/qr-product-nebula-coffee.png b/configs/bank-landing/qr-product-nebula-coffee.png new file mode 100644 index 0000000000000000000000000000000000000000..a56dd308cdb6eee1acbe4564d5b300f37bd5788d GIT binary patch literal 604 zcmV-i0;BzjP)e6lvEY}DgEmcj{TP>JoT z!&zT8QC#B`+H3vg<@7fM{rP+Ge_kHfFm8*o>Yw~g%WoMPWhINFvV0i@UMav=O)Yc4 z4d@Sus5bZ_6j$JTbJQQ;52kDz@^=Y78K@E`b1Sh5aJB`*p6vc`OWc#cxjB>dY|HKs z>(|0Rxw(5tmgU!v9uNS?-*Qq-{k0=_-*QIs8d-`8?j%uR6Ia`P^I{^V=S7f*jn z&Y`JKIqP)%?3Q@IUGCzMWKhxc;c%#2V^zkIwP7fV|wsQnqnM@s&Rij z^|dyqxJD>ZXlGjAvUWD$T1@WrPwI<*^JQgzXILq%X`)Ls9erAyPX(0PvSv;0XTGV< z^?i4zxHEk&)nOaX6k(xae9_P|AN1h*EX{FEQ>MTAcWr(O>e76y%KGT*@|^k2gvk8n j_pKC4_4()Z~|F$jc(JwT7-)pMjAAmGy(lP$}m{|X8XrEK?0e~4xrBlda8kAIm@@pakoA4^n3 zm-tJKx1t@mI=QAej0b$yE4~AD;2wv^C;o2gdJRF8{*S8>_xN5OQ2dO&?Kn~-5nOC^ z(ukwJPFVZlQGazg&iB;E=W+h)^0QE4E41o={VmEPK4pkrUns7S+lA*2oDCrY^#@le zCx|r^s|nY59sOZIpSW@C^Ie;JDkQfsMJZS4*bO-Ai`keIR|dEBwK%tNnwxJq{lULk zU)8w>0X0Qi@R%ZEs?_m!wRx6UCI?0-8+8kw&JIQ+lu4;l*WCTBJV$+v)K}5PU9{Ns z`L=SYufE+PKX%t&an5dR`&bXEEF4DP{xYk3|DN%!Bd~^5Hi7GBNT5~GC?#m zHU6)LY#>vR8hJjy^3w=DtJRI|Kef*VB9y68AyM-i7mg4?gzj|YdCgKc9U;>$+Be3k zQ?EGvAQBFzPM4$UK{B53Ko=PbO+&{YG7w8h2}c=7MnGLxdGmu1bu|$b3vOE`c2KBFF(k!}9}vM>KIzbw|_Gd2sa zBc6T@<9i1_f*hAE)5|#G!_(sl)PaYN&}S#0oin!h<+7s@4}E6{!WQ4Q;|x(qjO1Dr z+KBJ8f~=RmU-?&;v%Vqd?eACp*X8$OiY@f2zx!`d4t+xsqqvz|Xt#^+4LGtXRYr@2 z#B)+BI#vUIp(fN;872Bd{L4A-`Yz2Q6x6pK{(yEx$8Nx-&(a^1UleCg{dGB{IA@|a ze~4%1UvZAtsesTQ@S(cYp6_aNpBm^gI2@FiWt{lx^D8Y*RSvl*Zf02%zAT69JRj&Q zjo9@$f-^N!(Yb?c zI3F}*fArU>;b`stTAXW|Q-Ae5+_Pe?9y}*gmiWG(uynloT*DY*V{@s?nBtW> zm>Y6sjdg^%%rsy`9FEZb3r{G+$mL%lbjmrNpbaod|4gd$MyK*+3mHWKg1 z#1Asxj;OICH(!M%#1>>ZnXRjc2npO3p3SF-qaWXyZ3Z!eQ$$27jXb&H1(6%+aL8nS zRZLVp5V$2=$~rn66R)lEJWq(nukrzD%vio<;s#kic1AHuGcEklkROBxpCKofTLFQ_K5Afj2Svut;S=QC)Rz18>eF$_02cDcnqL-Z0%q5dw&m7`>G$Fe7^ z(2%{U0&<3`dZ=#HmA@9=2{PQC2{G>GZEt2!Tk!lb<=?XBGsq2oX#eml_yd9vh$R*f R({2C&002ovPDHLkV1m`S>$Cs> literal 0 HcmV?d00001 diff --git a/configs/bank-landing/qr-product-voidwave-playlist.png b/configs/bank-landing/qr-product-voidwave-playlist.png new file mode 100644 index 0000000000000000000000000000000000000000..a63ba20e479c290fbd10b2fffbfb3408137205eb GIT binary patch literal 602 zcmV-g0;T`6pHR9J=W zm{E$uFbqTmK0uD7tNTbffI%zG$WFH`{VPbYCX_TUKf;Xxw^(DUxbEB;DKnZ`%`C{i^ zaXzyeDg#;%e?n-t;4z1$O1L@9YHh-87Zl;ZE~De_`Jy)WpIfPKDjNp{JN;FkhrYL_ z4aIRd+WB3cqrd2{CnXqqE%+XhGNSpRj*4%if_R8_mwmx${L?o_Iz5L zE5q#^?z6LQIsH|fBNmjyQCL!(47eI`p*Yr)(%OorCymTETaUk1=ZI*d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); \ No newline at end of file diff --git a/configs/bank-landing/shop-pay.css b/configs/bank-landing/shop-pay.css new file mode 100644 index 0000000..c39a62e --- /dev/null +++ b/configs/bank-landing/shop-pay.css @@ -0,0 +1,348 @@ +/* GOA shop pay popup — QR_Taler style from taler-merchant-webui / @gnu-taler/web-util */ + +/* Animated blue ring (same as webui QR.tsx) */ +@property --angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} +@keyframes goa-pay-qr-rotate { + to { + --angle: 360deg; + } +} + +.shop-item[data-product] { + cursor: pointer; + transition: border-color 0.15s, transform 0.12s, background 0.15s; +} +.shop-item[data-product]:hover, +.shop-item[data-product]:focus-visible { + border-color: rgba(196, 181, 253, 0.75); + background: rgba(0, 0, 0, 0.4); + transform: translateY(-1px); + outline: none; +} +button.shop-item { + font: inherit; + color: inherit; + width: 100%; + appearance: none; + -webkit-appearance: none; +} + +.goa-pay-modal { + position: fixed; + inset: 0; + z-index: 80; + display: none; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(8, 6, 14, 0.75); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.goa-pay-modal.open { + display: flex; +} +.goa-pay-card { + position: relative; + background: rgba(36, 28, 48, 0.98); + border: 1px solid rgba(167, 139, 250, 0.45); + border-radius: 16px; + padding: 1.25rem 1.2rem 1.2rem; + max-width: 24rem; + width: 100%; + text-align: center; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + max-height: 92vh; + overflow-y: auto; +} +.goa-pay-x { + position: absolute; + top: 0.45rem; + right: 0.55rem; + border: 0; + background: transparent; + color: #c4b5fd; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + padding: 0.2rem 0.45rem; +} +.goa-pay-card h3 { + margin: 0 0 0.2rem; + font-size: 1.08rem; + color: #faf5ff; + padding-right: 1.5rem; +} +.goa-pay-amount { + margin: 0 0 0.85rem; + font-weight: 750; + color: #c4b5fd; + font-variant-numeric: tabular-nums; +} + +/* === QR_Taler frame (webui QR.tsx) === */ +.goa-pay-taler-qr { + width: 100%; + max-width: 280px; + margin: 0 auto 0.75rem; + padding: 10px; + border-radius: 20px; + box-sizing: border-box; + position: relative; + background: conic-gradient( + from var(--angle), + #0042b3 0deg, + #f1f1f4 20deg, + #f1f1f4 150deg, + #f1f1f4 160deg, + #0042b3 180deg, + #f1f1f4 200deg, + #f1f1f4 330deg, + #f1f1f4 340deg, + #0042b3 + ); + animation: goa-pay-qr-rotate 10s linear infinite; +} +.goa-pay-taler-qr__inner { + padding: 10px; + border-radius: 20px; + background: #fff; + line-height: 0; + min-height: 180px; + box-sizing: border-box; +} +/* Always an with explicit pixel size from JS */ +.goa-pay-taler-qr__inner img { + display: block !important; + margin: 0 auto !important; + border: 0 !important; + background: #fff; + image-rendering: pixelated; +} +/* Center logo plate — official qr-logo.png (webui ~100×50; PNG for Android) */ +.goa-pay-taler-qr__logo { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 28%; + height: auto; + max-width: 100px; + aspect-ratio: 200 / 95; + object-fit: contain; + pointer-events: none; + background: #fff; + border-radius: 4px; +} +/* Full encoded payload under QR — clickable (wallet / webextension) */ +.goa-pay-payload { + display: block; + margin: 0.45rem 0 0.55rem; + padding: 0 0.25rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.68rem; + line-height: 1.35; + color: #7eb6ff; + word-break: break-all; + text-align: center; + text-decoration: none; + border-bottom: 1px solid rgba(126, 182, 255, 0.4); + cursor: pointer; +} +.goa-pay-payload:hover { + color: #b8d6ff; + border-bottom-color: #7eb6ff; +} + +/* Settlement: collapsed by default, expand on click */ +.goa-pay-settle { + margin-top: 0.85rem; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + overflow: hidden; + background: rgba(0, 0, 0, 0.22); +} +.goa-pay-settle-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + margin: 0; + padding: 0.65rem 0.75rem; + border: 0; + background: transparent; + color: #cbd5e1; + font: inherit; + font-size: 0.78rem; + font-weight: 650; + text-align: left; + cursor: pointer; + line-height: 1.3; +} +.goa-pay-settle-toggle:hover { + background: rgba(148, 163, 184, 0.1); + color: #e2e8f0; +} +.goa-pay-settle-toggle-text { + flex: 1; +} +.goa-pay-settle-chevron { + flex-shrink: 0; + transition: transform 0.15s ease; + color: #94a3b8; + font-size: 0.85rem; +} +.goa-pay-modal.settle-open .goa-pay-settle-chevron { + transform: rotate(90deg); +} +.goa-pay-settle-body { + padding: 0 0.75rem 0.75rem; + border-top: 1px solid rgba(148, 163, 184, 0.25); +} +.goa-pay-settle-note { + margin-top: 0.55rem; +} +/* Settlement payto QR — plain box (not wallet pay / no logo / no blue ring) */ +.goa-pay-payto-qr { + width: fit-content; + max-width: 160px; + margin: 0.35rem auto 0.55rem; + padding: 0.45rem 0.5rem 0.5rem; + border-radius: 10px; + background: #fff; + border: 1px dashed rgba(100, 116, 139, 0.75); + box-sizing: border-box; +} +.goa-pay-payto-qr__caption { + font-size: 0.62rem; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + text-align: center; + margin: 0 0 0.35rem; + line-height: 1.2; +} +.goa-pay-payto-qr__inner { + line-height: 0; + min-height: 0; +} +.goa-pay-payto-qr__inner img { + display: block !important; + margin: 0 auto !important; + background: #fff; + image-rendering: pixelated; +} +.goa-pay-sub { + margin: 0 0 0.55rem; + font-size: 0.75rem; + line-height: 1.35; + color: #b8a8c9; + font-weight: 500; + text-align: center; +} + +.goa-pay-hint { + margin: 0 0 0.75rem; + font-size: 0.82rem; + color: #b8a8c9; +} +.goa-pay-hint.err { + color: #fca5a5; +} +.goa-pay-cta { + display: block; + text-decoration: none; + font-weight: 750; + padding: 0.65rem 1rem; + border-radius: 11px; + background: linear-gradient(135deg, #a78bfa, #6366f1); + color: #0f0a1a; + margin-bottom: 0.75rem; +} +.goa-pay-cta:hover { + filter: brightness(1.06); +} +.goa-pay-cta.disabled { + opacity: 0.45; + pointer-events: none; +} +.goa-pay-label { + margin: 0.55rem 0 0.2rem; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #b8a8c9; +} +.goa-pay-line { + margin: 0 0 0.35rem; + text-align: center; + font-size: 0.88rem; + font-weight: 650; +} +.goa-pay-link { + color: #93c5fd; + text-decoration: none; + white-space: nowrap; +} +.goa-pay-link:hover { + text-decoration: underline; +} +.goa-pay-link.disabled { + opacity: 0.45; + pointer-events: none; +} +.goa-pay-sep { + margin: 0 0.4rem; + color: #b8a8c9; + font-weight: 500; +} +.goa-pay-close { + display: block; + width: 100%; + margin-top: 0.85rem; + border: 1px solid rgba(167, 139, 250, 0.45); + background: transparent; + color: #c4b5fd; + font-weight: 650; + padding: 0.5rem; + border-radius: 10px; + cursor: pointer; +} + +/* Bank (gold/teal) overrides */ +.goa-pay-modal.bank-theme .goa-pay-card { + background: rgba(42, 32, 24, 0.98); + border-color: rgba(232, 168, 56, 0.4); +} +.goa-pay-modal.bank-theme .goa-pay-amount { + color: #5eead4; +} +.goa-pay-modal.bank-theme .goa-pay-cta { + background: linear-gradient(135deg, #5eead4, #0d9488); + color: #042f2e; +} +.goa-pay-modal.bank-theme .goa-pay-link { + color: #5eead4; +} +.goa-pay-modal.bank-theme .goa-pay-x, +.goa-pay-modal.bank-theme .goa-pay-close { + color: #e8c878; + border-color: rgba(232, 168, 56, 0.35); +} +.goa-pay-modal.bank-theme .goa-pay-settle { + border-color: rgba(232, 168, 56, 0.3); + background: rgba(0, 0, 0, 0.28); +} +.goa-pay-modal.bank-theme .goa-pay-settle-toggle { + color: #e8c878; +} +.goa-pay-modal.bank-theme .goa-pay-settle-body { + border-top-color: rgba(232, 168, 56, 0.25); +} diff --git a/configs/bank-landing/shop-pay.js b/configs/bank-landing/shop-pay.js new file mode 100644 index 0000000..2c0573b --- /dev/null +++ b/configs/bank-landing/shop-pay.js @@ -0,0 +1,410 @@ +/** + * GOA shop pay popup — public only (no merchant secrets). + * Flow: POST templates/{id} → taler://pay/… + payto links. + * + * QR display matches taler-merchant-webui QR_Taler + * (@gnu-taler/web-util QR.tsx): animated #0042B3 conic ring + qr-logo.png. + * Uses qrcode-generator (same lib as webui) via global QRCode if present, + * else falls back to canvas from qrcode.min.js (davidshimjs). + */ +(function () { + var MERCHANT_HOST = "taler.hacktivism.ch"; + /* goa-shop: dedicated instance with fixed-order product templates */ + var INSTANCE = "goa-shop"; + var SHOP_PAYTO = + "payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop"; + var SHOP_PAYTO_HTTPS = "https://bank.hacktivism.ch/webui/"; + + function introBase() { + var b = document.querySelector("base"); + return b && b.href ? b.href : "/intro/"; + } + + function logoSrc() { + /* PNG: Android often drops SVG wordmark fill inheritance (missing "taler") */ + return introBase() + "qr-logo.png"; + } + + function templateHttps(productId) { + return ( + "https://" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/templates/" + + encodeURIComponent(productId) + ); + } + + function payTemplateUri(productId) { + return ( + "taler://pay-template/" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/" + + encodeURIComponent(productId) + ); + } + + function ensureModal() { + var m = document.getElementById("goa-pay-modal"); + if (m) return m; + m = document.createElement("div"); + m.id = "goa-pay-modal"; + m.className = "goa-pay-modal"; + m.setAttribute("role", "dialog"); + m.setAttribute("aria-modal", "true"); + m.setAttribute("aria-labelledby", "goa-pay-title"); + m.hidden = true; + m.innerHTML = + '
' + + ' ' + + '

Pay with Taler

' + + '

' + + '

Preparing payment…

' + + '

Wallet payment

' + + '

Scan with the GNU Taler Wallet to pay this product

' + + '
' + + '
' + + ' ' + + "
" + + ' ' + + ' Open payment link →' + + '

' + + ' taler:// URI' + + ' ·' + + ' HTTPS' + + "

" + + '
' + + ' " + + ' " + + "
" + + ' ' + + "
"; + document.body.appendChild(m); + function close() { + m.classList.remove("open"); + m.hidden = true; + // collapse settlement on close + var body = document.getElementById("goa-pay-settle-body"); + var btn = document.getElementById("goa-pay-settle-toggle"); + if (body) body.hidden = true; + if (btn) { + btn.setAttribute("aria-expanded", "false"); + m.classList.remove("settle-open"); + } + } + m.addEventListener("click", function (e) { + if (e.target === m) close(); + }); + document.getElementById("goa-pay-x").onclick = close; + document.getElementById("goa-pay-close").onclick = close; + document.getElementById("goa-pay-settle-toggle").onclick = function () { + var body = document.getElementById("goa-pay-settle-body"); + var btn = document.getElementById("goa-pay-settle-toggle"); + var open = body.hidden; + body.hidden = !open; + btn.setAttribute("aria-expanded", open ? "true" : "false"); + m.classList.toggle("settle-open", open); + if (open) { + // paint payto QR when first expanded + renderQr(document.getElementById("goa-pay-qr-payto"), SHOP_PAYTO, 140); + var pp = document.getElementById("goa-pay-payto-payload"); + if (pp) { + pp.href = SHOP_PAYTO; + pp.textContent = SHOP_PAYTO; + } + } + }; + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && m.classList.contains("open")) close(); + }); + return m; + } + + /** + * Render URI as a real PNG (not a live canvas). + * davidshimjs paints canvas then often hides it for an img; if the modal is + * still display:none, layout collapses and you only see the blue ring. + * We always encode off-DOM and inject a fixed-size image. + */ + function renderQr(hostEl, text, size) { + if (!hostEl) return; + hostEl.innerHTML = ""; + size = size || 220; + if (!text) { + hostEl.innerHTML = '

'; + return; + } + if (typeof QRCode === "undefined") { + hostEl.innerHTML = + '

QR library missing (qrcode.min.js)

'; + return; + } + var level = + QRCode.CorrectLevel && QRCode.CorrectLevel.M != null + ? QRCode.CorrectLevel.M + : QRCode.CorrectLevel && QRCode.CorrectLevel.L != null + ? QRCode.CorrectLevel.L + : 1; + var scratch = document.createElement("div"); + scratch.setAttribute("aria-hidden", "true"); + scratch.style.cssText = + "position:fixed;left:-9999px;top:0;width:" + + size + + "px;height:" + + size + + "px;overflow:hidden;opacity:0;pointer-events:none"; + document.body.appendChild(scratch); + var dataUrl = ""; + try { + new QRCode(scratch, { + text: String(text), + width: size, + height: size, + colorDark: "#000000", + colorLight: "#ffffff", + correctLevel: level, + }); + var canvas = scratch.querySelector("canvas"); + var libImg = scratch.querySelector("img"); + if (canvas && canvas.width > 0) { + try { + dataUrl = canvas.toDataURL("image/png"); + } catch (e1) {} + } + if (!dataUrl && libImg && libImg.src && libImg.src.indexOf("data:") === 0) { + dataUrl = libImg.src; + } + } catch (err) { + dataUrl = ""; + } + if (scratch.parentNode) scratch.parentNode.removeChild(scratch); + + if (!dataUrl) { + hostEl.innerHTML = + '

QR encode failed

'; + return; + } + var img = document.createElement("img"); + img.alt = "QR code"; + img.width = size; + img.height = size; + img.src = dataUrl; + img.style.display = "block"; + img.style.width = size + "px"; + img.style.height = size + "px"; + img.style.maxWidth = "100%"; + img.style.margin = "0 auto"; + img.style.background = "#fff"; + hostEl.appendChild(img); + } + + function setLogo(imgEl) { + if (!imgEl) return; + imgEl.alt = "Taler"; + imgEl.width = 100; + imgEl.height = 50; + imgEl.decoding = "async"; + imgEl.src = logoSrc(); + imgEl.onerror = function () { + imgEl.src = introBase() + "qr-logo.svg"; + }; + imgEl.style.display = ""; + } + + function normalizePayUri(uri) { + if (!uri) return ""; + return String(uri) + .replace(/taler\.hacktivism\.ch:443/g, "taler.hacktivism.ch") + .replace(/:443\//g, "/") + .replace(/:443\?/g, "?"); + } + + /** Public: template → order → taler_pay_uri (no secrets). */ + function createPayUri(productId) { + var url = templateHttps(productId); + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + cache: "no-store", + }) + .then(function (r) { + if (!r.ok) throw new Error("template POST HTTP " + r.status); + return r.json(); + }) + .then(function (created) { + var oid = created.order_id; + var tok = created.token; + if (!oid || !tok) throw new Error("no order_id/token"); + var statusUrl = + "https://" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/orders/" + + encodeURIComponent(oid) + + "?token=" + + encodeURIComponent(tok); + return fetch(statusUrl, { cache: "no-store" }).then(function (r) { + return r.text().then(function (t) { + var pay = ""; + try { + var d = JSON.parse(t); + pay = d.taler_pay_uri || ""; + } catch (e) {} + if (!pay) { + pay = + "taler://pay/" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/" + + oid + + "/?c=" + + tok; + } + return { + taler_pay_uri: normalizePayUri(pay), + order_id: oid, + token: tok, + status_https: statusUrl.replace( + "taler.hacktivism.ch:443", + "taler.hacktivism.ch" + ), + template_https: url, + }; + }); + }); + }); + } + + function showPay(productId, name, amount) { + var m = ensureModal(); + if (document.body && document.body.getAttribute("data-shop-theme") === "bank") { + m.classList.add("bank-theme"); + } else { + m.classList.remove("bank-theme"); + } + document.getElementById("goa-pay-title").textContent = name || productId; + document.getElementById("goa-pay-amount").textContent = amount || ""; + var status = document.getElementById("goa-pay-status"); + status.textContent = "Preparing payment…"; + status.className = "goa-pay-hint"; + var open = document.getElementById("goa-pay-open"); + open.removeAttribute("href"); + open.classList.add("disabled"); + open.onclick = null; + var payLink = document.getElementById("goa-pay-uri"); + payLink.removeAttribute("href"); + payLink.classList.add("disabled"); + payLink.textContent = "taler:// URI"; + document.getElementById("goa-pay-uri-https").href = templateHttps(productId); + document.getElementById("goa-pay-uri-https").textContent = "HTTPS"; + var paytoLink = document.getElementById("goa-pay-payto"); + paytoLink.href = SHOP_PAYTO; + paytoLink.textContent = "payto:// URI"; + document.getElementById("goa-pay-payto-https").href = SHOP_PAYTO_HTTPS; + document.getElementById("goa-pay-payto-https").textContent = "HTTPS"; + + setLogo(document.getElementById("goa-pay-qr-pay-logo")); + + // Reset settlement panel (collapsed until user expands) + var settleBody = document.getElementById("goa-pay-settle-body"); + var settleBtn = document.getElementById("goa-pay-settle-toggle"); + if (settleBody) settleBody.hidden = true; + if (settleBtn) settleBtn.setAttribute("aria-expanded", "false"); + m.classList.remove("settle-open"); + var paytoHost = document.getElementById("goa-pay-qr-payto"); + if (paytoHost) paytoHost.innerHTML = ""; + + // Open first so layout exists, then paint wallet QR only + m.hidden = false; + m.classList.add("open"); + var tpl = payTemplateUri(productId); + renderQr( + document.getElementById("goa-pay-qr-pay"), + tpl, + 220 + ); + var payload = document.getElementById("goa-pay-qr-payload"); + if (payload) { + payload.href = tpl; + payload.textContent = tpl; + } + + createPayUri(productId) + .then(function (info) { + var pay = info.taler_pay_uri; + open.href = pay; + open.classList.remove("disabled"); + open.textContent = "Open payment link →"; + payLink.href = pay; + payLink.classList.remove("disabled"); + payLink.textContent = "taler:// URI"; + var uh = document.getElementById("goa-pay-uri-https"); + uh.href = info.status_https || info.template_https; + uh.textContent = "HTTPS"; + // Live unpaid taler://pay (fresh order each open) + renderQr(document.getElementById("goa-pay-qr-pay"), pay, 220); + if (payload) { + payload.href = pay; + payload.textContent = pay; + } + status.textContent = "Ready — scan in wallet"; + }) + .catch(function (err) { + status.textContent = + "Payment setup failed: " + (err && err.message ? err.message : err); + status.className = "goa-pay-hint err"; + open.textContent = "Retry"; + open.onclick = function (e) { + e.preventDefault(); + showPay(productId, name, amount); + }; + }); + } + + function bind() { + document.querySelectorAll("[data-product]").forEach(function (el) { + el.addEventListener("click", function (e) { + e.preventDefault(); + var id = el.getAttribute("data-product"); + var nameEl = el.querySelector(".name"); + var priceEl = el.querySelector(".price"); + showPay( + id, + nameEl ? nameEl.textContent.trim() : id, + priceEl ? priceEl.textContent.trim() : "" + ); + }); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bind); + } else { + bind(); + } +})(); diff --git a/configs/bank-landing/terms.html b/configs/bank-landing/terms.html new file mode 100644 index 0000000..4a8f4ff --- /dev/null +++ b/configs/bank-landing/terms.html @@ -0,0 +1,75 @@ + + + + + + No Formal Terms · GOA Bank + + + +
bank.hacktivism.ch · GOA
+

No Formal Terms · GOA Exploration Bank

+

This is a self-hosted GNU Taler regional bank at + bank.hacktivism.ch (hacktivism.ch) for the explorational currency + GOA.

+

No formal terms of service apply beyond this short notice.

+ +
+ GOA · explorational + Not legal tender. No guaranteed real-world value, redemption, or convertibility. + Accounts and demo withdraws are for exploration of the GNU Taler stack only. +
+ +

By using this bank you acknowledge

+
    +
  • GOA is for exploration and testing only.
  • +
  • There is no guaranteed availability, support, or uptime.
  • +
  • Operators may reset accounts, balances, or configuration without notice.
  • +
  • Registration data and wire-style transfers may identify account holders to operators.
  • +
  • Software is provided as-is, without warranty.
  • +
+

If you do not agree, do not use this bank.

+ +

Related

+ + +

Privacy

+

Processing under Swiss FADP (revDSG). What data is retained + (accounts, ledger, logs, …) is listed on + /privacy.

+
bank.hacktivism.ch · landing terms
+ + diff --git a/configs/bonfire/README.md b/configs/bonfire/README.md new file mode 100644 index 0000000..8ce50a4 --- /dev/null +++ b/configs/bonfire/README.md @@ -0,0 +1,28 @@ +# Bonfire — bonfire.hacktivism.ch + +| Item | Value | +|------|--------| +| Live | `/home/hernani/koopa-bonfire/` | +| Image | `bonfirenetworks/bonfire:1.0.5-social-amd64` | +| Port | **9021** → Caddy | +| Ground zero | `2026/2026-07-09--bonfire-ground-zero.md` | + +Secrets: `koopa-admin-secrets/…/koopa-bonfire/{.env,users.env}`. + +**Upstream:** [Running your own](https://docs.bonfirenetworks.org/running-your-own.html), [Hosting guide](https://docs.bonfirenetworks.org/deploy.html) (we use podman-compose, not Co-op Cloud). + +## Branding + +Hacktivism theme + hacktivism magician logo. `scripts/bonfire/apply-branding.sh`. +Logo bind-mount: `2026/2026-07-11--bonfire-logo-not-served.md`. + +## Boot + +```bash +scripts/bonfire/install-systemd.sh +``` + +Units: `container-koopa-bonfire-db`, `container-koopa-bonfire`, `gitbot-mirror`. +Needs `loginctl enable-linger hernani`. Run `podman-compose up -d` once before first boot. + +Public feeds: `public-feeds.md`. \ No newline at end of file diff --git a/configs/bonfire/assets/img/favicon.svg b/configs/bonfire/assets/img/favicon.svg new file mode 100644 index 0000000..e1d3dc3 --- /dev/null +++ b/configs/bonfire/assets/img/favicon.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/bonfire/assets/img/logo.png b/configs/bonfire/assets/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..03e96c28f7cbd8307bcffcad0456e2ee3c497bb9 GIT binary patch literal 98601 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≺Fg5FEI6*GS3JIpz}S^FWX312^i@e}Wwi1G8& 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/bonfire/compose.yml b/configs/bonfire/compose.yml new file mode 100644 index 0000000..eb94daa --- /dev/null +++ b/configs/bonfire/compose.yml @@ -0,0 +1,55 @@ +# koopa-bonfire — ground zero (host Caddy → :9021) +services: + web: + image: docker.io/bonfirenetworks/bonfire:1.0.5-social-amd64 + container_name: koopa-bonfire + restart: unless-stopped + env_file: [.env] + environment: + POSTGRES_HOST: db + HOSTNAME: bonfire.hacktivism.ch + PUBLIC_PORT: "443" + SERVER_PORT: "4000" + DB_MIGRATE_INDEXES_CONCURRENTLY: "false" + MIX_ENV: prod + ports: + - "9021:4000" + volumes: + - ./data/uploads:/opt/app/data/uploads + # Bind-mount into priv/static/images (Bonfire serves /images/* from there). + - ./data/branding/logo.svg:/opt/app/lib/bonfire-1.0.5-social/priv/static/images/hacktivism-logo.svg:ro + - ./data/branding/favicon.svg:/opt/app/lib/bonfire-1.0.5-social/priv/static/images/hacktivism-favicon.svg:ro + depends_on: + db: + condition: service_healthy + labels: + org.hacktivism.service: bonfire + org.hacktivism.host_port: "9021" + org.hacktivism.site: bonfire.hacktivism.ch + org.hacktivism.managed_by: koopa-admin + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:4000/ || exit 1"] + interval: 30s + timeout: 10s + retries: 15 + start_period: 180s + + db: + image: docker.io/library/postgres:15-alpine + container_name: koopa-bonfire-db + restart: unless-stopped + env_file: [.env] + environment: + POSTGRES_USER: postgres + POSTGRES_DB: bonfire_db + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d bonfire_db"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + +volumes: + db-data: diff --git a/configs/bonfire/container-koopa-bonfire-db.service b/configs/bonfire/container-koopa-bonfire-db.service new file mode 100644 index 0000000..29bd9c7 --- /dev/null +++ b/configs/bonfire/container-koopa-bonfire-db.service @@ -0,0 +1,21 @@ +# user systemd - Postgres for Bonfire +[Unit] +Description=Bonfire Postgres (koopa-bonfire-db) +Wants=network-online.target +After=network-online.target +RequiresMountsFor=%t/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=on-failure +RestartSec=15 +TimeoutStartSec=300 +TimeoutStopSec=70 +WorkingDirectory=/home/hernani/koopa-bonfire +ExecStart=/usr/bin/podman-compose up -d db +ExecStop=/usr/bin/podman stop -t 30 koopa-bonfire-db +Type=oneshot +RemainAfterExit=yes + +[Install] +WantedBy=default.target diff --git a/configs/bonfire/container-koopa-bonfire.service b/configs/bonfire/container-koopa-bonfire.service new file mode 100644 index 0000000..9caf3cc --- /dev/null +++ b/configs/bonfire/container-koopa-bonfire.service @@ -0,0 +1,21 @@ +# user systemd - Bonfire web +[Unit] +Description=Bonfire web (koopa-bonfire) +Wants=network-online.target container-koopa-bonfire-db.service +After=network-online.target container-koopa-bonfire-db.service +RequiresMountsFor=%t/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=on-failure +RestartSec=20 +TimeoutStartSec=300 +TimeoutStopSec=120 +WorkingDirectory=/home/hernani/koopa-bonfire +ExecStart=/usr/bin/podman-compose up -d web +ExecStop=/usr/bin/podman stop -t 30 koopa-bonfire +Type=oneshot +RemainAfterExit=yes + +[Install] +WantedBy=default.target diff --git a/configs/bonfire/gitbot-mirror.service b/configs/bonfire/gitbot-mirror.service new file mode 100644 index 0000000..f02e2a0 --- /dev/null +++ b/configs/bonfire/gitbot-mirror.service @@ -0,0 +1,18 @@ +# user systemd - git.ngi-0.eu -> Bonfire gitbot + +[Unit] +Description=Bonfire gitbot (git.ngi-0.eu poll) +Wants=network-online.target container-koopa-bonfire.service +After=network-online.target container-koopa-bonfire.service + +[Service] +Type=simple +Restart=always +RestartSec=30 +WorkingDirectory=/home/hernani/koopa-bonfire +ExecStart=/usr/bin/python3 /home/hernani/koopa-bonfire/bin/gitbot-mirror.py --loop +StandardOutput=append:/home/hernani/koopa-bonfire/gitbot.log +StandardError=append:/home/hernani/koopa-bonfire/gitbot.log + +[Install] +WantedBy=default.target diff --git a/configs/bonfire/public-feeds.md b/configs/bonfire/public-feeds.md new file mode 100644 index 0000000..bdceb78 --- /dev/null +++ b/configs/bonfire/public-feeds.md @@ -0,0 +1,11 @@ +# Bonfire public feeds + +Posts land in the author outbox first. Copy to local/internet feeds or guests see stale timelines. + +**Official:** [Feed structure](https://docs.bonfirenetworks.org/feed_structure.html) (`FeedPublish`). +No official republish recipe — SQL + gitbot below are ops workarounds. + +- One-off SQL: `scripts/bonfire/publish-outbox-to-public.sql` +- Gitbot: republish every cycle (`gitbot-mirror.py`) + +Check: https://bonfire.hacktivism.ch/feed/local (not only `/`). \ No newline at end of file diff --git a/configs/bonfire/theme-hacktivism.json b/configs/bonfire/theme-hacktivism.json new file mode 100644 index 0000000..7545914 --- /dev/null +++ b/configs/bonfire/theme-hacktivism.json @@ -0,0 +1,29 @@ +{ + "name": "hacktivism", + "palette": "exchange-dark (git.hacktivism.ch / forgejo theme-hacktivism)", + "colors": { + "color-base-100": "#1a1410", + "color-base-200": "#221c16", + "color-base-300": "#2a2018", + "color-base-content": "#fff6e8", + "color-primary": "#e8a838", + "color-primary-content": "#1a1410", + "color-secondary": "#3d3128", + "color-secondary-content": "#fff6e8", + "color-accent": "#3ecfbf", + "color-accent-content": "#0e1c1e", + "color-neutral": "#14110e", + "color-neutral-content": "#ebe0d0", + "color-info": "#2563eb", + "color-info-content": "#ffffff", + "color-success": "#16a34a", + "color-success-content": "#ffffff", + "color-warning": "#f0d090", + "color-warning-content": "#1a1410", + "color-error": "#b91c1c", + "color-error-content": "#ffffff", + "radius-box": "0.875rem", + "radius-field": "0.5rem", + "radius-selector": "0.75rem" + } +} \ No newline at end of file diff --git a/configs/caddy/Caddyfile b/configs/caddy/Caddyfile new file mode 100644 index 0000000..03f88aa --- /dev/null +++ b/configs/caddy/Caddyfile @@ -0,0 +1,185 @@ +# Internal only (not in the browser URL): +# 9010 merchant API | 9011 exchange API | 9012 bank API +# 9013 bank landing | 9014 exchange landing | 9015 merchant landing +# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | | 9200 forgejo-ssh +# 9090 tops ng1 | 9091 tops ng2 | 9092 tops ng3 + +{ + email info+koopa@hacktivism.ch + http_port 9000 + https_port 9001 + auto_https disable_redirects + # Caddy listens on 9001 behind VeciGate/https-proxy :443. + # Default HTTP/3 would send Alt-Svc: h3=":9001" — break public HTTPS on :443. + servers { + protocols h1 h2 + } +} + +(proxy_public) { + header_up Host {host} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Port 443 + header_down Location "^https?://[^/]+:90[0-9]{2}(.*)$" "https://{host}$1" +} + +taler.hacktivism.ch { + tls /etc/caddy/certs/taler.hacktivism.ch/fullchain.pem /etc/caddy/certs/taler.hacktivism.ch/privkey.pem + header Alt-Svc "clear" + + # Public landing first + redir / /intro/ 302 + + handle /intro* { + reverse_proxy 127.0.0.1:9015 { + import proxy_public + } + } + + # SPA: /webui → /webui/ + redir /webui /webui/ 302 + + # Merchant API + WebUI (nginx :9010 → unix socket) + reverse_proxy https://127.0.0.1:9010 { + transport http { + tls_insecure_skip_verify + } + import proxy_public + } +} + +exchange.hacktivism.ch { + tls /etc/caddy/certs/exchange.hacktivism.ch/fullchain.pem /etc/caddy/certs/exchange.hacktivism.ch/privkey.pem + header Alt-Svc "clear" + + # Public landing first + redir / /intro/ 302 + + handle /intro* { + reverse_proxy 127.0.0.1:9014 { + import proxy_public + } + } + + reverse_proxy 127.0.0.1:9011 { + import proxy_public + } +} + +bank.hacktivism.ch { + header Alt-Svc "clear" + + # Public landing first + redir / /intro/ 302 + + handle /intro* { + reverse_proxy 127.0.0.1:9013 { + import proxy_public + } + } + + # Static terms/privacy on landing nginx :9013 + handle /terms* { + reverse_proxy 127.0.0.1:9013 { + import proxy_public + } + } + handle /privacy* { + reverse_proxy 127.0.0.1:9013 { + import proxy_public + } + } + + reverse_proxy 127.0.0.1:9012 { + import proxy_public + } +} + +castopod.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9020 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + } +} + +bonfire.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9021 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + flush_interval -1 + } +} + +prime.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9022 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + flush_interval -1 + } +} + +bt.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9023 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + } +} + +# 9024 forgejo HTTP (SSH :9200 host-direct, not via Caddy) +git.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9024 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + flush_interval -1 + transport http { + read_timeout 3600s + write_timeout 3600s + } + } +} + + +# Taler Operations design previews (static nginx) +tops.ng1.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9090 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +tops.ng2.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9091 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +tops.ng3.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9092 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +http://taler.hacktivism.ch, http://exchange.hacktivism.ch, http://bank.hacktivism.ch, http://castopod.hacktivism.ch, http://bonfire.hacktivism.ch, http://prime.hacktivism.ch, http://bt.hacktivism.ch, http://git.hacktivism.ch, http://tops.ng1.hacktivism.ch, http://tops.ng2.hacktivism.ch, http://tops.ng3.hacktivism.ch { + handle /.well-known/acme-challenge/* { + root * /var/www/acme + file_server + } + handle { + redir https://{host}{uri} permanent + } +} diff --git a/configs/caddy/Caddyfile.taler-host b/configs/caddy/Caddyfile.taler-host new file mode 100644 index 0000000..be292f6 --- /dev/null +++ b/configs/caddy/Caddyfile.taler-host @@ -0,0 +1,24 @@ +# LEGACY — historical Taler-only Caddy sketch (merchant backend :8081). +# Do NOT use on koopa. Current multi-vhost config: +# configs/caddy/Caddyfile (canonical) +# host/caddy/Caddyfile (same content) +# +# Kept only as archaeology of the early taler.hacktivism.ch TLS layout. + +# Multi-domain reverse proxy (TLS terminate on host) +# Backend at the time: container nginx TLS on 8081 + +{ +# no global auto-HTTPS email yet; per-site manual certs +auto_https disable_redirects +} + +taler.hacktivism.ch { +tls /etc/caddy/certs/taler.hacktivism.ch/fullchain.pem /etc/caddy/certs/taler.hacktivism.ch/privkey.pem + +reverse_proxy https://127.0.0.1:8081 { +transport http { +tls_insecure_skip_verify +} +} +} diff --git a/configs/caddy/README.md b/configs/caddy/README.md new file mode 100644 index 0000000..432d73c --- /dev/null +++ b/configs/caddy/README.md @@ -0,0 +1,9 @@ +# Caddy config mirrors + +| File | Role | +|------|------| +| **`Caddyfile`** | Multi-vhost config mirror (`/etc/caddy/Caddyfile` on host) | +| `git.hacktivism.ch.caddy` | Site-block snippet for Forgejo | +| `Caddyfile.taler-host` | **Legacy** Taler-only sketch — do not deploy | + +Also: `host/caddy/Caddyfile` (same as `Caddyfile` here). diff --git a/configs/caddy/git.hacktivism.ch.caddy b/configs/caddy/git.hacktivism.ch.caddy new file mode 100644 index 0000000..3addce5 --- /dev/null +++ b/configs/caddy/git.hacktivism.ch.caddy @@ -0,0 +1,15 @@ +# 9024 forgejo HTTP (SSH :9200 host-direct, not via Caddy) +# Live: merged into /etc/caddy/Caddyfile (see also full Caddyfile mirror) +git.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9024 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + flush_interval -1 + transport http { + read_timeout 3600s + write_timeout 3600s + } + } +} diff --git a/configs/castopod/README.md b/configs/castopod/README.md new file mode 100644 index 0000000..f10213f --- /dev/null +++ b/configs/castopod/README.md @@ -0,0 +1,21 @@ +# Castopod — castopod.hacktivism.ch + +| Item | Value | +|------|--------| +| Live | `/home/hernani/koopa-castopod/` | +| Compose | `compose.yml` (mirror, no secrets) | +| Port | **9020** → Caddy | +| Site | https://castopod.hacktivism.ch/ | + +Secrets: `koopa-admin-secrets/…/koopa-castopod/` and host `~/koopa-castopod/{.env,users.env}`. + +```bash +cd ~/koopa-castopod && set -a && source .env && set +a && podman-compose up -d +scripts/castopod/install-systemd.sh +scripts/castopod/apply-branding.sh +``` + +Daylogs: `2026/2026-07-09--castopod-content.md` (stack/accounts), +`2026/2026-07-13--castopod-boot-branding.md` (boot/branding). + +Podcast content is **not** versioned in admin-log. diff --git a/configs/castopod/assets/css/theme-hacktivism-overlay.css b/configs/castopod/assets/css/theme-hacktivism-overlay.css new file mode 100644 index 0000000..3e62cd6 --- /dev/null +++ b/configs/castopod/assets/css/theme-hacktivism-overlay.css @@ -0,0 +1,64 @@ +/* + * Optional warm-dark overlay for castopod.hacktivism.ch + * NOT an official Castopod theme — Castopod only documents 6 accent colors + * (pine, crimson, lake, amber, jacaranda, onyx). See: + * https://docs.castopod.org/main/en/user-guide/instance/settings/ + * + * Palette matches git.hacktivism.ch / bonfire (exchange-dark): + * body #1a1410, cream text, gold accent #e8a838, teal #3ecfbf. + * Prefer official theme "amber" first; this CSS deepens the page chrome. + */ + +:root, +.theme-amber, +.theme-pine, +.theme-onyx { + --color-accent-base: 38 78% 56% !important; /* ~#e8a838 */ + --color-accent-hover: 38 72% 48% !important; + --color-accent-muted: 38 60% 32% !important; + --color-accent-contrast: 28 22% 8% !important; + --color-heading-foreground: 40 80% 75% !important; + --color-heading-background: 28 22% 14% !important; + --color-background-elevated: 28 18% 14% !important; + --color-background-base: 28 22% 8% !important; + --color-background-navigation: 28 20% 10% !important; + --color-background-header: 28 22% 9% !important; + --color-background-highlight: 28 18% 16% !important; + --color-background-backdrop: 0 0% 0% !important; + --color-border-subtle: 32 18% 28% !important; + --color-border-contrast: 40 50% 70% !important; + --color-border-navigation: 32 18% 28% !important; + --color-text-base: 36 80% 95% !important; /* ~#fff6e8 */ + --color-text-muted: 32 20% 72% !important; +} + +body { + background-color: #1a1410 !important; + background-image: + radial-gradient(ellipse 90% 55% at 50% 108%, rgba(26, 107, 110, 0.35) 0%, transparent 55%), + radial-gradient(circle 420px at 12% 18%, rgba(232, 168, 56, 0.22) 0%, transparent 62%), + linear-gradient(165deg, #2c1e14 0%, #1a1410 38%, #12181a 72%, #0e1c1e 100%) !important; + background-attachment: fixed !important; + color: #fff6e8 !important; +} + +a { + color: #f0c86a; +} +a:hover { + color: #5eead4; +} + +/* Primary-ish buttons / chips that use solid white on accent */ +.bg-white { + background-color: rgba(42, 32, 24, 0.92) !important; + color: #fff6e8 !important; + border: 1px solid rgba(232, 168, 56, 0.28); +} + +/* Footer */ +footer, +.page-footer { + border-top: 1px solid rgba(232, 168, 56, 0.22); + color: #c9b8a0 !important; +} diff --git a/configs/castopod/assets/img/favicon.svg b/configs/castopod/assets/img/favicon.svg new file mode 100644 index 0000000..e1d3dc3 --- /dev/null +++ b/configs/castopod/assets/img/favicon.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/castopod/assets/img/logo.png b/configs/castopod/assets/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..03e96c28f7cbd8307bcffcad0456e2ee3c497bb9 GIT binary patch literal 98601 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≺Fg5FEI6*GS3JIpz}S^FWX312^i@e}Wwi1G8& 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/castopod/compose.yml b/configs/castopod/compose.yml new file mode 100644 index 0000000..af26d1f --- /dev/null +++ b/configs/castopod/compose.yml @@ -0,0 +1,81 @@ +# koopa-castopod — Castopod behind host Caddy on :9020 +# Docs: https://docs.castopod.org/main/en/getting-started/docker/ +services: + castopod: + image: docker.io/castopod/castopod:1 + container_name: koopa-castopod + volumes: + - castopod-media:/app/public/media + # optional branding host dir (logo + CSS overlay); safe if empty + - ./branding:/branding:ro + environment: + MYSQL_DATABASE: castopod + MYSQL_USER: castopod + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + CP_BASEURL: ${CP_BASEURL} + CP_ANALYTICS_SALT: ${CP_ANALYTICS_SALT} + CP_CACHE_HANDLER: redis + CP_REDIS_HOST: redis + CP_REDIS_PASSWORD: ${MYSQL_PASSWORD} + networks: + - castopod-app + - castopod-db + ports: + - "9020:8080" + labels: + org.hacktivism.service: castopod + org.hacktivism.host_port: "9020" + org.hacktivism.site: castopod.hacktivism.ch + org.hacktivism.managed_by: koopa-admin + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_started + + mariadb: + image: docker.io/library/mariadb:11 + container_name: koopa-castopod-mariadb + networks: + - castopod-db + volumes: + - castopod-db:/var/lib/mysql + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: castopod + MYSQL_USER: castopod + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + restart: unless-stopped + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + start_period: 20s + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: docker.io/library/redis:7-alpine + container_name: koopa-castopod-redis + command: --requirepass ${MYSQL_PASSWORD} + volumes: + - castopod-cache:/data + networks: + - castopod-app + restart: unless-stopped + +volumes: + castopod-media: + castopod-db: + castopod-cache: + +networks: + castopod-app: + castopod-db: + internal: true diff --git a/configs/castopod/container-koopa-castopod-mariadb.service b/configs/castopod/container-koopa-castopod-mariadb.service new file mode 100644 index 0000000..c3c2324 --- /dev/null +++ b/configs/castopod/container-koopa-castopod-mariadb.service @@ -0,0 +1,21 @@ +# user systemd — MariaDB for Castopod +[Unit] +Description=Castopod MariaDB (koopa-castopod-mariadb) +Wants=network-online.target +After=network-online.target +RequiresMountsFor=%t/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=on-failure +RestartSec=15 +TimeoutStartSec=300 +TimeoutStopSec=70 +WorkingDirectory=/home/hernani/koopa-castopod +ExecStart=/usr/bin/podman-compose up -d mariadb +ExecStop=/usr/bin/podman stop -t 30 koopa-castopod-mariadb +Type=oneshot +RemainAfterExit=yes + +[Install] +WantedBy=default.target diff --git a/configs/castopod/container-koopa-castopod-redis.service b/configs/castopod/container-koopa-castopod-redis.service new file mode 100644 index 0000000..0923000 --- /dev/null +++ b/configs/castopod/container-koopa-castopod-redis.service @@ -0,0 +1,21 @@ +# user systemd — Redis for Castopod +[Unit] +Description=Castopod Redis (koopa-castopod-redis) +Wants=network-online.target +After=network-online.target +RequiresMountsFor=%t/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=on-failure +RestartSec=15 +TimeoutStartSec=180 +TimeoutStopSec=40 +WorkingDirectory=/home/hernani/koopa-castopod +ExecStart=/usr/bin/podman-compose up -d redis +ExecStop=/usr/bin/podman stop -t 15 koopa-castopod-redis +Type=oneshot +RemainAfterExit=yes + +[Install] +WantedBy=default.target diff --git a/configs/castopod/container-koopa-castopod.service b/configs/castopod/container-koopa-castopod.service new file mode 100644 index 0000000..5f28649 --- /dev/null +++ b/configs/castopod/container-koopa-castopod.service @@ -0,0 +1,21 @@ +# user systemd — Castopod web (depends on MariaDB + Redis) +[Unit] +Description=Castopod web (koopa-castopod) +Wants=network-online.target container-koopa-castopod-mariadb.service container-koopa-castopod-redis.service +After=network-online.target container-koopa-castopod-mariadb.service container-koopa-castopod-redis.service +RequiresMountsFor=%t/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=on-failure +RestartSec=20 +TimeoutStartSec=300 +TimeoutStopSec=120 +WorkingDirectory=/home/hernani/koopa-castopod +ExecStart=/usr/bin/podman-compose up -d castopod +ExecStop=/usr/bin/podman stop -t 30 koopa-castopod +Type=oneshot +RemainAfterExit=yes + +[Install] +WantedBy=default.target diff --git a/configs/exchange-landing/README.md b/configs/exchange-landing/README.md new file mode 100644 index 0000000..18357b2 --- /dev/null +++ b/configs/exchange-landing/README.md @@ -0,0 +1,23 @@ +# Exchange landing (`exchange.hacktivism.ch` intro) + +Static intro (stats + links) for the **GOA exchange**. Withdraw QR removed (was wrong). + +| Item | Value | +|------|--------| +| Container | `taler-hacktivism-exchange-ansible` | +| In-container path | `/var/www/exchange-landing/` | +| nginx | listen **9014** (`nginx-landing.conf`) | +| **Host port to publish / wire** | **`9014`** → `127.0.0.1:9014` | +| Public path (once Caddy wired) | e.g. `https://exchange.hacktivism.ch/intro/` *or* dedicated intro host | +| Link previews | Open Graph / Twitter meta → `og-goa-shop.png` (1200×630 GOA shop grid) | + +## Deploy + +```bash +# from laptop → koopa +scp -r configs/exchange-landing/* koopa:/tmp/exchange-landing/ +# on koopa: install nginx if missing, copy files, enable site, publish port +# ensure og-goa-shop.png is under /var/www/exchange-landing/ (served as /intro/og-goa-shop.png) +``` + +See `scripts/taler-landing/deploy-landings.sh`. diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html new file mode 100644 index 0000000..b3f0ba9 --- /dev/null +++ b/configs/exchange-landing/index.html @@ -0,0 +1,603 @@ + + + + + + GOA Exchange + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ GOA · Exchange +

GOA Exchange

+

Explorational exchange — coins, denoms, one-step withdraw.

+
+ +
+

Coins

+

From exchange DB · coin lifecycle

+
+
Known coins
+
Live (remaining)
+
Spent / zero
+
Remaining amt
+
Withdraw ops
+
Withdraw amt
+
Refresh
+
Coin deposits
+
Batch deposits
+
Recoup
+
Refunds
+
History events
+
+ +
+ +
+

Denominations

+

Signed keys in DB · public ladder on /keys

+
+
Denom keys
+
Unique values
+
Withdrawable now
+
+
+

Value ladder

+
+
+
+ +
+

Reserves · wire

+

Backing money movement

+
+
Reserves
+
Wire in
+
Wire in amt
+
Wire out
+
+

Loading…

+ +
+ +
+

Performance

+

Live HTTP probes + container memory (RSS)

+
+
/keys
+
/config
+
Loadavg
+
Container
+
PostgreSQL
+
Taler procs
+
Nginx
+
+
+

Top processes (RSS)

+
+
+

Memory from /proc + cgroup inside podman container.

+
+ +
+ + +
+ +
+

Withdraw GOA

+

Use the bank landing or wallet to add the exchange and withdraw.

+ +
+ + +
+ + + + + + diff --git a/configs/exchange-landing/nginx-landing.conf b/configs/exchange-landing/nginx-landing.conf new file mode 100644 index 0000000..3c55d47 --- /dev/null +++ b/configs/exchange-landing/nginx-landing.conf @@ -0,0 +1,24 @@ +# Exchange landing — :9014 (behind Caddy — no port in Location) +server { + listen 9014; + listen [::]:9014; + server_name exchange.hacktivism.ch _; + root /var/www/exchange-landing; + index index.html; + + absolute_redirect off; + port_in_redirect off; + + location = / { + return 302 /intro/; + } + location = /intro { + return 302 /intro/; + } + location /intro/ { + alias /var/www/exchange-landing/; + } + location / { + try_files $uri $uri/ =404; + } +} diff --git a/configs/exchange-landing/og-goa-shop.png b/configs/exchange-landing/og-goa-shop.png new file mode 100644 index 0000000000000000000000000000000000000000..aeaad870078fe2e89faa409718d3c3430aa112ba GIT binary patch literal 557173 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
7VAoh{5UoAiYM9&Ly$g4 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
I|8CJr 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

969O}CVy_Ydn!e9OM_wfAc8Q%NS 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

gzxgCdSd*5D& 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�kcEyv=#;$;UV{ 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%0DS6J_`m1kZ}5vhY>FQ9$R+*-VyQ7DDc1-6y>*Q; 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

qHPVWR)mld=^&xi zZ8JzytkooGM%?N$KevSTpouY>t(~6B@+f1B&R9NhfGpGKEW=8d@v+@mL`0G_)x@oB zdOO<$X;d3StJPtC;SdJsrwK+Fq;i1UAkDCepcT*4i4}Bo9?V;7?E@PNqBtTm8m%?+ z^9u;mbqq`aX__L1Bv#4?u?{hF3NBe%yRbtT57Oh`U0J~^D$BisX8nzE`o(@ zFd&Kqnb9BxhgXl1B`Hak(QdUEBneVVRu3Nb!*8%&QSHpPkwTCpjse~4_faZl`M^<^ zb$3T&B}A`KMM`Jd zNsUFdyBu9wB}r3cB#>6nPZI`dhEy>^NPm6A+`=-8%gc^2m1Rywiy+N3bMwnsu>e}Q zwISym=<9(!Z48~oB_h*8TZ47!YOq4!qR$D0bV;{j;m*HITTm_9%Lfp`XGyit8}#Y6 zVp5&b>9mniE6;2ztU_m+cBf0+X``fa>vE8g*o?)M1FXg>Uw=vJ=HBAc3LqE^`m{Rp z9GRQL_!xy+dv6daTpU0xNixIgfg?zzFxGhBs!)<$7z0u0BJc2|X`T-vuVJA(gwLyF zg+iArzK8wQUP4uXUOPCe5Pj?t8e6DMKWUzI|_Dhi0$tG%T^vSk7+B zC>*1%OOMp^DX*R)ovn~QmSTv_Bb<5M_~ZrF+F`R~$fA%pPe2mOCZYUW%18FD=jDhx zmnXV0Rq(2)UkH}@U`hcL*4^^_xYt;7&2F9J`?dVDXzyfq#3DmS?ejhC5A}FkqXd{L zz+YB={8F^!6)Un1&Q_QG+VtF-kVvVVuw`A)a;-CGl}{6ta`8cgP+mB7g1(TJRHr@@ zp0hF=qw^q$gT%YIDHm%+Dkm5j>n#4lMh?i*7L#QdG?BLm8sn_u#z+P`DN4Hdds?SN zLL!a944l7%0fbZx5(659h>+4f!=x##NMN*PU@h86l<}5wB`~;*6%rh9VGXJkG0+)A zEntDAl|!`SdA8Pf=(LtGsRQ%`f;1V>YCEP}l4d@wv$OhH3|4D5=0c$℞q{+;*(i zWLjfUIMR#(V~FF3B<+KU(8eIFKnu{updy!=S_pwu`=-r~onNDh3}=BqLu~mh8L`Ie zMCC%Q_xppJ(C%5r@8zWoYBgMDaE|kjJ%$S+-iLIHb_)OEU;GRH^o#$2 zPIrM;tK+i5p%^5ZIC6@)vBtSXDWB-TXQ&cEsj&qByC3VJnAf&g4>q{tmjEmw!8%5r zd%wuLFz#H zB+VQsCq##m))MkzatQ6X2q)AHb=yxQgtbsW@!tc}4T2v%GThqk|OA?J0 z7NbGY!ej;&#bnw6RXWiKBalX7GmBCJn`x|;pdnU@UeY5{uAcqG8H|GKzBM3~aLG7? z#R%gK0fsEg2;QZ}YEtdykBa=c;8}Dsk|bGz$TF;Pb6+Xx89FwXm#mOj1ie8@9JRe| zP5D@Gu08?WEoW&C^M6(lH=pk{*P~gcaPvD$YhhY{s|Q#{oppuRfkGQg6vu!iO;ePL zTx`#s9>y9X<79QWP1&5mV=QFGoiAx--PWNkO2wp^V<4qz;uu~=BDF$Fi*~7hvCZcBBu%{z;}S|_sm5C2fOKn6(#>fArDAlNkQj^dh>CNE zmR{yFg`RS2a-EFr4Peso*MwR^Zd zl?fq?*BZm%g|RXPh*-tbMjrgrj75F38kHt>RJR79zEZdR%M$Pze2*#}>aH1w+ExUw zaQ&FEtYokhjh)gAwu)k1lEiO^HJ^-y2f9O(dAYuH*`d`oJirG{g0O_n; zN=c{e3FQ?#i4evsR{}xg+;6NVw|5C|?efemS8baMNSQGxpW#dj$90I5!Wj3rQxx!E zjKmlX60Ff^VbI1WW)P7-^xUp5g~fW1)L56ATS_OySmRvlGVLthv5YZVV{PQp*$YFY zB*wdq7(Yp*lo)A{kt8!IgH-VRi?4Cw0#TXb4omqkfvm>N2w_Rm#Kq8( zP9T#q0QFu`3e(n_Fu}1F;y5NvQ*w7(FLY^bh*VsF{lcP6;`+!s1`nc$J2+|`?4^|F zSEFzuTtHGG{d^7JQ@&>KtGKYA3@@u%_SyVoDlr)YK(-%-RBOGUZau@xwMi+32npW> zXq_RHBvOh@r$`STg*@&8i!s_Wr0{M#ZqjS*Kph+0)~$2DQjsD{oM5h`1H(dkV5Nj( zDO!Uu(gC0r=kjhr#Sw{4{Ms{K0e5pFO%vy`r&<`TK}8rGo5}?zcWcIFb22WY6T&G} zkrVJ!qlqGyG1OS+x+aa=2Q1d5xfd2;6RZIxV*r$txLGLCQe$kE?_EZ_HV1Z0BqM^` z5XQ~vD9;WR;(vg~&~CT5bN?RyyZ`dP;_rRo*Z75>`z)u9A3-U}^=ohQAHMh{{>T66 z-?-R4okg-#I5rpsh-&>_=G?-x8$(+JC=CaoEyqpV%X%rMy>D}f`&eXeax=%4=64~) z4I4fDGi?Ee?KY_DsI?F&>48V(7;2u;V6+1(0msAx5Qa3pWLMkFZ2F8a2 zh$4l~9C)RqbPPx7)jVrCjVjDKweM+C*nJQLcpt%9^s22*wineOM<+x+eI z99Kckx7ZvA3Hozb=S_2|N#?IIy6njN>Yq1z2^FAP?#dVrsJb^(trVHyiYvk}SnJ%K zrQgqn0F(SiPc|ua!9+d1#9k{`H^^5(2MgUAz_Ws2QzIA*x1!!sg3?zVE1S1@Xe>na zIwzp&FNH3_3cY9*$^MmJS?}V)G5OVd9jO#bWOeW4HVv_eRES zF6BNbvEqVIo->f@EWLa`E4Qua)3D(;ZPtzT$os^jw5qy+hC0@Tr}DGHLTU0$FDoHJ zmC^iy@LmAG#os3H50NtkDsz&_0M%ZVnczAgrHgMOJP>INl(XAeQ(hyE${pAi#?Q4d zt^u;J7KD=IegqaU0nU&)Fa}_SK?{S84QZA*Fb$AVgv}hXjy6tE0Ea+$zr&)@XydGq zNCBB+fGOdvUOGc6i#6H>=(Q0-O0vv>4w#fkSQ1|cDIzdVcp$av z_!ob`#{D(!-CbjDet~{604WhlkeCc54N)t?8kx^=WAW+2%O*JAu|c5~f=s*mSYbRc zB?~~u$Lz~NXcgl2h#V9P)~u29evd^o?9HVwu_6GirR)8{9M+6Mc$btBYbRJaL=fum z@qJ3JC^ZAfuG^CjL~ZSx0IEv_2B8XzuVcAc@8J~r@o>VJ%~>?TU7-X(Tj}SP0yI`z z2TEGkzMLH^1X%5@v`%S52Jpx!4_apqZ1wC6AwlcZfngD7>s%bAQV6M>t8T0u%h9hH ztV>4>V5L(`3>j9qxs_P$m@J<4;`-dh^8pJcWOi~a80p!VS*DRnqO%OyBH#ax9W5&8DI!;CxqXtL@p<~dFLX))1?LnIsUN+3)6GaC?fRM0*x*WRwTJ-FRv1;^)Z#cne5%hG<1tQcR|4 zb-E0)6ypIvLlG-2xCj+VT5RQaC1t)ffcATe$W?Ub+8YMXJ>3DdS-~h2t&&VC+;gM= z>x>{m%KYpI#zY(5WptU^VK;LNmSUH1&^7~ij-Fv~3nSpov4tuQhz%Ez?DH+B_SR_)oSa;S+l!)^H z`&NNf4&=%bO-DsoG?~s&N};?A_)HuF0VU{#6@ zKo$AfP}JGYBiF5Umcev$E?*CIu%>ojU1w_yffEcTqP%)-?Z6m*Kj33LmY!8gkQIy- zQYnnqo?Tj+`{Den06nAI;I0z6vAj~By#Cd86%8yf3RCfA7PX(F>f%qt5v-#8{S_Rb zdVc^~#!bySLB{Tr3@{4&$lsf*AVHuSW&<|$M=nPd;#L0LbanRnDpp|69G7K5|!MUk`0IZCx- z2S|&L%i`QQkk(tkt+#k9@0MsiX)k~u7Of!?5mH2GqrG6`-e;{lG?lkJ8*7k0oO7CF z+`4{;*I&6vx6@^5c^StqaT&!Vf=I>~Gw?u0oVyj~EKnOjiEvO>8A$?XaW7aP0r(8_ zy`=nxeii^;sjKw+gDuVOfThE!zOUKhN8Vx^gpL9{D1z` zqyiPvsTdb4O97eI2&0H)jLkGkf)JK0$&f*j!2w<>a%?fH6jDiq1{K8^o%(epau*2K zPUWlXTsySQ+{}zU7_VYa@pkTq|tt@zmppIjWWqENGv!JviM?sVu2ip>polgfWQwwU;Ln7iz`td1#e=z?f}ph_j@#(W0V{Dl$Gtn-eC6`D zkVqC5S7^s`WPT1}C2s=M85 zgJM=bGHmVdd;ms>Y~`KSS0)FqioS0!%szm!EBB%iZ5qBJw{9WASz~3XvqWh;!p8yV z6j#;i3xVb$&JX{!v@(-G; zYOVK%C_+W#-;H_pxW9K>_QT*iE(Uf5Xd2qgVD$Vf6EuqJf{ZjBNoR~6+(6u@d|#^8 z(0Dcga--fgfq^9iRW!N&uHkt&+a@l?elva2-iBi8hh`yk2_v1WABuu)SJdC(FwSkt zS_eug0Y+;}x6y@9?~=^sIMBSSt(G^-?J0rO!tB0p{nUcfHCPi2+*`6Ar*> zOWj&lFa}F@7YgQxEZt%#;riHo9@IZ}^GAE|$GN~F6j_oY zP(+anSZTBaxgwOSVNjBNwnZglAGBVRW&_9O^RcotICnxM3Jcnzq_eoYc1nbm1rbR2 zJwsTmFpfniTz0E|k`XCq;rF{>sa;jevWE$)%|-ZlWHDN&WJVLW6pIVX;F&2}8y}cG z^D((JMr-GqY(w_6B9Ta#i$OUbKPUF1gcu*?_>;CGfQIwj_38LbY~dWVGoAj^6fyqq zMxbQW;w`l`S02k&TfcsV@R`-{)7~*M=H`}JT3E#x7k?{BHF2wj(HW6yktUwyCp1}L zJt9$24}={iu9LU`*Qv+sCjxwx{V;nX)4cAYuj?u4EXurX(Lez$5U%#ZanOIv7d{15Z^8 z3~Q?RPT<^Hjj_Z^A*>`b22$f#;?l7gwRUS+;D`do7!Z~iflXWtP8lhT6r{S3P;L%q z+Mr|`Yc0wOwC*F7bMtoak?{EeeaufGFc>21LS{G_*CK3&P}bK&<}5+S7<4ZD&akM- zv)4ClM*%<%^V%68n{>y$O@;>+Ybp${m>wJ&|Ge7#s2;IHWfI`Bmon*o<6Y{@M1cnl zJdf2Pp=)XbKgxBmT#P8eJ)%75j=7-1pMfb- zex4i!oCLwM2^6Zms-f&*|L=s}-Z9BFi$g))m*%{M@b7FG!E$CQVQdG`lU6 zg4m1vqVfU`7V{9)Yxw;Z>)24<8YfEa5!R)N#t4jb7CRdRb#M7q)`iX@SihYO9V-Qy z5oBI@AhLkQDveYzsSqGq2qY1P2UX+RnmoZDI#3_J5mkQ6=^i&K)C6AQQu zg+cKx6dkaX8AJ7Pb@x~?{knQE#koBOwvN|ReG|gIHO9i&0O(sYdVTMu*nGbVia-#k zgG)}xa8kDHhB!^-gdj4nx^sUB?tpb;L{RKaWzelKr9VZ^W^ojHzXu1GEF?xqTCsvG zLzs+CJH}Y!g2N*~#aQXGVhSKu4s_Bev~=%IjV84zaVv5`;f*0O3L_OlMMRl}EcF?j z0>G|O)>QUx3uKuliv4FwxY#>3C01aw)UhXXd3xM_+;HCG++tj;IY~CqMV33iCIqod zjvx$53a1E#k^6R)MwHV_!wRWWh`+VyW|)cr1e<{ zZANAsJ4*-fR9dWbF1N;HXfJz7D{}eK_$NTqrF*Mb5%C#Bh z`84L`+DS(-mf>e$kCxoaa|hJ&bpnXVQWy73DT`DICNZ?yU9ifVQJid%>4ZqRJ(8Fp=1L>0pGr#S zYpD@|HPGzN)>Nbp_2 z%}Er?Py(3MSa)lg%}!DU#45haRQthMUS+P}kA6)(dBQ$hgvOS#GF}#%tC>9m_*F0r z0;{5?(C6TRv)yTnQ%a;n$cV&f`iTpU-cd1e+(inD)(Mbeg{5awY-^tF)Pm8>tBAJD zP{IXrkKz_4F)lEzF^DK8iv$}}0C@Y*hA$TU0z)D4;Gqe?SBS~bm zW4l;fToe(}%Pa$9(Y=g?xrj)oC_yQ9h4HQ8U; zS+GV1{q*ouN&PMAB}UiK23A#h|1B|Or+xoS#ReB0A3&Ypq7z~wdLSdGpNir>SBZM# zeHjOSgm+Z`Sz$f1$%Co&b3|C52}&ZP76S{3O&#MTNrwPOdw3V|LBh=dCyOcb-N4RcuL;x<|*E)JEaXFH|Ddhp2oizAt53AIwt z^TD$_GIoK%_W-yXtGrv2bBh5Tzt1WkXxnJVT$IuqdUADV)Fi0Sti8>t z(qnD?gM7~Te>q}N80%iQSd}zEoWNa)^l(8bOjKB#8rYJ8{cr9FiDihBLd)(j+IcrWQ34B!eQs}HZvLv; z2V#V=<=tXpj4HFAGt=n+4R4}qOexD;WwTHDb7xeMB61)rnpF2NQeavUJ0_zy=o7_? zmT1#bpduejGt*clF|8H@Ye}sl86+qnSZudYHbtVrD9}Dx3>Fi|Y$knTg^Oe5sIX+TS$5)Lu8tTxJ{I4lqeX1MGIZHgxs?($dn#{vj`WzF&IUJcKucc zuV`8syOS5*$@y53dG;k!&GhYfN833@H=7g}Z(rQ~W@UTsQ=4~FWDe>=yWK_zL6Rh7 zS(dXEA9R@%FuviAO1nq87Ve4pSKVjx-^!tTYVu6f^pdiBNN}B+)EV^&9Dd4@l3y-z zhIwX6Dvt=MfG2dd5M`E+vvlMEN~h@LS!i#g`lEVpc~VxT)lqcBJiTQNK$_@BTDG<_ zBBMe0e<;4ic+e_$_X$FGPET`(lE@hi!*!ZwbDPH9beg_zdQRS^2K2KWguSVbS(cKe zX_cW?X;%{h%2;%Ae`vNrXq;iwE5$t-E-F803x-e#<||!{9zw!4le-2v#)0v%>|Eeb z+io#Xl6!-M*1-d;oHz~EuyOS&>sPLFa`^ylr9k&kN|H*;rn1CGj?-?p+1^~^&W$S^ zY{$&SZEP>2C0%SEA!2%2#=?n{taKN6<8OY*(bWY^w&TS>i82DAC7A+^C9+QW-jNd1 z>JUZqWJyZL^buKx5zv#CbyDJ!Cpd8OEIT_}y#2=gbchJ&qU$|z>hyI^%{++ReAWhGqQ1{!?ouyXn|Y;Cf6{~ob&v9>ZHQO3h>zFI~=CL9OKXp2ya)C!gk zuCTMcO}gU){pN~Uu%eF}mr#s5Y9d8uv`e%g-6*6cb-knlSZZB7J6tADaLk%Wh4HKc z0fE(4orY#aJ@!z_RP3^RKuHy~wxXv@v%iacKJH%$fQ;~LTU$Q+0=XgJw1m^pG9qrZ$kG8yNMaO`YO!S{TPotfqmMGT z&_yZBtxK0k@7!fcq_m_($`)EiYz!<~My#HHn7AFGqm(;uU1jdx8Y`VHQV6ot#S3by zHrxFk^Cu3ma(I=SufBmAcvFX!DC63tMTQZM;V7}RBgwYatdn4moZ#4*vn1EAuzuw# zb5apPNjn$u5ErarRftj_LJbV0G_SqdU$!z<1nra!THx4oeq6LQm`u}dDO9V&x*lMcmYF|xhGVNMWa)tW zm)>II)(uvapp69?LEK`^z?OPOES)*d z_QnQgJt49YLPVbU;N%*qT(S`vN%}&vDJ}7t(;PYf5PGo1jTe8+;{E$9WCkmJMnR)} zsENqWMU%&Evz2}b04rWrD&)z0ceJJJ@-udv9Q*E;atIx_6omuvZP1}+pX7zX{K2^A z?M37q=QT6SHM@PUkPmj#7>5~*YU5B)dNE8Asq5O+_*l*Tv>OklAon$zT}R z*OT5=?~_^DQU0WE?ryri5(7*4)b)juPC@G`Tdc=)SU?x6LC%WcjMK1anmRMLR!-ST zqrRWSAZz-5YPxX^q#0emQC7~~m05hBr|Gji)07+@r;32o&@D}iV!vWtjjBr{yJ|%I zt=$7-g%V${OK_DPBGOQFQD8U#LsZUpt>l7iK())RL_xTqr0oPz9keW17}Cd7Q$Cgs z7DS}Dn`)w!RX+RoKFi@VXBZ$jc<2x z2umglbNQL?aQ&HYv$W7fbYs?~>}jYh{s9C?9dxcV#;K@T?HEzbS;W8C-E6RB6hpLM}W7u(wq0 z+#RBHhb>a12Y0#98)1%wXYV<9yAYMz09!k z*u@3+fukv+QRtN z0>fHXD_OY3v?25sY@*5Y{P~BLgW*m#8BHrG#V3<}!r|&TTFby0KWB zp;U}Spp0cB)!3uQ__Z(mCI?O&#wdf7mR2Nr^@SIB{;Pk1y}yPLf~_P$9zMb+f9aPw zcK#fy+h!}`x0%Da z4(5?DM9F1ZSo*+Lr^We?Kh3G9K0vqA;o7rs@y1PTmQn4nf{9!~2`k@?0+Zn)q!2=`y*bnmrbO` z5SY}TLu2NjF%+#fVoKW_Uo_lmw)mnn4K*atU#n16-#!~9-G4Q zo4@xvTzcy=fBT1j!lD%j6XIL>@DNrag(L%d3-dho>7V7{kA9q3{=0uk z|NaJXG9XqUl_b+HPMws&E2e;*1fe1$hyBxjcaLxDLD58Den>k1;F|s zeU9E4#4?k+or&PPU7H;BLo`oA8H73SssJt~L(}4G3ZSjRLh*-i*#GN5Sx}e+I2VNq zP0n2_tt{SQsk~=sP;29n*=!;kstAZ)oeTyq)J{NG>-SBw5jEYDGvI9P9pMnMqc@cz z@VOplk_o&!M|E?`>**D7Z42sgLq&Zumgxb)rv9k*S%`f#S_v@PR9rWP=G{<( zgiwOMu?!ZM_~aLUn^Px_@bxeKDZShGIC%05=RWgUKJsgSpDRCpo$YIv$XZ|y9^+U3 z$$yQC1mFDfpJK9%GY>z?`Oo|ugVm$__`m-jShNBy1=>QUGv-blgHy-2-Htf)=wrNk z^A<})D4h_+hF&%xBLXYf(H3>$6c;}CImGfZYda}x3k%%Ib`Vh?r4)B}1|0h2&+*i+ z{}$_Sy}`48{$;vzU4G^>Kg*~8!SC@G|G$6B!tLus7&4tAWQ?^6gYj%Mn}b&ZnQzFZ zrmzf+Sztv7S?Skzt5oN?A*7ejutdUfiRvDcWd>u|mvi8&wC4!i0dB0t3I!tbhm-Mk zlF62vQFB?A1&uPBszTqdDTB5YgRWsMjop962$oL`*m6f-U@JNWMGC=Yn$dpj2|oLu z{X_b;&o{sPWj5Zr#{8MHeEjc!fsg*iZ}O#UH`&?Th9sl=$OS&}TfdFyZ}Xi${bSbO zzRD9H`vi~t>@Pqx&-4HDzoy+1Bs!rZ1j$aqu@8QN8wZwXJ@yE#?;k_IeVchLP$DJ~ znp6$|ixLq+$B3*&OBwEOuXE&cpWwvL{VewnF0(T~hgQb17%h2hHs>y0MMA1srb4f{ zG#4=fLIy3IqAq3Rf2R_u7Ac}D=&6zMI zq@M@QdB=Kr#e-Q0jDZO2fyLbeT6spsT9??yYr~fXGiPM%y?n2#_jP!m3*s)M#n=py z$0T%&Bgb|s6sA0oEtbqc+F9TOzx-=-j~(Or?|zkQul|J9iB%fB%R1z;FHozWcxY&uqW^JV)9~q?u%6 z(4%|eF!G^uygfI^;U}KpBcZUFMyeF6VB2cWefH-$@}Up2b?0p^ ze(xFXzwk2i+7>KZWJN|Xn4vv=Xly709DXEfIFuuIkBEx}C6<0zP=1~^W{&hf_rZnG zF}gA*!^zT$u=y16|CziGf^3|}#ie8v4C~6e5CMJ`){GN-E4u-A#M5VE@p9^-c z`?I`PmbXpE@a|=X?&-69_ILjw{jE*@>WhEG*7i0>&YkDjhd;t&zxkW|<(13mjSU7e zVdd;Y{NnHbueo;fCg1;8{~cQ!>zsVwBRu`HpXc#k`XyfY;uo1uc4&JYZo5C=(A|%<2rPAUi7eq0G~}H!T0VbQ z0(XZQY@F<45k6@|LDI+p+%nKHZpxJOh-pH(40}I$kfQRE;(A$rd#;Zct(mSLXsioV zV-SaZ$68a`gPPhq-FxdgcM};VzeeD7vEew#Fdc~?KYvT-@Q^&1GwMv?ZI&DL94OO7 zY^k%ZYHuFV@1r})=5ef>_oZsMxJj(5Qm7ory%2=WrIyD2*gYuebjm#SmfsSs4Oxb9 zo*!XtNax*Rs#yr3*d!ckJ_}M;o?LdsHKRMf8O)l5Z0@GRhF$wcw`o$Hr+wdU0GdFx z-)(6E0(aY2m1n}xf(-Sa6cerTxBqWa_NBeHq4K^l^_^yn#<96f%^I1Lq@~D%9tS|r z*n+k5-m)W=n=lG#HQpi0c+t**2FinfS{b6K#a3^NLl-{8$)}#=M_>6fEB-q)s?< z>L}ev@V#fArF-NAD~FD;k)$lNViJ>LREx)+c!E~c;@Q9c78|cTPk(2JgWWD4`HkPA zb@&L$)wft^Nsx}_FJ*d}9PIZj7Jn^{@{iReJr# z+zV_k>wKJIHfBCQ@UFB)pl=}xAu|(_3m^Lk99rcs{`vol+kf)|4$mFr;+5<4b;|Gl z-tY5~&wQ5e|Jy&JCE#P9{VYio^Q}MrW7c2%5sRG`&wk}E*jYZp$3ON-ZhrTB+<)UI zEVUGAnvqz`2j2e_w{~`DS$wz5*v4DlcJI>?)JAi|A|j<{)12PqgP+$ z;&;DE_x^1T41zH%?PvKVT0a#rSV*H$!Agwx}`Yx)ce^H5#Rl*zvlMeeh-D=?Kj@w zt<5by_l4i);SW5;kFQ^2U<{x8`OmSoy~!W{;lCk%`x-4Nc<~$8iI!G4|MbVX`uvaB z`r&hQ=jJg|u!H36ho9!wAf-LO#KH3y*nIINI=BQiNt$4~3uLV>JH0hTC+6hI6Rh35 z!E4|C4y`+P*-5v#dihO0@Tt$Ua{2`J)i?b(N<;+4STF_^^oO8VmcZFC&IMy{`cdDF z_r@%8#&~8;Dd)WnJA8eeS;vAEhVk^)>FQQLJBQ?{Y1=jC-A(;9tu8i>E!G@HX7V1_ zSpO+p;KnoPnLN#M5KA=d%~dAlUO?1$GIrN)inrfY#%bSJbUDE(d=S#8IKZqh87iF6 zR8(lBiLVY`1&Kziwj|&S%TZP6$XXN``;O-{H!6_Ve z=-&O@G^$nsa%D$Q&79({skP~+t{8+h$o!{^L!+f3QHGw%$YMBr`Y4~%f-L=65B7oz=5RCQ6)+5-Xf9^wOfQ( zMpmi9dX!lrBT&|$tnnGYGzg6q*;GCHo|^|jCfo1lT1o%_KULbvevXkfhO8x7eE2w* zZoJ9OA3n#iR+~j*ISurl{WdTD*`KkxutYT1rL{1}p<~B*{<$Br@x~R-bQU>8izNd$ zU-}7VXNOadJVt6_I<`Za#Vj8?%dx{J7~EUu?%jKweDo2r7}6+X5M^w~iYGt+D}3}< ze+#+1%1+j!pKY?;g7csMENXR`yKlb@S`)W@hN<1hZ*T0+x*Ra>-@9EbjtLe3NndG% zlv7RPz!0yt*L@=@Ox*XjF#8}%A}m`{aQOTQ2L1b7dHF{iFp5>#W+iIVf9)c#{^_5g z?r+eUUm!J>3m^V4$*p_5@%3-9yfffj6mwb#*4}u9+i$XAdlD+k%umTl8xr>!{i z!KX-fHo5-Nk7=*2u>8=&WZJp0ZuWZ|c?CM=RcQV%&Zal8j7O#?Aj}jc~KbwX>DLvIx%}HfoP*kCd{| zE1DP@`@pCy%cMUw#Y$q)CKHnWLW`x7N4WL&HEv(N%(9gnS22gulzTsXo?AbBp7%Za zIA(DPedr+bCyw#vPhNn{dn~lu%xS~H%yR4ECDNAQ;JK5SNMR(bZ*LJDJ229DC?V(s&7NElNsKA|C(j=lG5P!~c=PPko4fl5z37-{Qr8`v=6=uXEVOC|Y!) z7Df+9vu%t`++vW@W!aSqBV1wzD~xALJD{gN!uvf=qsG2GFDCul?R$s!@hM#C!m+~S zbFZ|YuMaJ&k5mKQ1!iC zj`a%K{TwU5tC=A-^e)Cr7G*|TVe}9so&BAX#~RpbBbatGanwgq$3}HV<=#f*b$>dc z(y&Twj^Xz>i&2^8I9#V$>Qx@_S^A?~_i&i!pRqdtZ!cvQKjqy5jQMkCxFx5UmlWTM z0z9UEO|;RbYn#V!{3hE3u2o!Fb2nOp9dVGo7nHKy6~D@?aBz-MfWiue1+>XXwPj)T z5d9>hzj==ZQo6B340_15dwk{J{EvL=tA9$=R&*{6HAYoCov0DlARwC|i zaR16x4jw*&X}9PnTcpykeBlw6R}XUOTYt@+S6`vMvdH3*!&oWFv}L=uO&kf{|Gr0= zpX-wC^tt`kTP&)G11cgKB($ZslUsq6G2Kp^XTI`9Uj5cz(C%%~5|&IzmJT0An~cr% zHBb^QJXP8{wvi{-Rbw@XU_qG9r~N%wau=_%Q{QAue}zrU@G{K@WN>zxjoKz=ek&r?<6<5SoSKM>+k$5Au^A{D}Uwx0#RIB-*(_D7A}@nX=!08cO3? z5df{KfHcftu06wsxIZO+X`7c zw?RZ4dgxKID9S@<96G$pk>dxM>$bq8w3e4?ojk?Gd+T&gJ;WoQ{6$tSe289Wg$=FH z@8G;02kP#(=fL!(H1BPliC(RDlT2Rz-`aZpcJDs;QwLg=i_oN|j}`{HUE;Ym>E;?@ z(4(t>WWc4`Du(Q3%>fr;3 zwj$L75}k7F;m4RebetQ{KhNfyZ*ctKN9fHjV3Z&;eG)TZZZ6`$VvB`thi+?**MIl} z*57;`(eJalwS#GOSy?^A`rFq@*6z{jDCYvLln;zcXgZPS+lrx?l#8sl&u@{yxrXgPX0H>jMO#j&w4Ky z<7Rp5dr=;f)+GfDWGyK~-Gy*;DNml}m_WnVPtzggAZWWOL-$R=5DF72TwcUsm|*l7 zL!aBNANDB9=B%YT@Y)0yJUjg}^XE?CE)n{*sYsf#dew?|N6RhJCIo~o0a~T{H(4O2 z0k0;szS4?m+*xy`SGcoQ&!S!rCX2{BR|HsvL}HC}f~%*8OY2f5OKVY4mwrDZN%~~; zxxam%zHXs95iLD{?fdARb)wcOG?vYKx6z44t2R=#uv!wQ8Dg`C>dw>ZwCQi(K`yVb za_(X7-nmQX#tnJ`w%6ATZs{h6p5C?`8s5p zwvq^|kwTI56WU5K2QEmeg?mw#(;xX5>-X=oaq9*Pk&91bGJ`}ReQJ4wDFy#^F~nry zEjG67mz(T<53!3&Qg)9i)`Yb-2i?nxc~iUcD@v?V>0@LS0UG`F@1ZVe?3RZ8dX(^2 zk6TgSqgzt3++N`Idz)mbK(`eAq{kp#L$x!qETyd?Y&Ia8?;>@A*|>)>npA*@Bo<4$ zu}*)mjaptHTI!PxV`MfCH(MEl@zHm={ML&DDbI{*IP`~eT2 zKFggquCmmcM+?iNPkn&Chk`<_t!aa<{_@M+DM~m z3&AVj{}#9IU8l8nhn=Q%1%;5pv-?q8v@Z8|o? z#d-?H$5F{4-sK*C5a&yIo`-eObkRr`8%gjO6Xi(QbytO1s`S$eToo+S0vc0oIw>!tqt*hL;`!?2E;*~>WgOt`lBeu3tafg#P)isgsTu<`6gR%7TW%X44<5ZJV0oMCXLp>t39 zpu7!vKa{BF`vq>5`)Q~zM);e%>q}jl|Gxhl(WoWa6!frO+aUzJqDAmn4Wl!xj8HzSnvE3$X=$O5s>RBYBiy`x71i%!?%agi zZ*%O>L9Q(wnn$35Lt%8AeGCCXnp(|@W}~;3zcbvM9v_D?pO8EwlXaNp4?zi=8W1Kt>Gi-s1S_ zGhBc9HR430vW%a6|5;x9&bK+$?U1%5>i8<>pL&EBUitxp&2^SrZA99~*bHe6N`S#& zMg&LwJ6}ZM-ccH8>NB~1b~m1~b;gMY!J6F8|E?DcXl;SSgiI5xc1R zI~COX!4*j)x|n`SWMZUoDeU8J8`Dqdb-J|9pP{q7LPsZLH?MH->J!snnSajZ2na#v6!fR*3jOOYkG{bvzvXOY`C8`oXza-nfCfI%&ulN zcINt2&vj}zM%5Q#SU)cZq;HV>aww+xy+a1yk#*Z8S%&ed=V)?9xlWVcJIS~^bIFkl5kA{Tm=$?_0wTK4Rj7kbL0k`+BborJavzp6UDUtJ55y5{ zv1yAC!7*&QYePLlTn7udbWE;=g@F5B6mfS~j0_^I!TN~Zv5{m>9bquorXxYfHb%|y zzEA!vhd=a5(v=0o&K4KH_7_~a_ccey)dmquq!OT0l!8MNi^Sud^O3Q$w8f2mHkvcFW!`0OBa(0lMzde z5|%;g048bs96EIX4s^Nr+(r5~??N}?^&kBWPkrPwh{YB3)()-Ia&ln-l?{mWfO~qt zBOiW>v=j0AcmERB?xNF-42_W~snBFbKpf@6_tUQ|PCMAuF?Gs!!RD@KH7YX;-3K8< zX(8*_#@C3#VDukBw-)UNB_xA1B~uFGn5f<2*yE3L{ByrZcIW`Dg$_H<{VjL?%^!fZ z^tN_rwPS=bu#*uTqsKko62s0yKm<>Z;$GQelL-}GGjbEM(sHxbKL9_^eTRbA`4;T8%DlwcwZY% zjA2Lq(e(6c9UB4l%HPg7U9eroc^{Qow}pH=i^DV_7!4Spe1-LnetDu7k_OUf~TYOzp8 zHE5dHLgmkMW>V=ADTGh4=<7G;-J$NYn04~*3+L&i=Q9jeHTwStuszVHX%=W_IfTc# zt<v$^sf42nP1Le)@*70dzV=NOI ~L0HX}AQ#%0EW@T5eJMEb@Okbe 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 Vy;2o~7%Tt)002ovPDHLkV1j3=oB{v< literal 0 HcmV?d00001 diff --git a/configs/exchange-landing/qr-logo.png b/configs/exchange-landing/qr-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aeffa23d3ec9d66f1041cc50d28cd2037e0e4e56 GIT binary patch literal 8563 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 tCI$W<00960e6}m<00006Nkl \ No newline at end of file diff --git a/configs/firewalld/public-ports.md b/configs/firewalld/public-ports.md new file mode 100644 index 0000000..12ea2b2 --- /dev/null +++ b/configs/firewalld/public-ports.md @@ -0,0 +1,61 @@ +# firewalld zone public (eno1) + +## Current (2026-07-10, verified) + +Expected open ports after Forgejo git-SSH: + +``` +sudo firewall-cmd --list-ports +# … 80/tcp 443/tcp 8080/tcp 9000/tcp 9001/tcp 23235/tcp 9200/tcp +``` + +| Port | Purpose | LAN probe (2026-07-10) | +|------|---------|------------------------| +| 80/tcp | `http-proxy` → Caddy 9000 (IPv6 HTTP/ACME) | via Caddy path | +| **443/tcp** | `https-proxy` → Caddy 9001 (IPv6 HTTPS) | via Caddy path | +| 9000/tcp | Caddy HTTP (WAN:80 → 9000) | **OK** | +| 9001/tcp | Caddy HTTPS (WAN:443 → 9001) | **OK** | +| 8080/tcp | Tor OR | — | +| 23235/tcp | (legacy list; host SSH is :22, WAN via VeciGate 23235→22) | — | +| **9200/tcp** | **Forgejo git-SSH** (podman rootlessport) | **OK** after add+reload | + +### Not opened on purpose + +| Port | Note | +|------|------| +| 9020–9024 | rootless apps; public HTTP only via Caddy → `127.0.0.1:90xx` | +| 9024 | Forgejo HTTP internal to Caddy only | + +LAN `nc` to 9021/9024 → **connection refused** is expected without firewalld open (and not required). + +### Commands + +```bash +sudo firewall-cmd --permanent --add-port=9200/tcp +sudo firewall-cmd --reload # or systemctl restart firewalld +sudo firewall-cmd --list-ports +``` + +### Verify (from another host on LAN / WAN) + +```bash +nc -vz 192.168.100.95 9200 +nc -vz 212.51.151.254 9200 # hairpin / public A +ssh -p 9200 -T git@git.hacktivism.ch +# expect: Hi there, … authenticated … Forgejo does not provide shell access. +``` + +### Why 443 + +IPv4 HTTPS is DNAT’d to **9001**. IPv6 has **no DNAT** — clients hit host **:443** directly. + +### Why 9200 + +Forgejo advertises `SSH_PORT=9200`. Traffic hits **host:9200** (not Caddy). Without firewalld **9200/tcp**, clients get **connection refused** even while `ss` shows LISTEN and localhost works. + +## History + +| When | Change | +|------|--------| +| 2026-07-09 | 443/tcp re-added for IPv6 | +| 2026-07-10 | **9200/tcp** for Forgejo git-SSH (verified LAN + hairpin + SSH auth) | diff --git a/configs/forgejo/README.md b/configs/forgejo/README.md new file mode 100644 index 0000000..a5d9a6a --- /dev/null +++ b/configs/forgejo/README.md @@ -0,0 +1,60 @@ +# Forgejo (git.hacktivism.ch) + +Rootless stack on koopa: `~/koopa-forgejo/` (podman user hernani). +Site: https://git.hacktivism.ch — HTTP `:9024`, git-SSH `:9200`. + +## Branding theme: `hacktivism` (exchange-dark) + +Global default and intended look match **exchange.hacktivism.ch**: + +- Warm dark body (`#1a1410`), cream text (`#fff6e8`) +- Gold primary (`#e8a838` / `#f0c86a`), teal accents (`#3ecfbf`) +- Readable contrast on body, menus, code, forms, labels, messages + +### Files (this repo) + +| Path | Role | +|------|------| +| `assets/css/theme-hacktivism.css` | Full dark theme (based on forgejo-dark vars + exchange palette) | +| `assets/img/logo.svg` / `logo.png` / `favicon.svg` | Kamek-inspired logo (blue robe, flying on wand); not Nintendo art | +| `compose.yml` | Rootless compose + `FORGEJO__ui__DEFAULT_THEME=hacktivism` | +| `app.ini.example` | Config sketch | + +### Live paths on koopa + +``` +~/koopa-forgejo/data/custom/public/assets/css/theme-hacktivism.css +~/koopa-forgejo/data/custom/public/assets/img/{logo.svg,logo.png,favicon.svg} +~/koopa-forgejo/data/custom/conf/app.ini # [ui] DEFAULT_THEME = hacktivism +``` + +Compose also sets `FORGEJO__ui__DEFAULT_THEME=hacktivism`. + +### Deploy assets (do not wipe git data) + +```bash +install -m 644 configs/forgejo/assets/css/theme-hacktivism.css \ + ~/koopa-forgejo/data/custom/public/assets/css/ +install -m 644 configs/forgejo/assets/img/* \ + ~/koopa-forgejo/data/custom/public/assets/img/ +# optional mirror if present: +# cp same under data/gitea/custom/public/assets/... +``` + +**Never** remove `~/koopa-forgejo/data/git/` — repositories live there (`hernani/koopa-admin-log`, …). + +### Logged-in users + +`DEFAULT_THEME` only applies to new sessions / users without a preference. +Existing accounts store `user.theme` in Postgres. Force all users: + +```bash +podman exec koopa-forgejo-db psql -U forgejo -d forgejo \ + -c "UPDATE \"user\" SET theme = 'hacktivism';" +``` + +Or: Profile → Appearance → theme **hacktivism**, then hard-reload. + +### Registration + +Public signup is disabled (`DISABLE_REGISTRATION`, no registration button). diff --git a/configs/forgejo/app.ini.example b/configs/forgejo/app.ini.example new file mode 100644 index 0000000..4758efe --- /dev/null +++ b/configs/forgejo/app.ini.example @@ -0,0 +1,79 @@ +APP_NAME = hacktivism git +RUN_USER = git +RUN_MODE = prod +WORK_PATH = /var/lib/gitea + +[repository] +ROOT = /var/lib/gitea/git/repositories +ENABLE_PUSH_CREATE_USER = true +ENABLE_PUSH_CREATE_ORG = true +DEFAULT_PRIVATE = public + +[repository.local] +LOCAL_COPY_PATH = /tmp/gitea/local-repo + +[repository.upload] +TEMP_PATH = /tmp/gitea/uploads + +[server] +APP_DATA_PATH = /var/lib/gitea +SSH_DOMAIN = git.hacktivism.ch +HTTP_PORT = 3000 +ROOT_URL = https://git.hacktivism.ch/ +DISABLE_SSH = false +; In rootless gitea container only internal ssh server is supported +START_SSH_SERVER = true +SSH_PORT = 9200 +SSH_LISTEN_PORT = 2222 +BUILTIN_SSH_SERVER_USER = git +LFS_START_SERVER = +DOMAIN = git.hacktivism.ch +LANDING_PAGE = explore + +[database] +PATH = /var/lib/gitea/data/gitea.db +DB_TYPE = postgres +HOST = db:5432 +NAME = forgejo +USER = forgejo +PASSWD = @inline-secret@ + +[session] +PROVIDER_CONFIG = /var/lib/gitea/data/sessions +PROVIDER = memory + +[picture] +AVATAR_UPLOAD_PATH = /var/lib/gitea/data/avatars +REPOSITORY_AVATAR_UPLOAD_PATH = /var/lib/gitea/data/repo-avatars + +[attachment] +PATH = /var/lib/gitea/data/attachments + +[log] +ROOT_PATH = /var/lib/gitea/data/log +LEVEL = Info + +[security] +INSTALL_LOCK = true +SECRET_KEY = @inline-secret@ +REVERSE_PROXY_LIMIT = 1 +REVERSE_PROXY_TRUSTED_PROXIES = * +INTERNAL_TOKEN = @inline-secret@ + +[service] +DISABLE_REGISTRATION = true +REQUIRE_SIGNIN_VIEW = false +SHOW_REGISTRATION_BUTTON = false +DEFAULT_ALLOW_CREATE_ORGANIZATION = true +ALLOW_ONLY_EXTERNAL_REGISTRATION = false + +[lfs] +PATH = /var/lib/gitea/git/lfs + +[openid] +ENABLE_OPENID_SIGNUP = false +ENABLE_OPENID_SIGNIN = false + +[oauth2] +JWT_SECRET = @inline-secret@ + diff --git a/configs/forgejo/assets/css/theme-hacktivism.css b/configs/forgejo/assets/css/theme-hacktivism.css new file mode 100644 index 0000000..1e32fea --- /dev/null +++ b/configs/forgejo/assets/css/theme-hacktivism.css @@ -0,0 +1,275 @@ +.chroma .bp{color:#fabd2f}.chroma .c,.chroma .c1,.chroma .ch,.chroma .cm{color:#777e94}.chroma .cp{color:#8ec07c}.chroma .cpf{color:#649bc4}.chroma .cs{color:#9075cd}.chroma .dl{color:#649bc4}.chroma .gd{color:#fff;background-color:#5f3737}.chroma .ge{color:#ddee30}.chroma .gh{color:#ffaa10}.chroma .gi{color:#fff;background-color:#3a523a}.chroma .go{color:#777e94}.chroma .gp{color:#ebdbb2}.chroma .gr{color:#f43}.chroma .gs{color:#ebdbb2}.chroma .gt{color:#ff7540}.chroma .gu{color:#b8bb26}.chroma .il{color:#649bc4}.chroma .k{color:#ff7540}.chroma .kc{color:#649bc4}.chroma .kd{color:#ff7540}.chroma .kn{color:#ffaa10}.chroma .kp{color:#5f8700}.chroma .kr{color:#ff7540}.chroma .kt{color:#ff7b72}.chroma .m,.chroma .mb,.chroma .mf,.chroma .mh,.chroma .mi,.chroma .mo{color:#649bc4}.chroma .n{color:#c9d1d9}.chroma .na,.chroma .nb{color:#fabd2f}.chroma .nc{color:#ffaa10}.chroma .nd{color:#8ec07c}.chroma .ne{color:#ff7540}.chroma .nf,.chroma .ni{color:#fabd2f}.chroma .nl{color:#ff7540}.chroma .nn{color:#c9d1d9}.chroma .no{color:#649bc4}.chroma .nt{color:#ff7540}.chroma .nv{color:#ebdbb2}.chroma .nx{color:#b6bac5}.chroma .o{color:#ff7540}.chroma .ow{color:#5f8700}.chroma .p{color:#d2d4db}.chroma .s,.chroma .s1,.chroma .s2{color:#b8bb26}.chroma .sa{color:#ffaa10}.chroma .sb{color:#b8bb26}.chroma .sc{color:#ffaa10}.chroma .sd{color:#b8bb26}.chroma .se{color:#ff8540}.chroma .sh{color:#b8bb26}.chroma .si{color:#ffaa10}.chroma .sr{color:#9075cd}.chroma .ss{color:#ff8540}.chroma .sx{color:#ffaa10}.chroma .vc,.chroma .vg,.chroma .vi{color:#649bee}.chroma .w{color:#7f8699}.CodeMirror.cm-s-default .cm-property,.CodeMirror.cm-s-paper .cm-property{color:#a0cc75}.CodeMirror.cm-s-default .cm-header,.CodeMirror.cm-s-paper .cm-header{color:#9daccc}.CodeMirror.cm-s-default .cm-quote,.CodeMirror.cm-s-paper .cm-quote{color:#090}.CodeMirror.cm-s-default .cm-keyword,.CodeMirror.cm-s-paper .cm-keyword{color:#cc8a61}.CodeMirror.cm-s-default .cm-atom,.CodeMirror.cm-s-paper .cm-atom{color:#ef5e77}.CodeMirror.cm-s-default .cm-number,.CodeMirror.cm-s-paper .cm-number{color:#ff5656}.CodeMirror.cm-s-default .cm-def,.CodeMirror.cm-s-paper .cm-def{color:#e4e4e4}.CodeMirror.cm-s-default .cm-variable-2,.CodeMirror.cm-s-paper .cm-variable-2{color:#00bdbf}.CodeMirror.cm-s-default .cm-variable-3,.CodeMirror.cm-s-paper .cm-variable-3{color:#085}.CodeMirror.cm-s-default .cm-comment,.CodeMirror.cm-s-paper .cm-comment{color:#8e9ab3}.CodeMirror.cm-s-default .cm-string,.CodeMirror.cm-s-paper .cm-string{color:#a77272}.CodeMirror.cm-s-default .cm-string-2,.CodeMirror.cm-s-paper .cm-string-2{color:#f50}.CodeMirror.cm-s-default .cm-meta,.CodeMirror.cm-s-paper .cm-meta,.CodeMirror.cm-s-default .cm-qualifier,.CodeMirror.cm-s-paper .cm-qualifier{color:#ffb176}.CodeMirror.cm-s-default .cm-builtin,.CodeMirror.cm-s-paper .cm-builtin{color:#b7c951}.CodeMirror.cm-s-default .cm-bracket,.CodeMirror.cm-s-paper .cm-bracket{color:#997}.CodeMirror.cm-s-default .cm-tag,.CodeMirror.cm-s-paper .cm-tag{color:#f1d273}.CodeMirror.cm-s-default .cm-attribute,.CodeMirror.cm-s-paper .cm-attribute{color:#bfcc70}.CodeMirror.cm-s-default .cm-hr,.CodeMirror.cm-s-paper .cm-hr{color:#999}.CodeMirror.cm-s-default .cm-url,.CodeMirror.cm-s-paper .cm-url{color:#c5cfd0}.CodeMirror.cm-s-default .cm-link,.CodeMirror.cm-s-paper .cm-link{color:#d8c792}.CodeMirror.cm-s-default .cm-error,.CodeMirror.cm-s-paper .cm-error{color:#dbdbeb}.markup [src$="#gh-light-mode-only"],.markup [src$="#light-mode-only"],.markup [href$="#gh-light-mode-only"],.markup [href$="#light-mode-only"]{display:none}.markup [src$="#gh-dark-mode-only"],.markup [src$="#dark-mode-only"],.markup [href$="#gh-dark-mode-only"],.markup [href$="#dark-mode-only"]{display:unset}:root{--steel-900: #0e0c0a;--steel-850: #14110e;--steel-800: #1a1410;--steel-750: #221c16;--steel-700: #2a2018;--steel-650: #332820;--steel-600: #3d3128;--steel-550: #4a3c30;--steel-500: #5c4d3e;--steel-450: #6e5d4c;--steel-400: #84705c;--steel-350: #9a8570;--steel-300: #b09a84;--steel-250: #c9b8a0;--steel-200: #dccbb4;--steel-150: #ebe0d0;--steel-100: #fff6e8;--is-dark-theme: true;--color-primary: #e8a838;--color-primary-contrast: #1a1410;--color-primary-dark-1: #d4922a;--color-primary-dark-2: #c47a18;--color-primary-dark-3: #a86512;--color-primary-dark-4: #8a5210;--color-primary-dark-5: #6b400c;--color-primary-dark-6: #4d2e08;--color-primary-dark-7: #2e1b05;--color-primary-light-1: #f0c86a;--color-primary-light-2: #f5d68a;--color-primary-light-3: #f8e2a8;--color-primary-light-4: #fbecc4;--color-primary-light-5: #fdf3d8;--color-primary-light-6: #fef8e8;--color-primary-light-7: #fffdf5;--color-primary-alpha-10: #e8a83819;--color-primary-alpha-20: #e8a83833;--color-primary-alpha-30: #e8a8384b;--color-primary-alpha-40: #e8a83866;--color-primary-alpha-50: #e8a83880;--color-primary-alpha-60: #e8a83899;--color-primary-alpha-70: #e8a838b3;--color-primary-alpha-80: #e8a838cc;--color-primary-alpha-90: #e8a838e1;--color-primary-hover: var(--color-primary-dark-1);--color-primary-active: var(--color-primary-dark-3);--color-secondary: #3d3128;--color-secondary-dark-1: #4a3c30;--color-secondary-dark-2: #5c4d3e;--color-secondary-dark-3: #6e5d4c;--color-secondary-dark-4: #84705c;--color-secondary-dark-5: #9a8570;--color-secondary-dark-6: #b09a84;--color-secondary-dark-7: #c9b8a0;--color-secondary-dark-8: #dccbb4;--color-secondary-dark-9: #ebe0d0;--color-secondary-dark-10: #fff6e8;--color-secondary-dark-11: #fff6e8;--color-secondary-dark-12: #ffffff;--color-secondary-dark-13: #ffffff;--color-secondary-light-1: #332820;--color-secondary-light-2: #2a2018;--color-secondary-light-3: #221c16;--color-secondary-light-4: #1a1410;--color-secondary-alpha-10: #3d312819;--color-secondary-alpha-20: #3d312833;--color-secondary-alpha-30: #3d31284b;--color-secondary-alpha-40: #3d312866;--color-secondary-alpha-50: #3d312880;--color-secondary-alpha-60: #3d312899;--color-secondary-alpha-70: #3d3128b3;--color-secondary-alpha-80: #3d3128cc;--color-secondary-alpha-90: #3d3128e1;--color-secondary-hover: var(--color-secondary-dark-1);--color-secondary-active: var(--color-secondary-dark-2);--color-console-fg: #fff6e8;--color-console-fg-subtle: #c9b8a0;--color-console-bg: #14110e;--color-console-border: #3d3128;--color-console-hover-bg: #ffffff16;--color-console-active-bg: #454a57;--color-console-menu-bg: #383c47;--color-console-menu-border: #5c6374;--color-red: #b91c1c;--color-orange: #e8a838;--color-yellow: #f0d090;--color-olive: #91a313;--color-green: #15803d;--color-teal: #3ecfbf;--color-blue: #2563eb;--color-violet: #7c3aed;--color-purple: #9333ea;--color-pink: #db2777;--color-brown: #a47252;--color-grey: var(--steel-500);--color-black: #111827;--color-red-light: #dc2626;--color-orange-light: #f0c86a;--color-yellow-light: #f8e2a8;--color-olive-light: #839311;--color-green-light: #16a34a;--color-teal-light: #5eead4;--color-blue-light: #3b82f6;--color-violet-light: #8b5cf6;--color-purple-light: #a855f7;--color-pink-light: #ec4899;--color-brown-light: #94674a;--color-grey-light: var(--steel-300);--color-black-light: #1f2937;--color-red-dark-1: #a71919;--color-orange-dark-1: #d34f0b;--color-yellow-dark-1: #b67c04;--color-olive-dark-1: #839311;--color-green-dark-1: #137337;--color-teal-dark-1: #0c857a;--color-blue-dark-1: #1554e0;--color-violet-dark-1: #6a1feb;--color-purple-dark-1: #8519e7;--color-pink-dark-1: #c7216b;--color-brown-dark-1: #94674a;--color-black-dark-1: #0f1623;--color-red-dark-2: #941616;--color-orange-dark-2: #bb460a;--color-yellow-dark-2: #ca8a04;--color-olive-dark-2: #91a313;--color-green-dark-2: #15803d;--color-teal-dark-2: #0a766d;--color-blue-dark-2: #2563eb;--color-violet-dark-2: #5c14d8;--color-purple-dark-2: #7c3aed;--color-pink-dark-2: #b11d5f;--color-brown-dark-2: #a47252;--color-black-dark-2: #111827;--color-ansi-black: #1d2328;--color-ansi-red: #cc4848;--color-ansi-green: #87ab63;--color-ansi-yellow: #cc9903;--color-ansi-blue: #3a8ac6;--color-ansi-magenta: #d22e8b;--color-ansi-cyan: #00918a;--color-ansi-white: var(--color-console-fg-subtle);--color-ansi-bright-black: #424851;--color-ansi-bright-red: #d15a5a;--color-ansi-bright-green: #93b373;--color-ansi-bright-yellow: #eaaf03;--color-ansi-bright-blue: #4e96cc;--color-ansi-bright-magenta: #d74397;--color-ansi-bright-cyan: #00b6ad;--color-ansi-bright-white: var(--color-console-fg);--color-gold: #f0c86a;--color-white: #ffffff;--color-pure-black: #000000;--color-diff-removed-word-bg: #783030;--color-diff-added-word-bg: #255c39;--color-diff-removed-row-bg: #432121;--color-diff-moved-row-bg: #825718;--color-diff-added-row-bg: #1b3625;--color-diff-removed-row-border: #783030;--color-diff-moved-row-border: #a67a1d;--color-diff-added-row-border: #255c39;--color-diff-inactive: var(--steel-650);--color-error-border: #783030;--color-error-bg: #5f2525;--color-error-bg-active: #783030;--color-error-bg-hover: #783030;--color-error-text: #fef2f2;--color-success-border: #1f6e3c;--color-success-bg: #1d462c;--color-success-text: #aef0c2;--color-warning-border: #a67a1d;--color-warning-bg: #644821;--color-warning-text: #fff388;--color-info-border: #2e50b0;--color-info-bg: #2a396b;--color-info-text: var(--steel-100);--color-red-badge: #b91c1c;--color-red-badge-bg: #b91c1c22;--color-red-badge-hover-bg: #b91c1c44;--color-green-badge: #16a34a;--color-green-badge-bg: #16a34a22;--color-green-badge-hover-bg: #16a34a44;--color-yellow-badge: #ca8a04;--color-yellow-badge-bg: #ca8a0422;--color-yellow-badge-hover-bg: #ca8a0444;--color-orange-badge: #ea580c;--color-orange-badge-bg: #ea580c22;--color-orange-badge-hover-bg: #ea580c44;--color-git: #f05133;--color-icon-green: #3fb950;--color-icon-red: #f85149;--color-icon-purple: #aa76ff;--color-body: #1a1410;--color-box-header: #2a2018;--color-box-body: #221c16;--color-box-body-highlight: #332820;--color-text-dark: #ffffff;--color-text: #fff6e8;--color-text-light: #ebe0d0;--color-text-light-1: #dccbb4;--color-text-light-2: #c9b8a0;--color-text-light-3: #c9b8a0;--color-footer: #0e0c0a;--color-timeline: #3d3128;--color-input-text: #fff6e8;--color-input-background: #2a2018;--color-input-toggle-background: #332820;--color-input-border: #5c4d3e;--color-input-border-hover: #e8a838;--color-header-wrapper: #14110e;--color-header-wrapper-transparent: #14110e00;--color-light: #2a2018;--color-light-mimic-enabled: rgba(42, 32, 24, 0.9);--color-light-border: #3d3128;--color-hover: #332820;--color-active: #3d3128;--color-menu: #2a2018;--color-card: #2a2018;--color-markup-table-row: #221c16;--color-markup-code-block: #14110e;--color-markup-code-inline: var(--steel-850);--color-button: #332820;--color-code-bg: #14110e;--color-shadow: #00000066;--color-secondary-bg: var(--steel-700);--color-text-focus: #fff;--color-expand-button: #3d3128;--color-placeholder-text: #b09a84;--color-editor-line-highlight: var(--color-primary-alpha-20);--color-project-board-bg: var(--color-secondary-light-2);--color-project-board-dark-label: #0e0c0a;--color-caret: var(--color-text);--color-reaction-bg: #00000033;--color-reaction-active-bg: var(--color-primary-alpha-40);--color-reaction-hover-bg: var(--color-primary-alpha-20);--color-tooltip-text: #1a1410;--color-tooltip-bg: #fff6e8;--color-nav-bg: #0e0c0a;--color-nav-hover-bg: #2a2018;--color-nav-text: #fff6e8;--color-secondary-nav-bg: #14110e;--color-label-text: #fff6e8;--color-label-bg: #3d3128ee;--color-label-hover-bg: #4a3c30ee;--color-label-active-bg: #5c4d3eee;--color-label-bg-alt: var(--steel-550);--color-accent: #3ecfbf;--color-small-accent: #3ecfbf;--color-highlight-fg: #f0c86a;--color-highlight-bg: #3d3128;--color-overlay-backdrop: #000000aa;--checkerboard-color-1: #474747;--checkerboard-color-2: #313131;accent-color:var(--color-accent);color-scheme: dark;--color-project-board-light-label: #fff6e8;--color-page-header-bg: #14110e;}.emoji[aria-label="check mark"],.emoji[aria-label="currency exchange"],.emoji[aria-label="TOP arrow"],.emoji[aria-label="END arrow"],.emoji[aria-label="ON! arrow"],.emoji[aria-label="SOON arrow"],.emoji[aria-label="heavy dollar sign"],.emoji[aria-label=copyright],.emoji[aria-label=registered],.emoji[aria-label="trade mark"],.emoji[aria-label=multiply],.emoji[aria-label=plus],.emoji[aria-label=minus],.emoji[aria-label=divide],.emoji[aria-label="curly loop"],.emoji[aria-label="double curly loop"],.emoji[aria-label="wavy dash"],.emoji[aria-label="paw prints"],.emoji[aria-label="musical note"],.emoji[aria-label="musical notes"]{filter:invert(100%) hue-rotate(180deg)}i.grey.icon.icon.icon.icon{color:var(--steel-350)!important}.ui.secondary.vertical.menu{border-radius:.28571429rem!important;overflow:hidden}.ui.basic.primary.button.item{background-color:var(--color-active)!important;color:var(--color-text)!important;box-shadow:none!important}.ui.red.label.notification_count,.ui.primary.label,.ui.primary.labels .label{background-color:var(--color-primary-light-3)!important}.repository.view.issue .comment-list .code-comment+.code-comment{margin:1.25rem 0!important;padding-top:1.25rem!important;border-top-color:var(--steel-650)!important}.ui.labeled.icon.buttons>.button>.icon,.ui.labeled.icon.button>.icon{background-color:var(--color-light)!important}#review-box .review-comments-counter{background-color:var(--color-shadow)!important;color:var(--color-white)!important;margin-left:.5em}.ui.basic.labels .primary.label,.ui.ui.ui.basic.primary.label{color:var(--color-text-dark)!important}.ui.yellow.label.pending-label{color:var(--color-warning-text)!important}::selection{background:var(--steel-100)!important;color:var(--color-pure-black)!important}strong.attention-important,svg.attention-important{color:var(--color-violet-light)}strong.attention-note,svg.attention-note{color:var(--color-blue-light)}strong.attention-caution,svg.attention-caution{color:var(--color-red-light)}.ui.basic.red.button{background-color:var(--color-red);color:var(--color-white)}.ui.basic.red.button:hover,.ui.basic.red.button:focus{background-color:var(--color-red-dark-1);color:var(--color-white)}.ui.basic.red.button:active{background-color:var(--color-red-dark-2);color:var(--color-white)} + + +/* hacktivism exchange-dark — warm dark gold/teal like exchange.hacktivism.ch */ +body { + background-color: #1a1410 !important; + background-image: + radial-gradient(ellipse 90% 55% at 50% 108%, rgba(26, 107, 110, 0.45) 0%, transparent 55%), + radial-gradient(circle 420px at 12% 18%, rgba(232, 168, 56, 0.28) 0%, transparent 62%), + linear-gradient(165deg, #2c1e14 0%, #1a1410 38%, #12181a 72%, #0e1c1e 100%) !important; + background-attachment: fixed !important; + color: #fff6e8 !important; +} + +#navbar { + background: linear-gradient(105deg, #1a1410 0%, #2a2018 40%, #0e1c1e 100%) !important; + border-bottom: 1px solid rgba(232, 168, 56, 0.35) !important; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.45), 0 1px 0 rgba(232, 168, 56, 0.12) inset !important; +} + +#navbar .item, +#navbar a.item { + color: #fff6e8 !important; + font-weight: 600; + border-radius: 10px !important; +} +#navbar .item:hover, +#navbar a.item:hover { + background: rgba(232, 168, 56, 0.16) !important; + color: #f0c86a !important; +} +#navbar .item.active { + background: rgba(62, 207, 191, 0.18) !important; + color: #5eead4 !important; + box-shadow: 0 0 0 1px rgba(62, 207, 191, 0.35); +} + +.ui.primary.button, +.ui.primary.buttons .button { + background: linear-gradient(135deg, #f0c86a 0%, #c47a18 100%) !important; + color: #1a1410 !important; + border: none !important; + box-shadow: 0 4px 16px rgba(232, 168, 56, 0.35) !important; + font-weight: 700 !important; + border-radius: 10px !important; +} +.ui.primary.button:hover, +.ui.primary.buttons .button:hover { + filter: brightness(1.08); + box-shadow: 0 6px 22px rgba(232, 168, 56, 0.45) !important; +} + +a { color: #f0c86a; } +a:hover { color: #5eead4; } + +.ui.card, +.ui.cards > .card, +.ui.segment { + background: rgba(42, 32, 24, 0.82) !important; + border: 1px solid rgba(232, 168, 56, 0.28) !important; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35) !important; + border-radius: 14px !important; +} + +.ui.attached.header, +.ui.top.attached.header { + background: linear-gradient(135deg, #2a2018 0%, #221c16 50%, #1a2222 100%) !important; + border-color: rgba(232, 168, 56, 0.25) !important; + color: #f0d090 !important; +} + +.ui.secondary.pointing.menu .item.active, +.ui.secondary.pointing.menu .active.item { + color: #5eead4 !important; + border-color: #3ecfbf !important; +} + +.page-footer, +footer.page-footer { + background: #0e0c0a !important; + border-top: 1px solid rgba(232, 168, 56, 0.22) !important; + color: #c9b8a0 !important; +} +.page-footer a { color: #f0c86a !important; } +.page-footer a:hover { color: #5eead4 !important; } + +.ui.table thead th { + background: #2a2018 !important; + color: #f0d090 !important; + border-bottom: 1px solid rgba(232, 168, 56, 0.28) !important; +} + +.ui.input > input:focus, +.ui.form input:focus, +.ui.form textarea:focus { + border-color: #e8a838 !important; + box-shadow: 0 0 0 3px rgba(232, 168, 56, 0.22) !important; +} + +::selection { + background: rgba(232, 168, 56, 0.45); + color: #1a1410; +} + +::-webkit-scrollbar-track { background: #14110e; } +::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #c47a18, #3ecfbf); + border-radius: 999px; +} + +.markup h1, .markup h2, .markup h3 { color: #f0d090; } +.markup h1 { border-bottom-color: rgba(232, 168, 56, 0.35); } + + + +/* readability: strong contrast on common UI chrome */ +html, body, .page-content { + color: #fff6e8 !important; +} +.ui.menu .item, +.ui.breadcrumb a, +.ui.breadcrumb .section, +.ui.header, +.flex-item .flex-item-title, +.flex-item .flex-item-body, +.repository .repo-header .flex-item-title, +.ui.table, +.ui.table td, +.ui.table th, +.ui.form .field > label, +.ui.checkbox label, +.ui.list .item, +.meta, +.desc, +.time-since, +.text.grey, +.ui.text.grey { + color: #ebe0d0 !important; +} +.ui.header, +h1, h2, h3, h4, +.flex-item .flex-item-title a, +.repository .repo-title .repo-header-title a { + color: #fff6e8 !important; +} +/* muted secondary text — keep above WCAG-ish on #1a1410 */ +.text.light, +.text.light-2, +.ui.text.light, +.color-text-light-2, +.secondary.text { + color: #dccbb4 !important; +} +/* code / file view */ +.code-view, +.file-view, +.repository.file.editor .editor-wrapper, +.cm-s-default, +.CodeMirror { + color: #fff6e8 !important; + background-color: #14110e !important; +} +code, kbd, tt, +.markup code, +.ui.label code { + color: #f8e2a8 !important; + background: rgba(232, 168, 56, 0.12) !important; +} +/* markdown body */ +.markup, +.markup p, +.markdown, +.readme { + color: #fff6e8 !important; +} +.markup a { color: #f0c86a !important; } +.markup a:hover { color: #5eead4 !important; } +/* buttons non-primary stay readable */ +.ui.basic.button, +.ui.button { + color: #fff6e8 !important; +} +.ui.basic.button { + background: transparent !important; + box-shadow: 0 0 0 1px rgba(232, 168, 56, 0.35) inset !important; +} +.ui.basic.button:hover { + background: rgba(232, 168, 56, 0.12) !important; + color: #f0c86a !important; +} +/* primary stays gold-on-dark for max contrast */ +.ui.primary.button, +.ui.primary.buttons .button { + color: #1a1410 !important; +} +/* secondary nav tabs */ +.ui.secondary.pointing.menu .item { + color: #dccbb4 !important; +} +.ui.secondary.pointing.menu .item:hover { + color: #f0c86a !important; +} +.ui.secondary.pointing.menu .item.active, +.ui.secondary.pointing.menu .active.item { + color: #5eead4 !important; + border-color: #3ecfbf !important; +} +/* form fields */ +.ui.form input, +.ui.form textarea, +.ui.form select, +.ui.input > input, +.ui.selection.dropdown, +.ui.dropdown .menu { + color: #fff6e8 !important; + background: #2a2018 !important; + border-color: #5c4d3e !important; +} +.ui.dropdown .menu > .item { + color: #fff6e8 !important; +} +.ui.dropdown .menu > .item:hover { + background: rgba(232, 168, 56, 0.14) !important; + color: #f0c86a !important; +} +/* disabled state still distinguishable but readable */ +.disabled, .ui.disabled { + opacity: 0.55 !important; +} +/* issue / PR list */ +.flex-item-body, +.issue-title, +.comment-header, +.timeline-item { + color: #ebe0d0 !important; +} +/* labels: light text on dark chips */ +.ui.label:not(.primary):not(.red):not(.green):not(.teal):not(.yellow):not(.orange):not(.blue) { + background: #3d3128 !important; + color: #fff6e8 !important; + border-color: rgba(232, 168, 56, 0.25) !important; +} +.ui.primary.label { + background: linear-gradient(135deg, #f0c86a, #c47a18) !important; + color: #1a1410 !important; +} +/* empty / help text */ +.ui.placeholder, +.ui.message { + color: #ebe0d0 !important; +} +.ui.info.message { + background: rgba(62, 207, 191, 0.12) !important; + color: #fff6e8 !important; + box-shadow: 0 0 0 1px rgba(62, 207, 191, 0.35) inset !important; +} +.ui.warning.message { + background: rgba(232, 168, 56, 0.14) !important; + color: #fff6e8 !important; +} +.ui.error.message { + background: rgba(185, 28, 28, 0.25) !important; + color: #fef2f2 !important; +} +/* footer links high contrast */ +.page-footer, +footer.page-footer, +.page-footer .container { + color: #dccbb4 !important; +} +/* explore repo description */ +.flex-item-body .time-since, +.repos-search .meta { + color: #c9b8a0 !important; +} diff --git a/configs/forgejo/assets/img/favicon.svg b/configs/forgejo/assets/img/favicon.svg new file mode 100644 index 0000000..e1d3dc3 --- /dev/null +++ b/configs/forgejo/assets/img/favicon.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/forgejo/assets/img/logo.png b/configs/forgejo/assets/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..03e96c28f7cbd8307bcffcad0456e2ee3c497bb9 GIT binary patch literal 98601 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≺Fg5FEI6*GS3JIpz}S^FWX312^i@e}Wwi1G8& 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/forgejo/compose.yml b/configs/forgejo/compose.yml new file mode 100644 index 0000000..0103f29 --- /dev/null +++ b/configs/forgejo/compose.yml @@ -0,0 +1,86 @@ +# koopa-forgejo — ROOTLESS Forgejo image + rootless podman (user hernani) +# Site: https://git.hacktivism.ch | HTTP :9024 | SSH :9200 +services: + forgejo: + image: codeberg.org/forgejo/forgejo:11-rootless + container_name: koopa-forgejo + restart: unless-stopped + user: "1000:1000" + userns_mode: keep-id + env_file: [.env] + environment: + USER_UID: "1000" + USER_GID: "1000" + FORGEJO__database__DB_TYPE: postgres + FORGEJO__database__HOST: db:5432 + FORGEJO__database__NAME: forgejo + FORGEJO__database__USER: forgejo + FORGEJO__database__PASSWD: ${FORGEJO_DB_PASSWORD} + FORGEJO__server__DOMAIN: git.hacktivism.ch + FORGEJO__server__SSH_DOMAIN: git.hacktivism.ch + FORGEJO__server__ROOT_URL: https://git.hacktivism.ch/ + FORGEJO__server__HTTP_PORT: "3000" + FORGEJO__server__SSH_PORT: "9200" + FORGEJO__server__SSH_LISTEN_PORT: "2222" + FORGEJO__server__START_SSH_SERVER: "true" + FORGEJO__server__DISABLE_SSH: "false" + FORGEJO__server__LANDING_PAGE: explore + FORGEJO__service__DISABLE_REGISTRATION: "true" + FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "false" + FORGEJO__service__SHOW_REGISTRATION_BUTTON: "false" + FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false" + FORGEJO__service__DEFAULT_ALLOW_CREATE_ORGANIZATION: "true" + FORGEJO__openid__ENABLE_OPENID_SIGNIN: "false" + FORGEJO__openid__ENABLE_OPENID_SIGNUP: "false" + FORGEJO__repository__DEFAULT_PRIVATE: public + FORGEJO__repository__ENABLE_PUSH_CREATE_USER: "true" + FORGEJO__repository__ENABLE_PUSH_CREATE_ORG: "true" + FORGEJO__security__INSTALL_LOCK: "true" + FORGEJO__session__PROVIDER: memory + FORGEJO__log__LEVEL: Info + FORGEJO____APP_NAME: "hacktivism git" + FORGEJO__ui__DEFAULT_THEME: hacktivism + FORGEJO__ui__THEMES: hacktivism,forgejo-auto,forgejo-light,forgejo-dark,gitea-auto,gitea-light,gitea-dark + ports: + - "9024:3000" + - "9200:2222" + volumes: + - ./data:/var/lib/gitea + - ./config:/etc/gitea + - /etc/localtime:/etc/localtime:ro + depends_on: + db: + condition: service_healthy + labels: + org.hacktivism.service: forgejo + org.hacktivism.variant: rootless + org.hacktivism.host_port: "9024" + org.hacktivism.ssh_port: "9200" + org.hacktivism.site: git.hacktivism.ch + org.hacktivism.managed_by: koopa-admin + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/healthz"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 90s + + db: + image: docker.io/library/postgres:16-alpine + container_name: koopa-forgejo-db + restart: unless-stopped + environment: + POSTGRES_USER: forgejo + POSTGRES_PASSWORD: ${FORGEJO_DB_PASSWORD} + POSTGRES_DB: forgejo + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U forgejo -d forgejo"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + +volumes: + db-data: diff --git a/configs/merchant-landing/README.md b/configs/merchant-landing/README.md new file mode 100644 index 0000000..9a5d998 --- /dev/null +++ b/configs/merchant-landing/README.md @@ -0,0 +1,42 @@ +# Merchant landing (`taler.hacktivism.ch` intro) + +Static intro for the **GOA merchant** backend (dual currency GOA + CHF). + +| Item | Value | +|------|--------| +| Container | `taler-hacktivism` | +| In-container path | `/var/www/merchant-landing/` | +| nginx | listen **9015** (`nginx-landing.conf`) | +| **Host port to publish / wire** | **`9015`** → `127.0.0.1:9015` | +| Public path | `https://taler.hacktivism.ch/intro/` | + +## Features (page) + +| Piece | Notes | +|-------|--------| +| Wallet fold | Collapsed “Get the wallet · Android / iOS / web browsers” | +| Web icons | Chrome · Firefox · Opera · Ubuntu Touch (gold+blue; [taler.net wallet](https://www.taler.net/de/wallet.html)) | +| GOA shop samples | `data-product` buttons → public template pay (`goa-shop` instance) | +| Link previews | Open Graph / Twitter meta → `og-goa-shop.png` (1200×630) for chats & social | +| Shop pay popup | `shop-pay.js` / `shop-pay.css` — **QR_Taler** (blue ring + `qr-logo.svg`), settlement payto expandable | +| Get GOA first | Hint + link to bank (plain “pay here”) | +| Stats | Dual-currency aggregates; **recent activity** split GOA/CHF (≤5 each); refund badge violet | +| Performance | Live HTTP probes + container RSS | + +Shop product ids/amounts must stay in sync with +`scripts/taler-monitoring/check_e2e.sh` → `E2E_SHOP_PRODUCTS` +(e2e random-picks **2** products per run by default). + +## Deploy + +```bash +podman cp configs/merchant-landing/index.html \ + taler-hacktivism:/var/www/merchant-landing/index.html +podman cp configs/merchant-landing/og-goa-shop.png \ + taler-hacktivism:/var/www/merchant-landing/og-goa-shop.png +podman cp configs/merchant-landing/shop-pay.js \ + taler-hacktivism:/var/www/merchant-landing/shop-pay.js +# shop-pay.css, qr-logo.svg, qrcode.min.js as needed +``` + +Also see `scripts/taler-landing/deploy-landings.sh` if present. diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html new file mode 100644 index 0000000..8b69e86 --- /dev/null +++ b/configs/merchant-landing/index.html @@ -0,0 +1,1016 @@ + + + + + + GOA Merchant · dual currency + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ stack· + taler-merchant 1.6.9 + · + taler-merchant-webui 1.6.8~dev6 + · + libtalermerchant 1.6.9 + · + libtalerexchange 1.6.7~dev2 + · + taler-terms-generator 1.6.7~dev2 +

+
+
+ Merchant · dual currency +

GOA Merchant

+

+ Payments in GOA + (explorational) + and CHF + (taler-ops). +

+
+ +

+ Get GOA first. + You need coins in your wallet before you can pay here. +
+ Install the wallet → one-step withdraw at the bank, then return to this shop. +
+ Get money at the bank → +

+ +
+ + +
+ + + + +
+

Merchant stats

+

From merchant DB · dual currency side by side

+
+
Instances
+
Orders
+
Paid
+
Unpaid
+
Wired
+
Refunds
+
+ + +
+
+
GOA
+
explorational · hacktivism
+ + paid volume +
+
Orders
+
Paid
+
Wired
+
Unpaid
+
+
all orders
+
+
+
CHF
+
taler-ops
+ + paid volume +
+
Orders
+
Paid
+
Wired
+
Unpaid
+
+
all orders
+
+
+

Loading…

+ +
+ +
+

Performance

+

Live HTTP probes + container memory (RSS)

+
+
/config
+
/terms
+
/webui/
+
Loadavg
+
Container
+
PostgreSQL
+
Taler procs
+
Nginx
+
+
+

Top processes (RSS)

+
+
+

Memory from /proc + cgroup inside podman container.

+
+ +
+

Recent activity

+

Latest 5 payments & refunds per currency

+
+
+

GOA

+
    +
  • Loading…
  • +
+
+
+

CHF

+
    +
  • Loading…
  • +
+
+
+
+ +
+

Merchant SPA

+

taler.hacktivism.ch · GOA + CHF

+ +
+ + +
+ + + + + diff --git a/configs/merchant-landing/nginx-landing.conf b/configs/merchant-landing/nginx-landing.conf new file mode 100644 index 0000000..a8166f0 --- /dev/null +++ b/configs/merchant-landing/nginx-landing.conf @@ -0,0 +1,24 @@ +# Merchant landing — :9015 (behind Caddy — no port in Location) +server { + listen 9015; + listen [::]:9015; + server_name taler.hacktivism.ch _; + root /var/www/merchant-landing; + index index.html; + + absolute_redirect off; + port_in_redirect off; + + location = / { + return 302 /intro/; + } + location = /intro { + return 302 /intro/; + } + location /intro/ { + alias /var/www/merchant-landing/; + } + location / { + try_files $uri $uri/ =404; + } +} diff --git a/configs/merchant-landing/og-goa-shop.png b/configs/merchant-landing/og-goa-shop.png new file mode 100644 index 0000000000000000000000000000000000000000..aeaad870078fe2e89faa409718d3c3430aa112ba GIT binary patch literal 557173 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
7VAoh{5UoAiYM9&Ly$g4 z0X@uMIN88x^g?)W!rzqHYWr3AhGVnylS;440~sS7N`{+08-Sta>V0G&jI@tIcrk}x` z-=AY!UzL4J^k-8zMFhN!#ytuCAV7sqD}2@?pFQik)X4{xYcu6V&`g>Wd3Tv`FgKFs z*Fq{!TW@M9>W$lWzWctG5cnUz>7RJ~df9KRQOI@qKSV{*a$0Tun=5t{x;$FJ4%&U{ zw)ojSqRJ(l+|qp|lOVyH3mNan(h!RjX?R=~$XSnI!)^*7koR?9T0I|sAfp~3ratvo zT5cDEO!n&ml|dHPbelG~cl1#sRJSDm&aW!$=S^70_w*vdUM7kPV8PZUwPIYgT0eWB zc+<*(8ASUjdd5bF3))y@xV?>~IvBJl8P*RLrWw|4dqfqUf*(bX1qr%KHM*9GZ$0{6 zQcg+tG0Jv9M0u`^DZC4+d?1QsKM4 z<3l;3vIg?^A4oov`!l-j2HJDNDjOB-iRe~kU=^!RTn`5{KuxWf{eg3ZlhZ@i-E<%F zcgFMurR?QIEVazF2QlM$9;UW8Z+q+qX@+tT>{4Z(t>F1D;|WP^Qy$kWmC`hkVDg3P z)eI4Eog(#DduhIoi>F4nrLb6DbyErPIu}nHi$lV*V#}&7k>x-ACt3|pjRY$ z?&yna*GF-g^Qv|z>SjJNSVT%iJdshyA>uT@V}{A4+Mbz0JRa0LA^*xD$BA$<#L+2+ zRz@rGR06x-Z^FGRnHVnW|xWL!s`oR*%L3LgZEYU(anR&F-XZosI|EB<@awl za_h#e*4S==J%|w=OaxJxIZs&U|Pa{7ldogr~HPL{A@R}*wABEA~!(ChZ*7PqRpPI4zywK7-X6j zrJzk=4sz4np$hd_kyQMgt`;E=jb7?>7Rp%9rq(k4<(18(F_cT#tZE|wbL%$;cvB=w zHW!$Oz-dS+hng|>K16e!lZBP0ZOF1a48VtsoBQgC%97*XpAaqE3^_e4snAB{-17BF zhoUyo!*?)?L~X(Jr8??REJD8xnMTKS$ZziyJwCotHwfG&pyj8AMY6lXsnLp1{)3tE zi~R@#H*$q;@C>02=%}UhQpP~t*IOAVP^vR*C|;UAA2U157t-(lCJ`*dPk3+g2p3(< zI##dEfAvnOdOaEkFH>E`q98Ez`rS76#soMkK|2s&wAuJQ)_@7vesZksIsNDdndl5o zZlak%{|Tr`O3nyfh-ENzs&S!FrFJ~un{FucdO!FBy$>061|d3AZ^K+Aq;ywE3Vp@r zA(|Bei2B{%&SHbhwf-cd#J12*|9N?4w^@nq6?*TE`(KuP6 znSGmigY7)^b0?Z2!#ZDa(PazE{7D3UzrAQXa@Yeqpg*FzYP3cFW2L6HkeyB&=eD3F z6_BICf@{=IT<#EG5Q&9*dlxp*HDm8Y{d!q1>n4DJ*6Zp^wRG60J>4f1yk^c}L7~xI zqI`pL7v(=Cr~{(TvE%d+lWzFaDTAqP^xGLl%sVq)C%apfLDN1_CxzO1c##&Q{JH9H z`!D-tomVH)6^-*}Zxx-t_CW)Ko+_-qOB+*(3i5NU`-5i4m#Mi=%dd6;tA<{xF;wxp zcpd>f-m*()MAt*DVr=EHY&D`(nT-;OMu8dpAiA38RZk81G(O_~CxUrn<%Tx_GR@f6hP=_7E}Lx~%qRKNUhT+2+ijmp6HccC!r1^~ozG0%CBMT0f}VD`!y9Ai&j0$mSIs*R&GH8~ z3;L-M<1@3DsdnuTvs=YyY_EMYcr}*BWfIsS&@!o8z+8j?f6}}s*s-TBAuOJc-2sQO zNoHJ%!Y;UcF4j+4g%gWEM5?jNf&sTSAV%**um-^wB$ z^d05LIqZ$c6~1D=Zfuu>qShGeLI%aA5%dg&lOG+-o0$&yB{V9iG*V+J*L z5V~4HM|QU#K&b*nwXdAhjG2yW_XE{xk$m4EI5#VJ0$$Rh6l_>{D_#npCZ}}uKYsj% z(arTs71fn{NPd=|jFj}JPR0FU`?AwxP@mUHdi0j6L+U#Q2>sO|wQbwS%V)nJ6KdMX z;vP)G=)=2p<0wuQ-upCz#r(XxU!yA)eB;Q2^ZmZhkRyn6UJXe^j%}N2VkbmqePrKb zuvu|9%Vjvbu`A9wFq6cDSzFfBXa#%RN&cq~bI~{fS9WUY^U;P1@?XTT~;}dNRa$Y^JKLK(^ktDbiM4ZT6L%e-b&Z?gl>b+OdeZJ}Ma|QI` zaTp0Z4xDUYahULClH6o*sPs)FV+cBt4X5445#a9C>VpItY7CxOvX}Kf5~O+$W*&=# zBVx^P+WCF4!HpcY_ssN@wZ_xX?&)#Va8Gjdx#G#pRDOb#u-I{P$ZSHvTpQvI<+|oS zPy;@E&LDaVeLtM&umX&q5c5o;%!go?ft%S^(6he#(Isk?rE9*h0?OOG*fzJp1at*n zuOFYDj!#dat6E%{9RM(Q10C=+*|Ksn>2kQLOdI$|WN?5trcTtx@MW<}ungm8IVuho zOY=Q4LLvFWzoQ25sRpH>${au#?FB=5DRt-3uYkMfmLP!xVa5C~-A>WD<~;bSL<{BLpe?o zn{@Pp9l6w$NPzMa9X;pjKU)=6AMnb*`}@|y8fzn}NuzR@5k}YVU~R4YG*sG@r*9ZS!FTyIWY>M)1kXdTP0YM)i~}cQ!HOvV$cK6R8T-WOX8o!9>xTPo-g9TUUUPWEIAn&8`zv-Jd;ONWksY%x6i@$FLVn zsTWAcr*=CVJxQzsocb^Jg8et*_XCVV2P*IxXu?h^O^Cea>1WcE8u)6i@Z*$qb|T{W zPwaQMQ6Z_8MjLP$3CAKzy8JXx#dTXRQ5EZguFfQM7Mes1B%T|jBx5acCl%PNdOJ3f z%<5VXV&cU!DceJRJ#Rxo2T#p;fb}?HZXaw5J+#UiySwcLwz4iXNmPl)_w(ZR2e$P| zs4`jCUnlVoD5x%JFoSMAM=_dyy)!gwKu?AC?Q`tEREyF|MSKlC z0~Ge;zg`F9ix+Y;hPdC!O9Qz(L-#k__cD=ZU zj?jRPmP`kOk8=&RYJ}>VmfcI6g}NW-#zv~SL!S(vPf57*4RU|+U`L3BK>dliverKU zX&GIgc6A^2LILZuqJOsy7bSW!)B1+C6fKm7TO^;#JC8KssrmE-vut<&2CMn3Nbp-MI;&o^%?Fw4 zw*Aw#M6sLu*TPowztrqqJIAls<|HaT!;mK>N>V?)Jfl6kA!r6)>LTQ3{*Zu`!>GXU z9tVDMJ791qV~rEH3`_j;Cv$k6?FZa6)2-re#|&I^BR`y0`wG${o}bEGz{}m}_S_Zpjkk!)-JwXQ0s;1BdHICizRvF1QjpS~tZeLx zr6!=#(uV&*`24JmvLWiLG!it*`)^jW7NoRy{gs3;uJ4%th7Gdj*B65>#898Tb+Suu zHVYn;S+%y7<6_94M@d5RPP6pc524XC!C68t)5EiDCg{DflM*SLA@J8hVDn93`aL4y zi`19>*zTT@w$3Z_u6)2J(=(gz#$f%fqrI}sgHw-+1bVK~XK@voI zE6hoV!cLtZU0ssIT&AQs{qpqLJ12yH3|H2~=M=*AV}|;OZOMX^As_~w=~Nt> z;|q<|UzKeou{9(X1@?oD!#bYV*23za>N1S{Gie)nD>VG)gAwqG=4!Uez!M6_JruR^ z`#lmG`lxOSjllh{56Vze{o2`f$bVc>>n@~wcc!K@c2-09^T6u;B|*piNK4IF@Y;hd zMd;%qrLWveZDaS-p06E02Y%3pKDOoSY^PqHFELH2tCBH znSgWFNN|$iUC&>b#w@g33cCQF2cBzXaD2iJu!tFe5W1Pg!GhfbmkPLzi%5wEe? zUji~2pMq{LnUwb>TIPeOZ!^uvn%*X zNdu)yU*aNQsVw$+ue^Sgj_ddk8-2-%wL+ z#qQpDspmm8+Rq>J0-j$z?>S!B%r0n$c*6SEJSGVc+^?XZGnKVN<;PMdUdPtN*9K#U zNBtS@2g+z312I(|c&|ZUf!HrF_U@fEzQltp`rAqGGwIN%#LlOvpi3%}%O{fs#AneB zPEYZv#e#X4z`KO-Ev~_G=!1BI#21mnLU$J1yw=Lo=z|N`y=ZmEaR|~$rAql!eM{$U zi}w(ej{N>Q#Xnnly0{1X zB$VoYHK3}r?&sJ#+n`TB+2Y*uy-Hr=Z&E%uWL=B$j%?!CQ3%cG6nSgg(XZ*EIM^FHT z4@Xs=K$%A=srCh+r>k-c0)(2Nrz#sq9*WOqOEN_LxZ&-i*OM^Js|&nyDJv_}Qh6Ze z2h61$w(Zf(0FLj$D0=p{cSAfO zPOP<}?rQ7tX13L?&CfwQYV8G_c~e7@P==nS9|!!c>;k*O{HnLhTh4}o( zS9bE@*uwWc{T*NvWEHV!@mn;KP|ND}Js zPL4V4P;NbwO78bUpHngcZpfZCwz>wKCj7bZhdz`0X!u3x^N!G+JG_`;sXjO^tczEb zWb&X7!3;#%(rB0qbo7naJHOnfZuS~rmd2COMm$zozw8zQ1`&5Dx(Pc5K5sV*IIW5Q zFiJUV@uKFIfU|78wDWmzeyy`!Mb9+#0nL7UTa|=RAD`tNHNa%G(YFc*AKC!z9R?qi z6;9SC62o7H+>@nBbyyi5dEWLjH@xldX$H?}^(EYRU_|qCb8^?TKHHMhVo|P`O$5Yu z9$Xj+r~C;T@j6|zFKXquacPqJ_0!V_nOqk$o8=BFQHvkfocgBJpwDyGMSse~BVkW` zS5(v(!2M1@>QsIMFnIF4llK$gzN8WN_wn26t$6y;aXR&RB5-ku$Ka&_Ouv^?Z3kdPwAY{1=lGn{_1#gt!Z*7LA!tWP-_Y00Ll;=*>C9e}VzaRwB zq+prfjeA!&yU)u{T|JucO~vDjDl17J&8>>^mdk}ZI2wYL`kJ$DM0nKbp_=r=kQ>)GoXK8>Rgr}A#mC$>|0KVk#%c$p~e1G@F2$TC7*Owo-FAwbH z`Gz$Gv4Soe!-riSWJu})e33gF0|Q{}m#fXtp3soTNlz-@sTZvK%MR`{Wh%+L{n6II z8)KxC0GpZzf&^oPpA~qd41hl`tNvchWpNfiobyM;yvCf6`7$x;CJ$NTWsgZ0R6J$c zQ9#wxpxaKeGwT%bXjm>WBgWe%lCbB?t8W{lG6O2xG? z8h=SYGMU6!cuZ4AB{(@4?{|QYG#(Tzze_0iw?8py=<%wwGd`qv>%-sv z)nmRDtLpu%Bgr>fGC26IJm7Q8Wz;Non#a(G{-ATvG?&)rG9K$Y&*d7|Lom%fzHZaN zMzsba_kFK6;hysVRycF!=HJi|C?-PB4r+cE)!lW=72-bcjKptq8GvBSjBu0B zn$qU!TT=~Fg&@d=>Exj zhw0uMIfDco^XF*zlg>Jl_6AZPS(5YjvJ(moGf?+LZ!FikhVY!XBb39ltZFW5v%B#* z1&ZI4{FSkzC_-Hka>uvhq$dQT167U&NgSw*l{MP0>UGc~iSM63y$8mfq7;3!i~M`H ze%BR9)BRfRX%H{PThLHdG<%j-^A|gl90p@4p|RcJ?oGZTN;{m*(1v_%;j!^9s^O1S z)m-Oa{Xbunu;9f+=YDQoSXO(kHDq9v(uTa>=!YcQ?f*F6CY9Ze1caoy1oPABBpT$R z@OpVB>wLq(!RBs+V-USlI)riN=dI%^5%yts&qu`sBKV=AWVIETX&@~4dALKee*s(^ z*r6inRC@pDTeB0s`26}#^73yZBuD4XSrA268>dyoUB!?anjhX{j$bppgofngoBpRG8FpR%CIco$vsR4+zC5z8 z1uPaLxNL?`&Ep8SL#QVrcE83ks<`$PS_73m-e+<@XBF;!Dy=8EpBM0jY-!Y zN+vmNe`n9W?j|YUJ4qn)-5#&kZ?G|@6QK$^k6K#N3!3Q%_0KCS6$!>I2Sv)@*wu~W zj>YJSa3y45KCa&lAxl&@XDj!4$2)~pA0Vm>DZ0ha^?AQ^VwM(;D7iq`m)$_q)#kn# zOx8%T4~sJoTW>kKH-l2F4lLD*BN%Z5soLFcky8x54b5(}3fc;{o<7=e)^Nse4nK@& zh}PKe`&UZ21}-vKW{xzLP}mrE0i8K-MV1l}P|PX98SWLjMsI)VIck{ntu|blri=8eInw4%GW$8h8(j|-SVf+1Z`JI9eQ6p z`U+9bRIMTzo!|IB4}{#Tiv6Ca_|!h9U%1~3ysNeyUy0A2?cVja9kUA-Ut0NmDBO80 zQFv(BJzkA}$~eI6(0N+(5<6PcMdcPWaY8mUV#~)Lj#z)h+T{klR7q{pW7P-2XHM^9 z6N6AkJ)BTZDpx!c>u`gfPuJW|BP7N>*ZEBRVI5Na_j1^XQ16cyjQ}|p@3^uFTR^$~ z(n-H#6cYSsgOsb@mF+JY*osazq$VkRx64i{N7|lN^p=bMccO-VSNPh3KV!tzgJA0Hz?|y=4>Dzq6heLje^8xjg zhO>AUd&)MuL8fF=DdmI!Y{@-VV?xKmZbZV38n3x%G_c&ROvFw?%#uW-Ze}w>{TU%? z(%jz*`9TA_dl0Jl*|aFaZ}Oi&%JrD4d3lZ-DpKO3Wz1`VtHDkdNhgzzPLQH zToFC&VZcUb5(@6W$fLY>FTvVksam`4PkvaL9@U~C?0SiZ7Q=TQ`X+|9&{p)!e8!8~R0!?84JNy2*JXJbw2+m6KedBncsto4df z4n&II^A)N-2seaFxcb9M+I9;DZ5r1E9EBjR=8qW6pv#*3p26ZTtM~2NNxCkgNsZvc1a8g# znc)l7ChY1WX!kk0C#Q+R{{pc+VmBe7GPG5?w>_R7`@IZvdWa2o;4Ad!U@cZ^pc{Uu z89do?EaN!NUELl}(g`W1oww7?%&LE~{iA;K?EpptbNvqpNf%pqr$_7L)P&x#0d5vT zZ~B|l1#KEP5Se{uQuFtDuftc$Z0Q_>H^0_6tgVz!$4dySqclhW27p2v&IZF^JkA!7 zvqo;GJ`o7&lA)qkNi>#n+#>ROVbw%iRHo6O`E5Y!s~4>C-%`dpG?PL|by18t~tu)3)yCF987=U7+$3|g-1B|6hh*RlS4j@rb1Ic~eoS(oXr%N(G z`vm$aqwe^wI;i<)4JP3cb+aH@%eUX;x@mT9<22{{IZ|t*7HFJ@zo431@`u!ub&BUw z!04|wvtMpxHLs#`X|^DnYXzhM3v-N!7zUk(QqIr5)3?OWQSS;p_I}=C?moOu#oZ<1 z7R@Fp45h<5v^78G!O@feMX}d_V$Gi)o8Q^*7gblb5z)EAc4zs@VaG&5#Z3b`*0*}X zGa321&j)vRkQpq^v9yMGAvu|vCt8*(%L#Qpn=RTcM&*j zy~=N1RdZL9OENb#K|$DX!kTLl+O7E zvr?Slp{sRI=@=yK6NvRNjGiMm?RK2yN0`uzfQPjE6+hp;E)r;$HAfpA$D$e<#4;g7 z|LZGxSKe-Kc;5?u$$<3mWNCn~87a{O%-z5IG?n5w9wa)TSCLUQNpZ5{M3$2|3sL^M z2EoM~EIt4CB*!eTo9iDOh~q4gV*1H3R7hxyU#k3ip1}KYjb2nxE@YCE+i5CxyvbQi z05`hFAye$lovpkR*#~OJ5Hs!Ugf@$t+c9rMZYwRWHeJHzsDx)Qk7$?@Ta2@r*pFj8 zQH0n~Jus3F;`}{R(&@dul9lPaO#u-x!=k&0fo4mM5zLFu+cD)WZt#9h_sJ7x}SVc-Mt_D1FAV~#^8@QaH-e1K_GzzYQ5()>-@^R zk-U&K>Wy8S18bvlI>#bOO6rrtMgrXZp?(98p~_MYbJ0RXK_8!EYoSAFq&+#}utI$G z3gZZ8F`tGI#D8*KL1X5h!z%N0bqA)4@WF26o~gCEz*<5q|Qr{J8*h9 z&wa*%GH6SOcq~`G)bxu-<-`H7!_N!+#-(i+uljL)-D#zsFRh$pB~L%b2;mf?8pSas z&gqw%mwBNxG^0yYDguAH+Z0z6vj`f7y2I5&R3_}l;{(i_h>CX(XY(WJ5^`98JI9_f z+W}mqFV}KxXZrGrgvuI(31vD!9ah8N^A65zj=(fmB$~5l{@kB-CZdU0>kCx9^DoB1 zbf%0Ma+r#UO8S~bm}Q!de@Dtb1-tF&O7DoHae-VyK8mpYLQmJ=uW=|DzV33XYrMUx?~g>|hUX{@tb*i5 zl{zdimv#C?b`2U~-|c}|$Ovl%bswQRTYW;Z1^zl`nxNeYK4M$4vqjcBX%XKD)>j+W zHYuCwmjQc*W#6$f>@Hl!H8Nu$k0*)bhy=^(2XyI}HV3e=)oU0DW))0Gkf~72>07T9 zC`=L|kB0d?feNxC!I5QLx(!vcbYI&M^T?zuDE@7b!x6_#?lfI(r*g2W$P5-T7BaQ! zxj1Xp=a0(KT1jmC)rbh*YYkOobBo+~mlP_q|@%3>TeWuP)Q2 z$-U3ha-_mfmYcLEM&?}HaWC?u?@4<9xl;SEBvk`}|&Mn>s$4=Mz!ZF)_R~Y;`KsQW!18k z5rrSp_a@TkN7&H!v*gc0{FicF9|-ir{7L-0qEKqOlr4YdLMa%byoW{dTW{XW`gU*} zd_%<~9Ku!2tUbfmSxS>5KsYP|q_3A&{?0I|o1OS)eFprG^-|5k;s+DsvRp|Q(#hM> zQrD3+c7g2j`=Ri%6$B(KI5beUf@ZP}4*;@z92o@W70vWnhu<(x9}QwRZ*tFiiN5Cc(1-P;p6jW<1|W%n7WEJc6T^#( z^PLH?_~MDN`c0iX$oGY+_qCXWS*$G1T8nSIE71wR)h74BNGQT?FU&S}F@?F+3Gqv@ z;pVCoXRNMeh#D^)VA*3*-$2jpDyx=~A?>=c;!>L;JLi;ATE@~!&p=-CR4YcHovW|5 zqT2FnuNvj$hADLNN|^{u6HmI~+yEbBJ#WHFXg zaA*>OSYEYMUSx3Ujz%|oB)Yk!u`1zt(>|d12{SDNam~3X#Osd4bcBY4^c6{2?xUWX zg~*-kbUUsCXI-+!x;}<}Bo;Btlz`mUqdn?iQzRznorc_DcdxVkG3{?8ECN8C!kTss zbK%;%I?p-|n~h-VP{1qE&);b{{#_FZ zg%i^s6eJojKk0iZ*B(bzmM-#}d^h5`=Ff5Ee;%vwzYHrDqhmX*Pj+hipr&E6mNF1PGB)fsQ!W-qZM02O|oU^Ui0DSNp#xW1QqLTadB_*XZ_ zY#I8ur%pNygc1LD#se`Z?-oNiGWR1?WNiSM(=M`DFo>EQc%d#_34%1di0T^8OyQ6j zBT5g*N+aC&K2v)&$S4+IP4VSI=K%hX&Z67VMtxQYi!B1% zAY9{^h#%gcKL3=j7l!ximfn9`?PIo1fn38fP?Snsaf{d6bnHTo*fVy569UMzcOD`b zn?%07e>pjeN<0|a6oY@{WvxJzE2=WP z3b0bD7dup;W=$tJM_CNJarJ$8yGRBS@~ATOvQ-cSPRB%MwmeeNT`i5}XG?4Bi)1@n z#pB?sgj8lAYdl@$V%*?$HF6neazvW8YkmsqqQ=W=8lRr(Y79|6xsZ$K7!sW2*H(Eb z`a%7QgjZ|q`|lqdSQP7(dCZn$K0*KYF8}9t3qV7n@Nr9{omlip*?Zxa9&(dqoZ+b- zkI;05UKYm9OjYy}#oFI#0X`LZ!?7GAb%2cYK__un7}WT7kCuzifGK@TRe~Qu@LUKP|MYKgV1ZHXxZEq=!7+q%9t1_!#NT zYn*j2Gg!m1RrVdd<~$`p=YP>tb=#h)jdbQ*tTrm`KT|4FAA(k*b}N2~&Le~dab@p} z`J-B0sXcuHA7fbk|J#ZG-OnWFUv8ZRBUq|@^8S8t8syL-pLoClI_+^vOYog9`rdAm zdDy2c*WcaCy}A=meLYDE1khry3g_Ab$v;xMGod%BJp^Dqp{M6)t_ccYHvuXIbU!!s z*}+*$KcpwIY-fEFdjaD_RG}yxDhYW_h2_!K^RD#zs}HWaybYV;+^!f27vuH%lF{z< z-z=J<5=r#zTX`4$e69_SvH*W>;(neaoi9cP2Y&k+VBAM1e}9I5vbG;<)wN~sT1%y& zETfRf!dv6V|DwhJU!-6PdjjYm?wM2wq9I#IW6V3`em8@x@slU^$R2)mwe7Jcj?o`C zLJP^3DF@Q|2Hm5)BRUlkQsv}~K;5?q73I|R4jG?qYc5AJTkf(Lh} zaSd*b2AAOO?iSqrZO*uN-19#8X}Wuiy?U)$RkP-t#lEMHsrk}uqZ)Jv3j;}6=p|Gjc%v_#c`eo5K(g`X>ossyB+TLb*VoeMCi9WFQbTj}u! zHbMb&sDOov$n3km*l37h^0Tt=bDw+WYOS7TYNY%ge+*;NOVfUjT?6qvk3FKQ5wBE zLo3Fcr^DhkoMWT9rG2XCr^$*=FD?eT#=RN9t3b<>Dm^lWK!u*CY~*7L{$DkTG{*$F z)gbtL#GisRE~~C%mBl(NA@WTCf7)1{M7qpfSVk|yehHR*@iNr)2d-~v+3|3|!C(3* zs;nuL$f3a=r6L>VUHsNW;+m>;O|^i3G%UOa!5LmEZnkk+0!EF-NQYxLpPK$Vj=%fK zmCu=Mo6aJQm@%7K=VA0Dm?tTUNWOn`l&8bO$r68_@u-?j43PA7%VzqR;0Ig$FP0@@LuS#5$3o`)e_n zrN7d*c0qDgQWtDOJRTT^gj3BHG~IcExC=%^d2xSvQ;n|f8f_ppQ|4UgyuMaD#T9iO zhA-b0jdA^pMy5)`Z%z=0v73^XtM=DNK3nE!1!#GcRp;p))eq=SLYTaC zFavY7xQlse2fY6KIjiitxfEcKBJI6$VdsBhe7!aX|L8@;YNaEK9j!ObE0r z>XsZctrv`JwUg%4Li1;3Y>gr=iw!V^ea3-jz4TGJ(82r_!E_`=zntBYtNC@KqOe4# zU@myQq_NJ?B%>?w0C6k;5bz-+6OZ*f=SoUP(b!aZ8agx&tq*faej)j!sg)_alxq`e zWk(X<{zb#kpu_bKfn@OZN*bfo+9HS{B`+V~;&^i+fY*X8zGi zqhC=>v;4Ay`R0wfY881~M7*<^bo<#j@-ez(lcDZ%IEU_^#_xOh7jhF8Q7^;|6P3$h z@4P;xU3t)B`P1wznz`4<$dDU(WV8lw+7+i2)m$WKtcD@$k!Ys$U-36qxBWEI#bI|x zTa|$Fpy{u8;7CXHj=}cFH*gz}&hD*epNC(GQ=bszCr-yrKJp&Zu28Gku0}?!S5?~^ z@6;=97DTI4TkscK9h9Qbq2v(} z0x!I}C4aVJwv8in$a7hTGmn?L+kF!QPtHS#EsGnNE6r3|))MU#HOg;jQXR+4Q*^VR z&fE|{a4JXZley%ha)Fnll*9^0{bkpu*wM>PqC|O@?+)-1;|gc$IsF#&UiE@#Xx0$G z4K&_~){K__o0?^=rFdV3;B!lJ6p}11%>d0&k8tIJ?U$Jg!i&OJ{!PjiZuK*iwWT2F z&vXT1rgL9$z7TDC<%mndAAeC!knvF84IiRJd%Cv@aRwhQJ@EK!RM(m3)SWuSDy_In zd*nED3MNkGD?JNHd&B;qgXrh-_092AjbR!01pYHYq*Xw9y$YLHoxzOtY)Xz6Kjquu z10V)n#EmN7HVUg#3YoTTb~a>J5SOc(B+>Ph*lI5MFNcdECS!%k>`_bq;Qeu1JEHLQ z$iJm-oL0IS-tf< zXk9vfMlK1{MVc34?%`)1s~Z+yRpY2ADhrY@ z@HEZ7f8ZHbxUaRK&$mli>GPsC;4jqI9G>zg5A>jlnTU!=#tOcar6~57Nq(k|C$u=s z(IYGhx|kfWK?RXc85d84^P_71so|H^>$ZEZwMVT5L=9#~TjvJ_X4c&NVso4nK{!}C z6H1tGL$H3T|F;qyPOxHW=H%ne*Il}exrow+n-8EzLD3g;3f4fjlRKu%d_X*ntz*ML zr^JkbG%)qTeX30cZB~g*?nYuZ)AVj;y_8Ms{=VShH<6odN{)hT>6yh!(^~y3ez0F~ zlACJ`r}?Mu^7u2Mk+>0m{;VYf?M?S^h|`fv@c72r=NxDdHOk*Fnw?%#-^|O~yUQmr z$YUDB3<6_6q?k3$T!zSoqhpr`enDq0Y2g!{5 zW3xhh5cNnigD$Pf7aiibzh#Ohy|e6WWMYr4of~2$#Y!rWwqb(iWM}!=stI$zUKnSP zF@@WXFxP~dIQb?3y}zSHU~&vRR2U$Umm;L`-ME#_k@bIyntY>%Po8$_fOZawzWC}k zr(mNjmj$IxO-9KhNRIs1jfW;~Pl@;BZh`}e2Dl4G07Ea#e%0qM9!YE>3uuDG4|{N) zvIeRjPJGrpXqJzI={T7hDg(qlqvZI@Wcg{N=2&fo0#{;vak(dPD+oGnxX{@s0Lx9W zRXBncyZ-hy!owwuB5a!T#4gr8MV)7Y;<1%{>l`zO1rgn!B2GPqmCZXRY|R4dSoX;| zN^y8u_C9(jyeCRKhq?j zkPyx^Q_*iXg&SFqyciX~pyL_Fo6bx{!pb3#H>uh?wa}_MzA&pMxqf8&_|4Mf-x#DO zAU~slmF!N2U$|aLh9-Jb{0{1oqn?IkP0jNH*}f)_+Eflt@?XkDpC0Y%umD#Vf@Qfb zY|;%90w%*rHbP|6iENP`hMEVCx`%rE&~N-OA0?G*33bL_a$73~lVUVRNAkBkiHdke zw41ai^JT&Q;EN#@y9UbUQO?u(VN*-e*3TXrG295f%Q+MgRsWS#n+ZI?JyzKa)wfxo z)n3Sym3@`c?0R48&S6H*S~E=v!rZC;KEMedO`#@(QH2?8#|g7%P~yGQi?ds1H-5>G zW;a`$_gAVRt5zDXKImnj;~x9o+2?rXz}dETtX>6HjD1HBP)pCL%AygPh3Z!HkNvYoY6BYp<-!a$dyrpxWlM(7H>+|t;W&KXGLjrz@cGl1IBpY_@ha1*$^X>h|8u}o*zpVf$Z>#L zF3*rMUk*Wni*rgNb|kPH9@$IVzSc%)r{IC-+unPqd>1^F)5Y%lpEud7K6rOLK|xD3s8vc zyXbKwbv94KsgL6maC?@<>+`&E`ExnpKH6Jdr^aVuSjW8l5RPhZ$L4q58 zo&0X!XSRlKwnnv|LZ%&SzMDR9tc#c~A>_njrbY6HR1#$RUA%z{{~NWcXxZFsar}a6 z((vKv$`@hKz=0noJci{*%q9boG92&CK4onDMNlU zn_pr{6hU0(-;^$hD;s)tvX(i%L+X8tKg=G|D2KvL3Xok`sQxPt+7A9j&>|) z$|6yc_0(K*wH;{>YxNW&`;&_bQd2y4G@K=ptlqX=CV%~3M+;DJ${s^)aEp(Zo<_)JcdCA8JAFQtG^hPvsY{lAVDlqcz4`#SHaz#}&9 z=wWw7BxR5&BVo5D>PVPTlmkwE*N;w$Ht@4HO^m+DtDiPKG^i`&5q(WwDe}kvnF~ zeNajlC1i*bp3J&as7-3{SZI^_*aocFn9}l(UOcQ@`hsGfH0!md^pIJ>k;=-JcwjcN zyPdj6pTmVi$XQm_57u{PFB2hE$7iSO6CtF4(tR_^bM9_QQQ+LZ?QYEX%=q1d+vFg^GbEd>(aM!GyT<5wZGZ-=+d_rkn{Cl zH>{4Wc^zsKPqkmaVGaJWuVlkEzbBq#mWI#@+@vncojNYvLJ?d9-#|ElE26T{gwV`5TJ#{00X`_*5&YPd_^ zP?3Iyf^2h_)q*NKz1{2q+LpJ{(E7n&-Mw4>WI6^YPSZS0pZg- z*S)AoJ6M{w`Uoo&PJdA2CNW@hsI_F}$xVjfn92I%+va;c-5uKz{c`x#nHX!(^}#GL zU;16E!wi%cJVqAy$?-#$24Gm+jby%W))og(9>(P}9nq|%9+a-TEkKGz)#%MwEoU6$ z8Qm~o$iE^4=9NkiK-nzO&1_eRt2vC5!&mQC)7~A|&rMZH41U*X}3mZb2fr z|B1K8-v<3#PFq6?oI*4p=EAf@o>X(IO>j0c@)iaQWO9lrI8Yzx$01O%n;Df;It(&! z9;8ZtW4Mzea`6V!=?-Gec0BBaGYUvFm&Qv@e_&92Y|kt2O1(JhTUt7&up6Ts-D9+B z5x6!YltxD{Gf91Pqj(p8fp|$3YXrC=h}9+OeT$u0(Xn&HaJ)W6^_Uh06JREC7J`2@ z{@DJk+;4`cf?V{q>4D8c6gKsCU7?il>f!r|s!t{XTZaBot^)rpiDyegcD>Z!j+PJV zg50X4X6KttL_XW>@`?yPZX=Nq(Ml8g$NkeaZ=D#raSV~iYXu$e72O;gG0`TS?>jZH zt>>zH)xfApJ&y?s!Kz()ACK#D&z3>XV1Uf3<2L0jy4?iJ`==gug<<~cX9PVtm87Gpz}0{%xgb`lsVrB?w_+Uz4<3;J|k)=?M)r{9y6hQxtQcw zdl+WV<+Cw{wy|b8TV+20U?}TJ)14IoF`({_iOYo5~e_> z@P(Ttvs(Q-4P&wlB9U9;%>}D+rM^V!SUYZlJL!7`i6*$Gk<=>dt-aUZG~U;%Y55u- z{ zB%3#pzo>57J4ij8Ui*vD=h8ECp(?=91iipUlvD7F$*sia?|@E#>|=Vv$!1$f=C2jE zN1^D87Q)jl&)568;dIX7bbY5zr?d{O$7vejhlAIKhSK-EhQ3+%cT1Wz<}3l^)<3rnj131^vxRcramEJg-Z7DEVVf=@90L~hv9WmJx7Y%q{nnQqaRSOsMeV1n=*ap^AC8w4o1tbUk`X2LrB2AluY*IC(0 zRL#Uuatq5fXx1B<5bBBL;FlKmkYulZ^o|=11!iJGs0j0!oGxN!Frdi!<3lYn4J-tu zVtxkV>e4#9=kVDdw4fjDVrsVC#$=6MDJ)$uozA0}YK7(-2{WMArrsP#F6BD3to{xe z<2H^ZbhXsRHR>|(@fcrmzw!}GXeWb0uwF8~$uPua)&83oEi^#vCK!S4jV}e8FKQLu zW{F6j<-G^Ayr3RYU85o#05&+BLGL6iz-j?1r2`MYaD6Yh5TZt+pJP5d%050bBPHz) zfP5eA)pNn+UF$!8mHpGwp2C(L#r~mxf5mUNuJN*38qziYli%65y2JgZNh+V1%r-?l zX?`@c+=?f1?NCl)N*e*FK*2HiG9Sn>x~`d$m|w{Xupu_IEPrPPtaU+vP#8ZLqeX<( zFWz`M^HWw2l~yxpx2`G62o(q-pX6-jNb^nDa71A_SKH&UK*d*`<;u<6)(7F`3UZ%|v@@+S{idTD67eAW z*YAIZ&MNM~b6%=>Vs-lMqW&`<`VMjBe=j?1-*A|F@S&#+lzH$wGk2n-Gc(+zfc&ga zHBV>Pi6*8Wnvve70-4RsyVVCY$oOMxveGjvB%gYH01wgy?BCIR@1^}M#Z zZ|LYf*3|OeH;MxiXO?GgRT>&A!Ruk%ik5p}pC3C4Tm%Z)CZjf*+^n9LA{_jB;o5az%y%rbpJaHY zl(eW)%h2)nYSQlD+KL}~)csRd`+b3q@6mJFTAk2q_IyaJnY^i=p^V;r?o{Lcay+kn zd+)bW9M^wDI~6n0m+d0$QT0kTNChH$b$R!_sMquIpu)Ri#J}y#kT3l zQ25^W)eY#Q11o#OKK$|bcXQ|yphL(Op!IycJ47VExCjHi*F53OoWx<@RXVyQ) z&C9hQLRv;G$V#8v5O7$Z*pT1HeOhyVFPg-cX&$Sg_1Iqr1JCj&TWI3*-F$wt8Q9m8 z`r#O*u1S4W!{Iw-mLmrEQm#hkq9F)YX5vI4r4XWx-RsC?ThxCOL`D>}SGh5Fv5AW0 zx;Y3^PAG^fS9H4hxtFN!ru5F#vOKm&=iMqI(zn?G>8V`hE*LenVCKb?R>sY15J9Sx zo=duPTzWe?6vml(+@z`oSGs?B4 zW28j>awI7uSQxatFb9*Te$p^;ZbhJe^ZanrIQ+r({@>~f1$b28QsZTZ(ys8EqgV^m zkjIgL;Pe=~G*QRfA72q^x2MR1K3zfM?t|yuc#FP5z4C4s9?_VRuZxkkW~~{D<3nf& zwEa$fL(%L}p8&~rare#M_7>J7A9WM80u=yc@&p2>Nb7| zD4{CffiZF?ih4Ns-$bpJAEPUDPwfP*1G{6ys%}aY-k68g z5pq5+o-FtGr}qauFPSECyO&_n=cKIXhr;=~N}qw$r2r`8 z3Nu`bxDqkIB}0C9)xX;JR>}n{EZs-jJ^O2)`UFbl4J^(HxFeqN`pnB$pl5GPOJ3+V79T4-C}5O|Z}rEXXG4EDRY5 z>0v4JjI8IY61$Z&I=lF4=RffWPbzf4P$~1)S_ss{8K7HOCeq37#EPxHGz!+x#XN0! zd4S{&rWEa&lOiy{$fKB(_xx5jWh}d+q~2*VNoBPT`iKP+pZ+2*$Bckie*F$ zLa0dVAWrKu3)F}Kem@tfYg&UmyGS%hx^XhbAAd9M`Bofw%cSj9m0?D!j&hutE}}8Y zrGDjRqxa?+is@TPcnas}p@0h%MO?5TZcT75){}J@1TQA3o$~6JlEX`*DlBkrR-!_!(p1xc!m1(wQ{>XdV+`C4v zH^jgkkoUYhcj6p7Ti4;Ui?km+BY~;dsldz@qMz+vON<(=_9t9U%a8l6@-!Q*oY6aX zd8prS=c!H~=aZXWW*?u&yMa2&>p;7G9q?NaMd*B7y$OSCJ^nU%u|InD+Rtix6HE(b z-47PLFeLv6+2lY8hSO;mJN_LQB)WiFk{?u9g55%UNTSL3MOEk#4|Lhe^zSyrzI*=6TWyHf2^j za$O}B`g29FbSw1>A%-unlP1rTDngMDUrDNBrh$zKpT5@`^PXTGX{nBIhw~9hObzU& znz*s{!#Y)hqBB(xKgvxRc#2UXnGMB9DSpkQJelOC|hP}s5ofp5IJIC?%+ zf8DUnbpOG5EfxPGA}eMx4e-zEs-eCV58ea@L-3|U{uvMCHnrZh#^23St3N*8=vV&& znCyPaVWkxAO*LZ#d&zTqxE8={K_Wifoo}cub!z{h@19>EPT;1it%JFxmd%N&)Pxt+% z$g9V9lH}(H)0gKV5yj=o`rTKrgOI27_Ltx`%b@7CR~N5!(ze;!wt2$;>^f?e)Q6Vz z69p3B}6@)~&J7O4jO915d$0BCvr9$p+|iWVdu7KF`=W;7?aD8;5{ zWuU3>0D=o9WGMop$E)EJ`x!y+V-<(QjLee$D{um|FBE^RkvOZ$2zOhtyjoBtg3jumr-C5O;2;xc1Ah$-tean+zBc1 zU1r_!B6XRUHg%5o?C+8Gq0;!FqLgyx@_6)bNDS?S^`$$YQ>G%v0q`Q@B)uJAQ1(vj zSInANW;iQ;*lb^Jn0?wT$VEbmo7fJ!Trdu+|Jp<6gFUTXseI1Q5|;>`aI1RGVMyOj zDlWwWgt>x$qWJS>3-dADn#B{{PD+Cj?~8iWG@iUlZbIQ#6}n^e6#gzp>9IU?e~;P+ zWX!h$BQ?9u^jL-P{$91g9+=)8B$&@_w=%F>zA@ecEIN+do>RwQFOkc>57Ev8+{f6n zKz&_L0YHEh-n75W=C@N)Y1{HR@rBobz_#`Ib4UkL;0F@damQe|hsMjc{zj*ItQwc) z*itXjvTjtp&VL*4T_Ynd$McSU%<6yTv_o5Ri-KYs*B?oUmTvoSB`~;9Xky0C!pd#E zIO^+r3KBjW4d=VQ>vq2nDst@o)L_C})1uig-(u||Koh@S;61@dd*MG76{Ucc@%>-> z_pgM0*JoQwWA>#06XeV~fSlUN?7DvB`p*DFLV&eA7Qv8h#@v809W>A(np+Rknmmn~ zQy}r^|ZwDhGp|EX(da~kcyhkuNID`u#I2HPL+S4p1tNy_w1D>t8ykr`bVqt&!# zTn9D&1nxgHH042yNI#v7P(r9s+jBm{N#=}Mkz|KuX^?K+Y+GYdqD_Y$-Sm6CY||mf zzY*mg5rjZ{^30z`H|jub&Kq2Et~o(3#Yw$=q)Jk6b0cz6TF~0efo>H1o7PzxBCtt52;5_dHPR& z$fNvGd8kWSXst)zZT~!OL~ZBnw7p}Gv^G3Q1P$<2Shy_IE=(M!M2LX~$~NA=dw66> z#n$c)c+NQm(f9QgnYNC+Ma7$JV}fB4OWI2GQDh50vFtBM=s4Ce5M^^4@&d{Sbgkw= z8avEMk`YWv+@Ulk;XezLexzpHTa_&S$M)Y(v^EmZ-D;J}+ZnaKtbRAIEBskq`aD77 z?%2?kXLo zyl=ZIxdn7UChz7Un*&8Wm)k%}dfD^iq{)}r&V8mS!}EdNrr&n4CLYKLGO(igQj#;= zFwx3A(y*fVen|qtA#V2G>jsnC0$_$8Ft%nZ!*z2b!*Q9sOuIcOUgy)SZYim_)ySK3 z)-q?*MqnNq5{3rP@}j^iW44*`Jan|+&a3zCfV4dzldCKEm{lA!3HwNC2Nt;|O+Nd> z`us^8+WYDFQLkU$_aeQdUtV6Gool?`pbzIdSm(Q$m!qkAH+VE9f}+uUT*P++-pAdN zWZu;kZ5SxU1aBwQ>FWh6DeP&ckOJilEc|ex3aBA!bl6=n?xjZ_u?nm4KI(@#7P(u3 z?BZ8S4LkMP;|Obx0(Hy{1DN|AU%~(Mzo+wnW29L9>AH1VRk&2XLm$ZNDBub0S$I)c zr9p#w>~U*c6h|cV^M7*m9CR6*)~&=Ki#~wLym1_Cl(dAZ3y3Uj!jDfggJl>RSlAg= z*H>V=0+kuT`8YBJYSSJ>rj8pv;wqB6^>|(i-ae42| zo+vltkszr&(R3T{(t0RstrMRL!ptvZ5mCv%%>D`U3)jYn{pWGdI^F(+0f0>2s`*7z z%Bc5#46 zlBMUN^4C9m4)9K7_Bx6y^%%?g6H%)q1k7!A60>|z&~~c^FfBEfqq3{R)6y#YRi@iU zF!7WU{1kEgXN{zh9^**A7lub>ecrrl7Qo=!iL^UF)vdnM_>Ofe$exmw2uh04u9Gve z4-Q*Qm8d$VYP_otE$J|)Xr}H@P2tcdDg@%iDU2T$YhmBtf61N_-)Z$xXP#xKR`OrM zx|E}*)o7pRka%*SkQgr5o6|GpQVGb$ES=)}MKN_4|5@pGqp3&m)yD_g`N|d45_BF> zUP@pIKGm#%^+R5duy|MuG08t|{t7><$vmc#B65hGzW*aNiFk4~{?YkmY?BZt+17M1 zdD_?T;&-|AgrX=cyzw+VQ@Mgg=?55*Q4JNka>rZA>9yZe_bG_R;dV(f8_afeUm#eS z>#3U?Yx5=*suZP(n3rZ~WILG_R&Sl6XLK?9m(+f!vlY3@jf=HaT&uJ{YZsC}4|G02 z*8-?oQ$^fxrAeAe&A^USF*etobZ_|FJ4L}=gD7EZUw|Z6O8R3Gr8>ApO`!Cc*mv45 z!{Z`WhWzcqPWio~!gx$iSAM@XQ~|vn1D$*s#iNsOz)KFZ6PHSFgT3~6P!CiJBtOxf z@2ygfgU5O|V*9h+ug_Dev-_UdTeh+C9-F^Dc?NFEXX6=hgoThgEqdIPNK+T`-Or2P z-M>D<=s!2yiqNV#zJW(}&7VR zL&4f^mPX1SWsiqBqf93?L}y8rT_Rkz2!Ag2G}+xb}N>_b-<$ptfrGUjv$`d4(RpbCG8uZTrVS-m9}u%X8~ z7R@=@ zjR2BNuOFO1D+F)|-&3gMBmGHeJBBB`e=q#elzXk{U~`hu_6#w>t#yOq<$+aCu3lMD z-WPz@?kis}^|G=DkeAfF4nYryPg)H@ul`8Dc^mm2r1_t*WzW}OAJEC?4}b~dPgX_NX7)^_vu}=Q;wiv5VI0l zRlStO7a42js(MlKXC?b|TsSTK7gw`%3B=}?dGk7K1K#_TM2;ChpVt=5>;lmt`#*JT zPt6Njfm2QQKS+mu_MW7C;4xk4Rh~?XJm=WAH$dQ1hGJ z@?wz_(NIMd{+bn1!{5Q)v`Z$Sf|@FEzw41c_HP|$B{TLXp8KW92!91NVb8MUy1F{} zp>!r6Jj~>Rs>i0s3abupKEo*3fpSYe9Nl}2&M_CtegcWqj7h8>iBlR=i$)-_(oFJ2 zO&g(62E7`6I-WL~dgT36LE^B`*T}fT9Cn0+`$wNHsH66hy#0@rP+{tuedA5(HqSE_S80%435 zO_Azt`&^2AtL(a`Qr;%1ipKMwxR7Va>&$qBStTBfXv7Hn7H+(z|7$+Qe<5dY{p4uOzqYCEM( zBCk%rL0m=rmPm`?Ad~qKMyq_yq_h6I^SGXN;}Xvp>#(KFo%w0uk&i0#14_f9jU*Zl z4jJrgd7AgOuk4A6T(r0C9>yxSJ{WqM!G?j;@)J=gU0BSLw*@Y0XFZT<^+l7VKgi$6 z3buT=uSz`0OYMcf)_ltiR!&DkAca_PbOvRHr@D*<@ zDx+wPULN7c_p`$Fp*+t!?PKPob^y`F&1XBj_0*K(3wf$_m=W6Y7 zp#H6=rR!y^{-@j(^~UAvGiMH{rngu{osm&9!9<%@QPfRlt@P&M(oLs8wI`Ut zrd}+oSbm>$YGu(fL7J#VJ-Q(yCplHdA?dWva^(sGf_F)v!jOnAHg3+M2%rwDqJ z^00k4yoLjL@Pyo8Whbnh!?DNzt~b#2H|Z@OOyQ{%LS$X8X>ZSRY#JT~3sl<>PI1L5 z=l;7RbjJ2j^S#1$4mLDca;4jzIt^L(hdlx8+{3=5Vcs2nX;j0IlMNr?g z8SA5uBwJw8dkQ=6734RleK_^gUw}HaSj6o(v=VK9urv3zB6?!sf6 z`(?*D@iHA(RJWB|4O?aUUo&VTz4Lce;sMXgx8w2it|=Xe!Pu)QoL=bb?d@*V&)ztC z_KOoAu2eHjvTxr3?NP7Yq2vo7UE1m01OFZ9C+PwL3#Z_`Ql3S%_ns|@&p!(wMfzOP z;_+!D@T{rKT#GyFAzJRPZ%;VoHwh5_grBoSHpA9d`{YJNQUUn==e<+#N0LLc-?Qtf zkxPwRtDl~()Ol(=mMVlM)&CI0f3{u_?$UleywvfUIMA`bIVWmd`~L9w``>(h`V5-T zKs0N6rYDI;i}a#85Ielll#$>b8NY#~=A2T{5wQow>R#|NtX%#waTC6)19IS-oC38a+4x1?9UHc?f{a)6}J7P59 z=1kCw2GE0ExiBMcUtdqo#v}dpaE;L;FUCYrSh;NH(Mjj<{NcOhi6V-@5>VXD8uzTCk z?^bIY$f*m-*%d-9@bQ)9J!ej$dVbE*l`rp%L-qF%@7^0*D10N#cP7JU&m}qxE}Kt3 z+co=aw_mo;h4*1(Edh#t!VkSZ|4GYJ`^BL1K3aOZ_?^X68tFr-OPNs>F4(L1f*~sI zfm~_rhY=%V=#0XP=Ix{FIPoHWB++GGQUaz5a{n(DbD@fr`H99miM4H^+%_n)jjl#5 zi?bP;b2SUS-~WDbvxL?ia+yzhRr()*N7aQxzvE+;xmDg61K| zae7p7wx6MBN{D5{fR!^}x|c(I%os>FT^3dNZS}Y{i|Zd z$m%4_p`i~Ta(O6A`efarUvQzY!eMKJHJiLX3N+Z5IU9d4#3xgTi1Uj{t1;xH!&XHx zA!p0qDC4I7q-+T?O|AJV#_+`ztAOUSCIYMcTmpjI4yv{$ok>6p9dcQr)B>i88vDrm zK)Rfu>=MH1ayXQwK4k*Ah55m3i>6GN;Eotvh0;F2tK#cNgItTLo(@ZfI=F&mD=mCQ zQUq4DZvw$?1n7gL&|ODHHe~gPVn%X=K^?Pjphiarn;aX$0vr&6wXTbWDso`ZY;f(E zcW8EjvFU;|Z1X4vJj;65<=FRUPNnl6BP9ys-t*psDEbm~19Rvl6>88%nIfYgv`VC? zA6rUF#&%2qp409Dt^Lj?H>YPCs~A&QDugA~uQjKPaCx`bC0vt#xD&?bgM)At>c$i3 zjE*ZDpL{B}Qvya!UX%k)UpK?82|Qt(z66-$74(Rya6Hf*2^YF5DkqwlqKrts!?N^m zP7zaMw!G#a(T#iFBOa*^$?9&sXPoD1PE5CbWNvX48ttgc<~R60)idiN-`zFq!qnY? zJIY8cuE?%aei`f5`ZHXKKBwPxLVY26Kv$r|#s|r&Cs!Y(W+k&X5Z^%V6$FPS1oso@ zngn=sKffp_S}pnF&z%TKxZlE$AuH1bs-4tIS^A1r;~WJRaCMcw5^xSP-pY05yq z*NXX1kcmat{^Bm}&$>ryAK8UIo|XDZG|4CL2i+<&U@kk?iQw7q5wwLBx%{L=Li9R4 z)5bTukAtvMXo<5U4t;;0w;gjXe4#z)hHyW&A4x{Lwb5j_U`e)=yFhhaTEMStAI@Uqr^WSX zoEbKV{+5QA>bD>Vl{*#)&G2&gxTdd*Mkv9PCnKAK{x>LW7vnfUkiBH36;`H&Z4)B> zNJT%)={|*G>o)rJPP!$s4T+=vSIyc9wp;rZsgmFr(`%?FsRugFaRXO$ zminSp9vh069eW9NVR3e%7$_*gfH4xCKn3J>LBXCsu&)vMs3y{5?<)e_9r=aENb)7@ zRNqkI!@gV*(wHC?9td)&J}LxGYmC4RrgbI3d3Qe#r?$uCEYzk5*^ziZKYxRNU}M0M zSb*sU|C+~D8~JW+oUpRwR_)!*4XIcY^LC6lUolFMqyAkZpQTw~*!?X}7CMw07O84n z17t)H@BuX;#hfcf-cW|n27yW2=*L_#52_A@Hj){l0XYl=uA413Si;KBA_16@O6P}8 z039SXIh|b{qm`EkSYm&{b(xW1fYoY5-r?_;74-mhek2iRQ3x)Q$6o!3+x2y_*{ILM zs-UaqkGV%%JxgtecsO=!l}SU7v)`#OVo3W{5}ZXWDgjnG*CZRdR6y*?7#nYC=lnJ< zlPA0sthI>|!rtl^?g#o$@aEQd89w`RyrGR6Xg%M2_t+5|SvijH9RN8ncb#uVn@PcslYUUdZ_7)z7b-HMSnz6I! zWCea5f17)63i7G5YAyY?2SmCdcG@jnt9K0?aN=3miA z1x}&R{XI{0M}i~Mzd}ZOdTga!0e9T}BhvhD$4-A~P+X_P5THZIohv>K{Wh`)s$phY z5-aV%TyMUH8cNqxoGzm#+G|7Cz;-!erUpLbVa$ zmWt5_6jXN48=cG_j>MO{V_S_|(Pk65lFL3z<<=VQvD9*Soa1Y2TGmoO0^4o{Qvtuk zPcZt!h!e_OFtoq;(f9Kc#E~}j=kX&HrmG#K z;-`_s1z6AQLotuz%n&dTQH=_|T?oFH^ z_xY;!^6oB|VzvCm&Wnp!m@xJ6*X0~rm-2wSG9vNObfv?+RQFR{54zr8W3>y4iVqtM zi>*EpDu?diU6D$70rrZ}_c{LPlX&ETG|rt5Fy9nb`4rOUf+0ouvX?_)ln)%_rou-% zu>eb@{1hLF;Mg-ddH-cO81rr^z*XL~Wx1NVQdtXrwBG#4p4DTL%)0jrQf~5vGsZuv z$_%pb4BD)G%${Z=q*zvawY zyqtkq!}QJShf)b#1r)ww$9rQ~0-9hq8Q-y+Ezdkd{h7^dd;Ce(J-&`7pV-XKU1KQO zM_>OCrZ!M@n!31d*F_Y)G3FuY}qodxcnzP@Zk06(gX`v^np=hvR1<^36K`HrO}ie zZwT#rHexVf_A|M;#*(>*uxjA}T>ION9KWKEQcY1GYcMql^+p}cA%+YXZPaKgxa%LEzw33YzM4*+sSko8{2QcPHJNi97*aaq zpu0h6jgYOCk-f!mga9EWQj32rUcm9Y*U;ngN?(5-t>)B`)3HYs{0?KjXn%2QRf z-Sq<7lRV2(e}BUXJN9wh+&vGe9weeVi9_Q0Fs|Pc%ZjM}($c31W#SO@3^!Xpq-|Oi zaBpbBd%;XAL=FPdQqeM>i|YsgB?z71>8M46=5WE2krsyMb(D+=z42Mbl+N=m0`I}o zQxLQs+=Jv3>G?zw7>0pqn*IftHO$W|Q%dQ-@w6*`Z$g5kqOgr;Jel*>>ZFsbV) zcGo9avT8q$I`(J|JoZTDEuT-RPe7mJ<)v%8v8_$myT`F7C!k(KHESr;}Rx zFa(sffwmQO9G^9`LfK#ng<%*7vxG29C=|BUgk}?6pTww7Fk}k)O@jsVhUhPs85*3$ zyg9QN?(e5ks!%pe3?XP(HAcrqS-WWqcRaR%k!C-tzn{84_?++pj|@_Qm0{z?S0{7C zOmJ_C%GH2r?J0{|6BAzv8F&+=UHOSeW=KFtR}Cp!3e?bpM(`f6&rDUaK?9bw5JEY` zgx`4zgv8bwOW2f$1<&kwj8dh6s5s=FRvKlPn5v33Ugea7&*Yu2{7XjGX&!oL2RnCe zWprXEa~Ch<#G}qo(G;IFmo6FoZ&y8cRE4 zM%o5dJ;2l^IBMldT=0Smcxv4peD#uBIs3%{POcZ z0?q*~9(PJ5{{KRyL)QL8IAl)Q3W1D-A-k zdGi)*TXFU|=Wy`Bhw{iHYj}Fy(|}-PWR%^bJ2A^828QNgmP|}3Xf~bS5QI&mS;w*( zH0pJo5vq}oRuqAaQc~N za>pIFvw7o2mMmGy;LspaS-ukM*>;}`!V}DdUm`1iue04rBYoP@d3N&m1@8^o^AK%) z*X|1qe^vB-0BIpM(Mh2~VWTLSC7P2BW|gNnV432j=O0UL%Hn^2ehVW_fh_55>d`c0SsrJqO=X#_Jvtm zYovj5|8kYTQV1+%BQTwLh$zvtECiC0Q9{|4tA5l-X@I3^PBqzo#Q_|;>L|u`Hy9hO zF*IN>I#p%&)HdpqH84P!ICZd{dz|#*K>hP8TtoBTpWrN^Y&|W{|JR)(d(T9H`@M6D zBwDIOLM2-NI0QqbJ%s(Hg5+>Zq@g{#ikLFe1PMt*MwWS(2bJM|=hIrET~iY*$1m3n zK_iBH2#Q*g>X>BD(^eyq@H?utW8$BhN6h>=rGJx-pYCsSl;21?mvPsN^x7KJf8O67 z|07cJPHSUVZPFSu7|)s1^hDdQ1ZFZZ3@=cI^#2UUp-!hU?bVARPoe}%pdD$)w(2TC zXn!?22*+eb>yqWouqUHBSLmHbM zJwA>k#CyQf8ZS~Yv_CL80rv+>J6=e=pO*9rE-RuVUL3;qnR>&22-Q?eCfc?r8&H)t z<#L6|ItXbxkD~%a2@Gvhl9G}XRHdZPkW{6hTr#Mbf-*K`Q!smWA2n-&T~nhRaM&s? zI^$GU9(NS8=Ff*}3Dj=t<6BYnNhA%lHHK=8Vo&VEn3_N|YDl~3{J>ZiQdg*`GWBhv z?AWp$Q7JQL*&^ip*)&RJN`nhI;D~t~aO7#6aKQ;|cyKefUVS@vuYMXynZdq6rj&Ai zY^6juZ466NEmc7~%E^jZMwW0U@X{gX8}%ugwuQ8t%S!+c{p~zFK912h2uh-CryS=kc1thk z1qRFn{1*(`H5g5NvllQN6~ceG@}=zBcHTIOcK?RZePGWH{AnZD79|5`!tEy^96v9! zR6^M{bzR38mNYGeR-PI|&}=HO4F<~#IREsQGr1GiKQTgmvVko1arz66VsLO4k36=E zA+?xY8zqDD3Y)L=+&8;%p*uCR;8bcX)rZqL8(H?C}TD1 zn5Ky-B~80d(`sTGo>5J#?zMeOhI{(HhCuMJb60eq2ZVq7k5>>o&l%c%5LCWWE%^Jg zx6unCO9+>AGTg9IDX}e^ZQHh>6`XbUxh!9?g8T2kpAG9bBZOqv&Yet7PEe^-D3vRe zOBIx|u^KjpghpeMsi|=^7KUliKUku_Iz**h#VnOE4AXtN5LlsDwR|r9NsJ0SBMB3u zB%rM6@9V=M-)7TnHkh26VsdhV$;ldx`Xs_AQ7%_0m8%F$=X^>N!+`!lNxe43_AMKk zniyyHym>tK*yAi-vWQbpJ(YX!zKabT*0V@2Va}{sK*OyInV)<2z3{=7Bt1-CLF?KN zKM!g5WtdM)IU8{lSF$ys_a@tZNi(ZZoDLG76}^{E7>+ldA*he<;JjC!#5@1=B5u6# zHm>-MX7jd5s?|X!yijUP93{IU1y;RApJB6P!7#ID4Pha9dh1Tc8wz2ToR@<^vlk)# z5N69(u87J&t7hu~RJ-A~LL-#pQDumdqncDE!q!+8NJ`Y3b+lHLOQz#uY1xkNr!vr% zWc%h3Mz`M0hujb=M&dz0^$0mYUi0!8g2^$Awl~ZvJzna z7yK+0(;r#to@Gv$xBhwW)v+W}dI259Hlh5qP7J=z>KmRGL6s#pJiW$3R*B+r-u{NozvC55Y4n3|#@fB0yqD$Cy|(np8^$+MZ;1u!?Su zQY|&;EB8?ngIGc{S+`y3w1p`J6)EVKf~tf*LprLhl0ntf4E39o`vlv@*E6tSJ}y@BXyIK&`1-6G|j{2+*&kT@_$K-X7E&?? z*flwYELUBhFGWQ*F{%}?ZKQ2uSWT!;P}WW64)wEQ@j_OuSjLhCi>Oxmu^Wm;-DYg( z1lt-DjE?T6(XgpEnt%aK3mO&!eFF%sF_~ocY=c9OUd}sSaT5P|#SJ_*GKN_l!jgjc z-HU7Z&M-LJ4q8+=CBE+M9&8JZ9o*=c`Tvo&@{eWDeh2ZZ z-(AV(iARugEp%04*?o-ep5m!pqg0w(s0qP12BI>XN?*S-@mG$Aol*^yDp8UZlx@+l zElQ=bBj;CSpOx=}NFrDR8vy6LN<+B60}vYUS~xENTqEUHC~Y8L*M1=GL3{5z{d5n9 z*fHFGG|<{1Xh(L9(5N>#%V2foh&UQekjlh(@!))Krb}@p0-?<1`vID&;;*vy4&*w>z$;^4hV2Im>vbQ(ajW>GawbZItst(oM1uX=>vUV9%;?}Wa= z*;tmsz(CoWrcjtdAngWyrp19P7BWx*49Sk26O4>aqASBL8PPyFzLvRg!JL zlSY8(5&6lJxO53SLVX>ZB(mRo@t;Ld6BMgNx-+daLTTDmRe^1u@-A4%w6MT7ZlnwN zM|Ze>@+c#tQFK9=tqBF8)d)kaN~>^eN2D{k(?vHOlSBZuSX~`EDB{|ywG&QMDwU9i zq+YK%(s0+{xtsO`iKpPn>LkYt4%Q%<<$X`PfYZX}QW0gtbDpDaF#TrjnN(3*zwqdjEqh(I$o!48Psfr zD3_S1TU1K}*s{#-sRnarEuviRV{}q6G&BU`+p%_yF|_tEjy`dJ&OYXFHs0_676Mxe zqypQ2lS)9fF*8pN+GVQ}MRt7DX_G~j@`SdkM%Qc#|LQ4Z(9>%huWGE~tQ6&SRlw+C=Xv?Nta>C?YNPko*fCU&ytz~wZML36_F>6x zELy+I!u5@k*tW(nOYGXdow2bAjz94f7R+DF+Q-&1Ha5oS=x%oI+=0>-1O0tet5vLK z(|sXkF*P+wy*`0iF_<@NHhtATN@mF+{*dnPt_`$Si1vIDqi?c3g^*9IfJ1P3_b13w5S+ZmaLxcUk)kFL^+qX}?5MCI&t1b!jZ}{2|59CU9_7j$?s{}5w>|uGno3gFLsTt^kdBXF z*)-916QeoF+<_8@?6;I^3F-}tZM(FqBEf zaLFTEf-TSujkZmy<}d?&vst=mC39ylpi=HP*nRIKSqhyUyj|0L^~c? zIy|qLF81U#qzOEa`QdJ%$hOSS{aNPBY4e%b<3!wk9r>4x-`n$2sGj^@CAPWCd0SGx zNps??A(8%mA>5Zy*$69tJMAHTq5cebJjG<1sXzQ<<3u|%giZpSWQ(3;kF8ETg6Tb6 zCB|9mw&$_f4hwyE$z6~A?bPT_$jXN2B~7AXdj7*_#bzE{gotH?yFpUj(_*fzG||l(7=4r&1hoaDv>TyqP*Z4Ifu)d2plUX%K7m9b zOo7o~h7#EJ6i}xk9l8C~m_=!KgXYFZ*|FvsX6?5><%19BqPL#S{>QH1(jWbfXP(}{ zKxrNg6R<4g)D(k;VCnM39JcCUmd=}xP&QjOZQ$lx?qzIzgnDCwfvQP=*<{J`Ar{P? zLtn+DTs0^gC7M%;rZCwy*5J`Kqu6Q)vogTe$vVT8#ZVJW?AnPMYjVi)6;zCeP(mZ! z)rcH2NIUV@-pqN1#+0wJ^jX)y_SoOmz_k>3h!d>@tvq6%?DqY)?C~AttRx5yfzg^| zX(Z0dXohqaK4>;9Ow*uJb`)E-Z2^vUJdYL;VkiN*%l>H0x2=fGE?J%0~R02;yKH?oi3jWjQN}D$7;m3ym^0N?6pbIs)M+MVd8AhKXuwgm&%;wR#g_Xi6nX)vRLarfcl3 z{QF+~;FK9Ub>D@E2Q(c=b1s81Cw5 zu350*oCJqta>#}9@N5S9h8Uk1W9P1&Oik^gQt5Y0-;FX#SyZZJFa?uiV{F^91*H_G zX)-uCz(I!`!uZ|ejE#+97zUL}8LjM4-|g$fRk*Tl|KU9*wp3Mt2DvH_XIsfEVWY7u z+gX)YJ1Q&bh86}6b4Xn}&pq~P7r53Ro`G|Sj0c)ZQz;pUW(~_~BFbeNSTqros(p@U zp4LdM(9I@gHOb03eH?Y*B2;Y@OB+nm$NFs}*k%QVL@EoVHOgO!(}|HmSU7rH8=(|Z z7?eaAWlOYG#;Sv1RhT=lh@~r5uyV=%%pG3nhUuC}ZJ@AO^Tb*-Hqt0Df9^aQjRvDT zchj^iWXYu7sH3njO$l}hB_-AV1vITX1LiQol+@}IOxDKOxqBN^jY%xK!Fa<4X=9pD zsT!2Zj=CRVJ7gc-J(nk8I(g&{0}K4O{iR|n`XcB9>HP|NDj9EKoV@d4Z|>*acl}V^ zgzF{?`ISiEBoZ=WMjzk&dPRu1xH)suu}RxfLn%AeC-6hkDIC$Ct4F@)trN5q#c5+j zMZ^hNEPayFguK);l7&GfRwr~k2Pt`&Eyy$5fG(RM{I-stv9r9%HeA7f)<&V^0NZ+;8=pMEMd>(H>!!ayk-)9iCXZ)6D!iL$3aO(H85 zq_V-<2{jvS2t>(5TLx$oX(;q$llsUQ!cquBIX`y8WiT2Dtubtk-E3mmCKaoM9&a62czT#*OT)vdPa+w`l zH}mK{w==$bH)hFT_PjY9u>WFa4G%DDpv3H9lai=|sv&h9iG^s^C=HjW*)^Eo&gjBsW;s#mg#{B(!~4nx;f6*j zX8?PIt=6D)z@x#bx%5bDS9#@)G!H^cUrq6BzDNbLdSifZl>!04l&dC~xDkCEkplxcF%`hg03AWXAR`oI+ z|2gIMyK=M*7zQ+Ki;6La*IfL1p1flV&+K>tV{wC;oOHrMl|#ZvA<(u)3r$luL4qkk zYe~J?z;stqm0D2Meax1NIOCibuyb-JH{bbti~*PMGNpe{^Zagv2E7j|Cvu->;q((q zJGXW3WsG#L5|3o@Vy8mCrCK}jQFwtHEpkubu3!}Ml2bDqlaUeK{7!2vFbtDMt;Xo+ z2n!c3XaD_Iv3b*GrfO4c-@cV?+csmEf}z0y1YlWBtY#g{s?%sRsaC7ZpFf{!b%3D1 zl5M+-@SxFJx}j^%giQs=ptvy5_J92p*ve533!%_TVY{mY8isHu*Ut527?K4G<}o*xBm+Z3OpK4SWBYbTS!Oj^x@-xDAAUHi@4FWv1oP(2 zBlyVpY$30Fe^v}f;?wGMcV$6=5C&j_@JL=~wAX4iw5vij4YOq$qP-V_fc7Tz8mZkS z+k~D`^;K%$N7Q`oMwGT0?lb8x!9>lXZYlSLfB;cOxoULFZen6%n3ATgP+DS?DhOL( zHJX$SFsUP(BOJbJAxH1Gl!=|2C|4x5DD&78o0za9$}Ax)g`pjCN@;+U16ZFjBS@NGMsEtiBHCChAC#h5o%9Rq*bbJ9@zZ1&udOgkiV4hH={COL$;#j#agWcZ&Poe0( zaCF{Tcq?jgsm>pai=;&)j&r zeH}|?wEYpNTR`F830*}S<*0KU(UW)nn}z3y&eMbQSCMi|l6WJ}i>j{9@pO*uklqC4 z7|m+sydxCO8!U9mz37A+Tj)G2Xl?ry(zIWU0Md;`3^J5$oe>y#j>k&8Y%bW*US}(% zLPq6DD-9%FxY0CTCwp)2qNZ@sy!-(pTMDxz8C=@fh!RfG)fMByce1jiNnwexee9gf z*5FS_8SjXhyuPiPFQ#Go!yA89al?|N!a zkFnsyNh*gP&KuwILgo!ssZK3q)sjQmxn&3IA797z9UCb3Y342%;)LT4VBydJrIMy> zfYE3mR1bJfv+o{uHGW<^Dl7 zuG@|>3|Fxz5Yl!Z6#Y}x;u|AlXu4xcdk>`U!j>p$iG92ZV(oKiN6%uUe zzKwU5o|jFFO4VThgZ5{`Gh3PH8=*u4VQcqy+wdm{+VuqzqKa0w``|6mI4cMUL!u3Z z9@D(#g12+js*_nawvnxm@8t2_w^QmbF{u%4^eByw)u=Qw}5CmatU zXI~&UiMwKWeLMBqIJUAWmr9tXNy#v%R7~nqHAZ&qLR&VQ*~H5I z_ha_F`HYN=Fw|%;&|h`Js3dOar8kuou6i{_t++j~ULk>U*gB_z5(kNpn3yHuI&I=1isU_XW4U4KNDXU2a80Vy; zm$3ihAtpw*Q5_tB!68;ZvYwrFfo=8!hC@^-+gV^nIIlj00Ii&7eJNa0qao0f2DAH@ zvU24i9I@(T<_;{RHr`-l$Nx{=p9gD_rRROv?<|?$a`)Po-l~_`dYgU03)1hLH!v(NM_J>5Ms zy)V_Z-n#p@Wae4?$2li6zwO>z)z#C*oam{l@5?Xqt8jpQQJyR5CFf**XiZXdyq24w&&h=+FzM&Lt=P5zMY#4*@L6Fl=4Ybf>OOeRv(;JM>1g*cL7b(jyk@VCBgtIYbxZ4KLbn1gTI zIaBL#_!vIR)B(*vg;IthC( zI;KUh7Y)Xu$(PXQ1k%hMusb-!oB>SNy%Ps(KA_d~*)%xU2IATv)N#Z<=k{@HaxO|o z^xoG_#bLRq^L~36s^4yZ`#3DW_b@a)wKeZ_5JFHCMSY#=vGEHQhr>Qw^c+VmB2p&C zMcA;Eiz1~yI4hH4iShM`#{Ru7A2>A&FECT$%dg4Tzr6iP1f_x{I!Mz8!?_P1{G zg+F+PeEAH6B2E;(ge%#G}zVkCEIQP|f`X zIA6uNP=YIQ)gHFoCd)ij7L-EgUDz98_eL-@A*$(1^7TIQR3Ai1F(}X;yp4JkMo4)afCO)0Ui~N6y?D( zpZ@3{=e{!!@%vx<$2|4eXSuQ|*}Ziu?7dPVmK+=Rclg0uzt2H&1KE)QB&lP!L5s@^ zi@_p22a0aDhdoYF3=YOOMcky}=Hz zy!JBHlHlTnb3DH}LicrWF1)W<6w;?ux#-d!FLQ|Fn<6=an6lczz3zZ04PRw+L=ev0vM|-UuWK4EO(mP#STSH}%&6}Ip zYD^jRFf$FEyn{2IgM)o~y)FXDU@&BDeVto3uCsSApx5cv0bH$h48}z}@E)ED;Z0~X zyy`0Db?{XpFof!Oflp<9SfivAAs#LSxyrECV~s;)GT2Qn05SD-glHHpckwtDkOANf z-a|)su+~u&W4e7wv44$fknxERuX5|g1y+_y{{8>ef5_kZ+y67yZoY}?=42|c)T#=M z7i3bSFqrCqu2A%Ftm!eI{Ln)@ct$hWxyEXLgKXst-+k)_*Y_&MCPQQ@EFIRPkXToN z%Fs$v`7*qZ)}zT`EXhol_g{LF_g{LN6aDiP`*8VLLHcTm#&QDiQ58zf>)07lAYgt=ar5qRByt>JWlc&j4 zpL1tcSst!18t<{Yx5a2Q!Wc>3Q*^pI>`mU_FnBK!D1^{}k4(!T{C<=?&AWc2IVlpG z)-^Uwoo(S;4tn$5bjy#@DsG?GF4zSy7z`K;2SJ-r&VNsi@_iD|P)do3I))yQO0m4W zOjVYY!?Ix%A99a*pD#*7OTTF|$`<*6hml*3(~db8eAi~sBCzf-kn(mjpyof4IM#R5 z_dhC#*bMvarvx5_D=g~2dDorB81(eX?Ua#dh2C*z9(7FTupYN#(o5VnZjKYho%ixJ zewe5i=yqovDyTNquH&|U-itVIa6KiXUV?NiEuG`<{i83_*P7L(`zZj~$x*`Ng`uMby)0*KwMStG zoVs+0fA_Ea6|z$&aMc)ZBue%WVhMx}-n?{xts}Bw`y7KCg1Ln>*AeUhRfv*%g&Pbo zn>*-Hg+@?HkgHwPxpn9Z%y5XxGNdU3h*EiqgE8ZAL09&WLQq&s<-u7^*Jory*nIXS zY*n%T{tt2KiL>0=dW#b)eeOGP8Zj!ViXHYg_bIFa>nMDNlRdo9;0!l!L3Mq|*@rjS zINhbYypA1iBgz3%gC7Nr7gLQ;(t#f#g=ML4IdyS^eOvH@*WX5V*F))QVI~*?Ka%+~ zG0kNip=)a!)kKomroUi?yiWjf{K5y@Q9iNRri6`~xmsct4eZ*ROt$-f94jyXYEJt+5%Az%_p? zX5hUJVmTiw2HWBM28bRb)oB&npmHAH=2C6`!fI%={cQi zJ-ZNCJli`v^t!8j;De9z_T{(v!4IBC=NaBRI(ZJ>GaQaERRJiLmsU7+>J&PgWzxSJ zbK!QbIWGv904${x{oV@qUF@^7v(3)VE~YZj>yvkKvRq+I$^OAE-G0vZ|Mjyx{n1Zy z{=Nsfdif33mY3-FI+$o9d7n~IqYS}4cr+Y?={}9@_ylYuP?K54hL93xZAcT83M<++ z_dM2_xT{;H}nNtR32d+_9E9^|J!bAs;Lb$n$I7@B$B<%rJhRjZ^Cgy~g$ZF>k!R%ia)_ z58zc`qDTzE<}|*<7=_RZtsSzEWWz2i`aBf4+;bDEPUPq2OKdPpa1+qVL&Gn4(5);b^4{*vZlgy5|+!CsagV;ov5Ha1Rj z_1bl+szNEn!Ttc1<*copq}T7Ucd*0G{uZNgNvA95tz_sdLkNq~0`FwZfC#`Qsd}hP z`oqV6S8jJbye)QcqQ(1=bXNr(hq*lb#LcBZ32f70TA}B?485>v%~$^IL|!j zER+e5nPzomh0$n4HLAk+-)5gK@*&&5O~Hz3|9cT2cid-M^!>4Bz+r5!nti_*q)O*b z6IJH5>9AD8yKy9)zUel#`};Wen?7Zc?9Oq=T^3m>-9Yf_p* z06|ifBkx-gxnmxOq14lYS)W}0bGjcA5E#;9CV^lVIKz=1fA1Ya$kvK9nXaK#5Vm#JBfP{~Kr2jT z7&*_z%0;BJ6s`gd-Wp^k>2^AFm0+!(QJN7aA2`RK{!4#>?BoWnEHPG*=PMvOKnI)* zPOb>rRbauDgtWn3C~rb_3+E4z+J|(&tc%~-VSIBJHLL(hsVritOZV(LbikM)UO*;Q zi17LxMkVF$7~w%_g)sqQ@eXWhkxH|ydoVg+^ZTz*4JuB5=;M6mXFtQ6&wig5FJC5e zJ$4Vq><`CS>o^#U*e^VTGO&w!x#HZ)Nj7>bT>Z|QJp9N>EtqO_ zIT%?6!x33#==)txu9qyIyTF%Ue2%RHgUUOUzKR675TOkUW>D|Fewqp&oF(Cdj!j)-(Fv@w z+;TU#b!9W^)s-j_Kpm+wM2i*Zg+oMblN37=q7mEvH|b~tRB6u^DSavewZWK3shA;s zvYnz33|<%D`F51K71_MkdL=9~t%arrvgbG=z~24=!@-yjfB574#83Pb&ph)DzVwwZ z;YVSgE^LK0WdH-RjFpvDHa1Ryr`EA*v$xxJ%iUmN-3g0c2odIYv}IDes;WXNg;bJL zr%uu9_1WIqW;7l$8XW)vp)#y7ynXp?bk^g-`SU#S#1s7TKm7Y_ZEw@*o&ljS*3`_} z01gB;-@TxPHrs~)Eg+-qn8iM70N9j5D{7`;0zs^INJQ8xybG)v(Nv0b@4} z+VyK_ADF(OU`!+b;il4f!@J-9dmGU5G1VGu<&Z*BlqJ1hpVhT>Zr!@Y>C>m_cDq!z zWNq}orKxrbJdHJXc;WC;c+=(bWPqp z{NFpZPY;`LK{r6o)jm2Jh=~uM*q^b{DN-sE{Xcjk2pLl$q`=$hYt5pM3-3GOAxyzu zLH#!vFv%f|XK5ih3jR3inP*b-Y6jNrqu0kpSAFbfrFGC)bC#;C7N)9B{Jwo1S}Zig z41Kme2ikwXlLc!AIgc%o3()0hhFJ4`^N}Zj4Q?*n`CcDw1VqM#RYYgPqh%;pu5^ed z8SCg}J*3u*ih?`~3r6NWJtfGsBG;Pbo}!z1%ux-vhe^ZWC{}@nVe7Du74UwDH`uW(>Ig|+yoap8nQ3XQggWv=rxAAgdS&UxN?`7&qD^jSNZQ&ke<97fIX z?f?LQ07*naR7wi0%X$0O0mH%})sU6$E>C>uJlj})^XtzubQ-*fS_vM6s`Z503(>pf z-*_+Ll!z&WQlhn{qjQY4b%BU>JgzZ70$3Bkcn~sxB#`b7S;8 z&MT}R((Ma;KA`j!nGU7dh0sVJ0W6WE^#xLdLMu{gtf@d|DCHyA62#qu0Bua$jQ|!h zrTbaHg#9oSK9ANzNKy2iF!s(SeKr*U*0xqs{b$txd^KQxjQ!)D1GvP*9x9@JkPOl! z$Zds;qc7_I4ohlNg>X8A(`duR?f>o)PVT}nz0WluL+Fd}T$L%=+1+7fb)649{scdK z@g<6);M|$hT)FZl{iPLp-Cm618&+2ObUIy%GJr#BicQ^4-+RxVyw!$j13d&BLooy= z1S>1c^m<)(cXt_&$2bpFWvHr>yxZerAO9qS;gIzcr+ECS5A&UGeucfgKC5f1;37;c zL`+Rh(gQ%O)^;x(Z3oQPPKE~`*i=DKTI0RPM90VzZ73m-N>UiZa=%Y6*KF_Xg|QdY zvI@yTnzY4!)Q58tkqMl3cq%fjxOQ!e|Lm{+Kl$88*Z3=c=_lCRyvFKE$$$7){tQ3- zFaCSp+A8s?7c>#819UOqM9=Z~(-$~@W|`|(U*++K&++tQ4=_I1WmtfB9rkU;_ut&) z<*R#)eGe;RVF4f3PvC|y!Icg zZ>^8sgSU5V=iin6MI_k+QOEMq%#Amy*GunOdxey{zZQtWFW!itAn?g820BGiu6r$1Lnqp<$&VB3tXahHg zoZI?Qv+6GR?V{s2;7q$c$Cv@#`t%>>9zIXa}{e7_GT2_A@oST;h zc`R1fg1=9nGvQCi|8$C^;G4YrR?VoEgAx$$2?u=+rK^cOHyu82!8i_zIB)PoL=Uqp zqpGTj^V&?T<8;=ddwb8!OEX<6^r*bs!_ej#1XmE*4NdjH1YMYb6^hm zW54j{xbXCoIJ1w^vHQFw=o>Ub(b9W_D8Y~L?f~m{@pc#K4p7R5jne{Uh27ery0wF} zK`_}$=$=1=Is*uWnYLG#$Ce{TG+nIJeHP{p+vr(v1UT_bkTQ!2V1VN2Gc7 z?i=$V0_%e~d33CVjO&pL^P;`orO3H(LFBdy>f}hJvBr^Sx{ms^8LjpG-g`xhJL)3< z>14!#M6;*{4=;l59xZ*)h0y|UGb)#J_WXVP!598HWwk@6U$Q^i#o3BXJ9Nhcrj*ZO zyr`t36+-rK4wPtOOCBH6ObE7!vw;a=07Td;jj`w~17}%U>f=0EC+a;jM9xz&6(7k& z38<*!u0f#Jm9dgo2k!)Bv_7D`l=`?%mxUx-&;@lBM191LEH&*PsAiRqX8UQ56+G_PNiHWgV_w zyUJ*=&*tV0`u!!&ojr|~86FpPbu7kKNEt-aQzUqL55F4`_;-4=B_&b{VM=+E!hry5 zg9E>@u@SVMoTsb|H#cwLoabPFpS;uIl~-QoBOm(+uf6;tySsZVE%lKyYKu8PX%h*i z@;2J`_#q?3pdR$*$>%Fv@ zL?*G)2k^vOB+%=vu($1a_W5n-JWQq6_|N~^-{ItaAD|Mej0U@`t~si~4t+7=;j4F@YXGqFVRXyv&~MBJ&Jq9oC8(Fm&F6 z6lkfCmBfuC%VL9%KlRf*@z|%>zJ7~0FTahH3aOwdM<^*UK31%i8JFL_%F@y@Cr+N` z`n4Ouo|Y0PJkHo4T3<(Fi6A{#X9KW>$4`S;G@qx>lnVNGDLuL<3YM08IBzkgith&q zGPIPKa*PLh*$Qg8!~WnV!`*%GCH<8SN-3laHe>B&N?B{~{Yd7*VZhgv;~riI89_XG ztm{mJJB!*7Y63WLO^V%}qz#I8yVHj)e?82Npk=0&!kWbyJJV^_i|*S5Yee&$Q+{bX ztt~NM=UwNpXRG`3xPu!zVFJ6TU7{A5=dhpON&g?GSqrkd<{f_O&zZC0HPT+YkY%>0 zKNqxN8mF0N(xvl{dyS?Q(|ezoct08ucM`nqn%4X2i%ef|-i^f(-zR$F{0er9DhjqA zR!T{?+nvx?I*bN4(UykZG`EfO5aZ_GlP~8qXfcnSBc>LJ!v?GknoV3s%>H)zoJ9-R z3_B?q9xX=L^rADHmmPX;k~Vl`M~5^>=P_l~fM_)OTcUOD%ot5RMBRr8sBDi-I_Axt z+cA8n`!J+ziDprs^P6VVNgp(kjP{givmEa!6svxaD8Nh;B_e86066PGgH{<@3#6*B z&XUP4xl(96ne=4ZQMi&%{E<)cQ?sqj5TzIGC|wj4%lydNs4MX_vKS~Ata@0JWUhh#P*&wG?6 zFlTZp8IKBdF6pdxahbyw4&yxK5OOxyeH-4|yGGZqbME0s`LjR&$M_$9<@b<1MXogC zauApz=fC-V=x*R-V1pr=h(p>-!^T<{A%9n_lGa}|mw2$WVhY|uQa9oiIq z4Szp<`V5^;m$%=%98yG76oIuit|_7NnsbwppL_Y3*~>f#R3skxI+-kKg4AhHShOTj zoXPTx7hnAz{pAdu?=vd*=;adUgNQ0lP!%RHsPGt5P?<5{q6ogkN*Vjm*CNv(VtQ9v zYYoN@aLynU;an|{nL=wxrZaS|>10`ODk6yGoX1+%q$LO+s)0F2CbN(>m&(G63_KWD zg=&R9mElgTD-d1>mWd2N&%MdAT71-7A8DVC0KbaqfG(zyC-!;7?LwtuXovQ^Z!T5` z3)B8^mr`NgS>GP!6ol_ChkB0e$)n@Id3`{{0hShqXx+V<0Q@GNL!25rA2d@ZHGm@=eIOGU5S;neAqy!F;) zUVQOIKKP*zVIA!4?D2_Dev04ySHHnvFl1?|55hj^iC$qb117Qm_<(3X{!bP=6% zn5x2vM zKFEdhr`g-wMk!04_wZtsSFVrv!)M=M+hovP4w_X6K!;Q@71G9?^<+{ajbhnv@ae~Y zhR5#zC|6$HdrxT$-K9R)Zr;EYmW$_4G1xtbvq<1^l$AwE*>d?@ zEotHfm?_pw8?16nI;q|Zs;a61f9EXL8J3s(+}ym$c%0G6`wR|-c$t9}^w3Lsr*%=uTKvoijnE5ruUw7MuCe91EEx(N9YmOg6I8WF%ZDrLo3D z2L1eERnvOQi|*wiRtcXC+8>7nGjU%bYUz_kK1ej_p*D<-sSHhJ*IDpt;(7d}2j{(? zwe2R@%<*?|zB+5kvMi*#Mv}^+aX9R=!_wJiz&5}NCJNisU&Q&PINU}(uDnjW52k-N z58!EoK#SU6_p_4qJDawSX>e&?MaVYbbQCCfyU%@OKg>TX1TTC_6BZ=4SNopYd(lFV zCHPeOJh6@@Wf-U9x^yR=4!+fgef==~xtCVt`?(?HzxfHl9L z7M)9#lO|J#=J#Wy!N)XAQMY}HB`4-BCb3OEuo^@Q1rI4JlMKB@6wAZV-g)B?FAy6Q zd&xJPmt$SeQ%9WwwX!vjd@|JUwdiuIe?_G6#<6zJqL5^r4#N4mT(%b~q?IX!(h8MB z)`8LAY@3H@@ekGG&T{_P_46p-tJs*=Cn$a zJ+e1iuZzEZr}ADn>a{1XX^W)m)GcZ`)sVQv$VWSzu#kHeVuNfP3LqUJHkERV0NFYW?hf2=R=SA{zd_6?u`TYw_d&RpjhFzPM)+R{!EiXlS<5Fr{%OYJlI`tn zDpRt%yUWJL33|OA&Kd9yDMM9L+X@Z=6ID(R+sE(5Shj74<47>1KidJ#HY+RGB+;nW zL9fTzAb_3gob~l}cDDD}+}z~cxeL7Z>gzo5~ZS=zWTyjT;6nK{dI~sZ-GxiU-8rd5?8*Jbh%25m<}f8%E=OwARr^2s(>c7g#>dQK@m-lK_*N+YHcHW4{z<4C5Mk2Ca_lRkq# z(tw)>qtXg5WpoiS7Qji7e~V=Y^A=$oc;h_DR2&@Bw9$kjr4UD`a}wi zlmaC{N$?5`W3ry$<3IJ|tUve|&Ky9ok5mS03WOLSyvB@^>2-iv%9OH{U z+`$&r!8X1aqde%P9&)8mwy_ja28%Hw3L^_k0K_VXav91xhF7mcVOi?*u!YA|1_VNr~X;$W=6^(couuDt4y;wF!OLi3X!eTpBvwa4qb`((W? z#)BuYiaJ8EBpJA!LMy{9Xbh@!BlMeG-g3yw}lrhUA=vKuc ztHDIA6`edsDN!?syoJH2VmuzRd$7a){x(~CyKHV>XLIWYm#@CbmFrizeDyNduD`{& z8jy8!y8VoPzlV^L3W-GHDX`9=tU+sm$08*dXOXm=Q6$of2<;pd`nWfbeYX}0}$>lxbDStL<$oZrmYMXs@r z!W?PYpxy!a&FZ@wMV547tUTF&tNKln#@;Oqx^^`)z9 zjkdA6z}gZoJ(cqattpHJA*sCM#;uzyuPz}q>~C+8X|T44gaC(78X;9kC2#A{5$Cjb zb$>K<2h;ze29^MCP0&V^Dgv2-*;SbmV=8+6E~8?E^A%DFDqEqX#0tRKuts_?#v+xV z-(ABw$7rz2z8JH#nxpd!=c+jOG(`5}dwtZw>X$ydH&>d zhS0q1o!PbpKtl+DNT;{9qdLn()ZSU0F(YN%&wazaoOm8+Dp~wOZSbmfuQroo5wmFm7)p;j%}#4$d((Z^?#C7q)~*OH(mp>A#7aOs ze&(7p>sk7|b>6%{&fIy|>H|0J*QQIi;e=xVVTa=$i+?xuDajt1_x0@M=98luCf2Mk zuA9wV#*Il%T)=xDxN~uqC#R`l_1u{QVtt5OrF6n}I^bMHv(8)T3J0@nb)Q$tfP zem{y7{(51W{Imw&>L_@`?u7>>0-YEUGa_K%@C<_gf%#lQ6$6q;>O@hs75f_hw%Z`aF23yfTyC<>7cqD)aoka z5|qVO1;msDl)@W{A5{n|P*yX#dJQ))^i&UJB-WHche>(%sy)PtW%XPS=S$3}L<&V| zCF4?1nhs;r#g<))s*gqU`b*m^Ej#Xi`cZ!DiA&u4;@2>u6O3@B!8x=qxo}eR@Wl*i zHtBbvDhp(`gwyLh^S$f5ba}{Xr^8c^oMP?N27mu|KF{-S-@tYH6y9N^#*9I070tt7 zz&7d6qMko@)2yEa#M@Oa1X8HlD0ALVCOU~phemjT5~Ai@y9k7)do<}nDjHv5F8-Sa zWW|TYCE(XtRHpFGfR{)VN_Oy0tR!{T(^AGXa4{y+4?a}F%42tXg@Z~r7_IJL; zORxTr7k=JqDK%VfF0*$RZfiNN*>&a4mbb_B>hKBNdu1;y?;rqQAy z7EJ6#=`)C|At^}G3#pP^q}v9=D@g=Jy+QBNyE4hXCUs9_tR07`c6Xj{^1X9&Y_8e! zqYQ+Ey}bjBsrdA#Kh6IBK3lhLV#)!-{awzUKS#IQr7XrE+pJMlv+_blrGM8q!gi*(_>>}jq6vha%*doGiT27=G$-a@WT(Ydg2uOdwZ-d zE#Zu-84&YrgL`9*Y^M^=`6FRbwPPGw-p-Jq-q&PGU5idiN?%GvDH_(}I|H(AKXudx zFwqMHIanFA2c^ZxGB8sOp@>Jh&(ja}_zS=E86JIfgIpC<<%sTbkL653WkPk{sEeqS zqHuzjFW=%jFJGmSU2-My#zkFGffpVptH6vUr{lZW19ah4 zH?d{~R(=3)ZJkvUBoE@X;uWkl^!t4V!vR%QprRZ_V&)}-Sa^pq70!n}YpK7E8+rzN zJ0Jur8(jzq?I`)q3x?f|BQajmo7<2Kh}G8}^@|P1^1~L&(TEsG^q~@J`ghxhPUp3O z8qs31_=zHB6Rd#xdD`trK#Z6nEF`x6M8jv9Mztf?`d$3D5E3uI34sN4o?(iDo&DWF zY+0}e&3n#ve}(5vmTJ_`p6=5_#i0?JSxr(Th}0&dJDicKm4rSK$ApE_50b&(+;3qY_{r?a;(eR+f*?XcWmLFE~ux4}2QdxaNXy+M|r;(eFS z@zABy{OXs!!XJMBO{(rPUV2LB@lu2In5r=}n$$sBQM0?p*jO~bMNAQqLN>-ujK+pJ zk*bh1vhIPR9h0VNM+6dcvz~mlqawMD)#2VcL`a7+wnPYtk{a(7%4@XGv17r(?ts<) z3I6zJf0ECB`j7DOk9?RjC(l!rhRatj^TG=+@$yT*!`qiPxp94u-JKnZ(LR+CIG=|q zQG|6=Ngb*RdPPU(C@;d;NEyn|1I`H)dz{|G);1z3)Emzw{9Q&d+|36DO7_id($?=1so#%s2Sbx4z7?FMNYNyU*(SGTnZM zvKoWT3G~ST<_H%IS0^GhEIRc6<{ga#p8B!@oKlogMkYWTk zo?Z5%nH%pEG@Wn~CkX~L;}%%T0F^aVRl)Mo5`&c$Zr;4c+S(c&(Wfjb9=!hnzW3~R z7>^4&c~+-wr8Mp7F}Np=qk^bV+hz%io$UAc-bhhza-R0QYqQ$hfy^Hz=pEQ&pl~Q8 zn!qWm@UjH2(Ox3$h_#;KFZ{bd%_pB)#i=o!l*|MfE6|So2 z>x{A}a4L(^1D47ZjLI>~t1FbYVsCIjr<3Enz*!U7loIa^Rb^31*1CtuKTAO?q8?4y zOD6p^?`;IE(wHTUtdLqLM5t2Q%a?!voGGcXu?<2>tdoT5w-zZq&Rfa~l$0!W*YVyn z+TTTK!P066iN!hFREv8rku1?LZbW3K)=VQGA3g3FVMgH$xtuY8JvNpqQibvjF`s>w8q-#!f=`pA1BzbIJVKP5)UfupFAhBu0 zVfUJSe+~m!XS2lGHcJAmrkip2J32l0n=xASE`&dINEHeZf$0iY0ZD8+%abb7+?`Fd z)>siTAk;sCDf95~moD*X){aZ0LH>Xj`w()!T z^B&Hin_t#A{pr#I8dsw>NF!SFWG1a|>WQcOS40YWI={Yq9od=cjp+rnbu}^dc{>W2 z*mbqA{2|8CqJ^%0#=7>oZ`}AeN#?bAdaZd+k3_{qfe-*iRp3p5w-w%7 zwC*7LE6Al4=yib-?+Qc`H{` z+Z*xauYR2up8o>h|NalTcJnI3;efIZMvUdg{L-3(8hS8{CZ*Rz(uV3f6?Jxkt| ztgQ5T@WK1|@JBw#$3Ff^{^LLQQU2q<@bB~N4}Zw7{pK(8EC2i-Ga3{uudm{T!}$Vm zkvt^ByDmH~27jla%A*|8dx51jZLp2ua~KSDSTotM6NX`0Dg1oDhYB zGJ?uOxiaTj=bk_ zfQNYI@jLKX*P8>-Od;Xc+Jz|QbbAsQ5dOXh)N7f*qFv@%%)RC`y195#9Q~}#Z|0rf zd|oeDw~?lp+A&>mre<%5THbxJ%`|7uEMn`4IlnuM&Uo01FC)d)x%q(LbdmM<`k0JD zG!alq13rofWQkbSOd=wfC6HbO2A0xTH>TeaJpP%F!^%1g-oh3)8ILyUseP2!_;P^p z7N=CbxLRLDU?VVkgpy?4E~?vy&MK%r;PKAlr9-OV3<>X`EU?uW=?sX#7^`mWQeL@5 zZWWpA;%$jD7FQUgknD~3=$+`W`tSyV3bQlBRSsMRuETH$qk*Ct_o>VZmF-db4n|~@ z0!#(ts*8GS#D#MMF0P$nqxTvEJ3vd%Cm%V*r{8yyYH$Um9jm9#kguHL+b>+JkJoNo|s#Nh;68brOr$)FX}wW29bB1xjR;BZHCxEpl?(qd0JU z?xR1&U;Yb!nJ3@>I4?Z+27mMa`?vV!cfP~+!HA-i2$`X@K;g|beU6{_na}Wl{m=dyzwooa$lv^1f1U5Y z^c?G_Pf$98kS30;YsPpHf%YCA@5y$y7$QOh{XeB}KBN#TA;6`Ink|uB(KayWglnc! z2$PZAX1?Fe-gL) z81qMvCRz98G4}D}_rv@u+VlF_xu8SpoTJ8X2|CNCQL7I^|7@^ZT#3w+gHegfLctG#LrICV02*Iv9z6E}POjh28*jcv;S0zFmI`k@ zT4W51F}YR<;iybOmUkEo2jqE{fGzp-_I0K!uDAV^L$hiY`k}3R#{ZiLRLVK+ zs413H7pQ}tNBhg^-_}|@0g$U1fKxI-6VG-CXue1we^H;zBSf_Ovc9jJJ#StgF1lX( zdoP;c#I&x{)JW9lB-Z1kuRlFN`IUPm^r>;ZyA)*uuL0M~gkQ@ZoE& znv4^CRygjqHWwe*DQb$zG(C{he?QJ0j+;_A_dL&BFvqC+2k0?<0{i@Ycu^C+g4s3CUI zP=)8vA>Ym8c-nMw63LIzlqAmUeT>Xmr-SU|h%5)0g|;{czCvIT-Usni=de|Ya8-az zyodb(#m!yJtpOb}l&SEgB_*~QJiEhfdKY@EonHrYfEkR?XuQi94g}*}fjP*jhFzRp z#+VMqW)Zv+cyF=7;mFu4hHTv|IQ7^GF0HL_`Q|Hp;v=PapK}hx*Hey z&I?!g($_CzZO(^3be<l4y7`jmvm*0IqgtZ~l87as;E()G~_9edm{44z7AH2ZN z{M?W8vw!lF{N4ZhzvDmuFaIXL^?Se0nfsS1DR9En#=OAAJzgHS%V+xkUI4n9xl7H! zlF0f!lSDbJ)}EY#E@B#V7L&GB0(YA`-9517IF9tkJDIh346{0wnon$O9}R7- zF{Z*A%i7usgM;gg$77aPVK^Ld|NZy#()XXk7(*w|Fs`Br_Y0;IFYkqwvhCa@>tRwQMUD^}l*h{@vd%J+l3SbC5V_+1hacseFYfa7@4v#0-4S`GPh}mT zF%cwIKEsYOHuC#<_|j8cxpj?Odz)l^jZhv}jziiyA?46G8(7>L$|ZUpx9%7f5hrq7o?jR%lGC;DVHM7+?K6q@6klOjILmGN+Ew;`r{?Of6{XH%&3FW}dbcgqUJt)d?x_I`crK_OrDsD7I}u zYi-Reo7eW^Jl}M`0a2IC&j34zf!mYUZ93#Q^KLRrD9sB$A*pcKb*9tn+M@mK-^xRp zag>dH9B^~`^M!2fV~~s%kw)fStF5DzNIpWyAmT23Lx&eKIo)jo3y8G56m)O1vF^ke z3c0`$j7Sdu0`z$LfsdQ3Ij{3lO3dPpx;^#%MFTQ>-TCY+{Nd$B4{OIFBkZ{6rSBGj zTcWi=&HIh_!I?fXiglad(Y$SZMH-c5WRr+(I#+ z+jWqw;7tx?)l_Tp7PJDTLG{9)T8C1GLSmiAS&tF~;7U}0!CQ+rMR5472>`QmxXpda z&0XZk(ib@t20N@!Hh?Dwqdf{!apJ*~WG6aQ#V*=el#uues5FDUlHp#4Df(2Vhhw=; z9W+v4ybTc|p>RU5H>$XNWs}wWI^1_6=i&SA=kX7$A;(*c%6(3rJIl)H^L+i;H~H!} zUjenwr#|@e9%(!vy|O;ozEv) z(bNfEbkbT-(E*`mHYA$4^FEGGpo2SUkwK{+=RGb}%Y*n{+Bix)sf?=d z__5%#AOA`IkN@lc16x-&`5S-zZ}IGNFQKv}WT!_d26$f~Gf$_J(anNGV5@@BaFen$ z;7543kM~2AFlZE+RAkzbY3O8J=(55mI7$0=@-cTh~7tz&d=#lv8I$C&7jE46A%qUvt{#jjqt*~~V^z_eon89ND@YtCPn_Z!yTbLOCYcb1A4Nn`e|Q_{+y&m3JuxP2B?w-82TFGKm)f zjOl2_#S5$SS4vP4k*#6rfb3$*60I|^BQQg(tQeLBE?>drlHdF4clqk`n~bwHI-O<8 z%7Ii(A9{sy8QLuK$bC;BeTQ2+HxOMxDGaWtkkaE*K+uOVsEnc4?{Z^v6Fg*D#?H=8 zv`tg0pT&BCabX-}OG0KEL`W%W;qOsse+s+`V%4^lQkd5M7I5joT8GveAq2*ht#v)H zaDy?qp?VEVojO;_>LTe+|hY#pps01EMfqEC28Y(&+v((5%H(T^DGDhbW;A1{-a)jp5 zc#qS8hdJzR&D9nm~1@0G~xKd^@f-MMIeU-4V3M`daPt z#8k4HpI>FCF{~k^`poN&=Ea|Ww(zH&+sqVgf$1tg$J=P8o16YUk2;x5MwgyxPTJ+P z2313C^;7qgGvjvD!dNf9_9$1E>(dGLcD1O%?y$BnL%>j6lK&>5&sG|WOY5?@6cLyE>b zdvM zb;Oc@YM0DAOeK)615UENJzzKhYnB)e1x~C{_&$4;z^NSXVx zM&r|02n9it$BaxxXGzoTb?J0ruy>W4n^(Y&5xyeJ94p9^kyjRs;H-KGHkB^rbIw?qZ*R?=SsHo_OM8 z{O0ffI#n5T^Q zKu?IE252mRM+z5lC>i&k;N*!I?Q>DkKCb?ON$P`(BZWXKh4TXALYZjqK^ddoeW3u!>!%K&%>x?q!f z(qfv$ctp7W^!^{utpO(=0Kv&~+tsse4HkMQaausXOLu*p;b@H6-X`}BBSNtm6?dEz zy$-AtumZFL<-i6aGKoX#MB2JcVB--Stq|BvHkd)m@IFc@Yc_#L)9v&a?r&qwY09x= zeQlj?Z-vpg;Dk_kVd6Uw*;hWSo8mo8m71_6eAD-hHm-^R2?vBdUizk>P0bDv2?VJv zu)u2aF5033j}4`deN*lH-8vS(8zBW76w>>cI;e2cVSQk|7$N9%`*b_qcpVkAI7Nn1 z9@H3$oA~MmP8{GneRi$pm;dGG`SSN}A(u}hgrsx|WQGtn!b_4&cCjPL`K1e7I{7d+ z-`=LG3S2H2dxsVRZ+&3t3X32xZJluJ4fnZu>jn=$`WS=3J|2sZn#!0kF38#u_s(No z5oTJn-Gyi~r`ljtVgmZfzq_V-WX;qf%mwEyLV(2tKT=qnEfBuftm)RFgvZCd)DtTA zB_VGYeH7t6Sm@;|><{+Y-zr!?-2r7W-UPkvwEjkhS0lg{s{z)wl&sgfJDFc$oe`~k z*O0lOL}Z@TK%@s1_YfKNeUD&;2H#84w)^V)3Wtk(iW6)Xx;$0k9fWD_?4V7>kmVB(2>a;AIZE?bGZPQuC(pZ`W1*(e9V4RiqYX0) z$`S3Ee)hvYYj4W8Wn4~!6SqmDY(7ii)#6LEbCBmn5AFnAO(4`!z|ieJlemp@7xv|x zzqdb2FX+6N6EC8{88?U+To!4;@G2T?4ebAJr%fFRbYNqISv3VD6HXBqFXt>l+g(M# zS=5WM))F@^SYKP`{zo1L#)xVcXZCS^fY3SkPWY|!RMO+EkH(-4i#$F$f7H+hK7dy^ zi*Oav8#;-SzIQ;mwMDf%plcP{XZWJTjjB+<#(9cj%)wxrrIn1{sT0t#P?cffsytfs z@Ree3bI8G#z?6N;s*h!z!uA-9704deg0Yrd<&<8cv|}{f=G1z@x%(tSZF2EU&ZxSM zQ=ZcgoFnU>;v3(4m4E$}=Qw%dBA@v5r#N--6uJ_>AQ&KQ;aHs3ARquM{)I> za=s}i?Ako#V~*)@kE7qS#u%-2d1TZ`^bPg7mMs>O*KTh(3F?wj|H^qEkifJLNEga@ z-&5e#@fi=^d$d%P)*16t`Fx8nNVA%_Lc7xOMAo_AkFpH#3}A@6ge( zytKsHQkQ-%&=@oh;Vj;kROJE1I55{7NkCk-96*W1;j9Y`#u(`~FcU*hW9uu zLQ1-Q=*t|C6jk`%T1#2&G92$P+`fXb9lG6hPM$bVfB7Wi1I2DR;LORBTz+edD_7no z&q2x@V+7U+@=k^`71kJ3rZL7u5pRoi7J)`7Nm*6MsIMpRF{vf?y_XaA#(Qxj!%@m6 zXyizhr2=Ch&X_nRwvM<5fWuE-%!~Rxvd#uAjF@8L?=_)BpkZIN`y==^2^aQg0d_S-lLQb><}L-l19>*_Y&^~&V>MGlY$(3bc!9|TTG~hyJnr9 zXz?hll8kViyl{>OKlWkXx_p_4ED%WPE{IKR#w>EzC~44DazLFjm9?T@O|J%!dCJGkTBUZ zF6th}XP3gqwW=K`j5bpOr4sufUQkSRdJ2TR_xX|@ysdRkgLCe1&fuM3XSZT7P;}Qk zG8kn%qA~?gzqe-o&iYuZSB5muD!U; zcYA$2Y+M&*XF&wJ2pYhNC^JojNFCJv^&(=*J86H?sw~Q~)+YDf*ZW+$uk({S*S8~4 za9H0T1{%&I%IJx-wke~%**wz59RA)QvQv$oLw!rMy*8i~L_o#NZ`v=eKjHQTJ=xNRAZA_wL6|@;{i}}pP4~^@L|0ANY?c9b zNV}#tJ8{J1ZCFm}GdF?$9yaNZlZ!ju|MIZ&(&oov|3JjZ;bO2WfH|r9M|hzV&^a&t zLDVd^#t4{xrZm^MYsc*@$`kKi`@aZg=US5?sLGOlFC)_$yeE$o@X8v5x2&H&#p!dW z@#X+)b}{AvB@9GxCTKx~N``olUV%_36{7LhSB+4p^aP4X2{GwIR0f6v+-S&nbCb+L z-%3Pbaia=9Hb@&-T;uTprKwmsvrOkiAAE^-Hn83t1X{kt>{JY{S?pjLZ%;6)x(v!b z2jh&&Yrp-`9RmVCB~J(Y4-ul3z7dsQFe9U;Jr+pa^3|; z+c)&)db-vd>ePE2Kl63k0W|<-AqvGN%6s#Rd*Qev1Hgrizmw%yS8;Ra7QNH(3%~eB z`Q}Ss;klQd4O&2UOgY}8n|U5M+hwVnhcB7oqLL{TVr0jyvhCd-OrEz^iQ%ofR|o<4JSO-0Z!4$mT|&SSxctCIEz*Ryc9rH z`A}_Cf;A<^J4EtBka$#NG#Mlzs(z={*lss9V)DnXUMwe^wir=w$EX_{sc{g5XlyAN zquz4@>*;29X(qpu<8WrMkU>y(JQ{NP%mYZJFoS~8Xn=Qym6eU)cEw1pl*xe3>r1s>bW8L#x%>nUum=;U1-3R6_*ET#N3 zX|BnV@(X~h)JwGnO=RFXZ(A&f$?8nC9qb(4P8TmFqrr%cHL$MY#OgXXuV2N*JvO9P zw{_9d;TG5&Q~h-eCi!uXLw{d8&W*F}r&6X8csbE7(H>(_Zv_E^m~U_XXb%U%M#f); z4|&>1VG+)vg{7Z&_|CW9So3wph#!_#u!uYfyAp@15q<+Bt4*ZZax{xFrJMo3y16*qD>=y$$tnq-QWZ zV10d+vMeaeF?pWXKym8tc~0`gzf-`~w}kd1v(ZibcGlr`Dd0Heo23i87W*!JKl@)Z z9|Geh0$*N*!X$al!Fa@I1iI5fqVT?gwpp=o+Aan+j(?-`F=m=5;c z*18{0wRnV*wf2~`DbuRezal37w|@gjxA=bZWPujVxkVsX2!1t^SDNIQPzO(Dm^~pv z=x=MZn+7_pZ9G0@ye7T00-J^UVv zp5?ILrUga_QG>A4EDaH9t&?gTzTSZE!SH%$H`JO$G7g!Fz}1C|_wm@J2l>Hs&rua6 zy?!5W9ZKuqJ`2zJ3ulnaOW4tMlsfx9Bk(YetpE5k%z2MTz$rTIYCIVXIA6mJfLNF~x_TT%o(R$6&P0 z^7;}>XU{PjS-$r9@A94J-r}+MJ)N(9)QhLQvnsQ`GAmNkU0TN5is7)p zT7$I}LO>=ZQYxqom#B>-lJQ0mM^E>4s&=w#X9H0)v%D9Wn%$Rp4yp1Kkwp=Jcvm?j z1>ReXw{-Fxp(I*m^m;2SiyZGQZ{2)}n>!^R{LrU)`jb!a=YQ#!_{dZ5<5zzD3w-&j z|C&xG#}t;$tpT~zVV+n=CKc8@WXEF|(_iV+>2?{JQ2@Fl&?8)`=qhmN7A+~!WbU5h zetXR4_P#Ualtk{RpJP0xJ)(}z3V{@XiC6T?qdCUXL1GESYm4}8Y<{tc|Q z5%@7US0NBsM7_4-Jzff26+qNer%$nUYm21&V~jz{#1w7oQ?)>(_Q=*CRAOPenvv>! z<6O6cwC(pjoMUHLBkd_TX;-x8XArqgX<|Zzf->QsVXerr993zCgE9T(9Hk`$>#+{{ zFP`sr1Hd}u@IJKRhB7-4!*?(2VEp1|w#lZ!$2V z(}Fbd(4sx~%|I`jg!A5~10eULM<0Ix<$L3 zodl5%I)~0S{~m%yqhLNlJ+}uvt)<5`RUqOqsUy|?Q4qhZzndEX@GZkw)?~}sXGm>t zrAmr+dPM4zX0A*wPR;X%b0+&??huGcsHf@Q^qa}&Ztmw>|D~BTNmrk|&vB|8_#i>&Nfd`#s6vL9Wl@%U-=n^kK{{l+s0C4$` zI-rF>YQ=^7F8~5p9bkuBXfX}~@ZRI42Ui8{0$&is!7Yd^^igG@Fn9thQveu;AB}PQ zL)ahU#w9pM$4mUMM2rp609Im#BTO;E=?cA`(>uM4>wM5I z5+P^brLk-=7Vq|PJ5F+B zRh1}}v)o-J^EqGqSKnehc!Upr>H+@x|Kq>pPyUIY<=1}o*LmifU*q-HU*yKt78*~# z*QL`5(PS4vKZ(X+13M}@7(S{;>;y2Po~Gu(IP0)5rNx{3q4$M;@-mRH~UAw>YNKu^DCMDI+-s2UF>UT7w{kbFFwtg^#k-liBlP)BIE5V1;6>*-{e<+YW=+!873}P8MGZYmQH}%K&{U|X zDHcTEn|+||T-ti%d;=`{*8UR#w8?ZL9~ogO%OYrExmN071B3;~II&I|f6Y$xe7d$z zqVv%f3Ltn0b_$V`WgSN4fU+?3bRHo`SKn*is5Zwt)03O@N1A=uLeN-}i&M^(S+tqdeoDNCqz&#&wl!!wHufO2_pijNYJq6Y z1?Jj!wKT;vL7|?PkucDDzV;|4ovHRSOf!(`5e^1yJ@4AM2cor)#1B$d&vZYfyEp#d z1THo+wlkQ*oN=fb^sZs@r8zLY>P_r{Q}+lgygEWK3rNT6;ftZ*{5J#T)0b(^{B(x!;L_mqdqaD!X?f zZLr(rwbx(c%3E*JQ%UL;l-3BLD5^0h*Lyth;C;XlVaK@9E|wCJD2k#2m0>+tuc8B#qB~K`;axJ`j|1#B(SK2cQLfIh5r_oWwxh=) z6jSgKT(0TU$>H(M+AF3uw!4}3@}oMO3_{SAuP8@XS}g!L!ePhgVg2Q zlf3VJALJ7s{Uks8b3e!9AAUb?y}ifZ`@6r#um9R_^8B+uptG`sM=~CIHdapYbD#Sq zo_h3SEN9EC=6zPOz%J>o_SpXMO}_lC&-3el@DI6h^L3U^FEOmfWJ*RFyayN31*J&y z=jgd`6odAta}NJ)_|Mg;Esaq=FAZEo2g1kbq>S%U0y)xBJ004((-g_Oene1V#z2Z| zKvk85G;oEj48|BtS(0Y~D5^|}b0M&k=`1nD=8~L}G_9xi zR^PHqLVZX9K9s{quDzEMCv#+{gVz#6nEN4pHx&(N?h}Bz_UJTK_R`H$DwR?i1X$x@ zAV!j9-M}<+KJNGqXRDe$B4xO@cNXJpECS)snXJLDFj?*%cy*U128-;r@J7hSd5Lpg z)>}x++pVuu8;Fg!w|{_CDrg`^ZJT=k6%7+gv~B8XDoTqD z7eEs?;W!6Pt!7Sy??nunC!0T1qD>CE^DYz)YdgYz>d!RKriMP);ZYCLIh0U5)CSa{*oMu>6v+v71e`FrWGw(XJJzD#lqP81jmo`1+8^5=G zzdEIS4k;n^YkFQkW&hTFU0++)^JJp=6GG_?tLeCNDFw3HYh2rZpHJGDd`8a{P9U1= z*3Xj!*^}+l3MS0MV1y8qWl5H0NGa)dy8sM_!-?OCdHptr0R~}zoBNoxf)=m0fHJZrPbEOqZ6WqK5Yz%VqZ_QdV)8CfNf(rEsC>! zJE8OCO>_dLZeMo@t<_~3J)Z>X(u>`G*0x32+_wF@_UDtv{w(D)oil$CoQwpgKi$OQ zIDPHu-_@J-oK^L(xtCHRR#sLh%QDijC9_o)C;xQbVbgXz1p3)!K;|q zAskZK7|8@-W0ktW7{52f4MzwQKv(4i!djehcw>>q<0^|C7nHVwUPk}i3G|A_%M#oO z;R?W`y~K+QOa>z;FYhzHX%JPHT=WyDy!71L^fpfOlYi_fI{jt7^WsbV(=R^H_pc7f^JR>; zcp>r9P}(vGtxAFOlFB-y>PFUAW7GI(<_ap+{tnuE14?)u?U`?nP7iV_Z{CueR zgNROANC60`J#9z0S=FY~O5R)A$(U14kUCbxEXuV~L;Sb@_^)$i^DR0j`&ctV ziNMGSiL?O_sO_>tUV&p7>38U?)Qq>}noUKZQ>~Mzb5fBFcN;F@ZNUic8Csr`62 zVZ5~21*u1*(Ba(*!fz<05x~LzfWc^twT3Lq==Hi}T9aj2OdEDcCGpOLl=9dIN@?ITDPgDm@J>Rk$YqqT*F(?uakzNa#hO4&qWY{aBgAXmuB{+ml^DZC0 znSV)Z$B8fxr4OmznUGZ8g4EQPi3;m1F0MnNLa_xQkzNBLux@3jw$|(SIXE~7j3948 zNh+Hv3nhOCWJ~a^(Va>}gS3~V5lO&L8vGPcY1_RmeX9ggrwI@zf%PdM9|0|ATb19F zSbPMZr<~m&L?0%Qa~>fTT6HPLBb@7pHA2Y1GK;qV^cyq-NW9~tRqUz`YKR)JL!`9L zWM?xWo0AwTljMSiz2)0``kZqnh!%6MbDZ~GCa@;9EdZBd>Rz*z{^E~kRw!8)Ch@nm z-qg<>K!?P`itUmu=Ghb(BR)H={_w5wm}z&*|C_W6e4LBaRbVHB8?l1tB8*@`zs~@B zQ~ZqvWNO+ruh;6&T6HXG6kAMbM1Pb z&kknLZ1t(|LW=>{?wi&aH6UV3w|a{0*49VV(4^o6$0SrDO8L9a6S>@t#Sl==MjWn< z9=4`aYww>tXD)MO0nKn?@D7Qh&I7e(fU=gulvn^0;8|*GT->L_s=-i=FHZ}MXahS^ zEP95i2_G%;)Q4>##KsAswGMRKBfz^^1h@fjQWQY6&TkCqHoDpRU2AHk09its)1R(6 zqoIxlD~ws;X)*D>wpm%TM++bolS@fV$S^JzO9X1P9 zy2`Q!u#(7K6I1lHVIl}+id}4U#HH(blNRDd2=-BP{hb0EzBMpST1MD>4IxcXXBpO6 zG8J-xr3!4cOiEUkdUSJzEeDKl-b7y8qTlO4-i-^GKuH%BZ!I{3b0yw5Y-Mmofge`{ z^vI)yL?Q5{jUwuV)I%^jh z?peO~ofmlXwd-6w_Xr>S@Q+dEtNi{~zRMqc?YrDGkT0!M8cS&$NQ*TkLWRm>#s&tB z^?{vceQVQAX}l6<#Vp^Q$|>`&DVvC4D>CbR0IC8oCCIc!NFRjW<5Cqc%*oUl-&#i| z=Wudv`ur|2zG?6d|&$l%}c-2g5NvEZ%AQ*#?Q`|&75?7;{Of$_i(li#7hmP|8<&y5YNv;!gEv4W zv4uyFQB{KLTU+#4rr+z(>2y#&l$TdN=j!#F{QdvopYhYb@EQKMfA0Ux|LcGHn_Rzj znWc>k>xM`f9J(gDA02>qFot(4l`!eNBu$d|DM`zz?a&D!==Qo)*5Yl2QW^M=Uguip zB)ele{PAua$B7h&ceU;IHGdZ(>}S?llvE%BqoOhu-Z_-a;+g@wySwb~kEkjeJ`+BI zr=DK7$Cr7|ir zrYa3tp2vA4=Q3mKpEZunJ31yn09xTW-*`w7Z;NYS2<*w0JW4G~LMvDjt12e$N7^46 znV%_=#3AqW#5>jc(FC{?IXyL2BV=8mq~4!W|JRFO)a7dFBc*3e*YgRjZUIW|s)6l3 zn@FKV)c0}T2VdAVdvD!W(f_Lb=d{+;&wZRMgZ8|bg2jC6oEpq-r4TNf7p?ym5pE_j zy{5)^Vs5t1pZRS&n1*Nx-KTdYj8QWk=lj>;b@sL1m2)nxA1R|NRhfmXlcR*BC<=@* zbHTfre|uj8*NHqaWAck61}|a(jAq;#`c%;QOTM&D$Xdc5q?C2^N3^}$)sHO>68qIMU$yvDYe_zTr0J+-3Qb6ms1R;iFZ7K zo$WrFc-cig-=@cF9&ppV&ka~t<7E)oDpSuiozB{{B}5jaZ`yQJCVYBFthFJk6<_qQ zXh!2rHilN6@FE0tS*-+mPV{rsR`BmE7yr1&q0WnFm41ghx7mv%#;NK`d5BPj(N&Iq zw};dUXG*H=9g5ekBDx*OJ4m5GNUW5>7_}DR3}Ay0e2iv@Sff6aQFqZ{A*2eHV@gvX zq(|loeQFim&!HzlR^Up6tw5R(Rdf<>GeptF-!u#^5AeH!t~iOcJ@$4CyCX+obF5y% z_?+Reit}Q~$rEE9`M??*7Yju9I%8R|bfU}3$u+Y6X>PnRumI@fZ<@s!QKJIsH7TIj7JsYq6&plT#A+pl$1e) zJ(Fa)Mr%p0-(hKaiGHug>go!4zD%dn2VgXUot-^4H*fOgul#^lU;6`Ic>ZO6@Z!tt z?Cf!4>n0^*a=I+#%Rys>3R9G1(xb@;{4;@27S(kO%Plq!2AI7uWm(YEOI%#Nz1181%1uOd z>-5Tqf!8ZyGG3Fdkb-?eFgEavl7>$eCPEabO2)Q(urmUn8RKByy#nX&{I|H$zy-(;DhmYdg!ReMN79|iW zswRa)$^Zs>$wa~Iz-EX&>nHcQ^j;+v@OyE1VS^2XKst?#C19;G;5EWGj3Ox=l^p>p z?gu`m(t{8sUKkJxODNi6@hB`B@Gh;M9&bDyxeQiwP##eF5tR>VrYUgbJV+J$%0~_OWhD;lQLAbo#9{Ak3-u}y5srbTWfft z7^eG)rl>(+FC{_yut_94C~1VIzxx>Yjc7*G|FLJ}#5>Y{rWTWzRy$gU6V3QfdPt&9 z(T+V+ug8hxQNNoyRWNpFp=;JoAh%4w*X_=3&9!MkfHvUNtT4%j3~8`ooljt&jDOMbw+a>^QaiWIBM=@|P3A;n)&MvEQ8c&HR_m z?lredBB0rpAW{*#3H>eVRl&ugB(60kY2MAfbb8*_>)~S%D$SE=Kyxy6kjd`^^?y1I zxD=sKbrRkaLPlC)Si}<&?A`2u&8LtBD)&quC)!ypPB#URESeFE25iw$J4*FcCpxMQ zFvfwKaS&4kutg!9w~!`uGUX7@ws3aCsMojFSqZ$Vsw(EHd&b3xJkM#s!#OXRXkSS* z0NE6+b8g;ij0Q|zr-kfJfobi{FMQu1^zDU1%2p+{8Yr3sk>-zCa^~lkQJrXaY;HYY zd^5(=ze`cTz%*2?wu&a=+j{xt<#ErvtP`I#4XK~6HJ^PuiYQuNi}_{D=o0?(x7QYl!YGWN(j^Q$0k!!qT}* zFqS;~r5Abr`78AM>->?Q`bpNh=XmLRTYT@zKF`0rL#6t7t+7s0Rv{flYlSNf0*Uh$ z<2_bLEQG2}F%2iIxl!CSQI{L|Xl7pgNI zz9&W(&41y6+J8CxeYod9EFXY=DI8$1_JE?=1yN$X!&RZoxe^*l#!_c3^rwa4_K3mu zE^q(f2G1!$-jV2zpu3#0vfgK9ZHd*jW!5*ASwFeT%IXrmrPFNm9wJ}M>75ry1yO=0 z3a~XV%v$MTJ zQB{<_!eZ&^E_p}M6Dv48#uiXHv=n422QRVSVO@#NErr}-Z}%bhq&ncwPQ>B`+I>o0TaxHv~x}bqTX%f>NbyByN+qwCpa(y#keTZSr_j- zI`1$l3U>E)8IMOi{P6pE`st6ey|+uMM&=QmI(ZhUGG2V~du(rSbN1AQFy6Xl115Va z1%;dssVYihOkir|c^=XnLh1lyioK%X%!eN4$$#e;_|9+r2G_svd2GIf&ojK1NUedu zlrIWHAsr%DC@ILCAX6IST}Y9x+1hQ_V#*l{f;LDgD65LCtu2<9*Z9DPo}j<9gfW(t zl{GHj*W=QIm-xmvzRHd3Z*Xfa2h$g?iSI<)o>=hp(i`#nD<*jNzs5Mwaf(b8{h zIq~hsXs`Jx2;TU)5v-Dp;r3qZAPTPn*eo25zk_7bR&1XiMwii{3rr8s%w3`JQmb^{D0CIV-H{}mqSfl zOhB@mbYfeKzs8PlgJRK!a3Sz1P?*Zaw8A(p@A7)uPTfxcjF7E0U9?t?mO~z%w~>so z-XXPWjTGP-|0}U<4wJ@dHL6Z2n_Cp2#p;|`V|rB_V#|2_UAho*+T;a4JSh^1Z}3DF^x(4 zw}<|2!FA@PCQdUJ(=m|^HZ}j6k7rvk4caB2H>HslMG?SDrE2gp&7I)F`-wF@{h8XD z#&UY}+i8P`_Gb%B)$O#pgupduS!hV2%r*4L#p2QJmrT?vNb8)|Vl)#4@Qq=2T&H5L zHkOBpB>hE4Ykto~`O{#C1oACtPm4}CJ0Xs4N17ncxf;9z!NrRg7!HT*9}HW4Idf|! z#}F31ebW@ghAB4*vd!!J`aWX%v&rj+-=)}FiulZT!e{zD{@;=D;dtW~+*WUxvaSr( zo)=T&IuRjiD6)3WT%JqWY`u)ld8z$=&I3e1q8(AGTmPdo&<=Z_>VAy50U@H;I26sM z5;tod*;USCjlns~csQhEg07qLv5YwqX@g!vsOlsFbzTUJ0DKvQj$JT-g#@%ibtTzS zAGzE?^;9f2X|Qe>01;=vS%h;)uMl*h)c6}?imN4lPm$R*%s68(gt8EnR#OShu*x{7 zpl~H#I@V9-T)c0C6XygYf0=A$#Ok?qx@#vOKgrIO5#RdqOT6*gCQp6jQ#|qXC%ATX zn}6{e-(*=ngjjkIr-uy2HxP16;T+aUbQZPKWDZhOR3TbzmB-W@pT%QqURF}J_eac+ zy9wsZyH&|JbdPD2A?>GayLmhw zMrL>VT-M-jC<>YO%w`XNH=pD7e$aM;+JP;BRFa}JNUf1lQC1~pJY;t~BYjn{MbcK124UXkS))>ZYLrcJmYdL8+xN}@#w_6ZM} z&M?+d3D_5w)zhntAH9fv|AV-1ba67UfUOMsmKTD;R>+N$oW6LGot;gpojrt?p?#Uc zSc6J?pra0AruDBD3nnq~EThp7u$(!6j{edzw{C4wRfd(774{DXTzc>lKl8_bmVf^1 z|AgJWeVp}yG3*6OW+<)V!@IaAKzQN^mehOmy@G5-279z!>x|zPJ${irn%IW5JwfYT zm0S50f;!GKk?p>2zR3NgtS5QJ%z? zf{ZS`ZlB?JMCHdgW$|8t7Z`6y+j#R*I?^gNJUn!~X68-aD+V@Tn?7YDyTi znZR=(0VW2IuQLNC^=KgI&icllO+b~XC%`0lMdP$0h=TI@jQ!jm>)BMormA^+852H$ zMp9}jGYo8z$OvzAek4cdS=`~_Timf0;9aw5T3{9gZK$9nD{Iilwe;?4Ihj^k-tA4; zx>+#K%4)A4Q=E!q*EN1!%fpKQ_KpCYY8@&DLu6n^KyE>hsa>pNZnNhy0>kIVm_|;A zko^G-DZx26e~oDq2k!7#MEt0~qp<=OG6fI0-t9JTnq77*R@O1wQ`_9>yE*qxQaVbj zFxQjD*#sCljT1E2PwBwzjJHK&f7qC$LnFcjbg_z&NY0b@u@tHYfe>y|}gF=i&#RFp65Aw@WrRWs}=!U-Ms(+2e3lO?{SVQgNE} zK@m1SDR4Ga6e+8!zUI8iHt+89TRXqahdaWqbzZpYq_gUoNR4c@AXRWUY7x0@N8C5A z{WOw1iE6hutT+d2=2ZLh#cf%X#8$?gZ5Kh8@hh4{b^8mX8qUd4D4z9>4N0OrO6qRC7 zX%0%y*p!rR$jXM|!u=bZIMpFrE-1tfOKY%t@jSA>jz7>`|KT=Y`O@=@i;SQAnV;v< zrT6pNE8G0uAH2-gn;B0%@-RKUjvc**Bgbhlg(1)Sl*N8vAa!(58?+VPm1(!5F$7c8 z)7{pF_HHtn7QJb?EHN*N)c`7(a4r8lO=`8YUl+5XL1l?W-l*QNri@{}w1QGOLS!s0FM|jv<4P)) zmzG&wUFF7&J*=^4rJH5J`$o@AG{Ge+0z*kj8JN9zx>;W9a7aLl00@;{qI%2tOk*lf z-szC*4oWJ#a~ycf#*+_o>BoPZH@^7{ul(Ux>Dw%VeEv&pZ{DQeU*VKK zi7`QSt6k1q2-n(xLxAI6JA(5|V4z4*`w5F!7LWkn_P6sG6YuT5;DifDx63F zrbBtCEAe`digVTj(!2Pb3;vS`PG3mnGZZ46@%5PP9+o?hPwl_kc>t{#>FT&{gIn1<0N)2GMz+XF-7*GYsd-#m2ng7EJxBg zYp;i`YlsG)pd+y38CZS>yHjB@)OSp=3XB3&Hk`5JS5_Bm1?*I0#+bt<7$ zDrWB0%YL|Syy!IHLq_&oyS>xw!sCvi4lkUG$aV8TA!E0pm^R}yDZ<|Fghh^iJfXc? zP4ysgom;#)A2m4=@;xIEHvRj#6EOK=OY%cPlQ zTZzd{wbicn7|mM%=8fa*_p{L9Lu`mcJO(I=qf}m5gJg*TZlPO!hv+) z9KJMBv{!*qPz2!0ZX1fN5?^)@Zi#B77!L%dlsJ=98p}c97+O!|ExPYGaiYVy2YRfW z^c406u4~!2?-csPGD5(3%klg-U*@@Qy~TyIkMZ-r_$TSCoaGz8`!)XX^Di?Pon={F z#QRfJV~HswBFm|~!J#Ot3hMwV*dL8SIZAIrKp>S?Z_}K;Xit!19d~o1o
I|8CJr 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

969O}CVy_Ydn!e9OM_wfAc8Q%NS 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

gzxgCdSd*5D& zMst_AzFLSbu@NKOi_oa3f5&XX%#c7)mnbL7Yk8j$+Y=s&V%et3eZ+QqwU^U z?K(8e^zC#Ocr;3zv#o)@9rg7x+LpF9Tuhlr7aaksrrp!#uG!WvO@ONge#Ob;JXcRJ zWm48gVcve2#*DJok|c?Dd#YuHs0X-jqmhno;&m3^IJ2(3k0z>u0Z_A2`HXK4RLG07 zeqfcGZ@G!5PCm_(Pn`mpplv~P{g8NyvX^_k+eIl4Qw2P(gx~tV*ta6c&ehMb7|MkX zj9nwbLFUJ=`WaeP{y)57uAhy58Tl?|Vi|vCcvGlvBaLwF8NRuS{gyIt?H=kpnx|;| zXY=mKKgIaxSR;H^C)?%Who0i; zr=RBFp#x-(pTiUav}N_+Dr*OpIr-FChQk6sVisc!RtRFo5L>e}Dks4d2w8C8@GAXW z^VG==w1cdhGRTL>#33T!Rs!L%uVQ?bnm{rlbWNJ8v&PpScdDj?wyYys)=#VuAo^0_`J zt0aN|UUGK9aGjHh{R_yf>_$CRkS+>(bky4Vy#4HTGKS3{vbIIdH8pB= zD)d_feXGPc5O-utA`^OpT^@b<94BtQmE~?iBCL0(CHbE)BezL5CwFS<4(7;*m@++#!g)_&Lv(?4%opZmk%-X+Z z?{&>{YIG%xYH9oVIq5rD&F71*^WNU;f8C#aHkV9uiA!WLhC?&F-ODVth7X${n^##) zeKCLvpolRAGVSp6*|R+M#G|Y(A7wq0oEmDJ>L8p$MQ@S07(;GM{F@k?mkP(37FW8~ zSirShGrc}BJKUu2aXz6!{S5G;?YMpwi*>cksS^nf?Vq;=HJ_^l{WZD?8em^R%woZxHuO*0#`wI!+#s!U}5-vQRj$k}_@aSmj(L zKr`RA5AiTEq!=yf%89-17A5&MxSe*`GK%rP!SY&-|m9O7( z54-(MWVVVBU5xesg=YkMrM>iWx1#J@zh2`!tEJd375bR1gt4CyxEoai%AYj_x&Cuh znQXh}a@<=1ccOZZbqytc9xMH{BG`MK_iN#67SHaePW2kd;3v_@cT>QtIqM@rfd6vY zim23Cgu(}?EW8__(H3XCz@I7wvcu+PkH;Q+jN5L10qZMgICrjxN)mdzeFpsAyRkl?dIY$=67B8bg(+BD&Ny=90H;pvaA8Yhm7uUagc3gMpsnXgt%TyrMW@vqP^x|mX7M51|ZOFz_Iu^miV<7VVF z%?xqV`l<~Qar550&ppSL@h<&K#K<1vZ&g*Y0pSBuyF~lBs|41jZ0#02@%SmOJN8^s zVLXT=1(i9%@99?f_$c7B2Ksexe5@H`0)y9Koo1nvu(rC4)rRlf_XyT4cyP-VBqa_I z=17^0A%Gx=22*CV#Q1sDw5UN5)8?}qeNNn$nS0U;n%O{01xbk3y`88e5&&=_I5$4N zrB?v1x)$Y`JR!kDnRdI@91%0THSUe?w=?FI3-^xBuMp2H1~6~EH=O6Sy(Y?-pn`XI z03Sx~ts#4qKB=`ce}89L6$i?Sz%&#!V6S|LM}(xU2HjXoq7oE2L#O%H!%uMRIVU-= zzQj@|rRZBo1R9Gpp#gQ)nUg4I1FOYi@h*iHj&0Y?5^?kd%nfat|7^~2;qY% z7cN1{1Sb^6dPP(S5o{ij!R8Gh(us=wDD3z=ht@r(4pL0r^QY`r)e@3zcV6~VU;Co< z`+4d-Unv7v3vm(h*+nWq`z<$Z;!HW`9S$iho}nwfyyKJy{gh>BLt+~zP(pC# z>;)e9&V#)8RYy3~HJshm3}hD!q*fsW7-4a>HPvQ72PQIcN``eLR@9g~WlX;bdY5#r zWj@4f>VLId6TUZf`J%=G8+A#vwibIb7>_-`;*C9G#FcU$kEjOmV2@L#o{Y`mkkSX} zx7M^@Kf-3JKVuZ^iE*mRdXC&DYjrK-)QjdAmCv8DRS25x9!4_dQY)SpdVxpEcLZBF2jR}vCrdXVzdzOYA0IPQ zIu*v*KSSC|kgLbBYOJd{`}tz`oYEz=+&5#O@z`=nJzALy>=Pjc*aRU2gWR#PxlO0j zq1)}k_5fowQl*s6q$PvJuU8v^fgpsrU?eNoh=av$2Z3XAYZt8rS(+e)CbGhsog+)u zQOO(-wOzwi!DAckA5~nY&GiZMwC-jLREX;#Xkwp@&Wl8* zIBNinOcTy;_1WIp<@&Y5bdrh(<{Tl`m49DU;AA<@m2w!Fm=Z`7n7p9VUF5>%4*g+H zcX1hPPMRfX6QE)dLYCAzQo0y;9&C*L7+SiYy}i*VjeV=8fQ<+SaX+jB=U1xK>n>fj z-#S|M;eETA4fnjH-pqnU`5`%O()@kaQnY}CcWQD(M{WMUZMGuMfwKVnfNqX04 zjNkH}Nux0hSc%UJA%0}-eSZ9^ce)VAR7!yeo-{MZ=@PoK)%(U;|IfQsj_T|4>7=pm zo9>WRd*$f$Q&j#@53<%ia1%bKdahE+hq58c=T*X{c$p2nQe>SAG|%isPHy z7${a_*~I5YrK?2zTb|LjXv-q&0d-s6A5gz)A-hUo8q2H~o*jaASFr1NUyN=XEpvU! zM%Mn_EZ4wE&uqJ{brW&*$>_nVI1xwD|LD?ZtwkwSI}sxQ>CmPbJg{;L^VcC%pUAC@$=9B*Cqt)5*y5oHWh2{`%|L+}z~M znKNWr*4j_rm9qNWU~p2Sq@}Z%kr$SJ&mdU>7D5VjT`>$>Enc+^RMczQJbb2|5m-a}kp@qkiZIB+5Rny@%2a7wVav_z^>wzsx1)*?j)$dKK+Wq?K) zhK;Rz6!^XZ<2z~%-OKp+F=}aYJSX;3pD7l+`ne}16h%RrrWkF=(gb5P5=oLIK6a&X zBr3s54`w1n5cV8OCO!+3bmXo;3W>!BFqh7G-e26Kq`-u;Q;j5GyL4jdtkPtHml8>X zsSEKOk|9o2pbrJ+ujStAF$mxf2KHrbw02=(#QXZaYY3oL#~iW>9AH~ zjAm(djm4#9`u#pdF+?UA&RUcJZ(nS>nt8Bey!R^ks1EMno!+bdy^HBs$7cwdD=6)g4AeF`8i?ms`U1a_1iE~S8My?&N5m1 zfAx;mq&2no2nW`Bqq0^CV=SVu6ulu`X-E<sZ^W3A{&O48 zQo{Zx!Pz3nxgF2630Zqxef|b_(+DC};9(pU*?QiO0*X!DCdlOBwivuVGERi^EPp2n zOjCh!o~fmg-iyGELZxl}Q>`fv^h>a&WH31={r)NgSI2rFR0$c}sx2y!K5%~tAk_^F zq5{M4`<;(7XaQX_@}~8Mw)e@bp{|Wd?5*-SR1Gm`%P<&brvP7OsUQVQUR34t+sVI+GjBl zUir6il^g-%M$Wp}5vNb--a8H=wKcbGPDm+B@3-aU71q}d@Z^(^`;;6?VQdYklOmF! z{qtj!%H-?l+2u#?=j3pWCJQ;zT zXf>a$7mPJ5RH)6Bk130CQrU}ga52j6v;{oz9nr|dbn9e$U$g^mzlHX>FQ0*RL30bLq+@4I=pavFw%7AF19UWwp`9 zyA}#he|I8Ma%v1g5r4)QD>3L|WV`qs(gRjLNVYMi+EeUIk$JqDbFepmzm>i8gENq* z3@H^hm28_KHdPeX;H+1~5-G7d$5}{KLh2-GqP!Aft#@&B4ylsTtY)0gIE1Y%{e;YC z)2y0TQxQ&9jGy?K@Sv>|3MUl~iAIn*kk$kXtf-XK*w3MllnKGyr9vr5l4KOlR@QH; zF@c?%kRVXls2rAcxP+{OtA7}pWnXo^y5`&D@telXopkM`XZOt7OQUC)I1+?X0oM>1>qu0F(FHb-83eUWjH3iWkMq4>LL;R;UFeQQvEs>xCxaov9V1?@i z2BCbS3FC~9`KlC!(Ii=dbDkmMM7=NKy7g|-l@c0iAI_BOa=CXoXHTVR?5>PzG16_2 zd2m<2pf3Lu%c%~vas(Kcr<)$=Y_iTk|P8e@#lDl4ODR|Ga>R4Bq& zx$*l;@@{a+6_JgpbweP!SRnpNm&-wMzg+JZ&L^4B)-fy$c_tYyuFyGtkcG8nto5Kw zZ)XST94;TSePN4qrw=XIdlnQIT#SCvj7f#Z#p1tLiEQ9A> zt?RX|PyN0fVrQt}dRzP6L?x@POwttgtaHH{U#%A#*G0(~jz*sO_q}~I>`HfSYnf#* z+^b7X^9;GzHpgeprzp=8vZC(dHh;OiZfSGRvzk0-j49cR&V{VgRt9FwJnR^O=Tq6z z%fecV2_ZzDX(L5laaOKk2Z`YRWre^wiIfWMB!w|Zp>PONm5>i}I^6{%ptS)3S(;(2 zAxW~b&y-4hKZdPChya9%m^Ii?mx1CjD_rxe^;?N6?@0rIL?)^KZo=5fmipE8{Wd~R zIX`DXOVNYPxe*3dInzDuwfV5{$4t_PXe^j`c^k0N)+y5lW}dQtL2q);wfYmg$mof* zxakvlKNEdY|Frw6HU;NtWnQ1_Q2?tIm7lV>=Xr12W)Q)mwmQx8>}iKX>0(?4PK`JD z!$KuV&Hc#>Db*5>5=0PWUG+^P#z<{GL`7Z-(a6wYT?`ue2W48>3qbt`Y@AD6!l7eL z#h#1-)*Ifwc%!UpE+;RLhF{_y<=0b)cLgPF`(l9h-_K<~^W6z3oL9OMsn|&pu6@&Y z^Q!m12feyXZhZh`fuR`oVK8Lp^cfz$>+76;@L_Ck!0E@ILT>G_MwjKzBEtH(PS#kT zz%OJ56IKusi1|!>&)PlD3r#MZ3w5?Yw7@%8dhwKF)1b_U)>{kKXiQ;!3=U&Rgd|DQ z+I~j`7+=a(%&_ENjcaW$b-Mif|Li$DF0(!R=KM6adrS!ve>d8cz*Cv%NYrF4M#rUM z5tV%!nE-(uU&>&!jOCZP?b5|KU|wi+0aD@I)l|7)T!`~0uKz?z`l(B)~@kW)avP{TF91)#3xVkj5zPeEj(M!d2q@}ft6AI zv=r8%9Ta$0gAz%3j+YGD+WY($mYB^mUTFc8D%}vVGVr+!&gEEPf*X({O&4)au)MfJ zKJ0s!^~M;xTASfU9FvKsmHIf2d@)yhCiQ_75^XdB zK@vhkjk*Gs_rtuH?+cVWVx)ov?Lr&iQ^f=OSpke)PTd1OV0NP3ulYwG~($ zeDH<$4@N|n8LBl>TN}ZER?$&iOs5{}Q~grTmN|kJUj5}_EWvwk|D|B)TuE^2*}}Z4 zUKZ!BcVVtu;p(Ea_ZAc7cQvyW1WLWP0t(9b2*J8qisOF(UhS*wcRo0vaGHfIVX!lx zbK*K)_s4#W{KgZU9u|m&MU+ah)}VwVk%F$yx&5tg;`ZGg7J9op_JuF;(8oT*`ENWx zvAfGk)+I?K$~tU_`GgZF>$Cf6T|8Mjn~&kGx3%stcXcZ`8o);Am>Bt@Z73X()3on>ch18X#$G^LYf$ONQR z7;7*^L8?-8KEOIhl4fN)A?z0AKH(DpA;LZ?Tusw(W$Y{=;y(vMRxD1TDGjJDFx#vQ zaFX?Bvh>EJ#79aZlMd%(V4T$y|2U_GC=Ip(RSTQqLf9&YmIjMM;oS-o6o>@pQXeuy zSa$mZhC3JNc2-G95i$$0aU)mB-`bR~>4Cb$*W&A%-Mur}RsuiiRy8%;y39#@xx z>O>Cfg2!VB$uq}PZgZb2!9-gsF13o3YKcF8otUUAP3nhPpYJC-T`cujT$9|->hGG= z|NEACMuD+7;il}*s?OJG;H&lZSOF?k)?A01A=6GGgE;3(7s846x751MT+5UXcT)^~ zOwIVtNGS6AM=kE{jItjuw+Ow9vFFrcYRAu-wvE{gEGXr#1i)J3k$U4$$%v94njBe1 zqD8nG17qTXt}WL1dz)YGXsHJQo10cNqs+2APGg2e7qQU{Sz^Ys=25=paTggC1TM8j za?m2mRuFcLAkxwR*%w zSneA@2zY`OUf79!Qh!(6M4AfGl_~piRr-Un_wual;}GD{+G^ekM4-icD`U)r<}=BQ z<^f*1q1TW2K#6ytq^wz&(MfQ5+R*<7e zWr9jGR>!yo6-yT8F|0SB`Ml$AIkFbcpS zoxx(lo>&*{pB<$%Zq?3{=(00If@8K3Xx-pgOcVw=QvIuKF?`KSUoJbTJk?1UDULA6%mnITHHK)go< z#=5)$ZLebSL=?-%uyaD-oI*H3q7;&x#1?E{IM2;DT*K|RJ)cY{*4I~AURmVe!2@(U z9n$UsJG*^)!vP2t+(^!Z=Ba}`#U!w6I z%FJ&SY|vijB^OkD#rARV*H>3N+Rxfqq>*R3#4}aaJXGrLaF@mS>*hEOYGXR~yta3= zJhZJ(N7+#IpCWqqj2MMS8B3+?Jqk9KRrj&xgm746%f7YFmHYBDQKAme)aNJZ?4$9% z$It9KFVpj_Ml!OsPxzJx&0t7XAIE`c(&=68X9H#Sz29hj`J_34o>A~=7SpHgxz^e- zvBJg-8KaG;w=;IGY<^u@N8IQTRBd}E{kG@nx4~dg#v^D0l-lbZ56#ILg{HI3s%zUo zvRS3AUTuBbMpre)jAg!*G6oNIQ}1=bmpTUXcP@f@Bj8!NFI^o3@>`Il{;8is) zm!?db!~f#}SYtH_ickcrDmf=Ap;hMF(fjHKY)^z3kqH|HaxYWKu+E;zQo4o(Ferwd z3ONbEN|dxHtI@+ve(>FI=ACbQ6HDEc-K|Y_wl^`w5MehsxNwLYZ$8QkUh+~%Q_Nt9 z>U6?BA`wb@K+1X@$#|<#vtR0cZ=+$s3aUvu_T(_43#o1!`>$m2<6#bPWo)D&M2HsN_TXXz}w#PDt`6XKgNIjwclcA zr$@H962PheT9ta%sAeci))&EmP~$zd&bPKcoYb%LJYy6rvevuIS8T+}g|_KR80G&a zc+j?OMc4EAs?mVsRf6|=xSrTYwVlE>p|i+vnx!y-Vuh*>XJJ;Cx z!0WdF0Du5VL_t&^m_r7t?I!Ktj`nYu%OW7htpNK--S=8bkZE)cm!Nn>erfbsWTB4s zl^Ffq)kLZ0wKB-qLU@4PhGaW&qRA3ajg_V0fjf(}H?_@e1eeg(HX;jz^&@KgO0#OE z9z6QU4#wv6sGS5vV#v@s+|G7E!qTWp=% z#0h~dG+mkEw4-kgJ4*0`)9{Hd^15rd=DE+|Id6U)>o2{HzO-yEFY@9a`vBM8ek-5- z=l`Blciqo{g(W&dA!{sDsgw^daFv(#@d|7MAxk--B;1rjm55ro|64$}(?M&EHU=Ri zQplQeEGlMJ`Ih?mb!pnu#xj1kmo|ZUdb=u6Wc;&ds!V5MJX=a+<3bFpP%$W6P42D7 z2d&!gOR0y6W}n22geACDUNc*+y;MeyQG=`y-Ywc95K?)9kbo469`5j?@Bc%*|2^;E zq5JOUo^O8>DI8807z=}8A6*D?{SjXA>euq#5Bv~mmeK3&pt@Z|l7RN$P^uD!MbYTr zsxEPU3#6=c7L_P}HanjjqRRG4U|UfF0S*z)a)UA6om)8Xc_VD$aW5q)OhKYjwAM)J zLumN1HQ3aGyh`Y zCbw6L>d)wvCIVU$NrttDH4%gfjH|YOkH+E(%c)@(R7}BgpRWCX^aU>VXIbakATe3S zm=xb^tXnD$Rau3S9=&0TNOnAhWIwY95${UpnZwHBomQc8-V zD8Z-E`$t_0WA=u~I*USC1oC7{R*}ZSjn5_qyRPOKc;)@suR2_ca&-b&ml`~|SQgDJ zx97`MNule+_GFoI zd%9}(YzMaH5ftZb=f%#27`I6Xi3zex&MD)mJOlG^ z+$Beb^(cY@Y>`fo2*>W$Io|*MZ{dCK`d)tTQ@_KxQ;%}w@H)ylw9P=KXuFP5DT92- zJzu{EZQ+AI@ki+{EilwMN-7YF-e7=KmMqOsQelgbfiYy~i$OB7lc zI2=j|D!_u^x}D%qIOpRUMn4Qiqr15oI6r9^UB$fXi5lCf+CGbj#I#zX-u4Ho-M6wsKKg? z>#7D!ML=b|d;RBo+S6;_IDwHK@DsJ?wT=HQ0B%~JHT#Z|g;%ec-_pj3Isjg+j1q*c zV9gTnY%e9?RIlnpeO=Ua>@Wf?Ho7`rr>-BD`g6Qyl(;jZ`;Sy$;z?UKc$fQs{h0xO zDLpxYFBt?XKh6RZy)Pg)`b>Cey{%3lYqB6*fi<7 zI_t_>OQ+NE2X0Wh>(8p!qwb3w|0$(dU0r2&ceiw*R7rxfb|SsD{@hZ~s_)H>i)ptO z{Jtz#TA`Rwt4qjI*sC%)??HOGi*q3ucL0=aa3k#Xdh~Yt^m;vi9bTZW$1G4u67CCt zg%#zY86@h41;-0mmp489)l{at-e2DFj?Y(0;;iw`Y{ub&tKZJffT6Ptox$arPLk2- zc3JFnSxPcwr^{MLvTX)j_|l!6e&9Q7ed|G9`a|z!J8_sJ2YKWF{4;#?@Bb4{e&H^T zb(a14>1qa{XJLs-q^-#Xb%E5CbJ^8DV7J$U+PUF_KueSYLUIJ_YQf;C&Lh|2hIKJz zUNHP>1*}HPBqqTgdL%w++`Hgb&uBPJWWmDOW`nmt!XKuFE`gJ0L zlUIXP*d|N*=h~5kLHpsXT`Ko$5!~Xe^XHSq;;cbQNzvQo+T(|K&pY43gWtY~!S)u{ z9lr*wXUYhd;4I|*0YVg{S;nEYb-wwvyZEi;72fwFA0$gthQpkN};MV z%L3C#N}Q7v#KLF03J>LR+53R|sg_s1aWxaN2rfUl_m5&CLeGr+)lY?5@$isIR$- zoMN3F(;ug~{*4;=XAvt#$qn^qwLO2H;^z5w1n6-AL{}x5VEifbvd1P5|NMHw*gh87 zPj2*^$lj_zEHPI7Ny;S?_0t*}G`SzBu^cP5PQ%S|UNX(7yLfD`Z*#Lj5sVrG08hzFPn&uv)QE;WLaTBhszh|!pP*acrQAeUj>plf264CplLEMfj4TxutRxO& ztl{MSCrxd$KT6+ivQ5jP%UcyN{X{LlXr zANo80fRkVSCf9aX(1k&&5a;CCSj6=6%Gtu8*j6p!f;Br*#>;Meg00KzSE@)qL z)%UOCibN~3n7n%uz`!&=WznKrLP zP493a41?#Y%ZlN6Mb1hu(Me)lR}Y;?EO~#IH@*HHbd=-V>8Dv;TJr9Ap{~|Bw9d)W z6lWYJFIdbv96fxPJ3jpx)(;%y^>29#rYO)w!OGGKN~S&$N=TgINjM(HE+er9UBp_4 zahfbiyzAj`K%%4%o{x`j@1hF8{8t9~aqI#I@!~q>vSDy^-~y9;0UhLK&So zr%aL(B@U$&6-rdN*@Wu5TBU3E%w{l*bV+oND|@d+VTejpaB+->2rdL{j7WLlA&QY_ zloUncu?-`J*r|g%HrO*5R(n%!X0x8E*k6=E$J@ZEy_B|bomHRO-BbHytcA5jWsG5G zX9w_E6eEZgT>-1b9Kp`YI-!yTZ8V!(TSzHTX@U^mH7a_3Iv0`Z=GfLVFHRWA&kpis?C`s)>X%8;E-7I0) zAEKa)d8LA7nPBZHm4E5V*bwvf%d;t-&oqH=^s}~@gO{T|1|Ui46`FyRyyye(;rT!G zE(S{(BnenY(HmfP`{)as-21sN^6iiQE}Lgha&&o(et$?O>u{tiId#`PeERSF1783C z_~UeLyM;6BE4=P6{24y+bAOMm`yXe0VZ{sNS9&1!Q5r@PLCH)k&-^G+`M&}qr82z> zDJw>I0EVsc3>8yTa{Rim2uZ``{h2J2+?Mxac)Hm6JC6(XEahO@z2l`V`S+!%fAfp@ zj!Vn1yHdp)zhk@~pp5@hxzc!0r*aah!gWY*eJ`!`KFc1ZHvq(tC3~vV9*rUG#g=RV z5deim3QV4}kSd;c(+%Xk4T`}I3kz#VrEpdH>x6V0*ur2`;L`E$M}_B|6P9VzkiIEy!ZvY|2=O*MU~BUNoy2kI&#YKVznb5?s|a6IkH6JiayW1`2=rz{VVy;|Mequ7S^z)@N+;0$-){OA|%nOF}0m& z{{2kP`#Qq}DD!1TeKA_9N7zx*fP}bb*dTXFsX%x)b4vsv6wcLl4;Q^Y!g{i{Tt^b= z&mAvhg*i2{ua^LHgW5H|ekSb2V{T>>+BB^n_siDWt2a`c2h)D`h<$-Lmx6iU;*L4r zZ7s2(BiSm#IT4_21ior{7A@_V;8NLWBVb(A7TT|<#|@8|OAJL70W~il`_Gd2oA-&X zEwU17k|jNH)ctQ1U5lU1H>oC}ko9{fG;@#2b3JxZt0EDBt`I1&9W& z6ODo0&+)hyM%%RSM(LdUKddYGt(oF;{{m~9>gjNWS9emDST#~80#TiT(=6wx#JNoQ65?myz(uIDde9WT%(Io zKCWh9=1P}YF2PqziA+H${eCEf@-eTH3e<|qN5d_|L6&7KuPmdolyk*d&|VmZN{O!y zJAadi-#sfKj{O%YtHdF(-{a5AH7^7plY~6akx7C`GEAb#P8`GCbS;l>oIxlk3Pb0> zB67WpJha5ip7X=J`rY5lmw)Bgx%bnbXT>R2ogp!f8@r2~dGskh^$-4UUiTM%hQSN2 zr*rKg-t?0{&L{uwKW1l;vnbPN?eNw-az-qamC!$lk?gF+x@g?0&WzGp9^WAZPS&z) zU8b_1PzST-7SSby)u0AhQ)_tNb_ll$aZx(LQvp%~BXSO;)pM!7hSvT?T;ms|k0 zU1>y(dO&n7@v%tu&*s?g-1OL$@rf-WGe!yq!<-Wb4sh(~LGr;Fx>-s$%NX-ui;By_yWb|7FZucdDt5wr3ck~>@Go~QXE6Jwm0~`cfEz*`_h*w` zkCh0)>1~(-7EmRLz=qqZ(J=v7-GDxS@W_4g94ynj0WYOO`8i= zc!#yN)Mu@>SYbW8S0=4GS!?X832dfG+t$Un2)2P&^RRF04ZwBl>Ic+jV-LU0hv|Wo-x;?h#*y+^TvGauX*cnrOIesn&8r2`grgCP)C64 z3uWH^nRHzvUP5WL@L-eHI=Dnqx)Az|Dxx(YyOa{6b?F)vV}iwOgs!xpx7DGg&Uj$B z(dLofyBfiZi!J+)M-qwhSH4`zahW#HCkYc*y2NA#j#}&kAy6to1z^}{O(UkD^MORI zwOC^r4u(Fks1Rk~SLbY*6rsGS)=$4*N(Fw)2J0&BIZ+6{l%*l87KThJgmowfA}_f2 z^IzirkA04{C%0I7_yX%^1}t4DSSm79vcxIjID2%Rm;XQiIPd-|e;#|{Am?^>NQFY= z1&0^AbWfh*i@)%T6puf_t}z_^?pJctd*8_gn=yY%GlX8Q2-sEze=e9@>Yy3 z>%5C?e0G$1ybGea%<$F4$ubx7o_zxeBbMX+wt4@yUJIL z6D32+6A|6G%rmMe*KEKJN|&p8zcj$&D@L-h7-@0R`lM4qw<(I8BS#LizP3tl=K`q& zs|#?J1M90Knd2MZxQj=gc!U?c=tbOe>+?Bq@Gxr!*T{<@z1}WamhrVa@8r~zPkM~b z`qYNjAEHF0tsyyXqN@r|!x zYwH|BIvk#Tj_xTIM>^qyOe1yVOY=@)E{kofaNqR$oo) zw|V>4v+NyK_(1=Tb_RUi^Lx^}XVFXQb8FT-vZ9KQKJt_nXRYep&beR!r_}Y6c;gt- z>SAoHaqGt{#M5_*W6pT(jr(WO*5_G3qxGu~?zGO0lh+vIHxKE^^St4oJZ~A@O!ix* zZ7g-`k>GtKiaM<$1hBBAw(ngl@QbKU07Hk&vU;(LJ;^_){D}6 zSBfBdDugRSe3RyNR@%#}p*#bnj&ax5MRcce(N`H7Kj$eih@lhhuDEUQtP8}`-c#@P z5~FrZb@jch{dkpvHpUnf1ffiL7e5&Svr9=LJT+Y+yc<-YxJxBaQZg6}=odMiG{Yg# z#=Ao}>jR0#l+Tg9<%N;|5&AAn#+W6EDr+xJm7jYcPg4{{b*E4$<%1yC1*uEzPAD9x z0Ma`F3de$!T=?eweCClyNSBr&Ng>T(d4a_fM|jT5Udl}`y`8)xc+&JZ@`@Mo<3IPm z@!_BUC7$})y_{HD#ti!0w7kj_kDuhu|L|*k_n-R{oL^Ra*9U%pr@nkATaP@!Qqn;< zIdSc^tE(kO-7aJ8f3QJO;ohjKh)>#aOmDOdI78g8xBphif&`z+ly2ynm&(Pc5=J)RSJiXlk zZ+zn$x%0_8+1c1+YkP-{jSY75f`^}YikH6nFj#hU)vw;ehd=xgKKLU)#B*-Bk^c53N`ev+B_*buPo^B3l0j-M+8CCXmihL#zRt^E z_#AG2{tM`DoCV!S8-r2;GxQ0J3S*EexT0Aba;6HT5;(1^%-upFLsoj74>-8K#`&}7 z`OK$3%Uj<5R?eJ!n8l6+>u^#+08p$;QL00V<;Z~*l&~bi(YG3rgp9_1!qi-zJPYe; zDba1vQYGbZaZN;~7#?i1!j6TYX?><>3cS%P$OL!IdKqQ*)b+VB*RdLSRWa#l#Mqit z#$q*1VK0s9@vTg+k-sm-y>2gYqeric|6-__dcW+qTuK(#w0@iP-ihGgjC!@5-IJa* z?{oHaUz7)@6RqJJ~WgotpvWJ5fG>ZcHzds ztJr{OR{|H@Cv4;1>fo;=#!&!j^xx=vnr?9IsA5}*)+3Kr`+6xQYxNOGCs0Tf5*eUN zD}2zzvL!8O^7y-}_Eqhul$-eedDg)^w7|*j9p7|jFTOZVqV;*P|Jy*$dE5c4t?1+~ z(59o3OBqnJdS>(YDy~|+U2)T{HuU2wwC6KGe?D@3%t^$>hm0r_**J)$%?ACB?4!>;)V#30~`Sm0bv{phZ6pu zaE2sFszb#(Fcx7fw$M0j(biJn&~@i$3C?Je5Ioujiv(#BgkwmM2;-2ox|e&W5RLUa zsV^=!W8#@C0h2g`}Ns zh{oZNi6YI?APAQ)tyv1^2huMG=)RvKoDD*C<#zA)AswviI)GR{#kRGE-e8BclknW< z-omS2{YqZ>ikI_}m%NCh$B(kGw7@XW+1}Z~SVDi-_Gec&ki@+-3d|CL!WzH@R|}sy z&(C9P5Y8a1BTZ6l_)lRtU@V6>cUgYo6f2LPWbM%>So+2TBTqmRR02Ls?fs$>&wdwzIrdiPv4E)(e#(Pyyk;H$hieU zPgz`aYj8eMibtwN#7fJ(rNjZRaIsD!Ma-fj5KiHDF^LcfvHKyOS$>}vvO0HgReTAB z&otz{tOUkdq)0qFLO5_HDEAjtYnbC1t$Tl%L(bNJukP{ZsVFne+2~#%syS#~hp?uETh-Far0)Mck?r!s(XEZ3lUv{WjPo8Pv1AbPit~W zDKprxPng=+HJM<}IS>9ixu^5`Qk3dF5m@)&tB{05H_{t1ti}pc;#yV!LLpS8G;1pm zG9l}9=wu71Bqd1{DwU8(jPro6wN*TnXR0*%R49-M&MBnuYtxDV`ezAQDoGG1B{9OV zu)0X$H2Gk_k=1opyIn4vJ;VBeLmWJI4M}%_YfoImwMUO}_~0Rm!tvOnPhpHii429L z&_x*e(9c#Q><~;JAuNSS_~^$!&tLnyzre5l*WaeEZGfp9sd8Y3SeqlIL7)*%Bb~vS zApcgUvts-1^^T9fG`72eT%!Z%RGU?^E%gbEf=90CFpXq?lNbdY_HzgYDzs zTL0Cthp02xb!Tw#lpbSsVXH(!^*(QU2Q>S(4HTSItZ4q5G)8TI$2!Kkx3!^^=a_!> zNf-1p1WJsv?%Mv%Q_r!sONmfneb&aejxp5r^S1dQUHyF#Di3`aJ~z%{gv1($lu2Y@ z)v3QRTaKlS=TkkF=3z1(WWB94sKh}K)LN5-bnB5Jlcp(J>xoG2ETneQdnbK2K1lv& z6(gyMMwUwM=yPi{&;6jt7h{3D9M-$}o+;*<>}~x#eY2WfuSxP+smSfKU9JAQa|(xA zYk&C58oNM_#0QE8fNQdguBNh=F)4A~(PvP(6U5$#Yp47?92#2tY%?lN5zZlf z9vDc{1Z~>`>dLs;He92BzZ8LRa%`@~#0IHS+rPyQs= zU3VkPODkw&$&F@rr^l(&r})~PU*juZ`6_qa^);S&@(Gr@T@od|J4XO|jj>3j@b1wP zqkX(3r2o6Vhc$jD`6MrY!E?!vEpy8&U&`Y1ZejcG z@37WgE)^nMGBtytAYyT$FRz+4Cu$k5;=HXPY$C*iYco~Hg~cYQCP{@iC-T|dC`@ZZ%RuH%S@n zY;a)t0MC2=^LXTuN4WL2TiNaHA~AFl=?#xUkf0do0;?^z-F6! z&SZ=w%2Cr^=4Bxp3__v=&_hhT@Z6-llIq|V0qqOi&8Z#M-uaVhZgJA2K@cBpV0*yz z>9o21mRXd%5-^Lv*2L$;T29^^#wTsXU>n~UuDlfM+dlP*vJJCb87D3Ny)K+~dg}ab z@<(6W#u}B2*|g0`B~7!Et>PJEVNKZ>ynQ5vZLm}aHE#Y!T*fjt%@fbKioeGe*qSWi zQ$PbQri1osS};HZ4xzn;%_S0D_)I(qt?4e|r~tIm_t+sY>Vw0a9Td?q~F5ZTD608Yfk;X(|tuun-)sp7Bw++XD<{>UGrurSOGyMsR4 zJGp0oCQID%{1@_)SG<~^{nzTikdRT~1ZW&-0mmg{cZ;Ox^U0mzgjQ-Xz5B3yx#$}`FZtaMV&oqCddcaw#5oqm6Zu3ALN z4!9xKS%%t?sEoXTot7rA3ejjzjYtio;W~09J10$aMogmCV+$v zWP-MuBG;^}9_Q!($-m;uU;YaJ;2-`il*=i4L$tMYQ|CzwNVL@$2c1Mw7>z_CRDyE_ z##n@vbkYp#6jp1TG8BPOl(Y0V^e`i~?a#t&ifQy4T*;(qtUHX=wBi*cXt3j&Udo+dNyyJW8GZJpUt?`AvMo0X!qxzqc-&L*nM0rSKe z6MNk3=AGBm+AAE$klSe!AVuhO4Md?wcz1=ecjm$LxMwE*yQTB?$j#hrs=Cq%2 znI$;x=edmoDdyuG`Q9;o9?gTkt6)iXHc)D7i`Fv=YJR*zUi1cfrAq{;JWAg3Jr$ck zM)ae0y$lH-bdiICEK3khuxm8iCMTD0^tPM0{&_d^+?U?Q%`d&36VJa1o23}#_kv-b zqfJF^PY^7~1cn+01;dSPo_gp}9{l!wJbdr{>^^yx(+@vRVqig}q%x%wK!8XHij>t@ ztlIDT_#dG}5SEaE)71nNzh6K3$RGZRpP)a`oZZ;vLm&A#-~P_SY+cyEDS@+=m6au~ zzy5k|y74-0f5CJ4bASHN^20y+7+PkiX;=ED147DIdd6ve^Y*k-XnP>$eNOcTJ z68bP^P>bf^rcM8uFNyRrokaPtD#ntijNP3+=PztOfrby*o(v2<<(s=hm zEkMb@{#723w;F2=i)#m2?yd6BBTuoidJvm~&^a9`f}AW7iNraB)rM=YJx)kiOd?gq za&0KQqMBdMPMjM1iN6Qab+jk>SQJHYvj~=I5du?} zbjR9awHH5UuVfLfvM`tyLw_B-s?XhM-&fLG`J58SjzTIQ2WDqDAYbZo-AlfUm%Zin zyzDLC&H8h$;Z%QvC(l03*Bh_hRK`JyT2*V0*BpeQt(n;YzGZnChn$U?`vrr<0ml0{B` z^mClJ?s|$B-NM<$1&+V&H9Yv0uTq>n&q4>*vXt#dA4lK+2=0Zqk{mq3H7|Y zhm=5Cfpk7*rV=SJ!lsV{WpSv6JK>coKITw;XzOywh;S8^6c?<~VWW(oxD2;MQA-36 zECNJMR5H=!2WwpIUsaMsVN4O^vj~bN2q)?Fdi47Pw6=7+3miXj4N4`PKYyObAAb@o zbUGcBO0d?D4+{qUL115{AfVsh<>^yTbLhYUvb2M-*1Lw!5tsRL3itadQYz0%anh%% zw~oPZ$Y79rH!hWsB^mu*p9|-=%eot5%lN7b-7ahE>l{0Fg27F=PCjG%s9EG6?6=|iK{^axjbR-BU&%&Iw!T~wX-RjiNk zmI&|qYJtw;BBw80;OVm)JohEn@;HxjV0D$#8|T?PvyBmw-F}b3?k+M(SXo))&ZqBW zX?dAJZx=oEJ6)P)^n2&o+}h$A2_ltfqal|#>7(U6_2g+j@{!->ZQt`f+;Yo}Y+X1@ zr`thxmeBnRy!9KCGi+ekFRT4WMp8;gYAZjV!^p60Lq)xXGf z*B$2U>Bq>Dgd#5x)*s&(@BuRgUnKP*xcMAQ3=Zn3*`9_Aq_I|pk5ir z6wj;^KtCUnnHBPZ=CA+U-{UBAT!K6rqf&^=OJ4(;z3b?`~s@z1aA5} z<8!9z#(N3UKKh15Tj78(#iIsIezG8!T(v(*cV>QkNK`~rdRn!HH zBGL=zm`Nkn^;ty-Q36*HxM&NacDV@jXHwF~jJILHn^aogN zG0rl`3$kv9wY7DwIewfMKj&6n|Ni&z(l@@I`|iA(Z+`AeochjVtdO#tb}%|*s&w9+ z$t#)e>WRa1nakKAjXx6{-q7g-QBzu0Vw@!@Wr!2*T?ljt(27*3B=M=dL!eC)gJ;%r z_cFib@MMDy#$~_M~z$ZWbd5)gAj$E4%-^K^U&bld! zOUo4b5MvDYeDi+3ecyw8?sH${yI=ci-uwO!@~8jIPw_jy{b~M>|KsoQ@Iw#av|({+ z5rM+!0u_Ri`m8QNz>Kk$0HGvBUXUn%_7{1<@e?Qb-nYM+N zufOpdeEyElqqSGMrGo|I&>Oql`|(fm!sp*iKQCCh<|xN+yN!*He~QI!2bl^wy*}se zdyoUS-O7%&T=SCKxc`nju-1}>J<}3=g>bd=q~>aI6|BFC=Scu+MdP#4YT8*Y_^!rM zDd*IstnqYLu4xj74eS;*B}h zSbq)%Mozy!U@#bxri#UdB?iL*PoF%^+S)2hON-As_u*KKaR{kOmTErK?Ck7fj3rAu z6xy)6J3uN$r_;eYjkP|6Mv^GBHVpED?I)i?B?$)(9N^HQBWPW)wYACFGw11aQr6a1 zNVAL>q}X0TBUoLwC_$#kKrC5Q@vm`&zCdj0FLW{X_+aT;XOVR>PlM<08FBZpQvba;u~turK2Vu~E860G!bM+e%V z9B8BItRJA?-$iIkCKHO@fTx~1NtUMEaNYHRT_ccI`SaaqgcKMT!a_J$Sz2Rzv&Z?< zr+Mk^w{q*v*J1Jj&N#Gn47CAkNK!?fTZ}1KTI|y4q$sH-yD)>`}S zoy5Ls+~b;F=k*edtRYQ8a3}Y2qHqr)L9>xImY{`jY2JM z*6b2p1{W6>Szcadb91wS>`v&$z0bXQeJw{UN9}JPe^%Q7)Hg0_&-7r%6!bA702eVw zM;Fq#5TE4H*2uq7LwFj1SuYFf- zj8R)fU?zxDeLjk6i?O*s?p4}+X65!;^_Op(cNuJEsJuGBtNm_<4c4wd#yj8tZeH_&cOy?6;?9R3;DK*_0Vgcol`f{MSYBAiXpP8SxlwA7 zBX#CePiGxmKA>YHSfAEjCK}-m7uLxVm)&}gbXF#MX8>U!PV+;l@KfD?z7f47Qq+@i3$vd4jW2%rF5dj+*Yld!yqFa`|-CJBT_JyZldTK&DJANaPF>earD)%WVn!V z__?>Rb;p<3)f%BC%SpzWhaX}s&)M!boIiJ-&5cbC99-wXfddrF%k=xZoIk%omL@DMFQ5`xQ^Kwc!duOMJfk6wTPX;H z1spbb=W1&w$c8eq4}qAO(aT;T({01`>f+kCZ{}VIOmAF)lRl`hHjbqON4Vq8Z}O2( zeS!DA?Ty@j&z)S@JjeR_3cbx8di|W|J^xm2yy<2>f5#VCUR*}&oLm>=)@NcgRxrqO zE^KW0Oj|C*k#t}SOV8}`oSSdtr7!<3w$E?S>+O=Ji64J!NJWYsXhdpw{|~-{4}JJ^ z{QF<|FZ|e#zK_lA3miIhkp6DYJ$K*D^PhJ!i;I$c`vOwAQa062mkcX}#2Jl|ik;lC ze&{G7-)8IVIizu%ICh+@+d(@U&Mism@L70)UOy+x{5pq(r=B(%8JWZmY9%)KFUsc#noP-h`^M^Avb6k@D%~x{{hLtTJpyu7 zRMd+v4i^k~?F5BspY4mKSCT-YOL;hFMKxW=SaY>8*%RTw0jg;PZtnwvt{>g9>vS8@ zxOb8ptABdh}BfsmuS16frBVv3>GyIT{nq`Qdh%&bTz@^c-~IyL_``pQ<2T>L zmp=Rn&OQ7TD=uM4Cb+`aIgt_*L3OeBK66-naM8N52Ufk&l8hydg| z9}wJ{;CAR@fNJjwgYYcHx&_8z_pPH`$v!R*Z(o!6X}M3PNy^iw&haPz^q=HqFMBz? z;ec*u0c!)*+IhDc82~;deF}M>wbA%w!1(=bdHEp4LeAq)p5oVk<3rqe=T~|E``*pZ z{^g(LyI%2fe)(VhGGF@Q=U81@B+a_$e27nN?UgVo{5vHH4u_JGyvSKtSm39B=4W{O zyT6Y!7j}>`MO(*U=vgx-jve7I{H6be2OfHuhaPx<#WbNm=#ffEr?1((>wd1k{Y7lA zc3HaSDC*D}g9{f}w30;>o2SmwJAWQ^-BH})0?S8_qEA1BR4GFYLM0_lQ-%G+h$!M* z3J0Q#D{b*EZ3Lc(*7hiA)R?zneneM1fkTSKyO|5&Z9JpQ--++sd!0R7XIyshb*thL z#X3X-QkwY$I~O)K84Lz=yIodR*GbdV$No8g4r?7sC8SaUew`bg(@9g7mKVu79l$H4 zgTa7azlSlPlHg`H7|`u>SYBRXI2>~B+yxFCSfkTTt9VNLSl6TNyj*)j^-S;nxV^pW z8HzH&IVg%88L|-PVI6k68Chr11OK|f85j(ANwW?r?V_!v)6D?E>C>lKSXki5k;5E1 zbOfVw&R;monX~6uSm?04yo{2nG%7bIP$gv4#~7ev;h$whFoxR7Jti7Mz1L&j4AC;Uyozd5Z|(x;L)K(4r*k;@mR-_E-NqYpbig@l~(j?5W4u z-Z;ngHyvf6+oju0xc{49NAK>kw6@01AV=$*cs_JG9jtX+xUd1n_m;(>tU(Ef#8C`- zoO*17PB%peOJN;ZqAGFn=JC*Km02^ z=lbh8bYO|?tPK%(-`Z&iDi5YtKc{l5DVI$`s{L(*XeQkwzfA1@~^`>KJv&+(A z7vn_8x+y?uhQn<#p>VRxp%XW;+t+MuZ}G^Z50NE;=iG7wiE^Ae`Jk__N=ie%6fp)G z7;C`z?4>G^6a!6~b%N2%BBXaUzZ&8qHoZP-@^v|5YH~gm8_Vl3@w+HESMFq`J}*Wd zUwaPtIXI(&%{{;NIrX(Ps{7U3H*T@7UFc=EiiqG!*|1HYih5$xc;BW3)pR6L>HT%G zLoCx`Of~kr627Pmic-q3er)OW)%N_klX0#;gKI1Sog9V>BT@0zFmwLvSVOkMto^^M*wu_qeSxU7>bzl*@0D-9wrI`xy% z1;iLg{U&XULrQ#Xn2I-8JtJBmMHDdSW98L5K^vbbX)qiz%yTcW$Dpnw;EJdna^;YT zk@wXOOzI7;4`<^!RH)$`^%gCYu5F?L+lgh zc;eg^Pe1k;H@)Eby!g9b!dnll@#PPFg46fhPnv5M(i9tfxkVVm(fK@TrI;r`;?+`S zftFHA2ug`mDi|^%^Ohig$WnQC&UyDvK&b>1_5d&F)WB%G_9B#LC5n=D)!fJS)1E8V z*8_GAVa3! zxOW(9NRov0^@C_raQ}mk@K1l~U-P=xyox{c{qN$XFMS#R_doro{OAApE4V?Q^%ZaR zw^rkfS2|PxloR2VpnN#sw&&i;>)-rF&R*ESSXf%>crZ;s2$+P6A&SRF3A=efW2bLM0J{rA26oex2C(9=HrU8@Ba5ZStlb= zu|ND=ch=DDW*j`QMw%ocmX`-@78e)j?51pPZBrCEove!xis5jGQi@L2WiafswYA0J z&cQLoLB=yQF1I<|PTH);7ZU@gUOZ6m88jQ4n^*%$k}wz!!A19Ki`IrTQ!Fnpvbfmw zB7!q0xlEB44Eia%{Q-l#N1{@!6G*98U0G$A4|(E=Cs|nNvb40op+kqU)^hsHDbAce z$Li_|OG`@#*#xG1+pGtPYBAL=mbDh;-UxTb_-#_DYK)?8;2IpQQhr7V5A4Nf(v(k1 zFxUx%P)#q7XShVT0B4aQ@s0={C`D1w?JTq1AMg)<@!#?Aqfhas*S(UJV>gpHuglzf z-?tg|cUfLrqcQ1z zgs9fs=wu2;yvi|^QkQDn;2KIoDSJj8vUIIw@;)|d2pQLHM&|mck-tUWEvq?>IJbgK zjjwB9PW`4)U?;vOo`KFWH3rr^XF)vk;vN;VtYB5Plu{! z?1;|Rre92ou~mYhWnWkRO%=#BGJZADd*XbWAzw=&r+z;!&4?0zvCg$zDdLO00k-_s zm2wh5(nQCaih3O}tw4=hRiY*nHIx~HR__EE(!91!oVJ0U76feUJuQM?JMFU#0d4_o zh@eZ2YB@3>vo?mg!Go5MD>pz_6OzOER-y-^e$3*M$L?_>6KymOLlicB8gSRN4EhT~ z)}HZx8BzP=2vMU5t86)oFoFLEv~RQV<0!UevaAcV|ItQf%aBR8x%$nK+gcc2q!SZ z98nk+WyWB8NPqhb-#op+*2%NH=9RDFZ9nv0zO?u|Jn)6DVVoulN{SfqSs$hU+9~_e z)#B81;0NlLFjV7H(1=o3%zl9#@m#l>avolUx_#5f1W zVFyEs;gABLo6%WcW>*2m1INO`U}u-aST>zz@tR{?kX~>RN@0y9bqeVOGSD>r{ul)I zIAyN%RH@I|XihJ`s5Zh;u*JD>RtkX&LUasHTncNhvIJxC0deQCc}=RS^8rwWkY&ue zx~%8jus!R-XR;Iw2K^Af$#MAbF*==$ot+&vHa5!)(Vb3KGH?hn+pIM{X~uzdl0;yw z#)ObHHneMLX$h4iY;A9m=Q&a;5|xy|Y?>w%MeY+rBuTB0E)D3^7US|_o)?XhyE{E} zVaU=9qcv$Fan@n1#u`JX+hKiujjR(4BF^}*4bG5ek~CXFrv5p`<_Ns8V379_QnI+{ z17@E;KcL_5v$C?vH7BlRduxlc=gzRb)8o+L13vSUwk11W2uX~WRZo_rLc)fNDGthv z<>lA}CYBYo{XK4l_94oN>U^OV+xEdd~Bz@pePIroduqJ;z^$Wl0GQ! z8fbHaCHDZ85)^g_!tb>r#7PtuoRBy?i^MZ-EW#PipMI1N{J=Z;&p-An99v!Fm;TA$ z=hWl((n%7W33CF02{bq%9L{QR8l0uqFK|-xvw!KQcb93yU*3-EJ4B z3o-{f7a$bINS0RD`L$pB1Yf!P9{$pw`w8Ct&UdkO?r9{x%{YN5JkwZ41yuUv2Z6#} zhL4HHPM|iQMI~@TP2jw8heFdMq^eB>m@3(ST%Vp^nmSSI-sKddUF}ZV)8hRd|LkSI;KxRMnTJYdTyREslK0#Vvk-sFAJ~jzVg}ic1J}sg0oQ4<>Sy){^QTi665NW zKWk05&LN#6l?tm1430uTUpclqjvxNte}-3o;GNw4-~&8;|HFL$ZLj4I-TFEXB9S)7 z3P@C!Cw3m=%*I)g#DiF-C_p=0u5km6*&SfEdf1%-BDW|byvtQQ*n=SNEqIdDrs!N_ zhnh@7>I8#)z{6j?hyM8uUiPY2^4fQQFKL!>@8`aRbCyJb3RbWh;V(`EsLicWE5_)7 zcr}%I_NwT|U$)D-vLF2X4evUY|DEuMoYgcOK>R+EKFk! zx4z){965TF?X4awt4kCPjvYP1%JKrc15K(j5)}gB6S9*zUD}JJq~SUtLfRT@VPSEF zrKMFKd-7@i>A(2byz3or=f{5HN4Vjp8~NLR<8N~Rz4vhVz#1Bd)jGI;Bv@fdRDw!6 zoZGm--Cw_lg_VPxI=_jMDT|$D(li4gF9zgAk71$db{80KZ(y9ki6jK2&V9@+mEvRq z%CihX^QA{Fbf$vk>$RT3W>1Zz1YW~bEOh{H53>@gE9a;)^59h5E2Lw2d5I)V+1%VyZ7T=?J?Z`6#qYYZ0YLbB#ohrYXZ=&Tx2^UT=Ywl@+eP;U>v>^QSR|;lT$U zP}zJ`py4}*2v<)d!EW4g1OZ?)`2ryh0V zZMsqJzwAw?Yp?4p;z(p&4Tr(3`f<2RNRBgU@UH!V|Is_?Op74A9_O)Em2N}AcA48#XZbftTQ;{ zFvd|724x&}Sdb+Op(Ib-_b}TVJAC(dzm`|L`E}^tfbV?yE;8*{l4-gBI%-)JXB-}N z3)VMm=z^r1a_eo+XJuuLbLTEF9Oe|c;n|ZnC(z!inQYvDWg`QzzNo-NiXalB8ix+lpBrgpcbaC2OmzEG;bnU`&A$ z3g>(VL*e641UbPcZs=r+LkADCy|cs4_BMsiQ7R$JGFDetz5991#bX+fsT`#C#1r#N)zASX^-!`9X|XV0Et&>yg}vg~7+0nS(i5+zl2 zCSJ0Awle5QAsURV60og18;S_+SF(dC{e$A(*7V2-H(ybVLIe=a3UGMfNH_z+kVuI| zqKyNY(OK+-dko$K-?AVj+8WX%0h3~lMkOhJ(=epMfXc8AOkf)nPLMbP8_%K(gHQ$1 zDH6|T2Vs2tF{cp1f$-p{)dszLmKQ$f7|*}{7$5%7M_K7AUi6~pa_Za@q-hGKo+O+9;cH@T%nNyZUwOQJ42dC2rN2nix67EAu&F~m;>!>h_&opGqtu@>*tJh#xt};WT-|Ns}{K860!3x zri|}JGPwJV8X=nT2gm2yd{bSF$WD#*a*4~;V1S*q*H8OC>YiHbl0}>(NkeaKV@%KN z6IZ>vUXMx7m}J<3N}rf3$k1pzYqE2_Ui;BGrZU`T0d6xGV50G;5jJ25H&CsBsKeWF z(6r8$AjH5Tx&elY2qA02GWhr>k@8+WY%lb(Ndr*_G-kOG*6(ZYRwF|G5CHphLcI;uluq0@ykev=ohoZuZVdMyhSbgW?Da!`uC5`5^P&+&UteVbk?80Ln;XiPsxXou|u z=GAr&*B>Gai?RS7LE*lh^-%{$rSn33VGAESt8cKmMg{u4QVORnQahHDl$|r@`ONQr zlGnZVwS3oWU&ZdZ4bFV$5pu0bq#R*f_&rnBLENa^*2jIyxLj(h_+HCAYuyC~g_5dF z*bqI4Vmt>MGOdU}*_MsD5m~)WEBZ6JCN6(R#<8@3^1GW940FxG!Xn3yAIBK)g1NJu zv$i&1xwFJAx7@&&zW!~xX_q8R0~sykv^4TA)Wn*iD;jrMy;bU~W3!FcHp6%^zwD!T2Wr&HT zRDuv-0*E4|LMiXwwz{$$*d+!bI>ZDm9+WdBfaRPh6$vR5*4EZp=yciK+yr2Cbrm6f zKh@9qxkw7eMl&XxeCmwyCl(gncSf-Eo` z0g^Ojp}R<$Wo&KlFdXI{yp{@Uec)yhfMY2ooz4ad> zO!sP)H^SmD&VxoaFOZ2NRgz*bM9B^-Ybz9k9{DgQO%iffAgy6oXjTv3!0&$QQ@r5z zo9QH56#W6I^2}_b9VR5|FjixQML3Hp4O||avcdg+(9bc(B2$U6_1uGXR%$dOwt+;? zd>LgYH9WdXlE%fCd3DcAwGWQ%t(Qqood=*At<$`+voQvNDEFq4>9j zU2n_0?;fv9laV#c=rZj&mqHhrXKd=;OA%|T%pI}F-~$r88b)Lz=UfD^st;nscNdhw zNQ=Ih2DDAk$dswR8@F6u%Gd`}X>N6D{S0>1v^tHZ3#}MB?R9fA59f-3Gzrm-0S+et zbaSD}d+*g?jrG6R$8xlF%8C=-nOLzPpZ+!J@ zdGU9>gwH2?>;Iu9U2Qf4!i$Pyro$9esT7a>vL;!@{t%vdV?+9d>qh`H>%ZKY!zI{w;QgIbXf| zTbw>~mbG<3XJLuLXc8fCwvQB+FMi<*SZxrQhuM_KD0C0k@3Zyo2f6mdP4pgo98R8R z!6i76B7`EtPmr z|A&Jx24a`B1TO-tHjQ(~s;D^n^UwbpQ%#EUJgN+8`!98Y-C7Sy2_e|+^*Mk3JbA9k zIvFQVo}?&rIM2MRo7O`r)E#5g$`NgP#O2CC<=gTe|3@7C|lb$?eBe$ZOh+nhOpWPEG#UBVCt0@fiW5> ze0;BdZ&3DSQ5c_Q>J}an{%q?Rz3rRvZ zOUa}_6hnlP2<7()tDR>w4lSurSfQ~P2u2KN4N`bEld;D8qdJQ$Jd1U(yUpPv2ROfd zgwKEBOT7N|uco)Nfy0s@i2>aSY$v001eR{6L+T`2!w`!Q1|=PxPC`)(C~^a7mw_Iz za_Atx_1hm|adDYLYs+kJo@cqcfH9z*K*|(hEzWo!aS^mtXM9hj&NJtW!eJ8F+S;Kg z3erx9_k~a85eZ{gSBcD#7>HYhJ-wtqFgkZ9>7lbAu1Um*y3Tbm>v~pswlTaa1E9b- zhYF;Wx*-SJ-P7y8w~}x6(G~N6Rg=sLQw=E-p0{te=@~f5#R7k`pBL6lG<1lOdGA72 z?eJL=QEU->o~lEpTAcBKih#wu12Xk9$C!2PQX$CGfQ)IbZe=Rq87#0#_xs+N>{>bT zSuP&-uCd?6OQf7OahmtL+ z*8FX(_sb+caHsbF8;H`oE(%CLPjAdd8AnbAS$ zKFu9x?qjzDLt_!zAch7t)WL1AkJ%Ywc6x}uM%n~L60%VHfPu<+c8Aag3X8}s!a(8# zy3ircf+KeYI@dTX!`%&>lo$c0cXs&HCqK=LZoQ4pp@a0bL0g}hRY~dN`P4EKIxDfp z2Q#+L;OQ$qGo=s$B~zq>1c?las_(DCs@ zyF)rjm%0TX^V{Eiklo!JxxK>yeC8=Bf>JF6CS+%dYOfUzi$Do`rM*N# zpVwmkl)HKWqFjin5!bhEUjBl(#=N@d@+1U=U!T_uv_aq@pJETd6H0_39!YN8A78e&; zSm=;uDM=zpROXwck}{aN&j9QDJx#K}$f{>%^l{Zpp8LA!9BU0vKKVEgJ@g>fSXS4T zefCjX$uIl1wu>r8l|bMVU0R<%CAjZ9=kOOXd{L8;>%tjWf&iu5X0Gr- z5$m8Tz(@EK(EE$WxJ)?$fl4IK`fTo2N<^Y?y1)hYqA`{6IRP@|u_tfnHAtt(b%8dD zEM4L8M;_zaV@Ft8$k2Kh>zt2g8FH(RI$8JVh%)uO%-Wf{po`D+GKEr#c|zYz ze73uEt!r88fHUCsunrtkHK>pw;Ai{C({Rb_CO8 z>!gj0i)I{wh5}k%8xC#PWEf3~r`4PcqFf;Lw)^2x@sqIlAPs5Ob>S_bqF$+5`$m~# z^#xE_dMC4aMyow;k&K`Y3x5}O$&GPzhE{N? zO7U1N&=z?)F0#lRaIT7D=A59g;DkeYpSrRkK0eU?6qQ+SxtF>$){GZpaKeJHC>+Ad zkcQ3+bXI~Y9Q{F`H~;8QP+Wh6&wcH)oICXt$4(q&m}ESj@9?j`{4wr)`eCq+C(fN= zN9FWnLD4Hn9OQkC8x+{hZOm>?u{%Wf2FN@|8G#6vJZB)$25G?-eT*KWwV)Up%%Jc> zzA;Dy9o0c8#lljT&e1L_t4pk`EU~<_!ur8Os7{Ba)iqXD)_C6se;iW`eL#9!kYp*= zR_+q?94`^em&@Bw-W0ZJ1D znNVJxvR?7g!g`W?aQTSf>FCG;dl5E>tIE6J2Y}BMmV;}DSYKTyHwC%Lkx9b&3)>t!QLvt+ zy!5sgaM#!F=HS6~hK0mBi4-A?x&H{iWL^n;|!3;fN${@3~8_x&J$nzaeDppnj=|R^7iBgI*NsuZ* z$__F~NRou%aEKEHh0fX7*xkk@JPc8+r3x;Q5iAW%at|gtRFZ^vTs}B^kr$y) z3XFweUa-*Vu(a5rySU8C@?mZ}beQ!6tE?YbXJvhjPTC z9~uUOKEr&M!Jv<}j(lh^`2Z8#Eww4o#jxR>fDp(;k)~`Qa@LA6)Bwhy zQp?hEhD#JfC+G-;$vqoYcGvmE|L|M<=70Mwe(b&P=1>3851_Xs zZV-t=hjUa}&9J}4^71m*U3V={J$;@N*Idi4E-;0cN3|V5YS|w4$cuu-B=h@$D?CVQ zOu&p3Dv?NKF}jD6T?Acz^EW@j8(#Mcl+D4I0APlxBKGx2;W$_K9(QdRpwL%9E@5ZqxuZ zx&uYxj4j(=|GeGUI!WJYv;ll3W`?mrKKYUp1a*%cvhyYy()Y&f0GL5OU z7a?&R0HVgCngjw(e|}p(j(%TPVx^R%X1w z@&VVs8`j_$G<|$4-`Vh+t38_lRogv8tePj6a!*{c=xP;0wa+6aG3+$l44K#Ug zi0S93ejl^dhg^^vNR0vmF4yFRrpPV2Kg13TjOk-+0WxD{X^j&%U&rCY2RVBD7}sBW zJ%{aR#dmQX~kOARNd-`y&Bq^s)pW&0g`)PK1L-L{^O;ZnEIqmCbgS(n6 z0OQxH6P|sFXWfFrI_2a4IEND&AuYlRu#Pm#z)6xMLnuj>Wvnc(&|U1Zy0*;0gX`RQ z!z~;*aFE5tMbb1OO9eZ-o9ygtbLQ+h?z`_hJodz6?CfkZ9PXl&V|j6j#qJVW(nVWK znkJsNW;`Hh3Qdv(fWXswDmI+8s6+)rsVRXi=PYTeD0E1k;CvAJ-Q59so_phmHY_bI zam&rm zd3J+&KrCjyy~!mmMIr}8fTis7xX*c@FG~aq-4ujCJ4@!$(rq%&3vRgaMvfdk#J%_5 z3o1oqi#Xv~(avgwQW)#qc7@fLVu+QBM0$gm5(<|W967Mg$A9Oe+`+ zxrW3d(v*d@Yf*VYW;Mg24_Ff6=Mup?1qnr=(OPo&$N@h3vCnYiz(EeKuVRKfNNY+k z$7c~VW&cQ#u(Yy3zqd5;9eZs(mI<&Og_{e@?Yw+ zZ7=%fEbwiwWmc$~X>}O&;Hf{OP8^EY7_CuKHWH!DYTuM|^pKsrg`1UoB^+yBs;uwBDlpZ)~) z+!@q?6_mB8ET!o8SY2LVC=#r(47aw)hePa8Qw%l5?g07&Y`;&(g6<7b1A!eHc6SSO zQBV{HqYDxQ3u%|5hYxYX%{Ox6b8hCA=RTKPZoY-XhYph{0WN3I@3XzrKJ@Y5 z3aPi>CWh4bdE zBMj{;F^qG_6|T0Qm(1Im0vlPi(V`(lT~VpGtpe=8l`O52b={(*T>QEhrEFIe1=bp} zEc5a1q(oWIrgolR3PK`<^8bj`{n{GFU+d?5f)ydz17TiTgFyQ@P`f(}ds{wZo3j`k z#uNxCQL;<7ld-zeWpR0lJ(dB+Ze4v3_EYA>SUSsBzHa~ zff7}wJ|#d3MHamtjE^stYgp)JVJ$~_JQ@l-qdm@nRHTi05rJgia-X?0%X|zbt+mg* z<#f3(?|TN-=BCA=LLE$vHQ@I>e@+!Vxc=Ojs{ z*xcOV>tFu{x7>UKD=RDHgF%SrWzjmgP9r&c?mQ~EJI7QWP@2k#*&DA)!&luA2g`{_Sf5MRb&iVnotx&5&>zVz$glBS^58S_n*O*B-wo@_;V2vS3Q|pRhE`rRb8#M zKm&~i8X(k=L&A23L+mV;OS9z~*LHTshQ=L&HUx(sIA*jlnyt<3%!egs2MZD)sFt7r z&}g(kcXd^l>JnL%mHK7A%%|VGA|hPu2akvw_rCj7imGaQi_E+?ZiI)2hxBqE#K-es7+6ks?vf-#r52BmJu8 zcaf4XHM|&LuXjHd5cD~ zfsk5zcy&SfV3AmUcOMc$QCm>^UB| z?>^Sn`n>Ym8@%!Mc?Ow5V))_rzsp;%zQpnQd0f^9U+CBgg1CCYiV&h?R2+Qn?PL(| z#@4=9ZKrE&-ychzq#A=8hKxcvjP!*<4kg2+oDX%DGX-cnoG-p|^!9$AZ)fi=`&}~m zAOK6o8k7n;c3GAdKu`G2;SjB=?Ey>*UTOE(y-tTEm0(C)NoHd|Peql5|(U4|)zN)=9|^+*-Q-k6-_wH3O9 z9!f?WJ#w6qfMtZgZCj- zrPuFba*rPrDGx3LJDhh|7jzOUwp0N+dmmD=RU}y7T*3Ghr6mZDkP4-vO6N`%tVK5# z*bV6<#lK~ko7OrKQLjAc?|8A@X2aBsp33DY(h4K314GXZr9JAJCcXkgL*>3A&pg;m zL)iPS2KnCKRx-^(ilwtT&1Q#oyB+S+2G$+c)0CRj<*ePhOcX~PJJO=t7u;C7#ibXo z^2B3Lb7*P?+g}IgX=+sf!UBcB076Ej*?>qYjP3H%U-=CG(VzP|E912Bbv--SHx={{y!6IJe&W*~!?`}O^tjyO zg{{DSTR4+ho-;K)&F}v1@9{go^H2C&zwzI2PK#bV`(l=RGjU?xhA+y?NpAGU&aOEDZ`Z5iJ${JcV|`r@!_kHhspcFT6;z zYw=c)Ze^@rT_<1MU@+*@U)v;G-J+=^GEdRQVY+?HdLP@(a5kr%v^Y9)3P%^)2hWY9X%GH5oq++uBxa}wbk9lgcJ9+rIasUFjaE>((MtDW;~T=p38pGpB!om1{S6{rfL+ z^_>fJ;-EXD3Nhc?Nz=Xk`BbiY-kwh9?U{7ssZF2qkbsju(lCOOEQ~c9^@vlLs00JZeghq%U=B}hf#i4Avv0?4+ z_Pdp~TN$6WHmsjYc_J;yvo6OD&+x@Be2&K+Ji*$U;hW$57O%ebE<(jD-CAaCX@Pgo zy~)w}S(M9(WT9Q)=XT!b~3Ve)61Cj+Yf9Lvf6b#!*uCNBdc<> zWu8T^MnjKv3NV5!U9X8LfJ_iBTr8sZUNrV%g zNNWC;>AwbDTZ<5RW|eB#%D&488sque|m{Zd|{@wQHAYwb~>} zM5om*sxF2Kod~cd07kj7q}`Ow&CQTzcjyq$efZOS>QkTM(EMT2exG;Wy~J;S_qVye zaGkWDffqEIO&UqWp}Bcno{{B-{#GAr4gG#MtaCOZk<)aV^JozhCrQxo5TQ^~crPL- z^gdLP0w#4vFTcgrTj!Bd7GRf0h@3pj$yCr0OX3I}X)1lDaz6m@4le}Gx==I#hg1p( zg3n5`pJjX3R;J6A#fnO;H($=|Lf*m`lGQ>;qz<|+!6q+%KW-}f>VCHEb3gXrRo!J9 ze&mg%)ojvkH?bxM7v>4h5f{KVtr%-_2E85)4WE4OQU2Ax{UQJKpZ_7h`14=k*xU^1 z<~rUQthb;bGa>aZwFcdY$X(D-+giWQ>e>yWD50r9wl&^mJbd~Hf9?P97wDyiL$fg} zOEYtYoa7tdeu;nd5C1p5^2JYa88 z%6@$Lz0wx9ox1eV#>~FEvD=ON%i9B}JCD_!rV{Sfr=5P|#@MQ(%vF!nfz@ntGsIVl zAYuvX;A_0^+W1>;zm4mZ)frN|G3LjQ-RYdWft5o^k|DTT+AKAzWh4gUJ)yi&sp}wp zVRHLGJKc{~)NUZwZtb2JtTlser|VVeEfZ4)*%#Gp8!N1$&)qhDwdXa$`6n|kCV@l; zd%5;^z8#-gl~u5P;TT4MN6+VdpivJ6;6rM`-s3m6kXEk3r2g#`DH!Y1Z6NWJ$9K3S zyW+(wE3Wub@5*l%IJ}z<{C=2kdmX!XE1Wg&!lF~WcEU>(ioQ)b^6*)vj?QrT;sw&x zEuy|h2#rQ^^NovS-XIkS<4JOl>GkP#Hy|^_R&ls9!+noF#v_kC!sCxU&Vy$k=FG`c zXbt^dkInT>R+pDqy3}QDbB(m$!{sK-MHiSV7K4@%UMZXbXAS*)K%_OwEph75Ek63x z6sJ!lWLcYUe&-S|pSzCH4}#ZNlOu)3nvi0xv_O~~MU1mKLTqwmwnJOpVr%Ipryd(1|c;L7g%4SoC%UBPCy2M&*k+sRyHWHlP0LS9m`87T4Z< zmxk0M>BDthtb3<{PIheRQyBTaiKU1%<-AVQtz8F3s3gX=aZ**is%Z^w4Hu)+_gm$jCBFu>xGLgT!p5h+YAkR+Tq{4mE3oyBH8XhIcp zz?T&di7jL4)!g{IT5| zeC^L#ZltGRIYO%uGu`Qsr)dB<5jbzjj6p}5!5}SCd=pIGXK~>=U;gaJICK0K7tf#L zW=rv*$Dd@-?_#qpv&|pJO`;X)&*ID7!g3Dg4twj&ho7reEq9m##+nT)^(jl; z{mKi$jS*|Q?va!`8PMVeenN$knEK-zqhOEG`_rHzXJ*CYb z0vfVlwaTi1M5t67SfD$c(bwk;a8UHN4qolh;~g%%=Xtq{`}9r}f^A^bK1N_S#@Svo z<-G{{Pm;))1iFO+PLezi1r(w<3c;Qt1$@sj-Q^zcG|Oq6SX2M|fp8zF*7}JSMzOV4C>yG{2ZBk!%e?F5x z8!PW!GtQh4Me4xNQ43|bhvn2uXzOmY{VZVBGr5Bnm}Mb4CZ(_P*##cw3^X;f_Z`K$ zjCWsqo%I{bOsfV4PqQ&-Hlp%rYijWP1KM>gkwES2nrQ?Gr^)eNHY~?ZOAVtvtYmrjpmBa|J#o}y@#pO0c;wUPbDM_3p#99+a5xp#DdTyRC{p`>1 zPcHv2h}0D2>&0E&aCeVD)vr@TLfjCD3d`?yQ>k>2%Y!^a2BI6upAY8}41xbUjflNp zZFEm5SqdR#X>)6f<+T-(#xznz7~_hCM&MDTgPim4Uf?4iew>dy`xG~Cta58@gLZq0 zK^D@lA`vQd310yIO5kOvl-5Y5&~eOGZ%{WqDmOYV_DDUw$N66g; zXeW)&ZpV+)>^~^2dagAaC`8sPzDXGZ4ZuNZV0n)eB}+vVptY!+^OD)_%m4V{pLaW$ z5mEz-J*fnhMM%$)qCopaSu7%?yD6!#nI#+Kqyvla8Z9EEPz87ym;qr;5Y-DTJs>Pf z#rQmCIzGy=nTPQG7B*EuFVO>1prr`O=gQJ(_CY!f9`t~<-W6^vpU2q&URu^xGP+t5 z$BHCLn3-wgg(o*Dul?u+Uj5PYoH}`yPkrjseD-sn=b2}pB~NoUw>E+UZcM0Vs3SU^ z7OhsBLx+ygY$WvhDc}3vOZ?XV_xIRXTc*`YICSU;kyh9|r@z&s*Bf9=sF-Q34NPN^ zgl8-Op%8dt(dNY56P!5oIHoTVHh@+pT8!867A@x%S1 z-R>Yn#O79)JkJUSae0n1Ew?CSv(0e6+i&v0-<$GY@2HKI?5f?E0B2EBkVKkJvq7Gh z09H}3LppMs5_yGi5!MAkcIgbOH?Hu|$vMuRdW7$M|A+j`fA0yff}qYY(H?mC{a{0r@TiCL3eYV-o`po1wm?4IO(xYFgbXsxV5~@pq~+U zTH$=whN@^PtfPCSz`YMMW#T90z7Xy|?AfJ{BSjFXukL5U?+c0gk+YX`GWdME_>$Br z`7vr!Re(;N98o*>IQ64Eb{wdf=s)jamsa18T|iWJWbL?eyDG^h2UrlcF|qdf4&QCt za~u*u(pI(rF8s7@#2Y^V=l(Z*w!OHhEKAFm#|MFno5JjO7bFG4FKb@X`AnIbRVyhtU^n5@TwobjMwDL?;|u z3igf&yLn$t`kes8iT6LwrWzw6jgCnffu1l?C(PTOQCYwJ+zg+&uOB})r?&xTd%Z9f zBWp7IYuDfIfH3bqwq@$$=!;m7EBaEutTwNP>8g{*@?N&P!FZ5`P$$-f(#-_HV&j2! z%>0=Xn10IIl?AYdMk7MYCcTXf`ld&oWoR99|H;!l{nS%D`pBa^c>lwkICh+7qd~9V zWAWwwrTfIc;7D)V~9G;ox(ndz+2M9v++hY(u7n#~5yC?Qs%G`tj&EK7;B##+nG z)qN50ryNMM_?#4XW>0HD2xZJx62V4w;5~bl$Cu6xcYBtxmxPI*>_nkV*r$7B+{^*at zOMft++3H|&gNy?UN;-#(6s}-LD=85|Gd-nITG1b*m^{Pf10K5nB+otbG;=dE+_-*= z=U;w>=U;ji(dck?A-^U?Q2)r{W zr9;&#Zv%s57b9rmKC?^vAc0ns?gs+n$QNLM^P}sxuhPWJdo7)e(MWRnf4)dN9N8h= z{`Q;wj#Y@}4#5TxA>9jswWdh<3zg!OkZ2_lQjq5kEo4X;%p95+AtDq(AidxiV4X)w zf^@!SQ295yD|jPJe5Y!5}3a1QxD9gVRJJK^HEj z5*|;eyylIf6(8Y&Q%{p^Nkpbdq9jytL*YaaWw$OzpfS#1eMYmbS=qeCQvVVS8I!vV zghES!^8)82-m8$5Dhdjd2a_9g6pTfKlm=+|JFD8{AWJT z?936mgC&g5i-H9Kq(TUCV^KbU)IvdOQxFoRB`R`6QH1kh&RCO^IfqmZFC@7WXyLhW z?J`jm*yCO)oEHe^2;{49Pp(WOq>z{JRP)eSgOs3T0E2C2LS;kixNzwjwhWj#8;ULz zz-w8_xMXg>4`%cB=gZIr0xI@u8MJp4kGXLE>m-Vbr9S3wY^Pp~0t>^m@a^9h2W=P5 z8M}tBolA@zv+aDf@An%ZpY&a26HOk(v5$uEU7Y~2pZ9T_RKb29=3Q>PrQA)@ys8sY zlv2cTTopEw<=X2)in#DYpSgN2`&g&;ix1rH{q7XJ+~)UJIGZoHGot21Fj8@_;@4E* zR)HB5e`+DX+j625-#)gZ-aC!czWO{iUXy;e7xSpzmr2O*@HWPuahawx%AzZc;xMs>42!x$ zJfybo-lzS1zVF3R)M$nizMrh=<#)W+K_YSW&8z}(A&4(iMrLG2WwCWjI$`eK_|Y%V~@hgBTdd-+2rL5m(lTA zyl>!~3RRJ8U|d;ean7QoA{z`)!qeRxaOu)2$7iRQnmJ7*W_ahKWo>PpNS($xLvC^$ zCOkRsFy3QC1VUoHC*u$oR|QwUxlGIk-CQ$}v(TDBCk;BygjTyvtI+@vw2+*4q-lm3 zq@?`;>zga|Hn-T?*reO*GDrucd4{o;wv5Qq4DH;$){%p>eWg)}A)6>L?nYUQVH2&H zaHRmRESZJ1I7@ZB7zRj+DtcvPa{r+52dj;)529KVL$CxkH@fI(9jO~=9fuUXusDR{ zoZ!;M>rBtgaO(IWo_Y3hzW;+a!Fd{u2EItoh$2Pqa-v2Q40}RqLZTx?OQ3~g({wre zz!{!<_8D4ngG-mL^2%#(^5Sc+<3!A$KVY-F$xnXn3miE#%kuIP>r0Cqn(gqnfAjCM zyt>N2|HFUJkwb?Wqyt=T(Mo|c;2m1Zf=9A9qj*2h?!9JS-OhaSlnm`)Y;CV9E2tN- zC_fwJU(~|N^;A<|3jW)Xal6xLFAA`^Vtcs|$c&?i&8AenFJ68fkFi~YLWg_zRyyCcN#?!z-S-y(3xjxMAMK-BBf9Wtg%Eou+XfxNF~TE zXx$?7DOUEme(Ne*=_-j37+*NAqD;pOX%=bUU~)&Z-6ZYjAOusLX%K=e>+`K|eG7nA zvqc<5!4@ehfTn56AWQLhnvEvyb{nZQS(*j?IBUq$jDEKdUJ!`{rD6mT&VrUvV4*n> z-eHje;1Vq!I{hp@OHfWCwE`;wV1Pggg~bFxdpt^dR3sSKWiBthM5I!38`8}|VqFGO z7Ob}zNqGMt26Fm?lt!yrO}}zZDIL<5@;oQcgP6Uyg(D;-#^eWn*T!uuSFGQa!%_QR z2yj5J*T<8iltL+4*@{C`Gwd&-1SW>QNwx2|vV`sX*zP#>rUqgSSB7xEQo_u1r(gp1 zLa`=mLl8)YV274LXH+7w#s=99snD^+WPQ48eSYF|AK~417I^8Um&l#rGoO2wcBjSa z(k+yX5Vl}gB0~WNAuuMx6%}Q3V*)48ivo--#$5}Xa2PM}D#BP#l*Cw*;jOyBx6d=4C%W|lX=KI)c zjF<2>)CdL$5UP?7t$dRbVD@7G1Vg_`lx*2Cv8?Usg=4HJ@An*)Nqh5NGC||Vk83Y) zAC@5*VNY9N!pD=qpM&h%Lo$vil=!L>@B1A0s!g)vtLH8R{eGX`pdYf=YIDUG>4iIy zV)i>eJCSE5`Ns9;_c+&H8D={XV0^hJRDti|o={%6ED|QG^OdI%=ui@UxDky+1w}3Y zPz)($B_(~g)U~|^v^=I{5luSJP6Oj>wxB?a(bW8i5jp-4b{=%KZGw*jKVgF%G80Ay zbB_(~jv_fi?E3qIvWzC5WBYhj&gKO4xB{`5j3)22K<+21JPB~yZ6Q2pdR+Zn2RVM% zR&F3g#IT}thA$D6<-BWi=>ri*+Rv?Rry(2fg5#V!Om2uHfx|H%<<#Lh4j(zptsB?4 zerW+A1=BM#eDTX);S-#+2%T5d?w-4+%iqE&gsKv$@&|-_U=3A)>C9IMA5woi-aqjp-4pJCPemAe~fRx zw!zv!@z4XuaVF;bKf1xXSl(gSN3PGA?JpcR)eBtw-;WvNt@3Qij|4+R1@{7#Q%#hnb!2(W$5Oo1M z-i>xU2H4%W)`1`ISdcBSu!5;ok0gfFuzG~B{>~2nEDL;i1bA6{ABMKYegix^uL-^Y z#fApGwU&0fO@A<;*W1E5OB^*o6qWjlbmqY1@kkLPghrM77Xn+PG?cRK(u0r+NnF@R ziib`-!Ti*5vRQwPQwjOnAK#9Pat zn_*Ima1oJ+LyA?gzA1_{UM~tUWPz3qG9!8LlFZiXe=g$kuo?k|OKr@&qbTMgwzO2@opPZdXqq6mO11gXbt*x~yzJlC+&Rn1}g zfUq;~oV8xsdbOal5Oi8iA}KL>4n>NeaTXtGa+4SAY{*O+#=?S-rh2!X@pR2558Qu( zR=dfy>$mv!cV6Jw$zweG^wTUa-NYGNR8kJ=llU`G(^PV9p_{q3amK4WTya>jtp zd(rmHlpFhazwg|h&t!*ST^ZCU|KSOhcdNb#ftLauD(jR?oCyxKsGqc4Ey_&9-K!z& z-tS33#`x#EIQU{RgJm4N+Sh~k^?AK>YGk#FLJfczy`LR_dz)ikbMxy2;Och_vn=wEB0|4GYA-~ZM z3NDsSD@ks05|t1GsTtrSFr>^MnPYWjl~;cF3a3xr#}~i+c|P^&&v5+cX>#LO-dy9t zrOTv)O|pSSi3D$b(D}ikWGDe{3xQ)th}21!W6T=&Ki1~d{CT4Mbp}~R()K)Z@)+kY zY|_g!JTcyRGGm7GS}IcOv5pWyk4~}BjaYhZi5D;S>Gsy>^=BYHM3xzPX@;>5oWNSp zLec27nVo6VY%1DuR8+~*nAFf+@3OGC#L~*UY^|-cvA#jKpV9AjQJKT#2IFm@$V){M z#b~KWnw?N}B$8;QXeSBNNt3n3CEVr~v72D1ya!;9lr^xm68B-aR}5ze!*I%kIYr4n zf$_>&z>fiZyPsA@j)wb{_ns(<=ytno_PR{X9$|HLl~${Rkg}*|X+bEo5GWn9w6ekL z=icSXM<3)vPdr50Gu*nhNz`Zo7GwLwiNbkFkjD)=SdoLYx5bGghxo`xo(2Jzt}O8U z3$OC}xr+=8v^&#Wy>^-V&K%?ECmtnBdpKu;(5#g71_Pdd;Uzx*`A_g){n!5`fAz2a zH8$6lNpwgZ)G7iAxwS~4?qsm4@p;dl&Rw`x{S-kDj{w?M6!^jx@k7sdLWD|$6hR>B&iwxN)AHUX@1bR7#PC^2-SWh7!tvwBS>IS^Wn~rD^t767tj*CX zi1e5Li=-ry3cRWuGbLqFX-uGGNQDwYp@_(x=b@vIa{AB%40@hc)Wqe4!UZlv6%p~= zhKO^N1Q4sy(5!4Mvb1#tC2~9x<7G%+q;P19aSBFap)F}F&gV2536YjqS5$5jfq5&0 zKxwsYY)o##m=xMlT80X*gYJN=???*P6<*;jXen{HkfvuWTI-mRwwXo2`5?02tmNM2z(wNh7Q!&LRp>R3d|R zZDDiADhUiL6)NOvEwL_VYhx3s1c&AiF+DSd5LDv8r4+@Tyes1@5MR6-&V_V9A&2&{ z81X>MRF~r4vW&Bjr3n__``R@=DDBQ{MM=pakOGXgNbi`Q>L4YUJVU6c8eeO2lmwII zNUbq~0yGjR?-0VEM2xcnZ!KG!tIW?dm_PI|H&%Q6tKa{(eB&G6;y?M-KZnf+4AK-W zlRyemk}S&+-V#ZV5)pVAQdX_QyK?-4OoZ|tX@hoSW^$ws-)kx9b_e`F|CfJ2l4$;^*{G&6Md|4CVk1+y$8>1C!gC-81s7p z1Wh{UZhqCI46r(gRT6E4e{XI6Nn`l_Hc|e!3TjllPe8-bBb!H!-d-YL^|X=F@bV>) zQ_$+i^MLm?)Z6C!{*AVsCd_CqaIrbosbkx&N3qyW=+Iu>-$x4Ce$P|y&q&`6aPB)z zQJnm{iVR#`qfXB(KEz|FD(mb(k=*N^@8teIIBonsgC>LxGUUdvy1LA%L7!jywf~%FAAgFt8MCsw!fWTxvA(v2auL?1M2SHt z5p+B#bUn1gxg2LT!UX|zA;VVgg-4??`2c|>&3mMIO2=i`JVnTaG|R}n!ut>@v_8k0 zf<+_+Xy~UIF7qH~*mRPGrFF0{+nz^wK^#SNW*bCNgI2p8STYU<>444NCToiuEUd1x zy0*&t`Z~RCpMI}Tuip!zrp6*1$S711kx`6J5*kV)l?*o|LCQcecM_slGu3R-KyvHq zHP)}*psC|+4oA7k?UkZ?r>)u-q%0zP#lGM|HMQcXkw5BD#F2T$5X2J03seTi`@K!x zX$xD8s{o7gJ&K};EX!D3UFH4<9%J?L0`sLnv?g<4k;G(Oc<1eFOi#~n-|<6y z=*fq9@r5_(=P6O5u~vbQL@LHxQB?PWJWc7eT0HagQ+O{q|L!H8|KV$#d*=$Nm2_rj zS-gIg>FFjP|M0Ukl^`vd&)%bTjJ2?^c#9vr^dmm|xli&}{>oqB@BFvFfyuILBhB30EC8#kt4JY<;|9*#>R!C}C>MU5d3iCO4=!3MMI{kiiIRm18n{J*2|fz&ev5oylY#O^l+2@;W?2QG(GD zXLFR+0l2flcI?Pmp1l8AY^F#=jFtsM#iw}ZQBt9V#tDyC3Dy^YU2l=a)hk3&<6Ia+ z@JirSjN8)^UsPWke(EdWtN!o$OH>w3*-dZn{b&bcx0G_)vKGWC{$?$4teKme z10QrC@xEX?Tf8^Kk;eEORD(w25V;wUZmk9&(_4&4u_9EvQ(j>DeR99W@!5HP`Deev zZ~yM^^4tIGKjhc`v;Q3%tLwPDheTk2C~0FX%pW;ScYTAkjSZ9v8e3jC5TRIsurfRg z7yJRGJl0!4G3XBvBIcD>Ugg+{qZL>)9^aC_SSyNW8!{O~5gTs}QffA~x~!~k5JiD? z8OFGjsTd#EI=J(}Y9&xp$4;ss!UR6X&KV`+jH_YV;iOr1(6*nfm&yH_{QJF9&ZOUd zj(v~mZTPko2)FHvozJshKXt#qAMfK%WR&p$wP5o8FKgGSgE{xQO~fcB;5XyJ>4{^( z=va)5&-M!s0jzD0ARMjB)0R<>N??wnTy#;TrXFc8H_*yfWA{n47t3SEo88BYwA*p@ z8{PRFVwdms|Jh!M_CXQZ{j`HpMfVe38=0hXSS_K9;+48)RRu(Lerfl5q4(RTJ89cA zvhKn;cWR<;#8tHUf&xA15eZiTfA2-R(}lR+d?6BS6*zC$=xvhBcKG_Q{1QL;%fHCn z=~H+oSXx=*@~fBW4|;eEiLf-hVY($*>)%4?DQrHA6A@lOnx!aZ(L@NZa4w|6SUgHd zEQ)vDy~*Q8H7DjCCX?%2UlqJ@VU2Ef60Za{3n_0_;xF$P?4tDYB1aGK$@|5@iK$eRi@$?mm5G=b7xX&?!B7J zRXRus$}rrdsvx1?9vrCjK8lF3EZMEzSMIwfh?{=E0_=A-Ue(_MGub(ZR*KDjkDH5& zNU4xgvv}hMr%s)$*3zDmG_bF=(~U;R~He&PH4i{JY_+O0-N9h3qsBCxmbJp6bBA||(OhA6{# z!TZu#ExnZbo=)vu5!Gko&R#wJ<4#&VC|RYSRK4@$S%#33!-o$u7!2t3yCg|7R3$4J zGF~8Mgmh7n{-G+sRfwW8m<6FF);NxJPVng2$51XNj$#J=jAqgVBzc|&;9lv#ywD1* zCAmw98jcGW&yl%xlmzbs>myPL0~b;mmC)gu#TXU*FCyvjJ`WYm;wbdb6?uYi zhLlC>AJv$6Ksb*z4(TOH)JD4qZ-f36Qs9LR=XC<5bYPHJOCnl4{m4gXswu2U@Ubqm zgaUBpa3~#NYyg`YjV6QKkeM!5uAM_*ab6JVHknD$B8bgX!JQf@90*gNg(0w5vbw`f zq4r7jB@_z{P!tY#bJNy3wq zQdGhp6C|jC{oBOvRol1xM1@+^&ndX(Z z&htP0!@tKDKmS>N;cK7AbT`n*%JywP3Wi(pt1LIh))*plM+G8 z?r;3{|ANj`8}G7Ejj~F0rC1=#qAo?XRu|q$Nm&3Q;l}bR8=GAm9h?Z_%&z9&l+#N1 zItui`0kY}@j0%)3Y@Gd5Gq%sMYQjwXe6me{uk6D8rts}YXX~KjaA1w49s0E2bB-Uo zpZhq-`0R(QF>+mov*2z#--BQ%`yHo)%<~;tYh(5hz68F9Y^-|I!y76BPGcu7>5uiu zy%E>$nW+SeA&-r+%}OX00%prXS){G?sn4V_5!kQSKzJe zOKJQ>QG_vOsEKn2*LpwNUVXkBlu1zs4W?Q>NILxBtt(s^ z+>h5MNsUCuGKhrA5QS7OLwJW*5l%?(F{>LLe*arndFHWsP98hOs~7v6Uv87iBUtBg z-e9c373BxDiV#wA^3V+JW|O)0JVGi4gN*f!RTi!+aPI1LHkX&_Zf&u(wMnm^(M?m* zEK~;)P6U<>p(MM|0wp7qS2!(jO5!Yl#Hk$b17pA=aF|fC9*I{1uN7VkWUOhmTFkdQ zWNRBNU%E=VzJYeZZgfhFtbcct*1Na<)ev=c&Q-=__4`^(xqePx>!0@?Cj^T6rd>ET zrITGo??1rn#z9)RjZle6MyI3C~WP}z$Og-&yGCw=V!w)@x_ntT3InVPye3dKL zZsBBusL^C)b(t*R;_E;86&}9-K1^>5FC1Sf85drcRJsQeh-rkxAK@k@b*C~O4RL-@_jDA?Xh>@ zPIWEA`(~>&IpGUt5qn@hSPYWB0b2FByi#CJM_~YO6Z`g7)@bqj&%m5 zL;#JDNEsIlvBCxuA@wP88fP*z0_y`K3>(C*l?-Wj&IiCmh7N^eyvG*-x59{9%qCzMuYv2_vQ@CbtMLh*Se7O`&z?h>{3nGCG|$ zuf6pm{d@^66hZ}-ZY*P*%kbV2Yk_gT+A|=jg2k?2V<{xa@Qe|RlwtQih`B2SS(;U4 z*zc5mH99v7mPqkDWLfY>l$x~Lfu}OyDMU!C6iyb!_8|AlMXUkttO6~lSfbSxBveChvls%7p^RD=J5e17H%@wSmDU* zECHF$!?X1vf!E0p^utJ`k~nK{vB&$AlSkV)W57DR*VVHO<1+;5OF4p|s~qmr{X2gCfMnMn19H+RKCscPdWV=xfM<3L%vRxUatl!*Dteu@+ z^qus4b^|f$0M$Y6X|HpC5H{ewUb>0YROf=TE(BU@8jS{7mXW4ubXy)+jSm%p-U5}*WQPq?T~o_J8wC+-^#^hQl*&f7EwZp@kMlFX_-8nI`Ycy(+~hmYzesOm9iIe2#Qkh?<+Aiy{yV)I5uu_wh&HUnGfE z8Tfhf=pm$>CNdFGJ3(npyVaoGY9Xa2&2!e)m$`BE2H$`8d)&BogXQIAHn%p&vlQt- zIgb*doqSlA2T+mH66K5u^CB>dF$G>3Q&;%h#^a zX+MhfuBZkj&_ZFICAAr?)(qFK-QxM@U*uDt`3R>@9Vg8!*A`X~LZUp_yw9Qe86JG_ zepIA*^R4r|@bc?ix_T4uHSJc5mDLsc-3`9>lV9eE#~;O}eIgy<<0g4NKq(h0Qvt^2 zXdTgsr`gz8;fF83!dJfVS^oFG{_Fg$-}nu-wt6($;aw275%qq*_rE)Pz9qAyn9W|) z-0hNVTRJIYd)F8MU4G!|R$*91Mp(Lc!$=Nn>CnNTz2ytw%n>JXc$PwAhqY8FuL>p-2pMcNrGiH(oWL3Q&|}YVeEv9tuA`wfQYvgAkgk;~G_w>*+$75_ zePd`e6Bce>WqD(fuy<-AsW5ql(h;};h?6(~W)8g6SPM}Uli7@9I^oK~J1qCEBk@=tbkMYrIG5AZ4Kkl$d`=W4 zcpJp?eF4CELi(ncj)4KChU;f|mYz6{0FSi>Ap}tr)#k#zU^0~xw`7Cap#w4&mnf^3 ziAW8LcetW}Kt){t@mMGJ^M2Of=ON_hu^y!G9Gab`-D;EPIfFq;OT~zinQJW$$;Rd; zAAR;&{>Q)fw>fp zr=I1p$KT?g{ri`B?vr2QAN}Kh&gSZe_|;$f3O+Y@r;rqt0G%x=j}`?y99UyeT9R7> zII{jGu?%zB;kVoK#rx>+cqeeK&^q-3ljp>-9mLnm zy!&#K9A`l7<=hQ#dh&#?pRar=#?L>geCVV_Vy6eV-?kgwu|9sgEk@&g+AaM^3Ry*j z>)%Tuv8Je`V_|EvS3EOS2F+<7^ z^U;M+I6{TKa2pP1e9&epSOL}t2gmv?GN0n5rg``}i zUUz`A5~VcG2h9+?M@Wxz1Dwq;Ss#-wlbg3`M6(>4)f}Fi;q7Y+#GN@z9sn#uhE#c| zIAy|GWSz%iaJoUJPocySG9hWr%rnzIN+X#<>jn}uxlA1;%M!F)3T=W!E8MzulbN~0L~+E%#uk^ZUgPA+Q8-v zXC8YD?{XroF@TaW5f>hWBa5O1ki;#nUti&s*WTf0fALp%{>7L0$G`QzB2tJW z9l$#0QBjOB7V996L)mvwYhXvt+RurO2 zQ3Dr1se-xWDh5zssW@C!;@eTAQ+a~0yYq#<;O#eGfKcVw3sC~cMH){Y*i7wa6D<|S zIK0jAA}ZKd3U3UNY~V%EoWfhI2w;Pj30ao&#C=cj_?bu9>=`1Z3+*U@l7`4jth2-_ zCO08bZVduOESn*4fkL>z(uq-6<*>rxgb44J^(X+T$eh5* z;yo;wOD6Bp-Q1wvYIE%9T#>TtSzTRcb90GSdxkhlkV+TnNs35DXqkYHkUl~ZDjx~} z8#D`Z=V>+?eDu)|)3FJeR(OKYeW;q7V++Qrlo85BB-OerB;I8hlM=@fQKYfnj5v08i=iKmEp*ZfeJUq> zkq%zi%r5v7!g-u1>~tYV97*q7P$t0^zYBuhzIabU&5$CfE<8gmsomRqDR{ge03f`B zVL$L5gbIF@F@_|ToH=!v>8TdojU}{HNC|nKVXa3AkM{;+3{p8h_VgK?H4J)PgmY*W zbToyxh@#k1u%}(YUbhIN&`RJ$gz+BHXmH`m4gTlf{yV(!?g~@W9o{+jD*eqhE?>UF zZ~gY~@R?71iu+C-$J(KGl@^ejLC`-`3NJjy`T#Il7g!L&gU_K{)x5?_iE<#E!;4VW z+4vY#j5HRl1gXgpQu5BF8^lwGNb?j3dXkPJkXDMoQ>G)<##r2*?R+<$eQC3Zq7raf zFH|6~9zrF~P~afo>ZL90YwpeVd9;nmULHF#2%`$<0XNIxcw)lO^UBtLsnpLTl=`^~zV)R(tYHDr800ATF^DqikB?)Pp*9Cu3*-Dz-4Q&Hvb;=BrG z-e~-CQgo_TrvYPfbesfbW-X91EFvC<&4W{}BSmI1woH%Cktv@2t}&=?a%FUE$Kji)^fKkYzba31S`7 zikgVHfsm#s#ZJ%@DhpBok|0xNHdofbWsJbWxse=U`z=@H* zmG7i4iquL*YD^cYN21WisDo9L`gD+felS|Oj@A*!kS@`P8mw)uaOvU&9)IE~oO3iA z4Q|}HNzzD|oo$iz2RLhj(}2c#P)Y%cS6+RKC~olVQx9?W%t_W)*ANcwyYD`n^}K!I z5`XgT=UKe9fshTRW;(1bFSD|=$WMI!(>(X|lQ^4UtwE^>6=_hCxLA$SG%t)}Dl zwhQZdV8&E7jx18_7z(U9F*N$Bz=ki3vvQu)Xk5O`N4(2zbkC~u#>!6|QKZSTUI;YF zkXGuPA&DX)6nXB^QG}G5+!(Zuak=Eg{82vi@P~*=h&@QH5kg^&K}Z#xI@Mq>uz_7A zEKwsOV~g{bU&oq32%0*Gb*o@`f{==$e%a9elttfs5tWt_CBf1Qnu699fmg^)Mz7mr zdS;qqM~G}dMz)sqOD8$R;% zN11KRBJzla(s&fsIzrmGCrV zh`;hz{|o-+Z~piEZ@=@e_y>RQzvs{V)F(Oj+V@#sS>f$-Zy|ineW&lE)9M6JO$5fD z^*P?!keOi}s$d8@W09q!E5PD|R-z9n$wDLut;zd_wBKjC5#daR&HHq>Hdt6(#5sx9 zg^j?ldgi@h4&K>wu6zOk5_iN!P!fG2RRC z+U<9f&au}$P0HGtn2L6r{TRRIu3*nyVwLUn*-qZ>?K0=~^Srj*`~F<+eVuF1^hqy* zG<3Oy4!;@pzm`E1!1F?fVVGxF$E-w!f{(w1yWLY2@Y`X~Ck<-(!cS7T55M1GaO%!p z&2XI5_5*y~P4HuM_$S-+``vUWeYP(u^DfBwPRz3LbMNlx?tU-BgG;|=)Vn!|NZh#4 z+k}C;t@Oja#&I_=b6YB8`GOHZCF?^a!YZG`%aCe#7p9n}h&=nDq@^IdL-q8+(qt@c zMd3k5I+SY`;ZLu#DqXA*H2}#*cO4tS6Q6jNpZnGSnEA60v#_|xyWjqO5QeDLCeo5L zHE6GKxd~MRy~QJuN*92JWRO`FZf-GfO^(hz#HQ_V@m+8Sv^Mv=1R%9{D8HCuJ?OBlOpuzy(Q6#=l}G(JpIhGL{gI33}YRa zE?p&wA0SqNbp;;dGMuyI&Ji~n_|)?KKm7qpXr6fTEDt~W7*{V}Vd2_!E?&OM#jDp@ zyuJb|W@dVdwY61lT))g0KmTby{>kSsW`HUe0}&$IQV5z!90YNlBg=#EwXcfid9>0* zTJzeEUgx79eun?Y-~5~W-~X?Fjb3+?Na+zl=rVmW?A=9x3Gu!}$M^MYZsUBu7#BZj z{`v{~L-k&b1*M!TNhifmeX;Ziwg(FDw&PZ)qy!SL34K_A50~mrsXkV=1hFD8f z6r4(DpsCyV%oD{;KJnp?Gt-`Cur^>OX_8xyR{|px*5o8A!FxdxDRO5~k;E#)TW`Eh zufK)V5-&8dO0Y#igEGyUz;O#;xd=O3(DU-(1YUwtHUwX!C((+`=13*U`{B>|xp|aQ z|KoTHH>I01Q<6Gbs5&+u3fmZ(UfvA7^h zAwp$9feY+6A@E+X(I3!gh4)ZJ4Hj26dH3=Kl31aIXJu`J?((~Q>Z515 zcIk1B%t&rre2ck6bNa#4ARISu-s1H)UdIqKGkb`m$B)s#6DiPI;*2FXjwEWJG}s)x zD+)ab4+J31;)4d8>hOafyiU8_;MDPXw3Y~;)9-HJj9_bX3#~hqmRqUYRzABAb^xpX z@WFdo*wKD(e`P=O=ytm@HvjA3!Cv}S`+Z;TG`r|_?{g>8$@q6W`E~mR-|9fwPNbTH zj?1KMvAulM9Y1IL^xBUtyVre;U<9SjvybzR7b_Ef=ef17CPYXSt4M!(eX+5!DOVj{{8qp-0rn@dm+nf zOhSwg3SNzWH~tds7a-dy-Dh9-D#JtGP3vms6YPEd+xc#kR(h*|F(}d%sZuTLRpAOA03eV8GH;MNh}H@l=od-~ywVJ8&Q`C_?1>|M>FZzR z3qSWW+}c>?l^=eSt<{U1nBAn^YH)o`GjMZAIZJLVS_aR^TZeZ+PsKYz`jUuQ>?Qok z8wvNFJj1(JR(bWEbvDcqa;X^fH)yvyOm(JdwWnydIwZ{oN=cU2mbmcFTfFo7n=CEf z;^yKKS(>3$gjO-JifAM)S`ArLI4X5hM8PVPL|TQ2pbse?uAow9E~Gw*;@B`m3RyX; z29FGt15J)_K@3+4f%Ks|B5{b=t2jbL8m}d-c8kt*2PGtf%`L9Jd!Ef3H_-z_0};{% zg$vGN^(;#O(pTx9_u7El2`nEmAd4ARQkTQwm5Va6w|Vairirhj^kvR^IsTMvtm^oK z1sy&}O_XC%d)FxL(3zaMsScMeyu+=<>pbxA6RdAuCr%Q2y_Aa=uJOnQ+(z# zAIJNkQR18pb6>z9O$!|%h1Qxl4gjim7FSTPq>_LliW*$IzQCdRS)TszM|k?fAK{zd z_(S3(!P%gXXl+@cR)o}uVlDchKeGcrwKpX7)%M-@ zahN<|{Yx4NLJF)iXd#eZ5l4`wePl9+k_pCvP9kEb$@2jp|HyNkI&_TwdY_~a3|GR3 z6jdFn$5ulk93oak9mT6}zs1t(O_FAeaXCV2umWivs8Z`mgS1Ft0$UTn2SN6b{_YAE zT*3O00_PpZ7?f9}Y0CWUG_6*PLBEfX1^d7_yl^COgQ@9h)>hYpc(w`*JA?({iIm1V z8B$_!=tPlk8IH}J;F(80gvwM1NGXjh0)RS{A0!uoD2cI7k~zcN>@*jzzQxU@t2E;n zFKnntimB4;Bo4d^_as0eaWbUpg;YubMBzB?NnIcCC?#>ukmotzX*QcX1|#p4bwG`6q{%07EvtT_T?A+2CNI&K8!pHQUPfQ5H2@{K?GV@{nq&d>k+ z*AUK;Zgp8&ycP=6v|9YcXFklMXHU{;Hc+;YB%|L;G1hYA$We|QJIST1*SWHA9p^KS z9zD*~)C^KdjO&6*u`a_KN2G!jg%i4PHYLgjrn{Ds?pBuFt9=qAGr z^d3`AcLK8x1acJPG!BsMN2g#vVApP+@AljA`zimuZ*Xd#$L+?H+BROdv0mIRCH5en z@BF;SH!MI5!GID(F6sDp(ngq)1mHu~_fjUjxxUDqRvP0x*c>egOc3wsW;vOHM?U#1 zU;p!emZSII#|!7)VfE%EX52MCesZ0k|EVM7Uhpsf{Y_rGvW0ClaH2u#a)cE`GD4#8 zR^n_%R-_a-d4xZFt3m&TCA2s~Yw9TR%n9bEW|*07Gt-$x3eDDLpX-Y^dGoEec>CNN z+*-OpmZfN^Q6hjjq&Ayn#|TxbN%DwJ6_Z7q}{9eB8s%NkV>Y4EmGJ;qRup{VNxZ`5>7w`xHumQ8wAH; zXvfrFjeFNb0U!_e>PTyp3TfO?9Dxs2{JeDt6(NKqi4ug;m~M{`KmHVtow=VpO^J(? zIX5t91sOqnRVE>=6e+Sdx>;iU^&fe=y#rNEL0_LYx;FhK(wM(u@gwzUS1~gT|haY)@sVKqZAa#N zCPKIXI3aKjluYnpq{_)ruogXDgkmsWS)^A8B+^N|u%L2!Hm@d|l!7eJ&^n^oY+_x$ zr+v87{NB&8BQ}c|GCPJXq}&Ii$8Gm1>NaArIk?gp8+vIqy&qYmpxx{D;@&l1fv^rK zB#k&`Yjcesz4{6=-DG`viTmz5#mxL1LMmjTN9cXvpgDq`B0?cmgmDgI9oh>J5i-_z zsaV_Sva+&@H3pLo_@$rw86Ld5C@MvOB(oXGJwRFt5U zW^=Pgq~N|IO&&V)B&(YP)>6;!{hROb=9}mE>Q_F`b5EW@WUEBpfB~-r)*A#tQ&jlC zZns%Vr`6!k|GBS$H{4vj%HndL-}zVHMz@diqjMK9UV^n1(30o*HrCL+Zu@<2w)Z8Z zzm|@Qhw!|1V&dKN`L=&Q=AcqE?{(Wx-)?tqO!7N-0^8qDeLXk)VziGOjH`1eecz3b zv@7sd{%$N-x2w+|6!XLvCc3s8MdF((9_J!p5W|>vjZ0UradYVw z-sCjmm{y}fr_(BmO#~s^AjTff?`#m<#XEA70v=IR6^!D9=2Vj?j)@XU62~a52tw?d zV00S{SpcYVhc~v+x-v)~#DfzNp|m23nzWA`A&z5~SC+YP>lPxCw5Hm0+8vrvM7q^y zY2g~H3yZkT9u1*rMoDmCy(4!v$ZiRVcfpZS^%SDiXf+!FQlh06JFz=jBwE#lBV)pT!GMqDb*Bj~p)>TYT=usrTZ}MBMA=DgMUxRi!*e(rh#s z3G2$>vQ(Rah`kPF=8yZoH$n4JSUF935oEcm`e^BL2h%}?S!@d z2ItS8LrG9N#(ImFg_@Wx*a2nwpw|dgA?j_5s)%8I3`HRvD2XA%c!yG@ZlR|?=+f!5 zNSaBgmZn6d&nBe`x@|(!Y_*V5lBENhaT6&cP>S4o;zo=&A=MlwU?6$+(I+^4=qNrn zs7T>mjuI5IfSSzoQA!g>3Eh6iRHsGma^5`mD%R#C%?8d{gb&Oq?_B}Y=xV+2vJ4uB z`$ih@65$n6hDxVaWaKtOszPVeTfDP0+f$)Hh8;CC4xAde$39bA^{aq!b-WjqZ5K$k zr2}8DFg|22;tNQ(nCoI>PoaofQ|%EuX~b@y>vwjKcdONz$4(UMxo|WaF&k?uyz=r7 zQCW|Z$B*K@#~MR!!+k0dY$hj5t=d5M%m-U|Lf}E`CSFOp{Q--MODx{JMH0n4`NU%! zKXjO_%{3OUU!~QEsw$PTC^8}iMADO*tbkB0!dr}0I4eoCLJ7;D-)FPeC2CG{xHW^e z85iGqi?>f7;zJJ~CsGMnZqRC2+zaOr-l1?59Ac=^)_Py^tl?2{7GVhG+ldeRn&|?PDvu0B)0* zW4_uATv7OJ=L_o{2VycXHu1tEKRe){?v=|bL`Xpj6@T0|TIsfjekTLH%l+L+6hMg4 z1et@r6yw(|#SQm5$0R!GZs)F#Wxb-rZsW4kHFrI(qCd()RD5`>bFNT6xV?5KM$ArQ zbr6H6H0bJr>XpVELpjG_lij^VaXiZDYaf=E3ff8)o`A5HmezQesWBT0{&OX%O&~(n?jd$?T6tlC3&}xA4IYLPCL8!bYaHP2- zPrG1KqFB&uHkqG4!I8Pc%ult+yIWj*^DWL_xX6VI7g@Nr$l7`jq#|m@G}}!g(FE%e zHfU*SEovz_7Gv@t_HG@{T9PDaU$xsUnn{CByG5*YNYRrLV>8l0pRKJ8`n{BXKWC6; z&=2_^X zDA9Q8yM%XzV_q;#L;>=+o%W7mj{Z2Xt}1}R`ypiIhinQ_0DA(5^g+{Od2xYPUwMho zefg)+DpctdNQ|*ux_kwt&d_c*7?>=a$2qJC43ykuL~%koFnr^i-@$vq6OTX2=f3bs zIE{n7nK-4fEs6a_N)ocdGR0TgRY@> zWqJ9qo(DOmA&@C3BJm>prUbYUp+aCtxTCfq6#o==Zv;zlSycUU#XXg&BOz6%(QMG| z_9!b%N~uV_CBxEg#Q479@N}C`Kld~<4auO_WxAD+Wd@|i7O1o?04(nycOm^Q^%-x! z^Cl)sX(SD-a7f{+dE;CVSdXPDW{(OWm2fz8&?zLOxk~`)u_6RWymLsc5YCdDpd;68 zHAAYiD$8zP!Uuh%W-9<>b-V3$*3mtGGahglUq>s}36$jyhS_h0-76i! zgw4$9ZMlYnyB{wNWdALYON zKmVly2zG3BHyLCpCU+z{4%(YO=)W09s9Y!nGwl{yYc{(*ZZ2L�kcEyv=#;$;UV{ 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%0DS6J_`m1kZ}5vhY>FQ9$R+*-VyQ7DDc1-6y>*Q; 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

qHPVWR)mld=^&xi zZ8JzytkooGM%?N$KevSTpouY>t(~6B@+f1B&R9NhfGpGKEW=8d@v+@mL`0G_)x@oB zdOO<$X;d3StJPtC;SdJsrwK+Fq;i1UAkDCepcT*4i4}Bo9?V;7?E@PNqBtTm8m%?+ z^9u;mbqq`aX__L1Bv#4?u?{hF3NBe%yRbtT57Oh`U0J~^D$BisX8nzE`o(@ zFd&Kqnb9BxhgXl1B`Hak(QdUEBneVVRu3Nb!*8%&QSHpPkwTCpjse~4_faZl`M^<^ zb$3T&B}A`KMM`Jd zNsUFdyBu9wB}r3cB#>6nPZI`dhEy>^NPm6A+`=-8%gc^2m1Rywiy+N3bMwnsu>e}Q zwISym=<9(!Z48~oB_h*8TZ47!YOq4!qR$D0bV;{j;m*HITTm_9%Lfp`XGyit8}#Y6 zVp5&b>9mniE6;2ztU_m+cBf0+X``fa>vE8g*o?)M1FXg>Uw=vJ=HBAc3LqE^`m{Rp z9GRQL_!xy+dv6daTpU0xNixIgfg?zzFxGhBs!)<$7z0u0BJc2|X`T-vuVJA(gwLyF zg+iArzK8wQUP4uXUOPCe5Pj?t8e6DMKWUzI|_Dhi0$tG%T^vSk7+B zC>*1%OOMp^DX*R)ovn~QmSTv_Bb<5M_~ZrF+F`R~$fA%pPe2mOCZYUW%18FD=jDhx zmnXV0Rq(2)UkH}@U`hcL*4^^_xYt;7&2F9J`?dVDXzyfq#3DmS?ejhC5A}FkqXd{L zz+YB={8F^!6)Un1&Q_QG+VtF-kVvVVuw`A)a;-CGl}{6ta`8cgP+mB7g1(TJRHr@@ zp0hF=qw^q$gT%YIDHm%+Dkm5j>n#4lMh?i*7L#QdG?BLm8sn_u#z+P`DN4Hdds?SN zLL!a944l7%0fbZx5(659h>+4f!=x##NMN*PU@h86l<}5wB`~;*6%rh9VGXJkG0+)A zEntDAl|!`SdA8Pf=(LtGsRQ%`f;1V>YCEP}l4d@wv$OhH3|4D5=0c$℞q{+;*(i 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 ~L0HX}AQ#%0EW@T5eJMEb@Okbe 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 Vy;2o~7%Tt)002ovPDHLkV1j3=oB{v< literal 0 HcmV?d00001 diff --git a/configs/merchant-landing/qr-logo.png b/configs/merchant-landing/qr-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aeffa23d3ec9d66f1041cc50d28cd2037e0e4e56 GIT binary patch literal 8563 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 tCI$W<00960e6}m<00006Nkl \ No newline at end of file diff --git a/configs/merchant-landing/qr-payto.png b/configs/merchant-landing/qr-payto.png new file mode 100644 index 0000000000000000000000000000000000000000..2be70b216a7c4217c137a36be9082e29dbd96445 GIT binary patch literal 602 zcmV-g0;T`6pHR9J=W znO&;GKnR6}SwNRGTc0J(0s>w=M>FXG?PihjQ2$G^d#yDmG~Zge9~N5NMe`7+PIKjElG9Py<* zQ-;`k{MF@=-~aBn{IAM25?Wph`7ymd!RTlGt;?AWx&&@9=h&+~Km1djdqTe!p;>91 zUwNR_=ibUZ8|)JgD5SVOTz7oc=H3?8IfJjfIWy@ya4$<1P$Ec?J$W--T5#Hjv=%oG z-}1QwH=lTlKvv)xx$gNPvjItb1zAUrw! ztu)W#gtQPoP$IFvRp*|^u*MCgvWqD#^QSgPSh2GS486r&`lmFfSsLQb^@Xd9&`KS6 zSVprTHzG^U{7{;EWnG~rG*lid?r6djLSnG3AxpBq)#jXo_X)IjeNmmCwU+0%Pc|!6 z>g>RCl;3C(n9NY9&VHgg&&nDz0X8ebSZly}L1l2b3$C%;oiUT8);RU6HkW7x o{ufh-3pPF79r)+#$G^+}0Nz87Dqcs^`~Uy|07*qoM6N<$g1Q|al>h($ literal 0 HcmV?d00001 diff --git a/configs/merchant-landing/qr-product-beacon-badge.png b/configs/merchant-landing/qr-product-beacon-badge.png new file mode 100644 index 0000000000000000000000000000000000000000..e01486e1d4bf246337f606534b9367414aa743ac GIT binary patch literal 608 zcmV-m0-ybfP)6lkhO)lT_@rSaE7zk76V2pgx27&6<8Cp%EU-+L|F+AH(@o^%aUJ1bo2g~ zoQIoWudh~OtybCVJHFC$iLmk|iiMKn|8jFUBQHztHQJ0t+xceZ_oGQGp|;Y5QxWi}kwWFS2v&FE4+MZGp%WU+KBKiRHnC z)6LQ8-^n>7ju=bC<{q1yS%c$l#4asfq3C$|xgXf&U_KQ7!fy-kJztS~ECk0xW>}D uHj(rNxaT+dWbTYey;c?ApN}8^F8>2hr2G}wjM)kR0000K@4h2>CNJYWw$L-xdsPSxW2Imynqxy5^rR`|&UHZgG{kU(GHr z?xokbW7TQ~UL%ZH81a7LUc42q0A=72N34xY%S|6f#3d8=Sc?=zy4)KgPRGq6#P~Tg zlZd<0g8U%9m8k#vJD1Df_4RxE|5&c8BhbRfh%&VHs=w#*NC^F{K$p1cBR{9}urn%1BudQWk_5cZPw=*Sg?jazFAnGOH-+a3`7h|Q<5rH>2>GlXbyAJC9nl0 zw&siCDfn!~l9jY3qpbPvbgs^(C{d{Rtu_q_I2!Vlm`}(NY0n>r^L`(z*rPdX-ER&io;uv>q}bL?igA=ZeY?E)sg6nxJ_wl|t>joY4p>wJZ0 z5^4_Paom!DSGH)sPlixjtKa?kbUvj7qq9XXfB3Dx1Y8A4!-Z{?W#K-1n*8nCA{n@I zi$Ik%)mI)gw&(vd2lLqqicecCt*!VD=bx`1|1SRn$~GG&R3v|P00000NkvXXu0mjf DjZz@D literal 0 HcmV?d00001 diff --git a/configs/merchant-landing/qr-product-comet-cap.png b/configs/merchant-landing/qr-product-comet-cap.png new file mode 100644 index 0000000000000000000000000000000000000000..1bea33faab97352ea6d20aa40e804f3d9def064a GIT binary patch literal 524 zcmV+n0`vWeP)>M8>MJN$^?6# z(}9}aG$aIh1gVkpdpVy%aF48`)Q#!a-X{Z5inE#)HUHv45wfdAO>@hW3{1Kv%s2Aj z;p<)BrKmx);re{I9NYuv&op7R;bk<9+WvZ$gsh;XozqzXb=~bv526Pn?<}v>L&rNZ zk%LGO(J~o)$T30{uI_DiRfsUE+cjZqMYqMVA8(n6LB!t5>GN&_i6>tXgH%KvJKid0 zRBYFT?7LR+9O~vJqYOk5GUs-DfE&|Bj!e`b^W)z5(s-@agzOWHIq4g1%Qiw3mdo8} z6>ZCQJX;OcfFXDGL6GE4P7(Gs$aZ_H`|$~AYS7u?CVPg{M*LYOYOufE%0o&fZOgK- zOrzQxvrh;^MjnG|kb16pj7|;G-2MtW=~5EzBe;9U|2vWx{4jp-EBF9}fN)Ps1=scf O0000fa{IYls-|^$N zWwvgM#f4Q{6Zi^pTo%xMAJ;l!Vg< zL!?NU>!wyX)(9?5h^9;wZQPmP&AGc$T=fC6WLH!?f`j;0eMqekXQi(<=PyLr#o(&? zfLyQq>CKlC+N&%>C0(leyWX6Y`SDjZN?E99@O`VlroYCgTq-ku0QW2foDcA7ms;%p zT%~|LHRc1nI3IuFuQ{)_uF`s|P^a(q=O_-?!Xt`UV~TU7{HZx-H)KoxinOl&!hHg- z%AE57Mq~~;=Pv*1%@Iw`uu7LzWEpdP0B0+7B^7ix~TlMJ3s1JRGFFRmKCo**Tc3H^{x5~+3f(H_bbLTskG)5 z%l-ZSr7vG;QM}^Kv7Y*-aE>+WX^WWa5{#pJ7>+dm4bu?w-00000NkvXXu0mjfa5pm9 literal 0 HcmV?d00001 diff --git a/configs/merchant-landing/qr-product-nebula-coffee.png b/configs/merchant-landing/qr-product-nebula-coffee.png new file mode 100644 index 0000000000000000000000000000000000000000..a56dd308cdb6eee1acbe4564d5b300f37bd5788d GIT binary patch literal 604 zcmV-i0;BzjP)e6lvEY}DgEmcj{TP>JoT z!&zT8QC#B`+H3vg<@7fM{rP+Ge_kHfFm8*o>Yw~g%WoMPWhINFvV0i@UMav=O)Yc4 z4d@Sus5bZ_6j$JTbJQQ;52kDz@^=Y78K@E`b1Sh5aJB`*p6vc`OWc#cxjB>dY|HKs z>(|0Rxw(5tmgU!v9uNS?-*Qq-{k0=_-*QIs8d-`8?j%uR6Ia`P^I{^V=S7f*jn z&Y`JKIqP)%?3Q@IUGCzMWKhxc;c%#2V^zkIwP7fV|wsQnqnM@s&Rij z^|dyqxJD>ZXlGjAvUWD$T1@WrPwI<*^JQgzXILq%X`)Ls9erAyPX(0PvSv;0XTGV< z^?i4zxHEk&)nOaX6k(xae9_P|AN1h*EX{FEQ>MTAcWr(O>e76y%KGT*@|^k2gvk8n j_pKC4_4()Z~|F$jc(JwT7-)pMjAAmGy(lP$}m{|X8XrEK?0e~4xrBlda8kAIm@@pakoA4^n3 zm-tJKx1t@mI=QAej0b$yE4~AD;2wv^C;o2gdJRF8{*S8>_xN5OQ2dO&?Kn~-5nOC^ z(ukwJPFVZlQGazg&iB;E=W+h)^0QE4E41o={VmEPK4pkrUns7S+lA*2oDCrY^#@le zCx|r^s|nY59sOZIpSW@C^Ie;JDkQfsMJZS4*bO-Ai`keIR|dEBwK%tNnwxJq{lULk zU)8w>0X0Qi@R%ZEs?_m!wRx6UCI?0-8+8kw&JIQ+lu4;l*WCTBJV$+v)K}5PU9{Ns z`L=SYufE+PKX%t&an5dR`&bXEEF4DP{xYk3|DN%!Bd~^5Hi7GBNT5~GC?#m zHU6)LY#>vR8hJjy^3w=DtJRI|Kef*VB9y68AyM-i7mg4?gzj|YdCgKc9U;>$+Be3k zQ?EGvAQBFzPM4$UK{B53Ko=PbO+&{YG7w8h2}c=7MnGLxdGmu1bu|$b3vOE`c2KBFF(k!}9}vM>KIzbw|_Gd2sa zBc6T@<9i1_f*hAE)5|#G!_(sl)PaYN&}S#0oin!h<+7s@4}E6{!WQ4Q;|x(qjO1Dr z+KBJ8f~=RmU-?&;v%Vqd?eACp*X8$OiY@f2zx!`d4t+xsqqvz|Xt#^+4LGtXRYr@2 z#B)+BI#vUIp(fN;872Bd{L4A-`Yz2Q6x6pK{(yEx$8Nx-&(a^1UleCg{dGB{IA@|a ze~4%1UvZAtsesTQ@S(cYp6_aNpBm^gI2@FiWt{lx^D8Y*RSvl*Zf02%zAT69JRj&Q zjo9@$f-^N!(Yb?c zI3F}*fArU>;b`stTAXW|Q-Ae5+_Pe?9y}*gmiWG(uynloT*DY*V{@s?nBtW> zm>Y6sjdg^%%rsy`9FEZb3r{G+$mL%lbjmrNpbaod|4gd$MyK*+3mHWKg1 z#1Asxj;OICH(!M%#1>>ZnXRjc2npO3p3SF-qaWXyZ3Z!eQ$$27jXb&H1(6%+aL8nS zRZLVp5V$2=$~rn66R)lEJWq(nukrzD%vio<;s#kic1AHuGcEklkROBxpCKofTLFQ_K5Afj2Svut;S=QC)Rz18>eF$_02cDcnqL-Z0%q5dw&m7`>G$Fe7^ z(2%{U0&<3`dZ=#HmA@9=2{PQC2{G>GZEt2!Tk!lb<=?XBGsq2oX#eml_yd9vh$R*f R({2C&002ovPDHLkV1m`S>$Cs> literal 0 HcmV?d00001 diff --git a/configs/merchant-landing/qr-product-voidwave-playlist.png b/configs/merchant-landing/qr-product-voidwave-playlist.png new file mode 100644 index 0000000000000000000000000000000000000000..a63ba20e479c290fbd10b2fffbfb3408137205eb GIT binary patch literal 602 zcmV-g0;T`6pHR9J=W zm{E$uFbqTmK0uD7tNTbffI%zG$WFH`{VPbYCX_TUKf;Xxw^(DUxbEB;DKnZ`%`C{i^ zaXzyeDg#;%e?n-t;4z1$O1L@9YHh-87Zl;ZE~De_`Jy)WpIfPKDjNp{JN;FkhrYL_ z4aIRd+WB3cqrd2{CnXqqE%+XhGNSpRj*4%if_R8_mwmx${L?o_Iz5L zE5q#^?z6LQIsH|fBNmjyQCL!(47eI`p*Yr)(%OorCymTETaUk1=ZI*d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); \ No newline at end of file diff --git a/configs/merchant-landing/shop-pay.css b/configs/merchant-landing/shop-pay.css new file mode 100644 index 0000000..c39a62e --- /dev/null +++ b/configs/merchant-landing/shop-pay.css @@ -0,0 +1,348 @@ +/* GOA shop pay popup — QR_Taler style from taler-merchant-webui / @gnu-taler/web-util */ + +/* Animated blue ring (same as webui QR.tsx) */ +@property --angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} +@keyframes goa-pay-qr-rotate { + to { + --angle: 360deg; + } +} + +.shop-item[data-product] { + cursor: pointer; + transition: border-color 0.15s, transform 0.12s, background 0.15s; +} +.shop-item[data-product]:hover, +.shop-item[data-product]:focus-visible { + border-color: rgba(196, 181, 253, 0.75); + background: rgba(0, 0, 0, 0.4); + transform: translateY(-1px); + outline: none; +} +button.shop-item { + font: inherit; + color: inherit; + width: 100%; + appearance: none; + -webkit-appearance: none; +} + +.goa-pay-modal { + position: fixed; + inset: 0; + z-index: 80; + display: none; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(8, 6, 14, 0.75); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.goa-pay-modal.open { + display: flex; +} +.goa-pay-card { + position: relative; + background: rgba(36, 28, 48, 0.98); + border: 1px solid rgba(167, 139, 250, 0.45); + border-radius: 16px; + padding: 1.25rem 1.2rem 1.2rem; + max-width: 24rem; + width: 100%; + text-align: center; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + max-height: 92vh; + overflow-y: auto; +} +.goa-pay-x { + position: absolute; + top: 0.45rem; + right: 0.55rem; + border: 0; + background: transparent; + color: #c4b5fd; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + padding: 0.2rem 0.45rem; +} +.goa-pay-card h3 { + margin: 0 0 0.2rem; + font-size: 1.08rem; + color: #faf5ff; + padding-right: 1.5rem; +} +.goa-pay-amount { + margin: 0 0 0.85rem; + font-weight: 750; + color: #c4b5fd; + font-variant-numeric: tabular-nums; +} + +/* === QR_Taler frame (webui QR.tsx) === */ +.goa-pay-taler-qr { + width: 100%; + max-width: 280px; + margin: 0 auto 0.75rem; + padding: 10px; + border-radius: 20px; + box-sizing: border-box; + position: relative; + background: conic-gradient( + from var(--angle), + #0042b3 0deg, + #f1f1f4 20deg, + #f1f1f4 150deg, + #f1f1f4 160deg, + #0042b3 180deg, + #f1f1f4 200deg, + #f1f1f4 330deg, + #f1f1f4 340deg, + #0042b3 + ); + animation: goa-pay-qr-rotate 10s linear infinite; +} +.goa-pay-taler-qr__inner { + padding: 10px; + border-radius: 20px; + background: #fff; + line-height: 0; + min-height: 180px; + box-sizing: border-box; +} +/* Always an with explicit pixel size from JS */ +.goa-pay-taler-qr__inner img { + display: block !important; + margin: 0 auto !important; + border: 0 !important; + background: #fff; + image-rendering: pixelated; +} +/* Center logo plate — official qr-logo.png (webui ~100×50; PNG for Android) */ +.goa-pay-taler-qr__logo { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 28%; + height: auto; + max-width: 100px; + aspect-ratio: 200 / 95; + object-fit: contain; + pointer-events: none; + background: #fff; + border-radius: 4px; +} +/* Full encoded payload under QR — clickable (wallet / webextension) */ +.goa-pay-payload { + display: block; + margin: 0.45rem 0 0.55rem; + padding: 0 0.25rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.68rem; + line-height: 1.35; + color: #7eb6ff; + word-break: break-all; + text-align: center; + text-decoration: none; + border-bottom: 1px solid rgba(126, 182, 255, 0.4); + cursor: pointer; +} +.goa-pay-payload:hover { + color: #b8d6ff; + border-bottom-color: #7eb6ff; +} + +/* Settlement: collapsed by default, expand on click */ +.goa-pay-settle { + margin-top: 0.85rem; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + overflow: hidden; + background: rgba(0, 0, 0, 0.22); +} +.goa-pay-settle-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + margin: 0; + padding: 0.65rem 0.75rem; + border: 0; + background: transparent; + color: #cbd5e1; + font: inherit; + font-size: 0.78rem; + font-weight: 650; + text-align: left; + cursor: pointer; + line-height: 1.3; +} +.goa-pay-settle-toggle:hover { + background: rgba(148, 163, 184, 0.1); + color: #e2e8f0; +} +.goa-pay-settle-toggle-text { + flex: 1; +} +.goa-pay-settle-chevron { + flex-shrink: 0; + transition: transform 0.15s ease; + color: #94a3b8; + font-size: 0.85rem; +} +.goa-pay-modal.settle-open .goa-pay-settle-chevron { + transform: rotate(90deg); +} +.goa-pay-settle-body { + padding: 0 0.75rem 0.75rem; + border-top: 1px solid rgba(148, 163, 184, 0.25); +} +.goa-pay-settle-note { + margin-top: 0.55rem; +} +/* Settlement payto QR — plain box (not wallet pay / no logo / no blue ring) */ +.goa-pay-payto-qr { + width: fit-content; + max-width: 160px; + margin: 0.35rem auto 0.55rem; + padding: 0.45rem 0.5rem 0.5rem; + border-radius: 10px; + background: #fff; + border: 1px dashed rgba(100, 116, 139, 0.75); + box-sizing: border-box; +} +.goa-pay-payto-qr__caption { + font-size: 0.62rem; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + text-align: center; + margin: 0 0 0.35rem; + line-height: 1.2; +} +.goa-pay-payto-qr__inner { + line-height: 0; + min-height: 0; +} +.goa-pay-payto-qr__inner img { + display: block !important; + margin: 0 auto !important; + background: #fff; + image-rendering: pixelated; +} +.goa-pay-sub { + margin: 0 0 0.55rem; + font-size: 0.75rem; + line-height: 1.35; + color: #b8a8c9; + font-weight: 500; + text-align: center; +} + +.goa-pay-hint { + margin: 0 0 0.75rem; + font-size: 0.82rem; + color: #b8a8c9; +} +.goa-pay-hint.err { + color: #fca5a5; +} +.goa-pay-cta { + display: block; + text-decoration: none; + font-weight: 750; + padding: 0.65rem 1rem; + border-radius: 11px; + background: linear-gradient(135deg, #a78bfa, #6366f1); + color: #0f0a1a; + margin-bottom: 0.75rem; +} +.goa-pay-cta:hover { + filter: brightness(1.06); +} +.goa-pay-cta.disabled { + opacity: 0.45; + pointer-events: none; +} +.goa-pay-label { + margin: 0.55rem 0 0.2rem; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #b8a8c9; +} +.goa-pay-line { + margin: 0 0 0.35rem; + text-align: center; + font-size: 0.88rem; + font-weight: 650; +} +.goa-pay-link { + color: #93c5fd; + text-decoration: none; + white-space: nowrap; +} +.goa-pay-link:hover { + text-decoration: underline; +} +.goa-pay-link.disabled { + opacity: 0.45; + pointer-events: none; +} +.goa-pay-sep { + margin: 0 0.4rem; + color: #b8a8c9; + font-weight: 500; +} +.goa-pay-close { + display: block; + width: 100%; + margin-top: 0.85rem; + border: 1px solid rgba(167, 139, 250, 0.45); + background: transparent; + color: #c4b5fd; + font-weight: 650; + padding: 0.5rem; + border-radius: 10px; + cursor: pointer; +} + +/* Bank (gold/teal) overrides */ +.goa-pay-modal.bank-theme .goa-pay-card { + background: rgba(42, 32, 24, 0.98); + border-color: rgba(232, 168, 56, 0.4); +} +.goa-pay-modal.bank-theme .goa-pay-amount { + color: #5eead4; +} +.goa-pay-modal.bank-theme .goa-pay-cta { + background: linear-gradient(135deg, #5eead4, #0d9488); + color: #042f2e; +} +.goa-pay-modal.bank-theme .goa-pay-link { + color: #5eead4; +} +.goa-pay-modal.bank-theme .goa-pay-x, +.goa-pay-modal.bank-theme .goa-pay-close { + color: #e8c878; + border-color: rgba(232, 168, 56, 0.35); +} +.goa-pay-modal.bank-theme .goa-pay-settle { + border-color: rgba(232, 168, 56, 0.3); + background: rgba(0, 0, 0, 0.28); +} +.goa-pay-modal.bank-theme .goa-pay-settle-toggle { + color: #e8c878; +} +.goa-pay-modal.bank-theme .goa-pay-settle-body { + border-top-color: rgba(232, 168, 56, 0.25); +} diff --git a/configs/merchant-landing/shop-pay.js b/configs/merchant-landing/shop-pay.js new file mode 100644 index 0000000..2c0573b --- /dev/null +++ b/configs/merchant-landing/shop-pay.js @@ -0,0 +1,410 @@ +/** + * GOA shop pay popup — public only (no merchant secrets). + * Flow: POST templates/{id} → taler://pay/… + payto links. + * + * QR display matches taler-merchant-webui QR_Taler + * (@gnu-taler/web-util QR.tsx): animated #0042B3 conic ring + qr-logo.png. + * Uses qrcode-generator (same lib as webui) via global QRCode if present, + * else falls back to canvas from qrcode.min.js (davidshimjs). + */ +(function () { + var MERCHANT_HOST = "taler.hacktivism.ch"; + /* goa-shop: dedicated instance with fixed-order product templates */ + var INSTANCE = "goa-shop"; + var SHOP_PAYTO = + "payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop"; + var SHOP_PAYTO_HTTPS = "https://bank.hacktivism.ch/webui/"; + + function introBase() { + var b = document.querySelector("base"); + return b && b.href ? b.href : "/intro/"; + } + + function logoSrc() { + /* PNG: Android often drops SVG wordmark fill inheritance (missing "taler") */ + return introBase() + "qr-logo.png"; + } + + function templateHttps(productId) { + return ( + "https://" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/templates/" + + encodeURIComponent(productId) + ); + } + + function payTemplateUri(productId) { + return ( + "taler://pay-template/" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/" + + encodeURIComponent(productId) + ); + } + + function ensureModal() { + var m = document.getElementById("goa-pay-modal"); + if (m) return m; + m = document.createElement("div"); + m.id = "goa-pay-modal"; + m.className = "goa-pay-modal"; + m.setAttribute("role", "dialog"); + m.setAttribute("aria-modal", "true"); + m.setAttribute("aria-labelledby", "goa-pay-title"); + m.hidden = true; + m.innerHTML = + '

' + + ' ' + + '

Pay with Taler

' + + '

' + + '

Preparing payment…

' + + '

Wallet payment

' + + '

Scan with the GNU Taler Wallet to pay this product

' + + '
' + + '
' + + ' ' + + "
" + + '
' + + ' Open payment link →' + + '

' + + ' taler:// URI' + + ' ·' + + ' HTTPS' + + "

" + + '
' + + ' " + + ' " + + "
" + + ' ' + + "
"; + document.body.appendChild(m); + function close() { + m.classList.remove("open"); + m.hidden = true; + // collapse settlement on close + var body = document.getElementById("goa-pay-settle-body"); + var btn = document.getElementById("goa-pay-settle-toggle"); + if (body) body.hidden = true; + if (btn) { + btn.setAttribute("aria-expanded", "false"); + m.classList.remove("settle-open"); + } + } + m.addEventListener("click", function (e) { + if (e.target === m) close(); + }); + document.getElementById("goa-pay-x").onclick = close; + document.getElementById("goa-pay-close").onclick = close; + document.getElementById("goa-pay-settle-toggle").onclick = function () { + var body = document.getElementById("goa-pay-settle-body"); + var btn = document.getElementById("goa-pay-settle-toggle"); + var open = body.hidden; + body.hidden = !open; + btn.setAttribute("aria-expanded", open ? "true" : "false"); + m.classList.toggle("settle-open", open); + if (open) { + // paint payto QR when first expanded + renderQr(document.getElementById("goa-pay-qr-payto"), SHOP_PAYTO, 140); + var pp = document.getElementById("goa-pay-payto-payload"); + if (pp) { + pp.href = SHOP_PAYTO; + pp.textContent = SHOP_PAYTO; + } + } + }; + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && m.classList.contains("open")) close(); + }); + return m; + } + + /** + * Render URI as a real PNG (not a live canvas). + * davidshimjs paints canvas then often hides it for an img; if the modal is + * still display:none, layout collapses and you only see the blue ring. + * We always encode off-DOM and inject a fixed-size image. + */ + function renderQr(hostEl, text, size) { + if (!hostEl) return; + hostEl.innerHTML = ""; + size = size || 220; + if (!text) { + hostEl.innerHTML = '

'; + return; + } + if (typeof QRCode === "undefined") { + hostEl.innerHTML = + '

QR library missing (qrcode.min.js)

'; + return; + } + var level = + QRCode.CorrectLevel && QRCode.CorrectLevel.M != null + ? QRCode.CorrectLevel.M + : QRCode.CorrectLevel && QRCode.CorrectLevel.L != null + ? QRCode.CorrectLevel.L + : 1; + var scratch = document.createElement("div"); + scratch.setAttribute("aria-hidden", "true"); + scratch.style.cssText = + "position:fixed;left:-9999px;top:0;width:" + + size + + "px;height:" + + size + + "px;overflow:hidden;opacity:0;pointer-events:none"; + document.body.appendChild(scratch); + var dataUrl = ""; + try { + new QRCode(scratch, { + text: String(text), + width: size, + height: size, + colorDark: "#000000", + colorLight: "#ffffff", + correctLevel: level, + }); + var canvas = scratch.querySelector("canvas"); + var libImg = scratch.querySelector("img"); + if (canvas && canvas.width > 0) { + try { + dataUrl = canvas.toDataURL("image/png"); + } catch (e1) {} + } + if (!dataUrl && libImg && libImg.src && libImg.src.indexOf("data:") === 0) { + dataUrl = libImg.src; + } + } catch (err) { + dataUrl = ""; + } + if (scratch.parentNode) scratch.parentNode.removeChild(scratch); + + if (!dataUrl) { + hostEl.innerHTML = + '

QR encode failed

'; + return; + } + var img = document.createElement("img"); + img.alt = "QR code"; + img.width = size; + img.height = size; + img.src = dataUrl; + img.style.display = "block"; + img.style.width = size + "px"; + img.style.height = size + "px"; + img.style.maxWidth = "100%"; + img.style.margin = "0 auto"; + img.style.background = "#fff"; + hostEl.appendChild(img); + } + + function setLogo(imgEl) { + if (!imgEl) return; + imgEl.alt = "Taler"; + imgEl.width = 100; + imgEl.height = 50; + imgEl.decoding = "async"; + imgEl.src = logoSrc(); + imgEl.onerror = function () { + imgEl.src = introBase() + "qr-logo.svg"; + }; + imgEl.style.display = ""; + } + + function normalizePayUri(uri) { + if (!uri) return ""; + return String(uri) + .replace(/taler\.hacktivism\.ch:443/g, "taler.hacktivism.ch") + .replace(/:443\//g, "/") + .replace(/:443\?/g, "?"); + } + + /** Public: template → order → taler_pay_uri (no secrets). */ + function createPayUri(productId) { + var url = templateHttps(productId); + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + cache: "no-store", + }) + .then(function (r) { + if (!r.ok) throw new Error("template POST HTTP " + r.status); + return r.json(); + }) + .then(function (created) { + var oid = created.order_id; + var tok = created.token; + if (!oid || !tok) throw new Error("no order_id/token"); + var statusUrl = + "https://" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/orders/" + + encodeURIComponent(oid) + + "?token=" + + encodeURIComponent(tok); + return fetch(statusUrl, { cache: "no-store" }).then(function (r) { + return r.text().then(function (t) { + var pay = ""; + try { + var d = JSON.parse(t); + pay = d.taler_pay_uri || ""; + } catch (e) {} + if (!pay) { + pay = + "taler://pay/" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/" + + oid + + "/?c=" + + tok; + } + return { + taler_pay_uri: normalizePayUri(pay), + order_id: oid, + token: tok, + status_https: statusUrl.replace( + "taler.hacktivism.ch:443", + "taler.hacktivism.ch" + ), + template_https: url, + }; + }); + }); + }); + } + + function showPay(productId, name, amount) { + var m = ensureModal(); + if (document.body && document.body.getAttribute("data-shop-theme") === "bank") { + m.classList.add("bank-theme"); + } else { + m.classList.remove("bank-theme"); + } + document.getElementById("goa-pay-title").textContent = name || productId; + document.getElementById("goa-pay-amount").textContent = amount || ""; + var status = document.getElementById("goa-pay-status"); + status.textContent = "Preparing payment…"; + status.className = "goa-pay-hint"; + var open = document.getElementById("goa-pay-open"); + open.removeAttribute("href"); + open.classList.add("disabled"); + open.onclick = null; + var payLink = document.getElementById("goa-pay-uri"); + payLink.removeAttribute("href"); + payLink.classList.add("disabled"); + payLink.textContent = "taler:// URI"; + document.getElementById("goa-pay-uri-https").href = templateHttps(productId); + document.getElementById("goa-pay-uri-https").textContent = "HTTPS"; + var paytoLink = document.getElementById("goa-pay-payto"); + paytoLink.href = SHOP_PAYTO; + paytoLink.textContent = "payto:// URI"; + document.getElementById("goa-pay-payto-https").href = SHOP_PAYTO_HTTPS; + document.getElementById("goa-pay-payto-https").textContent = "HTTPS"; + + setLogo(document.getElementById("goa-pay-qr-pay-logo")); + + // Reset settlement panel (collapsed until user expands) + var settleBody = document.getElementById("goa-pay-settle-body"); + var settleBtn = document.getElementById("goa-pay-settle-toggle"); + if (settleBody) settleBody.hidden = true; + if (settleBtn) settleBtn.setAttribute("aria-expanded", "false"); + m.classList.remove("settle-open"); + var paytoHost = document.getElementById("goa-pay-qr-payto"); + if (paytoHost) paytoHost.innerHTML = ""; + + // Open first so layout exists, then paint wallet QR only + m.hidden = false; + m.classList.add("open"); + var tpl = payTemplateUri(productId); + renderQr( + document.getElementById("goa-pay-qr-pay"), + tpl, + 220 + ); + var payload = document.getElementById("goa-pay-qr-payload"); + if (payload) { + payload.href = tpl; + payload.textContent = tpl; + } + + createPayUri(productId) + .then(function (info) { + var pay = info.taler_pay_uri; + open.href = pay; + open.classList.remove("disabled"); + open.textContent = "Open payment link →"; + payLink.href = pay; + payLink.classList.remove("disabled"); + payLink.textContent = "taler:// URI"; + var uh = document.getElementById("goa-pay-uri-https"); + uh.href = info.status_https || info.template_https; + uh.textContent = "HTTPS"; + // Live unpaid taler://pay (fresh order each open) + renderQr(document.getElementById("goa-pay-qr-pay"), pay, 220); + if (payload) { + payload.href = pay; + payload.textContent = pay; + } + status.textContent = "Ready — scan in wallet"; + }) + .catch(function (err) { + status.textContent = + "Payment setup failed: " + (err && err.message ? err.message : err); + status.className = "goa-pay-hint err"; + open.textContent = "Retry"; + open.onclick = function (e) { + e.preventDefault(); + showPay(productId, name, amount); + }; + }); + } + + function bind() { + document.querySelectorAll("[data-product]").forEach(function (el) { + el.addEventListener("click", function (e) { + e.preventDefault(); + var id = el.getAttribute("data-product"); + var nameEl = el.querySelector(".name"); + var priceEl = el.querySelector(".price"); + showPay( + id, + nameEl ? nameEl.textContent.trim() : id, + priceEl ? priceEl.textContent.trim() : "" + ); + }); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bind); + } else { + bind(); + } +})(); diff --git a/configs/ports.md b/configs/ports.md new file mode 100644 index 0000000..c49ed7c --- /dev/null +++ b/configs/ports.md @@ -0,0 +1,25 @@ +# Port map (see also host/overview/services.md) + +| Port | Role | +|------|------| +| 22 | sshd (WAN often via VeciGate 23235) | +| 80 | systemd `http-proxy.socket` → Caddy **9000** | +| 443 | systemd `https-proxy.socket` → Caddy **9001** | +| 9000 | Caddy HTTP + ACME webroot | +| 9001 | Caddy HTTPS (vhosts) | +| 9010 | podman `taler-hacktivism` (merchant) | +| 9011 | podman **`taler-hacktivism-exchange-ansible`** (exchange) | +| 9012 | podman `taler-hacktivism-bank` (libeufin-bank) | +| 9013–9015 | bank / exchange / merchant public landings | +| 9020 | podman `koopa-castopod` → Caddy `castopod.hacktivism.ch` | +| 9021 | podman `koopa-bonfire` → Caddy `bonfire.hacktivism.ch` | +| 9022 | podman `koopa-prime` (Jellyfin) → Caddy `prime.hacktivism.ch` | +| 9023 | podman qBittorrent → Caddy `bt.hacktivism.ch` | +| **9024** | podman **`koopa-forgejo`** (HTTP) → Caddy **`git.hacktivism.ch`** | +| **9200** | podman **Forgejo git-SSH** (host-direct; not Caddy) | +| 9090–9092 | podman `koopa-tops-ng1` … `ng3` → Caddy `tops.ng{1,2,3}.hacktivism.ch` | +| 8080 | Tor ORPort | + +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. diff --git a/configs/prime/README.md b/configs/prime/README.md new file mode 100644 index 0000000..2b0c287 --- /dev/null +++ b/configs/prime/README.md @@ -0,0 +1,16 @@ +# Prime — Jellyfin + qBittorrent + +| Item | Value | +|------|--------| +| Live | `/home/hernani/koopa-prime/` | +| Compose | `compose.yml` (this dir; **no secret values**) | +| Jellyfin | **9022** → Caddy `prime.hacktivism.ch` | +| qBittorrent | **9023** → Caddy `bt.hacktivism.ch` | +| Topic | `2026/2026-07-09--koopa-prime-jellyfin.md` | + +`WEBUI_PASSWORD` is `${WEBUI_PASSWORD}` in the mirror — live value only in +`koopa-admin-secrets/…/koopa-prime/` / host env (not admin-log). + +```bash +cd ~/koopa-prime && podman-compose up -d +``` diff --git a/configs/prime/compose.yml b/configs/prime/compose.yml new file mode 100644 index 0000000..5a74e76 --- /dev/null +++ b/configs/prime/compose.yml @@ -0,0 +1,47 @@ +# koopa-prime — Jellyfin + qBittorrent +# Media source: /home/hernani/Downloads +services: + jellyfin: + image: docker.io/linuxserver/jellyfin:10.10.7 + container_name: koopa-prime-jellyfin + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/Zurich + - JELLYFIN_PublishedServerUrl=https://prime.hacktivism.ch + volumes: + - ./config/jellyfin:/config + - ./cache:/cache + - /home/hernani/Downloads:/media/downloads:ro + ports: + - "9022:8096" + labels: + org.hacktivism.service: jellyfin + org.hacktivism.host_port: "9022" + org.hacktivism.site: prime.hacktivism.ch + org.hacktivism.managed_by: koopa-admin + + qbittorrent: + image: docker.io/linuxserver/qbittorrent:5.0.4 + container_name: koopa-prime-qbittorrent + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/Zurich + - WEBUI_PORT=8080 + - WEBUI_PASSWORD=${WEBUI_PASSWORD} + - TORRENTING_PORT=6881 + volumes: + - ./config/qbittorrent:/config + - /home/hernani/Downloads:/downloads + ports: + - "9023:8080" + - "6881:6881" + - "6881:6881/udp" + labels: + org.hacktivism.service: qbittorrent + org.hacktivism.host_port: "9023" + org.hacktivism.site: bt.hacktivism.ch + org.hacktivism.managed_by: koopa-admin diff --git a/configs/shared/goa-stats-snippet.html b/configs/shared/goa-stats-snippet.html new file mode 100644 index 0000000..7070aee --- /dev/null +++ b/configs/shared/goa-stats-snippet.html @@ -0,0 +1,39 @@ + + diff --git a/configs/shared/goa-stats.css b/configs/shared/goa-stats.css new file mode 100644 index 0000000..9f3f45f --- /dev/null +++ b/configs/shared/goa-stats.css @@ -0,0 +1,86 @@ +/* Shared GOA flow stats (used on bank / exchange / merchant landings) */ +.stats { + background: rgba(18, 28, 28, 0.55); + border: 1px solid rgba(62, 207, 191, 0.28); + border-radius: 16px; + padding: 1.1rem 1.15rem 1.2rem; + margin: 0 0 1.5rem; +} +.stats h2 { + margin: 0 0 0.85rem; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted, #c9b8a0); + text-align: center; +} +.stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem 0.85rem; +} +@media (min-width: 420px) { + .stats-grid { grid-template-columns: 1fr 1fr 1fr; } +} +.stat { + background: rgba(0, 0, 0, 0.22); + border-radius: 10px; + padding: 0.55rem 0.65rem; + min-width: 0; +} +.stat .k { + display: block; + font-size: 0.68rem; + color: var(--muted, #c9b8a0); + letter-spacing: 0.04em; + text-transform: uppercase; + margin-bottom: 0.2rem; + text-align: center; +} +.stat .v { + display: block; + font-size: 1.05rem; + font-weight: 750; + color: #f0d090; + word-break: break-word; + font-variant-numeric: tabular-nums; + text-align: center; +} +.stat .v.teal { color: #5eead4; } +.stat.wide { grid-column: 1 / -1; } +.wd-list { + list-style: none; + margin: 0.15rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.wd-list li { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.25rem 0.75rem; + font-size: 0.88rem; + font-weight: 650; + color: #f0d090; + font-variant-numeric: tabular-nums; + border-bottom: 1px solid rgba(201, 184, 160, 0.12); + padding-bottom: 0.3rem; +} +.wd-list li:last-child { border-bottom: none; padding-bottom: 0; } +.wd-list .amt { color: #5eead4; font-weight: 750; } +.wd-list .meta { + color: var(--muted, #c9b8a0); + font-size: 0.78rem; + font-weight: 600; +} +.stats-foot { + margin: 0.75rem 0 0; + font-size: 0.72rem; + color: var(--muted, #c9b8a0); + text-align: center; +} +.stats-foot a { color: #e8c878; } +.stats-foot.err { color: #f0a090; } diff --git a/configs/shared/goa-stats.js b/configs/shared/goa-stats.js new file mode 100644 index 0000000..8689216 --- /dev/null +++ b/configs/shared/goa-stats.js @@ -0,0 +1,130 @@ +/* Load GOA flow stats from bank landing stats.json (CORS on bank). */ +(function () { + var STATS_URL = "https://bank.hacktivism.ch/intro/stats.json"; + var BANK_INTRO = "https://bank.hacktivism.ch/intro/"; + + function fmtCest(unix, fallback) { + var base = ""; + if (fallback) { + base = String(fallback).replace(/\s*(CEST|CET|UTC|Z)\s*$/i, "").trim(); + } else if (unix != null && unix !== "") { + try { + base = new Intl.DateTimeFormat("de-CH", { + timeZone: "Europe/Zurich", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false + }).format(new Date(Number(unix) * 1000)); + } catch (e) { + base = ""; + } + } + return base ? base + " Europe/Zurich" : ""; + } + + function set(id, v) { + var el = document.getElementById(id); + if (el) el.textContent = v == null || v === "" ? "—" : String(v); + } + + function showFallback(msg) { + var box = document.getElementById("stats"); + var foot = document.getElementById("st-foot"); + if (box) box.hidden = false; + if (foot) { + foot.hidden = false; + foot.className = "stats-foot err"; + foot.innerHTML = + (msg || "Stats offline") + + ' · bank.hacktivism.ch'; + } + } + + function loadStats() { + var box = document.getElementById("stats"); + var foot = document.getElementById("st-foot"); + if (!box) return; + box.hidden = false; + + // Same-origin first (bank landing), else public bank URL + var urls = []; + if (location.hostname.indexOf("bank.hacktivism") !== -1) { + urls.push("stats.json"); + } + urls.push(STATS_URL); + + function tryNext(i) { + if (i >= urls.length) { + showFallback("No stats"); + return; + } + fetch(urls[i], { cache: "no-store", mode: "cors" }) + .then(function (r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (d) { + if (!d || !d.ok) throw new Error("bad stats"); + var w = d.withdraws || {}; + var h24 = w.last_24h || {}; + var h7 = w.last_7d || {}; + var ba = d.bank_accounts || {}; + var wl = d.wallets || {}; + var flow = d.flow || {}; + var fin = flow.incoming || {}; + var fwd = flow.withdraw || {}; + set("st-accounts", ba.total != null ? ba.total : "—"); + set("st-wallets", wl.unique_reserves != null ? wl.unique_reserves : "—"); + set("st-incoming", fin.amount || flow.total_in || "—"); + set("st-withdraw", fwd.amount || w.total_amount || "—"); + set("st-24h", h24.amount || "GOA:0"); + set("st-7d", h7.amount || "GOA:0"); + + var list = document.getElementById("st-recent"); + if (list) { + list.innerHTML = ""; + var rows = (d.recent_withdraws || []).slice(0, 3); + if (!rows.length) { + var empty = document.createElement("li"); + empty.className = "meta"; + empty.textContent = "—"; + list.appendChild(empty); + } else { + rows.forEach(function (row) { + var li = document.createElement("li"); + var amt = document.createElement("span"); + amt.className = "amt"; + amt.textContent = row.amount || "?"; + var meta = document.createElement("span"); + meta.className = "meta"; + meta.textContent = fmtCest(row.at_unix, row.at || row.at_iso) || ""; + li.appendChild(amt); + li.appendChild(meta); + list.appendChild(li); + }); + } + } + if (foot) { + foot.hidden = false; + foot.className = "stats-foot"; + foot.innerHTML = + 'Source: bank.hacktivism.ch' + + (d.generated_at_human ? " · " + d.generated_at_human : ""); + } + }) + .catch(function () { + tryNext(i + 1); + }); + } + tryNext(0); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", loadStats); + } else { + loadStats(); + } +})(); diff --git a/configs/systemd/caddy.service.d-no-resume.conf b/configs/systemd/caddy.service.d-no-resume.conf new file mode 100644 index 0000000..8a32924 --- /dev/null +++ b/configs/systemd/caddy.service.d-no-resume.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile diff --git a/configs/systemd/http-proxy.socket b/configs/systemd/http-proxy.socket new file mode 100644 index 0000000..1dda110 --- /dev/null +++ b/configs/systemd/http-proxy.socket @@ -0,0 +1,11 @@ +[Unit] +Description=Forward incoming HTTP :80 to Caddy on 127.0.0.1:9000 + +[Socket] +ListenStream=0.0.0.0:80 +ListenStream=[::]:80 +BindIPv6Only=yes +Service=https-proxy@9000.service + +[Install] +WantedBy=sockets.target diff --git a/configs/systemd/https-proxy.socket b/configs/systemd/https-proxy.socket new file mode 100644 index 0000000..bedf449 --- /dev/null +++ b/configs/systemd/https-proxy.socket @@ -0,0 +1,11 @@ +[Unit] +Description=Forward incoming HTTPS :443 to Caddy on 127.0.0.1:9001 + +[Socket] +ListenStream=0.0.0.0:443 +ListenStream=[::]:443 +BindIPv6Only=yes +Service=https-proxy@9001.service + +[Install] +WantedBy=sockets.target diff --git a/configs/systemd/https-proxy@.service b/configs/systemd/https-proxy@.service new file mode 100644 index 0000000..a61c775 --- /dev/null +++ b/configs/systemd/https-proxy@.service @@ -0,0 +1,5 @@ +[Unit] +Description=Proxy for incoming HTTPS traffic to port %i + +[Service] +ExecStart=/usr/lib/systemd/systemd-socket-proxyd 127.0.0.1:%i diff --git a/configs/taler-exchange-ansible/Containerfile b/configs/taler-exchange-ansible/Containerfile new file mode 100644 index 0000000..1f0e667 --- /dev/null +++ b/configs/taler-exchange-ansible/Containerfile @@ -0,0 +1,26 @@ +FROM docker.io/library/debian:trixie + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update -yqq && \ + apt-get install -yqq \ + ansible \ + cron \ + git \ + locales \ + openssh-server \ + python3 \ + python3-debian \ + systemd \ + whois # mkpasswd provided by whois package + +RUN mkdir -p /etc/ansible/facts.d + +##################################################################### +## WARNING: THIS ALLOWS FOR COMPLETELY UNAUTHENTICATED SSH SESSIONS # +####### FOR TESTING ENVIRONMENT ONLY! ############################### +RUN echo "root:$(mkpasswd -s vault_pass.txt +fi + +# Refuse obvious non-systemd targets early +if ! podman exec taler-hacktivism-exchange-ansible cat /proc/1/comm 2>/dev/null | grep -qx systemd; then + echo "error: container PID1 is not systemd. Run ./run-container-koopa.sh first." >&2 + exit 1 +fi + +exec ansible-playbook -v \ + --limit taler-hacktivism-exchange-ansible \ + -e @inventories/host_vars/taler-hacktivism-exchange-ansible/public.yml \ + -e @inventories/host_vars/taler-hacktivism-exchange-ansible/secrets.yml \ + playbooks/setup-hacktivism-goa.yml diff --git a/configs/taler-exchange-ansible/inventories-default.snippet b/configs/taler-exchange-ansible/inventories-default.snippet new file mode 100644 index 0000000..ae62297 --- /dev/null +++ b/configs/taler-exchange-ansible/inventories-default.snippet @@ -0,0 +1,6 @@ +# From live ~/ansible-taler-exchange/inventories/default (koopa lines only) +# hacktivism GOA exchange: podman container on koopa (API host:9011; ansible via podman) +taler-hacktivism-exchange-ansible ansible_connection=podman ansible_user=root + +[testing] +taler-hacktivism-exchange-ansible diff --git a/configs/taler-exchange-ansible/public.yml b/configs/taler-exchange-ansible/public.yml new file mode 100644 index 0000000..a580052 --- /dev/null +++ b/configs/taler-exchange-ansible/public.yml @@ -0,0 +1,31 @@ +--- +# Event currency (GOA) — aligned with regional-currency without fiat. +# Target: Caddy → host :9011 → container exchange. + +domain_name: "hacktivism.ch" +exchange_domain: "exchange.hacktivism.ch" +TARGET_HOST_NAME: "taler-hacktivism-exchange-ansible" + +taler_repo_suites: trixie + +# regional-currency style flags (systemd required) +require_systemd: true +do_conversion: false +# greenfield with new offline key: set true after offline key exists in container +do_offline: false +exchange_account_id: "1" +use_static_goa_coins: true +exchange_wire_method: x-taler-bank + +CURRENCY: GOA +CURRENCY_ROUND_UNIT: "GOA:0.00000001" +EXCHANGE_BASE_URL: "https://exchange.hacktivism.ch/" +EXCHANGE_HTTP_PORT: 9011 + +EXCHANGE_MASTER_PUB: TW6K5FXF81VYCAH0YWYX0SX98KBBSJ42VX27WAX01FTFH400QG10 + +EXCHANGE_TERMS_ETAG: "no-terms-v0" +EXCHANGE_PP_ETAG: "no-privacy-v0" + +EXCHANGE_BANK_ACCOUNT_PAYTO: "payto://x-taler-bank/bank.hacktivism.ch/exchange?receiver-name=GOA%20Exchange" +EXCHANGE_WIRE_GATEWAY_URL: "https://bank.hacktivism.ch/accounts/exchange/taler-wire-gateway/" diff --git a/configs/taler-exchange-ansible/run-container-koopa.sh b/configs/taler-exchange-ansible/run-container-koopa.sh new file mode 100755 index 0000000..88049c0 --- /dev/null +++ b/configs/taler-exchange-ansible/run-container-koopa.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Recreate taler-hacktivism-exchange-ansible with systemd (regional-currency style). +# Image: Containerfile (debian + systemd + ssh). Port: only 9011. +# Ansible: ansible_connection=podman → systemctl inside container. +set -euo pipefail + +NAME=taler-hacktivism-exchange-ansible +IMAGE=localhost/taler-hacktivism-exchange-ansible:base +ROOT="$(cd "$(dirname "$0")" && pwd)" + +cd "$ROOT" +podman build -f Containerfile -t "$IMAGE" + +echo "Stopping/removing existing $NAME (if any)..." +podman rm -f "$NAME" 2>/dev/null || true + +podman run -d \ + --name "$NAME" \ + --hostname "$NAME" \ + --network pasta \ + --systemd=always \ + -p 9011:9011 \ + --label org.hacktivism.service=taler-exchange \ + --label org.hacktivism.host_port=9011 \ + --label org.hacktivism.site=exchange.hacktivism.ch \ + --label org.hacktivism.currency=GOA \ + --label org.hacktivism.managed_by=ansible-taler-exchange \ + --label org.hacktivism.init=systemd \ + "$IMAGE" \ + /usr/sbin/init + +echo "waiting for systemd..." +for i in $(seq 1 30); do + if podman exec "$NAME" systemctl is-system-running 2>/dev/null | grep -Eq 'running|degraded'; then + break + fi + sleep 1 +done + +podman ps --filter "name=$NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}" +echo -n "PID1: " +podman exec "$NAME" cat /proc/1/comm +echo -n "systemd: " +podman exec "$NAME" systemctl is-system-running || true +echo "Next: ./deploy-hacktivism-goa.sh" diff --git a/configs/taler-exchange/README.md b/configs/taler-exchange/README.md new file mode 100644 index 0000000..44aa37e --- /dev/null +++ b/configs/taler-exchange/README.md @@ -0,0 +1,44 @@ +# Exchange config (inside ansible container) + +**Live container:** `taler-hacktivism-exchange-ansible` +**Image:** `localhost/taler-hacktivism-exchange-ansible:landing` +**Deploy tree on host:** `~/ansible-taler-exchange/` → see **`configs/taler-exchange-ansible/`** + +This directory mirrors **exchange software config** (overrides, coins, terms), not the Ansible wrapper itself. + +Container: **`taler-hacktivism-exchange-ansible`** · image tags `localhost/taler-hacktivism-exchange-ansible-live:9011` (live) and optional snapshot +Host port: **9011** (pasta) · public: **https://exchange.hacktivism.ch/** (Caddy → 9011) + +**Snapshot + restore (local tar, no registry):** +→ [`2026/2026-07-09--exchange-snapshot-and-restore.md`](../../2026/2026-07-09--exchange-snapshot-and-restore.md) + +## Terms of service (wallets) + +Wallets require a working `/terms` (and usually `/privacy`) with a +`Taler-Terms-Version` header. If missing, many apps hang on accept / +`isPending` during the first withdraw QR. + +This site uses **minimal stubs** — no formal legal ToS: + +| Config | Value | +|--------|--------| +| `TERMS_ETAG` | `no-terms-v0` | +| `PRIVACY_ETAG` | `no-privacy-v0` | +| files | `/var/lib/taler-exchange/terms/en/no-terms-v0.{html,txt,md}` etc. | + +Install / refresh inside the exchange container as root: + +```bash +/usr/local/bin/install_no_terms.sh +# ensure exchange-overrides.conf has TERMS_ETAG / PRIVACY_ETAG (see above) +runuser -u taler-exchange-httpd -- /usr/local/bin/start_exchange.sh --restart +curl -sS -D- http://127.0.0.1:9011/terms | head +``` + +Sources: `configs/taler-exchange/terms-src/` and +`scripts/taler-exchange/install_no_terms.sh`. + +## Secrets + +`ATTRIBUTE_ENCRYPTION_KEY` and wire/DB credentials are **not** in this tree. +See repo-root `SECRETS.md` and `koopa-admin-secrets/containers/taler-exchange/secrets/`. diff --git a/configs/taler-exchange/conf.d-exchange-coins.conf b/configs/taler-exchange/conf.d-exchange-coins.conf new file mode 100644 index 0000000..ad2ef70 --- /dev/null +++ b/configs/taler-exchange/conf.d-exchange-coins.conf @@ -0,0 +1,331 @@ +# GOA denominations for exchange.hacktivism.ch +# Units: 1 GOA = 1000 mGOA = 1_000_000 uGOA +# VALUE uses base currency; coin sections cover 1–2–5 ladders at u/m/whole scale. + +# --- uGOA (10^-6 GOA) --- +[coin_goa_0_000001] +VALUE = GOA:0.000001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_000002] +VALUE = GOA:0.000002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_000005] +VALUE = GOA:0.000005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_00001] +VALUE = GOA:0.00001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_00002] +VALUE = GOA:0.00002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_00005] +VALUE = GOA:0.00005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_0001] +VALUE = GOA:0.0001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_0002] +VALUE = GOA:0.0002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_0005] +VALUE = GOA:0.0005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +# --- mGOA (10^-3 GOA) --- +[coin_goa_0_001] +VALUE = GOA:0.001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_002] +VALUE = GOA:0.002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_005] +VALUE = GOA:0.005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_01] +VALUE = GOA:0.01 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_02] +VALUE = GOA:0.02 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_05] +VALUE = GOA:0.05 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_1] +VALUE = GOA:0.1 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_2] +VALUE = GOA:0.2 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_5] +VALUE = GOA:0.5 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +# --- whole GOA --- +[coin_goa_1_0] +VALUE = GOA:1 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_2_0] +VALUE = GOA:2 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_5_0] +VALUE = GOA:5 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_10_0] +VALUE = GOA:10 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +# --- large whole GOA (align with common_amounts 20…1000) --- +[coin_goa_20_0] +VALUE = GOA:20 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_50_0] +VALUE = GOA:50 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_100_0] +VALUE = GOA:100 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_200_0] +VALUE = GOA:200 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_1000_0] +VALUE = GOA:1000 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA diff --git a/configs/taler-exchange/exchange-overrides.conf b/configs/taler-exchange/exchange-overrides.conf new file mode 100644 index 0000000..6778410 --- /dev/null +++ b/configs/taler-exchange/exchange-overrides.conf @@ -0,0 +1,52 @@ +# Manual site overrides for exchange.hacktivism.ch (GOA exploration currency). +# Same role as /etc/taler-merchant/merchant-overrides.conf on taler-hacktivism. +# Do not edit overrides.conf (tooling). Do not edit package conf.d defaults. +# No conf.d/zz-* drop-ins. + +[exchange] +CURRENCY = GOA +CURRENCY_ROUND_UNIT = GOA:0.00000001 +TINY_AMOUNT = GOA:0.00000001 +DEFAULT_P2P_EXPIRATION = 14 days +BASE_URL = https://exchange.hacktivism.ch/ +SERVE = tcp +PORT = 9011 +MASTER_PUBLIC_KEY = TW6K5FXF81VYCAH0YWYX0SX98KBBSJ42VX27WAX01FTFH400QG10 +# Secret: ATTRIBUTE_ENCRYPTION_KEY — not in this repo. +# Live: /etc/taler-exchange/secrets/exchange-attribute-encryption.secret.conf +# Mirror: sibling koopa-admin-secrets/containers/taler-exchange/secrets/ +@inline-secret@ exchange-attribute-encryption ../secrets/exchange-attribute-encryption.secret.conf + +# Wallet ToS/PP: short "No Terms Required" stubs (install via install_no_terms.sh). +# Without this, /terms returns 501 "not configured" and apps hang on accept/isPending. +# HTML uses same dark palette as merchant dual-currency terms (bump etag to refresh cache). +TERMS_DIR = ${TALER_DATA_HOME}terms/ +TERMS_ETAG = no-terms-v2 +PRIVACY_DIR = ${TALER_DATA_HOME}terms/ +PRIVACY_ETAG = exchange-pp-swiss-v0 + +[currency-goa] +ENABLED = YES +name = "GOA exploration currency" +code = GOA +# SI display units. taler-exchange rejects scale keys outside [-8, 24] (no Ronna/Quetta). +fractional_input_digits = 8 +fractional_normal_digits = 0 +fractional_trailing_zero_digits = 0 +alt_unit_names_are_symbols = NO +alt_unit_names = {"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"} +common_amounts = "GOA:10 GOA:20 GOA:50 GOA:100 GOA:200 GOA:1000" + +### Disable package demonstrator currencies (like merchant-overrides) +[currency-kudos] +ENABLED = NO + +[currency-testkudos] +ENABLED = NO + +### Regional bank (libeufin, x-taler-bank) — no IBAN +[exchange-account-1] +PAYTO_URI = payto://x-taler-bank/bank.hacktivism.ch/exchange?receiver-name=GOA%20Exchange +ENABLE_CREDIT = YES +ENABLE_DEBIT = YES +@inline-secret@ exchange-accountcredentials-1 ../secrets/exchange-accountcredentials-1.secret.conf diff --git a/configs/taler-exchange/taler-exchange.conf.includes.txt b/configs/taler-exchange/taler-exchange.conf.includes.txt new file mode 100644 index 0000000..f8392e8 --- /dev/null +++ b/configs/taler-exchange/taler-exchange.conf.includes.txt @@ -0,0 +1,3 @@ +32:@inline-matching@ conf.d/*.conf +35:@inline@ overrides.conf +50:@inline@ exchange-overrides.conf diff --git a/configs/taler-exchange/terms-src/no-privacy-v0.en.rst b/configs/taler-exchange/terms-src/no-privacy-v0.en.rst new file mode 100644 index 0000000..9a3706e --- /dev/null +++ b/configs/taler-exchange/terms-src/no-privacy-v0.en.rst @@ -0,0 +1,15 @@ +No Privacy Policy Required +========================== + +This is an **experimental / exploration** GNU Taler exchange for **GOA**. + +**No formal privacy policy is required** for this demo service. + +Note (high level): + +* Wire transfers via the regional bank may identify bank account holders. +* The exchange processes withdrawals, deposits, and related operations + as required by the GNU Taler protocol. +* Logs may be kept for operation and debugging. + +Do not use this service if that is unacceptable. diff --git a/configs/taler-exchange/terms-src/no-terms-v0.en.rst b/configs/taler-exchange/terms-src/no-terms-v0.en.rst new file mode 100644 index 0000000..ff6b131 --- /dev/null +++ b/configs/taler-exchange/terms-src/no-terms-v0.en.rst @@ -0,0 +1,16 @@ +No Terms Required +================= + +This is an **experimental / exploration** GNU Taler exchange for the +currency **GOA** (hacktivism.ch). + +**No formal terms of service are required** to use this service. + +By withdrawing or using GOA coins you acknowledge that: + +* This service is for exploration and testing only. +* There is no guaranteed availability, support, or redemption. +* Operators may reset balances or change configuration without notice. +* Do not use real money you cannot afford to lose. + +If you do not agree, do not use this exchange. diff --git a/configs/taler-hacktivism-bank/README.md b/configs/taler-hacktivism-bank/README.md new file mode 100644 index 0000000..1f51875 --- /dev/null +++ b/configs/taler-hacktivism-bank/README.md @@ -0,0 +1,98 @@ +# Bank — live container `taler-hacktivism-bank` + +**Reflected from host koopa.** + +| Item | Live | +|------|------| +| Container | **`taler-hacktivism-bank`** | +| Image | **`localhost/taler-hacktivism-banking:live`** | +| Ports | **9012** (API), **9013** (landing) | +| Site | `https://bank.hacktivism.ch` | + +Config path in this repo: **`configs/taler-hacktivism-bank/`** (was `configs/taler-bank/`). +Scripts: `scripts/taler-bank/`. Secrets: `koopa-admin-secrets/.../taler-bank/` → `/root/bank-*-password.txt`. + +--- + +Image: `docker.io/library/debian:trixie` + package `libeufin-bank` +Publish: **9012/tcp only** +Labels: `org.hacktivism.service=taler-bank`, `host_port=9012`, `site=bank.hacktivism.ch`, `currency=GOA` + +## Role + +Regional **GOA Exploration Bank** for GNU Taler: + +- **No IBAN**, no libeufin-nexus, no fiat conversion (`ALLOW_CONVERSION = no`) +- **`WIRE_TYPE = x-taler-bank`** (payto://x-taler-bank/bank.hacktivism.ch/…) +- Wallet default exchange: `https://exchange.hacktivism.ch/` +- Mobile withdraw: Bank SPA → login → **Withdraw** → Taler Wallet (QR / deeplink) + +## Config layout (merchant/exchange pattern) + +| Path in container | Mirror | +|-------------------|--------| +| `libeufin-bank.conf` | includes: `libeufin-bank.conf.includes.txt` | +| **`bank-overrides.conf`** | **`bank-overrides.conf`** | + +## Manual start + +| Path | User | Role | +|------|------|------| +| `/root/start_base_services_for_taler_bank.sh` | **root** | logs, postgres, DB ensure → shell as `libeufin-bank` | +| `/usr/local/bin/start_bank.sh` | **libeufin-bank** | start / `--restart` `libeufin-bank serve` | +| `/usr/local/bin/check_bank-health.sh` | any | health | + +## Ports / Caddy + +| Port | Role | +|------|------| +| 9012 | libeufin-bank HTTP (podman pasta) | +| 9001 | Caddy HTTPS `bank.hacktivism.ch` → 127.0.0.1:9012 | + +**DNS:** A/AAAA for `bank.hacktivism.ch` → koopa public IP (required for TLS/ACME and phones). + +## Accounts (typical) + +| Login | Purpose | +|-------|---------| +| `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) | + +## Mobile withdraw (GOA) + +1. Install **GNU Taler Wallet** (Android / iOS / browser). +2. Open **https://bank.hacktivism.ch/** (after DNS + cert work). +3. Login as `explorer` (or registered user with debt headroom). +4. **Withdraw** → amount in GOA → open / scan with wallet. +5. Wallet uses `DEFAULT_EXCHANGE` = exchange.hacktivism.ch. + +Exchange must have wire account pointing at this bank (payto + wire gateway credentials) and offline **enable-account** for `/keys` withdraw to fully work. + +​**Details (diagram `/keys` ↔ Wire ↔ Account):** +`configs/taler-exchange/README.md` → section **/keys, Wire and Account**. + +## Public landing (withdraw QR) + +English intro + QR: **`https://bank.hacktivism.ch/`** → `/intro/` +Details: `configs/bank-landing/`. Refresh demo URI: `scripts/taler-bank/make-demo-withdraw-qr.sh`. + +### Wallet says “transfer money” — auto-confirm + +Normal Taler bank flow after the wallet selects the exchange: + +1. Withdrawal status → **`selected`** +2. Bank must **`POST …/withdrawals/$ID/confirm`** → wires GOA user → exchange +3. Exchange **wirewatch** sees credit → wallet can finish withdraw + +For the demo QR (`explorer`), automate step 2: + +```bash +# 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 & +``` + +Script: `scripts/taler-bank/auto-confirm-withdrawals.sh` +Watches IDs in `/var/www/bank-landing/withdraw.uri` and `withdraw-watch.ids`. \ No newline at end of file diff --git a/configs/taler-hacktivism-bank/bank-overrides.conf b/configs/taler-hacktivism-bank/bank-overrides.conf new file mode 100644 index 0000000..83fb887 --- /dev/null +++ b/configs/taler-hacktivism-bank/bank-overrides.conf @@ -0,0 +1,39 @@ +# Regional GOA bank — no IBAN / Nexus / fiat conversion. +# Container: /etc/libeufin/bank-overrides.conf +# Inlined last from /etc/libeufin/libeufin-bank.conf via @inline@ bank-overrides.conf + +[libeufin-bank] +CURRENCY = GOA +WIRE_TYPE = x-taler-bank +BASE_URL = https://bank.hacktivism.ch/ +NAME = "GOA Exploration Bank" +SERVE = tcp +PORT = 9012 +BIND_TO = 0.0.0.0 +DEFAULT_DEBT_LIMIT = GOA:100000 +ALLOW_REGISTRATION = yes +ALLOW_CONVERSION = no +REGISTRATION_BONUS = GOA:0 +WIRE_TRANSFER_FEES = GOA:0 +DEFAULT_EXCHANGE = https://exchange.hacktivism.ch/ + +[currency-goa] +ENABLED = YES +name = "GOA exploration currency" +code = GOA +fractional_input_digits = 8 +fractional_normal_digits = 0 +fractional_trailing_zero_digits = 0 +alt_unit_names_are_symbols = NO +alt_unit_names = {"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"} + +[currency-kudos] +ENABLED = NO +[currency-testkudos] +ENABLED = NO +[currency-euro] +ENABLED = NO +[currency-swiss-francs] +ENABLED = NO +[currency-netzbon] +ENABLED = NO diff --git a/configs/taler-hacktivism-bank/libeufin-bank.conf.includes.txt b/configs/taler-hacktivism-bank/libeufin-bank.conf.includes.txt new file mode 100644 index 0000000..735e78f --- /dev/null +++ b/configs/taler-hacktivism-bank/libeufin-bank.conf.includes.txt @@ -0,0 +1,7 @@ +# /etc/libeufin/libeufin-bank.conf (package entrypoint) +# Site overrides last: +@inline@ bank-overrides.conf + +# DB (package default in /usr/share/libeufin/config.d/bank.conf): +# [libeufin-bankdb-postgres] +# CONFIG = postgres:///libeufin diff --git a/configs/taler-hacktivism/README.md b/configs/taler-hacktivism/README.md new file mode 100644 index 0000000..127cd32 --- /dev/null +++ b/configs/taler-hacktivism/README.md @@ -0,0 +1,63 @@ +# taler-merchant (container `taler-hacktivism`) + +Image: `localhost/taler-hacktivism-live:landing` +Publish: **9010/tcp only** +Labels: `org.hacktivism.service=taler-merchant`, `host_port=9010`, `site=taler.hacktivism.ch` + +## Config layout (same pattern as exchange) + +| Path in container | Mirror in this dir | +|-------------------|--------------------| +| `taler-merchant.conf` | includes documented in `taler-merchant.conf.includes.txt` | +| `overrides.conf` | tooling — do not edit (not mirrored) | +| **`merchant-overrides.conf`** | **`merchant-overrides.conf`** | +| `conf.d/merchant.conf` | `conf.d-merchant.conf` | +| nginx `sites-available/taler-merchant` | `nginx-taler-merchant.conf` | + +Secrets (`secrets/*.secret.conf`, SMTP password) are **not** mirrored. + +## Manual start (merchant model) + +| Path | User | Role | +|------|------|------| +| `/root/start_base_services_for_taler.sh` | **root** | logs, SMS env, postgres → shell as `taler-merchant-httpd` | +| `/usr/local/bin/start_merchant.sh` | **taler-merchant-httpd** | start / `--restart` httpd + helpers | +| `/usr/local/bin/check_merchant-health.sh` | any | health (socket + nginx) | + +Also under `/usr/local/bin/`: email/SMS helpers, `stats--merchant-payments.sh`. +Certbot loop: `/root/scripts/certbot_renew.sh` (started from base script). + +Git scripts: `scripts/taler-merchant/`. + +## Site settings (overrides) + +- **Default currency:** CHF (`[taler]` / `[merchant]`) +- **Exchanges (multi-currency):** + - CHF → `exchange.taler-ops.ch` (package default `[merchant-exchange-chf]` in `tops.conf` — do not redeclare as `merchant-exchange-tops`) + - GOA → `exchange.hacktivism.ch` (`[merchant-exchange-goa]` in `merchant-overrides.conf`) +- **SERVE:** unix socket → nginx :9010 SSL → Caddy :9001 +- **Self-provisioning:** YES; **no mandatory TAN** (SMS off) +- **Terms:** `TERMS_ETAG = merchant-tos-dual-v0` — short dual-currency notice (GOA explorational + CHF real). Sources in `terms-src/`; install with `scripts/taler-merchant/install_dual_terms.sh` inside the container, then restart `taler-merchant-httpd`. Public: `https://taler.hacktivism.ch/terms` + +Wire status is **per account × exchange**: an `x-taler-bank` GOA payto shows `ready` for hacktivism and `unsupported` for taler-ops (expected). CHF/IBAN accounts use taler-ops. + +### Container → public exchange (pasta) + +Inside pasta, `exchange.hacktivism.ch` must not resolve to **127.0.0.1** (merchant nginx self-signed). Pin public A in container `/etc/hosts`: + +``` +212.51.151.254 exchange.hacktivism.ch bank.hacktivism.ch taler.hacktivism.ch +``` + +After conf/hosts changes: `taler-merchant-exchangekeyupdate -t` and `taler-merchant-kyccheck -t`.## Ports + +| Port | Role | +|------|------| +| 9010 | nginx TLS in container (podman) | +| unix sock | `taler-merchant-httpd` | + +## Demo instance (2026-07-09) + +Self-provisioned shop **`goa-demo-cp4zqk`**, bank account same username, payto linked, seed **GOA:5000**. + +Details + credential paths: **`demo-instance-goa-demo-cp4zqk.md`**. diff --git a/configs/taler-hacktivism/conf.d-merchant.conf b/configs/taler-hacktivism/conf.d-merchant.conf new file mode 100644 index 0000000..e0fd7ef --- /dev/null +++ b/configs/taler-hacktivism/conf.d-merchant.conf @@ -0,0 +1,7 @@ +# Package drop-in: /etc/taler-merchant/conf.d/merchant.conf +# Read secret sections into configuration, but only +# if we have permission to do so. +@inline-secret@ merchantdb-postgres ../secrets/merchant-db.secret.conf + +[merchant] +SERVE = systemd diff --git a/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md b/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md new file mode 100644 index 0000000..95aa009 --- /dev/null +++ b/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md @@ -0,0 +1,63 @@ +# Demo merchant instance `goa-demo-cp4zqk` + +Created **2026-07-09** via self-provisioning (`POST /instances`, no SMS TAN). + +## Merchant + +| Field | Value | +|-------|--------| +| SPA | https://taler.hacktivism.ch/webui/ | +| Instance ID | `goa-demo-cp4zqk` | +| Display name | GOA Demo Shop cp4zqk | +| Instance password | **not in this repo** — `koopa-admin-secrets/koopa/host-root/taler-merchant/merchant-goa-demo-cp4zqk-password.txt` → `/root/…` | +| Backend base | `https://taler.hacktivism.ch/instances/goa-demo-cp4zqk/` | +| Self-provisioning | YES; `mandatory_tan_channels: []` | + +Login: SPA → instance id + password (or bearer `secret-token:$PASSWORD`). + +## Bank account (linked) + +| Field | Value | +|-------|--------| +| Bank SPA | https://bank.hacktivism.ch/webui/ | +| Username | `goa-demo-cp4zqk` | +| Password | **not in this repo** — `koopa-admin-secrets/koopa/host-root/taler-bank/bank-goa-demo-cp4zqk-password.txt` → `/root/…` | +| Payto | `payto://x-taler-bank/bank.hacktivism.ch/goa-demo-cp4zqk?receiver-name=GOA%20Demo%20Shop%20cp4zqk` | +| Seed balance | **GOA:5000** (admin credit) | +| Debit threshold | GOA:100000 (default) | + +Merchant wire account (private API): + +```http +POST /instances/goa-demo-cp4zqk/private/accounts +Authorization: Bearer secret-token:… +{ "payto_uri": "payto://x-taler-bank/bank.hacktivism.ch/goa-demo-cp4zqk?receiver-name=GOA%20Demo%20Shop%20cp4zqk" } +``` + +Verified: account **active**, `h_wire` present. + +### Credit facade (automatic settlement import) + +Merchant **wirewatch** imports bank credits via the **Taler Revenue API** (not the exchange wire-gateway): + +| Field | Value | +|-------|--------| +| `credit_facade_url` | `https://bank.hacktivism.ch/accounts/goa-demo-cp4zqk/taler-revenue/` | +| credentials | **bearer** bank access token (Basic is rejected on history) | +| process | `taler-merchant-wirewatch` (+ `depositcheck`) | + +Setup helper (host root): `scripts/taler-merchant/setup_credit_facade.sh` + +Without this facade, bank credits still land (exchange→merchant), but orders stay `wired=false` / `private/transfers` empty. + +## Related config + +- Exchanges: **CHF** `taler-ops` + **GOA** `exchange.hacktivism.ch` (both enabled) +- SMS TAN: off +- Bank DEFAULT_EXCHANGE: `https://exchange.hacktivism.ch/` +- Container hosts: public IP `212.51.151.254` for `exchange.hacktivism.ch` (not 127.0.0.1) + +### SPA / KYC for this GOA payto + +- **hacktivism / GOA:** `status=ready` (settlement path) +- **taler-ops / CHF:** `unsupported-account` — expected (x-taler-bank vs CHF/IBAN) diff --git a/configs/taler-hacktivism/merchant-overrides.conf b/configs/taler-hacktivism/merchant-overrides.conf new file mode 100644 index 0000000..164eeac --- /dev/null +++ b/configs/taler-hacktivism/merchant-overrides.conf @@ -0,0 +1,71 @@ +# Site overrides for taler.hacktivism.ch (container taler-hacktivism). +# Multi-currency: CHF (taler-ops) + GOA (local exchange). +# Do not edit overrides.conf (tooling). + +[taler] +CURRENCY = CHF + +### Disable package demonstrator KUDOS only +[merchant-exchange-kudos] +DISABLED = YES + +[currency-KUDOS] +ENABLED = NO + +[merchant] +CURRENCY = CHF +DATABASE = postgres +# Dual-currency short notice (GOA explorational + CHF real). +# Install: scripts/taler-merchant/install_dual_terms.sh (inside container). +# Bump etag when text changes so wallets/browsers drop long-lived /terms cache. +TERMS_ETAG = merchant-tos-dual-v2 +TERMS_DIR = ${TALER_DATA_HOME}terms/ +# Swiss FADP privacy (install: scripts/taler-merchant/install_swiss_privacy.sh) +PRIVACY_ETAG = merchant-pp-swiss-v0 +PRIVACY_DIR = ${TALER_DATA_HOME}terms/ + +UNIXPATH = /var/run/taler-merchant/httpd/merchant-http.sock +UNIXPATH_MODE = 660 +SERVE = unix + +DEFAULT_PERSONA = point-of-sale + +# Demo short deadlines (package: pay 1d, refund 15d, wire 1w). +# Wire happens after refund deadline; refund/pay windows also kept short. +DEFAULT_PAY_DELAY = 1 minute +DEFAULT_REFUND_DELAY = 30 s +DEFAULT_WIRE_TRANSFER_DELAY = 30 s + +ENABLE_SELF_PROVISIONING = YES +# No mandatory TAN (SMS auth off) +MANDATORY_TAN_CHANNELS = +HELPER_EMAIL = /usr/local/bin/taler-hacktivism-email-helper.sh +# HELPER_SMS = /usr/local/bin/taler-hacktivism-sms-helper-wrapper.sh + +[merchantdb-postgres] +CONFIG = postgres:///taler-merchant + +### CHF — package default in /usr/share/taler-merchant/config.d/tops.conf +### section [merchant-exchange-chf] → https://exchange.taler-ops.ch/ +### Do NOT redeclare the same EXCHANGE_BASE_URL under another section name +### (exchangekeyupdate: "configured in multiple sections"). +### Ensure package CHF stays enabled (only disable KUDOS above). + +### GOA — local exchange.hacktivism.ch (x-taler-bank / bank.hacktivism.ch) +[merchant-exchange-goa] +EXCHANGE_BASE_URL = https://exchange.hacktivism.ch/ +MASTER_KEY = TW6K5FXF81VYCAH0YWYX0SX98KBBSJ42VX27WAX01FTFH400QG10 +CURRENCY = GOA + +[currency-goa] +ENABLED = YES +name = "GOA exploration currency" +code = GOA +fractional_input_digits = 8 +fractional_normal_digits = 0 +fractional_trailing_zero_digits = 0 +# Keep in sync with exchange-overrides [currency-goa] (wallet compares unit maps). +# Scale keys only in [-8, 24] (exchange rejects outside that range). +alt_unit_names_are_symbols = NO +alt_unit_names = {"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"} +common_amounts = "GOA:10 GOA:20 GOA:50 GOA:100 GOA:200 GOA:1000" diff --git a/configs/taler-hacktivism/nginx-taler-merchant.conf b/configs/taler-hacktivism/nginx-taler-merchant.conf new file mode 100644 index 0000000..73e6cbc --- /dev/null +++ b/configs/taler-hacktivism/nginx-taler-merchant.conf @@ -0,0 +1,50 @@ +# Container: /etc/nginx/sites-available/taler-merchant +# Enabled: sites-enabled/taler-merchant → this file +# Host publish: 9010/tcp (podman pasta). Caddy terminates public HTTPS and +# reverse_proxies to https://127.0.0.1:9010 (see configs/caddy/Caddyfile). + +server { + listen 9010 ssl; + listen [::]:9010 ssl; + + server_name taler.hacktivism.ch; + + ssl_certificate /etc/letsencrypt/live/taler.hacktivism.ch/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/taler.hacktivism.ch/privkey.pem; + + access_log /var/log/nginx/merchant.log; + error_log /var/log/nginx/merchant.err; + + # Public landing first (Caddy should also redir / → /intro/; this covers :9010) + location = / { + return 302 /intro/; + } + + # Merchant-httpd only serves /terms and /privacy without trailing slash + location = /terms/ { + return 302 /terms; + } + location = /privacy/ { + return 302 /privacy; + } + + location / { + proxy_pass http://unix:/var/run/taler-merchant/httpd/merchant-http.sock; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host "taler.hacktivism.ch"; + proxy_set_header X-Forwarded-Proto "https"; + } +} + +server { + # ACME HTTP-01 is handled by Caddy :9000; this block kept for reference. + # listen 9010; + # listen [::]:9010; + + server_name taler.hacktivism.ch; + + location /.well-known/acme-challenge/ { + alias /var/www/letsencrypt/.well-known/acme-challenge/; + } +} diff --git a/configs/taler-hacktivism/taler-merchant.conf.includes.txt b/configs/taler-hacktivism/taler-merchant.conf.includes.txt new file mode 100644 index 0000000..367b443 --- /dev/null +++ b/configs/taler-hacktivism/taler-merchant.conf.includes.txt @@ -0,0 +1,5 @@ +# From /etc/taler-merchant/taler-merchant.conf (include order) +@inline-matching@ conf.d/*.conf +@inline@ overrides.conf +# Site overrides last (glob; file is merchant-overrides.conf) +@inline-matching@ merchant-overrides.conf diff --git a/configs/taler-hacktivism/terms-src/merchant-tos-dual-v0.en.rst b/configs/taler-hacktivism/terms-src/merchant-tos-dual-v0.en.rst new file mode 100644 index 0000000..29bc489 --- /dev/null +++ b/configs/taler-hacktivism/terms-src/merchant-tos-dual-v0.en.rst @@ -0,0 +1,43 @@ +No Formal Terms · Dual Currency Notice +====================================== + +This is a **self-hosted GNU Taler merchant backend** at +``taler.hacktivism.ch`` (hacktivism.ch). + +**No formal terms of service** from Taler Operations AG (or any other +third-party portal operator) apply to this instance. This short notice +is the site policy for using the backend. + +Dual currency +------------- + +This backend is configured for **two currencies at once**: + +* **GOA** — explorational / experimental currency of the local + stack (``exchange.hacktivism.ch``, ``bank.hacktivism.ch``). + GOA is **not** legal tender. It has **no guaranteed real-world + value**, redemption, or convertibility. +* **CHF** — Swiss francs, a **real** currency. CHF amounts are + real money settled via the CHF exchange configured on this host + (taler-ops / TOPS infrastructure as deployed). Treat CHF with + the seriousness of ordinary payments. + +By creating a merchant instance, accepting payments, or otherwise +using this service you acknowledge that: + +* GOA is for exploration and testing only. +* CHF involves real money — only use funds you control and can + afford to risk on a self-hosted experimental stack. +* There is no guaranteed availability, support, or uptime. +* Operators may reset GOA state, change configuration, delete + instance data, or interrupt service without notice. +* Software is provided as-is, without warranty. + +If you do not agree, do not use this merchant backend. + +Related +------- + +* Exchange (GOA): https://exchange.hacktivism.ch/terms +* Bank intro: https://bank.hacktivism.ch/intro/ +* Merchant intro: https://taler.hacktivism.ch/intro/ diff --git a/configs/tops/Caddyfile.snippet b/configs/tops/Caddyfile.snippet new file mode 100644 index 0000000..a566ae5 --- /dev/null +++ b/configs/tops/Caddyfile.snippet @@ -0,0 +1,29 @@ +# Paste into host Caddyfile (/etc/caddy/Caddyfile) and reload Caddy. +# Ports: 9090 ng1 | 9091 ng2 | 9092 ng3 + +tops.ng1.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9090 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +tops.ng2.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9091 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +tops.ng3.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9092 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} diff --git a/configs/tops/README.md b/configs/tops/README.md new file mode 100644 index 0000000..2fbc439 --- /dev/null +++ b/configs/tops/README.md @@ -0,0 +1,73 @@ +# tops — `koopa-tops-ng1` … `ng3` + +| Item | Value | +|------|--------| +| Live | `/home/hernani/koopa-tops/` | +| Containers | `koopa-tops-ng1`, `koopa-tops-ng2`, `koopa-tops-ng3` | +| Image | `docker.io/library/nginx:1.27-alpine` | +| Compose | `compose.yml` (this dir) | +| Secrets | none | + +Site HTML is **not** in this repo (container wiring only). + +## Containers + +| Container | Host port | Caddy host | +|-----------|-----------|------------| +| `koopa-tops-ng1` | **9090** | `tops.ng1.hacktivism.ch` | +| `koopa-tops-ng2` | **9091** | `tops.ng2.hacktivism.ch` | +| `koopa-tops-ng3` | **9092** | `tops.ng3.hacktivism.ch` | + +Caddy snippet: `Caddyfile.snippet`. + + +## nginx + +Mirror: `nginx-default.conf` → mounted as `/etc/nginx/conf.d/default.conf` in each container. + +### Cache (always fresh) + +Preview sites must not be cached by browsers/proxies: + +- `Cache-Control: no-store, no-cache, must-revalidate, max-age=0, private` +- `Pragma: no-cache` / `Expires: 0` +- `etag off` · `if_modified_since off` · `expires -1` + +After editing the conf on the host (bind mount), reload nginx **inside** the container (do not stop the container): + +```bash +for c in koopa-tops-ng1 koopa-tops-ng2 koopa-tops-ng3; do + podman exec "$c" nginx -t && podman exec "$c" nginx -s reload +done +``` + +## Autostart + +User unit `container-koopa-tops.service` (mirror in this dir) starts all three via compose. + +```bash +# as hernani on koopa +mkdir -p ~/.config/systemd/user +cp ~/koopa-tops/deploy/container-koopa-tops.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now container-koopa-tops.service +``` + +Needs linger so the unit runs without an interactive login: + +```bash +# once as root, if needed +loginctl enable-linger hernani +loginctl show-user hernani -p Linger +``` + +Check: + +```bash +systemctl --user status container-koopa-tops.service +podman ps --filter name=koopa-tops +``` + +## Related + +`configs/ports.md` · `host/overview/LIVE.md` diff --git a/configs/tops/compose.yml b/configs/tops/compose.yml new file mode 100644 index 0000000..60da5c1 --- /dev/null +++ b/configs/tops/compose.yml @@ -0,0 +1,61 @@ +# koopa-tops — host ports 9090–9092 +# Live: /home/hernani/koopa-tops/ +# Usage: cd ~/koopa-tops && podman compose -f deploy/compose.yml up -d + +services: + tops-ng1: + image: docker.io/library/nginx:1.27-alpine + container_name: koopa-tops-ng1 + restart: unless-stopped + ports: + - "9090:80" + volumes: + - ../ng1:/usr/share/nginx/html:ro + labels: + org.hacktivism.service: tops-ng1 + org.hacktivism.host_port: "9090" + 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/"] + interval: 30s + timeout: 5s + retries: 3 + + tops-ng2: + image: docker.io/library/nginx:1.27-alpine + container_name: koopa-tops-ng2 + restart: unless-stopped + ports: + - "9091:80" + volumes: + - ../ng2:/usr/share/nginx/html:ro + labels: + org.hacktivism.service: tops-ng2 + org.hacktivism.host_port: "9091" + 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/"] + interval: 30s + timeout: 5s + retries: 3 + + tops-ng3: + image: docker.io/library/nginx:1.27-alpine + container_name: koopa-tops-ng3 + restart: unless-stopped + ports: + - "9092:80" + volumes: + - ../ng3:/usr/share/nginx/html:ro + labels: + org.hacktivism.service: tops-ng3 + org.hacktivism.host_port: "9092" + 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/"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/configs/tops/container-koopa-tops.service b/configs/tops/container-koopa-tops.service new file mode 100644 index 0000000..5a22cab --- /dev/null +++ b/configs/tops/container-koopa-tops.service @@ -0,0 +1,35 @@ +# tops.ng1–ng3 (podman compose) — start on user session / boot (linger) +# +# On koopa as hernani: +# mkdir -p ~/.config/systemd/user +# cp deploy/container-koopa-tops.service ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now container-koopa-tops.service +# +# Once as root (if not already): loginctl enable-linger hernani + +[Unit] +Description=Podman koopa-tops (tops.ng1–ng3 static nginx) +Documentation=file:///home/hernani/koopa-tops/README.md +Wants=network-online.target +After=network-online.target +RequiresMountsFor=%t/containers /home/hernani/koopa-tops + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/home/hernani/koopa-tops +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=on-failure +RestartSec=15 +TimeoutStartSec=120 +TimeoutStopSec=60 + +ExecStartPre=/bin/bash -c "chmod -R a+rX /home/hernani/koopa-tops/ng1 /home/hernani/koopa-tops/ng2 /home/hernani/koopa-tops/ng3 2>/dev/null || true" + +ExecStart=/bin/bash -c "if podman compose version >/dev/null 2>&1; then podman compose -f deploy/compose.yml up -d; elif command -v podman-compose >/dev/null 2>&1; then podman-compose -f deploy/compose.yml up -d; else echo \"no podman compose\" >&2; exit 1; fi" + +ExecStop=/bin/bash -c "if podman compose version >/dev/null 2>&1; then podman compose -f deploy/compose.yml stop; elif command -v podman-compose >/dev/null 2>&1; then podman-compose -f deploy/compose.yml stop; else podman stop koopa-tops-ng1 koopa-tops-ng2 koopa-tops-ng3 2>/dev/null || true; fi" + +[Install] +WantedBy=default.target diff --git a/configs/tops/nginx-default.conf b/configs/tops/nginx-default.conf new file mode 100644 index 0000000..286110f --- /dev/null +++ b/configs/tops/nginx-default.conf @@ -0,0 +1,47 @@ +# Shared nginx config for tops.ng1–ng3 static previews. +# Language root redirect mirrors taler-ops.ch (Accept-Language → /en|/de|/fr). +# Cache: always revalidate / never store — design previews must show latest HTML/assets. + +map $http_accept_language $accept_lang { + default en; + ~*^de de; + ~*^fr fr; + ~*^en en; +} + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Relative Location: so Caddy/HTTPS clients stay on https://host/... + absolute_redirect off; + port_in_redirect off; + server_name_in_redirect off; + + # Never serve stale preview content from browser/proxy caches + etag off; + if_modified_since off; + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0, private" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + + # Like https://taler-ops.ch/ — bare root picks language from Accept-Language + location = / { + return 302 /$accept_lang/; + } + location = /index.html { + return 302 /$accept_lang/; + } + + # Bare page names that exist under /{lang}/ (ng2; 302→404 on ng1 if missing) + location ~ ^/(merchants|users|users-[a-z]+)\.html$ { + return 302 /$accept_lang$uri; + } + + location / { + try_files $uri $uri/ $uri/index.html =404; + } +} diff --git a/configs/tor/Containerfile b/configs/tor/Containerfile new file mode 100644 index 0000000..b4ef558 --- /dev/null +++ b/configs/tor/Containerfile @@ -0,0 +1,10 @@ +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 +EXPOSE 8080 9051 +ENTRYPOINT ["/usr/bin/tor"] +CMD ["-f", "/etc/tor/torrc", "--runasdaemon", "0"] diff --git a/configs/tor/README.md b/configs/tor/README.md new file mode 100644 index 0000000..cd14262 --- /dev/null +++ b/configs/tor/README.md @@ -0,0 +1,53 @@ +# Tor relay — koopa (`KoopaRelay`) + +Live: **podman** container **`koopa-tor-relay`** (rootless hernani, `--network host`). +Host layout: `~/koopa-tor-relay/{torrc,data,log,build}`. +Mirror in this repo: `configs/tor/`. + +| Setting | Value | +|---------|--------| +| Nickname | `KoopaRelay` | +| Fingerprint | `02C7EECCDF7814FBA2DB1CBAED000D06E8A32EE8` | +| ORPort | **8080** (IPv4 + IPv6; VeciGate WAN DNAT → koopa:8080) | +| ControlPort | **127.0.0.1:9051** | +| ExitRelay | **0** (non-exit) | +| SocksPort | **0** | +| MyFamily | `52BB94DDC1292F950CF728708AC48523E018A718` | +| Bandwidth* | 2000 MBytes rate/burst | +| Image | `localhost/koopa-tor-relay:latest` (Debian bookworm + tor + nyx) | + +## Files + +| File | Role | +|------|------| +| `torrc` | Active policy (mounted read-only into container) | +| `Containerfile` | Image build (tor, nyx, tor-geoipdb) | +| `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) | + +## Ops + +```bash +# status +podman ps --filter name=koopa-tor-relay +systemctl --user status container-koopa-tor-relay +ss -lntp | grep -E '8080|9051' +tail -f ~/koopa-tor-relay/log/notices.log + +# monitor +podman exec -it koopa-tor-relay nyx +``` + +### Cutover (host package → container) + +1. `sudo systemctl stop tor && sudo systemctl disable tor` (prefer `mask` so it never returns) +2. `sudo ~/koopa-tor-relay/migrate-identity.sh` (or copy of this script) +3. `podman start koopa-tor-relay` / user unit enable +4. Confirm fingerprint and ORPort reachability + +Do **not** run host `tor.service` and the container at the same time. + +Monitoring helpers: `scripts/monitoring/tor_*.sh`. +Firewall: `configs/firewalld/public-ports.md` (**8080/tcp**). +Router: `../vecigate-admin-log` (WAN :8080 → koopa). diff --git a/configs/tor/container-koopa-tor-relay.service b/configs/tor/container-koopa-tor-relay.service new file mode 100644 index 0000000..051b56e --- /dev/null +++ b/configs/tor/container-koopa-tor-relay.service @@ -0,0 +1,28 @@ +# container-koopa-tor-relay.service +# Install on koopa as hernani: +# mkdir -p ~/.config/systemd/user +# cp configs/tor/container-koopa-tor-relay.service ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now container-koopa-tor-relay.service +# Requires: loginctl enable-linger hernani (root, once) +# +# autogenerated by Podman 5.8.3 (paths are host-specific; regenerate if container id changes) + +[Unit] +Description=Podman container-koopa-tor-relay.service +Documentation=man:podman-generate-systemd(1) +Wants=network-online.target +After=network-online.target +RequiresMountsFor=/run/user/1000/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=always +TimeoutStopSec=70 +ExecStart=/usr/bin/podman start koopa-tor-relay +ExecStop=/usr/bin/podman stop -t 10 koopa-tor-relay +ExecStopPost=/usr/bin/podman stop -t 10 koopa-tor-relay +Type=forking + +[Install] +WantedBy=default.target diff --git a/configs/tor/migrate-identity.sh b/configs/tor/migrate-identity.sh new file mode 100755 index 0000000..344fddd --- /dev/null +++ b/configs/tor/migrate-identity.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Copy host Tor identity → container data. Works with: sudo ./migrate-identity.sh +set -euo pipefail + +if [[ "$(id -u)" -ne 0 ]]; then + exec sudo -H "$0" "$@" +fi + +OWNER="${SUDO_USER:-hernani}" +if [[ "$OWNER" == root ]]; then OWNER=hernani; fi +BASE="$(getent passwd "$OWNER" | cut -d: -f6)/koopa-tor-relay" +SRC=/var/lib/tor +DST="$BASE/data" + +if [[ ! -d "$BASE" ]]; then + echo "ERROR: missing $BASE" >&2 + exit 1 +fi +if systemctl is-active --quiet tor 2>/dev/null; then + echo "ERROR: stop host tor first: systemctl stop tor" >&2 + exit 1 +fi +if command -v podman >/dev/null 2>&1; then + if runuser -u "$OWNER" -- podman ps --format "{{.Names}}" 2>/dev/null | grep -qx koopa-tor-relay; then + echo "ERROR: stop container first: podman stop koopa-tor-relay" >&2 + exit 1 + fi +fi + +echo "Copying $SRC → $DST (identity keys as $OWNER)…" +mkdir -p "$DST" +rsync -aHAX --delete "$SRC"/ "$DST"/ +chown -R "${OWNER}:${OWNER}" "$DST" +chmod 700 "$DST" + +echo "--- fingerprint ---" +[[ -f "$DST/fingerprint" ]] && cat "$DST/fingerprint" || echo "(no fingerprint file yet)" +[[ -f "$DST/fingerprint-ed25519" ]] && cat "$DST/fingerprint-ed25519" || true +echo "OK — $DST ready. Then: podman start koopa-tor-relay" diff --git a/configs/tor/torrc b/configs/tor/torrc new file mode 100644 index 0000000..63cd8cf --- /dev/null +++ b/configs/tor/torrc @@ -0,0 +1,16 @@ +# KoopaRelay — active policy (podman koopa-tor-relay, host network) +# Live paths: ~/koopa-tor-relay/{torrc,data,log} on koopa +Log notice file /var/log/tor/notices.log +Nickname KoopaRelay +ContactInfo info+koopa@hacktivism.ch +# dual-stack ORPort (IPv4 + IPv6) +ORPort 8080 +ControlPort 127.0.0.1:9051 +ExitRelay 0 +SocksPort 0 +MyFamily 52BB94DDC1292F950CF728708AC48523E018A718 +BandwidthRate 2000 MBytes +BandwidthBurst 2000 MBytes +RelayBandwidthRate 2000 MBytes +RelayBandwidthBurst 2000 MBytes +DataDirectory /var/lib/tor diff --git a/configs/tor/torrc.minimal b/configs/tor/torrc.minimal new file mode 100644 index 0000000..a05f52c --- /dev/null +++ b/configs/tor/torrc.minimal @@ -0,0 +1,192 @@ +## Configuration file for a typical Tor user +## Last updated 9 October 2013 for Tor 0.2.5.2-alpha. +## (may or may not work for much older or much newer versions of Tor.) +## +## Lines that begin with "## " try to explain what's going on. Lines +## that begin with just "#" are disabled commands: you can enable them +## by removing the "#" symbol. +## +## See 'man tor', or https://www.torproject.org/docs/tor-manual.html, +## for more options you can use in this file. +## +## Tor will look for this file in various places based on your platform: +## https://www.torproject.org/docs/faq#torrc + +## Tor opens a socks proxy on port 9050 by default -- even if you don't +## configure one below. Set "SocksPort 0" if you plan to run Tor only +## as a relay, and not make any local application connections yourself. +#SocksPort 9050 # Default: Bind to localhost:9050 for local connections. +#SocksPort 192.168.0.1:9100 # Bind to this address:port too. + +## Entry policies to allow/deny SOCKS requests based on IP address. +## First entry that matches wins. If no SocksPolicy is set, we accept +## all (and only) requests that reach a SocksPort. Untrusted users who +## can access your SocksPort may be able to learn about the connections +## you make. +#SocksPolicy accept 192.168.0.0/16 +#SocksPolicy reject * + +## Logs go to stdout at level "notice" unless redirected by something +## else, like one of the below lines. You can have as many Log lines as +## you want. +## +## We advise using "notice" in most cases, since anything more verbose +## may provide sensitive information to an attacker who obtains the logs. +## +## Send all messages of level 'notice' or higher to /var/log/tor/notices.log +#Log notice file /var/log/tor/notices.log +## Send every possible message to /var/log/tor/debug.log +#Log debug file /var/log/tor/debug.log +## Use the system log instead of Tor's logfiles +#Log notice syslog +## To send all messages to stderr: +#Log debug stderr + +## Uncomment this to start the process in the background... or use +## --runasdaemon 1 on the command line. This is ignored on Windows; +## see the FAQ entry if you want Tor to run as an NT service. +#RunAsDaemon 1 + +## The directory for keeping all the keys/etc. By default, we store +## things in $HOME/.tor on Unix, and in Application Data\tor on Windows. +#DataDirectory /var/lib/tor + +## The port on which Tor will listen for local connections from Tor +## controller applications, as documented in control-spec.txt. +#ControlPort 9051 +## If you enable the controlport, be sure to enable one of these +## authentication methods, to prevent attackers from accessing it. +#HashedControlPassword 16:872860B76453A77D60CA2BB8C1A7042072093276A3D701AD684053EC4C +#CookieAuthentication 1 + +############### This section is just for location-hidden services ### + +## Once you have configured a hidden service, you can look at the +## contents of the file ".../hidden_service/hostname" for the address +## to tell people. +## +## HiddenServicePort x y:z says to redirect requests on port x to the +## address y:z. + +#HiddenServiceDir /var/lib/tor/hidden_service/ +#HiddenServicePort 80 127.0.0.1:80 + +#HiddenServiceDir /var/lib/tor/other_hidden_service/ +#HiddenServicePort 80 127.0.0.1:80 +#HiddenServicePort 22 127.0.0.1:22 + +################ This section is just for relays ##################### +# +## See https://www.torproject.org/docs/tor-doc-relay for details. + +## Required: what port to advertise for incoming Tor connections. +#ORPort 9001 +## If you want to listen on a port other than the one advertised in +## ORPort (e.g. to advertise 443 but bind to 9090), you can do it as +## follows. You'll need to do ipchains or other port forwarding +## yourself to make this work. +#ORPort 443 NoListen +#ORPort 127.0.0.1:9090 NoAdvertise + +## The IP address or full DNS name for incoming connections to your +## relay. Leave commented out and Tor will guess. +#Address noname.example.com + +## If you have multiple network interfaces, you can specify one for +## outgoing traffic to use. +# OutboundBindAddress 10.0.0.5 + +## A handle for your relay, so people don't have to refer to it by key. +#Nickname ididnteditheconfig + +## Define these to limit how much relayed traffic you will allow. Your +## own traffic is still unthrottled. Note that RelayBandwidthRate must +## be at least 20 KB. +## Note that units for these config options are bytes per second, not bits +## per second, and that prefixes are binary prefixes, i.e. 2^10, 2^20, etc. +#RelayBandwidthRate 100 KB # Throttle traffic to 100KB/s (800Kbps) +#RelayBandwidthBurst 200 KB # But allow bursts up to 200KB/s (1600Kbps) + +## Use these to restrict the maximum traffic per day, week, or month. +## Note that this threshold applies separately to sent and received bytes, +## not to their sum: setting "4 GB" may allow up to 8 GB total before +## hibernating. +## +## Set a maximum of 4 gigabytes each way per period. +#AccountingMax 4 GB +## Each period starts daily at midnight (AccountingMax is per day) +#AccountingStart day 00:00 +## Each period starts on the 3rd of the month at 15:00 (AccountingMax +## is per month) +#AccountingStart month 3 15:00 + +## Administrative contact information for this relay or bridge. This line +## can be used to contact you if your relay or bridge is misconfigured or +## something else goes wrong. Note that we archive and publish all +## descriptors containing these lines and that Google indexes them, so +## spammers might also collect them. You may want to obscure the fact that +## it's an email address and/or generate a new address for this purpose. +#ContactInfo Random Person +## You might also include your PGP or GPG fingerprint if you have one: +#ContactInfo 0xFFFFFFFF Random Person + +## Uncomment this to mirror directory information for others. Please do +## if you have enough bandwidth. +#DirPort 9030 # what port to advertise for directory connections +## If you want to listen on a port other than the one advertised in +## DirPort (e.g. to advertise 80 but bind to 9091), you can do it as +## follows. below too. You'll need to do ipchains or other port +## forwarding yourself to make this work. +#DirPort 80 NoListen +#DirPort 127.0.0.1:9091 NoAdvertise +## Uncomment to return an arbitrary blob of html on your DirPort. Now you +## can explain what Tor is if anybody wonders why your IP address is +## contacting them. See contrib/tor-exit-notice.html in Tor's source +## distribution for a sample. +#DirPortFrontPage /etc/tor/tor-exit-notice.html + +## Uncomment this if you run more than one Tor relay, and add the identity +## key fingerprint of each Tor relay you control, even if they're on +## different networks. You declare it here so Tor clients can avoid +## using more than one of your relays in a single circuit. See +## https://www.torproject.org/docs/faq#MultipleRelays +## However, you should never include a bridge's fingerprint here, as it would +## break its concealability and potentionally reveal its IP/TCP address. +#MyFamily $keyid,$keyid,... + +## A comma-separated list of exit policies. They're considered first +## to last, and the first match wins. If you want to _replace_ +## the default exit policy, end this with either a reject *:* or an +## accept *:*. Otherwise, you're _augmenting_ (prepending to) the +## default exit policy. Leave commented to just use the default, which is +## described in the man page or at +## https://www.torproject.org/documentation.html +## +## Look at https://www.torproject.org/faq-abuse.html#TypicalAbuses +## for issues you might encounter if you use the default exit policy. +## +## If certain IPs and ports are blocked externally, e.g. by your firewall, +## you should update your exit policy to reflect this -- otherwise Tor +## users will be told that those destinations are down. +## +## For security, by default Tor rejects connections to private (local) +## networks, including to your public IP address. See the man page entry +## for ExitPolicyRejectPrivate if you want to allow "exit enclaving". +## +#ExitPolicy accept *:6660-6667,reject *:* # allow irc ports but no more +#ExitPolicy accept *:119 # accept nntp as well as default exit policy +#ExitPolicy reject *:* # no exits allowed + +## Bridge relays (or "bridges") are Tor relays that aren't listed in the +## main directory. Since there is no complete public list of them, even an +## ISP that filters connections to all the known Tor relays probably +## won't be able to block all the bridges. Also, websites won't treat you +## differently because they won't know you're running Tor. If you can +## be a real relay, please do; but if not, be a bridge! +#BridgeRelay 1 +## By default, Tor will advertise your bridge to users through various +## mechanisms like https://bridges.torproject.org/. If you want to run +## a private bridge, for example because you'll give out your bridge +## address manually to your friends, uncomment this line: +#PublishServerDescriptor 0 + diff --git a/configs/tor/torrc.sample b/configs/tor/torrc.sample new file mode 100644 index 0000000..7be6773 --- /dev/null +++ b/configs/tor/torrc.sample @@ -0,0 +1,257 @@ +## Configuration file for a typical Tor user +## Last updated 28 February 2019 for Tor 0.3.5.1-alpha. +## (may or may not work for much older or much newer versions of Tor.) +## +## Lines that begin with "## " try to explain what's going on. Lines +## that begin with just "#" are disabled commands: you can enable them +## by removing the "#" symbol. +## +## See 'man tor', or https://www.torproject.org/docs/tor-manual.html, +## for more options you can use in this file. +## +## Tor will look for this file in various places based on your platform: +## https://support.torproject.org/tbb/tbb-editing-torrc/ + +## Tor opens a SOCKS proxy on port 9050 by default -- even if you don't +## configure one below. Set "SOCKSPort 0" if you plan to run Tor only +## as a relay, and not make any local application connections yourself. +#SOCKSPort 9050 # Default: Bind to localhost:9050 for local connections. +#SOCKSPort 192.168.0.1:9100 # Bind to this address:port too. + +## Entry policies to allow/deny SOCKS requests based on IP address. +## First entry that matches wins. If no SOCKSPolicy is set, we accept +## all (and only) requests that reach a SOCKSPort. Untrusted users who +## can access your SOCKSPort may be able to learn about the connections +## you make. +#SOCKSPolicy accept 192.168.0.0/16 +#SOCKSPolicy accept6 FC00::/7 +#SOCKSPolicy reject * + +## Logs go to stdout at level "notice" unless redirected by something +## else, like one of the below lines. You can have as many Log lines as +## you want. +## +## We advise using "notice" in most cases, since anything more verbose +## may provide sensitive information to an attacker who obtains the logs. +## +## Send all messages of level 'notice' or higher to /var/log/tor/notices.log +#Log notice file /var/log/tor/notices.log +## Send every possible message to /var/log/tor/debug.log +#Log debug file /var/log/tor/debug.log +## Use the system log instead of Tor's logfiles +#Log notice syslog +## To send all messages to stderr: +#Log debug stderr + +## Uncomment this to start the process in the background... or use +## --runasdaemon 1 on the command line. This is ignored on Windows; +## see the FAQ entry if you want Tor to run as an NT service. +#RunAsDaemon 1 + +## The directory for keeping all the keys/etc. By default, we store +## things in $HOME/.tor on Unix, and in Application Data\tor on Windows. +#DataDirectory /var/lib/tor + +## The port on which Tor will listen for local connections from Tor +## controller applications, as documented in control-spec.txt. +#ControlPort 9051 +## If you enable the controlport, be sure to enable one of these +## authentication methods, to prevent attackers from accessing it. +#HashedControlPassword 16:872860B76453A77D60CA2BB8C1A7042072093276A3D701AD684053EC4C +#CookieAuthentication 1 + +############### This section is just for location-hidden services ### + +## Once you have configured a hidden service, you can look at the +## contents of the file ".../hidden_service/hostname" for the address +## to tell people. +## +## HiddenServicePort x y:z says to redirect requests on port x to the +## address y:z. + +#HiddenServiceDir /var/lib/tor/hidden_service/ +#HiddenServicePort 80 127.0.0.1:80 + +#HiddenServiceDir /var/lib/tor/other_hidden_service/ +#HiddenServicePort 80 127.0.0.1:80 +#HiddenServicePort 22 127.0.0.1:22 + +################ This section is just for relays ##################### +# +## See https://community.torproject.org/relay for details. + +## Required: what port to advertise for incoming Tor connections. +#ORPort 9001 +## If you want to listen on a port other than the one advertised in +## ORPort (e.g. to advertise 443 but bind to 9090), you can do it as +## follows. You'll need to do ipchains or other port forwarding +## yourself to make this work. +#ORPort 443 NoListen +#ORPort 127.0.0.1:9090 NoAdvertise +## If you want to listen on IPv6 your numeric address must be explicitly +## between square brackets as follows. You must also listen on IPv4. +#ORPort [2001:DB8::1]:9050 + +## The IP address or full DNS name for incoming connections to your +## relay. Leave commented out and Tor will guess. +#Address noname.example.com + +## If you have multiple network interfaces, you can specify one for +## outgoing traffic to use. +## OutboundBindAddressExit will be used for all exit traffic, while +## OutboundBindAddressOR will be used for all OR and Dir connections +## (DNS connections ignore OutboundBindAddress). +## If you do not wish to differentiate, use OutboundBindAddress to +## specify the same address for both in a single line. +#OutboundBindAddressExit 10.0.0.4 +#OutboundBindAddressOR 10.0.0.5 + +## A handle for your relay, so people don't have to refer to it by key. +## Nicknames must be between 1 and 19 characters inclusive, and must +## contain only the characters [a-zA-Z0-9]. +## If not set, "Unnamed" will be used. +#Nickname ididnteditheconfig + +## Define these to limit how much relayed traffic you will allow. Your +## own traffic is still unthrottled. Note that RelayBandwidthRate must +## be at least 75 kilobytes per second. +## Note that units for these config options are bytes (per second), not +## bits (per second), and that prefixes are binary prefixes, i.e. 2^10, +## 2^20, etc. +#RelayBandwidthRate 100 KBytes # Throttle traffic to 100KB/s (800Kbps) +#RelayBandwidthBurst 200 KBytes # But allow bursts up to 200KB (1600Kb) + +## Use these to restrict the maximum traffic per day, week, or month. +## Note that this threshold applies separately to sent and received bytes, +## not to their sum: setting "40 GB" may allow up to 80 GB total before +## hibernating. +## +## Set a maximum of 40 gigabytes each way per period. +#AccountingMax 40 GBytes +## Each period starts daily at midnight (AccountingMax is per day) +#AccountingStart day 00:00 +## Each period starts on the 3rd of the month at 15:00 (AccountingMax +## is per month) +#AccountingStart month 3 15:00 + +## Administrative contact information for this relay or bridge. This line +## can be used to contact you if your relay or bridge is misconfigured or +## something else goes wrong. Note that we archive and publish all +## descriptors containing these lines and that Google indexes them, so +## spammers might also collect them. You may want to obscure the fact that +## it's an email address and/or generate a new address for this purpose. +## +## If you are running multiple relays, you MUST set this option. +## +#ContactInfo Random Person +## You might also include your PGP or GPG fingerprint if you have one: +#ContactInfo 0xFFFFFFFF Random Person + +## Uncomment this to mirror directory information for others. Please do +## if you have enough bandwidth. +#DirPort 9030 # what port to advertise for directory connections +## If you want to listen on a port other than the one advertised in +## DirPort (e.g. to advertise 80 but bind to 9091), you can do it as +## follows. below too. You'll need to do ipchains or other port +## forwarding yourself to make this work. +#DirPort 80 NoListen +#DirPort 127.0.0.1:9091 NoAdvertise +## Uncomment to return an arbitrary blob of html on your DirPort. Now you +## can explain what Tor is if anybody wonders why your IP address is +## contacting them. See contrib/tor-exit-notice.html in Tor's source +## distribution for a sample. +#DirPortFrontPage /etc/tor/tor-exit-notice.html + +## Uncomment this if you run more than one Tor relay, and add the identity +## key fingerprint of each Tor relay you control, even if they're on +## different networks. You declare it here so Tor clients can avoid +## using more than one of your relays in a single circuit. See +## https://support.torproject.org/relay-operators/multiple-relays/ +## However, you should never include a bridge's fingerprint here, as it would +## break its concealability and potentially reveal its IP/TCP address. +## +## If you are running multiple relays, you MUST set this option. +## +## Note: do not use MyFamily on bridge relays. +#MyFamily $keyid,$keyid,... + +## Uncomment this if you want your relay to be an exit, with the default +## exit policy (or whatever exit policy you set below). +## (If ReducedExitPolicy, ExitPolicy, or IPv6Exit are set, relays are exits. +## If none of these options are set, relays are non-exits.) +#ExitRelay 1 + +## Uncomment this if you want your relay to allow IPv6 exit traffic. +## (Relays do not allow any exit traffic by default.) +#IPv6Exit 1 + +## Uncomment this if you want your relay to be an exit, with a reduced set +## of exit ports. +#ReducedExitPolicy 1 + +## Uncomment these lines if you want your relay to be an exit, with the +## specified set of exit IPs and ports. +## +## A comma-separated list of exit policies. They're considered first +## to last, and the first match wins. +## +## If you want to allow the same ports on IPv4 and IPv6, write your rules +## using accept/reject *. If you want to allow different ports on IPv4 and +## IPv6, write your IPv6 rules using accept6/reject6 *6, and your IPv4 rules +## using accept/reject *4. +## +## If you want to _replace_ the default exit policy, end this with either a +## reject *:* or an accept *:*. Otherwise, you're _augmenting_ (prepending to) +## the default exit policy. Leave commented to just use the default, which is +## described in the man page or at +## https://support.torproject.org/relay-operators +## +## Look at https://support.torproject.org/abuse/exit-relay-expectations/ +## for issues you might encounter if you use the default exit policy. +## +## If certain IPs and ports are blocked externally, e.g. by your firewall, +## you should update your exit policy to reflect this -- otherwise Tor +## users will be told that those destinations are down. +## +## For security, by default Tor rejects connections to private (local) +## networks, including to the configured primary public IPv4 and IPv6 addresses, +## and any public IPv4 and IPv6 addresses on any interface on the relay. +## See the man page entry for ExitPolicyRejectPrivate if you want to allow +## "exit enclaving". +## +#ExitPolicy accept *:6660-6667,reject *:* # allow irc ports on IPv4 and IPv6 but no more +#ExitPolicy accept *:119 # accept nntp ports on IPv4 and IPv6 as well as default exit policy +#ExitPolicy accept *4:119 # accept nntp ports on IPv4 only as well as default exit policy +#ExitPolicy accept6 *6:119 # accept nntp ports on IPv6 only as well as default exit policy +#ExitPolicy reject *:* # no exits allowed + +## Uncomment this if you want your exit relay to reevaluate its exit policy on +## existing connections when the exit policy is modified. +#ReevaluateExitPolicy 1 + +## Bridge relays (or "bridges") are Tor relays that aren't listed in the +## main directory. Since there is no complete public list of them, even an +## ISP that filters connections to all the known Tor relays probably +## won't be able to block all the bridges. Also, websites won't treat you +## differently because they won't know you're running Tor. If you can +## be a real relay, please do; but if not, be a bridge! +## +## Warning: when running your Tor as a bridge, make sure than MyFamily is +## NOT configured. +#BridgeRelay 1 +## By default, Tor will advertise your bridge to users through various +## mechanisms like https://bridges.torproject.org/. If you want to run +## a private bridge, for example because you'll give out your bridge +## address manually to your friends, uncomment this line: +#BridgeDistribution none + +## Configuration options can be imported from files or folders using the %include +## option with the value being a path. This path can have wildcards. Wildcards are +## expanded first, using lexical order. Then, for each matching file or folder, the following +## rules are followed: if the path is a file, the options from the file will be parsed as if +## they were written where the %include option is. If the path is a folder, all files on that +## folder will be parsed following lexical order. Files starting with a dot are ignored. Files +## on subfolders are ignored. +## The %include option can be used recursively. +#%include /etc/torrc.d/*.conf + diff --git a/host/README.md b/host/README.md new file mode 100644 index 0000000..79c7694 --- /dev/null +++ b/host/README.md @@ -0,0 +1,16 @@ +# openSUSE host `koopa` + +OS: **openSUSE Tumbleweed** · hostname **koopa** · LAN `192.168.100.95/24` · public AAAA on `eno1` + +This directory holds **host-level** config only (systemd, firewalld, Caddy, network). +Application configs stay under `configs/taler-*` and `scripts/`. + +| Path | Content | +|------|---------| +| `overview/` | Service map (diagram + table) | +| `systemd/` | socket proxies + caddy drop-in | +| `firewalld/` | public zone dump | +| `caddy/` | live Caddyfile | +| `network/` | addressing notes | + +Edge router: **VeciGate** (`../vecigate-admin-log`). diff --git a/host/caddy/Caddyfile b/host/caddy/Caddyfile new file mode 100644 index 0000000..03f88aa --- /dev/null +++ b/host/caddy/Caddyfile @@ -0,0 +1,185 @@ +# Internal only (not in the browser URL): +# 9010 merchant API | 9011 exchange API | 9012 bank API +# 9013 bank landing | 9014 exchange landing | 9015 merchant landing +# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | | 9200 forgejo-ssh +# 9090 tops ng1 | 9091 tops ng2 | 9092 tops ng3 + +{ + email info+koopa@hacktivism.ch + http_port 9000 + https_port 9001 + auto_https disable_redirects + # Caddy listens on 9001 behind VeciGate/https-proxy :443. + # Default HTTP/3 would send Alt-Svc: h3=":9001" — break public HTTPS on :443. + servers { + protocols h1 h2 + } +} + +(proxy_public) { + header_up Host {host} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Port 443 + header_down Location "^https?://[^/]+:90[0-9]{2}(.*)$" "https://{host}$1" +} + +taler.hacktivism.ch { + tls /etc/caddy/certs/taler.hacktivism.ch/fullchain.pem /etc/caddy/certs/taler.hacktivism.ch/privkey.pem + header Alt-Svc "clear" + + # Public landing first + redir / /intro/ 302 + + handle /intro* { + reverse_proxy 127.0.0.1:9015 { + import proxy_public + } + } + + # SPA: /webui → /webui/ + redir /webui /webui/ 302 + + # Merchant API + WebUI (nginx :9010 → unix socket) + reverse_proxy https://127.0.0.1:9010 { + transport http { + tls_insecure_skip_verify + } + import proxy_public + } +} + +exchange.hacktivism.ch { + tls /etc/caddy/certs/exchange.hacktivism.ch/fullchain.pem /etc/caddy/certs/exchange.hacktivism.ch/privkey.pem + header Alt-Svc "clear" + + # Public landing first + redir / /intro/ 302 + + handle /intro* { + reverse_proxy 127.0.0.1:9014 { + import proxy_public + } + } + + reverse_proxy 127.0.0.1:9011 { + import proxy_public + } +} + +bank.hacktivism.ch { + header Alt-Svc "clear" + + # Public landing first + redir / /intro/ 302 + + handle /intro* { + reverse_proxy 127.0.0.1:9013 { + import proxy_public + } + } + + # Static terms/privacy on landing nginx :9013 + handle /terms* { + reverse_proxy 127.0.0.1:9013 { + import proxy_public + } + } + handle /privacy* { + reverse_proxy 127.0.0.1:9013 { + import proxy_public + } + } + + reverse_proxy 127.0.0.1:9012 { + import proxy_public + } +} + +castopod.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9020 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + } +} + +bonfire.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9021 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + flush_interval -1 + } +} + +prime.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9022 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + flush_interval -1 + } +} + +bt.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9023 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + } +} + +# 9024 forgejo HTTP (SSH :9200 host-direct, not via Caddy) +git.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9024 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + flush_interval -1 + transport http { + read_timeout 3600s + write_timeout 3600s + } + } +} + + +# Taler Operations design previews (static nginx) +tops.ng1.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9090 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +tops.ng2.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9091 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +tops.ng3.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9092 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +http://taler.hacktivism.ch, http://exchange.hacktivism.ch, http://bank.hacktivism.ch, http://castopod.hacktivism.ch, http://bonfire.hacktivism.ch, http://prime.hacktivism.ch, http://bt.hacktivism.ch, http://git.hacktivism.ch, http://tops.ng1.hacktivism.ch, http://tops.ng2.hacktivism.ch, http://tops.ng3.hacktivism.ch { + handle /.well-known/acme-challenge/* { + root * /var/www/acme + file_server + } + handle { + redir https://{host}{uri} permanent + } +} diff --git a/host/caddy/README.md b/host/caddy/README.md new file mode 100644 index 0000000..92e5219 --- /dev/null +++ b/host/caddy/README.md @@ -0,0 +1,19 @@ +# Host Caddy + +Live: `/etc/caddy/Caddyfile` on koopa (root/`caddy` group). +Canonical mirror in this repo: **`configs/caddy/Caddyfile`** (same content as `host/caddy/Caddyfile`). + +| Listen | Role | +|--------|------| +| **9000** | HTTP + ACME webroot + HTTPS redirect | +| **9001** | HTTPS vhosts | + +Public sites: `taler` / `exchange` / `bank` / `castopod` / `bonfire` / `prime` / `bt` / **`git`** / **`paivana`** / tops.ng*. +Forgejo HTTP: Caddy → `127.0.0.1:9024`. Git SSH is **host :9200** (not Caddy). + +Validate/reload on host: + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` diff --git a/host/firewalld/README.md b/host/firewalld/README.md new file mode 100644 index 0000000..26c4db3 --- /dev/null +++ b/host/firewalld/README.md @@ -0,0 +1,18 @@ +# firewalld (zone `public`, interface `eno1`) + +## Open ports (current) + +| Port | Purpose | +|------|---------| +| 80/tcp | systemd → Caddy HTTP 9000 | +| 9000/tcp | Caddy HTTP (VeciGate WAN:80) | +| 9001/tcp | Caddy HTTPS (VeciGate WAN:443) | +| 8080/tcp | Tor OR | +| 23235/tcp | (legacy list; SSH is service `ssh` on 22) | +| services | `ssh`, `dhcpv6-client` | + +## Removed + +- 443/tcp, 8082/tcp — obsolete after 9000-series DNAT + +Live dump: `list-all.txt`. diff --git a/host/firewalld/list-all.txt b/host/firewalld/list-all.txt new file mode 100644 index 0000000..68bc660 --- /dev/null +++ b/host/firewalld/list-all.txt @@ -0,0 +1,19 @@ +public (default, active) + target: default + ingress-priority: 0 + egress-priority: 0 + icmp-block-inversion: no + interfaces: eno1 + sources: + services: dhcpv6-client ssh + ports: 80/tcp 443/tcp 8080/tcp 9000/tcp 9001/tcp 23235/tcp 9200/tcp + protocols: + forward: yes + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + +# Snapshot note (2026-07-10): 9200/tcp = Forgejo git-SSH (verified open). +# Re-export as root: firewall-cmd --list-all > host/firewalld/list-all.txt diff --git a/host/network/README.md b/host/network/README.md new file mode 100644 index 0000000..eeed5bc --- /dev/null +++ b/host/network/README.md @@ -0,0 +1,10 @@ +# Network + +| Interface | Address | Role | +|-----------|---------|------| +| eno1 | 192.168.100.95/24 | LAN (default route via VeciGate) | +| eno1 | 2a02:168:53a8::/64 (dynamic) | Global IPv6 (AAAA for taler/exchange) | +| lo | 127.0.0.1 | local | + +SSH: LAN `:22`, WAN via VeciGate `23235` → `:22`. +Tor ORPort: `:8080` (WAN DNAT on VeciGate). diff --git a/host/overview/LIVE.md b/host/overview/LIVE.md new file mode 100644 index 0000000..30f810d --- /dev/null +++ b/host/overview/LIVE.md @@ -0,0 +1,104 @@ +# Live inventory — koopa (2026-07-13) + +Snapshot of **what runs on host koopa** (openSUSE Tumbleweed). No secrets. + +Refresh command ideas: + +```bash +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' +``` + +## Host + +| Item | Value | +|------|--------| +| Hostname | `koopa` | +| Role | App host behind VeciGate (WAN :80→9000, :443→9001) | +| Edge | Caddy **9000/9001**, systemd socket proxies **80/443** | +| Tor | **podman `koopa-tor-relay`** (host net) ORPort **8080** dual-stack, ControlPort **127.0.0.1:9051** — not host `tor.service` | + +## Running podman containers + +| Name | Image | Ports (host) | Public site | +|------|--------|--------------|-------------| +| `taler-hacktivism` | `localhost/taler-hacktivism-live:landing` | **9010**, **9015** | `taler.hacktivism.ch` | +| **`taler-hacktivism-exchange-ansible`** | `localhost/taler-hacktivism-exchange-ansible:landing` | **9011**, **9014** | `exchange.hacktivism.ch` | +| `taler-hacktivism-bank` | **`localhost/taler-hacktivism-banking:live`** | **9012**, **9013** | `bank.hacktivism.ch` | +| `koopa-castopod` (+ mariadb, redis) | `castopod/castopod:1` | **9020** | `castopod.hacktivism.ch` | +| `koopa-bonfire` (+ postgres) | `bonfire:1.0.5-social-amd64` | **9021** | `bonfire.hacktivism.ch` | +| `koopa-prime-jellyfin` | `linuxserver/jellyfin:10.10.7` | **9022** | `prime.hacktivism.ch` | +| `koopa-prime-qbittorrent` | `linuxserver/qbittorrent:5.0.4` | **9023**, **6881** | `bt.hacktivism.ch` | +| `koopa-forgejo` (+ postgres) | `forgejo:11-rootless` | **9024**, **9200** | `git.hacktivism.ch` | +| **`koopa-tor-relay`** | `localhost/koopa-tor-relay:latest` | **8080**, **9051** (host net) | Tor OR (non-exit) | +| `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` | + +### Naming note (exchange) + +Live exchange container is **`taler-hacktivism-exchange-ansible`** (not `taler-hacktivism-exchange-ansible` / not without `-ansible`). +Managed from **`/home/hernani/ansible-taler-exchange/`** (see `configs/taler-exchange-ansible/`). + +## Caddy vhosts → backends + +| Host | Backend | +|------|---------| +| `taler.hacktivism.ch` | 9010 (API) + 9015 (`/intro`) | +| `exchange.hacktivism.ch` | 9011 + 9014 (`/intro`) | +| `bank.hacktivism.ch` | 9012 + 9013 (`/intro`, terms, privacy) | +| `castopod.hacktivism.ch` | 9020 | +| `bonfire.hacktivism.ch` | 9021 | +| `prime.hacktivism.ch` | 9022 | +| `bt.hacktivism.ch` | 9023 | +| `git.hacktivism.ch` | 9024 (HTTP); git-SSH **9200** host-direct | +| `tops.ng1.hacktivism.ch` | 9090 | +| `tops.ng2.hacktivism.ch` | 9091 | +| `tops.ng3.hacktivism.ch` | 9092 | + +Config: `/etc/caddy/Caddyfile` (mirror `configs/caddy/Caddyfile`). + +## Paths on host (`hernani`) + +| Path | Role | +|------|------| +| `~/ansible-taler-exchange/` | Ansible + scripts for exchange-ansible container | +| `~/koopa-castopod/` | Castopod compose | +| `~/koopa-bonfire/` | Bonfire compose + gitbot | +| `~/koopa-prime/` | Jellyfin + qBittorrent | +| `~/koopa-forgejo/` | Forgejo rootless | +| `~/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) | + +## Start models + +| Stack | How | +|-------|-----| +| Taler merchant/bank | root `start_base_services_*` → service user → `/usr/local/bin/start_*.sh` | +| Exchange (GOA) | `~/ansible-taler-exchange/run-container-koopa.sh` then `deploy-hacktivism-goa.sh` | +| User apps | `cd ~/koopa-* && podman-compose up -d` | +| tops (ng1–ng3) | user unit `container-koopa-tops.service` (linger) | + +## Config mirrors in this repo (`configs/`) + +| Live container | Git path | +|----------------|----------| +| `taler-hacktivism` | `configs/taler-hacktivism/` | +| `taler-hacktivism-bank` (image **…-banking**) | `configs/taler-hacktivism-bank/` | +| `taler-hacktivism-exchange-ansible` | `configs/taler-exchange-ansible/` + conf in `configs/taler-exchange/` | +| `koopa-tops-ng1` … `ng3` | `configs/tops/` | + +## Related docs in this repo + +| Topic | Doc | +|-------|-----| +| Ports | `configs/ports.md` | +| Diagram | `host/overview/services.md` | +| Exchange Ansible (koopa) | `configs/taler-exchange-ansible/` | +| Forgejo | `2026/2026-07-10--forgejo-rootless.md` | +| Bonfire public feeds | `configs/bonfire/public-feeds.md` | +| Castopod content | `2026/2026-07-09--castopod-content.md` | diff --git a/host/overview/services.md b/host/overview/services.md new file mode 100644 index 0000000..c725124 --- /dev/null +++ b/host/overview/services.md @@ -0,0 +1,100 @@ +# Service overview — koopa + +**Live container/port inventory:** [`LIVE.md`](LIVE.md) (state as observed on host). + +Diagram + +```mermaid +flowchart TB + subgraph Internet + Client[Client IPv4/IPv6] + end + + subgraph VeciGate["VeciGate (MikroTik)"] + DNAT80["DNAT :80 → koopa:9000"] + DNAT443["DNAT :443 → koopa:9001"] + DNATSSH["DNAT :23235 → koopa:22"] + DNATTOR["DNAT :8080 → koopa:8080"] + end + + subgraph Host["openSUSE host koopa"] + subgraph Edge["Edge on host"] + S80["systemd http-proxy.socket :80"] + S443["systemd https-proxy.socket :443"] + CaddyHTTP["Caddy :9000 HTTP\nACME + redirect"] + CaddyHTTPS["Caddy :9001 HTTPS"] + FW["firewalld public\n9000,9001,80,8080,ssh"] + end + + subgraph Podman["podman rootless (hernani)"] + Merch["taler-hacktivism\n:9010"] + Exch["taler-hacktivism-exchange-ansible\n:9011"] + Bank["taler-bank-hacktivism\n:9012"] + Castopod["koopa-castopod\n:9020"] + Bonfire["koopa-bonfire\n:9021"] + Prime["koopa-prime jellyfin\n:9022"] + BT["qbittorrent\n:9023"] + Forgejo["koopa-forgejo ROOTLESS\n:9024 HTTP / :9200 SSH"] + end + + Tor["tor ORPort :8080"] + SSH["sshd :22"] + end + + Client --> DNAT80 --> CaddyHTTP + Client --> DNAT443 --> CaddyHTTPS + Client --> DNATSSH --> SSH + Client --> DNATTOR --> Tor + + S80 --> CaddyHTTP + S443 --> CaddyHTTPS + + CaddyHTTP -->|"/.well-known"| ACME["/var/www/acme"] + CaddyHTTP -->|other| Redir[301 HTTPS] + CaddyHTTPS -->|taler.hacktivism.ch| Merch + CaddyHTTPS -->|exchange.hacktivism.ch| Exch + CaddyHTTPS -->|bank.hacktivism.ch| Bank + CaddyHTTPS -->|castopod.hacktivism.ch| Castopod + CaddyHTTPS -->|bonfire.hacktivism.ch| Bonfire + CaddyHTTPS -->|prime.hacktivism.ch| Prime + CaddyHTTPS -->|bt.hacktivism.ch| BT + CaddyHTTPS -->|git.hacktivism.ch| Forgejo +``` + +## Port table + +| Port | Listener | Backend / notes | +|------|----------|-----------------| +| 22 | sshd | LAN; WAN via 23235 | +| 80 | systemd socket | → 127.0.0.1:**9000** | +| 443 | systemd socket | → 127.0.0.1:**9001** | +| 9000 | caddy | HTTP + ACME | +| 9001 | caddy | HTTPS vhosts | +| 9010 | podman | **merchant** nginx | +| 9011 | podman | **exchange** httpd | +| 9012 | podman | **bank** libeufin | +| 9020 | podman | **castopod** | +| 9021 | podman | **bonfire** | +| 9022 | podman | **jellyfin** (prime) | +| 9023 | podman | **qbittorrent** (bt) | +| **9024** | podman | **forgejo** HTTP → `git.hacktivism.ch` | +| **9200** | podman | **forgejo** git-SSH (host-direct) | +| 9090–9092 | podman | **tops** `koopa-tops-ng1`…`ng3` | +| 8080 | tor | ORPort | + +Full port notes: `configs/ports.md`. + +## Service index (details elsewhere) + +| Service | Detail docs | +|---------|-------------| +| Caddy | `host/caddy/`, `configs/caddy/` | +| firewalld | `host/firewalld/` | +| systemd proxies | `host/systemd/` | +| Merchant container | `configs/taler-hacktivism/`, `scripts/taler-merchant/` | +| Exchange container | `configs/taler-exchange/`, `scripts/taler-exchange/` | +| **Forgejo rootless** | `configs/forgejo/`, `2026/2026-07-10--forgejo-rootless.md` | +| tops (`koopa-tops-ng*`) | `configs/tops/` | +| Tor relay (`koopa-tor-relay` podman) | `configs/tor/`, `host/tor/` | +| Tor monitoring scripts | `scripts/monitoring/` | +| VeciGate NAT | `../vecigate-admin-log/ip/firewall/nat/` | diff --git a/host/overview/services.txt b/host/overview/services.txt new file mode 100644 index 0000000..a37748b --- /dev/null +++ b/host/overview/services.txt @@ -0,0 +1,24 @@ + Internet + | + +-------------+-------------+ + | VeciGate | + | :80→9000 :443→9001 | + | :23235→22 :8080→8080 | + +-------------+-------------+ + | + koopa 192.168.100.95 + | + +----------------------+----------------------+ + | | | + systemd Caddy other + :80 → 9000 :9000 HTTP sshd :22 + :443 → 9001 :9001 HTTPS tor :8080 + | | + +----------+-----------+ + | + +-------+--------+ + | | + :9010 merchant :9011 exchange + (nginx TLS) (httpd TCP) + | | + unix sock postgres/secmods diff --git a/host/systemd/README.md b/host/systemd/README.md new file mode 100644 index 0000000..ddc73ee --- /dev/null +++ b/host/systemd/README.md @@ -0,0 +1,13 @@ +# systemd units (host) + +| Unit | Role | +|------|------| +| `caddy.service` | reverse proxy (drop-in: no `--resume`) | +| `http-proxy.socket` | bind **:80** → `systemd-socket-proxyd` → `127.0.0.1:9000` | +| `https-proxy.socket` | bind **:443** → `systemd-socket-proxyd` → `127.0.0.1:9001` | +| `https-proxy@.service` | template: `systemd-socket-proxyd 127.0.0.1:%i` | +| `firewalld.service` | host firewall | +| `sshd.service` | SSH | +| `tor.service` | Tor relay (ORPort 8080) | + +Privileged ports 80/443 are held by **systemd**, not by Caddy (Caddy listens on 9000/9001 as user `caddy`). diff --git a/host/systemd/caddy.service.d-no-resume.conf b/host/systemd/caddy.service.d-no-resume.conf new file mode 100644 index 0000000..8a32924 --- /dev/null +++ b/host/systemd/caddy.service.d-no-resume.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile diff --git a/host/systemd/http-proxy.socket b/host/systemd/http-proxy.socket new file mode 100644 index 0000000..1dda110 --- /dev/null +++ b/host/systemd/http-proxy.socket @@ -0,0 +1,11 @@ +[Unit] +Description=Forward incoming HTTP :80 to Caddy on 127.0.0.1:9000 + +[Socket] +ListenStream=0.0.0.0:80 +ListenStream=[::]:80 +BindIPv6Only=yes +Service=https-proxy@9000.service + +[Install] +WantedBy=sockets.target diff --git a/host/systemd/https-proxy.socket b/host/systemd/https-proxy.socket new file mode 100644 index 0000000..bedf449 --- /dev/null +++ b/host/systemd/https-proxy.socket @@ -0,0 +1,11 @@ +[Unit] +Description=Forward incoming HTTPS :443 to Caddy on 127.0.0.1:9001 + +[Socket] +ListenStream=0.0.0.0:443 +ListenStream=[::]:443 +BindIPv6Only=yes +Service=https-proxy@9001.service + +[Install] +WantedBy=sockets.target diff --git a/host/systemd/https-proxy@.service b/host/systemd/https-proxy@.service new file mode 100644 index 0000000..a61c775 --- /dev/null +++ b/host/systemd/https-proxy@.service @@ -0,0 +1,5 @@ +[Unit] +Description=Proxy for incoming HTTPS traffic to port %i + +[Service] +ExecStart=/usr/lib/systemd/systemd-socket-proxyd 127.0.0.1:%i diff --git a/host/tor/README.md b/host/tor/README.md new file mode 100644 index 0000000..9bf2e15 --- /dev/null +++ b/host/tor/README.md @@ -0,0 +1,27 @@ +# 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`). + +| Port | Bind | Role | +|------|------|------| +| **8080** | `0.0.0.0` + `[::]` | ORPort (dual-stack) | +| **9051** | `127.0.0.1` | ControlPort (nyx / stem) | + +Non-exit relay (`ExitRelay 0`, `SocksPort 0`). + +### Root checklist + +```bash +sudo systemctl disable --now tor +sudo systemctl mask tor +# linger already required for rootless user units: +# sudo loginctl enable-linger hernani +``` + +### User unit + +```bash +systemctl --user enable --now container-koopa-tor-relay.service +``` diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 0000000..c38a4de --- /dev/null +++ b/ops/README.md @@ -0,0 +1,5 @@ +# ops/ + +Host hygiene and short pointers. **Dated day/topic logs live under `../2026/`.** + +- `ROOT_HYGIENE.md` — secrets layout (values in `koopa-admin-secrets`) diff --git a/ops/ROOT_HYGIENE.md b/ops/ROOT_HYGIENE.md new file mode 100644 index 0000000..7a8c749 --- /dev/null +++ b/ops/ROOT_HYGIENE.md @@ -0,0 +1,11 @@ +# Secrets layout + +``` +../koopa-admin-secrets/ + koopa/host-root/taler-bank/… → /root/bank-*-password.txt + koopa/host-root/taler-merchant/… → /root/merchant-*-password.txt + containers//secrets/ → files inside podman containers +``` + +Deploy/pull: `koopa-admin-secrets/scripts/deploy-to-koopa.sh` / `pull-from-koopa.sh`. +No password **values** in `koopa-admin-log`. diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..34ef2bc --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,47 @@ +# Scripts + +| Dir | Source on koopa | +|-----|-----------------| +| `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-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) | +| `taler-wallet-cli/` | thin wrappers; **benchmarks live in** `../benchmarks/` | +| `castopod/` | host `hernani` podman-compose `~/koopa-castopod` — see `castopod/README.md` | + +**Secrets:** never in this tree — sibling **`../koopa-admin-secrets`** (`koopa/host-root//` ↔ `/root/` on host; `containers/…/secrets/` for in-container). + +## Manual start model (all three) + +1. **root** runs `/root/start_base_services_for_taler_*.sh` + → Debian-style postgres perms + start (`pg_ctlcluster` / `init.d`) + → (+ exchange: secmods/helpers; merchant: nginx) + → interactive shell as service user + → automation: add **`--no-shell`** then run step 2 via `runuser` +2. **service user** runs `/usr/local/bin/start_*.sh` [ `--restart` ] + → application process only + +Postgres ownership (Debian defaults, if cluster was root-owned after bad ops): + +```text +chown -R root:postgres /etc/postgresql +chmod confs 640 / dirs 755 +chown -R postgres:postgres /var/lib/postgresql /var/log/postgresql /var/run/postgresql +# only remove postmaster.pid / socket locks when pg_isready fails and no live postgres +pg_ctlcluster 17 main start +``` + +| Container | Root base | App start | App user | +|-----------|-----------|-----------|----------| +| `taler-hacktivism` | `start_base_services_for_taler.sh` | `start_merchant.sh` | `taler-merchant-httpd` | +| `taler-hacktivism-exchange-ansible` | `start_base_services_for_taler_exchange.sh` | `start_exchange.sh` | `taler-exchange-httpd` | + +Exchange one-shots (root, offline / wire): +`taler-exchange/wire-enable-and-upload.sh`, `offline-sign-upload-keys.sh`, `start_wire_helpers.sh` +| `taler-hacktivism-bank` | `start_base_services_for_taler_bank.sh` | `start_bank.sh` | `libeufin-bank` | + +`runuser -u USER -- bash` (never `-u` with `-s` on util-linux). + +SMS helper symlinks into `/var/taler-src/...` are not copied (merchant only). diff --git a/scripts/bonfire/README.md b/scripts/bonfire/README.md new file mode 100644 index 0000000..c68baff --- /dev/null +++ b/scripts/bonfire/README.md @@ -0,0 +1,13 @@ +# Bonfire scripts + +Live: `/home/hernani/koopa-bonfire/`. +Docs: `configs/bonfire/README.md`, [Bonfire docs](https://docs.bonfirenetworks.org/). + +| Script | Role | +|--------|------| +| `gitbot-mirror.py` | Poll git.ngi-0.eu, post via GraphQL, republish feeds | +| `install-systemd.sh` | Boot + gitbot units | +| `apply-branding.sh` | Logo + hacktivism theme (`bonfire remote`) | +| `publish-outbox-to-public.sql` | One-off outbox → public feeds | + +No official gitbot or feed-republish guides — custom ops scripts. \ No newline at end of file diff --git a/scripts/bonfire/apply-branding.exs b/scripts/bonfire/apply-branding.exs new file mode 100644 index 0000000..ec4278e --- /dev/null +++ b/scripts/bonfire/apply-branding.exs @@ -0,0 +1,56 @@ +# Apply hacktivism branding to Bonfire instance settings. +# Run on koopa: printf '%s\n' "$(cat apply-branding.exs)" | podman exec -i koopa-bonfire /opt/app/bin/bonfire remote +# +# Instance settings API: https://docs.bonfirenetworks.org/settings_system.html + +palette = %{ + "color-base-100" => "#1a1410", + "color-base-200" => "#221c16", + "color-base-300" => "#2a2018", + "color-base-content" => "#fff6e8", + "color-primary" => "#e8a838", + "color-primary-content" => "#1a1410", + "color-secondary" => "#3d3128", + "color-secondary-content" => "#fff6e8", + "color-accent" => "#3ecfbf", + "color-accent-content" => "#0e1c1e", + "color-neutral" => "#14110e", + "color-neutral-content" => "#ebe0d0", + "color-info" => "#2563eb", + "color-info-content" => "#ffffff", + "color-success" => "#16a34a", + "color-success-content" => "#ffffff", + "color-warning" => "#f0d090", + "color-warning-content" => "#1a1410", + "color-error" => "#b91c1c", + "color-error-content" => "#ffffff", + "radius-box" => "0.875rem", + "radius-field" => "0.5rem", + "radius-selector" => "0.75rem" +} + +opts = [skip_boundary_check: true, scope: :instance] + +settings = %{ + ui: %{ + theme: %{ + instance_name: "hacktivism bonfire", + instance_tagline: "hacktivism magician - federated FOSS timeline", + instance_description: + "Bonfire on hacktivism.ch — local FOSS posts, gitbot commits, and federation.", + instance_icon: "/images/hacktivism-logo.svg", + instance_theme: "dark", + instance_theme_light: "light", + preferred: :custom, + custom_instance: palette + } + } +} + +case Bonfire.Common.Settings.set(settings, opts) do + {:ok, _} -> + IO.puts("ok branding settings applied") + + other -> + IO.inspect(other, label: "branding settings failed") +end \ No newline at end of file diff --git a/scripts/bonfire/apply-branding.sh b/scripts/bonfire/apply-branding.sh new file mode 100755 index 0000000..d16d769 --- /dev/null +++ b/scripts/bonfire/apply-branding.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Deploy hacktivism Bonfire branding (hacktivism magician logo + exchange-dark palette). +# Live root: ~/koopa-bonfire/ +set -euo pipefail + +ROOT="${KOOPA_BONFIRE_ROOT:-$HOME/koopa-bonfire}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." 2>/dev/null && pwd || true)" +BRANDING_DST="$ROOT/data/branding" +ENV_FILE="$ROOT/.env" + +install -d -m 755 "$BRANDING_DST" + +if [[ -d "$REPO_ROOT/configs/bonfire/assets/img" ]]; then + BRANDING_SRC="$REPO_ROOT/configs/bonfire/assets/img" + install -m 644 "$BRANDING_SRC/logo.svg" "$BRANDING_DST/logo.svg" + install -m 644 "$BRANDING_SRC/favicon.svg" "$BRANDING_DST/favicon.svg" + install -m 644 "$BRANDING_SRC/logo.png" "$BRANDING_DST/logo.png" +elif [[ ! -f "$BRANDING_DST/logo.svg" ]]; then + echo "missing $BRANDING_DST/logo.svg (copy assets or run from koopa-admin-log checkout)" >&2 + exit 1 +fi + +# APP_NAME drives suffix and og:site_name when instance_name is unset in HTML meta. +if [[ -f "$ENV_FILE" ]]; then + if grep -q '^APP_NAME=' "$ENV_FILE"; then + sed -i 's/^APP_NAME=.*/APP_NAME=hacktivism bonfire/' "$ENV_FILE" + else + echo 'APP_NAME=hacktivism bonfire' >> "$ENV_FILE" + fi + if grep -q '^INSTANCE_DESCRIPTION=' "$ENV_FILE"; then + sed -i 's/^INSTANCE_DESCRIPTION=.*/INSTANCE_DESCRIPTION=Bonfire on hacktivism.ch - FOSS posts and gitbot commits./' "$ENV_FILE" + else + echo 'INSTANCE_DESCRIPTION=Bonfire on hacktivism.ch - FOSS posts and gitbot commits.' >> "$ENV_FILE" + fi +fi + +if [[ "$SCRIPT_DIR/apply-branding.exs" != "$ROOT/bin/apply-branding.exs" ]]; then + install -m 755 "$SCRIPT_DIR/apply-branding.exs" "$ROOT/bin/apply-branding.exs" +fi + +echo "Applying instance theme settings via bonfire remote..." +sleep 2 +printf '%s\n' "$(cat "$ROOT/bin/apply-branding.exs")" | podman exec -i koopa-bonfire /opt/app/bin/bonfire remote >/tmp/bonfire-branding.log 2>&1 || { + echo "remote apply failed; see /tmp/bonfire-branding.log" >&2 + tail -30 /tmp/bonfire-branding.log >&2 + exit 1 +} +grep -q 'ok branding settings applied' /tmp/bonfire-branding.log + +echo "Recreating web container (compose branding volume)..." +cd "$ROOT" +podman-compose up -d --no-deps --force-recreate web + +echo "Done. Check https://bonfire.hacktivism.ch/ (hard-reload if cached)." \ No newline at end of file diff --git a/scripts/bonfire/gitbot-mirror.py b/scripts/bonfire/gitbot-mirror.py new file mode 100755 index 0000000..80722b8 --- /dev/null +++ b/scripts/bonfire/gitbot-mirror.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Poll git.ngi-0.eu and post new commits to Bonfire as gitbot.""" +from __future__ import annotations +import json, os, pathlib, re, subprocess, sys, time, urllib.error, urllib.request + +GQL = os.environ.get("BONFIRE_GQL", "http://127.0.0.1:9021/api/graphql") +GIT = os.environ.get("GIT_BASE", "https://git.ngi-0.eu") +STATE = pathlib.Path(os.environ.get("GITBOT_STATE", "/home/hernani/koopa-bonfire/gitbot-state.json")) +USERS = pathlib.Path(os.environ.get("BONFIRE_USERS_ENV", "/home/hernani/koopa-bonfire/users.env")) +INTERVAL = int(os.environ.get("GITBOT_INTERVAL", "300")) +MAX_FAILS = int(os.environ.get("GITBOT_MAX_FAILS", "3")) +MAX_TIME = 45 +OUTBOX_FEED = os.environ.get( + "GITBOT_OUTBOX_FEED", "019f487a-df20-4ed0-a334-40ec3eac23e7" +) +FEED_INTERNET = os.environ.get( + "GITBOT_FEED_INTERNET", "0aab414c-eb0a-ac1d-8c81-ef0d74ec55da" +) +FEED_LOCAL = os.environ.get( + "GITBOT_FEED_LOCAL", "797632fc-029e-06f0-1031-410d73a5558e" +) +PUBLISH_CMD = os.environ.get( + "GITBOT_PUBLISH_CMD", + "podman exec -i koopa-bonfire-db psql -U postgres -d bonfire_db", +) + +_BARE_HOST = re.compile( + r"(?<!https://)(?<!http://)" + r"(?<![\w./@-])" + r"((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+(?:[a-zA-Z]{2,}))" + r"(?![\w./:])" +) + +MUT = """ +mutation CreatePost($pc: PostContentInput!) { + createPost(postContent: $pc) { id } +} +""" + +def sanitize_text(text: str) -> str: + """Add https:// to bare hostnames (Bonfire URI.parse breaks without it).""" + return _BARE_HOST.sub(r"https://\1", text) + +def load_users(): + d = {} + for line in USERS.read_text().splitlines(): + if "=" in line and not line.strip().startswith("#"): + k, v = line.split("=", 1) + d[k.strip()] = v.strip() + return d + +def http_json(url, data=None, headers=None, timeout=MAX_TIME): + req = urllib.request.Request( + url, + data=None if data is None else json.dumps(data).encode(), + headers=headers or {}, + method="GET" if data is None else "POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.load(r) + +def gql(token, query, variables=None): + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + body = {"query": query} + if variables is not None: + body["variables"] = variables + return http_json(GQL, body, headers) + +def bonfire_ready() -> bool: + base = GQL.rsplit("/api/", 1)[0] + "/" + try: + with urllib.request.urlopen(base, timeout=10) as r: + return 200 <= r.status < 500 + except (urllib.error.URLError, TimeoutError, OSError): + return False + +def login(email_or_user, password): + res = gql( + None, + "mutation($e:String!,$p:String!){ login(emailOrUsername:$e, password:$p){ token currentUsername } }", + {"e": email_or_user, "p": password}, + ) + tok = (res.get("data") or {}).get("login") or {} + if not tok.get("token"): + raise RuntimeError(f"login failed: {res}") + return tok["token"] + +def select_user(token, username): + res = gql( + token, + "mutation($u:String!){ selectUser(username:$u){ token currentUsername } }", + {"u": username}, + ) + t = (res.get("data") or {}).get("selectUser") or {} + return t.get("token") or token + +def create_post(token, body, summary, name): + res = gql( + token, + MUT, + {"pc": {"htmlBody": body, "summary": summary[:140], "name": name}}, + ) + post = (res.get("data") or {}).get("createPost") + if not post: + raise RuntimeError(f"createPost failed: {res}") + return post["id"] + +def post_commit(token, full, c): + sha = c["sha"] + short = sha[:8] + raw_msg = c["commit"]["message"].split("\n")[0][:140] + msg = sanitize_text(raw_msg) + html = c.get("html_url") or f"{GIT}/{full}/commit/{sha}" + author = (c.get("commit") or {}).get("author", {}).get("name") or "?" + full_body = ( + f"[gitbot] commit {short} on {full}\n" + f"{msg}\nby {author}\n{html}\n#git #ngi0 #foss" + ) + summary = sanitize_text(f"{full}@{short}: {msg}")[:140] + try: + return create_post(token, full_body, summary, f"commit {short}") + except RuntimeError: + minimal_body = f"[gitbot] commit {short} on {full}\n{html}\n#git #ngi0 #foss" + return create_post(token, minimal_body, f"{full}@{short}", f"commit {short}") + +def load_state(): + state = {"seen": [], "fails": {}} + if STATE.exists(): + state = json.loads(STATE.read_text()) + state.setdefault("seen", []) + state.setdefault("fails", {}) + return state + +def save_state(state, seen): + state["seen"] = list(seen)[-800:] + STATE.write_text(json.dumps(state, indent=2)) + +def publish_outbox_to_public() -> bool: + """Copy foss outbox into local/internet feeds (ops workaround; no official republish API).""" + sql = f""" +INSERT INTO bonfire_data_social_feed_publish (id, feed_id) +SELECT fp.id, '{FEED_INTERNET}'::uuid +FROM bonfire_data_social_feed_publish fp +WHERE fp.feed_id = '{OUTBOX_FEED}'::uuid +ON CONFLICT DO NOTHING; +INSERT INTO bonfire_data_social_feed_publish (id, feed_id) +SELECT fp.id, '{FEED_LOCAL}'::uuid +FROM bonfire_data_social_feed_publish fp +WHERE fp.feed_id = '{OUTBOX_FEED}'::uuid +ON CONFLICT DO NOTHING; +""" + try: + proc = subprocess.run( + PUBLISH_CMD.split(), + input=sql, + text=True, + capture_output=True, + timeout=30, + check=False, + ) + if proc.returncode != 0: + print("publish feeds failed:", proc.stderr.strip(), file=sys.stderr) + return False + print("published outbox -> local/internet feeds", flush=True) + return True + except (OSError, subprocess.TimeoutExpired) as e: + print("publish feeds error:", e, file=sys.stderr) + return False + +def once(): + if not bonfire_ready(): + print("bonfire not ready, skipping cycle", flush=True) + return 0 + + u = load_users() + tok = login(u.get("FOSS_USER", "foss"), u["FOSS_PW"]) + try: + tok = select_user(tok, u.get("GITBOT_USER", "gitbot")) + except Exception as e: + print("select gitbot skipped:", e, file=sys.stderr) + + state = load_state() + seen = set(state["seen"]) + fails = state["fails"] + skipped = set(state.get("skipped") or []) + + repos = http_json(f"{GIT}/api/v1/repos/search?limit=30&sort=updated")["data"] + new_n = 0 + for repo in repos: + full = repo["full_name"] + owner, name = full.split("/", 1) + try: + commits = http_json(f"{GIT}/api/v1/repos/{owner}/{name}/commits?limit=10") + except Exception as e: + print("repo fail", full, e, file=sys.stderr) + continue + for c in reversed(commits): + sha = c["sha"] + if sha in seen or sha in skipped: + continue + try: + pid = post_commit(tok, full, c) + print(f"posted {full}@{sha[:8]} -> {pid}", flush=True) + seen.add(sha) + fails.pop(sha, None) + new_n += 1 + save_state(state, seen) + time.sleep(0.4) + except Exception as e: + n = fails.get(sha, 0) + 1 + fails[sha] = n + state["fails"] = fails + save_state(state, seen) + print("post fail", full, sha[:8], f"try={n}", e, file=sys.stderr) + if n >= MAX_FAILS: + skipped.add(sha) + state["skipped"] = list(skipped)[-200:] + save_state(state, seen) + print("skip", full, sha[:8], "after repeated failures", file=sys.stderr) + publish_outbox_to_public() + print(f"done new={new_n} tracked={len(seen)} skipped={len(skipped)}", flush=True) + return new_n + +def main(): + loop = "--loop" in sys.argv + if loop: + print(f"gitbot loop interval={INTERVAL}s gql={GQL}", flush=True) + while True: + try: + once() + except Exception as e: + print("cycle error:", e, file=sys.stderr, flush=True) + time.sleep(INTERVAL) + else: + once() + +if __name__ == "__main__": + main() diff --git a/scripts/bonfire/install-systemd.sh b/scripts/bonfire/install-systemd.sh new file mode 100755 index 0000000..b8ac043 --- /dev/null +++ b/scripts/bonfire/install-systemd.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Install Bonfire + gitbot user systemd units on koopa. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +UNIT_DIR="${HOME}/.config/systemd/user" +BIN_DIR="${HOME}/koopa-bonfire/bin" + +mkdir -p "${UNIT_DIR}" "${BIN_DIR}" +install -m 755 "${ROOT}/scripts/bonfire/gitbot-mirror.py" "${BIN_DIR}/gitbot-mirror.py" +cp "${ROOT}/configs/bonfire/container-koopa-bonfire-db.service" "${UNIT_DIR}/" +cp "${ROOT}/configs/bonfire/container-koopa-bonfire.service" "${UNIT_DIR}/" +cp "${ROOT}/configs/bonfire/gitbot-mirror.service" "${UNIT_DIR}/" + +systemctl --user daemon-reload +systemctl --user enable container-koopa-bonfire-db.service container-koopa-bonfire.service gitbot-mirror.service + +if [[ -f "${HOME}/koopa-bonfire/gitbot.pid" ]]; then + old_pid="$(cat "${HOME}/koopa-bonfire/gitbot.pid" 2>/dev/null || true)" + [[ -n "${old_pid}" ]] && kill "${old_pid}" 2>/dev/null || true + rm -f "${HOME}/koopa-bonfire/gitbot.pid" +fi + +if ! podman ps --format '{{.Names}}' | grep -qx koopa-bonfire; then + (cd "${HOME}/koopa-bonfire" && podman-compose up -d) +fi +systemctl --user start container-koopa-bonfire-db.service container-koopa-bonfire.service +systemctl --user restart gitbot-mirror.service + +systemctl --user --no-pager status container-koopa-bonfire-db.service container-koopa-bonfire.service gitbot-mirror.service diff --git a/scripts/bonfire/publish-outbox-to-public.sql b/scripts/bonfire/publish-outbox-to-public.sql new file mode 100644 index 0000000..158bf55 --- /dev/null +++ b/scripts/bonfire/publish-outbox-to-public.sql @@ -0,0 +1,28 @@ +-- Republish outbox activities into guest-visible feeds (ops workaround). +-- FeedPublish model: https://docs.bonfirenetworks.org/feed_structure.html +-- No official republish procedure. Default outbox = foss. +-- Run: +-- podman exec -i koopa-bonfire-db psql -U postgres -d bonfire_db < publish-outbox-to-public.sql + +\set OUTBOX_FEED '019f487a-df20-4ed0-a334-40ec3eac23e7' +\set FEED_INTERNET '0aab414c-eb0a-ac1d-8c81-ef0d74ec55da' +\set FEED_LOCAL_USERS '797632fc-029e-06f0-1031-410d73a5558e' + +INSERT INTO bonfire_data_social_feed_publish (id, feed_id) +SELECT fp.id, :'FEED_INTERNET'::uuid +FROM bonfire_data_social_feed_publish fp +WHERE fp.feed_id = :'OUTBOX_FEED'::uuid +ON CONFLICT DO NOTHING; + +INSERT INTO bonfire_data_social_feed_publish (id, feed_id) +SELECT fp.id, :'FEED_LOCAL_USERS'::uuid +FROM bonfire_data_social_feed_publish fp +WHERE fp.feed_id = :'OUTBOX_FEED'::uuid +ON CONFLICT DO NOTHING; + +SELECT n.name, count(*) +FROM bonfire_data_social_feed_publish fp +LEFT JOIN bonfire_data_social_named n ON n.id = fp.feed_id +GROUP BY 1 +ORDER BY 2 DESC +LIMIT 15; diff --git a/scripts/caddy/README.md b/scripts/caddy/README.md new file mode 100644 index 0000000..8b022fd --- /dev/null +++ b/scripts/caddy/README.md @@ -0,0 +1,15 @@ +# Caddy on koopa + +Live: **`/etc/caddy/Caddyfile`**. +Mirror: **`configs/caddy/Caddyfile`** (same as `host/caddy/Caddyfile`). + +## Reload after edit + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` + +## New HTTPS vhost + +Add a site block to the Caddyfile (live + mirror), then validate and reload. Include the host in the shared HTTP site list (ACME + redirect) at the bottom of the file. diff --git a/scripts/castopod/README.md b/scripts/castopod/README.md new file mode 100644 index 0000000..b87d6c7 --- /dev/null +++ b/scripts/castopod/README.md @@ -0,0 +1,33 @@ +# Castopod ops scripts (koopa) + +Live stack: `/home/hernani/koopa-castopod/` (podman-compose). +These scripts live in admin-log and should be **copied** to the host when changed. + + +## Usage (on koopa as `hernani`) + +```bash +# copy once +mkdir -p ~/koopa-castopod/bin +cp /path/to/koopa-admin-log/scripts/castopod/{lib.sh,status.sh,up.sh,install-systemd.sh,apply-branding.sh} \ + ~/koopa-castopod/bin/ +chmod +x ~/koopa-castopod/bin/*.sh + +~/koopa-castopod/bin/status.sh +~/koopa-castopod/bin/up.sh +~/koopa-castopod/bin/install-systemd.sh # boot units +~/koopa-castopod/bin/apply-branding.sh # logo + theme + optional overlay +``` + +Env overrides: `CP_MAX_TIME`, `CP_PULL_TIMEOUT`, `CP_HEALTH_TRIES`, `CP_BASEURL`, +`CP_SITE_NAME`, `CP_SITE_DESCRIPTION`, `CP_THEME` (default `amber`), `CP_APPLY_OVERLAY` (default `1`). + +Passwords stay in `~/koopa-castopod/.env` and `users.env` (mode 600) — never in admin-log. + +## Official branding docs + +Instance settings (name, description, site icon, six accent themes): + +https://docs.castopod.org/main/en/user-guide/instance/settings/ + +See also `configs/castopod/README.md` and `2026/2026-07-13--castopod-boot-branding.md`. diff --git a/scripts/castopod/apply-branding.sh b/scripts/castopod/apply-branding.sh new file mode 100755 index 0000000..e630e1c --- /dev/null +++ b/scripts/castopod/apply-branding.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Apply hacktivism branding to Castopod (official settings + optional CSS overlay). +# +# Official docs (instance settings — site name/description/icon + 6 accent themes): +# https://docs.castopod.org/main/en/user-guide/instance/settings/ +# Docker/compose background: +# https://docs.castopod.org/main/en/getting-started/docker/ +# +# Logo: same magician mark as git.hacktivism.ch / bonfire.hacktivism.ch +# (configs/castopod/assets/img/logo.png — ≥512×512 required by Castopod). +# Official accent closest to gold: "amber" (among pine/crimson/lake/amber/jacaranda/onyx). +# Optional warm-dark CSS: NOT official — see configs/castopod/assets/css/theme-hacktivism-overlay.css +set -euo pipefail + +ROOT="${KOOPA_CASTOPOD_ROOT:-$HOME/koopa-castopod}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." 2>/dev/null && pwd || true)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +: "${CP_BASEURL:=https://castopod.hacktivism.ch}" +: "${CP_ADMIN_GATEWAY:=cp-admin}" +: "${CP_SITE_NAME:=hacktivism castopod}" +: "${CP_SITE_DESCRIPTION:=Castopod on hacktivism.ch — FOSS Airwaves and free-culture podcasting.}" +: "${CP_THEME:=amber}" +: "${CP_APPLY_OVERLAY:=1}" + +BRANDING_DST="${ROOT}/branding" +ICON_SRC="" +CSS_SRC="" + +if [[ -d "${REPO_ROOT}/configs/castopod/assets/img" ]]; then + ICON_SRC="${REPO_ROOT}/configs/castopod/assets/img/logo.png" + CSS_SRC="${REPO_ROOT}/configs/castopod/assets/css/theme-hacktivism-overlay.css" +elif [[ -f "${ROOT}/branding/logo.png" ]]; then + ICON_SRC="${ROOT}/branding/logo.png" + CSS_SRC="${ROOT}/branding/theme-hacktivism-overlay.css" +else + echo "missing logo.png (run from koopa-admin-log checkout or copy assets to ${ROOT}/branding/)" >&2 + exit 1 +fi + +install -d -m 755 "${BRANDING_DST}" +if [[ "$(realpath "${ICON_SRC}" 2>/dev/null || echo "${ICON_SRC}")" != "$(realpath "${BRANDING_DST}/logo.png" 2>/dev/null || echo "${BRANDING_DST}/logo.png")" ]]; then + install -m 644 "${ICON_SRC}" "${BRANDING_DST}/logo.png" +fi +if [[ -n "${CSS_SRC}" && -f "${CSS_SRC}" ]]; then + if [[ "$(realpath "${CSS_SRC}" 2>/dev/null || echo "${CSS_SRC}")" != "$(realpath "${BRANDING_DST}/theme-hacktivism-overlay.css" 2>/dev/null || true)" ]]; then + install -m 644 "${CSS_SRC}" "${BRANDING_DST}/theme-hacktivism-overlay.css" + fi +fi + +# Mount overlay into public assets if compose branding volume is present +if [[ -d "${ROOT}/branding" ]]; then + # Ensure host path for compose bind exists before recreate + true +fi + +COOKIE=$(mktemp /tmp/cp-brand-cj.XXXXXX) +trap 'rm -f "$COOKIE"' EXIT + +echo "== admin login ==" +cp_admin_login "$COOKIE" + +csrf_from() { + local url=$1 + local html + html=$(cp_curl -c "$COOKIE" -b "$COOKIE" "$url") + # multiple possible CSRF field names + printf '%s' "$html" | sed -n 's/.*name="csrf_test_name" value="\([^"]*\)".*/\1/p' | head -1 +} + +echo "== official: general settings (site name/description/icon) ==" +# UI/docs: https://docs.castopod.org/main/en/user-guide/instance/settings/ +# POST route: /{admin}/settings/instance (SettingsController::attemptInstanceEdit) +GEN_GET="${CP_BASEURL}/${CP_ADMIN_GATEWAY}/settings" +GEN_POST="${CP_BASEURL}/${CP_ADMIN_GATEWAY}/settings/instance" +csrf=$(csrf_from "$GEN_GET") +[[ -n "$csrf" ]] || { echo "ERROR: no CSRF on settings general" >&2; exit 1; } + +code=$(cp_curl -c "$COOKIE" -b "$COOKIE" -o /tmp/cp-gen.out -w '%{http_code}' \ + -X POST "${GEN_POST}" \ + -F "csrf_test_name=${csrf}" \ + -F "site_name=${CP_SITE_NAME}" \ + -F "site_description=${CP_SITE_DESCRIPTION}" \ + -F "site_icon=@${BRANDING_DST}/logo.png;type=image/png") +echo "general POST → HTTP $code" +# accept redirect +if [[ "$code" != "303" && "$code" != "302" && "$code" != "200" ]]; then + echo "WARN: unexpected status; body:" >&2 + head -c 400 /tmp/cp-gen.out >&2 || true +fi + +echo "== official: theme accent (${CP_THEME}) ==" +THEME_URL="${CP_BASEURL}/${CP_ADMIN_GATEWAY}/settings/theme" +csrf=$(csrf_from "$THEME_URL") +[[ -n "$csrf" ]] || { echo "ERROR: no CSRF on settings theme" >&2; exit 1; } + +code=$(cp_curl -c "$COOKIE" -b "$COOKIE" -o /tmp/cp-theme.out -w '%{http_code}' \ + -X POST "${THEME_URL}" \ + --data-urlencode "csrf_test_name=${csrf}" \ + --data-urlencode "theme=${CP_THEME}") +echo "theme POST → HTTP $code" + +if [[ "${CP_APPLY_OVERLAY}" == "1" && -f "${BRANDING_DST}/theme-hacktivism-overlay.css" ]]; then + echo "== optional: warm-dark CSS overlay (not official) ==" + # Serve from container public path via bind (compose) or copy into media volume + podman cp "${BRANDING_DST}/theme-hacktivism-overlay.css" \ + koopa-castopod:/app/public/assets/hacktivism-overlay.css 2>/dev/null || true + + # Idempotent inject before </head> in public layouts (re-run after image upgrade) + # Themes are root-owned in the image — patch as root (re-run after image upgrade). + # shellcheck disable=SC2016 + podman exec -u 0 koopa-castopod sh -c ' + LINK="<!-- hacktivism-overlay --><link rel=\"stylesheet\" href=\"/assets/hacktivism-overlay.css\">" + for f in /app/themes/cp_app/home.php \ + /app/themes/cp_app/podcast/_layout.php \ + /app/themes/cp_app/episode/_layout.php \ + /app/themes/cp_app/pages/_layout.php \ + /app/themes/cp_app/embed.php; do + [ -f "$f" ] || continue + if grep -q "hacktivism-overlay" "$f"; then + echo "already: $f" + continue + fi + awk -v link="$LINK" "{ if (\$0 ~ /<\\/head>/ && !done) { print link; done=1 } print }" "$f" > "$f.tmp" \ + && mv "$f.tmp" "$f" \ + && echo "patched: $f" + done + ' || echo "WARN: layout patch skipped (container missing?)" +fi + +# Clear page cache so theme/name show up +podman exec koopa-castopod sh -c 'rm -rf /app/writable/cache/* 2>/dev/null || true' || true + +echo "Done. Hard-reload https://castopod.hacktivism.ch/" +echo "Official UI also: ${CP_BASEURL}/${CP_ADMIN_GATEWAY}/settings (and /settings/theme)" diff --git a/scripts/castopod/install-systemd.sh b/scripts/castopod/install-systemd.sh new file mode 100755 index 0000000..9aed4e9 --- /dev/null +++ b/scripts/castopod/install-systemd.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Install Castopod user systemd units on koopa (boot via linger, like Bonfire). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +UNIT_DIR="${HOME}/.config/systemd/user" +BIN_DIR="${HOME}/koopa-castopod/bin" + +mkdir -p "${UNIT_DIR}" "${BIN_DIR}" + +for f in lib.sh status.sh up.sh apply-branding.sh; do + if [[ -f "${ROOT}/scripts/castopod/${f}" ]]; then + install -m 755 "${ROOT}/scripts/castopod/${f}" "${BIN_DIR}/${f}" + fi +done + +cp "${ROOT}/configs/castopod/container-koopa-castopod-mariadb.service" "${UNIT_DIR}/" +cp "${ROOT}/configs/castopod/container-koopa-castopod-redis.service" "${UNIT_DIR}/" +cp "${ROOT}/configs/castopod/container-koopa-castopod.service" "${UNIT_DIR}/" + +systemctl --user daemon-reload +systemctl --user enable \ + container-koopa-castopod-mariadb.service \ + container-koopa-castopod-redis.service \ + container-koopa-castopod.service + +if ! podman ps --format '{{.Names}}' | grep -qx koopa-castopod; then + (cd "${HOME}/koopa-castopod" && set -a && source .env && set +a && podman-compose up -d) +fi + +systemctl --user start \ + container-koopa-castopod-mariadb.service \ + container-koopa-castopod-redis.service \ + container-koopa-castopod.service + +systemctl --user --no-pager status \ + container-koopa-castopod-mariadb.service \ + container-koopa-castopod-redis.service \ + container-koopa-castopod.service + +echo "OK. Needs: loginctl enable-linger hernani (already set on koopa)." +echo "Public: https://castopod.hacktivism.ch/" diff --git a/scripts/castopod/lib.sh b/scripts/castopod/lib.sh new file mode 100644 index 0000000..4c64f32 --- /dev/null +++ b/scripts/castopod/lib.sh @@ -0,0 +1,116 @@ +# shellcheck shell=bash +# Shared helpers for Castopod ops on koopa — fail fast, never hang forever. + +set -euo pipefail + +# --- defaults (override via env) --- +: "${CP_BASEURL:=https://castopod.hacktivism.ch}" +: "${CP_COMPOSE_DIR:=/home/hernani/koopa-castopod}" +: "${CP_CONNECT_TIMEOUT:=10}" # seconds — TCP connect +: "${CP_MAX_TIME:=120}" # seconds — whole HTTP transfer +: "${CP_PULL_TIMEOUT:=600}" # seconds — image pull +: "${CP_HEALTH_TRIES:=30}" # health poll attempts +: "${CP_HEALTH_SLEEP:=5}" # seconds between polls + +# curl that cannot hang forever +cp_curl() { + curl -sS \ + --connect-timeout "${CP_CONNECT_TIMEOUT}" \ + --max-time "${CP_MAX_TIME}" \ + --retry 2 \ + --retry-delay 2 \ + --retry-connrefused \ + "$@" +} + +# HTTP status only (no hang) +cp_http_code() { + local url=$1 + cp_curl -o /dev/null -w '%{http_code}' "$url" || echo "000" +} + +# Run command with hard wall-clock limit (GNU coreutils timeout) +cp_timeout() { + local secs=$1 + shift + if command -v timeout >/dev/null 2>&1; then + timeout --signal=TERM --kill-after=15s "${secs}" "$@" + else + # fallback: no timeout binary — still run, but warn + echo "WARN: timeout(1) missing; running without wall limit: $*" >&2 + "$@" + fi +} + +# Non-interactive answer stream for spark / CLI that prompts [y,n] or passwords. +# Usage: cp_yes | podman exec -i … php spark … +# Prefer SQL for activate when possible (spark prompts hang without TTY). +cp_yes() { + # enough y's for a few prompts; never block waiting for input + yes y 2>/dev/null | head -n 20 +} + +# Poll until URL returns expected code or give up +cp_wait_http() { + local url=$1 + local want=${2:-200} + local i code + for ((i = 1; i <= CP_HEALTH_TRIES; i++)); do + code=$(cp_http_code "$url") + echo "health try $i/${CP_HEALTH_TRIES}: $url → $code" + if [[ "$code" == "$want" || "$code" =~ ^[23] ]]; then + return 0 + fi + sleep "${CP_HEALTH_SLEEP}" + done + echo "ERROR: $url still not healthy after ${CP_HEALTH_TRIES} tries (last=$code)" >&2 + return 1 +} + +# MariaDB password from compose .env (no hang if missing) +cp_mysql_password() { + local envf="${CP_COMPOSE_DIR}/.env" + [[ -f "$envf" ]] || { echo "ERROR: missing $envf" >&2; return 1; } + # shellcheck disable=SC1090 + grep -E '^MYSQL_PASSWORD=' "$envf" | head -1 | cut -d= -f2- +} + +cp_mysql() { + local pw + pw=$(cp_mysql_password) + podman exec koopa-castopod-mariadb \ + mariadb -ucastopod -p"$pw" castopod "$@" +} + +# Activate shield users without spark interactive prompt +cp_activate_users() { + cp_mysql -e "UPDATE cp_users SET active=1 WHERE username IN ('admin','ngi');" +} + +# Session login for admin UI automation (uses users.env) +cp_admin_login() { + local cookie=${1:-/tmp/cp-cj} + local envf="${CP_COMPOSE_DIR}/users.env" + [[ -f "$envf" ]] || { echo "ERROR: missing $envf" >&2; return 1; } + # shellcheck disable=SC1090 + source "$envf" + [[ -n "${ADMIN_PW:-}" ]] || { echo "ERROR: ADMIN_PW empty" >&2; return 1; } + + rm -f "$cookie" + cp_curl -c "$cookie" -b "$cookie" "${CP_BASEURL}/cp-auth/login" -o /tmp/cp-login.html + local csrf + csrf=$(sed -n 's/.*name="csrf_test_name" value="\([^"]*\)".*/\1/p' /tmp/cp-login.html | head -1) + [[ -n "$csrf" ]] || { echo "ERROR: no CSRF on login page" >&2; return 1; } + + local code + code=$(cp_curl -c "$cookie" -b "$cookie" -o /dev/null -w '%{http_code}' \ + -X POST "${CP_BASEURL}/cp-auth/login" \ + --data-urlencode "csrf_test_name=${csrf}" \ + --data-urlencode "email=admin@castopod.hacktivism.ch" \ + --data-urlencode "password=${ADMIN_PW}") + # 303 see other is success + if [[ "$code" != "303" && "$code" != "302" && "$code" != "200" ]]; then + echo "ERROR: login HTTP $code" >&2 + return 1 + fi +} diff --git a/scripts/castopod/status.sh b/scripts/castopod/status.sh new file mode 100644 index 0000000..2a234df --- /dev/null +++ b/scripts/castopod/status.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Quick Castopod health — bounded network, no hangs. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib.sh +source "${ROOT}/lib.sh" + +echo "== podman ==" +podman ps --filter name=koopa-castopod --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true + +echo "== local backend :9020 ==" +code=$(cp_http_code "http://127.0.0.1:9020/health" || true) +echo "local health → ${code:-000} (307 redirect to public is OK)" + +echo "== public ==" +# Note: do not put bare @url in curl -w; @ means "read file" in some curl contexts. +for path in "/" "/@foss" "/@foss/feed.xml" "/@foss/episodes/four-freedoms"; do + url="${CP_BASEURL}${path}" + code=$(cp_http_code "$url") + printf ' %s → %s\n' "$url" "$code" +done diff --git a/scripts/castopod/up.sh b/scripts/castopod/up.sh new file mode 100644 index 0000000..dc911ef --- /dev/null +++ b/scripts/castopod/up.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Start Castopod stack with hard timeouts on pull/up (no infinite hang). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib.sh +source "${ROOT}/lib.sh" + +cd "${CP_COMPOSE_DIR}" + +echo "== pull (max ${CP_PULL_TIMEOUT}s) ==" +cp_timeout "${CP_PULL_TIMEOUT}" podman-compose pull + +echo "== up ==" +cp_timeout 180 podman-compose up -d + +echo "== wait for app on :9020 ==" +# Castopod often 307 from /health to https — accept 2xx/3xx +ok=0 +for ((i = 1; i <= CP_HEALTH_TRIES; i++)); do + code=$(cp_http_code "http://127.0.0.1:9020/" || true) + echo "try $i: local / → $code" + if [[ "$code" =~ ^[23] ]]; then + ok=1 + break + fi + sleep "${CP_HEALTH_SLEEP}" +done +[[ "$ok" -eq 1 ]] || { echo "ERROR: app not answering on 9020"; podman-compose ps; exit 1; } + +echo "== public via Caddy ==" +cp_wait_http "${CP_BASEURL}/" || true +echo "done." diff --git a/scripts/monitoring/countries.txt b/scripts/monitoring/countries.txt new file mode 100755 index 0000000..9177e28 --- /dev/null +++ b/scripts/monitoring/countries.txt @@ -0,0 +1,249 @@ +AF=Afghanistan +AX=Åland Islands +AL=Albania +DZ=Algeria +AS=American Samoa +AD=Andorra +AO=Angola +AI=Anguilla +AQ=Antarctica +AG=Antigua and Barbuda +AR=Argentina +AM=Armenia +AW=Aruba +AU=Australia +AT=Austria +AZ=Azerbaijan +BS=Bahamas +BH=Bahrain +BD=Bangladesh +BB=Barbados +BY=Belarus +BE=Belgium +BZ=Belize +BJ=Benin +BM=Bermuda +BT=Bhutan +BO=Bolivia +BQ=Bonaire, Sint Eustatius and Saba +BA=Bosnia and Herzegovina +BW=Botswana +BV=Bouvet Island +BR=Brazil +IO=British Indian Ocean Territory +BN=Brunei Darussalam +BG=Bulgaria +BF=Burkina Faso +BI=Burundi +KH=Cambodia +CM=Cameroon +CA=Canada +CV=Cabo Verde +KY=Cayman Islands +CF=Central African Republic +TD=Chad +CL=Chile +CN=China +CX=Christmas Island +CC=Cocos (Keeling) Islands +CO=Colombia +KM=Comoros +CG=Congo +CD=Congo (DRC) +CK=Cook Islands +CR=Costa Rica +CI=Côte d'Ivoire +HR=Croatia +CU=Cuba +CW=Curaçao +CY=Cyprus +CZ=Czechia +DK=Denmark +DJ=Djibouti +DM=Dominica +DO=Dominican Republic +EC=Ecuador +EG=Egypt +SV=El Salvador +GQ=Equatorial Guinea +ER=Eritrea +EE=Estonia +SZ=Eswatini +ET=Ethiopia +FK=Falkland Islands +FO=Faroe Islands +FJ=Fiji +FI=Finland +FR=France +GF=French Guiana +PF=French Polynesia +TF=French Southern Territories +GA=Gabon +GM=Gambia +GE=Georgia +DE=Germany +GH=Ghana +GI=Gibraltar +GR=Greece +GL=Greenland +GD=Grenada +GP=Guadeloupe +GU=Guam +GT=Guatemala +GG=Guernsey +GN=Guinea +GW=Guinea-Bissau +GY=Guyana +HT=Haiti +HM=Heard Island and McDonald Islands +VA=Holy See +HN=Honduras +HK=Hong Kong +HU=Hungary +IS=Iceland +IN=India +ID=Indonesia +IR=Iran +IQ=Iraq +IE=Ireland +IM=Isle of Man +IL=Israel +IT=Italy +JM=Jamaica +JP=Japan +JE=Jersey +JO=Jordan +KZ=Kazakhstan +KE=Kenya +KI=Kiribati +KP=North Korea +KR=South Korea +KW=Kuwait +KG=Kyrgyzstan +LA=Laos +LV=Latvia +LB=Lebanon +LS=Lesotho +LR=Liberia +LY=Libya +LI=Liechtenstein +LT=Lithuania +LU=Luxembourg +MO=Macao +MG=Madagascar +MW=Malawi +MY=Malaysia +MV=Maldives +ML=Mali +MT=Malta +MH=Marshall Islands +MQ=Martinique +MR=Mauritania +MU=Mauritius +YT=Mayotte +MX=Mexico +FM=Micronesia +MD=Moldova +MC=Monaco +MN=Mongolia +ME=Montenegro +MS=Montserrat +MA=Morocco +MZ=Mozambique +MM=Myanmar +NA=Namibia +NR=Nauru +NP=Nepal +NL=Netherlands +NC=New Caledonia +NZ=New Zealand +NI=Nicaragua +NE=Niger +NG=Nigeria +NU=Niue +NF=Norfolk Island +MK=North Macedonia +MP=Northern Mariana Islands +NO=Norway +OM=Oman +PK=Pakistan +PW=Palau +PS=Palestine +PA=Panama +PG=Papua New Guinea +PY=Paraguay +PE=Peru +PH=Philippines +PN=Pitcairn +PL=Poland +PT=Portugal +PR=Puerto Rico +QA=Qatar +RE=Réunion +RO=Romania +RU=Russia +RW=Rwanda +BL=Saint Barthélemy +SH=Saint Helena +KN=Saint Kitts and Nevis +LC=Saint Lucia +MF=Saint Martin +PM=Saint Pierre and Miquelon +VC=Saint Vincent and the Grenadines +WS=Samoa +SM=San Marino +ST=Sao Tome and Principe +SA=Saudi Arabia +SN=Senegal +RS=Serbia +SC=Seychelles +SL=Sierra Leone +SG=Singapore +SX=Sint Maarten +SK=Slovakia +SI=Slovenia +SB=Solomon Islands +SO=Somalia +ZA=South Africa +GS=South Georgia and the South Sandwich Islands +SS=South Sudan +ES=Spain +LK=Sri Lanka +SD=Sudan +SR=Suriname +SJ=Svalbard and Jan Mayen +SE=Sweden +CH=Switzerland +SY=Syria +TW=Taiwan +TJ=Tajikistan +TZ=Tanzania +TH=Thailand +TL=Timor-Leste +TG=Togo +TK=Tokelau +TO=Tonga +TT=Trinidad and Tobago +TN=Tunisia +TR=Türkiye +TM=Turkmenistan +TC=Turks and Caicos Islands +TV=Tuvalu +UG=Uganda +UA=Ukraine +AE=United Arab Emirates +GB=United Kingdom +US=United States +UM=U.S. Minor Outlying Islands +UY=Uruguay +UZ=Uzbekistan +VU=Vanuatu +VE=Venezuela +VN=Vietnam +VG=Virgin Islands (British) +VI=Virgin Islands (U.S.) +WF=Wallis and Futuna +EH=Western Sahara +YE=Yemen +ZM=Zambia +ZW=Zimbabwe diff --git a/scripts/monitoring/tor_inbound_connects.sh b/scripts/monitoring/tor_inbound_connects.sh new file mode 100755 index 0000000..168e6fc --- /dev/null +++ b/scripts/monitoring/tor_inbound_connects.sh @@ -0,0 +1,8 @@ +#!/bin/sh +for ((;;)) + do + date && + echo -n "Tor IPv4 inbound connects: "; ss -s -4 | grep 192.168.100.95:https | wc -l && + echo -n "Tor IPv6 inbound connects: "; ss -s -6 | grep -E '::1]:https' | wc -l && + sleep 3600 + done diff --git a/scripts/monitoring/tor_show_relays--koopa+firecuda.sh b/scripts/monitoring/tor_show_relays--koopa+firecuda.sh new file mode 100755 index 0000000..07799a6 --- /dev/null +++ b/scripts/monitoring/tor_show_relays--koopa+firecuda.sh @@ -0,0 +1,2 @@ +#!/bin/bash + for ((;;)); do date && time ./tor_show_relays.sh -5000 | grep '|' | cat -n | grep -Ei 'koopa|firecuda' && sleep 86400; done diff --git a/scripts/monitoring/tor_show_relays.sh b/scripts/monitoring/tor_show_relays.sh new file mode 100755 index 0000000..116fe9b --- /dev/null +++ b/scripts/monitoring/tor_show_relays.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +CACHE_DIR="cache" +COUNTRY_FILE="countries.txt" +DEFAULT_TOP=100 +TOPN=$DEFAULT_TOP + +mkdir -p "$CACHE_DIR" + +# Parse -N flag +if [[ "$1" =~ ^-([0-9]+)$ ]]; then + TOPN="${BASH_REMATCH[1]}" +fi + +# =========================== +# LOAD COUNTRY NAMES +# =========================== + +declare -A COUNTRY_NAME +while IFS='=' read -r ISO NAME; do + [[ -z "$ISO" ]] && continue + COUNTRY_NAME["$ISO"]="$NAME" +done < "$COUNTRY_FILE" + +# =========================== +# SMART FETCH (ETag-based) +# =========================== + +fetch_if_new() { + local url="$1" + local outfile="$2" + local etagfile="${outfile}.etag" + + echo "→ Checking $outfile" + curl -s \ + --etag-save "$etagfile" \ + --etag-compare "$etagfile" \ + -o "$outfile" \ + "$url" + + echo " Size: $(du -h "$outfile" | cut -f1)" +} + +DETAILS_JSON="$CACHE_DIR/details.json" +BANDWIDTH_JSON="$CACHE_DIR/bandwidth.json" +MERGED="$CACHE_DIR/merged.txt" + +echo "=== STEP 1: Downloading (if new) ===" +fetch_if_new \ + "https://onionoo.torproject.org/details?type=relay&fields=fingerprint,country,nickname" \ + "$DETAILS_JSON" + +fetch_if_new \ + "https://onionoo.torproject.org/bandwidth?type=relay&fields=fingerprint,write_history" \ + "$BANDWIDTH_JSON" + +# =========================== +# STEP 2: MERGE EVERYTHING IN ONE jq PASS +# =========================== + +echo +echo "=== STEP 2: Merging in jq (single pass) ===" + +jq -s -r ' + # Build index of details by fingerprint + (.[0].relays + | map({ + fp: .fingerprint, + country: (.country // "??"), + nickname: (.nickname // "UnknownRelay") + }) + | INDEX(.fp) + ) as $d + + # Iterate over bandwidth relays + | .[1].relays[] + | .fingerprint as $fp + | ($d[$fp].country) as $cc + | ($d[$fp].nickname) as $nick + | (.write_history["1_month"].factor // 0) as $bw + + # Output: bw fp cc nickname + | "\($bw) \($fp) \($cc) \($nick)" +' "$DETAILS_JSON" "$BANDWIDTH_JSON" > "$MERGED" + +echo " Merged lines: $(wc -l < "$MERGED")" + +# =========================== +# STEP 3: SORT + PRINT +# =========================== + +echo +echo "=== STEP 3: Sorting and printing ===" +echo +echo "=== Top $TOPN Tor Relays (by 1-month write factor) ===" +echo + +sort -nr "$MERGED" | head -n "$TOPN" | while read -r BW FP CC NICK; do + CC_UP=$(echo "$CC" | tr '[:lower:]' '[:upper:]') + FULL="${COUNTRY_NAME[$CC_UP]}" + [[ -z "$FULL" ]] && FULL="$NICK" + + printf "%-40s | %12.2f | %-20s | %2s (%s)\n" "$FP" "$BW" "$NICK" "$CC_UP" "$FULL" + +done + +echo +echo "Done." diff --git a/scripts/monitoring/tor_stats_per_country.sh b/scripts/monitoring/tor_stats_per_country.sh new file mode 100755 index 0000000..b7e99cc --- /dev/null +++ b/scripts/monitoring/tor_stats_per_country.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +PORT=8080 +DB="/usr/share/GeoIP/GeoLite2-Country.mmdb" +LOGFILE="/dev/null" +COUNTRY_FILE="countries.txt" + +declare -A COUNTRY_COUNT +declare -A SEEN +declare -A COUNTRY_NAME + +# --- Parse -N flag (e.g. -10 means show top 10) --- +TOPN=0 +if [[ "$1" =~ ^-([0-9]+)$ ]]; then + TOPN="${BASH_REMATCH[1]}" +fi + +# --- Load external country list --- +while IFS='=' read -r ISO NAME; do + [[ -z "$ISO" ]] && continue + COUNTRY_NAME["$ISO"]="$NAME" +done < "$COUNTRY_FILE" + +echo "Monitoring port $PORT..." +echo "Updating table every 5 seconds." +[[ $TOPN -gt 0 ]] && echo "Showing only Top $TOPN countries." + +LAST_REFRESH=0 + +while true; do + # FAST LOOP: collect new connections + IPS=$(ss -tn sport = :$PORT | awk 'NR>1 {print $5}' | cut -d: -f1) + + for IP in $IPS; do + [[ "$IP" == "127.0.0.1" ]] && continue + + if [[ -z "${SEEN[$IP]}" ]]; then + SEEN[$IP]=1 + + RAW=$(mmdblookup --file "$DB" --ip "$IP" country iso_code 2>/dev/null) + ISO=$(echo "$RAW" | grep -oE '[A-Z]{2}') + [[ -z "$ISO" ]] && ISO="UNKNOWN" + + COUNTRY_COUNT["$ISO"]=$(( COUNTRY_COUNT["$ISO"] + 1 )) + + NAME="${COUNTRY_NAME[$ISO]}" + [[ -z "$NAME" ]] && NAME="Unknown Country" + + echo "$(date '+%F %T') - $IP - $ISO ($NAME)" >> "$LOGFILE" + fi + done + + # SLOW LOOP: refresh display every 5 seconds + NOW=$(date +%s) + if (( NOW - LAST_REFRESH >= 5 )); then + LAST_REFRESH=$NOW + + clear + echo "=== Live GeoIP Stats (Port $PORT) ===" + echo "(Updated: $(date '+%H:%M:%S'))" + echo + + # Sort by count (descending) + SORTED=$(for ISO in "${!COUNTRY_COUNT[@]}"; do + echo "${COUNTRY_COUNT[$ISO]} $ISO" + done | sort -rn) + + COUNT=0 + while read -r LINE; do + NUM=$(echo "$LINE" | awk '{print $1}') + ISO=$(echo "$LINE" | awk '{print $2}') + NAME="${COUNTRY_NAME[$ISO]}" + [[ -z "$NAME" ]] && NAME="Unknown Country" + + echo "$ISO ($NAME): $NUM" + + ((COUNT++)) + [[ $TOPN -gt 0 && $COUNT -ge $TOPN ]] && break + done <<< "$SORTED" + fi + + sleep 0.1 +done diff --git a/scripts/taler-bank/README.md b/scripts/taler-bank/README.md new file mode 100644 index 0000000..df29e4b --- /dev/null +++ b/scripts/taler-bank/README.md @@ -0,0 +1,71 @@ +# taler-bank scripts + +Container: **`taler-hacktivism-bank`** (libeufin-bank, GOA, **no IBAN**). + +| File | Container path | User | +|------|----------------|------| +| `start_base_services_for_taler_bank.sh` | `/root/` | **root** | +| `start_bank.sh` | `/usr/local/bin/` | **libeufin-bank** | +| `check_bank-health.sh` | `/usr/local/bin/` | libeufin-bank / any | +| `landing-stats.sh` | `/usr/local/bin/` | root (in container) — writes `/var/www/bank-landing/stats.json` | +| `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 | +| `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 | + +## Usage + +```bash +# root in container +./start_base_services_for_taler_bank.sh +# then as libeufin-bank in /usr/local/bin: +./start_bank.sh --restart +``` + +## Landing stats (inside container) + +```bash +# on koopa host — copy + run (writes /var/www/bank-landing/stats.json) +./landing-stats-install.sh +./landing-stats-install.sh --cron # every minute inside container (* * * * *) +./landing-stats-install.sh --run-only +``` + +Details + JSON schema: `configs/bank-landing/README.md`. + +## Demo withdraw + auto-account API + +`demo-withdraw-api.py` listens on **127.0.0.1:19096** (proxied by nginx on the landing): + +| Path | Behaviour | +|------|-----------| +| `GET /demo-withdraw.json` | Mint one-shot withdraw from shared **`explorer`** pool; write `withdraw.uri` + watch ids | +| `GET /auto-account.json` | Public `POST /accounts` with generated **`goa-account-<random>`** user + password containing **pleasechangeme**; **balance GOA:0**; return credentials once | + +Install / restart: + +```bash +./install-demo-withdraw-api.sh +# Public checks: +curl -sS https://bank.hacktivism.ch/intro/demo-withdraw.json | head +curl -sS https://bank.hacktivism.ch/intro/auto-account.json | head # creates a real account +``` + +Requires **python3** in the bank container. Env: `BANK_URL`, `BANK_USER`/`BANK_PASS` +(or `/root/bank-explorer-password.txt`), `AMOUNT` (default `GOA:10` for shared withdraws). + +### Auto-confirm (explorer only) + +```bash +# loop inside container — refuses non-explorer unless ALLOW_NON_EXPLORER=1 +auto-confirm-withdrawals.sh --loop 4 +``` + +Only confirms withdrawals owned by **`explorer`** when status is `selected` +(community demo path). Does not confirm arbitrary customer withdraws. + +## 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 new file mode 100755 index 0000000..3b9dc85 --- /dev/null +++ b/scripts/taler-bank/auto-confirm-withdrawals.sh @@ -0,0 +1,143 @@ +#!/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). +# +# 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_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) +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}" +LOOP=0 +SLEEP=5 +if [ "${1:-}" = "--loop" ]; then + LOOP=1 + SLEEP="${2:-5}" +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 +fi + +PASS="${BANK_PASS:-}" +if [ -z "$PASS" ]; then + for f in "/root/bank-${USER}-password.txt" /root/bank-explorer-password.txt; do + if [ -f "$f" ]; then PASS=$(tr -d '\n' <"$f"); break; fi + done +fi +[ -n "$PASS" ] || { echo "no password for $USER" >&2; exit 1; } + +# 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 +} + +token() { + curl -sS -m 12 -u "${USER}:${PASS}" \ + -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite","refreshable":true}' \ + "${BANK}/accounts/${USER}/token" +} + +# IDs created for the community pool (landing + watch list only) +known_ids() { + if [ -f "${LANDING_DIR}/withdraw.uri" ]; then + basename "$(tr -d '\n' <"${LANDING_DIR}/withdraw.uri")" + 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 + fi +} + +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) + [ -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) + + # 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:-?}" + 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 +} + +once() { + local tjson tok ids + 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) + if [ -z "$ids" ]; then + echo "no withdrawal ids to watch (landing withdraw.uri / withdraw-watch.ids)" + return 0 + fi + while read -r wid; do + [ -n "$wid" ] || continue + confirm_one "$wid" "$tok" + done <<<"$ids" +} + +if [ "$LOOP" -eq 1 ]; then + echo "auto-confirm loop every ${SLEEP}s for pool user=$USER only" + while true; do + once || true + sleep "$SLEEP" + done +else + once +fi diff --git a/scripts/taler-bank/check_bank-health.sh b/scripts/taler-bank/check_bank-health.sh new file mode 100755 index 0000000..c01ead2 --- /dev/null +++ b/scripts/taler-bank/check_bank-health.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Health check for manual libeufin-bank (same style as merchant/exchange health). +# Container: taler-hacktivism-bank + +CONF=/etc/libeufin/libeufin-bank.conf +PORT=$(grep -E '^\s*PORT\s*=' /etc/libeufin/bank-overrides.conf 2>/dev/null | tail -1 | awk -F= '{gsub(/ /,"",$2); print $2}') +PORT=${PORT:-9012} + +green() { echo -e "\e[32m$1\e[0m"; } +red() { echo -e "\e[31m$1\e[0m"; } +yellow() { echo -e "\e[33m$1\e[0m"; } + +fail=0 +ok() { green "[OK] $1"; } +bad() { red "[FAIL] $1"; fail=1; } + +echo "=== Taler Bank (libeufin-bank) Health Check ===" + +if pgrep -f 'libeufin-bank serve|MainKt serve|tech.libeufin.bank.MainKt' >/dev/null 2>&1; then + ok "process libeufin-bank serve" +else + bad "process libeufin-bank serve is NOT running" +fi + +if curl -sf -m 3 "http://127.0.0.1:${PORT}/config" >/dev/null 2>&1; then + ok "HTTP /config on 127.0.0.1:${PORT}" +else + bad "no HTTP response on 127.0.0.1:${PORT}/config" +fi + +if curl -sf -m 3 "http://127.0.0.1:${PORT}/taler-integration/config" >/dev/null 2>&1; then + ok "HTTP /taler-integration/config on 127.0.0.1:${PORT}" +else + yellow "[WARN] no HTTP response on /taler-integration/config" +fi + +if pg_isready >/dev/null 2>&1; then + ok "postgresql accepting connections" +else + yellow "[WARN] postgresql not ready (or pg_isready missing)" +fi + +if [ "$fail" -eq 0 ]; then + green "=== ALL CRITICAL CHECKS PASSED ===" + exit 0 +fi +red "=== SOME CHECKS FAILED ===" +exit 1 diff --git a/scripts/taler-bank/credit-account.sh b/scripts/taler-bank/credit-account.sh new file mode 100755 index 0000000..fd3ac15 --- /dev/null +++ b/scripts/taler-bank/credit-account.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Credit a bank user by transferring from admin (creates regional GOA via admin debit). +# Run as root on koopa host (bank on 127.0.0.1:9012). +# +# Usage: +# credit-account.sh [USERNAME] [AMOUNT] +# credit-account.sh explorer GOA:1000 +set -euo pipefail + +BANK="${BANK_URL:-http://127.0.0.1:9012}" +TO_USER="${1:-explorer}" +AMOUNT="${2:-GOA:1000}" +ADMIN_PASS="${BANK_ADMIN_PASS:-}" +if [ -z "$ADMIN_PASS" ] && [ -f /root/bank-admin-password.txt ]; then + ADMIN_PASS=$(tr -d '\n' </root/bank-admin-password.txt) +fi +[ -n "$ADMIN_PASS" ] || { echo "Need BANK_ADMIN_PASS or /root/bank-admin-password.txt" >&2; exit 1; } + +W=$(mktemp -d) +trap 'rm -rf "$W"' EXIT + +echo '{"scope":"readwrite"}' >"$W/tok.json" +curl -sS -m 15 -u "admin:${ADMIN_PASS}" \ + -H 'Content-Type: application/json' \ + -d @"$W/tok.json" \ + "${BANK}/accounts/admin/token" >"$W/tok.out" +TOKEN=$(python3 -c "import json;print(json.load(open('$W/tok.out')).get('access_token',''))") +[ -n "$TOKEN" ] || { echo "admin token failed:"; cat "$W/tok.out"; exit 1; } + +# payto for x-taler-bank regional accounts +PAYTO="payto://x-taler-bank/bank.hacktivism.ch/${TO_USER}?receiver-name=${TO_USER}&message=credit-from-admin" + +python3 - "$AMOUNT" "$PAYTO" "$W/tx.json" <<'PY' +import json, sys, os +amount, payto, out = sys.argv[1], sys.argv[2], sys.argv[3] +# ShortHashCode: Crockford base32 of 32 random bytes (52 chars) +_ALPH = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +def crockford32(data: bytes) -> str: + n = int.from_bytes(data, "big") + bits = len(data) * 8 + outc = [] + while bits > 0: + bits -= 5 + outc.append(_ALPH[(n >> bits) & 31] if bits >= 0 else _ALPH[(n << (-bits)) & 31]) + if bits <= 0: + break + # pad to full groups + s = "".join(outc) + # simpler bit stream + return None +def crock32(b: bytes) -> str: + bits = 0 + val = 0 + outc = [] + for byte in b: + val = (val << 8) | byte + bits += 8 + while bits >= 5: + bits -= 5 + outc.append(_ALPH[(val >> bits) & 31]) + if bits: + outc.append(_ALPH[(val << (5 - bits)) & 31]) + return "".join(outc) +uid = crock32(os.urandom(32)) +json.dump({"payto_uri": payto, "amount": amount, "request_uid": uid}, open(out, "w")) +print("request_uid", uid, "len", len(uid)) +PY + +echo "POST admin -> $TO_USER amount=$AMOUNT" +curl -sS -m 15 \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d @"$W/tx.json" \ + "${BANK}/accounts/admin/transactions" | tee "$W/tx.out" +echo + +# show balances +for u in admin "$TO_USER"; do + curl -sS -m 10 -u "admin:${ADMIN_PASS}" \ + -H 'Content-Type: application/json' -d '{"scope":"readonly"}' \ + "${BANK}/accounts/admin/token" >"$W/rtok" 2>/dev/null || true +done + +# re-token readonly and print target balance +echo '{"scope":"readonly"}' >"$W/rtok.json" +# admin can GET any account +curl -sS -m 10 -H "Authorization: Bearer ${TOKEN}" \ + "${BANK}/accounts/${TO_USER}" | tee "$W/acc.out" +echo +python3 -c " +import json +d=json.load(open('$W/acc.out')) +b=d.get('balance') or {} +print('RESULT', '${TO_USER}', 'balance=', b.get('amount'), b.get('credit_debit_indicator')) +print('debit_threshold=', d.get('debit_threshold')) +" diff --git a/scripts/taler-bank/demo-withdraw-api.py b/scripts/taler-bank/demo-withdraw-api.py new file mode 100755 index 0000000..b0ff9bb --- /dev/null +++ b/scripts/taler-bank/demo-withdraw-api.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +""" +HTTP helper for bank landing: + - GET /demo-withdraw.json — mint shared-pool (explorer) demo withdraw + - GET /auto-account.json — create a personal bank account (balance 0) + and return one-time credentials for the user to copy + +Listens on 127.0.0.1:19096 (only inside bank container / localhost). +Nginx proxies /intro/*.json → this service. + +Env: + BANK_URL default http://127.0.0.1:9012 + BANK_USER default explorer + BANK_PASS or /root/bank-explorer-password.txt + AMOUNT default GOA:10 + LANDING_DIR default /var/www/bank-landing +""" +from __future__ import annotations + +import json +import os +import re +import secrets +import ssl +import string +import time +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +BANK = os.environ.get("BANK_URL", "http://127.0.0.1:9012").rstrip("/") +# Public HTTPS base (Caddy) — absolute webui / login links +BANK_PUBLIC = os.environ.get("BANK_PUBLIC", "https://bank.hacktivism.ch").rstrip("/") +USER = os.environ.get("BANK_USER", "explorer") +AMOUNT = os.environ.get("AMOUNT", "GOA:10") +LANDING = Path(os.environ.get("LANDING_DIR", "/var/www/bank-landing")) +LISTEN = ("127.0.0.1", int(os.environ.get("DEMO_WITHDRAW_PORT", "19096"))) + + +def public_webui_url() -> str: + return f"{BANK_PUBLIC}/webui/" + + +def load_pass() -> str: + p = os.environ.get("BANK_PASS", "").strip() + if p: + return p + for f in ( + Path(f"/root/bank-{USER}-password.txt"), + Path("/root/bank-explorer-password.txt"), + ): + if f.is_file(): + return f.read_text().strip() + raise RuntimeError("no BANK_PASS / explorer password file") + + +def http_json(method: str, url: str, body=None, headers=None, auth=None): + data = None if body is None else json.dumps(body).encode() + h = dict(headers or {}) + if body is not None: + h["Content-Type"] = "application/json" + if auth: + import base64 + + token = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode() + h["Authorization"] = f"Basic {token}" + req = urllib.request.Request(url, data=data, method=method, headers=h) + ctx = ssl.create_default_context() + try: + with urllib.request.urlopen(req, context=ctx, timeout=20) as r: + raw = r.read().decode() + return r.status, json.loads(raw) if raw.strip() else {} + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + return e.code, json.loads(raw) + except Exception: + return e.code, {"raw": raw[:500]} + + +def mint_withdraw() -> dict: + pw = load_pass() + code, tok = http_json( + "POST", + f"{BANK}/accounts/{USER}/token", + {"scope": "readwrite", "refreshable": True}, + auth=(USER, pw), + ) + if code != 200 or not tok.get("access_token"): + raise RuntimeError(f"token failed HTTP {code}: {tok}") + access = tok["access_token"] + code, wd = http_json( + "POST", + f"{BANK}/accounts/{USER}/withdrawals", + {"suggested_amount": AMOUNT}, + headers={"Authorization": f"Bearer {access}"}, + ) + if code not in (200, 201): + raise RuntimeError(f"withdrawal create HTTP {code}: {wd}") + uri = wd.get("taler_withdraw_uri") or "" + wid = wd.get("withdrawal_id") or "" + if not uri: + 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() + LANDING.mkdir(parents=True, exist_ok=True) + (LANDING / "withdraw.uri").write_text(uri + "\n") + (LANDING / "withdraw.amount").write_text(AMOUNT + "\n") + (LANDING / "withdraw.created").write_text( + time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()) + "\n" + ) + watch_withdrawal(wid) + return { + "ok": True, + "taler_withdraw_uri": uri, + "withdrawal_id": wid, + "amount": AMOUNT, + "pool_account": USER, + "taler_integration_base": f"{BANK_PUBLIC}/taler-integration/", + "hint": "Open in GNU Taler Wallet (iOS/Android/desktop). No bank registration.", + "created": time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()), + } + + +def watch_withdrawal(wid: str) -> None: + """Queue withdrawal_id for auto-confirm loop (shared pool + personal).""" + if not wid: + return + LANDING.mkdir(parents=True, exist_ok=True) + watch = LANDING / "withdraw-watch.ids" + ids = set() + if watch.is_file(): + ids = {ln.strip() for ln in watch.read_text().splitlines() if ln.strip()} + ids.add(str(wid).strip()) + watch.write_text("\n".join(sorted(ids)) + "\n") + + + +# Funny stems for usernames (bank-safe [a-z0-9-]). +# Source: hand-curated in this file only — not scraped from the web. +# Keep culture-neutral: light space / physics wordplay, no animals, foods, +# body parts, religion, politics, or slang that can offend. +_FUNNY_STEMS = ( + "nebula-nudge", + "orbit-echo", + "voidwave-vibe", + "comet-crumb", + "quark-pulse", + "plasma-spark", + "astro-glint", + "lunar-loop", + "warp-ripple", + "photon-bloom", + "galaxy-drift", + "rocket-ribbon", + "satellite-swirl", + "meteor-mint", + "stardust-swirl", + "hyperdrive-hum", + "cosmic-coral", + "space-spark", + "nova-nibble", + "aurora-arc", + "solar-swish", + "pulsar-pop", + "comet-cloud", + "orbit-opal", + "zenith-zip", + "eclipse-echo", + "horizon-hum", + "starlight-step", +) + + +def _rand_username_and_name() -> tuple[str, str]: + # Shown as goa-account-<funnypiece>-<tag> (e.g. goa-account-space-potato-k3m9x) + stem = secrets.choice(_FUNNY_STEMS) + alphabet = string.ascii_lowercase + string.digits + tag = "".join(secrets.choice(alphabet) for _ in range(5)) + username = f"goa-account-{stem}-{tag}" + name = username + return username, name + + +def _rand_password() -> str: + # Embed "pleasechangeme" with random material before and after. + alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789" + prefix = "".join(secrets.choice(alphabet) for _ in range(4)) + suffix = "".join(secrets.choice(alphabet) for _ in range(6)) + return prefix + "pleasechangeme" + suffix + + +def create_personal_account() -> dict: + """Public registration: auto username/password, balance starts at 0.""" + username, name = _rand_username_and_name() + password = _rand_password() + code, body = http_json( + "POST", + f"{BANK}/accounts", + { + "username": username, + "password": password, + "name": name, + }, + ) + if code not in (200, 201, 204): + # retry once on conflict + if code in (409, 400): + username, name = _rand_username_and_name() + code, body = http_json( + "POST", + f"{BANK}/accounts", + { + "username": username, + "password": password, + "name": name, + }, + ) + if code not in (200, 201, 204): + raise RuntimeError(f"register failed HTTP {code}: {body}") + webui = public_webui_url() + # Same shared-pool withdraw as step 2 (explorer + auto-confirm) + wd = mint_withdraw() + withdraw_uri = wd["taler_withdraw_uri"] + return { + "ok": True, + "created_for_you": True, + "username": username, + "password": password, + "name": name, + "display_name": name, + "balance": "GOA:0", + "balance_note": "Starts at zero — not the shared community pool.", + "taler_withdraw_uri": withdraw_uri, + "withdrawal_id": wd["withdrawal_id"], + "withdraw_amount": wd.get("amount") or AMOUNT, + "pool_account": wd.get("pool_account") or USER, + "qr_payload": withdraw_uri, + "webui": webui, + "account_url": webui, + "login_url": webui, + "hint": ( + f"Login at {webui} with username {username} and the password shown " + "(not stored for recovery). Wallet QR is taler://withdraw/… from the " + f"shared pool ({USER}), same as step 2." + ), + "created": time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()), + "created_human": time.strftime("%Y-%m-%d %H:%M %Z", time.localtime()), + } + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + sys_stderr = __import__("sys").stderr + sys_stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) + + def _cors(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.send_header("Cache-Control", "no-store") + + def do_OPTIONS(self): + self.send_response(204) + self._cors() + self.end_headers() + + def do_GET(self): + path = self.path.split("?", 1)[0] + try: + if path in ( + "/", + "/demo-withdraw.json", + "/intro/demo-withdraw.json", + ): + body = mint_withdraw() + elif path in ( + "/auto-account.json", + "/intro/auto-account.json", + ): + body = create_personal_account() + else: + self.send_response(404) + self._cors() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"ok":false,"error":"not found"}') + return + raw = json.dumps(body).encode() + self.send_response(200) + self._cors() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + except Exception as e: + raw = json.dumps({"ok": False, "error": str(e)}).encode() + self.send_response(500) + self._cors() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + +def main(): + httpd = ThreadingHTTPServer(LISTEN, Handler) + print(f"demo-withdraw-api on http://{LISTEN[0]}:{LISTEN[1]}/", flush=True) + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/taler-bank/install-demo-withdraw-api.sh b/scripts/taler-bank/install-demo-withdraw-api.sh new file mode 100755 index 0000000..5366486 --- /dev/null +++ b/scripts/taler-bank/install-demo-withdraw-api.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Install + start demo-withdraw API + auto-confirm loop inside bank container. +# Host: +# ./scripts/taler-bank/install-demo-withdraw-api.sh +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +CTR="${BANK_CONTAINER:-taler-hacktivism-bank}" + +podman cp "$ROOT/demo-withdraw-api.py" "$CTR:/usr/local/bin/demo-withdraw-api.py" +podman cp "$ROOT/auto-confirm-withdrawals.sh" "$CTR:/usr/local/bin/auto-confirm-withdrawals.sh" +podman cp "$ROOT/refresh-demo-withdraw.sh" "$CTR:/usr/local/bin/refresh-demo-withdraw.sh" +podman exec -u root "$CTR" chmod 755 \ + /usr/local/bin/demo-withdraw-api.py \ + /usr/local/bin/auto-confirm-withdrawals.sh \ + /usr/local/bin/refresh-demo-withdraw.sh + +# nginx: proxy demo-withdraw.json +NGX=/etc/nginx/sites-available/bank-landing +podman exec -u root "$CTR" bash -lc ' +set -e +f=/etc/nginx/sites-available/bank-landing +if ! grep -q demo-withdraw.json "$f"; then + # insert before location /intro/ + python3 - <<PY +from pathlib import Path +p=Path("/etc/nginx/sites-available/bank-landing") +t=p.read_text() +block=""" location = /intro/demo-withdraw.json { + proxy_pass http://127.0.0.1:19096/demo-withdraw.json; + proxy_http_version 1.1; + proxy_set_header Host \$host; + add_header Cache-Control "no-store" always; + add_header Access-Control-Allow-Origin * always; + } +""" +if "demo-withdraw.json" not in t: + t=t.replace(" location /intro/ {", block+" location /intro/ {", 1) + p.write_text(t) + print("nginx location added") +else: + print("nginx already has demo-withdraw") +if "auto-account.json" not in t: + t=p.read_text() + block2=""" location = /intro/auto-account.json { + proxy_pass http://127.0.0.1:19096/auto-account.json; + proxy_http_version 1.1; + proxy_set_header Host \$host; + add_header Cache-Control "no-store" always; + add_header Access-Control-Allow-Origin * always; + } +""" + t=t.replace(" location /intro/ {", block2+" location /intro/ {", 1) + p.write_text(t) + print("nginx auto-account location added") +else: + print("nginx already has auto-account") +PY + nginx -t && nginx -s reload || true +else + echo "nginx already configured" +fi +' + +# start/restart API +podman exec -u root "$CTR" bash -lc ' +pkill -f "demo-withdraw-api.py" 2>/dev/null || true +nohup python3 /usr/local/bin/demo-withdraw-api.py \ + >>/var/log/demo-withdraw-api.log 2>&1 </dev/null & +echo "api pid $!" +# auto-confirm loop +pkill -f "auto-confirm-withdrawals.sh --loop" 2>/dev/null || true +nohup /usr/local/bin/auto-confirm-withdrawals.sh --loop 4 \ + >>/var/log/auto-confirm-withdrawals.log 2>&1 </dev/null & +echo "auto-confirm pid $!" +sleep 1 +curl -sS -m 8 http://127.0.0.1:19096/demo-withdraw.json | head -c 300; echo +' + +echo "OK: demo-withdraw API + auto-confirm in $CTR" +echo "Public: https://bank.hacktivism.ch/intro/demo-withdraw.json" diff --git a/scripts/taler-bank/landing-stats-install.sh b/scripts/taler-bank/landing-stats-install.sh new file mode 100644 index 0000000..8086558 --- /dev/null +++ b/scripts/taler-bank/landing-stats-install.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Install + run landing-stats.sh *inside* the bank container, and optionally +# install a cron entry there. Documented in configs/bank-landing/README.md. +# +# Run on koopa host (root or user in podman group): +# ./landing-stats-install.sh # copy + one-shot run +# ./landing-stats-install.sh --cron # also install *minutely* cron inside container +# ./landing-stats-install.sh --run-only # only exec existing script +set -euo pipefail + +DO_CRON=0 +RUN_ONLY=0 +# cron schedule (default: every minute) +: "${LANDING_STATS_CRON:=* * * * *}" +for a in "$@"; do + case "$a" in + --cron) DO_CRON=1 ;; + --run-only) RUN_ONLY=1 ;; + -h|--help) + sed -n '2,12p' "$0" | sed 's/^# \?//' + exit 0 + ;; + esac +done + +ROOT=$(cd "$(dirname "$0")" && pwd) +SRC="$ROOT/landing-stats.sh" +[ -f "$SRC" ] || { echo "missing $SRC" >&2; exit 1; } + +# Prefer the known names from ops docs +C="" +for name in taler-hacktivism-bank taler-bank-hacktivism; do + if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then + C=$name + break + fi +done +if [ -z "$C" ]; then + C=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -i bank | head -1 || true) +fi +[ -n "$C" ] || { echo "no bank container running" >&2; exit 1; } +echo "container=$C" + +# Host password → container /root (if not already there) +if [ -f /root/bank-explorer-password.txt ]; then + podman cp /root/bank-explorer-password.txt "$C:/root/bank-explorer-password.txt" 2>/dev/null || true +fi + +if [ "$RUN_ONLY" != "1" ]; then + podman exec "$C" mkdir -p /usr/local/bin /var/www/bank-landing + podman cp "$SRC" "$C:/usr/local/bin/landing-stats.sh" + podman exec "$C" chmod 755 /usr/local/bin/landing-stats.sh + echo "installed /usr/local/bin/landing-stats.sh" +fi + +# Ensure landing dir exists for nginx intro +podman exec "$C" mkdir -p /var/www/bank-landing + +echo "running landing-stats.sh inside $C …" +podman exec \ + -e LANDING_DIR=/var/www/bank-landing \ + -e BANK_USER=explorer \ + "$C" /usr/local/bin/landing-stats.sh + +if [ "$DO_CRON" = "1" ]; then + # default: every minute (* * * * *); override with LANDING_STATS_CRON='*/5 * * * *' + podman exec -e LANDING_STATS_CRON="$LANDING_STATS_CRON" "$C" bash -c ' + sched="${LANDING_STATS_CRON:-* * * * *}" + line="$sched TZ=Europe/Zurich LANDING_DIR=/var/www/bank-landing /usr/local/bin/landing-stats.sh >>/var/log/landing-stats.log 2>&1" + (crontab -l 2>/dev/null | grep -v landing-stats.sh; echo "$line") | crontab - + echo "cron installed:"; crontab -l | grep landing-stats + ' +fi + +echo "public check: curl -sS https://bank.hacktivism.ch/intro/stats.json | head" +# If host maps 9013 → container landing, show local file +podman exec "$C" head -c 400 /var/www/bank-landing/stats.json 2>/dev/null || true +echo diff --git a/scripts/taler-bank/landing-stats.sh b/scripts/taler-bank/landing-stats.sh new file mode 100755 index 0000000..c226e5c --- /dev/null +++ b/scripts/taler-bank/landing-stats.sh @@ -0,0 +1,612 @@ +#!/bin/bash +# Generate public landing stats JSON for bank.hacktivism.ch intro page. +# +# *** Run INSIDE the bank container *** (taler-hacktivism-bank). +# Writes: $LANDING_DIR/stats.json → https://bank.hacktivism.ch/intro/stats.json +# +# Pure bash + curl + awk (no python). Times in Europe/Zurich (CET/CEST). +# +# Documented: configs/bank-landing/README.md +set -euo pipefail + +LANDING_DIR="${LANDING_DIR:-/var/www/bank-landing}" +# Demo funding account (for recent withdraw list / flow) +BANK_USER="${BANK_USER:-explorer}" +# Admin lists all accounts + can read other accounts' txs +ADMIN_USER="${ADMIN_USER:-admin}" +# Per-account transaction window (libeufin delta). Was -100 → systematically +# undercounted credits/withdraws on active accounts (broken public stats). +TX_DELTA="${TX_DELTA:--50000}" +# Max accounts to list + scan (was 80 → missed later accounts; bank has 100+). +MAX_SCAN_ACCOUNTS="${MAX_SCAN_ACCOUNTS:-500}" +# Account-list page size for GET /accounts?delta=… (must cover all users) +ACCOUNTS_DELTA="${ACCOUNTS_DELTA:--500}" +# curl timeout per account (deeper history needs more headroom) +TX_CURL_TIMEOUT="${TX_CURL_TIMEOUT:-25}" +export TZ="${TZ:-Europe/Zurich}" + +PASS="${BANK_PASS:-}" +ADMIN_PASS="${BANK_ADMIN_PASS:-}" + +detect_bank() { + if [ -n "${BANK_URL:-}" ]; then + echo "${BANK_URL%/}" + return + fi + local port="" conf u code + for conf in /etc/libeufin/bank-overrides.conf /etc/libeufin/libeufin-bank.conf; do + if [ -f "$conf" ]; then + port=$(grep -E '^\s*PORT\s*=' "$conf" 2>/dev/null | tail -1 | awk -F= '{gsub(/ /,"",$2); print $2}') + [ -n "$port" ] && break + fi + done + port="${port:-9012}" + for u in "http://127.0.0.1:${port}" "http://127.0.0.1:9012" "http://127.0.0.1:8080"; do + code=$(curl -sS -m 2 -o /dev/null -w '%{http_code}' "$u/config" 2>/dev/null || echo 000) + if [ "$code" = "200" ]; then + echo "$u" + return + fi + done + echo "http://127.0.0.1:${port}" +} + +BANK="$(detect_bank)" +BANK="${BANK%/}" + +read_pass_file() { + local f + for f in "$@"; do + if [ -f "$f" ] && [ -r "$f" ]; then + tr -d '\n' <"$f" + return 0 + fi + done + return 1 +} + +if [ -z "$PASS" ]; then + PASS=$(read_pass_file \ + "/root/bank-${BANK_USER}-password.txt" \ + /root/bank-explorer-password.txt \ + "/etc/libeufin/secrets/bank-${BANK_USER}-password.txt" || true) +fi +if [ -z "$ADMIN_PASS" ]; then + ADMIN_PASS=$(read_pass_file \ + /root/bank-admin-password.txt \ + /etc/libeufin/secrets/bank-admin-password.txt || true) +fi + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT +OUT="$LANDING_DIR/stats.json" +RUN="$LANDING_DIR/stats-run.json" +TMP="${OUT}.tmp.$$" +mkdir -p "$LANDING_DIR" + +now_iso() { date +%Y-%m-%dT%H:%M%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/'; } +now_unix() { date +%s; } +now_human() { date +"%Y-%m-%d %H:%M %Z"; } +iso_from_unix() { + local u="$1" + date -d "@${u}" +%Y-%m-%dT%H:%M%z 2>/dev/null | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/' \ + || date -r "${u}" +%Y-%m-%dT%H:%M%z 2>/dev/null | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/' \ + || echo "" +} +# Human CEST/CET label for display: "2026-07-09 20:49 CEST" +human_from_unix() { + local u="$1" + date -d "@${u}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null \ + || date -r "${u}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null \ + || echo "" +} + +json_str() { + printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr '\n' ' ')" +} + +# Public run status for intro page. Never wipe stats.json on failure. +write_run() { + local ok_json="$1" msg="${2:-}" + cat >"$RUN" <<EOF +{ + "ok": ${ok_json}, + "at": $(json_str "$(now_iso)"), + "at_human": $(json_str "$(now_human)"), + "error": $( [ -n "$msg" ] && json_str "$msg" || echo null ) +} +EOF +} + +write_err() { + local msg="$1" + write_run false "$msg" + rm -f "$TMP" 2>/dev/null || true + echo "error: $msg (stats.json left unchanged; stats-run.json updated)" >&2 +} + +token_for() { + local user="$1" pass="$2" out="$3" + echo '{"scope":"readonly"}' >"$WORKDIR/tok-req.json" + curl -sS -m 12 -u "${user}:${pass}" \ + -H 'Content-Type: application/json' \ + -d @"$WORKDIR/tok-req.json" \ + "${BANK}/accounts/${user}/token" >"$out" || true + awk -F'"' '/access_token/ { + for (i=1;i<=NF;i++) if ($i=="access_token") { print $(i+2); exit } + }' "$out" 2>/dev/null || true +} + +extract_field() { + # extract "key":"value" or "key": number from a JSON blob (first hit) + local key="$1" file="$2" + awk -v key="$key" ' + function between(s, k, p, rest, q2, r) { + p = index(s, "\"" k "\"") + if (p == 0) return "" + rest = substr(s, p + length(k) + 2) + while (rest ~ /^[[:space:]:]/) rest = substr(rest, 2) + if (substr(rest, 1, 1) == "\"") { + rest = substr(rest, 2) + q2 = index(rest, "\"") + if (q2 == 0) return "" + return substr(rest, 1, q2 - 1) + } + r = "" + while (rest ~ /^[0-9]/) { + r = r substr(rest, 1, 1) + rest = substr(rest, 2) + } + return r + } + { print between($0, key); exit } + ' "$file" 2>/dev/null +} + +# --- tokens --- +if [ -z "$PASS" ] && [ -z "$ADMIN_PASS" ]; then + write_err "no bank password (explorer or admin) for stats" + exit 1 +fi + +ADMIN_TOKEN="" +if [ -n "$ADMIN_PASS" ]; then + ADMIN_TOKEN=$(token_for "$ADMIN_USER" "$ADMIN_PASS" "$WORKDIR/admin-tok.json") +fi +USER_TOKEN="" +if [ -n "$PASS" ]; then + USER_TOKEN=$(token_for "$BANK_USER" "$PASS" "$WORKDIR/user-tok.json") +fi +# Prefer admin for everything when available +AUTH_TOKEN="${ADMIN_TOKEN:-$USER_TOKEN}" +if [ -z "$AUTH_TOKEN" ]; then + write_err "token failed (admin/explorer)" + exit 1 +fi + +# --- account list --- +ACCOUNTS_N=0 +ACCOUNTS_USERS=0 +: >"$WORKDIR/usernames.txt" +if [ -n "$ADMIN_TOKEN" ]; then + # Use a large negative delta so we list *all* accounts (delta=-80 truncated + # the roster and undercounted bank_accounts + flow). + curl -sS -m 30 -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + "${BANK}/accounts?delta=${ACCOUNTS_DELTA}" >"$WORKDIR/accounts.json" || true + # usernames from "username":"..." + tr -d '\n' <"$WORKDIR/accounts.json" \ + | sed 's/},{/}\n{/g' \ + | while IFS= read -r line; do + u=$(printf '%s' "$line" | sed -n 's/.*"username"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + [ -n "$u" ] && echo "$u" + done >"$WORKDIR/usernames.txt" || true + ACCOUNTS_N=$(grep -cve '^\s*$' "$WORKDIR/usernames.txt" 2>/dev/null || echo 0) + ACCOUNTS_USERS=$(grep -Eve '^(admin|exchange)$' "$WORKDIR/usernames.txt" 2>/dev/null | grep -cve '^\s*$' || echo 0) +else + echo "$BANK_USER" >"$WORKDIR/usernames.txt" + ACCOUNTS_N=1 + ACCOUNTS_USERS=1 +fi + +# --- scan txs: distinguish incoming (credit) vs withdraw (Taler debit) --- +# all-wd.tsv: amount \t t_s \t subject \t username \t reserve +# all-in.tsv: amount \t t_s \t subject \t username +# flow.tsv: kind \t amount_num kind = incoming | withdraw | other_out +: >"$WORKDIR/all-wd.tsv" +: >"$WORKDIR/all-in.tsv" +: >"$WORKDIR/flow.tsv" +SCAN_OK=0 +SCAN_EMPTY=0 +SCAN_FAIL=0 +# Scan customer accounts only (not exchange/admin — exchange credits would double-count) +{ + echo "$BANK_USER" + grep -Eve "^(admin|exchange|${BANK_USER})$" "$WORKDIR/usernames.txt" 2>/dev/null || true +} | awk 'NF && !seen[$0]++' | head -n "$MAX_SCAN_ACCOUNTS" >"$WORKDIR/scan-users.txt" + +while IFS= read -r uname; do + [ -n "$uname" ] || continue + case "$uname" in admin|exchange) continue ;; esac + # Safe filename (usernames are mostly [A-Za-z0-9_-]) + safe=$(printf '%s' "$uname" | tr -c 'A-Za-z0-9._-' '_') + code=$(curl -sS -m "${TX_CURL_TIMEOUT}" -o "$WORKDIR/tx-${safe}.json" -w '%{http_code}' \ + -H "Authorization: Bearer ${AUTH_TOKEN}" \ + "${BANK}/accounts/${uname}/transactions?delta=${TX_DELTA}" 2>/dev/null || echo 000) + # 204 / empty body: account with no transactions — normal, not an error + if [ "$code" = "204" ] || [ ! -s "$WORKDIR/tx-${safe}.json" ]; then + SCAN_EMPTY=$((SCAN_EMPTY + 1)) + continue + fi + if [ "$code" != "200" ]; then + SCAN_FAIL=$((SCAN_FAIL + 1)) + continue + fi + SCAN_OK=$((SCAN_OK + 1)) + tr -d '\n' <"$WORKDIR/tx-${safe}.json" \ + | sed 's/},{/}\n{/g' \ + >"$WORKDIR/tx-lines.txt" 2>/dev/null || continue + awk -v uname="$uname" -v flowf="$WORKDIR/flow.tsv" -v inf="$WORKDIR/all-in.tsv" ' + function between(s, key, p, rest, q2, r) { + p = index(s, "\"" key "\"") + if (p == 0) return "" + rest = substr(s, p + length(key) + 2) + while (rest ~ /^[[:space:]:]/) rest = substr(rest, 2) + if (substr(rest, 1, 1) == "\"") { + rest = substr(rest, 2) + q2 = index(rest, "\"") + if (q2 == 0) return "" + return substr(rest, 1, q2 - 1) + } + r = "" + while (rest ~ /^[0-9]/) { + r = r substr(rest, 1, 1) + rest = substr(rest, 2) + } + return r + } + function amt_n(a, p) { + p = index(a, ":") + if (p == 0) return 0 + return substr(a, p + 1) + 0 + } + { + line = $0 + dir = between(line, "direction") + amt = between(line, "amount") + ts = between(line, "t_s") + subj = between(line, "subject") + low = tolower(subj) + if (amt == "") next + n = amt_n(amt) + if (dir == "credit") { + # Incoming: bank credits into customer accounts (admin top-up, transfers in) + print "incoming\t" n >> flowf + printf "%s\t%s\t%s\t%s\n", amt, ts, subj, uname >> inf + } else if (dir == "debit" && index(low, "withdraw") > 0) { + # Withdraw: Taler withdrawal debit → exchange → wallet coins + print "withdraw\t" n >> flowf + res = subj + sub(/^.*[Ww]ithdrawal[ ]+/, "", res) + gsub(/[^A-Za-z0-9]/, "", res) + printf "%s\t%s\t%s\t%s\t%s\n", amt, ts, subj, uname, res + } else if (dir == "debit") { + # Other debits (non-withdraw transfers) + print "other_out\t" n >> flowf + } + } + ' "$WORKDIR/tx-lines.txt" >>"$WORKDIR/all-wd.tsv" 2>/dev/null || true +done <"$WORKDIR/scan-users.txt" + +# Sum by kind +TOTAL_IN_N=$(awk -F'\t' '$1=="incoming"{s+=$2} END{printf "%.8f", s+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0) +TOTAL_WD_FLOW_N=$(awk -F'\t' '$1=="withdraw"{s+=$2} END{printf "%.8f", s+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0) +TOTAL_OTHER_OUT_N=$(awk -F'\t' '$1=="other_out"{s+=$2} END{printf "%.8f", s+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0) +N_INCOMING=$(awk -F'\t' '$1=="incoming"{c++} END{print c+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0) +# total out = withdraw + other debits +TOTAL_OUT_N=$(awk -v a="${TOTAL_WD_FLOW_N:-0}" -v b="${TOTAL_OTHER_OUT_N:-0}" 'BEGIN{printf "%.8f", a+b}') + +# Sort by t_s descending +sort -t$'\t' -k2,2nr "$WORKDIR/all-wd.tsv" -o "$WORKDIR/all-wd-sorted.tsv" 2>/dev/null \ + || cp "$WORKDIR/all-wd.tsv" "$WORKDIR/all-wd-sorted.tsv" +sort -t$'\t' -k2,2nr "$WORKDIR/all-in.tsv" -o "$WORKDIR/all-in-sorted.tsv" 2>/dev/null \ + || cp "$WORKDIR/all-in.tsv" "$WORKDIR/all-in-sorted.tsv" + +# Unique reserves = individual wallet withdraws (each wallet reserve_pub) +WALLETS_N=$(awk -F'\t' '$5!=""{print $5}' "$WORKDIR/all-wd-sorted.tsv" | sort -u | grep -cve '^\s*$' || echo 0) +# Accounts that funded at least one withdraw +ACCOUNTS_WITH_WD=$(awk -F'\t' '$4!=""{print $4}' "$WORKDIR/all-wd-sorted.tsv" | sort -u | grep -cve '^\s*$' || echo 0) + +# Aggregates +NOW=$(now_unix) +GEN_ISO=$(now_iso) +GEN_HUMAN=$(date +"%Y-%m-%d %H:%M %Z") + +# Build recent withdraws JSON array (up to 10) with CEST times — landing shows all 10 +RECENT_WD_N="${RECENT_WD_N:-10}" +: >"$WORKDIR/recent.jsonl" +N_WD=0 +TOTAL_WD=0 +W24=0; N24=0; W7=0; N7=0 +DAY=$((NOW - 86400)) +WEEK=$((NOW - 7 * 86400)) +LAST_AMT=""; LAST_TS=""; LAST_SUBJ="" + +while IFS=$'\t' read -r amt ts subj uname res; do + [ -n "$amt" ] || continue + n=$(printf '%s' "$amt" | awk -F: '{print $2+0}') + N_WD=$((N_WD + 1)) + TOTAL_WD=$(awk -v a="$TOTAL_WD" -v b="$n" 'BEGIN{printf "%.8f", a+b}') + ts_n=${ts:-0} + if [ "$ts_n" -ge "$DAY" ] 2>/dev/null; then + W24=$(awk -v a="$W24" -v b="$n" 'BEGIN{printf "%.8f", a+b}') + N24=$((N24 + 1)) + fi + if [ "$ts_n" -ge "$WEEK" ] 2>/dev/null; then + W7=$(awk -v a="$W7" -v b="$n" 'BEGIN{printf "%.8f", a+b}') + N7=$((N7 + 1)) + fi + if [ -z "$LAST_AMT" ]; then + LAST_AMT=$amt + LAST_TS=$ts_n + LAST_SUBJ=$subj + fi + if [ "$N_WD" -le "$RECENT_WD_N" ]; then + at_h=""; at_iso="" + if [ -n "$ts_n" ] && [ "$ts_n" != "0" ]; then + at_h=$(human_from_unix "$ts_n") + at_iso=$(iso_from_unix "$ts_n") + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$amt" "$ts_n" "$at_h" "$at_iso" "$uname" "$res" >>"$WORKDIR/recent.jsonl" + fi +done <"$WORKDIR/all-wd-sorted.tsv" + +fmt_goa() { + awk -v n="$1" 'BEGIN{ + if (n+0 == int(n+0)) printf "GOA:%d", int(n+0); + else printf "GOA:%.8g", n+0; + }' +} + +TOTAL_AMT=$(fmt_goa "$TOTAL_WD") +TOTAL_IN_AMT=$(fmt_goa "${TOTAL_IN_N:-0}") +TOTAL_OUT_AMT=$(fmt_goa "${TOTAL_OUT_N:-0}") +TOTAL_WD_FLOW_AMT=$(fmt_goa "${TOTAL_WD_FLOW_N:-0}") +TOTAL_OTHER_OUT_AMT=$(fmt_goa "${TOTAL_OTHER_OUT_N:-0}") +W24_AMT=$(fmt_goa "$W24") +W7_AMT=$(fmt_goa "$W7") + +LAST_AT_H=""; LAST_AT_ISO="" +if [ -n "${LAST_TS:-}" ] && [ "$LAST_TS" != "0" ]; then + LAST_AT_H=$(human_from_unix "$LAST_TS") + LAST_AT_ISO=$(iso_from_unix "$LAST_TS") +fi + +# recent_withdraws JSON +RECENT_JSON="[" +first=1 +while IFS=$'\t' read -r amt ts_n at_h at_iso uname res; do + [ -n "$amt" ] || continue + if [ "$first" = 1 ]; then first=0; else RECENT_JSON="${RECENT_JSON},"; fi + RECENT_JSON="${RECENT_JSON} + { + \"kind\": \"withdraw\", + \"amount\": $(json_str "$amt"), + \"at\": $(json_str "$at_h"), + \"at_iso\": $(json_str "$at_iso"), + \"at_unix\": ${ts_n:-null}, + \"account\": $(json_str "$uname"), + \"reserve\": $(json_str "$res") + }" +done <"$WORKDIR/recent.jsonl" +RECENT_JSON="${RECENT_JSON} + ]" + +# recent incoming (up to 5) +: >"$WORKDIR/recent-in.jsonl" +N_IN_LIST=0 +while IFS=$'\t' read -r amt ts subj uname; do + [ -n "$amt" ] || continue + N_IN_LIST=$((N_IN_LIST + 1)) + [ "$N_IN_LIST" -le 5 ] || break + ts_n=${ts:-0} + at_h=""; at_iso="" + if [ -n "$ts_n" ] && [ "$ts_n" != "0" ]; then + at_h=$(human_from_unix "$ts_n") + at_iso=$(iso_from_unix "$ts_n") + fi + printf '%s\t%s\t%s\t%s\t%s\n' "$amt" "$ts_n" "$at_h" "$at_iso" "$uname" >>"$WORKDIR/recent-in.jsonl" +done <"$WORKDIR/all-in-sorted.tsv" + +RECENT_IN_JSON="[" +first=1 +while IFS=$'\t' read -r amt ts_n at_h at_iso uname; do + [ -n "$amt" ] || continue + if [ "$first" = 1 ]; then first=0; else RECENT_IN_JSON="${RECENT_IN_JSON},"; fi + RECENT_IN_JSON="${RECENT_IN_JSON} + { + \"kind\": \"incoming\", + \"amount\": $(json_str "$amt"), + \"at\": $(json_str "$at_h"), + \"at_iso\": $(json_str "$at_iso"), + \"at_unix\": ${ts_n:-null}, + \"account\": $(json_str "$uname") + }" +done <"$WORKDIR/recent-in.jsonl" +RECENT_IN_JSON="${RECENT_IN_JSON} + ]" + +# Balance of explorer (optional display not required in footer) +BALANCE="GOA:0" +if [ -n "${USER_TOKEN:-$AUTH_TOKEN}" ]; then + curl -sS -m 10 -H "Authorization: Bearer ${USER_TOKEN:-$AUTH_TOKEN}" \ + "${BANK}/accounts/${BANK_USER}" >"$WORKDIR/acct.json" || true + BALANCE=$(awk -F'"' '/"amount"/ { + for (i=1;i<=NF;i++) if ($i=="amount") { print $(i+2); exit } + }' "$WORKDIR/acct.json" 2>/dev/null || echo "GOA:0") +fi + +# Demo block kept for page QR logic only (not shown in footer) +DEMO_URI=""; DEMO_AMT=""; DEMO_CREATED=""; DEMO_WID=""; DEMO_STATUS="" +[ -f "$LANDING_DIR/withdraw.uri" ] && DEMO_URI=$(tr -d '\n' <"$LANDING_DIR/withdraw.uri") +[ -f "$LANDING_DIR/withdraw.amount" ] && DEMO_AMT=$(tr -d '\n' <"$LANDING_DIR/withdraw.amount") +[ -f "$LANDING_DIR/withdraw.created" ] && DEMO_CREATED=$(tr -d '\n' <"$LANDING_DIR/withdraw.created") +if [ -n "$DEMO_URI" ]; then + DEMO_WID=$(basename "$DEMO_URI") + curl -sS -m 8 \ + "${BANK}/taler-integration/withdrawal-operation/${DEMO_WID}" \ + >"$WORKDIR/demo-wd.json" 2>/dev/null || true + DEMO_STATUS=$(awk -F'"' '/"status"/ { + for (i=1;i<=NF;i++) if ($i=="status") { print $(i+2); exit } + }' "$WORKDIR/demo-wd.json" 2>/dev/null || true) +fi +DEMO_READY=false +case "$DEMO_STATUS" in + pending|selected) DEMO_READY=true ;; + "") [ -n "$DEMO_URI" ] && DEMO_READY=true ;; +esac +[ "$DEMO_STATUS" = "confirmed" ] && DEMO_READY=false +[ "$DEMO_STATUS" = "aborted" ] && DEMO_READY=false + +# Sanity: never publish an empty-looking success if admin scan should have accounts +if [ -n "$ADMIN_TOKEN" ] && [ "${ACCOUNTS_N:-0}" = "0" ]; then + write_err "accounts list empty after admin scan" + exit 1 +fi + +# Live performance probes (same idea as exchange landing-stats) +# Milliseconds as integer, rounded (not truncated) so sub-ms loopback does not +# always show 0 when we fall back to in-container URLs. +measure_ms() { + local url="$1" t + t=$(curl -sS -o /dev/null -m 8 -w '%{time_total}' "$url" 2>/dev/null || echo "") + [ -z "$t" ] && { echo "null"; return; } + awk -v t="$t" 'BEGIN{ + ms = (t+0)*1000 + if (ms > 0 && ms < 1) ms = 1 + printf "%d", int(ms + 0.5) + }' +} +num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; } +# Prefer public URL for real client latency (Caddy → bank); fall back to loopback. +# Integration used to probe only $BANK (127.0.0.1:9012) → ~0–1 ms and not comparable +# to /config which already used the public host. +BANK_PUBLIC_BASE="${BANK_PUBLIC_URL:-https://bank.hacktivism.ch}" +CONFIG_MS=$(measure_ms "${BANK_PUBLIC_BASE}/config") +CONFIG_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK_PUBLIC_BASE}/config" 2>/dev/null || echo "000") +if [ "$CONFIG_HTTP" != "200" ]; then + CONFIG_MS=$(measure_ms "${BANK}/config") + CONFIG_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK}/config" 2>/dev/null || echo "000") +fi +INT_MS=$(measure_ms "${BANK_PUBLIC_BASE}/taler-integration/config") +INT_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK_PUBLIC_BASE}/taler-integration/config" 2>/dev/null || echo "000") +if [ "$INT_HTTP" != "200" ]; then + INT_MS=$(measure_ms "${BANK}/taler-integration/config") + INT_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK}/taler-integration/config" 2>/dev/null || echo "000") +fi +WEBUI_MS=$(measure_ms "${BANK_PUBLIC_BASE}/webui/") +WEBUI_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK_PUBLIC_BASE}/webui/" 2>/dev/null || echo "000") +LOADAVG="" +[ -r /proc/loadavg ] && LOADAVG=$(awk '{print $1","$2","$3}' /proc/loadavg) + +MEM_JSON='"container_rss_human": "—"' +MEM_HELPER="${MEM_HELPER:-/usr/local/lib/landing-mem-snapshot.sh}" +if [ -f "$MEM_HELPER" ]; then + # shellcheck disable=SC1090 + . "$MEM_HELPER" + mem_snapshot_json || true +fi + +cat >"$TMP" <<EOF +{ + "ok": true, + "currency": "GOA", + "timezone": $(json_str "$TZ"), + "generated_at": $(json_str "$GEN_ISO"), + "generated_at_human": $(json_str "$GEN_HUMAN"), + "generated_at_unix": $NOW, + "source": "in-container landing-stats.sh", + "bank_url": $(json_str "$BANK"), + "scan": { + "tx_delta": $(json_str "$TX_DELTA"), + "accounts_delta": $(json_str "$ACCOUNTS_DELTA"), + "max_scan_accounts": ${MAX_SCAN_ACCOUNTS:-0}, + "accounts_listed": ${ACCOUNTS_N:-0}, + "accounts_scanned_ok": ${SCAN_OK:-0}, + "accounts_empty_tx": ${SCAN_EMPTY:-0}, + "accounts_scan_fail": ${SCAN_FAIL:-0}, + "note": "empty_tx includes HTTP 204 (no ledger rows) — normal for new auto-accounts" + }, + "bank_accounts": { + "total": ${ACCOUNTS_N:-0}, + "users": ${ACCOUNTS_USERS:-0}, + "with_withdraws": ${ACCOUNTS_WITH_WD:-0} + }, + "wallets": { + "unique_reserves": ${WALLETS_N:-0}, + "note": "unique reserve pubs from Taler withdrawals (one per wallet withdraw)" + }, + "balance_explorer": $(json_str "$BALANCE"), + "flow": { + "incoming": { + "label": "Incoming bank credits", + "count": ${N_INCOMING:-0}, + "amount": $(json_str "$TOTAL_IN_AMT"), + "value": ${TOTAL_IN_N:-0} + }, + "withdraw": { + "label": "Taler withdrawals to wallets", + "count": ${N_WD:-0}, + "amount": $(json_str "$TOTAL_WD_FLOW_AMT"), + "value": ${TOTAL_WD_FLOW_N:-0} + }, + "other_out": { + "label": "Other debits (non-withdraw)", + "amount": $(json_str "$TOTAL_OTHER_OUT_AMT"), + "value": ${TOTAL_OTHER_OUT_N:-0} + }, + "total_in": $(json_str "$TOTAL_IN_AMT"), + "total_in_value": ${TOTAL_IN_N:-0}, + "total_out": $(json_str "$TOTAL_OUT_AMT"), + "total_out_value": ${TOTAL_OUT_N:-0}, + "note": "incoming=credits; withdraw=Taler withdrawal debits; excl. admin+exchange accounts" + }, + "withdraws": { + "count": ${N_WD:-0}, + "total_amount": $(json_str "$TOTAL_AMT"), + "total_value": ${TOTAL_WD:-0}, + "last_amount": $( [ -n "$LAST_AMT" ] && json_str "$LAST_AMT" || echo null ), + "last_at": $( [ -n "$LAST_AT_H" ] && json_str "$LAST_AT_H" || echo null ), + "last_at_iso": $( [ -n "$LAST_AT_ISO" ] && json_str "$LAST_AT_ISO" || echo null ), + "last_at_unix": ${LAST_TS:-null}, + "last_subject": $( [ -n "$LAST_SUBJ" ] && json_str "$LAST_SUBJ" || echo null ), + "last_24h": { "count": ${N24:-0}, "amount": $(json_str "$W24_AMT"), "value": ${W24:-0} }, + "last_7d": { "count": ${N7:-0}, "amount": $(json_str "$W7_AMT"), "value": ${W7:-0} } + }, + "recent_withdraws": $RECENT_JSON, + "recent_incoming": $RECENT_IN_JSON, + "demo": { + "uri": $( [ -n "$DEMO_URI" ] && json_str "$DEMO_URI" || echo null ), + "amount": $( [ -n "$DEMO_AMT" ] && json_str "$DEMO_AMT" || echo null ), + "created": $( [ -n "$DEMO_CREATED" ] && json_str "$DEMO_CREATED" || echo null ), + "withdrawal_id": $( [ -n "$DEMO_WID" ] && json_str "$DEMO_WID" || echo null ), + "status": $( [ -n "$DEMO_STATUS" ] && json_str "$DEMO_STATUS" || echo null ), + "ready": $DEMO_READY + }, + "performance": { + "config_http": $(json_str "$CONFIG_HTTP"), + "config_ms": $(num_or_null "$CONFIG_MS"), + "integration_http": $(json_str "$INT_HTTP"), + "integration_ms": $(num_or_null "$INT_MS"), + "webui_http": $(json_str "$WEBUI_HTTP"), + "webui_ms": $(num_or_null "$WEBUI_MS"), + "loadavg": $(json_str "${LOADAVG:-}"), + "memory": { +${MEM_JSON} + } + } +} +EOF +grep -q '"ok": true' "$TMP" || { write_err "tmp json missing ok:true"; exit 1; } +mv -f "$TMP" "$OUT" +write_run true +echo "ok accounts=${ACCOUNTS_N} wallets=${WALLETS_N} withdraws=${N_WD} total=${TOTAL_AMT} tz=${TZ} -> $OUT" diff --git a/scripts/taler-bank/make-demo-withdraw-qr.sh b/scripts/taler-bank/make-demo-withdraw-qr.sh new file mode 100755 index 0000000..f7d96f0 --- /dev/null +++ b/scripts/taler-bank/make-demo-withdraw-qr.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# Create a demo GOA withdrawal for user explorer and write QR assets under LANDING_DIR. +# Run on koopa (host) with bank on 127.0.0.1:9012. +set -euo pipefail + +BANK="${BANK_URL:-http://127.0.0.1:9012}" +USER="${BANK_USER:-explorer}" +PASS="${BANK_PASS:-}" +AMOUNT="${AMOUNT:-GOA:10}" +LANDING_DIR="${LANDING_DIR:-/var/www/bank-landing}" + +if [ -z "$PASS" ]; then + if [ -f /root/bank-explorer-password.txt ]; then + PASS=$(tr -d '\n' </root/bank-explorer-password.txt) + elif [ -f /tmp/bank-caddy-wire.log ]; then + PASS=$(grep -E '^explorer=' /tmp/bank-caddy-wire.log | tail -1 | cut -d= -f2-) + fi +fi +if [ -z "$PASS" ]; then + echo "Set BANK_PASS or put password in /root/bank-explorer-password.txt" >&2 + exit 1 +fi + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +echo "{\"scope\":\"readwrite\",\"refreshable\":true}" >"$WORKDIR/tok.json" +curl -sS -m 15 -u "${USER}:${PASS}" \ + -H "Content-Type: application/json" \ + -d @"$WORKDIR/tok.json" \ + "${BANK}/accounts/${USER}/token" >"$WORKDIR/tok.out" + +TOKEN=$(python3 - "$WORKDIR/tok.out" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +print(d.get("access_token","")) +PY +) +if [ -z "$TOKEN" ]; then + echo "token failed:" >&2 + cat "$WORKDIR/tok.out" >&2 + exit 1 +fi + +echo "{\"suggested_amount\":\"${AMOUNT}\"}" >"$WORKDIR/wd.json" +curl -sS -m 15 \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d @"$WORKDIR/wd.json" \ + "${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") + sys.exit(1) +print(d.get("withdrawal_id",""), file=sys.stderr) +PY +) + +mkdir -p "$LANDING_DIR" +printf '%s\n' "$URI" >"$LANDING_DIR/withdraw.uri" +printf '%s\n' "$AMOUNT" >"$LANDING_DIR/withdraw.amount" +date -u +%Y-%m-%dT%H:%MZ >"$LANDING_DIR/withdraw.created" +# track id for auto-confirm-withdrawals.sh +WID=$(basename "$URI") +echo "$WID" >>"$LANDING_DIR/withdraw-watch.ids" +sort -u "$LANDING_DIR/withdraw-watch.ids" -o "$LANDING_DIR/withdraw-watch.ids" +echo "watch_id=$WID (auto-confirm when wallet selects)" + +# QR as SVG (no external deps) via python qrcode if present, else pure matrix via segno/qrcode +python3 - "$URI" "$LANDING_DIR/withdraw-qr.svg" <<'PY' +import sys +uri, out = sys.argv[1], sys.argv[2] +try: + import qrcode + import qrcode.image.svg + img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage) + img.save(out) + print("qrcode lib ok") +except Exception as e: + # minimal fallback: write HTML with data attribute for client-side QR + open(out, "w").write( + f'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" width="8" height="8">' + f'<!-- QR_FALLBACK uri={uri} --></svg>\n' + ) + open(out + ".uri", "w").write(uri) + print("fallback:", e) +PY + +# Also PNG if pillow/qrcode available +python3 - "$URI" "$LANDING_DIR/withdraw-qr.png" <<'PY' || true +import sys +uri, out = sys.argv[1], sys.argv[2] +import qrcode +img = qrcode.make(uri, box_size=8, border=2) +img.save(out) +print("png ok", out) +PY + +echo "URI=$URI" +echo "wrote under $LANDING_DIR" +ls -la "$LANDING_DIR"/withdraw* 2>/dev/null || true + +# Refresh public stats.json when landing-stats is available (in-container path) +if [ -x /usr/local/bin/landing-stats.sh ]; then + LANDING_DIR="$LANDING_DIR" /usr/local/bin/landing-stats.sh || true +elif [ -x "$(dirname "$0")/landing-stats.sh" ]; then + LANDING_DIR="$LANDING_DIR" "$(dirname "$0")/landing-stats.sh" || true +fi diff --git a/scripts/taler-bank/refresh-demo-withdraw.sh b/scripts/taler-bank/refresh-demo-withdraw.sh new file mode 100755 index 0000000..7e23454 --- /dev/null +++ b/scripts/taler-bank/refresh-demo-withdraw.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Create a fresh demo GOA withdraw for landing QR (no python — runs in bank container). +# Writes under LANDING_DIR (default /var/www/bank-landing). +# +# Inside container: +# /usr/local/bin/refresh-demo-withdraw.sh +# Host: +# podman exec taler-hacktivism-bank /usr/local/bin/refresh-demo-withdraw.sh +set -euo pipefail + +BANK="${BANK_URL:-http://127.0.0.1:9012}" +USER="${BANK_USER:-explorer}" +AMOUNT="${AMOUNT:-GOA:10}" +LANDING_DIR="${LANDING_DIR:-/var/www/bank-landing}" +PASS="${BANK_PASS:-}" + +if [ -z "$PASS" ]; then + for f in "/root/bank-${USER}-password.txt" /root/bank-explorer-password.txt; do + if [ -f "$f" ]; then PASS=$(tr -d '\n' <"$f"); break; fi + done +fi +[ -n "$PASS" ] || { echo "Set BANK_PASS or /root/bank-explorer-password.txt" >&2; exit 1; } + +BANK="${BANK%/}" +TOK=$(curl -sS -m 12 -u "${USER}:${PASS}" \ + -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite","refreshable":true}' \ + "${BANK}/accounts/${USER}/token") +TOKEN=$(printf '%s' "$TOK" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +[ -n "$TOKEN" ] || { echo "token fail: $TOK" >&2; exit 1; } + +WD=$(curl -sS -m 15 \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "{\"suggested_amount\":\"${AMOUNT}\"}" \ + "${BANK}/accounts/${USER}/withdrawals") +URI=$(printf '%s' "$WD" | sed -n 's/.*"taler_withdraw_uri"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +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") + +mkdir -p "$LANDING_DIR" +printf '%s\n' "$URI" >"$LANDING_DIR/withdraw.uri" +printf '%s\n' "$AMOUNT" >"$LANDING_DIR/withdraw.amount" +date -u +%Y-%m-%dT%H:%MZ >"$LANDING_DIR/withdraw.created" +echo "$WID" >>"$LANDING_DIR/withdraw-watch.ids" +sort -u "$LANDING_DIR/withdraw-watch.ids" -o "$LANDING_DIR/withdraw-watch.ids" 2>/dev/null || true + +echo "URI=$URI" +echo "WID=$WID" + +if [ -x /usr/local/bin/landing-stats.sh ]; then + LANDING_DIR="$LANDING_DIR" /usr/local/bin/landing-stats.sh || true +fi diff --git a/scripts/taler-bank/start_bank.sh b/scripts/taler-bank/start_bank.sh new file mode 100755 index 0000000..bbd812d --- /dev/null +++ b/scripts/taler-bank/start_bank.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# Start / restart libeufin-bank serve (manual, no systemd). +# Run as: libeufin-bank +# Same role as start_merchant.sh / start_exchange.sh. +# +# Container: taler-hacktivism-bank +# Prerequisite: /root/start_base_services_for_taler_bank.sh (as root) for postgres. +# +# Usage: +# start_bank.sh +# start_bank.sh --restart | -r +# start_bank.sh --help + +set -u + +usage() { + cat <<'EOF' +Usage: start_bank.sh [--restart|-r] [--help|-h] + + (default) Start libeufin-bank serve if not already running. + --restart Stop live serve process, then start cleanly. + Does not touch postgres (use /root/start_base_services_for_taler_bank.sh). +EOF +} + +DO_RESTART=0 +for arg in "$@"; do + case "$arg" in + --restart|-r) DO_RESTART=1 ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown option: $arg" >&2; usage >&2; exit 2 ;; + esac +done + +if [ "$(id -un)" != "libeufin-bank" ]; then + echo "This script must be run as user libeufin-bank" >&2 + exit 1 +fi + +CONF=/etc/libeufin/libeufin-bank.conf +LOG_DIR=/var/log/libeufin-bank +PORT=$(grep -E '^\s*PORT\s*=' /etc/libeufin/bank-overrides.conf 2>/dev/null | tail -1 | awk -F= '{gsub(/ /,"",$2); print $2}') +PORT=${PORT:-9012} + +# Match both wrapper name and the Java MainKt process that actually listens. +list_serve_pids() { + ps -eo pid=,stat=,args= 2>/dev/null | while read -r pid stat args; do + case "$stat" in Z*) continue ;; esac + case "$args" in + *start_bank.sh*) continue ;; + *check_bank-health*) continue ;; + esac + case "$args" in + *libeufin-bank\ serve*|/usr/bin/libeufin-bank\ serve*|*MainKt\ serve*|*tech.libeufin.bank.MainKt*) + echo "$pid" + ;; + esac + done | sort -u +} + +kill_serve() { + local pids + pids=$(list_serve_pids | tr '\n' ' ') + if [ -z "${pids// }" ]; then + echo "No live libeufin-bank serve to stop." + return 0 + fi + echo "Stopping PIDs: $pids" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 2 + local left + left=$(list_serve_pids | tr '\n' ' ') + if [ -n "${left// }" ]; then + echo "SIGKILL remaining: $left" + # shellcheck disable=SC2086 + kill -KILL $left 2>/dev/null || true + sleep 1 + fi + echo "libeufin-bank serve stopped." +} + +if [ "$DO_RESTART" -eq 1 ]; then + echo "=== restart: kill libeufin-bank serve ===" + kill_serve +fi + +echo "Start libeufin-bank serve (port $PORT):" +LOG_FILE="$LOG_DIR/libeufin-bank-$(date +%Y-%m-%d).log" +mkdir -p "$LOG_DIR" +touch "$LOG_FILE" 2>/dev/null || true + +if [ "$DO_RESTART" -eq 0 ] && [ -n "$(list_serve_pids)" ]; then + echo "libeufin-bank serve already running" +else + nohup libeufin-bank serve -c "$CONF" >>"$LOG_FILE" 2>&1 & + disown 2>/dev/null || true + sleep 2 +fi + +echo "Live processes:" +ps -eo pid,stat,args 2>/dev/null | grep -E 'libeufin-bank serve|MainKt serve' | grep -v grep | grep -v ' Z ' || true + +if [ -x /usr/local/bin/check_bank-health.sh ]; then + /usr/local/bin/check_bank-health.sh || exit 1 +elif [ -x ./check_bank-health.sh ]; then + ./check_bank-health.sh || exit 1 +fi +exit 0 diff --git a/scripts/taler-bank/start_base_services_for_taler_bank.sh b/scripts/taler-bank/start_base_services_for_taler_bank.sh new file mode 100755 index 0000000..e8873bb --- /dev/null +++ b/scripts/taler-bank/start_base_services_for_taler_bank.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# Root: base services for manual libeufin-bank (like merchant/exchange start_base). +# Then interactive shell as libeufin-bank → run start_bank.sh there. +# +# Container: taler-hacktivism-bank +# +# Usage: +# /root/start_base_services_for_taler_bank.sh +# /root/start_base_services_for_taler_bank.sh --no-shell +# /root/start_base_services_for_taler_bank.sh --no-shell --start-bank +# /root/start_base_services_for_taler_bank.sh --no-shell --start-bank --restart + +set -e +CONF=/etc/libeufin/libeufin-bank.conf +LOG_DIR=/var/log/libeufin-bank +PWD_BIN=/usr/local/bin +BANK_STARTER=start_bank.sh +BANK_USER=libeufin-bank + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root" >&2 + exit 1 +fi + +NO_SHELL=0 +START_BANK=0 +BANK_RESTART=0 +for arg in "$@"; do + case "$arg" in + --no-shell|-n) NO_SHELL=1 ;; + --start-bank) START_BANK=1; NO_SHELL=1 ;; + --restart|-r) BANK_RESTART=1 ;; + --help|-h) + cat <<'EOF' +Usage: start_base_services_for_taler_bank.sh [options] + + (default) Start postgres + dirs, then interactive shell as libeufin-bank + --no-shell Start base services only, do not open a shell + --start-bank After base services, run /usr/local/bin/start_bank.sh as libeufin-bank + (implies --no-shell) + --restart With --start-bank: pass --restart to start_bank.sh +EOF + exit 0 + ;; + *) + echo "Unknown option: $arg" >&2 + exit 2 + ;; + esac +done + +# --- Debian postgresql defaults (pg_createcluster layout) --- +ensure_postgresql() { + echo " Debian perms on /etc/postgresql + data/log/run..." + if [ -d /etc/postgresql ]; then + chown -R root:postgres /etc/postgresql + find /etc/postgresql -type d -exec chmod 755 {} \; + find /etc/postgresql -type f -name '*.conf' -exec chmod 640 {} \; + fi + chown -R postgres:postgres /var/lib/postgresql /var/log/postgresql 2>/dev/null || true + mkdir -p /var/run/postgresql + chown postgres:postgres /var/run/postgresql + chmod 2775 /var/run/postgresql 2>/dev/null || chmod 775 /var/run/postgresql + + # Snakeoil TLS key: postgres fails if it cannot read the key (common in rootless images). + # Prefer fixing perms; if still broken, force ssl=off for local-only DB. + if [ -f /etc/ssl/private/ssl-cert-snakeoil.key ]; then + chown root:ssl-cert /etc/ssl/private/ssl-cert-snakeoil.key 2>/dev/null || true + chmod 640 /etc/ssl/private/ssl-cert-snakeoil.key 2>/dev/null || true + fi + for pgconf in /etc/postgresql/*/main/postgresql.conf; do + [ -f "$pgconf" ] || continue + if grep -qE '^\s*ssl\s*=' "$pgconf"; then + sed -i 's/^\s*ssl\s*=.*/ssl = off/' "$pgconf" + else + echo "ssl = off" >>"$pgconf" + fi + done + + if pg_isready -q 2>/dev/null; then + echo " already accepting connections" + pg_isready || true + return 0 + fi + + rm -f /var/run/postgresql/.s.PGSQL.*.lock 2>/dev/null || true + if ! pgrep -u postgres -x postgres >/dev/null 2>&1; then + rm -f /var/lib/postgresql/*/main/postmaster.pid 2>/dev/null || true + fi + + if command -v pg_ctlcluster >/dev/null 2>&1 && command -v pg_lsclusters >/dev/null 2>&1; then + while read -r ver name _rest; do + [ -n "$ver" ] || continue + echo " pg_ctlcluster $ver $name start" + pg_ctlcluster "$ver" "$name" start 2>/dev/null || true + done < <(pg_lsclusters --no-header 2>/dev/null || true) + fi + if ! pg_isready -q 2>/dev/null; then + if [ -x /etc/init.d/postgresql ]; then + /etc/init.d/postgresql start || true + else + service postgresql start || true + fi + fi + sleep 1 + pg_isready || true +} + +echo "Create log + data dirs... giving permission to ${BANK_USER}:" +mkdir -p "$LOG_DIR" /var/lib/libeufin-bank +chown "${BANK_USER}:${BANK_USER}" "$LOG_DIR" /var/lib/libeufin-bank +chmod 755 "$LOG_DIR" /var/lib/libeufin-bank + +echo "Start base services needed for GOA Exploration Bank." +echo "" + +echo "1. postgresql:" +ensure_postgresql +# ensure role + DB (postgres:///libeufin) — Debian createuser/createdb as postgres +su -s /bin/bash postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='libeufin-bank'\" | grep -q 1 || createuser -s libeufin-bank" || true +su -s /bin/bash postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='libeufin'\" | grep -q 1 || createdb -O libeufin-bank libeufin" || true +pg_isready || true + +if [ "$START_BANK" -eq 1 ]; then + echo "" + echo "2. start_bank.sh as ${BANK_USER}:" + if [ ! -x "$PWD_BIN/$BANK_STARTER" ]; then + echo "missing $PWD_BIN/$BANK_STARTER" >&2 + exit 1 + fi + if [ "$BANK_RESTART" -eq 1 ]; then + runuser -u "$BANK_USER" -- "$PWD_BIN/$BANK_STARTER" --restart + else + runuser -u "$BANK_USER" -- "$PWD_BIN/$BANK_STARTER" + fi + exit $? +fi + +if [ "$NO_SHELL" -eq 1 ]; then + echo "Base services started (--no-shell). Next: runuser -u ${BANK_USER} -- $PWD_BIN/$BANK_STARTER [--restart]" + exit 0 +fi + +echo "2. Switching now to user ${BANK_USER}, in $PWD_BIN; find executable $BANK_STARTER there!" +echo "" +cd "$PWD_BIN" +# util-linux: -u and -s are mutually exclusive (same as exchange/merchant) +exec runuser -u "$BANK_USER" -- bash diff --git a/scripts/taler-dns/README.md b/scripts/taler-dns/README.md new file mode 100644 index 0000000..e77a83c --- /dev/null +++ b/scripts/taler-dns/README.md @@ -0,0 +1,48 @@ +# Container DNS pins (hacktivism.ch) + +Pasta networking often has **no usable DNS**. Wirewatch needs: + +```text +https://bank.hacktivism.ch/accounts/exchange/taler-wire-gateway/ +``` + +## Bake into images (preferred) + +On koopa: + +```bash +# copy bake assets, then: +SRC_DIR=/tmp/taler-dns-bake ./bake-hosts-into-images.sh +``` + +This: + +1. Installs `/usr/local/bin/pin-hacktivism-hosts` **inside** each container +2. Hooks `start_base_services_for_taler*.sh` (bank + merchant) +3. Enables systemd `pin-hacktivism-hosts.service` (exchange, before wirewatch) +4. **Commits** images: + - `localhost/taler-hacktivism-banking:live` (+ `:hosts-pinned`) + - `localhost/taler-hacktivism-live:landing` (+ `:hosts-pinned`) + - `localhost/taler-hacktivism-exchange-ansible:landing` (+ `:hosts-pinned`) + +Pin IP default: `212.51.151.254` (`PIN_IP=` to override). + +## One-shot without bake + +```bash +./pin-container-hosts.sh # apply now +./pin-container-hosts.sh --check # report resolve + /config +``` + +## After recreate + +If you start from an **old** image without the bake, run `pin-container-hosts.sh` once. +If you use `:hosts-pinned` / updated `:live`/`:landing` images, start_base / systemd re-pin on boot. + +Optional podman flags when recreating (extra safety): + +```bash +--add-host=bank.hacktivism.ch:212.51.151.254 \ +--add-host=exchange.hacktivism.ch:212.51.151.254 \ +--add-host=taler.hacktivism.ch:212.51.151.254 +``` diff --git a/scripts/taler-dns/bake-hosts-into-images.sh b/scripts/taler-dns/bake-hosts-into-images.sh new file mode 100644 index 0000000..8323ed5 --- /dev/null +++ b/scripts/taler-dns/bake-hosts-into-images.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# Install in-container pin script (+ systemd on exchange), hook start_base scripts, +# commit images so recreate keeps the bake. +# +# Run on koopa: +# ./bake-hosts-into-images.sh +set -euo pipefail + +PIN_IP="${PIN_IP:-212.51.151.254}" +SRC_DIR="${SRC_DIR:-/tmp/taler-dns-bake}" +mkdir -p "$SRC_DIR" + +# expect these next to us or in SRC_DIR +SCRIPT_IN="${SCRIPT_IN:-$SRC_DIR/pin-hacktivism-hosts.in-container.sh}" +UNIT_IN="${UNIT_IN:-$SRC_DIR/pin-hacktivism-hosts.service}" + +[ -f "$SCRIPT_IN" ] || { echo "missing $SCRIPT_IN" >&2; exit 1; } + +install_into() { + local C="$1" + if ! podman ps --format '{{.Names}}' | grep -qx "$C"; then + echo "SKIP $C (not running)" + return 0 + fi + echo "=== install into $C ===" + podman cp "$SCRIPT_IN" "$C:/usr/local/bin/pin-hacktivism-hosts" + podman exec "$C" chmod 755 /usr/local/bin/pin-hacktivism-hosts + # run once now + podman exec -e PIN_IP="$PIN_IP" "$C" /usr/local/bin/pin-hacktivism-hosts + podman exec "$C" grep hacktivism /etc/hosts || true +} + +hook_start_base() { + local C="$1" base="$2" + podman exec "$C" bash -lc " + set -e + f='$base' + if [ ! -f \"\$f\" ]; then echo \"no \$f\"; exit 0; fi + if grep -q pin-hacktivism-hosts \"\$f\"; then + echo \"already hooked: \$f\" + exit 0 + fi + # insert right after shebang / first line block + cp \"\$f\" \"\$f.bak-before-pin\" + { + head -n 1 \"\$f\" + echo '' + echo '# Pin public GOA hostnames (pasta DNS). Baked by bake-hosts-into-images.sh' + echo 'if [ -x /usr/local/bin/pin-hacktivism-hosts ]; then' + echo ' PIN_IP=${PIN_IP} /usr/local/bin/pin-hacktivism-hosts || true' + echo 'fi' + tail -n +2 \"\$f\" + } > \"\$f.new\" + mv \"\$f.new\" \"\$f\" + chmod +x \"\$f\" + echo \"hooked \$f\" + " +} + +# --- bank --- +install_into taler-hacktivism-bank +hook_start_base taler-hacktivism-bank /root/start_base_services_for_taler_bank.sh + +# --- merchant --- +install_into taler-hacktivism +hook_start_base taler-hacktivism /root/start_base_services_for_taler.sh + +# --- exchange (systemd) --- +install_into taler-hacktivism-exchange-ansible +if [ -f "$UNIT_IN" ]; then + podman cp "$UNIT_IN" taler-hacktivism-exchange-ansible:/etc/systemd/system/pin-hacktivism-hosts.service + podman exec taler-hacktivism-exchange-ansible bash -lc ' + sed -i "s/PIN_IP=212.51.151.254/PIN_IP='"${PIN_IP}"'/" /etc/systemd/system/pin-hacktivism-hosts.service + systemctl daemon-reload + systemctl enable pin-hacktivism-hosts.service + systemctl start pin-hacktivism-hosts.service + systemctl is-enabled pin-hacktivism-hosts.service + # ensure wirewatch starts after pin + mkdir -p /etc/systemd/system/taler-exchange-wirewatch.service.d + cat > /etc/systemd/system/taler-exchange-wirewatch.service.d/pin-hosts.conf <<EOF +[Unit] +After=pin-hacktivism-hosts.service +Requires=pin-hacktivism-hosts.service +EOF + systemctl daemon-reload + systemctl restart taler-exchange-wirewatch 2>/dev/null || true + ' +fi + +# --- commit images --- +echo "=== commit images ===" +podman commit taler-hacktivism-bank \ + localhost/taler-hacktivism-banking:live +podman commit taler-hacktivism-bank \ + localhost/taler-hacktivism-banking:hosts-pinned + +podman commit taler-hacktivism \ + localhost/taler-hacktivism-live:landing +podman commit taler-hacktivism \ + localhost/taler-hacktivism-live:hosts-pinned + +podman commit taler-hacktivism-exchange-ansible \ + localhost/taler-hacktivism-exchange-ansible:landing +podman commit taler-hacktivism-exchange-ansible \ + localhost/taler-hacktivism-exchange-ansible:hosts-pinned + +echo +echo "Images updated:" +podman images | grep -E 'hacktivism|hosts-pinned' | head -20 +echo +echo "Verify:" +for C in taler-hacktivism-bank taler-hacktivism-exchange-ansible taler-hacktivism; do + echo -n "$C: " + podman exec "$C" getent ahostsv4 bank.hacktivism.ch 2>/dev/null | head -1 || echo FAIL +done +echo "done." diff --git a/scripts/taler-dns/pin-container-hosts.sh b/scripts/taler-dns/pin-container-hosts.sh new file mode 100755 index 0000000..619e5ac --- /dev/null +++ b/scripts/taler-dns/pin-container-hosts.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Pin public GOA hostnames inside Taler containers (pasta often has no useful DNS). +# Required so wirewatch can reach https://bank.hacktivism.ch/... wire-gateway. +# +# Run on koopa (host): +# ./pin-container-hosts.sh # apply + show resolve +# ./pin-container-hosts.sh --check # only show +# PIN_IP=212.51.151.254 ./pin-container-hosts.sh +set -euo pipefail + +CHECK_ONLY=0 +[ "${1:-}" = "--check" ] && CHECK_ONLY=1 + +DOMAINS=(bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch) +CONTAINERS=( + taler-hacktivism-bank + taler-hacktivism-exchange-ansible + taler-hacktivism +) + +PIN_IP="${PIN_IP:-}" +if [ -z "$PIN_IP" ]; then + PIN_IP=$(getent ahostsv4 bank.hacktivism.ch 2>/dev/null | awk '{print $1; exit}' || true) +fi +[ -n "$PIN_IP" ] || PIN_IP=212.51.151.254 + +echo "PIN_IP=$PIN_IP" +echo "domains: ${DOMAINS[*]}" +echo + +pin_one() { + local C="$1" + if ! podman ps --format '{{.Names}}' | grep -qx "$C"; then + echo "=== $C === SKIP (not running)" + return 0 + fi + if [ "$CHECK_ONLY" != "1" ]; then + # Keep localhost lines; drop previous hacktivism pins; append ours + podman exec "$C" bash -lc " + set -e + TMP=\$(mktemp) + # keep non-hacktivism lines + if [ -f /etc/hosts ]; then + grep -vE 'hacktivism\\.ch|[[:space:]]bank\\.hacktivism|[[:space:]]exchange\\.hacktivism|[[:space:]]taler\\.hacktivism' /etc/hosts > \"\$TMP\" || true + fi + # ensure localhost + grep -qE '^127\\.0\\.0\\.1[[:space:]]+localhost' \"\$TMP\" 2>/dev/null || echo '127.0.0.1 localhost' >> \"\$TMP\" + grep -qE '^::1[[:space:]]' \"\$TMP\" 2>/dev/null || echo '::1 localhost ip6-localhost ip6-loopback' >> \"\$TMP\" + echo '${PIN_IP} bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch' >> \"\$TMP\" + # de-dupe consecutive blank lines lightly + cat \"\$TMP\" > /etc/hosts + rm -f \"\$TMP\" + " + # wirewatch needs bank DNS after pin + if [ "$C" = "taler-hacktivism-exchange-ansible" ]; then + podman exec "$C" systemctl try-restart taler-exchange-wirewatch 2>/dev/null || true + fi + fi + + echo "=== $C ===" + podman exec "$C" grep -E 'hacktivism|localhost' /etc/hosts 2>/dev/null || true + for h in "${DOMAINS[@]}"; do + # ahostsv4 first (matches what wire tools need) + ip=$(podman exec "$C" getent ahostsv4 "$h" 2>/dev/null | awk '{print $1; exit}' || true) + [ -n "$ip" ] || ip=$(podman exec "$C" getent hosts "$h" 2>/dev/null | awk '{print $1; exit}' || true) + code=$(podman exec "$C" curl -skS -m 4 -o /dev/null -w '%{http_code}' "https://${h}/config" 2>/dev/null || echo 000) + if [ -n "$ip" ] && [ "$code" = "200" ]; then + echo " OK $h → $ip /config=$code" + elif [ -n "$ip" ]; then + echo " WARN $h → $ip /config=$code" + else + echo " FAIL $h → (no resolve) /config=$code" + fi + done + echo +} + +for C in "${CONTAINERS[@]}"; do + pin_one "$C" +done + +echo "done (CHECK_ONLY=$CHECK_ONLY)" diff --git a/scripts/taler-dns/pin-hacktivism-hosts.in-container.sh b/scripts/taler-dns/pin-hacktivism-hosts.in-container.sh new file mode 100644 index 0000000..abffe67 --- /dev/null +++ b/scripts/taler-dns/pin-hacktivism-hosts.in-container.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Runs *inside* a Taler container. Pasta regenerates /etc/hosts on start — +# call this from systemd (exchange) or start_base_*.sh (merchant/bank). +set -euo pipefail + +PIN_IP="${PIN_IP:-212.51.151.254}" +MARKER="# hacktivism-goa-pin" + +TMP=$(mktemp) +if [ -f /etc/hosts ]; then + # drop any previous pin marker lines and lines that name our domains + grep -vE "${MARKER}|hacktivism\\.ch" /etc/hosts >"$TMP" || true +else + : >"$TMP" +fi + +grep -qE '^127\.0\.0\.1[[:space:]]' "$TMP" 2>/dev/null \ + || echo '127.0.0.1 localhost' >>"$TMP" +grep -qE '^::1[[:space:]]' "$TMP" 2>/dev/null \ + || echo '::1 localhost ip6-localhost ip6-loopback' >>"$TMP" + +{ + cat "$TMP" + echo "$MARKER" + echo "${PIN_IP} bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" + echo "$MARKER" +} > /etc/hosts +rm -f "$TMP" + +logger -t pin-hacktivism-hosts "pinned ${PIN_IP} → bank/exchange/taler.hacktivism.ch" 2>/dev/null || true +exit 0 diff --git a/scripts/taler-dns/pin-hacktivism-hosts.service b/scripts/taler-dns/pin-hacktivism-hosts.service new file mode 100644 index 0000000..d944f86 --- /dev/null +++ b/scripts/taler-dns/pin-hacktivism-hosts.service @@ -0,0 +1,16 @@ +[Unit] +Description=Pin bank/exchange/taler.hacktivism.ch in /etc/hosts (pasta DNS) +DefaultDependencies=no +After=local-fs.target +Before=network-online.target taler-exchange-wirewatch.service taler-exchange-httpd.service +Wants=network-pre.target + +[Service] +Type=oneshot +RemainAfterExit=yes +Environment=PIN_IP=212.51.151.254 +ExecStart=/usr/local/bin/pin-hacktivism-hosts + +[Install] +WantedBy=multi-user.target +WantedBy=taler-exchange-wirewatch.service diff --git a/scripts/taler-exchange/archive/README.md b/scripts/taler-exchange/archive/README.md new file mode 100644 index 0000000..b3a2f83 --- /dev/null +++ b/scripts/taler-exchange/archive/README.md @@ -0,0 +1,35 @@ +# Exchange archive / bootstrap + +## `exchange-bootstrap.sh` (greenfield only) + +One-shot bootstrap for a **new** exchange container. **Not** used for daily ops. + +Includes current **GOA exploration currency**: + +- `exchange-overrides.conf` pattern (no `zz-*`) +- `PORT = 9011`, `SERVE = tcp` +- Unit names: Yotta…Atomic-GOA (scale −8…24) +- ATM `common_amounts` 10…1000 +- Full 1–2–5 **coin** ladder (Micro-GOA … 10 GOA) in `conf.d/exchange-coins.conf` + +After bootstrap: + +1. `/root/start_base_services_for_taler_exchange.sh` (root) +2. `./start_exchange.sh` as `taler-exchange-httpd` +3. Offline: wire enable + denom/signkey sign for `/keys` + Live helpers: `../wire-enable-and-upload.sh`, `../offline-sign-upload-keys.sh`, `../start_wire_helpers.sh` + +## Removed + +| Script | Why | +|--------|-----| +| `exchange-start-all.sh` | Redundant with base + `start_exchange.sh` | +| old zz-hacktivism bootstrap | Wrong ports/currency; superseded | + +## Daily layout (merchant model) + +| Path | User | +|------|------| +| `/root/start_base_services_for_taler_exchange.sh` | root | +| `/usr/local/bin/start_exchange.sh` | `taler-exchange-httpd` | +| `/usr/local/bin/check_exchange-health.sh` | httpd / any | diff --git a/scripts/taler-exchange/archive/exchange-bootstrap.sh b/scripts/taler-exchange/archive/exchange-bootstrap.sh new file mode 100755 index 0000000..96c0dc1 --- /dev/null +++ b/scripts/taler-exchange/archive/exchange-bootstrap.sh @@ -0,0 +1,451 @@ +#!/bin/bash +# Greenfield bootstrap for exchange.hacktivism.ch (GOA exploration currency). +# Run as root inside container. Not for daily ops — use start_base + start_exchange. +# +# Writes: +# /etc/taler-exchange/exchange-overrides.conf +# /etc/taler-exchange/conf.d/exchange-coins.conf +# Ensures main conf inlines exchange-overrides.conf (merchant pattern). +# Does NOT use conf.d/zz-*. +# +# After this: offline denom sign + wire still required for /keys withdraw. + +set -euo pipefail +CONF=/etc/taler-exchange/taler-exchange.conf +OV=/etc/taler-exchange/exchange-overrides.conf +COINS=/etc/taler-exchange/conf.d/exchange-coins.conf +export DEBIAN_FRONTEND=noninteractive + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root" >&2 + exit 1 +fi + +echo "=== 1. postgres ===" +/etc/init.d/postgresql start +sleep 2 +pg_isready || true + +echo "=== 2. master key (offline user) ===" +mkdir -p /var/lib/taler-exchange/offline +chown -R taler-exchange-offline:taler-exchange-offline /var/lib/taler-exchange/offline +chown taler-exchange-offline:taler-exchange-offline /var/lib/taler-exchange 2>/dev/null || true +OUT=$(runuser -u taler-exchange-offline -- taler-exchange-offline -c "$CONF" setup 2>&1) || true +echo "$OUT" +MASTER_PUB=$(echo "$OUT" | grep -oE '[A-Z0-9]{40,}' | tail -1) +if [ -z "${MASTER_PUB:-}" ]; then + echo "ERROR: no master public key from offline setup" >&2 + exit 1 +fi +echo "MASTER_PUBLIC_KEY=$MASTER_PUB" +ATTR_KEY=$(openssl rand -hex 32) +SECRETS_DIR=/etc/taler-exchange/secrets +mkdir -p "$SECRETS_DIR" +cat >"$SECRETS_DIR/exchange-attribute-encryption.secret.conf" <<SEOF +# generated by exchange-bootstrap.sh +[exchange] +ATTRIBUTE_ENCRYPTION_KEY = ${ATTR_KEY} +SEOF +chmod 640 "$SECRETS_DIR/exchange-attribute-encryption.secret.conf" +chown root:taler-exchange-httpd "$SECRETS_DIR/exchange-attribute-encryption.secret.conf" 2>/dev/null || true + +echo "=== 3. exchange-overrides.conf (site; not zz-*) ===" +# Ensure main conf inlines overrides (merchant pattern) +if ! grep -q 'exchange-overrides.conf' "$CONF" 2>/dev/null; then + printf '\n# Manual site overrides (merchant pattern)\n@inline@ exchange-overrides.conf\n' >>"$CONF" +fi +# Remove legacy zz drop-ins if present +rm -f /etc/taler-exchange/conf.d/zz-hacktivism.conf \ + /etc/taler-exchange/conf.d/zz-hacktivism-coins.conf 2>/dev/null || true + +cat >"$OV" <<EOF +# Manual site overrides for exchange.hacktivism.ch (GOA exploration currency). +# Same role as /etc/taler-merchant/merchant-overrides.conf on taler-hacktivism. +# Do not edit overrides.conf (tooling). Do not edit package conf.d defaults. +# No conf.d/zz-* drop-ins. + +[exchange] +CURRENCY = GOA +CURRENCY_ROUND_UNIT = GOA:0.00000001 +TINY_AMOUNT = GOA:0.00000001 +DEFAULT_P2P_EXPIRATION = 14 days +BASE_URL = https://exchange.hacktivism.ch/ +SERVE = tcp +PORT = 9011 +MASTER_PUBLIC_KEY = ${MASTER_PUB} +# ATTRIBUTE_ENCRYPTION_KEY via @inline-secret@ (see secrets/) +@inline-secret@ exchange-attribute-encryption ../secrets/exchange-attribute-encryption.secret.conf + +[currency-goa] +ENABLED = YES +name = "GOA exploration currency" +code = GOA +# SI display units. taler-exchange rejects scale keys outside [-8, 24] (no Ronna/Quetta). +fractional_input_digits = 8 +fractional_normal_digits = 0 +fractional_trailing_zero_digits = 0 +alt_unit_names_are_symbols = NO +alt_unit_names = {"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"} +common_amounts = "GOA:10 GOA:20 GOA:50 GOA:100 GOA:200 GOA:1000" + +### Disable package demonstrator currencies (like merchant-overrides) +[currency-kudos] +ENABLED = NO + +[currency-testkudos] +ENABLED = NO +EOF +echo "Wrote $OV" +grep -E '^(name|code|PORT|CURRENCY|alt_unit|fractional_input|common_)' "$OV" || true + +echo "=== 4. GOA coin denominations (1–2–5 ladder) ===" +if [ -f "$COINS" ] && ! grep -q 'VALUE = GOA' "$COINS" 2>/dev/null; then + mv "$COINS" "${COINS}.package-kudos" + echo "Moved package coins -> ${COINS}.package-kudos" +fi + +cat >"$COINS" << 'COINS_EOF' +# GOA denominations for exchange.hacktivism.ch +# Units: 1 GOA = 1000 mGOA = 1_000_000 uGOA +# VALUE uses base currency; coin sections cover 1–2–5 ladders at u/m/whole scale. + +# --- uGOA (10^-6 GOA) --- +[coin_goa_0_000001] +VALUE = GOA:0.000001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_000002] +VALUE = GOA:0.000002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_000005] +VALUE = GOA:0.000005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_00001] +VALUE = GOA:0.00001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_00002] +VALUE = GOA:0.00002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_00005] +VALUE = GOA:0.00005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_0001] +VALUE = GOA:0.0001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_0002] +VALUE = GOA:0.0002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_0005] +VALUE = GOA:0.0005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +# --- mGOA (10^-3 GOA) --- +[coin_goa_0_001] +VALUE = GOA:0.001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_002] +VALUE = GOA:0.002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_005] +VALUE = GOA:0.005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_01] +VALUE = GOA:0.01 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_02] +VALUE = GOA:0.02 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_05] +VALUE = GOA:0.05 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_1] +VALUE = GOA:0.1 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_2] +VALUE = GOA:0.2 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_5] +VALUE = GOA:0.5 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +# --- whole GOA --- +[coin_goa_1_0] +VALUE = GOA:1 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_2_0] +VALUE = GOA:2 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_5_0] +VALUE = GOA:5 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_10_0] +VALUE = GOA:10 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_20_0] +VALUE = GOA:20 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_50_0] +VALUE = GOA:50 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_100_0] +VALUE = GOA:100 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_200_0] +VALUE = GOA:200 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_1000_0] +VALUE = GOA:1000 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA +COINS_EOF + +echo "Wrote $COINS ($(grep -c '^\[coin_' "$COINS") coin sections)" + +echo "=== 5. database ===" +taler-exchange-dbconfig 2>&1 || true +runuser -u taler-exchange-httpd -- taler-exchange-dbinit -c "$CONF" 2>&1 || true + +echo "=== 6. next steps (manual) ===" +echo " - /root/start_base_services_for_taler_exchange.sh # root: secmods + shell as httpd" +echo " - ./start_exchange.sh # as taler-exchange-httpd" +echo " - offline: sign denominations + wire accounts for /keys" +echo " - MASTER_PUBLIC_KEY=$MASTER_PUB" +echo "DONE bootstrap" diff --git a/scripts/taler-exchange/check_exchange-health.sh b/scripts/taler-exchange/check_exchange-health.sh new file mode 100755 index 0000000..3214745 --- /dev/null +++ b/scripts/taler-exchange/check_exchange-health.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# Health check for manual taler-exchange (same style as check_merchant-health.sh). +# Runnable as taler-exchange-httpd (or any user that can see processes + curl localhost). + +CONF=/etc/taler-exchange/taler-exchange.conf +SOCK=/run/taler-exchange/httpd/exchange-http.sock + +green() { echo -e "\e[32m$1\e[0m"; } +red() { echo -e "\e[31m$1\e[0m"; } +yellow() { echo -e "\e[33m$1\e[0m"; } + +fail=0 +ok() { green "[OK] $1"; } +bad() { red "[FAIL] $1"; fail=1; } +warn() { yellow "[WARN] $1"; } + +echo "=== Taler Exchange Health Check ===" + +# 1. secmods (started by root base script) +for p in taler-exchange-secmod-rsa taler-exchange-secmod-cs taler-exchange-secmod-eddsa; do + if pgrep -f "$p" >/dev/null 2>&1; then + ok "process $p" + else + bad "process $p not running" + fi +done + +# 2. httpd +if pgrep -f taler-exchange-httpd >/dev/null 2>&1; then + ok "process taler-exchange-httpd" +else + bad "process taler-exchange-httpd is NOT running" +fi + +# 3. serving mode +SERVE=$(taler-exchange-config -c "$CONF" -s exchange -o SERVE 2>/dev/null || echo unix) +PORT=$(taler-exchange-config -c "$CONF" -s exchange -o PORT 2>/dev/null || echo 9011) + +if [ "$SERVE" = "tcp" ]; then + if curl -sf -m 3 "http://127.0.0.1:${PORT}/config" >/dev/null 2>&1; then + ok "HTTP /config on 127.0.0.1:${PORT}" + elif curl -sf -m 3 "http://127.0.0.1:${PORT}/keys" >/dev/null 2>&1; then + ok "HTTP /keys on 127.0.0.1:${PORT}" + else + bad "no HTTP response on 127.0.0.1:${PORT} (/config|/keys)" + fi +else + if [ -S "$SOCK" ]; then + ok "socket $SOCK" + else + bad "socket does NOT exist: $SOCK" + fi +fi + +# 4. wire helpers — ensure (no systemd) then require +if [ "${SKIP_ENSURE:-0}" != "1" ]; then + if [ -x /usr/local/bin/ensure_exchange_helpers.sh ]; then + echo "--- ensure_exchange_helpers ---" + /usr/local/bin/ensure_exchange_helpers.sh || warn "ensure_exchange_helpers exited non-zero" + elif [ -x "$(dirname "$0")/ensure_exchange_helpers.sh" ]; then + echo "--- ensure_exchange_helpers ---" + "$(dirname "$0")/ensure_exchange_helpers.sh" || warn "ensure_exchange_helpers exited non-zero" + elif [ -x /root/ensure_exchange_helpers.sh ]; then + echo "--- ensure_exchange_helpers ---" + /root/ensure_exchange_helpers.sh || warn "ensure_exchange_helpers exited non-zero" + fi +fi + +live_helper() { + local p="$1" + # COMM is 15 chars; use full cmdline match + pgrep -f "(^|/)(${p})( |$)" >/dev/null 2>&1 +} + +for p in taler-exchange-aggregator taler-exchange-wirewatch taler-exchange-transfer taler-exchange-closer; do + if live_helper "$p"; then + ok "process $p" + else + bad "process $p not running (settlement needs transfer+aggregator)" + fi +done + +if [ "$fail" -eq 0 ]; then + green "=== ALL CRITICAL CHECKS PASSED ===" + exit 0 +fi +red "=== SOME CHECKS FAILED ===" +exit 1 diff --git a/scripts/taler-exchange/ensure_exchange_helpers.sh b/scripts/taler-exchange/ensure_exchange_helpers.sh new file mode 100755 index 0000000..91d8c57 --- /dev/null +++ b/scripts/taler-exchange/ensure_exchange_helpers.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Ensure exchange wire/db helper processes are running (no systemd). +# Root inside taler-exchange container (or host if packages installed there). +# +# Usage: +# ensure_exchange_helpers.sh +# ENSURE_ONLY=1 ensure_exchange_helpers.sh # start missing, no health summary +set -euo pipefail + +CONF="${TALER_EXCHANGE_CONFIG:-/etc/taler-exchange/taler-exchange.conf}" +LOG_DIR="${TALER_EXCHANGE_LOG_DIR:-/var/log/taler-exchange}" + +if [ "$(id -u)" -ne 0 ]; then + echo "root only" >&2 + exit 1 +fi + +mkdir -p "$LOG_DIR" +chmod 755 "$LOG_DIR" 2>/dev/null || true + +# Live process under user. Note: Linux COMM is 15 chars — never use pgrep -x +# for long names like taler-exchange-aggregator / taler-exchange-wirewatch. +is_running() { + local user="$1" + local bin="$2" + local base + base=$(basename "$bin") + # [t] trick avoids matching this grep/pgrep itself + ps -u "$user" -o args= 2>/dev/null | grep -qE "(^|/)[${base:0:1}]${base:1}( |$)" \ + || pgrep -u "$user" -f "(^|/)(${base})( |$)" >/dev/null 2>&1 +} + +# Start with nohup so SIGHUP from parent shell exit does not kill helpers. +start_one() { + local user="$1" + local name="$2" + local bin="$3" + shift 3 + if is_running "$user" "$bin"; then + echo "already: $name ($user)" + return 0 + fi + echo "start: $name as $user" + # shellcheck disable=SC2086 + nohup runuser -u "$user" -- "$bin" "$@" >>"$LOG_DIR/${name}.log" 2>&1 </dev/null & + disown 2>/dev/null || true + local i + for i in 1 2 3 4 5 6; do + sleep 0.4 + if is_running "$user" "$bin"; then + echo " ok: $name" + return 0 + fi + done + echo " FAIL: $name did not stay up (see $LOG_DIR/${name}.log)" >&2 + tail -15 "$LOG_DIR/${name}.log" 2>/dev/null || true + return 1 +} + +ec=0 +start_one taler-exchange-aggregator taler-exchange-aggregator \ + /usr/bin/taler-exchange-aggregator -c "$CONF" -L INFO || ec=1 +start_one taler-exchange-closer taler-exchange-closer \ + /usr/bin/taler-exchange-closer -c "$CONF" -L INFO || ec=1 +start_one taler-exchange-wire taler-exchange-wirewatch \ + /usr/bin/taler-exchange-wirewatch -c "$CONF" -L INFO || ec=1 +start_one taler-exchange-wire taler-exchange-transfer \ + /usr/bin/taler-exchange-transfer -c "$CONF" -L INFO || ec=1 + +echo "--- live helpers ---" +ps -eo pid,user,stat,etime,args 2>/dev/null \ + | grep -E 'taler-exchange-(aggregator|closer|wirewatch|transfer)' \ + | grep -vE 'grep| Z |ensure_exchange' || true + +exit "$ec" diff --git a/scripts/taler-exchange/install_no_terms.sh b/scripts/taler-exchange/install_no_terms.sh new file mode 100755 index 0000000..55c0191 --- /dev/null +++ b/scripts/taler-exchange/install_no_terms.sh @@ -0,0 +1,263 @@ +#!/bin/bash +# Install "No Terms Required" ToS + privacy for the exchange (styled like merchant terms). +# Run as root inside the exchange container. +set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" + +CONF="${TALER_EXCHANGE_CONFIG:-/etc/taler-exchange/taler-exchange.conf}" +DATA_HOME=$(taler-exchange-config -c "$CONF" -f -s PATHS -o TALER_DATA_HOME 2>/dev/null || true) +DATA_HOME="${DATA_HOME:-/var/lib/taler-exchange/}" +TERMS_DIR="${DATA_HOME%/}/terms" +LANG_DIR="$TERMS_DIR/en" +mkdir -p "$LANG_DIR" + +# Bump when HTML/style changes (long Cache-Control on /terms). +TOS_ETAG="${TERMS_ETAG:-no-terms-v2}" +# Prefer Swiss FADP privacy install (install_swiss_privacy.sh); fallback etag here +PP_ETAG="${PRIVACY_ETAG:-exchange-pp-swiss-v0}" + +# Shared palette with merchant-tos-dual / bank terms pages +COMMON_CSS=' + :root { color-scheme: dark light; } + body { + font-family: system-ui, -apple-system, sans-serif; + max-width: 40rem; margin: 2rem auto; padding: 0 1.1rem 3rem; + line-height: 1.5; color: #e8e6e3; background: #1a1520; + } + h1 { font-size: 1.35rem; font-weight: 800; margin: 0 0 1rem; color: #f5f0ea; } + h2 { font-size: 1.05rem; margin: 1.4rem 0 0.5rem; color: #e8c878; } + p, li { font-size: 0.98rem; } + ul { padding-left: 1.2rem; } + code, a { color: #5eead4; } + a { text-decoration: none; } + a:hover { text-decoration: underline; } + .badge { + display: inline-block; font-size: 0.72rem; font-weight: 700; + letter-spacing: 0.06em; text-transform: uppercase; + color: #c4b5fd; border: 1px solid rgba(196,181,253,0.35); + border-radius: 999px; padding: 0.2rem 0.65rem; margin-bottom: 0.85rem; + } + .cur { + border-radius: 12px; padding: 0.75rem 0.9rem; margin: 0.85rem 0 1rem; + border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); + } + .cur strong { display: block; font-size: 1.05rem; margin-bottom: 0.25rem; color: #5eead4; } + .muted { color: #a39e98; font-size: 0.88rem; } + footer { margin-top: 2rem; font-size: 0.85rem; color: #a39e98; } +' + +write_tos() { + local base="$1" + local title="No Terms Required" + local body_txt body_md + + body_txt='No Terms Required. + +This is an experimental / exploration GNU Taler exchange for the currency GOA (hacktivism.ch). + +No formal terms of service are required to use this service. + +By withdrawing or using GOA coins you acknowledge that: +- This service is for exploration and testing only. +- GOA is not legal tender and has no guaranteed real-world value or redemption. +- There is no guaranteed availability, support, or uptime. +- Operators may reset balances or change configuration without notice. +- Do not use real money you cannot afford to lose. + +If you do not agree, do not use this exchange. + +Related: +- Bank: https://bank.hacktivism.ch/intro/ +- Merchant terms: https://taler.hacktivism.ch/terms +- Bank terms: https://bank.hacktivism.ch/terms + +Privacy: +Processing under Swiss FADP (revDSG). What data is retained is listed at +https://exchange.hacktivism.ch/privacy +' + + body_md='# No Terms Required + +This is an **experimental / exploration** GNU Taler exchange for the currency **GOA** (hacktivism.ch). + +**No formal terms of service** are required to use this service. + +By withdrawing or using GOA coins you acknowledge that: + +- This service is for exploration and testing only. +- GOA is not legal tender and has no guaranteed real-world value or redemption. +- There is no guaranteed availability, support, or uptime. +- Operators may reset balances or change configuration without notice. +- Do not use real money you cannot afford to lose. + +If you do not agree, do not use this exchange. + +## Related + +- [Bank intro](https://bank.hacktivism.ch/intro/) +- [Bank terms](https://bank.hacktivism.ch/terms) +- [Merchant terms](https://taler.hacktivism.ch/terms) +- [Exchange privacy](https://exchange.hacktivism.ch/privacy) + +## Privacy + +Processing under Swiss FADP (revDSG). What data is retained is listed on +[exchange.hacktivism.ch/privacy](https://exchange.hacktivism.ch/privacy). +' + + printf '%s\n' "$body_txt" >"$LANG_DIR/${base}.txt" + printf '%s\n' "$body_md" >"$LANG_DIR/${base}.md" + cat >"$LANG_DIR/${base}.html" <<HTML +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"/> + <meta name="viewport" content="width=device-width, initial-scale=1"/> + <title>${title} + + + +
exchange.hacktivism.ch · GOA
+

${title}

+

This is an experimental / exploration GNU Taler exchange for the + currency GOA at exchange.hacktivism.ch (hacktivism.ch).

+

No formal terms of service are required to use this service.

+ +
+ GOA · explorational + Not legal tender. No guaranteed real-world value, redemption, or convertibility. + Issued only for exploration and testing on this stack. +
+ +

By withdrawing or using GOA coins you acknowledge

+
    +
  • This service is for exploration and testing only.
  • +
  • There is no guaranteed availability, support, or uptime.
  • +
  • Operators may reset balances or change configuration without notice.
  • +
  • Do not use real money you cannot afford to lose.
  • +
  • Software is provided as-is, without warranty.
  • +
+

If you do not agree, do not use this exchange.

+ +

Related

+ + +

Privacy

+

Processing under Swiss FADP (revDSG). What data is retained + (reserves, wire-in, coins, logs, …) is listed on + /privacy.

+
Version ${base} · hacktivism.ch
+ + +HTML +} + +write_pp() { + # Legacy short PP only if not using install_swiss_privacy.sh etag + local base="$1" + local title="Privacy notice · GOA Exchange" + local body_txt body_md + + body_txt='No Privacy Policy Required. + +This is an experimental / exploration GNU Taler exchange for GOA. + +No formal privacy policy is required for this demo service. + +High-level notes: +- Wire transfers via the regional bank may identify bank account holders. +- The exchange processes withdrawals, deposits, and related protocol operations. +- Logs may be kept for operation and debugging. + +Do not use this service if that is unacceptable. + +Related: +- Exchange terms: https://exchange.hacktivism.ch/terms +' + + body_md='# No Privacy Policy Required + +This is an **experimental / exploration** GNU Taler exchange for **GOA**. + +**No formal privacy policy** is required for this demo service. + +## High-level notes + +- Wire transfers via the regional bank may identify bank account holders. +- The exchange processes withdrawals, deposits, and related protocol operations. +- Logs may be kept for operation and debugging. + +Do not use this service if that is unacceptable. + +## Related + +- [Exchange terms](https://exchange.hacktivism.ch/terms) +' + + printf '%s\n' "$body_txt" >"$LANG_DIR/${base}.txt" + printf '%s\n' "$body_md" >"$LANG_DIR/${base}.md" + cat >"$LANG_DIR/${base}.html" < + + + + + ${title} + + + +
exchange.hacktivism.ch · privacy
+

${title}

+

This is an experimental / exploration GNU Taler exchange for + GOA at exchange.hacktivism.ch.

+

No formal privacy policy is required for this demo service.

+ +

High-level notes

+
    +
  • Wire transfers via the regional bank may identify bank account holders.
  • +
  • The exchange processes withdrawals, deposits, and related protocol operations.
  • +
  • Logs may be kept for operation and debugging.
  • +
+

Do not use this service if that is unacceptable.

+ +

Related

+ +
Version ${base}
+ + +HTML +} + +write_tos "$TOS_ETAG" +# Prefer Swiss FADP privacy installer when present (precise retention tables) +if [ -x /usr/local/bin/install_swiss_privacy.sh ]; then + PRIVACY_ETAG="$PP_ETAG" /usr/local/bin/install_swiss_privacy.sh +else + write_pp "$PP_ETAG" +fi + +chmod -R a+rX "$TERMS_DIR" +if id taler-exchange-httpd >/dev/null 2>&1; then + chown -R taler-exchange-httpd: "$TERMS_DIR" 2>/dev/null || true +fi + +echo "Installed under $LANG_DIR:" +ls -la "$LANG_DIR"/${TOS_ETAG}.* "$LANG_DIR"/${PP_ETAG}.* 2>/dev/null || ls -la "$LANG_DIR" +echo "Config should set:" +echo " TERMS_ETAG = ${TOS_ETAG}" +echo " PRIVACY_ETAG = ${PP_ETAG}" +echo " TERMS_DIR / PRIVACY_DIR = \${TALER_DATA_HOME}terms/" diff --git a/scripts/taler-exchange/install_swiss_privacy.sh b/scripts/taler-exchange/install_swiss_privacy.sh new file mode 100644 index 0000000..0519d50 --- /dev/null +++ b/scripts/taler-exchange/install_swiss_privacy.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# Swiss FADP privacy for exchange.hacktivism.ch — run inside exchange container. +set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" + +ETAG="${PRIVACY_ETAG:-exchange-pp-swiss-v0}" +CONF="${TALER_EXCHANGE_CONFIG:-/etc/taler-exchange/taler-exchange.conf}" +DATA_HOME=$(taler-exchange-config -c "$CONF" -f -s PATHS -o TALER_DATA_HOME 2>/dev/null || true) +DATA_HOME="${DATA_HOME:-/var/lib/taler-exchange/}" +DIR="${PRIVACY_DIR:-${DATA_HOME%/}/terms}/en" +mkdir -p "$DIR" + +cat >"$DIR/${ETAG}.txt" <<'EOF' +Privacy notice — exchange.hacktivism.ch (Swiss FADP / revDSG) + +Controller: operators of the hacktivism.ch GNU Taler stack. +Service: experimental GOA exchange (not a licensed Swiss bank or e-money institution). + +Data retained (precise): +1) Reserves — reserve public keys, current remaining amounts, creation/close times: for service life or wipe. +2) Wire-in (reserves_in) — credit amounts, timestamps, bank-side account reference as provided by the bank/wire gateway: for service life; links reserve funding to bank accounts. +3) Coins — known coin public keys, denomination serials, remaining value, spend/deposit linkage as required by protocol: for service life. +4) Withdraw / refresh / recoup / refund / deposit tables — protocol operation records and timestamps: for service life. +5) Wire-out — transfers to merchant accounts (amounts, payto/target as stored, times): for service life. +6) Keys / denominations — public denomination and signing keys (not personal data by themselves). +7) Technical logs — HTTP and process logs (IP, path, status): days–weeks via rotation. +8) Terms acceptance markers if stored by clients — as required by wallet software. + +Not retained by the exchange as plaintext identity of wallet holders: GNU Taler is designed so coin spend is unlinkable to customer bank identity at the exchange when used as intended. Funding reserves via the bank may still identify the bank account holder on the bank side. + +Purposes: operate GOA exchange protocol, settle with bank/merchants, security, operations. +Legal basis (FADP): provision of requested exchange service; proportionate operation of experimental stack. +Recipients: bank.hacktivism.ch (wire); merchants depositing GOA; host operators. No sale of data. +Rights: access, rectification, deletion, objection (FADP); complaint to Swiss FDPIC (EDÖB). +Security: TLS; experimental stack. + +Related: https://exchange.hacktivism.ch/terms · https://bank.hacktivism.ch/intro/privacy.html · https://taler.hacktivism.ch/privacy +EOF + +cat >"$DIR/${ETAG}.md" <<'EOF' +# Privacy notice · GOA Exchange · Swiss FADP + +Controller: operators of **hacktivism.ch**. Service: experimental **GOA** exchange at `exchange.hacktivism.ch`. + +Swiss Federal Act on Data Protection (**FADP / revDSG**, since 1 Sep 2023). + +## Data retained + +| Data | Examples | Retention | +|------|----------|-----------| +| Reserves | Reserve pubs, remaining amount, times | Service life or wipe | +| Wire-in | Amounts, timestamps, bank account ref from wire path | Service life | +| Coins | Coin pubs, denoms, remaining, deposit links | Service life | +| Protocol ops | Withdraw, refresh, deposit, refund, recoup | Service life | +| Wire-out | Merchant settlement amounts / targets | Service life | +| Technical logs | IP, path, status | Days–weeks (rotation) | + +**Design note:** Coin spend is intended to be unlinkable to the customer at the exchange. Funding a reserve via the bank may identify the **bank** account holder on the bank system. + +## Purposes + +Run the GOA exchange protocol; wire settlement; security and operations. + +## Rights + +FADP access, correction, deletion, objection. Complaint: Swiss **FDPIC / EDÖB**. + +## Related + +- [Exchange terms](https://exchange.hacktivism.ch/terms) +- [Bank privacy](https://bank.hacktivism.ch/intro/privacy.html) +- [Merchant privacy](https://taler.hacktivism.ch/privacy) +EOF + +cat >"$DIR/${ETAG}.html" <<'HTML' + + + + + + Privacy notice · GOA Exchange · Swiss FADP + + + +
exchange.hacktivism.ch · privacy · CH
+

Privacy notice · GOA Exchange

+

+ Swiss Federal Act on Data Protection (FADP / revDSG, since 1 Sep 2023). + Experimental GOA exchange at exchange.hacktivism.ch — not a licensed bank. +

+ +

1. Controller

+

Operators of the hacktivism.ch GNU Taler stack. No separate DPO for this experimental service.

+ +

2. Data retained

+ + + + + + + + + + +
DataExamplesTypical retention
ReservesReserve public keys, remaining amount, timesService life or wipe
Wire-inAmounts, timestamps, bank account reference from wire pathService life
CoinsCoin pubs, denominations, remaining value, deposit linksService life
Protocol operationsWithdraw, refresh, deposit, refund, recoupService life
Wire-outMerchant settlement amounts / targetsService life
Technical logsIP, path, statusDays–weeks (rotation)
+

Design note: Coin spend is intended to be unlinkable to the customer + at the exchange. Funding a reserve via the bank may identify the bank account + holder on the bank system.

+ +

3. Purposes

+
    +
  • Operate the GOA GNU Taler exchange protocol
  • +
  • Wire settlement with bank and merchants
  • +
  • Security, debugging, capacity monitoring
  • +
+ +

4. Your rights

+

Access, rectification, deletion, objection under the FADP. Complaint: + Swiss FDPIC / EDÖB.

+ +

Related

+ +
exchange-pp-swiss-v0 · Swiss FADP (revDSG)
+ + +HTML + +chmod -R a+rX "${DATA_HOME%/}/terms" +if id taler-exchange-httpd >/dev/null 2>&1; then + chown -R taler-exchange-httpd: "${DATA_HOME%/}/terms" 2>/dev/null || true +fi +echo "ok exchange privacy $ETAG -> $DIR" +ls -la "$DIR"/${ETAG}.* diff --git a/scripts/taler-exchange/landing-stats-exchange.sh b/scripts/taler-exchange/landing-stats-exchange.sh new file mode 100644 index 0000000..35452d4 --- /dev/null +++ b/scripts/taler-exchange/landing-stats-exchange.sh @@ -0,0 +1,300 @@ +#!/bin/bash +# Run INSIDE taler-hacktivism-exchange-ansible. +# Writes /var/www/exchange-landing/stats.json +# +# Data lives in Postgres DB taler-exchange, schema exchange.* +# (reserves, reserves_in, known_coins, withdraw, denominations, …) +# +# IMPORTANT: never use psql -F$'\t' -v ON_ERROR_STOP=1 +# If the tab arg is lost, -F eats -v and ON_ERROR_STOP=1 becomes the +# *username* → peer auth fails → silent empty counts (all zeros). +# Never overwrite stats.json on failure — write stats-run.json instead. +set -euo pipefail +export TZ="${TZ:-Europe/Zurich}" +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" +LANDING_DIR="${LANDING_DIR:-/var/www/exchange-landing}" +OUT="$LANDING_DIR/stats.json" +RUN="$LANDING_DIR/stats-run.json" +TMP="${OUT}.tmp.$$" +DB="${EXCHANGE_DB:-taler-exchange}" +BASE_URL="${EXCHANGE_BASE_URL:-https://exchange.hacktivism.ch}" +ERRLOG="${LANDING_STATS_ERRLOG:-/var/log/landing-stats-exchange.err}" +mkdir -p "$LANDING_DIR" + +now_iso() { date +%Y-%m-%dT%H:%M%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/'; } +now_human() { date +"%Y-%m-%d %H:%M %Z"; } +json_str() { + printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n\r\t' ' ')" +} + +write_run() { + local ok_json="$1" msg="${2:-}" + cat >"$RUN" <&2 + rm -f "$TMP" 2>/dev/null || true + exit 1 +} + +# Prefer runuser; fall back to su. Always absolute-ish via PATH. +as_postgres() { + if command -v runuser >/dev/null 2>&1; then + runuser -u postgres -- "$@" + elif command -v su >/dev/null 2>&1; then + su -s /bin/bash postgres -c "$*" + else + return 127 + fi +} + +# Single value or one row with '|' separators (no -F, no -v flags). +psqlq() { + local sql="$1" out ec + set +e + out=$(as_postgres psql -d "$DB" -At -c "$sql" 2>>"$ERRLOG") + ec=$? + set -e + if [ "$ec" -ne 0 ]; then + abort "psql failed (ec=$ec): ${sql:0:120}" + fi + printf '%s' "$out" +} + +fmt_goa() { + local v="${1:-0}" f="${2:-0}" + v=${v//[^0-9-]/}; f=${f//[^0-9-]/}; v=${v:-0}; f=${f:-0} + if [ "$f" = "0" ] || [ -z "$f" ]; then + printf 'GOA:%s' "$v" + return + fi + awk -v v="$v" -v f="$f" 'BEGIN{ + frac = sprintf("%08d", f+0); sub(/0+$/, "", frac) + if (frac == "") printf "GOA:%d", v+0 + else printf "GOA:%d.%s", v+0, frac + }' +} + +q_count() { + local n + n=$(psqlq "SELECT count(*)::text FROM $1;") + # strip whitespace/newlines + n=$(printf '%s' "$n" | tr -d '[:space:]') + [[ "$n" =~ ^[0-9]+$ ]] || abort "bad count for $1: '$n'" + printf '%s' "$n" +} + +# --- preflight --- +command -v psql >/dev/null 2>&1 || abort "psql not in PATH ($PATH)" +command -v runuser >/dev/null 2>&1 || command -v su >/dev/null 2>&1 || abort "neither runuser nor su in PATH" +PROBE=$(psqlq "SELECT 1;") +[ "$PROBE" = "1" ] || abort "postgres not reachable (SELECT 1 → '$PROBE')" + +# --- coin lifecycle --- +KNOWN_COINS=$(q_count "exchange.known_coins") +COINS_LIVE=$(psqlq "SELECT count(*)::text FROM exchange.known_coins WHERE (remaining).val > 0 OR (remaining).frac > 0;") +COINS_LIVE=$(printf '%s' "${COINS_LIVE:-0}" | tr -d '[:space:]') +COINS_LIVE=${COINS_LIVE:-0} +COINS_SPENT=$(( KNOWN_COINS - COINS_LIVE )) + +REM_ROW=$(psqlq "SELECT coalesce(sum((remaining).val),0)::text || '|' || coalesce(sum((remaining).frac),0)::text FROM exchange.known_coins;") +IFS='|' read -r REM_VAL REM_FRAC <<<"${REM_ROW:-0|0}" +REMAINING_AMT=$(fmt_goa "${REM_VAL:-0}" "${REM_FRAC:-0}") + +DENOMS_TOTAL=$(q_count "exchange.denominations") +DENOM_VALUES=$(psqlq "SELECT count(DISTINCT ((coin).val, (coin).frac))::text FROM exchange.denominations;") +DENOM_VALUES=$(printf '%s' "${DENOM_VALUES:-0}" | tr -d '[:space:]') + +NOW_US=$(date +%s)000000 +DENOMS_WITHDRAWABLE=$(psqlq "SELECT count(*)::text FROM exchange.denominations WHERE valid_from <= ${NOW_US} AND expire_withdraw > ${NOW_US};") +DENOMS_WITHDRAWABLE=$(printf '%s' "${DENOMS_WITHDRAWABLE:-0}" | tr -d '[:space:]') + +RESERVES=$(q_count "exchange.reserves") +RESERVES_IN=$(q_count "exchange.reserves_in") +WIN_ROW=$(psqlq "SELECT coalesce(sum((credit).val),0)::text || '|' || coalesce(sum((credit).frac),0)::text FROM exchange.reserves_in;") +IFS='|' read -r WIN_VAL WIN_FRAC <<<"${WIN_ROW:-0|0}" +WIRE_IN_AMT=$(fmt_goa "${WIN_VAL:-0}" "${WIN_FRAC:-0}") + +WITHDRAW_OPS=$(q_count "exchange.withdraw") +WOUT_ROW=$(psqlq "SELECT coalesce(sum((amount_with_fee).val),0)::text || '|' || coalesce(sum((amount_with_fee).frac),0)::text FROM exchange.withdraw;") +IFS='|' read -r WO_VAL WO_FRAC <<<"${WOUT_ROW:-0|0}" +WITHDRAW_AMT=$(fmt_goa "${WO_VAL:-0}" "${WO_FRAC:-0}") + +REFRESH_OPS=$(q_count "exchange.refresh") +RECOUP=$(q_count "exchange.recoup") +REFUNDS=$(q_count "exchange.refunds") +COIN_DEPOSITS=$(q_count "exchange.coin_deposits") +BATCH_DEPOSITS=$(q_count "exchange.batch_deposits") +WIRE_OUT=$(q_count "exchange.wire_out") +COIN_HISTORY=$(q_count "exchange.coin_history") +WIRE_ACCTS=$(q_count "exchange.wire_accounts") + +# known coins by denom value (pipe-separated) +BY_DENOM_TSV=$(psqlq " +SELECT (d.coin).val::text || '|' || (d.coin).frac::text || '|' || count(*)::text || '|' || + count(*) FILTER (WHERE (k.remaining).val > 0 OR (k.remaining).frac > 0)::text +FROM exchange.known_coins k +JOIN exchange.denominations d ON d.denominations_serial = k.denominations_serial +GROUP BY (d.coin).val, (d.coin).frac +ORDER BY (d.coin).val, (d.coin).frac; +") + +BY_DENOM_JSON="[" +bf=1 +while IFS='|' read -r dv df cnt live; do + [ -z "${dv:-}" ] && continue + amt=$(fmt_goa "$dv" "$df") + if [ "$bf" = 1 ]; then bf=0; else BY_DENOM_JSON="${BY_DENOM_JSON},"; fi + BY_DENOM_JSON="${BY_DENOM_JSON} + {\"value\": $(json_str "$amt"), \"coins\": ${cnt:-0}, \"live\": ${live:-0}}" +done <<<"$BY_DENOM_TSV" +BY_DENOM_JSON="${BY_DENOM_JSON} + ]" + +LADDER_TSV=$(psqlq " +SELECT (coin).val::text || '|' || (coin).frac::text || '|' || count(*)::text +FROM exchange.denominations +GROUP BY (coin).val, (coin).frac +ORDER BY (coin).val, (coin).frac; +") +LADDER_JSON="[" +lf=1 +while IFS='|' read -r dv df nkeys; do + [ -z "${dv:-}" ] && continue + amt=$(fmt_goa "$dv" "$df") + if [ "$lf" = 1 ]; then lf=0; else LADDER_JSON="${LADDER_JSON},"; fi + LADDER_JSON="${LADDER_JSON} + {\"value\": $(json_str "$amt"), \"keys\": ${nkeys:-0}}" +done <<<"$LADDER_TSV" +LADDER_JSON="${LADDER_JSON} + ]" + +# recent wire-in / withdraw activity (no personal names — reserve_pub hex truncated) +RECENT_JSON="[" +rf=1 +while IFS='|' read -r ts val frac; do + [ -z "${ts:-}" ] && continue + sec=$(awk -v t="$ts" 'BEGIN{printf "%d", int(t/1000000)}') + human=$(date -d "@${sec}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$sec") + amt=$(fmt_goa "${val:-0}" "${frac:-0}") + if [ "$rf" = 1 ]; then rf=0; else RECENT_JSON="${RECENT_JSON},"; fi + RECENT_JSON="${RECENT_JSON} + {\"kind\": \"wire_in\", \"amount\": $(json_str "$amt"), \"ts_human\": $(json_str "$human"), \"ts_us\": ${ts:-0}}" +done < <(psqlq " +SELECT execution_date::text || '|' || (credit).val::text || '|' || (credit).frac::text +FROM exchange.reserves_in +ORDER BY execution_date DESC +LIMIT 8; +") +while IFS='|' read -r ts val frac; do + [ -z "${ts:-}" ] && continue + sec=$(awk -v t="$ts" 'BEGIN{printf "%d", int(t/1000000)}') + human=$(date -d "@${sec}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$sec") + amt=$(fmt_goa "${val:-0}" "${frac:-0}") + if [ "$rf" = 1 ]; then rf=0; else RECENT_JSON="${RECENT_JSON},"; fi + RECENT_JSON="${RECENT_JSON} + {\"kind\": \"withdraw\", \"amount\": $(json_str "$amt"), \"ts_human\": $(json_str "$human"), \"ts_us\": ${ts:-0}}" +done < <(psqlq " +SELECT execution_date::text || '|' || (amount_with_fee).val::text || '|' || (amount_with_fee).frac::text +FROM exchange.withdraw +ORDER BY execution_date DESC +LIMIT 6; +") +RECENT_JSON="${RECENT_JSON} + ]" + +# live performance +measure_ms() { + local url="$1" t + t=$(curl -sS -o /dev/null -m 8 -w '%{time_total}' "$url" 2>/dev/null || echo "") + [ -z "$t" ] && { echo "null"; return; } + awk -v t="$t" 'BEGIN{printf "%d", (t+0)*1000}' +} +KEYS_MS=$(measure_ms "${BASE_URL}/keys") +CONFIG_MS=$(measure_ms "${BASE_URL}/config") +KEYS_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BASE_URL}/keys" 2>/dev/null || echo "000") +CONFIG_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BASE_URL}/config" 2>/dev/null || echo "000") +LOADAVG="" +[ -r /proc/loadavg ] && LOADAVG=$(awk '{print $1","$2","$3}' /proc/loadavg) + +WW="false" +pgrep -f taler-exchange-wirewatch >/dev/null 2>&1 && WW="true" + +STAT_EVENTS=$(q_count "exchange.exchange_statistic_counter_event") + +# Fail closed: zero denoms usually means broken query path (table always has keys) +if [ "${DENOMS_TOTAL:-0}" = "0" ]; then + abort "denominations count is 0 — refusing to publish (check peer auth / PATH)" +fi + +GEN=$(now_iso) +HUMAN=$(now_human) +num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; } + +MEM_JSON='"container_rss_human": "—"' +MEM_HELPER="${MEM_HELPER:-/usr/local/lib/landing-mem-snapshot.sh}" +if [ -f "$MEM_HELPER" ]; then + # shellcheck disable=SC1090 + . "$MEM_HELPER" + mem_snapshot_json || true +fi + +cat >"$TMP" < $OUT" diff --git a/scripts/taler-exchange/offline-sign-upload-keys.sh b/scripts/taler-exchange/offline-sign-upload-keys.sh new file mode 100755 index 0000000..0268d9b --- /dev/null +++ b/scripts/taler-exchange/offline-sign-upload-keys.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Offline: download future keys from local exchange, sign, upload. Restore public BASE_URL. +# Run as root inside taler-hacktivism-exchange-ansible. +set -euo pipefail +CONF=/etc/taler-exchange/taler-exchange.conf +OV=/etc/taler-exchange/exchange-overrides.conf +PUBLIC='https://exchange.hacktivism.ch/' +LOCAL='http://127.0.0.1:9011/' + +[ "$(id -u)" -eq 0 ] || { echo "root only"; exit 1; } + +sed -i "s|^BASE_URL = .*|BASE_URL = ${LOCAL}|" "$OV" +echo "BASE_URL=$(grep '^BASE_URL' "$OV")" + +echo "=== download ===" +if ! runuser -u taler-exchange-offline -- \ + taler-exchange-offline -c "$CONF" -L INFO download > /tmp/future-keys.json 2>/tmp/dl.err; then + echo "download failed:"; cat /tmp/dl.err + # still show size +fi +echo "dl_err:"; cat /tmp/dl.err | tail -30 +echo "dl_size=$(wc -c /dev/null || echo 0)" +head -c 400 /tmp/future-keys.json 2>/dev/null; echo + +if [ ! -s /tmp/future-keys.json ]; then + echo "empty download — check secmod connectivity / management API" + sed -i "s|^BASE_URL = .*|BASE_URL = ${PUBLIC}|" "$OV" + exit 1 +fi + +echo "=== sign ===" +runuser -u taler-exchange-offline -- \ + taler-exchange-offline -c "$CONF" -L INFO sign < /tmp/future-keys.json > /tmp/signed-keys.json 2>/tmp/sg.err +echo "sg_err:"; cat /tmp/sg.err | tail -20 +echo "sg_size=$(wc -c /tmp/up.err +echo "up_err:"; cat /tmp/up.err | tail -20 + +sed -i "s|^BASE_URL = .*|BASE_URL = ${PUBLIC}|" "$OV" +echo "BASE_URL restored=$(grep '^BASE_URL' "$OV")" + +echo "=== /keys probe ===" +sleep 1 +# wake suspended handlers with a tiny delay +for i in 1 2 3 4 5 6; do + code=$(curl -sS -m 12 -o /tmp/keys.json -w '%{http_code}' http://127.0.0.1:9011/keys || true) + sz=$(wc -c /dev/null || echo 0) + echo "try $i code=$code size=$sz" + if [ "$code" = "200" ] && [ "$sz" -gt 200 ]; then + echo KEYS_OK + head -c 300 /tmp/keys.json; echo + grep -oE '"master_public_key"[[:space:]]*:[[:space:]]*"[^"]+"' /tmp/keys.json | head -1 || true + exit 0 + fi + sleep 2 +done +echo KEYS_FAIL +tail -25 /var/log/taler-exchange/taler-exchange-httpd-*.log 2>/dev/null | tail -25 +exit 1 diff --git a/scripts/taler-exchange/start_base_services_for_taler_exchange.sh b/scripts/taler-exchange/start_base_services_for_taler_exchange.sh new file mode 100755 index 0000000..ad247e4 --- /dev/null +++ b/scripts/taler-exchange/start_base_services_for_taler_exchange.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# Root: base services for manual exchange (like merchant start_base_services_for_taler.sh). +# Then interactive shell as taler-exchange-httpd → run start_exchange.sh there. +# +# Secmods run as dedicated users (not httpd) — started here as root. +# httpd is started by /usr/local/bin/start_exchange.sh as taler-exchange-httpd. +# +# Usage: +# /root/start_base_services_for_taler_exchange.sh +# /root/start_base_services_for_taler_exchange.sh --no-shell + +set -e +CONF=/etc/taler-exchange/taler-exchange.conf +LOG_DIR=/var/log/taler-exchange +PWD_BIN=/usr/local/bin +EXCHANGE_STARTER=start_exchange.sh + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root" >&2 + exit 1 +fi + +NO_SHELL=0 +for arg in "$@"; do + case "$arg" in + --no-shell|-n) NO_SHELL=1 ;; + --help|-h) + echo "Usage: $0 [--no-shell]" + exit 0 + ;; + esac +done + +# --- Debian postgresql defaults (pg_createcluster layout) --- +ensure_postgresql() { + echo " Debian perms on /etc/postgresql + data/log/run..." + if [ -d /etc/postgresql ]; then + chown -R root:postgres /etc/postgresql + find /etc/postgresql -type d -exec chmod 755 {} \; + find /etc/postgresql -type f -name '*.conf' -exec chmod 640 {} \; + fi + chown -R postgres:postgres /var/lib/postgresql /var/log/postgresql 2>/dev/null || true + mkdir -p /var/run/postgresql + chown postgres:postgres /var/run/postgresql + chmod 2775 /var/run/postgresql 2>/dev/null || chmod 775 /var/run/postgresql + + if pg_isready -q 2>/dev/null; then + echo " already accepting connections" + pg_isready || true + return 0 + fi + + rm -f /var/run/postgresql/.s.PGSQL.*.lock 2>/dev/null || true + if ! pgrep -u postgres -x postgres >/dev/null 2>&1; then + rm -f /var/lib/postgresql/*/main/postmaster.pid 2>/dev/null || true + fi + + if command -v pg_ctlcluster >/dev/null 2>&1 && command -v pg_lsclusters >/dev/null 2>&1; then + while read -r ver name _rest; do + [ -n "$ver" ] || continue + echo " pg_ctlcluster $ver $name start" + pg_ctlcluster "$ver" "$name" start 2>/dev/null || true + done < <(pg_lsclusters --no-header 2>/dev/null || true) + fi + if ! pg_isready -q 2>/dev/null; then + if [ -x /etc/init.d/postgresql ]; then + /etc/init.d/postgresql start || true + else + service postgresql start || true + fi + fi + sleep 1 + pg_isready || true +} + +echo "Create log + runtime dirs... giving permission to taler-exchange users:" +mkdir -p "$LOG_DIR" +mkdir -p /run/taler-exchange/secmod-rsa /run/taler-exchange/secmod-cs \ + /run/taler-exchange/secmod-eddsa /run/taler-exchange/httpd +chown root:root /run/taler-exchange +chmod 755 /run/taler-exchange +chown taler-exchange-secmod-rsa:taler-exchange-secmod /run/taler-exchange/secmod-rsa +chown taler-exchange-secmod-cs:taler-exchange-secmod /run/taler-exchange/secmod-cs +chown taler-exchange-secmod-eddsa:taler-exchange-secmod /run/taler-exchange/secmod-eddsa +chown taler-exchange-httpd:www-data /run/taler-exchange/httpd +chmod 755 /run/taler-exchange/secmod-rsa /run/taler-exchange/secmod-cs /run/taler-exchange/secmod-eddsa +chmod 750 /run/taler-exchange/httpd +chown taler-exchange-httpd: "$LOG_DIR" +chmod 755 "$LOG_DIR" + +# Package default: each secmod user owns its tree (keys/ must not be root-owned). +mkdir -p /var/lib/taler-exchange/secmod-rsa /var/lib/taler-exchange/secmod-cs \ + /var/lib/taler-exchange/secmod-eddsa +chown -R taler-exchange-secmod-rsa:taler-exchange-secmod /var/lib/taler-exchange/secmod-rsa +chown -R taler-exchange-secmod-cs:taler-exchange-secmod /var/lib/taler-exchange/secmod-cs +chown -R taler-exchange-secmod-eddsa:taler-exchange-secmod /var/lib/taler-exchange/secmod-eddsa +chmod 700 /var/lib/taler-exchange/secmod-rsa /var/lib/taler-exchange/secmod-cs \ + /var/lib/taler-exchange/secmod-eddsa + +echo "Start base services needed for Taler Exchange." +echo "" + +echo "1. postgresql:" +ensure_postgresql + +# Linux COMM is 15 chars — never pgrep -x for long names; match full path in args. +start_bg() { + local user="$1"; shift + local name="$1"; shift + local bin="$1" + if ps -eo args= 2>/dev/null | grep -F "$bin" | grep -v grep >/dev/null 2>&1; then + echo " already running: $name" + return 0 + fi + echo " start $name as $user" + nohup runuser -u "$user" -- "$@" >>"$LOG_DIR/${name}.log" 2>&1 /dev/null || true + sleep 0.3 +} + +echo "2. crypto secmods:" +start_bg taler-exchange-secmod-rsa taler-exchange-secmod-rsa \ + /usr/bin/taler-exchange-secmod-rsa -c "$CONF" -L INFO +start_bg taler-exchange-secmod-cs taler-exchange-secmod-cs \ + /usr/bin/taler-exchange-secmod-cs -c "$CONF" -L INFO +start_bg taler-exchange-secmod-eddsa taler-exchange-secmod-eddsa \ + /usr/bin/taler-exchange-secmod-eddsa -c "$CONF" -L INFO + +echo "3. wire/db helpers (need wire config to stay up):" +start_bg taler-exchange-aggregator taler-exchange-aggregator \ + /usr/bin/taler-exchange-aggregator -c "$CONF" -L INFO || true +start_bg taler-exchange-closer taler-exchange-closer \ + /usr/bin/taler-exchange-closer -c "$CONF" -L INFO || true +start_bg taler-exchange-wire taler-exchange-wirewatch \ + /usr/bin/taler-exchange-wirewatch -c "$CONF" -L INFO || true +start_bg taler-exchange-wire taler-exchange-transfer \ + /usr/bin/taler-exchange-transfer -c "$CONF" -L INFO || true + +if [ "$NO_SHELL" -eq 1 ]; then + echo "Base services started (--no-shell). Next: runuser -u taler-exchange-httpd -- $PWD_BIN/$EXCHANGE_STARTER [--restart]" + exit 0 +fi + +echo "4. Switching now to user taler-exchange-httpd, in $PWD_BIN; find executable $EXCHANGE_STARTER there!" +echo "" +cd "$PWD_BIN" +# same pattern as merchant: -u and -s/--shell are mutually exclusive on util-linux runuser +exec runuser -u taler-exchange-httpd -- bash diff --git a/scripts/taler-exchange/start_exchange.sh b/scripts/taler-exchange/start_exchange.sh new file mode 100755 index 0000000..48f8171 --- /dev/null +++ b/scripts/taler-exchange/start_exchange.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Start / restart taler-exchange-httpd (manual, no systemd). +# Run as: taler-exchange-httpd +# Same role as start_merchant.sh for the merchant. +# +# Usage: +# start_exchange.sh +# start_exchange.sh --restart | -r +# start_exchange.sh --help + +set -u + +usage() { + cat <<'EOF' +Usage: start_exchange.sh [--restart|-r] [--help|-h] + + (default) Start taler-exchange-httpd if not already running. + --restart Stop live taler-exchange-httpd, then start cleanly. + Does not touch postgres/secmods/wire helpers + (those come from /root/start_base_services_for_taler_exchange.sh). +EOF +} + +DO_RESTART=0 +for arg in "$@"; do + case "$arg" in + --restart|-r) DO_RESTART=1 ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown option: $arg" >&2; usage >&2; exit 2 ;; + esac +done + +if [ "$(id -un)" != "taler-exchange-httpd" ]; then + echo "This script must be run as user taler-exchange-httpd" >&2 + exit 1 +fi + +CONF=/etc/taler-exchange/taler-exchange.conf +LOG_DIR=/var/log/taler-exchange +PORT=$(taler-exchange-config -c "$CONF" -s exchange -o PORT 2>/dev/null || echo 9011) + +list_httpd_pids() { + ps -eo pid=,stat=,args= 2>/dev/null | while read -r pid stat args; do + case "$stat" in Z*) continue ;; esac + case "$args" in + *start_exchange.sh*) continue ;; + esac + case "$args" in + *taler-exchange-httpd\ *|taler-exchange-httpd\ *|/usr/bin/taler-exchange-httpd\ *) + echo "$pid" + ;; + esac + done | sort -u +} + +kill_httpd() { + local pids + pids=$(list_httpd_pids | tr '\n' ' ') + if [ -z "${pids// }" ]; then + echo "No live taler-exchange-httpd processes to stop." + return 0 + fi + echo "Stopping PIDs: $pids" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 2 + local left + left=$(list_httpd_pids | tr '\n' ' ') + if [ -n "${left// }" ]; then + echo "SIGKILL remaining: $left" + # shellcheck disable=SC2086 + kill -KILL $left 2>/dev/null || true + sleep 1 + fi + echo "taler-exchange-httpd stopped." +} + +if [ "$DO_RESTART" -eq 1 ]; then + echo "=== restart: kill taler-exchange-httpd ===" + kill_httpd +fi + +echo "Start taler-exchange-httpd:" +LOG_FILE="$LOG_DIR/taler-exchange-httpd-$(date +%Y-%m-%d).log" +mkdir -p "$LOG_DIR" +touch "$LOG_FILE" 2>/dev/null || true + +if [ "$DO_RESTART" -eq 0 ] && [ -n "$(list_httpd_pids)" ]; then + echo "taler-exchange-httpd already running" +else + nohup taler-exchange-httpd -c "$CONF" -L INFO >>"$LOG_FILE" 2>&1 & + disown 2>/dev/null || true + sleep 2 +fi + +echo "Live processes:" +ps -eo pid,stat,args 2>/dev/null | grep taler-exchange-httpd | grep -v grep | grep -v ' Z ' || true + +if [ -x /usr/local/bin/check_exchange-health.sh ]; then + /usr/local/bin/check_exchange-health.sh || exit 1 +elif [ -x ./check_exchange-health.sh ]; then + ./check_exchange-health.sh || exit 1 +fi +exit 0 diff --git a/scripts/taler-exchange/start_wire_helpers.sh b/scripts/taler-exchange/start_wire_helpers.sh new file mode 100755 index 0000000..4343267 --- /dev/null +++ b/scripts/taler-exchange/start_wire_helpers.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Start wire/db helpers only (root, no interactive shell, no systemd). +# Uses nohup so helpers survive the launching shell. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +if [ -x "$ROOT/ensure_exchange_helpers.sh" ]; then + exec "$ROOT/ensure_exchange_helpers.sh" +fi +if [ -x /usr/local/bin/ensure_exchange_helpers.sh ]; then + exec /usr/local/bin/ensure_exchange_helpers.sh +fi +# Fallback if ensure not installed yet +CONF=/etc/taler-exchange/taler-exchange.conf +LOG_DIR=/var/log/taler-exchange +[ "$(id -u)" -eq 0 ] || { echo "root only"; exit 1; } +mkdir -p "$LOG_DIR" + +start_bg() { + local user="$1"; shift + local name="$1"; shift + local bin="$1" + if pgrep -u "$user" -x "$(basename "$bin")" >/dev/null 2>&1; then + echo "already: $name" + return 0 + fi + echo "start: $name as $user" + nohup runuser -u "$user" -- "$@" >>"$LOG_DIR/${name}.log" 2>&1 /dev/null || true + sleep 0.4 +} + +start_bg taler-exchange-aggregator taler-exchange-aggregator \ + /usr/bin/taler-exchange-aggregator -c "$CONF" -L INFO +start_bg taler-exchange-closer taler-exchange-closer \ + /usr/bin/taler-exchange-closer -c "$CONF" -L INFO +start_bg taler-exchange-wire taler-exchange-wirewatch \ + /usr/bin/taler-exchange-wirewatch -c "$CONF" -L INFO +start_bg taler-exchange-wire taler-exchange-transfer \ + /usr/bin/taler-exchange-transfer -c "$CONF" -L INFO + +sleep 1 +pgrep -af 'taler-exchange-(aggregator|closer|wirewatch|transfer|httpd|secmod)' || true +[ -x /usr/local/bin/check_exchange-health.sh ] && /usr/local/bin/check_exchange-health.sh || true diff --git a/scripts/taler-exchange/wire-enable-and-upload.sh b/scripts/taler-exchange/wire-enable-and-upload.sh new file mode 100755 index 0000000..fd44646 --- /dev/null +++ b/scripts/taler-exchange/wire-enable-and-upload.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# One-shot: offline enable wire account + fees, upload to local exchange, verify /keys. +# Run as root inside container taler-hacktivism-exchange-ansible. +set -euo pipefail + +CONF=/etc/taler-exchange/taler-exchange.conf +OV=/etc/taler-exchange/exchange-overrides.conf +PAYTO='payto://x-taler-bank/bank.hacktivism.ch/exchange?receiver-name=GOA%20Exchange' +LOCAL_BASE='http://127.0.0.1:9011/' +OPS=/tmp/offline-wire-ops-$$.json +OFFLINE_USER=taler-exchange-offline + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root in exchange container" >&2 + exit 1 +fi + +echo "=== 1. Ensure wire account stanza in exchange-overrides ===" +if ! grep -q '\[exchange-account-1\]' "$OV" 2>/dev/null; then + cat >>"$OV" <<'EOF' + +### Regional bank (libeufin, x-taler-bank) — no IBAN +[exchange-account-1] +PAYTO_URI = payto://x-taler-bank/bank.hacktivism.ch/exchange?receiver-name=GOA%20Exchange +ENABLE_CREDIT = YES +ENABLE_DEBIT = YES +@inline-secret@ exchange-accountcredentials-1 ../secrets/exchange-accountcredentials-1.secret.conf +EOF + echo "appended exchange-account-1" +else + echo "exchange-account-1 already present" +fi + +# credentials must be readable by wire helpers + httpd +if [ -f /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf ]; then + chown root:taler-exchange-wire /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf 2>/dev/null \ + || chown root:root /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf + chmod 640 /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf +fi + +echo "=== 2. Point BASE_URL at local httpd for offline upload ===" +# save public URL +PUBLIC_BASE=$(taler-config -c "$CONF" -s exchange -o BASE_URL 2>/dev/null || echo 'https://exchange.hacktivism.ch/') +# temporary override file (highest priority if inlined last — patch OV) +if grep -q '^BASE_URL' "$OV"; then + sed -i "s|^BASE_URL = .*|BASE_URL = ${LOCAL_BASE}|" "$OV" +else + echo "BASE_URL = ${LOCAL_BASE}" >>"$OV" +fi +echo "BASE_URL now: $(taler-config -c "$CONF" -s exchange -o BASE_URL)" + +echo "=== 3. Sign enable-account + wire-fee + global-fee (as offline user) ===" +# global-fee: year, history, account, purse, purse_timeout, history_expiration, max_free_purses +runuser -u "$OFFLINE_USER" -- taler-exchange-offline -c "$CONF" \ + enable-account "$PAYTO" \ + wire-fee now x-taler-bank GOA:0 GOA:0 \ + global-fee now GOA:0 GOA:0 GOA:0 '1 day' '1 year' 5 \ + >"$OPS" +echo "ops bytes: $(wc -c <"$OPS")" +head -c 200 "$OPS"; echo + +echo "=== 4. Upload ops to exchange ===" +runuser -u "$OFFLINE_USER" -- taler-exchange-offline -c "$CONF" upload <"$OPS" +echo "upload exit: $?" + +echo "=== 5. Restore public BASE_URL ===" +sed -i "s|^BASE_URL = .*|BASE_URL = ${PUBLIC_BASE}|" "$OV" +# ensure trailing slash style +if ! grep -q "BASE_URL = https://exchange.hacktivism.ch" "$OV"; then + sed -i "s|^BASE_URL = .*|BASE_URL = https://exchange.hacktivism.ch/|" "$OV" +fi +echo "BASE_URL restored: $(taler-config -c "$CONF" -s exchange -o BASE_URL)" + +echo "=== 6. Probe /keys (may need httpd already running) ===" +for i in 1 2 3 4 5; do + code=$(curl -sS -m 8 -o /tmp/keys-out.json -w '%{http_code}' http://127.0.0.1:9011/keys || echo fail) + sz=$(wc -c /dev/null || echo 0) + echo "try $i: http=$code size=$sz" + if [ "$code" = "200" ] && [ "${sz:-0}" -gt 100 ]; then + echo "KEYS_OK" + head -c 250 /tmp/keys-out.json; echo + # show accounts if present + grep -oE '"payto_uri"[^,}]+|"master_public_key"[^,}]+' /tmp/keys-out.json | head -10 || true + exit 0 + fi + sleep 2 +done + +echo "KEYS_NOT_YET — check logs" +tail -30 /var/log/taler-exchange/taler-exchange-httpd-*.log 2>/dev/null | tail -30 +exit 1 diff --git a/scripts/taler-landing/deploy-landings.sh b/scripts/taler-landing/deploy-landings.sh new file mode 100644 index 0000000..fe3eb07 --- /dev/null +++ b/scripts/taler-landing/deploy-landings.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Install exchange + merchant landing pages inside containers and ensure nginx +# listens on 9014 (exchange) / 9015 (merchant). +# +# Port map (wire these in Caddy / podman -p): +# 9013 bank landing (already on taler-hacktivism-bank) +# 9014 exchange landing (taler-hacktivism-exchange-ansible) +# 9015 merchant landing (taler-hacktivism) +# +# Run on koopa (host) with podman access. +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +# allow override when files already on host /tmp +SRC_EX="${SRC_EX:-$ROOT/configs/exchange-landing}" +SRC_MER="${SRC_MER:-$ROOT/configs/merchant-landing}" +SRC_QR="${SRC_QR:-$ROOT/configs/bank-landing/qrcode.min.js}" + +C_EX=taler-hacktivism-exchange-ansible +C_MER=taler-hacktivism + +echo "=== exchange landing → $C_EX :9014 ===" +# nginx may be missing on exchange image +if ! podman exec "$C_EX" command -v nginx >/dev/null 2>&1; then + echo "installing nginx in $C_EX …" + podman exec "$C_EX" bash -c 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq nginx' +fi +podman exec "$C_EX" mkdir -p /var/www/exchange-landing /etc/nginx/sites-available /etc/nginx/sites-enabled +podman cp "$SRC_EX/index.html" "$C_EX:/var/www/exchange-landing/index.html" +if [ -f "$SRC_QR" ]; then + podman cp "$SRC_QR" "$C_EX:/var/www/exchange-landing/qrcode.min.js" +fi +podman cp "$SRC_EX/nginx-landing.conf" "$C_EX:/etc/nginx/sites-available/exchange-landing" +podman exec "$C_EX" bash -c ' + ln -sfn /etc/nginx/sites-available/exchange-landing /etc/nginx/sites-enabled/exchange-landing + # ensure sites-enabled is included + if ! grep -q sites-enabled /etc/nginx/nginx.conf 2>/dev/null; then + sed -i "/http {/a\\ include /etc/nginx/sites-enabled/*;" /etc/nginx/nginx.conf 2>/dev/null || true + fi + nginx -t + if command -v systemctl >/dev/null && systemctl is-system-running >/dev/null 2>&1; then + systemctl enable nginx 2>/dev/null || true + systemctl restart nginx || nginx + else + nginx -s reload 2>/dev/null || nginx + fi + ss -lntp | grep 9014 || netstat -lntp 2>/dev/null | grep 9014 || true +' + +echo "=== merchant landing → $C_MER :9015 ===" +podman exec "$C_MER" mkdir -p /var/www/merchant-landing +podman cp "$SRC_MER/index.html" "$C_MER:/var/www/merchant-landing/index.html" +podman cp "$SRC_MER/nginx-landing.conf" "$C_MER:/etc/nginx/sites-available/merchant-landing" +podman exec "$C_MER" bash -c ' + ln -sfn /etc/nginx/sites-available/merchant-landing /etc/nginx/sites-enabled/merchant-landing + nginx -t + nginx -s reload 2>/dev/null || systemctl reload nginx 2>/dev/null || true + ss -lntp | grep 9015 || true +' + +echo +echo "=== inside-container checks ===" +podman exec "$C_EX" curl -sS -m 3 -o /dev/null -w "exchange_landing=%{http_code}\n" http://127.0.0.1:9014/intro/ || true +podman exec "$C_MER" curl -sS -m 3 -o /dev/null -w "merchant_landing=%{http_code}\n" http://127.0.0.1:9015/intro/ || true + +echo +echo "PORTS TO WIRE (host → Caddy / firewall / podman -p):" +echo " 9013 bank landing (taler-hacktivism-bank) already published" +echo " 9014 exchange landing (taler-hacktivism-exchange-ansible) NEEDS -p 9014:9014" +echo " 9015 merchant landing (taler-hacktivism) NEEDS -p 9015:9015" +echo +echo "If host curl to :9014/:9015 fails, republish pasta ports (commit+replace) — see README." diff --git a/scripts/taler-merchant/README.md b/scripts/taler-merchant/README.md new file mode 100644 index 0000000..339d693 --- /dev/null +++ b/scripts/taler-merchant/README.md @@ -0,0 +1,56 @@ +# taler-merchant scripts + +Mirrored from podman `taler-hacktivism`. + +| File | Location in container | User | +|------|----------------------|------| +| `start_base_services_for_taler.sh` | `/root/` | root | +| `start_merchant.sh` | `/usr/local/bin/` | `taler-merchant-httpd` | +| `ensure_merchant_helpers.sh` | `/usr/local/bin/` | root → `taler-merchant-httpd` | +| `setup_credit_facade.sh` | host root (`/root/koopa-admin-log/…`) | root on koopa | +| `check_merchant-health.sh` | `/usr/local/bin/` | any | +| `certbot_renew.sh` | `/root/scripts/` | root (bg from base) | +| `stats--merchant-payments.sh` | `/usr/local/bin/` | ops | +| `taler-hacktivism-email-helper.sh` | `/usr/local/bin/` | merchant (SMTP password via env in git mirror) | +| `taler-hacktivism-sms-helper-wrapper.sh` | `/usr/local/bin/` | merchant | + +### Settlement (wired / transfers) + +Automatic import needs: + +1. **`credit_facade_url`** = `…/accounts/$BANK_USER/taler-revenue/` (not `taler-wire-gateway/`) +2. **Bearer** bank token in `credit_facade_credentials` (Basic fails on history) +3. Running **`taler-merchant-wirewatch`** + **`taler-merchant-depositcheck`** + +```bash +# on koopa as root +./setup_credit_facade.sh +./ensure_merchant_helpers.sh # inside container or via start_merchant +``` + +SMS backends are symlinks into `/var/taler-src/...` (not copied). + +## Usage + +```bash +# root in container +./start_base_services_for_taler.sh +# then as taler-merchant-httpd in /usr/local/bin: +./start_merchant.sh --restart +# nginx (TLS frontend) if not already up — root: +# /etc/init.d/nginx start +./check_merchant-health.sh +``` + +### `check_merchant-health.sh` + +| Check | Severity | +|-------|----------| +| socket + listener + httpd + nginx | FAIL | +| merchant `/config` (unix sock or `:9010`) | FAIL | +| each enabled `[merchant-exchange-*]`: `GET …/keys` | FAIL | +| `MASTER_KEY` matches `master_public_key` in `/keys` | FAIL | +| helpers (webhook, exchangekeyupdate, depositcheck) | WARN | + +Disabled exchanges (`DISABLED = YES`) are skipped. +If public exchange URL is unreachable from the container, falls back to `http://127.0.0.1:9011/keys` (and pasta host IPs). diff --git a/scripts/taler-merchant/certbot_renew.sh b/scripts/taler-merchant/certbot_renew.sh new file mode 100755 index 0000000..f34007f --- /dev/null +++ b/scripts/taler-merchant/certbot_renew.sh @@ -0,0 +1,5 @@ +#!/bin/sh +while true; do + certbot renew --quiet + sleep 12h +done diff --git a/scripts/taler-merchant/check_merchant-health.sh b/scripts/taler-merchant/check_merchant-health.sh new file mode 100755 index 0000000..18dfc24 --- /dev/null +++ b/scripts/taler-merchant/check_merchant-health.sh @@ -0,0 +1,222 @@ +#!/bin/bash +# Health check for manual taler-merchant (container taler-hacktivism). +# Style: check_exchange-health.sh — [OK] / [FAIL] / [WARN], exit 1 on critical fail. +# +# Keys checks: for each enabled [merchant-exchange-*] with EXCHANGE_BASE_URL, +# GET …/keys and (if set) verify MASTER_KEY appears in the response. + +CONF="${TALER_MERCHANT_CONFIG:-/etc/taler-merchant/taler-merchant.conf}" +OVERRIDES=/etc/taler-merchant/merchant-overrides.conf +SOCK="/var/run/taler-merchant/httpd/merchant-http.sock" +SS_BIN=$(command -v ss) +CURL_BIN=$(command -v curl) + +green() { echo -e "\e[32m$1\e[0m"; } +red() { echo -e "\e[31m$1\e[0m"; } +yellow() { echo -e "\e[33m$1\e[0m"; } + +fail=0 +ok() { green "[OK] $1"; } +bad() { red "[FAIL] $1"; fail=1; } +warn() { yellow "[WARN] $1"; } + +is_url() { + case "$1" in + http://*|https://*) return 0 ;; + *) return 1 ;; + esac +} + +# GET /keys; try primary URL then optional host-local fallbacks for same exchange. +# Writes body to $1 (path). Returns 0 on HTTP success with non-empty body. +fetch_keys() { + local out="$1" + local primary="$2" + shift 2 + local url + for url in "$primary" "$@"; do + [ -z "$url" ] && continue + if $CURL_BIN -skf -m 6 "$url" -o "$out" 2>/dev/null \ + || $CURL_BIN -sf -m 6 "$url" -o "$out" 2>/dev/null; then + if [ -s "$out" ] && grep -qE 'master_public_key|"currency"' "$out" 2>/dev/null; then + echo "$url" + return 0 + fi + fi + done + return 1 +} + +echo "=== Taler Merchant Health Check ===" + +# --- 1–4: local service --- +if [ -S "$SOCK" ]; then + ok "socket exists: $SOCK" +else + bad "socket does NOT exist: $SOCK" +fi + +if [ -n "$SS_BIN" ] && $SS_BIN -xl 2>/dev/null | grep -q "$SOCK"; then + ok "Merchant-HTTPD listening on socket" +elif [ -n "$SS_BIN" ]; then + bad "no listener on socket" +else + warn "ss not available — skip socket listener check" +fi + +if pgrep -f taler-merchant-httpd >/dev/null 2>&1; then + ok "process taler-merchant-httpd" +else + bad "process taler-merchant-httpd is NOT running" +fi + +if pgrep -f "nginx: master" >/dev/null 2>&1; then + ok "nginx master process" +else + bad "nginx master process is NOT running" +fi + +# --- 5: merchant /config --- +if [ -n "$CURL_BIN" ]; then + cfg_ok=0 + if [ -S "$SOCK" ] && $CURL_BIN -sf -m 3 --unix-socket "$SOCK" "http://localhost/config" >/dev/null 2>&1; then + ok "merchant /config via unix socket" + cfg_ok=1 + elif $CURL_BIN -skf -m 3 "https://127.0.0.1:9010/config" >/dev/null 2>&1; then + ok "merchant /config via https://127.0.0.1:9010/config" + cfg_ok=1 + elif $CURL_BIN -sf -m 3 "http://127.0.0.1:9010/config" >/dev/null 2>&1; then + ok "merchant /config via http://127.0.0.1:9010/config" + cfg_ok=1 + fi + [ "$cfg_ok" -eq 0 ] && bad "merchant /config unreachable (socket and :9010)" +else + warn "curl missing — skip /config" +fi + +# --- 6: exchange /keys for configured exchanges --- +echo "--- configured exchanges (/keys) ---" + +# Emit lines: SECTION|DISABLED|BASE|MASTER (from overrides + optional taler-config) +list_exchanges() { + # Prefer site overrides file (authoritative for this host) + if [ -f "$OVERRIDES" ]; then + awk ' + BEGIN { sec=""; dis="NO"; base=""; master="" } + /^\[merchant-exchange-/ { + if (sec != "") printf "%s|%s|%s|%s\n", sec, dis, base, master + sec=$0; gsub(/[\[\] \t\r]/, "", sec) + dis="NO"; base=""; master="" + next + } + /^\[/ { + if (sec != "") printf "%s|%s|%s|%s\n", sec, dis, base, master + sec=""; next + } + sec=="" { next } + /^[ \t]*#/ { next } + { + line=$0 + sub(/[ \t]*#.*$/, "", line) + if (match(line, /^[ \t]*DISABLED[ \t]*=[ \t]*/)) { + dis=substr(line, RSTART+RLENGTH); gsub(/^[ \t]+|[ \t]+$/, "", dis) + } else if (match(line, /^[ \t]*EXCHANGE_BASE_URL[ \t]*=[ \t]*/)) { + base=substr(line, RSTART+RLENGTH); gsub(/^[ \t]+|[ \t]+$/, "", base) + } else if (match(line, /^[ \t]*MASTER_KEY[ \t]*=[ \t]*/)) { + master=substr(line, RSTART+RLENGTH); gsub(/^[ \t]+|[ \t]+$/, "", master) + } + } + END { if (sec != "") printf "%s|%s|%s|%s\n", sec, dis, base, master } + ' "$OVERRIDES" + fi +} + +if [ -z "$CURL_BIN" ]; then + bad "curl missing — cannot check exchange /keys" +else + exch_count=0 + while IFS='|' read -r sec dis base master; do + [ -z "$sec" ] && continue + case "${dis:-NO}" in + YES|yes|true|True|1) + warn "exchange $sec: DISABLED — skip /keys" + continue + ;; + esac + if ! is_url "$base"; then + # package stubs without URL (e.g. kudos only DISABLED) + warn "exchange $sec: no EXCHANGE_BASE_URL — skip" + continue + fi + exch_count=$((exch_count + 1)) + base_slash="${base%/}/" + primary="${base_slash}keys" + keys_tmp=$(mktemp 2>/dev/null || echo "/tmp/m-keys-$$-${exch_count}.json") + + # Fallbacks when public name is not reachable from container: + # host loopback ports published by podman (exchange :9011). + got_url= + if got_url=$(fetch_keys "$keys_tmp" "$primary" \ + "http://127.0.0.1:9011/keys" \ + "http://host.containers.internal:9011/keys" \ + "http://10.0.2.2:9011/keys"); then + ok "exchange $sec: /keys reachable ($got_url)" + if [ -n "$master" ]; then + if grep -qF "$master" "$keys_tmp" 2>/dev/null; then + ok "exchange $sec: MASTER_KEY matches /keys" + else + mpks=$(grep -oE '"master_public_key"[[:space:]]*:[[:space:]]*"[^"]+"' "$keys_tmp" 2>/dev/null | head -2) + bad "exchange $sec: MASTER_KEY does not match /keys (config MASTER_KEY=$master)" + [ -n "$mpks" ] && warn " seen in /keys: $mpks" + fi + else + warn "exchange $sec: no MASTER_KEY in config — skip key match" + fi + else + bad "exchange $sec: no /keys from $primary (and local :9011 fallbacks)" + fi + rm -f "$keys_tmp" 2>/dev/null || true + done </dev/null 2>&1 +} + +# Settlement needs wirewatch + depositcheck; others needed for ops. +for p in \ + taler-merchant-webhook \ + taler-merchant-kyccheck \ + taler-merchant-wirewatch \ + taler-merchant-depositcheck \ + taler-merchant-exchangekeyupdate \ + taler-merchant-reconciliation +do + if live_helper "$p"; then + ok "process $p" + else + bad "process $p not running" + fi +done + +if [ "$fail" -eq 0 ]; then + green "=== ALL CRITICAL CHECKS PASSED ===" + exit 0 +fi +red "=== SOME CHECKS FAILED ===" +exit 1 diff --git a/scripts/taler-merchant/ensure_merchant_helpers.sh b/scripts/taler-merchant/ensure_merchant_helpers.sh new file mode 100755 index 0000000..b626dd4 --- /dev/null +++ b/scripts/taler-merchant/ensure_merchant_helpers.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Ensure merchant helper processes are running (no systemd). +# Prefer run as taler-merchant-httpd; root may use runuser. +# +# Usage: +# ensure_merchant_helpers.sh +# (as root) ensure_merchant_helpers.sh # re-exec as taler-merchant-httpd +set -euo pipefail + +CONF="${TALER_MERCHANT_CONFIG:-/etc/taler-merchant/taler-merchant.conf}" +LOG_DIR="${TALER_MERCHANT_LOG_DIR:-/var/log/taler-merchant}" + +# As root: start wirewatch supervisor (needs root for runuser), then re-exec as httpd. +if [ "$(id -un)" = "root" ]; then + mkdir -p "$LOG_DIR" + if [ -x /usr/local/bin/taler-merchant-wirewatch-supervise.sh ]; then + if ! ps -eo args= 2>/dev/null | grep -q 'taler-merchant-wirewatch-supervise\.sh'; then + echo "start: taler-merchant-wirewatch-supervise" + nohup /usr/local/bin/taler-merchant-wirewatch-supervise.sh \ + >>"$LOG_DIR/wirewatch-supervise.nohup" 2>&1 /dev/null || true + else + echo "already: taler-merchant-wirewatch-supervise" + fi + fi + exec runuser -u taler-merchant-httpd -- "$0" "$@" +fi + +if [ "$(id -un)" != "taler-merchant-httpd" ]; then + echo "run as taler-merchant-httpd or root" >&2 + exit 1 +fi + +mkdir -p "$LOG_DIR" +chmod 755 "$LOG_DIR" 2>/dev/null || true + +# Linux COMM is 15 chars — do not use pgrep -x for taler-merchant-wirewatch etc. +is_running() { + local bin="$1" + local base + base=$(basename "$bin") + ps -u "$(id -un)" -o args= 2>/dev/null | grep -qE "(^|/)[${base:0:1}]${base:1}( |$)" \ + || pgrep -u "$(id -un)" -f "(^|/)(${base})( |$)" >/dev/null 2>&1 +} + +start_one() { + local name="$1" + local bin="$2" + shift 2 + if is_running "$bin"; then + echo "already: $name" + return 0 + fi + echo "start: $name" + nohup "$bin" "$@" >>"$LOG_DIR/${name}.log" 2>&1 /dev/null || true + local i + for i in 1 2 3 4 5 6; do + sleep 0.4 + if is_running "$bin"; then + echo " ok: $name" + return 0 + fi + done + echo " FAIL: $name did not stay up (see $LOG_DIR/${name}.log)" >&2 + tail -20 "$LOG_DIR/${name}.log" 2>/dev/null || true + return 1 +} + +ec=0 +# Helpers needed for settlement / ops (not only httpd). +start_one taler-merchant-webhook /usr/bin/taler-merchant-webhook || ec=1 +start_one taler-merchant-kyccheck /usr/bin/taler-merchant-kyccheck || ec=1 +# Prefer already-running supervisor (started as root above); else bare wirewatch. +if ps -eo args= 2>/dev/null | grep -q 'taler-merchant-wirewatch-supervise\.sh'; then + echo "already: taler-merchant-wirewatch (via supervise)" +elif is_running /usr/bin/taler-merchant-wirewatch; then + echo "already: taler-merchant-wirewatch" +else + start_one taler-merchant-wirewatch /usr/bin/taler-merchant-wirewatch \ + -c "$CONF" -L INFO || ec=1 +fi +start_one taler-merchant-depositcheck /usr/bin/taler-merchant-depositcheck || ec=1 +start_one taler-merchant-exchangekeyupdate /usr/bin/taler-merchant-exchangekeyupdate || ec=1 +start_one taler-merchant-reconciliation /usr/bin/taler-merchant-reconciliation || ec=1 + +if ! is_running taler-merchant-httpd; then + echo "start: taler-merchant-httpd" + nohup /usr/bin/taler-merchant-httpd --log=info \ + >>"$LOG_DIR/taler-merchant-httpd-$(date +%Y-%m-%d).log" 2>&1 /dev/null || true + sleep 1 + is_running taler-merchant-httpd || ec=1 +fi + +echo "--- live merchant ---" +ps -eo pid,user,stat,etime,args 2>/dev/null \ + | grep taler-merchant | grep -vE 'grep| Z |ensure_merchant' || true + +exit "$ec" diff --git a/scripts/taler-merchant/install_dual_terms.sh b/scripts/taler-merchant/install_dual_terms.sh new file mode 100644 index 0000000..dbb464d --- /dev/null +++ b/scripts/taler-merchant/install_dual_terms.sh @@ -0,0 +1,232 @@ +#!/bin/bash +# Install dual-currency "No Formal Terms" ToS for the merchant backend. +# Run as root inside the merchant container (taler-hacktivism). +# +# Sets files under TERMS_DIR/en/ for TERMS_ETAG = merchant-tos-dual-v0 +# (see configs/taler-hacktivism/merchant-overrides.conf). +set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" + +ETAG="${TERMS_ETAG:-merchant-tos-dual-v2}" +CONF="${TALER_MERCHANT_CONFIG:-/etc/taler-merchant/taler-merchant.conf}" + +DATA_HOME="" +if command -v taler-config >/dev/null 2>&1; then + DATA_HOME=$(taler-config -c "$CONF" -f -s PATHS -o TALER_DATA_HOME 2>/dev/null || true) +fi +# common fallbacks +for d in \ + "$DATA_HOME" \ + /var/lib/taler-merchant/ \ + /var/lib/taler-merchant +do + [ -n "${d:-}" ] || continue + d="${d%/}/" + if [ -d "$d" ] || mkdir -p "$d" 2>/dev/null; then + DATA_HOME="$d" + break + fi +done +DATA_HOME="${DATA_HOME:-/var/lib/taler-merchant/}" +TERMS_DIR="${TERMS_DIR:-${DATA_HOME%/}/terms}" +LANG_DIR="$TERMS_DIR/en" +mkdir -p "$LANG_DIR" + +TITLE="No Formal Terms · Dual Currency Notice" + +BODY_MD='# No Formal Terms · Dual Currency Notice + +This is a **self-hosted GNU Taler merchant backend** at `taler.hacktivism.ch` (hacktivism.ch). + +**No formal terms of service** from Taler Operations AG (or any other third-party portal operator) apply to this instance. This short notice is the site policy for using the backend. + +## Dual currency + +This backend is configured for **two currencies at once**: + +- **GOA** — explorational / experimental currency of the local stack (`exchange.hacktivism.ch`, `bank.hacktivism.ch`). GOA is **not** legal tender. It has **no guaranteed real-world value**, redemption, or convertibility. +- **CHF** — Swiss francs, a **real** currency. CHF amounts are real money settled via the CHF exchange configured on this host (taler-ops / TOPS infrastructure as deployed). Treat CHF with the seriousness of ordinary payments. + +By creating a merchant instance, accepting payments, or otherwise using this service you acknowledge that: + +- GOA is for exploration and testing only. +- CHF involves real money — only use funds you control and can afford to risk on a self-hosted experimental stack. +- There is no guaranteed availability, support, or uptime. +- Operators may reset GOA state, change configuration, delete instance data, or interrupt service without notice. +- Software is provided as-is, without warranty. + +If you do not agree, do not use this merchant backend. + +## Related + +- Exchange (GOA): https://exchange.hacktivism.ch/terms +- Bank intro: https://bank.hacktivism.ch/intro/ +- Merchant intro: https://taler.hacktivism.ch/intro/ +' + +BODY_TXT='No Formal Terms · Dual Currency Notice + +This is a self-hosted GNU Taler merchant backend at taler.hacktivism.ch (hacktivism.ch). + +No formal terms of service from Taler Operations AG (or any other third-party portal operator) apply to this instance. This short notice is the site policy for using the backend. + +Dual currency +------------- +This backend is configured for two currencies at once: + +- GOA — explorational / experimental currency of the local stack + (exchange.hacktivism.ch, bank.hacktivism.ch). GOA is not legal tender. + It has no guaranteed real-world value, redemption, or convertibility. +- CHF — Swiss francs, a real currency. CHF amounts are real money + settled via the CHF exchange configured on this host (taler-ops / TOPS + infrastructure as deployed). Treat CHF with the seriousness of + ordinary payments. + +By creating a merchant instance, accepting payments, or otherwise using +this service you acknowledge that: + +- GOA is for exploration and testing only. +- CHF involves real money — only use funds you control and can afford + to risk on a self-hosted experimental stack. +- There is no guaranteed availability, support, or uptime. +- Operators may reset GOA state, change configuration, delete instance + data, or interrupt service without notice. +- Software is provided as-is, without warranty. + +If you do not agree, do not use this merchant backend. + +Related +------- +- Exchange (GOA): https://exchange.hacktivism.ch/terms +- Bank intro: https://bank.hacktivism.ch/intro/ +- Merchant intro: https://taler.hacktivism.ch/intro/ +' + +printf '%s\n' "$BODY_TXT" >"$LANG_DIR/${ETAG}.txt" +printf '%s\n' "$BODY_MD" >"$LANG_DIR/${ETAG}.md" + +# HTML (browser-friendly; style close to exchange short terms) +cat >"$LANG_DIR/${ETAG}.html" < + + + + + ${TITLE} + + + +
taler.hacktivism.ch · merchant
+

${TITLE}

+

This is a self-hosted GNU Taler merchant backend at + taler.hacktivism.ch (hacktivism.ch).

+

No formal terms of service from Taler Operations AG + (or any other third-party portal operator) apply to this instance. + This short notice is the site policy for using the backend.

+ +

Dual currency

+

This backend is configured for two currencies at once:

+
+
+ GOA · explorational + Local stack currency + (exchange, + bank). + Not legal tender. No guaranteed real-world value, redemption, or convertibility. +
+
+ CHF · real + Swiss francs. Real money, settled via the CHF exchange configured + on this host (taler-ops / TOPS infrastructure as deployed). + Treat CHF with the seriousness of ordinary payments. +
+
+ +

By using this service you acknowledge

+
    +
  • GOA is for exploration and testing only.
  • +
  • CHF involves real money — only use funds you control and can afford to risk on a self-hosted experimental stack.
  • +
  • There is no guaranteed availability, support, or uptime.
  • +
  • Operators may reset GOA state, change configuration, delete instance data, or interrupt service without notice.
  • +
  • Software is provided as-is, without warranty.
  • +
+

If you do not agree, do not use this merchant backend.

+ +

Related

+ + +

Privacy

+

Processing under Swiss FADP (revDSG). What data is retained + (instances, orders, deposits, logs, …) is listed on + /privacy.

+
Version ${ETAG} · hacktivism.ch
+ + +HTML + +# Optional PDF so Accept: */* / wallets preferring PDF still get content +PDF="$LANG_DIR/${ETAG}.pdf" +if command -v pandoc >/dev/null 2>&1; then + if pandoc -f markdown -t pdf -o "$PDF" "$LANG_DIR/${ETAG}.md" 2>/dev/null; then + echo "pdf via pandoc: $PDF" + elif command -v wkhtmltopdf >/dev/null 2>&1; then + wkhtmltopdf "$LANG_DIR/${ETAG}.html" "$PDF" 2>/dev/null && echo "pdf via wkhtmltopdf" || true + fi +elif command -v wkhtmltopdf >/dev/null 2>&1; then + wkhtmltopdf "$LANG_DIR/${ETAG}.html" "$PDF" 2>/dev/null && echo "pdf via wkhtmltopdf" || true +fi +# If no PDF toolchain: leave absent — httpd will serve md/html/txt by Accept + +chmod -R a+rX "$TERMS_DIR" +if id taler-merchant-httpd >/dev/null 2>&1; then + chown -R taler-merchant-httpd: "$TERMS_DIR" 2>/dev/null \ + || chown -R taler-merchant-httpd:www-data "$TERMS_DIR" 2>/dev/null \ + || true +fi + +echo "Installed under $LANG_DIR:" +ls -la "$LANG_DIR"/${ETAG}.* 2>/dev/null || ls -la "$LANG_DIR" +echo +echo "Config should set:" +echo " [merchant]" +echo " TERMS_ETAG = ${ETAG}" +echo " TERMS_DIR = \${TALER_DATA_HOME}terms/" +echo "Then restart taler-merchant-httpd." diff --git a/scripts/taler-merchant/install_swiss_privacy.sh b/scripts/taler-merchant/install_swiss_privacy.sh new file mode 100644 index 0000000..f2ca0d7 --- /dev/null +++ b/scripts/taler-merchant/install_swiss_privacy.sh @@ -0,0 +1,195 @@ +#!/bin/bash +# Install Swiss FADP privacy policy for merchant backend (taler.hacktivism.ch). +# Run as root inside taler-hacktivism. Sets files for PRIVACY_ETAG=merchant-pp-swiss-v0 +set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" + +ETAG="${PRIVACY_ETAG:-merchant-pp-swiss-v0}" +CONF="${TALER_MERCHANT_CONFIG:-/etc/taler-merchant/taler-merchant.conf}" +DATA_HOME="" +if command -v taler-config >/dev/null 2>&1; then + DATA_HOME=$(taler-config -c "$CONF" -f -s PATHS -o TALER_DATA_HOME 2>/dev/null || true) +fi +DATA_HOME="${DATA_HOME:-/var/lib/taler-merchant/}" +PRIVACY_DIR="${PRIVACY_DIR:-${DATA_HOME%/}/terms}" +LANG_DIR="$PRIVACY_DIR/en" +mkdir -p "$LANG_DIR" + +TITLE="Privacy notice · GOA/CHF Merchant · Swiss FADP" +BODY_TXT='Privacy notice — taler.hacktivism.ch merchant backend (Swiss FADP / revDSG) + +Controller: operators of the hacktivism.ch GNU Taler stack. +This dual-currency merchant backend accepts GOA (explorational) and CHF (via taler-ops exchange configuration). + +Data retained (precise): +1) Merchant instances — merchant_id, serial, creation metadata, config flags: lifetime of instance + up to 12 months after delete or stack wipe. +2) Authentication — API tokens / login tokens (hashed or bearer secrets as stored by software): until expiry, revoke, or instance delete. +3) Contract terms / orders — order_id, amount (currency:amount), summary (free text), paid/wired flags, creation time: for service lifetime; public landing stats may show anonymised aggregates and recent amounts/summaries. +4) Deposits & refunds — deposit proofs, refund amounts/reasons, timestamps: for service lifetime or until operational wipe. +5) Settlement accounts — payto URIs / account labels configured on instances: while configured. +6) Exchange interaction metadata — which exchange (GOA/CHF) was used for deposits: with related order records. +7) Technical logs — HTTP/access and application logs (IP, path, status): typically days–weeks via rotation. +8) Public stats.json — aggregate counts and recent activity without full account credentials: overwritten minutely. + +Not retained by this merchant backend: wallet private keys; full card PANs; unsolicited government ID documents unless an operator enables separate KYC tooling. + +Purposes: accept GNU Taler payments, refunds, settlement, abuse prevention, operations. +Legal basis (FADP): performance of service requested by merchant operators/customers; proportionate operation of a public experimental stack. +Recipients: configured exchanges (exchange.hacktivism.ch for GOA; exchange.taler-ops.ch for CHF as configured); host operators. No sale of data. +Rights: access, rectification, deletion, objection under FADP; complaint to Swiss FDPIC (EDÖB). +Security: TLS; experimental — no certified ISMS. Do not put sensitive personal data in order summaries. + +Related: https://taler.hacktivism.ch/terms · https://exchange.hacktivism.ch/privacy · https://bank.hacktivism.ch/intro/privacy.html +' + +BODY_MD='# Privacy notice · Merchant · Swiss FADP + +Controller: operators of the **hacktivism.ch** GNU Taler stack (`taler.hacktivism.ch`). +Dual currency: **GOA** (explorational) and **CHF** (taler-ops exchange path). + +Processing under the Swiss Federal Act on Data Protection (**FADP / revDSG**, since 1 Sep 2023). + +## Data retained + +| Data | Examples | Retention | +|------|----------|-----------| +| Instances | merchant_id, serial, config | Instance life + up to 12 months after delete/wipe | +| Auth | API / login tokens | Until expiry, revoke, or instance delete | +| Orders | order_id, amount, summary, paid/wired, time | Service life or wipe; public stats may show recent aggregates | +| Deposits / refunds | proofs, amounts, reasons | Service life or wipe | +| Settlement | payto / account labels | While configured | +| Exchange metadata | GOA/CHF exchange used | With related order records | +| Technical logs | IP, path, status | Days–weeks (rotation) | +| Public stats.json | Aggregates, recent activity | Overwritten continuously | + +**Not retained:** wallet private keys; card PANs; government ID unless separate KYC is enabled. + +## Purposes + +Accept payments, refunds, settlement; security and operations of this experimental backend. + +## Rights + +Access, correction, deletion, objection (FADP). Complaint: Swiss **FDPIC / EDÖB**. + +## Related + +- [Merchant terms](https://taler.hacktivism.ch/terms) +- [Exchange privacy](https://exchange.hacktivism.ch/privacy) +- [Bank privacy](https://bank.hacktivism.ch/intro/privacy.html) +' + +cat >"$LANG_DIR/${ETAG}.txt" <"$LANG_DIR/${ETAG}.md" <"$LANG_DIR/${ETAG}.html" <<'HTML' + + + + + + Privacy notice · GOA/CHF Merchant · Swiss FADP + + + +
taler.hacktivism.ch · privacy · CH
+

Privacy notice · GOA/CHF Merchant

+

+ Swiss Federal Act on Data Protection (FADP / revDSG, since 1 Sep 2023). + Dual-currency merchant backend at taler.hacktivism.ch: + GOA (explorational) and CHF (taler-ops path). +

+ +

1. Controller

+

Operators of the hacktivism.ch GNU Taler stack. Experimental public deployment; + no separate DPO appointed.

+ +

2. Data retained

+ + + + + + + + + + + + +
DataExamplesTypical retention
Instancesmerchant_id, serial, configInstance life + up to 12 months after delete/wipe
AuthenticationAPI / login tokensUntil expiry, revoke, or instance delete
Ordersorder_id, amount, summary, paid/wired, timeService life or wipe; public stats may list recent amounts/summaries
Deposits / refundsproofs, amounts, reasonsService life or wipe
Settlement accountspayto / account labelsWhile configured
Exchange metadataGOA/CHF exchange usedWith related order records
Technical logsIP, path, statusDays–weeks (rotation)
Public stats.jsonAggregates, recent activityOverwritten continuously
+

Not retained: wallet private keys; payment card PANs; government ID documents + unless a separate KYC feature is enabled by operators.

+ +

3. Purposes

+
    +
  • Accept GNU Taler payments (GOA and/or CHF), refunds, and settlement
  • +
  • Authenticate instance operators
  • +
  • Security, abuse prevention, debugging
  • +
+ +

4. Recipients

+
    +
  • Local GOA exchange (exchange.hacktivism.ch)
  • +
  • CHF exchange as configured (e.g. exchange.taler-ops.ch)
  • +
  • Host/infrastructure operators under this deployment
  • +
+

No sale of personal data.

+ +

5. Your rights

+

Access, rectification, deletion, and objection under the FADP (within legal limits). + Complaint: Swiss Federal Data Protection and Information Commissioner + (FDPIC / EDÖB).

+ +

Related

+ +
merchant-pp-swiss-v0 · Swiss FADP (revDSG)
+ + +HTML + +chmod -R a+rX "$PRIVACY_DIR" +if id taler-merchant-httpd >/dev/null 2>&1; then + chown -R taler-merchant-httpd: "$PRIVACY_DIR" 2>/dev/null \ + || chown -R taler-merchant-httpd:www-data "$PRIVACY_DIR" 2>/dev/null || true +fi +echo "ok privacy $ETAG -> $LANG_DIR" +ls -la "$LANG_DIR"/${ETAG}.* diff --git a/scripts/taler-merchant/landing-stats-merchant.sh b/scripts/taler-merchant/landing-stats-merchant.sh new file mode 100644 index 0000000..cee1bc7 --- /dev/null +++ b/scripts/taler-merchant/landing-stats-merchant.sh @@ -0,0 +1,439 @@ +#!/bin/bash +# Run INSIDE taler-hacktivism. Writes /var/www/merchant-landing/stats.json +# +# Current taler-merchant schema: +# merchant.merchant_instances → merchant_serial, merchant_id +# merchant_instance_.* → per-instance tables +# +# IMPORTANT: +# - cron uses a minimal PATH. Without /usr/sbin, runuser is missing and +# every query used to fail silently → all zeros (and overwrote good data). +# - Never write stats.json on failure: leave the previous good file in place. +# - Avoid: psql -F$'\t' -v ON_ERROR_STOP=… (if -F loses the tab arg, -v is +# eaten as fieldsep and ON_ERROR_STOP becomes the *username*). +set -euo pipefail +export TZ="${TZ:-Europe/Zurich}" +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" +LANDING_DIR="${LANDING_DIR:-/var/www/merchant-landing}" +OUT="$LANDING_DIR/stats.json" +RUN="$LANDING_DIR/stats-run.json" +TMP="${OUT}.tmp.$$" +DB="${MERCHANT_DB:-taler-merchant}" +ACTIVITY_LIMIT="${ACTIVITY_LIMIT:-12}" +ERRLOG="${LANDING_STATS_ERRLOG:-/var/log/landing-stats-merchant.err}" +mkdir -p "$LANDING_DIR" + +now_iso() { date +%Y-%m-%dT%H:%M%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/'; } +now_human() { date +"%Y-%m-%d %H:%M %Z"; } +json_str() { + printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n\r\t' ' ' | sed 's/ */ /g')" +} + +# Public run status for the intro page (never wipe stats.json on failure). +write_run() { + local ok_json="$1" msg="${2:-}" + cat >"$RUN" <&2 + rm -f "$TMP" /tmp/merch_cur_$$.tsv /tmp/merch_act_$$.tsv 2>/dev/null || true + exit 1 +} + +as_postgres() { + if command -v runuser >/dev/null 2>&1; then + runuser -u postgres -- "$@" + elif command -v su >/dev/null 2>&1; then + su -s /bin/bash postgres -c "$*" + else + return 127 + fi +} + +# pipe SQL on stdin — never -f on root-owned temps (postgres cannot read them) +psql_pipe() { + local fs=$'\t' ec + set +e + as_postgres psql -d "$DB" -At -F"$fs" 2>>"$ERRLOG" + ec=$? + set -e + return "$ec" +} + +# Required single-value query. Empty/fail → abort (no site write). +psqlq() { + local sql="$1" out ec + set +e + out=$(as_postgres psql -d "$DB" -At -c "$sql" 2>>"$ERRLOG") + ec=$? + set -e + if [ "$ec" -ne 0 ]; then + abort "psql failed (ec=$ec): ${sql:0:120}" + fi + # strip trailing newlines only + printf '%s' "$out" | tr -d '\r' +} + +# "64.03" or "64.03000001" → clean CUR:value using decimal string (no binary float) +fmt_cur_num() { + local c="$1" n="$2" + awk -v c="$c" -v n="$n" 'BEGIN{ + gsub(/ /,"",n) + if (n == "" || n+0 == 0 && n !~ /[1-9]/) { printf "%s:0", c; exit } + if (n ~ /[eE]/) { + x = n+0 + s = sprintf("%.8f", x) + } else { + s = n + } + if (s ~ /\./) { + split(s, a, ".") + whole = a[1]; frac = substr(a[2] "00000000", 1, 8) + extra = substr(a[2], 9, 1) + if (extra != "" && extra+0 >= 5) { + f = frac+0 + 1 + if (f >= 100000000) { whole = whole+1; f = f - 100000000 } + frac = sprintf("%08d", f) + } + sub(/0+$/, "", frac) + if (frac == "") printf "%s:%s", c, whole + else printf "%s:%s.%s", c, whole, frac + } else { + printf "%s:%s", c, s + } + }' +} + +# --- preflight: tools + DB reachability (fail closed) --- +command -v psql >/dev/null 2>&1 || abort "psql not in PATH ($PATH)" +command -v runuser >/dev/null 2>&1 || command -v su >/dev/null 2>&1 || abort "neither runuser nor su in PATH" + +PROBE=$(psqlq "SELECT 1;") +[ "$PROBE" = "1" ] || abort "postgres not reachable (SELECT 1 → '$PROBE')" + +SERIALS=$(psqlq "SELECT merchant_serial::text FROM merchant.merchant_instances ORDER BY merchant_serial;") +# If table exists but we got nothing, still ok (empty install). If table missing, psqlq aborted. + +# Build list of existing instance schemas +SCHEMAS="" +INSTANCES=0 +while read -r serial; do + [ -z "$serial" ] && continue + # only pure integers + [[ "$serial" =~ ^[0-9]+$ ]] || abort "bad merchant_serial='$serial'" + sch="merchant_instance_${serial}" + exists=$(psqlq "SELECT 1 FROM pg_namespace WHERE nspname = '${sch}';") + if [ "$exists" != "1" ]; then + # schema lag — skip, do not abort (instance row without schema yet) + continue + fi + INSTANCES=$((INSTANCES + 1)) + SCHEMAS="${SCHEMAS}${SCHEMAS:+ }$sch:$serial" +done <<<"$SERIALS" + +# Sanity: if instance schemas exist in PG but SERIALS empty → query path broken +NS_COUNT=$(psqlq "SELECT count(*)::text FROM pg_namespace WHERE nspname LIKE 'merchant_instance_%';") +NS_COUNT=$(printf '%s' "$NS_COUNT" | tr -d '[:space:]') +if [ "${NS_COUNT:-0}" -gt 0 ] && [ "$INSTANCES" -eq 0 ]; then + abort "pg has ${NS_COUNT} merchant_instance_* schemas but resolved INSTANCES=0 (query/PATH bug)" +fi + +# --- dual-currency aggregation entirely in PostgreSQL (numeric) --- +{ + echo "SELECT" + echo " coalesce(nullif(split_part(c.contract_terms->>'amount', ':', 1), ''), '?') AS currency," + echo " count(*)::bigint," + echo " count(*) FILTER (WHERE c.paid)::bigint," + echo " count(*) FILTER (WHERE NOT c.paid)::bigint," + echo " count(*) FILTER (WHERE c.wired)::bigint," + echo " round(coalesce(sum(NULLIF(split_part(c.contract_terms->>'amount', ':', 2), '')::numeric), 0), 8)::text," + echo " round(coalesce(sum(NULLIF(split_part(c.contract_terms->>'amount', ':', 2), '')::numeric) FILTER (WHERE c.paid), 0), 8)::text" + echo "FROM (" + first=1 + for item in $SCHEMAS; do + sch=${item%%:*} + if [ "$first" = 1 ]; then first=0; else echo " UNION ALL "; fi + echo "SELECT contract_terms, paid, wired FROM ${sch}.merchant_contract_terms" + done + if [ "$first" = 1 ]; then + echo "SELECT NULL::jsonb AS contract_terms, false AS paid, false AS wired WHERE false" + fi + echo ") c" + echo "WHERE c.contract_terms ? 'amount'" + echo "GROUP BY 1 ORDER BY 1;" +} | psql_pipe >"/tmp/merch_cur_$$.tsv" || abort "currency aggregate query failed" +CUR_TSV=$(cat "/tmp/merch_cur_$$.tsv" 2>/dev/null || true) +rm -f "/tmp/merch_cur_$$.tsv" + +TOTAL_CONTRACTS=0; TOTAL_PAID=0; TOTAL_WIRED=0; TOTAL_UNPAID=0 +declare -A C_CONTRACTS C_PAID C_UNPAID C_WIRED C_AMT C_AMT_PAID + +while IFS=$'\t' read -r cur contracts paid unpaid wired amt amt_paid; do + [ -z "${cur:-}" ] && continue + TOTAL_CONTRACTS=$((TOTAL_CONTRACTS + contracts)) + TOTAL_PAID=$((TOTAL_PAID + paid)) + TOTAL_UNPAID=$((TOTAL_UNPAID + unpaid)) + TOTAL_WIRED=$((TOTAL_WIRED + wired)) + C_CONTRACTS[$cur]=$contracts + C_PAID[$cur]=$paid + C_UNPAID[$cur]=$unpaid + C_WIRED[$cur]=$wired + C_AMT[$cur]=$amt + C_AMT_PAID[$cur]=$amt_paid +done <<<"$CUR_TSV" + +# deposits / refunds +DEPOSITS=0; REFUNDS=0 +for item in $SCHEMAS; do + sch=${item%%:*} + d=$(psqlq "SELECT count(*)::text FROM ${sch}.merchant_deposits;") + r=$(psqlq "SELECT count(*)::text FROM ${sch}.merchant_refunds;") + d=$(printf '%s' "$d" | tr -d '[:space:]') + r=$(printf '%s' "$r" | tr -d '[:space:]') + [[ "$d" =~ ^[0-9]+$ ]] || abort "bad deposits count for $sch: '$d'" + [[ "$r" =~ ^[0-9]+$ ]] || abort "bad refunds count for $sch: '$r'" + DEPOSITS=$((DEPOSITS + d)) + REFUNDS=$((REFUNDS + r)) +done + +# --- recent activity: 5 latest events per currency (GOA + CHF), payments + refunds --- +ACT_PER_CURRENCY="${ACT_PER_CURRENCY:-5}" + +# Query latest ACT_PER_CURRENCY events for one currency code (payments ∪ refunds). +# Writes TSV rows: ts kind order_id amount summary status +query_activity_for_currency() { + local cur="$1" + local out="$2" + { + echo "SELECT * FROM (" + echo "SELECT * FROM (" + first=1 + for item in $SCHEMAS; do + sch=${item%%:*} + if [ "$first" = 1 ]; then first=0; else echo " UNION ALL "; fi + cat <>'amount','') AS amount, + left(translate(coalesce(contract_terms->>'summary',''), E'\t\n\r', ' '), 64) AS summary, + CASE WHEN wired THEN 'wired' ELSE 'paid' END AS status +FROM ${sch}.merchant_contract_terms +WHERE paid + AND upper(split_part(coalesce(contract_terms->>'amount',''), ':', 1)) = upper('${cur}') +SQL + done + if [ "$first" = 1 ]; then + echo "SELECT 0::bigint AS ts, ''::text AS kind, ''::text AS order_id, ''::text AS amount, ''::text AS summary, ''::text AS status WHERE false" + fi + echo " UNION ALL " + first=1 + for item in $SCHEMAS; do + sch=${item%%:*} + if [ "$first" = 1 ]; then first=0; else echo " UNION ALL "; fi + cat <"$out" || abort "activity query failed for $cur" +} + +# Build JSON array of activity items from a TSV file +activity_tsv_to_json() { + local tsv_file="$1" + local json="[" + local af=1 + local ts kind oid amt sum st sec human_fmt iso + while IFS=$'\t' read -r ts kind oid amt sum st; do + [ -z "${ts:-}" ] && continue + [ "$ts" = "0" ] && [ -z "$kind" ] && continue + sec=$(awk -v t="$ts" 'BEGIN{printf "%d", int(t/1000000)}') + human_fmt=$(date -d "@${sec}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$sec") + iso=$(date -d "@${sec}" +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/' || true) + if [ "$af" = 1 ]; then af=0; else json="${json},"; fi + json="${json} + { + \"ts_us\": ${ts:-0}, + \"ts\": $(json_str "$iso"), + \"ts_human\": $(json_str "$human_fmt"), + \"kind\": $(json_str "$kind"), + \"order_id\": $(json_str "$oid"), + \"amount\": $(json_str "$amt"), + \"summary\": $(json_str "$sum"), + \"status\": $(json_str "$st") + }" + done <"$tsv_file" + json="${json} + ]" + printf '%s' "$json" +} + +query_activity_for_currency GOA "/tmp/merch_act_goa_$$.tsv" +query_activity_for_currency CHF "/tmp/merch_act_chf_$$.tsv" +ACT_GOA_JSON=$(activity_tsv_to_json "/tmp/merch_act_goa_$$.tsv") +ACT_CHF_JSON=$(activity_tsv_to_json "/tmp/merch_act_chf_$$.tsv") +rm -f "/tmp/merch_act_goa_$$.tsv" "/tmp/merch_act_chf_$$.tsv" + +# Flat recent_activity: GOA then CHF (compat; landing prefers by_currency) +ACT_JSON="[" +af=1 +for block in "$ACT_GOA_JSON" "$ACT_CHF_JSON"; do + # strip outer [ ] and inject if non-empty + inner=$(printf '%s' "$block" | sed '1s/^\s*\[//; $s/\]\s*$//') + # empty array → skip + if printf '%s' "$inner" | grep -q '"kind"'; then + if [ "$af" = 1 ]; then af=0; else ACT_JSON="${ACT_JSON},"; fi + ACT_JSON="${ACT_JSON}${inner}" + fi +done +ACT_JSON="${ACT_JSON} + ]" + +ACT_BY_CUR_JSON="[ + { + \"currency\": \"GOA\", + \"limit\": ${ACT_PER_CURRENCY}, + \"items\": ${ACT_GOA_JSON} + }, + { + \"currency\": \"CHF\", + \"limit\": ${ACT_PER_CURRENCY}, + \"items\": ${ACT_CHF_JSON} + } + ]" + +# by_currency JSON — GOA then CHF then rest +CUR_JSON="[" +cf=1 +emit_cur() { + local cur="$1" + [ -n "${C_CONTRACTS[$cur]+x}" ] || return 1 + local amt_fmt paid_fmt + amt_fmt=$(fmt_cur_num "$cur" "${C_AMT[$cur]}") + paid_fmt=$(fmt_cur_num "$cur" "${C_AMT_PAID[$cur]}") + if [ "$cf" = 1 ]; then cf=0; else CUR_JSON="${CUR_JSON},"; fi + CUR_JSON="${CUR_JSON} + { + \"currency\": $(json_str "$cur"), + \"contracts\": ${C_CONTRACTS[$cur]:-0}, + \"paid\": ${C_PAID[$cur]:-0}, + \"unpaid\": ${C_UNPAID[$cur]:-0}, + \"wired\": ${C_WIRED[$cur]:-0}, + \"amount_sum\": $(json_str "$amt_fmt"), + \"amount_paid_sum\": $(json_str "$paid_fmt") + }" +} +emit_cur GOA || true +emit_cur CHF || true +for cur in "${!C_CONTRACTS[@]}"; do + case "$cur" in GOA|CHF) continue ;; esac + emit_cur "$cur" || true +done +CUR_JSON="${CUR_JSON} + ]" + +GEN=$(now_iso) +HUMAN=$(now_human) + +# Live performance probes (exchange-style) +measure_ms() { + local url="$1" t + t=$(curl -skS -o /dev/null -m 8 -w '%{time_total}' "$url" 2>/dev/null || echo "") + [ -z "$t" ] && { echo "null"; return; } + awk -v t="$t" 'BEGIN{printf "%d", (t+0)*1000}' +} +num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; } +MERCHANT_PUBLIC_BASE="${MERCHANT_PUBLIC_URL:-https://taler.hacktivism.ch}" +MERCHANT_LOCAL="${MERCHANT_LOCAL_URL:-https://127.0.0.1:9010}" +CONFIG_MS=$(measure_ms "${MERCHANT_PUBLIC_BASE}/config") +CONFIG_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' "${MERCHANT_PUBLIC_BASE}/config" 2>/dev/null || echo "000") +if [ "$CONFIG_HTTP" != "200" ]; then + CONFIG_MS=$(measure_ms "${MERCHANT_LOCAL}/config") + CONFIG_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' "${MERCHANT_LOCAL}/config" 2>/dev/null || echo "000") +fi +TERMS_MS=$(measure_ms "${MERCHANT_PUBLIC_BASE}/terms") +TERMS_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' -H "Accept: text/html" "${MERCHANT_PUBLIC_BASE}/terms" 2>/dev/null || echo "000") +WEBUI_MS=$(measure_ms "${MERCHANT_PUBLIC_BASE}/webui/") +WEBUI_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' "${MERCHANT_PUBLIC_BASE}/webui/" 2>/dev/null || echo "000") +LOADAVG="" +[ -r /proc/loadavg ] && LOADAVG=$(awk '{print $1","$2","$3}' /proc/loadavg) + +MEM_JSON='"container_rss_human": "—"' +MEM_HELPER="${MEM_HELPER:-/usr/local/lib/landing-mem-snapshot.sh}" +if [ -f "$MEM_HELPER" ]; then + # shellcheck disable=SC1090 + . "$MEM_HELPER" + mem_snapshot_json || true +fi + +# Atomic write only after full success +cat >"$TMP" <", + "dual_currency": true, + "currencies_note": "CHF (taler-ops) + GOA (hacktivism)", + "timezone": $(json_str "$TZ"), + "generated_at": $(json_str "$GEN"), + "generated_at_human": $(json_str "$HUMAN"), + "instances": ${INSTANCES:-0}, + "orders": ${TOTAL_CONTRACTS:-0}, + "paid": ${TOTAL_PAID:-0}, + "unpaid": ${TOTAL_UNPAID:-0}, + "wired": ${TOTAL_WIRED:-0}, + "deposits": ${DEPOSITS:-0}, + "refunds": ${REFUNDS:-0}, + "by_currency": $CUR_JSON, + "recent_activity_limit_per_currency": ${ACT_PER_CURRENCY}, + "recent_activity_by_currency": $ACT_BY_CUR_JSON, + "recent_activity": $ACT_JSON, + "performance": { + "config_http": $(json_str "$CONFIG_HTTP"), + "config_ms": $(num_or_null "$CONFIG_MS"), + "terms_http": $(json_str "$TERMS_HTTP"), + "terms_ms": $(num_or_null "$TERMS_MS"), + "webui_http": $(json_str "$WEBUI_HTTP"), + "webui_ms": $(num_or_null "$WEBUI_MS"), + "loadavg": $(json_str "${LOADAVG:-}"), + "memory": { +${MEM_JSON} + } + } +} +EOF + +# basic JSON sanity before publish +grep -q '"ok": true' "$TMP" || abort "tmp json missing ok:true" +mv -f "$TMP" "$OUT" +write_run true +echo "ok merchant instances=$INSTANCES orders=$TOTAL_CONTRACTS paid=$TOTAL_PAID refunds=$REFUNDS -> $OUT" diff --git a/scripts/taler-merchant/setup_credit_facade.sh b/scripts/taler-merchant/setup_credit_facade.sh new file mode 100755 index 0000000..3ab0403 --- /dev/null +++ b/scripts/taler-merchant/setup_credit_facade.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Configure merchant bank account credit facade for automatic settlement import. +# +# Root cause (2026-07-09): merchant wirewatch must call the **Taler Revenue API** +# (`…/taler-revenue/`), not the exchange wire-gateway (`…/taler-wire-gateway/`). +# On this bank, history endpoints accept **Bearer** tokens only (Basic → 401). +# +# Usage (as root on koopa host): +# setup_credit_facade.sh +# MERCHANT_INSTANCE=goa-demo-cp4zqk setup_credit_facade.sh +# BANK_USER=… BANK_PW_FILE=… MERCHANT_PW_FILE=… setup_credit_facade.sh +# +# Effects: +# - PATCH /instances/$INST/private/accounts/$H_WIRE with credit_facade_* +# - long-lived refreshable bank token (1y) +# - restarts taler-merchant-wirewatch (once, after facade is set) +set -euo pipefail + +INST="${MERCHANT_INSTANCE:-goa-demo-cp4zqk}" +BANK_USER="${BANK_USER:-$INST}" +MERCHANT_PW_FILE="${MERCHANT_PW_FILE:-/root/merchant-${INST}-password.txt}" +BANK_PW_FILE="${BANK_PW_FILE:-/root/bank-${BANK_USER}-password.txt}" +MER_URL="${MERCHANT_URL:-https://127.0.0.1:9010}" +BANK_URL="${BANK_URL:-http://127.0.0.1:9012}" +PUBLIC_BANK="${PUBLIC_BANK_BASE:-https://bank.hacktivism.ch}" +FACADE_URL="${CREDIT_FACADE_URL:-${PUBLIC_BANK}/accounts/${BANK_USER}/taler-revenue/}" + +if [ "$(id -un)" != "root" ]; then + echo "run as root on koopa host" >&2 + exit 1 +fi +if [ ! -r "$MERCHANT_PW_FILE" ] || [ ! -r "$BANK_PW_FILE" ]; then + echo "need readable $MERCHANT_PW_FILE and $BANK_PW_FILE" >&2 + exit 1 +fi + +MPW=$(tr -d '\n' <"$MERCHANT_PW_FILE") +BPW=$(tr -d '\n' <"$BANK_PW_FILE") +AUTH="Authorization: Bearer secret-token:${MPW}" + +echo "=== bank token for $BANK_USER ===" +TOK=$(curl -sS -u "${BANK_USER}:${BPW}" -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite","refreshable":true,"duration":{"d_us":31536000000000}}' \ + "${BANK_URL}/accounts/${BANK_USER}/token" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') +echo "tok_len=${#TOK}" + +echo "=== verify revenue history ===" +code=$(curl -sS -m 15 -o /tmp/rev-check.json -w "%{http_code}" \ + -H "Authorization: Bearer ${TOK}" \ + "${BANK_URL}/accounts/${BANK_USER}/taler-revenue/history?delta=-3") +echo "revenue_history_http=$code" +python3 -c 'import json;d=json.load(open("/tmp/rev-check.json"));print("incoming",len(d.get("incoming_transactions")or[]))' +[ "$code" = "200" ] || { echo "FAIL revenue history"; exit 1; } + +echo "=== PATCH credit_facade ($FACADE_URL) ===" +H_WIRE=$(curl -sk -H "$AUTH" "${MER_URL}/instances/${INST}/private/accounts" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["accounts"][0]["h_wire"])') +export FACADE_URL TOK +BODY=$(python3 - <<'PY' +import json, os +print(json.dumps({ + "credit_facade_url": os.environ["FACADE_URL"], + "credit_facade_credentials": {"type": "bearer", "token": os.environ["TOK"]}, +})) +PY +) +curl -sk -X PATCH -H "$AUTH" -H 'Content-Type: application/json' -d "$BODY" \ + "${MER_URL}/instances/${INST}/private/accounts/${H_WIRE}" \ + -w "PATCH_HTTP=%{http_code}\n" -o /dev/null + +echo "=== restart wirewatch in merchant container ===" +su - hernani -c 'podman exec taler-hacktivism bash -c " +set +e +for p in \$(ps -eo pid=,args= | awk \"\\\$0 ~ /\\/usr\\/bin\\/taler-merchant-wirewatch/ {print \\\$1}\"); do + kill \$p 2>/dev/null || true +done +sleep 1 +runuser -u taler-merchant-httpd -- nohup /usr/bin/taler-merchant-wirewatch \ + -c /etc/taler-merchant/taler-merchant.conf -L INFO \ + >>/var/log/taler-merchant/wirewatch.log 2>&1 & +sleep 2 +ps -eo pid,etime,args | grep -E \"[t]aler-merchant-wirewatch\" || echo FAIL_no_wirewatch +tail -8 /var/log/taler-merchant/wirewatch.log +"' + +echo "=== private/transfers (sample) ===" +curl -sk -H "$AUTH" "${MER_URL}/instances/${INST}/private/transfers" \ + | python3 -c 'import sys,json;d=json.load(sys.stdin);t=d.get("transfers")or[];print("transfers",len(t))' + +echo OK_credit_facade diff --git a/scripts/taler-merchant/start_base_services_for_taler.sh b/scripts/taler-merchant/start_base_services_for_taler.sh new file mode 100755 index 0000000..ff26c8d --- /dev/null +++ b/scripts/taler-merchant/start_base_services_for_taler.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# Root: base services for manual merchant (no systemd). +# Pattern (all three stacks): root base → shell as service user → start_*.sh +# +# Container: taler-hacktivism +# Then as taler-merchant-httpd: /usr/local/bin/start_merchant.sh [--restart] +# +# Usage: +# /root/start_base_services_for_taler.sh # interactive shell as service user +# /root/start_base_services_for_taler.sh --no-shell # base only (automation) + +set -e + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root" >&2 + exit 1 +fi + +NO_SHELL=0 +for arg in "$@"; do + case "$arg" in + --no-shell|-n) NO_SHELL=1 ;; + --help|-h) + echo "Usage: $0 [--no-shell]" + exit 0 + ;; + esac +done + +PWD_BIN="/usr/local/bin" +MERCHANT_STARTER="start_merchant.sh" +LOG_DIR=/var/log/taler-merchant +RUN_DIR=/var/run/taler-merchant/httpd + +# --- Debian postgresql defaults (pg_createcluster layout) --- +ensure_postgresql() { + echo " Debian perms on /etc/postgresql + data/log/run..." + if [ -d /etc/postgresql ]; then + chown -R root:postgres /etc/postgresql + find /etc/postgresql -type d -exec chmod 755 {} \; + find /etc/postgresql -type f -name '*.conf' -exec chmod 640 {} \; + fi + chown -R postgres:postgres /var/lib/postgresql /var/log/postgresql 2>/dev/null || true + mkdir -p /var/run/postgresql + chown postgres:postgres /var/run/postgresql + # Debian package uses setgid sticky on run dir + chmod 2775 /var/run/postgresql 2>/dev/null || chmod 775 /var/run/postgresql + + if pg_isready -q 2>/dev/null; then + echo " already accepting connections" + pg_isready || true + return 0 + fi + + # Stale socket lock only when not accepting + rm -f /var/run/postgresql/.s.PGSQL.*.lock 2>/dev/null || true + if ! pgrep -u postgres -x postgres >/dev/null 2>&1; then + rm -f /var/lib/postgresql/*/main/postmaster.pid 2>/dev/null || true + fi + + if command -v pg_ctlcluster >/dev/null 2>&1 && command -v pg_lsclusters >/dev/null 2>&1; then + while read -r ver name _rest; do + [ -n "$ver" ] || continue + echo " pg_ctlcluster $ver $name start" + pg_ctlcluster "$ver" "$name" start 2>/dev/null || true + done < <(pg_lsclusters --no-header 2>/dev/null || true) + fi + if ! pg_isready -q 2>/dev/null; then + if [ -x /etc/init.d/postgresql ]; then + /etc/init.d/postgresql start || true + else + service postgresql start || true + fi + fi + sleep 1 + pg_isready || true +} + +echo "Start certbot renewal (background)..." +if [ -x /root/scripts/certbot_renew.sh ]; then + /root/scripts/certbot_renew.sh & +elif [ -x ./scripts/certbot_renew.sh ]; then + ./scripts/certbot_renew.sh & +fi + +echo "Create log + runtime dirs... permissions for taler-merchant-httpd / www-data:" +mkdir -p "$LOG_DIR" /var/run/taler-merchant "$RUN_DIR" +chown taler-merchant-httpd: "$LOG_DIR" +chmod 755 "$LOG_DIR" +chown taler-merchant-httpd:www-data /var/run/taler-merchant "$RUN_DIR" +chmod 755 /var/run/taler-merchant "$RUN_DIR" +# merchant app data (package default home) +if [ -d /var/lib/taler-merchant ]; then + chown -R taler-merchant-httpd:www-data /var/lib/taler-merchant 2>/dev/null || true +fi + +echo "Start base services needed for Taler Merchant." +echo "" + +if [ -f /root/.taler-secrets-env ]; then + echo -n "Read secrets needed for SMS delivery:" + set -a + # shellcheck disable=SC1091 + source /root/.taler-secrets-env && echo " OK" + set +a +else + echo "No /root/.taler-secrets-env (SMS may fail)" +fi + +echo "1. postgresql:" +ensure_postgresql + +echo "2. nginx:" +if [ -x /etc/init.d/nginx ]; then + /etc/init.d/nginx start 2>/dev/null || nginx || true +else + service nginx start 2>/dev/null || nginx || true +fi + +if [ "$NO_SHELL" -eq 1 ]; then + echo "Base services started (--no-shell). Next: runuser -u taler-merchant-httpd -- $PWD_BIN/$MERCHANT_STARTER [--restart]" + exit 0 +fi + +echo "3. Switching now to user taler-merchant-httpd, in $PWD_BIN; find executable $MERCHANT_STARTER there!" +echo "" +cd "$PWD_BIN" +# util-linux: -u and -s/--shell are mutually exclusive +exec runuser -u taler-merchant-httpd -- env \ + CLICKSEND_API_KEY="${CLICKSEND_API_KEY:-}" \ + CLICKSEND_USERNAME="${CLICKSEND_USERNAME:-}" \ + TELESIGN_AUTH_TOKEN="${TELESIGN_AUTH_TOKEN:-}" \ + bash diff --git a/scripts/taler-merchant/start_merchant.sh b/scripts/taler-merchant/start_merchant.sh new file mode 100755 index 0000000..a6d1d90 --- /dev/null +++ b/scripts/taler-merchant/start_merchant.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# Start / restart taler-merchant (manual, no systemd). +# Run as: taler-merchant-httpd +# +# Usage: +# start_merchant.sh +# start_merchant.sh --restart | -r +# start_merchant.sh --help + +set -u + +usage() { + cat <<'EOF' +Usage: start_merchant.sh [--restart|-r] [--help|-h] + + (default) Start merchant helpers + httpd. + --restart Stop live taler-merchant-* daemons, then start cleanly. + Does not touch postgres/nginx. +EOF +} + +DO_RESTART=0 +for arg in "$@"; do + case "$arg" in + --restart|-r) DO_RESTART=1 ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown option: $arg" >&2; usage >&2; exit 2 ;; + esac +done + +if [ "$(id -un)" != "taler-merchant-httpd" ]; then + echo "This script must be run as user taler-merchant-httpd" >&2 + exit 1 +fi + +list_merchant_pids() { + ps -eo pid=,stat=,args= 2>/dev/null | while read -r pid stat args; do + case "$stat" in Z*) continue ;; esac + case "$args" in + *start_merchant.sh*) continue ;; + esac + case "$args" in + *taler-merchant-httpd\ *|taler-merchant-httpd\ *) + echo "$pid" ;; + *taler-merchant-webhook*|*taler-merchant-kyccheck*|*taler-merchant-wirewatch*) + echo "$pid" ;; + *taler-merchant-depositcheck*|*taler-merchant-exchangekeyupdate*|*taler-merchant-reconciliation*) + echo "$pid" ;; + esac + done | sort -u +} + +kill_taler_merchant() { + local pids + pids=$(list_merchant_pids | tr '\n' ' ') + if [ -z "${pids// }" ]; then + echo "No live taler-merchant processes to stop." + return 0 + fi + echo "Stopping PIDs: $pids" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 2 + local left + left=$(list_merchant_pids | tr '\n' ' ') + if [ -n "${left// }" ]; then + echo "SIGKILL remaining: $left" + # shellcheck disable=SC2086 + kill -KILL $left 2>/dev/null || true + sleep 1 + fi + echo "taler-merchant daemons stopped." +} + +TIMESTAMP=$(date +"%Y%m%d_%H%M") +BACKUP_DIR="/var/taler-backups" +LOG_DIR="/var/log/taler-merchant" + +if [ "$DO_RESTART" -eq 1 ]; then + echo "=== restart: kill taler-merchant-* ===" + kill_taler_merchant +fi + +BACKUP_NAME="taler-merchant-$TIMESTAMP.sql" +echo -n "Backup taler-merchant DB: " +mkdir -p "$BACKUP_DIR" 2>/dev/null || true +if pg_dump taler-merchant >"$BACKUP_DIR/$BACKUP_NAME" 2>/dev/null; then + echo "OK" +else + echo "SKIP/FAIL (postgres?)" +fi +echo "Start taler-merchant components:" + +LOG_FILE="$LOG_DIR/taler-merchant-httpd-$(date +%Y-%m-%d).log" +mkdir -p "$LOG_DIR" +touch "$LOG_FILE" + +# Prefer dedicated ensure script (nohup + logs per helper). +if [ -x /usr/local/bin/ensure_merchant_helpers.sh ]; then + /usr/local/bin/ensure_merchant_helpers.sh || true +elif [ -x "$(dirname "$0")/ensure_merchant_helpers.sh" ]; then + "$(dirname "$0")/ensure_merchant_helpers.sh" || true +else + nohup taler-merchant-httpd --log=info >>"$LOG_FILE" 2>&1 /dev/null || true + sleep 2 + nohup taler-merchant-webhook >>"$LOG_DIR/taler-merchant-webhook.log" 2>&1 >"$LOG_DIR/taler-merchant-kyccheck.log" 2>&1 >"$LOG_DIR/taler-merchant-wirewatch.log" 2>&1 >"$LOG_DIR/taler-merchant-depositcheck.log" 2>&1 >"$LOG_DIR/taler-merchant-exchangekeyupdate.log" 2>&1 >"$LOG_DIR/taler-merchant-reconciliation.log" 2>&1 /dev/null || true + sleep 1 +fi + +echo "Live processes:" +ps -eo pid,stat,args 2>/dev/null | grep taler-merchant | grep -v grep | grep -v ' Z ' || true + +if [ -x /usr/local/bin/check_merchant-health.sh ]; then + /usr/local/bin/check_merchant-health.sh || exit 1 +elif [ -x ./check_merchant-health.sh ]; then + ./check_merchant-health.sh || exit 1 +fi +exit 0 diff --git a/scripts/taler-merchant/stats--merchant-payments.sh b/scripts/taler-merchant/stats--merchant-payments.sh new file mode 100755 index 0000000..ac9fcff --- /dev/null +++ b/scripts/taler-merchant/stats--merchant-payments.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Tested for DB scheme v38: + +set -eu + +runuser -u taler-merchant-httpd -- bash -c ' +printf "%-38s | %8s | %12s\n" "merchant_id" "payments" "total_amount" +printf "%-38s-+-%-8s-+-%-12s\n" "--------------------------------------" "--------" "------------" + +for s in $(psql -t -A taler-merchant -c " + SELECT schemaname FROM pg_tables + WHERE schemaname LIKE '\''merchant_instance_%'\'' + AND tablename = '\''merchant_deposits'\'' +"); do + mid=$(psql -t -A taler-merchant -c " + SELECT merchant_id FROM merchant.merchant_instances + WHERE merchant_serial = ${s#merchant_instance_} + " 2>/dev/null || echo "?") + + psql -t -A taler-merchant -c " + SELECT + '\''$mid'\'', + count(*), + round( + (COALESCE(sum((amount_with_fee).val), 0) + + COALESCE(sum((amount_with_fee).frac), 0)::numeric / 1000000000) + , 2) + FROM $s.merchant_deposits + " 2>/dev/null +done | while read line; do + printf "%-38s | %8s | %12s\n" $(echo "$line" | tr "|" " ") +done +' diff --git a/scripts/taler-merchant/taler-hacktivism-email-helper.sh b/scripts/taler-merchant/taler-hacktivism-email-helper.sh new file mode 100755 index 0000000..dada913 --- /dev/null +++ b/scripts/taler-merchant/taler-hacktivism-email-helper.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Usage: echo "body text" | ./taler-hacktivism-email-helper.sh email@example.com +# Needs ``swaks'' to be installed. +TO="$1" +SUBJECT="Taler Merchant Auth Code" +BODY=$(cat) + +# SMTP password: set SMTP_PASSWORD in the environment (not stored in git). +# Live container may still use a literal in-file password — do not re-commit it. +swaks --server mail.cyon.ch \ + --port 587 \ + --auth LOGIN \ + --auth-user taler-merchant@hacktivism.ch \ + --auth-password "${SMTP_PASSWORD:?set SMTP_PASSWORD}" \ + --tls \ + --from taler-merchant@hacktivism.ch \ + --to "$TO" \ + --header "Subject: $SUBJECT" \ + --body "$BODY" diff --git a/scripts/taler-merchant/taler-hacktivism-sms-helper-wrapper.sh b/scripts/taler-merchant/taler-hacktivism-sms-helper-wrapper.sh new file mode 100755 index 0000000..d42eea7 --- /dev/null +++ b/scripts/taler-merchant/taler-hacktivism-sms-helper-wrapper.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +PHONE_NO="$1" +JSON_STRING="{\"CONTACT_PHONE\":\"${PHONE_NO}\"}" +exec /usr/local/bin/taler-hacktivism-sms-helper.sh $JSON_STRING diff --git a/scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh b/scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh new file mode 100755 index 0000000..467c7c9 --- /dev/null +++ b/scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Run inside merchant container (as root). +# Restarts taler-merchant-wirewatch when it exits. +# Without systemd, wirewatch exits on PG config NOTIFY and would stay down. +set +e +LOG=/var/log/taler-merchant/wirewatch-supervise.log +WW_LOG=/var/log/taler-merchant/wirewatch.log +CONF="${TALER_MERCHANT_CONFIG:-/etc/taler-merchant/taler-merchant.conf}" +mkdir -p /var/log/taler-merchant +echo "$(date -u +%FT%TZ) supervise start" >>"$LOG" +for p in $(ps -eo pid=,args= | awk '$2=="/usr/bin/taler-merchant-wirewatch"{print $1}'); do + kill "$p" 2>/dev/null || true +done +sleep 1 +while true; do + echo "$(date -u +%FT%TZ) start wirewatch" >>"$LOG" + if [ "$(id -un)" = "root" ]; then + runuser -u taler-merchant-httpd -- /usr/bin/taler-merchant-wirewatch -c "$CONF" -L INFO >>"$WW_LOG" 2>&1 + ec=$? + else + /usr/bin/taler-merchant-wirewatch -c "$CONF" -L INFO >>"$WW_LOG" 2>&1 + ec=$? + fi + echo "$(date -u +%FT%TZ) wirewatch exit=$ec; sleep 2" >>"$LOG" + sleep 2 +done diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md new file mode 100644 index 0000000..35edc67 --- /dev/null +++ b/scripts/taler-monitoring/README.md @@ -0,0 +1,100 @@ +# taler-monitoring + +Report for the **GOA** stack with clear severity tags and **per-area test IDs**: + +| Tag | Meaning | +|-----|---------| +| `[OK]` | check passed | +| `[INFO]` | inside status (container, ports, log noise) | +| `[WARN]` | degraded but maybe not fatal | +| `[ERROR]` | component problem | +| `[BLOCKER]` | **withdraw/pay path cannot complete** because of this | + +IDs: **`www-001`**, **`inside-001`**, **`versions-001`**, **`sanity-001`**, **`server-001`**, **`e2e-001`** … +Catalog: **[TESTS.md](./TESTS.md)**. + +```text +[OK] www-001 exchange /config https://exchange…/config +[BLOCKER] e2e-015 prereq: merchant HTTP 502 +``` + +End of each phase: totals + list of **BLOCKERS** and **ERRORS**. + +## Commands + +```bash +# Local GOA stack (may use SSH for inside/e2e) +./taler-monitoring.sh # urls + inside + versions + e2e +./taler-monitoring.sh inside # containers on koopa +./taler-monitoring.sh versions # deb.taler.net + package versions vs trixie +./taler-monitoring.sh sanity +./taler-monitoring.sh e2e + +# Other domains — public HTTPS only, never SSH +./taler-monitoring.sh -d taler.net +./taler-monitoring.sh --domain taler-ops.ch +./taler-monitoring.sh -d demo.taler.net urls +./taler-monitoring.sh taler.net # bare domain = same as -d +``` + +| Domain | Bank | Exchange | Merchant | Currency | +|--------|------|----------|----------|----------| +| `hacktivism.ch` (default) | bank.hacktivism.ch | exchange.hacktivism.ch | taler.hacktivism.ch | GOA | +| `taler.net` / `demo.taler.net` | bank.demo.taler.net | exchange.demo.taler.net | backend.demo.taler.net | KUDOS | +| `taler-ops.ch` | bank.* (probe) | exchange.taler-ops.ch | backend.* (probe) | CHF | +| other | bank.DOMAIN | exchange.DOMAIN | backend/taler/merchant (probe) | any | + +**SSH only for koopa** (`hacktivism.ch` / `-d koopa`). + +Other domains: never SSH. Optional **e2e** aborts cleanly on login/KYC. + +### E2E amounts (variable) + +| | Local (koopa) | Remote | +|--|---------------|--------| +| **ATM withdraw** | 20 · 50 · 100 · 200 | 10 · 20 · 50 | +| **Pay ladder** | 0.01 … 10 | 0.01 … 1 | + +```bash +./taler-monitoring.sh e2e +./taler-monitoring.sh -d taler.net urls e2e +# customize: +E2E_WITHDRAW_VALUES="20 50 100" E2E_PAY_VALUES="0.05 1 5" ./taler-monitoring.sh e2e +E2E_VARIABLE=0 WITHDRAW_AMT=GOA:50 PAY_AMT=GOA:1 ./taler-monitoring.sh e2e # single fixed +# GOA shop catalog (local hacktivism): full list in E2E_SHOP_PRODUCTS; each run +# shuffles and pays E2E_SHOP_PICK_N products (default 2). +# E2E_SHOP_PRODUCTS lines: id|Product name|GOA:amount +# E2E_SHOP_PICK_N=2 +# (landing QR = taler://pay-template/…/{id}; popup = live taler://pay after POST templates/{id}) +# remote secrets: +# E2E_BANK_ADMIN_PASS=… E2E_MERCHANT_TOKEN=… +``` + +## Phases + +| Phase | What | +|-------|------| +| **inside** | SSH koopa: processes, postgres, local /config,/keys, wirewatch, recent log ERRORs | +| **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 | + +```bash +# package suite (default trixie on deb.taler.net) +TALER_APT_SUITE=trixie ./taler-monitoring.sh versions +TALER_PKG_BEHIND=error ./taler-monitoring.sh versions # any behind = ERROR +``` + +E2E maps failures to blockers, e.g.: + +- `bank-confirm HTTP 409` → wallet did not select exchange +- `no GOA balance` → wirewatch / transfer +- `create order failed` → merchant auth/instance +- `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 + +## Needs + +- SSH `koopa` (inside/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 new file mode 100644 index 0000000..51686d4 --- /dev/null +++ b/scripts/taler-monitoring/TESTS.md @@ -0,0 +1,165 @@ +# taler-monitoring — test IDs by area + +Every check line is numbered **per area** as `AREA-NNN` (zero-padded): + +| Area | Phase script | Meaning | +|------|--------------|---------| +| **www** | `check_urls.sh` | public HTTPS (outside-in) | +| **inside** | `check_inside.sh` | containers / processes on koopa | +| **versions** | `check_versions.sh` | deb.taler.net available + packages vs trixie | +| **sanity** | `check_sanity.sh` | public + server per component | +| **server** | `check_server.sh` | SSH host ports / processes | +| **e2e** | `check_e2e.sh` | withdraw + pay cycle | + +Format in output: + +```text +[OK] www-001 exchange /config https://exchange…/config +[ERROR] e2e-012 bank-auth: admin token failed +[BLOCKER] e2e-015 prereq: merchant HTTP 502 +``` + +IDs are assigned **in run order** within the area (`set_area` resets the counter). Optional soft checks still consume a number when they WARN. + +--- + +## www — public URLs (`./taler-monitoring.sh urls`) + +| ID | Check | +|----|--------| +| www-… | exchange `/config`, currency, **alt_unit_names** | +| www-… | exchange `/keys` (+ alt_unit_names soft) | +| www-… | exchange `/intro/`, `/` (302→intro) | +| www-… | **exchange `/terms`** body (not empty / not API error) | +| www-… | **exchange `/privacy`** body | +| www-… | exchange `/terms/` (200 or redirect) | +| www-… | bank `/config`, currency, **alt_unit_names** | +| www-… | bank integration / webui / intro / `/` | +| www-… | **bank `/terms`** body | +| www-… | **bank `/privacy`** (or `/intro/privacy.html` fallback) | +| www-… | merchant `/config`, currency, currencies alt_unit_names | +| www-… | each merchant `exchanges[]` `/config` alt_unit_names | +| www-… | merchant `/intro/`, `/webui/`, `/` | +| www-… | **merchant `/terms`** body (dual-currency notice) | +| www-… | **merchant `/privacy`** body (must not be `not configured`) | +| www-… | merchant `/terms/` redirect | +| 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/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). + +**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. + +**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`. + +(IDs after a failed early check may shift if later soft checks are skipped when body missing — numbering follows **executed** checks.) + +--- + +## inside — koopa SSH (`./taler-monitoring.sh inside`) + +| ID | Check (typical order) | +|----|------------------------| +| inside-001 | ssh koopa | +| inside-002+ | per-component emit: container, ports, libeufin/httpd, postgres, local `/config`/`/keys`, wirewatch, DNS pin, caddy | + +Remote lines `E|comp|LEVEL|key|detail` each become one numbered result. + +--- + +## sanity — bank · exchange · merchant (`./taler-monitoring.sh sanity`) + +| ID | Section | +|----|---------| +| sanity-001… | bank public + server | +| sanity-… | exchange public + server | +| sanity-… | merchant public + server | + +Sequential through the whole script (one `set_area sanity`). + +--- + +## versions — packages vs deb.taler.net (`./taler-monitoring.sh versions`) + +### Outside (runner / public network — no SSH) + +| ID (order) | Check | +|------------|--------| +| versions-… | DNS `deb.taler.net` | +| versions-… | HTTPS portal + apt base URL | +| versions-… | suite `InRelease` / `Release` | +| versions-… | suite `Packages` + `Packages.gz` | +| versions-… | sample pool `.deb` fetchable (Range 200/206) | +| versions-… | suite offers `taler-exchange`, `taler-merchant`, `libeufin-bank` | +| versions-… | optional `trixie-testing` Packages | +| versions-… | TLS verify (soft) | + +### Inside (SSH koopa containers) + +| ID | Check | +|----|--------| +| versions-… | ssh koopa | +| versions-… | each container → `InRelease` (pasta can reach apt repo) | +| versions-… | each container lists `deb.taler.net` in apt sources | +| versions-… | each installed `taler*` / `libeufin*` / `libtaler*` / `libdonau*` vs suite version | +| versions-… | core packages installed (`taler-exchange`, `libeufin-bank`, `taler-merchant`) | + +Outside always runs. Inside skipped with `SKIP_SSH=1` (still reports outside results). + +Compare rules: + +- **match** suite → OK +- **ahead** of suite (often testing/dev) → INFO +- **behind** suite → ERROR for core packages, WARN otherwise (`TALER_PKG_BEHIND=error` forces ERROR) + +Default suite: **trixie** (`TALER_APT_SUITE`, `TALER_APT_BASE=https://deb.taler.net/apt/debian`). + +Without SSH (`SKIP_SSH=1` or remote domain): still runs outside-in repo checks; skips container install compare. + +--- + +## server — SSH ports (`./taler-monitoring.sh server`) + +| ID | Check | +|----|--------| +| server-001 | ssh | +| server-002+ | containers, local pasta ports, processes, caddy | + +--- + +## e2e — payment path (`./taler-monitoring.sh e2e`) + +| ID | Step (approx.) | +|----|----------------| +| e2e-001 | budget info | +| e2e-002 | wallet-cli present | +| e2e-003 | mode / currency info | +| e2e-004… | secrets, reachability gates | +| e2e-… | account, credit, withdraw, confirm, coins, order, pay ladder | +| e2e-… | **GOA shop products** — full catalog list; **random pick of 2** (override `E2E_SHOP_PICK_N`) | +| e2e-… | balances, dig on failure | + +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 +only `E2E_SHOP_PICK_N` products (**default 2**). Flow matches the landing popup +(public POST `/templates/{id}`, not private orders). Report labels use product name. + +Blockers keep the same ID prefix: `[BLOCKER] e2e-0NN step: message`. + +--- + +## Run one area + +```bash +./taler-monitoring.sh urls # www only +./taler-monitoring.sh inside # inside only +./taler-monitoring.sh versions # deb.taler.net + package drift +./taler-monitoring.sh e2e # e2e only +./taler-monitoring.sh -d taler.net urls +``` diff --git a/scripts/taler-monitoring/check_e2e.sh b/scripts/taler-monitoring/check_e2e.sh new file mode 100755 index 0000000..2cbf8c4 --- /dev/null +++ b/scripts/taler-monitoring/check_e2e.sh @@ -0,0 +1,1220 @@ +#!/usr/bin/env bash +# Full withdraw + pay cycle; clear BLOCKERS when the path cannot finish. +# Skips remaining steps if overall budget exceeded (E2E_TIMEOUT, default 90s). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.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) +: "${E2E_SETTLE_ROUNDS:=18}" +: "${E2E_SETTLE_SLEEP:=3}" +E2E_START=$(date +%s) +BAL_BEFORE="(not captured)" +BAL_AFTER="(not captured)" +E2E_REPORTED=0 +e2e_left() { echo $(( E2E_TIMEOUT - ($(date +%s) - E2E_START) )); } +e2e_over() { [ "$(e2e_left)" -le 0 ]; } + +# Fixed short timeout — used even when e2e budget is exhausted / on EXIT +# stdout only for JSON; logs go to stderr file so balance parse stays clean +wcli_bal_snap() { + local out="$1" + if [ -z "${WALLET_CLI:-}" ] || [ ! -f "${WDB:-}" ]; then + echo "(no wallet)" >"$out" + return 1 + fi + local err="${out}.err" + if command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' 10 \ + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \ + >"$out" 2>"$err" || true + else + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \ + >"$out" 2>"$err" || true + fi + [ -s "$out" ] +} + +fmt_bal() { + python3 -c ' +import json,re,sys +raw=open(sys.argv[1]).read() if sys.argv[1] else "" +if not raw.strip() or raw.strip().startswith("(no"): + print(raw.strip() or "(empty)"); sys.exit(0) +# extract first JSON object with "balances" +d=None +for m in re.finditer(r"\{", raw): + try: + d=json.loads(raw[m.start():]) + if isinstance(d, dict) and "balances" in d: + break + except Exception: + d=None +if not isinstance(d, dict): + print(re.sub(r"\s+"," ", raw)[:200]); sys.exit(0) +parts=[] +for b in d.get("balances") or []: + cur=(b.get("scopeInfo") or {}).get("currency") or "?" + parts.append("%s avail=%s pendingIn=%s" % (cur, b.get("available"), b.get("pendingIncoming"))) +print("; ".join(parts) if parts else "(no balances)") +' "${1:-/dev/null}" 2>/dev/null || echo "(unreadable)" +} + +# Numeric available for currency CUR (from balance file or fresh wallet snap) +wallet_avail_num() { + local f="${1:-}" + if [ -z "$f" ] || [ ! -s "$f" ]; then + f="$SCRATCH/bal-live.out" + wcli_bal_snap "$f" || wcli balance >"$f" 2>&1 || true + fi + python3 -c ' +import json,re,sys +cur=sys.argv[2] +raw=open(sys.argv[1]).read() if sys.argv[1] else "" +d=None +for m in re.finditer(r"\{", raw): + try: + d=json.loads(raw[m.start():]) + if isinstance(d, dict) and "balances" in d: break + except Exception: + d=None +if isinstance(d, dict): + for b in d.get("balances") or []: + c=(b.get("scopeInfo") or {}).get("currency") or "" + av=b.get("available") or "" + if c==cur or av.startswith(cur+":"): + try: + print(float(av.split(":",1)[1])); sys.exit(0) + except Exception: + pass +# text fallback +m=re.search(r"%s:([0-9]+(?:\.[0-9]+)?)" % re.escape(cur), raw) +print(float(m.group(1)) if m else 0.0) +' "${f:-/dev/null}" "${CUR:-GOA}" 2>/dev/null || echo 0 +} + +# Wait for spendable balance (settlement / wirewatch lag). Returns 0 if avail > min. +# Emphasizes timing, not hard failure, while waiting. +wait_wallet_balance() { + local min_n="${1:-0}" + local rounds="${2:-15}" + local sleep_s="${3:-3}" + local r av + info "wallet settle wait" "up to ${rounds}×${sleep_s}s for avail>${min_n} ${CUR} (wirewatch lag is common)" + for r in $(seq 1 "$rounds"); do + wcli run-until-done >"$SCRATCH/settle-run.out" 2>&1 || true + wcli_bal_snap "$SCRATCH/bal-live.out" || wcli balance >"$SCRATCH/bal-live.out" 2>&1 || true + av=$(wallet_avail_num "$SCRATCH/bal-live.out") + if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > float(sys.argv[2]) else 1)" "$av" "$min_n" 2>/dev/null; then + ok "wallet balance ready" "avail=${CUR}:${av} after ~$((r * sleep_s))s settle wait" + info "balance" "$(fmt_bal "$SCRATCH/bal-live.out")" + return 0 + fi + if [ "$r" = "1" ] || [ "$((r % 3))" = "0" ]; then + info "settlement lag" "round $r/${rounds}: avail=${CUR}:${av} (still waiting — not an error yet)" + fi + # gentle wirewatch nudge mid-wait (local only) + if [ "$r" = "4" ] || [ "$r" = "8" ]; then + if [ "${E2E_REMOTE:-0}" != "1" ] && koopa_ssh_ok; then + koopa_ssh_run 8 \ + 'podman exec taler-hacktivism-exchange-ansible systemctl restart taler-exchange-wirewatch 2>/dev/null || true' \ + >/dev/null 2>&1 || true + fi + fi + sleep "$sleep_s" + done + av=$(wallet_avail_num "$SCRATCH/bal-live.out") + warn "settlement lag" "after ${rounds}×${sleep_s}s still avail=${CUR}:${av} — coins may still be in flight" + return 1 +} + +print_balances() { + section "e2e · wallet balances" + info "BALANCE before" "$BAL_BEFORE" + # refresh after if we can + if [ -n "${SCRATCH:-}" ]; then + wcli_bal_snap "$SCRATCH/bal-end.out" || true + if [ -s "$SCRATCH/bal-end.out" ]; then + BAL_AFTER=$(fmt_bal "$SCRATCH/bal-end.out") + fi + fi + info "BALANCE after " "$BAL_AFTER" +} + +e2e_finish() { + local code="${1:-1}" + if [ "$E2E_REPORTED" = "1" ]; then + return "$code" + fi + E2E_REPORTED=1 + print_balances + summary || true + return "$code" +} + +e2e_skip_rest() { + local why="$1" + warn "e2e timeout" "budget ${E2E_TIMEOUT}s exceeded — $why" + blocker "e2e-timeout" "skipped remaining steps after ${E2E_TIMEOUT}s ($why)" + section "e2e · report" + info "WITHDRAW" "incomplete (timeout)" + info "PAY" "SKIPPED (timeout)" + # If we already started a withdrawal, dig inside for missing coins + if [ -n "${WID:-}" ]; then + dig_when_no_coins 2>/dev/null || true + fi + e2e_finish 1 + exit 1 +} + +# Area e2e-### — withdraw + pay cycle +set_area e2e +section "e2e · prerequisites" +info "e2e budget" "${E2E_TIMEOUT}s (set E2E_TIMEOUT= to change)" + +# Remote domains (not koopa): public path only, tiny amounts, abort on login/KYC +: "${E2E_REMOTE:=0}" +if [ "${LOCAL_STACK:-1}" != "1" ]; then + E2E_REMOTE=1 + SKIP_SSH=1 + E2E_FAKE_INCOMING=0 +fi + +# Currency for amounts / balance checks +CUR="${EXPECT_CURRENCY:-}" +if [ -z "$CUR" ]; then + CUR=$(curl -skS -m 8 "$BANK_PUBLIC/config" 2>/dev/null | python3 -c 'import sys,json +try: print(json.load(sys.stdin).get("currency") or "") +except Exception: print("")' 2>/dev/null || true) +fi +[ -z "$CUR" ] && CUR=$(curl -skS -m 8 "$EXCHANGE_PUBLIC/config" 2>/dev/null | python3 -c 'import sys,json +try: print(json.load(sys.stdin).get("currency") or "") +except Exception: print("")' 2>/dev/null || true) +[ -z "$CUR" ] && CUR="GOA" + +# --------------------------------------------------------------------------- +# Variable amounts +# Withdraw = typical ATM notes only (Bankomat): 20 / 50 / 100 / 200 … +# Pay = several smaller purchase amounts +# Env: +# E2E_WITHDRAW_VALUES="20 50 100" # bare numbers → prefixed with CUR +# E2E_PAY_VALUES="0.01 0.05 0.5 1 2 5" +# E2E_VARIABLE=0 # single fixed WITHDRAW_AMT / PAY_AMT +# E2E_ATM_MAX=N # try at most N ATM withdraws (budget) +# --------------------------------------------------------------------------- +: "${E2E_VARIABLE:=1}" +: "${E2E_ATM_MAX:=4}" +if [ "$E2E_REMOTE" = "1" ]; then + # remote: still ATM-shaped but smaller notes; keep cheap + : "${E2E_WITHDRAW_VALUES:=10 20 50}" + : "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}" +else + # local GOA: classic ATM denominations + : "${E2E_WITHDRAW_VALUES:=20 50 100 200}" + : "${E2E_PAY_VALUES:=0.01 0.05 0.1 0.5 1 2 5 10}" +fi + +# Build CUR:amount lists +build_amt_list() { + local cur="$1"; shift + local v out="" + for v in "$@"; do + case "$v" in + *:*) out="${out}${out:+ }$v" ;; + *) out="${out}${out:+ }${cur}:${v}" ;; + esac + done + printf '%s' "$out" +} +# shellcheck disable=SC2086 +WITHDRAW_LIST=$(build_amt_list "$CUR" $E2E_WITHDRAW_VALUES) +# shellcheck disable=SC2086 +PAY_LIST=$(build_amt_list "$CUR" $E2E_PAY_VALUES) + +if [ "$E2E_VARIABLE" != "1" ]; then + if [ "$E2E_REMOTE" = "1" ]; then + WITHDRAW_LIST="${WITHDRAW_AMT:-${CUR}:20}" + PAY_LIST="${PAY_AMT:-${CUR}:0.01}" + else + WITHDRAW_LIST="${WITHDRAW_AMT:-${CUR}:20}" + PAY_LIST="${PAY_AMT:-${CUR}:0.01}" + fi +fi + +# Credit = sum of planned ATM withdraws + small buffer for fees +CREDIT_AMT=$(python3 -c ' +import sys +cur=sys.argv[1] +vals=sys.argv[2].split() +s=0.0 +for a in vals: + try: s += float(a.split(":",1)[-1]) + except Exception: pass +buf = max(10.0, s * 0.05) +print("%s:%g" % (cur, s + buf)) +' "$CUR" "$WITHDRAW_LIST" 2>/dev/null || echo "${CUR}:100") + +# First withdraw/pay for legacy single-step labels +WITHDRAW_AMT=$(printf '%s' "$WITHDRAW_LIST" | awk '{print $1}') +PAY_AMT=$(printf '%s' "$PAY_LIST" | awk '{print $1}') + +# More wall time when running ATM ladder +if [ "$E2E_VARIABLE" = "1" ]; then + n_w=$(printf '%s' "$WITHDRAW_LIST" | wc -w | tr -d ' ') + n_p=$(printf '%s' "$PAY_LIST" | wc -w | tr -d ' ') + need=$(( 40 + n_w * 35 + n_p * 20 )) + if [ "${E2E_TIMEOUT}" -lt "$need" ]; then + E2E_TIMEOUT=$need + fi + # cap ATM attempts + WITHDRAW_LIST=$(printf '%s' "$WITHDRAW_LIST" | tr ' ' '\n' | head -n "$E2E_ATM_MAX" | tr '\n' ' ' | sed 's/ *$//') +fi + +BANK_HOST=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$BANK_PUBLIC"'").hostname or "bank")' 2>/dev/null || echo bank) + +SCRATCH=$(mktemp -d) +WDB="$SCRATCH/wallet.sqlite3" +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 + +# Abort cleanly on login / KYC / registration barriers (esp. remote domains) +e2e_abort_auth() { + local step="$1" msg="$2" + warn "$step" "$msg" + blocker "$step" "abort (login/KYC): $msg" + section "e2e · report" + info "ABORTED" "login or KYC blocked further e2e — not retrying" + info "hint" "remote e2e needs open bank registration + credit path; set E2E_BANK_ADMIN_PASS / E2E_MERCHANT_TOKEN if you have them" + e2e_finish 1 + exit 1 +} + +is_auth_kyc_body() { + # stdin or file arg: true if looks like login/KYC barrier + local f="${1:-}" + local t + if [ -n "$f" ] && [ -f "$f" ]; then t=$(head -c 800 "$f" 2>/dev/null || true) + else t=$(cat 2>/dev/null || true); fi + printf '%s' "$t" | grep -qiE 'kyc|legitim|login required|unauthorized|forbidden|captcha|challenge|tan_|not.?allowed|registration.?disabled|admin.?only|permission.?denied|401|403' +} + +WALLET_CLI=$(find_wallet_cli) || { + blocker "prereq" "taler-wallet-cli not found (set WALLET_CLI=)" + exit 1 +} +ok "wallet-cli ($WALLET_CLI)" +info "e2e mode" "$([ "$E2E_REMOTE" = "1" ] && echo "remote/public domain (no SSH)" || echo "local koopa stack")" +info "currency" "$CUR" +info "ATM withdraw ladder" "$WITHDRAW_LIST (credit $CREDIT_AMT)" +info "pay ladder" "$PAY_LIST" +info "e2e budget" "${E2E_TIMEOUT}s" + +# 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:-}" + MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}" +else + ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-$(read_secret "taler-bank/bank-admin-password.txt" || true)}" + MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-$(read_secret "taler-merchant/merchant-goa-demo-cp4zqk-password.txt" || true)}}" +fi +if [ -n "$ADMIN_PASS" ]; then + ok "bank admin secret" +elif [ "$E2E_REMOTE" = "1" ]; then + warn "bank admin secret" "missing — will try public registration only" +else + blocker "prereq" "bank admin password missing (SECRETS_ROOT or koopa /root)" + exit 1 +fi +if [ -n "$MPW" ]; then + ok "merchant secret (instance ${MERCHANT_INSTANCE})" +elif [ "$E2E_REMOTE" = "1" ]; then + warn "merchant secret" "missing — order create may fail (set E2E_MERCHANT_TOKEN)" +else + blocker "prereq" "merchant instance password missing" + exit 1 +fi + +# Public reachability gates (remote: soft-skip if bank/merchant absent) +for pair in \ + "bank|$BANK_PUBLIC/config" \ + "exchange|$EXCHANGE_PUBLIC/config" \ + "exchange-keys|$EXCHANGE_PUBLIC/keys" \ + "bank-integration|$BANK_PUBLIC/taler-integration/config" \ + "merchant|$MERCHANT_PUBLIC/config" +do + IFS='|' read -r name url <<<"$pair" + code=$(http_code "$url") + if [ "$code" = "200" ]; then + ok "reachable $name" + else + if [ "$E2E_REMOTE" = "1" ] && [[ "$name" == bank* || "$name" == merchant ]]; then + warn "reachable $name" "HTTP $code — e2e may abort" + else + blocker "prereq" "$name HTTP $code ($url) — cannot start payment cycle" + fi + fi +done +# Remote: need at least bank+exchange+merchant for a full cycle +if [ "$E2E_REMOTE" = "1" ]; then + bc=$(http_code "$BANK_PUBLIC/config") + ec=$(http_code "$EXCHANGE_PUBLIC/config") + mc=$(http_code "$MERCHANT_PUBLIC/config") + if [ "$bc" != "200" ] || [ "$ec" != "200" ]; then + e2e_abort_auth "prereq" "bank/exchange not publicly usable (bank=$bc exchange=$ec) — skip e2e" + fi + if [ "$mc" != "200" ]; then + warn "prereq" "merchant HTTP $mc — will try withdraw only if possible" + fi +fi +if [ "${#BLOCKERS[@]}" -gt 0 ]; then + exit 1 +fi + +USER="mon$(date +%s | tail -c 6)" +USER_PW=$(python3 -c 'import secrets;print(secrets.token_urlsafe(10))') +info "e2e user" "$USER withdraw=$WITHDRAW_AMT pay=$PAY_AMT" +BANK="$BANK_PUBLIC" +INST="$MERCHANT_INSTANCE" + +# $1 = max seconds for this call (optional); rest = wallet-cli args +wcli() { + local maxc=12 + if [[ "${1:-}" =~ ^[0-9]+$ ]]; then + maxc=$1 + shift + fi + e2e_over && return 124 + local cap + cap=$(e2e_left) + [ "$cap" -gt "$maxc" ] && cap=$maxc + [ "$cap" -lt 3 ] && return 124 + if command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' "$cap" \ + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + else + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + fi +} + +# Pay must not be starved: use reserved seconds even if global budget is tight +wcli_pay() { + local cap=$E2E_PAY_SECS + local left + left=$(e2e_left) + if [ "$left" -gt "$cap" ]; then + cap=$E2E_PAY_SECS + elif [ "$left" -ge 8 ]; then + cap=$left + else + # still try once with floor 12s so Alarm clock is not the blocker + cap=12 + fi + info "pay wallet cap" "${cap}s" + if command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' "$cap" \ + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + else + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + fi +} + +# When coins never become available: run inside + targeted bank/exchange dig +dig_when_no_coins() { + section "e2e · coins missing — inside dig" + info "reason" "wallet empty after withdraw path (often wirewatch timing) — probing bank + exchange" + if [ -n "${WID:-}" ]; then + curl -sS -m 6 -o "$SCRATCH/wd-dig.json" \ + "$BANK_PUBLIC/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || true + info "backend withdrawal-operation" "$(python3 -c ' +import json +try: + d=json.load(open("'"$SCRATCH"'/wd-dig.json")) + print("status=%s transfer_done=%s selection_done=%s amount=%s reserve=%s" % ( + d.get("status"), d.get("transfer_done"), d.get("selection_done"), + d.get("amount"), (d.get("selected_reserve_pub") or "-")[:24])) +except Exception as e: + print("unreadable", e) +' 2>/dev/null)" + fi + # Full component inside report (capped SSH; ignore its exit) + if [ "${SKIP_SSH}" != "1" ] && [ -x "$ROOT/check_inside.sh" ]; then + info "inside" "running check_inside.sh …" + # subshell so its PASS_N/FAIL_N/summary don't poison ours; still print to stdout + ( + # shellcheck disable=SC2030 + "$ROOT/check_inside.sh" || true + ) || true + else + warn "inside" "skipped (SKIP_SSH=1 or check_inside.sh missing)" + fi + # Extra targeted: wirewatch + recent journal + bank→exchange path + if koopa_ssh_ok; then + section "e2e · coins missing — exchange/bank focus" + DIG=$(koopa_ssh_bash 15 <<'REMOTE' || true +set +e +emit() { printf 'D|%s|%s\n' "$1" "$(printf '%s' "$2" | tr '\n' ' ' | head -c 220)"; } +EX=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1) +BANK=$(podman ps --format '{{.Names}}' | grep -iE 'hacktivism-bank|taler-bank' | head -1) +[ -z "$BANK" ] && BANK=$(podman ps --format '{{.Names}}' | grep -i bank | head -1) +if [ -n "$EX" ]; then + podman exec "$EX" pgrep -af taler-exchange-wirewatch 2>/dev/null | head -1 | \ + { read -r l; [ -n "$l" ] && emit OK "wirewatch: $l" || emit ERROR "wirewatch not running"; } + podman exec "$EX" pgrep -af taler-exchange-transfer 2>/dev/null | head -1 | \ + { read -r l; [ -n "$l" ] && emit OK "transfer: $l" || emit WARN "transfer not running"; } + podman exec "$EX" pgrep -af taler-exchange-aggregator 2>/dev/null | head -1 | \ + { read -r l; [ -n "$l" ] && emit OK "aggregator: $l" || emit WARN "aggregator not running"; } + # last wirewatch log lines if journal available + j=$(podman exec "$EX" bash -c 'journalctl -u "taler-exchange-wirewatch*" -n 8 --no-pager 2>/dev/null | tail -5' 2>/dev/null | tr '\n' ';' | head -c 280) + [ -n "$j" ] && emit INFO "wirewatch journal: $j" + # reserve lookup via exchange if tools exist (best-effort) + c=$(curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000) + emit INFO "exchange /keys HTTP $c" +else + emit ERROR "no exchange container" +fi +if [ -n "$BANK" ]; then + podman exec "$BANK" pgrep -af 'MainKt serve|libeufin-bank serve' 2>/dev/null | head -1 | \ + { read -r l; [ -n "$l" ] && emit OK "libeufin: $l" || emit ERROR "libeufin not running"; } + c=$(curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:9012/taler-integration/config 2>/dev/null || echo 000) + emit INFO "bank integration /config HTTP $c" +else + emit ERROR "no bank container" +fi +REMOTE +) + while IFS= read -r line; do + case "$line" in + D\|*) + IFS="|" read -r _ lvl msg <<<"$line" + case "$lvl" in + OK) ok "dig $msg" ;; + ERROR) err "dig" "$msg" ;; + WARN) warn "dig" "$msg" ;; + INFO) info "dig" "$msg" ;; + esac + ;; + esac + done <<<"$DIG" + fi +} + +# Baseline wallet balance (empty DB) — always shown even if we abort later +wcli_bal_snap "$SCRATCH/bal-before.out" || true +BAL_BEFORE=$(fmt_bal "$SCRATCH/bal-before.out") +info "BALANCE before" "$BAL_BEFORE" + +# --------------------------------------------------------------------------- +section "e2e · bank (account · credit · withdraw)" +# --------------------------------------------------------------------------- +AT="" +if [ -n "${ADMIN_PASS:-}" ]; then + AT=$(curl -sS -m 15 -u "admin:${ADMIN_PASS}" -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite"}' \ + "$BANK/accounts/admin/token" 2>"$SCRATCH/at.err" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || true) +fi +if [ -n "$AT" ]; then + ok "admin token" +elif [ "$E2E_REMOTE" = "1" ]; then + warn "admin token" "unavailable — trying open registration" +else + if is_auth_kyc_body "$SCRATCH/at.err"; then + e2e_abort_auth "bank-auth" "admin login blocked (KYC/auth) — $(head -c 80 "$SCRATCH/at.err" 2>/dev/null)" + fi + blocker "bank-auth" "admin token failed — check admin password / bank up ($(head -c 80 "$SCRATCH/at.err" 2>/dev/null))" + exit 1 +fi + +# Create account: with admin bearer if we have it, else unauthenticated registration (demo banks) +if [ -n "$AT" ]; then + code=$(curl -sS -m 15 -o "$SCRATCH/acc.json" -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $AT" -H 'Content-Type: application/json' \ + -d "{\"username\":\"${USER}\",\"password\":\"${USER_PW}\",\"name\":\"Monitoring\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"${CUR}:1000\"}" \ + "$BANK/accounts") +else + code=$(curl -sS -m 15 -o "$SCRATCH/acc.json" -w '%{http_code}' -X POST \ + -H 'Content-Type: application/json' \ + -d "{\"username\":\"${USER}\",\"password\":\"${USER_PW}\",\"name\":\"Monitoring\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"${CUR}:1000\"}" \ + "$BANK/accounts") +fi +if [ "$code" = "200" ] || [ "$code" = "201" ]; then + ok "create account $USER" +elif [ "$code" = "401" ] || [ "$code" = "403" ] || is_auth_kyc_body "$SCRATCH/acc.json"; then + e2e_abort_auth "bank-account" "registration/login blocked HTTP $code — $(head -c 100 "$SCRATCH/acc.json" | tr '\n' ' ')" +else + if [ "$E2E_REMOTE" = "1" ]; then + e2e_abort_auth "bank-account" "POST /accounts HTTP $code — cannot open account on this domain" + fi + blocker "bank-account" "POST /accounts HTTP $code — $(head -c 100 "$SCRATCH/acc.json" | tr '\n' ' ')" + exit 1 +fi + +if [ -n "$AT" ]; then + export USER_NAME="$USER" CREDIT_AMT="$CREDIT_AMT" OUT="$SCRATCH/credit.json" BANK_HOST="$BANK_HOST" + python3 - <<'PY' +import json, os +ALPH = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +def crock32(b): + bits = val = 0; o = [] + for byte in b: + val = (val << 8) | byte; bits += 8 + while bits >= 5: + bits -= 5; o.append(ALPH[(val >> bits) & 31]) + if bits: o.append(ALPH[(val << (5 - bits)) & 31]) + return "".join(o) +uid = crock32(os.urandom(32)) +user = os.environ["USER_NAME"] +host = os.environ.get("BANK_HOST") or "bank" +json.dump({ + "payto_uri": f"payto://x-taler-bank/{host}/{user}?receiver-name={user}&message=mon", + "amount": os.environ["CREDIT_AMT"], + "request_uid": uid, +}, open(os.environ["OUT"], "w")) +PY + curl -sS -m 15 -o "$SCRATCH/credit.out" -H "Authorization: Bearer $AT" \ + -H 'Content-Type: application/json' -d @"$SCRATCH/credit.json" \ + "$BANK/accounts/admin/transactions" >/dev/null || true + if grep -q row_id "$SCRATCH/credit.out" 2>/dev/null; then + ok "credit $CREDIT_AMT → $USER" + else + if is_auth_kyc_body "$SCRATCH/credit.out"; then + e2e_abort_auth "bank-credit" "admin credit blocked (KYC/auth)" + fi + if [ "$E2E_REMOTE" = "1" ]; then + e2e_abort_auth "bank-credit" "admin transfer failed — no credit path on remote ($(head -c 80 "$SCRATCH/credit.out" | tr '\n' ' '))" + fi + blocker "bank-credit" "admin transfer failed — $(head -c 100 "$SCRATCH/credit.out" | tr '\n' ' ')" + exit 1 + fi +else + # No admin: remote demo may start funded or require cash-in — try withdraw later; warn + warn "bank-credit" "skipped (no admin) — withdraw may fail without balance" +fi + +UT=$(curl -sS -m 15 -u "${USER}:${USER_PW}" -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite"}' \ + "$BANK/accounts/${USER}/token" 2>"$SCRATCH/ut.err" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || true) +if [ -n "$UT" ]; then + ok "user token" +elif is_auth_kyc_body "$SCRATCH/ut.err" || is_auth_kyc_body /dev/null; then + e2e_abort_auth "bank-auth" "user token failed (login/KYC) for $USER" +else + e2e_abort_auth "bank-auth" "user token failed for $USER — $(head -c 80 "$SCRATCH/ut.err" 2>/dev/null)" +fi + +# --------------------------------------------------------------------------- +section "e2e · wallet setup (exchange + ToS once)" +# --------------------------------------------------------------------------- +if ! wcli exchanges add "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-add.out" 2>&1; then + if ! grep -qiE 'already|ok' "$SCRATCH/ex-add.out"; then + blocker "wallet-exchange" "exchanges add failed — $(tail -c 160 "$SCRATCH/ex-add.out" | tr '\n' ' ')" + else + ok "wallet exchange known" + fi +else + ok "wallet add exchange" +fi +wcli exchanges update "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-up.out" 2>&1 || true +if wcli exchanges accept-tos "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-tos.out" 2>&1; then + ok "wallet accept ToS" +else + if grep -qiE 'pending|tos|terms' "$SCRATCH/ex-tos.out"; then + blocker "wallet-tos" "accept-tos failed — wallet may refuse withdraw ($(tail -c 120 "$SCRATCH/ex-tos.out" | tr '\n' ' '))" + else + warn "wallet accept ToS" "$(tail -c 80 "$SCRATCH/ex-tos.out" | tr '\n' ' ')" + fi +fi + +wd_status() { + curl -sS -m 8 -o "$SCRATCH/wd-st.json" -w '' \ + "$BANK/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || true + python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("status",""))' 2>/dev/null || true +} +wd_status_detail() { + python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json")) +print("status=%s reserve=%s exchange=%s" % ( + d.get("status"), + (d.get("selected_reserve_pub") or "-")[:20], + (d.get("selected_exchange") or d.get("selected_exchange_account") or "-")[:60], +))' 2>/dev/null || echo "(no status body)" +} + +fake_incoming_speedup() { + [ "${E2E_FAKE_INCOMING}" = "1" ] || return 0 + st=$(wd_status) + RPUB=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("selected_reserve_pub") or "")' 2>/dev/null || true) + AMT=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("amount") or "'"$WITHDRAW_AMT"'")' 2>/dev/null || echo "$WITHDRAW_AMT") + DEBIT=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("sender_wire") or "")' 2>/dev/null || true) + [ -z "$DEBIT" ] && DEBIT="payto://x-taler-bank/${BANK_HOST}/${USER}?receiver-name=${USER}" + [ -z "$RPUB" ] && return 0 + [ -z "${AT:-}" ] && return 0 + WG="${BANK}/accounts/exchange/taler-wire-gateway/admin/add-incoming" + curl -sS -m 10 -o "$SCRATCH/fake-in.json" -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $AT" -H 'Content-Type: application/json' \ + -d "{\"amount\":\"${AMT}\",\"reserve_pub\":\"${RPUB}\",\"debit_account\":\"${DEBIT}\"}" \ + "$WG" >/dev/null || true + if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then + koopa_ssh_run 22 \ + 'podman exec taler-hacktivism-exchange-ansible bash -c "runuser -u taler-exchange-wire -- timeout 15 taler-exchange-wirewatch -c /etc/taler-exchange/taler-exchange.conf -t -L INFO 2>&1 | tail -5"' \ + >/dev/null 2>&1 || true + fi +} + +# One ATM-style withdraw: create → accept → select → confirm → settle +# returns 0 on wallet balance increase, 1 on soft fail (ladder continues) +e2e_one_withdraw() { + WITHDRAW_AMT="$1" + local tag + tag=$(printf '%s' "$WITHDRAW_AMT" | tr '.:' '__') + section "e2e · ATM withdraw $WITHDRAW_AMT" + e2e_over && { warn "ATM withdraw" "budget exhausted — skip $WITHDRAW_AMT"; return 1; } + + curl -sS -m 15 -o "$SCRATCH/wd-$tag.json" -H "Authorization: Bearer $UT" \ + -H 'Content-Type: application/json' \ + -d "{\"amount\":\"${WITHDRAW_AMT}\",\"exchange_url\":\"${EXCHANGE_PUBLIC}/\"}" \ + "$BANK/accounts/${USER}/withdrawals" + WID=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("withdrawal_id") or d.get("id") or "")' 2>/dev/null || true) + URI=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("taler_withdraw_uri") or "")' 2>/dev/null || true) + URI_CLEAN=$(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) + if [ -z "$WID" ] || [ -z "$URI_CLEAN" ]; then + warn "ATM withdraw $WITHDRAW_AMT" "create failed — $(head -c 80 "$SCRATCH/wd-$tag.json" | tr '\n' ' ')" + return 1 + fi + ok "ATM withdrawal created $WITHDRAW_AMT ($WID)" + cp "$SCRATCH/wd-$tag.json" "$SCRATCH/wd.json" + + USE_URI="$URI" + [ -z "$USE_URI" ] && USE_URI="$URI_CLEAN" + if wcli withdraw accept-uri --exchange "${EXCHANGE_PUBLIC}/" "$USE_URI" >"$SCRATCH/accept-$tag.out" 2>&1; then + ok "wallet accept $WITHDRAW_AMT" + elif [ "$USE_URI" != "$URI_CLEAN" ] && [ -n "$URI_CLEAN" ] \ + && wcli withdraw accept-uri --exchange "${EXCHANGE_PUBLIC}/" "$URI_CLEAN" >"$SCRATCH/accept-$tag.out" 2>&1; then + ok "wallet accept $WITHDRAW_AMT (no :443)" + else + warn "ATM withdraw $WITHDRAW_AMT" "accept-uri failed — $(tail -c 100 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')" + return 1 + fi + cp "$SCRATCH/accept-$tag.out" "$SCRATCH/accept.out" + + st="" + for i in 1 2 3 4; do + e2e_over && break + wcli run-until-done >"$SCRATCH/sel-$tag-$i.out" 2>&1 || true + st=$(wd_status) + case "$st" in selected|confirmed|aborted) break ;; esac + done + if [ "$st" != "selected" ] && [ "$st" != "confirmed" ]; then + wcli transactions >"$SCRATCH/tx-pre.json" 2>&1 || true + RPUB=$(python3 -c ' +import re +t=open("'"$SCRATCH"'/accept.out").read()+open("'"$SCRATCH"'/tx-pre.json").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 "") +' 2>/dev/null || true) + EPAYTO=$(curl -sS -m 12 "$EXCHANGE_PUBLIC/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/sel.json" -X POST \ + -H 'Content-Type: application/json' \ + -d "{\"reserve_pub\":\"$RPUB\",\"selected_exchange\":\"$EPAYTO\"}" \ + "$BANK/taler-integration/withdrawal-operation/${WID}" >/dev/null || true + st=$(wd_status) + fi + fi + if [ "$st" != "selected" ] && [ "$st" != "confirmed" ]; then + warn "ATM withdraw $WITHDRAW_AMT" "not selected (status=${st:-?})" + return 1 + fi + if [ "$st" != "confirmed" ]; then + code=$(curl -sS -m 15 -o "$SCRATCH/conf-$tag.json" -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $UT" -H 'Content-Type: application/json' -d '{}' \ + "$BANK/accounts/${USER}/withdrawals/${WID}/confirm") + case "$code" in + 200|204) ok "bank confirm $WITHDRAW_AMT" ;; + *) warn "ATM withdraw $WITHDRAW_AMT" "confirm HTTP $code"; return 1 ;; + esac + fi + fake_incoming_speedup + + # Short per-ATM poll; full settle wait happens after the ladder (avoids false FAIL) + local ok_bal=0 r av + for r in 1 2 3 4 5 6; do + wcli run-until-done >"$SCRATCH/run-$tag-$r.out" 2>&1 || true + wcli_bal_snap "$SCRATCH/bal-$tag.out" || wcli balance >"$SCRATCH/bal-$tag.out" 2>&1 || true + av=$(wallet_avail_num "$SCRATCH/bal-$tag.out") + if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > 0 else 1)" "$av" 2>/dev/null; then + ok_bal=1 + ok "wallet funded after ATM $WITHDRAW_AMT" "avail=${CUR}:${av}" + info "balance" "$(fmt_bal "$SCRATCH/bal-$tag.out")" + break + fi + sleep 2 + done + if [ "$ok_bal" = "1" ]; then + return 0 + fi + # Bank side often already confirmed — treat as timing lag, not hard fail + st=$(wd_status) + if [ "$st" = "confirmed" ]; then + warn "ATM withdraw $WITHDRAW_AMT" "bank confirmed; wallet not funded yet (settlement timing — will recheck later)" + return 2 + fi + warn "ATM withdraw $WITHDRAW_AMT" "no wallet balance yet (status=${st:-?})" + return 1 +} + +# One payment: e2e_one_pay AMOUNT [SUMMARY] [TAG] +# SUMMARY defaults to "monitoring pay $AMOUNT"; TAG defaults from amount. +e2e_one_pay() { + PAY_AMT="$1" + local PAY_SUM="${2:-monitoring pay ${PAY_AMT}}" + local tag="${3:-}" + if [ -z "$tag" ]; then + tag=$(printf '%s' "$PAY_AMT" | tr '.:' '__') + fi + # shell-safe tag for files + tag=$(printf '%s' "$tag" | tr -c 'A-Za-z0-9._-' '_') + section "e2e · pay $PAY_AMT · $PAY_SUM" + e2e_over && { warn "pay" "budget exhausted — skip $PAY_AMT ($PAY_SUM)"; return 1; } + if [ -z "${MPW:-}" ]; then + warn "pay $PAY_AMT" "no merchant token" + return 1 + fi + AUTH="Authorization: Bearer secret-token:${MPW}" + # JSON-escape summary for curl -d + local SUM_JSON + SUM_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$PAY_SUM" 2>/dev/null || printf '"%s"' "$PAY_SUM") + curl -skS -m 15 -o "$SCRATCH/ord-$tag.json" -X POST \ + -H "$AUTH" -H 'Content-Type: application/json' \ + -d "{\"order\":{\"summary\":${SUM_JSON},\"amount\":\"${PAY_AMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}" \ + "${MERCHANT_PUBLIC}/instances/${INST}/private/orders" 2>"$SCRATCH/ord-$tag.err" || true + if ! grep -q order_id "$SCRATCH/ord-$tag.json" 2>/dev/null; then + if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then + koopa_ssh_run 20 \ + "curl -skS -m 12 -X POST -H 'Authorization: Bearer secret-token:${MPW}' -H 'Content-Type: application/json' -d '{\"order\":{\"summary\":${SUM_JSON},\"amount\":\"${PAY_AMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}' 'https://127.0.0.1:9010/instances/${INST}/private/orders'" \ + >"$SCRATCH/ord-$tag.json" 2>"$SCRATCH/ord-$tag.err" || true + fi + fi + 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-$tag.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-$tag.json" 2>/dev/null || true) + if [ -z "$OID" ]; then + if is_auth_kyc_body "$SCRATCH/ord-$tag.json"; then + e2e_abort_auth "merchant-order" "merchant auth/KYC on pay $PAY_AMT ($PAY_SUM)" + fi + warn "pay $PAY_AMT ($PAY_SUM)" "order create failed — $(head -c 80 "$SCRATCH/ord-$tag.json" | tr '\n' ' ')" + return 1 + fi + ok "merchant order $OID ($PAY_AMT · $PAY_SUM)" + curl -skS -m 12 -o "$SCRATCH/ord-det-$tag.json" -H "$AUTH" \ + "${MERCHANT_PUBLIC}/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-$tag.json" 2>/dev/null || true) + if [ -z "$PAYURI" ] && [ -n "$OTOK" ]; then + MH=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$MERCHANT_PUBLIC"'").hostname or "taler.hacktivism.ch")' 2>/dev/null || echo "taler.hacktivism.ch") + PAYURI="taler://pay/${MH}/instances/${INST}/${OID}/?c=${OTOK}" + fi + # normalize default HTTPS port (wallets accept both) + PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g') + if [ -z "$PAYURI" ]; then + warn "pay $PAY_AMT ($PAY_SUM)" "no pay URI for $OID" + return 1 + fi + ok "taler_pay_uri ready ($tag)" + if ! wcli_pay handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$tag.out" 2>&1; then + warn "pay $PAY_AMT ($PAY_SUM)" "handle-uri failed — $(tail -c 100 "$SCRATCH/pay-$tag.out" | tr '\n' ' ')" + return 1 + fi + ok "wallet handle pay $PAY_AMT ($PAY_SUM)" + local r + for r in 1 2 3; do + wcli_pay run-until-done >"$SCRATCH/pay-run-$tag.out" 2>&1 || true + wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true + curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \ + "${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true + 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 \ + || 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-$tag.json" 2>/dev/null; then + ok "payment settled $PAY_AMT ($PAY_SUM · order $OID)" + return 0 + fi + done + warn "pay $PAY_AMT ($PAY_SUM)" "not settled for order $OID" + return 1 +} + +# GOA shop catalog (full list) — keep in sync with configs/merchant-landing/index.html +# id|product_name|amount +# Landing QR = taler://pay-template/…/{id}; popup = public POST templates/{id}. +# E2E does not pay every product every run: shuffle + pick E2E_SHOP_PICK_N (default 2). +: "${E2E_SHOP_PRODUCTS:=orbit-sticker|Orbit sticker pack|GOA:2 +nebula-coffee|Nebula coffee|GOA:5 +voidwave-playlist|Voidwave playlist|GOA:8 +comet-cap|Comet cap|GOA:15 +shuttle-pass|Shuttle day pass|GOA:25 +beacon-badge|Beacon badge|GOA:3 +relay-pin|Relay pin|GOA:4 +eclipse-shades|Eclipse shades|GOA:12 +star-chart|Star chart print|GOA:7 +rainbow-pill|Rainbow pill (sample/joke)|GOA:4.2 +blue-or-red-pill|Blue or red pill (sample/joke)|GOA:1.5}" +: "${E2E_SHOP_PICK_N:=2}" +# Public templates live on goa-shop (landing shop-pay.js); not the e2e default demo instance. +: "${E2E_SHOP_INSTANCE:=goa-shop}" + +# Settlement payto shown on landing shop popup (static) +: "${E2E_SHOP_PAYTO:=payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop}" + +# Pay one shop product the way the public landing does: +# POST /instances/{inst}/templates/{id} body {} → order_id+token → taler_pay_uri +# Also checks pay-template URI shape (what qrencode PNGs encode). +# Usage: e2e_one_pay_public_template PRODUCT_ID PRODUCT_NAME AMOUNT +e2e_one_pay_public_template() { + local pid="$1" + local pname="${2:-$1}" + local pamt="$3" + local tag + tag=$(printf 'shop_%s' "$pid" | tr -c 'A-Za-z0-9._-' '_') + section "e2e · shop product · $pname ($pid) · $pamt" + e2e_over && { warn "shop $pname" "budget exhausted — skip"; return 1; } + + 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") + local TPL_URI="taler://pay-template/${MH}/instances/${INST}/${pid}" + local TPL_HTTPS="${MERCHANT_PUBLIC}/instances/${INST}/templates/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$pid")" + info "shop $pname" "pay-template URI $TPL_URI" + info "shop $pname" "public template POST $TPL_HTTPS" + info "shop $pname" "settlement payto $E2E_SHOP_PAYTO" + + # Public create (no merchant secret) — same as shop-pay.js + curl -skS -m 15 -o "$SCRATCH/tpl-$tag.json" -X POST \ + -H 'Content-Type: application/json' \ + -d '{}' \ + "$TPL_HTTPS" 2>"$SCRATCH/tpl-$tag.err" || true + if ! grep -q order_id "$SCRATCH/tpl-$tag.json" 2>/dev/null; then + if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then + koopa_ssh_run 20 \ + "curl -skS -m 12 -X POST -H 'Content-Type: application/json' -d '{}' 'https://127.0.0.1:9010/instances/${INST}/templates/${pid}'" \ + >"$SCRATCH/tpl-$tag.json" 2>"$SCRATCH/tpl-$tag.err" || true + fi + fi + 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/tpl-$tag.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/tpl-$tag.json" 2>/dev/null || true) + if [ -z "$OID" ] || [ -z "$OTOK" ]; then + warn "shop $pname ($pid)" "public template POST failed — $(head -c 100 "$SCRATCH/tpl-$tag.json" 2>/dev/null | tr '\n' ' ')" + return 1 + fi + ok "shop $pname" "public order $OID (template $pid)" + + local statusUrl="${MERCHANT_PUBLIC}/instances/${INST}/orders/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OID")?token=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OTOK")" + curl -skS -m 12 -o "$SCRATCH/tpl-st-$tag.json" "$statusUrl" 2>/dev/null || true + PAYURI=$(python3 -c 'import json,sys +try: + d=json.load(open(sys.argv[1])) + print(d.get("taler_pay_uri") or "") +except Exception: print("") +' "$SCRATCH/tpl-st-$tag.json" 2>/dev/null || true) + if [ -z "$PAYURI" ]; then + PAYURI="taler://pay/${MH}/instances/${INST}/${OID}/?c=${OTOK}" + fi + PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g') + if ! printf '%s' "$PAYURI" | grep -q '^taler://pay/'; then + warn "shop $pname ($pid)" "bad pay URI: $PAYURI" + return 1 + fi + ok "shop $pname" "taler_pay_uri $PAYURI" + + if ! wcli_pay handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$tag.out" 2>&1; then + warn "shop $pname ($pid)" "handle-uri failed — $(tail -c 120 "$SCRATCH/pay-$tag.out" | tr '\n' ' ')" + return 1 + fi + ok "shop $pname" "wallet accepted pay URI" + + local r AUTH="" + if [ -n "${MPW:-}" ]; then + AUTH="Authorization: Bearer secret-token:${MPW}" + fi + for r in 1 2 3; do + wcli_pay run-until-done >"$SCRATCH/pay-run-$tag.out" 2>&1 || true + wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true + if [ -n "$AUTH" ]; then + curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \ + "${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true + if 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-$tag.json" 2>/dev/null; then + ok "shop $pname" "payment settled ($pamt · order $OID)" + 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)" + return 0 + fi + done + warn "shop $pname ($pid)" "not settled for order $OID" + return 1 +} + +# --------------------------------------------------------------------------- +section "e2e · ATM withdraw ladder" +# --------------------------------------------------------------------------- +WITHDRAW_OK=0 +WITHDRAW_OK_N=0 +WITHDRAW_LAG_N=0 +WITHDRAW_FAIL_N=0 +WITHDRAW_REPORT="" +for WITHDRAW_AMT in $WITHDRAW_LIST; do + e2e_over && { warn "ATM ladder" "time budget low — stopping more ATM withdraws (not a protocol error)"; break; } + set +e + e2e_one_withdraw "$WITHDRAW_AMT" + wc=$? + set -e + case "$wc" in + 0) + WITHDRAW_OK=1 + WITHDRAW_OK_N=$((WITHDRAW_OK_N + 1)) + WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=OK" + ;; + 2) + WITHDRAW_LAG_N=$((WITHDRAW_LAG_N + 1)) + WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=LAG" + ;; + *) + WITHDRAW_FAIL_N=$((WITHDRAW_FAIL_N + 1)) + WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=FAIL" + ;; + esac +done +info "ATM withdraw summary" "$WITHDRAW_REPORT (ok=$WITHDRAW_OK_N lag=$WITHDRAW_LAG_N fail=$WITHDRAW_FAIL_N)" + +# Settlement catch-up: bank may have confirmed while wallet was still empty +section "e2e · wallet settlement (timing)" +if ! wait_wallet_balance 0 "${E2E_SETTLE_ROUNDS}" "${E2E_SETTLE_SLEEP}"; then + if [ "$WITHDRAW_LAG_N" -gt 0 ] || [ "$WITHDRAW_OK_N" -gt 0 ]; then + warn "settlement timing" "ATM path reached bank confirm (lag=$WITHDRAW_LAG_N ok=$WITHDRAW_OK_N) but wallet empty after wait — TIME lag, not protocol error" + fi +fi +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 + WITHDRAW_OK=1 + if [ "$WITHDRAW_OK_N" = "0" ]; then + warn "settlement timing" "coins arrived after ATM ladder (${CUR}:${av_now}) — earlier 'FAIL' was timing lag" + WITHDRAW_REPORT="${WITHDRAW_REPORT} → late-OK avail=${CUR}:${av_now}" + fi + ok "spendable balance for payments" "${CUR}:${av_now}" +else + if [ "$WITHDRAW_OK" != "1" ]; then + warn "withdraw" "still no spendable ${CUR} after settle wait" + # diagnostic dig only — do not treat as hard stop if we can still see bank state + if [ "$E2E_REMOTE" != "1" ]; then + dig_when_no_coins || true + fi + # one last balance after dig (dig takes wall time — often enough for wirewatch) + wait_wallet_balance 0 8 3 || true + 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 + WITHDRAW_OK=1 + warn "settlement timing" "coins present after dig wait (${CUR}:${av_now}) — timing, not blocker" + else + blocker "withdraw-settle" "no spendable ${CUR} after ATM ladder + settle wait ($WITHDRAW_LIST)" + section "e2e · report" + info "WITHDRAW" "$WITHDRAW_REPORT — no coins after extended wait" + info "PAY" "SKIPPED (no spendable balance after settle wait)" + exit 1 + fi + fi +fi + +# --------------------------------------------------------------------------- +section "e2e · variable payments (if balance allows)" +# --------------------------------------------------------------------------- +PAY_OK=0 +PAY_OK_N=0 +PAY_FAIL_N=0 +PAY_SKIP_N=0 +PAY_REPORT="" +if [ -z "${MPW:-}" ]; then + if [ "$E2E_REMOTE" = "1" ]; then + e2e_abort_auth "merchant-order" "no merchant token — set E2E_MERCHANT_TOKEN" + fi + blocker "merchant-order" "no merchant token" + exit 1 +fi + +for PAY_AMT in $PAY_LIST; do + e2e_over && { warn "pay ladder" "time budget low — stopping more payments"; break; } + av_now=$(wallet_avail_num) + pay_n=$(python3 -c 'import sys; print(float(sys.argv[1].split(":",1)[-1]))' "$PAY_AMT" 2>/dev/null || echo 0) + if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-12 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_n" 2>/dev/null; then + warn "pay $PAY_AMT" "skip — insufficient avail ${CUR}:${av_now} (need ${pay_n})" + PAY_SKIP_N=$((PAY_SKIP_N + 1)) + PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=SKIP(bal)" + continue + fi + set +e + e2e_one_pay "$PAY_AMT" + pc=$? + set -e + if [ "$pc" = "0" ]; then + PAY_OK=1 + PAY_OK_N=$((PAY_OK_N + 1)) + PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=OK" + else + PAY_FAIL_N=$((PAY_FAIL_N + 1)) + PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=FAIL" + fi +done +info "pay summary" "$PAY_REPORT (ok=$PAY_OK_N fail=$PAY_FAIL_N skip=$PAY_SKIP_N)" +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 + warn "pay" "balance ${CUR}:${av_now} but no pay amount completed — check merchant/timing (not necessarily empty wallet)" + else + blocker "pay-settle" "no payment succeeded and no spendable balance ($PAY_LIST)" + fi +fi + +# --------------------------------------------------------------------------- +section "e2e · GOA shop products (merchant landing catalog)" +# --------------------------------------------------------------------------- +# Public template POST + wallet pay — full catalog list, random pick of N products. +SHOP_OK=0 +SHOP_OK_N=0 +SHOP_FAIL_N=0 +SHOP_SKIP_N=0 +SHOP_REPORT="" +if [ "$E2E_REMOTE" = "1" ] || [ "${CUR:-}" != "GOA" ]; then + info "goa-shop" "SKIPPED (remote or non-GOA currency)" +else + # Build catalog array from E2E_SHOP_PRODUCTS (id|name|amount lines) + SHOP_CATALOG=() + while IFS= read -r line; do + [ -z "$line" ] && continue + case "$line" in \#*) continue ;; esac + SHOP_CATALOG+=("$line") + done </dev/null || echo 0) + if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-12 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_n" 2>/dev/null; then + warn "goa-shop $pname ($pid)" "skip — insufficient avail ${CUR}:${av_now} (need ${pay_n})" + SHOP_SKIP_N=$((SHOP_SKIP_N + 1)) + SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=SKIP(bal)" + continue + fi + set +e + e2e_one_pay_public_template "$pid" "$pname" "$pamt" + pc=$? + set -e + if [ "$pc" = "0" ]; then + SHOP_OK=1 + SHOP_OK_N=$((SHOP_OK_N + 1)) + PAY_OK=1 + PAY_OK_N=$((PAY_OK_N + 1)) + SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=OK" + else + SHOP_FAIL_N=$((SHOP_FAIL_N + 1)) + SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=FAIL" + fi + done </dev/null || echo "(n/a)")" +info "scratch" "$SCRATCH" +# Success if we had coins and at least one pay, OR coins + only pay skips (nothing affordable) +if [ "${#BLOCKERS[@]}" -eq 0 ]; then + if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK" = "1" ]; then + exit 0 + fi + if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK_N" = "0" ] && [ "$PAY_FAIL_N" = "0" ]; then + warn "pay" "no payment tried/completed — withdraw OK" + exit 0 + fi + if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK" != "1" ]; then + # money there, pays failed → soft exit 1 without inventing blockers if already warned + exit 1 + fi +fi +exit 1 diff --git a/scripts/taler-monitoring/check_inside.sh b/scripts/taler-monitoring/check_inside.sh new file mode 100755 index 0000000..aba6115 --- /dev/null +++ b/scripts/taler-monitoring/check_inside.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Inside status for bank / exchange / merchant. Hard-capped SSH — never hang forever. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +# Area inside-### — container / process state on koopa (SSH) +set_area inside +section "inside · collect from koopa" + +if [ "${SKIP_SSH}" = "1" ]; then + warn "ssh" "SKIP_SSH=1 — skipped" + summary + exit 0 +fi + +if ! koopa_ssh_ok; then + err "ssh" "cannot reach ${KOOPA_SSH} in ${SSH_CONNECT_TIMEOUT}s — set SKIP_SSH=1 to skip inside" + summary + exit 1 +fi +ok "ssh ${KOOPA_SSH}" + +# One short remote script (≤ SSH_CMD_TIMEOUT). Every slow step is local curl -m 3 or quick pgrep. +RAW=$( + koopa_ssh_bash "${SSH_CMD_TIMEOUT}" <<'REMOTE' || true +set +e +emit() { printf 'E|%s|%s|%s|%s\n' "$1" "$2" "$3" "$(printf '%s' "${4:-}" | tr '\n\r' ' ' | head -c 200)"; } +# quick curl +hc() { curl -skS -m 3 -o /tmp/mb -w '%{http_code}' "$1" 2>/dev/null || echo 000; } +# quick process check inside container (pgrep only) +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) +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) + +# Domain resolve inside container (wirewatch needs bank.hacktivism.ch → real IP) +# emit: comp LEVEL dns "host → ip" or fail +check_dns() { + local comp="$1" ctr="$2" host="$3" + local line ip code + # Prefer IPv4 (wirewatch/libcurl often happier; avoid dead AAAA) + line=$(podman exec "$ctr" getent ahostsv4 "$host" 2>/dev/null | head -1) + [ -z "$line" ] && line=$(podman exec "$ctr" getent hosts "$host" 2>/dev/null | head -1) + ip=$(echo "$line" | awk '{print $1}') + if [ -z "$ip" ]; then + emit "$comp" ERROR "dns $host" "no resolve — run pin-container-hosts.sh" + return 1 + fi + # 127.0.0.1 is almost always wrong for public bank/exchange from inside pasta + if [ "$ip" = "127.0.0.1" ] || [ "$ip" = "::1" ]; then + emit "$comp" ERROR "dns $host" "$ip (loopback — wirewatch will fail)" + return 1 + fi + code=$(podman exec "$ctr" curl -skS -m 3 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000) + if [ "$code" = "200" ]; then + emit "$comp" OK "dns $host" "→ $ip /config=$code" + else + emit "$comp" WARN "dns $host" "→ $ip /config=$code" + fi +} + +if [ -z "$BANK" ]; then emit bank ERROR container "not running" +else + emit bank INFO container "$(podman ps --filter name=$BANK --format '{{.Names}} {{.Status}}' | head -1)" + emit bank INFO ports "$(podman ps --filter name=$BANK --format '{{.Ports}}' | head -1)" + hasp "$BANK" 'MainKt serve|libeufin-bank serve' && emit bank OK libeufin "running" || emit bank ERROR libeufin "not running — API/withdraw dead" + podman exec "$BANK" pg_isready -q 2>/dev/null && emit bank OK postgres "ready" || emit bank ERROR postgres "not ready" + c=$(hc http://127.0.0.1:9012/config) + [ "$c" = "200" ] && emit bank OK "local /config" "HTTP $c" || emit bank ERROR "local /config" "HTTP $c" + c=$(hc http://127.0.0.1:9012/taler-integration/config) + [ "$c" = "200" ] && emit bank OK "local integration" "HTTP $c" || emit bank ERROR "local integration" "HTTP $c" + hasp "$BANK" 'nginx' && emit bank OK nginx ":9013" || emit bank WARN nginx "not running" + check_dns bank "$BANK" bank.hacktivism.ch || true + check_dns bank "$BANK" exchange.hacktivism.ch || true +fi + +if [ -z "$EX" ]; then emit exchange ERROR container "not running" +else + emit exchange INFO container "$(podman ps --filter name=$EX --format '{{.Names}} {{.Status}}' | head -1)" + c=$(hc http://127.0.0.1:9011/config) + [ "$c" = "200" ] && emit exchange OK "local /config" "HTTP $c" || emit exchange ERROR "local /config" "HTTP $c" + c=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000) + [ "$c" = "200" ] && emit exchange OK "local /keys" "HTTP $c" || emit exchange ERROR "local /keys" "HTTP $c" + hasp "$EX" 'taler-exchange-httpd' && emit exchange OK httpd "running" || emit exchange ERROR httpd "not running" + hasp "$EX" 'taler-exchange-wirewatch' && emit exchange OK wirewatch "running" || emit exchange ERROR wirewatch "not running — withdraw stuck after bank confirm" + hasp "$EX" 'taler-exchange-aggregator' && emit exchange OK aggregator "running" || emit exchange WARN aggregator "not running" + hasp "$EX" 'taler-exchange-transfer' && emit exchange OK transfer "running" || emit exchange WARN transfer "not running" + # critical for wire gateway + check_dns exchange "$EX" bank.hacktivism.ch || true + check_dns exchange "$EX" exchange.hacktivism.ch || true + check_dns exchange "$EX" taler.hacktivism.ch || true +fi + +if [ -z "$MER" ]; then emit merchant ERROR container "not running" +else + emit merchant INFO container "$(podman ps --filter name=$MER --format '{{.Names}} {{.Status}}' | head -1)" + c=$(hc https://127.0.0.1:9010/config) + [ "$c" = "200" ] && emit merchant OK "local /config" "HTTP $c" || emit merchant ERROR "local /config" "HTTP $c" + hasp "$MER" 'taler-merchant-httpd' && emit merchant OK httpd "running" || emit merchant ERROR httpd "not running" + hasp "$MER" 'taler-merchant-wirewatch' && emit merchant OK wirewatch "running" || emit merchant WARN wirewatch "not running" + hasp "$MER" 'taler-merchant-depositcheck' && emit merchant OK depositcheck "running" || emit merchant WARN depositcheck "not running" + check_dns merchant "$MER" bank.hacktivism.ch || true + check_dns merchant "$MER" exchange.hacktivism.ch || true + check_dns merchant "$MER" taler.hacktivism.ch || true +fi + +if systemctl is-active caddy >/dev/null 2>&1 || pgrep -x caddy >/dev/null 2>&1; then + emit caddy OK process "active" +else + emit caddy ERROR process "not active" +fi +echo DONE +REMOTE +) + +if [ -z "$RAW" ] || ! echo "$RAW" | grep -q '^E|'; then + err "ssh" "remote timed out or empty (cap ${SSH_CMD_TIMEOUT}s)" + summary + exit 1 +fi + +while IFS= read -r line; do + case "$line" in + E\|*) + IFS='|' read -r _ comp level key detail <<<"$line" + case "$level" in + OK) ok "[$comp] $key${detail:+ ($detail)}" ;; + ERROR) err "$comp" "$key" "$detail" ;; + WARN) warn "[$comp] $key" "$detail" ;; + INFO) info "[$comp] $key" "$detail" ;; + esac + ;; + esac +done <<<"$RAW" + +summary diff --git a/scripts/taler-monitoring/check_sanity.sh b/scripts/taler-monitoring/check_sanity.sh new file mode 100755 index 0000000..1486e9b --- /dev/null +++ b/scripts/taler-monitoring/check_sanity.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# Sanity checks for bank · exchange · merchant (public + server-side). +# Sections are independent; continues after failures. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +expect_code() { + local label="$1" want="$2" url="$3" + local code + code=$(http_code "$url") + case ",$want," in + *",$code,"*) ok "$label" ;; + *) fail "$label" "HTTP $code (want $want) $url" ;; + esac +} + +json_currency() { + python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("currency") or "")' "$1" 2>/dev/null || true +} + +# Area sanity-### — public + optional server-side per component +set_area sanity + +# --------------------------------------------------------------------------- +section "sanity · bank" +# --------------------------------------------------------------------------- +expect_code "bank public /config" 200 "$BANK_PUBLIC/config" +expect_code "bank public /taler-integration/config" 200 "$BANK_PUBLIC/taler-integration/config" +expect_code "bank public /webui/" 200 "$BANK_PUBLIC/webui/" + +code=$(http_body "$BANK_PUBLIC/config" "$tmp/bank-config.json") +if [ "$code" = "200" ]; then + cur=$(json_currency "$tmp/bank-config.json") + [ "$cur" = "GOA" ] && ok "bank currency GOA" || fail "bank currency" "got ${cur:-?}" + # wire type / name if present + python3 - "$tmp/bank-config.json" <<'PY' 2>/dev/null && ok "bank config JSON object" || fail "bank config JSON" +import json,sys +d=json.load(open(sys.argv[1])) +sys.exit(0 if isinstance(d, dict) and d.get("currency") else 1) +PY + if json_has_alt_unit_names "$tmp/bank-config.json" >/tmp/alt-bank-s.$$ 2>&1; then + ok "bank /config alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000) +echo "LOCAL_CONFIG=$code" +code2=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:9012/taler-integration/config 2>/dev/null || echo 000) +echo "LOCAL_INT=$code2" +if podman exec "$BANK" bash -c 'pgrep -f "MainKt serve|libeufin-bank serve" >/dev/null' 2>/dev/null; then + echo "LIBEUFIN=1" +else + echo "LIBEUFIN=0" +fi +if podman exec "$BANK" bash -c 'pg_isready -q' 2>/dev/null; then + echo "PG=1" +else + echo "PG=0" +fi +# in-container health if present +if podman exec "$BANK" test -x /usr/local/bin/check_bank-health.sh 2>/dev/null; then + podman exec "$BANK" /usr/local/bin/check_bank-health.sh 2>&1 | sed 's/^/HEALTH /' | tail -20 + echo "HEALTH_EC=${PIPESTATUS[0]}" +fi +REMOTE +) + echo "$BOUT" | grep -q '^CTR=.\+' && ok "bank container $(echo "$BOUT" | sed -n 's/^CTR=//p' | head -1)" || fail "bank container" "not found" + echo "$BOUT" | grep -q 'LOCAL_CONFIG=200' && ok "bank local :9012/config" || fail "bank local :9012/config" + echo "$BOUT" | grep -q 'LOCAL_INT=200' && ok "bank local :9012/taler-integration/config" || fail "bank local integration" + echo "$BOUT" | grep -q 'LIBEUFIN=1' && ok "bank libeufin-bank process" || fail "bank libeufin-bank process" + echo "$BOUT" | grep -q 'PG=1' && ok "bank postgres ready" || warn "bank postgres" "pg_isready failed" + if echo "$BOUT" | grep -q 'HEALTH '; then + if echo "$BOUT" | grep -qE 'HEALTH_EC=0|ALL CRITICAL CHECKS PASSED'; then + ok "bank check_bank-health.sh" + else + # health script may false-fail process grep; warn not fail if local config ok + warn "bank check_bank-health.sh" "non-zero or incomplete" + fi + fi +else + warn "bank server-side" "ssh ${KOOPA_SSH} unavailable" +fi + +# --------------------------------------------------------------------------- +section "sanity · exchange" +# --------------------------------------------------------------------------- +expect_code "exchange public /config" 200 "$EXCHANGE_PUBLIC/config" +expect_code "exchange public /keys" 200 "$EXCHANGE_PUBLIC/keys" +expect_code "exchange public /terms" 200 "$EXCHANGE_PUBLIC/terms" + +code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ex-config.json") +if [ "$code" = "200" ]; then + cur=$(json_currency "$tmp/ex-config.json") + [ "$cur" = "GOA" ] && ok "exchange currency GOA" || fail "exchange currency" "got ${cur:-?}" + if json_has_alt_unit_names "$tmp/ex-config.json" "GOA" >/tmp/alt-ex-s.$$ 2>&1; then + ok "exchange /config alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000) +echo "LOCAL_CONFIG=$code" +codek=$(curl -sS -m 8 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000) +echo "LOCAL_KEYS=$codek" +if [ -n "$EX" ]; then + if podman exec "$EX" bash -c 'pgrep -f taler-exchange-httpd >/dev/null' 2>/dev/null; then + echo "HTTPD=1" + else + echo "HTTPD=0" + fi + for p in taler-exchange-secmod-rsa taler-exchange-secmod-eddsa taler-exchange-wirewatch taler-exchange-aggregator; do + if podman exec "$EX" bash -c "pgrep -f $p >/dev/null" 2>/dev/null; then + echo "PROC_$p=1" + else + echo "PROC_$p=0" + fi + done + if podman exec "$EX" test -x /usr/local/bin/check_exchange-health.sh 2>/dev/null; then + SKIP_ENSURE=1 podman exec -e SKIP_ENSURE=1 "$EX" /usr/local/bin/check_exchange-health.sh 2>&1 | sed 's/^/HEALTH /' | tail -25 + fi +fi +REMOTE +) + echo "$EOUT" | grep -q '^CTR=.\+' && ok "exchange container $(echo "$EOUT" | sed -n 's/^CTR=//p' | head -1)" || fail "exchange container" + echo "$EOUT" | grep -q 'LOCAL_CONFIG=200' && ok "exchange local :9011/config" || fail "exchange local :9011/config" + echo "$EOUT" | grep -q 'LOCAL_KEYS=200' && ok "exchange local :9011/keys" || fail "exchange local :9011/keys" + echo "$EOUT" | grep -q 'HTTPD=1' && ok "exchange-httpd process" || warn "exchange-httpd process" "not detected" + echo "$EOUT" | grep -q 'PROC_taler-exchange-wirewatch=1' && ok "exchange wirewatch" || warn "exchange wirewatch" "not running" + echo "$EOUT" | grep -q 'PROC_taler-exchange-aggregator=1' && ok "exchange aggregator" || warn "exchange aggregator" "not running" +else + warn "exchange server-side" "ssh unavailable" +fi + +# --------------------------------------------------------------------------- +section "sanity · merchant" +# --------------------------------------------------------------------------- +expect_code "merchant public /config" 200 "$MERCHANT_PUBLIC/config" +expect_code "merchant public /webui/" 200 "$MERCHANT_PUBLIC/webui/" + +code=$(http_body "$MERCHANT_PUBLIC/config" "$tmp/mer-config.json") +if [ "$code" = "200" ]; then + if python3 - "$tmp/mer-config.json" "$EXCHANGE_PUBLIC" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +want=sys.argv[2].rstrip("/") +curs=list((d.get("currencies") or {}).keys()) +ex=d.get("exchanges") or [] +urls=[] +for e in ex: + if isinstance(e, dict): + u=e.get("base_url") or e.get("url") or e.get("exchange_base_url") or "" + if u: urls.append(u.rstrip("/")) + elif isinstance(e, str): + urls.append(e.rstrip("/")) +ok_goa = "GOA" in curs or any((e.get("currency") if isinstance(e, dict) else None)=="GOA" for e in ex) +ok_ex = any(want in u or "exchange.hacktivism.ch" in u for u in urls) +print("currencies", curs) +print("exchanges", urls[:5]) +sys.exit(0 if ok_goa and ok_ex else 1) +PY + then + ok "merchant config GOA + exchange.hacktivism.ch" + else + fail "merchant config GOA + exchange" "see currencies/exchanges" + fi + if json_has_alt_unit_names "$tmp/mer-config.json" >/tmp/alt-mer-s.$$ 2>&1; then + ok "merchant currencies alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000) +echo "LOCAL_CONFIG=$code" +if [ -n "$MER" ]; then + if podman exec "$MER" bash -c 'pgrep -f taler-merchant-httpd >/dev/null' 2>/dev/null; then + echo "HTTPD=1" + else + echo "HTTPD=0" + fi + if podman exec "$MER" test -x /usr/local/bin/check_merchant-health.sh 2>/dev/null; then + SKIP_ENSURE=1 podman exec -e SKIP_ENSURE=1 "$MER" /usr/local/bin/check_merchant-health.sh 2>&1 | sed 's/^/HEALTH /' | tail -30 + fi +fi +REMOTE +) + echo "$MOUT" | grep -q '^CTR=.\+' && ok "merchant container $(echo "$MOUT" | sed -n 's/^CTR=//p' | head -1)" || fail "merchant container" + echo "$MOUT" | grep -q 'LOCAL_CONFIG=200' && ok "merchant local :9010/config" || fail "merchant local :9010/config" + echo "$MOUT" | grep -q 'HTTPD=1' && ok "merchant-httpd process" || warn "merchant-httpd process" "not detected" + if echo "$MOUT" | grep -q 'HEALTH '; then + if echo "$MOUT" | grep -qiE 'ALL CRITICAL|HEALTH_EC=0|\[OK\]'; then + ok "merchant check_merchant-health.sh (sample OK)" + else + warn "merchant check_merchant-health.sh" "see remote output" + fi + fi +else + warn "merchant server-side" "ssh unavailable" +fi + +summary diff --git a/scripts/taler-monitoring/check_server.sh b/scripts/taler-monitoring/check_server.sh new file mode 100755 index 0000000..961b4c8 --- /dev/null +++ b/scripts/taler-monitoring/check_server.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Server-side component checks on koopa (via SSH). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +# Area server-### — host/container ports via SSH +set_area server +section "server · ssh ${KOOPA_SSH}" + +if ! koopa_ssh_ok; then + fail "ssh ${KOOPA_SSH}" "unreachable within ${SSH_CONNECT_TIMEOUT}s (SKIP_SSH=1 to skip)" + summary + exit 1 +fi +ok "ssh ${KOOPA_SSH}" + +# Run a remote script; collect structured lines +REMOTE=$(koopa_ssh_bash "${SSH_CMD_TIMEOUT}" <<'REMOTE' +set +e +report() { printf 'R|%s|%s|%s\n' "$1" "$2" "$3"; } + +# containers +for name in taler-hacktivism-bank taler-hacktivism-exchange-ansible taler-hacktivism; do + if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then + st=$(podman ps --filter "name=^${name}$" --format '{{.Status}}' | head -1) + report OK "container $name" "$st" + else + # soft match + hit=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E "$name|bank|exchange|hacktivism" | head -3 | tr '\n' ' ') + if podman ps --format '{{.Names}}' 2>/dev/null | grep -q bank && [ "$name" = taler-hacktivism-bank ]; then + bn=$(podman ps --format '{{.Names}}' | grep -i bank | head -1) + report OK "container bank ($bn)" "$(podman ps --filter name="$bn" --format '{{.Status}}' | head -1)" + elif podman ps --format '{{.Names}}' 2>/dev/null | grep -qi exchange && echo "$name" | grep -qi exchange; then + en=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1) + report OK "container exchange ($en)" "$(podman ps --filter name="$en" --format '{{.Status}}' | head -1)" + elif podman ps --format '{{.Names}}' 2>/dev/null | grep -qx taler-hacktivism && [ "$name" = taler-hacktivism ]; then + report OK "container taler-hacktivism" "$(podman inspect -f '{{.State.Status}}' taler-hacktivism 2>/dev/null)" + else + report FAIL "container $name" "not running (seen: $hit)" + fi + fi +done + +# local HTTP on pasta ports +for spec in \ + "bank-api|http://127.0.0.1:9012/config|200" \ + "bank-integration|http://127.0.0.1:9012/taler-integration/config|200" \ + "landing|http://127.0.0.1:9013/intro/|200" \ + "exchange|http://127.0.0.1:9011/config|200" \ + "merchant|https://127.0.0.1:9010/config|200" +do + IFS='|' read -r id url want <<<"$spec" + code=$(curl -skS -m 5 -o /tmp/mon-s.out -w '%{http_code}' "$url" 2>/dev/null || echo 000) + if [ "$code" = "$want" ]; then + report OK "local $id : ${url#*//}" "HTTP $code" + else + report FAIL "local $id" "HTTP $code want $want" + fi +done + +# processes inside bank +BANK=$(podman ps --format '{{.Names}}' | grep -iE 'bank|hacktivism-bank' | head -1) +if [ -n "$BANK" ]; then + if podman exec "$BANK" bash -c 'pgrep -f "MainKt serve|libeufin-bank serve" >/dev/null' 2>/dev/null; then + report OK "libeufin-bank process" "in $BANK" + else + report FAIL "libeufin-bank process" "not running in $BANK" + fi + if podman exec "$BANK" bash -c 'pg_isready -q' 2>/dev/null; then + report OK "postgres (bank)" "ready" + else + report WARN "postgres (bank)" "pg_isready failed" + fi + if podman exec "$BANK" bash -c 'pgrep -x nginx >/dev/null' 2>/dev/null; then + report OK "nginx landing" "in $BANK" + else + report WARN "nginx landing" "not seen in $BANK" + fi +else + report FAIL "bank container" "none" +fi + +# exchange process / systemd if any +EX=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1) +if [ -n "$EX" ]; then + if podman exec "$EX" bash -c 'pgrep -f taler-exchange-httpd >/dev/null || systemctl is-active taler-exchange-httpd 2>/dev/null | grep -q active' 2>/dev/null; then + report OK "exchange-httpd" "in $EX" + else + # config answering is enough + report WARN "exchange-httpd process" "not detected; port check above" + fi +fi + +MER=$(podman ps --format '{{.Names}}' | grep -E '^taler-hacktivism$' | head -1) +[ -z "$MER" ] && MER=$(podman ps --format '{{.Names}}' | grep -i merchant | head -1) +if [ -n "$MER" ]; then + if podman exec "$MER" bash -c 'pgrep -f taler-merchant-httpd >/dev/null || true; curl -sk -m 3 -o /dev/null -w %{http_code} https://127.0.0.1:9010/config' 2>/dev/null | grep -q 200; then + report OK "merchant-httpd" "responds in $MER" + else + report WARN "merchant-httpd" "check manually in $MER" + fi +fi + +# caddy on host +if systemctl is-active caddy >/dev/null 2>&1 || pgrep -x caddy >/dev/null 2>&1; then + report OK "caddy" "active" +else + report WARN "caddy" "not detected as active" +fi +REMOTE +) + +while IFS= read -r line; do + case "$line" in + R\|*) + IFS='|' read -r _ st label detail <<<"$line" + case "$st" in + OK) ok "$label${detail:+ ($detail)}" ;; + FAIL) fail "$label" "$detail" ;; + WARN) warn "$label" "$detail" ;; + esac + ;; + esac +done <<<"$REMOTE" + +summary diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh new file mode 100755 index 0000000..2f7d1de --- /dev/null +++ b/scripts/taler-monitoring/check_urls.sh @@ -0,0 +1,636 @@ +#!/usr/bin/env bash +# Outside-in public HTTPS checks (no SSH). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +# Area www-### — public HTTPS (outside-in) +set_area www +section "www · public URLs · ${TALER_DOMAIN:-?} (outside-in, no SSH)" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +check_url() { + local label="$1" expect="$2" url="$3" + local code + code=$(http_code "$url") + case ",$expect," in + *",$code,"*) ok "$label $url" ;; + *) fail "$label $url" "got $code want $expect" ;; + esac +} + +# Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack +check_url_soft() { + local label="$1" expect="$2" url="$3" + local code + code=$(http_code "$url") + case ",$expect," in + *",$code,"*) ok "$label $url" ;; + *) + if [ "${LOCAL_STACK:-1}" = "0" ]; then + warn "$label $url" "got $code (optional on remote domain)" + else + fail "$label $url" "got $code want $expect" + fi + ;; + esac +} + +expect_currency() { + local label="$1" file="$2" want="${EXPECT_CURRENCY:-}" + local cur + cur=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("currency",""))' "$file" 2>/dev/null || true) + if [ -z "$want" ]; then + info "$label currency" "${cur:-?}" + return + fi + if [ "$cur" = "$want" ]; then + ok "$label currency=$want" + else + fail "$label currency" "got ${cur:-?} want $want" + fi +} + +# Legal docs: /terms and /privacy must be HTTP 200 with a real document body +# (not empty, not "not configured", not JSON API error). +# $1=label $2=url $3=optional needle regex (case-insensitive) for local stack +check_legal_doc() { + local label="$1" url="$2" needle="${3:-}" + local f code soft + soft=0 + [ "${LOCAL_STACK:-1}" = "0" ] && soft=1 + f=$(mktemp) + code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" \ + -H "Accept: text/html,text/markdown,text/plain,*/*" \ + -o "$f" -w '%{http_code}' "$url" 2>/dev/null || echo 000) + if [ "$code" != "200" ]; then + rm -f "$f" + if [ "$soft" = "1" ]; then + warn "$label" "HTTP $code — $url" + else + fail "$label" "HTTP $code — $url" + fi + return + fi + if [ ! -s "$f" ]; then + rm -f "$f" + fail "$label" "empty body — $url" + return + fi + # merchant returns plain "not configured" when PRIVACY_ETAG missing + if grep -qiE '^(not configured)\s*$' "$f" 2>/dev/null \ + || grep -qiE '"code"\s*:\s*21' "$f" 2>/dev/null; then + rm -f "$f" + fail "$label" "not configured / API error — $url" + return + fi + if [ -n "$needle" ] && [ "${LOCAL_STACK:-1}" = "1" ]; then + if ! grep -qiE "$needle" "$f" 2>/dev/null; then + warn "$label content" "missing /$needle/ — $url" + rm -f "$f" + return + fi + fi + ok "$label" "HTTP 200 · $(wc -c <"$f" | tr -d ' ') bytes" + rm -f "$f" +} + +# --- exchange (core; always required) --- www-001 … +check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config" +code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ec.json") +if [ "$code" = "200" ]; then + expect_currency "exchange" "$tmp/ec.json" + # currency_specification.alt_unit_names (wallet codec) + if json_has_alt_unit_names "$tmp/ec.json" "${EXPECT_CURRENCY:-}" >/tmp/alt-ex.$$ 2>&1; then + ok "exchange /config alt_unit_names" "$(tr '\n' '; ' /dev/null || echo "000 0") + code=$(printf '%s' "$out" | awk '{print $1}') + t_s=$(printf '%s' "$out" | awk '{print $2}') + ms=$(awk -v t="${t_s:-0}" 'BEGIN{ + ms=(t+0)*1000 + if (ms>0 && ms<1) ms=1 + printf "%d", int(ms+0.5) + }') + case ",$expect," in + *",$code,"*) + if [ "$ms" -ge "${PERF_FAIL_MS}" ] 2>/dev/null; then + fail "$label" "HTTP $code · ${ms} ms ≥ fail ${PERF_FAIL_MS} ms · $url" + elif [ "$ms" -ge "${PERF_WARN_MS}" ] 2>/dev/null; then + warn "$label" "HTTP $code · ${ms} ms ≥ warn ${PERF_WARN_MS} ms · $url" + else + ok "$label" "HTTP $code · ${ms} ms · $url" + fi + ;; + *) + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "$label" "HTTP $code want $expect · ${ms} ms · $url" + else + warn "$label" "HTTP $code want $expect · ${ms} ms · $url" + fi + ;; + esac +} + +# Bank first (wallet-critical paths before UI chrome) +check_perf "perf bank /taler-integration/config" "$BANK_PUBLIC/taler-integration/config" +check_perf "perf bank /config" "$BANK_PUBLIC/config" +check_perf "perf bank /intro/" "$BANK_PUBLIC/intro/" +check_perf "perf bank /intro/stats.json" "$BANK_PUBLIC/intro/stats.json" 200 +check_perf "perf bank /webui/" "$BANK_PUBLIC/webui/" 200,301,302 + +# Exchange +check_perf "perf exchange /config" "$EXCHANGE_PUBLIC/config" +check_perf "perf exchange /keys" "$EXCHANGE_PUBLIC/keys" +check_perf "perf exchange /intro/" "$EXCHANGE_PUBLIC/intro/" + +# Merchant +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" + + +# Terms + privacy (legal docs) +check_legal_doc "exchange /terms" "$EXCHANGE_PUBLIC/terms" "terms|GOA|exploration|FADP|revDSG|privacy" +check_legal_doc "exchange /privacy" "$EXCHANGE_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|exploration" +# trailing slash: 200 or redirect to bare path +code=$(http_code "$EXCHANGE_PUBLIC/terms/") +case "$code" in + 200|301|302) ok "exchange /terms/" "HTTP $code" ;; + *) + if [ "${LOCAL_STACK:-1}" = "1" ]; then warn "exchange /terms/" "HTTP $code" + else warn "exchange /terms/" "HTTP $code" + fi + ;; +esac + +# --- bank --- +if [ "${LOCAL_STACK:-1}" = "0" ]; then + check_url_soft "bank /config" 200 "$BANK_PUBLIC/config" +else + check_url "bank /config" 200 "$BANK_PUBLIC/config" +fi +code=$(http_body "$BANK_PUBLIC/config" "$tmp/bc.json") +if [ "$code" = "200" ]; then + expect_currency "bank" "$tmp/bc.json" + if json_has_alt_unit_names "$tmp/bc.json" >/tmp/alt-bank.$$ 2>&1; then + ok "bank /config alt_unit_names" "$(tr '\n' '; ' /dev/null || true)" + else + fail "bank /intro/auto-account.json" "invalid withdraw/login (HTTP body bad)" + fi + ;; + 405|501|404|502|503|000) + fail "bank /intro/auto-account.json" "HTTP $aa_code (want 200; 405/501 = broken)" + ;; + *) + fail "bank /intro/auto-account.json" "HTTP $aa_code want 200" + ;; + esac +fi + +# Bank legal docs (landing nginx via Caddy /terms* /privacy* or /intro/*) +check_legal_doc "bank /terms" "$BANK_PUBLIC/terms" "terms|GOA|exploration|bank|FADP|revDSG" +# Prefer /privacy; fall back to /intro/privacy.html for older deploys +code=$(http_code "$BANK_PUBLIC/privacy") +if [ "$code" = "200" ]; then + check_legal_doc "bank /privacy" "$BANK_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|bank" +else + if [ "${LOCAL_STACK:-1}" = "1" ]; then + check_legal_doc "bank /privacy (or /intro/privacy.html)" \ + "$BANK_PUBLIC/intro/privacy.html" "privacy|FADP|revDSG|data|GOA|bank" + # still report bare /privacy failure for local + warn "bank /privacy" "HTTP $code — prefer Caddy handle /privacy* → landing" + else + check_url_soft "bank /privacy" 200 "$BANK_PUBLIC/privacy" + fi +fi + +# --- merchant --- +if [ "${LOCAL_STACK:-1}" = "0" ]; then + check_url_soft "merchant /config" 200 "$MERCHANT_PUBLIC/config" +else + check_url "merchant /config" 200 "$MERCHANT_PUBLIC/config" +fi +code=$(http_body "$MERCHANT_PUBLIC/config" "$tmp/mc.json") +if [ "$code" = "200" ]; then + want="${EXPECT_CURRENCY:-}" + if python3 - "$tmp/mc.json" "$want" <<'PY' +import json,sys +try: + d=json.load(open(sys.argv[1])) +except Exception: + sys.exit(2) +want=sys.argv[2] +if not want: + sys.exit(0) +curs=list((d.get("currencies") or {}).keys()) +ex=d.get("exchanges") or [] +ok = want in curs or any((e.get("currency") if isinstance(e,dict) else None)==want for e in ex) +if not ok and isinstance(d.get("currency"), str): + ok = d["currency"]==want +sys.exit(0 if ok else 1) +PY + then + ok "merchant /config (${want:-currency} ok)" + else + ec=$? + if [ "$ec" = "2" ]; then + warn "merchant /config" "non-JSON body" + elif [ -n "$want" ]; then + fail "merchant /config currency" "want $want" + else + info "merchant /config" "ok" + fi + fi + # merchant-local currency maps (GOA + CHF, …) + if json_has_alt_unit_names "$tmp/mc.json" >/tmp/alt-mer.$$ 2>&1; then + ok "merchant /config currencies alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000) + if [ "$code" = "200" ]; then + ok "$label" "HTTP redirect→200 · $url" + elif [ "$soft" = "1" ]; then + warn "$label" "HTTP $code — $url" + else + fail "$label" "HTTP $code after redirect — $url" + fi + ;; + *) + if [ "$soft" = "1" ]; then + warn "$label" "HTTP $code — $url" + else + fail "$label" "HTTP $code — $url" + fi + ;; + esac +} + +# Soft external (app stores / upstream docs): WARN if down, never ERROR +check_external_soft() { + local label="$1" url="$2" + local code + code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000) + case "$code" in + 200|204|301|302|303|307|308) ok "$label" "HTTP $code · $url" ;; + *) warn "$label" "HTTP $code (external soft) · $url" ;; + esac +} + +# Parse one landing HTML: collect absolute https + root-relative href/src; +# resolve against base; classify own-stack vs external. +# Writes lists: $1.own $1.ext (one URL per line) +extract_landing_urls() { + local base="$1" html="$2" out_prefix="$3" + python3 - "$base" "$html" "$out_prefix" <<'PY' +import re, sys +from urllib.parse import urljoin, urlparse + +base, html_path, out = sys.argv[1], sys.argv[2], sys.argv[3] +html = open(html_path, encoding="utf-8", errors="replace").read() +base = base.rstrip("/") + "/" +parsed_base = urlparse(base) +# Hard-check only the three public Taler hosts for this stack (not git.* etc.) +own_hosts = { + (parsed_base.hostname or "").lower(), + "bank.hacktivism.ch", + "exchange.hacktivism.ch", + "taler.hacktivism.ch", +} +# include configured public hosts when domain differs (demo / ops) +for envu in ( + __import__("os").environ.get("BANK_PUBLIC", ""), + __import__("os").environ.get("EXCHANGE_PUBLIC", ""), + __import__("os").environ.get("MERCHANT_PUBLIC", ""), +): + h = urlparse(envu).hostname if envu else None + if h: + own_hosts.add(h.lower()) + +raw = set() +for m in re.finditer( + r'''(?:href|src|content)=["']([^"'#]+)["']''', html, re.I +): + raw.add(m.group(1).strip()) +# bare absolute URLs in scripts (fetch, template strings) +for m in re.finditer(r'''https://[^\s"'<>\\]+''', html): + u = m.group(0).rstrip("\\).,;'\"") + # strip trailing punctuation leftovers + while u and u[-1] in ".,);]}\"'": + u = u[:-1] + if u.startswith("https://"): + raw.add(u) + +own, ext = set(), set() +skip_prefix = ("data:", "javascript:", "mailto:", "taler://", "blob:") +skip_exact = {"website", "summary_large_image", "image/png", "en_US"} +for r in raw: + if not r or r in skip_exact: + continue + if r.startswith(skip_prefix): + continue + # meta content noise + if re.fullmatch(r"\d+", r) or r.startswith("width="): + continue + if " " in r and not r.startswith("http"): + continue + if r.startswith("//"): + absu = "https:" + r + elif r.startswith("http://") or r.startswith("https://"): + absu = r + elif r.startswith("/"): + absu = urljoin(base, r) + else: + # relative asset + if "/" in r or r.endswith((".js", ".css", ".png", ".svg", ".html", ".json", ".uri")): + absu = urljoin(base + "intro/", r) + else: + continue + # drop query-only noise / anchors already stripped + p = urlparse(absu) + if p.scheme not in ("http", "https"): + continue + # normalize: drop fragment + absu = absu.split("#", 1)[0] + host = (p.hostname or "").lower() + # og image query ok + if host in own_hosts: + own.add(absu) + else: + ext.add(absu) + +open(out + ".own", "w").write("\n".join(sorted(own)) + ("\n" if own else "")) +open(out + ".ext", "w").write("\n".join(sorted(ext)) + ("\n" if ext else "")) +print(f"own={len(own)} ext={len(ext)}") +PY +} + +check_one_landing() { + local name="$1" base="$2" + local html="$tmp/landing-${name}.html" + local pref="$tmp/urls-${name}" + local code n own_n ext_n + code=$(http_body "${base}/intro/" "$html") + if [ "$code" != "200" ]; then + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "landing ${name} /intro/" "HTTP $code" + else + warn "landing ${name} /intro/" "HTTP $code" + fi + return + fi + ok "landing ${name} /intro/" "HTTP 200 · $(wc -c <"$html" | tr -d ' ') bytes" + + # Required static assets (hard on local) + check_landing_asset "landing ${name} qrcode.min.js" "${base}/intro/qrcode.min.js" + check_landing_asset "landing ${name} og-goa-shop.png" "${base}/intro/og-goa-shop.png" + check_landing_asset "landing ${name} qr-logo.png" "${base}/intro/qr-logo.png" 1 + + n=$(extract_landing_urls "$base" "$html" "$pref" 2>/dev/null || echo "own=0 ext=0") + info "landing ${name} link extract" "$n" + own_n=0 + ext_n=0 + [ -f "${pref}.own" ] && own_n=$(grep -c . "${pref}.own" 2>/dev/null || echo 0) + [ -f "${pref}.ext" ] && ext_n=$(grep -c . "${pref}.ext" 2>/dev/null || echo 0) + if [ "${own_n:-0}" -lt 1 ]; then + fail "landing ${name} own-stack links" "none extracted from HTML" + else + ok "landing ${name} own-stack links" "${own_n} URLs to probe" + fi + + # Probe every own-stack URL from the page + if [ -f "${pref}.own" ]; then + while IFS= read -r u; do + [ -n "$u" ] || continue + # skip mint endpoints that create resources on GET if any (auto-account creates accounts) + case "$u" in + */intro/auto-account.json) + # shape checked separately; still require 200 GET + ;; + esac + code=$(http_code "$u") + case "$code" in + 200) ok "landing ${name} link" "HTTP 200 · $u" ;; + 301|302|303|307|308) + code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$u" 2>/dev/null || echo 000) + if [ "$code" = "200" ]; then + ok "landing ${name} link" "redirect→200 · $u" + else + fail "landing ${name} link" "HTTP $code after redirect · $u" + fi + ;; + 405|501) + # some APIs reject wrong method — try GET already failed; soft note + fail "landing ${name} link" "HTTP $code · $u" + ;; + *) + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "landing ${name} link" "HTTP $code · $u" + else + warn "landing ${name} link" "HTTP $code · $u" + fi + ;; + esac + done < "${pref}.own" + fi + + # External store / docs: soft + if [ -f "${pref}.ext" ]; then + while IFS= read -r u; do + [ -n "$u" ] || continue + check_external_soft "landing ${name} external" "$u" + done < "${pref}.ext" + fi +} + +check_one_landing "bank" "$BANK_PUBLIC" +check_one_landing "merchant" "$MERCHANT_PUBLIC" +check_one_landing "exchange" "$EXCHANGE_PUBLIC" + +# Cross-links between the three landings (always on local stack) +if [ "${LOCAL_STACK:-1}" = "1" ]; then + check_landing_asset "cross bank→merchant intro" "$MERCHANT_PUBLIC/intro/" + check_landing_asset "cross bank→exchange intro" "$EXCHANGE_PUBLIC/intro/" + check_landing_asset "cross merchant→bank intro" "$BANK_PUBLIC/intro/" + check_landing_asset "cross exchange→bank intro" "$BANK_PUBLIC/intro/" +fi + +# Bank-only: shared-pool withdraw mint + static withdraw files + shop assets +if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then + check_landing_asset "bank shop-pay.js" "$BANK_PUBLIC/intro/shop-pay.js" 1 + check_landing_asset "bank shop-pay.css" "$BANK_PUBLIC/intro/shop-pay.css" 1 + 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 [ -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" + fi + ;; + 405|501|404|502|503|000) + fail "bank /intro/demo-withdraw.json" "HTTP $dw_code (want 200)" + ;; + *) + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "bank /intro/demo-withdraw.json" "HTTP $dw_code want 200" + else + warn "bank /intro/demo-withdraw.json" "HTTP $dw_code" + fi + ;; + esac +fi + +# Merchant landing shop assets +check_landing_asset "merchant shop-pay.js" "$MERCHANT_PUBLIC/intro/shop-pay.js" 1 +check_landing_asset "merchant shop-pay.css" "$MERCHANT_PUBLIC/intro/shop-pay.css" 1 + +summary diff --git a/scripts/taler-monitoring/check_versions.sh b/scripts/taler-monitoring/check_versions.sh new file mode 100755 index 0000000..eed9e5f --- /dev/null +++ b/scripts/taler-monitoring/check_versions.sh @@ -0,0 +1,469 @@ +#!/usr/bin/env bash +# Area versions-### — Taler packages vs deb.taler.net (trixie) + repo availability. +# +# Checks: +# 1) deb.taler.net apt endpoints reachable (InRelease / Packages / sample .deb) +# 2) containers can reach deb.taler.net (install path from inside) +# 3) installed taler*/libeufin*/libtaler*/libdonau* versions vs suite index +# +# Env: +# TALER_APT_SUITE=trixie +# TALER_APT_BASE=https://deb.taler.net/apt/debian +# TALER_APT_INDEX=…/dists/trixie/main/binary-amd64/Packages +# TALER_APT_TESTING_INDEX=…/dists/trixie-testing/… +# TALER_PKG_BEHIND=warn|error (default: core packages ERROR if behind, else warn) +# SKIP_SSH=1 skip container install checks +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +set_area versions + +SUITE="${TALER_APT_SUITE:-trixie}" +APT_BASE="${TALER_APT_BASE:-https://deb.taler.net/apt/debian}" +APT_BASE="${APT_BASE%/}" +INDEX="${TALER_APT_INDEX:-${APT_BASE}/dists/${SUITE}/main/binary-amd64/Packages}" +INRELEASE="${TALER_APT_INRELEASE:-${APT_BASE}/dists/${SUITE}/InRelease}" +TESTING_INDEX="${TALER_APT_TESTING_INDEX:-${APT_BASE}/dists/${SUITE}-testing/main/binary-amd64/Packages}" +BEHIND_MODE="${TALER_PKG_BEHIND:-warn}" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +http_get() { + # usage: http_get URL [outfile] → prints http_code; body to outfile or /dev/null + local url="$1" out="${2:-/dev/null}" + curl -sS --max-time 30 -L -o "$out" -w '%{http_code}' "$url" 2>/dev/null || echo 000 +} + +# --------------------------------------------------------------------------- +# 1) OUTSIDE — deb.taler.net install source (runner / laptop, no SSH) +# --------------------------------------------------------------------------- +section "versions · outside · deb.taler.net (${SUITE})" + +# DNS +HOST_APT="${APT_BASE#https://}"; HOST_APT="${HOST_APT#http://}"; HOST_APT="${HOST_APT%%/*}" +if getent hosts "$HOST_APT" >/dev/null 2>&1 \ + || python3 -c "import socket; socket.getaddrinfo('${HOST_APT}', 443)" >/dev/null 2>&1; then + ip=$(python3 -c "import socket; print(socket.getaddrinfo('${HOST_APT}',443)[0][4][0])" 2>/dev/null || true) + ok "outside DNS ${HOST_APT}" "${ip:-resolved}" +else + fail "outside DNS ${HOST_APT}" "unresolvable — cannot use as apt source" +fi + +# HTTPS portal / apt root +code=$(http_get "https://${HOST_APT}/") +[ "$code" = "200" ] && ok "outside https://${HOST_APT}/" "HTTP $code" \ + || warn "outside https://${HOST_APT}/" "HTTP $code" + +code=$(http_get "${APT_BASE}/") +case "$code" in + 200|301|302) ok "outside apt base" "${APT_BASE}/ HTTP $code" ;; + *) fail "outside apt base" "${APT_BASE}/ HTTP $code" ;; +esac + +# Suite metadata (what apt update needs) +code=$(http_get "$INRELEASE" "$tmp/InRelease") +if [ "$code" = "200" ] && [ -s "$tmp/InRelease" ]; then + ok "outside InRelease" "${SUITE} HTTP 200 · $(wc -c <"$tmp/InRelease" | tr -d ' ') bytes" +else + fail "outside InRelease" "HTTP $code — $INRELEASE (apt update will fail)" +fi + +RELEASE_URL="${APT_BASE}/dists/${SUITE}/Release" +code=$(http_get "$RELEASE_URL" "$tmp/Release") +[ "$code" = "200" ] && ok "outside Release" "${SUITE} HTTP 200" \ + || warn "outside Release" "HTTP $code — $RELEASE_URL" + +# Packages (plain) — required for version compare +code=$(http_get "$INDEX" "$tmp/Packages") +if [ "$code" = "200" ] && [ -s "$tmp/Packages" ] && grep -q '^Package: ' "$tmp/Packages"; then + n_pkg=$(grep -c '^Package: ' "$tmp/Packages" || true) + ok "outside Packages" "${SUITE} HTTP 200 · ${n_pkg} packages" +else + fail "outside Packages" "HTTP $code — $INDEX" + # still try more probes, but cannot compare versions without index +fi + +# Packages.gz (apt often prefers this) +PKGZ_URL="${APT_BASE}/dists/${SUITE}/main/binary-amd64/Packages.gz" +code=$(http_get "$PKGZ_URL" "$tmp/Packages.gz") +if [ "$code" = "200" ] && [ -s "$tmp/Packages.gz" ]; then + ok "outside Packages.gz" "${SUITE} HTTP 200 · $(wc -c <"$tmp/Packages.gz" | tr -d ' ') bytes" +else + warn "outside Packages.gz" "HTTP $code — apt may still use plain Packages" +fi + +# Sample pool .deb downloadable (proves packages are installable, not just listed) +if [ -s "$tmp/Packages" ]; then + DEB_PATH=$(awk ' + /^Package: taler-exchange$/ { p=1 } + p && /^Filename: / { sub(/^Filename: /,""); print; exit } + p && /^$/ { p=0 } + ' "$tmp/Packages") + [ -z "$DEB_PATH" ] && DEB_PATH=$(awk ' + /^Package: taler-merchant$/ { p=1 } + p && /^Filename: / { sub(/^Filename: /,""); print; exit } + p && /^$/ { p=0 } + ' "$tmp/Packages") + if [ -n "$DEB_PATH" ]; then + DEB_URL="${APT_BASE}/${DEB_PATH}" + code=$(curl -sS --max-time 30 -o /dev/null -w '%{http_code}' -r 0-128 "$DEB_URL" 2>/dev/null || echo 000) + case "$code" in + 200|206) + ok "outside pool .deb" "$(basename "$DEB_PATH") HTTP $code" + ;; + *) + fail "outside pool .deb" "HTTP $code — $DEB_URL (index ok but debs not fetchable)" + ;; + esac + else + warn "outside pool .deb" "no Filename for taler-exchange/merchant in index" + fi + + for pkg in taler-exchange taler-merchant libeufin-bank; do + ver=$(awk -v p="$pkg" ' + $0=="Package: "p { hit=1; next } + hit && /^Version: / { sub(/^Version: /,""); print; exit } + hit && /^$/ { hit=0 } + ' "$tmp/Packages") + if [ -n "$ver" ]; then + ok "outside suite offers ${pkg}" "$ver" + else + fail "outside suite offers ${pkg}" "missing from ${SUITE} Packages" + fi + done +fi + +HAVE_TESTING=0 +code=$(http_get "$TESTING_INDEX" "$tmp/Packages-testing") +if [ "$code" = "200" ] && [ -s "$tmp/Packages-testing" ] && grep -q '^Package: ' "$tmp/Packages-testing"; then + HAVE_TESTING=1 + ok "outside ${SUITE}-testing Packages" "HTTP 200 (optional compare)" +else + info "outside ${SUITE}-testing Packages" "HTTP ${code:-000} (optional)" +fi + +# TLS: certificate verify (curl default) already used above; explicit openssl probe if available +if command -v openssl >/dev/null 2>&1; then + if echo | openssl s_client -servername "$HOST_APT" -connect "${HOST_APT}:443" 2>/dev/null \ + | grep -q 'Verify return code: 0'; then + ok "outside TLS ${HOST_APT}" "verify ok" + else + # curl succeeded with default CA — soft warn only + warn "outside TLS ${HOST_APT}" "openssl verify not clean (curl may still work)" + fi +fi + +# Need Packages index for later compare +if [ ! -s "$tmp/Packages" ] || ! grep -q '^Package: ' "$tmp/Packages"; then + fail "outside Packages usable" "cannot continue version compare without suite index" + summary + exit 1 +fi + +# --------------------------------------------------------------------------- +# 2) INSIDE — containers can reach deb.taler.net (pasta / install path) +# --------------------------------------------------------------------------- +section "versions · inside · containers → deb.taler.net" + +if [ "${SKIP_SSH}" = "1" ]; then + warn "ssh" "SKIP_SSH=1 — skip container reachability + installed package compare" + info "outside-only" "deb.taler.net public checks completed above" + summary + exit 0 +fi +if ! koopa_ssh_ok; then + err "ssh" "cannot reach ${KOOPA_SSH} — set SKIP_SSH=1 to skip container checks" + info "outside-only" "deb.taler.net public checks completed above" + summary + exit 1 +fi +ok "ssh ${KOOPA_SSH}" + +INRELEASE_URL="${APT_BASE}/dists/${SUITE}/InRelease" +# Write remote script to a file (avoids bash 3.2 parse bugs with case/;; inside $(…)</dev/null | grep -Fx "$want" | head -1) + if [ -n "$c" ]; then echo "$c"; return; fi + case "$want" in + *exchange*) podman ps --format '{{.Names}}' 2>/dev/null | grep -i exchange | head -1 ;; + *bank*) podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism-bank|taler-bank' | head -1 ;; + *) + c=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E '^taler-hacktivism$' | head -1) + if [ -n "$c" ]; then echo "$c"; return; fi + podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'merchant|hacktivism' | grep -viE 'bank|exchange' | head -1 + ;; + esac +} +for want in taler-hacktivism-exchange-ansible taler-hacktivism-bank taler-hacktivism; do + c=$(resolve_ctr "$want") + [ -n "$c" ] || continue + role=other + case "$c" in + *exchange*) role=exchange ;; + *bank*) role=bank ;; + *) role=merchant ;; + esac + code=$(podman exec "$c" curl -sS -m 12 -o /dev/null -w '%{http_code}' "$INRELEASE_URL" 2>/dev/null || echo 000) + echo "R|${role}|${c}|${code}" + if podman exec "$c" bash -lc 'grep -Rqs deb.taler.net /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null'; then + echo "S|${role}|${c}|yes" + else + echo "S|${role}|${c}|no" + fi + podman exec "$c" dpkg-query -W -f '${Package}\t${Version}\n' 2>/dev/null \ + | awk -v role="$role" -v ctr="$c" ' + $1 ~ /^(taler-|libeufin-|libtaler|libdonau)/ { + printf "P|%s|%s|%s|%s\n", role, ctr, $1, $2 + }' +done +REMOTE +} >"$tmp/remote-versions.sh" + +REMOTE_OUT=$(koopa_ssh_bash 60 <"$tmp/remote-versions.sh" || true) + +printf '%s\n' "$REMOTE_OUT" | grep -E '^R\|' >"$tmp/reach.tsv" || true +printf '%s\n' "$REMOTE_OUT" | grep -E '^S\|' >"$tmp/sources.tsv" || true +printf '%s\n' "$REMOTE_OUT" | grep -E '^P\|' >"$tmp/installed.tsv" || true + +if [ -s "$tmp/reach.tsv" ]; then + while IFS='|' read -r _ role ctr code; do + if [ "$code" = "200" ]; then + ok "container ${role} → deb.taler.net" "${ctr} InRelease HTTP $code" + else + fail "container ${role} → deb.taler.net" "${ctr} InRelease HTTP $code — apt install/update will fail" + fi + done <"$tmp/reach.tsv" +else + warn "container → deb.taler.net" "no reachability rows" +fi + +if [ -s "$tmp/sources.tsv" ]; then + while IFS='|' read -r _ role ctr has; do + if [ "$has" = "yes" ]; then + ok "container ${role} apt source" "${ctr} lists deb.taler.net" + else + warn "container ${role} apt source" "${ctr} no deb.taler.net in sources.list*" + fi + done <"$tmp/sources.tsv" +fi + +if [ ! -s "$tmp/installed.tsv" ]; then + fail "installed packages" "none found in taler containers" + summary + exit 1 +fi +n_inst=$(wc -l <"$tmp/installed.tsv" | tr -d ' ') +ok "collected installed packages" "${n_inst} rows" + +# --------------------------------------------------------------------------- +# 3) compare installed vs trixie (and note testing) +# --------------------------------------------------------------------------- +section "versions · compare installed vs ${SUITE}" + +export BEHIND_MODE HAVE_TESTING +export TALER_APT_SUITE="$SUITE" +CORE_PKGS="taler-exchange taler-exchange-database taler-merchant taler-merchant-webui libeufin-bank libeufin-common libtalerexchange libtalermerchant taler-terms-generator" +export CORE_PKGS + +python3 - "$tmp/Packages" "$tmp/Packages-testing" "$tmp/installed.tsv" <<'PY' >"$tmp/py.out" +import sys, os +from collections import defaultdict + +main_idx, test_idx, inst_path = sys.argv[1:4] +have_testing = os.environ.get("HAVE_TESTING", "0") == "1" +core = set(os.environ.get("CORE_PKGS", "").split()) +suite = os.environ.get("TALER_APT_SUITE", "trixie") + +def _ord_ch(c): + if c == "~": + return -1 + if c.isdigit(): + return 0 + if c.isalpha(): + return ord(c) + return ord(c) + 256 + +def _split_ver(v): + epoch = 0 + if ":" in v: + e, v = v.split(":", 1) + try: + epoch = int(e) + except ValueError: + epoch = 0 + if "-" in v: + upstream, deb = v.rsplit("-", 1) + else: + upstream, deb = v, "" + return epoch, upstream, deb + +def _cmp_part(a, b): + ia = ib = 0 + while ia < len(a) or ib < len(b): + while True: + ca = a[ia] if ia < len(a) and not a[ia].isdigit() else "" + cb = b[ib] if ib < len(b) and not b[ib].isdigit() else "" + if ca == "" and cb == "": + break + if ca == "" and cb: + return -1 if _ord_ch(cb) > 0 else (1 if cb == "~" else -1) + if cb == "" and ca: + return 1 if _ord_ch(ca) > 0 else (-1 if ca == "~" else 1) + oa, ob = _ord_ch(ca), _ord_ch(cb) + ia += 1 + ib += 1 + if oa != ob: + return (oa > ob) - (oa < ob) + sa = sb = "" + while ia < len(a) and a[ia].isdigit(): + sa += a[ia]; ia += 1 + while ib < len(b) and b[ib].isdigit(): + sb += b[ib]; ib += 1 + na = int(sa) if sa else 0 + nb = int(sb) if sb else 0 + if na != nb: + return (na > nb) - (na < nb) + return 0 + +def deb_cmp(a, b): + if a == b: + return 0 + ea, ua, da = _split_ver(a) + eb, ub, db = _split_ver(b) + if ea != eb: + return (ea > eb) - (ea < eb) + c = _cmp_part(ua, ub) + if c: + return c + return _cmp_part(da, db) + +def parse_packages(path): + pkgs = {} + if not path or not os.path.isfile(path) or os.path.getsize(path) == 0: + return pkgs + cur = ver = None + with open(path, encoding="utf-8", errors="replace") as f: + for line in f: + line = line.rstrip("\n") + if line.startswith("Package: "): + if cur and ver and (cur not in pkgs or deb_cmp(ver, pkgs[cur]) > 0): + pkgs[cur] = ver + cur = line[9:].strip() + ver = None + elif line.startswith("Version: ") and cur: + ver = line[9:].strip() + elif line == "" and cur: + if ver and (cur not in pkgs or deb_cmp(ver, pkgs[cur]) > 0): + pkgs[cur] = ver + cur = ver = None + if cur and ver and (cur not in pkgs or deb_cmp(ver, pkgs[cur]) > 0): + pkgs[cur] = ver + return pkgs + +main = parse_packages(main_idx) +testing = parse_packages(test_idx) if have_testing else {} + +by_pkg = defaultdict(list) +with open(inst_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line.startswith("P|"): + continue + parts = line.split("|") + if len(parts) < 5: + continue + _, role, ctr, pkg, ver = parts[:5] + by_pkg[pkg].append((role, ctr, ver)) + +for pkg in sorted(by_pkg.keys()): + versions = sorted({v for _, _, v in by_pkg[pkg]}, key=lambda v: v) + # pick "highest" via deb_cmp + inst = versions[0] + for v in versions[1:]: + if deb_cmp(v, inst) > 0: + inst = v + roles = ",".join(sorted({r for r, _, _ in by_pkg[pkg]})) + multi = len(set(versions)) > 1 + trixie = main.get(pkg) + testv = testing.get(pkg) + status = "ok" + detail = "" + if multi: + status = "warn" + detail = "multiple installed: " + ", ".join(sorted(set(versions))) + if trixie is None: + if status == "ok": + status = "info" + detail = (detail + "; " if detail else "") + f"not in {suite} main index" + else: + c = deb_cmp(inst, trixie) + if c == 0: + detail = (detail + "; " if detail else "") + f"= {suite} {trixie}" + elif c < 0: + status = "behind" + detail = f"installed {inst} < {suite} {trixie}" + else: + status = "ahead" + detail = f"installed {inst} > {suite} {trixie}" + if testv and deb_cmp(inst, testv) == 0: + detail += f" (={suite}-testing)" + elif testv: + detail += f" (testing has {testv})" + core_flag = "1" if pkg in core else "0" + print(f"{status}|{pkg}|{inst}|{trixie or '-'}|{roles}|{core_flag}|{detail}") +PY + +ok_n=0; ahead_n=0; behind_n=0 +while IFS='|' read -r status pkg inst trixie roles core detail; do + [ -n "${status:-}" ] || continue + label="pkg ${pkg} (${roles})" + case "$status" in + ok) + ok "$label" "${inst}" + ok_n=$((ok_n + 1)) + ;; + ahead) + info "$label" "$detail" + ahead_n=$((ahead_n + 1)) + ;; + behind) + behind_n=$((behind_n + 1)) + if [ "$core" = "1" ] || [ "$BEHIND_MODE" = "error" ]; then + fail "$label" "$detail" + else + warn "$label" "$detail" + fi + ;; + warn) + warn "$label" "$detail" + ;; + info) + info "$label" "installed ${inst}${detail:+ — $detail}" + ;; + *) + info "$label" "$status $detail" + ;; + esac +done <"$tmp/py.out" + +# Core packages must exist somewhere +for need in taler-exchange libeufin-bank taler-merchant; do + if grep -qE "^P\|[^|]+\|[^|]+\|${need}\|" "$tmp/installed.tsv"; then + ok "core installed ${need}" + else + fail "core installed ${need}" "not present in any taler container" + fi +done + +info "suite" "deb.taler.net ${SUITE} (testing_index=${HAVE_TESTING})" +info "tally" "match=${ok_n} ahead=${ahead_n} behind=${behind_n}" + +summary diff --git a/scripts/taler-monitoring/harness-live.txt b/scripts/taler-monitoring/harness-live.txt new file mode 100644 index 0000000..b7d5500 --- /dev/null +++ b/scripts/taler-monitoring/harness-live.txt @@ -0,0 +1,7 @@ +# Optional: taler-harness against *our* exchange (needs a harness version +# that accepts current /config JSON). Older installs often FAIL codec checks +# even when the exchange is fine — trust `./taler-monitoring.sh urls` first. +# +# idcommand + +lint-exchange-goa taler-harness deployment lint-exchange-url https://exchange.hacktivism.ch/ diff --git a/scripts/taler-monitoring/harness-tests.txt b/scripts/taler-monitoring/harness-tests.txt new file mode 100644 index 0000000..1c63e3b --- /dev/null +++ b/scripts/taler-monitoring/harness-tests.txt @@ -0,0 +1,41 @@ +# Real-life–relevant taler-harness integration tests +# (from: taler-harness list-integrationtests) +# +# Criteria: everyday user/merchant/bank paths — withdraw, pay, deposit, refund, +# libeufin bank, templates/paywall. Excludes KYC edge cases, experimental, +# timetravel, perf, backup, mailbox, most fault-injection. +# +# These spin up a local TESTKUDOS stack (same client code paths as production). +# For tests against *public* demos, see harness-live.txt / `./taler-monitoring.sh live`. + +# --- Core path: get money + spend it (the GOA story) --- +simple-payment +withdrawal-bank-integrated +payment + +# --- Withdraw variants users actually hit --- +withdrawal-manual +withdrawal-external +withdrawal-idempotent + +# --- Pay variants shops use --- +payment-template +paywall-flow +payment-idempotency +payment-zero +payment-abort +otp + +# --- After pay: coins / merchant ops --- +deposit +refund +refund-auto +wallet-refresh + +# --- Bank stack we run (libeufin / regional) --- +libeufin-bank +bank-api + +# --- Client hygiene --- +wallet-config +term-of-service-format diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh new file mode 100755 index 0000000..d405a2d --- /dev/null +++ b/scripts/taler-monitoring/lib.sh @@ -0,0 +1,536 @@ +# shellcheck shell=bash +# Shared helpers for taler-monitoring (laptop or koopa). + +# Default stack = GOA / hacktivism (overridden by TALER_DOMAIN / --domain) +: "${TALER_DOMAIN:=hacktivism.ch}" +: "${BANK_PUBLIC:=https://bank.hacktivism.ch}" +: "${EXCHANGE_PUBLIC:=https://exchange.hacktivism.ch}" +: "${MERCHANT_PUBLIC:=https://taler.hacktivism.ch}" +: "${BANK_LOCAL:=http://127.0.0.1:9012}" +: "${EXCHANGE_LOCAL:=http://127.0.0.1:9011}" +: "${MERCHANT_LOCAL:=https://127.0.0.1:9010}" +: "${LANDING_LOCAL:=http://127.0.0.1:9013}" +: "${KOOPA_SSH:=koopa}" +: "${MERCHANT_INSTANCE:=goa-demo-cp4zqk}" +: "${WITHDRAW_AMT:=GOA:20}" # single-shot fallback; e2e ladder uses ATM notes +: "${PAY_AMT:=GOA:0.01}" +: "${CREDIT_AMT:=GOA:400}" # covers ATM ladder 20+50+100+200 +: "${TIMEOUT:=12}" +: "${E2E_TIMEOUT:=55}" # whole e2e budget; skip rest when exceeded +: "${E2E_PAY_SECS:=22}" # dedicated seconds for pay handle-uri (avoid Alarm clock) +# Devtest: inject reserve credit via wire-gateway admin/add-incoming (optional). +# Default off once wirewatch DNS works; set E2E_FAKE_INCOMING=1 to force. +: "${E2E_FAKE_INCOMING:=0}" +# SSH must never hang the monitoring run +: "${SSH_CONNECT_TIMEOUT:=3}" +: "${SSH_CMD_TIMEOUT:=12}" # hard cap for whole remote script (seconds) +: "${SKIP_SSH:=0}" +# Expected currency for public /config checks (empty = report only, don't fail) +: "${EXPECT_CURRENCY:=GOA}" +# 1 = this is the local koopa/hacktivism stack (inside/e2e/SSH make sense) +: "${LOCAL_STACK:=1}" +# Probe merchant host candidates when applying a generic domain (0=off) +: "${TALER_DOMAIN_PROBE:=1}" + +BANK_PUBLIC=${BANK_PUBLIC%/} +EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} +MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} + +# --------------------------------------------------------------------------- +# Domain presets → public bank / exchange / merchant base URLs +# +# TALER_DOMAIN=hacktivism.ch (default, GOA, local stack) +# TALER_DOMAIN=taler.net → demo.taler.net (KUDOS) +# TALER_DOMAIN=demo.taler.net +# TALER_DOMAIN=taler-ops.ch → exchange.taler-ops.ch (CHF; bank/merchant if up) +# TALER_DOMAIN=example.org → bank/exchange/backend|taler|merchant.example.org +# +# Explicit BANK_PUBLIC / EXCHANGE_PUBLIC / MERCHANT_PUBLIC still win if set +# *after* apply_taler_domain, or pass full URLs via --bank/--exchange/--merchant. +# --------------------------------------------------------------------------- +_normalize_domain() { + local d="$1" + d="${d#https://}" + d="${d#http://}" + d="${d%%/*}" + d="${d%%:*}" + # Strip service host prefix only if a real domain remains (has a dot). + # e.g. bank.demo.taler.net → demo.taler.net, taler.hacktivism.ch → hacktivism.ch + # but NOT taler.net → net + case "$d" in + bank.*|exchange.*|taler.*|backend.*|merchant.*|shop.*|libeufin.*) + rest="${d#*.}" + if [[ "$rest" == *.* ]]; then + d="$rest" + fi + ;; + esac + printf '%s' "$d" +} + +_probe_https_config() { + # 0 if https://$1/config returns 200 + local host="$1" code + code=$(curl -skS --max-redirs 0 -m 4 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000) + [ "$code" = "200" ] +} + +apply_taler_domain() { + local raw="${1:-}" + local d + [ -n "$raw" ] || return 0 + d=$(_normalize_domain "$raw") + TALER_DOMAIN="$d" + + case "$d" in + # Local koopa stack only — SSH / inside / e2e allowed + koopa|hacktivism.ch|hacktivism) + BANK_PUBLIC="https://bank.hacktivism.ch" + EXCHANGE_PUBLIC="https://exchange.hacktivism.ch" + MERCHANT_PUBLIC="https://taler.hacktivism.ch" + EXPECT_CURRENCY="GOA" + LOCAL_STACK=1 + SKIP_SSH=0 + TALER_DOMAIN="hacktivism.ch" + : "${WITHDRAW_AMT:=GOA:20}" + : "${PAY_AMT:=GOA:0.01}" + : "${CREDIT_AMT:=GOA:400}" + ;; + taler.net|demo.taler.net) + # Official public demo (KUDOS) — public only, never SSH; tiny e2e amounts + BANK_PUBLIC="https://bank.demo.taler.net" + EXCHANGE_PUBLIC="https://exchange.demo.taler.net" + MERCHANT_PUBLIC="https://backend.demo.taler.net" + EXPECT_CURRENCY="KUDOS" + LOCAL_STACK=0 + SKIP_SSH=1 + MERCHANT_INSTANCE="${MERCHANT_INSTANCE:-sandbox}" + WITHDRAW_AMT="${WITHDRAW_AMT:-KUDOS:20}" + PAY_AMT="${PAY_AMT:-KUDOS:0.01}" + CREDIT_AMT="${CREDIT_AMT:-KUDOS:100}" + TALER_DOMAIN="demo.taler.net" + ;; + taler-ops.ch) + # Public CHF exchange; bank/merchant hosts vary — probe common names + EXCHANGE_PUBLIC="https://exchange.taler-ops.ch" + BANK_PUBLIC="https://bank.taler-ops.ch" + MERCHANT_PUBLIC="https://backend.taler-ops.ch" + EXPECT_CURRENCY="CHF" + LOCAL_STACK=0 + SKIP_SSH=1 + WITHDRAW_AMT="${WITHDRAW_AMT:-CHF:20}" + PAY_AMT="${PAY_AMT:-CHF:0.01}" + CREDIT_AMT="${CREDIT_AMT:-CHF:100}" + if [ "${TALER_DOMAIN_PROBE}" = "1" ]; then + local h + for h in bank.taler-ops.ch bank.demo.taler-ops.ch; do + _probe_https_config "$h" && { BANK_PUBLIC="https://$h"; break; } + done + for h in backend.taler-ops.ch merchant.taler-ops.ch taler.taler-ops.ch shop.taler-ops.ch; do + _probe_https_config "$h" && { MERCHANT_PUBLIC="https://$h"; break; } + done + fi + ;; + *) + # Any other domain — public HTTPS only, never SSH to koopa + BANK_PUBLIC="https://bank.${d}" + EXCHANGE_PUBLIC="https://exchange.${d}" + MERCHANT_PUBLIC="https://backend.${d}" + EXPECT_CURRENCY="${EXPECT_CURRENCY:-}" # unknown — don't hard-fail currency + LOCAL_STACK=0 + SKIP_SSH=1 + if [ "${TALER_DOMAIN_PROBE}" = "1" ]; then + local h + for h in "backend.${d}" "taler.${d}" "merchant.${d}" "shop.${d}"; do + _probe_https_config "$h" && { MERCHANT_PUBLIC="https://$h"; break; } + done + for h in "bank.${d}" "libeufin.${d}"; do + _probe_https_config "$h" && { BANK_PUBLIC="https://$h"; break; } + done + _probe_https_config "exchange.${d}" || true + fi + ;; + esac + + BANK_PUBLIC=${BANK_PUBLIC%/} + EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} + MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} + + # Hard rule: only the local koopa/hacktivism stack may use SSH + if [ "${LOCAL_STACK}" != "1" ]; then + SKIP_SSH=1 + fi +} + +# Apply TALER_DOMAIN from env once (CLI exports TALER_DOMAIN_APPLIED=1 after overrides). +if [ "${TALER_DOMAIN_APPLIED:-0}" != "1" ] \ + && [ -n "${TALER_DOMAIN:-}" ] && [ "${TALER_DOMAIN}" != "hacktivism.ch" ]; then + apply_taler_domain "$TALER_DOMAIN" + TALER_DOMAIN_APPLIED=1 +fi + +# Safe SSH: publickey only, short connect, overall alarm so we never block forever. +SSH_BASE_OPTS=( + -o BatchMode=yes + -o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" + -o ConnectionAttempts=1 + -o ServerAliveInterval=3 + -o ServerAliveCountMax=2 + -o StrictHostKeyChecking=accept-new + -o PreferredAuthentications=publickey + -o PasswordAuthentication=no + -o KbdInteractiveAuthentication=no + -o GSSAPIAuthentication=no + -o NumberOfPasswordPrompts=0 +) + +# Hard wall-clock timeout so ssh/curl never block the monitoring run forever. +with_timeout() { + local secs="$1"; shift + if command -v gtimeout >/dev/null 2>&1; then + gtimeout --kill-after=2 "$secs" "$@" + return $? + fi + if command -v timeout >/dev/null 2>&1; then + timeout -k 2 "$secs" "$@" 2>/dev/null || timeout --kill-after=2 "$secs" "$@" + return $? + fi + # Portable: perl alarm + process group kill + perl -e ' + use strict; use warnings; + my $secs = shift @ARGV; + my $pid = fork(); + die "fork: $!" unless defined $pid; + if ($pid == 0) { + setpgrp(0, 0); + exec @ARGV; + exit 127; + } + $SIG{ALRM} = sub { + kill "TERM", -$pid; + select(undef, undef, undef, 1.0); + kill "KILL", -$pid; + exit 124; + }; + alarm $secs; + waitpid($pid, 0); + my $code = $? >> 8; + alarm 0; + exit $code; + ' "$secs" "$@" +} + +# Probe: 0 if koopa SSH works quickly +koopa_ssh_ok() { + [ "${SKIP_SSH}" = "1" ] && return 1 + with_timeout $((SSH_CONNECT_TIMEOUT + 3)) \ + ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" 'echo ok' >/dev/null 2>&1 +} + +# Run remote bash -s with optional stdin script; hard-capped +# usage: koopa_ssh_bash [timeout_secs] <<'EOF' ... EOF +# or: koopa_ssh_run timeout_secs 'remote command' +koopa_ssh_run() { + local t="${1:-$SSH_CMD_TIMEOUT}" + shift + with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" "$@" +} + +koopa_ssh_bash() { + local t="${1:-$SSH_CMD_TIMEOUT}" + with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" 'bash -s' +} + +if [ "${NO_COLOR:-0}" = "1" ] || [ ! -t 1 ]; then + G= R= Y= C= N= B= +else + G=$'\e[32m'; R=$'\e[31m'; Y=$'\e[33m'; C=$'\e[36m'; B=$'\e[1m'; N=$'\e[0m' +fi + +PASS_N=0 +FAIL_N=0 +WARN_N=0 +INFO_N=0 +BLOCKERS=() # human-readable payment/withdraw blockers +ERRORS=() # all ERROR lines (component scope) + +# Test IDs by area: www-001, e2e-001, inside-001, … +# Usage: set_area www then each ok/fail/warn/info/blocker/err auto-numbers. +TEST_AREA="" +TEST_N=0 +# Last issued id (www-001); set by _take_tid — not via $(…) so TEST_N persists. +LAST_TID="" +set_area() { + TEST_AREA="$1" + TEST_N=0 + LAST_TID="" +} +# Assign next id into LAST_TID (must not run in a subshell). +_take_tid() { + LAST_TID="" + if [ -z "${TEST_AREA:-}" ]; then + return + fi + TEST_N=$((TEST_N + 1)) + LAST_TID=$(printf '%s-%03d' "$TEST_AREA" "$TEST_N") +} +_fmt_tid() { + # prefix "www-001 " or empty + if [ -n "${LAST_TID:-}" ]; then + printf '%s ' "$LAST_TID" + fi +} + +ok() { + local label="$1" + _take_tid + printf '%s[OK]%s %s%s\n' "$G" "$N" "$(_fmt_tid)" "$label" + PASS_N=$((PASS_N + 1)) +} +# component-scoped error: err bank "libeufin down" "detail" +err() { + local comp="$1" msg="$2" detail="${3:-}" + _take_tid + printf '%s[ERROR]%s %s%s: %s%s\n' "$R" "$N" "$(_fmt_tid)" "$comp" "$msg" "${detail:+ — $detail}" + FAIL_N=$((FAIL_N + 1)) + ERRORS+=("${LAST_TID:+$LAST_TID }[$comp] $msg${detail:+ — $detail}") +} +# legacy fail label ... +fail() { + local label="$1" detail="${2:-}" + _take_tid + printf '%s[ERROR]%s %s%s%s\n' "$R" "$N" "$(_fmt_tid)" "$label" "${detail:+ — $detail}" + FAIL_N=$((FAIL_N + 1)) + ERRORS+=("${LAST_TID:+$LAST_TID }$label${detail:+ — $detail}") +} +warn() { + local label="$1" detail="${2:-}" + _take_tid + printf '%s[WARN]%s %s%s%s\n' "$Y" "$N" "$(_fmt_tid)" "$label" "${detail:+ — $detail}" + WARN_N=$((WARN_N + 1)) +} +info() { + local label="$1" detail="${2:-}" + _take_tid + if [ -n "$detail" ]; then + printf '%s[INFO]%s %s%s — %s\n' "$C" "$N" "$(_fmt_tid)" "$label" "$detail" + else + printf '%s[INFO]%s %s%s\n' "$C" "$N" "$(_fmt_tid)" "$label" + fi + INFO_N=$((INFO_N + 1)) +} +blocker() { + # Payment/withdraw path cannot proceed because of this + local step="$1" msg="$2" + _take_tid + printf '%s[BLOCKER]%s %s%s: %s\n' "$R$B" "$N" "$(_fmt_tid)" "$step" "$msg" + BLOCKERS+=("${LAST_TID:+$LAST_TID }[$step] $msg") + FAIL_N=$((FAIL_N + 1)) + ERRORS+=("BLOCKER ${LAST_TID:+$LAST_TID }[$step] $msg") +} +section() { printf '\n%s== %s ==%s\n' "$B" "$*" "$N"; } + +summary() { + echo "" + if [ "${#BLOCKERS[@]}" -gt 0 ]; then + printf '%s--- BLOCKERS (fix/withdraw path) ---%s\n' "$R$B" "$N" + local b + for b in "${BLOCKERS[@]}"; do + printf '%s • %s%s\n' "$R" "$b" "$N" + done + fi + if [ "${#ERRORS[@]}" -gt 0 ] && [ "${#BLOCKERS[@]}" -lt "${#ERRORS[@]}" ]; then + printf '%s--- ERRORS ---%s\n' "$R" "$N" + local e + for e in "${ERRORS[@]}"; do + case "$e" in BLOCKER*) continue ;; esac + printf '%s • %s%s\n' "$R" "$e" "$N" + done + fi + printf 'totals: %s%d OK%s' "$G" "$PASS_N" "$N" + [ "$FAIL_N" -gt 0 ] && printf ', %s%d ERROR%s' "$R" "$FAIL_N" "$N" + [ "$WARN_N" -gt 0 ] && printf ', %s%d WARN%s' "$Y" "$WARN_N" "$N" + [ "$INFO_N" -gt 0 ] && printf ', %d INFO' "$INFO_N" + [ "${#BLOCKERS[@]}" -gt 0 ] && printf ', %s%d BLOCKER%s' "$R" "${#BLOCKERS[@]}" "$N" + printf '\n' + [ "$FAIL_N" -eq 0 ] +} + +http_code() { + local url="$1"; shift + curl -skS --max-redirs 0 -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000 +} + +http_body() { + local url="$1" out="$2"; shift 2 + curl -skS --max-redirs 0 -m "${TIMEOUT}" -o "$out" -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000 +} + +# --------------------------------------------------------------------------- +# Currency unit map checks (wallet codec: alt_unit_names must include "0") +# --------------------------------------------------------------------------- +# Returns 0 if JSON body at $1 has usable alt_unit_names. +# Supports: +# - exchange/bank: currency_specification.alt_unit_names +# - merchant: currencies..alt_unit_names for each code +# Optional $2 = required currency code for currency_specification.currency +json_has_alt_unit_names() { + local file="$1" want_cur="${2:-}" + python3 - "$file" "$want_cur" <<'PY' +import json, sys +path, want = sys.argv[1], sys.argv[2] +try: + d = json.load(open(path)) +except Exception as e: + print(f"json-error: {e}") + sys.exit(2) + +def check_au(au, label): + if not isinstance(au, dict) or not au: + print(f"{label}: missing/empty alt_unit_names") + return False + if "0" not in au or not str(au.get("0") or "").strip(): + print(f"{label}: alt_unit_names missing non-empty key \"0\" (have {sorted(au.keys())})") + return False + print(f"{label}: alt_unit_names ok (0={au.get('0')!r}, n={len(au)})") + return True + +ok = True +cs = d.get("currency_specification") +if isinstance(cs, dict): + if want and cs.get("currency") and cs.get("currency") != want: + print(f"currency_specification.currency={cs.get('currency')!r} want {want!r}") + ok = False + if not check_au(cs.get("alt_unit_names"), "currency_specification"): + ok = False +elif "currency_specification" in d: + print("currency_specification: not an object") + ok = False + +curs = d.get("currencies") +if isinstance(curs, dict) and curs: + for code, spec in curs.items(): + if not isinstance(spec, dict): + print(f"currencies.{code}: not an object") + ok = False + continue + if not check_au(spec.get("alt_unit_names"), f"currencies.{code}"): + ok = False + +if not isinstance(cs, dict) and not (isinstance(curs, dict) and curs): + # neither shape — fail + print("no currency_specification or currencies map") + ok = False + +sys.exit(0 if ok else 1) +PY +} + +# Check one exchange base URL's /config for alt_unit_names. +# $1=label $2=base_url $3=expected currency (optional) $4=strict(1) or soft(0) +check_exchange_alt_units() { + local label="$1" base="$2" want_cur="${3:-}" strict="${4:-1}" + local f code + base="${base%/}" + f=$(mktemp) + code=$(http_body "${base}/config" "$f") + if [ "$code" != "200" ]; then + rm -f "$f" + if [ "$strict" = "1" ]; then + fail "$label /config" "HTTP $code ($base)" + else + warn "$label /config" "HTTP $code ($base)" + fi + return + fi + local out ec + set +e + out=$(json_has_alt_unit_names "$f" "$want_cur" 2>&1) + ec=$? + set -e + rm -f "$f" + if [ "$ec" -eq 0 ]; then + ok "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" + else + if [ "$strict" = "1" ]; then + fail "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" + else + warn "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" + fi + fi +} + +# From merchant /config JSON file: walk exchanges[] and check each base_url/config. +# Local stack hosts (hacktivism.ch or $EXCHANGE_PUBLIC host) are strict; others soft. +check_merchant_listed_exchanges_alt_units() { + local mer_json="$1" + local list + list=$(python3 - "$mer_json" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +for e in d.get("exchanges") or []: + if not isinstance(e, dict): + continue + u = (e.get("base_url") or e.get("url") or "").rstrip("/") + c = e.get("currency") or "" + if u: + print(f"{c}\t{u}") +PY +) + if [ -z "$list" ]; then + fail "merchant exchanges[]" "empty — no exchanges to check for alt_unit_names" + return + fi + local line cur url strict host + while IFS=$'\t' read -r cur url; do + [ -n "$url" ] || continue + host="${url#https://}"; host="${host#http://}"; host="${host%%/*}" + strict=1 + case "$host" in + *hacktivism.ch) strict=1 ;; + *) + # foreign exchange (e.g. taler-ops) — soft unless it is our configured EXCHANGE_PUBLIC + if [ "$url" = "${EXCHANGE_PUBLIC}" ] || [ "$url" = "${EXCHANGE_PUBLIC}/" ]; then + strict=1 + else + strict=0 + fi + ;; + esac + check_exchange_alt_units "exchange ${cur:-?} ${host}" "$url" "$cur" "$strict" + done <<<"$list" +} + +SECRETS_ROOT="${SECRETS_ROOT:-}" +if [ -z "$SECRETS_ROOT" ]; then + for d in \ + "$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)/koopa-admin-secrets/koopa/host-root" \ + "/Users/newkamek/src/koopa/koopa-admin-secrets/koopa/host-root" \ + "$HOME/src/koopa/koopa-admin-secrets/koopa/host-root" + do + if [ -d "$d/taler-bank" ]; then SECRETS_ROOT=$d; break; fi + done +fi + +read_secret() { + local rel="$1" + if [ -n "${SECRETS_ROOT:-}" ] && [ -f "${SECRETS_ROOT}/${rel}" ]; then + tr -d '\n' <"${SECRETS_ROOT}/${rel}" + return 0 + fi + koopa_ssh_ok || return 1 + koopa_ssh_run 10 "tr -d '\\n' /dev/null || true" 2>/dev/null +} + +find_wallet_cli() { + if [ -n "${WALLET_CLI:-}" ] && [ -f "$WALLET_CLI" ]; then + echo "$WALLET_CLI"; return 0 + fi + for c in \ + /Users/newkamek/src/taler/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs \ + "$(command -v taler-wallet-cli 2>/dev/null || true)" + do + [ -n "$c" ] && [ -f "$c" ] && { echo "$c"; return 0; } + done + return 1 +} diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh new file mode 100755 index 0000000..321525b --- /dev/null +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# taler-monitoring — public URL / stack checks for a Taler domain +# +# ./taler-monitoring.sh # local GOA (urls + inside + e2e) +# ./taler-monitoring.sh -d taler.net urls # public demo, no SSH +# ./taler-monitoring.sh --domain taler-ops.ch # public ops, no SSH +# TALER_DOMAIN=demo.taler.net ./taler-monitoring.sh urls +# +# Tags: [OK] [INFO] [WARN] [ERROR] [BLOCKER] +# Exit 0 only if every selected phase exits 0. + +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) + +usage() { + cat <<'EOF' +taler-monitoring — bank / exchange / merchant checks + +Usage: + ./taler-monitoring.sh [options] [phases...] + +Phases: + urls public HTTPS (no SSH) ← default with --domain + inside container status via SSH (local stack only) + versions taler packages vs deb.taler.net trixie + repo availability + sanity public + optional server + server server-side only (SSH) + e2e withdraw + pay (small amounts; remote aborts on login/KYC) + all urls + inside + versions + sanity + e2e (SSH phases only on koopa) + +Options: + -d, --domain DOMAIN target domain + koopa / hacktivism.ch → local stack, SSH ok + everything else → no SSH; e2e optional (tiny amounts) + presets: koopa | hacktivism.ch | taler.net | taler-ops.ch + generic: bank/exchange/backend. + --bank URL override bank base (https://…) + --exchange URL override exchange base + --merchant URL override merchant base + --currency CODE expected currency (GOA, KUDOS, CHF, …); empty = report only + --no-probe do not probe alternate merchant/bank hostnames + -h, --help + +Examples: + ./taler-monitoring.sh -d taler.net + ./taler-monitoring.sh -d taler-ops.ch urls + ./taler-monitoring.sh -d demo.taler.net --currency KUDOS + ./taler-monitoring.sh --exchange https://exchange.taler-ops.ch urls + +Env (same meaning): + TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC EXPECT_CURRENCY + SKIP_SSH=1 NO_COLOR=1 +EOF +} + +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +PHASES=() +DOMAIN_SET=0 +BANK_OVERRIDE="" +EXCHANGE_OVERRIDE="" +MERCHANT_OVERRIDE="" +CURRENCY_OVERRIDE="" +NO_PROBE=0 + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + -d|--domain) + [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; } + apply_taler_domain "$2" + DOMAIN_SET=1 + shift 2 + ;; + --bank) + [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; } + BANK_OVERRIDE="${2%/}"; shift 2 + ;; + --exchange) + [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; } + EXCHANGE_OVERRIDE="${2%/}"; shift 2 + ;; + --merchant) + [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; } + MERCHANT_OVERRIDE="${2%/}"; shift 2 + ;; + --currency) + [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; } + CURRENCY_OVERRIDE="$2"; shift 2 + ;; + --no-probe) NO_PROBE=1; shift ;; + urls|inside|versions|sanity|server|e2e|all) PHASES+=("$1"); shift ;; + *) + # bare domain shorthand: ./taler-monitoring.sh taler.net + if [[ "$1" == *.* && "$1" != *://* && "$1" != -* ]]; then + apply_taler_domain "$1" + DOMAIN_SET=1 + shift + else + echo "Unknown: $1" >&2; usage >&2; exit 2 + fi + ;; + esac +done + +if [ "$NO_PROBE" = "1" ]; then + TALER_DOMAIN_PROBE=0 +fi +if [ -n "$CURRENCY_OVERRIDE" ]; then + EXPECT_CURRENCY="$CURRENCY_OVERRIDE" +fi +if [ -n "$BANK_OVERRIDE" ]; then BANK_PUBLIC="$BANK_OVERRIDE"; fi +if [ -n "$EXCHANGE_OVERRIDE" ]; then EXCHANGE_PUBLIC="$EXCHANGE_OVERRIDE"; fi +if [ -n "$MERCHANT_OVERRIDE" ]; then MERCHANT_PUBLIC="$MERCHANT_OVERRIDE"; fi + +# Only koopa may use SSH. Remote domains: public + optional e2e (no SSH). +if [ "${LOCAL_STACK:-1}" != "1" ]; then + SKIP_SSH=1 +fi + +# Remote: no SSH; ATM withdraw ladder set in check_e2e (smaller notes) +if [ "${LOCAL_STACK}" != "1" ]; then + E2E_FAKE_INCOMING=0 + E2E_REMOTE=1 + : "${E2E_WITHDRAW_VALUES:=10 20 50}" + : "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}" +fi + +# Export so check_*.sh (re-source lib) see the same targets via env +export TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC +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 TALER_DOMAIN_APPLIED=1 + +# Default phases +if [ "${#PHASES[@]}" -eq 0 ]; then + if [ "${LOCAL_STACK}" = "1" ]; then + PHASES=(urls inside versions e2e) # koopa: SSH ok + else + PHASES=(urls) # remote default: public only; pass "e2e" to try + fi +fi + +OUT_PHASES=() +for p in "${PHASES[@]}"; do + if [ "$p" = "all" ]; then + if [ "${LOCAL_STACK}" = "1" ]; then + OUT_PHASES+=(urls inside versions sanity e2e) + else + # remote: repo index check only (no installed pkgs without SSH) + OUT_PHASES+=(urls versions e2e) + fi + else + OUT_PHASES+=("$p") + fi +done +PHASES=() +seen=" " +for p in "${OUT_PHASES[@]}"; do + # Drop SSH-only phases for non-koopa (e2e is allowed — public path only) + # versions still runs: outside-in deb.taler.net checks; container parts soft-skip if no SSH + if [ "${LOCAL_STACK}" != "1" ] || [ "${SKIP_SSH}" = "1" ]; then + case "$p" in inside|server) + echo "[INFO] skip phase '$p' (only koopa uses SSH)" >&2 + continue + ;; + esac + fi + case "$seen" in *" $p "*) ;; *) PHASES+=("$p"); seen="$seen$p " ;; esac +done + +if [ "${#PHASES[@]}" -eq 0 ]; then + PHASES=(urls) +fi + +printf 'target domain=%s\n' "${TALER_DOMAIN}" +printf ' bank %s\n' "$BANK_PUBLIC" +printf ' exchange %s\n' "$EXCHANGE_PUBLIC" +printf ' merchant %s\n' "$MERCHANT_PUBLIC" +printf ' currency expect=%s skip_ssh=%s\n' "${EXPECT_CURRENCY:-any}" "$SKIP_SSH" + +chmod +x "$ROOT"/check_*.sh 2>/dev/null || true + +ec=0 +for p in "${PHASES[@]}"; do + case "$p" in + urls) "$ROOT/check_urls.sh" || ec=1 ;; + inside) "$ROOT/check_inside.sh" || ec=1 ;; + versions) "$ROOT/check_versions.sh" || ec=1 ;; + sanity) "$ROOT/check_sanity.sh" || ec=1 ;; + server) "$ROOT/check_server.sh" || ec=1 ;; + e2e) "$ROOT/check_e2e.sh" || ec=1 ;; + esac +done +exit "$ec" diff --git a/scripts/taler-sanity/README.md b/scripts/taler-sanity/README.md new file mode 100644 index 0000000..ad6a566 --- /dev/null +++ b/scripts/taler-sanity/README.md @@ -0,0 +1,31 @@ +# Taler sanity checks (koopa) + +Run **on koopa as root** (reads `/root/*-password.txt`, may use `podman` as `hernani`). + +| Script | What it checks | +|--------|----------------| +| `check_helpers-running.sh` | **No systemd:** starts missing exchange/merchant helpers (`ensure_*`), then requires transfer/aggregator/wirewatch/… | +| `check_stack-health.sh` | Public + local `/config` and exchange `/keys` HTTP 200; merchant has CHF+GOA | +| `check_merchant-delays.sh` | Global + instance delays are short (pay/refund/wire ≤ 120s) | +| `check_exchange-wirewatch.sh` | Wire gateway reachable with **bearer**; auth method; hosts pin | +| `check_settlement.sh` | Paid orders: `wired`, deposit_total, bank balances/transfers | +| `run_all.sh` | Runs all of the above | + +Helper ensure scripts (in containers, no systemd): + +| Script | Role | +|--------|------| +| `../taler-exchange/ensure_exchange_helpers.sh` | `nohup` aggregator, closer, wirewatch, **transfer** | +| `../taler-merchant/ensure_merchant_helpers.sh` | `nohup` wirewatch, depositcheck, kyccheck, webhook, … | +| Health: `check_*-health.sh` | Calls ensure (unless `SKIP_ENSURE=1`), then **FAIL** if still missing | + +```bash +# on koopa +cd /path/to/koopa-admin-log/scripts/taler-sanity +./run_all.sh +./check_settlement.sh 2026.190-03V36SPAZ8CK6 +``` + +Env overrides: `BANK_URL`, `MERCHANT_URL`, `EXCHANGE_PUBLIC`, `BANK_PUBLIC`, `MERCHANT_PUBLIC`, `MERCHANT_INSTANCE`. + +See also: `../taler-wallet-cli/` for end-to-end withdraw → pay → settlement. diff --git a/scripts/taler-sanity/check_exchange-wirewatch.sh b/scripts/taler-sanity/check_exchange-wirewatch.sh new file mode 100644 index 0000000..b92da39 --- /dev/null +++ b/scripts/taler-sanity/check_exchange-wirewatch.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Sanity: exchange can read bank wire gateway (bearer + public URL). +# Run on koopa as root (needs /root/bank-exchange-password.txt + podman). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" +need_root + +echo "=== Exchange wirewatch / wire gateway ===" + +if ! read_pw EPW /root/bank-exchange-password.txt; then + fail "missing /root/bank-exchange-password.txt" + exit 1 +fi + +ET=$(bank_token exchange "$EPW") +if [ -z "$ET" ]; then + fail "could not get bank token for exchange user" + exit 1 +fi +pass "bank token for exchange user" + +# History must work with Bearer (Basic is rejected by libeufin on history) +tmp=$(mktemp) +code=$(curl -sk -m 15 -o "$tmp" -w '%{http_code}' \ + -H "Authorization: Bearer ${ET}" \ + "${BANK_PUBLIC}/accounts/exchange/taler-wire-gateway/history/incoming?delta=-10") +if [ "$code" = "200" ]; then + pass "wire gateway history via ${BANK_PUBLIC} (HTTP $code)" + python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(" incoming_count", len(d.get("incoming_transactions") or []))' "$tmp" +else + # fallback local + code2=$(curl -sS -m 15 -o "$tmp" -w '%{http_code}' \ + -H "Authorization: Bearer ${ET}" \ + "${BANK_URL}/accounts/exchange/taler-wire-gateway/history/incoming?delta=-10") + if [ "$code2" = "200" ]; then + warn "public wire gateway HTTP $code; local BANK_URL OK ($code2) — check DNS/hosts in exchange container" + else + fail "wire gateway history public=$code local=$code2" + fi +fi +rm -f "$tmp" + +# Basic must fail on history (documents expected auth mode) +code_b=$(curl -sk -m 10 -o /dev/null -w '%{http_code}' \ + -u "exchange:${EPW}" \ + "${BANK_URL}/accounts/exchange/taler-wire-gateway/history/incoming?delta=-5") +if [ "$code_b" = "401" ]; then + pass "Basic auth correctly rejected on history ($code_b) — use bearer TOKEN" +else + warn "Basic on history returned $code_b (expected 401)" +fi + +# Container credential + wirewatch process +if su - hernani -c 'podman exec taler-hacktivism-exchange-ansible true' 2>/dev/null; then + su - hernani -c 'podman exec taler-hacktivism-exchange-ansible bash -c " + set +e + echo \"--- secret (redacted) ---\" + if [ -r /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf ]; then + sed \"s/secret-token:.*/secret-token:***/; s/^TOKEN = .*/TOKEN = ***/; s/^PASSWORD = .*/PASSWORD = ***/\" \ + /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf + else + # root-only file: cat as root via outer exec + true + fi + echo \"--- hosts bank ---\" + grep bank.hacktivism.ch /etc/hosts || echo \"(no hosts pin)\" + echo \"--- wirewatch ---\" + ps -eo pid,stat,args | grep \"[w]irewatch\" || echo \"(no wirewatch process)\" + "' 2>&1 || warn "podman exec exchange failed" + + AUTH_METHOD=$(su - hernani -c 'podman exec -u root taler-hacktivism-exchange-ansible \ + grep -E "^WIRE_GATEWAY_AUTH_METHOD" /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf' 2>/dev/null | awk '{print $3}') + GW_URL=$(su - hernani -c 'podman exec -u root taler-hacktivism-exchange-ansible \ + grep -E "^WIRE_GATEWAY_URL" /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf' 2>/dev/null | awk '{print $3}') + info "WIRE_GATEWAY_URL=$GW_URL" + info "WIRE_GATEWAY_AUTH_METHOD=$AUTH_METHOD" + if [ "$AUTH_METHOD" = "bearer" ]; then pass "auth method bearer" + else fail "auth method is '$AUTH_METHOD' (want bearer + TOKEN=)" + fi + case "$GW_URL" in + https://bank.hacktivism.ch/*) pass "gateway uses public bank URL" ;; + http://127.*|http://host.containers*) warn "gateway uses local URL: $GW_URL" ;; + *) warn "unexpected gateway URL: $GW_URL" ;; + esac +else + warn "exchange container not reachable via podman" +fi + +echo "=== summary fails=$FAILS ===" +exit "$FAILS" diff --git a/scripts/taler-sanity/check_helpers-running.sh b/scripts/taler-sanity/check_helpers-running.sh new file mode 100755 index 0000000..dd28666 --- /dev/null +++ b/scripts/taler-sanity/check_helpers-running.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Sanity: ensure exchange + merchant helpers are up (no systemd). +# Runs on koopa as root; uses podman into both containers. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" +need_root + +fail=0 +pass() { echo "OK $1"; } +warn() { echo "WARN $1"; } +bad() { echo "FAIL $1"; fail=$((fail + 1)); } + +EXC_CT="${EXCHANGE_CONTAINER:-taler-hacktivism-exchange-ansible}" +MER_CT="${MERCHANT_CONTAINER:-taler-hacktivism}" + +echo "=== ensure + check helpers (no systemd) ===" + +# Deploy/ensure scripts if present on host under admin-log mount or /tmp +deploy_into() { + local ct="$1" + local src="$2" + local dst="$3" + if [ -f "$src" ]; then + su - hernani -c "podman cp $(printf %q "$src") ${ct}:${dst}" 2>/dev/null \ + || podman cp "$src" "${ct}:${dst}" 2>/dev/null || true + su - hernani -c "podman exec ${ct} chmod 755 ${dst}" 2>/dev/null || true + fi +} + +ADMIN="${KOOPA_ADMIN_LOG:-/home/hernani/koopa-admin-log}" +# Prefer workspace copy if synced; else scripts next to this file +EX_SCRIPTS="$ROOT/../taler-exchange" +MER_SCRIPTS="$ROOT/../taler-merchant" +[ -d "$ADMIN/scripts/taler-exchange" ] && EX_SCRIPTS="$ADMIN/scripts/taler-exchange" +[ -d "$ADMIN/scripts/taler-merchant" ] && MER_SCRIPTS="$ADMIN/scripts/taler-merchant" + +deploy_into "$EXC_CT" "$EX_SCRIPTS/ensure_exchange_helpers.sh" /usr/local/bin/ensure_exchange_helpers.sh +deploy_into "$EXC_CT" "$EX_SCRIPTS/check_exchange-health.sh" /usr/local/bin/check_exchange-health.sh +deploy_into "$MER_CT" "$MER_SCRIPTS/ensure_merchant_helpers.sh" /usr/local/bin/ensure_merchant_helpers.sh +deploy_into "$MER_CT" "$MER_SCRIPTS/check_merchant-health.sh" /usr/local/bin/check_merchant-health.sh + +echo "--- exchange container: ensure ---" +if su - hernani -c "podman exec $EXC_CT /usr/local/bin/ensure_exchange_helpers.sh"; then + pass "exchange helpers ensure" +else + bad "exchange helpers ensure failed" +fi + +echo "--- merchant container: ensure ---" +if su - hernani -c "podman exec $MER_CT /usr/local/bin/ensure_merchant_helpers.sh"; then + pass "merchant helpers ensure" +else + bad "merchant helpers ensure failed" +fi + +echo "--- process presence ---" +check_ct_proc() { + local ct="$1" + local p="$2" + # COMM is 15 chars — match full cmdline + if su - hernani -c "podman exec $ct pgrep -f '(^|/)${p}( |$)'" >/dev/null 2>&1; then + pass "$ct: $p" + else + bad "$ct: $p missing" + fi +} + +for p in taler-exchange-httpd taler-exchange-aggregator taler-exchange-transfer \ + taler-exchange-wirewatch taler-exchange-closer; do + check_ct_proc "$EXC_CT" "$p" +done +for p in taler-merchant-httpd taler-merchant-wirewatch taler-merchant-depositcheck \ + taler-merchant-webhook taler-merchant-kyccheck \ + taler-merchant-exchangekeyupdate taler-merchant-reconciliation; do + check_ct_proc "$MER_CT" "$p" +done + +echo "=== summary fails=$fail ===" +exit "$fail" diff --git a/scripts/taler-sanity/check_merchant-delays.sh b/scripts/taler-sanity/check_merchant-delays.sh new file mode 100644 index 0000000..10b47ef --- /dev/null +++ b/scripts/taler-sanity/check_merchant-delays.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Sanity: merchant global + demo instance delays are demo-short (pay/refund/wire). +# Run on koopa (network to :9010 / public merchant). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +# Expected upper bounds (seconds) — fail if longer (package defaults 1d/15d/1w) +MAX_PAY_S=${MAX_PAY_S:-120} +MAX_REFUND_S=${MAX_REFUND_S:-120} +MAX_WIRE_S=${MAX_WIRE_S:-120} + +echo "=== Merchant delay defaults (expect short demo values) ===" + +tmp=$(mktemp) +curl -sk -m 12 -o "$tmp" "${MERCHANT_PUBLIC}/config" || curl -sk -m 12 -o "$tmp" "${MERCHANT_URL}/config" +python3 - "$tmp" "$MAX_PAY_S" "$MAX_REFUND_S" "$MAX_WIRE_S" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +max_pay, max_ref, max_wire = map(float, sys.argv[2:5]) +fails=0 +for key, mx in ( + ("default_pay_delay", max_pay), + ("default_refund_delay", max_ref), + ("default_wire_transfer_delay", max_wire), +): + us=d.get(key,{}).get("d_us") + s=(us or 0)/1e6 + ok = us is not None and s <= mx + print(f"{'OK' if ok else 'FAIL'} {key}: {s:.0f}s (max {mx:.0f}s)") + if not ok: fails+=1 +print("currency", d.get("currency")) +sys.exit(fails) +PY +ec=$? +rm -f "$tmp" +FAILS=$((FAILS + ec)) + +# Instance goa-demo if password present +if [ -f /root/merchant-goa-demo-cp4zqk-password.txt ]; then + MPW=$(tr -d '\n' = wd and not d.get("wired"): + print(" FAIL wire deadline passed but wired=false") + sys.exit(2) + if now < wd: + print(" INFO still before wire deadline — settlement not due yet") + if d.get("wired"): + print(" OK wired=true") +sys.exit(0) +PY + ec=$? + [ "$ec" -eq 2 ] && FAILS=$((FAILS + 1)) + rm -f "$ot" +done + +echo "=== private/transfers ===" +tr=$(mktemp) +curl -sk -m 15 -H "$AUTH" -o "$tr" \ + "${MERCHANT_URL}/instances/${INST}/private/transfers" +python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); t=d.get("transfers") or []; print(f" transfers={len(t)}"); +[print(" ", x) for x in t[:10]]' "$tr" +rm -f "$tr" "$tmp" + +# Bank balances +BANK_USER="${BANK_USER:-$INST}" +if read_pw BPW "/root/bank-${BANK_USER}-password.txt" || read_pw BPW /root/bank-goa-demo-cp4zqk-password.txt; then + BT=$(bank_token "$BANK_USER" "$BPW") + if [ -n "$BT" ]; then + curl -sS -m 12 -H "Authorization: Bearer ${BT}" \ + "${BANK_URL}/accounts/${BANK_USER}" | python3 -c 'import sys,json; d=json.load(sys.stdin); print("merchant_bank_balance", d.get("balance"))' + curl -sS -m 12 -H "Authorization: Bearer ${BT}" \ + "${BANK_URL}/accounts/${BANK_USER}/transactions?delta=-10" \ + | python3 -c 'import sys,json; d=json.load(sys.stdin); txs=d.get("transactions") or []; print(f"merchant_bank_tx={len(txs)}"); +[print(f" {t.get(\"direction\")} {t.get(\"amount\")} {t.get(\"subject\",\"\")[:60]}") for t in txs[:8]]' + fi +fi + +if read_pw EPW /root/bank-exchange-password.txt; then + ET=$(bank_token exchange "$EPW") + if [ -n "$ET" ]; then + curl -sS -m 12 -H "Authorization: Bearer ${ET}" \ + "${BANK_URL}/accounts/exchange" | python3 -c 'import sys,json; d=json.load(sys.stdin); print("exchange_bank_balance", d.get("balance"))' + fi +fi + +echo "=== summary fails=$FAILS ===" +exit "$FAILS" diff --git a/scripts/taler-sanity/check_stack-health.sh b/scripts/taler-sanity/check_stack-health.sh new file mode 100644 index 0000000..1d39fe0 --- /dev/null +++ b/scripts/taler-sanity/check_stack-health.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Sanity: public + local Taler endpoints respond (GOA stack). +# Run on koopa as root (or any user with network to services). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +echo "=== Taler stack health ===" + +# Public HTTPS (via Caddy / VeciGate) +for url in \ + "${EXCHANGE_PUBLIC}/config" \ + "${EXCHANGE_PUBLIC}/keys" \ + "${BANK_PUBLIC}/config" \ + "${MERCHANT_PUBLIC}/config" +do + code=$(http_code "$url") + if [ "$code" = "200" ]; then pass "$url -> $code" + else fail "$url -> $code" + fi +done + +# Local loopback ports (containers via pasta) +for spec in \ + "bank ${BANK_URL}/config" \ + "merchant ${MERCHANT_URL}/config" \ + "exchange-via-public ${EXCHANGE_PUBLIC}/config" +do + name=${spec%% *} + url=${spec#* } + code=$(http_code "$url") + if [ "$code" = "200" ]; then pass "local $name -> $code" + else fail "local $name -> $code" + fi +done + +# Merchant multi-currency +tmp=$(mktemp) +curl -sk -m 12 -o "$tmp" "${MERCHANT_PUBLIC}/config" || true +if python3 - "$tmp" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +ex=[e.get("currency") for e in d.get("exchanges") or []] +cur=list((d.get("currencies") or {}).keys()) +ok = "GOA" in ex and "CHF" in ex and "GOA" in cur +print("currency_default", d.get("currency")) +print("exchanges", ex) +print("currencies", cur) +sys.exit(0 if ok else 1) +PY +then pass "merchant has CHF + GOA exchanges" +else fail "merchant missing CHF/GOA in /config" +fi +rm -f "$tmp" + +# Exchange currency GOA +tmp=$(mktemp) +curl -sk -m 12 -o "$tmp" "${EXCHANGE_PUBLIC}/config" || true +if python3 - "$tmp" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +c=(d.get("currency") or d.get("currency_specification",{}).get("currency")) +print("exchange_currency", c) +sys.exit(0 if c=="GOA" else 1) +PY +then pass "exchange currency GOA" +else fail "exchange currency not GOA" +fi +rm -f "$tmp" + +echo "=== summary fails=$FAILS ===" +exit "$FAILS" diff --git a/scripts/taler-sanity/lib.sh b/scripts/taler-sanity/lib.sh new file mode 100644 index 0000000..851fa9d --- /dev/null +++ b/scripts/taler-sanity/lib.sh @@ -0,0 +1,60 @@ +# shellcheck shell=bash +# Common helpers for Taler sanity checks (run on koopa host as root). +# shellcheck disable=SC2034 + +: "${BANK_URL:=http://127.0.0.1:9012}" +: "${MERCHANT_URL:=https://127.0.0.1:9010}" +: "${EXCHANGE_PUBLIC:=https://exchange.hacktivism.ch}" +: "${BANK_PUBLIC:=https://bank.hacktivism.ch}" +: "${MERCHANT_PUBLIC:=https://taler.hacktivism.ch}" + +pass() { echo "OK $*"; } +fail() { echo "FAIL $*"; FAILS=$((FAILS + 1)); } +warn() { echo "WARN $*"; } +info() { echo "INFO $*"; } + +FAILS=0 + +need_root() { + if [ "$(id -u)" -ne 0 ]; then + echo "This check expects root on koopa (password files under /root)." >&2 + exit 2 + fi +} + +read_pw() { + # read_pw VAR path + local _v="$1" _p="$2" + if [ ! -f "$_p" ]; then + eval "$_v=" + return 1 + fi + eval "$_v=\$(tr -d '\\n' <\"$_p\")" +} + +bank_token() { + # bank_token USER PASS -> prints access_token + local user="$1" pass="$2" + curl -sS -m 15 -u "${user}:${pass}" \ + -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite","refreshable":true}' \ + "${BANK_URL}/accounts/${user}/token" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token",""))' +} + +merchant_auth_header() { + # merchant_auth_header PASSWORD + printf 'Authorization: Bearer secret-token:%s' "$1" +} + +http_code() { + # http_code URL [curl args...] + local url="$1" + shift + curl -sk -m 12 -o /dev/null -w '%{http_code}' "$@" "$url" 2>/dev/null || echo "000" +} + +json_get() { + # json_get FILE python-expr-on-d + python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print($2)" "$1" +} diff --git a/scripts/taler-sanity/run_all.sh b/scripts/taler-sanity/run_all.sh new file mode 100644 index 0000000..e33f68d --- /dev/null +++ b/scripts/taler-sanity/run_all.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Run all sanity checks; exit non-zero if any failed. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +ec=0 +for s in \ + check_helpers-running.sh \ + check_stack-health.sh \ + check_merchant-delays.sh \ + check_exchange-wirewatch.sh \ + check_settlement.sh +do + echo "" + echo "########## $s ##########" + if bash "$ROOT/$s"; then + echo "PASS $s" + else + echo "FAIL $s (exit $?)" + ec=1 + fi +done +exit $ec diff --git a/scripts/taler-shared/install_privacy_docs.sh b/scripts/taler-shared/install_privacy_docs.sh new file mode 100644 index 0000000..9d4fb48 --- /dev/null +++ b/scripts/taler-shared/install_privacy_docs.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Write privacy policy files (txt/md/html) into a TERMS/PRIVACY dir. +# Used by exchange + merchant installers. Style matches dual-currency terms. +# +# Usage (sourced or): install_privacy_docs DIR ETAG TITLE BADGE +# Environment: BODY_MD, BODY_TXT, BODY_HTML_MAIN (inner body HTML after h1) +set -euo pipefail + +install_privacy_docs() { + local dir="$1" etag="$2" title="$3" badge="$4" + local lang="${5:-en}" + local out="$dir/$lang" + mkdir -p "$out" + + printf '%s\n' "${BODY_TXT:?}" >"$out/${etag}.txt" + printf '%s\n' "${BODY_MD:?}" >"$out/${etag}.md" + + cat >"$out/${etag}.html" < + + + + + ${title} + + + +
${badge}
+

${title}

+${BODY_HTML_MAIN} +
Version ${etag} · Swiss FADP (revDSG) · hacktivism.ch
+ + +HTML + chmod -R a+rX "$dir" + echo "privacy docs: $out/${etag}.{txt,md,html}" +} diff --git a/scripts/taler-shared/mem-snapshot.sh b/scripts/taler-shared/mem-snapshot.sh new file mode 100644 index 0000000..b011202 --- /dev/null +++ b/scripts/taler-shared/mem-snapshot.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# Memory snapshot for landing-stats (source after json_str is defined). +# Sets MEM_JSON (fields to embed inside performance.memory). +# IMPORTANT: no pipelines around the /proc loop (bash subshell loses counters). + +mem_fmt_bytes() { + awk -v b="${1:-0}" 'BEGIN{ + b = b + 0 + if (b < 1024) printf "%d B", b + else if (b < 1048576) printf "%.1f KiB", b / 1024 + else if (b < 1073741824) printf "%.1f MiB", b / 1048576 + else printf "%.2f GiB", b / 1073741824 + }' +} + +mem_snapshot_json() { + local rss_kb rss_b pid comm cmd short + local sum_b=0 cgroup_b="" limit_b="" + local postgres_b=0 taler_b=0 nginx_b=0 java_b=0 redis_b=0 other_b=0 + local n_pg=0 n_taler=0 n_nginx=0 n_java=0 n_redis=0 n_other=0 + local allf topf top_json top_n=0 lim_json cg_json hum cjs sjs hjs + + if [ -r /sys/fs/cgroup/memory.current ]; then + cgroup_b=$(tr -d ' \n' /dev/null || true) + if [ -r /sys/fs/cgroup/memory.max ]; then + limit_b=$(tr -d ' \n' /dev/null || true) + [ "$limit_b" = "max" ] && limit_b="" + fi + elif [ -r /sys/fs/cgroup/memory/memory.usage_in_bytes ]; then + cgroup_b=$(tr -d ' \n' /dev/null || true) + fi + + allf=$(mktemp) + topf=$(mktemp) + + for st in /proc/[0-9]*/status; do + [ -f "$st" ] || continue + pid=${st#/proc/} + pid=${pid%/status} + rss_kb=$(awk '/^VmRSS:/{print $2; exit}' "$st" 2>/dev/null || true) + [[ "$rss_kb" =~ ^[0-9]+$ ]] || continue + rss_b=$((rss_kb * 1024)) + sum_b=$((sum_b + rss_b)) + comm=$(awk '/^Name:/{print $2; exit}' "$st" 2>/dev/null || echo "?") + cmd="" + if [ -r "/proc/$pid/cmdline" ]; then + cmd=$(tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | sed 's/[[:space:]]*$//') + fi + [ -n "$cmd" ] || cmd=$comm + short=$(printf '%s' "$cmd" | cut -c1-100) + + case "$comm $cmd" in + *postgres*|*postmaster*) + postgres_b=$((postgres_b + rss_b)); n_pg=$((n_pg + 1)) ;; + *taler-exchange*|*taler-merchant*|*taler-auditor*|*taler-helper*|*taler_*) + taler_b=$((taler_b + rss_b)); n_taler=$((n_taler + 1)) ;; + *nginx*) + nginx_b=$((nginx_b + rss_b)); n_nginx=$((n_nginx + 1)) ;; + *java*|*libeufin*|*MainKt*) + java_b=$((java_b + rss_b)); n_java=$((n_java + 1)) ;; + *redis-server*|*redis*) + redis_b=$((redis_b + rss_b)); n_redis=$((n_redis + 1)) ;; + *) + other_b=$((other_b + rss_b)); n_other=$((n_other + 1)) ;; + esac + printf '%s\t%s\t%s\n' "$rss_b" "$comm" "$short" >>"$allf" + done + + sort -t$'\t' -nr -k1,1 "$allf" 2>/dev/null | head -10 >"$topf" + rm -f "$allf" + + local total_b=$sum_b + if [[ "$cgroup_b" =~ ^[0-9]+$ ]] && [ "$cgroup_b" -gt 0 ]; then + total_b=$cgroup_b + fi + + top_json="[" + top_n=0 + while IFS=$'\t' read -r rss_b comm short; do + [ -z "${rss_b:-}" ] && continue + [ "$top_n" -gt 0 ] && top_json="${top_json}," + top_n=$((top_n + 1)) + hum=$(mem_fmt_bytes "$rss_b") + cjs=$(json_str "$comm") + sjs=$(json_str "$short") + hjs=$(json_str "$hum") + top_json="${top_json} + {\"rss_bytes\": ${rss_b}, \"rss_human\": ${hjs}, \"comm\": ${cjs}, \"cmd\": ${sjs}}" + done <"$topf" + top_json="${top_json} + ]" + rm -f "$topf" + + lim_json="null" + if [[ "$limit_b" =~ ^[0-9]+$ ]] && [ "$limit_b" -gt 0 ]; then + lim_json=$limit_b + fi + cg_json="null" + if [[ "$cgroup_b" =~ ^[0-9]+$ ]]; then + cg_json=$cgroup_b + fi + + MEM_JSON=" + \"container_rss_bytes\": ${total_b}, + \"container_rss_human\": $(json_str "$(mem_fmt_bytes "$total_b")"), + \"proc_sum_rss_bytes\": ${sum_b}, + \"proc_sum_rss_human\": $(json_str "$(mem_fmt_bytes "$sum_b")"), + \"cgroup_bytes\": ${cg_json}, + \"cgroup_limit_bytes\": ${lim_json}, + \"postgres_rss_bytes\": ${postgres_b}, + \"postgres_rss_human\": $(json_str "$(mem_fmt_bytes "$postgres_b")"), + \"postgres_n\": ${n_pg}, + \"taler_rss_bytes\": ${taler_b}, + \"taler_rss_human\": $(json_str "$(mem_fmt_bytes "$taler_b")"), + \"taler_n\": ${n_taler}, + \"nginx_rss_bytes\": ${nginx_b}, + \"nginx_rss_human\": $(json_str "$(mem_fmt_bytes "$nginx_b")"), + \"nginx_n\": ${n_nginx}, + \"java_rss_bytes\": ${java_b}, + \"java_rss_human\": $(json_str "$(mem_fmt_bytes "$java_b")"), + \"java_n\": ${n_java}, + \"redis_rss_bytes\": ${redis_b}, + \"redis_rss_human\": $(json_str "$(mem_fmt_bytes "$redis_b")"), + \"redis_n\": ${n_redis}, + \"other_rss_bytes\": ${other_b}, + \"other_rss_human\": $(json_str "$(mem_fmt_bytes "$other_b")"), + \"other_n\": ${n_other}, + \"top\": ${top_json} +" +} diff --git a/scripts/taler-shared/terms-style.css.fragment b/scripts/taler-shared/terms-style.css.fragment new file mode 100644 index 0000000..ead665c --- /dev/null +++ b/scripts/taler-shared/terms-style.css.fragment @@ -0,0 +1,37 @@ +/* Shared dark palette for hacktivism.ch Taler terms & privacy pages */ +:root { color-scheme: dark light; } +body { + font-family: system-ui, -apple-system, sans-serif; + max-width: 42rem; margin: 2rem auto; padding: 0 1.1rem 3rem; + line-height: 1.5; color: #e8e6e3; background: #1a1520; +} +h1 { font-size: 1.35rem; font-weight: 800; margin: 0 0 1rem; color: #f5f0ea; } +h2 { font-size: 1.05rem; margin: 1.5rem 0 0.5rem; color: #e8c878; } +h3 { font-size: 0.95rem; margin: 1rem 0 0.4rem; color: #c4b5fd; } +p, li, td, th { font-size: 0.95rem; } +ul, ol { padding-left: 1.2rem; } +code, a { color: #5eead4; } +a { text-decoration: none; } +a:hover { text-decoration: underline; } +.badge { + display: inline-block; font-size: 0.72rem; font-weight: 700; + letter-spacing: 0.06em; text-transform: uppercase; + color: #c4b5fd; border: 1px solid rgba(196,181,253,0.35); + border-radius: 999px; padding: 0.2rem 0.65rem; margin-bottom: 0.85rem; +} +.note { + border-radius: 12px; padding: 0.75rem 0.9rem; margin: 0.85rem 0 1rem; + border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); + font-size: 0.9rem; color: #c8c4bf; +} +table { + width: 100%; border-collapse: collapse; margin: 0.6rem 0 1rem; + font-size: 0.88rem; +} +th, td { + border: 1px solid rgba(255,255,255,0.12); padding: 0.45rem 0.55rem; + text-align: left; vertical-align: top; +} +th { background: rgba(0,0,0,0.35); color: #e8c878; font-weight: 700; } +.muted { color: #a39e98; font-size: 0.88rem; } +footer { margin-top: 2rem; font-size: 0.85rem; color: #a39e98; } diff --git a/scripts/taler-wallet-cli/goa-deb-withdraw-pay.sh b/scripts/taler-wallet-cli/goa-deb-withdraw-pay.sh new file mode 100755 index 0000000..7eb8e30 --- /dev/null +++ b/scripts/taler-wallet-cli/goa-deb-withdraw-pay.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Debian/Ubuntu · same as bank landing deb pane (install + run) +set -eu + +# install (deb.taler.net · trixie): keyring, apt source, packages +if ! command -v taler-wallet-cli >/dev/null 2>&1; then + sudo mkdir -p /etc/apt/keyrings && \ + sudo wget -q -O /etc/apt/keyrings/taler-systems.gpg https://taler.net/taler-systems.gpg && \ + echo 'deb [signed-by=/etc/apt/keyrings/taler-systems.gpg] https://deb.taler.net/apt/debian trixie main' | sudo tee /etc/apt/sources.list.d/taler.list && \ + sudo apt-get update && sudo apt-get install -y taler-wallet-cli wget +fi +command -v wget >/dev/null 2>&1 || sudo apt-get install -y wget + +# private wallet DB under /tmp (not the default home DB) +tw() { + taler-wallet-cli \ + --wallet-db="${TMPDIR:-/tmp}/taler-wallet-goa.sqlite3" \ + --no-throttle "$@" +} + +# mint from bank, register exchange, withdraw GOA:10 +wget -q -O demo-withdraw.json https://bank.hacktivism.ch/intro/demo-withdraw.json && \ +tw exchanges add https://exchange.hacktivism.ch/ && \ +tw exchanges accept-tos https://exchange.hacktivism.ch/ && \ +tw withdraw accept-uri --exchange https://exchange.hacktivism.ch/ \ + "$(grep -o 'taler://[^"]*' demo-withdraw.json)" && \ +tw run-until-done && \ + +# pay orbit-sticker (GOA:2); balance ends at GOA:8 +tw handle-uri --yes \ + taler://pay-template/taler.hacktivism.ch/instances/goa-shop/orbit-sticker && \ +tw run-until-done && tw balance diff --git a/scripts/taler-wallet-cli/goa-posix-withdraw-pay.sh b/scripts/taler-wallet-cli/goa-posix-withdraw-pay.sh new file mode 100755 index 0000000..1de3ab7 --- /dev/null +++ b/scripts/taler-wallet-cli/goa-posix-withdraw-pay.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Unix/macOS · monorepo CLI (same flow as bank landing unix pane) +# +# install (once): +# git + node (LTS) + monorepo build deps +# git clone https://git.taler.net/taler-typescript-core.git +# cd taler-typescript-core && ./bootstrap && ./configure && make +# +# run from taler-typescript-core after make (needs: node, wget|curl, grep) +set -eu + +command -v node >/dev/null 2>&1 || { echo "install: node (LTS)" >&2; exit 1; } +[ -f packages/taler-wallet-cli/bin/taler-wallet-cli.mjs ] || { + echo "install/build: run from taler-typescript-core after make" >&2 + exit 1 +} +command -v wget >/dev/null 2>&1 || command -v curl >/dev/null 2>&1 || { + echo "install: wget or curl" >&2 + exit 1 +} + +# monorepo CLI + private wallet DB (not system package / default home DB) +tw() { + node packages/taler-wallet-cli/bin/taler-wallet-cli.mjs \ + --wallet-db="${TMPDIR:-/tmp}/taler-wallet-goa.sqlite3" \ + --no-throttle "$@" +} + +# mint from bank, register exchange, withdraw GOA:10 +(wget -q -O demo-withdraw.json https://bank.hacktivism.ch/intro/demo-withdraw.json || \ + curl -fsS -o demo-withdraw.json https://bank.hacktivism.ch/intro/demo-withdraw.json) && \ +tw exchanges add https://exchange.hacktivism.ch/ && \ +tw exchanges accept-tos https://exchange.hacktivism.ch/ && \ +tw withdraw accept-uri --exchange https://exchange.hacktivism.ch/ \ + "$(grep -o 'taler://[^"]*' demo-withdraw.json)" && \ +tw run-until-done && \ + +# pay orbit-sticker (GOA:2); balance ends at GOA:8 +tw handle-uri --yes \ + taler://pay-template/taler.hacktivism.ch/instances/goa-shop/orbit-sticker && \ +tw run-until-done && tw balance diff --git a/scripts/taler-wallet-cli/run-amount-ladder-bench.sh b/scripts/taler-wallet-cli/run-amount-ladder-bench.sh new file mode 100755 index 0000000..a06aa0e --- /dev/null +++ b/scripts/taler-wallet-cli/run-amount-ladder-bench.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# Compatibility wrapper — canonical benchmark lives under benchmarks/amount-ladder/ +exec "$(cd "$(dirname "$0")/../.." && pwd)/benchmarks/amount-ladder/run.sh" "$@" diff --git a/scripts/taler-wallet-cli/run-extreme-amount-tests.sh b/scripts/taler-wallet-cli/run-extreme-amount-tests.sh new file mode 100755 index 0000000..1774304 --- /dev/null +++ b/scripts/taler-wallet-cli/run-extreme-amount-tests.sh @@ -0,0 +1,350 @@ +#!/bin/bash +# Extreme amount tests for GOA: largest practical price vs atomic/tiny coin. +# +# Live denoms (exchange.hacktivism.ch): GOA:0.000001 … GOA:10 +# Currency TINY / round unit: GOA:0.00000001 (Atomic-GOA) +# +# Run on the local machine (wallet) with ssh BatchMode to koopa for bank/merchant ops. +# Requires: monorepo taler-wallet-cli, passwords on koopa /root/*-password.txt +# +# Usage: +# ./run-extreme-amount-tests.sh +# HIGH_AMOUNT=GOA:500 TINY_AMOUNT=GOA:0.000001 ./run-extreme-amount-tests.sh +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +SCRATCH="${ROOT}/.tmp/extreme-$(date -u +%Y%m%d-%H%M%S)" +mkdir -p "$SCRATCH" +chmod 700 "$SCRATCH" + +MONO="${MONO:-/Users/newkamek/src/taler/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs}" +WDB="${WDB:-$SCRATCH/wallet.sqlite3}" +EX="${EXCHANGE_URL:-https://exchange.hacktivism.ch/}" +KOOPA="${KOOPA_HOST:-koopa}" +INST="${MERCHANT_INSTANCE:-goa-demo-cp4zqk}" +BANK_PUBLIC="${BANK_PUBLIC:-https://bank.hacktivism.ch}" +MER_PUBLIC="${MER_PUBLIC:-https://taler.hacktivism.ch}" + +# High price: many max-denom (GOA:10) coins. Tiny: smallest issued coin. +HIGH_AMOUNT="${HIGH_AMOUNT:-GOA:1000}" +TINY_AMOUNT="${TINY_AMOUNT:-GOA:0.000001}" +# True atomic (round unit) — may fail if no coin below 1e-6 +ATOMIC_AMOUNT="${ATOMIC_AMOUNT:-GOA:0.00000001}" +# Fat bank balance for withdraw buffer +FAT_CREDIT="${FAT_CREDIT:-GOA:50000}" +FAT_USER="${FAT_USER:-extreme-fat}" +# withdraw a bit more than HIGH +WITHDRAW_AMOUNT="${WITHDRAW_AMOUNT:-GOA:1500}" + +wcli() { node "$MONO" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"; } + +log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*"; } +die() { echo "FAIL: $*" >&2; exit 1; } + +command -v node >/dev/null || die "node missing" +[ -f "$MONO" ] || die "wallet missing: $MONO" +ssh -o BatchMode=yes -o ConnectTimeout=10 "$KOOPA" 'echo ok' >/dev/null || die "ssh $KOOPA failed" + +log "scratch=$SCRATCH" +log "HIGH=$HIGH_AMOUNT TINY=$TINY_AMOUNT ATOMIC=$ATOMIC_AMOUNT WITHDRAW=$WITHDRAW_AMOUNT FAT=$FAT_USER+$FAT_CREDIT" + +# --- 1) koopa: bank account + credit + templates + orders --- +log "=== koopa setup (admin credit, templates, orders) ===" +# Generate bank password locally (not committed) +FAT_PW=$(python3 -c 'import secrets; print(secrets.token_urlsafe(12))') +echo "$FAT_PW" >"$SCRATCH/fat-bank.password" +chmod 600 "$SCRATCH/fat-bank.password" + +cat >"$SCRATCH/koopa-setup.sh" < /tmp/extreme-setup.out 2>&1 +set -x +INST=$INST +FAT_USER=$FAT_USER +FAT_PW='$(printf %s "$FAT_PW" | sed "s/'/'\\\\''/g")' +FAT_CREDIT=$FAT_CREDIT +HIGH_AMOUNT=$HIGH_AMOUNT +TINY_AMOUNT=$TINY_AMOUNT +ATOMIC_AMOUNT=$ATOMIC_AMOUNT +BANK=http://127.0.0.1:9012 +MER=https://127.0.0.1:9010 + +ADMIN_PASS=\$(tr -d '\\n' =5: + bits-=5; o.append(a[(v>>bits)&31]) +if bits: o.append(a[(v<<(5-bits))&31]) +print("".join(o))') + curl -sS -H "Authorization: Bearer \${AT}" -H 'Content-Type: application/json' \\ + -d "{\\"payto_uri\\":\\"\${PAYTO}\\",\\"amount\\":\\"\${FAT_CREDIT}\\",\\"request_uid\\":\\"\${UID}\\"}" \\ + "\${BANK}/accounts/admin/transactions" | tee /tmp/credit.out + echo +fi + +# balance +curl -sS -H "Authorization: Bearer \${AT}" "\${BANK}/accounts/\${FAT_USER}" | tee /tmp/fat-bal.json +echo +python3 -c 'import json;d=json.load(open("/tmp/fat-bal.json"));print("FAT_BALANCE", (d.get("balance") or {}).get("amount"), (d.get("balance") or {}).get("credit_debit_indicator"))' + +# store bank pw for host-side withdraw +printf '%s' "\$FAT_PW" > /root/bank-\${FAT_USER}-password.txt +chmod 600 /root/bank-\${FAT_USER}-password.txt + +# templates +for id_summary_amount in \\ + "extreme-high|extreme high price|\${HIGH_AMOUNT}" \\ + "extreme-tiny|extreme tiny coin|\${TINY_AMOUNT}" \\ + "extreme-atomic|true atomic round unit|\${ATOMIC_AMOUNT}" +do + IFS='|' read -r TID SUM AMT <<<"\$id_summary_amount" + body=\$(python3 -c "import json; print(json.dumps({ + 'template_id': '\$TID', + 'template_description': '\$SUM', + 'editable_defaults': False, + 'template_contract': { + 'summary': '\$SUM', + 'amount': '\$AMT', + 'minimum_age': 0, + }, + }))") + # try POST then PATCH + code=\$(curl -sk -o /tmp/tpl.json -w '%{http_code}' -X POST -H "\$AUTH" -H 'Content-Type: application/json' \\ + -d "\$body" "\${MER}/instances/\${INST}/private/templates") + echo "template_\$TID post=\$code \$(head -c 120 /tmp/tpl.json)" + if [ "\$code" = "409" ] || [ "\$code" = "400" ]; then + curl -sk -X PATCH -H "\$AUTH" -H 'Content-Type: application/json' \\ + -d "\$body" "\${MER}/instances/\${INST}/private/templates/\${TID}" -w " patch=%{http_code}\\n" + fi +done + +# also list templates +curl -sk -H "\$AUTH" "\${MER}/instances/\${INST}/private/templates" | python3 -m json.tool | head -80 + +# create two firm orders (high + tiny) for deterministic pay URIs +create_order() { + local sum="\$1" amt="\$2" tag="\$3" + curl -sk -X POST -H "\$AUTH" -H 'Content-Type: application/json' \\ + -d "{\\"order\\":{\\"summary\\":\\"\${sum}\\",\\"amount\\":\\"\${amt}\\",\\"fulfillment_message\\":\\"extreme-ok\\"},\\"create_token\\":true}" \\ + "\${MER}/instances/\${INST}/private/orders" > "/tmp/ord-\${tag}.json" + python3 - "\$tag" <<'PY' +import json,sys +tag=sys.argv[1] +d=json.load(open(f"/tmp/ord-{tag}.json")) +print(f"ORDER_{tag.upper()}", d.get("order_id"), "token", (d.get("token") or "")[:12]) +open(f"/tmp/oid-{tag}.txt","w").write(d.get("order_id") or "") +open(f"/tmp/otok-{tag}.txt","w").write(d.get("token") or "") +PY +} +create_order "extreme-high-order" "\$HIGH_AMOUNT" high +create_order "extreme-tiny-order" "\$TINY_AMOUNT" tiny +create_order "extreme-atomic-order" "\$ATOMIC_AMOUNT" atomic + +# create bank integrated withdrawal for fat user +BT=\$(curl -sS -u "\${FAT_USER}:\${FAT_PW}" -H 'Content-Type: application/json' \\ + -d '{"scope":"readwrite"}' "\${BANK}/accounts/\${FAT_USER}/token" \\ + | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))') +curl -sS -H "Authorization: Bearer \${BT}" -H 'Content-Type: application/json' \\ + -d "{\\"amount\\":\\"\$WITHDRAW_AMOUNT\\",\\"exchange_url\\":\\"https://exchange.hacktivism.ch/\\"}" \\ + "\${BANK}/accounts/\${FAT_USER}/withdrawals" | tee /tmp/wd-create.json +echo +python3 - <<'PY' +import json +d=json.load(open("/tmp/wd-create.json")) +wid=d.get("withdrawal_id") or d.get("id") +uri=d.get("taler_withdraw_uri") +print("WID", wid) +print("WURI", uri) +open("/tmp/wid.txt","w").write(wid or "") +open("/tmp/wuri.txt","w").write(uri or "") +PY + +# publish artifacts for hernani-readable pull +cp /tmp/oid-*.txt /tmp/otok-*.txt /tmp/wuri.txt /tmp/wid.txt /tmp/fat-bal.json /tmp/extreme-setup.out /home/hernani/ 2>/dev/null || true +chmod 644 /home/hernani/oid-*.txt /home/hernani/otok-*.txt /home/hernani/wuri.txt /home/hernani/wid.txt 2>/dev/null || true + +echo SETUP_EXTREME_OK +EOS + +# inject WITHDRAW_AMOUNT into script (heredoc already expanded partially - fix) +# The setup script uses $WITHDRAW_AMOUNT as shell var on koopa - need to export into script +sed -i.bak "s|\$WITHDRAW_AMOUNT|$WITHDRAW_AMOUNT|g" "$SCRATCH/koopa-setup.sh" 2>/dev/null || \ + sed -i '' "s|\$WITHDRAW_AMOUNT|$WITHDRAW_AMOUNT|g" "$SCRATCH/koopa-setup.sh" + +scp -o BatchMode=yes "$SCRATCH/koopa-setup.sh" "${KOOPA}:/tmp/extreme-setup.sh" +# also scp credit-account for reliable credit +scp -o BatchMode=yes "$ROOT/scripts/taler-bank/credit-account.sh" "${KOOPA}:/tmp/credit-account.sh" || true + +# run via screen window 0 (root@koopa) — screen-run cannot source local-machine paths +SESSION=$(screen -ls 2>/dev/null | awk 'match($0, /[0-9]+\.[A-Za-z0-9._-]+/) { print substr($0, RSTART, RLENGTH); exit }') +[ -n "$SESSION" ] || die "no screen session" +screen -S "$SESSION" -p 0 -X stuff $'bash /tmp/extreme-setup.sh; chmod 644 /tmp/extreme-setup.out /home/hernani/*.txt 2>/dev/null\n' + +for i in $(seq 1 90); do + if ssh -o BatchMode=yes "$KOOPA" 'grep -q SETUP_EXTREME_OK /tmp/extreme-setup.out 2>/dev/null'; then + log "setup ready @${i}s" + break + fi + sleep 1 + [ "$i" -eq 90 ] && { ssh -o BatchMode=yes "$KOOPA" 'tail -80 /tmp/extreme-setup.out' || true; die "setup timeout"; } +done +ssh -o BatchMode=yes "$KOOPA" 'cat /tmp/extreme-setup.out' >"$SCRATCH/koopa-setup.out" || true +# pull artifacts (hernani-readable) +scp -o BatchMode=yes "${KOOPA}:/home/hernani/wuri.txt" "${KOOPA}:/home/hernani/wid.txt" \ + "${KOOPA}:/home/hernani/oid-high.txt" "${KOOPA}:/home/hernani/oid-tiny.txt" \ + "${KOOPA}:/home/hernani/oid-atomic.txt" \ + "${KOOPA}:/home/hernani/otok-high.txt" "${KOOPA}:/home/hernani/otok-tiny.txt" \ + "${KOOPA}:/home/hernani/otok-atomic.txt" \ + "$SCRATCH/" 2>/dev/null || true + +WURI=$(tr -d '\n' <"$SCRATCH/wuri.txt" 2>/dev/null || true) +WID=$(tr -d '\n' <"$SCRATCH/wid.txt" 2>/dev/null || true) +OID_HIGH=$(tr -d '\n' <"$SCRATCH/oid-high.txt" 2>/dev/null || true) +OID_TINY=$(tr -d '\n' <"$SCRATCH/oid-tiny.txt" 2>/dev/null || true) +OID_ATOMIC=$(tr -d '\n' <"$SCRATCH/oid-atomic.txt" 2>/dev/null || true) +TOK_HIGH=$(tr -d '\n' <"$SCRATCH/otok-high.txt" 2>/dev/null || true) +TOK_TINY=$(tr -d '\n' <"$SCRATCH/otok-tiny.txt" 2>/dev/null || true) +TOK_ATOMIC=$(tr -d '\n' <"$SCRATCH/otok-atomic.txt" 2>/dev/null || true) + +log "WURI=$WURI" +log "orders high=$OID_HIGH tiny=$OID_TINY atomic=$OID_ATOMIC" +[ -n "$WURI" ] || die "no withdraw URI" +[ -n "$OID_HIGH" ] && [ -n "$OID_TINY" ] || die "orders missing" + +# ensure auto-confirm watches this withdrawal +ssh -o BatchMode=yes "$KOOPA" "echo $WID >> /var/www/bank-landing/withdraw-watch.ids 2>/dev/null; sort -u /var/www/bank-landing/withdraw-watch.ids -o /var/www/bank-landing/withdraw-watch.ids 2>/dev/null; true" +# root confirm via screen +screen -S "$SESSION" -p 0 -X stuff $'BPW=$(tr -d "\\n" &1 | tee "$SCRATCH/ex-add.out" || true +wcli exchanges update "$EX" 2>&1 | tee "$SCRATCH/ex-upd.out" || true +wcli exchanges tos "$EX" 2>&1 | tee "$SCRATCH/ex-tos.out" || true +wcli exchanges accept-tos "$EX" 2>&1 | tee "$SCRATCH/ex-accept.out" || true + +log "=== withdraw accept-uri ===" +wcli withdraw accept-uri --exchange "$EX" "$WURI" 2>&1 | tee "$SCRATCH/accept-uri.out" || true + +for round in $(seq 1 20); do + log "run-until-done round $round" + wcli run-until-done 2>&1 | tee -a "$SCRATCH/rud.out" || true + wcli balance 2>&1 | tee "$SCRATCH/bal-$round.out" + if grep -qE "GOA:" "$SCRATCH/bal-$round.out"; then + # check non-zero available + if python3 -c 'import re,sys; t=open(sys.argv[1]).read(); +m=re.search(r"available[^0-9A-Z]*GOA:([0-9.]+)", t) +print(m.group(1) if m else ""); sys.exit(0 if m and float(m.group(1))>0 else 1)' "$SCRATCH/bal-$round.out" 2>/dev/null; then + log "coins available" + break + fi + fi + # re-confirm withdraw on bank + screen -S "$SESSION" -p 0 -X stuff $'BPW=$(tr -d "\\n" /dev/null; echo\n' + sleep 3 +done + +wcli balance 2>&1 | tee "$SCRATCH/balance-after-withdraw.out" +wcli transactions 2>&1 | tee "$SCRATCH/tx-after-withdraw.out" | head -100 + +# --- 3) pay high + tiny (+ try atomic) --- +pay_order() { + local tag="$1" oid="$2" tok="$3" + local uri="taler://pay/taler.hacktivism.ch/instances/${INST}/${oid}/?c=${tok}" + echo "$uri" >"$SCRATCH/pay-${tag}.uri" + log "=== pay $tag order=$oid ===" + log "uri=$uri" + wcli handle-uri "$uri" 2>&1 | tee "$SCRATCH/pay-${tag}-handle.out" || true + # some wallets use payments prepare/confirm + wcli run-until-done 2>&1 | tee -a "$SCRATCH/pay-${tag}-rud.out" || true + # check merchant + ssh -o BatchMode=yes "$KOOPA" "MPW=\$(tr -d '\\n' "$SCRATCH/order-${tag}-status.json" 2>/dev/null || true + python3 - "$SCRATCH/order-${tag}-status.json" "$tag" <<'PY' || true +import json,sys +p,tag=sys.argv[1],sys.argv[2] +try: + d=json.load(open(p)) +except Exception as e: + print(f"STATUS_{tag} unreadable", e); raise SystemExit +print(f"STATUS_{tag}", "order_status=", d.get("order_status"), "paid=", d.get("paid"), + "wired=", d.get("wired"), "deposit_total=", d.get("deposit_total"), + "amount=", (d.get("contract_terms") or {}).get("amount")) +PY +} + +pay_order high "$OID_HIGH" "$TOK_HIGH" +wcli balance 2>&1 | tee "$SCRATCH/balance-after-high.out" + +pay_order tiny "$OID_TINY" "$TOK_TINY" +wcli balance 2>&1 | tee "$SCRATCH/balance-after-tiny.out" + +if [ -n "$OID_ATOMIC" ] && [ -n "$TOK_ATOMIC" ]; then + pay_order atomic "$OID_ATOMIC" "$TOK_ATOMIC" || true + wcli balance 2>&1 | tee "$SCRATCH/balance-after-atomic.out" || true +fi + +# template URIs for docs +{ + echo "taler://pay-template/taler.hacktivism.ch/instances/${INST}/extreme-high/" + echo "taler://pay-template/taler.hacktivism.ch/instances/${INST}/extreme-tiny/" + echo "taler://pay-template/taler.hacktivism.ch/instances/${INST}/extreme-atomic/" +} | tee "$SCRATCH/template-uris.txt" + +log "=== summary ===" +python3 - <&1 | tee "$SCRATCH/ex-add.out" || true +wcli exchanges update "$EX" 2>&1 | tee "$SCRATCH/ex-upd.out" || true +wcli exchanges tos "$EX" 2>&1 | tee "$SCRATCH/ex-tos.out" || true +wcli exchanges accept-tos "$EX" 2>&1 | tee "$SCRATCH/ex-accept-tos.out" + +if [ -z "$URI" ]; then + URI=$(ssh -o BatchMode=yes koopa 'tr -d "\n" "$SCRATCH/withdraw.uri" + +echo "=== withdraw accept-uri ===" +wcli withdraw accept-uri --exchange "$EX" "$URI" 2>&1 | tee "$SCRATCH/accept-uri.out" + +WID=$(basename "$URI") +echo "=== ensure watch + auto-confirm on koopa ===" +ssh -o BatchMode=yes koopa "echo $WID >> /var/www/bank-landing/withdraw-watch.ids; sort -u /var/www/bank-landing/withdraw-watch.ids -o /var/www/bank-landing/withdraw-watch.ids" +# one-shot confirm as root via ssh if hernani can sudo? usually not — use screen later +# public status +curl -sS "https://bank.hacktivism.ch/taler-integration/withdrawals/${WID}" 2>/dev/null | head -c 500 || true +echo + +for round in 1 2 3 4 5 6 7 8; do + echo "=== run-until-done round $round ===" + wcli run-until-done & + PID=$! + ( sleep 20; kill $PID 2>/dev/null ) & + wait $PID 2>/dev/null || true + wcli balance 2>&1 | tee "$SCRATCH/bal-$round.out" + wcli transactions 2>&1 | tee "$SCRATCH/tx-$round.out" | head -80 + if grep -qE '"available": "GOA:[1-9]' "$SCRATCH/bal-$round.out"; then + echo "OK coins received" + exit 0 + fi + sleep 3 +done +echo "FAIL no balance" >&2 +exit 1 From 4c911b3bac42e5188b59c2d055afcc7109f23cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Mon, 13 Jul 2026 10:12:00 +0200 Subject: [PATCH 002/326] docs: new root as prior history lost (orphan + GC); ~215 commits not recoverable --- .gitignore | 7 + 2026/2026-06-29.md | 4 + 2026/2026-06-30.md | 4 + 2026/2026-07-01.md | 3 + 2026/2026-07-02.md | 3 + 2026/2026-07-07.md | 3 + 2026/2026-07-08.md | 6 + ...26-07-09--amount-ladder-partial-results.md | 47 + 2026/2026-07-09--bonfire-ground-zero.md | 46 + 2026/2026-07-09--castopod-content.md | 34 + ...26-07-09--exchange-snapshot-and-restore.md | 201 ++ 2026/2026-07-09--ipv6-15d6.md | 34 + 2026/2026-07-09--keys-wire.md | 17 + 2026/2026-07-09--koopa-prime-jellyfin.md | 43 + 2026/2026-07-09--merchant-bank-link.md | 8 + 2026/2026-07-09.md | 6 + 2026/2026-07-10--admin-log-origin-forgejo.md | 40 + 2026/2026-07-10--bonfire-federate.md | 60 + 2026/2026-07-10--forgejo-rootless.md | 90 + ...2026-07-10--forgejo-theme-exchange-dark.md | 27 + 2026/2026-07-10.md | 21 + 2026/2026-07-11--bonfire-branding-feeds.md | 25 + 2026/2026-07-11--bonfire-gitbot-systemd.md | 26 + 2026/2026-07-11--bonfire-logo-not-served.md | 19 + 2026/2026-07-11.md | 5 + 2026/2026-07-13--castopod-boot-branding.md | 31 + 2026/README.md | 3 + README.md | 86 + SECRETS.md | 34 + benchmarks/README.md | 10 + benchmarks/amount-ladder/README.md | 90 + benchmarks/amount-ladder/run.sh | 375 +++ .../amount-ladder/sample-load-inside.sh | 26 + configs/README.md | 19 + configs/bank-landing/README.md | 250 ++ configs/bank-landing/index.html | 2055 +++++++++++++++++ configs/bank-landing/nginx-cors-stats.conf | 6 + configs/bank-landing/nginx-landing.conf | 71 + configs/bank-landing/og-goa-shop.png | Bin 0 -> 557173 bytes configs/bank-landing/privacy.html | 137 ++ configs/bank-landing/qr-logo.png | Bin 0 -> 8563 bytes configs/bank-landing/qr-logo.svg | 1 + configs/bank-landing/qr-payto.png | Bin 0 -> 602 bytes .../bank-landing/qr-product-beacon-badge.png | Bin 0 -> 608 bytes .../qr-product-blue-or-red-pill.png | Bin 0 -> 617 bytes configs/bank-landing/qr-product-comet-cap.png | Bin 0 -> 524 bytes .../qr-product-eclipse-shades.png | Bin 0 -> 609 bytes .../bank-landing/qr-product-nebula-coffee.png | Bin 0 -> 604 bytes .../bank-landing/qr-product-orbit-sticker.png | Bin 0 -> 597 bytes .../bank-landing/qr-product-rainbow-pill.png | Bin 0 -> 599 bytes configs/bank-landing/qr-product-relay-pin.png | Bin 0 -> 528 bytes .../bank-landing/qr-product-shuttle-pass.png | Bin 0 -> 609 bytes .../bank-landing/qr-product-star-chart.png | Bin 0 -> 527 bytes .../qr-product-voidwave-playlist.png | Bin 0 -> 602 bytes configs/bank-landing/qrcode.min.js | 1 + configs/bank-landing/shop-pay.css | 348 +++ configs/bank-landing/shop-pay.js | 410 ++++ configs/bank-landing/terms.html | 75 + configs/bonfire/README.md | 28 + configs/bonfire/assets/img/favicon.svg | 90 + configs/bonfire/assets/img/logo.png | Bin 0 -> 98601 bytes configs/bonfire/assets/img/logo.svg | 90 + configs/bonfire/compose.yml | 55 + .../container-koopa-bonfire-db.service | 21 + .../bonfire/container-koopa-bonfire.service | 21 + configs/bonfire/gitbot-mirror.service | 18 + configs/bonfire/public-feeds.md | 11 + configs/bonfire/theme-hacktivism.json | 29 + configs/caddy/Caddyfile | 185 ++ configs/caddy/Caddyfile.taler-host | 24 + configs/caddy/README.md | 9 + configs/caddy/git.hacktivism.ch.caddy | 15 + configs/castopod/README.md | 21 + .../assets/css/theme-hacktivism-overlay.css | 64 + configs/castopod/assets/img/favicon.svg | 90 + configs/castopod/assets/img/logo.png | Bin 0 -> 98601 bytes configs/castopod/assets/img/logo.svg | 90 + configs/castopod/compose.yml | 81 + .../container-koopa-castopod-mariadb.service | 21 + .../container-koopa-castopod-redis.service | 21 + .../castopod/container-koopa-castopod.service | 21 + configs/exchange-landing/README.md | 23 + configs/exchange-landing/index.html | 603 +++++ configs/exchange-landing/nginx-landing.conf | 24 + configs/exchange-landing/og-goa-shop.png | Bin 0 -> 557173 bytes configs/exchange-landing/qr-logo.png | Bin 0 -> 8563 bytes configs/exchange-landing/qr-logo.svg | 1 + configs/firewalld/public-ports.md | 61 + configs/forgejo/README.md | 60 + configs/forgejo/app.ini.example | 79 + .../forgejo/assets/css/theme-hacktivism.css | 275 +++ configs/forgejo/assets/img/favicon.svg | 90 + configs/forgejo/assets/img/logo.png | Bin 0 -> 98601 bytes configs/forgejo/assets/img/logo.svg | 90 + configs/forgejo/compose.yml | 86 + configs/merchant-landing/README.md | 42 + configs/merchant-landing/index.html | 1016 ++++++++ configs/merchant-landing/nginx-landing.conf | 24 + configs/merchant-landing/og-goa-shop.png | Bin 0 -> 557173 bytes configs/merchant-landing/qr-logo.png | Bin 0 -> 8563 bytes configs/merchant-landing/qr-logo.svg | 1 + configs/merchant-landing/qr-payto.png | Bin 0 -> 602 bytes .../qr-product-beacon-badge.png | Bin 0 -> 608 bytes .../qr-product-blue-or-red-pill.png | Bin 0 -> 617 bytes .../merchant-landing/qr-product-comet-cap.png | Bin 0 -> 524 bytes .../qr-product-eclipse-shades.png | Bin 0 -> 609 bytes .../qr-product-nebula-coffee.png | Bin 0 -> 604 bytes .../qr-product-orbit-sticker.png | Bin 0 -> 597 bytes .../qr-product-rainbow-pill.png | Bin 0 -> 599 bytes .../merchant-landing/qr-product-relay-pin.png | Bin 0 -> 528 bytes .../qr-product-shuttle-pass.png | Bin 0 -> 609 bytes .../qr-product-star-chart.png | Bin 0 -> 527 bytes .../qr-product-voidwave-playlist.png | Bin 0 -> 602 bytes configs/merchant-landing/qrcode.min.js | 1 + configs/merchant-landing/shop-pay.css | 348 +++ configs/merchant-landing/shop-pay.js | 410 ++++ configs/ports.md | 25 + configs/prime/README.md | 16 + configs/prime/compose.yml | 47 + configs/shared/goa-stats-snippet.html | 39 + configs/shared/goa-stats.css | 86 + configs/shared/goa-stats.js | 130 ++ .../systemd/caddy.service.d-no-resume.conf | 3 + configs/systemd/http-proxy.socket | 11 + configs/systemd/https-proxy.socket | 11 + configs/systemd/https-proxy@.service | 5 + configs/taler-exchange-ansible/Containerfile | 26 + configs/taler-exchange-ansible/README.md | 33 + .../deploy-hacktivism-goa.sh | 25 + .../inventories-default.snippet | 6 + configs/taler-exchange-ansible/public.yml | 31 + .../run-container-koopa.sh | 45 + configs/taler-exchange/README.md | 44 + .../taler-exchange/conf.d-exchange-coins.conf | 331 +++ .../taler-exchange/exchange-overrides.conf | 52 + .../taler-exchange.conf.includes.txt | 3 + .../terms-src/no-privacy-v0.en.rst | 15 + .../terms-src/no-terms-v0.en.rst | 16 + configs/taler-hacktivism-bank/README.md | 98 + .../taler-hacktivism-bank/bank-overrides.conf | 39 + .../libeufin-bank.conf.includes.txt | 7 + configs/taler-hacktivism/README.md | 63 + configs/taler-hacktivism/conf.d-merchant.conf | 7 + .../demo-instance-goa-demo-cp4zqk.md | 63 + .../taler-hacktivism/merchant-overrides.conf | 71 + .../nginx-taler-merchant.conf | 50 + .../taler-merchant.conf.includes.txt | 5 + .../terms-src/merchant-tos-dual-v0.en.rst | 43 + configs/tops/Caddyfile.snippet | 29 + configs/tops/README.md | 73 + configs/tops/compose.yml | 61 + configs/tops/container-koopa-tops.service | 35 + configs/tops/nginx-default.conf | 47 + configs/tor/Containerfile | 10 + configs/tor/README.md | 53 + configs/tor/container-koopa-tor-relay.service | 28 + configs/tor/migrate-identity.sh | 39 + configs/tor/torrc | 16 + configs/tor/torrc.minimal | 192 ++ configs/tor/torrc.sample | 257 +++ host/README.md | 16 + host/caddy/Caddyfile | 185 ++ host/caddy/README.md | 19 + host/firewalld/README.md | 18 + host/firewalld/list-all.txt | 19 + host/network/README.md | 10 + host/overview/LIVE.md | 104 + host/overview/services.md | 100 + host/overview/services.txt | 24 + host/systemd/README.md | 13 + host/systemd/caddy.service.d-no-resume.conf | 3 + host/systemd/http-proxy.socket | 11 + host/systemd/https-proxy.socket | 11 + host/systemd/https-proxy@.service | 5 + host/tor/README.md | 27 + ops/README.md | 5 + ops/ROOT_HYGIENE.md | 11 + scripts/README.md | 47 + scripts/bonfire/README.md | 13 + scripts/bonfire/apply-branding.exs | 56 + scripts/bonfire/apply-branding.sh | 55 + scripts/bonfire/gitbot-mirror.py | 240 ++ scripts/bonfire/install-systemd.sh | 29 + scripts/bonfire/publish-outbox-to-public.sql | 28 + scripts/caddy/README.md | 15 + scripts/castopod/README.md | 33 + scripts/castopod/apply-branding.sh | 137 ++ scripts/castopod/install-systemd.sh | 41 + scripts/castopod/lib.sh | 116 + scripts/castopod/status.sh | 21 + scripts/castopod/up.sh | 32 + scripts/monitoring/countries.txt | 249 ++ scripts/monitoring/tor_inbound_connects.sh | 8 + .../tor_show_relays--koopa+firecuda.sh | 2 + scripts/monitoring/tor_show_relays.sh | 108 + scripts/monitoring/tor_stats_per_country.sh | 83 + scripts/taler-bank/README.md | 71 + .../taler-bank/auto-confirm-withdrawals.sh | 143 ++ scripts/taler-bank/check_bank-health.sh | 48 + scripts/taler-bank/credit-account.sh | 96 + scripts/taler-bank/demo-withdraw-api.py | 316 +++ .../taler-bank/install-demo-withdraw-api.sh | 80 + scripts/taler-bank/landing-stats-install.sh | 78 + scripts/taler-bank/landing-stats.sh | 612 +++++ scripts/taler-bank/make-demo-withdraw-qr.sh | 112 + scripts/taler-bank/refresh-demo-withdraw.sh | 54 + scripts/taler-bank/start_bank.sh | 109 + .../start_base_services_for_taler_bank.sh | 148 ++ scripts/taler-dns/README.md | 48 + scripts/taler-dns/bake-hosts-into-images.sh | 116 + scripts/taler-dns/pin-container-hosts.sh | 82 + .../pin-hacktivism-hosts.in-container.sh | 31 + .../taler-dns/pin-hacktivism-hosts.service | 16 + scripts/taler-exchange/archive/README.md | 35 + .../archive/exchange-bootstrap.sh | 451 ++++ .../taler-exchange/check_exchange-health.sh | 88 + .../taler-exchange/ensure_exchange_helpers.sh | 75 + scripts/taler-exchange/install_no_terms.sh | 263 +++ .../taler-exchange/install_swiss_privacy.sh | 168 ++ .../taler-exchange/landing-stats-exchange.sh | 300 +++ .../offline-sign-upload-keys.sh | 62 + .../start_base_services_for_taler_exchange.sh | 148 ++ scripts/taler-exchange/start_exchange.sh | 104 + scripts/taler-exchange/start_wire_helpers.sh | 43 + .../taler-exchange/wire-enable-and-upload.sh | 91 + scripts/taler-landing/deploy-landings.sh | 72 + scripts/taler-merchant/README.md | 56 + scripts/taler-merchant/certbot_renew.sh | 5 + .../taler-merchant/check_merchant-health.sh | 222 ++ .../taler-merchant/ensure_merchant_helpers.sh | 100 + scripts/taler-merchant/install_dual_terms.sh | 232 ++ .../taler-merchant/install_swiss_privacy.sh | 195 ++ .../taler-merchant/landing-stats-merchant.sh | 439 ++++ scripts/taler-merchant/setup_credit_facade.sh | 91 + .../start_base_services_for_taler.sh | 133 ++ scripts/taler-merchant/start_merchant.sh | 126 + .../stats--merchant-payments.sh | 33 + .../taler-hacktivism-email-helper.sh | 19 + .../taler-hacktivism-sms-helper-wrapper.sh | 5 + .../taler-merchant-wirewatch-supervise.sh | 26 + scripts/taler-monitoring/README.md | 100 + scripts/taler-monitoring/TESTS.md | 165 ++ scripts/taler-monitoring/check_e2e.sh | 1220 ++++++++++ scripts/taler-monitoring/check_inside.sh | 141 ++ scripts/taler-monitoring/check_sanity.sh | 281 +++ scripts/taler-monitoring/check_server.sh | 128 + scripts/taler-monitoring/check_urls.sh | 636 +++++ scripts/taler-monitoring/check_versions.sh | 469 ++++ scripts/taler-monitoring/harness-live.txt | 7 + scripts/taler-monitoring/harness-tests.txt | 41 + scripts/taler-monitoring/lib.sh | 536 +++++ scripts/taler-monitoring/taler-monitoring.sh | 198 ++ scripts/taler-sanity/README.md | 31 + .../taler-sanity/check_exchange-wirewatch.sh | 92 + scripts/taler-sanity/check_helpers-running.sh | 81 + scripts/taler-sanity/check_merchant-delays.sh | 72 + scripts/taler-sanity/check_settlement.sh | 119 + scripts/taler-sanity/check_stack-health.sh | 73 + scripts/taler-sanity/lib.sh | 60 + scripts/taler-sanity/run_all.sh | 22 + scripts/taler-shared/install_privacy_docs.sh | 74 + scripts/taler-shared/mem-snapshot.sh | 130 ++ scripts/taler-shared/terms-style.css.fragment | 37 + .../taler-wallet-cli/goa-deb-withdraw-pay.sh | 32 + .../goa-posix-withdraw-pay.sh | 41 + .../run-amount-ladder-bench.sh | 3 + .../run-extreme-amount-tests.sh | 350 +++ scripts/taler-wallet-cli/withdraw-qr1-goa.sh | 54 + 268 files changed, 24161 insertions(+) create mode 100644 .gitignore create mode 100644 2026/2026-06-29.md create mode 100644 2026/2026-06-30.md create mode 100644 2026/2026-07-01.md create mode 100644 2026/2026-07-02.md create mode 100644 2026/2026-07-07.md create mode 100644 2026/2026-07-08.md create mode 100644 2026/2026-07-09--amount-ladder-partial-results.md create mode 100644 2026/2026-07-09--bonfire-ground-zero.md create mode 100644 2026/2026-07-09--castopod-content.md create mode 100644 2026/2026-07-09--exchange-snapshot-and-restore.md create mode 100644 2026/2026-07-09--ipv6-15d6.md create mode 100644 2026/2026-07-09--keys-wire.md create mode 100644 2026/2026-07-09--koopa-prime-jellyfin.md create mode 100644 2026/2026-07-09--merchant-bank-link.md create mode 100644 2026/2026-07-09.md create mode 100644 2026/2026-07-10--admin-log-origin-forgejo.md create mode 100644 2026/2026-07-10--bonfire-federate.md create mode 100644 2026/2026-07-10--forgejo-rootless.md create mode 100644 2026/2026-07-10--forgejo-theme-exchange-dark.md create mode 100644 2026/2026-07-10.md create mode 100644 2026/2026-07-11--bonfire-branding-feeds.md create mode 100644 2026/2026-07-11--bonfire-gitbot-systemd.md create mode 100644 2026/2026-07-11--bonfire-logo-not-served.md create mode 100644 2026/2026-07-11.md create mode 100644 2026/2026-07-13--castopod-boot-branding.md create mode 100644 2026/README.md create mode 100644 README.md create mode 100644 SECRETS.md create mode 100644 benchmarks/README.md create mode 100644 benchmarks/amount-ladder/README.md create mode 100755 benchmarks/amount-ladder/run.sh create mode 100755 benchmarks/amount-ladder/sample-load-inside.sh create mode 100644 configs/README.md create mode 100644 configs/bank-landing/README.md create mode 100644 configs/bank-landing/index.html create mode 100644 configs/bank-landing/nginx-cors-stats.conf create mode 100644 configs/bank-landing/nginx-landing.conf create mode 100644 configs/bank-landing/og-goa-shop.png create mode 100644 configs/bank-landing/privacy.html create mode 100644 configs/bank-landing/qr-logo.png create mode 100644 configs/bank-landing/qr-logo.svg create mode 100644 configs/bank-landing/qr-payto.png create mode 100644 configs/bank-landing/qr-product-beacon-badge.png create mode 100644 configs/bank-landing/qr-product-blue-or-red-pill.png create mode 100644 configs/bank-landing/qr-product-comet-cap.png create mode 100644 configs/bank-landing/qr-product-eclipse-shades.png create mode 100644 configs/bank-landing/qr-product-nebula-coffee.png create mode 100644 configs/bank-landing/qr-product-orbit-sticker.png create mode 100644 configs/bank-landing/qr-product-rainbow-pill.png create mode 100644 configs/bank-landing/qr-product-relay-pin.png create mode 100644 configs/bank-landing/qr-product-shuttle-pass.png create mode 100644 configs/bank-landing/qr-product-star-chart.png create mode 100644 configs/bank-landing/qr-product-voidwave-playlist.png create mode 100644 configs/bank-landing/qrcode.min.js create mode 100644 configs/bank-landing/shop-pay.css create mode 100644 configs/bank-landing/shop-pay.js create mode 100644 configs/bank-landing/terms.html create mode 100644 configs/bonfire/README.md create mode 100644 configs/bonfire/assets/img/favicon.svg create mode 100644 configs/bonfire/assets/img/logo.png create mode 100644 configs/bonfire/assets/img/logo.svg create mode 100644 configs/bonfire/compose.yml create mode 100644 configs/bonfire/container-koopa-bonfire-db.service create mode 100644 configs/bonfire/container-koopa-bonfire.service create mode 100644 configs/bonfire/gitbot-mirror.service create mode 100644 configs/bonfire/public-feeds.md create mode 100644 configs/bonfire/theme-hacktivism.json create mode 100644 configs/caddy/Caddyfile create mode 100644 configs/caddy/Caddyfile.taler-host create mode 100644 configs/caddy/README.md create mode 100644 configs/caddy/git.hacktivism.ch.caddy create mode 100644 configs/castopod/README.md create mode 100644 configs/castopod/assets/css/theme-hacktivism-overlay.css create mode 100644 configs/castopod/assets/img/favicon.svg create mode 100644 configs/castopod/assets/img/logo.png create mode 100644 configs/castopod/assets/img/logo.svg create mode 100644 configs/castopod/compose.yml create mode 100644 configs/castopod/container-koopa-castopod-mariadb.service create mode 100644 configs/castopod/container-koopa-castopod-redis.service create mode 100644 configs/castopod/container-koopa-castopod.service create mode 100644 configs/exchange-landing/README.md create mode 100644 configs/exchange-landing/index.html create mode 100644 configs/exchange-landing/nginx-landing.conf create mode 100644 configs/exchange-landing/og-goa-shop.png create mode 100644 configs/exchange-landing/qr-logo.png create mode 100644 configs/exchange-landing/qr-logo.svg create mode 100644 configs/firewalld/public-ports.md create mode 100644 configs/forgejo/README.md create mode 100644 configs/forgejo/app.ini.example create mode 100644 configs/forgejo/assets/css/theme-hacktivism.css create mode 100644 configs/forgejo/assets/img/favicon.svg create mode 100644 configs/forgejo/assets/img/logo.png create mode 100644 configs/forgejo/assets/img/logo.svg create mode 100644 configs/forgejo/compose.yml create mode 100644 configs/merchant-landing/README.md create mode 100644 configs/merchant-landing/index.html create mode 100644 configs/merchant-landing/nginx-landing.conf create mode 100644 configs/merchant-landing/og-goa-shop.png create mode 100644 configs/merchant-landing/qr-logo.png create mode 100644 configs/merchant-landing/qr-logo.svg create mode 100644 configs/merchant-landing/qr-payto.png create mode 100644 configs/merchant-landing/qr-product-beacon-badge.png create mode 100644 configs/merchant-landing/qr-product-blue-or-red-pill.png create mode 100644 configs/merchant-landing/qr-product-comet-cap.png create mode 100644 configs/merchant-landing/qr-product-eclipse-shades.png create mode 100644 configs/merchant-landing/qr-product-nebula-coffee.png create mode 100644 configs/merchant-landing/qr-product-orbit-sticker.png create mode 100644 configs/merchant-landing/qr-product-rainbow-pill.png create mode 100644 configs/merchant-landing/qr-product-relay-pin.png create mode 100644 configs/merchant-landing/qr-product-shuttle-pass.png create mode 100644 configs/merchant-landing/qr-product-star-chart.png create mode 100644 configs/merchant-landing/qr-product-voidwave-playlist.png create mode 100644 configs/merchant-landing/qrcode.min.js create mode 100644 configs/merchant-landing/shop-pay.css create mode 100644 configs/merchant-landing/shop-pay.js create mode 100644 configs/ports.md create mode 100644 configs/prime/README.md create mode 100644 configs/prime/compose.yml create mode 100644 configs/shared/goa-stats-snippet.html create mode 100644 configs/shared/goa-stats.css create mode 100644 configs/shared/goa-stats.js create mode 100644 configs/systemd/caddy.service.d-no-resume.conf create mode 100644 configs/systemd/http-proxy.socket create mode 100644 configs/systemd/https-proxy.socket create mode 100644 configs/systemd/https-proxy@.service create mode 100644 configs/taler-exchange-ansible/Containerfile create mode 100644 configs/taler-exchange-ansible/README.md create mode 100755 configs/taler-exchange-ansible/deploy-hacktivism-goa.sh create mode 100644 configs/taler-exchange-ansible/inventories-default.snippet create mode 100644 configs/taler-exchange-ansible/public.yml create mode 100755 configs/taler-exchange-ansible/run-container-koopa.sh create mode 100644 configs/taler-exchange/README.md create mode 100644 configs/taler-exchange/conf.d-exchange-coins.conf create mode 100644 configs/taler-exchange/exchange-overrides.conf create mode 100644 configs/taler-exchange/taler-exchange.conf.includes.txt create mode 100644 configs/taler-exchange/terms-src/no-privacy-v0.en.rst create mode 100644 configs/taler-exchange/terms-src/no-terms-v0.en.rst create mode 100644 configs/taler-hacktivism-bank/README.md create mode 100644 configs/taler-hacktivism-bank/bank-overrides.conf create mode 100644 configs/taler-hacktivism-bank/libeufin-bank.conf.includes.txt create mode 100644 configs/taler-hacktivism/README.md create mode 100644 configs/taler-hacktivism/conf.d-merchant.conf create mode 100644 configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md create mode 100644 configs/taler-hacktivism/merchant-overrides.conf create mode 100644 configs/taler-hacktivism/nginx-taler-merchant.conf create mode 100644 configs/taler-hacktivism/taler-merchant.conf.includes.txt create mode 100644 configs/taler-hacktivism/terms-src/merchant-tos-dual-v0.en.rst create mode 100644 configs/tops/Caddyfile.snippet create mode 100644 configs/tops/README.md create mode 100644 configs/tops/compose.yml create mode 100644 configs/tops/container-koopa-tops.service create mode 100644 configs/tops/nginx-default.conf create mode 100644 configs/tor/Containerfile create mode 100644 configs/tor/README.md create mode 100644 configs/tor/container-koopa-tor-relay.service create mode 100755 configs/tor/migrate-identity.sh create mode 100644 configs/tor/torrc create mode 100644 configs/tor/torrc.minimal create mode 100644 configs/tor/torrc.sample create mode 100644 host/README.md create mode 100644 host/caddy/Caddyfile create mode 100644 host/caddy/README.md create mode 100644 host/firewalld/README.md create mode 100644 host/firewalld/list-all.txt create mode 100644 host/network/README.md create mode 100644 host/overview/LIVE.md create mode 100644 host/overview/services.md create mode 100644 host/overview/services.txt create mode 100644 host/systemd/README.md create mode 100644 host/systemd/caddy.service.d-no-resume.conf create mode 100644 host/systemd/http-proxy.socket create mode 100644 host/systemd/https-proxy.socket create mode 100644 host/systemd/https-proxy@.service create mode 100644 host/tor/README.md create mode 100644 ops/README.md create mode 100644 ops/ROOT_HYGIENE.md create mode 100644 scripts/README.md create mode 100644 scripts/bonfire/README.md create mode 100644 scripts/bonfire/apply-branding.exs create mode 100755 scripts/bonfire/apply-branding.sh create mode 100755 scripts/bonfire/gitbot-mirror.py create mode 100755 scripts/bonfire/install-systemd.sh create mode 100644 scripts/bonfire/publish-outbox-to-public.sql create mode 100644 scripts/caddy/README.md create mode 100644 scripts/castopod/README.md create mode 100755 scripts/castopod/apply-branding.sh create mode 100755 scripts/castopod/install-systemd.sh create mode 100644 scripts/castopod/lib.sh create mode 100644 scripts/castopod/status.sh create mode 100644 scripts/castopod/up.sh create mode 100755 scripts/monitoring/countries.txt create mode 100755 scripts/monitoring/tor_inbound_connects.sh create mode 100755 scripts/monitoring/tor_show_relays--koopa+firecuda.sh create mode 100755 scripts/monitoring/tor_show_relays.sh create mode 100755 scripts/monitoring/tor_stats_per_country.sh create mode 100644 scripts/taler-bank/README.md create mode 100755 scripts/taler-bank/auto-confirm-withdrawals.sh create mode 100755 scripts/taler-bank/check_bank-health.sh create mode 100755 scripts/taler-bank/credit-account.sh create mode 100755 scripts/taler-bank/demo-withdraw-api.py create mode 100755 scripts/taler-bank/install-demo-withdraw-api.sh create mode 100644 scripts/taler-bank/landing-stats-install.sh create mode 100755 scripts/taler-bank/landing-stats.sh create mode 100755 scripts/taler-bank/make-demo-withdraw-qr.sh create mode 100755 scripts/taler-bank/refresh-demo-withdraw.sh create mode 100755 scripts/taler-bank/start_bank.sh create mode 100755 scripts/taler-bank/start_base_services_for_taler_bank.sh create mode 100644 scripts/taler-dns/README.md create mode 100644 scripts/taler-dns/bake-hosts-into-images.sh create mode 100755 scripts/taler-dns/pin-container-hosts.sh create mode 100644 scripts/taler-dns/pin-hacktivism-hosts.in-container.sh create mode 100644 scripts/taler-dns/pin-hacktivism-hosts.service create mode 100644 scripts/taler-exchange/archive/README.md create mode 100755 scripts/taler-exchange/archive/exchange-bootstrap.sh create mode 100755 scripts/taler-exchange/check_exchange-health.sh create mode 100755 scripts/taler-exchange/ensure_exchange_helpers.sh create mode 100755 scripts/taler-exchange/install_no_terms.sh create mode 100644 scripts/taler-exchange/install_swiss_privacy.sh create mode 100644 scripts/taler-exchange/landing-stats-exchange.sh create mode 100755 scripts/taler-exchange/offline-sign-upload-keys.sh create mode 100755 scripts/taler-exchange/start_base_services_for_taler_exchange.sh create mode 100755 scripts/taler-exchange/start_exchange.sh create mode 100755 scripts/taler-exchange/start_wire_helpers.sh create mode 100755 scripts/taler-exchange/wire-enable-and-upload.sh create mode 100644 scripts/taler-landing/deploy-landings.sh create mode 100644 scripts/taler-merchant/README.md create mode 100755 scripts/taler-merchant/certbot_renew.sh create mode 100755 scripts/taler-merchant/check_merchant-health.sh create mode 100755 scripts/taler-merchant/ensure_merchant_helpers.sh create mode 100644 scripts/taler-merchant/install_dual_terms.sh create mode 100644 scripts/taler-merchant/install_swiss_privacy.sh create mode 100644 scripts/taler-merchant/landing-stats-merchant.sh create mode 100755 scripts/taler-merchant/setup_credit_facade.sh create mode 100755 scripts/taler-merchant/start_base_services_for_taler.sh create mode 100755 scripts/taler-merchant/start_merchant.sh create mode 100755 scripts/taler-merchant/stats--merchant-payments.sh create mode 100755 scripts/taler-merchant/taler-hacktivism-email-helper.sh create mode 100755 scripts/taler-merchant/taler-hacktivism-sms-helper-wrapper.sh create mode 100755 scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh create mode 100644 scripts/taler-monitoring/README.md create mode 100644 scripts/taler-monitoring/TESTS.md create mode 100755 scripts/taler-monitoring/check_e2e.sh create mode 100755 scripts/taler-monitoring/check_inside.sh create mode 100755 scripts/taler-monitoring/check_sanity.sh create mode 100755 scripts/taler-monitoring/check_server.sh create mode 100755 scripts/taler-monitoring/check_urls.sh create mode 100755 scripts/taler-monitoring/check_versions.sh create mode 100644 scripts/taler-monitoring/harness-live.txt create mode 100644 scripts/taler-monitoring/harness-tests.txt create mode 100755 scripts/taler-monitoring/lib.sh create mode 100755 scripts/taler-monitoring/taler-monitoring.sh create mode 100644 scripts/taler-sanity/README.md create mode 100644 scripts/taler-sanity/check_exchange-wirewatch.sh create mode 100755 scripts/taler-sanity/check_helpers-running.sh create mode 100644 scripts/taler-sanity/check_merchant-delays.sh create mode 100644 scripts/taler-sanity/check_settlement.sh create mode 100644 scripts/taler-sanity/check_stack-health.sh create mode 100644 scripts/taler-sanity/lib.sh create mode 100644 scripts/taler-sanity/run_all.sh create mode 100644 scripts/taler-shared/install_privacy_docs.sh create mode 100644 scripts/taler-shared/mem-snapshot.sh create mode 100644 scripts/taler-shared/terms-style.css.fragment create mode 100755 scripts/taler-wallet-cli/goa-deb-withdraw-pay.sh create mode 100755 scripts/taler-wallet-cli/goa-posix-withdraw-pay.sh create mode 100755 scripts/taler-wallet-cli/run-amount-ladder-bench.sh create mode 100755 scripts/taler-wallet-cli/run-extreme-amount-tests.sh create mode 100755 scripts/taler-wallet-cli/withdraw-qr1-goa.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5516c4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.bak +*.pyc +__pycache__/ +.DS_Store +*.swp +*~ +.tmp/ diff --git a/2026/2026-06-29.md b/2026/2026-06-29.md new file mode 100644 index 0000000..e6bf974 --- /dev/null +++ b/2026/2026-06-29.md @@ -0,0 +1,4 @@ +# 2026-06-29 + +- koopa-admin-log started +- merchant start/health; container TLS :8081 + certbot :8082 diff --git a/2026/2026-06-30.md b/2026/2026-06-30.md new file mode 100644 index 0000000..caa594b --- /dev/null +++ b/2026/2026-06-30.md @@ -0,0 +1,4 @@ +# 2026-06-30 + +- separate exchange container reverted → conf.d drop-in +- exchange start/health + GOA zz- drop-in diff --git a/2026/2026-07-01.md b/2026/2026-07-01.md new file mode 100644 index 0000000..8bc350c --- /dev/null +++ b/2026/2026-07-01.md @@ -0,0 +1,3 @@ +# 2026-07-01 + +- bank start scripts + GOA bank-overrides diff --git a/2026/2026-07-02.md b/2026/2026-07-02.md new file mode 100644 index 0000000..339dd5e --- /dev/null +++ b/2026/2026-07-02.md @@ -0,0 +1,3 @@ +# 2026-07-02 + +- exchange ensure-helpers; bank demo withdraw/credit helpers diff --git a/2026/2026-07-07.md b/2026/2026-07-07.md new file mode 100644 index 0000000..3aab877 --- /dev/null +++ b/2026/2026-07-07.md @@ -0,0 +1,3 @@ +# 2026-07-07 + +- taler.hacktivism.ch LE (certbot), notBefore ~20:44 CEST / notAfter ~2026-10-05 diff --git a/2026/2026-07-08.md b/2026/2026-07-08.md new file mode 100644 index 0000000..d2329bc --- /dev/null +++ b/2026/2026-07-08.md @@ -0,0 +1,6 @@ +# 2026-07-08 + +- Caddy live: taler LE certs (from 07.07.) → /etc/caddy/certs; :443 then 8085/8443 then 9000/9001 + ACME webroot +- firewalld 9000/9001/80; drop obsolete 443/8082 +- exchange GOA overrides (no KUDOS) +- exchange.hacktivism.ch LE via Caddy ACME ~21:36 CEST diff --git a/2026/2026-07-09--amount-ladder-partial-results.md b/2026/2026-07-09--amount-ladder-partial-results.md new file mode 100644 index 0000000..df357c7 --- /dev/null +++ b/2026/2026-07-09--amount-ladder-partial-results.md @@ -0,0 +1,47 @@ +# Amount ladder bench — partial results (interrupted 2026-07-09) + + +## Setup +| Item | Value | +|------|-------| +| Bank user | `bench-fat` | +| Password file (koopa host) | `/root/bank-bench-fat-password.txt` | +| Admin credit | GOA:100000 | +| Withdraw | GOA:25000 → wallet available GOA:25000 | +| Merchant instance | `goa-demo-cp4zqk` | +| Free-amount template | `goa-free` | +| Fixed template (legacy) | `goa` amount GOA:5 fixed-order | +| Withdraw wall time | ~393 s (confirm path issues) | + +## Payments (all failed — template URL 404) + +| # | Amount | handle (s) | rud (s) | total (s) | status | loadavg | +|--:|--------|----------:|--------:|----------:|--------|--------:| +| 1 | `GOA:0.000001` | 0.209 | 0.168 | **0.377** | error | 2.17 | +| 2 | `GOA:0.001` | 0.234 | 0.17 | **0.404** | error | 2.06 | +| 3 | `GOA:0.05` | 0.203 | 0.168 | **0.371** | error | 2.21 | +| 4 | `GOA:0.5` | 0.199 | 0.169 | **0.369** | error | 2.17 | +| 5 | `GOA:1` | 0.204 | 0.167 | **0.37** | error | 2.07 | +| 6 | `GOA:3` | 0.201 | 0.168 | **0.37** | error | 2.22 | +| 7 | `GOA:7` | 0.198 | 0.168 | **0.366** | error | 2.18 | +| 8 | `GOA:10` | 0.286 | 0.17 | **0.456** | error | 2.23 | +| 9 | `GOA:25` | 0.204 | 0.167 | **0.372** | error | 2.21 | +| 10 | `GOA:50` | 0.204 | 0.167 | **0.372** | error | 2.41 | +| 11 | `GOA:100` | 0.279 | 0.166 | **0.445** | error | 2.27 | +| 12 | `GOA:250` | 0.294 | 0.168 | **0.462** | error | 2.23 | +| 13 | `GOA:500` | 0.21 | 0.165 | **0.375** | error | 2.12 | +| 14 | `GOA:1000` | 0.202 | 0.169 | **0.37** | error | 2.33 | +| 15 | `GOA:2500` | 0.198 | 0.164 | **0.362** | error | 2.31 | +| 16 | `GOA:5000` | 0.201 | 0.167 | **0.368** | error | 2.43 | + +### Failure +Wallet requested: +``` +GET https://taler.hacktivism.ch/instances/goa-demo-cp4zqk/goa-free/templates/ +→ 404 +``` +URI used: `taler://pay-template/taler.hacktivism.ch/instances/goa-demo-cp4zqk/goa-free/?amount=…&summary=…` +Fix next: correct pay-template path / merchant public template endpoint. + +## Planned amount ladder (not all run) +`GOA:0.000001 0.001 0.05 0.5 1 3 7 10 25 50 100 250 500 1000 2500 5000` diff --git a/2026/2026-07-09--bonfire-ground-zero.md b/2026/2026-07-09--bonfire-ground-zero.md new file mode 100644 index 0000000..12402f8 --- /dev/null +++ b/2026/2026-07-09--bonfire-ground-zero.md @@ -0,0 +1,46 @@ +# Bonfire ground zero + gitbot (2026-07-09) + +## Stack + +| Item | Value | +|------|--------| +| Path | `/home/hernani/koopa-bonfire/` | +| Containers | `koopa-bonfire`, `koopa-bonfire-db` | +| Port | **9021** → Caddy `bonfire.hacktivism.ch` | +| Image | `bonfirenetworks/bonfire:1.0.5-social-amd64` | +| Env | secrets **64** chars; `DB_MIGRATE_INDEXES_CONCURRENTLY=false` | + +Ground zero: wiped volume + dir, clean migrate (import_me `20200828094944` applied, ~109 migrations, identity tables present). + +## Accounts + +| Login | Role | +|-------|------| +| `foss` / `foss@bonfire.hacktivism.ch` | operator account | +| character `gitbot` | same account (multi-profile) | + +Passwords: `/home/hernani/koopa-bonfire/users.env` (mode 600). + +Public: https://bonfire.hacktivism.ch/ + +## Gitbot (commit mirror) + +- Live: `~/koopa-bonfire/bin/gitbot-mirror.py` +- Mirror in repo: `scripts/bonfire/gitbot-mirror.py` +- State: `~/koopa-bonfire/gitbot-state.json` +- Poll Forgejo every **300s** (`gitbot-mirror.service`); log `~/koopa-bonfire/gitbot.log` +- No webhook — API only + +```bash +systemctl --user status gitbot-mirror.service +tail -20 ~/koopa-bonfire/gitbot.log +python3 ~/koopa-bonfire/bin/gitbot-mirror.py +``` + +More: `2026/2026-07-11--bonfire-gitbot-systemd.md`. + +## Lessons + +- First boot needs **≥64-byte** `SECRET_KEY_BASE`. +- Migrations: **`DB_MIGRATE_INDEXES_CONCURRENTLY=false`** on empty DB (avoids concurrent index in txn). +- Partial DB → wipe volume and ground zero rather than patch half-migrated state. diff --git a/2026/2026-07-09--castopod-content.md b/2026/2026-07-09--castopod-content.md new file mode 100644 index 0000000..a59749e --- /dev/null +++ b/2026/2026-07-09--castopod-content.md @@ -0,0 +1,34 @@ +# Castopod — admin note (2026-07-09) + +Site: **https://castopod.hacktivism.ch/** +Admin: https://castopod.hacktivism.ch/cp-admin + +## Stack + +| Item | Value | +|------|--------| +| Live root | `~/koopa-castopod/` | +| Compose | `podman-compose` (mirror: `configs/castopod/compose.yml`) | +| Containers | `koopa-castopod`, `koopa-castopod-mariadb`, `koopa-castopod-redis` | +| Host port | **9020** → Caddy | + +Secrets (not in this repo): `~/koopa-castopod/.env`, `~/koopa-castopod/users.env` (mode 600). +Also: `koopa-admin-secrets/…/koopa-castopod/`. + +## Accounts + +| User | Role | +|------|------| +| `admin` | instance superadmin | +| `ngi` | podcaster | + +## Ops + +```bash +cd ~/koopa-castopod && set -a && source .env && set +a && podman-compose up -d +# scripts: scripts/castopod/{up,status,lib}.sh +``` + +Podcast content is managed in Castopod, not in admin-log. + +Boot/branding: `2026/2026-07-13--castopod-boot-branding.md`, `configs/castopod/`, `scripts/castopod/`. diff --git a/2026/2026-07-09--exchange-snapshot-and-restore.md b/2026/2026-07-09--exchange-snapshot-and-restore.md new file mode 100644 index 0000000..f2ff730 --- /dev/null +++ b/2026/2026-07-09--exchange-snapshot-and-restore.md @@ -0,0 +1,201 @@ +# Exchange snapshot + restore (GOA) — 2026-07-09 + +**Scope:** local on host **koopa** only. No registry, no upload, no public internet publish. + +Container was **stopped** then **committed/saved**. Live name: `taler-hacktivism-exchange-ansible`. + +--- + +## 1. What was stored (local files) + +Directory (hernani on koopa): + +```text +/home/hernani/images/taler-stack-20260709-snapshot/ +``` + +| File | Role | Size (approx.) | +|------|------|----------------| +| `taler-hacktivism-exchange-ansible-live-20260709-snapshot.tar` | full exchange image archive | **~1.6 G** | +| `taler-hacktivism-live-20260709-snapshot.tar` | merchant archive (partial earlier run) | ~2.4 G | +| `SHA256SUMS` | checksums for completed saves | | + +**Podman images (local store only):** + +| Image | Tag | Notes | +|-------|-----|--------| +| `localhost/taler-hacktivism-exchange-ansible-live` | `20260709-snapshot` | committed from stopped container | +| `localhost/taler-hacktivism-exchange-ansible-live` | `9011` | previous live tag (still present) | +| `localhost/taler-hacktivism-live` | `20260709-snapshot` | merchant (if commit kept) | +| `localhost/taler-bank-hacktivism-live` | `20260709-snapshot` | bank (if commit kept) | + +Verify: + +```bash +# as hernani@koopa +ls -lh /home/hernani/images/taler-stack-20260709-snapshot/ +sha256sum -c /home/hernani/images/taler-stack-20260709-snapshot/SHA256SUMS +podman images | grep exchange +podman ps -a --filter name=taler-hacktivism-exchange-ansible +``` + +Expected container state after stop: + +```text +taler-hacktivism-exchange-ansible Exited (…) localhost/taler-hacktivism-exchange-ansible-live:9011 0.0.0.0:9011->9011/tcp +``` + +--- + +## 2. How the snapshot was made + +```bash +# as hernani@koopa — local only +podman stop taler-hacktivism-exchange-ansible + +TAG=20260709-snapshot +OUT=/home/hernani/images/taler-stack-$TAG +mkdir -p "$OUT" + +podman commit taler-hacktivism-exchange-ansible \ + localhost/taler-hacktivism-exchange-ansible-live:$TAG + +podman save -o "$OUT/taler-hacktivism-exchange-ansible-live-$TAG.tar" \ + localhost/taler-hacktivism-exchange-ansible-live:$TAG + +sha256sum "$OUT/taler-hacktivism-exchange-ansible-live-$TAG.tar" | tee -a "$OUT/SHA256SUMS" +``` + +`-p` on commit pauses the container if still running; after `stop` it is optional. + +--- + +## 3. Restore from image (same host) + +### A. From local image tag (fast) + +```bash +podman rm -f taler-hacktivism-exchange-ansible # only if replacing dead/exited + +podman run -d --name taler-hacktivism-exchange-ansible \ + --network pasta \ + -p 9011:9011 \ + --label org.hacktivism.service=taler-exchange \ + --label org.hacktivism.host_port=9011 \ + --label org.hacktivism.site=exchange.hacktivism.ch \ + --label org.hacktivism.currency=GOA \ + --label org.hacktivism.managed_by=koopa-admin \ + localhost/taler-hacktivism-exchange-ansible-live:20260709-snapshot \ + sleep infinity +``` + +### B. From tar (if image store was wiped) + +```bash +podman load -i /home/hernani/images/taler-stack-20260709-snapshot/taler-hacktivism-exchange-ansible-live-20260709-snapshot.tar +# then same podman run as above +``` + +### C. Start services (manual model, no systemd) + +Inside container as **root**, then app user: + +```bash +podman exec -u root -it taler-hacktivism-exchange-ansible bash +# 1) base: postgres, runtime dirs, secmods, wire helpers +/root/start_base_services_for_taler_exchange.sh --no-shell +# 2) httpd +runuser -u taler-exchange-httpd -- /usr/local/bin/start_exchange.sh --restart +# 3) health +/usr/local/bin/check_exchange-health.sh +``` + +Host / public checks: + +```bash +curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9011/config +curl -sS -o /dev/null -w "%{http_code}\n" https://exchange.hacktivism.ch/config +curl -sS -o /dev/null -w "%{http_code}\n" https://exchange.hacktivism.ch/keys +``` + +Caddy already proxies `exchange.hacktivism.ch` → host `:9011` (see `configs/caddy/`, `configs/ports.md`). + +--- + +## 4. GOA configuration (authoritative tree) + +**Do not** edit package `taler-exchange.conf` defaults by hand. Site config lives in overrides + coins (merchant pattern). + +| In container | In admin-log repo | +|--------------|-------------------| +| `/etc/taler-exchange/exchange-overrides.conf` | `configs/taler-exchange/exchange-overrides.conf` | +| `/etc/taler-exchange/conf.d/exchange-coins.conf` | `configs/taler-exchange/conf.d-exchange-coins.conf` | +| secrets (wire gateway) | **not** in admin-log → `koopa-admin-secrets` | +| start scripts | `scripts/taler-exchange/` | + +GOA essentials in overrides: + +- `CURRENCY = GOA`, `BASE_URL = https://exchange.hacktivism.ch/` +- `SERVE = tcp`, `PORT = 9011` +- `[currency-goa]` ENABLED + unit names (Yotta…Atomic) +- `[exchange-account-1]` payto `x-taler-bank/bank.hacktivism.ch/exchange` +- Terms stubs: `TERMS_ETAG` / `PRIVACY_ETAG` = `no-terms-v0` / `no-privacy-v0` + +Deploy overrides from the local machine (example): + +```bash +# from laptop, secrets never committed to admin-log +scp configs/taler-exchange/exchange-overrides.conf \ + hernani@koopa:/tmp/exchange-overrides.conf +scp configs/taler-exchange/conf.d-exchange-coins.conf \ + hernani@koopa:/tmp/exchange-coins.conf +ssh hernani@koopa 'podman cp /tmp/exchange-overrides.conf taler-hacktivism-exchange-ansible:/etc/taler-exchange/exchange-overrides.conf + podman cp /tmp/exchange-coins.conf taler-hacktivism-exchange-ansible:/etc/taler-exchange/conf.d/exchange-coins.conf' +``` + +Greenfield (empty Debian + packages) only: +`scripts/taler-exchange/archive/exchange-bootstrap.sh` **as root inside** container — generates a **new** master key; not the same as restoring this snapshot. + +After config change: + +```bash +runuser -u taler-exchange-httpd -- /usr/local/bin/start_exchange.sh --restart +# optional offline keys / wire: +# scripts/taler-exchange/wire-enable-and-upload.sh +# scripts/taler-exchange/offline-sign-upload-keys.sh +# /usr/local/bin/install_no_terms.sh +``` + +--- + +## 5. Ansible + +There is **no** Ansible playbook in `koopa-admin-log` / this monorepo path for the exchange. +Operational model is: **podman** + **manual start scripts** + **GOA overrides** as above. + +If an external Ansible tree is added later, it should only: + +1. `podman load` / ensure image tag +2. `podman run` with pasta + `9011:9011` + labels above +3. copy GOA configs from `configs/taler-exchange/` +4. run `start_base` → `start_exchange` +5. never push images to a public registry without explicit request + +--- + +## 6. Related docs + +| Doc | Content | +|-----|---------| +| `configs/taler-exchange/README.md` | GOA site notes, terms, ports | +| `scripts/taler-exchange/archive/README.md` | bootstrap vs daily start | +| `configs/ports.md` | 9011 pasta | +| `ops/ROOT_HYGIENE.md` | secrets layout | + +--- + +## 7. Session note (2026-07-09) + +- Exchange **stopped** on request (`Exited 137`). +- Snapshot **local only** under `/home/hernani/images/…`. +- Known pre-stop issue: wirewatch had died after Postgres ownership/outage → bank confirms OK but wallet “Withdraw money from bank…” could hang until wirewatch imports credits. After restore, always verify **wirewatch** is live (`check_exchange-health.sh`). diff --git a/2026/2026-07-09--ipv6-15d6.md b/2026/2026-07-09--ipv6-15d6.md new file mode 100644 index 0000000..b2647cf --- /dev/null +++ b/2026/2026-07-09--ipv6-15d6.md @@ -0,0 +1,34 @@ +# 2026-07-09 — IPv6 `:15d6` reachability + +## Symptom + +`https://exchange.hacktivism.ch/config` failed with `ERR_ADDRESS_UNREACHABLE` +when clients preferred AAAA `2a02:168:53a8:0:d584:9e34:13cf:15d6`. +IPv4 `212.51.151.254` worked (HTTP 200). + +## Path comparison + +| | IPv4 | IPv6 | +|--|------|------| +| Public endpoint | WAN IP DNAT | Host global `:15d6` | +| VeciGate | dstnat 443→9001 | forward 443 (no NAT) | +| Host listener | Caddy `:9001` | `https-proxy.socket` `:443` → 9001 | +| firewalld needed | 9001 | **443** (+ 80 for ACME) | + +## Fix applied + +```bash +# on koopa (root) +firewall-cmd --permanent --zone=public --add-port=443/tcp +firewall-cmd --reload +``` + +VeciGate: confirmed IPv6 filter allow 443; added explicit allow **80** for ACME. + +## Verify + +```bash +curl -6 -sS -o /dev/null -w '%{http_code} %{remote_ip}\n' \ + https://exchange.hacktivism.ch/config +# 200 2a02:168:53a8:0:d584:9e34:13cf:15d6 +``` diff --git a/2026/2026-07-09--keys-wire.md b/2026/2026-07-09--keys-wire.md new file mode 100644 index 0000000..5ee24a4 --- /dev/null +++ b/2026/2026-07-09--keys-wire.md @@ -0,0 +1,17 @@ +# 2026-07-09 — `/keys` + Wire + Merchant GOA + +## Done + +1. **Exchange `/keys`:** was suspended (no wire accounts → then no online signing keys). + - `enable-account` + `wire-fee` + `global-fee` → offline **upload** (local `BASE_URL=http://127.0.0.1:9011/`) + - `download` → `sign` → `upload` for denoms + signkeys + - Result: `GET /keys` **200**, ~1 MB, currency GOA, master `TW6K5FXF…` +2. **Merchant health:** `check_merchant-health.sh` includes exchange `/keys` + MASTER_KEY match → **ALL CRITICAL PASSED** (GOA via `host.containers.internal:9011` fallback). +3. **Wire helpers:** secret `exchange-accountcredentials-1.secret.conf` needs group `taler-exchange-db` mode 640; then aggregator/closer/wirewatch/transfer OK. +4. **Smoke:** local+public `/keys` 200; bank wire-gateway config as `exchange` → 200 on `:9012`; bank SPA public earlier (LE cert). Full phone Withdraw is manual (wallet app). +5. **Admin-log:** docs diagram keys/wire; scripts `wire-enable-and-upload.sh`, `offline-sign-upload-keys.sh`, `start_wire_helpers.sh`; merchant overrides GOA; ports 9012 noted. + +## Manual withdraw (phone) + +1. https://bank.hacktivism.ch/ → login `explorer` (pw host `/root/bank-explorer-password.txt`) +2. Withdraw GOA → open in Taler Wallet (exchange.hacktivism.ch `/keys` must stay 200) diff --git a/2026/2026-07-09--koopa-prime-jellyfin.md b/2026/2026-07-09--koopa-prime-jellyfin.md new file mode 100644 index 0000000..2a71180 --- /dev/null +++ b/2026/2026-07-09--koopa-prime-jellyfin.md @@ -0,0 +1,43 @@ +# koopa-prime — Jellyfin + qBittorrent (2026-07-09) + +## Containers (podman-compose) + +Path: `/home/hernani/koopa-prime/` + +| Container | Role | Host port | +|-----------|------|-----------| +| `koopa-prime-jellyfin` | Video library (Jellyfin) | **9022** | +| `koopa-prime-qbittorrent` | BitTorrent client | **9023** WebUI, **6881** BT | + +## Media wiring + +| Host path | Jellyfin | qBittorrent | +|-----------|----------|-------------| +| `/home/hernani/Downloads` | `/media/downloads` **ro** (library) | `/downloads` **rw** (save path) | + +Downloads land in the same folder Jellyfin scans. + +## Auth (login required) + +- **Jellyfin:** admin account created via Startup API; library access needs login. +- **qBittorrent:** WebUI password required (401 without session). + +Credentials: `~/koopa-prime/users.env` (mode 600). + +## Public / Caddy + +| Host | Backend | +|------|---------| +| `prime.hacktivism.ch` | `127.0.0.1:9022` | +| `torrent.prime.hacktivism.ch` | `127.0.0.1:9023` (needs DNS CNAME) | + +See session notes for Caddy snippets. BT port **6881** may need firewall/VeciGate if peers should connect. + +## Ops + +```bash +cd ~/koopa-prime +podman-compose ps +podman-compose logs -f jellyfin +podman-compose up -d +``` diff --git a/2026/2026-07-09--merchant-bank-link.md b/2026/2026-07-09--merchant-bank-link.md new file mode 100644 index 0000000..31a417a --- /dev/null +++ b/2026/2026-07-09--merchant-bank-link.md @@ -0,0 +1,8 @@ +# 2026-07-09 — Merchant demo + bank link + +1. Disabled mandatory SMS/email TAN on merchant (`MANDATORY_TAN_CHANNELS` empty). +2. Self-provisioned instance `goa-demo-cp4zqk` via `POST /instances` (HTTP 200, no challenge). +3. Created bank user `goa-demo-cp4zqk`, credited **GOA:5000**. +4. Linked payto to merchant: `POST .../private/accounts` → active wire account. + +See `configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md`. diff --git a/2026/2026-07-09.md b/2026/2026-07-09.md new file mode 100644 index 0000000..3c96245 --- /dev/null +++ b/2026/2026-07-09.md @@ -0,0 +1,6 @@ +# 2026-07-09 + +- bank.hacktivism.ch LE ~05:43 CEST +- public intros :9013–9015 + landing-stats; merchant stats fail-closed +- dual-currency terms + Swiss FADP privacy (merchant/exchange) +- taler-monitoring (+ versions vs deb.taler.net) diff --git a/2026/2026-07-10--admin-log-origin-forgejo.md b/2026/2026-07-10--admin-log-origin-forgejo.md new file mode 100644 index 0000000..48340e6 --- /dev/null +++ b/2026/2026-07-10--admin-log-origin-forgejo.md @@ -0,0 +1,40 @@ +# koopa-admin-log origin on git.hacktivism.ch (2026-07-10) + +## Remote + +| Item | Value | +|------|--------| +| Host | **Forgejo rootless** `https://git.hacktivism.ch/` | +| Repo | **https://git.hacktivism.ch/hernani/koopa-admin-log** | +| Visibility | **public** (ops log only; **no secret values**) | +| Default branch | `main` | +| HTTPS clone | `https://git.hacktivism.ch/hernani/koopa-admin-log.git` | +| SSH clone | `ssh://git@git.hacktivism.ch:9200/hernani/koopa-admin-log.git` | + +## Local setup + +```bash +cd /path/to/koopa-admin-log +git remote add origin https://git.hacktivism.ch/hernani/koopa-admin-log.git +# or SSH (port 9200): +# git remote add origin ssh://git@git.hacktivism.ch:9200/hernani/koopa-admin-log.git + +git push -u origin main +``` + +Auth: Forgejo user **`hernani`** (admin; registration disabled site-wide). +Credentials: `koopa-admin-secrets/…/koopa-forgejo/users.env` — not in this repo. + +## Related + +- Forgejo install: `2026-07-10--forgejo-rootless.md` +- Secrets sibling: **not** hosted here (private material stays in `koopa-admin-secrets` only) + +## Network (SSH :9200) + +Public git-SSH requires: + +1. VeciGate WAN/hairpin + IPv6 forward **9200** → koopa +2. koopa firewalld **9200/tcp** + +See `2026-07-10--forgejo-rootless.md` (Network exposure) and `../vecigate-admin-log/docs/CHANGELOG.md`. diff --git a/2026/2026-07-10--bonfire-federate.md b/2026/2026-07-10--bonfire-federate.md new file mode 100644 index 0000000..e24068e --- /dev/null +++ b/2026/2026-07-10--bonfire-federate.md @@ -0,0 +1,60 @@ +# Bonfire — FEDERATE + public guest access (2026-07-10) + +Follow-up to ground zero: `2026-07-09--bonfire-ground-zero.md`. + +## Change + +| Item | Before | After | +|------|--------|--------| +| `.env` `FEDERATE` | unset | **`true`** | +| Container | recreated | `podman-compose up -d --force-recreate web` | +| `printenv FEDERATE` (web) | — | `true` | +| `HOSTNAME` / compose | `bonfire.hacktivism.ch` | unchanged | +| Port | **9021** → Caddy | unchanged | + +Only new env key vs ground-zero `.env`: **`FEDERATE`**. + +Live: `/home/hernani/koopa-bonfire/.env` (mode 600). +Mirror: `koopa-admin-secrets/koopa/home-hernani/koopa-bonfire/.env`. +Compose mirror: `configs/bonfire/compose.yml`. + +## Intent + +- Allow federation-related runtime paths (`FEDERATE` consumed in Bonfire `runtime.exs` as `true|yes|1`). +- Keep **guest-readable** public surfaces (no login required for explore / profiles that are already public). +- Operator stance: **federation enablement is accepted as OK** even if the UI banner lags. + +## Observed behaviour (after recreate) + +| Check | Result | +|-------|--------| +| Local `http://127.0.0.1:9021/` | 200; guest UI (Explore / Log in) | +| Guest UI banner | still may show **“Federation disabled”** | +| App RPC (earlier probe) | `activity_pub` instance sometimes still `federating: false`, `hostname: "localhost"` despite env | +| Public profiles | guest-readable when already public | +| Front / local feed | can be sparse depending on boundaries / feed window | + +So: **env is set and container healthy**; full ActivityPub “federating=true” in Application config may still need admin UI / deeper config if outbound federation is required later. + +## Ops + +```bash +cd ~/koopa-bonfire +grep ^FEDERATE= .env +podman exec koopa-bonfire printenv FEDERATE HOSTNAME +podman ps --filter name=koopa-bonfire +curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9021/ +``` + +Recreate web after env change: + +```bash +cd ~/koopa-bonfire +set -a && source .env && set +a +podman-compose up -d --force-recreate web +``` + +## Secrets / log rules + +- Values only in **`koopa-admin-secrets`**, not in this repo. +- Daylog major bullet only (see `2026-07-10.md`). diff --git a/2026/2026-07-10--forgejo-rootless.md b/2026/2026-07-10--forgejo-rootless.md new file mode 100644 index 0000000..ebfc2cf --- /dev/null +++ b/2026/2026-07-10--forgejo-rootless.md @@ -0,0 +1,90 @@ +# Forgejo rootless — git.hacktivism.ch (2026-07-10) + +## Emphasis: rootless + +Forgejo is installed **rootless end-to-end**: + +1. **Image:** `codeberg.org/forgejo/forgejo:11-rootless` (process runs as UID/GID **1000**, not container root). +2. **Podman:** started as user **hernani** (rootless podman), **not** rootful. +3. **Userns:** `userns_mode: keep-id` so container 1000 maps to host hernani. +4. **Bind mounts:** `./data` → `/var/lib/gitea`, `./config` → `/etc/gitea` under `~/koopa-forgejo/`. + +Do **not** switch to the non-rootless `forgejo:11` image under rootless podman: that image refuses to run as mapped root (`Forgejo is not supposed to be run as root`). + +## Stack + +| Item | Value | +|------|--------| +| Path | `/home/hernani/koopa-forgejo/` | +| Containers | `koopa-forgejo` (**rootless**), `koopa-forgejo-db` (postgres:16-alpine) | +| HTTP | host **9024** → Caddy `git.hacktivism.ch` | +| SSH (git) | host **9200** → container 2222 (direct; not via Caddy) | +| ROOT_URL | `https://git.hacktivism.ch/` | +| Landing | explore (public browse OK) | +| LE cert | automatic via Caddy (`CN=git.hacktivism.ch`) | + +Compose + redacted `app.ini`: `configs/forgejo/`. +Caddy site block: `configs/caddy/git.hacktivism.ch.caddy` (+ full `configs/caddy/Caddyfile`). + +## Access policy + +| Setting | Value | +|---------|--------| +| `DISABLE_REGISTRATION` | **true** — no free self-signup | +| `SHOW_REGISTRATION_BUTTON` | **false** | +| OpenID signup/signin | **false** | +| Public site | readable without login (`REQUIRE_SIGNIN_VIEW=false`) | +| Accounts | admin-created only (`forgejo admin user create`) | + +Reserved username `admin` cannot be used; first admin: **`hernani`**. + +Passwords: `~/koopa-forgejo/users.env` + DB/secrets in `.env` (mode 600). +Mirror: `koopa-admin-secrets/koopa/home-hernani/koopa-forgejo/`. + +## Start / status + +```bash +cd ~/koopa-forgejo +set -a && source .env && set +a +podman-compose up -d +podman ps --filter name=koopa-forgejo +curl -sS http://127.0.0.1:9024/api/healthz +curl -sS https://git.hacktivism.ch/api/healthz +``` + +## Caddy (host root) + +Live `/etc/caddy/Caddyfile` matches `configs/caddy/Caddyfile` (applied 2026-07-10; `http://git.` in ACME list; SSH note **9200**). + +Site block live in `/etc/caddy/Caddyfile` (mirror under `configs/caddy/`). +Prefer including `http://git.hacktivism.ch` in the shared ACME/redirect host list. + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` + +## Network exposure (2026-07-10) + +| Layer | Rule | +|-------|------| +| **VeciGate IPv4** | WAN **9200** → `192.168.100.95:9200`; hairpin LAN/LAN2 → public A:9200 | +| **VeciGate IPv6** | WAN→LAN forward **tcp/9200** | +| **koopa firewalld** | `public` **9200/tcp** (verified 2026-07-10: LAN + hairpin + `ssh -T`) | + +Docs: `../vecigate-admin-log` (`ip/firewall/nat/`, `ipv6/firewall/filter/`, `docs/CHANGELOG.md`); +host ports: `configs/firewalld/public-ports.md`. + +```bash +# host +sudo firewall-cmd --permanent --add-port=9200/tcp && sudo firewall-cmd --reload +# client +ssh -p 9200 -T git@git.hacktivism.ch +``` + +## Notes + +- DNS: `git.hacktivism.ch` → koopa (same family as other `*.hacktivism.ch`). +- Git over SSH: `ssh://git@git.hacktivism.ch:9200/…` +- Clone URLs advertise `SSH_PORT=9200`; container still listens on internal **2222**. +- Admin-log origin: `hernani/koopa-admin-log` on this Forgejo — see `2026-07-10--admin-log-origin-forgejo.md`. diff --git a/2026/2026-07-10--forgejo-theme-exchange-dark.md b/2026/2026-07-10--forgejo-theme-exchange-dark.md new file mode 100644 index 0000000..f255abe --- /dev/null +++ b/2026/2026-07-10--forgejo-theme-exchange-dark.md @@ -0,0 +1,27 @@ +# 2026-07-10 — Forgejo global theme (exchange-dark) + +## Goal + +Make https://git.hacktivism.ch look closer to https://exchange.hacktivism.ch: warm dark layout, gold + teal accents, readable text. + +## Done on koopa + +- Custom theme file `theme-hacktivism.css` (full variable set from forgejo-dark + exchange palette). +- Installed under `~/koopa-forgejo/data/custom/public/assets/css/` (and gitea/custom mirror). +- Logo/favicon under `…/assets/img/`. +- `DEFAULT_THEME = hacktivism` in app.ini + compose `FORGEJO__ui__*`. +- Forced DB: `UPDATE "user" SET theme = 'hacktivism'` (logged-in preference was still `forgejo-auto`). + +## Readability + +- Body/text cream on `#1a1410`; muted secondary not below ~`#c9b8a0`. +- Gold primary buttons with dark label text. +- Explicit overrides for menus, forms, code, labels, messages, footer. + +## Admin-log + +Source of truth: `configs/forgejo/assets/` + `configs/forgejo/README.md`. + +## Note + +Browser cache may hold old CSS (`Cache-Control: max-age=21600`). Hard-reload or private window if UI looks stale. diff --git a/2026/2026-07-10.md b/2026/2026-07-10.md new file mode 100644 index 0000000..95c9812 --- /dev/null +++ b/2026/2026-07-10.md @@ -0,0 +1,21 @@ +# 2026-07-10 + +- landing mem snapshot fix (pipeline → 0 B) +- Caddy redir / → /intro/ (three sites) +- SECRETS.md paths only; no secret material in admin-log +- **Bonfire** `FEDERATE=true` (+ web recreate); guest public profiles OK; UI may still say federation disabled + → topic: `2026-07-10--bonfire-federate.md`; secrets: `koopa-admin-secrets/…/koopa-bonfire/` +- **Forgejo rootless** on `git.hacktivism.ch` (HTTP **9024**, git-SSH **9200**); no free registration; admin `hernani` + → topic: `2026-07-10--forgejo-rootless.md`; secrets: `koopa-admin-secrets/…/koopa-forgejo/` +- **git-SSH :9200** exposed + **verified**: VeciGate DNAT/hairpin + IPv6; firewalld **9200/tcp** (LAN/hairpin/`ssh -T` OK) + → vecigate-admin-log CHANGELOG 2026-07-10; `configs/firewalld/public-ports.md` +- **admin-log origin** → Forgejo `hernani/koopa-admin-log` on git.hacktivism.ch (public; SSH :9200) + → topic: `2026-07-10--admin-log-origin-forgejo.md` +- admin-log: castopod/prime compose mirrors; firewalld list-all +9200; Caddyfile.taler-host marked legacy +- admin-log: Tor relay mirror documented (`configs/tor/`, host/tor) +- Caddyfile applied on host: git. in ACME/redirect list, SSH note :9200; live matches configs/caddy mirror (verified) +- Bonfire: republish foss outbox → Local users + public internet feeds; guest `/feed/local` shows posts +- Reflect live koopa state in admin-log: `host/overview/LIVE.md`, exchange name **taler-hacktivism-exchange-ansible**, ansible mirror +- configs/: rename mirrors to live container names (`taler-hacktivism`, `taler-hacktivism-bank`; image banking) +- **Forgejo branding**: theme `hacktivism` (exchange-dark) + Kamek-inspired logo (blue robe, flying on wand); compose DEFAULT_THEME + → `configs/forgejo/` diff --git a/2026/2026-07-11--bonfire-branding-feeds.md b/2026/2026-07-11--bonfire-branding-feeds.md new file mode 100644 index 0000000..6e808bf --- /dev/null +++ b/2026/2026-07-11--bonfire-branding-feeds.md @@ -0,0 +1,25 @@ +# Bonfire branding + public feeds (2026-07-11) + +After `2026/2026-07-11--bonfire-gitbot-systemd.md`. Logo: `2026/2026-07-11--bonfire-logo-not-served.md`. + +## Branding + +- hacktivism magician logo (same as git.hacktivism.ch). +- Deploy: `scripts/bonfire/apply-branding.sh` +- Logo URL: `/images/hacktivism-logo.svg` (compose bind-mount). + +**Official:** [Admin Tools — Instance Settings](https://docs.bonfirenetworks.org/admin-tools.html) (name/icon in UI). +Instance theme via `bonfire remote` matches [Settings System](https://docs.bonfirenetworks.org/settings_system.html) (`scope: :instance`). +No official doc for custom static files in podman — bind-mount workaround only. + +## Public feed (ops) + +Gitbot activity can stay in the user outbox; guests on `/` may see a stale feed until republished. + +**Official:** [Feed structure](https://docs.bonfirenetworks.org/feed_structure.html) (`FeedPublish`). +No official “republish outbox to local feed” procedure — ops SQL + gitbot workaround. + +- Continuous: `gitbot-mirror.py` republishes outbox → local + internet every cycle. +- One-off: `scripts/bonfire/publish-outbox-to-public.sql`. + +Check: https://bonfire.hacktivism.ch/feed/local diff --git a/2026/2026-07-11--bonfire-gitbot-systemd.md b/2026/2026-07-11--bonfire-gitbot-systemd.md new file mode 100644 index 0000000..5abce7b --- /dev/null +++ b/2026/2026-07-11--bonfire-gitbot-systemd.md @@ -0,0 +1,26 @@ +# Bonfire gitbot + boot (2026-07-11) + +After `2026/2026-07-09--bonfire-ground-zero.md`. + +## Problems + +- Bare hostnames in commit text crash `createPost` (`URI.parse/1`). +- No webhook; bot polls git.ngi-0.eu every 300s. +- Stack did not start on boot. + +## Fix + +- `gitbot-mirror.py`: prefix bare hosts, fallback post, skip after 3 fails. +- User systemd: `gitbot-mirror.service`, `container-koopa-bonfire-*.service` (`podman-compose up -d`). +- Install: `scripts/bonfire/install-systemd.sh` + +**Official:** [Hosting guide](https://docs.bonfirenetworks.org/deploy.html) recommends Co-op Cloud; we run podman-compose on koopa (ops choice, not upstream path). +GraphQL `createPost`: [API reference](https://docs.bonfirenetworks.org/api-reference.html) — no official gitbot mirror doc; custom script. + +## Ops + +```bash +systemctl --user status container-koopa-bonfire-db.service container-koopa-bonfire.service gitbot-mirror.service +tail -30 ~/koopa-bonfire/gitbot.log +python3 ~/koopa-bonfire/bin/gitbot-mirror.py +``` \ No newline at end of file diff --git a/2026/2026-07-11--bonfire-logo-not-served.md b/2026/2026-07-11--bonfire-logo-not-served.md new file mode 100644 index 0000000..efa27e4 --- /dev/null +++ b/2026/2026-07-11--bonfire-logo-not-served.md @@ -0,0 +1,19 @@ +# Bonfire logo not served (2026-07-11) + +After `scripts/bonfire/apply-branding.sh` the sidebar showed a broken image. + +## Symptom + +- `instance_icon` was `/images/hacktivism-logo.svg` but the file returned 404 +- `data/branding/` on disk is not a public URL; `/branding/` is not served by Bonfire + +## Official docs + +[Admin Tools — Instance Settings](https://docs.bonfirenetworks.org/admin-tools.html) covers instance name and icon in the admin UI only. + +No official guide for custom static files in Docker/podman. We use a bind-mount workaround. + +## Fix + +`compose.yml` mounts `data/branding/logo.svg` into `priv/static/images/hacktivism-logo.svg`. +Recreate web: `podman-compose up -d --no-deps --force-recreate web` \ No newline at end of file diff --git a/2026/2026-07-11.md b/2026/2026-07-11.md new file mode 100644 index 0000000..60875ad --- /dev/null +++ b/2026/2026-07-11.md @@ -0,0 +1,5 @@ +# 2026-07-11 + +- Bonfire gitbot + boot systemd → `2026/2026-07-11--bonfire-gitbot-systemd.md` +- Bonfire logo not served → `2026/2026-07-11--bonfire-logo-not-served.md` +- Bonfire branding + public feeds → `2026/2026-07-11--bonfire-branding-feeds.md` \ No newline at end of file diff --git a/2026/2026-07-13--castopod-boot-branding.md b/2026/2026-07-13--castopod-boot-branding.md new file mode 100644 index 0000000..c6e27b3 --- /dev/null +++ b/2026/2026-07-13--castopod-boot-branding.md @@ -0,0 +1,31 @@ +# Castopod — boot + branding (2026-07-13) + +Site: **https://castopod.hacktivism.ch/** · `~/koopa-castopod/` · Caddy **:9020** + +## Boot + +User systemd + linger (like Bonfire): + +```bash +scripts/castopod/install-systemd.sh +systemctl --user status container-koopa-castopod{,-mariadb,-redis}.service +``` + +Units: `configs/castopod/container-koopa-castopod*.service` + +## Branding + +Official settings: https://docs.castopod.org/main/en/user-guide/instance/settings/ +Docker: https://docs.castopod.org/main/en/getting-started/docker/ + +| Item | Host choice | +|------|-------------| +| Site icon | Magician logo (`configs/castopod/assets/img/`) | +| Theme | Castopod accent **amber** | +| Optional CSS | `assets/css/theme-hacktivism-overlay.css` (not upstream) | + +```bash +scripts/castopod/apply-branding.sh +``` + +Stack/accounts: `2026/2026-07-09--castopod-content.md`. diff --git a/2026/README.md b/2026/README.md new file mode 100644 index 0000000..6d32a14 --- /dev/null +++ b/2026/README.md @@ -0,0 +1,3 @@ +# Day logs 2026 + +Major ops only. One file per work day; written the same day (not backfilled). diff --git a/README.md b/README.md new file mode 100644 index 0000000..c165035 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# koopa-admin-log + +Ops log and config mirror for host **koopa** (openSUSE Tumbleweed). + +**Day logs:** `2026/` (major only, written same day). Topic notes: `YYYY-MM-DD--topic.md`. + +**Secrets:** not in this repo — see **`SECRETS.md`** and sibling **`koopa-admin-secrets`**. + +## Git origin + +| | | +|--|--| +| **origin** | `https://git.hacktivism.ch/hernani/koopa-admin-log.git` | +| Web | https://git.hacktivism.ch/hernani/koopa-admin-log | +| SSH | `ssh://git@git.hacktivism.ch:9200/hernani/koopa-admin-log.git` | + +Hosted on **Forgejo rootless** (this host). See `2026/2026-07-10--admin-log-origin-forgejo.md`. + +## Layout + +``` +host/ # openSUSE host-level config + overview + overview/services.md # graphical service map + systemd/ firewalld/ caddy/ network/ +configs/ # mirrored app configs (caddy, taler, forgejo, …) + taler-hacktivism/ # merchant container taler-hacktivism + taler-hacktivism-bank/ # bank container (image …-banking) + taler-exchange/ # exchange conf (inside ansible container) + taler-exchange-ansible/ # container taler-hacktivism-exchange-ansible + forgejo/ # rootless git.hacktivism.ch + castopod/ bonfire/ prime/ # compose mirrors (no secret values) + tops/ # koopa-tops-ng1…ng3 + autostart unit + tor/ # KoopaRelay ORPort 8080 +scripts/ # merchant, exchange, monitoring helpers + taler-merchant/ # merchant-model start scripts + taler-exchange/ # same model; archive/ = greenfield bootstrap +2026/ # day logs + dated topic notes +``` + +## Live containers (podman) + +**Authoritative snapshot of host koopa:** [`host/overview/LIVE.md`](host/overview/LIVE.md) (refreshed from live). + +| Name (podman) | Role | Host port | +|---------------|------|-----------| +| `taler-hacktivism` | merchant | **9010** (+ landing **9015**) | +| **`taler-hacktivism-exchange-ansible`** | exchange (Ansible/systemd) | **9011** (+ landing **9014**) | +| `taler-hacktivism-bank` | libeufin-bank (GOA) | **9012** (+ landing **9013**) | +| `koopa-castopod` (+ mariadb/redis) | Castopod | **9020** | +| `koopa-bonfire` (+ postgres) | Bonfire | **9021** | +| `koopa-prime-jellyfin` | Jellyfin | **9022** | +| `koopa-prime-qbittorrent` | qBittorrent | **9023** | +| `koopa-forgejo` (+ postgres) | Forgejo rootless | **9024** + SSH **9200** | +| `koopa-tops-ng1` … `ng3` | static nginx | **9090**–**9092** | + +Start model (Taler merchant/bank): **root** `start_base_services_*` → `/usr/local/bin/start_*.sh`. +Start model (exchange): `~/ansible-taler-exchange/` → `run-container-koopa.sh` + `deploy-hacktivism-goa.sh`. +Start model (user apps): `hernani` → `cd ~/koopa-* && podman-compose up -d`. +Start model (tops): user unit `container-koopa-tops.service` (linger). + +Public hosts: `taler.` / `exchange.` / `bank.` / `castopod.` / `bonfire.` / `prime.` / `bt.` / **`git.hacktivism.ch`** / `tops.ng1`–`ng3`.hacktivism.ch +(Caddy → 9010–9015 / 9020–**9024** / **9090–9092**; git-SSH host **9200** direct). + +Service **details**: `configs/taler-hacktivism*`, `configs/taler-exchange*`, `configs/forgejo/`, `configs/tops/`, `scripts/*`. +Host-wide picture: **`host/overview/services.md`**. +Port table: **`configs/ports.md`**. +Router: **`../vecigate-admin-log`**. + +**Benchmarks** (drive from the local machine, measure on koopa): **`benchmarks/`** +→ amount ladder: `benchmarks/amount-ladder/`. + +**Exchange snapshot/restore (GOA, local images only):** +→ `2026/2026-07-09--exchange-snapshot-and-restore.md` + +**Exchange `/keys` + Wire + bank account (diagram):** +`configs/taler-exchange/README.md` (section “/keys, Wire and Account”). + +**Castopod (stack/accounts/boot — not podcast content):** +`2026/2026-07-09--castopod-content.md` → https://castopod.hacktivism.ch/ + +**Bonfire FEDERATE + guest public (2026-07-10):** +`2026/2026-07-10--bonfire-federate.md` → https://bonfire.hacktivism.ch/ +(ground zero: `2026/2026-07-09--bonfire-ground-zero.md`) + +**Forgejo rootless (git.hacktivism.ch, no free registration, SSH :9200):** +`2026/2026-07-10--forgejo-rootless.md` → https://git.hacktivism.ch/ diff --git a/SECRETS.md b/SECRETS.md new file mode 100644 index 0000000..cf70fa3 --- /dev/null +++ b/SECRETS.md @@ -0,0 +1,34 @@ +# Secrets — not stored in this repo + +Live passwords/keys live in sibling **`koopa-admin-secrets`**. + +## Map + +### Taler — host `/root` + +| What | Mirror (`koopa-admin-secrets/…`) | Live | +|------|----------------------------------|------| +| Bank admin/exchange/explorer/demo/bench | `koopa/host-root/taler-bank/*.txt` | `/root/bank-*-password.txt` | +| Merchant instance goa-demo | `koopa/host-root/taler-merchant/…` | `/root/merchant-*-password.txt` | +| Exchange ATTRIBUTE_ENCRYPTION_KEY | `containers/taler-exchange/secrets/exchange-attribute-encryption.secret.conf` | container `/etc/taler-exchange/secrets/` | +| Exchange wire / DB | `…/*.secret.conf` (+ `.example` templates) | same under container | + +### Apps — `/home/hernani/` + +| What | Mirror | Live | +|------|--------|------| +| Castopod | `koopa/home-hernani/koopa-castopod/{.env,users.env}` (+ compose mirror in admin-log `configs/castopod/`) | `~/koopa-castopod/` | +| Bonfire (`FEDERATE=true`) | `koopa/home-hernani/koopa-bonfire/{.env,users.env,compose.yml}` | `~/koopa-bonfire/` | +| 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/` | + +## Rules + +- **koopa-admin-log**: paths only; `@inline-secret@` in configs, never password/key material. +- Pull/deploy: `koopa-admin-secrets/scripts/{pull,deploy}-from-koopa.sh` +- Full inventory: `koopa-admin-secrets/MAP.md` + +## Public (OK here) + +- `MASTER_PUBLIC_KEY` / merchant `MASTER_KEY` (exchange public master key) +- Port maps, unit ladders, non-secret overrides diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..9f618bf --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,10 @@ +# Benchmarks (koopa Taler stack) + +Run **from the local machine** against public endpoints. +**Measure load inside** the three Podman containers on koopa via `ssh hernani@koopa` + `podman exec` (no root/screen required for metrics). + +| Benchmark | What it does | +|-----------|----------------| +| [`amount-ladder/`](amount-ladder/) | Free-amount template payments across a wide GOA ladder; wall times + host/container CPU/RSS samples | + +Secrets: sibling repo **`koopa-admin-secrets`** (never committed here). diff --git a/benchmarks/amount-ladder/README.md b/benchmarks/amount-ladder/README.md new file mode 100644 index 0000000..666006d --- /dev/null +++ b/benchmarks/amount-ladder/README.md @@ -0,0 +1,90 @@ +# Amount-ladder payment benchmark + +## Model + +| Side | Machine | Role | +|------|---------|------| +| **Drive (external)** | Local machine (this tree) | `taler-wallet-cli`, orchestration, CSV/RESULTS | +| **SUT (internal)** | koopa containers | merchant / exchange / bank under load | +| **Measure (internal)** | `ssh hernani@koopa` | `loadavg`, `ps` in each container | + +``` +[ Local-machine wallet-cli ] --HTTPS--> taler.hacktivism.ch / exchange / bank + | ^ + | ssh sample_load | pasta :9010/:9011/:9012 + v | +[ hernani@koopa ] --podman exec--> process CPU/RSS snapshots +``` + +## Prerequisites + +1. Stack healthy (`check_*-health.sh` all green; public `/config` + exchange `/keys` 200). +2. Local wallet CLI (default path in script, override `MONO=`). +3. Secrets in **`../koopa-admin-secrets/koopa/host-root/`**: + - `taler-bank/bank-admin-password.txt` + - `taler-bank/bank-bench-fat-password.txt` (created if missing) + - `taler-merchant/merchant-goa-demo-cp4zqk-password.txt` +4. SSH: `hernani@koopa` BatchMode, `podman` without su. + +## Amount ladder (~20 payments: small / medium / large) + +| Band | Amounts (GOA) | +|------|----------------| +| **small** | `0.000001` `0.00001` `0.0001` `0.001` `0.01` `0.05` `0.1` `0.5` | +| **medium** | `1` `2` `5` `10` `25` `50` `100` | +| **large** | `250` `500` `1000` `2500` `5000` | + +Default list (20 steps): + +```text +GOA:0.000001 GOA:0.00001 GOA:0.0001 GOA:0.001 GOA:0.01 GOA:0.05 GOA:0.1 GOA:0.5 +GOA:1 GOA:2 GOA:5 GOA:10 GOA:25 GOA:50 GOA:100 +GOA:250 GOA:500 GOA:1000 GOA:2500 GOA:5000 +``` + +Σ ladder ≈ **GOA:9433** → default withdraw **GOA:50000**, admin credit **GOA:200000**. + +Override: `AMOUNTS='GOA:1 GOA:10' ./run.sh` + +## Pay-template URI (important) + +Do **not** put a trailing slash after the template id (wallet then 404s). + +```text +taler://pay-template/taler.hacktivism.ch/instances/goa-demo-cp4zqk/goa-free?amount=GOA:1 +``` + +- Template `goa-free` is free-amount with fixed summary `"pay"` → pass **only** `amount=` (no `summary=` or merchant 409). +- HTTP GET probe: + `https://taler.hacktivism.ch/instances/goa-demo-cp4zqk/templates/goa-free` → 200. + +## Run + +```bash +cd koopa-admin-log/benchmarks/amount-ladder +./run.sh +# short: +AMOUNTS='GOA:0.05 GOA:1 GOA:10' WITHDRAW_AMT=GOA:100 ./run.sh +``` + +Outputs under `../../.tmp/amount-bench-/`: + +| File | Content | +|------|---------| +| `payments.csv` | per-amount timings + status | +| `RESULTS.md` | table summary | +| `koopa-load-*.out` | **inside** process samples | +| `run.log` | timeline | + +## Columns (RESULTS) + +- **handle**: wall time of `wallet handle-uri` +- **run-until-done**: wallet background after handle +- **total**: sum +- **loadavg**: koopa 1‑min load after payment +- **status**: `paid` / `error` / `insufficient` / … + +## Related + +- Partial earlier run (wrong URI): `2026/2026-07-09--amount-ladder-partial-results.md` +- Instance notes: `configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md` diff --git a/benchmarks/amount-ladder/run.sh b/benchmarks/amount-ladder/run.sh new file mode 100755 index 0000000..b8e337d --- /dev/null +++ b/benchmarks/amount-ladder/run.sh @@ -0,0 +1,375 @@ +#!/bin/bash +# Amount-ladder benchmark — drive from the local machine, measure on koopa (inside containers). +# See README.md in this directory. +set -eu + +BENCH_DIR=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "$BENCH_DIR/../.." && pwd) +SECRETS="${SECRETS_DIR:-}" +if [ -z "$SECRETS" ]; then + if [ -d "$ROOT/../koopa-admin-secrets" ]; then + SECRETS=$(cd "$ROOT/../koopa-admin-secrets" && pwd) + else + SECRETS="$ROOT/../koopa-admin-secrets" + fi +fi + +SCRATCH="${ROOT}/.tmp/amount-bench-$(date -u +%Y%m%d-%H%M%S)" +mkdir -p "$SCRATCH" +chmod 700 "$SCRATCH" + +MONO="${MONO:-/Users/newkamek/src/taler/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs}" +WDB="$SCRATCH/wallet.sqlite3" +EX="${EXCHANGE_URL:-https://exchange.hacktivism.ch/}" +KOOPA="${KOOPA_HOST:-koopa}" +INST="${MERCHANT_INSTANCE:-goa-demo-cp4zqk}" +TPL="${TEMPLATE_ID:-goa-free}" +MER_HOST="${MER_HOST:-taler.hacktivism.ch}" +FAT_USER="${FAT_USER:-bench-fat}" +# ~20 payments: small → medium → large (atomic/tiny through multi-kilo) +# Override: AMOUNTS='GOA:1 GOA:10' ./run.sh +AMOUNTS="${AMOUNTS:-GOA:0.000001 GOA:0.00001 GOA:0.0001 GOA:0.001 GOA:0.01 GOA:0.05 GOA:0.1 GOA:0.5 GOA:1 GOA:2 GOA:5 GOA:10 GOA:25 GOA:50 GOA:100 GOA:250 GOA:500 GOA:1000 GOA:2500 GOA:5000}" +# sum of default ladder ≈ GOA:9433 → withdraw headroom for multi-coin + fees +WITHDRAW_AMT="${WITHDRAW_AMT:-GOA:50000}" +CREDIT_AMT="${CREDIT_AMT:-GOA:200000}" + +BANK_ADMIN_PW_FILE="${BANK_ADMIN_PW_FILE:-$SECRETS/koopa/host-root/taler-bank/bank-admin-password.txt}" +BANK_FAT_PW_FILE="${BANK_FAT_PW_FILE:-$SECRETS/koopa/host-root/taler-bank/bank-bench-fat-password.txt}" +MER_PW_FILE="${MER_PW_FILE:-$SECRETS/koopa/host-root/taler-merchant/merchant-${INST}-password.txt}" + +wcli() { node "$MONO" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"; } +log() { echo "$(date -u +%H:%M:%S) $*" | tee -a "$SCRATCH/run.log"; } +now() { python3 -c 'import time;print(time.time())'; } +die() { log "FAIL $*"; exit 1; } + +[ -f "$MONO" ] || die "wallet cli missing: $MONO" +[ -f "$BANK_ADMIN_PW_FILE" ] || die "missing $BANK_ADMIN_PW_FILE" +[ -f "$MER_PW_FILE" ] || die "missing $MER_PW_FILE" + +ADMIN_PW=$(tr -d '\n' <"$BANK_ADMIN_PW_FILE") +MER_PW=$(tr -d '\n' <"$MER_PW_FILE") +if [ -f "$BANK_FAT_PW_FILE" ]; then + FAT_PW=$(tr -d '\n' <"$BANK_FAT_PW_FILE") +else + FAT_PW=$(openssl rand -hex 12) + mkdir -p "$(dirname "$BANK_FAT_PW_FILE")" + printf '%s\n' "$FAT_PW" >"$BANK_FAT_PW_FILE" + chmod 600 "$BANK_FAT_PW_FILE" + log "generated fat password -> $BANK_FAT_PW_FILE (secrets repo)" +fi + +# --- remote: run short script as hernani, capture stdout --- +run_koopa() { + local id="$1" + shift + local script_local="$SCRATCH/remote-$id.sh" + local mark="BENCH_${id}_OK" + { + echo '#!/bin/bash' + echo 'set +e' + printf '%s\n' "$@" + echo "echo $mark" + } >"$script_local" + scp -o BatchMode=yes -q "$script_local" "${KOOPA}:/tmp/bench-run-$id.sh" + ssh -o BatchMode=yes "$KOOPA" "bash /tmp/bench-run-$id.sh; rm -f /tmp/bench-run-$id.sh" \ + >"$SCRATCH/koopa-$id.out" 2>&1 || true + if ! grep -q "$mark" "$SCRATCH/koopa-$id.out" 2>/dev/null; then + log "WARN run_koopa $id incomplete (see koopa-$id.out)" + return 1 + fi + return 0 +} + +# Measure **inside** containers (and host loadavg) +sample_load() { + local tag="$1" + scp -o BatchMode=yes -q "$BENCH_DIR/sample-load-inside.sh" "${KOOPA}:/tmp/sample-load-inside.sh" + ssh -o BatchMode=yes "$KOOPA" "bash /tmp/sample-load-inside.sh $(printf %q "$tag"); rm -f /tmp/sample-load-inside.sh" \ + >"$SCRATCH/koopa-load-$tag.out" 2>&1 || true +} + +log "scratch=$SCRATCH" +log "AMOUNTS=$AMOUNTS" + +# --- preflight (external, public HTTPS) --- +for url in \ + "https://taler.hacktivism.ch/config" \ + "https://exchange.hacktivism.ch/keys" \ + "https://taler.hacktivism.ch/instances/${INST}/templates/${TPL}" +do + code=$(curl -sk -o /dev/null -w '%{http_code}' -m 10 "$url" || echo err) + log "preflight $url -> $code" + [ "$code" = "200" ] || die "preflight failed $url" +done + +# --- bank setup on koopa (API localhost, orchestrated from the local machine) --- +log "=== bank setup $FAT_USER credit=$CREDIT_AMT withdraw=$WITHDRAW_AMT ===" +{ + echo '#!/bin/bash' + echo 'set -euo pipefail' + echo "export ADMIN_PW=$(printf %q "$ADMIN_PW")" + echo "export FAT_PW=$(printf %q "$FAT_PW")" + echo "export FAT_USER=$(printf %q "$FAT_USER")" + echo "export CREDIT_AMT=$(printf %q "$CREDIT_AMT")" + echo "export WITHDRAW_AMT=$(printf %q "$WITHDRAW_AMT")" + cat <<'EOS' +BANK=http://127.0.0.1:9012 +AT=$(curl -sS -u "admin:${ADMIN_PW}" -H "Content-Type: application/json" -d '{"scope":"readwrite"}' \ + "$BANK/accounts/admin/token" | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])") +echo admin_tok_len=${#AT} +curl -sS -o /tmp/acc.json -w "create:%{http_code}\n" -X POST -H "Authorization: Bearer $AT" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"${FAT_USER}\",\"password\":\"${FAT_PW}\",\"name\":\"Bench Fat\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"GOA:0\"}" \ + "$BANK/accounts" || true +echo create_body=$(head -c 160 /tmp/acc.json) +python3 - <<'PY' +import json, os +A="0123456789ABCDEFGHJKMNPQRSTVWXYZ" +def c32(b): + v=bits=0; o=[] + for x in b: + v=(v<<8)|x; bits+=8 + while bits>=5: + bits-=5; o.append(A[(v>>bits)&31]) + if bits: o.append(A[(v<<(5-bits))&31]) + return "".join(o) +uid=c32(os.urandom(32)) +user=os.environ["FAT_USER"] +amt=os.environ["CREDIT_AMT"] +payto=f"payto://x-taler-bank/bank.hacktivism.ch/{user}?receiver-name={user}&message=bench-credit" +json.dump({"payto_uri":payto,"amount":amt,"request_uid":uid}, open("/tmp/tx.json","w")) +print("credit", amt, "->", user, "uid", uid) +PY +curl -sS -o /tmp/cred.json -w "credit:%{http_code}\n" -X POST -H "Authorization: Bearer $AT" \ + -H "Content-Type: application/json" -d @/tmp/tx.json "$BANK/accounts/admin/transactions" +echo credit_body=$(head -c 200 /tmp/cred.json) +BT=$(curl -sS -u "${FAT_USER}:${FAT_PW}" -H "Content-Type: application/json" -d '{"scope":"readwrite"}' \ + "$BANK/accounts/${FAT_USER}/token" | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])") +echo fat_tok_len=${#BT} +curl -sS -H "Authorization: Bearer $BT" "$BANK/accounts/${FAT_USER}" -o /tmp/fatbal.json +echo fat_account=$(head -c 280 /tmp/fatbal.json) +curl -sS -o /tmp/wd.json -w "wd:%{http_code}\n" -X POST -H "Authorization: Bearer $BT" \ + -H "Content-Type: application/json" -d "{\"amount\":\"${WITHDRAW_AMT}\"}" \ + "$BANK/accounts/${FAT_USER}/withdrawals" +python3 - <<'PY' +import json +d=json.load(open("/tmp/wd.json")) +print("wd_keys", list(d.keys())[:20]) +uri=d.get("taler_withdraw_uri") or d.get("withdrawal_uri") or "" +wid=d.get("withdrawal_id") or d.get("id") or "" +if not uri: + for v in d.values(): + if isinstance(v, str) and v.startswith("taler"): + uri=v +open("/tmp/bench-wuri.txt","w").write(uri) +open("/tmp/bench-wid.txt","w").write(wid) +print("WURI", uri) +print("WID", wid) +assert uri and wid, d +PY +# cleanup setup temps on host (not durable) +rm -f /tmp/acc.json /tmp/tx.json /tmp/cred.json /tmp/fatbal.json /tmp/wd.json +echo BENCH_banksetup_OK +EOS +} >"$SCRATCH/remote-banksetup.sh" +scp -o BatchMode=yes -q "$SCRATCH/remote-banksetup.sh" "${KOOPA}:/tmp/bench-run-banksetup.sh" +ssh -o BatchMode=yes "$KOOPA" 'bash /tmp/bench-run-banksetup.sh; rm -f /tmp/bench-run-banksetup.sh' \ + | tee "$SCRATCH/koopa-banksetup.out" +grep -q BENCH_banksetup_OK "$SCRATCH/koopa-banksetup.out" || die "bank setup failed" +scp -o BatchMode=yes -q "${KOOPA}:/tmp/bench-wuri.txt" "${KOOPA}:/tmp/bench-wid.txt" "$SCRATCH/" +ssh -o BatchMode=yes "$KOOPA" 'rm -f /tmp/bench-wuri.txt /tmp/bench-wid.txt' || true +WURI=$(tr -d '\n' <"$SCRATCH/bench-wuri.txt") +WID=$(tr -d '\n' <"$SCRATCH/bench-wid.txt") +[ -n "$WURI" ] || die "missing withdraw URI" +log "WURI=$WURI WID=$WID" + +log "=== load baseline (internal) ===" +sample_load baseline + +log "=== wallet: exchange + withdraw (external) ===" +wcli exchanges add "$EX" 2>&1 | tee "$SCRATCH/ex-add.out" || true +wcli exchanges update "$EX" 2>&1 | tee "$SCRATCH/ex-upd.out" || true +wcli exchanges tos "$EX" 2>&1 | tee "$SCRATCH/ex-tos.out" || true +wcli exchanges accept-tos "$EX" 2>&1 | tee "$SCRATCH/ex-accept.out" || true + +TW0=$(now) +# 1) Wallet selects exchange (status → selected). Must happen BEFORE bank confirm. +wcli withdraw accept-uri --exchange "$EX" "$WURI" 2>&1 | tee "$SCRATCH/wd-accept.out" || true + +# 2) Wait until bank sees selection, then confirm once (libeufin rejects confirm if still unselected). +log "=== bank: wait selected → confirm ===" +run_koopa wdconfirm \ + "export FAT_USER=$(printf %q "$FAT_USER")" \ + "export FAT_PW=$(printf %q "$FAT_PW")" \ + "export WID=$(printf %q "$WID")" \ + 'BANK=http://127.0.0.1:9012' \ + 'BT=$(curl -sS -u "${FAT_USER}:${FAT_PW}" -H "Content-Type: application/json" -d "{\"scope\":\"readwrite\"}" "$BANK/accounts/${FAT_USER}/token" | python3 -c "import sys,json;print(json.load(sys.stdin).get(\"access_token\",\"\"))")' \ + 'echo WID=$WID tok_len=${#BT}' \ + 'st=""' \ + 'for i in $(seq 1 60); do + curl -sS "$BANK/taler-integration/withdrawal-operation/${WID}" -o /tmp/wdst.json || true + st=$(python3 -c "import json;print(json.load(open(\"/tmp/wdst.json\")).get(\"status\",\"\"))" 2>/dev/null || true) + echo "wait_select #$i status=$st" + case "$st" in selected|confirmed) break ;; esac + sleep 1 + done' \ + 'case "$st" in + selected) + echo confirming... + curl -sS -o /tmp/wdconf.out -w "confirm_http:%{http_code}\n" -X POST \ + -H "Authorization: Bearer $BT" -H "Content-Type: application/json" -d "{}" \ + "$BANK/accounts/${FAT_USER}/withdrawals/${WID}/confirm" + head -c 120 /tmp/wdconf.out; echo + ;; + confirmed) echo already_confirmed ;; + *) echo "WARN never selected (last=$st)" ;; + esac' \ + 'for i in $(seq 1 30); do + curl -sS "$BANK/taler-integration/withdrawal-operation/${WID}" -o /tmp/wdst.json || true + python3 -c "import json;d=json.load(open(\"/tmp/wdst.json\"));print(\"post\",d.get(\"status\"),\"transfer\",d.get(\"transfer_done\"))" + st=$(python3 -c "import json;print(json.load(open(\"/tmp/wdst.json\")).get(\"status\",\"\"))" 2>/dev/null || true) + td=$(python3 -c "import json;print(json.load(open(\"/tmp/wdst.json\")).get(\"transfer_done\"))" 2>/dev/null || true) + case "$st" in confirmed) break ;; esac + [ "$td" = "True" ] || [ "$td" = "true" ] && break + sleep 1 + done + rm -f /tmp/wdst.json /tmp/wdconf.out' || true + +# 3) Wallet finishes planchets (may take minutes for large withdraws) +log "=== wallet: run-until-done after confirm ===" +for round in $(seq 1 30); do + log "withdraw run-until-done round $round" + # timeout each round so we do not hang forever on long-poll + set +e + # 120s max per round + perl -e 'alarm shift; exec @ARGV' 120 \ + node "$MONO" --wallet-db="$WDB" --no-throttle --skip-defaults run-until-done \ + 2>&1 | tee -a "$SCRATCH/wd-rud.out" + set -e + wcli balance 2>&1 | tee "$SCRATCH/bal-wd-$round.out" + if python3 - "$SCRATCH/bal-wd-$round.out" <<'PY' +import re,sys +t=open(sys.argv[1]).read() +vals=[float(x) for x in re.findall(r"GOA:([0-9.]+)", t)] +sys.exit(0 if vals and max(vals) >= 100 else 1) +PY + then + log "withdraw OK" + break + fi + sleep 1 +done +TW1=$(now) +python3 -c "print('withdraw_wall_s', round(float('$TW1')-float('$TW0'), 3))" | tee "$SCRATCH/withdraw-time.txt" +wcli balance 2>&1 | tee "$SCRATCH/balance-before-pays.out" +sample_load after-withdraw + +CSV="$SCRATCH/payments.csv" +echo "idx,amount,handle_s,rud_s,total_s,status,order_id,loadavg,notes" >"$CSV" + +idx=0 +for AMT in $AMOUNTS; do + idx=$((idx + 1)) + # no trailing slash; amount only (template has fixed summary) + URI="taler://pay-template/${MER_HOST}/instances/${INST}/${TPL}?amount=${AMT}" + echo "$URI" >"$SCRATCH/uri-$idx.txt" + log "=== pay #$idx $AMT ===" + log "uri=$URI" + + load_before=$(ssh -o BatchMode=yes "$KOOPA" 'cut -d" " -f1 /proc/loadavg' 2>/dev/null || echo "?") + sample_load "pay$idx" & + SPID=$! + + T0=$(now) + set +e + wcli handle-uri --yes "$URI" 2>&1 | tee "$SCRATCH/pay-$idx-handle.out" + T1=$(now) + wcli run-until-done 2>&1 | tee "$SCRATCH/pay-$idx-rud.out" + T2=$(now) + set -e + + handle_s=$(python3 -c "print(round(float('$T1')-float('$T0'),3))") + rud_s=$(python3 -c "print(round(float('$T2')-float('$T1'),3))") + total_s=$(python3 -c "print(round(float('$T2')-float('$T0'),3))") + wait "$SPID" 2>/dev/null || true + load_after=$(ssh -o BatchMode=yes "$KOOPA" 'cut -d" " -f1 /proc/loadavg' 2>/dev/null || echo "?") + + run_koopa "ord$idx" \ + "export MPW=$(printf %q "$MER_PW")" \ + "export INST=$(printf %q "$INST")" \ + "export AMT=$(printf %q "$AMT")" \ + 'AUTH="Authorization: Bearer secret-token:${MPW}"' \ + 'curl -sk -H "$AUTH" "https://127.0.0.1:9010/instances/${INST}/private/orders?paid=YES&delta=-30" -o /tmp/ol.json' \ + 'python3 - </dev/null || true)' \ + 'if [ -n "$OID" ]; then + curl -sk -H "$AUTH" "https://127.0.0.1:9010/instances/${INST}/private/orders/${OID}" -o /tmp/od.json + python3 -c "import json;d=json.load(open(\"/tmp/od.json\"));print(\"DETAIL paid\",d.get(\"paid\"),\"status\",d.get(\"order_status\"),\"amount\",(d.get(\"contract_terms\")or{}).get(\"amount\"))" + fi + rm -f /tmp/ol.json /tmp/od.json /tmp/oid.txt' || true + + status=unknown + if grep -qE 'DETAIL paid True|DETAIL paid true' "$SCRATCH/koopa-ord$idx.out" 2>/dev/null; then + status=paid + elif grep -qiE 'insufficient balance' "$SCRATCH/pay-$idx-handle.out" 2>/dev/null; then + status=insufficient + elif grep -qiE 'error|fail|invalid|404|409|Unexpected HTTP' "$SCRATCH/pay-$idx-handle.out" 2>/dev/null; then + status=error + elif grep -qi 'paid' "$SCRATCH/pay-$idx-rud.out" 2>/dev/null; then + status=maybe + fi + oid=$(awk '/^ORDER /{print $2; exit}' "$SCRATCH/koopa-ord$idx.out" 2>/dev/null || true) + notes="load ${load_before}->${load_after}" + echo "$idx,$AMT,$handle_s,$rud_s,$total_s,$status,$oid,$load_after,$notes" >>"$CSV" + log "pay #$idx $AMT total=${total_s}s status=$status oid=$oid load=$load_after" + wcli balance 2>&1 | tee "$SCRATCH/bal-after-$idx.out" || true +done + +sample_load final +wcli balance 2>&1 | tee "$SCRATCH/balance-final.out" || true + +python3 - "$CSV" "$SCRATCH" <<'PY' | tee "$SCRATCH/RESULTS.md" +import csv, sys +csv_path, scratch = sys.argv[1], sys.argv[2] +rows = list(csv.DictReader(open(csv_path))) +print("# Amount ladder payment benchmark") +print() +print(f"- Scratch: `{scratch}`") +print(f"- Drive: Local-machine wallet-cli (external) · Measure: koopa podman (internal)") +print(f"- Template: `goa-free` on `goa-demo-cp4zqk`") +print(f"- URI: `taler://pay-template/…/instances/…/goa-free?amount=…` (no trailing slash, no summary)") +print() +print("| # | Amount | handle (s) | run-until-done (s) | **total (s)** | status | order | loadavg |") +print("|--:|--------|----------:|-------------------:|--------------:|--------|-------|--------:|") +for r in rows: + print(f"| {r['idx']} | `{r['amount']}` | {r['handle_s']} | {r['rud_s']} | **{r['total_s']}** | {r['status']} | `{r.get('order_id','')}` | {r.get('loadavg','')} |") +print() +paid = [r for r in rows if r["status"] == "paid"] +print(f"Paid **{len(paid)}/{len(rows)}**.") +if paid: + totals = [float(r["total_s"]) for r in paid if r["total_s"]] + if totals: + print(f"**Min** {min(totals):.3f}s · **Max** {max(totals):.3f}s · **Avg** {sum(totals)/len(totals):.3f}s") +print() +print("Inside samples: `koopa-load-*.out` in scratch.") +PY + +# durable copy of last RESULTS into benchmarks tree (no secrets) +cp "$SCRATCH/RESULTS.md" "$BENCH_DIR/LAST_RESULTS.md" +cp "$CSV" "$BENCH_DIR/LAST_payments.csv" +log "DONE -> $SCRATCH/RESULTS.md (also benchmarks/amount-ladder/LAST_*)" +cat "$SCRATCH/RESULTS.md" diff --git a/benchmarks/amount-ladder/sample-load-inside.sh b/benchmarks/amount-ladder/sample-load-inside.sh new file mode 100755 index 0000000..0895f7e --- /dev/null +++ b/benchmarks/amount-ladder/sample-load-inside.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Sample host + three-container CPU/RSS from koopa (run as hernani via ssh). +# Usage (local): ssh hernani@koopa 'bash -s' < sample-load-inside.sh +# Or: ./sample-load-inside.sh # if executed ON koopa as hernani +set -eu +tag="${1:-manual}" +echo "=== sample_load tag=$tag $(date -u +%FT%TZ) ===" +echo "=== loadavg ===" +cat /proc/loadavg +echo "=== mem ===" +free -h 2>/dev/null || true +echo "=== top host cpu ===" +ps -eo pid,pcpu,pmem,rss,etime,comm --sort=-pcpu 2>/dev/null | head -15 +echo "=== exchange (taler-hacktivism-exchange-ansible) ===" +podman exec taler-hacktivism-exchange-ansible \ + ps -eo pid,pcpu,pmem,rss,etime,args 2>/dev/null \ + | grep -E 'taler-exchange|postgres -D|PID' | head -25 || true +echo "=== merchant (taler-hacktivism) ===" +podman exec taler-hacktivism \ + ps -eo pid,pcpu,pmem,rss,etime,args 2>/dev/null \ + | grep -E 'taler-merchant|nginx|postgres -D' | head -25 || true +echo "=== bank (taler-bank-hacktivism) ===" +podman exec taler-bank-hacktivism \ + ps -eo pid,pcpu,pmem,rss,etime,args 2>/dev/null \ + | grep -E 'java|libeufin|postgres -D' | head -15 || true +echo "=== sample_load done ===" diff --git a/configs/README.md b/configs/README.md new file mode 100644 index 0000000..82ae124 --- /dev/null +++ b/configs/README.md @@ -0,0 +1,19 @@ +# Config mirrors (from host koopa) + +Directories are named to match **live podman container names** where possible. + +| Directory | Live container | Image (typical) | +|-----------|----------------|-----------------| +| `taler-hacktivism/` | `taler-hacktivism` | `taler-hacktivism-live:landing` | +| `taler-hacktivism-bank/` | `taler-hacktivism-bank` | **`taler-hacktivism-banking:live`** | +| `taler-exchange/` | conf inside exchange container | (see exchange-ansible) | +| `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` | +| `caddy/` `firewalld/` `systemd/` | host services | | +| `tor/` | **`koopa-tor-relay`** (podman host net) | `localhost/koopa-tor-relay:latest` | + +**Authoritative running inventory:** `host/overview/LIVE.md`. + +Secrets never live here — `SECRETS.md` / `koopa-admin-secrets`. diff --git a/configs/bank-landing/README.md b/configs/bank-landing/README.md new file mode 100644 index 0000000..d59e83a --- /dev/null +++ b/configs/bank-landing/README.md @@ -0,0 +1,250 @@ +# Bank landing (`bank.hacktivism.ch`) + +English intro for **GOA exploration** (badge: **Intergalactic · GOA · no IBAN**): +put coins in a GNU Taler wallet, optionally open your own bank account, and +point merchants at `taler.hacktivism.ch`. + +| Public URL | What | +|------------|------| +| `https://bank.hacktivism.ch/` · `/intro/` | Landing (nginx in bank container → host **9013**, Caddy) | +| `https://bank.hacktivism.ch/intro/stats.json` | **Live stats** (written *inside* bank container) | +| `https://bank.hacktivism.ch/intro/demo-withdraw.json` | Mint **shared-pool** (`explorer`) withdraw URI | +| `https://bank.hacktivism.ch/intro/auto-account.json` | Auto-create **personal** bank user (credentials once; balance **GOA:0**) | +| `https://bank.hacktivism.ch/webui/` | libeufin-bank SPA (register · login · withdraw) when libeufin is up on **9012** | +| `https://exchange.hacktivism.ch/` | Exchange (ToS `/terms`, keys `/keys`) | +| `https://taler.hacktivism.ch/` | Merchant backend | + +## Page layout (top → bottom) + +1. Hero (badge, short shared-account line) +2. **Get the wallet** — Play / F-Droid / iOS / web hub + **Debian/Ubuntu · Command line** (apt) + **Unix / POSIX · from source** (build monorepo or run `.mjs`) +3. **Withdraw GOA** — compact QR_Taler, open CTA, live pool balance note, generate-own-account link +4. **GOA shop · samples** — always visible (2 products + merchant link) +5. **How the shared account works** — collapsed fold (click to expand) +6. **Optional · own bank account** — collapsed fold: auto-create account + credentials + QR_Taler for bank UI +7. **GOA flow** stats +8. **Performance** +9. Footer + +## User flow (documented on the page) + +### Primary path (works without your own bank account) + +1. **Install wallet** — Android (Play / F-Droid), iOS, Ubuntu Touch (via Waydroid + Android wallet), web (Chrome/Firefox/Opera on taler.net). +2. **One QR / link** — live `taler://withdraw/…` from the **community-shared** bank pool (`explorer`). + Minted via `GET /intro/demo-withdraw.json` (or fallback `withdraw.uri`). + Auto-confirm finishes the bank side so coins reach the wallet. +3. **Surprising balance note** — shows live `balance_explorer` from `stats.json` + (“pool holds GOA:… (shared, but also yours)”). + +QR encoding matches merchant shop popup (**QR_Taler**): animated `#0042B3` conic +ring, off-DOM PNG, center `qr-logo.svg` (`shop-pay.css` classes). Label text: +**Scan in wallet**. + +### Optional: personal bank account (auto-created) + +In the optional fold: + +1. User clicks **Create my bank account**. +2. Browser calls `GET /intro/auto-account.json` (no form fields). +3. Server registers a new libeufin user via public `POST /accounts`. +4. UI shows **username / password** once (“created for you”; + not stored for recovery). +5. Username shape: **`goa-account-`** (shown to the user). + Password embeds **`pleasechangeme`** with random chars around it. +6. QR_Taler + link open `/webui/` so the user can log in with those credentials. + +Balance **starts at zero** — not the shared `explorer` pool. + +#### Deploy status — own bank account (**missing** until verified) + +The **own bank account** path is **not complete** unless all of the following +are live on `taler-hacktivism-bank`. Treat this as **missing** until checked: + +| Check | Expected | +|-------|----------| +| Landing HTML | Optional fold has **Create my bank account** | +| API process | `python3 /usr/local/bin/demo-withdraw-api.py` on **127.0.0.1:19096** | +| Nginx | `location = /intro/auto-account.json` → `:19096` | +| Public | `curl -sS https://bank.hacktivism.ch/intro/auto-account.json` returns JSON with `username` / `password` / `balance: "GOA:0"` | +| WebUI login | Credentials work at `https://bank.hacktivism.ch/webui/` | + +Install helper: `scripts/taler-bank/install-demo-withdraw-api.sh` +(or manual copy of `demo-withdraw-api.py` + `nginx-landing.conf` + restart API). + +### How the shared pool works + +1. Shared account `explorer` is pre-funded with GOA. +2. Each demo withdraw is a **one-shot** op from that pool (not a personal login). +3. Wallet accepts the URI; **auto-confirm only for `explorer`** (see + `auto-confirm-withdrawals.sh`). +4. Pool balance is communal; amounts you withdraw are yours in the wallet. + +### Helpers + +| Script | Role | +|--------|------| +| `scripts/taler-bank/demo-withdraw-api.py` | Loopback HTTP **:19096** — `/demo-withdraw.json` + `/auto-account.json` | +| `scripts/taler-bank/install-demo-withdraw-api.sh` | Copy into bank container, nginx locations, start API + auto-confirm loop | +| `scripts/taler-bank/auto-confirm-withdrawals.sh` | Confirm **explorer-only** withdrawals when status=`selected` | +| `scripts/taler-bank/refresh-demo-withdraw.sh` | Refresh static `withdraw.uri` (no python) | +| `configs/bank-landing/nginx-landing.conf` | `/intro/demo-withdraw.json`, `/intro/auto-account.json` proxies | + +Needs **python3** in the bank container for `demo-withdraw-api.py`. + +### CLI sketch (fresh wallet DB) + +```bash +# equivalent one-step in CLI terms: +taler-wallet-cli exchanges add https://exchange.hacktivism.ch/ +taler-wallet-cli exchanges accept-tos https://exchange.hacktivism.ch/ +# then withdraw from the exchange (amount chosen in wallet UI) +``` + +--- + +## Live stats (in bank container) + +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. + +| Piece | Path / role | +|-------|-------------| +| Generator (in-container) | `/usr/local/bin/landing-stats.sh` | +| Source in admin-log | `scripts/taler-bank/landing-stats.sh` | +| Host installer | `scripts/taler-bank/landing-stats-install.sh` | +| Output | `/var/www/bank-landing/stats.json` → `https://bank.hacktivism.ch/intro/stats.json` | +| Landing UI | `index.html` section **GOA flow · live** | + +### What the page shows + +- **Bank accounts** — registered accounts (libeufin `GET /accounts`, admin) +- **Wallets involved** — unique reserve pubs from Taler withdrawal debits +- **Withdraws / bank credits** — flow amounts (exchange mirror skipped where noted) +- **Last withdraws** — recent list with amount, time (**CEST** / `Europe/Zurich`) +- **Last 24h / 7d** — amount +- **`balance_explorer`** — live shared-pool balance (shown on withdraw step) +- Performance probes + container RSS (bottom of page) + +### Scan depth (must stay deep enough) + +| 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 | + +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) — +that is normal (e.g. fresh auto-accounts), not a stats failure. + +### `stats.json` shape (abridged) + +```json +{ + "ok": true, + "currency": "GOA", + "generated_at": "2026-07-10T20:35+02:00", + "balance_explorer": "GOA:960", + "bank_accounts": { "total": 56, "users": 55 }, + "wallets": { "unique_reserves": 51 }, + "withdraws": { + "count": 51, + "last_24h": { "amount": "GOA:…" }, + "last_7d": { "amount": "GOA:…" } + }, + "flow": { + "incoming": { "amount": "GOA:…", "count": 68 }, + "withdraw": { "amount": "GOA:…", "count": 51 } + }, + "source": "in-container landing-stats.sh" +} +``` + +### Install + refresh (on koopa host) + +```bash +# from laptop: copy scripts, then on koopa: +cd /path/to/koopa-admin-log/scripts/taler-bank + +# copy into container + one-shot run +sudo ./landing-stats-install.sh + +# every minute inside the container (* * * * *) +sudo ./landing-stats-install.sh --cron +# optional: LANDING_STATS_CRON='*/5 * * * *' ./landing-stats-install.sh --cron + +# later: only re-run +sudo ./landing-stats-install.sh --run-only +``` + +Requirements **inside** the container: + +- `curl`, `awk` (mawk OK), `sed`, `date` — **no python** +- libeufin-bank listening on loopback (script auto-detects `PORT` / 9012 / 8080) +- `/root/bank-explorer-password.txt` (or `BANK_PASS=…` on the `podman exec`) +- writable `/var/www/bank-landing/` (same tree nginx uses for `/intro/`) + +`make-demo-withdraw-qr.sh` also calls `landing-stats.sh` after refreshing the demo URI, +when the binary is already installed at `/usr/local/bin/landing-stats.sh`. + +### Manual one-liner (debug) + +```bash +podman exec -e LANDING_DIR=/var/www/bank-landing taler-hacktivism-bank \ + /usr/local/bin/landing-stats.sh +curl -sS https://bank.hacktivism.ch/intro/stats.json | python3 -m json.tool +``` + +--- + +## Host / container files + +Landing root (nginx in bank container), typically under something like +`/var/www/bank-landing/` or the container path mapped for **9013**: + +| File | Role | +|------|------| +| `index.html` | landing (wallet, shared withdraw, folds, stats) + Open Graph meta | +| `og-goa-shop.png` | **link preview** image (1200×630) for chats / social (OG + Twitter) | +| `shop-pay.css` / `shop-pay.js` | GOA shop popup + **QR_Taler** frame styles | +| `qr-logo.svg` | center logo on QR_Taler frames | +| `qrcode.min.js` | QR encode helper | +| `stats.json` | **live stats** (from in-container script) | +| `nginx-landing.conf` | bank container nginx (**9013**) | +| `withdraw.uri` | current demo `taler://withdraw/…` (optional) | +| `withdraw.amount` | e.g. `GOA:10` (optional) | +| `withdraw-watch.ids` | ids for auto-confirm helper (optional) | + +## Deploy landing HTML + helper API + +```bash +# landing assets +podman cp configs/bank-landing/index.html \ + taler-hacktivism-bank:/var/www/bank-landing/index.html +podman cp configs/bank-landing/og-goa-shop.png \ + taler-hacktivism-bank:/var/www/bank-landing/og-goa-shop.png +podman cp configs/bank-landing/shop-pay.js \ + taler-hacktivism-bank:/var/www/bank-landing/shop-pay.js +# … shop-pay.css, qr-logo.svg, qrcode.min.js as needed + +# demo-withdraw + auto-account API (python3 in container) +./scripts/taler-bank/install-demo-withdraw-api.sh +# or manually: +podman cp scripts/taler-bank/demo-withdraw-api.py \ + taler-hacktivism-bank:/usr/local/bin/demo-withdraw-api.py +podman cp configs/bank-landing/nginx-landing.conf \ + taler-hacktivism-bank:/etc/nginx/sites-available/bank-landing +# restart: python3 /usr/local/bin/demo-withdraw-api.py on 127.0.0.1:19096 +# nginx -s reload; auto-confirm-withdrawals.sh --loop +``` + +### Demo QR refresh (in container, no python) + +```bash +podman exec taler-hacktivism-bank /usr/local/bin/refresh-demo-withdraw.sh +# source: scripts/taler-bank/refresh-demo-withdraw.sh +``` + +Older host script (needs python3): `scripts/taler-bank/make-demo-withdraw-qr.sh` diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html new file mode 100644 index 0000000..5dfebb3 --- /dev/null +++ b/configs/bank-landing/index.html @@ -0,0 +1,2055 @@ + + + + + + GOA Exploration Bank + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ stack· + libeufin-bank 1.6.6 + · + libeufin-common 1.6.6 +

+
+
+ Intergalactic · GOA · no IBAN +

GOA Exploration Bank

+

+ Get your GOA in one step from the + shared bank account into your wallet — no registration. +

+
+ + +
+

1 Get the wallet

+
+ + + + +
+
+ + +
+

2 Withdraw GOA

+

One step · shared pool · no registration

+
+
+
+
+ +
+
Scan in wallet
+ Preparing withdraw… +
+ + Open in wallet → + + +

+ Community pool balance: + sits in the shared bank account + (everyone draws from the same pool — not your personal wallet). + Need more? Withdraw again from this pool, or create an own account + and fund it separately. +

+
+
+ + + + +
+ + +
+ + +
+ + +
+ + + + +
+

Performance

+

+ Live HTTP probes + container memory (RSS) +

+
+
+ /config + +
+
+ /taler-integration/config + +
+
+ /webui/ + +
+
+ Loadavg + +
+
+ Container + +
+
+ PostgreSQL + +
+
+ Java / libeufin + +
+
+ Nginx + +
+
+
+

Top processes (RSS)

+
+
+

Memory from /proc + cgroup inside podman container.

+
+ + +
+ + + + + diff --git a/configs/bank-landing/nginx-cors-stats.conf b/configs/bank-landing/nginx-cors-stats.conf new file mode 100644 index 0000000..6442861 --- /dev/null +++ b/configs/bank-landing/nginx-cors-stats.conf @@ -0,0 +1,6 @@ +# Snippet: CORS so exchange/merchant landings can fetch stats.json +# Include inside bank landing server { } or merge into bank-landing site. +# location for stats only: +# include /etc/nginx/snippets/goa-stats-cors.conf; (if placed as snippet) + +# Applied as extra location on :9013 bank-landing: diff --git a/configs/bank-landing/nginx-landing.conf b/configs/bank-landing/nginx-landing.conf new file mode 100644 index 0000000..251a713 --- /dev/null +++ b/configs/bank-landing/nginx-landing.conf @@ -0,0 +1,71 @@ +# Bank landing — :9013 (behind Caddy :443 — never put 9013 in redirects) +server { + listen 9013; + listen [::]:9013; + server_name bank.hacktivism.ch _; + root /var/www/bank-landing; + index index.html; + + # Critical behind reverse_proxy: no :9013 in Location headers + absolute_redirect off; + port_in_redirect off; + + location = / { + return 302 /intro/; + } + location = /intro { + return 302 /intro/; + } + # Terms (same dark palette as merchant/exchange terms pages) + location = /terms { + alias /var/www/bank-landing/terms.html; + default_type text/html; + add_header Cache-Control "no-store" always; + } + location = /terms/ { + return 302 /terms; + } + location = /privacy { + alias /var/www/bank-landing/privacy.html; + default_type text/html; + add_header Cache-Control "no-store" always; + } + location = /privacy/ { + return 302 /privacy; + } + # Fresh community-pool withdraw for one-click wallet funding + location = /intro/demo-withdraw.json { + proxy_pass http://127.0.0.1:19096/demo-withdraw.json; + proxy_http_version 1.1; + proxy_set_header Host $host; + add_header Cache-Control "no-store" always; + add_header Access-Control-Allow-Origin * always; + } + # Auto-create personal bank account (credentials returned once; balance GOA:0) + location = /intro/auto-account.json { + proxy_pass http://127.0.0.1:19096/auto-account.json; + proxy_http_version 1.1; + proxy_set_header Host $host; + add_header Cache-Control "no-store" always; + add_header Access-Control-Allow-Origin * always; + } + location /intro/ { + alias /var/www/bank-landing/; + } + location = /intro/stats.json { + alias /var/www/bank-landing/stats.json; + default_type application/json; + add_header Access-Control-Allow-Origin * always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + add_header Cache-Control "no-store" always; + } + location = /stats.json { + alias /var/www/bank-landing/stats.json; + default_type application/json; + add_header Access-Control-Allow-Origin * always; + add_header Cache-Control "no-store" always; + } + location / { + try_files $uri $uri/ =404; + } +} diff --git a/configs/bank-landing/og-goa-shop.png b/configs/bank-landing/og-goa-shop.png new file mode 100644 index 0000000000000000000000000000000000000000..aeaad870078fe2e89faa409718d3c3430aa112ba GIT binary patch literal 557173 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
7VAoh{5UoAiYM9&Ly$g4 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
I|8CJr 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

969O}CVy_Ydn!e9OM_wfAc8Q%NS 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

gzxgCdSd*5D& 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�kcEyv=#;$;UV{ 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%0DS6J_`m1kZ}5vhY>FQ9$R+*-VyQ7DDc1-6y>*Q; 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

qHPVWR)mld=^&xi zZ8JzytkooGM%?N$KevSTpouY>t(~6B@+f1B&R9NhfGpGKEW=8d@v+@mL`0G_)x@oB zdOO<$X;d3StJPtC;SdJsrwK+Fq;i1UAkDCepcT*4i4}Bo9?V;7?E@PNqBtTm8m%?+ z^9u;mbqq`aX__L1Bv#4?u?{hF3NBe%yRbtT57Oh`U0J~^D$BisX8nzE`o(@ zFd&Kqnb9BxhgXl1B`Hak(QdUEBneVVRu3Nb!*8%&QSHpPkwTCpjse~4_faZl`M^<^ zb$3T&B}A`KMM`Jd zNsUFdyBu9wB}r3cB#>6nPZI`dhEy>^NPm6A+`=-8%gc^2m1Rywiy+N3bMwnsu>e}Q zwISym=<9(!Z48~oB_h*8TZ47!YOq4!qR$D0bV;{j;m*HITTm_9%Lfp`XGyit8}#Y6 zVp5&b>9mniE6;2ztU_m+cBf0+X``fa>vE8g*o?)M1FXg>Uw=vJ=HBAc3LqE^`m{Rp z9GRQL_!xy+dv6daTpU0xNixIgfg?zzFxGhBs!)<$7z0u0BJc2|X`T-vuVJA(gwLyF zg+iArzK8wQUP4uXUOPCe5Pj?t8e6DMKWUzI|_Dhi0$tG%T^vSk7+B zC>*1%OOMp^DX*R)ovn~QmSTv_Bb<5M_~ZrF+F`R~$fA%pPe2mOCZYUW%18FD=jDhx zmnXV0Rq(2)UkH}@U`hcL*4^^_xYt;7&2F9J`?dVDXzyfq#3DmS?ejhC5A}FkqXd{L zz+YB={8F^!6)Un1&Q_QG+VtF-kVvVVuw`A)a;-CGl}{6ta`8cgP+mB7g1(TJRHr@@ zp0hF=qw^q$gT%YIDHm%+Dkm5j>n#4lMh?i*7L#QdG?BLm8sn_u#z+P`DN4Hdds?SN zLL!a944l7%0fbZx5(659h>+4f!=x##NMN*PU@h86l<}5wB`~;*6%rh9VGXJkG0+)A zEntDAl|!`SdA8Pf=(LtGsRQ%`f;1V>YCEP}l4d@wv$OhH3|4D5=0c$℞q{+;*(i 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 ~L0HX}AQ#%0EW@T5eJMEb@Okbe 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 Vy;2o~7%Tt)002ovPDHLkV1j3=oB{v< literal 0 HcmV?d00001 diff --git a/configs/bank-landing/privacy.html b/configs/bank-landing/privacy.html new file mode 100644 index 0000000..a7a3ae5 --- /dev/null +++ b/configs/bank-landing/privacy.html @@ -0,0 +1,137 @@ + + + + + + Privacy · GOA Bank · Swiss FADP + + + +

+

Privacy notice · GOA Exploration Bank

+

+ This notice describes personal data processing for the self-hosted bank at + bank.hacktivism.ch under the Swiss Federal Act on Data Protection + (FADP / revDSG, in force since 1 Sep 2023). It is an + explorational service, not a licensed Swiss bank. +

+ +

1. Controller

+

Operators of the hacktivism.ch GNU Taler stack (GOA exploration deployment). + Contact via the operational channels published for this host. No separate DPO + is appointed for this experimental service.

+ +

2. Categories of data retained

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DataExamplesTypical retention
Account identifiersUsername, account serial, registration timestampFor account lifetime + up to 12 months after deletion or archive wipe
Authentication secretsPassword hashes / tokens (not plaintext passwords)While account exists; tokens until expiry or revoke
Ledger / transactionsCredits, debits, amounts (GOA), subjects, counterparty account names, timestampsOperational retention for the service lifetime; may be wiped on stack reset
Balances & limitsAccount balance, debt limit, conversion flagsWhile account exists
Withdrawal operationsWithdrawal IDs, amounts, status (pending/selected/confirmed), reserve pub when knownUntil completed/aborted + short operational logs (days–weeks)
Technical logsHTTP access logs (IP, User-Agent, path, status), application logsTypically days to a few weeks (rotation); not used for marketing
Landing / demo artefactsPublic stats.json aggregates, demo withdraw URI stateStats overwritten continuously; demo URI until used or rotated
+

Not retained as payment content: wallet coin private keys (never sent to the bank). + Full browser history or device contacts are not collected by this bank service.

+ +

3. Purposes

+
    +
  • Operating bank accounts and GOA transfers for exploration of GNU Taler
  • +
  • Authenticating users and preventing abuse
  • +
  • Enabling withdrawals to the GOA exchange
  • +
  • Security, debugging, and capacity monitoring
  • +
+ +

4. Legal basis (Swiss FADP)

+

Processing is necessary to provide the service requested by the user (account / withdraw) + and for overriding private interests of secure operation of an experimental public stack + (Art. 6 and 31 FADP principles: lawfulness, proportionality, purpose limitation).

+ +

5. Recipients & transfers

+
    +
  • GOA exchange (exchange.hacktivism.ch): reserve/wire-related data required by the protocol
  • +
  • Infrastructure operators of this host (hosting, backups) under operational control
  • +
  • No sale of personal data. No intentional transfer outside Switzerland/EEA for this service; + infrastructure may use standard CDN/DNS resolvers
  • +
+ +

6. Your rights (FADP)

+

Subject to legal limits: right to information/access, rectification, deletion, and to object + to processing. Contact the operators. You may lodge a complaint with the Swiss Federal Data + Protection and Information Commissioner (FDPIC / EDÖB).

+ +

7. Security

+

TLS in transit; access control on bank API; experimental service — no certified ISMS. + Do not store sensitive personal data in transaction subjects.

+ +

Related

+
+
bank-pp-swiss-v0 · Swiss FADP (revDSG)
+ + diff --git a/configs/bank-landing/qr-logo.png b/configs/bank-landing/qr-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aeffa23d3ec9d66f1041cc50d28cd2037e0e4e56 GIT binary patch literal 8563 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 tCI$W<00960e6}m<00006Nkl \ No newline at end of file diff --git a/configs/bank-landing/qr-payto.png b/configs/bank-landing/qr-payto.png new file mode 100644 index 0000000000000000000000000000000000000000..2be70b216a7c4217c137a36be9082e29dbd96445 GIT binary patch literal 602 zcmV-g0;T`6pHR9J=W znO&;GKnR6}SwNRGTc0J(0s>w=M>FXG?PihjQ2$G^d#yDmG~Zge9~N5NMe`7+PIKjElG9Py<* zQ-;`k{MF@=-~aBn{IAM25?Wph`7ymd!RTlGt;?AWx&&@9=h&+~Km1djdqTe!p;>91 zUwNR_=ibUZ8|)JgD5SVOTz7oc=H3?8IfJjfIWy@ya4$<1P$Ec?J$W--T5#Hjv=%oG z-}1QwH=lTlKvv)xx$gNPvjItb1zAUrw! ztu)W#gtQPoP$IFvRp*|^u*MCgvWqD#^QSgPSh2GS486r&`lmFfSsLQb^@Xd9&`KS6 zSVprTHzG^U{7{;EWnG~rG*lid?r6djLSnG3AxpBq)#jXo_X)IjeNmmCwU+0%Pc|!6 z>g>RCl;3C(n9NY9&VHgg&&nDz0X8ebSZly}L1l2b3$C%;oiUT8);RU6HkW7x o{ufh-3pPF79r)+#$G^+}0Nz87Dqcs^`~Uy|07*qoM6N<$g1Q|al>h($ literal 0 HcmV?d00001 diff --git a/configs/bank-landing/qr-product-beacon-badge.png b/configs/bank-landing/qr-product-beacon-badge.png new file mode 100644 index 0000000000000000000000000000000000000000..e01486e1d4bf246337f606534b9367414aa743ac GIT binary patch literal 608 zcmV-m0-ybfP)6lkhO)lT_@rSaE7zk76V2pgx27&6<8Cp%EU-+L|F+AH(@o^%aUJ1bo2g~ zoQIoWudh~OtybCVJHFC$iLmk|iiMKn|8jFUBQHztHQJ0t+xceZ_oGQGp|;Y5QxWi}kwWFS2v&FE4+MZGp%WU+KBKiRHnC z)6LQ8-^n>7ju=bC<{q1yS%c$l#4asfq3C$|xgXf&U_KQ7!fy-kJztS~ECk0xW>}D uHj(rNxaT+dWbTYey;c?ApN}8^F8>2hr2G}wjM)kR0000K@4h2>CNJYWw$L-xdsPSxW2Imynqxy5^rR`|&UHZgG{kU(GHr z?xokbW7TQ~UL%ZH81a7LUc42q0A=72N34xY%S|6f#3d8=Sc?=zy4)KgPRGq6#P~Tg zlZd<0g8U%9m8k#vJD1Df_4RxE|5&c8BhbRfh%&VHs=w#*NC^F{K$p1cBR{9}urn%1BudQWk_5cZPw=*Sg?jazFAnGOH-+a3`7h|Q<5rH>2>GlXbyAJC9nl0 zw&siCDfn!~l9jY3qpbPvbgs^(C{d{Rtu_q_I2!Vlm`}(NY0n>r^L`(z*rPdX-ER&io;uv>q}bL?igA=ZeY?E)sg6nxJ_wl|t>joY4p>wJZ0 z5^4_Paom!DSGH)sPlixjtKa?kbUvj7qq9XXfB3Dx1Y8A4!-Z{?W#K-1n*8nCA{n@I zi$Ik%)mI)gw&(vd2lLqqicecCt*!VD=bx`1|1SRn$~GG&R3v|P00000NkvXXu0mjf DjZz@D literal 0 HcmV?d00001 diff --git a/configs/bank-landing/qr-product-comet-cap.png b/configs/bank-landing/qr-product-comet-cap.png new file mode 100644 index 0000000000000000000000000000000000000000..1bea33faab97352ea6d20aa40e804f3d9def064a GIT binary patch literal 524 zcmV+n0`vWeP)>M8>MJN$^?6# z(}9}aG$aIh1gVkpdpVy%aF48`)Q#!a-X{Z5inE#)HUHv45wfdAO>@hW3{1Kv%s2Aj z;p<)BrKmx);re{I9NYuv&op7R;bk<9+WvZ$gsh;XozqzXb=~bv526Pn?<}v>L&rNZ zk%LGO(J~o)$T30{uI_DiRfsUE+cjZqMYqMVA8(n6LB!t5>GN&_i6>tXgH%KvJKid0 zRBYFT?7LR+9O~vJqYOk5GUs-DfE&|Bj!e`b^W)z5(s-@agzOWHIq4g1%Qiw3mdo8} z6>ZCQJX;OcfFXDGL6GE4P7(Gs$aZ_H`|$~AYS7u?CVPg{M*LYOYOufE%0o&fZOgK- zOrzQxvrh;^MjnG|kb16pj7|;G-2MtW=~5EzBe;9U|2vWx{4jp-EBF9}fN)Ps1=scf O0000fa{IYls-|^$N zWwvgM#f4Q{6Zi^pTo%xMAJ;l!Vg< zL!?NU>!wyX)(9?5h^9;wZQPmP&AGc$T=fC6WLH!?f`j;0eMqekXQi(<=PyLr#o(&? zfLyQq>CKlC+N&%>C0(leyWX6Y`SDjZN?E99@O`VlroYCgTq-ku0QW2foDcA7ms;%p zT%~|LHRc1nI3IuFuQ{)_uF`s|P^a(q=O_-?!Xt`UV~TU7{HZx-H)KoxinOl&!hHg- z%AE57Mq~~;=Pv*1%@Iw`uu7LzWEpdP0B0+7B^7ix~TlMJ3s1JRGFFRmKCo**Tc3H^{x5~+3f(H_bbLTskG)5 z%l-ZSr7vG;QM}^Kv7Y*-aE>+WX^WWa5{#pJ7>+dm4bu?w-00000NkvXXu0mjfa5pm9 literal 0 HcmV?d00001 diff --git a/configs/bank-landing/qr-product-nebula-coffee.png b/configs/bank-landing/qr-product-nebula-coffee.png new file mode 100644 index 0000000000000000000000000000000000000000..a56dd308cdb6eee1acbe4564d5b300f37bd5788d GIT binary patch literal 604 zcmV-i0;BzjP)e6lvEY}DgEmcj{TP>JoT z!&zT8QC#B`+H3vg<@7fM{rP+Ge_kHfFm8*o>Yw~g%WoMPWhINFvV0i@UMav=O)Yc4 z4d@Sus5bZ_6j$JTbJQQ;52kDz@^=Y78K@E`b1Sh5aJB`*p6vc`OWc#cxjB>dY|HKs z>(|0Rxw(5tmgU!v9uNS?-*Qq-{k0=_-*QIs8d-`8?j%uR6Ia`P^I{^V=S7f*jn z&Y`JKIqP)%?3Q@IUGCzMWKhxc;c%#2V^zkIwP7fV|wsQnqnM@s&Rij z^|dyqxJD>ZXlGjAvUWD$T1@WrPwI<*^JQgzXILq%X`)Ls9erAyPX(0PvSv;0XTGV< z^?i4zxHEk&)nOaX6k(xae9_P|AN1h*EX{FEQ>MTAcWr(O>e76y%KGT*@|^k2gvk8n j_pKC4_4()Z~|F$jc(JwT7-)pMjAAmGy(lP$}m{|X8XrEK?0e~4xrBlda8kAIm@@pakoA4^n3 zm-tJKx1t@mI=QAej0b$yE4~AD;2wv^C;o2gdJRF8{*S8>_xN5OQ2dO&?Kn~-5nOC^ z(ukwJPFVZlQGazg&iB;E=W+h)^0QE4E41o={VmEPK4pkrUns7S+lA*2oDCrY^#@le zCx|r^s|nY59sOZIpSW@C^Ie;JDkQfsMJZS4*bO-Ai`keIR|dEBwK%tNnwxJq{lULk zU)8w>0X0Qi@R%ZEs?_m!wRx6UCI?0-8+8kw&JIQ+lu4;l*WCTBJV$+v)K}5PU9{Ns z`L=SYufE+PKX%t&an5dR`&bXEEF4DP{xYk3|DN%!Bd~^5Hi7GBNT5~GC?#m zHU6)LY#>vR8hJjy^3w=DtJRI|Kef*VB9y68AyM-i7mg4?gzj|YdCgKc9U;>$+Be3k zQ?EGvAQBFzPM4$UK{B53Ko=PbO+&{YG7w8h2}c=7MnGLxdGmu1bu|$b3vOE`c2KBFF(k!}9}vM>KIzbw|_Gd2sa zBc6T@<9i1_f*hAE)5|#G!_(sl)PaYN&}S#0oin!h<+7s@4}E6{!WQ4Q;|x(qjO1Dr z+KBJ8f~=RmU-?&;v%Vqd?eACp*X8$OiY@f2zx!`d4t+xsqqvz|Xt#^+4LGtXRYr@2 z#B)+BI#vUIp(fN;872Bd{L4A-`Yz2Q6x6pK{(yEx$8Nx-&(a^1UleCg{dGB{IA@|a ze~4%1UvZAtsesTQ@S(cYp6_aNpBm^gI2@FiWt{lx^D8Y*RSvl*Zf02%zAT69JRj&Q zjo9@$f-^N!(Yb?c zI3F}*fArU>;b`stTAXW|Q-Ae5+_Pe?9y}*gmiWG(uynloT*DY*V{@s?nBtW> zm>Y6sjdg^%%rsy`9FEZb3r{G+$mL%lbjmrNpbaod|4gd$MyK*+3mHWKg1 z#1Asxj;OICH(!M%#1>>ZnXRjc2npO3p3SF-qaWXyZ3Z!eQ$$27jXb&H1(6%+aL8nS zRZLVp5V$2=$~rn66R)lEJWq(nukrzD%vio<;s#kic1AHuGcEklkROBxpCKofTLFQ_K5Afj2Svut;S=QC)Rz18>eF$_02cDcnqL-Z0%q5dw&m7`>G$Fe7^ z(2%{U0&<3`dZ=#HmA@9=2{PQC2{G>GZEt2!Tk!lb<=?XBGsq2oX#eml_yd9vh$R*f R({2C&002ovPDHLkV1m`S>$Cs> literal 0 HcmV?d00001 diff --git a/configs/bank-landing/qr-product-voidwave-playlist.png b/configs/bank-landing/qr-product-voidwave-playlist.png new file mode 100644 index 0000000000000000000000000000000000000000..a63ba20e479c290fbd10b2fffbfb3408137205eb GIT binary patch literal 602 zcmV-g0;T`6pHR9J=W zm{E$uFbqTmK0uD7tNTbffI%zG$WFH`{VPbYCX_TUKf;Xxw^(DUxbEB;DKnZ`%`C{i^ zaXzyeDg#;%e?n-t;4z1$O1L@9YHh-87Zl;ZE~De_`Jy)WpIfPKDjNp{JN;FkhrYL_ z4aIRd+WB3cqrd2{CnXqqE%+XhGNSpRj*4%if_R8_mwmx${L?o_Iz5L zE5q#^?z6LQIsH|fBNmjyQCL!(47eI`p*Yr)(%OorCymTETaUk1=ZI*d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); \ No newline at end of file diff --git a/configs/bank-landing/shop-pay.css b/configs/bank-landing/shop-pay.css new file mode 100644 index 0000000..c39a62e --- /dev/null +++ b/configs/bank-landing/shop-pay.css @@ -0,0 +1,348 @@ +/* GOA shop pay popup — QR_Taler style from taler-merchant-webui / @gnu-taler/web-util */ + +/* Animated blue ring (same as webui QR.tsx) */ +@property --angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} +@keyframes goa-pay-qr-rotate { + to { + --angle: 360deg; + } +} + +.shop-item[data-product] { + cursor: pointer; + transition: border-color 0.15s, transform 0.12s, background 0.15s; +} +.shop-item[data-product]:hover, +.shop-item[data-product]:focus-visible { + border-color: rgba(196, 181, 253, 0.75); + background: rgba(0, 0, 0, 0.4); + transform: translateY(-1px); + outline: none; +} +button.shop-item { + font: inherit; + color: inherit; + width: 100%; + appearance: none; + -webkit-appearance: none; +} + +.goa-pay-modal { + position: fixed; + inset: 0; + z-index: 80; + display: none; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(8, 6, 14, 0.75); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.goa-pay-modal.open { + display: flex; +} +.goa-pay-card { + position: relative; + background: rgba(36, 28, 48, 0.98); + border: 1px solid rgba(167, 139, 250, 0.45); + border-radius: 16px; + padding: 1.25rem 1.2rem 1.2rem; + max-width: 24rem; + width: 100%; + text-align: center; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + max-height: 92vh; + overflow-y: auto; +} +.goa-pay-x { + position: absolute; + top: 0.45rem; + right: 0.55rem; + border: 0; + background: transparent; + color: #c4b5fd; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + padding: 0.2rem 0.45rem; +} +.goa-pay-card h3 { + margin: 0 0 0.2rem; + font-size: 1.08rem; + color: #faf5ff; + padding-right: 1.5rem; +} +.goa-pay-amount { + margin: 0 0 0.85rem; + font-weight: 750; + color: #c4b5fd; + font-variant-numeric: tabular-nums; +} + +/* === QR_Taler frame (webui QR.tsx) === */ +.goa-pay-taler-qr { + width: 100%; + max-width: 280px; + margin: 0 auto 0.75rem; + padding: 10px; + border-radius: 20px; + box-sizing: border-box; + position: relative; + background: conic-gradient( + from var(--angle), + #0042b3 0deg, + #f1f1f4 20deg, + #f1f1f4 150deg, + #f1f1f4 160deg, + #0042b3 180deg, + #f1f1f4 200deg, + #f1f1f4 330deg, + #f1f1f4 340deg, + #0042b3 + ); + animation: goa-pay-qr-rotate 10s linear infinite; +} +.goa-pay-taler-qr__inner { + padding: 10px; + border-radius: 20px; + background: #fff; + line-height: 0; + min-height: 180px; + box-sizing: border-box; +} +/* Always an with explicit pixel size from JS */ +.goa-pay-taler-qr__inner img { + display: block !important; + margin: 0 auto !important; + border: 0 !important; + background: #fff; + image-rendering: pixelated; +} +/* Center logo plate — official qr-logo.png (webui ~100×50; PNG for Android) */ +.goa-pay-taler-qr__logo { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 28%; + height: auto; + max-width: 100px; + aspect-ratio: 200 / 95; + object-fit: contain; + pointer-events: none; + background: #fff; + border-radius: 4px; +} +/* Full encoded payload under QR — clickable (wallet / webextension) */ +.goa-pay-payload { + display: block; + margin: 0.45rem 0 0.55rem; + padding: 0 0.25rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.68rem; + line-height: 1.35; + color: #7eb6ff; + word-break: break-all; + text-align: center; + text-decoration: none; + border-bottom: 1px solid rgba(126, 182, 255, 0.4); + cursor: pointer; +} +.goa-pay-payload:hover { + color: #b8d6ff; + border-bottom-color: #7eb6ff; +} + +/* Settlement: collapsed by default, expand on click */ +.goa-pay-settle { + margin-top: 0.85rem; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + overflow: hidden; + background: rgba(0, 0, 0, 0.22); +} +.goa-pay-settle-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + margin: 0; + padding: 0.65rem 0.75rem; + border: 0; + background: transparent; + color: #cbd5e1; + font: inherit; + font-size: 0.78rem; + font-weight: 650; + text-align: left; + cursor: pointer; + line-height: 1.3; +} +.goa-pay-settle-toggle:hover { + background: rgba(148, 163, 184, 0.1); + color: #e2e8f0; +} +.goa-pay-settle-toggle-text { + flex: 1; +} +.goa-pay-settle-chevron { + flex-shrink: 0; + transition: transform 0.15s ease; + color: #94a3b8; + font-size: 0.85rem; +} +.goa-pay-modal.settle-open .goa-pay-settle-chevron { + transform: rotate(90deg); +} +.goa-pay-settle-body { + padding: 0 0.75rem 0.75rem; + border-top: 1px solid rgba(148, 163, 184, 0.25); +} +.goa-pay-settle-note { + margin-top: 0.55rem; +} +/* Settlement payto QR — plain box (not wallet pay / no logo / no blue ring) */ +.goa-pay-payto-qr { + width: fit-content; + max-width: 160px; + margin: 0.35rem auto 0.55rem; + padding: 0.45rem 0.5rem 0.5rem; + border-radius: 10px; + background: #fff; + border: 1px dashed rgba(100, 116, 139, 0.75); + box-sizing: border-box; +} +.goa-pay-payto-qr__caption { + font-size: 0.62rem; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + text-align: center; + margin: 0 0 0.35rem; + line-height: 1.2; +} +.goa-pay-payto-qr__inner { + line-height: 0; + min-height: 0; +} +.goa-pay-payto-qr__inner img { + display: block !important; + margin: 0 auto !important; + background: #fff; + image-rendering: pixelated; +} +.goa-pay-sub { + margin: 0 0 0.55rem; + font-size: 0.75rem; + line-height: 1.35; + color: #b8a8c9; + font-weight: 500; + text-align: center; +} + +.goa-pay-hint { + margin: 0 0 0.75rem; + font-size: 0.82rem; + color: #b8a8c9; +} +.goa-pay-hint.err { + color: #fca5a5; +} +.goa-pay-cta { + display: block; + text-decoration: none; + font-weight: 750; + padding: 0.65rem 1rem; + border-radius: 11px; + background: linear-gradient(135deg, #a78bfa, #6366f1); + color: #0f0a1a; + margin-bottom: 0.75rem; +} +.goa-pay-cta:hover { + filter: brightness(1.06); +} +.goa-pay-cta.disabled { + opacity: 0.45; + pointer-events: none; +} +.goa-pay-label { + margin: 0.55rem 0 0.2rem; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #b8a8c9; +} +.goa-pay-line { + margin: 0 0 0.35rem; + text-align: center; + font-size: 0.88rem; + font-weight: 650; +} +.goa-pay-link { + color: #93c5fd; + text-decoration: none; + white-space: nowrap; +} +.goa-pay-link:hover { + text-decoration: underline; +} +.goa-pay-link.disabled { + opacity: 0.45; + pointer-events: none; +} +.goa-pay-sep { + margin: 0 0.4rem; + color: #b8a8c9; + font-weight: 500; +} +.goa-pay-close { + display: block; + width: 100%; + margin-top: 0.85rem; + border: 1px solid rgba(167, 139, 250, 0.45); + background: transparent; + color: #c4b5fd; + font-weight: 650; + padding: 0.5rem; + border-radius: 10px; + cursor: pointer; +} + +/* Bank (gold/teal) overrides */ +.goa-pay-modal.bank-theme .goa-pay-card { + background: rgba(42, 32, 24, 0.98); + border-color: rgba(232, 168, 56, 0.4); +} +.goa-pay-modal.bank-theme .goa-pay-amount { + color: #5eead4; +} +.goa-pay-modal.bank-theme .goa-pay-cta { + background: linear-gradient(135deg, #5eead4, #0d9488); + color: #042f2e; +} +.goa-pay-modal.bank-theme .goa-pay-link { + color: #5eead4; +} +.goa-pay-modal.bank-theme .goa-pay-x, +.goa-pay-modal.bank-theme .goa-pay-close { + color: #e8c878; + border-color: rgba(232, 168, 56, 0.35); +} +.goa-pay-modal.bank-theme .goa-pay-settle { + border-color: rgba(232, 168, 56, 0.3); + background: rgba(0, 0, 0, 0.28); +} +.goa-pay-modal.bank-theme .goa-pay-settle-toggle { + color: #e8c878; +} +.goa-pay-modal.bank-theme .goa-pay-settle-body { + border-top-color: rgba(232, 168, 56, 0.25); +} diff --git a/configs/bank-landing/shop-pay.js b/configs/bank-landing/shop-pay.js new file mode 100644 index 0000000..2c0573b --- /dev/null +++ b/configs/bank-landing/shop-pay.js @@ -0,0 +1,410 @@ +/** + * GOA shop pay popup — public only (no merchant secrets). + * Flow: POST templates/{id} → taler://pay/… + payto links. + * + * QR display matches taler-merchant-webui QR_Taler + * (@gnu-taler/web-util QR.tsx): animated #0042B3 conic ring + qr-logo.png. + * Uses qrcode-generator (same lib as webui) via global QRCode if present, + * else falls back to canvas from qrcode.min.js (davidshimjs). + */ +(function () { + var MERCHANT_HOST = "taler.hacktivism.ch"; + /* goa-shop: dedicated instance with fixed-order product templates */ + var INSTANCE = "goa-shop"; + var SHOP_PAYTO = + "payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop"; + var SHOP_PAYTO_HTTPS = "https://bank.hacktivism.ch/webui/"; + + function introBase() { + var b = document.querySelector("base"); + return b && b.href ? b.href : "/intro/"; + } + + function logoSrc() { + /* PNG: Android often drops SVG wordmark fill inheritance (missing "taler") */ + return introBase() + "qr-logo.png"; + } + + function templateHttps(productId) { + return ( + "https://" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/templates/" + + encodeURIComponent(productId) + ); + } + + function payTemplateUri(productId) { + return ( + "taler://pay-template/" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/" + + encodeURIComponent(productId) + ); + } + + function ensureModal() { + var m = document.getElementById("goa-pay-modal"); + if (m) return m; + m = document.createElement("div"); + m.id = "goa-pay-modal"; + m.className = "goa-pay-modal"; + m.setAttribute("role", "dialog"); + m.setAttribute("aria-modal", "true"); + m.setAttribute("aria-labelledby", "goa-pay-title"); + m.hidden = true; + m.innerHTML = + '
' + + ' ' + + '

Pay with Taler

' + + '

' + + '

Preparing payment…

' + + '

Wallet payment

' + + '

Scan with the GNU Taler Wallet to pay this product

' + + '
' + + '
' + + ' ' + + "
" + + ' ' + + ' Open payment link →' + + '

' + + ' taler:// URI' + + ' ·' + + ' HTTPS' + + "

" + + '
' + + ' " + + ' " + + "
" + + ' ' + + "
"; + document.body.appendChild(m); + function close() { + m.classList.remove("open"); + m.hidden = true; + // collapse settlement on close + var body = document.getElementById("goa-pay-settle-body"); + var btn = document.getElementById("goa-pay-settle-toggle"); + if (body) body.hidden = true; + if (btn) { + btn.setAttribute("aria-expanded", "false"); + m.classList.remove("settle-open"); + } + } + m.addEventListener("click", function (e) { + if (e.target === m) close(); + }); + document.getElementById("goa-pay-x").onclick = close; + document.getElementById("goa-pay-close").onclick = close; + document.getElementById("goa-pay-settle-toggle").onclick = function () { + var body = document.getElementById("goa-pay-settle-body"); + var btn = document.getElementById("goa-pay-settle-toggle"); + var open = body.hidden; + body.hidden = !open; + btn.setAttribute("aria-expanded", open ? "true" : "false"); + m.classList.toggle("settle-open", open); + if (open) { + // paint payto QR when first expanded + renderQr(document.getElementById("goa-pay-qr-payto"), SHOP_PAYTO, 140); + var pp = document.getElementById("goa-pay-payto-payload"); + if (pp) { + pp.href = SHOP_PAYTO; + pp.textContent = SHOP_PAYTO; + } + } + }; + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && m.classList.contains("open")) close(); + }); + return m; + } + + /** + * Render URI as a real PNG (not a live canvas). + * davidshimjs paints canvas then often hides it for an img; if the modal is + * still display:none, layout collapses and you only see the blue ring. + * We always encode off-DOM and inject a fixed-size image. + */ + function renderQr(hostEl, text, size) { + if (!hostEl) return; + hostEl.innerHTML = ""; + size = size || 220; + if (!text) { + hostEl.innerHTML = '

'; + return; + } + if (typeof QRCode === "undefined") { + hostEl.innerHTML = + '

QR library missing (qrcode.min.js)

'; + return; + } + var level = + QRCode.CorrectLevel && QRCode.CorrectLevel.M != null + ? QRCode.CorrectLevel.M + : QRCode.CorrectLevel && QRCode.CorrectLevel.L != null + ? QRCode.CorrectLevel.L + : 1; + var scratch = document.createElement("div"); + scratch.setAttribute("aria-hidden", "true"); + scratch.style.cssText = + "position:fixed;left:-9999px;top:0;width:" + + size + + "px;height:" + + size + + "px;overflow:hidden;opacity:0;pointer-events:none"; + document.body.appendChild(scratch); + var dataUrl = ""; + try { + new QRCode(scratch, { + text: String(text), + width: size, + height: size, + colorDark: "#000000", + colorLight: "#ffffff", + correctLevel: level, + }); + var canvas = scratch.querySelector("canvas"); + var libImg = scratch.querySelector("img"); + if (canvas && canvas.width > 0) { + try { + dataUrl = canvas.toDataURL("image/png"); + } catch (e1) {} + } + if (!dataUrl && libImg && libImg.src && libImg.src.indexOf("data:") === 0) { + dataUrl = libImg.src; + } + } catch (err) { + dataUrl = ""; + } + if (scratch.parentNode) scratch.parentNode.removeChild(scratch); + + if (!dataUrl) { + hostEl.innerHTML = + '

QR encode failed

'; + return; + } + var img = document.createElement("img"); + img.alt = "QR code"; + img.width = size; + img.height = size; + img.src = dataUrl; + img.style.display = "block"; + img.style.width = size + "px"; + img.style.height = size + "px"; + img.style.maxWidth = "100%"; + img.style.margin = "0 auto"; + img.style.background = "#fff"; + hostEl.appendChild(img); + } + + function setLogo(imgEl) { + if (!imgEl) return; + imgEl.alt = "Taler"; + imgEl.width = 100; + imgEl.height = 50; + imgEl.decoding = "async"; + imgEl.src = logoSrc(); + imgEl.onerror = function () { + imgEl.src = introBase() + "qr-logo.svg"; + }; + imgEl.style.display = ""; + } + + function normalizePayUri(uri) { + if (!uri) return ""; + return String(uri) + .replace(/taler\.hacktivism\.ch:443/g, "taler.hacktivism.ch") + .replace(/:443\//g, "/") + .replace(/:443\?/g, "?"); + } + + /** Public: template → order → taler_pay_uri (no secrets). */ + function createPayUri(productId) { + var url = templateHttps(productId); + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + cache: "no-store", + }) + .then(function (r) { + if (!r.ok) throw new Error("template POST HTTP " + r.status); + return r.json(); + }) + .then(function (created) { + var oid = created.order_id; + var tok = created.token; + if (!oid || !tok) throw new Error("no order_id/token"); + var statusUrl = + "https://" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/orders/" + + encodeURIComponent(oid) + + "?token=" + + encodeURIComponent(tok); + return fetch(statusUrl, { cache: "no-store" }).then(function (r) { + return r.text().then(function (t) { + var pay = ""; + try { + var d = JSON.parse(t); + pay = d.taler_pay_uri || ""; + } catch (e) {} + if (!pay) { + pay = + "taler://pay/" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/" + + oid + + "/?c=" + + tok; + } + return { + taler_pay_uri: normalizePayUri(pay), + order_id: oid, + token: tok, + status_https: statusUrl.replace( + "taler.hacktivism.ch:443", + "taler.hacktivism.ch" + ), + template_https: url, + }; + }); + }); + }); + } + + function showPay(productId, name, amount) { + var m = ensureModal(); + if (document.body && document.body.getAttribute("data-shop-theme") === "bank") { + m.classList.add("bank-theme"); + } else { + m.classList.remove("bank-theme"); + } + document.getElementById("goa-pay-title").textContent = name || productId; + document.getElementById("goa-pay-amount").textContent = amount || ""; + var status = document.getElementById("goa-pay-status"); + status.textContent = "Preparing payment…"; + status.className = "goa-pay-hint"; + var open = document.getElementById("goa-pay-open"); + open.removeAttribute("href"); + open.classList.add("disabled"); + open.onclick = null; + var payLink = document.getElementById("goa-pay-uri"); + payLink.removeAttribute("href"); + payLink.classList.add("disabled"); + payLink.textContent = "taler:// URI"; + document.getElementById("goa-pay-uri-https").href = templateHttps(productId); + document.getElementById("goa-pay-uri-https").textContent = "HTTPS"; + var paytoLink = document.getElementById("goa-pay-payto"); + paytoLink.href = SHOP_PAYTO; + paytoLink.textContent = "payto:// URI"; + document.getElementById("goa-pay-payto-https").href = SHOP_PAYTO_HTTPS; + document.getElementById("goa-pay-payto-https").textContent = "HTTPS"; + + setLogo(document.getElementById("goa-pay-qr-pay-logo")); + + // Reset settlement panel (collapsed until user expands) + var settleBody = document.getElementById("goa-pay-settle-body"); + var settleBtn = document.getElementById("goa-pay-settle-toggle"); + if (settleBody) settleBody.hidden = true; + if (settleBtn) settleBtn.setAttribute("aria-expanded", "false"); + m.classList.remove("settle-open"); + var paytoHost = document.getElementById("goa-pay-qr-payto"); + if (paytoHost) paytoHost.innerHTML = ""; + + // Open first so layout exists, then paint wallet QR only + m.hidden = false; + m.classList.add("open"); + var tpl = payTemplateUri(productId); + renderQr( + document.getElementById("goa-pay-qr-pay"), + tpl, + 220 + ); + var payload = document.getElementById("goa-pay-qr-payload"); + if (payload) { + payload.href = tpl; + payload.textContent = tpl; + } + + createPayUri(productId) + .then(function (info) { + var pay = info.taler_pay_uri; + open.href = pay; + open.classList.remove("disabled"); + open.textContent = "Open payment link →"; + payLink.href = pay; + payLink.classList.remove("disabled"); + payLink.textContent = "taler:// URI"; + var uh = document.getElementById("goa-pay-uri-https"); + uh.href = info.status_https || info.template_https; + uh.textContent = "HTTPS"; + // Live unpaid taler://pay (fresh order each open) + renderQr(document.getElementById("goa-pay-qr-pay"), pay, 220); + if (payload) { + payload.href = pay; + payload.textContent = pay; + } + status.textContent = "Ready — scan in wallet"; + }) + .catch(function (err) { + status.textContent = + "Payment setup failed: " + (err && err.message ? err.message : err); + status.className = "goa-pay-hint err"; + open.textContent = "Retry"; + open.onclick = function (e) { + e.preventDefault(); + showPay(productId, name, amount); + }; + }); + } + + function bind() { + document.querySelectorAll("[data-product]").forEach(function (el) { + el.addEventListener("click", function (e) { + e.preventDefault(); + var id = el.getAttribute("data-product"); + var nameEl = el.querySelector(".name"); + var priceEl = el.querySelector(".price"); + showPay( + id, + nameEl ? nameEl.textContent.trim() : id, + priceEl ? priceEl.textContent.trim() : "" + ); + }); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bind); + } else { + bind(); + } +})(); diff --git a/configs/bank-landing/terms.html b/configs/bank-landing/terms.html new file mode 100644 index 0000000..4a8f4ff --- /dev/null +++ b/configs/bank-landing/terms.html @@ -0,0 +1,75 @@ + + + + + + No Formal Terms · GOA Bank + + + +
bank.hacktivism.ch · GOA
+

No Formal Terms · GOA Exploration Bank

+

This is a self-hosted GNU Taler regional bank at + bank.hacktivism.ch (hacktivism.ch) for the explorational currency + GOA.

+

No formal terms of service apply beyond this short notice.

+ +
+ GOA · explorational + Not legal tender. No guaranteed real-world value, redemption, or convertibility. + Accounts and demo withdraws are for exploration of the GNU Taler stack only. +
+ +

By using this bank you acknowledge

+
    +
  • GOA is for exploration and testing only.
  • +
  • There is no guaranteed availability, support, or uptime.
  • +
  • Operators may reset accounts, balances, or configuration without notice.
  • +
  • Registration data and wire-style transfers may identify account holders to operators.
  • +
  • Software is provided as-is, without warranty.
  • +
+

If you do not agree, do not use this bank.

+ +

Related

+ + +

Privacy

+

Processing under Swiss FADP (revDSG). What data is retained + (accounts, ledger, logs, …) is listed on + /privacy.

+
bank.hacktivism.ch · landing terms
+ + diff --git a/configs/bonfire/README.md b/configs/bonfire/README.md new file mode 100644 index 0000000..8ce50a4 --- /dev/null +++ b/configs/bonfire/README.md @@ -0,0 +1,28 @@ +# Bonfire — bonfire.hacktivism.ch + +| Item | Value | +|------|--------| +| Live | `/home/hernani/koopa-bonfire/` | +| Image | `bonfirenetworks/bonfire:1.0.5-social-amd64` | +| Port | **9021** → Caddy | +| Ground zero | `2026/2026-07-09--bonfire-ground-zero.md` | + +Secrets: `koopa-admin-secrets/…/koopa-bonfire/{.env,users.env}`. + +**Upstream:** [Running your own](https://docs.bonfirenetworks.org/running-your-own.html), [Hosting guide](https://docs.bonfirenetworks.org/deploy.html) (we use podman-compose, not Co-op Cloud). + +## Branding + +Hacktivism theme + hacktivism magician logo. `scripts/bonfire/apply-branding.sh`. +Logo bind-mount: `2026/2026-07-11--bonfire-logo-not-served.md`. + +## Boot + +```bash +scripts/bonfire/install-systemd.sh +``` + +Units: `container-koopa-bonfire-db`, `container-koopa-bonfire`, `gitbot-mirror`. +Needs `loginctl enable-linger hernani`. Run `podman-compose up -d` once before first boot. + +Public feeds: `public-feeds.md`. \ No newline at end of file diff --git a/configs/bonfire/assets/img/favicon.svg b/configs/bonfire/assets/img/favicon.svg new file mode 100644 index 0000000..e1d3dc3 --- /dev/null +++ b/configs/bonfire/assets/img/favicon.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/bonfire/assets/img/logo.png b/configs/bonfire/assets/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..03e96c28f7cbd8307bcffcad0456e2ee3c497bb9 GIT binary patch literal 98601 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≺Fg5FEI6*GS3JIpz}S^FWX312^i@e}Wwi1G8& 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/bonfire/compose.yml b/configs/bonfire/compose.yml new file mode 100644 index 0000000..eb94daa --- /dev/null +++ b/configs/bonfire/compose.yml @@ -0,0 +1,55 @@ +# koopa-bonfire — ground zero (host Caddy → :9021) +services: + web: + image: docker.io/bonfirenetworks/bonfire:1.0.5-social-amd64 + container_name: koopa-bonfire + restart: unless-stopped + env_file: [.env] + environment: + POSTGRES_HOST: db + HOSTNAME: bonfire.hacktivism.ch + PUBLIC_PORT: "443" + SERVER_PORT: "4000" + DB_MIGRATE_INDEXES_CONCURRENTLY: "false" + MIX_ENV: prod + ports: + - "9021:4000" + volumes: + - ./data/uploads:/opt/app/data/uploads + # Bind-mount into priv/static/images (Bonfire serves /images/* from there). + - ./data/branding/logo.svg:/opt/app/lib/bonfire-1.0.5-social/priv/static/images/hacktivism-logo.svg:ro + - ./data/branding/favicon.svg:/opt/app/lib/bonfire-1.0.5-social/priv/static/images/hacktivism-favicon.svg:ro + depends_on: + db: + condition: service_healthy + labels: + org.hacktivism.service: bonfire + org.hacktivism.host_port: "9021" + org.hacktivism.site: bonfire.hacktivism.ch + org.hacktivism.managed_by: koopa-admin + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:4000/ || exit 1"] + interval: 30s + timeout: 10s + retries: 15 + start_period: 180s + + db: + image: docker.io/library/postgres:15-alpine + container_name: koopa-bonfire-db + restart: unless-stopped + env_file: [.env] + environment: + POSTGRES_USER: postgres + POSTGRES_DB: bonfire_db + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d bonfire_db"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + +volumes: + db-data: diff --git a/configs/bonfire/container-koopa-bonfire-db.service b/configs/bonfire/container-koopa-bonfire-db.service new file mode 100644 index 0000000..29bd9c7 --- /dev/null +++ b/configs/bonfire/container-koopa-bonfire-db.service @@ -0,0 +1,21 @@ +# user systemd - Postgres for Bonfire +[Unit] +Description=Bonfire Postgres (koopa-bonfire-db) +Wants=network-online.target +After=network-online.target +RequiresMountsFor=%t/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=on-failure +RestartSec=15 +TimeoutStartSec=300 +TimeoutStopSec=70 +WorkingDirectory=/home/hernani/koopa-bonfire +ExecStart=/usr/bin/podman-compose up -d db +ExecStop=/usr/bin/podman stop -t 30 koopa-bonfire-db +Type=oneshot +RemainAfterExit=yes + +[Install] +WantedBy=default.target diff --git a/configs/bonfire/container-koopa-bonfire.service b/configs/bonfire/container-koopa-bonfire.service new file mode 100644 index 0000000..9caf3cc --- /dev/null +++ b/configs/bonfire/container-koopa-bonfire.service @@ -0,0 +1,21 @@ +# user systemd - Bonfire web +[Unit] +Description=Bonfire web (koopa-bonfire) +Wants=network-online.target container-koopa-bonfire-db.service +After=network-online.target container-koopa-bonfire-db.service +RequiresMountsFor=%t/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=on-failure +RestartSec=20 +TimeoutStartSec=300 +TimeoutStopSec=120 +WorkingDirectory=/home/hernani/koopa-bonfire +ExecStart=/usr/bin/podman-compose up -d web +ExecStop=/usr/bin/podman stop -t 30 koopa-bonfire +Type=oneshot +RemainAfterExit=yes + +[Install] +WantedBy=default.target diff --git a/configs/bonfire/gitbot-mirror.service b/configs/bonfire/gitbot-mirror.service new file mode 100644 index 0000000..f02e2a0 --- /dev/null +++ b/configs/bonfire/gitbot-mirror.service @@ -0,0 +1,18 @@ +# user systemd - git.ngi-0.eu -> Bonfire gitbot + +[Unit] +Description=Bonfire gitbot (git.ngi-0.eu poll) +Wants=network-online.target container-koopa-bonfire.service +After=network-online.target container-koopa-bonfire.service + +[Service] +Type=simple +Restart=always +RestartSec=30 +WorkingDirectory=/home/hernani/koopa-bonfire +ExecStart=/usr/bin/python3 /home/hernani/koopa-bonfire/bin/gitbot-mirror.py --loop +StandardOutput=append:/home/hernani/koopa-bonfire/gitbot.log +StandardError=append:/home/hernani/koopa-bonfire/gitbot.log + +[Install] +WantedBy=default.target diff --git a/configs/bonfire/public-feeds.md b/configs/bonfire/public-feeds.md new file mode 100644 index 0000000..bdceb78 --- /dev/null +++ b/configs/bonfire/public-feeds.md @@ -0,0 +1,11 @@ +# Bonfire public feeds + +Posts land in the author outbox first. Copy to local/internet feeds or guests see stale timelines. + +**Official:** [Feed structure](https://docs.bonfirenetworks.org/feed_structure.html) (`FeedPublish`). +No official republish recipe — SQL + gitbot below are ops workarounds. + +- One-off SQL: `scripts/bonfire/publish-outbox-to-public.sql` +- Gitbot: republish every cycle (`gitbot-mirror.py`) + +Check: https://bonfire.hacktivism.ch/feed/local (not only `/`). \ No newline at end of file diff --git a/configs/bonfire/theme-hacktivism.json b/configs/bonfire/theme-hacktivism.json new file mode 100644 index 0000000..7545914 --- /dev/null +++ b/configs/bonfire/theme-hacktivism.json @@ -0,0 +1,29 @@ +{ + "name": "hacktivism", + "palette": "exchange-dark (git.hacktivism.ch / forgejo theme-hacktivism)", + "colors": { + "color-base-100": "#1a1410", + "color-base-200": "#221c16", + "color-base-300": "#2a2018", + "color-base-content": "#fff6e8", + "color-primary": "#e8a838", + "color-primary-content": "#1a1410", + "color-secondary": "#3d3128", + "color-secondary-content": "#fff6e8", + "color-accent": "#3ecfbf", + "color-accent-content": "#0e1c1e", + "color-neutral": "#14110e", + "color-neutral-content": "#ebe0d0", + "color-info": "#2563eb", + "color-info-content": "#ffffff", + "color-success": "#16a34a", + "color-success-content": "#ffffff", + "color-warning": "#f0d090", + "color-warning-content": "#1a1410", + "color-error": "#b91c1c", + "color-error-content": "#ffffff", + "radius-box": "0.875rem", + "radius-field": "0.5rem", + "radius-selector": "0.75rem" + } +} \ No newline at end of file diff --git a/configs/caddy/Caddyfile b/configs/caddy/Caddyfile new file mode 100644 index 0000000..03f88aa --- /dev/null +++ b/configs/caddy/Caddyfile @@ -0,0 +1,185 @@ +# Internal only (not in the browser URL): +# 9010 merchant API | 9011 exchange API | 9012 bank API +# 9013 bank landing | 9014 exchange landing | 9015 merchant landing +# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | | 9200 forgejo-ssh +# 9090 tops ng1 | 9091 tops ng2 | 9092 tops ng3 + +{ + email info+koopa@hacktivism.ch + http_port 9000 + https_port 9001 + auto_https disable_redirects + # Caddy listens on 9001 behind VeciGate/https-proxy :443. + # Default HTTP/3 would send Alt-Svc: h3=":9001" — break public HTTPS on :443. + servers { + protocols h1 h2 + } +} + +(proxy_public) { + header_up Host {host} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Port 443 + header_down Location "^https?://[^/]+:90[0-9]{2}(.*)$" "https://{host}$1" +} + +taler.hacktivism.ch { + tls /etc/caddy/certs/taler.hacktivism.ch/fullchain.pem /etc/caddy/certs/taler.hacktivism.ch/privkey.pem + header Alt-Svc "clear" + + # Public landing first + redir / /intro/ 302 + + handle /intro* { + reverse_proxy 127.0.0.1:9015 { + import proxy_public + } + } + + # SPA: /webui → /webui/ + redir /webui /webui/ 302 + + # Merchant API + WebUI (nginx :9010 → unix socket) + reverse_proxy https://127.0.0.1:9010 { + transport http { + tls_insecure_skip_verify + } + import proxy_public + } +} + +exchange.hacktivism.ch { + tls /etc/caddy/certs/exchange.hacktivism.ch/fullchain.pem /etc/caddy/certs/exchange.hacktivism.ch/privkey.pem + header Alt-Svc "clear" + + # Public landing first + redir / /intro/ 302 + + handle /intro* { + reverse_proxy 127.0.0.1:9014 { + import proxy_public + } + } + + reverse_proxy 127.0.0.1:9011 { + import proxy_public + } +} + +bank.hacktivism.ch { + header Alt-Svc "clear" + + # Public landing first + redir / /intro/ 302 + + handle /intro* { + reverse_proxy 127.0.0.1:9013 { + import proxy_public + } + } + + # Static terms/privacy on landing nginx :9013 + handle /terms* { + reverse_proxy 127.0.0.1:9013 { + import proxy_public + } + } + handle /privacy* { + reverse_proxy 127.0.0.1:9013 { + import proxy_public + } + } + + reverse_proxy 127.0.0.1:9012 { + import proxy_public + } +} + +castopod.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9020 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + } +} + +bonfire.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9021 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + flush_interval -1 + } +} + +prime.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9022 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + flush_interval -1 + } +} + +bt.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9023 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + } +} + +# 9024 forgejo HTTP (SSH :9200 host-direct, not via Caddy) +git.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9024 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + flush_interval -1 + transport http { + read_timeout 3600s + write_timeout 3600s + } + } +} + + +# Taler Operations design previews (static nginx) +tops.ng1.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9090 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +tops.ng2.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9091 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +tops.ng3.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9092 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +http://taler.hacktivism.ch, http://exchange.hacktivism.ch, http://bank.hacktivism.ch, http://castopod.hacktivism.ch, http://bonfire.hacktivism.ch, http://prime.hacktivism.ch, http://bt.hacktivism.ch, http://git.hacktivism.ch, http://tops.ng1.hacktivism.ch, http://tops.ng2.hacktivism.ch, http://tops.ng3.hacktivism.ch { + handle /.well-known/acme-challenge/* { + root * /var/www/acme + file_server + } + handle { + redir https://{host}{uri} permanent + } +} diff --git a/configs/caddy/Caddyfile.taler-host b/configs/caddy/Caddyfile.taler-host new file mode 100644 index 0000000..be292f6 --- /dev/null +++ b/configs/caddy/Caddyfile.taler-host @@ -0,0 +1,24 @@ +# LEGACY — historical Taler-only Caddy sketch (merchant backend :8081). +# Do NOT use on koopa. Current multi-vhost config: +# configs/caddy/Caddyfile (canonical) +# host/caddy/Caddyfile (same content) +# +# Kept only as archaeology of the early taler.hacktivism.ch TLS layout. + +# Multi-domain reverse proxy (TLS terminate on host) +# Backend at the time: container nginx TLS on 8081 + +{ +# no global auto-HTTPS email yet; per-site manual certs +auto_https disable_redirects +} + +taler.hacktivism.ch { +tls /etc/caddy/certs/taler.hacktivism.ch/fullchain.pem /etc/caddy/certs/taler.hacktivism.ch/privkey.pem + +reverse_proxy https://127.0.0.1:8081 { +transport http { +tls_insecure_skip_verify +} +} +} diff --git a/configs/caddy/README.md b/configs/caddy/README.md new file mode 100644 index 0000000..432d73c --- /dev/null +++ b/configs/caddy/README.md @@ -0,0 +1,9 @@ +# Caddy config mirrors + +| File | Role | +|------|------| +| **`Caddyfile`** | Multi-vhost config mirror (`/etc/caddy/Caddyfile` on host) | +| `git.hacktivism.ch.caddy` | Site-block snippet for Forgejo | +| `Caddyfile.taler-host` | **Legacy** Taler-only sketch — do not deploy | + +Also: `host/caddy/Caddyfile` (same as `Caddyfile` here). diff --git a/configs/caddy/git.hacktivism.ch.caddy b/configs/caddy/git.hacktivism.ch.caddy new file mode 100644 index 0000000..3addce5 --- /dev/null +++ b/configs/caddy/git.hacktivism.ch.caddy @@ -0,0 +1,15 @@ +# 9024 forgejo HTTP (SSH :9200 host-direct, not via Caddy) +# Live: merged into /etc/caddy/Caddyfile (see also full Caddyfile mirror) +git.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9024 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + flush_interval -1 + transport http { + read_timeout 3600s + write_timeout 3600s + } + } +} diff --git a/configs/castopod/README.md b/configs/castopod/README.md new file mode 100644 index 0000000..f10213f --- /dev/null +++ b/configs/castopod/README.md @@ -0,0 +1,21 @@ +# Castopod — castopod.hacktivism.ch + +| Item | Value | +|------|--------| +| Live | `/home/hernani/koopa-castopod/` | +| Compose | `compose.yml` (mirror, no secrets) | +| Port | **9020** → Caddy | +| Site | https://castopod.hacktivism.ch/ | + +Secrets: `koopa-admin-secrets/…/koopa-castopod/` and host `~/koopa-castopod/{.env,users.env}`. + +```bash +cd ~/koopa-castopod && set -a && source .env && set +a && podman-compose up -d +scripts/castopod/install-systemd.sh +scripts/castopod/apply-branding.sh +``` + +Daylogs: `2026/2026-07-09--castopod-content.md` (stack/accounts), +`2026/2026-07-13--castopod-boot-branding.md` (boot/branding). + +Podcast content is **not** versioned in admin-log. diff --git a/configs/castopod/assets/css/theme-hacktivism-overlay.css b/configs/castopod/assets/css/theme-hacktivism-overlay.css new file mode 100644 index 0000000..3e62cd6 --- /dev/null +++ b/configs/castopod/assets/css/theme-hacktivism-overlay.css @@ -0,0 +1,64 @@ +/* + * Optional warm-dark overlay for castopod.hacktivism.ch + * NOT an official Castopod theme — Castopod only documents 6 accent colors + * (pine, crimson, lake, amber, jacaranda, onyx). See: + * https://docs.castopod.org/main/en/user-guide/instance/settings/ + * + * Palette matches git.hacktivism.ch / bonfire (exchange-dark): + * body #1a1410, cream text, gold accent #e8a838, teal #3ecfbf. + * Prefer official theme "amber" first; this CSS deepens the page chrome. + */ + +:root, +.theme-amber, +.theme-pine, +.theme-onyx { + --color-accent-base: 38 78% 56% !important; /* ~#e8a838 */ + --color-accent-hover: 38 72% 48% !important; + --color-accent-muted: 38 60% 32% !important; + --color-accent-contrast: 28 22% 8% !important; + --color-heading-foreground: 40 80% 75% !important; + --color-heading-background: 28 22% 14% !important; + --color-background-elevated: 28 18% 14% !important; + --color-background-base: 28 22% 8% !important; + --color-background-navigation: 28 20% 10% !important; + --color-background-header: 28 22% 9% !important; + --color-background-highlight: 28 18% 16% !important; + --color-background-backdrop: 0 0% 0% !important; + --color-border-subtle: 32 18% 28% !important; + --color-border-contrast: 40 50% 70% !important; + --color-border-navigation: 32 18% 28% !important; + --color-text-base: 36 80% 95% !important; /* ~#fff6e8 */ + --color-text-muted: 32 20% 72% !important; +} + +body { + background-color: #1a1410 !important; + background-image: + radial-gradient(ellipse 90% 55% at 50% 108%, rgba(26, 107, 110, 0.35) 0%, transparent 55%), + radial-gradient(circle 420px at 12% 18%, rgba(232, 168, 56, 0.22) 0%, transparent 62%), + linear-gradient(165deg, #2c1e14 0%, #1a1410 38%, #12181a 72%, #0e1c1e 100%) !important; + background-attachment: fixed !important; + color: #fff6e8 !important; +} + +a { + color: #f0c86a; +} +a:hover { + color: #5eead4; +} + +/* Primary-ish buttons / chips that use solid white on accent */ +.bg-white { + background-color: rgba(42, 32, 24, 0.92) !important; + color: #fff6e8 !important; + border: 1px solid rgba(232, 168, 56, 0.28); +} + +/* Footer */ +footer, +.page-footer { + border-top: 1px solid rgba(232, 168, 56, 0.22); + color: #c9b8a0 !important; +} diff --git a/configs/castopod/assets/img/favicon.svg b/configs/castopod/assets/img/favicon.svg new file mode 100644 index 0000000..e1d3dc3 --- /dev/null +++ b/configs/castopod/assets/img/favicon.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/castopod/assets/img/logo.png b/configs/castopod/assets/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..03e96c28f7cbd8307bcffcad0456e2ee3c497bb9 GIT binary patch literal 98601 zcmZ^KbyQSu)b7wNCEXz)AtgPugi0zY4bmM-Gb1G((jf?lw4{W@&@BUkfYcDu-8nP& z@cZt%Yu!IS7Hb{W4Cj5{v-8={-X~f|OO1q(o)82Akvvy__6h{T1pdSX;o}0AGry@D z;KFL7{z?-B3g7~Ppy43U{YTI(5a^u{2()7f0!e3rKr|k?KXt*t7r53h)t&)Y;OAPo z6#`uFe4cBn;O*cMKA_}qht|&lA3gF>G4gq9VOi!cV1z zrKCm0_=JR{g@j1m`KJEA8(iJrI@B^qq&NUK@ch3Wd>mbEy?v})J^uH59&^_< zfj}5Rj(SEu-tXLzW`x9_X%(OkSt_o_2vB2H%Xvg5JIF*{`U2ftqKDUN;AGAe+%-a}HzBmRVU zz(B#cx`y0mjBnGcD$9DC^CRZ1^pze?DzXa3tc{ zapq!475CWqtsLHu@6j4Pc-nilLLN_}UfRDi6&cARa?nEJFUVF+%VbifVQ(j3p&b1# zIc&6}C$4pWwZzKWt>XF$x;yVR`a1J36(h?7exx_$6eYiqCefnHHp`^xbs=Jy4U7d} z;+*(vm6zE*gtM>r$F ztE<{4)oqk|(H~fs{1t^$<;jb}RDAN22p+?v{tgt#@*i819~&yK+}Y_^^gW#4^yP3ju(P$dwX@}O_y!xjBiuMJ6FV@=oFA;5Ae^jj z%JB&^Nj)5)_<$JS0NO6;N)cWayog7eSU{6lCfl*^H!8K=l&jA9UA>Noi$c*a9s5H7 z%N59vrCE)v`FdBb(~mgkIMWV_$Gc1`8MnD?eyPYs5xuMY3Pd46#dbAqC42+ZBts#v zg4B<0-7#>gXL_F;)4n(2;lokH@WTXSCqM&!wLGGT?tKFsq`9mCQ`W}n4NQ~v8uDmN z!r$8(L>O8qf~|VooB-8c^-<6P(cd4K^NHerJ18GjWMF^zsd7|-VCTUMY%>lyzc6wE zH$DeXxBKmJgdXLSn_{5ev3Rg2`_(@uR9O9Hjn9E5uNh%4b>sLBNeM^$4|H^~I~*sm zUPY9A#Rkrf&56V6={8L*M(G6FGnQR%cFU} zP+xd9*M`PW#AsF~D>T6_xZ%3G6ZC%2?l;=>fzpyN?!)U>j0(Wsg!lidCqnIyuEMeW zXh~Iry>k7H7n44**11cP4IAemC1fW0*(!x8-fMgIV-cetpoQpayf(TB$kN4{HYY`O z*3(zO0&j>qr7>>4eMNm%GofylMLq7?L#u3FQ3AIr{SIUIeC_yhw{aWdx{a8|Ir=vF z6C_T60a4?(rv2V?3>d|X#=X$e8Vk)$u)h2%7HnbuUVCwsMs^NaOps4jZ?pSP1++@< z+T|2I5}M6X-ji87es@SeE#UZ^6xxp-gQ9a{tXh!n`4UCjaQy_>=cWNfBc2xF{WJ1z zD<(IW;S)V#-3%Z=>(8yZN1$^UJ2TZem&pFp8K7hm&i|I6px=0k%@TkNY7MMv7(n!T zzn}t9M)$scE(~n&g73~gd_#&-31r5Aef5oRz_m$!MhZm-ESwA|{(n~B{s-5_xAoD+ z4>=Q0L`n025_GUjWl_(;C0{|D6wxK09zuIHi}gWT_*Fin1cVCnZ#OjuC{^zk*W}rN zBRpIEbg$;uj+&bvJ8hIy+Cx6nboRf1;~Z$#g6$$^0S>|Un=OX#~rYO*y%Ss=|>cT zvG1{iLEyMaIU>rN9O)ZwW|Lja*+WJ&#?XJp{Jajt>Bc^Mg8x&kv>1{|rVn8{*%D*k zWmCMWegpd6BL(a;qyC@rzLUywWTHv&mCmJ~P2Bob;_;gZeZm1GV;TcIxf`!5JyUoa zS|C(N)~*1%t?Wsi0q{UJA;jS55z+4-F;mnz%|-t+#x`CpZ0##1MvCw)n{I~y1$B~9 zYayt=nfN=$M2S%br5%JabttR~LrAxn+`njz2GAZucaC)Tb53*KrJ{DFGkf$;m@d|9 z@&pw>p6(`b+n93T5%xK>&cN3~^dDX;~l3tLuNR!&V176=E~g}X-`o%>ai zs)YVo%|K1NPq!;g-Tp%Z2CmH=|Byl=)bYn2HNJo8UB!wPlx$IeR-g5#(l zsx9#-q0?1gm9WrUSHYq#%9V;lI20P=QFuhQ{#qZI-Yo&G#vbk_dX6MpC1&o{RdAqrY-hRG3V161mBfol9&<-MDXNuPayc4~d;)O7fN5IG zmE-#6fR}HR-(m(BaKDv%ljOJ>>v3|tKAK$#B5Ok+^Zw+c-v2RTaM}&jM))tfF{+ho z#CK)12<QZWyGRi?@Y>IbGVeDPh3K_IhNFxQ>As5J ze8losX(#V2PM)zJN2c5hdHGg;Pf!5u4LfCfS42x5G6_3*3_HTZ%Z+PKZ5cdBPg~UF z3(G68h#?M){H*zMkw@hTaRAp(tnNX??TGIKCo0U(XuKG9A-G5>!}{A*|Ddg_Gu<9; zQih8}#*){9DOn;`AY*Mwev9TQaRZv?pDKrKb?RsM+ z{d$=FD=wxH}RqX2e^Q2IsF>0x&@>L$AjGRAhINGg5{rjMlJoO9wGfxpd{#0&+ z--I2UX>hZW!g1-TzuUT2b`V$cEpsf?xT8GGF~oOLWRJksipQ(K5Fhz!iz?Eb?O{uj zib|&s@3kKbHbkFq@#Z)%F3AkE%`hD z&#>Q)6mwvLmh4CFgrdp5lZi4wCa3=fYa#z6!N*?G=j(4bF71AZYZgTai|`OV)9+WE zrI8fZom4Lb(wJ%FeIVp??Uhyd?DzN@%PJX)Mwn?&dQUic79I%0JPl@C?Wx`XB z`77sX`FipS_+|Z{4$Np}{YsrIOC(evyGbNv(tf~{m>7!b;kPRJnMZ%$(X@u%FY(bC zH_wM$osKIv@P_8Hf|4jJe05>0of(0uh8@0xsNCnHd+*ZhBRfDd*C>;HZ9P8671DVW z>xsE4`?PS1WGa3@s!Pg&-qzr8Ns46$#OvQ*T1njCm(@@IMRcj+bV?O}2Tx|%gFP7& z!>-Xsv!1h_IJsKjN#0&YzhbI&T!nCra7A$cXTQ-v>=n`iUQ4Xz5{pnk5-I;B$xq~} zQzo5E-GdMsT5{FDbiJmiF_yAOa({iSRb`Tu7gEwYew^>*)^*jrbDBhnw9| z2>+vyrK06RXvd$me9S!7D`%xc=tB|!aE|>4exfi4Ll5sB9J%El^uNQQX&5%Zp%K6k zW96YG@JHjiJn+W|>kv|SJi_|XK20Q#XYF|nX7k)yyIQ-_p2!vP03^c_RlW8ULu(Ce zi~%InmivfLDa;>)CWRhi%3&{IG!k^5FLYhDZ9q)W`8ubD?X1*Zd=LAD=#38cj2czh zNS2R!kJsfi>|X4r&3%wg^52#JoPoQsIsxmoSx6YzTa(i7B(KrdA^Z<<{m-G5Bt+!6 zuh5s!OK2hiVW)tShgnD#)AOcT2>*tlcqU2>YXsRIR}CI)hS5BP!kHN=lauBQ@lxc= zqb&B+{{3#blVS@g_<>8i9G*md2Z6_h%UlIBYeUBIPjr-L}#qs|r>w(pQ$WbU+E*!_z`-8Johgc7VT$BDk)Qt(!jd z2}#0TV#|U*&C8xBuzQ}W-`50|1RNXl*syCn&yJTmgx|UD*myx~SK4uwl}W~IBVPt1 z*eQo+xj|pzc4NRUDJnP$`egC@;S=_B6*+znT90#?DOJ&QM@dQaMi&0nfR|Nq-J@J3 zhNqX%B6OTOmd1KU`hoka0uYvJl>nj6e0q6x z*P7ay^aZr=DDD%2?F#h~JaWh?PMMvEs-LB9eu(S2v#wn(p}U6QlK0YTRH6*ixqJv2X~l1pU=L4-HNc<1VIhxQ&LZMJ5Ilm|oXc-g9b?CK z){{)Rf@$zEAZvc#UERZqnme{#0PyN21c2AJDxVyrOWlH`eFp2DnrDtZKIq}w12m~F z68T$%K70%PtH5fOr~_qy!6RZdODX;_vg&y$4p}yl;v$drLvqFSEDcb)-v` z6|FUQxKgJA_O|*T_?Uko!@k5BcX3Fj+Uq)uz*UrL7NPJ)D_ z9H%IUgUE?=HiUKvWH@^K=(F)Pd1(z}%0e-r$5D9CblSb%SXP`lfCpr$sS`a-5#Mmd z4gcO_iragmbN%XiQQuWKjlSi-LviFOUp3KzejA`gmC^~q@@iy7_FU;N#EDIMk}w@K zqc8!rPV3Ch3+Z0*4ZL1=V7&N{>~HrWhTYVdz+t=3dm(l+NIisz!1`6i zteG%*P)D~{3qmfVs%eJZ^`AVgO=xFc6A|$>x+l-dx5vX>LPLfjQjzXA1_o|%O}$kq zlcavX==xCfCu3NHpm3;e*rgl~hM_i5s$W9Ws+4mZb2{stHhx6teJOWu$cx=^WNDQm6P>lbbyOk3Mc3|JL<`1Ko;E zqaZ`rQFoL-fcSCXKL?Txqn#|nre#M~MT}xABC^jl2d2k6##|P-Q1M~~22ff>;nQKL zI{sX~A?}tBa|gR?HMGi4^PtXw+6Qw~-SI=vVf>*wPSD*b&D!^-<2MU2 zGj`Md@Z@ZqM-&nXfP|KC!!0+K*yG#~@sqCIgO`7UQ2T@O>9?+YC|i|G6H4sMQWnCy zvlyX5L+a1Lt=6rY-Io)E?gD3ov=`lSojMXRr!Q>2C_3tq>jx3z2aP#DjhgvqrbKT^ zx#?Q}<>A9j^##^@%*4@RU4?F3wJ^l+Qy_cq3Nqz`z`yVba$Zb`3J=2Hu#J%TJMz3x zo~TbPE*MnGLvJWFx4O`pCw6e>((gz-v4f&KX6zSetJ>`Vj4cXX5K z3|5-RzrsNhh6ET}vs;U3B_Pcl0&xmAj*B%5D%>EU}j{hMvl^qZf=)MB@-hez1usVY&M!CVX z$xx)LC(yCr4$eQT_+|v%ExXn3YA9Q_v%vI;@jBt**|{1Qccb^>N(K3idxvA{vx@-P zCz8=pmY+roohH`ZDBwxLkGtt!WU@u~e%v;im8X7?kk&(-;2K3k=2=Q(`Vh6>Kz1e# zq$uxwBb++Fc$Zwp8qy$&85RmtOt-~iz=hmdfXHlWNKCYX5Wj8F+ME*kj*O(N$_(oJ#-ZV;*(wH{aABz3D=^9KGWHyl>i0A2k#ea%Iz4NN|n&b1S0mJZ$95m zn#4|mL)nF5XLgg4*fZ~a%3bO|k$Ht4K@>_aF3GztVFTH1XrmtPhxa`CIXf2;r!7)(F?QJW#4Y{3-zKX%UTUTSJRo4;fs6ydljevnRtOan*Qr z=A=WidmB+RLFyKW1(*Qi+;3_2BCo&I3+0c3*UCilBG=1xvvG3`!S)FyRYWy3 z)qfu=3J51s3!t;5T@6M?VeuwZmTV)QLAi$X|LG>vgH6K-FoH?q@j8#67S6(h*D! z3sv#It9lw=;`zc1?*jyU!T34gfi@%FKpyu%0+QpSl&6qcFQWsu-Iuk4!L=U&`8CG# zELKW2m_Yg4lzShkIb6eXWR*G*hWLb?OiEw{Tt3C%XGG9P38)@*)O7m#fm{DR)5!jQ zoXLFpezNU2DNexL1$3Ka&Ue7c{e{)MssU3I&6k;NxxpBb`Pv0s#S|=Y^RR7v> zw&8JEuzW#w*UDdqsUccF+38-h+*wo$*P0&S*3kJJvP?jVAzH<$qd#L0i{0WkN zB2|~!v=4=g%1DyxQ(_{uq*3Ue6pshoYqTJvk9(lQFd~JSAjVkMA+UKX+lbl2`3bZz z8>;abc0n7R9 zvB&^yLMpM5$2p;=fMvnW#;M?>ex4gl-+EPW9<~nBz4%2ssBqGUjS(LoD6l? zxBW!X;k>!Gy^_8X6i>u;>R6ns0XF6`+O28nGWw`YYbdnC2e`_q&hh|#R?fCE(Xx(2 zXSPbx7oX8h4F_96EJ1-?V z|B}K13nZA8Rx|fU-kB8Qo zqJiao%Wf+}by*z`P0$Mm>N5={DuC|Kd3mq74~>7}%xs6~Kq#qkGbpBHUjbtxV!0}Z zm8w0!32%~KtAh3JquTf(-7wGWNGa$HAhIIw(7M{Q^z@A%yEi(8_7t@v*bPq{aE;#4 z`Dos-LEWvUt zhJKzvubWZhD0YePuRi`3Zl{<(aLC$+0i-nY`(mT4fn!X36Oy7LcuPtU(+S>0i!>Bj zC5BS!Dc3Ai9&0#BmVs|6)pV6~Gj*^BBXuEkL}=odRyJhipaWOl$zdHChqmQEy&^qb z6hiW`X^rhM8EGuR&mp4yx)?45FsaJFokG+r1>KBS;~CwD|?J`9w2# zV8Fo zVr_xFf?XKS7mIMh&>TuDaJ=0$*Tao6hOkS^-Xs$O}85Y_D8V(ls_t2TAR>y)TN7=SQPQS9A1E#Y#f-|{S_|6O17s& zEi$(do=SJgeb>X&dbp-&Ixi2};c6z?Q7xsnqDCIC8I%29b3AI@q$3{I`8YV(eLjB8 z17@2&d`lmWDoJFrQ0;<2C@L`qQgr4a6l)kxGdJhTjuwYh@2o7C6;;NKKKU)Sm?t+H zc0OR^ST<9V1-+~x%uTw_(=&PZ*cX59RXZDv`H*NnmWgV!dt3-8sFe3o{~e`2`DImj zX)qtHbCP715z6qbF3z|bME1wB*hAuSdbl4Kz?L0`h}6?!6?N2z5`Hp#L#a@L^l1J5 zet5#@ItX$6OOPr5Q09SFY6{O;iRYAs#7h1AgD?u;x7bsJmZ2GnoiZ#jOj3Rr)HjWK zN5M+^3M}$V0AtT@|CR`Gj=blKl@aY4U`A53ul4Iec`WjG{xu_i7@}-Zm7j z7T0i(GTfJ0MlNtqD$lQ68vo9&7*D}j^NWl`C=F#eL_^-EJPKBahA>6yFm64G%?43f zogoA$@0-Ps@BN0tAh`F3$?Uh!G;(P7jvNTm7tI#Z2S=yEcJq;aeQLhic*x(bu^u!2 z{z6rwG9{CH(?gsLn!Gt;W2WCae;J3<7O<#Y11;B3%o*3dby90=oHE96r9T&~Sm zI^giauOy>YCSKx_RQt3wU-sZIXj2z*)Tb!uoZD5=TPR~l`JN*7FgAJJ(Y$YS4>8Jj z(PzsNOBf9mi@ifO61%TG%&#)!RN|D^dv}#Hm=!AZSot>ELyymGdo z5|f`6r^2Y-` zvVY(@>K2$Llb8@QFTaSGE&u#bZEYb>tDdY+FlLLY66DMevVRCYMY;7=BGUi5@?HJW zDqARoYOjR~xu<0lOGK&Y{$Q1o>GCfgxv3dA&^Q#C;&Tc&pcEcj(>DA*BY@9EeM2z)=qUyf@ zKEnCFJz5>1Tb&iVUoPwwXrC>AcYsXZpJ}>{2o$Q?yZw>kJG`(sM>~~cZd+5YJ5-9$ z*)Xrv?}dKJpiJK%gv1}^-y-u1a`Gwau}!1qNU4yix)!y#H#kNN55`+8cjW1J!2|OD zE(PCQnoBurOFtno)&=2vVw_^NpX6}yz$5kr zQNJLH7l|Xkor7hXP_14S@{(11YPa0i$ezg{Utb#o5N*-Ftdc>n?LjGh*eNKcyplz+ ziEn-aVt6$#7J?p$scp1VytCO1!>oa@XB=PKzH}1M4ePALscl&yI~J7lz~ns86l? z9Z-4ya{ID$y!-S|Y-x_SlN+cUfc_8Mu9iGG?8lL$DZIYaP7PUk?6SpBBSjWzJA3^0 z8)L2?Gjq5jn)5E+dj~;LE*bjv5IWzQcU3_u5 zh#5sNrT%JX_w{`?<(HO3&z)paY$2(NjMS|zaOzfXDkpjJ42!km)m>W~q`vD1cv=~btB&CQiTa4yOWI*lgYLW|0P&eTY2l$N{wzJvF5Td=h6o$&E6;azD7w` zoAd#-^czqcE(S!JBm@<2zV**gBDOr}9pyCB=SWv2vgN|9L~x?q?;#`&N0fZv7(A1t zCSjVS=D7~Y4Qt@I9l-NPeX~pev-t9!!7>beztM93ubr#H_nY$Lh3}oR02+SnJe;H~o~c z8&mkzW~4@^J(7y5ATL*I*1r!f3dLJQ>E*NtlLLY&@-j@*5&^B|2Gm8ZSP zZvPx#4srJ8Y7)11A$7*JRkl-#3aa*SdEk5@ZWmXQJ~Zk@Ut;XKY3ozWcUQz}DstUa z<_&*2fb)eO8=Uq@Tejr2m54JRmQx#U*CxwPyZ|nJ9kO)X4Z(jkg+s7+1hkZO7$ERU z`=%jgxDJm4sb6Oyt4>d$u?``j2IrFHz0+{rbtUo(RO4o%dz(=Fz&XRLs#o065&2O4 zkW<)6ERP7>9bFBZ?+6TB5XD*?u7b(zi5-%>xU?)nEG#W%#viz7?|M)^M=|MPw&%xE2sFbJKqXWaU|R{g_T*!$ z6A4Jd$9`ab%>=0U^ZQOEz#Z4GhHU_jS|F?w@)2@~J{f?@_q(I*QE|anv9Q1Miz0^jO4;XROAj-`V$h%gK0Op2*shy@WbvU<#6&2OpfTRD%jAtk<(sI=k7ep&< zFm!6Oo`@<>tuXcK^_kXB3NExz*CTNgj@Re$`gw zIe44jm)>19vij*q+!F|^(B&cto95*Y}!Se}lH`%XMQTo*cgxYdJ?_9R|a)+inw}W`^x&Rm*tKG zeP-Jg<`W>A>I6Yv|7wrxz)~l>;G#KED4xUbrui}wBPvB0K* zCKl$iCFI%jL%v(?zV@HOMT*tJm+!yrx#*n{eXON_7H5(C2{%svrr5^UroXjGw&nh`pZfTi4-i zej=XuT`2g}CDBQ5AnrzN%~;ipD4u4gU*x;jgA&;XT)`IuSQi7bdAR}M%<-aZVuH8> z$9MWCQTDPWZc2aTj~M${I~|J|wL@{fETW3}K$Tx{V%kdIcUAnuo&>roR_<#zbg}G; z0E{~Ewj;XFcJ@kj>o2M;kT>E@684gU3-h17M26xFhjekH+~}>;p1CuubVSJs70x5O zbF8+LjCOp}{(Nxs{Y&3RonTIGI^rtS%Bwf_n}v^gq`6!w_i1VthbXi2Sux?_d&E-D z=*K8@qx^@w`uz&aA2gWZZXYDgpS4=z6DpX{=@4+BKYIE<(*gBwB0cY`^0t8c93mcx z_cGQ3ITonrtL)nm$a1iIobuT}9+ebbk|ibwuS}nI5^Qpc8MP86{aYv|B!`XMejR%D z>}fB9QTz{dPn1DhmnvTCz;au``}8?FdUxyc;|Hb{*p}6@ zg+-^ zr;tMr%&?ks05vU`QvNgrGVr_35Fmw>sZd)iXt|ErLtPmT)v1435@Ely0Dxp9-eK|c zn}xJ$d*qxLF@3=xvW^SOV}ZyR*Ozy#-8 zke+4@X4~wJ%N+iHWz22`{z2Nu@{^y|){}Jo+}cX{a=h|6DnGx$%j@yxbK|Iu|d#;|Ce+6^Obz zj$Y`yFFFhcsi6h6ZH*iR_sS9T<3|o$xZD z1ZR!c{uNE!Ztj}nP?sbRCSD27z$7qFsss8$g_7VCYG>Mi$savCjYCHH8`DDP#TT+w zj0-4fl-ZZHKuUly2B_>`_wh_4?q(#|y#Mt`N#gB41F6q;8ZQDUF_7$pzgcFd{ynKlZh5bjK3qE=|}OJB)^<4|>@t%yT-p5%{5Az9tJoyLwnY zKSIi5ba1Bo2P|f=B{vWKce=Olum2(~kXx-Mr?Aav)C6Rg=BTUsZfI%6QI5ijrkQ3E zwN$GrAyOe;U!Fzs*x!HS@0P^EeAaJCWtfl41E-r}qOuUxO={rPmz$hF>1&>2GM2(gWiiM znL#nSm@J`K%k!PM1u?SXgAZpI7|d<3;u)o@&j~PAx2DdpXyCHS|+>>@A;&bHqE!+p{9MdF(lq@ljSz zp7vje8~+qG8oNsu&PFOFAa zS2qGE!wqr2rLAv0-CWTf+Pq~Oh5*hvRQc94L&NNWwUFFR1A!-2q!F&K9IUHy3Wtm@ zlcz$zh)BU)-3de@Mv1uxS1QRBbr=gcOOz|Y`UiwRF+K@k z4_W(^(Z`%fHqrit_>bF6xz}*vyE{`kJL|0YWFK#pE>8|%$pwR#S9J5&jGym&PZ>Sy zVaaNoj_-x<;v*RZ?qnJiq5at^Z@%#EoU7PlH@pQ>AJeidwIlRJ6v=;_h~a%dN)VF` zJJ^-{cgGSp4~8$4yp-JF)uEvi`stX5$dI3p-fuw$k=<=75(Z?XBLAgKo-s2^M$pYy zxcw~_Gs`8dA&wS*KYg8%HGI%|^Z9aq56(Lb^H)FE*?KV-*z^co@LzO>lMxBANx4u& z8@>ScbN0`MLn3l@?redI+mFT%w7?EN6Aityh)UD?A+KNXzS}Rm7Lq$T5_m>5u%z18 zkR91A+u2aZc79m;Z$dXQ%(Lt3u;)^t^@d}ZyTTEQoMlObe<+;pE(t%{`Jy} zJhhDdRFT;!&-*Kd{YaGBo%p%ybCI4yD954Wua<92Oy{R1>{_^=z}ucc2eTR!EGM{^ z{pGn9usFJ9Ck!ybpbpjr8JsTO<>&XEWxo4Y3G7wyyXCpToe~7(XkUVEv|Jba_*(8Q zKlVC~;+lHKHMpK+-TtgEt-udiar}O(;cD3^wq#Le_msNt^!K`cmi?HqyNZV?wk_UUk$P zP|?%G5dsz9_R}>VijB1_9LjQZ{I1S{@0(4%Cp`>d(c4ZEwGD^flkKKvM9{QlW}RSN z-0O>Z@$f*K9FOWYEHD>-uyPchB$pV`3r=(C`;^KjwZevCJ|aI7E;zUq0EXMjdQR5A zg1Y!hDeZn(iPYz6h$alseJqi(4o`6$Yv#>t$$cYA&tFRL*ws@o+MbgT(o@JoM(CvE zv>c0miW?rdj-_;D!OX3VPm$Yp@bZ&-+x~qJs7wvVyHe{U?Y(Zr_IC69^SLZEpzHb3 zxwdl;@HER$H-|hw81<}t&7HaReOo#^J|lO$fG<+LC}D{CuR}@ak(Ss3uv$J9PVTBxL`8hTS?zUi;M1d;E}}zQ$PKM_RD^K zts=!M4z6!?#mQ)I4n+d8R#O@U`m7mZzYmTd$PSVPsJrDkdNG0?1W{z<*e?YF`G^+~ z4=7{!_V62$gdll7FkIpLEil|hq6D2ud!%wn+D52vBboOPEd6B2TPSPg_<3TAvEn}e z3x2B1QIudp<8$kIMs;i(;-#xE#79bLpqDz-FT1@RfZlOnc;GISjHm{9*YOXII%i%! zpztTLYzu<2N*$1<2{`Tu2YRz^qyoSy30ye&hoq%J3AK?$#`#5_it8>suOXAWH!$q< z9n4;+f(>!z)`R7UA4G0pByXI5c;N(3e(agUe77#161AFOb zlxP&Unn%a>@3;vg_=hNCJW(%g0$*ZnWR{?06_JzuD#D@s=2FBsgKF8rsCD@X-3qf) z{fh$%U`Fl1^LwT@<9>cILlM7Yj=bv-BCpl7`twdDSbZ1np1bmvYbx9GDW!(pk+tXB z=2Fhg#=qLDeI8n7P=hN5F~K^sBi@S3nN*dW!T#-Wti0tNk4sWqqnio&C50;>5MZC zE7o0_Ut#8Z#qJ}|-6;;r8XqW-Xy~msoLs*xl6+@QQe-`9KhR`;nf8M7yDS_zuGPi_ zmR>Z=4%w`}qnX`iIUd_k&Wn@KENVRCm7(9cgD{r(;{N~n}a*olRtHwwCW`gk}ARVz0nB(8P+9f%0ujsmFEm2L= zmM&xpk53NQC#P{7B49&>6yvJq9ao2}+zT(OFptc_16{Q($4AQd^6yMg%J)ku%b(!E z7w%_YcfP7QVnQz=>p&-xh?c`#@6qXCWO)$iE)i;hf2Y}b%l{j-jKiCP@(UtJB+6Nd!#)ZIrlq{LYJN zs5`PN|NN)mYj)3lbzzW1Dtb-+d_E%`eOHn%>P#5t7lJ^bwU0*7YY4a7>q@;etMwC; zw2=b|xz@u!3+KE|_#1OH&WcX46F#rTsmmNbCqulDFnWVsOdl8}OGhG2Dg}OQ;1JnC zRV{hDb1wp}aq4fC<7nl~S6GtrGZ&c**=oA7xXnl7o0HfY9AAE4fTI&3=S^(~6&dPn z$3RfP=={7wsZ}92Eg1s5#=)E0ltl%5ta?a)d>Fko@cSrazuqK0pY`m0&)nS#IIWw1D1?Nk?AWX{SJ?%1M~sQt^WDaGH6qv zhy4e@<>y5J&I=unE9r~kZql^sJ&f7HqwV0H+_149#nbxr-Eim}Cou6R2fFx6eZnt! z0<@v_Wp@4$;c`SHk-5_NLKNahe{fP(zwsIo#}d!0%|0a;4^#JX_#7m{$AOar`+kJjdFpDhJ4A*?>TXn zBahS|Gu0MvP_=MoK#|W{wn`Ul=_rf9g(xzUfJ9(zZjI{D2<@ zijlTu`?d`~DIo29+2TG|SARO_ZIhk`<-7xUGBNjhktzOeISX^3;Jut3uUP&QW%xS! zL{j3MyU42!+1}LQd4sqMM901+KS%Gw(4@nNYh{=k`Wih03oqBPqk^3IB7YWtMl!)x zE;|nRveesv5zghvx=DF!)61IG>sZKo#&HU%ya;4f{^qt0fj+$Zwp5zA=aR1kW=~egZl_?~V|gAs z3#HL#bU4R*V!Om2Ak5i9>fZU8l4wZ`*H)M&81`t~ow5L} zFrX~xBw4RWl<$mE)4$S&+!+owJU)eMU8u&7xaI4G;l($RwhhZ}{?gj7w_2;qGprQ^ zFPEPqeK|(=^?Ap(BVysICt{V4LbM&?{&>V5IxZ&kvtj8&-A61v7vmUKSyzGUu!)kaNv&z zf8jyH&P+=ccSMyodYTm7eP(!wsil`48f)b8qTpSUzaRi+xw|1rhl=5{^!x3QXi&c*ra zn=QHm^h58fIo*J!(PLu_7-w8D$JJ(cuyDrMZG&PeyK_d`^jzJ|W$a~-5!e9bY=es} z^!Xc}su&{K{IzlbfIa7yV{B?!I3;bL9D=w%}sr)>n^Dst@I7LGPV27PF?- zTPMHy!ToPLEB+p-Pvgdz{W`1Otj-(Zj^{vK(c_#i+mh$JFA%znUB3F~a*lM15scn@!g)UbMIuFYZv>y~W+#-L*(?C{8I>9Eucohu~h^-Q6un zAYY#M{5Wgne`fC4vuF0cWDe6p(5m2EZ*0X9c9-IB)5E>jy>q4s=F|ud$x;Pt5V6D9 zoN&`R*2*%t<>5CD9<#rYoXBJXIun=5yU3@oxu`c{OMmigCUo+3>DSl0Pj*ZQvpGGG znS#z%YRLX^!fP{Ij20bKmCO$%CT*&}Ux>X{q^FIq|3iFA41D`I zSJFuF-tGSub~1E$MBn0k)a;kEb{Ikp%5G_~&vQBx-ng<01@K_0ECH8LRbeP-BTbOm zXtgu4ULkp0g+SG5=53u{VUyl0HDW$(MF=b%sM`3j1XnH0Y>hB1>z}G>mnP35wiCB{ zVy<=CCEvgYZ9RFVXH9JZ?y{W6OqlKFoYPt|;$es~BE6t1FTd&wuQLXW9PW?oRvQrl zS08b;!lDf49pQ0hE(c#9cc3FI7kjn=C^f(B4a;ru``X+_LB>FBz>^~Oy{)m#EqL%2 z+a0(3=S};caMx+|`V@hy_t*B=YC$tP-Mu!$KDTr2KnQ@{Og zXR;ii1E`-QWa{?SR&K~=%f-Y{mAoe9dujc$PL|@*TP0=f+DzjF#;p;b_*sex1CCMe zzxv{QEsl2W-~=x-(c3E~JeI4ya~)hfST7R%;KAnshN(#hz0SzghYGr|15)-@3WNbt z?8u0!s{wy+Z+*}>Gz5wHw~4?!&cgXs;l3ph)iP8tTqX$Y>%X8*-@VGB`6}JJh=UR zFOqe|y90Ay@mK1TIMb0|vVeiguDR8bMOCDkXSaD%dNjY9-Z(mK&-Z|0LaD7`+`ZunN1dPWS__$3PplTa|66=iiM#hPc)MBSZl;kDSaWko69#mOU#sO8B0TLWe0U*| z2$0=w33`m^krOE-cU8D*>fUXOP8*MrF=8J+GQ=_hdUxC*s2K7RUhIWF966Pz8@F{D zPgNVZgy?ETPC9x8b;4~Z+}yiZ`0+VSn_^w|R++-YHrEV#{tDKT>0!Zx)lG=Xh<(2f zA+V`(TSIaob@`ss&Z3#Af0LN~dy0E}hkn?n6MM>zXB+U!2k9=&RiJj8gC4y+J8vep zA9CB3NDEjC!HW=Z5{2)zKB+iVw_@KN$B@}jHQ2@lY5^{01H&0BSiE~DLvhpvO8gHGt=}A=rb{ldoUS-cB13r2v@O{6eTFmNY>zm+{!tlHmsp(e zhoxHHY&1E?E`G`{P==|xCTw&-NlKv2Kf{?$y@ClV3s%z%JQN8VTiI=7;_lVaN()q5 ze(@H|yST4EeS*BRLl!NtAIhoM#O|XWD4w1HC^AnDdFy-7c>@hzx`g9h-hKd&VzwNp zZw{d~QOmC)yHT4DSjk zEg{pVaR7bATGa-KR+qc*s{8MelLv8#FkgB0TL&#H!1=Im<;ok|w+*?~FkrZ%l=UX1 zJ!V_7c6|L0VAIFnTd%ga3dZ-yNtlZL&|~H zP&e{BdAC@0t3tka%$qRteX7$d*%DZI?b0TWg0HzVr>%4szY=Ept2@j*r)SWjn z{wfreeSTFwlv#SfO^ws0%HE=ziD^_XG0|3W8YP(v^0^`J3Dxb{&K|Q&D-bkY+=p^s zUktkDF8X+V)B;t73<57^M4q3f{GOi0{PsKDtPZckw`BiQx%C)~C%yq8I^e%scMd2K z#+pG_z-Q8^jrhQVbIa|bQ9dMb&BvZC`pHV6FH+x|ygzA|+6B7yUH0|K%X`0V7JcRh#J&!0-979<`q|&+m>3{)CXm;s z?Wnv3+(ymvZ@@;ybBi+=?l=h|MIHoS#@ZB@_laU1V&Ctpd|JAnhKvCagSNjwKVjDHH$L*scQ8A^-33yS zx0PGCdwKQgdHs70lQK4dpX^u}vfsw06NCNLQ0^zO4vMC{-m^#d)H}#N=Fi;ds*sc5*z#0sp(LC8iZ%B^x;;m^xUW> zEM&A}8CmhapSI!!kcz=3mGpp0lHulRi2^trkU*z+>Eklt;AURhfZB-((_WN7+uQrOKg6Bp6PtlN*H zm7Qz8cgmdJS41=Z$_-t1DNahHe7DYNv4Y{sdYjxR!_4;u?249-+@q|`TXT4~j zY_+cct+#Xd(0l&Ezy%#Njh3GBel(<;JE?6{Pv{(zU0ANN%ejIx!OQe7V@kwY(K$%E z&bkS0C)_*~Om2lzowi?Q>Ez&bWo{>0ML7_f5xfUoRg!%0?vxMyb(M@=ek=CDder_$XSY($>T0u(_$?<~7 zHxEC>o&)QK=2tQ`l*4IYwiNC7mqWE!bp#PQK*MAdRoq#lLmO~A@UQf)Rm%M4(g61h zSV}9EBOe5TgW*v8jOnMWxt!RXG@SL$8Wj1j(XmB1X_7bAFiTx*d zCp;NO;k_c9_f?oCCs=A8G31Km+AEtcwk@%aaU*}giZCox89@XGY#~7r67=J=v^?N6 zbxO;xa4eaU9vRmZPBA0~DzrqEhzsr_fs%~f$i5N;pG9DWzrzs03iUW=oFQ>wHAZo1 zT56bKa<+&&Q+XQVns`YlHu#j~wHmd52zZ9on`@0vvt zc|a;zT=obHz7RWJ6;?w}kNX_)K1%R{(EJAwY&YjqNzW3^L(`xFrWYw{OK(dkknBVS zITV7soz>$L%)W=+lv)ctx}ZW+LrnDE2{CG9$QqPqxzPiHr3hJ$mTH@q}U-;^SRn{sYcr_jOPIs$3^V>UoD+YIr?JF)k6t}WmUtk zi-Y*K$dvO$UxskPRF|bmx_BoZH@NYZ6UB z`0}48lUQJCP`tS3hY5H4(=Fz)Z%}u=g>4(M=A{J^zI@=P*={d6%$FL~E!o8n0_!KFp`^Hc?5x(2?Fv5ajx490+dNu}=J}(jBsU z^UsbSPYIYf@Bk$_)xf5x0Ev{~mfc3EfZrV( z7a?F#s{Xbz$pt^8m;q9~0(6?4>%Md~dwYg%XzZ)e)Y`BH=2iW^P$Hq4PYmY0hb(A8 zQBO!-JYTpQGpWnVo<3R8Upjt$>LMFDLAe$f;lqAIvJJfd2bImux$s4AzoB#bfkn__ zH;JL*BPDm(PHJr2$7caWAjr2+YA9u5u^aR;9bI%lY#tQ|O>^o_= zc#r5;%mQ&oHeSA;pya~DefZDJY5!j7TDj7HUuQ3A^Ch>YvFH$lH#4f0hNzv<*)lbf z7cC%E(U(<-oZA0F^^`|O{{%k_H5&`w)(Odj2|{`>`OYWcL}&8l-#xbo_~q*1;R)9X zimU`(K}RQB57WuyNt>kmnJ^jWxZ|`I0KO2@#LL=p@?R6Pjs15;wrqa((OgvW!D2cw z*ax}j9afe4XOyNWE<&Jocrsv7>K3QY>iVrz_-!D*z#!1Qo6c0QIK~J!2r~ysF1T0W z@2SFkqG}b&WiY4237a^HbV->{wJ5o^oET3oIT&Au?5)vb!3&SgvgIgSSOBfH>-&vxi;IkvJT% z`5O{T^t6No@{)V`9qafvc z)-+AXeEF*8mF7F_s`M#vLy^R!9uyvwq+UX3H(jA+cDZhRJ>i_(Ti~`0+M$1lP&r<~ z>krgvIZFf0N4ynSGC04oBuQVo%;UQ$f3z)t!;6W~>5_*rJ&p@5;XuQ{DgjBAwV9D; z@&b+m`oN*oPD>iR6jti>u+%Rf3Hog*YiiBVXLTtg*k}RSiMu93#m~mD-(Y=Zq8YVy z9_Dpgq|beRFa}j;fJHc0#0Q!qqrV=<=i-0eG$DB3cmem^mWe@u z!fc+N&=eKNI}(f9nv5V~4{nBb;EvxiI(q&}F9MHovXQ*j+1BDmoP<)Ch2R z098A`w6lMucKff$raqTfO?p`^tN9+i!O;wKxSb(O!QdUhqc#^lYVJ13;N~n*dd-r1 z%VRx-x@4qf=#a(%=}2F(GFYrfCh%>36udcI#lM3wIGIW~=t#>(<%Um7N;4ecy+k-8 zuv6Fr|I{QJgY@WeY@oMlX6myo6MgM1-v=#P^WmQl%8qsZ_Tyc%mPI%z9{>_-G;ixM zb{pzlRoi^y*zbQmjH$6tz;uTP)n4yw*qk#X&E0gewtAm@EZsSwN$XNek6RQ!CC`?d ztfF0Bac7W4O#keLzUFlw_Xjuhj)AY{9Jn=G|5Bpnq>Ol{>-gL@%J-t@x0Yq@ub{{Y z!}*&jkq*ZNt4fkBnz56ydAJSW%24vux8x(L!{=Z5q>)^yrv*co2mzmoK1uJQ|F=r_ z-a(sVBv+bm(n;_GqId=gaTiDCuiA=cA@76ugP~U97JOei(TV^UagMV+V#&wT&q}uR ztkWGb%)1_}&MaC_xEd3Vbspx9S3fQ`zLz;aVtIXtQI?KF8${>;m;V2eCnt&;HfZzw zCgqtI!j<_v(f{i3UNRRf7g!l+EQf2Y=*H#Nf%hTlYc}n>H$fNt={4!Ck|yfpFLFnS z>%06wxnR~a&q0-S?JLG_OXc)|_=8wdfX z2zJAd5zQp2B&GZh_9mbedYp79s8bWi{SHRABnlVvQM+Vnhu6m`OjzQ^RMo!bS^XN& z#b8fuKCOFixq>=9Lz(u!U30W?7v8hF=)R~~fbJ@SAs<}P1Jxn*48r@N398P=P)8?( zY&>NtPb~~IQErsgAU`;JrNVv>=PK$@!?yK6oJyY9p#Lc+5*j013H*jZ0ljYKkzqVJSxS(+onFmet~IXAZ}HcP!;$L4)V{&fPuP6 zeuaLgF@Bl=E32TvjlmDUJp05t%vsnoi4AG z(&k^1G6r5IWY>pJ(vrmdK) z!0H<1I_a8plO9}>0tMI!{+rbd7CCuhuu4e%ov~~g1n{j;sAterTqlgPOd8gOn?t~I z(A?4z-){Uj(O2j3fvjP_m0E{C59<0R@YOLzVAS8J4_-viEjL`EL-4|^AV`2cK_6s} zUnJ)2cf}=3vM<^H{v$9rKoaW-k;4=l-Pf-%Wfx&vh&D73evuV5uZ;RsEe~;AoRof= zHEgvGbz%?cunDo<4FO7XwRVnK8Vtn9x$j&^TWsIo%1eAN0*EodKlG91=kWGSCD3Gm zDX@cpd00W?^x<3H)Hg%WHqDY8#wXak|NNDDF=~e!R+rNQG7`2iwWRSEyvg4R>7*11 z%;Dnn?(u9R0))&CGZCF?LJqY1ndK$;lbFdt#X5AGL{NZ@4VYM6yCl<|%Ley!i2YJ` zIQVmR4e8l0>LmiFXB}f(^@6E)qu-ZKMjL97mEO}5;({GCE(Zm2-ISmS@k~OG0jBC= z15T*NU$_F(Z9#;BCLbS*;w;0T&=A2~6*mmlVk20Fn+n$eRc<`Ejxt9#)DLFI&*($m zqxixd-_k%;GdS}*N1-#@IrT<(3}Qu|n_#ms>2Y>aDRcIiWAC7Jh% zMU%j>3W0mF&TdB)Z+F9W5r*cpz(!6Oj2$y|7*O!A+Z(Tr_r&ZSBPZil+bf$73Zq$pOrbYm{AWdO$sd8#_5- zTf|qxo)&`e!85Ct5aGn_D6BVtYTln1rQ|W}I-MG{Rq< zamm%PSV|Fx%s^V?CL8O=&%eLYzt!OHIyubyAxXfCGQd0u58w<~andnik}dVNc~Z2G z6Mv|04`O6C+h`xCuv$3k4$8?LEilnYvbne<#IV%d3=Lz>Lgzx)m9!q)h7}9~z z(nCijn!le}yxo2o?3RNSi#iA=X?`fG#fikDREyq9QfOZL8C(QMX+$Lg*wEs#v}1Ft zc7N~id#x)Nymu5Qsnry%qwz!msD7yfuK?h@3NwUfBlJ?@ zHG1&qE4Z@Fp%6p*Ja=4cQs^emGwc>z|2I$z=IpoAp`;Pv*%Sj(rOqeq)DT*VZW9J7 z73R&mfV3j-0f0OQ2q1a=00=XMed68W%aVQn;qVQL$e;_GA>7awB#l^v>fK~;lphn^ zQDCva3g`sY8x4V0UQ}p5EoL@~32?XTOri$q(VTbmb&z60YinlOEx`Q zFhOuncA;F?;gn$d%9F>`ubn5*tn#l1cOs5wCxkm;JWVFXR7Mq<7g=6BZ`$_{3|Cja z`}RRztV2Lo`~Brh7klQrP!WWi#$V5ybRso`_cEXCVQuimYpgG`v41@K>F#S3pUjFc zxzCUWaHL_4J^r*qG7vksMOe~=FlP%z+^8PU5XuaATS$=+R+d5D&%z3d8fKA&n8eO4 zp^SYe9atMeg-ZJVTQ-44%%r3q3WV>85!V2Oa~yMoX1r!CD>*H1JYLm08;z{~Wy8;% zxciO9>e)H;9QWBuMbubwN9Az4nH|XOeY2E5FMr@Cppox)7!lz zQ1?=QeP{!h5E>qqq5-|Oe&hl-Xqk@x8rLSK8ooyGm|FQUqHfh79OTg7FAX9Y9;}MK zNk(mqQ9HW5>!%5Sxbaa#`xJZJ9et^AeFl{?qfo6vU90jS7NO>g#Of4S%W>+Z?2-K5?p5z8?2m%Q0dnWcqj}dSthBG7;NEdc1n! z`L7$LaG6Ipy;Mv#7O~dS(9oRX6?AmO1)ELti9J2C z<&?sNe8WW6OxH(PMdfVHE9m*|(f`moprHHpK*XoYNFj|q2T;lKYX_BIHPwYVKufDz zDf$s08U5}O_*PZHmi)hw;onq=-GEqFfe?lF9+@m3gZPAyqM@w*^3jiRGetq#Dv>fW zVm00|m3~VxH83^N55}(cu0fnUn-fb-S1pG#!J_HeCQ~v+DvS2CIy#!U=G7+H=T(U2 zmsrWMEx_fUwX(#z8~^62rz2>tJrEuvLV>UEk_=~a&0=@A-~TA*b_HC3aKpRs6FFme z$eD8|p03+-c3*M8_^BEmhCD0%-;4rYJ+*@@X7^2e*+0lEW?K)bw1wsY9zeNYJAXhw z?e}fSj>&cD5M&JamdMu$c{nWc>Fy7!dh6Zz*H7PX?)}q12X@oXg4E?N$#~;v<~-tF zITR~UJk7Q<;;3rKNASg6N61M|W?_VF|4d_2rtUhqi@#~ua8iy$!`zV+(S3(U7;MP! zq_ed*;r)Fj*6SN#k6%o%sFyS_Xh77&ulOnK;plr?OD^Ox(;xfrZqhd>=r`4)G(zQ# zr7UAZq_OeLl*5bPASJ`(ef@8056dC5c#yc3kiWZrs!BKi>dUPS`Ns3^xLgaxO;w^Y zJ{@yY>ZeFS~iRvyV%3>p2C}C`aX15kEiJB zo!9Pcz_xr`7^W{`YXm9v2w}VnWY->DF)}`F(3#ZN7Clurti4$!Ga5>Do#{Tv2G#s_ zx^Y}Oo=vAgg?OQc=tyu9{ZgcCNs!>QXy7xe;4`yv9h)?r&~}+9%jRNW8mpvgnIxWR zzp-`~J|hdyE6 zrrZ}{-Xg9vZ^7}t#1T_@g*3aKPk85X2-`Z7nesbY({Knt z{4z;u;y?Z$gBfwSft<99@vXCP!v4FS6tIj4k6U&yuE;L$o6+nOdZG-cuVg^54vLXh z*S2u0caFDGRV8-54d(MdTpHFX(qLDSfc>Qqk1k5GmK0RQI??Y9twluZuP<{ZU&-~*{$etz zoV7@s*Z$(nc>h;h7vG7UKT|i2Q=^r`t(O|lqN$I6o8%5W%7@hXBK24&?9KE^OH$3W z!(=1G9~`ZFGhJI{xOR8nc%1L~bR>KUa&;JeJF16dcnAor{f){Gg>+cb!vR=N=D(V-JW%iZyI+l@sLLU=)C)v z=1(ZTJp2?MAU)-yH04U8rGAS3ligQAl`k2f`vfziyj)?As!)Cb|9FSHrLPX3dqtMT(0{!J{GN1|MyUf3Q zT?81Uaa}2AGHu~tnZez`VEo^P!P)|*nv=#LL?x(J|9_dH6GYqQwNvoyg#ETUEUOh4 zN;Rp4NF#4X%C&c5g+Y4oUJ#r5YjBx%Xu$w2c|v2XHuECgVAG{NjlDi4=Z# zDBr9oyG|{;)(*5%Yr@5qc#?@5Q%T)G4%L5eo$93#2$=^EP*+d;xvo5epS|596Fll z+>=I204F7rx@!`~Sx3g_y3gOC%NM6h>GkAm4slB@AWuexidNxFCv5iFCKQ7@*_xd! z(Avip4!6((dSW%4zA+8nxuyyx5mxB|my{8ECeLhzB&9jw7Ev?tZxu%>dskJF<)_=O zF;4<)@Pjwh`WI_a(t?BfW%cSZRaWXt9e-QMx_`Ax7**_A4^|g^VABp2wJ?gSjX(uY z=!`hoUZ)f11%Q9F{1;#@jY3|`IX@T*>kZL3MDI~)EY-v28F*}!wCC?p$7@w(MWznL zS4jMv<3;E-`v35LQHqAYCOPWPi8AdS!mUI%w*3S(93w68R-H15^4r5BtU}qWnYT`) zHRu<>KfYmC;C{VgfVv`52-qLp>-Kg#Fyw+Zken;Oqq@u#;T(NA*h#T=zo*_C9@zd2I`uU z1H-xfT=c7fDZo~-&achw=Kt)#a2qLsLK@HI@XPKZQ#(P=qHOON_z&Gx6#1z-zh-wH z340QnB#29zII6DHrq!uJqZg%CDDbXW(+6;vuvrcnhiG$rN_SaY>8 z>QX(}^{`vxwt-)_Yj7WpLVUd6R)8*Aa+x3-U5n&Q099zS%&n$Wi+GiqNl_3|x9lHT z+gg6q3UrVDi}=uv4%!xPRNlxmt|+KcCb>g~9M}NY9A<1{m{MFWGvaS8Mugq*4#Rwk z02X^bQmqFd4Am45L%E^tf+ZJtr3e)>tP}l&a}BhM0IUr)N-+LPxb{JEGb1N=A_==< z+0-5RoS|DKr6@&(@$s6$7{63B4c9nszH4X(sv0)ok<=PDoK`8yr!M>*kjZj;8|={W|-pfkW!$~l2oG7`omcdw#+ zU+HNn-K3yZ>I3u@5)#VgTP-|%6r6QrWmTJ=ChcJUiG&iPqFnUEbR-MXPP^$O!;MJO zxrB^0W&0}XTESbBxF+eher`*G$>lk=9XN1wwr)66XY^!w9#PX5Zw9yUnjg`<1OUMh0940DlN+sS#!*i(#-L~ENh{L(x+QOHzq!Ng32P}4#!ORtDG`JuRXr5n z<*;ULdVTZtMs#J)Z@T-=Heglfc<3RcNXn>kpA*Izd+cW+IDR*Ce|pwhLljVY>Nd%p zweTrVQ9$@NSC&%dKax(+9WX5QJLahbS^38YjLoH1>qrmo%K3efV+<~DIohl9hgJaE zW*hDLW@JvXO~cg*erOX(f(SX#6Aw=pAr~nxS#DltNiwVe<*074hH7VtS`9_DNEbs4 z#+)+F6EFA=+-GMP=*Q%(d~wmjHjLg)A!g$4oae{R=B&B`#(&`NpuoUX@rtg&-pU^u z{LnU_RwE6#-i6qT_cY$7Q7Ur5`<2|^=Hq=KKOK@oq(Xw@jTP@1Qb0z-El?}Ck>C4* z3W=oupdp%!Tb@FbaH{d!O=+)(NFmLN2p55Wh|;{jH<}8M_oK&ESPYGMolF9&!i!Aa zPX1?hcWFmFFjaT2&n{`_T-#X@hA(dc6o&~0V5$=LC1qb+c$P++!tF1}Aii_C0@QqpzWt;q4E(W zdR$h#5Nj0g%UtTr9W~p`FO4hVL5tyll$SP6{=yCM*)tgxDE6r zTdc?51vvIClxo=dfn6C{t&_jwX4BBoh8M+`*YU=8V02U$7A^c$U41@~ zuQv;~rZhBUa(>bsqel4C%vV~z?b);m;In5(Xo@+Nd>VfBN&ZpWdUW*EN)g}v(RJHl znwUfCPYm$ymv@zQWav>~bZsO1xfc!w`VCx|I_G^yZ_KBhw`7;SAnP=%l={PeCFT)R zKx271675Cei-1Le3Q6ivN?r!oP4S{>ek=V5JoY_Ok!O_2RGxFw7!mLn$C)-=fHW)fL2vF-HxD-7RuuklIuF5h0(@in^IIQmyEHp zshW@wKF6s@$CL8cM$jpP2csGV(PIF0(fU3nE@*0&og3*b^y1%7A%7at0Goj>gFtUy z1=+VaW3$mp7DU=+PAr?NrAwnlRFA|Nq`e9bJ2HolhDkiL?{s*BEvPW~8K_(BF#mZJ zkV>e-RHOaVN9+k3pcJ?7{8ILG;CjIpyAB#eE+4Xuuzv*`5KElgZsp7{?#5icI18FA z8P!J9QMSgdMFpW(bK@YW02>{hf30`yVTbHc9_?&q$R*MX2XXC;_Qv(^5ucJN8OiBX z_gWH1uo6F*`p?yNXt+8|)wX0cG~_h2*{u+vgmtUqedH{_J1-;%@eGB@{D54o&5Qow zoI|kx$u3)mDyj13c-X7mDCgx_}S^}U;PxP0M4FLmA z*Wo0UIIHMqRs7G5Dp7wSc;_qh)-Jy7%_?jkm-uBOf?rkFd}uNrUfW&TPg5>~i=p_J zCPi9}aIF~^^|wA1u`z`>>ZbWa!;*+)Dp2`*$#n?>jElm+zj1%pN+uYIG$g`cby$3+ z-f+JiH_tE_ks-b^1hn8MI$13rg!Jqxf`;npxJtd6BQhBs^5)*~$ZY~Fs`2n0%8b5o zE(?c-;Y;Pji+>tOZ*jtyC+5BI)XR3Qc>g`jaJ;e+Y5;9kccl4T9;|n1)OO@FaE={= zA_|d>_6Lp)m8V$y+nqOmmk{nG72+?djNJcHRH+lpLZgyeBJ7&P$CZ80MNj?2j;Jh8 zLOdHiOe|9@ZV|I5{Kjs4+;9>ye%8*3r0^HOyyn?r;7G&o!f=~iA3oDl-=J0BvS#65 zVaYMVcpJyUO;x3io>B=LVQjbZ39%R8P&(CRKy$f8>~{4S-E|=#py(@?wIa z3kniydT6U&Q>hU-6*w$uJX& z-08zRwdrwkQ4s4h<3$u~jWeK0In(|IrW!+i;`@fCxpE?dF1n0|4rNCty+^CMe!lvk zZnhb?9x3-uYEPP)_6Kp*F8Tyari+|wKOxL{l;F4JC7lsfq~s%D$rTV94=3g1o8Rp7 zGrH}T4XS2k%id1!bN{KnMIpx;K-u0FGi#Xfx>GBWllvfs{9|Kl+P+mDwnFY?FK46hwVX_K7?cKR+JUHGMsW zAqG<`w(m+VpP-X_eyrxbJm?M+oTFGgMZpw=FLu8LSv_iRt^jA6toj$cDi{j6=YK#C z=BmzuwJ??U%>{ZIm%=3trffyuh2|qv4pakY8XpxfHN)Aj_$Mm}U%okO>HV@QrpaKp z=zX~R=rveYPEIAQMoK}R?`aORihZo`Echi?GOJ{Hk%2lvv5IoY+(LDwQ!E(&D4Qmn;NwY!Jd6MJ7Y8J zq|;7SB#Uri@Qhp_F4TGwytmV`TBD54aA(^dQ2!RjlOT)7H?=um>cH^*ou0MBRPbz( zbs}ne=AricTe1J!>nAcL0g=Cdt$X2A_tVV8@;YE1yiEBF+Y`%;zLYwQ8oZLB@jsS{*LB&~=H0gZr`w6C zbTiE74%PSg^!Sd^L^A6K9R?FdwA4*OPznz7vXLXKuYPl+aqaH8f4gCYI6}wwx7fIg zf8;6O{&^a5XYm^zh=*C_PjG*?k_DzmoQWaRLA0NINQF*T!&=X~a1C{1`O_lc{260^ zgffs9W3xAvEmEO2+m&6@L`bb1Cb8Pb*a(kE?;0ZYz;AyD!Q5?k!3iRh>lv-!k6tQ< zL6%qs1uK~*nxc&8A~&CjpEcVe(*OvM(g-gmb(PNM{Lsat&C`zi4a>vbXKHzyZM6`c z3K^R+vudOQ3U-R)-j_ZpFkH@#=Hv~JBT~&XbJtIB9TR?8qq?5(O9W9-P?(zJBKF&#ik%$|`O-=|p`EwQ> zygPFtD0;62hlo+E1 zqjefe%!_O{Zvx6}*mW&N?~c}h!v6*UkL-eSi#LjPijb_SH{oNKz|~d1@r&*-@vN%` z44UNgY#`vd-kQ-@b??FDL!Uf{+L!!z`h7XJj}Ad;DqXK^uVkJvXa%wUp2a~C(@JJt zPq_GMT6>+VL&+U6KP^@m#74;--!g9r*WcJ*04g_Y-Dec`&8g=uFD2t(^BQCAELT<% zl%~5+9K+H(L+nEyB6ZfEc?Kx%#$!vhoo+375ovoPBr8B6N@u zJrXzmaB=J<&*7tM+V|iC`)Sal-SPA6fq((Z^e_pG#k5tlbO1E9Z9ZwL5Xrp9cQ-0=8u7hHX` zTnifR?0D8`pzsB;TZ-B6&p5v1%epi49Joy>hwB&oycVK>ziAofSH|tTf^w{816>Ki ziGqW&k=z1142PV`c>u4HemMlek=sXDeV9j1C~(3*jvjGD%cZVOv`2)muZcVrk;&V_ z;T9M#i*9yK<eNWi2}ciXrr|TK>jEjHA0u-VT^vA2oi^s5^}h4BU6|dWxsDI7Lhd z+fd{RRZ1L6whwXjb79y4IL~))lV8q2|Afhl`o?}8*YqfeLhhF$>>aSDbq`yW^5&15 zXWDjUeA1lea0lNQPnX~0iPCGyRW}-yS>e6gnNiWOh{a)Gdc2_1>RUv-}IwCrwt3#fX zzbRLM)~ke4O1;Z*`@tfw)17mG46$RfqZapnFI}_K4ZBgHALH#E@?28EQlYhwpICJw z)N$*pAHEBf{ne=%!q{s%Y6D6PMAAZE7Lz&5s>Qm+S;E-m#YdyvF4i41DTSL(_|2?N zm2cBqpolGi6kdV&3OYY>^)Z|Qy@MSU+;ipY+sIEy$93kO$%TJEd{+bjr>e_GP{}Tdn{KoXtB0NTHzEr!1VyM=825ZKj%zXlJwnkM zZ4kxKM>|t}s#AqwE70`HTkeGm&NDYE`*sx=VhiZ5qV~`Ld*sM2$uQ#xJ&!#*2QPhZ z>R(=`?3Ksq7)Ig~CnG)PV87}JaANnTf|Hf>S#2(^D~NXcu#>WOb;d=DW-ORww20n}T<>Mr_BL3&JOkXKlhxr8{2ZhJO2e6zdgqgLRLVho-s zNk0x)_ZNHxl%q4r)jWQ^D9|1oO5sal+%Mlp7C{Mxe4Jo)`v4`|9JzfTq2|9Xxt85; z1F|*#rhJR9#-G6XHCW{<;*Bz9o+xu}kcHo(&>AJ_GnQ_v7Nh|MFXs5qWnnH@e;_}{wGaCwgn zplE#1_~Mfrgbo-{i=;lUBRWw*xq;1t7gHiysQT?*Q&e$R-ihsZ38kjO)ncsE%>!3< zE8%MKw}j}{6>vir_KTtXzq7s-o2*h!ZZ6D10a;9}1Z=vIbr1vLmFvm?+o`0ECZ}%R z4j5+7AubY*hkd_of$S~I`+iFD2}p%1F0|+@7hfNoYR8d-BBx%G&x09fIyQJyYzR3dA91% z3?S=$>0MkIg=XVtL~1q9UNc>6%L<7pa^GdAFv#WF{chc4L1lkEs^FeaPg6?m*hMi9MIpRRH636Q_Uo8ZBOyc^ z`%2}aZM%pmPqu(Vc$14o)Yjv6Pgb(}+P(=5Qqnl=IL6xhYrHaKc~KP8MpPHFQbVtmFpU&@~4+bCBxoW*C`CJ*RT!V74CN_uN z0>AlYsI+=cB{+QJn|fo56$TAwryNxy$B(LUf`7Vz-^YWLU*|cPQ{q!jCUi>CZW9hu zOt{-7*K_<#v^cfTC?GC%R}kSp#}=0mr2AvwgsK=NURMWU%{ZF`qy2%aSaruZ_3}M- zdeYn!3GRP!7OLl`os9>rf|$pqp6BoKAf1ev${d0}{svnleT$=RIO>QOl0WDq7ylIT zf_hf!>m%>%j*!Urw%pu&u`z#uGgW_lWfJ#p^<26vQmG@^LcL{3gF*xFA|i=Hq8)kB zs1zG&<=idVwkb>8uviP~bs|2P^lbj3T5;`J>SwZby=oba9rv%6H?}yLjI9~QzYw<+D;LCpy(%muJjY zj?IumFO3LFE-|1l(yjFnR-&ALj~zi7Qzk`NK5TE!b*sGHZE{1-5 z|4~+n9s9ep4vm@y(Zre#jN?^$)efL`M$3vJey{!9$MTXJFQ1qV-5bly!kE|diX{Au z9X2`0`EdK23ph*rYz28?Eguv4GT9(}d!~6n&wvDokpO@T=u`ML2W<^kgBQh$!J{=j zi9ug&m5$2uVO&u^6}g9|vk3mgh%q9@s$%g3w>k|pW&O;=l|~3F>Ye*P0E<9$zW{1N zCp#5c$-iD?7{d8bQct-{2?Mh3YFJ9V)Zf;3@X`-LsL^F0&I!GeKR9ZBeTY;$WO_UgdM!Y=b_*#6DV? zHsbm=^{!iJCud~>@5cFu<#{Zb zIz(q!cS`0C$=qQS{$r$>f}5dD1+GS$W}yhUCj9Wd4~oECNOi7r6#hZ*8`N0DW?(I9 zGbGEv=8$>BI;?MyER?>*`q0b3+GuXKaj$Pc;mmG4fau(ka@AM>(N0RFpOU##vgVk~ zonYK4qO)N3Ut3YIdq3;Eco8Ca5t=CcXZeUGACi>=Hp-G=FGtHP=wt$-GA~o13YAy- zHk?H5VdiyS{=li=WKsC26NhLCR)9gA4+#S0At63spSQ~j(2k}H78UKeYh!8=)6|Z+YZ(nc3sjb=2S_U!} z0=SL1Z%6tQ7DbO+4%>OV*LzY-;2 zC2nEvr*Ewu9`O$G71ob2?g-=dvCSdIono39xC(VOG&Q)UqX0C)65!mz641H@mJXpc z_jgA8rXBTX;4-jbe`0H-sUVwyt?-${SjAcgW`LRkH8$?E<4d`)p2ADItV_3+fKjAd zJjlz1hcqwsi${Ee^;0rG!TNEW$Dlu7N56mTLT|nAogGTojT_!qlhfFODjPD(w{ZD3 zi z2nLkIU?obBO+H`=B~lbYh(81*90e&~P*6}Tf}<#ybQXl5O9&`YNQ^)+tue&ma(#X8 zIp^))r`_FZtC@4;!<@6K)_V4S`fk5x52^NQ_FA>7X3d(T`;Y&KuUEM+Yhmu7^1(0& zkAu_djew*OlftVB;oM`w!arfNH1M5916~8Se^UOb#ru#PDP8$A7RfWOV``@Udw=U+ za=&@}H*mbV$DR3?OlAwIr@3*GSrwB)_DMk&^r?iDW_U*ifS#kt0MI3>Ym~}D!_qnFj8aP8jVv%Xz`SI zQ%0&_(<}9~qpb+QhVtJgwtM5!W6FDpW=mB1(ylS>64NfQ?Sjl*VB8FGbHuHJ;`hnh zcm4BD@%!*x2g+aRL)9W6p$=U&qzc4))C8kJT#GszFaWkjZF-&~c8#$WnO%XMBiR(6 zP4U?T%|@sxx`5B7WtEBt-=Yy91ch&65oL!D=%+^ar8GY31Lne47+;aOl=qK0f8T6{ z{*!X=!Z!V5cZnlF(104jWn-HBfTp-jTinLx2N*lS+6JtU?G(W7;CaF#43+`Mq67lYWBjl(;3PfG^7Aj7pSgKU#n^bR3fruWKT?1p6l||`Prj8CMyo47*DZT zNXuR=$(R~q8VZ6z@>=D_0JyHwmq*9f8VmsC0mx1$4P=D)U6g|m0f?VX&%2VX!O9mt zK<{DsE+lLHIy#tvv94u@Rs=N`HG;;jF(Q{J|1J$6Pujb0ebB-=2FEc(I!wF5)aTgs zDYibvG&5|wz_?We{M8}69~E~};Nf*pVEP0QF#YUy)TNKKs3!m~y+=(8ZAc9yj6is_ zV3rs&2YU|L6qE1bibLG!5YIk7OSH||rH0O18{T0QKE%@lQP9vS2T+N91wgz5%Hmd!Ww)&rpXVf^1HbF12TJrF;gtr6G4Pzm-u*V z7=c`1Vqqr}mJa5vQpB{ubB@li&ijI%6JW{pHvkNqL^i3#`*xlCZv5XFL7}ZgbqQc% zM@J_af9m)CanXO4LPd7yOXofTJ_A0C@1<~ginJt!nW6*3Eh*HTP4>O3&LyQUl2MS1 zfFbxV0Rrn#dM!?Z)mdZAg-HIb6yxtu=2|ayv{@gL6=N~hc5C5NtA)C1ie$K?-+OIx zV*p%NvA$p9|H6+!afEQyBZLt`iRuC9U7*=e@+!UZ_p-UJLULz?6<+QY}c52hFzbKtsY?44>9!&Y1bhQ;vK3k0v>x{ zpLVa=d`$8zQlNMoK@TP*nINh2Un=(#0kMd&7?UH}2w&_XE-{6sZjFi>k4P4k;1H$v zmZdXLqmu7D{o%P214Yrmq$1M=nV*ySW$^rI2@RJ99=~iE0fzp4lZ*f#NtArRT3DgY zM%38>>-;t@yG>>f5t(4I8-W8=?SdE*bN#B(2r$CPC?f-ffozb;dd4U#K@sf}h+t*} zuvrQ@UuQf(GHw#s@eaYrIiN53gT6HI7BCZZCF+)kt9$E*(~p~VHr2m;_iy8$|F{02 zo&V{*iKyQbH0|#b;3vQ@13x4f5s6~h^JE#|WsgJD|I^SZ>3^wGAf+zigp$~MI!-X@ zDhJ|Zml~6uiOs)cH2s`>^e8Np?ilqG^jYAk?)MXQ@BuxWV(OpMu-QW(eeZ?j#sIh` zlK=6)wDG$6cYZQ>VTh~bijbEofgOZ1!h{LvF^0-Mdj1KmosyS6C=|qlNt;8AK_n>{ zAM*Z^;iW(&!$nY9hX9W$0S*0QyX*rz%?5Z`MRoZeHz1CxfQx00QSrM5+6vPyFx3Ti zeS%p(#;i|+0pRNB?GFmRUzZFK>{~m&^mjyJJ6?mfEATCf!AgXAsKfgy*b=olYI2aQ zZzS|w?Sx;$_-_Y0;!-OSC?{PA-$%u%h&ZgQv9iL-icD5mStF)JybE)X7=1}=ZQMt% z{E5ncxTU$Os4+CAWMy`lnGxqI`nzV}&1q{D5Epq+@v2xV`A59Tv6@MY~YupqfXzQeGng+mm^*`8j!+$!OQe z&f$)fV=y+9{RtV*hQ}u2pxXrFhd9QG=VeVW1lp}_$WO50)?M`@H*y{2$*JQA3rezB z4+slO-4}`vzLkyh7X>)2@+b3C$8y&oT7{raHx}&oT7^X{(Uq zA9$*p2X`6h@nuzrUUD+JIRpcbS~PSD2<3o=%(wVJVwMvL-W2GP@2DCgUO?(*@9C|FwhZ@r;6&w4fgo z6yTa*1e|jH*5Q<+P9=Cd1V!~3@T0hW65rddbH5EVf?8C?s*}R4%gon{))dy8Y{#nG zv+DM&Z+A1_92j*+z#SFysTX@&h5bOrbm@e9eKL&rNeX4s0eHpOP_bh(#1@d{NLE5t z7|BO_6r)>G?zA%5pU8MmB`^0eb{Av_jJ!$;g0b{jI0)_#=1WQg4BEgmBN&oPdP?dP zC3vb_w~2QnSBnRY#2S(la*PXfqa`V+EUE+PZHHvT_W>6sx^2Olj<_e3uPMejFg2Jt z#x!_Vc#62?xbArwJXD5_UADF9rYh-8V5{6~DnND*wU$b?ZIY_}vtmMm@M1sb^U4kY^# zcX0VGnn|n_$YPECaP;27_;=Slg!&D2?08tm#!BI}F?DFNBsIo3#4F;p<5$4MY6}T~ z$qgdCjcSq}4A}Q@P&`7VIddIx>UVH{2lZp7THr$ocXt`PT}WShPG2Lw&019L%BSEdGx1xSWF;#2w zachcWC)u$RyYG#is=6uoDXM#*hoJi)Z(+QK=|1hPz=+8an?qJ0`3Uk6QjC#u0_7Ab zCP*}ztRD_V;IF7Fm2+BXDj4FcCe?%aadO%=VJf-gn z3q7IHm2#aX-i-|9$+_ccts{3XLiLkXm@LAOCwrn~YPi3FVn`7HV^G@$1iv+4qwOso zBQ+upl^Rcur@(26XN2Hlg31iS|49+NN~H5ih}%_zl7bteq0^ypD{M0hAiwGivpz>w zXHd7HBhvc8>ZLUaN=+jnNm$@Y?g8 zyc%!7i;TbYVSa5f+F-TD>MFKVUSp|(0U*)q7l#-h)BFm~hZ0MP(M@qZ_;O>uZXSbn zzi(*CsWoSzCFg#^sh{B71boS^w^Uv!yMT*!3O&V7TMZ?DD+luyF5S8Fj*+?GNC&jI26H1izlLZ zhpDHSdk;MJ+d)zpyth@RC!3$!IJR!T;l17`?Y+LCh;K1&6_EY)IkpN0fUM7v>Kv&S z&_;rp4W<8{N+fDZof^ei@C`~EnCivHaR`i!A_l>uX8U%38{?lEDn;eb8p|rSQ;r6J z1YZ9hJQ|UAg!5F5X#g{zUhm2Z7au>ZN=<;+buH6xrWkTzqt6{c-4 zCK955SheY+^1o=|ywPr(x4|G7vH(6YY@1>Ui?b3I!R^BQCY0r3vk>YplIOo^05IA?vNAODfFh!W;l;d& z#CC@ly`%wfs6oxrPmW(m@HHYBo{U1ca79o3cM!LpoCaY?8F8YgJKZEqef5l zGJ~O4G7&u<6++1I`!3HMNM&?T=C{PuXPD{)vpS86e}*(Ga206V=laxK4~{Db_*hYmL`F=>q}w(l?%%M zL8UyZm9qwBZFs-YK4S2UDKkW(w}gc{-HXpcg}-e{sQ$YI%GwXuMD%YH`Wz4<0k`Ra zfAZA*3u8qN9Ja0OcziLsrI~Mb8dDrylt%~Sb}`9(Bi6TMzGB?W*{e=CSU+U9KEbvT zAS$vsPvx>xc}_|9k_ea_8BLMVZt&>yQ6TD)K-=A=0Foh)2Sb2z65g}1aaxE}$)#07 z&wnkp#So0gT09nw=ygx{pO61f&XB6< z%i*?;5FG-_T-UfqXk#sLAdN|EhTOpkcP9q#iH_w+W8beB`j17uo)>Tq6}x3H7J z*ap%c*bMuhH$nM7s^P2&pRc^qZbtx~e#KHrT0WxhKdw;vX{tAeYbp4?UJRVUnCd;utByP#x= z>57{y-Ju}LA1eWYH3^}?F#AzR2OJ<2J0ErYw!&nRd2@wdmXfYyiRGMNyqFRXodI? zxe=p?)d1v_HOd&T1!{^|zWJKRUz>p7kB>>=3oaUFePlrNk}9QS&}f=}ky$Y*|I2zS zY)Fbu2rU^)b~xb{_xOl=`VkMf#i{Oap&2dEpr|UXR~9ZX{Ox3+&L0BOf8K`h|3wo# z|FcG;_phM>;1W2^)fcRj6oDJk(7ta{5*&vg0Dm2!rSBvTe$1712F;MFH-G-0(2<1i zPa9LP$oFhz$9CqAjHp+YV2KrO&Ee`knXfrmKg8LB9!{KiGhV}eJ4`FtEQI$b`;TqIOaR_{1U@5z*KrFgwX#XLGgc0&!2E(0DO<7 zAy|12=&3Ig3)gUVK1BvATtZ`~jmT8Dp&TG_Q;5poUBO^Gk6*|zvA19%dNE{p+87bA zU3j|BQ}-U;Pyr>yBLf4#XoL6)<0{0hkUAIu+RV^;fi|nq%;$-m|8-IRLm3>ikM;82 zhcdX@Ah``u0dXh|#*Xo{7~@|5_;-pw83LQ-e#7*^<%?pRZ`*UNYpmp-G74#?g=2JB zR!lhKkVo9%0e5+jWrCB_xSRtT+(|_#(%6}C7@u2}QZ-e5WN9AApS9CUiAtjk- zil`c+t+DEI>sz(zFzUp_HqW7zly=|QRJbu0j|-l-lCFI5c_3S6x9uhSMo{yyfDHG$5)FH3k@ z@KT?GI-Ik$U2QyoHNo>0aTsm!)URj!H$wcbO9k{;fy<_yY#w_T@^8@8_(!)vf0xVf zH$0X#BQ7}LF}HcFw>jsKrB0|N!^J9tMH|MV@qybv(fdLu@msop_FuV>^RK-2wg1D+ z(Q{p#?xU&Sd-B-cN2v&9ieC`cj>kNI3gD8cM(D^$RP9-8U^;4-lcC1%8<4)SqDVb!CDmz;$c@#2KT!>dG8OYd0mUvD|C5| zw>eTTpP2&CtU$6NoW8M}``aM?dd}r{xUN2F;3O?k*r=d*qSq-Ac*r(` zU9W_CZ*%}eOCU5~2XJ4rhI)!5K9!6E z21TLuN`=Ojzd_L_dS6K8e_?BU0Q|KBUGjo(k(BRf2#fWwMC(21=lp~$34k#nfW{7Z zJ`kYBQoju&po5Ky!KlZmqjW2F>N9q$6DG|C<7UpNT~N9exm#m>gYhl7s~Nim`}H{o z%^3wgFkT8l2`8elu6db@OqorL9`#4i<7S3OHn?w62Gq5%o`vjZwRk27kO%@)5yWJD zv|@;+i+F7w5{$Joo(%&0jzLqQ8w23`D~@pCQ@DShU;w1ak@&Np zAp<~*x?mY29dIk;%gp9yuUHm??Vl7yR4$#O*+QKU+8t?sc`J!YpqW10*BwF@_ z0Z4Utid!;V6MTcN=g?H>^8A@)fRsA{cN6K)1!9Vnms9-ddzXLL6V~y^1f~8}yjF=2 z=!|EV0V>=W0M}OXKlEE0uQz%F1WAjEBZTD630C5xxTXHtX%0NY7)YVD7Q968P3%;c zNM__2--jo>L3zo0<5Ce5eZE9$1Xj{~8G01(NA^;sI5I|Ayk+uy6fw4$l zhlNpF1RK%(JTe*s*hVvpKtV$;4Cy z?wO7VNka!Mx3&8>1l>vo`32!VJc-Yfk^a6(@*d|roe1cRNI4$peHnp{g2l-=qWuw7 zlWC~ZDcqWAea_zcF$e1h9IftgP(5U~I%Cq#n6yhuUsK@70c#A#7>q5!7NJDX=8=;m zwjzfxSA3}a(G*3BMnJ#C)KUVK;k!xiI5s{X$K~YH&9!n zO@GxpYxIbMFtt>rfy zFTZ8KI$_$*8M!qjj=~57U0SGcCScQoqIOCZVo7#KVz6B(5v`3aU9~o8DJj1ho5+jge121O?40F_5 zWn?~rd0ga?caE11$6Ytk;Q}T>`Mb97Z5sv*R64ZvtB(I=|9i>-4(3xLoEjqk+e^y+ zuHL)HxR!jmVsdiE@$8}A)yLf4o#`GmWuch-sv{B^;6bn0|0^p0ZBXvLxIK>Vz3&FV zU`*60QY(UrX@HfeTZ;PHsuNLf0i$A^!?r5+Sio9!`KVp8Q=hTFe#F800k@XlaBJ~3 zN2~knwilGXCI_;tAS;ow$f0gn6PZ{leO+I>v%}&JF3v50^%mQQ`PFQQ6 zBeHa30Q}-eMesPjqGux$`f%Q}*t^(3uy*;jHd^5VKHD+^t`fOw@6SGjrykvw&z2OR zPxa)f=KuTX`Cm35;rvxxhumSR&k>dXIa=N4pnlB6t;qyT7FhMn z_z3nshW*db-H%{&fS8~(1IRQf=phjln`rwK8E`eaK1Yk4P>B#rQp21`{WY2=VJ2Pc z->vtOhpxdHlD0x?(?0F zn|&+g^tYs(e!*zxk&Jd`GTvL6y*q0e9W;nIUClh&dY+^H@Wuf6#gK$PPP_n#yknP; z4hVg!2Lut9OHcUIA-4S>1ZM%w2IVVnr2h3ruC)w6e+g64q5f0uKjmkinyS&k&T3$# z$jo8SYs%9lQ&}?2E6U1YeS{~zE7MQt+jXB2P}{>4z>AhTymiqC*cDJw@BZH3`j_0J z$;SXZoPOLKt?n0@uNR7`8e6ohY{y@WZt1+(*TxiBbrh~*(p<1tJ?5Z#$bNOgZhOJR ztr_94b_B%^jE|7%ZDe{E+4&%B<$my-6JF$*!!;o3aSddn0Mcu#FxLnSf+_~7qY;qW ze?N5~(%c=6&Gt{neH)8pIa-WE@~NB#ynqg%q{Ah0SoWtLZR-#PSY4p2bJeB--+I)0 zZJQR~u7QOV<9Yr&|L*nr-~Mw7@R$E2{LN?o%#$gGOXS7?_{EXrfeaY|Zxa@zlrQSJ zV4vj*st-3(tEjdyq!mHJHvYsmQ{Y>b0dYtyf77c`wck&P6#*d>Y5E26onaq_3tpT* zt_l%L7g7Hy^#5#$VRdPN73j?3E^_Mgg8JOxt;0@R^13DWEuj~MBqqYQKPQEro;#Ip zT^9@ht;tzuQ>?a3n+tyP_;28Y`4<>CyTS1# zo&ec=ZwN$mbs#RQmbo^oJ=*3&ygS#ncC_lXqIO@+=jDIpuhHUf{M$i<-}f883@DO~ zLO0hYa-UoxHwM5jjwBRv3JpyN)c_}iMQM+|*Z0s67|2%LHYsG=#=3q13EcwUjgA?_ z@I->rv!Qpl+SpC=iBS0WR1$NVF+EqgO@_em;=H4A&{XzUqwP9)X{qF|JEW&hq7>Cy z0ds@DD5x%Wn9uj+Y`Lo!c}dk6yi3$Y9>Kml;AbkWJEyP2Ip_VY0@yrwF|@YmVim`$ zdwe+iIfCae-~HS8aQ3s@n{1~w#qlcJxjSkXcNrgxChAyV#!Jh~2=ELz)`>i^owu<=$UzRv0u6mC z;29j{(NfiDX{$jsX6A-8X>VJG_pAxE@0#Vo0|2x$T3)QS{Q5{jEKA5k+6>9_kc+fg zGyt~7>TMpI%7&6xm4F$+46ey@RV8>0B*)1BSY)*84A&g;NRN5M0jKORlQ}y$EUxQ> z{nk(16QXRXJ@;vRoO2D7KfvGd`M=6fPX2S2`5u4f_x>~7nSa5D^Pk})i1z0x&6s}OX*gOn|Ubp!;xhLl8<406g>aEPm%r4LK+~S zM9SX?IF>k6&wKecJ}=>SY!3M(L=+%!1;ke10Tr-9wMwOTaX**teCz%6da$NM9E(0d zZ5{DQX3H7bs)$LwFx0oA-j%37Mr4ebeJ~R-Swp5LQt18V|KXpwFvqtXs(53O-}!e_ z|HuC-)N9PG&#<5Uu1kmLRSbZXM@|hOhQ#nUvMECruvC-zT5tU4-~|{~IVCcZ1V}oj zuL2ORoEJk1Gz(?~b`%~>2G*uLVF$G7!&+}!f&vU6oFae#HBgIc0s|&it?vscaEtC# zjDakokns=jR5AIVLg)ponJjoi|Iy`EO*z_m##NG(^Pz_Xq))uFhHs4`B zIhKc`5B2NGx$NhvV$NB!VnRbfONPTn5ZDm<__Bt&qruF$wFaYen(RP1rC%fZz zG09yeqFx1!tbS`oF4p-QAuFbsLC&A)ouK^7gP^Pv0ohjG{kdeLyk9^Huq9&RQrAYR zaF=*-Bu>$oirS!kdR`euN#h^yz&X@4A+O(7(AII;NlnP;(hun~+SW00;J~{K)eK~e zkb_hR-+~+=?8@$RqV;MHsJr)@viv{(5BszK{4c>@{MR>*a{4C%N$jf_00~64A0NxX z*?iCAtIsVdq2mepzZoIyH{;z}{+?h2JR+0@mh>^N)HLWx>H1|y)!EH{F`X1Dez|EtfaHRp6?yF=}D}k-A#2tC8w>agP3-+lgaTo-}bhv)P?HZ-Jyf*ZO zWDw2rt}7R$T7WG=+Mzp7KqFU4`b@nFRNjFOI{^kPf!00g-h;TE(Hop8+WSm-6$79k zHCQ3&uY}^Sc3|z5Y5Jeme)Zpognm30zE301p^Nc0djd zzTD+v{Hc7gQ);z$$Vq;WkN8S{fU2-!!jcK0r==hyMbpOwo|Pu(mH%a*R6r_0?a(Pjbbvp$@!@(OFj zV4YDHy!vW^u4chFN6}&&0n~KLU)M8qH4C3@hv|s!K1f)t3Bd?BB1DxuLC;jYVFN(o zK}-mJhVK#-M^1lJ-bmk%xgl6+4Iu)M-hY>iKpT&xrRRp;CvbXyL@@5Q_$s3(Cx9s+ zKM2Zy@{>^ZvM!P49O?(4i@;tvbhO_z@NS4Zkf;F3JAx3}2C+4m8kH1c{=O)G=)g%4 z`JWn=P?QiO0HHJBU}1XG(U@W4sUudiAY;gm(DE}FeHwGHga|+i(W>VP%G5-xGmO&~ z9y0>n6?AvRYJAMYVkE0p#4+MKeg(9ZF4kL4-hAziIIjP-~q%U^&D9|KyAV1()W^U#Bhc8 zJ}{Ejp{y^k1V*laL>b&RvFvXaO8@F*h#Gjmxz9dLJ0U3lyY%Ix$@{OL=MZnI0Df^L z)g?Fac}bW^-w+}IIl&-E_>N0f;&fxgp4?^v*#Vl}3JavJQHc~R-fxxY48Q+6CFQ|G z4qVAX?XY){muMbnJ4Jo$4ER0DSjDm@NvV&#K=KmF3na@U7x}OX;7y&kZZ6ykU{EPT z>PSXtb_=qDFlQuZNKkb3PCF25>Ts3}*EC0`Ap3&uWi+D+P6}!YDiCFsCWzxOG}2w;xTwk^zhTFt6j>#;IXvWt_`(%5K*M{$PDC>BHwzSL0Pf)Z}mA^MPyW4{3#*iBz~q!O)gXv`&%F))A; zOQaa1J{r8P2_=8)dH77d3eCJy*`o{>&@KWW#=l?36EOC<9L$$B0MgBvxUF*n%Jn7% zuqoRqfcJ^`BVD7KaP3YBouz(C$fd3c>78S`Xn<_wt7y-V>RZ&5V0QxVc6Nx2e~h{# zXipJ$hWZ(zb@WbSJ$lzX=nZ<{VnTs}&4L0f3ZxxHDo;V$I?@FD{w8~)#>wlHp^{Cx z%~16as7FUJ_rIga?Daobb7WY{)rKX_Kf{Kzg6IP6=7*SEi z-g6cos|S*aU}-6-7_nl^l9GyywgpxWGxyks9`l7FUpef(n)2~?e0=_we~snmzw&qT zL-*(Tn?+-1LOP@38c8y`4hYKUCkQ`QF`t4Siu$5(YuT$#xV!j*%m+Ns(egX?>vIY` zCi41=Ed#@!jQ~|o0ENB{cuFz?Y#x-pEfHHFwg?S>d^3nDzRUsj-&6I7YtVWXelIIR z``}U2rAa~xU>!pL)gpY>HGy$(W1pP?a0GlBf5Z~*hoono>wsS+0#Fn34nnvgZ$N-g z%4HzH3!XDP^A;tM1l=Pje@EXLxg`u%ZN1-7VLAzo}97E1tse`!(9+^{NDgSSLADleY|42 ze5iMt(_i_U`7iW$qnYrxx+RW`5RCTM%>a-%_D8^Hz+bEIV=wkL=sr*szT#m05YX@_ zhDkHypgAMQ2M}E3_7C`gcf`#x{9Q3&ZL&q!I^v!#cQrWu7a1zT{ooq3S%VGOByHBA zKSxsVu0@-5NCVW%@LAXN|KI%Gf{=bOggKuP4CDs{NA8VA03h6eGlHjh!c7_A)8w); z!1qZ~v!F-ubw#iICxk_I+{4E;lxj5(5w}E}$54ELOn!o92TQuU#TInjm z10ABGQz{@HQ8-_Nr)13$kD0LGmONm=4i_4fe&`%{%n1t$smQb;i{bq>jwUXXIV~er zOqsLGIY;t{V;*tHiS96SBbJL1>(i3zeopn@61tV77 zDEeo0<9@#+d7ue1^dpcTsIVWHz#wxqyY&fWyTI83#FD9_z>{a8rLWo?6^k&87Wud1 zJ|==7V59Fua!_o6n3}Fx(94cecYA~eKhjj;H}X;79~jaP`!>AStU?7qQ^j_`y1CCF zsr-HtHzg?l{djHOxB=keR&k^FpI6?RiTfP907FUTBNy~}Rv|2^M|3QKk``G(TSL15 zSs{KE6li_`?gP-{7%&)t-9_yIq7HNwfPv6^ijv5L2H?HdHl2D;UW`GrEMgPV)rh5m zC?Y0u1*8S&9eE_rby{(Z3^C0Sa{f|A&%cP_|Ga=~koWJXM_<=@ZIhS`$IPPG2s#|2 z_5kc|m=l=b{ks`7kD&fGdj1PY^8;eNCN=1qSV9{S^p2i~q#$@eC09%wgD0mUr(w(z z#|3M~?8pUE=8Sd8h!rJk3MxjjVuu+E-C@m`fLVu}CKv!RXO9aGNd~}U_PAh2F4!e( zQ|1)ZDtK+l{qL3kK32hhT&q1IIeZFIjD| zZA-~ATo#jsd1^~U%D~QP^1#JE><{tQWq?wS%z4ta8_oh4F|q!=u(OgX2M<@<< zgXAToynh*$|1k9bT4d{*Yk*@XH(SdfeoA5DYzw6-+)^nt1m+opg9k(wF)pg z5k#m#&k;Q#RM0F@sa1{lDm#jd2_eua$tb<@sC)2UqI(U%y%2iOq@~m~6PE09&N2He znX-~`MPq@|^xV$6yO3-&l?pEGtjmmQhgDYNMgv!f~V<%rc1>c`-|18;Za|HpUw zw^C5?EkEH`?61D2Yx34)=us8~gDa(6Qu=pu%)N_a4&YEJLp8g}f|vbT=jQ7;!?;G6 z95H!FPrNb^aL9$Bf!5d5;A%R8qaiPchEmzEZTH2va*C(kF2n0l&>H|(MgwlD0B$5n z0(RE)K$33=lgSfO0x>pA$#Cwd1Ghx#d%>G-&mccUv;C0!9eo16LYl{D{T-&-n?@#X|Yv6#g{P|ONi63ATuTf1;ICWzH+{ja9kihK# zi}&ey43agOLVsn5EvIi+P(MQ40_sO-aSIv$03H1>Fa-*zWFcM9&Jfn15#cI|h=P&c zo0RA9h+jyeL6L51F(GemL(lF+le>Zca}sES(=ojggkE%7P)6~8xJ}DhGKD)tOu!q^UyOuY*z|+bs1pl zF3@e6!R=by1Q@quUadM5eS@Gj>gdq)C;Tr(A)hZL znj*J`o*-yKzh)O|e>4t{*)ZS#CIEi} zkYA_#L&bz9s%Oc75;YSr({QaNvby;OakF3~M!H||D^kYEsnGhI#$_}<^Nq`N-4=e? zjCIwPnw!=hjxUP6axocY)oy0ot^|wY5-OhOm!dyW8 z5bQLN?$~Jv0gH{j0}9kH5x)e#0$c#0y~9Pd<7U-VPF8!O22H==3PEk!?ckm zu&qz#!FwScW9aZ0H_~&jc%%u0s~SB0^&Fi&!n+Uw(B*j`0CEHVf#)~mFS|a8=_4nG=iuC78rlJYMQ=OP%;N6dl?%z?49SLo2yhJ0Oo zPrQ$q9pC?0y5ZCBb$Evq(zkt?VpQ${iJYF>@I2)ujp;rYsD86ehQC+Jr#@w|Np<}Z znH-e=D6*#QpmsN0Q@b0kv6$fX*AGHtpY{S$y}t{NNyzmFgVCwgSvAr7i(`LpcFR3n z9@+=tH-HnQ?o2#o}nMoEPJKO=>YC`r-|K6YKx*u^)vrr=GU>Zki5h zpIhU&os(Us;?tM8I`3E#&}@fLYPL_`I{fmRbK}-jR5ersbs$@}X)wPFvuK!qPl|tV ze7aHp&nwyheL6v11M69cf2}UUd#MkAOGH>N6#2aD`YZ@W!0M_m-53Bj@-#_ho&i#zQXXCL}|^IO@&#lilp zp6;NiS0ksZy<%2Ri`wNOW+NWaL{9bMbK*(G{o>weG6F7nJ8=AjuxyG16!d%`*GVFo zL#JR4*ep4-&$X9s{xpbh!u_J}ku9V?8vRWm2TNtauWb|u^TfC4dJ)P2ZB)aqjo`mQ zb=33E$b`=rUZ0D&UD$_gAUmDwsQO%*oMfLppnqWNW5lK z)b2z+|3LNz%xIxh-sKE4MOLn@jC7yqeA9er@vNk%VW+wgHgyeiDH_j|>ff=ZgTqmWLgqW)%5dw(68`E6sn#f`~sBjsc%PapLagC@#x4$b{P+#Z=pBACH(m=a00B+<` z5$U}lucDc4YxQOUh)AsY5^S8bD?-`e9RfmoA7zYsyb^NgE`#P2HGzpw?6e1nAu(*6 z1%;v&_;mn@=@Rv8L?cZ>SFSTC*tms09 zlDy*-J-CeCbjW#NOdkm_gK&Qr`p^33L&d*g@H{8z@LA*1EzrZztnaRmN*0uVHbKoe zHuWi^;*VwcZjJaV7;A0;^+TjNMce}MD~vSpFf!V zB50RmRn1^@;iZ~3AahVfRHK$#gPfzBfcz-9?Gk}c5znrQY7>ZnDp0owcmO*Iir)<9I8UraUum%3>bNl9U#h;G-8CUb_EL~MVTWydHT4>SI;_mM5 z?oNT?TC8YsO>lQD?%Lw+?poX_UV^(On}2s7l9#;P-0vGZb7qc9B-_W)8NRpOjRSFu zPn20<_1&i^r~LTxYe6;rhq!RF|I!ND)O@iO-x~jsu;AE_3|nXa_Ix_oWi`nKj`HEP zJl*fcJvjzMk4*SC*vS<#me!)n3jeenQ;y~lg@3p08_AQHgPkyRw7dwAr#Qq@XCHWe z`dV@5ujaP*PWXXHIn_yn*O?0;TglUJ;Pwx=)BcZ`y|aI(8}|((!dV4FCXApa%li3< z#UVRKM;N^7fQZZ?q=7^ShxJv#c}__p9yJkw@8wV)?tzUGxb~q|i+laUXSh`fSU(59 z$ycK8MVlPcg{e3vu-g2~nx{x4wL1eYQPRgGcKE@}TfCGqhCi)Nk<5N$L73!?1$eo+ zBR4&M*1^7hwE%@*QKY4UxtGYsVPq0)-h%wo5&=C3LUzjEsH46_IXhe%^r5iteO>d! zMhCec?=NS|4)sJNW~=X3WFm!$)hw`D8>@M@^6yXF!1O6b@~)=mll;G0m&wY3uAJQP)eYC-Dk)FjC=RW&b>UZK0NGUti@&Q1`yKlIUz>g5 zyv#)c_;F)LN*tLauV^i)N}lX|cdtK9aaD0UqJfCuc1EDlK-WF@pNspYGTIr7T978- zrP|2_^i(Wd?6Tyg2pVki8+xe*@i$m2#s7Ba(_;ARVZysfmo?L++auJWv6iLHf!oTn3^qwfREv758?=gQ$yxnAU!v#zmgPQ)My%1G--lhk)Poyg zps{zM74Wh~spqnjX(ek3Y2ec!SMG>&j~%sdGQ2(Y9fw8j%bfapBwX$&bT=#ibrTk8 z?$Oq6yeHr>Ly(}Ao3qa44>~~-pajfyK#Sx|#YRO}<&S%pvC&AZHvM=62^Jf^ zfS%MGJ-ogyKP^Ki#ddlg--)H{jd`~jFyCX0;Ei~e(v5g}sNTnq92Ir_F!P*1dRIgc zzV`t`mTP!bkPxn5Iib98tB`g7B2_(zNp&&}2mLQ=29bDtHF3caQ-MJ*Q7CMy}N!3j4|M~`T>8L|Iv zVVjuPp^0vLF4x%slgYjMY1ed%boyLp7=4cJVSI zr9T9w*CAg%b!H~SX-3~514UASL@i)@M6{oPGGRQN9rdIKk}jWRareOKo`Gm`hfo(D zPVHKO6;8s_ifREGny}Q5 zv(S#-ex9Wty4q#Z429_SOuT-8jsclojz@ahsCJ#Ss~^ig^O`ykzxMtc1H!UK&683!t(DArwr1U0t^HdDh(FhqXaiG;y}WY!t$8 z9hefMsE82Qc%8 zT?s!|m-KDnrRsvCo%4Zs7asZ8D6-(zt@9%l7M93`d|4F-<3=c7OA*p~Z`WVS)+Tb}W^oKOz}cq& zJIACE3ssBE>(_bt;|3xo?(s`tgbnnYe4n&z4=G?}SKGyyG99RT@cx9?dNIxLi_k_M z+0D0GNRMF8#J~_mw3RXr_&Mi(t4p??t|n~Bnp?EUMZkd*@W-o@lVO1{^;kHS*|^{wID}*oZ(4^f?-0*h)qj{jEV# zH(qKK=t7pWEteiYdqTXXjR@^}a0S;lsF2V9y;C^-oj(3w39dVqFlMj3lh!RG{s=6; zOzbZs&*M+U>`az2U5jGrjH=tEcTlksiDmQZ6y0|gws2}RZ85AjU|yNBKHYa6uJ0#& zmnbpoJIfpXY+TOwfNn=80o&ktDbPWrg+PW_!AlrTUroMk35JK!V%{18@2!ckK; zSxI7bm!ik>w{Y#RTNmt|W1Hk`AFM~OXaBm-Sj4H!xVTWOGpW-?ItqR;X%a}Cu=NYm z?TaukDZ>t+d*x6|=eBJ|2W4Vmd-cTKKg}@weutD*aBwuALNvro>h7*Kz>Hh}u}S6; z*bz2Ik>V>a8tE^g{IGfS($#mA^FaW50&kRud?5A$L%yqU8rwlu1hY^h+uaV*YrG`4 zMh0!IOXAM|uh_9MrZdzvf8agBT`KirB8f`2hMvHQ>=fTJ>Vdf$Y-okQ63^RBdv}do z?FQ}rLmEl`5Lb4zL74Z$I>};CYb7i@Vt<*I{^A~u5r-de@)EI0Q8#7wqc*u$Ebr{T z>u~*M)~eY%qS?C7!2M>Y4W^NrPc#=;eAI%EBvd<_;5C8RFxY}=5g&46gCZV**x0q)4Yxw{=jx<1aQ3Z-57BM!nr4r>*_?-?BuQp% z>y=l3<9!za^#^3FtY^+&2=Tx>Bla&F#I|aQhuFK*90BOuq->a8wfu*3 z*PxLJ1k##&@6L@Q%hYBO+^LWy>w;Z!L4ApEr=;sprjpW1b@*1EAHK^w< ziXu>jd_obq_fC^Vf_0C_E7GW#>456M^K=-C$jDpVFqT_s(vf7`(E+;c~g!HjezvM%+pKWTLo0e)N73r@u;u^MAt(+@~myD*Ye0#&a9D><>dYuK6*HTDOExF{KYoEr$IJP!4(P}Ytg zN^Z%m1}Lsm?p-RPn%7&=&fR)%bj=gTC$gfU5jgdCaIIg{UB~9)s0!A0EK$qy6nF|l{o_Enyq-+mu<)yzevEA{;)w18E&;P0n!|h{~ z+&nb;lXq$yzX5F z$UY>)9)#*eia*o~a8{weGsr>v0U5gy3-`fRjP>tGRqaR{Co=~9_rkqYexRm93{o8& zq1FeFY>5S-V$>g zDn~36*V&1`Yj@dq>xM19g{IP&qYWE}Xo@=oYqsEgsVZm25!ykiCs{ZfsqaNoMr?li zhSK*UIQt$m21F-o*6J03ew4z9Dp-=oh*k(^Du*p2CUYjbKVlPrcimy*f$za0P{~rr zq8OH;u|IDTis8**G)Hk4o-e`|fpx6oX8M8{bgetq`^n6360v@zk#1YA;q7v5FihF# zec^kS%=Md{GrstIfq{Hj&07as!t~G@*7NJlQ{0E65n)5p zAMI-vt1&iyxbc_QL%z}H^svM>D&Iq(%4(tH^L-_f5aZW91hSA~kM4^YT6&sT15@N| zY{EKbFU_YOAnk+@HjQ&A-yF)UI8il+qV*5%=!yyt5=yw)<)eY*icYf-@)Cd{$;s={ z1LYdjc=D;N7P#25(M2(N$}aU?a({VSXpz>0+|_Kgc%nyl+p_% z)TIUY0p96H;bv-w4f;1BTAd2+XsfiVrhY)}; zU-lLUSDh%w;u-q8vqolfkq?IsIW#w=PA(GeDTYOmf$s$o9Gs^$+mhUc%vWQ+@>(3; zg+$wpkJ;tH?*92qd|MCyF3cAtABe*69E^V#i>8AXgPwz;Gnq+><&vnUfjFuxSu+5fJ%p70Yl*duH}C}Dxd6+;-l8;%q!2E+(~wkt6@Y{Vt^yly37gV(41=FcGw zUfRXER0rqz+zGMV*>0CxEL$HUo}i5RFL;>2aJ7D&pYNSsU=n-iS2OebZQgYMRhJ6p zC{GY6$x*0OP#NrV4NU+5gp1ku{>f1P*++)>a5PVVBe54yKKK8s^J4h=!qMQ99$saE zF~JolTSgqO+R76!d378Tyi3eN{inA&5cd^6J0y3PmHbZySx9a3CaQSff&sZ1g7T?m z_+V%O3H?IpL;GG4wYYH$3Jn4|{UhL<2(|?v!LVFzAw1!Q-(#HUE;@~%VAU)F@lgt1 z+i{)=Qh@!D;C@gBp>G)>xWt59`&xpKQsl6!k5cdTZ4g!cVdG8Z%PWZ|RB!aAr40M8 z7=jTzOJ&&b5px#m(IRPCl?Z7Kh8L`4pSwb?7q|Pq@aI!hj;)LxFn(Y z$ZlWZZ*;(E9K4%)pWy}fQOLv&caZVKBAoYwt_&?(i&$vfD^$g#+10ug>0G?k`u&%fN z*0-BbTy_#6@X`b8kU2QX_qh1qr?D1QD5jl-%-iK%*-fc-@7TBvZ;l}xPNj^eHi}|S zI%&Yg7Z1}@V6u?%FtJ;kc7LG1KLs$vQS{(|96ShPK=xvcyAng1xAi4l$Vur&1qySt z@5*k73B|$h07tUsWfc>JQLZApRQU!DBqwgquCw?xh6!5V8BI*72SLU^8z#x59 z5Cw&zqk*iROk!v*T;~c~C#yzKk{D7qQh=%xARFfraG|mHk}f*(Wl?>^LD~fQ@ndlJ zZ)YlY_WOin#R&&9zR`Zc)w;J?_VBS1bOKK6<@-Py`Qg=^H9rEs$0!oI~ ztcJ1$6qs4dNK&60h4?% zde!8rPr5c&;hl&0N`t=#+aaQ7pi$~5E&t(_T^=P&idJJS%(mM+vxeRw*s}eK$S86( z>oX1L^{^LJ;vW#nPbC6*vK6w^6DU4)}Jy zI1P(fjynsY*BE{CP&kTHsLMti>LNgm5xRdz6ln(CLg}?<1e!D9Eut)H2In`5g7J6$ z1R1pj-Ppy;9dNuHAo^B9mnGO)JPFbX&G+m3}nmzwhEbvfSpW1zis5&mIjT@p4d=CYFCL{Yp1XwoO z^Xk|Cf~`oX8*+z`+sFIx%E^4TzT1CEOYgBvUW%LybJ3n$NV8lf7zPHIB5xGSa()-j zO6{T*`))F=I>F9|$*_fVokj}E`jN#HWqgSFy04MEjd2$}sF?j9Q-I?SbTd#nM5!HC zIF!~2nj2~cV4a87zY!Zi&Mw(_{(UZ1rFO`YfzBCBTMWF`seVav)Gp7IS+$5VEf z`LKldu$lLN-xA(|Ofz+yMYUkiwI}Hbtu#VrcpzQxxr|3guzFlNS*WlduXO*2p#YK% zoK?AfxM>GP)wXNQCC-+t$phqerYwH_c#}@`_Vyr9BPj*=<+ahZSOgOWPIA}YH__GU z&B$z61tyH<$e9=NY)Ks`!V0*CJJK?+h-!LA_Akk9sGrC;9hINUIEU^4XvuO_DVqBm z0<;6{JUPKulk{U?P%*J|uxdxUc6XH_jW|}ap@OqY>OFaQKX1e>@pV?ixi(Ext4@s~ z%jkRldBAIjnmnAJR36j>8}~1mm7%edQd;Qnw(HPDt7n5nHasO=?IVFm(?COp4LJsC zv70H17q5~szDoo{g3c6!d=TT z^c#VHL898|CCx(V$}SZ7ZW#|CCufDc%|{YU%n zqE)-j?-C>xTeh?&ReGvup4C35tT zT)0gu{!d|X!0ob1e*OjjL=Hv<5!!;t1>%pfm@`-cJ`*xZ8|Yhq<)Oj5V~t-97d9lm zGIbe!d%}LO4j0xxb#2}<(}>sOp=n}*=gOO~@s|aw3~DgQ<_fUn86ci50n3n&qVPH1 zzhe6yWba)Gyc7wvq4U=31|J4l6&!_la9u3QP?1vP8Y{K(&7kB$M1uZ6<4jS6;nNIF zPPQEs-dia|*i9aHz#Z1qZnF^7I>TqV&CH2klD4D7quq)5e=;-e^yKbej@x>Wp(d~y z05=q0h`Ch2hQenU1_WOyaUiAJ#@E|VzW0w~BLXH{PEX;`l>+DUU#^)I9$WqrHK$5G zaff&u08P;{^JGnK8C3n=NE?)HLbb&dzuK3Kmm8`WdfI*Hy9MW*>mI4mE1<0UE9x0A zdgtlEQ|U_t+&?ywKPJDl_ji>@b+Xqak-6FirvL;OU zVuH%@=5BJmiGP&=nLlEM4lAs8AttrVhv9ae+0uin$O>V*S9~I>NO!?nhFl{}Jk#FU zx}BKwL5QeC7G3WreSit^SNbe{i=JcGHfHPU=3=Nb^?=vN>xOt=|dV14MQ=vL%R*L zWLhpA;O?tB1^d#a696KE|EmFiQ_c$Vw>eM7Gn0`TJf@QM0$*=7z6jps8fkl=_O>BN zZoa1E--iE%Qs4r3U7-Wqy_wEtY$&870$Dm+gfc_)6+~P723l=F8;$$2Qh&OvAB6JD z?%yb2@dx!%;&nS?BIuieH^l7Gg!NzA5Kjj0QMOCwRKP| zxDUDJ^&LW*_fs%i+ApQxLoac_|HiY|X_oCf{zIjr>=XCb!>|5m=c3%<6rWAY0*7@a z|K)q!y$Tg@#HC;xk(m;;d&3)m=V*u_w!6K_b4a{7xe(j(k64D~@Rt*#qv<{*3yH1P zBp&ar^9$Vk%fRXI=M|=$9&9YD?)e|T z3D9xKKkiscyTi@6+I;|E6ly5SQ13Q(-6M_5J(~ z)_tHEzI|Zofig!PkHv-W3Om;6jlf3P*JKc{m1|{fh-o|j<{5M#u;l1=#n5m&xb-0>|J4aQ1xgpf*+Vvu z^GZB8v+{fX%oXx;NO~v_6hLwQ62+G(Ex$bYA8m!vbmz}&FaJ@?I`bs9 zpO#Ftt4T0Z9M@5<|19(r)b(RpLcqGlupa#V%QF7&(K|FlICu4HbI;yWa*vM{ex9@T zWK;2puR5awYZMuYDo#Jt$2W46TS$#%bRg2sC1l~PCXreajig?W@sywoVQ$eMH!Dee zBADNT|1317S{O0hG=0;FZ7ZkE))s={zLQxD6CV9*v3gBZX?GDa+MG3txL{7<8l|+w z2yLy|R$=uU_=sVYQ+DLb(xQ#Il?bu<)|iGn3@o&)2AJE`ec3P)J3PM)f!&DnTMk)2 z8^zQoQ$WP6Y_Hal#rAj5N`>=cickBNt2KDD-R`FCGZ0wz6>>T~{jVmz{^Uc{qQAW> z<6Ucik3`uc%jjO)5B9)sk`%Fnf4>rqD0k*Y9rgAV(0}jkx+5NuU4e2AK|j#tZUOkC z*JE~sjcsGPSrB%7-SvJjy@vNRABsJ)1<2?v3%|Co*}q+|dC2g8S~Z<{p%5vAfO7dQ zq8t6CQs`9HGMy!Q3TCF#5%>BKepB8Ie^bRCH+e>fj1Q3g@H|31LjSHC@tNgc)^l+J z6m#z;9>oUAA@Srytc7>qLQ4;2UPpTxU^4opK@&{!1O6h-W?kKq)B=7~b&CET7yq7!Uv>gl5w&jC zu=g?UYk5r_nentW`Y;y4EYNA*Sw6<9g18dn>Ftm*_c2T5A#Vj3`G8wDU0OYTXcs_2bP+y6tSAZh_)K9Cup#HztmJx9nv0H%E3K7D~Qwo zHdo87!BM*Ly53$V6+*53iGJDu*6myibrn_%QxUORj43yat4tuk(~?poZA00(!3WUO zLUh0^(LRN+L6*-d$JF9StW6HZ+JMt6gaK1Nq>y8l*vYKK z9_WL0t9~6kbQo$weYEWzvm^Z#;X@!&zhZ8pMZg=Sn*Y-W<}nRr?geW^i=CVjfM%@s zS{Ty!vi2v_y8G+px7q|TNH{d=m8HiuRkZ&69 ze#@2_dV7m3O*oPwS}V?4SSHhL%q~#%^V=DH&8x|q3FL0{Lg(oi^K}XT-`Z1vv=hLu zpLPw(AOMx97At^kgGO=sZb_*C1gT0T-~P=d|4jR{FbDWrZ!np`C&awoE8Suz2kScB zR9e=I{)8%Zn3)}_tA z8_sq%21<*#zp|1T&2Sb%V2wSv#xnJe?5;svc~jhtd`i{cFDl4iA9JMv`g_bUKH%FE z2aGK=5&VRWx45v#Y7%+8s-`CD?Z%!f@|*{$fa(cN>Ar@t0r`5a;vonZrXj|8uO`r< zv>s^QCK7}kItqlE%s~G^trCGA%pOk6oD63A5vq!Ah7pIjzuF+tFFfgHh%i(y3GPyr z51bnAVFrjfiOOV3e1^4hpGkSrK^1kprfj(w+;@^JBIN($;4nI(78!B z>A9ro%~eK5agW~kwH7QO*31ZKTu(9qQo!s zfWc1|XiNI`Y02NH>_usr(DYUlQn~6s*88dEezW{Z(UPDiWrg$|>HEM#>==8qxUxA5 z5fvv!%s0>@@}l@@QMIgptS8S_i`NmnIIIt&rGI2g-1xi9)?m#_#$d{(Dy*5a@eY4k zd_AqTxlbVcfwYD~&iExrTgYnel-?O%h!Y44Sr~x!FG&BZ~jm)%Zj5SheL?H z9f~%~bLrtYy;zuS&Gr^agAo*A+>?~##Q@Qo5xqs+Kdl=^U*@0lo^ha-FE5Z2vf>{C zz>#?N(&}xGb9$FSR}~81~D;dY&F8wN~wbw>~nrhEGOb}rEW8j|4h}$txML!07+d4 zxEaTHjwe=<1`(4JM{q7G{WxKxsBna0z=^ji;V&D6=tF9*N{b1+Rdv2U`NtC{)Xl5P z@Y4cbMa`0rk0QzrUuodqPOZA2!{wbN9U|>`NuygW5Q0C5NR2^SN(b-L&N6Q7@-&*w zEtzCJRBV~v8+e*Zd?#+Z+Sg&=EEx^q#Ea_J#Ff-zW{8f`aKt}Q{OK#(RyJ8M8cNCa z$s5@_&a$XiFIOeO@12<#zH-i)E`?)U^U>_&9Iw|^wb48;y2`v}Q*OIPCGt{ifgKuH zww!RL-Q!D>`l8UnT}GwGiB5$G9 zHvxZ{g|cQrwB^_bnJ3SYWOlehlD&f`AG&5bQrGsagN{`ek0_eG6>j<#3WpSea2|~B zesUD2JjFuUEsa~q!GA(+#w!WNer5j9Q2VA4A6+>WJ#6g_Za6K>Boqx}o4L?z(=8eS zniPAOLJC3w-{v`=?wsDvi+-MxsUBQT=Mo2aDBXW<&^k=ngV(aEyVb3!@${TJu*C+o zxL5}2TG?xMncF&RiwVX&R6Duumus5Jbmg=jFF_Tp^`i3E zCIN7%>EhuR2i1cgCm5@F?x{G_>zC!tY!=aO6Qnp=Oa&U4y9uof-E@Eax4D6tI3F|M z=U;97!kj`FJuv(_*UD;DT2l3-Y{51th!*5H;gO?~7oj>Vu@jOC1rrs95{qGVzt@5U zoyja!RSp*|>3>q{1Z0hGU;09*v_qh~5Vc1-=sonTgS4;=bNe94k=?a>A-gs}t+O5o zHH^pmELZkHBwM$h&D<oMWj9)f!JPrhre9di>Y@ z$#}WHE;BYP-p79;eUHgox|1EgR@5}IOuFs0wl=?zTMDx+Yoiw%06y%v>{Gr`F=wei zH`|Z=TH^p8kJYUAhGG$3e#(K{Stv}5@1d(%mHcX7voi3fCB5{*-M}FfWggLbVZ=eoc?Q_y_A6^KSEB%UHtP?@HPYtLz@k~ ze_hm`i&2njEou}}&a4#Ev>4>65YysldmMci`95472&c7cYi(`c1ZW z^vgo5nq|t1&p4)x-wbe=v57Db+e-j{&>d+@EPb`KwnS_9$ovvM_nb9QzWjZ+`gk9> zxtWs@tik%udw2o=8eQhe>KqYHmXfi~M^!)F#aLyt1<$h{)`{C{-qmTLEi0)cW<9xb z2=6Kyrz0Ps6#UCDPDUm2yCxCxb~|zLcXP9%3WEs?quIiVh}c6NEtgpa-f(Pf;g`L_ z+(L?nv@Me&S83yS+2+DYJK5&aNkZASqDk&jI5h9vL~A5owz^gPWHt6x{!%1F`|NX7 z7NlS-uVq#+mb<)!#Bdiuu6 z{cDvNv+x4G%$#G%1@doSxm<3v>c9{*+5QOo!q151u^+ZN9Y)tVbrM@=4ec`EBV@&i z2B7Y8ewN`fm1=GVS=zp*@rQN(3%)##|8{9d%p@aTOGX$d3Nr{_sEhKcxU}f2(li!N z%3U}aIH#<>-?>LWRNCy11Z<-Z^D(&$&6%B?+IESXD_qfJle1Bb*$^ecAq8Yu&yMzt zp@=KhN>Y9=U(!%N6>2$pr$uMCk>rMM) zfH)A5|FbtkJkC6AieIe814C!(SZ=A+DzoNBPe*0hp)P#F^RocrX8J_A(7W-TqB|ah zDELK*tCtr#qobBVx`eHN?{8Ze5|QFjbL-Plawp1F`qpAci%}IRMGuWu3#b|hd8H8H z*8uR11jG9gELd7WB%vJ2L*OkCS)kTJdina$3-X12wk!V;JYZG|9I}L}qA8p6V}_5c zsAb#kR^1{|e&%aJ@m}rr<2Fuc%*P&>{Mtnvor|GHf^s zdb&;f!p5oX?)o#jyUXa`$@l@?nEn{X@yDgR>2&z(G+$m#d7V0!@$}qp2VEj1MIo05 z*QMy7Z^HSm)z$?02yS2GArEhXW7A}hBt`eh>#N7k%g1h(r(V>0Ka~>o#(wt^CYJH~ z`c4(X%SwOHj?qnS*-|xdaw57~q_2A8H(96r{+_BEi(Z`p>;3G6L}YLhFZSvsme9kI zh$_M;d5Va%hnSw=FQRxt+lU-}!YK~c6uiu@R`?0KUG*pu*qiil8}eE5G0h$&po+=K zpg3{Y-l31;+o=G@H2Coy4R!NJ0m|){k^Q@2Ls5~Z`EL@hDn;VYLYg0MrPOgoe@K^T zRW3_dn(i0R6J^^{f9Gs8hG)O|c;6ozl|1TiDx)#C;NXBX$zA8Hh*GB-tv!->siXE} zM-cWRhlm7}2B2?K0KzYZk48dLOwF05{^c~`mVeR1S|iD0{%*v>f{_t1YS^EVvNm&T zBCa6o>H`tx`r8z%0}sHk-2?YQYhDZZYyrj2(o;Oq7zA)N<)VkB5| zq~Z`>l?kYR_*0ElbEhO%r>W?QoUJg^r~_9G7R5h6-+yhnc^oi=c=%rZwbsO`CxP|a z;tZnTPv>x=9eg2U#|lOO>t5SUPFF?Nps!}sk_02x@cYmDLWstg8g=ECW?bHWxkV&+ zDESx~;WoiG(T|ViEQM^7#5uHvO3t-p^XU)~uG_qQ2ZrAd0e4uEjXKgcIoDTbF~D82 zx6HSSzj==)V)xHy7g*ij=@uZkaj@+V5^Z4Z2lc2g1^2vaZt`#6hq7F{dEXW3rS&S| zEvCp-1^0$5x!6oBT8UE^^u3GcYh3!W@UlZNUK%7X+xJ3B;Ps4uzY_QtYz8uX=T=10 z0d3yeI_4}2UQVw=#JD+itKs`{g`?~%h`*~+I{5dUNp?K&@`)AR^tmxI-gVcPT+JBV z7m2x*_`78TRL= zF-B!b%w8q;WI_!z^4r=BUFIjp3`;DkMuJc!afOH5?tuY*vL~}ByG%6bYbVKGy^&#a zc!QQ(n@f#9ei`Zgtj8Cs*PGSjHmFA0U7+C{dVkL&iy4&JWe>)9x5i!s(zPh=eA zqPt?;lcC`AH_VZ;j-_^6L!;}z=R&sym!FV($XalM;4XQucTj)!3|O&3as9Q`>;%*F z<1j_8EMsX)azDKY_;-8Zi>d-f7P6TM;6CK95^=5Njs9!3%+<4kx32X2Xzd>*=vO1t zUSW2bv{x^3vWubOonG21qqR}U66fsk9_0R^{yC&G;8XL7C{oZPGEv{N(c0OnCPEpa z&m&u4-p~k6KTjtIz*yRYE^PZ{UG`KBz32I2YK-e|&wYz>aLY?NWG0k#W5pK(gsCpN zsIJsA0;YKKW+33){WhGqn&zUTv6i`HKsp(}!h)BgN3vEB<|N_}Zgkv(pk3lbbKpd| z`CA1=H<-Mfz(;j?0xQ9R?J|+Dk(28}^9KFN&d8BjL%PA|N)iQVX`sYCvK4^PKC32>e`-$T2 zSgP_0vs{);BVM$W^zVJxgF8+xE__vN*>g)6cbnLu}TaUS&Z* z&^IIcF|SF4=QZm+KB<28AcXe4thPt-3)VUfYbg}%Ld^71=HTb>{NXzj$*p&vvkC#F z6K@k(A1=F0{^aqpdj**b)41*W0e(b{N-ECnP z(uLeDiPF11o{@Crn`DA=_4Xp7(DZyKHMo8}2ks%8Ou`muDKnVMe|5 z8>btplWT;fy_v7^z+RtQhPz{`%xesX08m5nrIAAD=ZZCI-ggL{V`Ebc&0R17ICj8C zz(Z2e&RE0(!gIueYgsXjj8r9a?9};JL^Z^EJ`Ll11)q?NUfDc^*761Q(FTNt|ysJ=5(aJs0PJfC^x zEsv)sNM9F$6vHMwDxy0UiZCnW$QoewY4?h7^4~piShFW{(p>hqEIloRBkH6!zbX}{ zDHF#gW7U>S1t){8D<4dTIm9B@k!DOG>uIodubl#qLF7l*4$=F!_bxQW?O_2&l|k4v zd=6^BwY0ri#RoY=tCD2}8~v1w(Jk5X-1E zZfRviZ}*c&(mNlMAC2MfX=Jjo-qvT<`_i^Lz6`frPLL6vpQ8ewl;TgUU$-Iq2oEOE zu=p|kGn9C|F%q82s>aED;X~zENWp=LpI|)L+NW4R9 z_7W;c%`M^D{LV;FND5A0@j!OWC_l>|9t5{oawWN{lEL{DXurXTv-+Bd{iAFk(d}^L2M}z*ncEY4Q`+xZqc6Zyb2DsimioK2acRW(^JsF>8!Nkfa*V|q{#Sv9g4e)NEPNoZ%Ixg?i z&6xaa+X^@B=8_cHeS73bye(1hCh~(adLPQXodbC&a1pPpA<|>lGoptV&m?IF(5}E- zAcUKA!t=Hw(oJ+5!3gK5XAlLnS-{|^5i1%e0FAd9UvKpjOMwnOLwhMwTjA{=0_NRM z)YZcstoQE*K-i%3mO^TBYtF~+BFq=MokQII*7SdQUk!a^w}YdvhpFM5fAE6s@=kLf zNELTGTCg!|=N>J;_TMv#{b4|e9PfJ=H);9+K&B!uC3K7deJ8Km=i&)c)F8Rgn0Z)1 zSs`z(MB1+f-W>3Vx8`vrgRJc5nkWduaD9q25L@?AZhH-3A{?2* z!o3QsEyw(+g##~!ea(z2JI3Ho3QgI1QG5TlY0OLa@tSCcK3pg~?7+?fquB3OYA2c> z8NERB1VQr4o^SqlO0oC(u()grSSX68+PT1RXy&_oO}4N0X2f-pZCnj_0M4a#2Ebg+gy}2*s{W%CG`B-@{~OgrAES!rPvTSJeKwUJdxX-nLm?KADjM zCAI;y1lTpY`lBo>^2t7tUqa{y)leH~WXP}iahLP(O!1#SMK@@z?MAetH9=wSkM97c zUb?$_YIG}4ybvXW9b=*)Jq?1q2vR#%p@wbEfVT7b0HkxSke*r?mq0Du;d$zN(d)mp z0-u=3_}z|Sy0?E_g&ehWPwa$nAAQEMZlPbP~w;=5ii3 z?jww=Lw?}4d<*I};|B`GUo?x}=>H>v96;43#2(8Y{@z4EkMi_x-T|Lb{Y)yAbb}-- zcfDbhl0t?H*XP?dnp7Esg-?lBPVv0!(KgV8I#e?#3%xwvTs%Mgm%Htu2g7v@r@!U73|-50sfkcafp}g6eniY2@JxHK(|v zsVwsHgKsD*tbcbRv99AkUun?N-RA80255Pu{f=vL+T4(*$OZiu=wzKXg)B7ovR!7q zr=%?<6}4$L(tMWrD$RZyfpu;)>wAl?#(;Lj!}v0>AOA`^aXm8)$EpRH<)WdL8vIyD z<+-;~EHAEl@pW@_@uT3JWRb+vHdQUe|8t8IitHYfyCp0y%w=6)YCdXTXc-m+)*xTJ z;Fh(+{R==SOFFT%Pz$81m6OcJ>gIsoa>9-j7{zLfq!+K{ede!A{)>ghTjy;=E&2qZMXI`^RRyR7FsjI5Jr7W+`hqGbNifP{0Y>gYklf$TBm|)D?IUkMxa#lW z2i+Jz7yuN6hTj*s^oge9(o?{PW%4uZ`9Htf z?6mgDxPw;~q}%5$Rh721UfuFUM3a_EiirP=LcP>`dyyOcmPxG@;Bvr8WqR2Icy#MS zxm;dAiQvc8K3ZJ!JT4%-CN> zedjx`L4%I)*l)4PJxtf9F))y3=A1-6N*B`?%}zrP0G(Ez-_bo2svQrJIYIq-L~OEwa@X1Xrm{T5;Vref0mu3#F7g|srgfj9%)WPtv7LmzrJ zxPHc7V!?7BdjX28zxH60^~71UTXNCd{<+!v@BG3J_@t!IT^pFY+_5Q5q-}OT$^)5W zVWUQ};D!X`6?=*YtD%<_RvhZW5v}vbFrpp*L~Fy~X_Q$wZD;D_`e6TZM_g8PUj;)v z4KhjHlWe=a#bslol9Nh`TF24Bbh~Rc)aaW812F=)3b~h?S;p>kgk~eTZrBQ5wHGhD z9KYdGxMV`Y*gY@_?-RzBD}e{PLmewV;J}9t1aC1+_q)blz_jL3=wSPHyvGoz(gs;N zx~t%GT#RUkaj8d_d-3w~g8gJNbgfnu;# z)y2P?M^_(iC~pe{$)6R9rx0d%_s`D2N6>vYV}k;KzSvT}Uy^8EP8c80kGwy$)LlTlZ_Eqm?2h$|oB>ooUQo$_Z4+!Y^l zpp#+PIFxaSEx1IQSB&FvI$zF`o`#1up)dj!qv0XK6Yk+J(onc}dAfA{cA33|b+LZm zC!Kl)vqirA-_M7M%{O^1RlX)2cW+wHqF{!}ob2GiKC%5JHGzv|lVqR>b%|OsQ|5RD zMT|jIRB>ak?=~N~+Ys7}jDfw-2cy4@eUI(mPjST1#hG|ho(o8J*lNhst9!mRK4;He z|My=|EcnZ|bQHQs8ymc`@tOZlaR{FA>1GEC>gXVd2ndQh?5 zX}-)6n@rkO$f^@CIKoPqppV+)`Nx2gQkLBt83((Lr_|XzN{i$EsQc%ez7X`c(_%}- zBs#D6T}xlTl;hj7aWYVaT0nh0-&XFc5{|oro$D*a{cD%9W{0aoonz!9@?O0VXRTaYlIo5)D91w5odw4S{JoAH(&!I<}l}wZP{LWX95+R z{}G}3020h}`lW6Ce@mm&i!MMeY;*KLajLEik~20XMn08oQ<*weI3mcl<0TD- zV45JNjk;^?j(%T=nHB6efBLmmP9gz7kg5FxFJj~6(|^J!f*N@8uY{6wJ>!0nFDzMn zR%Y30H>1HK74psHO_s{L)B6e43Oy!qN1Kr+W(2tVDC&Vrj6o%;x9(;J&|pjXYLrEi zMIE6rR)ayE4-EIO`(;SZUcc7I?Dw&VKlqIbXdXE7jQMf?rF!C!-%`ou-&VyJsWi$H zOL&&8WZDa(R1^kLC7&qLK8iK7vPTMQn*MTC_4?ScNREr8>&Ch+gP<#VBw1)<;j`Xw zg@gc6p{h3iOt`b&>VL7isNsF~6ioTdzebt&cRB|bE1j3pCa#9vYe>5XUMfxe zCFKg=&=~I5-8_{)S(u^LAr!P|xb_j_TmkUZO3AA0$LW9W{7m#sEuOGP`kj-UFvOfD zLez#SR8s%w#c%KsMj!fv}%n4n2om4%88?9zm4~Y0E?_TcVZHBllMizM?4oN}Ek?c9vju5)tJg5?ejjq8Dxf zC(w*1`x&?iDhg|x{Y1nySU)248T@` zoEQtCiw%Q3JZ>PHXd`?-YP6d=>SgzD4QZtn!9MMDK@n~Lb&0~;sYgEUT*09PV|H}Q z+$Kqi@9)_UHUB(*>R0ZM$DQm?B@`eAdW z@jc7NSQ!{qz|2pWw*dtP4}nyM%+98ppg{#c4W#5b1>j|L88=+ZBHS&o0;WpMKw+0alMpQo^kp6wphv8OVL6k`c66%7|2`Hx(tSCnDzyBlrwJy7# zkH<@zoYlJxG{{}O^(4{njMkf@5sPMB&Kne$nWoQR86goFw9XCEooPI8d&=Fd(0;Qa zB3Q^A3p68CLY~Myo1(=ZXThAZ3`fVodcG1m+!Z;w)7#Ns-Cd(=#3slVO8*r{(M)Ah z-fjEwBap9%Ky4~n!|cn82=SB{MViPw(y1c!z8iztsfx^wQ7cCVnL!F+7FG&gAuM64 zL_-=9@R|zD>J=(iWXzSg^3qHx9Yx^47x=!)^u9_v_Unf5t6c{v|8t{Kc}!W-8#H6? zIt)qT!;lWZ1H*2|4@v#ujpsO3B%;5lS7*FpX zq!t9{B3@2mcYV)2$aQ>C!a_{RL@I$?jB1dU zm!0pAhijTl>IydeAI!_9gC-baB0)N8H5ieM%_ zqW?sOP&PtUfT45%Q9pRlDE;n5$vg&s{8s3{MVho|q>s<#{w2}o#w zxuaG>X`PBS+9S(|b4}TQM`$Iiu(@Lpe8$aTK1b&hw<{V%Mke=%)5ZIvt6_76b~{@H zE92;f>V#St_#H{Yty++ev2Gf4@VVC#zHed4x)#0)x(L6pH-`b}oL^kR8k%KYfdRpm zOS2%hzEI`hLrEKE?x63-o_LRP z?rCU-wIGjCkXxHJ`ox~`ewBU}nKH zQA1V98=ZS0qwOisjenW66KuzNB)T>d(wAp0-iw?c(ZUps%mt9@%IBd{FW^yU@#u zmhzUCit-bg*u$8Zv5}aVp%}lwjf4K(K&QX42Cf>iS0>?~qePV7@VPx^Ye@K^y(4|~ z`%@^Xk4$bb{?rekcCV9rp7A_W&ZX^Md+}nxK3u%e$jJ$VfDycS5Q!_>3 zo|`OhO6r*f8`#bH`|+Q3MQ=fU7}}egR%KUMlM`dQ&*l0nqApxk6UhP^`I2G7!Hikz z+1sOn^%I8@hH7cY>f5>xXRkreo7C(RCMmurIjNsICHDVv+acH~pRB*+jK8bC_J`7; zzQ#}BlLJOe=^ht0Eo$@kvo8CYVLq1@_&K9i3D0Pel!sCPqEnu^`=;q_dyR+Ai`BaI zXLlWt10!ekFVWR3)8pWh`Vcy+c#qhQjN{`M$Z_1adFvC>669S0WbZp7#PWXT(y`8C zz4fpe(Jow5vFkOnJopDB!+Onl8l>TN<|&OHhwzQ24JFM!sV80F-=tZ^|NerBf-$Ta zK{JVOT)@a@uqljH@JE*KBW%#A=X8#0d zd=i+`@sAbfJoX{uz}_re3NpS{9@oqkaz;%2ZC~Y=tAx0NO*7`;P)xX26^-Y@gu~aW z`fOfI1EE=r<&6Rrwvh6iTm!{jN7p)1l@O0h>jO@!^R?aSbKmT54amZCJyFXmBbDIg zEgZ7aS1&A}H=eRy}Z1sRgv^h^9q(e4kxaTN-)@X%`I3m2M|#)fKiFF}zLemNToxq?_&(*-OvTx=vhGgK5Nxl;Iy*z%}ykVl6> zogpi7X&y7$HrqCOn`y%zvPt+D{m0GEV`pwq0>JHnZL`2~HDF_f{5Q<>414p-Jt6WD z?A>|h+Ie_4@*a*2I+m{tx!fRzej4p&Pk8>dD!Uqbw~$50E>M~|uJ+JpSDZ$kAZy`O zmRw$|`brm*UGA%iC@sIEtISIKKzjY{fhOI>g2olK$C~P2{#Fvl;HL3Dhv1r=zyvG%j>Z@V{e)rSe3+B zTT@QJr>-*GXe<7V%&g^vgP+<=5R0A5bJ1*Vu>iT{n?w-J{f^8YF8g^9O0pU^vC}H~ zb7L=Qv$@ket>N+12(><(f7(X6(lhdmUzWIQA}E&mAU^Mtgf_O?)&c3dPGlp!Vqe}n z1*n&cz>5ikjc#?r?4c|ZtqqsxPk{DA97b)tp>gp^~=&hA}z`jFdZm_(c25LvC zm9^Me_Wv33PKgNG2(B3A1fMM*zw)Sfk~O15NqeP*yhC}+XVm9k)w3qie`Q135u$_( z%>m^b!f>-j(4J5^r|rUha;FAhEiHD57dxgR403IRo=@Xmfb_v7RUsgY?KBxd%iOU5 z)ejb3+I4PYj$e>gBXQ2!af0Eab?tkZTHG`4wbDhVh}Cb7M_+w+IyIYes$7mWT0lG-;wSku4U(|8Cois;K zr+O?}_g%0%*O3s?c0^9?dV2XV?khx!)!xyHk01%%O9^&b0~h<`$Q~ns*z@EDfsq$A zC)bzz#ws~<-q2Vl*5)wL7LUSzNon7D-rdwCWmmm$Q}u8KvpKNzi z_hL711?L=34s774k>-?6h(l_vBwDCREC%^iPO>Hoy9ImZN;6o#5|)Q4Yaz(Ac49%Z zgHBXQxY(r3@v%cZ%zrdEG>N(%6lI3JOjE`a+Z>UKGpcp-_Up> z-+P<$Wv`6-u68z=hDQCx8Ua-{XQcu%wjT!6VP4R_?w)B#k4zSN#4u+01Lr$!f8jnW4|TvX2eK>OFHrV6g# zaU^sI^(!u9BXLf|D5~IXuW+o<4p90UGc73*n~Ik)%gHl$P~QL zjuR0489_`({M^c}njA9Xcp%Lw58egvz;|~!a+Z|Sw{$Z%1FjFQh?$D}+nhXjiAaQc zGeRG{>iOK#((hlPPa#o~A7+*!=`?T#OLe+udrhC8(#=I=wO6agcBDergGD=%!e3|$ z5y243=ghMew9TNVbU=AG9euvXuMkY+vKA6@~{ zLT`UuaNV#Mw?Wchj;?o!#VScG;G7yMH3Jq#Jz$KCep028eMK z!UN5kr-ReC&^_;M*TFjI$|3W>HaFvu(ob>B?Nux+4F{TN3;4rn#R}%61jx6}TZc2V zO47Lw3Ytu#&6&^Hwis3ZvnoWUrO^vHFvyOVh1OFufe7$e>ipq45WAEwMBu3QD9pjH zs->(RW?)#Fr3(Ag73>^HsAr>fjc@&|ywpPAV`Q}okcBis(+OrP@{%al6ahaF>BSat z^_xNnzqyZQ4>@3yO4+-w>`}BJq!W4}ts!=|+vRr3?1H7yzW6W{F!sFtZ6oY5B-Hz#-s>A|8`qLm(EtOeDwaa>+YhsNN25Lqh!ww zK8NrRV66xSy1xnEDJL0xtnHjXW#Ha#sQ;QPM3{e>{%i2#Bx)$;wU~`pYF7Fw%XtY? zY#y%-C`5$`Hjhb*+rr-Tto)ik4EA-LR>xiTgGUGMOYnM8a|)f z8GIUeg!rA$LT6SzlArK_U+rRBclMqMv0X$BrIi^VWO`x(NdwLDX70yy?H)Rh*3C#M zT@R2(%J1HB?Yd{0InNXKZ?AGhyRU8HHlwkNYSpmrVKwLFoSy7TnEXEfDMV#9NJh4S z`ybqy(k(E2Cc+v|@&p`entC*AZm)S=m6JQqD;gnJ40#@H3cSqXx=!9T^c{|NfgK1% zW_^-(C2=KnRffo?bRh;ef^f@_+<)NAJKu=gQIx~*{Z{LAfrjTdx1AyQt1oNaUz|$Y1ijSVIArQa{e@) zSfh7tJdJd@vw6=?ZIXo;)DeHwX<0K8ytWb;LPO!ANM%yBudkW;Nj@;%)9jvfEh@+Z zp{%A=J43qs(-Fo7bqT%t0H_+~N1N-OzA_=xU9+SZP3`14%6}au zm$!)IRY6Q&;PXVaQ?0VRq1Zc;-1!AntaXVdoy0S3uh~5uW z-0*AUxbSHiw3i%bcI1!3Q6pHOsjRgdm{`;{7H8GBO`iCTkF`-&;7xQuN4Gd7p}ub7 zcWnARcQq1J&JgB-XX{L7(-FBZ-5i8n$H+07d#-5P#khK`Uex3xW+u(Lb&Im)j`Bs! zCgCRgCUzJzpabs;=!Kp`IW#@dzy46o=GQIE4AO}o7*(#3y39NIJRHr zs3TMQ?+0y_G$_PNx!UviEk9pbA%s-m^(F?R4j1Rv2{FC8x~z{b%|kRND}3%O2O@B``Q zZOrN=rEA8+J8aiu|DMyB-87HTkCaZM1LMjyxDIk-AB<8e7YgZ0m#XPP zof$*Xp%o|Pb37A1Z#4X$d!ZhX&%p}Vj2G`gygWGo=07#6FH|_Lqpjxu1-*!_=T6tf zJ0$7ecSk!I^ZG;xYL<6=^7G^`SYamli}*ebg8Z}kai+5_kCo&(agQG^D7X&Me=>=> zz|YX%m29ufO`)a;P~?C4LMVsS*bC6}Pv7Dr;Dgy;+K6%v;p8!gT|?Dn1Jo?6&xuS- ztEhMVT?-Tcu*ME$`()_0uq-1lChD@(`ilmpmDfL0#g;pZYJf(c~(mwZaS8Z zsb`+ll3frf^Y_?;<09RF_6zh7tT!vTptskYZc(2Ms0n9YT|4fY=)4au?;QG&bDmT4 zG-&&ZrJl-__Z`o`kPQ|~rt9n$solccr5H;~-GvSYUD5fVb~Cz*y)e4Pt>>8`Mp2)q zi-HK?)DoC_k#krMvV8=-vxWL8#+Ph}6gu@Tol7Ex5j2iAVblVuVD-oR<1vmD-)2YF zY-WXTZ_1w?Ct_m?&ygq2g)Ky-g&h?XGc$xSX)u^+AJRqDV0%6T93_$NjNJur%d31{ zmqY&R0FB;t{u+>H5co|JrZ$~}Ah(L2DBh6hKgjv;2nMYNNpd#P!t)1NAhYphh_=*4 zSk*Av1HS_f8qiAz&HNG^PKPF*TM!m!f}q~ly3k9h)bWBt*B2X0lNam`&)>tx{XKDU9Yc2k3^A!0lVRFH zD@gseOQc@VIEdVePC}*C*SW6@3tOSLS-+}lylad^KmXHE<`l*Euv+^IcN@!)D5zP) zM)}j}_`BA*gQXQQp1^xzyqqehIP1?9HDn6}Ou@ z5gvNF!9zh{w{PDMC~!-vR?(J<;byMXzqx~N_&Fs2F3P`Oe!_X-t1n!k>0m!xI5O;@ zFHhI=AQWD{ts|Xq&h%!-kN0$yg=FkMpMylM>oIsm(8iygsbi5p4x9c<%Mb>P`B7dYKD@fRnHC}iqoRH+*yZe|X;TEYWt4l!%OB48LolRh#TLIUI z5R+=?>K}`D9s7FxZ7c<;!{D1UfMdx!P@XqNx+ zTB`Fem2iB773I|NOrWZ4*d83}5uxmy?v`yBWslJ>js1g-Et@&lqngpH#S0 zq9~_W&t;fjjD^-(~90odDhh1gy-6`^q z^+>>I&JX;Vza`fb(@ttTVEa3$k0(sM8%RUZ-gN;LMGT!o!&>>I(9{oAEN)0wW8#s; zJjHj?U=f_N$9NnG3m zKhbRGnq{~SRa&P;ZU(_tTEL{=H!S≺Fg5FEI6*GS3JIpz}S^FWX312^i@e}Wwi1G8& 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/castopod/compose.yml b/configs/castopod/compose.yml new file mode 100644 index 0000000..af26d1f --- /dev/null +++ b/configs/castopod/compose.yml @@ -0,0 +1,81 @@ +# koopa-castopod — Castopod behind host Caddy on :9020 +# Docs: https://docs.castopod.org/main/en/getting-started/docker/ +services: + castopod: + image: docker.io/castopod/castopod:1 + container_name: koopa-castopod + volumes: + - castopod-media:/app/public/media + # optional branding host dir (logo + CSS overlay); safe if empty + - ./branding:/branding:ro + environment: + MYSQL_DATABASE: castopod + MYSQL_USER: castopod + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + CP_BASEURL: ${CP_BASEURL} + CP_ANALYTICS_SALT: ${CP_ANALYTICS_SALT} + CP_CACHE_HANDLER: redis + CP_REDIS_HOST: redis + CP_REDIS_PASSWORD: ${MYSQL_PASSWORD} + networks: + - castopod-app + - castopod-db + ports: + - "9020:8080" + labels: + org.hacktivism.service: castopod + org.hacktivism.host_port: "9020" + org.hacktivism.site: castopod.hacktivism.ch + org.hacktivism.managed_by: koopa-admin + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_started + + mariadb: + image: docker.io/library/mariadb:11 + container_name: koopa-castopod-mariadb + networks: + - castopod-db + volumes: + - castopod-db:/var/lib/mysql + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: castopod + MYSQL_USER: castopod + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + restart: unless-stopped + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + start_period: 20s + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: docker.io/library/redis:7-alpine + container_name: koopa-castopod-redis + command: --requirepass ${MYSQL_PASSWORD} + volumes: + - castopod-cache:/data + networks: + - castopod-app + restart: unless-stopped + +volumes: + castopod-media: + castopod-db: + castopod-cache: + +networks: + castopod-app: + castopod-db: + internal: true diff --git a/configs/castopod/container-koopa-castopod-mariadb.service b/configs/castopod/container-koopa-castopod-mariadb.service new file mode 100644 index 0000000..c3c2324 --- /dev/null +++ b/configs/castopod/container-koopa-castopod-mariadb.service @@ -0,0 +1,21 @@ +# user systemd — MariaDB for Castopod +[Unit] +Description=Castopod MariaDB (koopa-castopod-mariadb) +Wants=network-online.target +After=network-online.target +RequiresMountsFor=%t/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=on-failure +RestartSec=15 +TimeoutStartSec=300 +TimeoutStopSec=70 +WorkingDirectory=/home/hernani/koopa-castopod +ExecStart=/usr/bin/podman-compose up -d mariadb +ExecStop=/usr/bin/podman stop -t 30 koopa-castopod-mariadb +Type=oneshot +RemainAfterExit=yes + +[Install] +WantedBy=default.target diff --git a/configs/castopod/container-koopa-castopod-redis.service b/configs/castopod/container-koopa-castopod-redis.service new file mode 100644 index 0000000..0923000 --- /dev/null +++ b/configs/castopod/container-koopa-castopod-redis.service @@ -0,0 +1,21 @@ +# user systemd — Redis for Castopod +[Unit] +Description=Castopod Redis (koopa-castopod-redis) +Wants=network-online.target +After=network-online.target +RequiresMountsFor=%t/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=on-failure +RestartSec=15 +TimeoutStartSec=180 +TimeoutStopSec=40 +WorkingDirectory=/home/hernani/koopa-castopod +ExecStart=/usr/bin/podman-compose up -d redis +ExecStop=/usr/bin/podman stop -t 15 koopa-castopod-redis +Type=oneshot +RemainAfterExit=yes + +[Install] +WantedBy=default.target diff --git a/configs/castopod/container-koopa-castopod.service b/configs/castopod/container-koopa-castopod.service new file mode 100644 index 0000000..5f28649 --- /dev/null +++ b/configs/castopod/container-koopa-castopod.service @@ -0,0 +1,21 @@ +# user systemd — Castopod web (depends on MariaDB + Redis) +[Unit] +Description=Castopod web (koopa-castopod) +Wants=network-online.target container-koopa-castopod-mariadb.service container-koopa-castopod-redis.service +After=network-online.target container-koopa-castopod-mariadb.service container-koopa-castopod-redis.service +RequiresMountsFor=%t/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=on-failure +RestartSec=20 +TimeoutStartSec=300 +TimeoutStopSec=120 +WorkingDirectory=/home/hernani/koopa-castopod +ExecStart=/usr/bin/podman-compose up -d castopod +ExecStop=/usr/bin/podman stop -t 30 koopa-castopod +Type=oneshot +RemainAfterExit=yes + +[Install] +WantedBy=default.target diff --git a/configs/exchange-landing/README.md b/configs/exchange-landing/README.md new file mode 100644 index 0000000..18357b2 --- /dev/null +++ b/configs/exchange-landing/README.md @@ -0,0 +1,23 @@ +# Exchange landing (`exchange.hacktivism.ch` intro) + +Static intro (stats + links) for the **GOA exchange**. Withdraw QR removed (was wrong). + +| Item | Value | +|------|--------| +| Container | `taler-hacktivism-exchange-ansible` | +| In-container path | `/var/www/exchange-landing/` | +| nginx | listen **9014** (`nginx-landing.conf`) | +| **Host port to publish / wire** | **`9014`** → `127.0.0.1:9014` | +| Public path (once Caddy wired) | e.g. `https://exchange.hacktivism.ch/intro/` *or* dedicated intro host | +| Link previews | Open Graph / Twitter meta → `og-goa-shop.png` (1200×630 GOA shop grid) | + +## Deploy + +```bash +# from laptop → koopa +scp -r configs/exchange-landing/* koopa:/tmp/exchange-landing/ +# on koopa: install nginx if missing, copy files, enable site, publish port +# ensure og-goa-shop.png is under /var/www/exchange-landing/ (served as /intro/og-goa-shop.png) +``` + +See `scripts/taler-landing/deploy-landings.sh`. diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html new file mode 100644 index 0000000..b3f0ba9 --- /dev/null +++ b/configs/exchange-landing/index.html @@ -0,0 +1,603 @@ + + + + + + GOA Exchange + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ GOA · Exchange +

GOA Exchange

+

Explorational exchange — coins, denoms, one-step withdraw.

+
+ +
+

Coins

+

From exchange DB · coin lifecycle

+
+
Known coins
+
Live (remaining)
+
Spent / zero
+
Remaining amt
+
Withdraw ops
+
Withdraw amt
+
Refresh
+
Coin deposits
+
Batch deposits
+
Recoup
+
Refunds
+
History events
+
+ +
+ +
+

Denominations

+

Signed keys in DB · public ladder on /keys

+
+
Denom keys
+
Unique values
+
Withdrawable now
+
+
+

Value ladder

+
+
+
+ +
+

Reserves · wire

+

Backing money movement

+
+
Reserves
+
Wire in
+
Wire in amt
+
Wire out
+
+

Loading…

+ +
+ +
+

Performance

+

Live HTTP probes + container memory (RSS)

+
+
/keys
+
/config
+
Loadavg
+
Container
+
PostgreSQL
+
Taler procs
+
Nginx
+
+
+

Top processes (RSS)

+
+
+

Memory from /proc + cgroup inside podman container.

+
+ +
+ + +
+ +
+

Withdraw GOA

+

Use the bank landing or wallet to add the exchange and withdraw.

+ +
+ + +
+ + + + + + diff --git a/configs/exchange-landing/nginx-landing.conf b/configs/exchange-landing/nginx-landing.conf new file mode 100644 index 0000000..3c55d47 --- /dev/null +++ b/configs/exchange-landing/nginx-landing.conf @@ -0,0 +1,24 @@ +# Exchange landing — :9014 (behind Caddy — no port in Location) +server { + listen 9014; + listen [::]:9014; + server_name exchange.hacktivism.ch _; + root /var/www/exchange-landing; + index index.html; + + absolute_redirect off; + port_in_redirect off; + + location = / { + return 302 /intro/; + } + location = /intro { + return 302 /intro/; + } + location /intro/ { + alias /var/www/exchange-landing/; + } + location / { + try_files $uri $uri/ =404; + } +} diff --git a/configs/exchange-landing/og-goa-shop.png b/configs/exchange-landing/og-goa-shop.png new file mode 100644 index 0000000000000000000000000000000000000000..aeaad870078fe2e89faa409718d3c3430aa112ba GIT binary patch literal 557173 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
7VAoh{5UoAiYM9&Ly$g4 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
I|8CJr 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

969O}CVy_Ydn!e9OM_wfAc8Q%NS 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

gzxgCdSd*5D& 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�kcEyv=#;$;UV{ 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%0DS6J_`m1kZ}5vhY>FQ9$R+*-VyQ7DDc1-6y>*Q; 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

qHPVWR)mld=^&xi zZ8JzytkooGM%?N$KevSTpouY>t(~6B@+f1B&R9NhfGpGKEW=8d@v+@mL`0G_)x@oB zdOO<$X;d3StJPtC;SdJsrwK+Fq;i1UAkDCepcT*4i4}Bo9?V;7?E@PNqBtTm8m%?+ z^9u;mbqq`aX__L1Bv#4?u?{hF3NBe%yRbtT57Oh`U0J~^D$BisX8nzE`o(@ zFd&Kqnb9BxhgXl1B`Hak(QdUEBneVVRu3Nb!*8%&QSHpPkwTCpjse~4_faZl`M^<^ zb$3T&B}A`KMM`Jd zNsUFdyBu9wB}r3cB#>6nPZI`dhEy>^NPm6A+`=-8%gc^2m1Rywiy+N3bMwnsu>e}Q zwISym=<9(!Z48~oB_h*8TZ47!YOq4!qR$D0bV;{j;m*HITTm_9%Lfp`XGyit8}#Y6 zVp5&b>9mniE6;2ztU_m+cBf0+X``fa>vE8g*o?)M1FXg>Uw=vJ=HBAc3LqE^`m{Rp z9GRQL_!xy+dv6daTpU0xNixIgfg?zzFxGhBs!)<$7z0u0BJc2|X`T-vuVJA(gwLyF zg+iArzK8wQUP4uXUOPCe5Pj?t8e6DMKWUzI|_Dhi0$tG%T^vSk7+B zC>*1%OOMp^DX*R)ovn~QmSTv_Bb<5M_~ZrF+F`R~$fA%pPe2mOCZYUW%18FD=jDhx zmnXV0Rq(2)UkH}@U`hcL*4^^_xYt;7&2F9J`?dVDXzyfq#3DmS?ejhC5A}FkqXd{L zz+YB={8F^!6)Un1&Q_QG+VtF-kVvVVuw`A)a;-CGl}{6ta`8cgP+mB7g1(TJRHr@@ zp0hF=qw^q$gT%YIDHm%+Dkm5j>n#4lMh?i*7L#QdG?BLm8sn_u#z+P`DN4Hdds?SN zLL!a944l7%0fbZx5(659h>+4f!=x##NMN*PU@h86l<}5wB`~;*6%rh9VGXJkG0+)A zEntDAl|!`SdA8Pf=(LtGsRQ%`f;1V>YCEP}l4d@wv$OhH3|4D5=0c$℞q{+;*(i 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 ~L0HX}AQ#%0EW@T5eJMEb@Okbe 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 Vy;2o~7%Tt)002ovPDHLkV1j3=oB{v< literal 0 HcmV?d00001 diff --git a/configs/exchange-landing/qr-logo.png b/configs/exchange-landing/qr-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aeffa23d3ec9d66f1041cc50d28cd2037e0e4e56 GIT binary patch literal 8563 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 tCI$W<00960e6}m<00006Nkl \ No newline at end of file diff --git a/configs/firewalld/public-ports.md b/configs/firewalld/public-ports.md new file mode 100644 index 0000000..12ea2b2 --- /dev/null +++ b/configs/firewalld/public-ports.md @@ -0,0 +1,61 @@ +# firewalld zone public (eno1) + +## Current (2026-07-10, verified) + +Expected open ports after Forgejo git-SSH: + +``` +sudo firewall-cmd --list-ports +# … 80/tcp 443/tcp 8080/tcp 9000/tcp 9001/tcp 23235/tcp 9200/tcp +``` + +| Port | Purpose | LAN probe (2026-07-10) | +|------|---------|------------------------| +| 80/tcp | `http-proxy` → Caddy 9000 (IPv6 HTTP/ACME) | via Caddy path | +| **443/tcp** | `https-proxy` → Caddy 9001 (IPv6 HTTPS) | via Caddy path | +| 9000/tcp | Caddy HTTP (WAN:80 → 9000) | **OK** | +| 9001/tcp | Caddy HTTPS (WAN:443 → 9001) | **OK** | +| 8080/tcp | Tor OR | — | +| 23235/tcp | (legacy list; host SSH is :22, WAN via VeciGate 23235→22) | — | +| **9200/tcp** | **Forgejo git-SSH** (podman rootlessport) | **OK** after add+reload | + +### Not opened on purpose + +| Port | Note | +|------|------| +| 9020–9024 | rootless apps; public HTTP only via Caddy → `127.0.0.1:90xx` | +| 9024 | Forgejo HTTP internal to Caddy only | + +LAN `nc` to 9021/9024 → **connection refused** is expected without firewalld open (and not required). + +### Commands + +```bash +sudo firewall-cmd --permanent --add-port=9200/tcp +sudo firewall-cmd --reload # or systemctl restart firewalld +sudo firewall-cmd --list-ports +``` + +### Verify (from another host on LAN / WAN) + +```bash +nc -vz 192.168.100.95 9200 +nc -vz 212.51.151.254 9200 # hairpin / public A +ssh -p 9200 -T git@git.hacktivism.ch +# expect: Hi there, … authenticated … Forgejo does not provide shell access. +``` + +### Why 443 + +IPv4 HTTPS is DNAT’d to **9001**. IPv6 has **no DNAT** — clients hit host **:443** directly. + +### Why 9200 + +Forgejo advertises `SSH_PORT=9200`. Traffic hits **host:9200** (not Caddy). Without firewalld **9200/tcp**, clients get **connection refused** even while `ss` shows LISTEN and localhost works. + +## History + +| When | Change | +|------|--------| +| 2026-07-09 | 443/tcp re-added for IPv6 | +| 2026-07-10 | **9200/tcp** for Forgejo git-SSH (verified LAN + hairpin + SSH auth) | diff --git a/configs/forgejo/README.md b/configs/forgejo/README.md new file mode 100644 index 0000000..a5d9a6a --- /dev/null +++ b/configs/forgejo/README.md @@ -0,0 +1,60 @@ +# Forgejo (git.hacktivism.ch) + +Rootless stack on koopa: `~/koopa-forgejo/` (podman user hernani). +Site: https://git.hacktivism.ch — HTTP `:9024`, git-SSH `:9200`. + +## Branding theme: `hacktivism` (exchange-dark) + +Global default and intended look match **exchange.hacktivism.ch**: + +- Warm dark body (`#1a1410`), cream text (`#fff6e8`) +- Gold primary (`#e8a838` / `#f0c86a`), teal accents (`#3ecfbf`) +- Readable contrast on body, menus, code, forms, labels, messages + +### Files (this repo) + +| Path | Role | +|------|------| +| `assets/css/theme-hacktivism.css` | Full dark theme (based on forgejo-dark vars + exchange palette) | +| `assets/img/logo.svg` / `logo.png` / `favicon.svg` | Kamek-inspired logo (blue robe, flying on wand); not Nintendo art | +| `compose.yml` | Rootless compose + `FORGEJO__ui__DEFAULT_THEME=hacktivism` | +| `app.ini.example` | Config sketch | + +### Live paths on koopa + +``` +~/koopa-forgejo/data/custom/public/assets/css/theme-hacktivism.css +~/koopa-forgejo/data/custom/public/assets/img/{logo.svg,logo.png,favicon.svg} +~/koopa-forgejo/data/custom/conf/app.ini # [ui] DEFAULT_THEME = hacktivism +``` + +Compose also sets `FORGEJO__ui__DEFAULT_THEME=hacktivism`. + +### Deploy assets (do not wipe git data) + +```bash +install -m 644 configs/forgejo/assets/css/theme-hacktivism.css \ + ~/koopa-forgejo/data/custom/public/assets/css/ +install -m 644 configs/forgejo/assets/img/* \ + ~/koopa-forgejo/data/custom/public/assets/img/ +# optional mirror if present: +# cp same under data/gitea/custom/public/assets/... +``` + +**Never** remove `~/koopa-forgejo/data/git/` — repositories live there (`hernani/koopa-admin-log`, …). + +### Logged-in users + +`DEFAULT_THEME` only applies to new sessions / users without a preference. +Existing accounts store `user.theme` in Postgres. Force all users: + +```bash +podman exec koopa-forgejo-db psql -U forgejo -d forgejo \ + -c "UPDATE \"user\" SET theme = 'hacktivism';" +``` + +Or: Profile → Appearance → theme **hacktivism**, then hard-reload. + +### Registration + +Public signup is disabled (`DISABLE_REGISTRATION`, no registration button). diff --git a/configs/forgejo/app.ini.example b/configs/forgejo/app.ini.example new file mode 100644 index 0000000..4758efe --- /dev/null +++ b/configs/forgejo/app.ini.example @@ -0,0 +1,79 @@ +APP_NAME = hacktivism git +RUN_USER = git +RUN_MODE = prod +WORK_PATH = /var/lib/gitea + +[repository] +ROOT = /var/lib/gitea/git/repositories +ENABLE_PUSH_CREATE_USER = true +ENABLE_PUSH_CREATE_ORG = true +DEFAULT_PRIVATE = public + +[repository.local] +LOCAL_COPY_PATH = /tmp/gitea/local-repo + +[repository.upload] +TEMP_PATH = /tmp/gitea/uploads + +[server] +APP_DATA_PATH = /var/lib/gitea +SSH_DOMAIN = git.hacktivism.ch +HTTP_PORT = 3000 +ROOT_URL = https://git.hacktivism.ch/ +DISABLE_SSH = false +; In rootless gitea container only internal ssh server is supported +START_SSH_SERVER = true +SSH_PORT = 9200 +SSH_LISTEN_PORT = 2222 +BUILTIN_SSH_SERVER_USER = git +LFS_START_SERVER = +DOMAIN = git.hacktivism.ch +LANDING_PAGE = explore + +[database] +PATH = /var/lib/gitea/data/gitea.db +DB_TYPE = postgres +HOST = db:5432 +NAME = forgejo +USER = forgejo +PASSWD = @inline-secret@ + +[session] +PROVIDER_CONFIG = /var/lib/gitea/data/sessions +PROVIDER = memory + +[picture] +AVATAR_UPLOAD_PATH = /var/lib/gitea/data/avatars +REPOSITORY_AVATAR_UPLOAD_PATH = /var/lib/gitea/data/repo-avatars + +[attachment] +PATH = /var/lib/gitea/data/attachments + +[log] +ROOT_PATH = /var/lib/gitea/data/log +LEVEL = Info + +[security] +INSTALL_LOCK = true +SECRET_KEY = @inline-secret@ +REVERSE_PROXY_LIMIT = 1 +REVERSE_PROXY_TRUSTED_PROXIES = * +INTERNAL_TOKEN = @inline-secret@ + +[service] +DISABLE_REGISTRATION = true +REQUIRE_SIGNIN_VIEW = false +SHOW_REGISTRATION_BUTTON = false +DEFAULT_ALLOW_CREATE_ORGANIZATION = true +ALLOW_ONLY_EXTERNAL_REGISTRATION = false + +[lfs] +PATH = /var/lib/gitea/git/lfs + +[openid] +ENABLE_OPENID_SIGNUP = false +ENABLE_OPENID_SIGNIN = false + +[oauth2] +JWT_SECRET = @inline-secret@ + diff --git a/configs/forgejo/assets/css/theme-hacktivism.css b/configs/forgejo/assets/css/theme-hacktivism.css new file mode 100644 index 0000000..1e32fea --- /dev/null +++ b/configs/forgejo/assets/css/theme-hacktivism.css @@ -0,0 +1,275 @@ +.chroma .bp{color:#fabd2f}.chroma .c,.chroma .c1,.chroma .ch,.chroma .cm{color:#777e94}.chroma .cp{color:#8ec07c}.chroma .cpf{color:#649bc4}.chroma .cs{color:#9075cd}.chroma .dl{color:#649bc4}.chroma .gd{color:#fff;background-color:#5f3737}.chroma .ge{color:#ddee30}.chroma .gh{color:#ffaa10}.chroma .gi{color:#fff;background-color:#3a523a}.chroma .go{color:#777e94}.chroma .gp{color:#ebdbb2}.chroma .gr{color:#f43}.chroma .gs{color:#ebdbb2}.chroma .gt{color:#ff7540}.chroma .gu{color:#b8bb26}.chroma .il{color:#649bc4}.chroma .k{color:#ff7540}.chroma .kc{color:#649bc4}.chroma .kd{color:#ff7540}.chroma .kn{color:#ffaa10}.chroma .kp{color:#5f8700}.chroma .kr{color:#ff7540}.chroma .kt{color:#ff7b72}.chroma .m,.chroma .mb,.chroma .mf,.chroma .mh,.chroma .mi,.chroma .mo{color:#649bc4}.chroma .n{color:#c9d1d9}.chroma .na,.chroma .nb{color:#fabd2f}.chroma .nc{color:#ffaa10}.chroma .nd{color:#8ec07c}.chroma .ne{color:#ff7540}.chroma .nf,.chroma .ni{color:#fabd2f}.chroma .nl{color:#ff7540}.chroma .nn{color:#c9d1d9}.chroma .no{color:#649bc4}.chroma .nt{color:#ff7540}.chroma .nv{color:#ebdbb2}.chroma .nx{color:#b6bac5}.chroma .o{color:#ff7540}.chroma .ow{color:#5f8700}.chroma .p{color:#d2d4db}.chroma .s,.chroma .s1,.chroma .s2{color:#b8bb26}.chroma .sa{color:#ffaa10}.chroma .sb{color:#b8bb26}.chroma .sc{color:#ffaa10}.chroma .sd{color:#b8bb26}.chroma .se{color:#ff8540}.chroma .sh{color:#b8bb26}.chroma .si{color:#ffaa10}.chroma .sr{color:#9075cd}.chroma .ss{color:#ff8540}.chroma .sx{color:#ffaa10}.chroma .vc,.chroma .vg,.chroma .vi{color:#649bee}.chroma .w{color:#7f8699}.CodeMirror.cm-s-default .cm-property,.CodeMirror.cm-s-paper .cm-property{color:#a0cc75}.CodeMirror.cm-s-default .cm-header,.CodeMirror.cm-s-paper .cm-header{color:#9daccc}.CodeMirror.cm-s-default .cm-quote,.CodeMirror.cm-s-paper .cm-quote{color:#090}.CodeMirror.cm-s-default .cm-keyword,.CodeMirror.cm-s-paper .cm-keyword{color:#cc8a61}.CodeMirror.cm-s-default .cm-atom,.CodeMirror.cm-s-paper .cm-atom{color:#ef5e77}.CodeMirror.cm-s-default .cm-number,.CodeMirror.cm-s-paper .cm-number{color:#ff5656}.CodeMirror.cm-s-default .cm-def,.CodeMirror.cm-s-paper .cm-def{color:#e4e4e4}.CodeMirror.cm-s-default .cm-variable-2,.CodeMirror.cm-s-paper .cm-variable-2{color:#00bdbf}.CodeMirror.cm-s-default .cm-variable-3,.CodeMirror.cm-s-paper .cm-variable-3{color:#085}.CodeMirror.cm-s-default .cm-comment,.CodeMirror.cm-s-paper .cm-comment{color:#8e9ab3}.CodeMirror.cm-s-default .cm-string,.CodeMirror.cm-s-paper .cm-string{color:#a77272}.CodeMirror.cm-s-default .cm-string-2,.CodeMirror.cm-s-paper .cm-string-2{color:#f50}.CodeMirror.cm-s-default .cm-meta,.CodeMirror.cm-s-paper .cm-meta,.CodeMirror.cm-s-default .cm-qualifier,.CodeMirror.cm-s-paper .cm-qualifier{color:#ffb176}.CodeMirror.cm-s-default .cm-builtin,.CodeMirror.cm-s-paper .cm-builtin{color:#b7c951}.CodeMirror.cm-s-default .cm-bracket,.CodeMirror.cm-s-paper .cm-bracket{color:#997}.CodeMirror.cm-s-default .cm-tag,.CodeMirror.cm-s-paper .cm-tag{color:#f1d273}.CodeMirror.cm-s-default .cm-attribute,.CodeMirror.cm-s-paper .cm-attribute{color:#bfcc70}.CodeMirror.cm-s-default .cm-hr,.CodeMirror.cm-s-paper .cm-hr{color:#999}.CodeMirror.cm-s-default .cm-url,.CodeMirror.cm-s-paper .cm-url{color:#c5cfd0}.CodeMirror.cm-s-default .cm-link,.CodeMirror.cm-s-paper .cm-link{color:#d8c792}.CodeMirror.cm-s-default .cm-error,.CodeMirror.cm-s-paper .cm-error{color:#dbdbeb}.markup [src$="#gh-light-mode-only"],.markup [src$="#light-mode-only"],.markup [href$="#gh-light-mode-only"],.markup [href$="#light-mode-only"]{display:none}.markup [src$="#gh-dark-mode-only"],.markup [src$="#dark-mode-only"],.markup [href$="#gh-dark-mode-only"],.markup [href$="#dark-mode-only"]{display:unset}:root{--steel-900: #0e0c0a;--steel-850: #14110e;--steel-800: #1a1410;--steel-750: #221c16;--steel-700: #2a2018;--steel-650: #332820;--steel-600: #3d3128;--steel-550: #4a3c30;--steel-500: #5c4d3e;--steel-450: #6e5d4c;--steel-400: #84705c;--steel-350: #9a8570;--steel-300: #b09a84;--steel-250: #c9b8a0;--steel-200: #dccbb4;--steel-150: #ebe0d0;--steel-100: #fff6e8;--is-dark-theme: true;--color-primary: #e8a838;--color-primary-contrast: #1a1410;--color-primary-dark-1: #d4922a;--color-primary-dark-2: #c47a18;--color-primary-dark-3: #a86512;--color-primary-dark-4: #8a5210;--color-primary-dark-5: #6b400c;--color-primary-dark-6: #4d2e08;--color-primary-dark-7: #2e1b05;--color-primary-light-1: #f0c86a;--color-primary-light-2: #f5d68a;--color-primary-light-3: #f8e2a8;--color-primary-light-4: #fbecc4;--color-primary-light-5: #fdf3d8;--color-primary-light-6: #fef8e8;--color-primary-light-7: #fffdf5;--color-primary-alpha-10: #e8a83819;--color-primary-alpha-20: #e8a83833;--color-primary-alpha-30: #e8a8384b;--color-primary-alpha-40: #e8a83866;--color-primary-alpha-50: #e8a83880;--color-primary-alpha-60: #e8a83899;--color-primary-alpha-70: #e8a838b3;--color-primary-alpha-80: #e8a838cc;--color-primary-alpha-90: #e8a838e1;--color-primary-hover: var(--color-primary-dark-1);--color-primary-active: var(--color-primary-dark-3);--color-secondary: #3d3128;--color-secondary-dark-1: #4a3c30;--color-secondary-dark-2: #5c4d3e;--color-secondary-dark-3: #6e5d4c;--color-secondary-dark-4: #84705c;--color-secondary-dark-5: #9a8570;--color-secondary-dark-6: #b09a84;--color-secondary-dark-7: #c9b8a0;--color-secondary-dark-8: #dccbb4;--color-secondary-dark-9: #ebe0d0;--color-secondary-dark-10: #fff6e8;--color-secondary-dark-11: #fff6e8;--color-secondary-dark-12: #ffffff;--color-secondary-dark-13: #ffffff;--color-secondary-light-1: #332820;--color-secondary-light-2: #2a2018;--color-secondary-light-3: #221c16;--color-secondary-light-4: #1a1410;--color-secondary-alpha-10: #3d312819;--color-secondary-alpha-20: #3d312833;--color-secondary-alpha-30: #3d31284b;--color-secondary-alpha-40: #3d312866;--color-secondary-alpha-50: #3d312880;--color-secondary-alpha-60: #3d312899;--color-secondary-alpha-70: #3d3128b3;--color-secondary-alpha-80: #3d3128cc;--color-secondary-alpha-90: #3d3128e1;--color-secondary-hover: var(--color-secondary-dark-1);--color-secondary-active: var(--color-secondary-dark-2);--color-console-fg: #fff6e8;--color-console-fg-subtle: #c9b8a0;--color-console-bg: #14110e;--color-console-border: #3d3128;--color-console-hover-bg: #ffffff16;--color-console-active-bg: #454a57;--color-console-menu-bg: #383c47;--color-console-menu-border: #5c6374;--color-red: #b91c1c;--color-orange: #e8a838;--color-yellow: #f0d090;--color-olive: #91a313;--color-green: #15803d;--color-teal: #3ecfbf;--color-blue: #2563eb;--color-violet: #7c3aed;--color-purple: #9333ea;--color-pink: #db2777;--color-brown: #a47252;--color-grey: var(--steel-500);--color-black: #111827;--color-red-light: #dc2626;--color-orange-light: #f0c86a;--color-yellow-light: #f8e2a8;--color-olive-light: #839311;--color-green-light: #16a34a;--color-teal-light: #5eead4;--color-blue-light: #3b82f6;--color-violet-light: #8b5cf6;--color-purple-light: #a855f7;--color-pink-light: #ec4899;--color-brown-light: #94674a;--color-grey-light: var(--steel-300);--color-black-light: #1f2937;--color-red-dark-1: #a71919;--color-orange-dark-1: #d34f0b;--color-yellow-dark-1: #b67c04;--color-olive-dark-1: #839311;--color-green-dark-1: #137337;--color-teal-dark-1: #0c857a;--color-blue-dark-1: #1554e0;--color-violet-dark-1: #6a1feb;--color-purple-dark-1: #8519e7;--color-pink-dark-1: #c7216b;--color-brown-dark-1: #94674a;--color-black-dark-1: #0f1623;--color-red-dark-2: #941616;--color-orange-dark-2: #bb460a;--color-yellow-dark-2: #ca8a04;--color-olive-dark-2: #91a313;--color-green-dark-2: #15803d;--color-teal-dark-2: #0a766d;--color-blue-dark-2: #2563eb;--color-violet-dark-2: #5c14d8;--color-purple-dark-2: #7c3aed;--color-pink-dark-2: #b11d5f;--color-brown-dark-2: #a47252;--color-black-dark-2: #111827;--color-ansi-black: #1d2328;--color-ansi-red: #cc4848;--color-ansi-green: #87ab63;--color-ansi-yellow: #cc9903;--color-ansi-blue: #3a8ac6;--color-ansi-magenta: #d22e8b;--color-ansi-cyan: #00918a;--color-ansi-white: var(--color-console-fg-subtle);--color-ansi-bright-black: #424851;--color-ansi-bright-red: #d15a5a;--color-ansi-bright-green: #93b373;--color-ansi-bright-yellow: #eaaf03;--color-ansi-bright-blue: #4e96cc;--color-ansi-bright-magenta: #d74397;--color-ansi-bright-cyan: #00b6ad;--color-ansi-bright-white: var(--color-console-fg);--color-gold: #f0c86a;--color-white: #ffffff;--color-pure-black: #000000;--color-diff-removed-word-bg: #783030;--color-diff-added-word-bg: #255c39;--color-diff-removed-row-bg: #432121;--color-diff-moved-row-bg: #825718;--color-diff-added-row-bg: #1b3625;--color-diff-removed-row-border: #783030;--color-diff-moved-row-border: #a67a1d;--color-diff-added-row-border: #255c39;--color-diff-inactive: var(--steel-650);--color-error-border: #783030;--color-error-bg: #5f2525;--color-error-bg-active: #783030;--color-error-bg-hover: #783030;--color-error-text: #fef2f2;--color-success-border: #1f6e3c;--color-success-bg: #1d462c;--color-success-text: #aef0c2;--color-warning-border: #a67a1d;--color-warning-bg: #644821;--color-warning-text: #fff388;--color-info-border: #2e50b0;--color-info-bg: #2a396b;--color-info-text: var(--steel-100);--color-red-badge: #b91c1c;--color-red-badge-bg: #b91c1c22;--color-red-badge-hover-bg: #b91c1c44;--color-green-badge: #16a34a;--color-green-badge-bg: #16a34a22;--color-green-badge-hover-bg: #16a34a44;--color-yellow-badge: #ca8a04;--color-yellow-badge-bg: #ca8a0422;--color-yellow-badge-hover-bg: #ca8a0444;--color-orange-badge: #ea580c;--color-orange-badge-bg: #ea580c22;--color-orange-badge-hover-bg: #ea580c44;--color-git: #f05133;--color-icon-green: #3fb950;--color-icon-red: #f85149;--color-icon-purple: #aa76ff;--color-body: #1a1410;--color-box-header: #2a2018;--color-box-body: #221c16;--color-box-body-highlight: #332820;--color-text-dark: #ffffff;--color-text: #fff6e8;--color-text-light: #ebe0d0;--color-text-light-1: #dccbb4;--color-text-light-2: #c9b8a0;--color-text-light-3: #c9b8a0;--color-footer: #0e0c0a;--color-timeline: #3d3128;--color-input-text: #fff6e8;--color-input-background: #2a2018;--color-input-toggle-background: #332820;--color-input-border: #5c4d3e;--color-input-border-hover: #e8a838;--color-header-wrapper: #14110e;--color-header-wrapper-transparent: #14110e00;--color-light: #2a2018;--color-light-mimic-enabled: rgba(42, 32, 24, 0.9);--color-light-border: #3d3128;--color-hover: #332820;--color-active: #3d3128;--color-menu: #2a2018;--color-card: #2a2018;--color-markup-table-row: #221c16;--color-markup-code-block: #14110e;--color-markup-code-inline: var(--steel-850);--color-button: #332820;--color-code-bg: #14110e;--color-shadow: #00000066;--color-secondary-bg: var(--steel-700);--color-text-focus: #fff;--color-expand-button: #3d3128;--color-placeholder-text: #b09a84;--color-editor-line-highlight: var(--color-primary-alpha-20);--color-project-board-bg: var(--color-secondary-light-2);--color-project-board-dark-label: #0e0c0a;--color-caret: var(--color-text);--color-reaction-bg: #00000033;--color-reaction-active-bg: var(--color-primary-alpha-40);--color-reaction-hover-bg: var(--color-primary-alpha-20);--color-tooltip-text: #1a1410;--color-tooltip-bg: #fff6e8;--color-nav-bg: #0e0c0a;--color-nav-hover-bg: #2a2018;--color-nav-text: #fff6e8;--color-secondary-nav-bg: #14110e;--color-label-text: #fff6e8;--color-label-bg: #3d3128ee;--color-label-hover-bg: #4a3c30ee;--color-label-active-bg: #5c4d3eee;--color-label-bg-alt: var(--steel-550);--color-accent: #3ecfbf;--color-small-accent: #3ecfbf;--color-highlight-fg: #f0c86a;--color-highlight-bg: #3d3128;--color-overlay-backdrop: #000000aa;--checkerboard-color-1: #474747;--checkerboard-color-2: #313131;accent-color:var(--color-accent);color-scheme: dark;--color-project-board-light-label: #fff6e8;--color-page-header-bg: #14110e;}.emoji[aria-label="check mark"],.emoji[aria-label="currency exchange"],.emoji[aria-label="TOP arrow"],.emoji[aria-label="END arrow"],.emoji[aria-label="ON! arrow"],.emoji[aria-label="SOON arrow"],.emoji[aria-label="heavy dollar sign"],.emoji[aria-label=copyright],.emoji[aria-label=registered],.emoji[aria-label="trade mark"],.emoji[aria-label=multiply],.emoji[aria-label=plus],.emoji[aria-label=minus],.emoji[aria-label=divide],.emoji[aria-label="curly loop"],.emoji[aria-label="double curly loop"],.emoji[aria-label="wavy dash"],.emoji[aria-label="paw prints"],.emoji[aria-label="musical note"],.emoji[aria-label="musical notes"]{filter:invert(100%) hue-rotate(180deg)}i.grey.icon.icon.icon.icon{color:var(--steel-350)!important}.ui.secondary.vertical.menu{border-radius:.28571429rem!important;overflow:hidden}.ui.basic.primary.button.item{background-color:var(--color-active)!important;color:var(--color-text)!important;box-shadow:none!important}.ui.red.label.notification_count,.ui.primary.label,.ui.primary.labels .label{background-color:var(--color-primary-light-3)!important}.repository.view.issue .comment-list .code-comment+.code-comment{margin:1.25rem 0!important;padding-top:1.25rem!important;border-top-color:var(--steel-650)!important}.ui.labeled.icon.buttons>.button>.icon,.ui.labeled.icon.button>.icon{background-color:var(--color-light)!important}#review-box .review-comments-counter{background-color:var(--color-shadow)!important;color:var(--color-white)!important;margin-left:.5em}.ui.basic.labels .primary.label,.ui.ui.ui.basic.primary.label{color:var(--color-text-dark)!important}.ui.yellow.label.pending-label{color:var(--color-warning-text)!important}::selection{background:var(--steel-100)!important;color:var(--color-pure-black)!important}strong.attention-important,svg.attention-important{color:var(--color-violet-light)}strong.attention-note,svg.attention-note{color:var(--color-blue-light)}strong.attention-caution,svg.attention-caution{color:var(--color-red-light)}.ui.basic.red.button{background-color:var(--color-red);color:var(--color-white)}.ui.basic.red.button:hover,.ui.basic.red.button:focus{background-color:var(--color-red-dark-1);color:var(--color-white)}.ui.basic.red.button:active{background-color:var(--color-red-dark-2);color:var(--color-white)} + + +/* hacktivism exchange-dark — warm dark gold/teal like exchange.hacktivism.ch */ +body { + background-color: #1a1410 !important; + background-image: + radial-gradient(ellipse 90% 55% at 50% 108%, rgba(26, 107, 110, 0.45) 0%, transparent 55%), + radial-gradient(circle 420px at 12% 18%, rgba(232, 168, 56, 0.28) 0%, transparent 62%), + linear-gradient(165deg, #2c1e14 0%, #1a1410 38%, #12181a 72%, #0e1c1e 100%) !important; + background-attachment: fixed !important; + color: #fff6e8 !important; +} + +#navbar { + background: linear-gradient(105deg, #1a1410 0%, #2a2018 40%, #0e1c1e 100%) !important; + border-bottom: 1px solid rgba(232, 168, 56, 0.35) !important; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.45), 0 1px 0 rgba(232, 168, 56, 0.12) inset !important; +} + +#navbar .item, +#navbar a.item { + color: #fff6e8 !important; + font-weight: 600; + border-radius: 10px !important; +} +#navbar .item:hover, +#navbar a.item:hover { + background: rgba(232, 168, 56, 0.16) !important; + color: #f0c86a !important; +} +#navbar .item.active { + background: rgba(62, 207, 191, 0.18) !important; + color: #5eead4 !important; + box-shadow: 0 0 0 1px rgba(62, 207, 191, 0.35); +} + +.ui.primary.button, +.ui.primary.buttons .button { + background: linear-gradient(135deg, #f0c86a 0%, #c47a18 100%) !important; + color: #1a1410 !important; + border: none !important; + box-shadow: 0 4px 16px rgba(232, 168, 56, 0.35) !important; + font-weight: 700 !important; + border-radius: 10px !important; +} +.ui.primary.button:hover, +.ui.primary.buttons .button:hover { + filter: brightness(1.08); + box-shadow: 0 6px 22px rgba(232, 168, 56, 0.45) !important; +} + +a { color: #f0c86a; } +a:hover { color: #5eead4; } + +.ui.card, +.ui.cards > .card, +.ui.segment { + background: rgba(42, 32, 24, 0.82) !important; + border: 1px solid rgba(232, 168, 56, 0.28) !important; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35) !important; + border-radius: 14px !important; +} + +.ui.attached.header, +.ui.top.attached.header { + background: linear-gradient(135deg, #2a2018 0%, #221c16 50%, #1a2222 100%) !important; + border-color: rgba(232, 168, 56, 0.25) !important; + color: #f0d090 !important; +} + +.ui.secondary.pointing.menu .item.active, +.ui.secondary.pointing.menu .active.item { + color: #5eead4 !important; + border-color: #3ecfbf !important; +} + +.page-footer, +footer.page-footer { + background: #0e0c0a !important; + border-top: 1px solid rgba(232, 168, 56, 0.22) !important; + color: #c9b8a0 !important; +} +.page-footer a { color: #f0c86a !important; } +.page-footer a:hover { color: #5eead4 !important; } + +.ui.table thead th { + background: #2a2018 !important; + color: #f0d090 !important; + border-bottom: 1px solid rgba(232, 168, 56, 0.28) !important; +} + +.ui.input > input:focus, +.ui.form input:focus, +.ui.form textarea:focus { + border-color: #e8a838 !important; + box-shadow: 0 0 0 3px rgba(232, 168, 56, 0.22) !important; +} + +::selection { + background: rgba(232, 168, 56, 0.45); + color: #1a1410; +} + +::-webkit-scrollbar-track { background: #14110e; } +::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #c47a18, #3ecfbf); + border-radius: 999px; +} + +.markup h1, .markup h2, .markup h3 { color: #f0d090; } +.markup h1 { border-bottom-color: rgba(232, 168, 56, 0.35); } + + + +/* readability: strong contrast on common UI chrome */ +html, body, .page-content { + color: #fff6e8 !important; +} +.ui.menu .item, +.ui.breadcrumb a, +.ui.breadcrumb .section, +.ui.header, +.flex-item .flex-item-title, +.flex-item .flex-item-body, +.repository .repo-header .flex-item-title, +.ui.table, +.ui.table td, +.ui.table th, +.ui.form .field > label, +.ui.checkbox label, +.ui.list .item, +.meta, +.desc, +.time-since, +.text.grey, +.ui.text.grey { + color: #ebe0d0 !important; +} +.ui.header, +h1, h2, h3, h4, +.flex-item .flex-item-title a, +.repository .repo-title .repo-header-title a { + color: #fff6e8 !important; +} +/* muted secondary text — keep above WCAG-ish on #1a1410 */ +.text.light, +.text.light-2, +.ui.text.light, +.color-text-light-2, +.secondary.text { + color: #dccbb4 !important; +} +/* code / file view */ +.code-view, +.file-view, +.repository.file.editor .editor-wrapper, +.cm-s-default, +.CodeMirror { + color: #fff6e8 !important; + background-color: #14110e !important; +} +code, kbd, tt, +.markup code, +.ui.label code { + color: #f8e2a8 !important; + background: rgba(232, 168, 56, 0.12) !important; +} +/* markdown body */ +.markup, +.markup p, +.markdown, +.readme { + color: #fff6e8 !important; +} +.markup a { color: #f0c86a !important; } +.markup a:hover { color: #5eead4 !important; } +/* buttons non-primary stay readable */ +.ui.basic.button, +.ui.button { + color: #fff6e8 !important; +} +.ui.basic.button { + background: transparent !important; + box-shadow: 0 0 0 1px rgba(232, 168, 56, 0.35) inset !important; +} +.ui.basic.button:hover { + background: rgba(232, 168, 56, 0.12) !important; + color: #f0c86a !important; +} +/* primary stays gold-on-dark for max contrast */ +.ui.primary.button, +.ui.primary.buttons .button { + color: #1a1410 !important; +} +/* secondary nav tabs */ +.ui.secondary.pointing.menu .item { + color: #dccbb4 !important; +} +.ui.secondary.pointing.menu .item:hover { + color: #f0c86a !important; +} +.ui.secondary.pointing.menu .item.active, +.ui.secondary.pointing.menu .active.item { + color: #5eead4 !important; + border-color: #3ecfbf !important; +} +/* form fields */ +.ui.form input, +.ui.form textarea, +.ui.form select, +.ui.input > input, +.ui.selection.dropdown, +.ui.dropdown .menu { + color: #fff6e8 !important; + background: #2a2018 !important; + border-color: #5c4d3e !important; +} +.ui.dropdown .menu > .item { + color: #fff6e8 !important; +} +.ui.dropdown .menu > .item:hover { + background: rgba(232, 168, 56, 0.14) !important; + color: #f0c86a !important; +} +/* disabled state still distinguishable but readable */ +.disabled, .ui.disabled { + opacity: 0.55 !important; +} +/* issue / PR list */ +.flex-item-body, +.issue-title, +.comment-header, +.timeline-item { + color: #ebe0d0 !important; +} +/* labels: light text on dark chips */ +.ui.label:not(.primary):not(.red):not(.green):not(.teal):not(.yellow):not(.orange):not(.blue) { + background: #3d3128 !important; + color: #fff6e8 !important; + border-color: rgba(232, 168, 56, 0.25) !important; +} +.ui.primary.label { + background: linear-gradient(135deg, #f0c86a, #c47a18) !important; + color: #1a1410 !important; +} +/* empty / help text */ +.ui.placeholder, +.ui.message { + color: #ebe0d0 !important; +} +.ui.info.message { + background: rgba(62, 207, 191, 0.12) !important; + color: #fff6e8 !important; + box-shadow: 0 0 0 1px rgba(62, 207, 191, 0.35) inset !important; +} +.ui.warning.message { + background: rgba(232, 168, 56, 0.14) !important; + color: #fff6e8 !important; +} +.ui.error.message { + background: rgba(185, 28, 28, 0.25) !important; + color: #fef2f2 !important; +} +/* footer links high contrast */ +.page-footer, +footer.page-footer, +.page-footer .container { + color: #dccbb4 !important; +} +/* explore repo description */ +.flex-item-body .time-since, +.repos-search .meta { + color: #c9b8a0 !important; +} diff --git a/configs/forgejo/assets/img/favicon.svg b/configs/forgejo/assets/img/favicon.svg new file mode 100644 index 0000000..e1d3dc3 --- /dev/null +++ b/configs/forgejo/assets/img/favicon.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/forgejo/assets/img/logo.png b/configs/forgejo/assets/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..03e96c28f7cbd8307bcffcad0456e2ee3c497bb9 GIT binary patch literal 98601 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≺Fg5FEI6*GS3JIpz}S^FWX312^i@e}Wwi1G8& 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/configs/forgejo/compose.yml b/configs/forgejo/compose.yml new file mode 100644 index 0000000..0103f29 --- /dev/null +++ b/configs/forgejo/compose.yml @@ -0,0 +1,86 @@ +# koopa-forgejo — ROOTLESS Forgejo image + rootless podman (user hernani) +# Site: https://git.hacktivism.ch | HTTP :9024 | SSH :9200 +services: + forgejo: + image: codeberg.org/forgejo/forgejo:11-rootless + container_name: koopa-forgejo + restart: unless-stopped + user: "1000:1000" + userns_mode: keep-id + env_file: [.env] + environment: + USER_UID: "1000" + USER_GID: "1000" + FORGEJO__database__DB_TYPE: postgres + FORGEJO__database__HOST: db:5432 + FORGEJO__database__NAME: forgejo + FORGEJO__database__USER: forgejo + FORGEJO__database__PASSWD: ${FORGEJO_DB_PASSWORD} + FORGEJO__server__DOMAIN: git.hacktivism.ch + FORGEJO__server__SSH_DOMAIN: git.hacktivism.ch + FORGEJO__server__ROOT_URL: https://git.hacktivism.ch/ + FORGEJO__server__HTTP_PORT: "3000" + FORGEJO__server__SSH_PORT: "9200" + FORGEJO__server__SSH_LISTEN_PORT: "2222" + FORGEJO__server__START_SSH_SERVER: "true" + FORGEJO__server__DISABLE_SSH: "false" + FORGEJO__server__LANDING_PAGE: explore + FORGEJO__service__DISABLE_REGISTRATION: "true" + FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "false" + FORGEJO__service__SHOW_REGISTRATION_BUTTON: "false" + FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false" + FORGEJO__service__DEFAULT_ALLOW_CREATE_ORGANIZATION: "true" + FORGEJO__openid__ENABLE_OPENID_SIGNIN: "false" + FORGEJO__openid__ENABLE_OPENID_SIGNUP: "false" + FORGEJO__repository__DEFAULT_PRIVATE: public + FORGEJO__repository__ENABLE_PUSH_CREATE_USER: "true" + FORGEJO__repository__ENABLE_PUSH_CREATE_ORG: "true" + FORGEJO__security__INSTALL_LOCK: "true" + FORGEJO__session__PROVIDER: memory + FORGEJO__log__LEVEL: Info + FORGEJO____APP_NAME: "hacktivism git" + FORGEJO__ui__DEFAULT_THEME: hacktivism + FORGEJO__ui__THEMES: hacktivism,forgejo-auto,forgejo-light,forgejo-dark,gitea-auto,gitea-light,gitea-dark + ports: + - "9024:3000" + - "9200:2222" + volumes: + - ./data:/var/lib/gitea + - ./config:/etc/gitea + - /etc/localtime:/etc/localtime:ro + depends_on: + db: + condition: service_healthy + labels: + org.hacktivism.service: forgejo + org.hacktivism.variant: rootless + org.hacktivism.host_port: "9024" + org.hacktivism.ssh_port: "9200" + org.hacktivism.site: git.hacktivism.ch + org.hacktivism.managed_by: koopa-admin + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/healthz"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 90s + + db: + image: docker.io/library/postgres:16-alpine + container_name: koopa-forgejo-db + restart: unless-stopped + environment: + POSTGRES_USER: forgejo + POSTGRES_PASSWORD: ${FORGEJO_DB_PASSWORD} + POSTGRES_DB: forgejo + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U forgejo -d forgejo"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + +volumes: + db-data: diff --git a/configs/merchant-landing/README.md b/configs/merchant-landing/README.md new file mode 100644 index 0000000..9a5d998 --- /dev/null +++ b/configs/merchant-landing/README.md @@ -0,0 +1,42 @@ +# Merchant landing (`taler.hacktivism.ch` intro) + +Static intro for the **GOA merchant** backend (dual currency GOA + CHF). + +| Item | Value | +|------|--------| +| Container | `taler-hacktivism` | +| In-container path | `/var/www/merchant-landing/` | +| nginx | listen **9015** (`nginx-landing.conf`) | +| **Host port to publish / wire** | **`9015`** → `127.0.0.1:9015` | +| Public path | `https://taler.hacktivism.ch/intro/` | + +## Features (page) + +| Piece | Notes | +|-------|--------| +| Wallet fold | Collapsed “Get the wallet · Android / iOS / web browsers” | +| Web icons | Chrome · Firefox · Opera · Ubuntu Touch (gold+blue; [taler.net wallet](https://www.taler.net/de/wallet.html)) | +| GOA shop samples | `data-product` buttons → public template pay (`goa-shop` instance) | +| Link previews | Open Graph / Twitter meta → `og-goa-shop.png` (1200×630) for chats & social | +| Shop pay popup | `shop-pay.js` / `shop-pay.css` — **QR_Taler** (blue ring + `qr-logo.svg`), settlement payto expandable | +| Get GOA first | Hint + link to bank (plain “pay here”) | +| Stats | Dual-currency aggregates; **recent activity** split GOA/CHF (≤5 each); refund badge violet | +| Performance | Live HTTP probes + container RSS | + +Shop product ids/amounts must stay in sync with +`scripts/taler-monitoring/check_e2e.sh` → `E2E_SHOP_PRODUCTS` +(e2e random-picks **2** products per run by default). + +## Deploy + +```bash +podman cp configs/merchant-landing/index.html \ + taler-hacktivism:/var/www/merchant-landing/index.html +podman cp configs/merchant-landing/og-goa-shop.png \ + taler-hacktivism:/var/www/merchant-landing/og-goa-shop.png +podman cp configs/merchant-landing/shop-pay.js \ + taler-hacktivism:/var/www/merchant-landing/shop-pay.js +# shop-pay.css, qr-logo.svg, qrcode.min.js as needed +``` + +Also see `scripts/taler-landing/deploy-landings.sh` if present. diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html new file mode 100644 index 0000000..8b69e86 --- /dev/null +++ b/configs/merchant-landing/index.html @@ -0,0 +1,1016 @@ + + + + + + GOA Merchant · dual currency + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ stack· + taler-merchant 1.6.9 + · + taler-merchant-webui 1.6.8~dev6 + · + libtalermerchant 1.6.9 + · + libtalerexchange 1.6.7~dev2 + · + taler-terms-generator 1.6.7~dev2 +

+
+
+ Merchant · dual currency +

GOA Merchant

+

+ Payments in GOA + (explorational) + and CHF + (taler-ops). +

+
+ +

+ Get GOA first. + You need coins in your wallet before you can pay here. +
+ Install the wallet → one-step withdraw at the bank, then return to this shop. +
+ Get money at the bank → +

+ +
+ + +
+ + + + +
+

Merchant stats

+

From merchant DB · dual currency side by side

+
+
Instances
+
Orders
+
Paid
+
Unpaid
+
Wired
+
Refunds
+
+ + +
+
+
GOA
+
explorational · hacktivism
+ + paid volume +
+
Orders
+
Paid
+
Wired
+
Unpaid
+
+
all orders
+
+
+
CHF
+
taler-ops
+ + paid volume +
+
Orders
+
Paid
+
Wired
+
Unpaid
+
+
all orders
+
+
+

Loading…

+ +
+ +
+

Performance

+

Live HTTP probes + container memory (RSS)

+
+
/config
+
/terms
+
/webui/
+
Loadavg
+
Container
+
PostgreSQL
+
Taler procs
+
Nginx
+
+
+

Top processes (RSS)

+
+
+

Memory from /proc + cgroup inside podman container.

+
+ +
+

Recent activity

+

Latest 5 payments & refunds per currency

+
+
+

GOA

+
    +
  • Loading…
  • +
+
+
+

CHF

+
    +
  • Loading…
  • +
+
+
+
+ +
+

Merchant SPA

+

taler.hacktivism.ch · GOA + CHF

+ +
+ + +
+ + + + + diff --git a/configs/merchant-landing/nginx-landing.conf b/configs/merchant-landing/nginx-landing.conf new file mode 100644 index 0000000..a8166f0 --- /dev/null +++ b/configs/merchant-landing/nginx-landing.conf @@ -0,0 +1,24 @@ +# Merchant landing — :9015 (behind Caddy — no port in Location) +server { + listen 9015; + listen [::]:9015; + server_name taler.hacktivism.ch _; + root /var/www/merchant-landing; + index index.html; + + absolute_redirect off; + port_in_redirect off; + + location = / { + return 302 /intro/; + } + location = /intro { + return 302 /intro/; + } + location /intro/ { + alias /var/www/merchant-landing/; + } + location / { + try_files $uri $uri/ =404; + } +} diff --git a/configs/merchant-landing/og-goa-shop.png b/configs/merchant-landing/og-goa-shop.png new file mode 100644 index 0000000000000000000000000000000000000000..aeaad870078fe2e89faa409718d3c3430aa112ba GIT binary patch literal 557173 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
7VAoh{5UoAiYM9&Ly$g4 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
I|8CJr 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

969O}CVy_Ydn!e9OM_wfAc8Q%NS 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

gzxgCdSd*5D& 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�kcEyv=#;$;UV{ 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%0DS6J_`m1kZ}5vhY>FQ9$R+*-VyQ7DDc1-6y>*Q; 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

qHPVWR)mld=^&xi zZ8JzytkooGM%?N$KevSTpouY>t(~6B@+f1B&R9NhfGpGKEW=8d@v+@mL`0G_)x@oB zdOO<$X;d3StJPtC;SdJsrwK+Fq;i1UAkDCepcT*4i4}Bo9?V;7?E@PNqBtTm8m%?+ z^9u;mbqq`aX__L1Bv#4?u?{hF3NBe%yRbtT57Oh`U0J~^D$BisX8nzE`o(@ zFd&Kqnb9BxhgXl1B`Hak(QdUEBneVVRu3Nb!*8%&QSHpPkwTCpjse~4_faZl`M^<^ zb$3T&B}A`KMM`Jd zNsUFdyBu9wB}r3cB#>6nPZI`dhEy>^NPm6A+`=-8%gc^2m1Rywiy+N3bMwnsu>e}Q zwISym=<9(!Z48~oB_h*8TZ47!YOq4!qR$D0bV;{j;m*HITTm_9%Lfp`XGyit8}#Y6 zVp5&b>9mniE6;2ztU_m+cBf0+X``fa>vE8g*o?)M1FXg>Uw=vJ=HBAc3LqE^`m{Rp z9GRQL_!xy+dv6daTpU0xNixIgfg?zzFxGhBs!)<$7z0u0BJc2|X`T-vuVJA(gwLyF zg+iArzK8wQUP4uXUOPCe5Pj?t8e6DMKWUzI|_Dhi0$tG%T^vSk7+B zC>*1%OOMp^DX*R)ovn~QmSTv_Bb<5M_~ZrF+F`R~$fA%pPe2mOCZYUW%18FD=jDhx zmnXV0Rq(2)UkH}@U`hcL*4^^_xYt;7&2F9J`?dVDXzyfq#3DmS?ejhC5A}FkqXd{L zz+YB={8F^!6)Un1&Q_QG+VtF-kVvVVuw`A)a;-CGl}{6ta`8cgP+mB7g1(TJRHr@@ zp0hF=qw^q$gT%YIDHm%+Dkm5j>n#4lMh?i*7L#QdG?BLm8sn_u#z+P`DN4Hdds?SN zLL!a944l7%0fbZx5(659h>+4f!=x##NMN*PU@h86l<}5wB`~;*6%rh9VGXJkG0+)A zEntDAl|!`SdA8Pf=(LtGsRQ%`f;1V>YCEP}l4d@wv$OhH3|4D5=0c$℞q{+;*(i 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 ~L0HX}AQ#%0EW@T5eJMEb@Okbe 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 Vy;2o~7%Tt)002ovPDHLkV1j3=oB{v< literal 0 HcmV?d00001 diff --git a/configs/merchant-landing/qr-logo.png b/configs/merchant-landing/qr-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aeffa23d3ec9d66f1041cc50d28cd2037e0e4e56 GIT binary patch literal 8563 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 tCI$W<00960e6}m<00006Nkl \ No newline at end of file diff --git a/configs/merchant-landing/qr-payto.png b/configs/merchant-landing/qr-payto.png new file mode 100644 index 0000000000000000000000000000000000000000..2be70b216a7c4217c137a36be9082e29dbd96445 GIT binary patch literal 602 zcmV-g0;T`6pHR9J=W znO&;GKnR6}SwNRGTc0J(0s>w=M>FXG?PihjQ2$G^d#yDmG~Zge9~N5NMe`7+PIKjElG9Py<* zQ-;`k{MF@=-~aBn{IAM25?Wph`7ymd!RTlGt;?AWx&&@9=h&+~Km1djdqTe!p;>91 zUwNR_=ibUZ8|)JgD5SVOTz7oc=H3?8IfJjfIWy@ya4$<1P$Ec?J$W--T5#Hjv=%oG z-}1QwH=lTlKvv)xx$gNPvjItb1zAUrw! ztu)W#gtQPoP$IFvRp*|^u*MCgvWqD#^QSgPSh2GS486r&`lmFfSsLQb^@Xd9&`KS6 zSVprTHzG^U{7{;EWnG~rG*lid?r6djLSnG3AxpBq)#jXo_X)IjeNmmCwU+0%Pc|!6 z>g>RCl;3C(n9NY9&VHgg&&nDz0X8ebSZly}L1l2b3$C%;oiUT8);RU6HkW7x o{ufh-3pPF79r)+#$G^+}0Nz87Dqcs^`~Uy|07*qoM6N<$g1Q|al>h($ literal 0 HcmV?d00001 diff --git a/configs/merchant-landing/qr-product-beacon-badge.png b/configs/merchant-landing/qr-product-beacon-badge.png new file mode 100644 index 0000000000000000000000000000000000000000..e01486e1d4bf246337f606534b9367414aa743ac GIT binary patch literal 608 zcmV-m0-ybfP)6lkhO)lT_@rSaE7zk76V2pgx27&6<8Cp%EU-+L|F+AH(@o^%aUJ1bo2g~ zoQIoWudh~OtybCVJHFC$iLmk|iiMKn|8jFUBQHztHQJ0t+xceZ_oGQGp|;Y5QxWi}kwWFS2v&FE4+MZGp%WU+KBKiRHnC z)6LQ8-^n>7ju=bC<{q1yS%c$l#4asfq3C$|xgXf&U_KQ7!fy-kJztS~ECk0xW>}D uHj(rNxaT+dWbTYey;c?ApN}8^F8>2hr2G}wjM)kR0000K@4h2>CNJYWw$L-xdsPSxW2Imynqxy5^rR`|&UHZgG{kU(GHr z?xokbW7TQ~UL%ZH81a7LUc42q0A=72N34xY%S|6f#3d8=Sc?=zy4)KgPRGq6#P~Tg zlZd<0g8U%9m8k#vJD1Df_4RxE|5&c8BhbRfh%&VHs=w#*NC^F{K$p1cBR{9}urn%1BudQWk_5cZPw=*Sg?jazFAnGOH-+a3`7h|Q<5rH>2>GlXbyAJC9nl0 zw&siCDfn!~l9jY3qpbPvbgs^(C{d{Rtu_q_I2!Vlm`}(NY0n>r^L`(z*rPdX-ER&io;uv>q}bL?igA=ZeY?E)sg6nxJ_wl|t>joY4p>wJZ0 z5^4_Paom!DSGH)sPlixjtKa?kbUvj7qq9XXfB3Dx1Y8A4!-Z{?W#K-1n*8nCA{n@I zi$Ik%)mI)gw&(vd2lLqqicecCt*!VD=bx`1|1SRn$~GG&R3v|P00000NkvXXu0mjf DjZz@D literal 0 HcmV?d00001 diff --git a/configs/merchant-landing/qr-product-comet-cap.png b/configs/merchant-landing/qr-product-comet-cap.png new file mode 100644 index 0000000000000000000000000000000000000000..1bea33faab97352ea6d20aa40e804f3d9def064a GIT binary patch literal 524 zcmV+n0`vWeP)>M8>MJN$^?6# z(}9}aG$aIh1gVkpdpVy%aF48`)Q#!a-X{Z5inE#)HUHv45wfdAO>@hW3{1Kv%s2Aj z;p<)BrKmx);re{I9NYuv&op7R;bk<9+WvZ$gsh;XozqzXb=~bv526Pn?<}v>L&rNZ zk%LGO(J~o)$T30{uI_DiRfsUE+cjZqMYqMVA8(n6LB!t5>GN&_i6>tXgH%KvJKid0 zRBYFT?7LR+9O~vJqYOk5GUs-DfE&|Bj!e`b^W)z5(s-@agzOWHIq4g1%Qiw3mdo8} z6>ZCQJX;OcfFXDGL6GE4P7(Gs$aZ_H`|$~AYS7u?CVPg{M*LYOYOufE%0o&fZOgK- zOrzQxvrh;^MjnG|kb16pj7|;G-2MtW=~5EzBe;9U|2vWx{4jp-EBF9}fN)Ps1=scf O0000fa{IYls-|^$N zWwvgM#f4Q{6Zi^pTo%xMAJ;l!Vg< zL!?NU>!wyX)(9?5h^9;wZQPmP&AGc$T=fC6WLH!?f`j;0eMqekXQi(<=PyLr#o(&? zfLyQq>CKlC+N&%>C0(leyWX6Y`SDjZN?E99@O`VlroYCgTq-ku0QW2foDcA7ms;%p zT%~|LHRc1nI3IuFuQ{)_uF`s|P^a(q=O_-?!Xt`UV~TU7{HZx-H)KoxinOl&!hHg- z%AE57Mq~~;=Pv*1%@Iw`uu7LzWEpdP0B0+7B^7ix~TlMJ3s1JRGFFRmKCo**Tc3H^{x5~+3f(H_bbLTskG)5 z%l-ZSr7vG;QM}^Kv7Y*-aE>+WX^WWa5{#pJ7>+dm4bu?w-00000NkvXXu0mjfa5pm9 literal 0 HcmV?d00001 diff --git a/configs/merchant-landing/qr-product-nebula-coffee.png b/configs/merchant-landing/qr-product-nebula-coffee.png new file mode 100644 index 0000000000000000000000000000000000000000..a56dd308cdb6eee1acbe4564d5b300f37bd5788d GIT binary patch literal 604 zcmV-i0;BzjP)e6lvEY}DgEmcj{TP>JoT z!&zT8QC#B`+H3vg<@7fM{rP+Ge_kHfFm8*o>Yw~g%WoMPWhINFvV0i@UMav=O)Yc4 z4d@Sus5bZ_6j$JTbJQQ;52kDz@^=Y78K@E`b1Sh5aJB`*p6vc`OWc#cxjB>dY|HKs z>(|0Rxw(5tmgU!v9uNS?-*Qq-{k0=_-*QIs8d-`8?j%uR6Ia`P^I{^V=S7f*jn z&Y`JKIqP)%?3Q@IUGCzMWKhxc;c%#2V^zkIwP7fV|wsQnqnM@s&Rij z^|dyqxJD>ZXlGjAvUWD$T1@WrPwI<*^JQgzXILq%X`)Ls9erAyPX(0PvSv;0XTGV< z^?i4zxHEk&)nOaX6k(xae9_P|AN1h*EX{FEQ>MTAcWr(O>e76y%KGT*@|^k2gvk8n j_pKC4_4()Z~|F$jc(JwT7-)pMjAAmGy(lP$}m{|X8XrEK?0e~4xrBlda8kAIm@@pakoA4^n3 zm-tJKx1t@mI=QAej0b$yE4~AD;2wv^C;o2gdJRF8{*S8>_xN5OQ2dO&?Kn~-5nOC^ z(ukwJPFVZlQGazg&iB;E=W+h)^0QE4E41o={VmEPK4pkrUns7S+lA*2oDCrY^#@le zCx|r^s|nY59sOZIpSW@C^Ie;JDkQfsMJZS4*bO-Ai`keIR|dEBwK%tNnwxJq{lULk zU)8w>0X0Qi@R%ZEs?_m!wRx6UCI?0-8+8kw&JIQ+lu4;l*WCTBJV$+v)K}5PU9{Ns z`L=SYufE+PKX%t&an5dR`&bXEEF4DP{xYk3|DN%!Bd~^5Hi7GBNT5~GC?#m zHU6)LY#>vR8hJjy^3w=DtJRI|Kef*VB9y68AyM-i7mg4?gzj|YdCgKc9U;>$+Be3k zQ?EGvAQBFzPM4$UK{B53Ko=PbO+&{YG7w8h2}c=7MnGLxdGmu1bu|$b3vOE`c2KBFF(k!}9}vM>KIzbw|_Gd2sa zBc6T@<9i1_f*hAE)5|#G!_(sl)PaYN&}S#0oin!h<+7s@4}E6{!WQ4Q;|x(qjO1Dr z+KBJ8f~=RmU-?&;v%Vqd?eACp*X8$OiY@f2zx!`d4t+xsqqvz|Xt#^+4LGtXRYr@2 z#B)+BI#vUIp(fN;872Bd{L4A-`Yz2Q6x6pK{(yEx$8Nx-&(a^1UleCg{dGB{IA@|a ze~4%1UvZAtsesTQ@S(cYp6_aNpBm^gI2@FiWt{lx^D8Y*RSvl*Zf02%zAT69JRj&Q zjo9@$f-^N!(Yb?c zI3F}*fArU>;b`stTAXW|Q-Ae5+_Pe?9y}*gmiWG(uynloT*DY*V{@s?nBtW> zm>Y6sjdg^%%rsy`9FEZb3r{G+$mL%lbjmrNpbaod|4gd$MyK*+3mHWKg1 z#1Asxj;OICH(!M%#1>>ZnXRjc2npO3p3SF-qaWXyZ3Z!eQ$$27jXb&H1(6%+aL8nS zRZLVp5V$2=$~rn66R)lEJWq(nukrzD%vio<;s#kic1AHuGcEklkROBxpCKofTLFQ_K5Afj2Svut;S=QC)Rz18>eF$_02cDcnqL-Z0%q5dw&m7`>G$Fe7^ z(2%{U0&<3`dZ=#HmA@9=2{PQC2{G>GZEt2!Tk!lb<=?XBGsq2oX#eml_yd9vh$R*f R({2C&002ovPDHLkV1m`S>$Cs> literal 0 HcmV?d00001 diff --git a/configs/merchant-landing/qr-product-voidwave-playlist.png b/configs/merchant-landing/qr-product-voidwave-playlist.png new file mode 100644 index 0000000000000000000000000000000000000000..a63ba20e479c290fbd10b2fffbfb3408137205eb GIT binary patch literal 602 zcmV-g0;T`6pHR9J=W zm{E$uFbqTmK0uD7tNTbffI%zG$WFH`{VPbYCX_TUKf;Xxw^(DUxbEB;DKnZ`%`C{i^ zaXzyeDg#;%e?n-t;4z1$O1L@9YHh-87Zl;ZE~De_`Jy)WpIfPKDjNp{JN;FkhrYL_ z4aIRd+WB3cqrd2{CnXqqE%+XhGNSpRj*4%if_R8_mwmx${L?o_Iz5L zE5q#^?z6LQIsH|fBNmjyQCL!(47eI`p*Yr)(%OorCymTETaUk1=ZI*d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); \ No newline at end of file diff --git a/configs/merchant-landing/shop-pay.css b/configs/merchant-landing/shop-pay.css new file mode 100644 index 0000000..c39a62e --- /dev/null +++ b/configs/merchant-landing/shop-pay.css @@ -0,0 +1,348 @@ +/* GOA shop pay popup — QR_Taler style from taler-merchant-webui / @gnu-taler/web-util */ + +/* Animated blue ring (same as webui QR.tsx) */ +@property --angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} +@keyframes goa-pay-qr-rotate { + to { + --angle: 360deg; + } +} + +.shop-item[data-product] { + cursor: pointer; + transition: border-color 0.15s, transform 0.12s, background 0.15s; +} +.shop-item[data-product]:hover, +.shop-item[data-product]:focus-visible { + border-color: rgba(196, 181, 253, 0.75); + background: rgba(0, 0, 0, 0.4); + transform: translateY(-1px); + outline: none; +} +button.shop-item { + font: inherit; + color: inherit; + width: 100%; + appearance: none; + -webkit-appearance: none; +} + +.goa-pay-modal { + position: fixed; + inset: 0; + z-index: 80; + display: none; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(8, 6, 14, 0.75); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.goa-pay-modal.open { + display: flex; +} +.goa-pay-card { + position: relative; + background: rgba(36, 28, 48, 0.98); + border: 1px solid rgba(167, 139, 250, 0.45); + border-radius: 16px; + padding: 1.25rem 1.2rem 1.2rem; + max-width: 24rem; + width: 100%; + text-align: center; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + max-height: 92vh; + overflow-y: auto; +} +.goa-pay-x { + position: absolute; + top: 0.45rem; + right: 0.55rem; + border: 0; + background: transparent; + color: #c4b5fd; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + padding: 0.2rem 0.45rem; +} +.goa-pay-card h3 { + margin: 0 0 0.2rem; + font-size: 1.08rem; + color: #faf5ff; + padding-right: 1.5rem; +} +.goa-pay-amount { + margin: 0 0 0.85rem; + font-weight: 750; + color: #c4b5fd; + font-variant-numeric: tabular-nums; +} + +/* === QR_Taler frame (webui QR.tsx) === */ +.goa-pay-taler-qr { + width: 100%; + max-width: 280px; + margin: 0 auto 0.75rem; + padding: 10px; + border-radius: 20px; + box-sizing: border-box; + position: relative; + background: conic-gradient( + from var(--angle), + #0042b3 0deg, + #f1f1f4 20deg, + #f1f1f4 150deg, + #f1f1f4 160deg, + #0042b3 180deg, + #f1f1f4 200deg, + #f1f1f4 330deg, + #f1f1f4 340deg, + #0042b3 + ); + animation: goa-pay-qr-rotate 10s linear infinite; +} +.goa-pay-taler-qr__inner { + padding: 10px; + border-radius: 20px; + background: #fff; + line-height: 0; + min-height: 180px; + box-sizing: border-box; +} +/* Always an with explicit pixel size from JS */ +.goa-pay-taler-qr__inner img { + display: block !important; + margin: 0 auto !important; + border: 0 !important; + background: #fff; + image-rendering: pixelated; +} +/* Center logo plate — official qr-logo.png (webui ~100×50; PNG for Android) */ +.goa-pay-taler-qr__logo { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 28%; + height: auto; + max-width: 100px; + aspect-ratio: 200 / 95; + object-fit: contain; + pointer-events: none; + background: #fff; + border-radius: 4px; +} +/* Full encoded payload under QR — clickable (wallet / webextension) */ +.goa-pay-payload { + display: block; + margin: 0.45rem 0 0.55rem; + padding: 0 0.25rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.68rem; + line-height: 1.35; + color: #7eb6ff; + word-break: break-all; + text-align: center; + text-decoration: none; + border-bottom: 1px solid rgba(126, 182, 255, 0.4); + cursor: pointer; +} +.goa-pay-payload:hover { + color: #b8d6ff; + border-bottom-color: #7eb6ff; +} + +/* Settlement: collapsed by default, expand on click */ +.goa-pay-settle { + margin-top: 0.85rem; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + overflow: hidden; + background: rgba(0, 0, 0, 0.22); +} +.goa-pay-settle-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + margin: 0; + padding: 0.65rem 0.75rem; + border: 0; + background: transparent; + color: #cbd5e1; + font: inherit; + font-size: 0.78rem; + font-weight: 650; + text-align: left; + cursor: pointer; + line-height: 1.3; +} +.goa-pay-settle-toggle:hover { + background: rgba(148, 163, 184, 0.1); + color: #e2e8f0; +} +.goa-pay-settle-toggle-text { + flex: 1; +} +.goa-pay-settle-chevron { + flex-shrink: 0; + transition: transform 0.15s ease; + color: #94a3b8; + font-size: 0.85rem; +} +.goa-pay-modal.settle-open .goa-pay-settle-chevron { + transform: rotate(90deg); +} +.goa-pay-settle-body { + padding: 0 0.75rem 0.75rem; + border-top: 1px solid rgba(148, 163, 184, 0.25); +} +.goa-pay-settle-note { + margin-top: 0.55rem; +} +/* Settlement payto QR — plain box (not wallet pay / no logo / no blue ring) */ +.goa-pay-payto-qr { + width: fit-content; + max-width: 160px; + margin: 0.35rem auto 0.55rem; + padding: 0.45rem 0.5rem 0.5rem; + border-radius: 10px; + background: #fff; + border: 1px dashed rgba(100, 116, 139, 0.75); + box-sizing: border-box; +} +.goa-pay-payto-qr__caption { + font-size: 0.62rem; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + text-align: center; + margin: 0 0 0.35rem; + line-height: 1.2; +} +.goa-pay-payto-qr__inner { + line-height: 0; + min-height: 0; +} +.goa-pay-payto-qr__inner img { + display: block !important; + margin: 0 auto !important; + background: #fff; + image-rendering: pixelated; +} +.goa-pay-sub { + margin: 0 0 0.55rem; + font-size: 0.75rem; + line-height: 1.35; + color: #b8a8c9; + font-weight: 500; + text-align: center; +} + +.goa-pay-hint { + margin: 0 0 0.75rem; + font-size: 0.82rem; + color: #b8a8c9; +} +.goa-pay-hint.err { + color: #fca5a5; +} +.goa-pay-cta { + display: block; + text-decoration: none; + font-weight: 750; + padding: 0.65rem 1rem; + border-radius: 11px; + background: linear-gradient(135deg, #a78bfa, #6366f1); + color: #0f0a1a; + margin-bottom: 0.75rem; +} +.goa-pay-cta:hover { + filter: brightness(1.06); +} +.goa-pay-cta.disabled { + opacity: 0.45; + pointer-events: none; +} +.goa-pay-label { + margin: 0.55rem 0 0.2rem; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #b8a8c9; +} +.goa-pay-line { + margin: 0 0 0.35rem; + text-align: center; + font-size: 0.88rem; + font-weight: 650; +} +.goa-pay-link { + color: #93c5fd; + text-decoration: none; + white-space: nowrap; +} +.goa-pay-link:hover { + text-decoration: underline; +} +.goa-pay-link.disabled { + opacity: 0.45; + pointer-events: none; +} +.goa-pay-sep { + margin: 0 0.4rem; + color: #b8a8c9; + font-weight: 500; +} +.goa-pay-close { + display: block; + width: 100%; + margin-top: 0.85rem; + border: 1px solid rgba(167, 139, 250, 0.45); + background: transparent; + color: #c4b5fd; + font-weight: 650; + padding: 0.5rem; + border-radius: 10px; + cursor: pointer; +} + +/* Bank (gold/teal) overrides */ +.goa-pay-modal.bank-theme .goa-pay-card { + background: rgba(42, 32, 24, 0.98); + border-color: rgba(232, 168, 56, 0.4); +} +.goa-pay-modal.bank-theme .goa-pay-amount { + color: #5eead4; +} +.goa-pay-modal.bank-theme .goa-pay-cta { + background: linear-gradient(135deg, #5eead4, #0d9488); + color: #042f2e; +} +.goa-pay-modal.bank-theme .goa-pay-link { + color: #5eead4; +} +.goa-pay-modal.bank-theme .goa-pay-x, +.goa-pay-modal.bank-theme .goa-pay-close { + color: #e8c878; + border-color: rgba(232, 168, 56, 0.35); +} +.goa-pay-modal.bank-theme .goa-pay-settle { + border-color: rgba(232, 168, 56, 0.3); + background: rgba(0, 0, 0, 0.28); +} +.goa-pay-modal.bank-theme .goa-pay-settle-toggle { + color: #e8c878; +} +.goa-pay-modal.bank-theme .goa-pay-settle-body { + border-top-color: rgba(232, 168, 56, 0.25); +} diff --git a/configs/merchant-landing/shop-pay.js b/configs/merchant-landing/shop-pay.js new file mode 100644 index 0000000..2c0573b --- /dev/null +++ b/configs/merchant-landing/shop-pay.js @@ -0,0 +1,410 @@ +/** + * GOA shop pay popup — public only (no merchant secrets). + * Flow: POST templates/{id} → taler://pay/… + payto links. + * + * QR display matches taler-merchant-webui QR_Taler + * (@gnu-taler/web-util QR.tsx): animated #0042B3 conic ring + qr-logo.png. + * Uses qrcode-generator (same lib as webui) via global QRCode if present, + * else falls back to canvas from qrcode.min.js (davidshimjs). + */ +(function () { + var MERCHANT_HOST = "taler.hacktivism.ch"; + /* goa-shop: dedicated instance with fixed-order product templates */ + var INSTANCE = "goa-shop"; + var SHOP_PAYTO = + "payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop"; + var SHOP_PAYTO_HTTPS = "https://bank.hacktivism.ch/webui/"; + + function introBase() { + var b = document.querySelector("base"); + return b && b.href ? b.href : "/intro/"; + } + + function logoSrc() { + /* PNG: Android often drops SVG wordmark fill inheritance (missing "taler") */ + return introBase() + "qr-logo.png"; + } + + function templateHttps(productId) { + return ( + "https://" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/templates/" + + encodeURIComponent(productId) + ); + } + + function payTemplateUri(productId) { + return ( + "taler://pay-template/" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/" + + encodeURIComponent(productId) + ); + } + + function ensureModal() { + var m = document.getElementById("goa-pay-modal"); + if (m) return m; + m = document.createElement("div"); + m.id = "goa-pay-modal"; + m.className = "goa-pay-modal"; + m.setAttribute("role", "dialog"); + m.setAttribute("aria-modal", "true"); + m.setAttribute("aria-labelledby", "goa-pay-title"); + m.hidden = true; + m.innerHTML = + '

' + + ' ' + + '

Pay with Taler

' + + '

' + + '

Preparing payment…

' + + '

Wallet payment

' + + '

Scan with the GNU Taler Wallet to pay this product

' + + '
' + + '
' + + ' ' + + "
" + + '
' + + ' Open payment link →' + + '

' + + ' taler:// URI' + + ' ·' + + ' HTTPS' + + "

" + + '
' + + ' " + + ' " + + "
" + + ' ' + + "
"; + document.body.appendChild(m); + function close() { + m.classList.remove("open"); + m.hidden = true; + // collapse settlement on close + var body = document.getElementById("goa-pay-settle-body"); + var btn = document.getElementById("goa-pay-settle-toggle"); + if (body) body.hidden = true; + if (btn) { + btn.setAttribute("aria-expanded", "false"); + m.classList.remove("settle-open"); + } + } + m.addEventListener("click", function (e) { + if (e.target === m) close(); + }); + document.getElementById("goa-pay-x").onclick = close; + document.getElementById("goa-pay-close").onclick = close; + document.getElementById("goa-pay-settle-toggle").onclick = function () { + var body = document.getElementById("goa-pay-settle-body"); + var btn = document.getElementById("goa-pay-settle-toggle"); + var open = body.hidden; + body.hidden = !open; + btn.setAttribute("aria-expanded", open ? "true" : "false"); + m.classList.toggle("settle-open", open); + if (open) { + // paint payto QR when first expanded + renderQr(document.getElementById("goa-pay-qr-payto"), SHOP_PAYTO, 140); + var pp = document.getElementById("goa-pay-payto-payload"); + if (pp) { + pp.href = SHOP_PAYTO; + pp.textContent = SHOP_PAYTO; + } + } + }; + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && m.classList.contains("open")) close(); + }); + return m; + } + + /** + * Render URI as a real PNG (not a live canvas). + * davidshimjs paints canvas then often hides it for an img; if the modal is + * still display:none, layout collapses and you only see the blue ring. + * We always encode off-DOM and inject a fixed-size image. + */ + function renderQr(hostEl, text, size) { + if (!hostEl) return; + hostEl.innerHTML = ""; + size = size || 220; + if (!text) { + hostEl.innerHTML = '

'; + return; + } + if (typeof QRCode === "undefined") { + hostEl.innerHTML = + '

QR library missing (qrcode.min.js)

'; + return; + } + var level = + QRCode.CorrectLevel && QRCode.CorrectLevel.M != null + ? QRCode.CorrectLevel.M + : QRCode.CorrectLevel && QRCode.CorrectLevel.L != null + ? QRCode.CorrectLevel.L + : 1; + var scratch = document.createElement("div"); + scratch.setAttribute("aria-hidden", "true"); + scratch.style.cssText = + "position:fixed;left:-9999px;top:0;width:" + + size + + "px;height:" + + size + + "px;overflow:hidden;opacity:0;pointer-events:none"; + document.body.appendChild(scratch); + var dataUrl = ""; + try { + new QRCode(scratch, { + text: String(text), + width: size, + height: size, + colorDark: "#000000", + colorLight: "#ffffff", + correctLevel: level, + }); + var canvas = scratch.querySelector("canvas"); + var libImg = scratch.querySelector("img"); + if (canvas && canvas.width > 0) { + try { + dataUrl = canvas.toDataURL("image/png"); + } catch (e1) {} + } + if (!dataUrl && libImg && libImg.src && libImg.src.indexOf("data:") === 0) { + dataUrl = libImg.src; + } + } catch (err) { + dataUrl = ""; + } + if (scratch.parentNode) scratch.parentNode.removeChild(scratch); + + if (!dataUrl) { + hostEl.innerHTML = + '

QR encode failed

'; + return; + } + var img = document.createElement("img"); + img.alt = "QR code"; + img.width = size; + img.height = size; + img.src = dataUrl; + img.style.display = "block"; + img.style.width = size + "px"; + img.style.height = size + "px"; + img.style.maxWidth = "100%"; + img.style.margin = "0 auto"; + img.style.background = "#fff"; + hostEl.appendChild(img); + } + + function setLogo(imgEl) { + if (!imgEl) return; + imgEl.alt = "Taler"; + imgEl.width = 100; + imgEl.height = 50; + imgEl.decoding = "async"; + imgEl.src = logoSrc(); + imgEl.onerror = function () { + imgEl.src = introBase() + "qr-logo.svg"; + }; + imgEl.style.display = ""; + } + + function normalizePayUri(uri) { + if (!uri) return ""; + return String(uri) + .replace(/taler\.hacktivism\.ch:443/g, "taler.hacktivism.ch") + .replace(/:443\//g, "/") + .replace(/:443\?/g, "?"); + } + + /** Public: template → order → taler_pay_uri (no secrets). */ + function createPayUri(productId) { + var url = templateHttps(productId); + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + cache: "no-store", + }) + .then(function (r) { + if (!r.ok) throw new Error("template POST HTTP " + r.status); + return r.json(); + }) + .then(function (created) { + var oid = created.order_id; + var tok = created.token; + if (!oid || !tok) throw new Error("no order_id/token"); + var statusUrl = + "https://" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/orders/" + + encodeURIComponent(oid) + + "?token=" + + encodeURIComponent(tok); + return fetch(statusUrl, { cache: "no-store" }).then(function (r) { + return r.text().then(function (t) { + var pay = ""; + try { + var d = JSON.parse(t); + pay = d.taler_pay_uri || ""; + } catch (e) {} + if (!pay) { + pay = + "taler://pay/" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/" + + oid + + "/?c=" + + tok; + } + return { + taler_pay_uri: normalizePayUri(pay), + order_id: oid, + token: tok, + status_https: statusUrl.replace( + "taler.hacktivism.ch:443", + "taler.hacktivism.ch" + ), + template_https: url, + }; + }); + }); + }); + } + + function showPay(productId, name, amount) { + var m = ensureModal(); + if (document.body && document.body.getAttribute("data-shop-theme") === "bank") { + m.classList.add("bank-theme"); + } else { + m.classList.remove("bank-theme"); + } + document.getElementById("goa-pay-title").textContent = name || productId; + document.getElementById("goa-pay-amount").textContent = amount || ""; + var status = document.getElementById("goa-pay-status"); + status.textContent = "Preparing payment…"; + status.className = "goa-pay-hint"; + var open = document.getElementById("goa-pay-open"); + open.removeAttribute("href"); + open.classList.add("disabled"); + open.onclick = null; + var payLink = document.getElementById("goa-pay-uri"); + payLink.removeAttribute("href"); + payLink.classList.add("disabled"); + payLink.textContent = "taler:// URI"; + document.getElementById("goa-pay-uri-https").href = templateHttps(productId); + document.getElementById("goa-pay-uri-https").textContent = "HTTPS"; + var paytoLink = document.getElementById("goa-pay-payto"); + paytoLink.href = SHOP_PAYTO; + paytoLink.textContent = "payto:// URI"; + document.getElementById("goa-pay-payto-https").href = SHOP_PAYTO_HTTPS; + document.getElementById("goa-pay-payto-https").textContent = "HTTPS"; + + setLogo(document.getElementById("goa-pay-qr-pay-logo")); + + // Reset settlement panel (collapsed until user expands) + var settleBody = document.getElementById("goa-pay-settle-body"); + var settleBtn = document.getElementById("goa-pay-settle-toggle"); + if (settleBody) settleBody.hidden = true; + if (settleBtn) settleBtn.setAttribute("aria-expanded", "false"); + m.classList.remove("settle-open"); + var paytoHost = document.getElementById("goa-pay-qr-payto"); + if (paytoHost) paytoHost.innerHTML = ""; + + // Open first so layout exists, then paint wallet QR only + m.hidden = false; + m.classList.add("open"); + var tpl = payTemplateUri(productId); + renderQr( + document.getElementById("goa-pay-qr-pay"), + tpl, + 220 + ); + var payload = document.getElementById("goa-pay-qr-payload"); + if (payload) { + payload.href = tpl; + payload.textContent = tpl; + } + + createPayUri(productId) + .then(function (info) { + var pay = info.taler_pay_uri; + open.href = pay; + open.classList.remove("disabled"); + open.textContent = "Open payment link →"; + payLink.href = pay; + payLink.classList.remove("disabled"); + payLink.textContent = "taler:// URI"; + var uh = document.getElementById("goa-pay-uri-https"); + uh.href = info.status_https || info.template_https; + uh.textContent = "HTTPS"; + // Live unpaid taler://pay (fresh order each open) + renderQr(document.getElementById("goa-pay-qr-pay"), pay, 220); + if (payload) { + payload.href = pay; + payload.textContent = pay; + } + status.textContent = "Ready — scan in wallet"; + }) + .catch(function (err) { + status.textContent = + "Payment setup failed: " + (err && err.message ? err.message : err); + status.className = "goa-pay-hint err"; + open.textContent = "Retry"; + open.onclick = function (e) { + e.preventDefault(); + showPay(productId, name, amount); + }; + }); + } + + function bind() { + document.querySelectorAll("[data-product]").forEach(function (el) { + el.addEventListener("click", function (e) { + e.preventDefault(); + var id = el.getAttribute("data-product"); + var nameEl = el.querySelector(".name"); + var priceEl = el.querySelector(".price"); + showPay( + id, + nameEl ? nameEl.textContent.trim() : id, + priceEl ? priceEl.textContent.trim() : "" + ); + }); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bind); + } else { + bind(); + } +})(); diff --git a/configs/ports.md b/configs/ports.md new file mode 100644 index 0000000..c49ed7c --- /dev/null +++ b/configs/ports.md @@ -0,0 +1,25 @@ +# Port map (see also host/overview/services.md) + +| Port | Role | +|------|------| +| 22 | sshd (WAN often via VeciGate 23235) | +| 80 | systemd `http-proxy.socket` → Caddy **9000** | +| 443 | systemd `https-proxy.socket` → Caddy **9001** | +| 9000 | Caddy HTTP + ACME webroot | +| 9001 | Caddy HTTPS (vhosts) | +| 9010 | podman `taler-hacktivism` (merchant) | +| 9011 | podman **`taler-hacktivism-exchange-ansible`** (exchange) | +| 9012 | podman `taler-hacktivism-bank` (libeufin-bank) | +| 9013–9015 | bank / exchange / merchant public landings | +| 9020 | podman `koopa-castopod` → Caddy `castopod.hacktivism.ch` | +| 9021 | podman `koopa-bonfire` → Caddy `bonfire.hacktivism.ch` | +| 9022 | podman `koopa-prime` (Jellyfin) → Caddy `prime.hacktivism.ch` | +| 9023 | podman qBittorrent → Caddy `bt.hacktivism.ch` | +| **9024** | podman **`koopa-forgejo`** (HTTP) → Caddy **`git.hacktivism.ch`** | +| **9200** | podman **Forgejo git-SSH** (host-direct; not Caddy) | +| 9090–9092 | podman `koopa-tops-ng1` … `ng3` → Caddy `tops.ng{1,2,3}.hacktivism.ch` | +| 8080 | Tor ORPort | + +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. diff --git a/configs/prime/README.md b/configs/prime/README.md new file mode 100644 index 0000000..2b0c287 --- /dev/null +++ b/configs/prime/README.md @@ -0,0 +1,16 @@ +# Prime — Jellyfin + qBittorrent + +| Item | Value | +|------|--------| +| Live | `/home/hernani/koopa-prime/` | +| Compose | `compose.yml` (this dir; **no secret values**) | +| Jellyfin | **9022** → Caddy `prime.hacktivism.ch` | +| qBittorrent | **9023** → Caddy `bt.hacktivism.ch` | +| Topic | `2026/2026-07-09--koopa-prime-jellyfin.md` | + +`WEBUI_PASSWORD` is `${WEBUI_PASSWORD}` in the mirror — live value only in +`koopa-admin-secrets/…/koopa-prime/` / host env (not admin-log). + +```bash +cd ~/koopa-prime && podman-compose up -d +``` diff --git a/configs/prime/compose.yml b/configs/prime/compose.yml new file mode 100644 index 0000000..5a74e76 --- /dev/null +++ b/configs/prime/compose.yml @@ -0,0 +1,47 @@ +# koopa-prime — Jellyfin + qBittorrent +# Media source: /home/hernani/Downloads +services: + jellyfin: + image: docker.io/linuxserver/jellyfin:10.10.7 + container_name: koopa-prime-jellyfin + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/Zurich + - JELLYFIN_PublishedServerUrl=https://prime.hacktivism.ch + volumes: + - ./config/jellyfin:/config + - ./cache:/cache + - /home/hernani/Downloads:/media/downloads:ro + ports: + - "9022:8096" + labels: + org.hacktivism.service: jellyfin + org.hacktivism.host_port: "9022" + org.hacktivism.site: prime.hacktivism.ch + org.hacktivism.managed_by: koopa-admin + + qbittorrent: + image: docker.io/linuxserver/qbittorrent:5.0.4 + container_name: koopa-prime-qbittorrent + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/Zurich + - WEBUI_PORT=8080 + - WEBUI_PASSWORD=${WEBUI_PASSWORD} + - TORRENTING_PORT=6881 + volumes: + - ./config/qbittorrent:/config + - /home/hernani/Downloads:/downloads + ports: + - "9023:8080" + - "6881:6881" + - "6881:6881/udp" + labels: + org.hacktivism.service: qbittorrent + org.hacktivism.host_port: "9023" + org.hacktivism.site: bt.hacktivism.ch + org.hacktivism.managed_by: koopa-admin diff --git a/configs/shared/goa-stats-snippet.html b/configs/shared/goa-stats-snippet.html new file mode 100644 index 0000000..7070aee --- /dev/null +++ b/configs/shared/goa-stats-snippet.html @@ -0,0 +1,39 @@ + + diff --git a/configs/shared/goa-stats.css b/configs/shared/goa-stats.css new file mode 100644 index 0000000..9f3f45f --- /dev/null +++ b/configs/shared/goa-stats.css @@ -0,0 +1,86 @@ +/* Shared GOA flow stats (used on bank / exchange / merchant landings) */ +.stats { + background: rgba(18, 28, 28, 0.55); + border: 1px solid rgba(62, 207, 191, 0.28); + border-radius: 16px; + padding: 1.1rem 1.15rem 1.2rem; + margin: 0 0 1.5rem; +} +.stats h2 { + margin: 0 0 0.85rem; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted, #c9b8a0); + text-align: center; +} +.stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem 0.85rem; +} +@media (min-width: 420px) { + .stats-grid { grid-template-columns: 1fr 1fr 1fr; } +} +.stat { + background: rgba(0, 0, 0, 0.22); + border-radius: 10px; + padding: 0.55rem 0.65rem; + min-width: 0; +} +.stat .k { + display: block; + font-size: 0.68rem; + color: var(--muted, #c9b8a0); + letter-spacing: 0.04em; + text-transform: uppercase; + margin-bottom: 0.2rem; + text-align: center; +} +.stat .v { + display: block; + font-size: 1.05rem; + font-weight: 750; + color: #f0d090; + word-break: break-word; + font-variant-numeric: tabular-nums; + text-align: center; +} +.stat .v.teal { color: #5eead4; } +.stat.wide { grid-column: 1 / -1; } +.wd-list { + list-style: none; + margin: 0.15rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.wd-list li { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.25rem 0.75rem; + font-size: 0.88rem; + font-weight: 650; + color: #f0d090; + font-variant-numeric: tabular-nums; + border-bottom: 1px solid rgba(201, 184, 160, 0.12); + padding-bottom: 0.3rem; +} +.wd-list li:last-child { border-bottom: none; padding-bottom: 0; } +.wd-list .amt { color: #5eead4; font-weight: 750; } +.wd-list .meta { + color: var(--muted, #c9b8a0); + font-size: 0.78rem; + font-weight: 600; +} +.stats-foot { + margin: 0.75rem 0 0; + font-size: 0.72rem; + color: var(--muted, #c9b8a0); + text-align: center; +} +.stats-foot a { color: #e8c878; } +.stats-foot.err { color: #f0a090; } diff --git a/configs/shared/goa-stats.js b/configs/shared/goa-stats.js new file mode 100644 index 0000000..8689216 --- /dev/null +++ b/configs/shared/goa-stats.js @@ -0,0 +1,130 @@ +/* Load GOA flow stats from bank landing stats.json (CORS on bank). */ +(function () { + var STATS_URL = "https://bank.hacktivism.ch/intro/stats.json"; + var BANK_INTRO = "https://bank.hacktivism.ch/intro/"; + + function fmtCest(unix, fallback) { + var base = ""; + if (fallback) { + base = String(fallback).replace(/\s*(CEST|CET|UTC|Z)\s*$/i, "").trim(); + } else if (unix != null && unix !== "") { + try { + base = new Intl.DateTimeFormat("de-CH", { + timeZone: "Europe/Zurich", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false + }).format(new Date(Number(unix) * 1000)); + } catch (e) { + base = ""; + } + } + return base ? base + " Europe/Zurich" : ""; + } + + function set(id, v) { + var el = document.getElementById(id); + if (el) el.textContent = v == null || v === "" ? "—" : String(v); + } + + function showFallback(msg) { + var box = document.getElementById("stats"); + var foot = document.getElementById("st-foot"); + if (box) box.hidden = false; + if (foot) { + foot.hidden = false; + foot.className = "stats-foot err"; + foot.innerHTML = + (msg || "Stats offline") + + ' · bank.hacktivism.ch'; + } + } + + function loadStats() { + var box = document.getElementById("stats"); + var foot = document.getElementById("st-foot"); + if (!box) return; + box.hidden = false; + + // Same-origin first (bank landing), else public bank URL + var urls = []; + if (location.hostname.indexOf("bank.hacktivism") !== -1) { + urls.push("stats.json"); + } + urls.push(STATS_URL); + + function tryNext(i) { + if (i >= urls.length) { + showFallback("No stats"); + return; + } + fetch(urls[i], { cache: "no-store", mode: "cors" }) + .then(function (r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (d) { + if (!d || !d.ok) throw new Error("bad stats"); + var w = d.withdraws || {}; + var h24 = w.last_24h || {}; + var h7 = w.last_7d || {}; + var ba = d.bank_accounts || {}; + var wl = d.wallets || {}; + var flow = d.flow || {}; + var fin = flow.incoming || {}; + var fwd = flow.withdraw || {}; + set("st-accounts", ba.total != null ? ba.total : "—"); + set("st-wallets", wl.unique_reserves != null ? wl.unique_reserves : "—"); + set("st-incoming", fin.amount || flow.total_in || "—"); + set("st-withdraw", fwd.amount || w.total_amount || "—"); + set("st-24h", h24.amount || "GOA:0"); + set("st-7d", h7.amount || "GOA:0"); + + var list = document.getElementById("st-recent"); + if (list) { + list.innerHTML = ""; + var rows = (d.recent_withdraws || []).slice(0, 3); + if (!rows.length) { + var empty = document.createElement("li"); + empty.className = "meta"; + empty.textContent = "—"; + list.appendChild(empty); + } else { + rows.forEach(function (row) { + var li = document.createElement("li"); + var amt = document.createElement("span"); + amt.className = "amt"; + amt.textContent = row.amount || "?"; + var meta = document.createElement("span"); + meta.className = "meta"; + meta.textContent = fmtCest(row.at_unix, row.at || row.at_iso) || ""; + li.appendChild(amt); + li.appendChild(meta); + list.appendChild(li); + }); + } + } + if (foot) { + foot.hidden = false; + foot.className = "stats-foot"; + foot.innerHTML = + 'Source: bank.hacktivism.ch' + + (d.generated_at_human ? " · " + d.generated_at_human : ""); + } + }) + .catch(function () { + tryNext(i + 1); + }); + } + tryNext(0); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", loadStats); + } else { + loadStats(); + } +})(); diff --git a/configs/systemd/caddy.service.d-no-resume.conf b/configs/systemd/caddy.service.d-no-resume.conf new file mode 100644 index 0000000..8a32924 --- /dev/null +++ b/configs/systemd/caddy.service.d-no-resume.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile diff --git a/configs/systemd/http-proxy.socket b/configs/systemd/http-proxy.socket new file mode 100644 index 0000000..1dda110 --- /dev/null +++ b/configs/systemd/http-proxy.socket @@ -0,0 +1,11 @@ +[Unit] +Description=Forward incoming HTTP :80 to Caddy on 127.0.0.1:9000 + +[Socket] +ListenStream=0.0.0.0:80 +ListenStream=[::]:80 +BindIPv6Only=yes +Service=https-proxy@9000.service + +[Install] +WantedBy=sockets.target diff --git a/configs/systemd/https-proxy.socket b/configs/systemd/https-proxy.socket new file mode 100644 index 0000000..bedf449 --- /dev/null +++ b/configs/systemd/https-proxy.socket @@ -0,0 +1,11 @@ +[Unit] +Description=Forward incoming HTTPS :443 to Caddy on 127.0.0.1:9001 + +[Socket] +ListenStream=0.0.0.0:443 +ListenStream=[::]:443 +BindIPv6Only=yes +Service=https-proxy@9001.service + +[Install] +WantedBy=sockets.target diff --git a/configs/systemd/https-proxy@.service b/configs/systemd/https-proxy@.service new file mode 100644 index 0000000..a61c775 --- /dev/null +++ b/configs/systemd/https-proxy@.service @@ -0,0 +1,5 @@ +[Unit] +Description=Proxy for incoming HTTPS traffic to port %i + +[Service] +ExecStart=/usr/lib/systemd/systemd-socket-proxyd 127.0.0.1:%i diff --git a/configs/taler-exchange-ansible/Containerfile b/configs/taler-exchange-ansible/Containerfile new file mode 100644 index 0000000..1f0e667 --- /dev/null +++ b/configs/taler-exchange-ansible/Containerfile @@ -0,0 +1,26 @@ +FROM docker.io/library/debian:trixie + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update -yqq && \ + apt-get install -yqq \ + ansible \ + cron \ + git \ + locales \ + openssh-server \ + python3 \ + python3-debian \ + systemd \ + whois # mkpasswd provided by whois package + +RUN mkdir -p /etc/ansible/facts.d + +##################################################################### +## WARNING: THIS ALLOWS FOR COMPLETELY UNAUTHENTICATED SSH SESSIONS # +####### FOR TESTING ENVIRONMENT ONLY! ############################### +RUN echo "root:$(mkpasswd -s vault_pass.txt +fi + +# Refuse obvious non-systemd targets early +if ! podman exec taler-hacktivism-exchange-ansible cat /proc/1/comm 2>/dev/null | grep -qx systemd; then + echo "error: container PID1 is not systemd. Run ./run-container-koopa.sh first." >&2 + exit 1 +fi + +exec ansible-playbook -v \ + --limit taler-hacktivism-exchange-ansible \ + -e @inventories/host_vars/taler-hacktivism-exchange-ansible/public.yml \ + -e @inventories/host_vars/taler-hacktivism-exchange-ansible/secrets.yml \ + playbooks/setup-hacktivism-goa.yml diff --git a/configs/taler-exchange-ansible/inventories-default.snippet b/configs/taler-exchange-ansible/inventories-default.snippet new file mode 100644 index 0000000..ae62297 --- /dev/null +++ b/configs/taler-exchange-ansible/inventories-default.snippet @@ -0,0 +1,6 @@ +# From live ~/ansible-taler-exchange/inventories/default (koopa lines only) +# hacktivism GOA exchange: podman container on koopa (API host:9011; ansible via podman) +taler-hacktivism-exchange-ansible ansible_connection=podman ansible_user=root + +[testing] +taler-hacktivism-exchange-ansible diff --git a/configs/taler-exchange-ansible/public.yml b/configs/taler-exchange-ansible/public.yml new file mode 100644 index 0000000..a580052 --- /dev/null +++ b/configs/taler-exchange-ansible/public.yml @@ -0,0 +1,31 @@ +--- +# Event currency (GOA) — aligned with regional-currency without fiat. +# Target: Caddy → host :9011 → container exchange. + +domain_name: "hacktivism.ch" +exchange_domain: "exchange.hacktivism.ch" +TARGET_HOST_NAME: "taler-hacktivism-exchange-ansible" + +taler_repo_suites: trixie + +# regional-currency style flags (systemd required) +require_systemd: true +do_conversion: false +# greenfield with new offline key: set true after offline key exists in container +do_offline: false +exchange_account_id: "1" +use_static_goa_coins: true +exchange_wire_method: x-taler-bank + +CURRENCY: GOA +CURRENCY_ROUND_UNIT: "GOA:0.00000001" +EXCHANGE_BASE_URL: "https://exchange.hacktivism.ch/" +EXCHANGE_HTTP_PORT: 9011 + +EXCHANGE_MASTER_PUB: TW6K5FXF81VYCAH0YWYX0SX98KBBSJ42VX27WAX01FTFH400QG10 + +EXCHANGE_TERMS_ETAG: "no-terms-v0" +EXCHANGE_PP_ETAG: "no-privacy-v0" + +EXCHANGE_BANK_ACCOUNT_PAYTO: "payto://x-taler-bank/bank.hacktivism.ch/exchange?receiver-name=GOA%20Exchange" +EXCHANGE_WIRE_GATEWAY_URL: "https://bank.hacktivism.ch/accounts/exchange/taler-wire-gateway/" diff --git a/configs/taler-exchange-ansible/run-container-koopa.sh b/configs/taler-exchange-ansible/run-container-koopa.sh new file mode 100755 index 0000000..88049c0 --- /dev/null +++ b/configs/taler-exchange-ansible/run-container-koopa.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Recreate taler-hacktivism-exchange-ansible with systemd (regional-currency style). +# Image: Containerfile (debian + systemd + ssh). Port: only 9011. +# Ansible: ansible_connection=podman → systemctl inside container. +set -euo pipefail + +NAME=taler-hacktivism-exchange-ansible +IMAGE=localhost/taler-hacktivism-exchange-ansible:base +ROOT="$(cd "$(dirname "$0")" && pwd)" + +cd "$ROOT" +podman build -f Containerfile -t "$IMAGE" + +echo "Stopping/removing existing $NAME (if any)..." +podman rm -f "$NAME" 2>/dev/null || true + +podman run -d \ + --name "$NAME" \ + --hostname "$NAME" \ + --network pasta \ + --systemd=always \ + -p 9011:9011 \ + --label org.hacktivism.service=taler-exchange \ + --label org.hacktivism.host_port=9011 \ + --label org.hacktivism.site=exchange.hacktivism.ch \ + --label org.hacktivism.currency=GOA \ + --label org.hacktivism.managed_by=ansible-taler-exchange \ + --label org.hacktivism.init=systemd \ + "$IMAGE" \ + /usr/sbin/init + +echo "waiting for systemd..." +for i in $(seq 1 30); do + if podman exec "$NAME" systemctl is-system-running 2>/dev/null | grep -Eq 'running|degraded'; then + break + fi + sleep 1 +done + +podman ps --filter "name=$NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}" +echo -n "PID1: " +podman exec "$NAME" cat /proc/1/comm +echo -n "systemd: " +podman exec "$NAME" systemctl is-system-running || true +echo "Next: ./deploy-hacktivism-goa.sh" diff --git a/configs/taler-exchange/README.md b/configs/taler-exchange/README.md new file mode 100644 index 0000000..44aa37e --- /dev/null +++ b/configs/taler-exchange/README.md @@ -0,0 +1,44 @@ +# Exchange config (inside ansible container) + +**Live container:** `taler-hacktivism-exchange-ansible` +**Image:** `localhost/taler-hacktivism-exchange-ansible:landing` +**Deploy tree on host:** `~/ansible-taler-exchange/` → see **`configs/taler-exchange-ansible/`** + +This directory mirrors **exchange software config** (overrides, coins, terms), not the Ansible wrapper itself. + +Container: **`taler-hacktivism-exchange-ansible`** · image tags `localhost/taler-hacktivism-exchange-ansible-live:9011` (live) and optional snapshot +Host port: **9011** (pasta) · public: **https://exchange.hacktivism.ch/** (Caddy → 9011) + +**Snapshot + restore (local tar, no registry):** +→ [`2026/2026-07-09--exchange-snapshot-and-restore.md`](../../2026/2026-07-09--exchange-snapshot-and-restore.md) + +## Terms of service (wallets) + +Wallets require a working `/terms` (and usually `/privacy`) with a +`Taler-Terms-Version` header. If missing, many apps hang on accept / +`isPending` during the first withdraw QR. + +This site uses **minimal stubs** — no formal legal ToS: + +| Config | Value | +|--------|--------| +| `TERMS_ETAG` | `no-terms-v0` | +| `PRIVACY_ETAG` | `no-privacy-v0` | +| files | `/var/lib/taler-exchange/terms/en/no-terms-v0.{html,txt,md}` etc. | + +Install / refresh inside the exchange container as root: + +```bash +/usr/local/bin/install_no_terms.sh +# ensure exchange-overrides.conf has TERMS_ETAG / PRIVACY_ETAG (see above) +runuser -u taler-exchange-httpd -- /usr/local/bin/start_exchange.sh --restart +curl -sS -D- http://127.0.0.1:9011/terms | head +``` + +Sources: `configs/taler-exchange/terms-src/` and +`scripts/taler-exchange/install_no_terms.sh`. + +## Secrets + +`ATTRIBUTE_ENCRYPTION_KEY` and wire/DB credentials are **not** in this tree. +See repo-root `SECRETS.md` and `koopa-admin-secrets/containers/taler-exchange/secrets/`. diff --git a/configs/taler-exchange/conf.d-exchange-coins.conf b/configs/taler-exchange/conf.d-exchange-coins.conf new file mode 100644 index 0000000..ad2ef70 --- /dev/null +++ b/configs/taler-exchange/conf.d-exchange-coins.conf @@ -0,0 +1,331 @@ +# GOA denominations for exchange.hacktivism.ch +# Units: 1 GOA = 1000 mGOA = 1_000_000 uGOA +# VALUE uses base currency; coin sections cover 1–2–5 ladders at u/m/whole scale. + +# --- uGOA (10^-6 GOA) --- +[coin_goa_0_000001] +VALUE = GOA:0.000001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_000002] +VALUE = GOA:0.000002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_000005] +VALUE = GOA:0.000005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_00001] +VALUE = GOA:0.00001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_00002] +VALUE = GOA:0.00002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_00005] +VALUE = GOA:0.00005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_0001] +VALUE = GOA:0.0001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_0002] +VALUE = GOA:0.0002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_0005] +VALUE = GOA:0.0005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +# --- mGOA (10^-3 GOA) --- +[coin_goa_0_001] +VALUE = GOA:0.001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_002] +VALUE = GOA:0.002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_005] +VALUE = GOA:0.005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_01] +VALUE = GOA:0.01 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_02] +VALUE = GOA:0.02 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_05] +VALUE = GOA:0.05 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_1] +VALUE = GOA:0.1 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_2] +VALUE = GOA:0.2 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_5] +VALUE = GOA:0.5 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +# --- whole GOA --- +[coin_goa_1_0] +VALUE = GOA:1 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_2_0] +VALUE = GOA:2 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_5_0] +VALUE = GOA:5 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_10_0] +VALUE = GOA:10 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +# --- large whole GOA (align with common_amounts 20…1000) --- +[coin_goa_20_0] +VALUE = GOA:20 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_50_0] +VALUE = GOA:50 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_100_0] +VALUE = GOA:100 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_200_0] +VALUE = GOA:200 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_1000_0] +VALUE = GOA:1000 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA diff --git a/configs/taler-exchange/exchange-overrides.conf b/configs/taler-exchange/exchange-overrides.conf new file mode 100644 index 0000000..6778410 --- /dev/null +++ b/configs/taler-exchange/exchange-overrides.conf @@ -0,0 +1,52 @@ +# Manual site overrides for exchange.hacktivism.ch (GOA exploration currency). +# Same role as /etc/taler-merchant/merchant-overrides.conf on taler-hacktivism. +# Do not edit overrides.conf (tooling). Do not edit package conf.d defaults. +# No conf.d/zz-* drop-ins. + +[exchange] +CURRENCY = GOA +CURRENCY_ROUND_UNIT = GOA:0.00000001 +TINY_AMOUNT = GOA:0.00000001 +DEFAULT_P2P_EXPIRATION = 14 days +BASE_URL = https://exchange.hacktivism.ch/ +SERVE = tcp +PORT = 9011 +MASTER_PUBLIC_KEY = TW6K5FXF81VYCAH0YWYX0SX98KBBSJ42VX27WAX01FTFH400QG10 +# Secret: ATTRIBUTE_ENCRYPTION_KEY — not in this repo. +# Live: /etc/taler-exchange/secrets/exchange-attribute-encryption.secret.conf +# Mirror: sibling koopa-admin-secrets/containers/taler-exchange/secrets/ +@inline-secret@ exchange-attribute-encryption ../secrets/exchange-attribute-encryption.secret.conf + +# Wallet ToS/PP: short "No Terms Required" stubs (install via install_no_terms.sh). +# Without this, /terms returns 501 "not configured" and apps hang on accept/isPending. +# HTML uses same dark palette as merchant dual-currency terms (bump etag to refresh cache). +TERMS_DIR = ${TALER_DATA_HOME}terms/ +TERMS_ETAG = no-terms-v2 +PRIVACY_DIR = ${TALER_DATA_HOME}terms/ +PRIVACY_ETAG = exchange-pp-swiss-v0 + +[currency-goa] +ENABLED = YES +name = "GOA exploration currency" +code = GOA +# SI display units. taler-exchange rejects scale keys outside [-8, 24] (no Ronna/Quetta). +fractional_input_digits = 8 +fractional_normal_digits = 0 +fractional_trailing_zero_digits = 0 +alt_unit_names_are_symbols = NO +alt_unit_names = {"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"} +common_amounts = "GOA:10 GOA:20 GOA:50 GOA:100 GOA:200 GOA:1000" + +### Disable package demonstrator currencies (like merchant-overrides) +[currency-kudos] +ENABLED = NO + +[currency-testkudos] +ENABLED = NO + +### Regional bank (libeufin, x-taler-bank) — no IBAN +[exchange-account-1] +PAYTO_URI = payto://x-taler-bank/bank.hacktivism.ch/exchange?receiver-name=GOA%20Exchange +ENABLE_CREDIT = YES +ENABLE_DEBIT = YES +@inline-secret@ exchange-accountcredentials-1 ../secrets/exchange-accountcredentials-1.secret.conf diff --git a/configs/taler-exchange/taler-exchange.conf.includes.txt b/configs/taler-exchange/taler-exchange.conf.includes.txt new file mode 100644 index 0000000..f8392e8 --- /dev/null +++ b/configs/taler-exchange/taler-exchange.conf.includes.txt @@ -0,0 +1,3 @@ +32:@inline-matching@ conf.d/*.conf +35:@inline@ overrides.conf +50:@inline@ exchange-overrides.conf diff --git a/configs/taler-exchange/terms-src/no-privacy-v0.en.rst b/configs/taler-exchange/terms-src/no-privacy-v0.en.rst new file mode 100644 index 0000000..9a3706e --- /dev/null +++ b/configs/taler-exchange/terms-src/no-privacy-v0.en.rst @@ -0,0 +1,15 @@ +No Privacy Policy Required +========================== + +This is an **experimental / exploration** GNU Taler exchange for **GOA**. + +**No formal privacy policy is required** for this demo service. + +Note (high level): + +* Wire transfers via the regional bank may identify bank account holders. +* The exchange processes withdrawals, deposits, and related operations + as required by the GNU Taler protocol. +* Logs may be kept for operation and debugging. + +Do not use this service if that is unacceptable. diff --git a/configs/taler-exchange/terms-src/no-terms-v0.en.rst b/configs/taler-exchange/terms-src/no-terms-v0.en.rst new file mode 100644 index 0000000..ff6b131 --- /dev/null +++ b/configs/taler-exchange/terms-src/no-terms-v0.en.rst @@ -0,0 +1,16 @@ +No Terms Required +================= + +This is an **experimental / exploration** GNU Taler exchange for the +currency **GOA** (hacktivism.ch). + +**No formal terms of service are required** to use this service. + +By withdrawing or using GOA coins you acknowledge that: + +* This service is for exploration and testing only. +* There is no guaranteed availability, support, or redemption. +* Operators may reset balances or change configuration without notice. +* Do not use real money you cannot afford to lose. + +If you do not agree, do not use this exchange. diff --git a/configs/taler-hacktivism-bank/README.md b/configs/taler-hacktivism-bank/README.md new file mode 100644 index 0000000..1f51875 --- /dev/null +++ b/configs/taler-hacktivism-bank/README.md @@ -0,0 +1,98 @@ +# Bank — live container `taler-hacktivism-bank` + +**Reflected from host koopa.** + +| Item | Live | +|------|------| +| Container | **`taler-hacktivism-bank`** | +| Image | **`localhost/taler-hacktivism-banking:live`** | +| Ports | **9012** (API), **9013** (landing) | +| Site | `https://bank.hacktivism.ch` | + +Config path in this repo: **`configs/taler-hacktivism-bank/`** (was `configs/taler-bank/`). +Scripts: `scripts/taler-bank/`. Secrets: `koopa-admin-secrets/.../taler-bank/` → `/root/bank-*-password.txt`. + +--- + +Image: `docker.io/library/debian:trixie` + package `libeufin-bank` +Publish: **9012/tcp only** +Labels: `org.hacktivism.service=taler-bank`, `host_port=9012`, `site=bank.hacktivism.ch`, `currency=GOA` + +## Role + +Regional **GOA Exploration Bank** for GNU Taler: + +- **No IBAN**, no libeufin-nexus, no fiat conversion (`ALLOW_CONVERSION = no`) +- **`WIRE_TYPE = x-taler-bank`** (payto://x-taler-bank/bank.hacktivism.ch/…) +- Wallet default exchange: `https://exchange.hacktivism.ch/` +- Mobile withdraw: Bank SPA → login → **Withdraw** → Taler Wallet (QR / deeplink) + +## Config layout (merchant/exchange pattern) + +| Path in container | Mirror | +|-------------------|--------| +| `libeufin-bank.conf` | includes: `libeufin-bank.conf.includes.txt` | +| **`bank-overrides.conf`** | **`bank-overrides.conf`** | + +## Manual start + +| Path | User | Role | +|------|------|------| +| `/root/start_base_services_for_taler_bank.sh` | **root** | logs, postgres, DB ensure → shell as `libeufin-bank` | +| `/usr/local/bin/start_bank.sh` | **libeufin-bank** | start / `--restart` `libeufin-bank serve` | +| `/usr/local/bin/check_bank-health.sh` | any | health | + +## Ports / Caddy + +| Port | Role | +|------|------| +| 9012 | libeufin-bank HTTP (podman pasta) | +| 9001 | Caddy HTTPS `bank.hacktivism.ch` → 127.0.0.1:9012 | + +**DNS:** A/AAAA for `bank.hacktivism.ch` → koopa public IP (required for TLS/ACME and phones). + +## Accounts (typical) + +| Login | Purpose | +|-------|---------| +| `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) | + +## Mobile withdraw (GOA) + +1. Install **GNU Taler Wallet** (Android / iOS / browser). +2. Open **https://bank.hacktivism.ch/** (after DNS + cert work). +3. Login as `explorer` (or registered user with debt headroom). +4. **Withdraw** → amount in GOA → open / scan with wallet. +5. Wallet uses `DEFAULT_EXCHANGE` = exchange.hacktivism.ch. + +Exchange must have wire account pointing at this bank (payto + wire gateway credentials) and offline **enable-account** for `/keys` withdraw to fully work. + +​**Details (diagram `/keys` ↔ Wire ↔ Account):** +`configs/taler-exchange/README.md` → section **/keys, Wire and Account**. + +## Public landing (withdraw QR) + +English intro + QR: **`https://bank.hacktivism.ch/`** → `/intro/` +Details: `configs/bank-landing/`. Refresh demo URI: `scripts/taler-bank/make-demo-withdraw-qr.sh`. + +### Wallet says “transfer money” — auto-confirm + +Normal Taler bank flow after the wallet selects the exchange: + +1. Withdrawal status → **`selected`** +2. Bank must **`POST …/withdrawals/$ID/confirm`** → wires GOA user → exchange +3. Exchange **wirewatch** sees credit → wallet can finish withdraw + +For the demo QR (`explorer`), automate step 2: + +```bash +# 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 & +``` + +Script: `scripts/taler-bank/auto-confirm-withdrawals.sh` +Watches IDs in `/var/www/bank-landing/withdraw.uri` and `withdraw-watch.ids`. \ No newline at end of file diff --git a/configs/taler-hacktivism-bank/bank-overrides.conf b/configs/taler-hacktivism-bank/bank-overrides.conf new file mode 100644 index 0000000..83fb887 --- /dev/null +++ b/configs/taler-hacktivism-bank/bank-overrides.conf @@ -0,0 +1,39 @@ +# Regional GOA bank — no IBAN / Nexus / fiat conversion. +# Container: /etc/libeufin/bank-overrides.conf +# Inlined last from /etc/libeufin/libeufin-bank.conf via @inline@ bank-overrides.conf + +[libeufin-bank] +CURRENCY = GOA +WIRE_TYPE = x-taler-bank +BASE_URL = https://bank.hacktivism.ch/ +NAME = "GOA Exploration Bank" +SERVE = tcp +PORT = 9012 +BIND_TO = 0.0.0.0 +DEFAULT_DEBT_LIMIT = GOA:100000 +ALLOW_REGISTRATION = yes +ALLOW_CONVERSION = no +REGISTRATION_BONUS = GOA:0 +WIRE_TRANSFER_FEES = GOA:0 +DEFAULT_EXCHANGE = https://exchange.hacktivism.ch/ + +[currency-goa] +ENABLED = YES +name = "GOA exploration currency" +code = GOA +fractional_input_digits = 8 +fractional_normal_digits = 0 +fractional_trailing_zero_digits = 0 +alt_unit_names_are_symbols = NO +alt_unit_names = {"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"} + +[currency-kudos] +ENABLED = NO +[currency-testkudos] +ENABLED = NO +[currency-euro] +ENABLED = NO +[currency-swiss-francs] +ENABLED = NO +[currency-netzbon] +ENABLED = NO diff --git a/configs/taler-hacktivism-bank/libeufin-bank.conf.includes.txt b/configs/taler-hacktivism-bank/libeufin-bank.conf.includes.txt new file mode 100644 index 0000000..735e78f --- /dev/null +++ b/configs/taler-hacktivism-bank/libeufin-bank.conf.includes.txt @@ -0,0 +1,7 @@ +# /etc/libeufin/libeufin-bank.conf (package entrypoint) +# Site overrides last: +@inline@ bank-overrides.conf + +# DB (package default in /usr/share/libeufin/config.d/bank.conf): +# [libeufin-bankdb-postgres] +# CONFIG = postgres:///libeufin diff --git a/configs/taler-hacktivism/README.md b/configs/taler-hacktivism/README.md new file mode 100644 index 0000000..127cd32 --- /dev/null +++ b/configs/taler-hacktivism/README.md @@ -0,0 +1,63 @@ +# taler-merchant (container `taler-hacktivism`) + +Image: `localhost/taler-hacktivism-live:landing` +Publish: **9010/tcp only** +Labels: `org.hacktivism.service=taler-merchant`, `host_port=9010`, `site=taler.hacktivism.ch` + +## Config layout (same pattern as exchange) + +| Path in container | Mirror in this dir | +|-------------------|--------------------| +| `taler-merchant.conf` | includes documented in `taler-merchant.conf.includes.txt` | +| `overrides.conf` | tooling — do not edit (not mirrored) | +| **`merchant-overrides.conf`** | **`merchant-overrides.conf`** | +| `conf.d/merchant.conf` | `conf.d-merchant.conf` | +| nginx `sites-available/taler-merchant` | `nginx-taler-merchant.conf` | + +Secrets (`secrets/*.secret.conf`, SMTP password) are **not** mirrored. + +## Manual start (merchant model) + +| Path | User | Role | +|------|------|------| +| `/root/start_base_services_for_taler.sh` | **root** | logs, SMS env, postgres → shell as `taler-merchant-httpd` | +| `/usr/local/bin/start_merchant.sh` | **taler-merchant-httpd** | start / `--restart` httpd + helpers | +| `/usr/local/bin/check_merchant-health.sh` | any | health (socket + nginx) | + +Also under `/usr/local/bin/`: email/SMS helpers, `stats--merchant-payments.sh`. +Certbot loop: `/root/scripts/certbot_renew.sh` (started from base script). + +Git scripts: `scripts/taler-merchant/`. + +## Site settings (overrides) + +- **Default currency:** CHF (`[taler]` / `[merchant]`) +- **Exchanges (multi-currency):** + - CHF → `exchange.taler-ops.ch` (package default `[merchant-exchange-chf]` in `tops.conf` — do not redeclare as `merchant-exchange-tops`) + - GOA → `exchange.hacktivism.ch` (`[merchant-exchange-goa]` in `merchant-overrides.conf`) +- **SERVE:** unix socket → nginx :9010 SSL → Caddy :9001 +- **Self-provisioning:** YES; **no mandatory TAN** (SMS off) +- **Terms:** `TERMS_ETAG = merchant-tos-dual-v0` — short dual-currency notice (GOA explorational + CHF real). Sources in `terms-src/`; install with `scripts/taler-merchant/install_dual_terms.sh` inside the container, then restart `taler-merchant-httpd`. Public: `https://taler.hacktivism.ch/terms` + +Wire status is **per account × exchange**: an `x-taler-bank` GOA payto shows `ready` for hacktivism and `unsupported` for taler-ops (expected). CHF/IBAN accounts use taler-ops. + +### Container → public exchange (pasta) + +Inside pasta, `exchange.hacktivism.ch` must not resolve to **127.0.0.1** (merchant nginx self-signed). Pin public A in container `/etc/hosts`: + +``` +212.51.151.254 exchange.hacktivism.ch bank.hacktivism.ch taler.hacktivism.ch +``` + +After conf/hosts changes: `taler-merchant-exchangekeyupdate -t` and `taler-merchant-kyccheck -t`.## Ports + +| Port | Role | +|------|------| +| 9010 | nginx TLS in container (podman) | +| unix sock | `taler-merchant-httpd` | + +## Demo instance (2026-07-09) + +Self-provisioned shop **`goa-demo-cp4zqk`**, bank account same username, payto linked, seed **GOA:5000**. + +Details + credential paths: **`demo-instance-goa-demo-cp4zqk.md`**. diff --git a/configs/taler-hacktivism/conf.d-merchant.conf b/configs/taler-hacktivism/conf.d-merchant.conf new file mode 100644 index 0000000..e0fd7ef --- /dev/null +++ b/configs/taler-hacktivism/conf.d-merchant.conf @@ -0,0 +1,7 @@ +# Package drop-in: /etc/taler-merchant/conf.d/merchant.conf +# Read secret sections into configuration, but only +# if we have permission to do so. +@inline-secret@ merchantdb-postgres ../secrets/merchant-db.secret.conf + +[merchant] +SERVE = systemd diff --git a/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md b/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md new file mode 100644 index 0000000..95aa009 --- /dev/null +++ b/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md @@ -0,0 +1,63 @@ +# Demo merchant instance `goa-demo-cp4zqk` + +Created **2026-07-09** via self-provisioning (`POST /instances`, no SMS TAN). + +## Merchant + +| Field | Value | +|-------|--------| +| SPA | https://taler.hacktivism.ch/webui/ | +| Instance ID | `goa-demo-cp4zqk` | +| Display name | GOA Demo Shop cp4zqk | +| Instance password | **not in this repo** — `koopa-admin-secrets/koopa/host-root/taler-merchant/merchant-goa-demo-cp4zqk-password.txt` → `/root/…` | +| Backend base | `https://taler.hacktivism.ch/instances/goa-demo-cp4zqk/` | +| Self-provisioning | YES; `mandatory_tan_channels: []` | + +Login: SPA → instance id + password (or bearer `secret-token:$PASSWORD`). + +## Bank account (linked) + +| Field | Value | +|-------|--------| +| Bank SPA | https://bank.hacktivism.ch/webui/ | +| Username | `goa-demo-cp4zqk` | +| Password | **not in this repo** — `koopa-admin-secrets/koopa/host-root/taler-bank/bank-goa-demo-cp4zqk-password.txt` → `/root/…` | +| Payto | `payto://x-taler-bank/bank.hacktivism.ch/goa-demo-cp4zqk?receiver-name=GOA%20Demo%20Shop%20cp4zqk` | +| Seed balance | **GOA:5000** (admin credit) | +| Debit threshold | GOA:100000 (default) | + +Merchant wire account (private API): + +```http +POST /instances/goa-demo-cp4zqk/private/accounts +Authorization: Bearer secret-token:… +{ "payto_uri": "payto://x-taler-bank/bank.hacktivism.ch/goa-demo-cp4zqk?receiver-name=GOA%20Demo%20Shop%20cp4zqk" } +``` + +Verified: account **active**, `h_wire` present. + +### Credit facade (automatic settlement import) + +Merchant **wirewatch** imports bank credits via the **Taler Revenue API** (not the exchange wire-gateway): + +| Field | Value | +|-------|--------| +| `credit_facade_url` | `https://bank.hacktivism.ch/accounts/goa-demo-cp4zqk/taler-revenue/` | +| credentials | **bearer** bank access token (Basic is rejected on history) | +| process | `taler-merchant-wirewatch` (+ `depositcheck`) | + +Setup helper (host root): `scripts/taler-merchant/setup_credit_facade.sh` + +Without this facade, bank credits still land (exchange→merchant), but orders stay `wired=false` / `private/transfers` empty. + +## Related config + +- Exchanges: **CHF** `taler-ops` + **GOA** `exchange.hacktivism.ch` (both enabled) +- SMS TAN: off +- Bank DEFAULT_EXCHANGE: `https://exchange.hacktivism.ch/` +- Container hosts: public IP `212.51.151.254` for `exchange.hacktivism.ch` (not 127.0.0.1) + +### SPA / KYC for this GOA payto + +- **hacktivism / GOA:** `status=ready` (settlement path) +- **taler-ops / CHF:** `unsupported-account` — expected (x-taler-bank vs CHF/IBAN) diff --git a/configs/taler-hacktivism/merchant-overrides.conf b/configs/taler-hacktivism/merchant-overrides.conf new file mode 100644 index 0000000..164eeac --- /dev/null +++ b/configs/taler-hacktivism/merchant-overrides.conf @@ -0,0 +1,71 @@ +# Site overrides for taler.hacktivism.ch (container taler-hacktivism). +# Multi-currency: CHF (taler-ops) + GOA (local exchange). +# Do not edit overrides.conf (tooling). + +[taler] +CURRENCY = CHF + +### Disable package demonstrator KUDOS only +[merchant-exchange-kudos] +DISABLED = YES + +[currency-KUDOS] +ENABLED = NO + +[merchant] +CURRENCY = CHF +DATABASE = postgres +# Dual-currency short notice (GOA explorational + CHF real). +# Install: scripts/taler-merchant/install_dual_terms.sh (inside container). +# Bump etag when text changes so wallets/browsers drop long-lived /terms cache. +TERMS_ETAG = merchant-tos-dual-v2 +TERMS_DIR = ${TALER_DATA_HOME}terms/ +# Swiss FADP privacy (install: scripts/taler-merchant/install_swiss_privacy.sh) +PRIVACY_ETAG = merchant-pp-swiss-v0 +PRIVACY_DIR = ${TALER_DATA_HOME}terms/ + +UNIXPATH = /var/run/taler-merchant/httpd/merchant-http.sock +UNIXPATH_MODE = 660 +SERVE = unix + +DEFAULT_PERSONA = point-of-sale + +# Demo short deadlines (package: pay 1d, refund 15d, wire 1w). +# Wire happens after refund deadline; refund/pay windows also kept short. +DEFAULT_PAY_DELAY = 1 minute +DEFAULT_REFUND_DELAY = 30 s +DEFAULT_WIRE_TRANSFER_DELAY = 30 s + +ENABLE_SELF_PROVISIONING = YES +# No mandatory TAN (SMS auth off) +MANDATORY_TAN_CHANNELS = +HELPER_EMAIL = /usr/local/bin/taler-hacktivism-email-helper.sh +# HELPER_SMS = /usr/local/bin/taler-hacktivism-sms-helper-wrapper.sh + +[merchantdb-postgres] +CONFIG = postgres:///taler-merchant + +### CHF — package default in /usr/share/taler-merchant/config.d/tops.conf +### section [merchant-exchange-chf] → https://exchange.taler-ops.ch/ +### Do NOT redeclare the same EXCHANGE_BASE_URL under another section name +### (exchangekeyupdate: "configured in multiple sections"). +### Ensure package CHF stays enabled (only disable KUDOS above). + +### GOA — local exchange.hacktivism.ch (x-taler-bank / bank.hacktivism.ch) +[merchant-exchange-goa] +EXCHANGE_BASE_URL = https://exchange.hacktivism.ch/ +MASTER_KEY = TW6K5FXF81VYCAH0YWYX0SX98KBBSJ42VX27WAX01FTFH400QG10 +CURRENCY = GOA + +[currency-goa] +ENABLED = YES +name = "GOA exploration currency" +code = GOA +fractional_input_digits = 8 +fractional_normal_digits = 0 +fractional_trailing_zero_digits = 0 +# Keep in sync with exchange-overrides [currency-goa] (wallet compares unit maps). +# Scale keys only in [-8, 24] (exchange rejects outside that range). +alt_unit_names_are_symbols = NO +alt_unit_names = {"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"} +common_amounts = "GOA:10 GOA:20 GOA:50 GOA:100 GOA:200 GOA:1000" diff --git a/configs/taler-hacktivism/nginx-taler-merchant.conf b/configs/taler-hacktivism/nginx-taler-merchant.conf new file mode 100644 index 0000000..73e6cbc --- /dev/null +++ b/configs/taler-hacktivism/nginx-taler-merchant.conf @@ -0,0 +1,50 @@ +# Container: /etc/nginx/sites-available/taler-merchant +# Enabled: sites-enabled/taler-merchant → this file +# Host publish: 9010/tcp (podman pasta). Caddy terminates public HTTPS and +# reverse_proxies to https://127.0.0.1:9010 (see configs/caddy/Caddyfile). + +server { + listen 9010 ssl; + listen [::]:9010 ssl; + + server_name taler.hacktivism.ch; + + ssl_certificate /etc/letsencrypt/live/taler.hacktivism.ch/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/taler.hacktivism.ch/privkey.pem; + + access_log /var/log/nginx/merchant.log; + error_log /var/log/nginx/merchant.err; + + # Public landing first (Caddy should also redir / → /intro/; this covers :9010) + location = / { + return 302 /intro/; + } + + # Merchant-httpd only serves /terms and /privacy without trailing slash + location = /terms/ { + return 302 /terms; + } + location = /privacy/ { + return 302 /privacy; + } + + location / { + proxy_pass http://unix:/var/run/taler-merchant/httpd/merchant-http.sock; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host "taler.hacktivism.ch"; + proxy_set_header X-Forwarded-Proto "https"; + } +} + +server { + # ACME HTTP-01 is handled by Caddy :9000; this block kept for reference. + # listen 9010; + # listen [::]:9010; + + server_name taler.hacktivism.ch; + + location /.well-known/acme-challenge/ { + alias /var/www/letsencrypt/.well-known/acme-challenge/; + } +} diff --git a/configs/taler-hacktivism/taler-merchant.conf.includes.txt b/configs/taler-hacktivism/taler-merchant.conf.includes.txt new file mode 100644 index 0000000..367b443 --- /dev/null +++ b/configs/taler-hacktivism/taler-merchant.conf.includes.txt @@ -0,0 +1,5 @@ +# From /etc/taler-merchant/taler-merchant.conf (include order) +@inline-matching@ conf.d/*.conf +@inline@ overrides.conf +# Site overrides last (glob; file is merchant-overrides.conf) +@inline-matching@ merchant-overrides.conf diff --git a/configs/taler-hacktivism/terms-src/merchant-tos-dual-v0.en.rst b/configs/taler-hacktivism/terms-src/merchant-tos-dual-v0.en.rst new file mode 100644 index 0000000..29bc489 --- /dev/null +++ b/configs/taler-hacktivism/terms-src/merchant-tos-dual-v0.en.rst @@ -0,0 +1,43 @@ +No Formal Terms · Dual Currency Notice +====================================== + +This is a **self-hosted GNU Taler merchant backend** at +``taler.hacktivism.ch`` (hacktivism.ch). + +**No formal terms of service** from Taler Operations AG (or any other +third-party portal operator) apply to this instance. This short notice +is the site policy for using the backend. + +Dual currency +------------- + +This backend is configured for **two currencies at once**: + +* **GOA** — explorational / experimental currency of the local + stack (``exchange.hacktivism.ch``, ``bank.hacktivism.ch``). + GOA is **not** legal tender. It has **no guaranteed real-world + value**, redemption, or convertibility. +* **CHF** — Swiss francs, a **real** currency. CHF amounts are + real money settled via the CHF exchange configured on this host + (taler-ops / TOPS infrastructure as deployed). Treat CHF with + the seriousness of ordinary payments. + +By creating a merchant instance, accepting payments, or otherwise +using this service you acknowledge that: + +* GOA is for exploration and testing only. +* CHF involves real money — only use funds you control and can + afford to risk on a self-hosted experimental stack. +* There is no guaranteed availability, support, or uptime. +* Operators may reset GOA state, change configuration, delete + instance data, or interrupt service without notice. +* Software is provided as-is, without warranty. + +If you do not agree, do not use this merchant backend. + +Related +------- + +* Exchange (GOA): https://exchange.hacktivism.ch/terms +* Bank intro: https://bank.hacktivism.ch/intro/ +* Merchant intro: https://taler.hacktivism.ch/intro/ diff --git a/configs/tops/Caddyfile.snippet b/configs/tops/Caddyfile.snippet new file mode 100644 index 0000000..a566ae5 --- /dev/null +++ b/configs/tops/Caddyfile.snippet @@ -0,0 +1,29 @@ +# Paste into host Caddyfile (/etc/caddy/Caddyfile) and reload Caddy. +# Ports: 9090 ng1 | 9091 ng2 | 9092 ng3 + +tops.ng1.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9090 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +tops.ng2.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9091 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +tops.ng3.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9092 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} diff --git a/configs/tops/README.md b/configs/tops/README.md new file mode 100644 index 0000000..2fbc439 --- /dev/null +++ b/configs/tops/README.md @@ -0,0 +1,73 @@ +# tops — `koopa-tops-ng1` … `ng3` + +| Item | Value | +|------|--------| +| Live | `/home/hernani/koopa-tops/` | +| Containers | `koopa-tops-ng1`, `koopa-tops-ng2`, `koopa-tops-ng3` | +| Image | `docker.io/library/nginx:1.27-alpine` | +| Compose | `compose.yml` (this dir) | +| Secrets | none | + +Site HTML is **not** in this repo (container wiring only). + +## Containers + +| Container | Host port | Caddy host | +|-----------|-----------|------------| +| `koopa-tops-ng1` | **9090** | `tops.ng1.hacktivism.ch` | +| `koopa-tops-ng2` | **9091** | `tops.ng2.hacktivism.ch` | +| `koopa-tops-ng3` | **9092** | `tops.ng3.hacktivism.ch` | + +Caddy snippet: `Caddyfile.snippet`. + + +## nginx + +Mirror: `nginx-default.conf` → mounted as `/etc/nginx/conf.d/default.conf` in each container. + +### Cache (always fresh) + +Preview sites must not be cached by browsers/proxies: + +- `Cache-Control: no-store, no-cache, must-revalidate, max-age=0, private` +- `Pragma: no-cache` / `Expires: 0` +- `etag off` · `if_modified_since off` · `expires -1` + +After editing the conf on the host (bind mount), reload nginx **inside** the container (do not stop the container): + +```bash +for c in koopa-tops-ng1 koopa-tops-ng2 koopa-tops-ng3; do + podman exec "$c" nginx -t && podman exec "$c" nginx -s reload +done +``` + +## Autostart + +User unit `container-koopa-tops.service` (mirror in this dir) starts all three via compose. + +```bash +# as hernani on koopa +mkdir -p ~/.config/systemd/user +cp ~/koopa-tops/deploy/container-koopa-tops.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now container-koopa-tops.service +``` + +Needs linger so the unit runs without an interactive login: + +```bash +# once as root, if needed +loginctl enable-linger hernani +loginctl show-user hernani -p Linger +``` + +Check: + +```bash +systemctl --user status container-koopa-tops.service +podman ps --filter name=koopa-tops +``` + +## Related + +`configs/ports.md` · `host/overview/LIVE.md` diff --git a/configs/tops/compose.yml b/configs/tops/compose.yml new file mode 100644 index 0000000..60da5c1 --- /dev/null +++ b/configs/tops/compose.yml @@ -0,0 +1,61 @@ +# koopa-tops — host ports 9090–9092 +# Live: /home/hernani/koopa-tops/ +# Usage: cd ~/koopa-tops && podman compose -f deploy/compose.yml up -d + +services: + tops-ng1: + image: docker.io/library/nginx:1.27-alpine + container_name: koopa-tops-ng1 + restart: unless-stopped + ports: + - "9090:80" + volumes: + - ../ng1:/usr/share/nginx/html:ro + labels: + org.hacktivism.service: tops-ng1 + org.hacktivism.host_port: "9090" + 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/"] + interval: 30s + timeout: 5s + retries: 3 + + tops-ng2: + image: docker.io/library/nginx:1.27-alpine + container_name: koopa-tops-ng2 + restart: unless-stopped + ports: + - "9091:80" + volumes: + - ../ng2:/usr/share/nginx/html:ro + labels: + org.hacktivism.service: tops-ng2 + org.hacktivism.host_port: "9091" + 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/"] + interval: 30s + timeout: 5s + retries: 3 + + tops-ng3: + image: docker.io/library/nginx:1.27-alpine + container_name: koopa-tops-ng3 + restart: unless-stopped + ports: + - "9092:80" + volumes: + - ../ng3:/usr/share/nginx/html:ro + labels: + org.hacktivism.service: tops-ng3 + org.hacktivism.host_port: "9092" + 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/"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/configs/tops/container-koopa-tops.service b/configs/tops/container-koopa-tops.service new file mode 100644 index 0000000..5a22cab --- /dev/null +++ b/configs/tops/container-koopa-tops.service @@ -0,0 +1,35 @@ +# tops.ng1–ng3 (podman compose) — start on user session / boot (linger) +# +# On koopa as hernani: +# mkdir -p ~/.config/systemd/user +# cp deploy/container-koopa-tops.service ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now container-koopa-tops.service +# +# Once as root (if not already): loginctl enable-linger hernani + +[Unit] +Description=Podman koopa-tops (tops.ng1–ng3 static nginx) +Documentation=file:///home/hernani/koopa-tops/README.md +Wants=network-online.target +After=network-online.target +RequiresMountsFor=%t/containers /home/hernani/koopa-tops + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/home/hernani/koopa-tops +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=on-failure +RestartSec=15 +TimeoutStartSec=120 +TimeoutStopSec=60 + +ExecStartPre=/bin/bash -c "chmod -R a+rX /home/hernani/koopa-tops/ng1 /home/hernani/koopa-tops/ng2 /home/hernani/koopa-tops/ng3 2>/dev/null || true" + +ExecStart=/bin/bash -c "if podman compose version >/dev/null 2>&1; then podman compose -f deploy/compose.yml up -d; elif command -v podman-compose >/dev/null 2>&1; then podman-compose -f deploy/compose.yml up -d; else echo \"no podman compose\" >&2; exit 1; fi" + +ExecStop=/bin/bash -c "if podman compose version >/dev/null 2>&1; then podman compose -f deploy/compose.yml stop; elif command -v podman-compose >/dev/null 2>&1; then podman-compose -f deploy/compose.yml stop; else podman stop koopa-tops-ng1 koopa-tops-ng2 koopa-tops-ng3 2>/dev/null || true; fi" + +[Install] +WantedBy=default.target diff --git a/configs/tops/nginx-default.conf b/configs/tops/nginx-default.conf new file mode 100644 index 0000000..286110f --- /dev/null +++ b/configs/tops/nginx-default.conf @@ -0,0 +1,47 @@ +# Shared nginx config for tops.ng1–ng3 static previews. +# Language root redirect mirrors taler-ops.ch (Accept-Language → /en|/de|/fr). +# Cache: always revalidate / never store — design previews must show latest HTML/assets. + +map $http_accept_language $accept_lang { + default en; + ~*^de de; + ~*^fr fr; + ~*^en en; +} + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Relative Location: so Caddy/HTTPS clients stay on https://host/... + absolute_redirect off; + port_in_redirect off; + server_name_in_redirect off; + + # Never serve stale preview content from browser/proxy caches + etag off; + if_modified_since off; + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0, private" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + + # Like https://taler-ops.ch/ — bare root picks language from Accept-Language + location = / { + return 302 /$accept_lang/; + } + location = /index.html { + return 302 /$accept_lang/; + } + + # Bare page names that exist under /{lang}/ (ng2; 302→404 on ng1 if missing) + location ~ ^/(merchants|users|users-[a-z]+)\.html$ { + return 302 /$accept_lang$uri; + } + + location / { + try_files $uri $uri/ $uri/index.html =404; + } +} diff --git a/configs/tor/Containerfile b/configs/tor/Containerfile new file mode 100644 index 0000000..b4ef558 --- /dev/null +++ b/configs/tor/Containerfile @@ -0,0 +1,10 @@ +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 +EXPOSE 8080 9051 +ENTRYPOINT ["/usr/bin/tor"] +CMD ["-f", "/etc/tor/torrc", "--runasdaemon", "0"] diff --git a/configs/tor/README.md b/configs/tor/README.md new file mode 100644 index 0000000..cd14262 --- /dev/null +++ b/configs/tor/README.md @@ -0,0 +1,53 @@ +# Tor relay — koopa (`KoopaRelay`) + +Live: **podman** container **`koopa-tor-relay`** (rootless hernani, `--network host`). +Host layout: `~/koopa-tor-relay/{torrc,data,log,build}`. +Mirror in this repo: `configs/tor/`. + +| Setting | Value | +|---------|--------| +| Nickname | `KoopaRelay` | +| Fingerprint | `02C7EECCDF7814FBA2DB1CBAED000D06E8A32EE8` | +| ORPort | **8080** (IPv4 + IPv6; VeciGate WAN DNAT → koopa:8080) | +| ControlPort | **127.0.0.1:9051** | +| ExitRelay | **0** (non-exit) | +| SocksPort | **0** | +| MyFamily | `52BB94DDC1292F950CF728708AC48523E018A718` | +| Bandwidth* | 2000 MBytes rate/burst | +| Image | `localhost/koopa-tor-relay:latest` (Debian bookworm + tor + nyx) | + +## Files + +| File | Role | +|------|------| +| `torrc` | Active policy (mounted read-only into container) | +| `Containerfile` | Image build (tor, nyx, tor-geoipdb) | +| `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) | + +## Ops + +```bash +# status +podman ps --filter name=koopa-tor-relay +systemctl --user status container-koopa-tor-relay +ss -lntp | grep -E '8080|9051' +tail -f ~/koopa-tor-relay/log/notices.log + +# monitor +podman exec -it koopa-tor-relay nyx +``` + +### Cutover (host package → container) + +1. `sudo systemctl stop tor && sudo systemctl disable tor` (prefer `mask` so it never returns) +2. `sudo ~/koopa-tor-relay/migrate-identity.sh` (or copy of this script) +3. `podman start koopa-tor-relay` / user unit enable +4. Confirm fingerprint and ORPort reachability + +Do **not** run host `tor.service` and the container at the same time. + +Monitoring helpers: `scripts/monitoring/tor_*.sh`. +Firewall: `configs/firewalld/public-ports.md` (**8080/tcp**). +Router: `../vecigate-admin-log` (WAN :8080 → koopa). diff --git a/configs/tor/container-koopa-tor-relay.service b/configs/tor/container-koopa-tor-relay.service new file mode 100644 index 0000000..051b56e --- /dev/null +++ b/configs/tor/container-koopa-tor-relay.service @@ -0,0 +1,28 @@ +# container-koopa-tor-relay.service +# Install on koopa as hernani: +# mkdir -p ~/.config/systemd/user +# cp configs/tor/container-koopa-tor-relay.service ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now container-koopa-tor-relay.service +# Requires: loginctl enable-linger hernani (root, once) +# +# autogenerated by Podman 5.8.3 (paths are host-specific; regenerate if container id changes) + +[Unit] +Description=Podman container-koopa-tor-relay.service +Documentation=man:podman-generate-systemd(1) +Wants=network-online.target +After=network-online.target +RequiresMountsFor=/run/user/1000/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=always +TimeoutStopSec=70 +ExecStart=/usr/bin/podman start koopa-tor-relay +ExecStop=/usr/bin/podman stop -t 10 koopa-tor-relay +ExecStopPost=/usr/bin/podman stop -t 10 koopa-tor-relay +Type=forking + +[Install] +WantedBy=default.target diff --git a/configs/tor/migrate-identity.sh b/configs/tor/migrate-identity.sh new file mode 100755 index 0000000..344fddd --- /dev/null +++ b/configs/tor/migrate-identity.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Copy host Tor identity → container data. Works with: sudo ./migrate-identity.sh +set -euo pipefail + +if [[ "$(id -u)" -ne 0 ]]; then + exec sudo -H "$0" "$@" +fi + +OWNER="${SUDO_USER:-hernani}" +if [[ "$OWNER" == root ]]; then OWNER=hernani; fi +BASE="$(getent passwd "$OWNER" | cut -d: -f6)/koopa-tor-relay" +SRC=/var/lib/tor +DST="$BASE/data" + +if [[ ! -d "$BASE" ]]; then + echo "ERROR: missing $BASE" >&2 + exit 1 +fi +if systemctl is-active --quiet tor 2>/dev/null; then + echo "ERROR: stop host tor first: systemctl stop tor" >&2 + exit 1 +fi +if command -v podman >/dev/null 2>&1; then + if runuser -u "$OWNER" -- podman ps --format "{{.Names}}" 2>/dev/null | grep -qx koopa-tor-relay; then + echo "ERROR: stop container first: podman stop koopa-tor-relay" >&2 + exit 1 + fi +fi + +echo "Copying $SRC → $DST (identity keys as $OWNER)…" +mkdir -p "$DST" +rsync -aHAX --delete "$SRC"/ "$DST"/ +chown -R "${OWNER}:${OWNER}" "$DST" +chmod 700 "$DST" + +echo "--- fingerprint ---" +[[ -f "$DST/fingerprint" ]] && cat "$DST/fingerprint" || echo "(no fingerprint file yet)" +[[ -f "$DST/fingerprint-ed25519" ]] && cat "$DST/fingerprint-ed25519" || true +echo "OK — $DST ready. Then: podman start koopa-tor-relay" diff --git a/configs/tor/torrc b/configs/tor/torrc new file mode 100644 index 0000000..63cd8cf --- /dev/null +++ b/configs/tor/torrc @@ -0,0 +1,16 @@ +# KoopaRelay — active policy (podman koopa-tor-relay, host network) +# Live paths: ~/koopa-tor-relay/{torrc,data,log} on koopa +Log notice file /var/log/tor/notices.log +Nickname KoopaRelay +ContactInfo info+koopa@hacktivism.ch +# dual-stack ORPort (IPv4 + IPv6) +ORPort 8080 +ControlPort 127.0.0.1:9051 +ExitRelay 0 +SocksPort 0 +MyFamily 52BB94DDC1292F950CF728708AC48523E018A718 +BandwidthRate 2000 MBytes +BandwidthBurst 2000 MBytes +RelayBandwidthRate 2000 MBytes +RelayBandwidthBurst 2000 MBytes +DataDirectory /var/lib/tor diff --git a/configs/tor/torrc.minimal b/configs/tor/torrc.minimal new file mode 100644 index 0000000..a05f52c --- /dev/null +++ b/configs/tor/torrc.minimal @@ -0,0 +1,192 @@ +## Configuration file for a typical Tor user +## Last updated 9 October 2013 for Tor 0.2.5.2-alpha. +## (may or may not work for much older or much newer versions of Tor.) +## +## Lines that begin with "## " try to explain what's going on. Lines +## that begin with just "#" are disabled commands: you can enable them +## by removing the "#" symbol. +## +## See 'man tor', or https://www.torproject.org/docs/tor-manual.html, +## for more options you can use in this file. +## +## Tor will look for this file in various places based on your platform: +## https://www.torproject.org/docs/faq#torrc + +## Tor opens a socks proxy on port 9050 by default -- even if you don't +## configure one below. Set "SocksPort 0" if you plan to run Tor only +## as a relay, and not make any local application connections yourself. +#SocksPort 9050 # Default: Bind to localhost:9050 for local connections. +#SocksPort 192.168.0.1:9100 # Bind to this address:port too. + +## Entry policies to allow/deny SOCKS requests based on IP address. +## First entry that matches wins. If no SocksPolicy is set, we accept +## all (and only) requests that reach a SocksPort. Untrusted users who +## can access your SocksPort may be able to learn about the connections +## you make. +#SocksPolicy accept 192.168.0.0/16 +#SocksPolicy reject * + +## Logs go to stdout at level "notice" unless redirected by something +## else, like one of the below lines. You can have as many Log lines as +## you want. +## +## We advise using "notice" in most cases, since anything more verbose +## may provide sensitive information to an attacker who obtains the logs. +## +## Send all messages of level 'notice' or higher to /var/log/tor/notices.log +#Log notice file /var/log/tor/notices.log +## Send every possible message to /var/log/tor/debug.log +#Log debug file /var/log/tor/debug.log +## Use the system log instead of Tor's logfiles +#Log notice syslog +## To send all messages to stderr: +#Log debug stderr + +## Uncomment this to start the process in the background... or use +## --runasdaemon 1 on the command line. This is ignored on Windows; +## see the FAQ entry if you want Tor to run as an NT service. +#RunAsDaemon 1 + +## The directory for keeping all the keys/etc. By default, we store +## things in $HOME/.tor on Unix, and in Application Data\tor on Windows. +#DataDirectory /var/lib/tor + +## The port on which Tor will listen for local connections from Tor +## controller applications, as documented in control-spec.txt. +#ControlPort 9051 +## If you enable the controlport, be sure to enable one of these +## authentication methods, to prevent attackers from accessing it. +#HashedControlPassword 16:872860B76453A77D60CA2BB8C1A7042072093276A3D701AD684053EC4C +#CookieAuthentication 1 + +############### This section is just for location-hidden services ### + +## Once you have configured a hidden service, you can look at the +## contents of the file ".../hidden_service/hostname" for the address +## to tell people. +## +## HiddenServicePort x y:z says to redirect requests on port x to the +## address y:z. + +#HiddenServiceDir /var/lib/tor/hidden_service/ +#HiddenServicePort 80 127.0.0.1:80 + +#HiddenServiceDir /var/lib/tor/other_hidden_service/ +#HiddenServicePort 80 127.0.0.1:80 +#HiddenServicePort 22 127.0.0.1:22 + +################ This section is just for relays ##################### +# +## See https://www.torproject.org/docs/tor-doc-relay for details. + +## Required: what port to advertise for incoming Tor connections. +#ORPort 9001 +## If you want to listen on a port other than the one advertised in +## ORPort (e.g. to advertise 443 but bind to 9090), you can do it as +## follows. You'll need to do ipchains or other port forwarding +## yourself to make this work. +#ORPort 443 NoListen +#ORPort 127.0.0.1:9090 NoAdvertise + +## The IP address or full DNS name for incoming connections to your +## relay. Leave commented out and Tor will guess. +#Address noname.example.com + +## If you have multiple network interfaces, you can specify one for +## outgoing traffic to use. +# OutboundBindAddress 10.0.0.5 + +## A handle for your relay, so people don't have to refer to it by key. +#Nickname ididnteditheconfig + +## Define these to limit how much relayed traffic you will allow. Your +## own traffic is still unthrottled. Note that RelayBandwidthRate must +## be at least 20 KB. +## Note that units for these config options are bytes per second, not bits +## per second, and that prefixes are binary prefixes, i.e. 2^10, 2^20, etc. +#RelayBandwidthRate 100 KB # Throttle traffic to 100KB/s (800Kbps) +#RelayBandwidthBurst 200 KB # But allow bursts up to 200KB/s (1600Kbps) + +## Use these to restrict the maximum traffic per day, week, or month. +## Note that this threshold applies separately to sent and received bytes, +## not to their sum: setting "4 GB" may allow up to 8 GB total before +## hibernating. +## +## Set a maximum of 4 gigabytes each way per period. +#AccountingMax 4 GB +## Each period starts daily at midnight (AccountingMax is per day) +#AccountingStart day 00:00 +## Each period starts on the 3rd of the month at 15:00 (AccountingMax +## is per month) +#AccountingStart month 3 15:00 + +## Administrative contact information for this relay or bridge. This line +## can be used to contact you if your relay or bridge is misconfigured or +## something else goes wrong. Note that we archive and publish all +## descriptors containing these lines and that Google indexes them, so +## spammers might also collect them. You may want to obscure the fact that +## it's an email address and/or generate a new address for this purpose. +#ContactInfo Random Person +## You might also include your PGP or GPG fingerprint if you have one: +#ContactInfo 0xFFFFFFFF Random Person + +## Uncomment this to mirror directory information for others. Please do +## if you have enough bandwidth. +#DirPort 9030 # what port to advertise for directory connections +## If you want to listen on a port other than the one advertised in +## DirPort (e.g. to advertise 80 but bind to 9091), you can do it as +## follows. below too. You'll need to do ipchains or other port +## forwarding yourself to make this work. +#DirPort 80 NoListen +#DirPort 127.0.0.1:9091 NoAdvertise +## Uncomment to return an arbitrary blob of html on your DirPort. Now you +## can explain what Tor is if anybody wonders why your IP address is +## contacting them. See contrib/tor-exit-notice.html in Tor's source +## distribution for a sample. +#DirPortFrontPage /etc/tor/tor-exit-notice.html + +## Uncomment this if you run more than one Tor relay, and add the identity +## key fingerprint of each Tor relay you control, even if they're on +## different networks. You declare it here so Tor clients can avoid +## using more than one of your relays in a single circuit. See +## https://www.torproject.org/docs/faq#MultipleRelays +## However, you should never include a bridge's fingerprint here, as it would +## break its concealability and potentionally reveal its IP/TCP address. +#MyFamily $keyid,$keyid,... + +## A comma-separated list of exit policies. They're considered first +## to last, and the first match wins. If you want to _replace_ +## the default exit policy, end this with either a reject *:* or an +## accept *:*. Otherwise, you're _augmenting_ (prepending to) the +## default exit policy. Leave commented to just use the default, which is +## described in the man page or at +## https://www.torproject.org/documentation.html +## +## Look at https://www.torproject.org/faq-abuse.html#TypicalAbuses +## for issues you might encounter if you use the default exit policy. +## +## If certain IPs and ports are blocked externally, e.g. by your firewall, +## you should update your exit policy to reflect this -- otherwise Tor +## users will be told that those destinations are down. +## +## For security, by default Tor rejects connections to private (local) +## networks, including to your public IP address. See the man page entry +## for ExitPolicyRejectPrivate if you want to allow "exit enclaving". +## +#ExitPolicy accept *:6660-6667,reject *:* # allow irc ports but no more +#ExitPolicy accept *:119 # accept nntp as well as default exit policy +#ExitPolicy reject *:* # no exits allowed + +## Bridge relays (or "bridges") are Tor relays that aren't listed in the +## main directory. Since there is no complete public list of them, even an +## ISP that filters connections to all the known Tor relays probably +## won't be able to block all the bridges. Also, websites won't treat you +## differently because they won't know you're running Tor. If you can +## be a real relay, please do; but if not, be a bridge! +#BridgeRelay 1 +## By default, Tor will advertise your bridge to users through various +## mechanisms like https://bridges.torproject.org/. If you want to run +## a private bridge, for example because you'll give out your bridge +## address manually to your friends, uncomment this line: +#PublishServerDescriptor 0 + diff --git a/configs/tor/torrc.sample b/configs/tor/torrc.sample new file mode 100644 index 0000000..7be6773 --- /dev/null +++ b/configs/tor/torrc.sample @@ -0,0 +1,257 @@ +## Configuration file for a typical Tor user +## Last updated 28 February 2019 for Tor 0.3.5.1-alpha. +## (may or may not work for much older or much newer versions of Tor.) +## +## Lines that begin with "## " try to explain what's going on. Lines +## that begin with just "#" are disabled commands: you can enable them +## by removing the "#" symbol. +## +## See 'man tor', or https://www.torproject.org/docs/tor-manual.html, +## for more options you can use in this file. +## +## Tor will look for this file in various places based on your platform: +## https://support.torproject.org/tbb/tbb-editing-torrc/ + +## Tor opens a SOCKS proxy on port 9050 by default -- even if you don't +## configure one below. Set "SOCKSPort 0" if you plan to run Tor only +## as a relay, and not make any local application connections yourself. +#SOCKSPort 9050 # Default: Bind to localhost:9050 for local connections. +#SOCKSPort 192.168.0.1:9100 # Bind to this address:port too. + +## Entry policies to allow/deny SOCKS requests based on IP address. +## First entry that matches wins. If no SOCKSPolicy is set, we accept +## all (and only) requests that reach a SOCKSPort. Untrusted users who +## can access your SOCKSPort may be able to learn about the connections +## you make. +#SOCKSPolicy accept 192.168.0.0/16 +#SOCKSPolicy accept6 FC00::/7 +#SOCKSPolicy reject * + +## Logs go to stdout at level "notice" unless redirected by something +## else, like one of the below lines. You can have as many Log lines as +## you want. +## +## We advise using "notice" in most cases, since anything more verbose +## may provide sensitive information to an attacker who obtains the logs. +## +## Send all messages of level 'notice' or higher to /var/log/tor/notices.log +#Log notice file /var/log/tor/notices.log +## Send every possible message to /var/log/tor/debug.log +#Log debug file /var/log/tor/debug.log +## Use the system log instead of Tor's logfiles +#Log notice syslog +## To send all messages to stderr: +#Log debug stderr + +## Uncomment this to start the process in the background... or use +## --runasdaemon 1 on the command line. This is ignored on Windows; +## see the FAQ entry if you want Tor to run as an NT service. +#RunAsDaemon 1 + +## The directory for keeping all the keys/etc. By default, we store +## things in $HOME/.tor on Unix, and in Application Data\tor on Windows. +#DataDirectory /var/lib/tor + +## The port on which Tor will listen for local connections from Tor +## controller applications, as documented in control-spec.txt. +#ControlPort 9051 +## If you enable the controlport, be sure to enable one of these +## authentication methods, to prevent attackers from accessing it. +#HashedControlPassword 16:872860B76453A77D60CA2BB8C1A7042072093276A3D701AD684053EC4C +#CookieAuthentication 1 + +############### This section is just for location-hidden services ### + +## Once you have configured a hidden service, you can look at the +## contents of the file ".../hidden_service/hostname" for the address +## to tell people. +## +## HiddenServicePort x y:z says to redirect requests on port x to the +## address y:z. + +#HiddenServiceDir /var/lib/tor/hidden_service/ +#HiddenServicePort 80 127.0.0.1:80 + +#HiddenServiceDir /var/lib/tor/other_hidden_service/ +#HiddenServicePort 80 127.0.0.1:80 +#HiddenServicePort 22 127.0.0.1:22 + +################ This section is just for relays ##################### +# +## See https://community.torproject.org/relay for details. + +## Required: what port to advertise for incoming Tor connections. +#ORPort 9001 +## If you want to listen on a port other than the one advertised in +## ORPort (e.g. to advertise 443 but bind to 9090), you can do it as +## follows. You'll need to do ipchains or other port forwarding +## yourself to make this work. +#ORPort 443 NoListen +#ORPort 127.0.0.1:9090 NoAdvertise +## If you want to listen on IPv6 your numeric address must be explicitly +## between square brackets as follows. You must also listen on IPv4. +#ORPort [2001:DB8::1]:9050 + +## The IP address or full DNS name for incoming connections to your +## relay. Leave commented out and Tor will guess. +#Address noname.example.com + +## If you have multiple network interfaces, you can specify one for +## outgoing traffic to use. +## OutboundBindAddressExit will be used for all exit traffic, while +## OutboundBindAddressOR will be used for all OR and Dir connections +## (DNS connections ignore OutboundBindAddress). +## If you do not wish to differentiate, use OutboundBindAddress to +## specify the same address for both in a single line. +#OutboundBindAddressExit 10.0.0.4 +#OutboundBindAddressOR 10.0.0.5 + +## A handle for your relay, so people don't have to refer to it by key. +## Nicknames must be between 1 and 19 characters inclusive, and must +## contain only the characters [a-zA-Z0-9]. +## If not set, "Unnamed" will be used. +#Nickname ididnteditheconfig + +## Define these to limit how much relayed traffic you will allow. Your +## own traffic is still unthrottled. Note that RelayBandwidthRate must +## be at least 75 kilobytes per second. +## Note that units for these config options are bytes (per second), not +## bits (per second), and that prefixes are binary prefixes, i.e. 2^10, +## 2^20, etc. +#RelayBandwidthRate 100 KBytes # Throttle traffic to 100KB/s (800Kbps) +#RelayBandwidthBurst 200 KBytes # But allow bursts up to 200KB (1600Kb) + +## Use these to restrict the maximum traffic per day, week, or month. +## Note that this threshold applies separately to sent and received bytes, +## not to their sum: setting "40 GB" may allow up to 80 GB total before +## hibernating. +## +## Set a maximum of 40 gigabytes each way per period. +#AccountingMax 40 GBytes +## Each period starts daily at midnight (AccountingMax is per day) +#AccountingStart day 00:00 +## Each period starts on the 3rd of the month at 15:00 (AccountingMax +## is per month) +#AccountingStart month 3 15:00 + +## Administrative contact information for this relay or bridge. This line +## can be used to contact you if your relay or bridge is misconfigured or +## something else goes wrong. Note that we archive and publish all +## descriptors containing these lines and that Google indexes them, so +## spammers might also collect them. You may want to obscure the fact that +## it's an email address and/or generate a new address for this purpose. +## +## If you are running multiple relays, you MUST set this option. +## +#ContactInfo Random Person +## You might also include your PGP or GPG fingerprint if you have one: +#ContactInfo 0xFFFFFFFF Random Person + +## Uncomment this to mirror directory information for others. Please do +## if you have enough bandwidth. +#DirPort 9030 # what port to advertise for directory connections +## If you want to listen on a port other than the one advertised in +## DirPort (e.g. to advertise 80 but bind to 9091), you can do it as +## follows. below too. You'll need to do ipchains or other port +## forwarding yourself to make this work. +#DirPort 80 NoListen +#DirPort 127.0.0.1:9091 NoAdvertise +## Uncomment to return an arbitrary blob of html on your DirPort. Now you +## can explain what Tor is if anybody wonders why your IP address is +## contacting them. See contrib/tor-exit-notice.html in Tor's source +## distribution for a sample. +#DirPortFrontPage /etc/tor/tor-exit-notice.html + +## Uncomment this if you run more than one Tor relay, and add the identity +## key fingerprint of each Tor relay you control, even if they're on +## different networks. You declare it here so Tor clients can avoid +## using more than one of your relays in a single circuit. See +## https://support.torproject.org/relay-operators/multiple-relays/ +## However, you should never include a bridge's fingerprint here, as it would +## break its concealability and potentially reveal its IP/TCP address. +## +## If you are running multiple relays, you MUST set this option. +## +## Note: do not use MyFamily on bridge relays. +#MyFamily $keyid,$keyid,... + +## Uncomment this if you want your relay to be an exit, with the default +## exit policy (or whatever exit policy you set below). +## (If ReducedExitPolicy, ExitPolicy, or IPv6Exit are set, relays are exits. +## If none of these options are set, relays are non-exits.) +#ExitRelay 1 + +## Uncomment this if you want your relay to allow IPv6 exit traffic. +## (Relays do not allow any exit traffic by default.) +#IPv6Exit 1 + +## Uncomment this if you want your relay to be an exit, with a reduced set +## of exit ports. +#ReducedExitPolicy 1 + +## Uncomment these lines if you want your relay to be an exit, with the +## specified set of exit IPs and ports. +## +## A comma-separated list of exit policies. They're considered first +## to last, and the first match wins. +## +## If you want to allow the same ports on IPv4 and IPv6, write your rules +## using accept/reject *. If you want to allow different ports on IPv4 and +## IPv6, write your IPv6 rules using accept6/reject6 *6, and your IPv4 rules +## using accept/reject *4. +## +## If you want to _replace_ the default exit policy, end this with either a +## reject *:* or an accept *:*. Otherwise, you're _augmenting_ (prepending to) +## the default exit policy. Leave commented to just use the default, which is +## described in the man page or at +## https://support.torproject.org/relay-operators +## +## Look at https://support.torproject.org/abuse/exit-relay-expectations/ +## for issues you might encounter if you use the default exit policy. +## +## If certain IPs and ports are blocked externally, e.g. by your firewall, +## you should update your exit policy to reflect this -- otherwise Tor +## users will be told that those destinations are down. +## +## For security, by default Tor rejects connections to private (local) +## networks, including to the configured primary public IPv4 and IPv6 addresses, +## and any public IPv4 and IPv6 addresses on any interface on the relay. +## See the man page entry for ExitPolicyRejectPrivate if you want to allow +## "exit enclaving". +## +#ExitPolicy accept *:6660-6667,reject *:* # allow irc ports on IPv4 and IPv6 but no more +#ExitPolicy accept *:119 # accept nntp ports on IPv4 and IPv6 as well as default exit policy +#ExitPolicy accept *4:119 # accept nntp ports on IPv4 only as well as default exit policy +#ExitPolicy accept6 *6:119 # accept nntp ports on IPv6 only as well as default exit policy +#ExitPolicy reject *:* # no exits allowed + +## Uncomment this if you want your exit relay to reevaluate its exit policy on +## existing connections when the exit policy is modified. +#ReevaluateExitPolicy 1 + +## Bridge relays (or "bridges") are Tor relays that aren't listed in the +## main directory. Since there is no complete public list of them, even an +## ISP that filters connections to all the known Tor relays probably +## won't be able to block all the bridges. Also, websites won't treat you +## differently because they won't know you're running Tor. If you can +## be a real relay, please do; but if not, be a bridge! +## +## Warning: when running your Tor as a bridge, make sure than MyFamily is +## NOT configured. +#BridgeRelay 1 +## By default, Tor will advertise your bridge to users through various +## mechanisms like https://bridges.torproject.org/. If you want to run +## a private bridge, for example because you'll give out your bridge +## address manually to your friends, uncomment this line: +#BridgeDistribution none + +## Configuration options can be imported from files or folders using the %include +## option with the value being a path. This path can have wildcards. Wildcards are +## expanded first, using lexical order. Then, for each matching file or folder, the following +## rules are followed: if the path is a file, the options from the file will be parsed as if +## they were written where the %include option is. If the path is a folder, all files on that +## folder will be parsed following lexical order. Files starting with a dot are ignored. Files +## on subfolders are ignored. +## The %include option can be used recursively. +#%include /etc/torrc.d/*.conf + diff --git a/host/README.md b/host/README.md new file mode 100644 index 0000000..79c7694 --- /dev/null +++ b/host/README.md @@ -0,0 +1,16 @@ +# openSUSE host `koopa` + +OS: **openSUSE Tumbleweed** · hostname **koopa** · LAN `192.168.100.95/24` · public AAAA on `eno1` + +This directory holds **host-level** config only (systemd, firewalld, Caddy, network). +Application configs stay under `configs/taler-*` and `scripts/`. + +| Path | Content | +|------|---------| +| `overview/` | Service map (diagram + table) | +| `systemd/` | socket proxies + caddy drop-in | +| `firewalld/` | public zone dump | +| `caddy/` | live Caddyfile | +| `network/` | addressing notes | + +Edge router: **VeciGate** (`../vecigate-admin-log`). diff --git a/host/caddy/Caddyfile b/host/caddy/Caddyfile new file mode 100644 index 0000000..03f88aa --- /dev/null +++ b/host/caddy/Caddyfile @@ -0,0 +1,185 @@ +# Internal only (not in the browser URL): +# 9010 merchant API | 9011 exchange API | 9012 bank API +# 9013 bank landing | 9014 exchange landing | 9015 merchant landing +# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | | 9200 forgejo-ssh +# 9090 tops ng1 | 9091 tops ng2 | 9092 tops ng3 + +{ + email info+koopa@hacktivism.ch + http_port 9000 + https_port 9001 + auto_https disable_redirects + # Caddy listens on 9001 behind VeciGate/https-proxy :443. + # Default HTTP/3 would send Alt-Svc: h3=":9001" — break public HTTPS on :443. + servers { + protocols h1 h2 + } +} + +(proxy_public) { + header_up Host {host} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Port 443 + header_down Location "^https?://[^/]+:90[0-9]{2}(.*)$" "https://{host}$1" +} + +taler.hacktivism.ch { + tls /etc/caddy/certs/taler.hacktivism.ch/fullchain.pem /etc/caddy/certs/taler.hacktivism.ch/privkey.pem + header Alt-Svc "clear" + + # Public landing first + redir / /intro/ 302 + + handle /intro* { + reverse_proxy 127.0.0.1:9015 { + import proxy_public + } + } + + # SPA: /webui → /webui/ + redir /webui /webui/ 302 + + # Merchant API + WebUI (nginx :9010 → unix socket) + reverse_proxy https://127.0.0.1:9010 { + transport http { + tls_insecure_skip_verify + } + import proxy_public + } +} + +exchange.hacktivism.ch { + tls /etc/caddy/certs/exchange.hacktivism.ch/fullchain.pem /etc/caddy/certs/exchange.hacktivism.ch/privkey.pem + header Alt-Svc "clear" + + # Public landing first + redir / /intro/ 302 + + handle /intro* { + reverse_proxy 127.0.0.1:9014 { + import proxy_public + } + } + + reverse_proxy 127.0.0.1:9011 { + import proxy_public + } +} + +bank.hacktivism.ch { + header Alt-Svc "clear" + + # Public landing first + redir / /intro/ 302 + + handle /intro* { + reverse_proxy 127.0.0.1:9013 { + import proxy_public + } + } + + # Static terms/privacy on landing nginx :9013 + handle /terms* { + reverse_proxy 127.0.0.1:9013 { + import proxy_public + } + } + handle /privacy* { + reverse_proxy 127.0.0.1:9013 { + import proxy_public + } + } + + reverse_proxy 127.0.0.1:9012 { + import proxy_public + } +} + +castopod.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9020 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + } +} + +bonfire.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9021 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + flush_interval -1 + } +} + +prime.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9022 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + flush_interval -1 + } +} + +bt.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9023 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + } +} + +# 9024 forgejo HTTP (SSH :9200 host-direct, not via Caddy) +git.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9024 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + flush_interval -1 + transport http { + read_timeout 3600s + write_timeout 3600s + } + } +} + + +# Taler Operations design previews (static nginx) +tops.ng1.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9090 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +tops.ng2.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9091 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +tops.ng3.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9092 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +http://taler.hacktivism.ch, http://exchange.hacktivism.ch, http://bank.hacktivism.ch, http://castopod.hacktivism.ch, http://bonfire.hacktivism.ch, http://prime.hacktivism.ch, http://bt.hacktivism.ch, http://git.hacktivism.ch, http://tops.ng1.hacktivism.ch, http://tops.ng2.hacktivism.ch, http://tops.ng3.hacktivism.ch { + handle /.well-known/acme-challenge/* { + root * /var/www/acme + file_server + } + handle { + redir https://{host}{uri} permanent + } +} diff --git a/host/caddy/README.md b/host/caddy/README.md new file mode 100644 index 0000000..92e5219 --- /dev/null +++ b/host/caddy/README.md @@ -0,0 +1,19 @@ +# Host Caddy + +Live: `/etc/caddy/Caddyfile` on koopa (root/`caddy` group). +Canonical mirror in this repo: **`configs/caddy/Caddyfile`** (same content as `host/caddy/Caddyfile`). + +| Listen | Role | +|--------|------| +| **9000** | HTTP + ACME webroot + HTTPS redirect | +| **9001** | HTTPS vhosts | + +Public sites: `taler` / `exchange` / `bank` / `castopod` / `bonfire` / `prime` / `bt` / **`git`** / **`paivana`** / tops.ng*. +Forgejo HTTP: Caddy → `127.0.0.1:9024`. Git SSH is **host :9200** (not Caddy). + +Validate/reload on host: + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` diff --git a/host/firewalld/README.md b/host/firewalld/README.md new file mode 100644 index 0000000..26c4db3 --- /dev/null +++ b/host/firewalld/README.md @@ -0,0 +1,18 @@ +# firewalld (zone `public`, interface `eno1`) + +## Open ports (current) + +| Port | Purpose | +|------|---------| +| 80/tcp | systemd → Caddy HTTP 9000 | +| 9000/tcp | Caddy HTTP (VeciGate WAN:80) | +| 9001/tcp | Caddy HTTPS (VeciGate WAN:443) | +| 8080/tcp | Tor OR | +| 23235/tcp | (legacy list; SSH is service `ssh` on 22) | +| services | `ssh`, `dhcpv6-client` | + +## Removed + +- 443/tcp, 8082/tcp — obsolete after 9000-series DNAT + +Live dump: `list-all.txt`. diff --git a/host/firewalld/list-all.txt b/host/firewalld/list-all.txt new file mode 100644 index 0000000..68bc660 --- /dev/null +++ b/host/firewalld/list-all.txt @@ -0,0 +1,19 @@ +public (default, active) + target: default + ingress-priority: 0 + egress-priority: 0 + icmp-block-inversion: no + interfaces: eno1 + sources: + services: dhcpv6-client ssh + ports: 80/tcp 443/tcp 8080/tcp 9000/tcp 9001/tcp 23235/tcp 9200/tcp + protocols: + forward: yes + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + +# Snapshot note (2026-07-10): 9200/tcp = Forgejo git-SSH (verified open). +# Re-export as root: firewall-cmd --list-all > host/firewalld/list-all.txt diff --git a/host/network/README.md b/host/network/README.md new file mode 100644 index 0000000..eeed5bc --- /dev/null +++ b/host/network/README.md @@ -0,0 +1,10 @@ +# Network + +| Interface | Address | Role | +|-----------|---------|------| +| eno1 | 192.168.100.95/24 | LAN (default route via VeciGate) | +| eno1 | 2a02:168:53a8::/64 (dynamic) | Global IPv6 (AAAA for taler/exchange) | +| lo | 127.0.0.1 | local | + +SSH: LAN `:22`, WAN via VeciGate `23235` → `:22`. +Tor ORPort: `:8080` (WAN DNAT on VeciGate). diff --git a/host/overview/LIVE.md b/host/overview/LIVE.md new file mode 100644 index 0000000..30f810d --- /dev/null +++ b/host/overview/LIVE.md @@ -0,0 +1,104 @@ +# Live inventory — koopa (2026-07-13) + +Snapshot of **what runs on host koopa** (openSUSE Tumbleweed). No secrets. + +Refresh command ideas: + +```bash +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' +``` + +## Host + +| Item | Value | +|------|--------| +| Hostname | `koopa` | +| Role | App host behind VeciGate (WAN :80→9000, :443→9001) | +| Edge | Caddy **9000/9001**, systemd socket proxies **80/443** | +| Tor | **podman `koopa-tor-relay`** (host net) ORPort **8080** dual-stack, ControlPort **127.0.0.1:9051** — not host `tor.service` | + +## Running podman containers + +| Name | Image | Ports (host) | Public site | +|------|--------|--------------|-------------| +| `taler-hacktivism` | `localhost/taler-hacktivism-live:landing` | **9010**, **9015** | `taler.hacktivism.ch` | +| **`taler-hacktivism-exchange-ansible`** | `localhost/taler-hacktivism-exchange-ansible:landing` | **9011**, **9014** | `exchange.hacktivism.ch` | +| `taler-hacktivism-bank` | **`localhost/taler-hacktivism-banking:live`** | **9012**, **9013** | `bank.hacktivism.ch` | +| `koopa-castopod` (+ mariadb, redis) | `castopod/castopod:1` | **9020** | `castopod.hacktivism.ch` | +| `koopa-bonfire` (+ postgres) | `bonfire:1.0.5-social-amd64` | **9021** | `bonfire.hacktivism.ch` | +| `koopa-prime-jellyfin` | `linuxserver/jellyfin:10.10.7` | **9022** | `prime.hacktivism.ch` | +| `koopa-prime-qbittorrent` | `linuxserver/qbittorrent:5.0.4` | **9023**, **6881** | `bt.hacktivism.ch` | +| `koopa-forgejo` (+ postgres) | `forgejo:11-rootless` | **9024**, **9200** | `git.hacktivism.ch` | +| **`koopa-tor-relay`** | `localhost/koopa-tor-relay:latest` | **8080**, **9051** (host net) | Tor OR (non-exit) | +| `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` | + +### Naming note (exchange) + +Live exchange container is **`taler-hacktivism-exchange-ansible`** (not `taler-hacktivism-exchange-ansible` / not without `-ansible`). +Managed from **`/home/hernani/ansible-taler-exchange/`** (see `configs/taler-exchange-ansible/`). + +## Caddy vhosts → backends + +| Host | Backend | +|------|---------| +| `taler.hacktivism.ch` | 9010 (API) + 9015 (`/intro`) | +| `exchange.hacktivism.ch` | 9011 + 9014 (`/intro`) | +| `bank.hacktivism.ch` | 9012 + 9013 (`/intro`, terms, privacy) | +| `castopod.hacktivism.ch` | 9020 | +| `bonfire.hacktivism.ch` | 9021 | +| `prime.hacktivism.ch` | 9022 | +| `bt.hacktivism.ch` | 9023 | +| `git.hacktivism.ch` | 9024 (HTTP); git-SSH **9200** host-direct | +| `tops.ng1.hacktivism.ch` | 9090 | +| `tops.ng2.hacktivism.ch` | 9091 | +| `tops.ng3.hacktivism.ch` | 9092 | + +Config: `/etc/caddy/Caddyfile` (mirror `configs/caddy/Caddyfile`). + +## Paths on host (`hernani`) + +| Path | Role | +|------|------| +| `~/ansible-taler-exchange/` | Ansible + scripts for exchange-ansible container | +| `~/koopa-castopod/` | Castopod compose | +| `~/koopa-bonfire/` | Bonfire compose + gitbot | +| `~/koopa-prime/` | Jellyfin + qBittorrent | +| `~/koopa-forgejo/` | Forgejo rootless | +| `~/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) | + +## Start models + +| Stack | How | +|-------|-----| +| Taler merchant/bank | root `start_base_services_*` → service user → `/usr/local/bin/start_*.sh` | +| Exchange (GOA) | `~/ansible-taler-exchange/run-container-koopa.sh` then `deploy-hacktivism-goa.sh` | +| User apps | `cd ~/koopa-* && podman-compose up -d` | +| tops (ng1–ng3) | user unit `container-koopa-tops.service` (linger) | + +## Config mirrors in this repo (`configs/`) + +| Live container | Git path | +|----------------|----------| +| `taler-hacktivism` | `configs/taler-hacktivism/` | +| `taler-hacktivism-bank` (image **…-banking**) | `configs/taler-hacktivism-bank/` | +| `taler-hacktivism-exchange-ansible` | `configs/taler-exchange-ansible/` + conf in `configs/taler-exchange/` | +| `koopa-tops-ng1` … `ng3` | `configs/tops/` | + +## Related docs in this repo + +| Topic | Doc | +|-------|-----| +| Ports | `configs/ports.md` | +| Diagram | `host/overview/services.md` | +| Exchange Ansible (koopa) | `configs/taler-exchange-ansible/` | +| Forgejo | `2026/2026-07-10--forgejo-rootless.md` | +| Bonfire public feeds | `configs/bonfire/public-feeds.md` | +| Castopod content | `2026/2026-07-09--castopod-content.md` | diff --git a/host/overview/services.md b/host/overview/services.md new file mode 100644 index 0000000..c725124 --- /dev/null +++ b/host/overview/services.md @@ -0,0 +1,100 @@ +# Service overview — koopa + +**Live container/port inventory:** [`LIVE.md`](LIVE.md) (state as observed on host). + +Diagram + +```mermaid +flowchart TB + subgraph Internet + Client[Client IPv4/IPv6] + end + + subgraph VeciGate["VeciGate (MikroTik)"] + DNAT80["DNAT :80 → koopa:9000"] + DNAT443["DNAT :443 → koopa:9001"] + DNATSSH["DNAT :23235 → koopa:22"] + DNATTOR["DNAT :8080 → koopa:8080"] + end + + subgraph Host["openSUSE host koopa"] + subgraph Edge["Edge on host"] + S80["systemd http-proxy.socket :80"] + S443["systemd https-proxy.socket :443"] + CaddyHTTP["Caddy :9000 HTTP\nACME + redirect"] + CaddyHTTPS["Caddy :9001 HTTPS"] + FW["firewalld public\n9000,9001,80,8080,ssh"] + end + + subgraph Podman["podman rootless (hernani)"] + Merch["taler-hacktivism\n:9010"] + Exch["taler-hacktivism-exchange-ansible\n:9011"] + Bank["taler-bank-hacktivism\n:9012"] + Castopod["koopa-castopod\n:9020"] + Bonfire["koopa-bonfire\n:9021"] + Prime["koopa-prime jellyfin\n:9022"] + BT["qbittorrent\n:9023"] + Forgejo["koopa-forgejo ROOTLESS\n:9024 HTTP / :9200 SSH"] + end + + Tor["tor ORPort :8080"] + SSH["sshd :22"] + end + + Client --> DNAT80 --> CaddyHTTP + Client --> DNAT443 --> CaddyHTTPS + Client --> DNATSSH --> SSH + Client --> DNATTOR --> Tor + + S80 --> CaddyHTTP + S443 --> CaddyHTTPS + + CaddyHTTP -->|"/.well-known"| ACME["/var/www/acme"] + CaddyHTTP -->|other| Redir[301 HTTPS] + CaddyHTTPS -->|taler.hacktivism.ch| Merch + CaddyHTTPS -->|exchange.hacktivism.ch| Exch + CaddyHTTPS -->|bank.hacktivism.ch| Bank + CaddyHTTPS -->|castopod.hacktivism.ch| Castopod + CaddyHTTPS -->|bonfire.hacktivism.ch| Bonfire + CaddyHTTPS -->|prime.hacktivism.ch| Prime + CaddyHTTPS -->|bt.hacktivism.ch| BT + CaddyHTTPS -->|git.hacktivism.ch| Forgejo +``` + +## Port table + +| Port | Listener | Backend / notes | +|------|----------|-----------------| +| 22 | sshd | LAN; WAN via 23235 | +| 80 | systemd socket | → 127.0.0.1:**9000** | +| 443 | systemd socket | → 127.0.0.1:**9001** | +| 9000 | caddy | HTTP + ACME | +| 9001 | caddy | HTTPS vhosts | +| 9010 | podman | **merchant** nginx | +| 9011 | podman | **exchange** httpd | +| 9012 | podman | **bank** libeufin | +| 9020 | podman | **castopod** | +| 9021 | podman | **bonfire** | +| 9022 | podman | **jellyfin** (prime) | +| 9023 | podman | **qbittorrent** (bt) | +| **9024** | podman | **forgejo** HTTP → `git.hacktivism.ch` | +| **9200** | podman | **forgejo** git-SSH (host-direct) | +| 9090–9092 | podman | **tops** `koopa-tops-ng1`…`ng3` | +| 8080 | tor | ORPort | + +Full port notes: `configs/ports.md`. + +## Service index (details elsewhere) + +| Service | Detail docs | +|---------|-------------| +| Caddy | `host/caddy/`, `configs/caddy/` | +| firewalld | `host/firewalld/` | +| systemd proxies | `host/systemd/` | +| Merchant container | `configs/taler-hacktivism/`, `scripts/taler-merchant/` | +| Exchange container | `configs/taler-exchange/`, `scripts/taler-exchange/` | +| **Forgejo rootless** | `configs/forgejo/`, `2026/2026-07-10--forgejo-rootless.md` | +| tops (`koopa-tops-ng*`) | `configs/tops/` | +| Tor relay (`koopa-tor-relay` podman) | `configs/tor/`, `host/tor/` | +| Tor monitoring scripts | `scripts/monitoring/` | +| VeciGate NAT | `../vecigate-admin-log/ip/firewall/nat/` | diff --git a/host/overview/services.txt b/host/overview/services.txt new file mode 100644 index 0000000..a37748b --- /dev/null +++ b/host/overview/services.txt @@ -0,0 +1,24 @@ + Internet + | + +-------------+-------------+ + | VeciGate | + | :80→9000 :443→9001 | + | :23235→22 :8080→8080 | + +-------------+-------------+ + | + koopa 192.168.100.95 + | + +----------------------+----------------------+ + | | | + systemd Caddy other + :80 → 9000 :9000 HTTP sshd :22 + :443 → 9001 :9001 HTTPS tor :8080 + | | + +----------+-----------+ + | + +-------+--------+ + | | + :9010 merchant :9011 exchange + (nginx TLS) (httpd TCP) + | | + unix sock postgres/secmods diff --git a/host/systemd/README.md b/host/systemd/README.md new file mode 100644 index 0000000..ddc73ee --- /dev/null +++ b/host/systemd/README.md @@ -0,0 +1,13 @@ +# systemd units (host) + +| Unit | Role | +|------|------| +| `caddy.service` | reverse proxy (drop-in: no `--resume`) | +| `http-proxy.socket` | bind **:80** → `systemd-socket-proxyd` → `127.0.0.1:9000` | +| `https-proxy.socket` | bind **:443** → `systemd-socket-proxyd` → `127.0.0.1:9001` | +| `https-proxy@.service` | template: `systemd-socket-proxyd 127.0.0.1:%i` | +| `firewalld.service` | host firewall | +| `sshd.service` | SSH | +| `tor.service` | Tor relay (ORPort 8080) | + +Privileged ports 80/443 are held by **systemd**, not by Caddy (Caddy listens on 9000/9001 as user `caddy`). diff --git a/host/systemd/caddy.service.d-no-resume.conf b/host/systemd/caddy.service.d-no-resume.conf new file mode 100644 index 0000000..8a32924 --- /dev/null +++ b/host/systemd/caddy.service.d-no-resume.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile diff --git a/host/systemd/http-proxy.socket b/host/systemd/http-proxy.socket new file mode 100644 index 0000000..1dda110 --- /dev/null +++ b/host/systemd/http-proxy.socket @@ -0,0 +1,11 @@ +[Unit] +Description=Forward incoming HTTP :80 to Caddy on 127.0.0.1:9000 + +[Socket] +ListenStream=0.0.0.0:80 +ListenStream=[::]:80 +BindIPv6Only=yes +Service=https-proxy@9000.service + +[Install] +WantedBy=sockets.target diff --git a/host/systemd/https-proxy.socket b/host/systemd/https-proxy.socket new file mode 100644 index 0000000..bedf449 --- /dev/null +++ b/host/systemd/https-proxy.socket @@ -0,0 +1,11 @@ +[Unit] +Description=Forward incoming HTTPS :443 to Caddy on 127.0.0.1:9001 + +[Socket] +ListenStream=0.0.0.0:443 +ListenStream=[::]:443 +BindIPv6Only=yes +Service=https-proxy@9001.service + +[Install] +WantedBy=sockets.target diff --git a/host/systemd/https-proxy@.service b/host/systemd/https-proxy@.service new file mode 100644 index 0000000..a61c775 --- /dev/null +++ b/host/systemd/https-proxy@.service @@ -0,0 +1,5 @@ +[Unit] +Description=Proxy for incoming HTTPS traffic to port %i + +[Service] +ExecStart=/usr/lib/systemd/systemd-socket-proxyd 127.0.0.1:%i diff --git a/host/tor/README.md b/host/tor/README.md new file mode 100644 index 0000000..9bf2e15 --- /dev/null +++ b/host/tor/README.md @@ -0,0 +1,27 @@ +# 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`). + +| Port | Bind | Role | +|------|------|------| +| **8080** | `0.0.0.0` + `[::]` | ORPort (dual-stack) | +| **9051** | `127.0.0.1` | ControlPort (nyx / stem) | + +Non-exit relay (`ExitRelay 0`, `SocksPort 0`). + +### Root checklist + +```bash +sudo systemctl disable --now tor +sudo systemctl mask tor +# linger already required for rootless user units: +# sudo loginctl enable-linger hernani +``` + +### User unit + +```bash +systemctl --user enable --now container-koopa-tor-relay.service +``` diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 0000000..c38a4de --- /dev/null +++ b/ops/README.md @@ -0,0 +1,5 @@ +# ops/ + +Host hygiene and short pointers. **Dated day/topic logs live under `../2026/`.** + +- `ROOT_HYGIENE.md` — secrets layout (values in `koopa-admin-secrets`) diff --git a/ops/ROOT_HYGIENE.md b/ops/ROOT_HYGIENE.md new file mode 100644 index 0000000..7a8c749 --- /dev/null +++ b/ops/ROOT_HYGIENE.md @@ -0,0 +1,11 @@ +# Secrets layout + +``` +../koopa-admin-secrets/ + koopa/host-root/taler-bank/… → /root/bank-*-password.txt + koopa/host-root/taler-merchant/… → /root/merchant-*-password.txt + containers//secrets/ → files inside podman containers +``` + +Deploy/pull: `koopa-admin-secrets/scripts/deploy-to-koopa.sh` / `pull-from-koopa.sh`. +No password **values** in `koopa-admin-log`. diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..34ef2bc --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,47 @@ +# Scripts + +| Dir | Source on koopa | +|-----|-----------------| +| `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-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) | +| `taler-wallet-cli/` | thin wrappers; **benchmarks live in** `../benchmarks/` | +| `castopod/` | host `hernani` podman-compose `~/koopa-castopod` — see `castopod/README.md` | + +**Secrets:** never in this tree — sibling **`../koopa-admin-secrets`** (`koopa/host-root//` ↔ `/root/` on host; `containers/…/secrets/` for in-container). + +## Manual start model (all three) + +1. **root** runs `/root/start_base_services_for_taler_*.sh` + → Debian-style postgres perms + start (`pg_ctlcluster` / `init.d`) + → (+ exchange: secmods/helpers; merchant: nginx) + → interactive shell as service user + → automation: add **`--no-shell`** then run step 2 via `runuser` +2. **service user** runs `/usr/local/bin/start_*.sh` [ `--restart` ] + → application process only + +Postgres ownership (Debian defaults, if cluster was root-owned after bad ops): + +```text +chown -R root:postgres /etc/postgresql +chmod confs 640 / dirs 755 +chown -R postgres:postgres /var/lib/postgresql /var/log/postgresql /var/run/postgresql +# only remove postmaster.pid / socket locks when pg_isready fails and no live postgres +pg_ctlcluster 17 main start +``` + +| Container | Root base | App start | App user | +|-----------|-----------|-----------|----------| +| `taler-hacktivism` | `start_base_services_for_taler.sh` | `start_merchant.sh` | `taler-merchant-httpd` | +| `taler-hacktivism-exchange-ansible` | `start_base_services_for_taler_exchange.sh` | `start_exchange.sh` | `taler-exchange-httpd` | + +Exchange one-shots (root, offline / wire): +`taler-exchange/wire-enable-and-upload.sh`, `offline-sign-upload-keys.sh`, `start_wire_helpers.sh` +| `taler-hacktivism-bank` | `start_base_services_for_taler_bank.sh` | `start_bank.sh` | `libeufin-bank` | + +`runuser -u USER -- bash` (never `-u` with `-s` on util-linux). + +SMS helper symlinks into `/var/taler-src/...` are not copied (merchant only). diff --git a/scripts/bonfire/README.md b/scripts/bonfire/README.md new file mode 100644 index 0000000..c68baff --- /dev/null +++ b/scripts/bonfire/README.md @@ -0,0 +1,13 @@ +# Bonfire scripts + +Live: `/home/hernani/koopa-bonfire/`. +Docs: `configs/bonfire/README.md`, [Bonfire docs](https://docs.bonfirenetworks.org/). + +| Script | Role | +|--------|------| +| `gitbot-mirror.py` | Poll git.ngi-0.eu, post via GraphQL, republish feeds | +| `install-systemd.sh` | Boot + gitbot units | +| `apply-branding.sh` | Logo + hacktivism theme (`bonfire remote`) | +| `publish-outbox-to-public.sql` | One-off outbox → public feeds | + +No official gitbot or feed-republish guides — custom ops scripts. \ No newline at end of file diff --git a/scripts/bonfire/apply-branding.exs b/scripts/bonfire/apply-branding.exs new file mode 100644 index 0000000..ec4278e --- /dev/null +++ b/scripts/bonfire/apply-branding.exs @@ -0,0 +1,56 @@ +# Apply hacktivism branding to Bonfire instance settings. +# Run on koopa: printf '%s\n' "$(cat apply-branding.exs)" | podman exec -i koopa-bonfire /opt/app/bin/bonfire remote +# +# Instance settings API: https://docs.bonfirenetworks.org/settings_system.html + +palette = %{ + "color-base-100" => "#1a1410", + "color-base-200" => "#221c16", + "color-base-300" => "#2a2018", + "color-base-content" => "#fff6e8", + "color-primary" => "#e8a838", + "color-primary-content" => "#1a1410", + "color-secondary" => "#3d3128", + "color-secondary-content" => "#fff6e8", + "color-accent" => "#3ecfbf", + "color-accent-content" => "#0e1c1e", + "color-neutral" => "#14110e", + "color-neutral-content" => "#ebe0d0", + "color-info" => "#2563eb", + "color-info-content" => "#ffffff", + "color-success" => "#16a34a", + "color-success-content" => "#ffffff", + "color-warning" => "#f0d090", + "color-warning-content" => "#1a1410", + "color-error" => "#b91c1c", + "color-error-content" => "#ffffff", + "radius-box" => "0.875rem", + "radius-field" => "0.5rem", + "radius-selector" => "0.75rem" +} + +opts = [skip_boundary_check: true, scope: :instance] + +settings = %{ + ui: %{ + theme: %{ + instance_name: "hacktivism bonfire", + instance_tagline: "hacktivism magician - federated FOSS timeline", + instance_description: + "Bonfire on hacktivism.ch — local FOSS posts, gitbot commits, and federation.", + instance_icon: "/images/hacktivism-logo.svg", + instance_theme: "dark", + instance_theme_light: "light", + preferred: :custom, + custom_instance: palette + } + } +} + +case Bonfire.Common.Settings.set(settings, opts) do + {:ok, _} -> + IO.puts("ok branding settings applied") + + other -> + IO.inspect(other, label: "branding settings failed") +end \ No newline at end of file diff --git a/scripts/bonfire/apply-branding.sh b/scripts/bonfire/apply-branding.sh new file mode 100755 index 0000000..d16d769 --- /dev/null +++ b/scripts/bonfire/apply-branding.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Deploy hacktivism Bonfire branding (hacktivism magician logo + exchange-dark palette). +# Live root: ~/koopa-bonfire/ +set -euo pipefail + +ROOT="${KOOPA_BONFIRE_ROOT:-$HOME/koopa-bonfire}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." 2>/dev/null && pwd || true)" +BRANDING_DST="$ROOT/data/branding" +ENV_FILE="$ROOT/.env" + +install -d -m 755 "$BRANDING_DST" + +if [[ -d "$REPO_ROOT/configs/bonfire/assets/img" ]]; then + BRANDING_SRC="$REPO_ROOT/configs/bonfire/assets/img" + install -m 644 "$BRANDING_SRC/logo.svg" "$BRANDING_DST/logo.svg" + install -m 644 "$BRANDING_SRC/favicon.svg" "$BRANDING_DST/favicon.svg" + install -m 644 "$BRANDING_SRC/logo.png" "$BRANDING_DST/logo.png" +elif [[ ! -f "$BRANDING_DST/logo.svg" ]]; then + echo "missing $BRANDING_DST/logo.svg (copy assets or run from koopa-admin-log checkout)" >&2 + exit 1 +fi + +# APP_NAME drives suffix and og:site_name when instance_name is unset in HTML meta. +if [[ -f "$ENV_FILE" ]]; then + if grep -q '^APP_NAME=' "$ENV_FILE"; then + sed -i 's/^APP_NAME=.*/APP_NAME=hacktivism bonfire/' "$ENV_FILE" + else + echo 'APP_NAME=hacktivism bonfire' >> "$ENV_FILE" + fi + if grep -q '^INSTANCE_DESCRIPTION=' "$ENV_FILE"; then + sed -i 's/^INSTANCE_DESCRIPTION=.*/INSTANCE_DESCRIPTION=Bonfire on hacktivism.ch - FOSS posts and gitbot commits./' "$ENV_FILE" + else + echo 'INSTANCE_DESCRIPTION=Bonfire on hacktivism.ch - FOSS posts and gitbot commits.' >> "$ENV_FILE" + fi +fi + +if [[ "$SCRIPT_DIR/apply-branding.exs" != "$ROOT/bin/apply-branding.exs" ]]; then + install -m 755 "$SCRIPT_DIR/apply-branding.exs" "$ROOT/bin/apply-branding.exs" +fi + +echo "Applying instance theme settings via bonfire remote..." +sleep 2 +printf '%s\n' "$(cat "$ROOT/bin/apply-branding.exs")" | podman exec -i koopa-bonfire /opt/app/bin/bonfire remote >/tmp/bonfire-branding.log 2>&1 || { + echo "remote apply failed; see /tmp/bonfire-branding.log" >&2 + tail -30 /tmp/bonfire-branding.log >&2 + exit 1 +} +grep -q 'ok branding settings applied' /tmp/bonfire-branding.log + +echo "Recreating web container (compose branding volume)..." +cd "$ROOT" +podman-compose up -d --no-deps --force-recreate web + +echo "Done. Check https://bonfire.hacktivism.ch/ (hard-reload if cached)." \ No newline at end of file diff --git a/scripts/bonfire/gitbot-mirror.py b/scripts/bonfire/gitbot-mirror.py new file mode 100755 index 0000000..80722b8 --- /dev/null +++ b/scripts/bonfire/gitbot-mirror.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Poll git.ngi-0.eu and post new commits to Bonfire as gitbot.""" +from __future__ import annotations +import json, os, pathlib, re, subprocess, sys, time, urllib.error, urllib.request + +GQL = os.environ.get("BONFIRE_GQL", "http://127.0.0.1:9021/api/graphql") +GIT = os.environ.get("GIT_BASE", "https://git.ngi-0.eu") +STATE = pathlib.Path(os.environ.get("GITBOT_STATE", "/home/hernani/koopa-bonfire/gitbot-state.json")) +USERS = pathlib.Path(os.environ.get("BONFIRE_USERS_ENV", "/home/hernani/koopa-bonfire/users.env")) +INTERVAL = int(os.environ.get("GITBOT_INTERVAL", "300")) +MAX_FAILS = int(os.environ.get("GITBOT_MAX_FAILS", "3")) +MAX_TIME = 45 +OUTBOX_FEED = os.environ.get( + "GITBOT_OUTBOX_FEED", "019f487a-df20-4ed0-a334-40ec3eac23e7" +) +FEED_INTERNET = os.environ.get( + "GITBOT_FEED_INTERNET", "0aab414c-eb0a-ac1d-8c81-ef0d74ec55da" +) +FEED_LOCAL = os.environ.get( + "GITBOT_FEED_LOCAL", "797632fc-029e-06f0-1031-410d73a5558e" +) +PUBLISH_CMD = os.environ.get( + "GITBOT_PUBLISH_CMD", + "podman exec -i koopa-bonfire-db psql -U postgres -d bonfire_db", +) + +_BARE_HOST = re.compile( + r"(?<!https://)(?<!http://)" + r"(?<![\w./@-])" + r"((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+(?:[a-zA-Z]{2,}))" + r"(?![\w./:])" +) + +MUT = """ +mutation CreatePost($pc: PostContentInput!) { + createPost(postContent: $pc) { id } +} +""" + +def sanitize_text(text: str) -> str: + """Add https:// to bare hostnames (Bonfire URI.parse breaks without it).""" + return _BARE_HOST.sub(r"https://\1", text) + +def load_users(): + d = {} + for line in USERS.read_text().splitlines(): + if "=" in line and not line.strip().startswith("#"): + k, v = line.split("=", 1) + d[k.strip()] = v.strip() + return d + +def http_json(url, data=None, headers=None, timeout=MAX_TIME): + req = urllib.request.Request( + url, + data=None if data is None else json.dumps(data).encode(), + headers=headers or {}, + method="GET" if data is None else "POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.load(r) + +def gql(token, query, variables=None): + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + body = {"query": query} + if variables is not None: + body["variables"] = variables + return http_json(GQL, body, headers) + +def bonfire_ready() -> bool: + base = GQL.rsplit("/api/", 1)[0] + "/" + try: + with urllib.request.urlopen(base, timeout=10) as r: + return 200 <= r.status < 500 + except (urllib.error.URLError, TimeoutError, OSError): + return False + +def login(email_or_user, password): + res = gql( + None, + "mutation($e:String!,$p:String!){ login(emailOrUsername:$e, password:$p){ token currentUsername } }", + {"e": email_or_user, "p": password}, + ) + tok = (res.get("data") or {}).get("login") or {} + if not tok.get("token"): + raise RuntimeError(f"login failed: {res}") + return tok["token"] + +def select_user(token, username): + res = gql( + token, + "mutation($u:String!){ selectUser(username:$u){ token currentUsername } }", + {"u": username}, + ) + t = (res.get("data") or {}).get("selectUser") or {} + return t.get("token") or token + +def create_post(token, body, summary, name): + res = gql( + token, + MUT, + {"pc": {"htmlBody": body, "summary": summary[:140], "name": name}}, + ) + post = (res.get("data") or {}).get("createPost") + if not post: + raise RuntimeError(f"createPost failed: {res}") + return post["id"] + +def post_commit(token, full, c): + sha = c["sha"] + short = sha[:8] + raw_msg = c["commit"]["message"].split("\n")[0][:140] + msg = sanitize_text(raw_msg) + html = c.get("html_url") or f"{GIT}/{full}/commit/{sha}" + author = (c.get("commit") or {}).get("author", {}).get("name") or "?" + full_body = ( + f"[gitbot] commit {short} on {full}\n" + f"{msg}\nby {author}\n{html}\n#git #ngi0 #foss" + ) + summary = sanitize_text(f"{full}@{short}: {msg}")[:140] + try: + return create_post(token, full_body, summary, f"commit {short}") + except RuntimeError: + minimal_body = f"[gitbot] commit {short} on {full}\n{html}\n#git #ngi0 #foss" + return create_post(token, minimal_body, f"{full}@{short}", f"commit {short}") + +def load_state(): + state = {"seen": [], "fails": {}} + if STATE.exists(): + state = json.loads(STATE.read_text()) + state.setdefault("seen", []) + state.setdefault("fails", {}) + return state + +def save_state(state, seen): + state["seen"] = list(seen)[-800:] + STATE.write_text(json.dumps(state, indent=2)) + +def publish_outbox_to_public() -> bool: + """Copy foss outbox into local/internet feeds (ops workaround; no official republish API).""" + sql = f""" +INSERT INTO bonfire_data_social_feed_publish (id, feed_id) +SELECT fp.id, '{FEED_INTERNET}'::uuid +FROM bonfire_data_social_feed_publish fp +WHERE fp.feed_id = '{OUTBOX_FEED}'::uuid +ON CONFLICT DO NOTHING; +INSERT INTO bonfire_data_social_feed_publish (id, feed_id) +SELECT fp.id, '{FEED_LOCAL}'::uuid +FROM bonfire_data_social_feed_publish fp +WHERE fp.feed_id = '{OUTBOX_FEED}'::uuid +ON CONFLICT DO NOTHING; +""" + try: + proc = subprocess.run( + PUBLISH_CMD.split(), + input=sql, + text=True, + capture_output=True, + timeout=30, + check=False, + ) + if proc.returncode != 0: + print("publish feeds failed:", proc.stderr.strip(), file=sys.stderr) + return False + print("published outbox -> local/internet feeds", flush=True) + return True + except (OSError, subprocess.TimeoutExpired) as e: + print("publish feeds error:", e, file=sys.stderr) + return False + +def once(): + if not bonfire_ready(): + print("bonfire not ready, skipping cycle", flush=True) + return 0 + + u = load_users() + tok = login(u.get("FOSS_USER", "foss"), u["FOSS_PW"]) + try: + tok = select_user(tok, u.get("GITBOT_USER", "gitbot")) + except Exception as e: + print("select gitbot skipped:", e, file=sys.stderr) + + state = load_state() + seen = set(state["seen"]) + fails = state["fails"] + skipped = set(state.get("skipped") or []) + + repos = http_json(f"{GIT}/api/v1/repos/search?limit=30&sort=updated")["data"] + new_n = 0 + for repo in repos: + full = repo["full_name"] + owner, name = full.split("/", 1) + try: + commits = http_json(f"{GIT}/api/v1/repos/{owner}/{name}/commits?limit=10") + except Exception as e: + print("repo fail", full, e, file=sys.stderr) + continue + for c in reversed(commits): + sha = c["sha"] + if sha in seen or sha in skipped: + continue + try: + pid = post_commit(tok, full, c) + print(f"posted {full}@{sha[:8]} -> {pid}", flush=True) + seen.add(sha) + fails.pop(sha, None) + new_n += 1 + save_state(state, seen) + time.sleep(0.4) + except Exception as e: + n = fails.get(sha, 0) + 1 + fails[sha] = n + state["fails"] = fails + save_state(state, seen) + print("post fail", full, sha[:8], f"try={n}", e, file=sys.stderr) + if n >= MAX_FAILS: + skipped.add(sha) + state["skipped"] = list(skipped)[-200:] + save_state(state, seen) + print("skip", full, sha[:8], "after repeated failures", file=sys.stderr) + publish_outbox_to_public() + print(f"done new={new_n} tracked={len(seen)} skipped={len(skipped)}", flush=True) + return new_n + +def main(): + loop = "--loop" in sys.argv + if loop: + print(f"gitbot loop interval={INTERVAL}s gql={GQL}", flush=True) + while True: + try: + once() + except Exception as e: + print("cycle error:", e, file=sys.stderr, flush=True) + time.sleep(INTERVAL) + else: + once() + +if __name__ == "__main__": + main() diff --git a/scripts/bonfire/install-systemd.sh b/scripts/bonfire/install-systemd.sh new file mode 100755 index 0000000..b8ac043 --- /dev/null +++ b/scripts/bonfire/install-systemd.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Install Bonfire + gitbot user systemd units on koopa. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +UNIT_DIR="${HOME}/.config/systemd/user" +BIN_DIR="${HOME}/koopa-bonfire/bin" + +mkdir -p "${UNIT_DIR}" "${BIN_DIR}" +install -m 755 "${ROOT}/scripts/bonfire/gitbot-mirror.py" "${BIN_DIR}/gitbot-mirror.py" +cp "${ROOT}/configs/bonfire/container-koopa-bonfire-db.service" "${UNIT_DIR}/" +cp "${ROOT}/configs/bonfire/container-koopa-bonfire.service" "${UNIT_DIR}/" +cp "${ROOT}/configs/bonfire/gitbot-mirror.service" "${UNIT_DIR}/" + +systemctl --user daemon-reload +systemctl --user enable container-koopa-bonfire-db.service container-koopa-bonfire.service gitbot-mirror.service + +if [[ -f "${HOME}/koopa-bonfire/gitbot.pid" ]]; then + old_pid="$(cat "${HOME}/koopa-bonfire/gitbot.pid" 2>/dev/null || true)" + [[ -n "${old_pid}" ]] && kill "${old_pid}" 2>/dev/null || true + rm -f "${HOME}/koopa-bonfire/gitbot.pid" +fi + +if ! podman ps --format '{{.Names}}' | grep -qx koopa-bonfire; then + (cd "${HOME}/koopa-bonfire" && podman-compose up -d) +fi +systemctl --user start container-koopa-bonfire-db.service container-koopa-bonfire.service +systemctl --user restart gitbot-mirror.service + +systemctl --user --no-pager status container-koopa-bonfire-db.service container-koopa-bonfire.service gitbot-mirror.service diff --git a/scripts/bonfire/publish-outbox-to-public.sql b/scripts/bonfire/publish-outbox-to-public.sql new file mode 100644 index 0000000..158bf55 --- /dev/null +++ b/scripts/bonfire/publish-outbox-to-public.sql @@ -0,0 +1,28 @@ +-- Republish outbox activities into guest-visible feeds (ops workaround). +-- FeedPublish model: https://docs.bonfirenetworks.org/feed_structure.html +-- No official republish procedure. Default outbox = foss. +-- Run: +-- podman exec -i koopa-bonfire-db psql -U postgres -d bonfire_db < publish-outbox-to-public.sql + +\set OUTBOX_FEED '019f487a-df20-4ed0-a334-40ec3eac23e7' +\set FEED_INTERNET '0aab414c-eb0a-ac1d-8c81-ef0d74ec55da' +\set FEED_LOCAL_USERS '797632fc-029e-06f0-1031-410d73a5558e' + +INSERT INTO bonfire_data_social_feed_publish (id, feed_id) +SELECT fp.id, :'FEED_INTERNET'::uuid +FROM bonfire_data_social_feed_publish fp +WHERE fp.feed_id = :'OUTBOX_FEED'::uuid +ON CONFLICT DO NOTHING; + +INSERT INTO bonfire_data_social_feed_publish (id, feed_id) +SELECT fp.id, :'FEED_LOCAL_USERS'::uuid +FROM bonfire_data_social_feed_publish fp +WHERE fp.feed_id = :'OUTBOX_FEED'::uuid +ON CONFLICT DO NOTHING; + +SELECT n.name, count(*) +FROM bonfire_data_social_feed_publish fp +LEFT JOIN bonfire_data_social_named n ON n.id = fp.feed_id +GROUP BY 1 +ORDER BY 2 DESC +LIMIT 15; diff --git a/scripts/caddy/README.md b/scripts/caddy/README.md new file mode 100644 index 0000000..8b022fd --- /dev/null +++ b/scripts/caddy/README.md @@ -0,0 +1,15 @@ +# Caddy on koopa + +Live: **`/etc/caddy/Caddyfile`**. +Mirror: **`configs/caddy/Caddyfile`** (same as `host/caddy/Caddyfile`). + +## Reload after edit + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` + +## New HTTPS vhost + +Add a site block to the Caddyfile (live + mirror), then validate and reload. Include the host in the shared HTTP site list (ACME + redirect) at the bottom of the file. diff --git a/scripts/castopod/README.md b/scripts/castopod/README.md new file mode 100644 index 0000000..b87d6c7 --- /dev/null +++ b/scripts/castopod/README.md @@ -0,0 +1,33 @@ +# Castopod ops scripts (koopa) + +Live stack: `/home/hernani/koopa-castopod/` (podman-compose). +These scripts live in admin-log and should be **copied** to the host when changed. + + +## Usage (on koopa as `hernani`) + +```bash +# copy once +mkdir -p ~/koopa-castopod/bin +cp /path/to/koopa-admin-log/scripts/castopod/{lib.sh,status.sh,up.sh,install-systemd.sh,apply-branding.sh} \ + ~/koopa-castopod/bin/ +chmod +x ~/koopa-castopod/bin/*.sh + +~/koopa-castopod/bin/status.sh +~/koopa-castopod/bin/up.sh +~/koopa-castopod/bin/install-systemd.sh # boot units +~/koopa-castopod/bin/apply-branding.sh # logo + theme + optional overlay +``` + +Env overrides: `CP_MAX_TIME`, `CP_PULL_TIMEOUT`, `CP_HEALTH_TRIES`, `CP_BASEURL`, +`CP_SITE_NAME`, `CP_SITE_DESCRIPTION`, `CP_THEME` (default `amber`), `CP_APPLY_OVERLAY` (default `1`). + +Passwords stay in `~/koopa-castopod/.env` and `users.env` (mode 600) — never in admin-log. + +## Official branding docs + +Instance settings (name, description, site icon, six accent themes): + +https://docs.castopod.org/main/en/user-guide/instance/settings/ + +See also `configs/castopod/README.md` and `2026/2026-07-13--castopod-boot-branding.md`. diff --git a/scripts/castopod/apply-branding.sh b/scripts/castopod/apply-branding.sh new file mode 100755 index 0000000..e630e1c --- /dev/null +++ b/scripts/castopod/apply-branding.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Apply hacktivism branding to Castopod (official settings + optional CSS overlay). +# +# Official docs (instance settings — site name/description/icon + 6 accent themes): +# https://docs.castopod.org/main/en/user-guide/instance/settings/ +# Docker/compose background: +# https://docs.castopod.org/main/en/getting-started/docker/ +# +# Logo: same magician mark as git.hacktivism.ch / bonfire.hacktivism.ch +# (configs/castopod/assets/img/logo.png — ≥512×512 required by Castopod). +# Official accent closest to gold: "amber" (among pine/crimson/lake/amber/jacaranda/onyx). +# Optional warm-dark CSS: NOT official — see configs/castopod/assets/css/theme-hacktivism-overlay.css +set -euo pipefail + +ROOT="${KOOPA_CASTOPOD_ROOT:-$HOME/koopa-castopod}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." 2>/dev/null && pwd || true)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +: "${CP_BASEURL:=https://castopod.hacktivism.ch}" +: "${CP_ADMIN_GATEWAY:=cp-admin}" +: "${CP_SITE_NAME:=hacktivism castopod}" +: "${CP_SITE_DESCRIPTION:=Castopod on hacktivism.ch — FOSS Airwaves and free-culture podcasting.}" +: "${CP_THEME:=amber}" +: "${CP_APPLY_OVERLAY:=1}" + +BRANDING_DST="${ROOT}/branding" +ICON_SRC="" +CSS_SRC="" + +if [[ -d "${REPO_ROOT}/configs/castopod/assets/img" ]]; then + ICON_SRC="${REPO_ROOT}/configs/castopod/assets/img/logo.png" + CSS_SRC="${REPO_ROOT}/configs/castopod/assets/css/theme-hacktivism-overlay.css" +elif [[ -f "${ROOT}/branding/logo.png" ]]; then + ICON_SRC="${ROOT}/branding/logo.png" + CSS_SRC="${ROOT}/branding/theme-hacktivism-overlay.css" +else + echo "missing logo.png (run from koopa-admin-log checkout or copy assets to ${ROOT}/branding/)" >&2 + exit 1 +fi + +install -d -m 755 "${BRANDING_DST}" +if [[ "$(realpath "${ICON_SRC}" 2>/dev/null || echo "${ICON_SRC}")" != "$(realpath "${BRANDING_DST}/logo.png" 2>/dev/null || echo "${BRANDING_DST}/logo.png")" ]]; then + install -m 644 "${ICON_SRC}" "${BRANDING_DST}/logo.png" +fi +if [[ -n "${CSS_SRC}" && -f "${CSS_SRC}" ]]; then + if [[ "$(realpath "${CSS_SRC}" 2>/dev/null || echo "${CSS_SRC}")" != "$(realpath "${BRANDING_DST}/theme-hacktivism-overlay.css" 2>/dev/null || true)" ]]; then + install -m 644 "${CSS_SRC}" "${BRANDING_DST}/theme-hacktivism-overlay.css" + fi +fi + +# Mount overlay into public assets if compose branding volume is present +if [[ -d "${ROOT}/branding" ]]; then + # Ensure host path for compose bind exists before recreate + true +fi + +COOKIE=$(mktemp /tmp/cp-brand-cj.XXXXXX) +trap 'rm -f "$COOKIE"' EXIT + +echo "== admin login ==" +cp_admin_login "$COOKIE" + +csrf_from() { + local url=$1 + local html + html=$(cp_curl -c "$COOKIE" -b "$COOKIE" "$url") + # multiple possible CSRF field names + printf '%s' "$html" | sed -n 's/.*name="csrf_test_name" value="\([^"]*\)".*/\1/p' | head -1 +} + +echo "== official: general settings (site name/description/icon) ==" +# UI/docs: https://docs.castopod.org/main/en/user-guide/instance/settings/ +# POST route: /{admin}/settings/instance (SettingsController::attemptInstanceEdit) +GEN_GET="${CP_BASEURL}/${CP_ADMIN_GATEWAY}/settings" +GEN_POST="${CP_BASEURL}/${CP_ADMIN_GATEWAY}/settings/instance" +csrf=$(csrf_from "$GEN_GET") +[[ -n "$csrf" ]] || { echo "ERROR: no CSRF on settings general" >&2; exit 1; } + +code=$(cp_curl -c "$COOKIE" -b "$COOKIE" -o /tmp/cp-gen.out -w '%{http_code}' \ + -X POST "${GEN_POST}" \ + -F "csrf_test_name=${csrf}" \ + -F "site_name=${CP_SITE_NAME}" \ + -F "site_description=${CP_SITE_DESCRIPTION}" \ + -F "site_icon=@${BRANDING_DST}/logo.png;type=image/png") +echo "general POST → HTTP $code" +# accept redirect +if [[ "$code" != "303" && "$code" != "302" && "$code" != "200" ]]; then + echo "WARN: unexpected status; body:" >&2 + head -c 400 /tmp/cp-gen.out >&2 || true +fi + +echo "== official: theme accent (${CP_THEME}) ==" +THEME_URL="${CP_BASEURL}/${CP_ADMIN_GATEWAY}/settings/theme" +csrf=$(csrf_from "$THEME_URL") +[[ -n "$csrf" ]] || { echo "ERROR: no CSRF on settings theme" >&2; exit 1; } + +code=$(cp_curl -c "$COOKIE" -b "$COOKIE" -o /tmp/cp-theme.out -w '%{http_code}' \ + -X POST "${THEME_URL}" \ + --data-urlencode "csrf_test_name=${csrf}" \ + --data-urlencode "theme=${CP_THEME}") +echo "theme POST → HTTP $code" + +if [[ "${CP_APPLY_OVERLAY}" == "1" && -f "${BRANDING_DST}/theme-hacktivism-overlay.css" ]]; then + echo "== optional: warm-dark CSS overlay (not official) ==" + # Serve from container public path via bind (compose) or copy into media volume + podman cp "${BRANDING_DST}/theme-hacktivism-overlay.css" \ + koopa-castopod:/app/public/assets/hacktivism-overlay.css 2>/dev/null || true + + # Idempotent inject before </head> in public layouts (re-run after image upgrade) + # Themes are root-owned in the image — patch as root (re-run after image upgrade). + # shellcheck disable=SC2016 + podman exec -u 0 koopa-castopod sh -c ' + LINK="<!-- hacktivism-overlay --><link rel=\"stylesheet\" href=\"/assets/hacktivism-overlay.css\">" + for f in /app/themes/cp_app/home.php \ + /app/themes/cp_app/podcast/_layout.php \ + /app/themes/cp_app/episode/_layout.php \ + /app/themes/cp_app/pages/_layout.php \ + /app/themes/cp_app/embed.php; do + [ -f "$f" ] || continue + if grep -q "hacktivism-overlay" "$f"; then + echo "already: $f" + continue + fi + awk -v link="$LINK" "{ if (\$0 ~ /<\\/head>/ && !done) { print link; done=1 } print }" "$f" > "$f.tmp" \ + && mv "$f.tmp" "$f" \ + && echo "patched: $f" + done + ' || echo "WARN: layout patch skipped (container missing?)" +fi + +# Clear page cache so theme/name show up +podman exec koopa-castopod sh -c 'rm -rf /app/writable/cache/* 2>/dev/null || true' || true + +echo "Done. Hard-reload https://castopod.hacktivism.ch/" +echo "Official UI also: ${CP_BASEURL}/${CP_ADMIN_GATEWAY}/settings (and /settings/theme)" diff --git a/scripts/castopod/install-systemd.sh b/scripts/castopod/install-systemd.sh new file mode 100755 index 0000000..9aed4e9 --- /dev/null +++ b/scripts/castopod/install-systemd.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Install Castopod user systemd units on koopa (boot via linger, like Bonfire). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +UNIT_DIR="${HOME}/.config/systemd/user" +BIN_DIR="${HOME}/koopa-castopod/bin" + +mkdir -p "${UNIT_DIR}" "${BIN_DIR}" + +for f in lib.sh status.sh up.sh apply-branding.sh; do + if [[ -f "${ROOT}/scripts/castopod/${f}" ]]; then + install -m 755 "${ROOT}/scripts/castopod/${f}" "${BIN_DIR}/${f}" + fi +done + +cp "${ROOT}/configs/castopod/container-koopa-castopod-mariadb.service" "${UNIT_DIR}/" +cp "${ROOT}/configs/castopod/container-koopa-castopod-redis.service" "${UNIT_DIR}/" +cp "${ROOT}/configs/castopod/container-koopa-castopod.service" "${UNIT_DIR}/" + +systemctl --user daemon-reload +systemctl --user enable \ + container-koopa-castopod-mariadb.service \ + container-koopa-castopod-redis.service \ + container-koopa-castopod.service + +if ! podman ps --format '{{.Names}}' | grep -qx koopa-castopod; then + (cd "${HOME}/koopa-castopod" && set -a && source .env && set +a && podman-compose up -d) +fi + +systemctl --user start \ + container-koopa-castopod-mariadb.service \ + container-koopa-castopod-redis.service \ + container-koopa-castopod.service + +systemctl --user --no-pager status \ + container-koopa-castopod-mariadb.service \ + container-koopa-castopod-redis.service \ + container-koopa-castopod.service + +echo "OK. Needs: loginctl enable-linger hernani (already set on koopa)." +echo "Public: https://castopod.hacktivism.ch/" diff --git a/scripts/castopod/lib.sh b/scripts/castopod/lib.sh new file mode 100644 index 0000000..4c64f32 --- /dev/null +++ b/scripts/castopod/lib.sh @@ -0,0 +1,116 @@ +# shellcheck shell=bash +# Shared helpers for Castopod ops on koopa — fail fast, never hang forever. + +set -euo pipefail + +# --- defaults (override via env) --- +: "${CP_BASEURL:=https://castopod.hacktivism.ch}" +: "${CP_COMPOSE_DIR:=/home/hernani/koopa-castopod}" +: "${CP_CONNECT_TIMEOUT:=10}" # seconds — TCP connect +: "${CP_MAX_TIME:=120}" # seconds — whole HTTP transfer +: "${CP_PULL_TIMEOUT:=600}" # seconds — image pull +: "${CP_HEALTH_TRIES:=30}" # health poll attempts +: "${CP_HEALTH_SLEEP:=5}" # seconds between polls + +# curl that cannot hang forever +cp_curl() { + curl -sS \ + --connect-timeout "${CP_CONNECT_TIMEOUT}" \ + --max-time "${CP_MAX_TIME}" \ + --retry 2 \ + --retry-delay 2 \ + --retry-connrefused \ + "$@" +} + +# HTTP status only (no hang) +cp_http_code() { + local url=$1 + cp_curl -o /dev/null -w '%{http_code}' "$url" || echo "000" +} + +# Run command with hard wall-clock limit (GNU coreutils timeout) +cp_timeout() { + local secs=$1 + shift + if command -v timeout >/dev/null 2>&1; then + timeout --signal=TERM --kill-after=15s "${secs}" "$@" + else + # fallback: no timeout binary — still run, but warn + echo "WARN: timeout(1) missing; running without wall limit: $*" >&2 + "$@" + fi +} + +# Non-interactive answer stream for spark / CLI that prompts [y,n] or passwords. +# Usage: cp_yes | podman exec -i … php spark … +# Prefer SQL for activate when possible (spark prompts hang without TTY). +cp_yes() { + # enough y's for a few prompts; never block waiting for input + yes y 2>/dev/null | head -n 20 +} + +# Poll until URL returns expected code or give up +cp_wait_http() { + local url=$1 + local want=${2:-200} + local i code + for ((i = 1; i <= CP_HEALTH_TRIES; i++)); do + code=$(cp_http_code "$url") + echo "health try $i/${CP_HEALTH_TRIES}: $url → $code" + if [[ "$code" == "$want" || "$code" =~ ^[23] ]]; then + return 0 + fi + sleep "${CP_HEALTH_SLEEP}" + done + echo "ERROR: $url still not healthy after ${CP_HEALTH_TRIES} tries (last=$code)" >&2 + return 1 +} + +# MariaDB password from compose .env (no hang if missing) +cp_mysql_password() { + local envf="${CP_COMPOSE_DIR}/.env" + [[ -f "$envf" ]] || { echo "ERROR: missing $envf" >&2; return 1; } + # shellcheck disable=SC1090 + grep -E '^MYSQL_PASSWORD=' "$envf" | head -1 | cut -d= -f2- +} + +cp_mysql() { + local pw + pw=$(cp_mysql_password) + podman exec koopa-castopod-mariadb \ + mariadb -ucastopod -p"$pw" castopod "$@" +} + +# Activate shield users without spark interactive prompt +cp_activate_users() { + cp_mysql -e "UPDATE cp_users SET active=1 WHERE username IN ('admin','ngi');" +} + +# Session login for admin UI automation (uses users.env) +cp_admin_login() { + local cookie=${1:-/tmp/cp-cj} + local envf="${CP_COMPOSE_DIR}/users.env" + [[ -f "$envf" ]] || { echo "ERROR: missing $envf" >&2; return 1; } + # shellcheck disable=SC1090 + source "$envf" + [[ -n "${ADMIN_PW:-}" ]] || { echo "ERROR: ADMIN_PW empty" >&2; return 1; } + + rm -f "$cookie" + cp_curl -c "$cookie" -b "$cookie" "${CP_BASEURL}/cp-auth/login" -o /tmp/cp-login.html + local csrf + csrf=$(sed -n 's/.*name="csrf_test_name" value="\([^"]*\)".*/\1/p' /tmp/cp-login.html | head -1) + [[ -n "$csrf" ]] || { echo "ERROR: no CSRF on login page" >&2; return 1; } + + local code + code=$(cp_curl -c "$cookie" -b "$cookie" -o /dev/null -w '%{http_code}' \ + -X POST "${CP_BASEURL}/cp-auth/login" \ + --data-urlencode "csrf_test_name=${csrf}" \ + --data-urlencode "email=admin@castopod.hacktivism.ch" \ + --data-urlencode "password=${ADMIN_PW}") + # 303 see other is success + if [[ "$code" != "303" && "$code" != "302" && "$code" != "200" ]]; then + echo "ERROR: login HTTP $code" >&2 + return 1 + fi +} diff --git a/scripts/castopod/status.sh b/scripts/castopod/status.sh new file mode 100644 index 0000000..2a234df --- /dev/null +++ b/scripts/castopod/status.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Quick Castopod health — bounded network, no hangs. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib.sh +source "${ROOT}/lib.sh" + +echo "== podman ==" +podman ps --filter name=koopa-castopod --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true + +echo "== local backend :9020 ==" +code=$(cp_http_code "http://127.0.0.1:9020/health" || true) +echo "local health → ${code:-000} (307 redirect to public is OK)" + +echo "== public ==" +# Note: do not put bare @url in curl -w; @ means "read file" in some curl contexts. +for path in "/" "/@foss" "/@foss/feed.xml" "/@foss/episodes/four-freedoms"; do + url="${CP_BASEURL}${path}" + code=$(cp_http_code "$url") + printf ' %s → %s\n' "$url" "$code" +done diff --git a/scripts/castopod/up.sh b/scripts/castopod/up.sh new file mode 100644 index 0000000..dc911ef --- /dev/null +++ b/scripts/castopod/up.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Start Castopod stack with hard timeouts on pull/up (no infinite hang). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib.sh +source "${ROOT}/lib.sh" + +cd "${CP_COMPOSE_DIR}" + +echo "== pull (max ${CP_PULL_TIMEOUT}s) ==" +cp_timeout "${CP_PULL_TIMEOUT}" podman-compose pull + +echo "== up ==" +cp_timeout 180 podman-compose up -d + +echo "== wait for app on :9020 ==" +# Castopod often 307 from /health to https — accept 2xx/3xx +ok=0 +for ((i = 1; i <= CP_HEALTH_TRIES; i++)); do + code=$(cp_http_code "http://127.0.0.1:9020/" || true) + echo "try $i: local / → $code" + if [[ "$code" =~ ^[23] ]]; then + ok=1 + break + fi + sleep "${CP_HEALTH_SLEEP}" +done +[[ "$ok" -eq 1 ]] || { echo "ERROR: app not answering on 9020"; podman-compose ps; exit 1; } + +echo "== public via Caddy ==" +cp_wait_http "${CP_BASEURL}/" || true +echo "done." diff --git a/scripts/monitoring/countries.txt b/scripts/monitoring/countries.txt new file mode 100755 index 0000000..9177e28 --- /dev/null +++ b/scripts/monitoring/countries.txt @@ -0,0 +1,249 @@ +AF=Afghanistan +AX=Åland Islands +AL=Albania +DZ=Algeria +AS=American Samoa +AD=Andorra +AO=Angola +AI=Anguilla +AQ=Antarctica +AG=Antigua and Barbuda +AR=Argentina +AM=Armenia +AW=Aruba +AU=Australia +AT=Austria +AZ=Azerbaijan +BS=Bahamas +BH=Bahrain +BD=Bangladesh +BB=Barbados +BY=Belarus +BE=Belgium +BZ=Belize +BJ=Benin +BM=Bermuda +BT=Bhutan +BO=Bolivia +BQ=Bonaire, Sint Eustatius and Saba +BA=Bosnia and Herzegovina +BW=Botswana +BV=Bouvet Island +BR=Brazil +IO=British Indian Ocean Territory +BN=Brunei Darussalam +BG=Bulgaria +BF=Burkina Faso +BI=Burundi +KH=Cambodia +CM=Cameroon +CA=Canada +CV=Cabo Verde +KY=Cayman Islands +CF=Central African Republic +TD=Chad +CL=Chile +CN=China +CX=Christmas Island +CC=Cocos (Keeling) Islands +CO=Colombia +KM=Comoros +CG=Congo +CD=Congo (DRC) +CK=Cook Islands +CR=Costa Rica +CI=Côte d'Ivoire +HR=Croatia +CU=Cuba +CW=Curaçao +CY=Cyprus +CZ=Czechia +DK=Denmark +DJ=Djibouti +DM=Dominica +DO=Dominican Republic +EC=Ecuador +EG=Egypt +SV=El Salvador +GQ=Equatorial Guinea +ER=Eritrea +EE=Estonia +SZ=Eswatini +ET=Ethiopia +FK=Falkland Islands +FO=Faroe Islands +FJ=Fiji +FI=Finland +FR=France +GF=French Guiana +PF=French Polynesia +TF=French Southern Territories +GA=Gabon +GM=Gambia +GE=Georgia +DE=Germany +GH=Ghana +GI=Gibraltar +GR=Greece +GL=Greenland +GD=Grenada +GP=Guadeloupe +GU=Guam +GT=Guatemala +GG=Guernsey +GN=Guinea +GW=Guinea-Bissau +GY=Guyana +HT=Haiti +HM=Heard Island and McDonald Islands +VA=Holy See +HN=Honduras +HK=Hong Kong +HU=Hungary +IS=Iceland +IN=India +ID=Indonesia +IR=Iran +IQ=Iraq +IE=Ireland +IM=Isle of Man +IL=Israel +IT=Italy +JM=Jamaica +JP=Japan +JE=Jersey +JO=Jordan +KZ=Kazakhstan +KE=Kenya +KI=Kiribati +KP=North Korea +KR=South Korea +KW=Kuwait +KG=Kyrgyzstan +LA=Laos +LV=Latvia +LB=Lebanon +LS=Lesotho +LR=Liberia +LY=Libya +LI=Liechtenstein +LT=Lithuania +LU=Luxembourg +MO=Macao +MG=Madagascar +MW=Malawi +MY=Malaysia +MV=Maldives +ML=Mali +MT=Malta +MH=Marshall Islands +MQ=Martinique +MR=Mauritania +MU=Mauritius +YT=Mayotte +MX=Mexico +FM=Micronesia +MD=Moldova +MC=Monaco +MN=Mongolia +ME=Montenegro +MS=Montserrat +MA=Morocco +MZ=Mozambique +MM=Myanmar +NA=Namibia +NR=Nauru +NP=Nepal +NL=Netherlands +NC=New Caledonia +NZ=New Zealand +NI=Nicaragua +NE=Niger +NG=Nigeria +NU=Niue +NF=Norfolk Island +MK=North Macedonia +MP=Northern Mariana Islands +NO=Norway +OM=Oman +PK=Pakistan +PW=Palau +PS=Palestine +PA=Panama +PG=Papua New Guinea +PY=Paraguay +PE=Peru +PH=Philippines +PN=Pitcairn +PL=Poland +PT=Portugal +PR=Puerto Rico +QA=Qatar +RE=Réunion +RO=Romania +RU=Russia +RW=Rwanda +BL=Saint Barthélemy +SH=Saint Helena +KN=Saint Kitts and Nevis +LC=Saint Lucia +MF=Saint Martin +PM=Saint Pierre and Miquelon +VC=Saint Vincent and the Grenadines +WS=Samoa +SM=San Marino +ST=Sao Tome and Principe +SA=Saudi Arabia +SN=Senegal +RS=Serbia +SC=Seychelles +SL=Sierra Leone +SG=Singapore +SX=Sint Maarten +SK=Slovakia +SI=Slovenia +SB=Solomon Islands +SO=Somalia +ZA=South Africa +GS=South Georgia and the South Sandwich Islands +SS=South Sudan +ES=Spain +LK=Sri Lanka +SD=Sudan +SR=Suriname +SJ=Svalbard and Jan Mayen +SE=Sweden +CH=Switzerland +SY=Syria +TW=Taiwan +TJ=Tajikistan +TZ=Tanzania +TH=Thailand +TL=Timor-Leste +TG=Togo +TK=Tokelau +TO=Tonga +TT=Trinidad and Tobago +TN=Tunisia +TR=Türkiye +TM=Turkmenistan +TC=Turks and Caicos Islands +TV=Tuvalu +UG=Uganda +UA=Ukraine +AE=United Arab Emirates +GB=United Kingdom +US=United States +UM=U.S. Minor Outlying Islands +UY=Uruguay +UZ=Uzbekistan +VU=Vanuatu +VE=Venezuela +VN=Vietnam +VG=Virgin Islands (British) +VI=Virgin Islands (U.S.) +WF=Wallis and Futuna +EH=Western Sahara +YE=Yemen +ZM=Zambia +ZW=Zimbabwe diff --git a/scripts/monitoring/tor_inbound_connects.sh b/scripts/monitoring/tor_inbound_connects.sh new file mode 100755 index 0000000..168e6fc --- /dev/null +++ b/scripts/monitoring/tor_inbound_connects.sh @@ -0,0 +1,8 @@ +#!/bin/sh +for ((;;)) + do + date && + echo -n "Tor IPv4 inbound connects: "; ss -s -4 | grep 192.168.100.95:https | wc -l && + echo -n "Tor IPv6 inbound connects: "; ss -s -6 | grep -E '::1]:https' | wc -l && + sleep 3600 + done diff --git a/scripts/monitoring/tor_show_relays--koopa+firecuda.sh b/scripts/monitoring/tor_show_relays--koopa+firecuda.sh new file mode 100755 index 0000000..07799a6 --- /dev/null +++ b/scripts/monitoring/tor_show_relays--koopa+firecuda.sh @@ -0,0 +1,2 @@ +#!/bin/bash + for ((;;)); do date && time ./tor_show_relays.sh -5000 | grep '|' | cat -n | grep -Ei 'koopa|firecuda' && sleep 86400; done diff --git a/scripts/monitoring/tor_show_relays.sh b/scripts/monitoring/tor_show_relays.sh new file mode 100755 index 0000000..116fe9b --- /dev/null +++ b/scripts/monitoring/tor_show_relays.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +CACHE_DIR="cache" +COUNTRY_FILE="countries.txt" +DEFAULT_TOP=100 +TOPN=$DEFAULT_TOP + +mkdir -p "$CACHE_DIR" + +# Parse -N flag +if [[ "$1" =~ ^-([0-9]+)$ ]]; then + TOPN="${BASH_REMATCH[1]}" +fi + +# =========================== +# LOAD COUNTRY NAMES +# =========================== + +declare -A COUNTRY_NAME +while IFS='=' read -r ISO NAME; do + [[ -z "$ISO" ]] && continue + COUNTRY_NAME["$ISO"]="$NAME" +done < "$COUNTRY_FILE" + +# =========================== +# SMART FETCH (ETag-based) +# =========================== + +fetch_if_new() { + local url="$1" + local outfile="$2" + local etagfile="${outfile}.etag" + + echo "→ Checking $outfile" + curl -s \ + --etag-save "$etagfile" \ + --etag-compare "$etagfile" \ + -o "$outfile" \ + "$url" + + echo " Size: $(du -h "$outfile" | cut -f1)" +} + +DETAILS_JSON="$CACHE_DIR/details.json" +BANDWIDTH_JSON="$CACHE_DIR/bandwidth.json" +MERGED="$CACHE_DIR/merged.txt" + +echo "=== STEP 1: Downloading (if new) ===" +fetch_if_new \ + "https://onionoo.torproject.org/details?type=relay&fields=fingerprint,country,nickname" \ + "$DETAILS_JSON" + +fetch_if_new \ + "https://onionoo.torproject.org/bandwidth?type=relay&fields=fingerprint,write_history" \ + "$BANDWIDTH_JSON" + +# =========================== +# STEP 2: MERGE EVERYTHING IN ONE jq PASS +# =========================== + +echo +echo "=== STEP 2: Merging in jq (single pass) ===" + +jq -s -r ' + # Build index of details by fingerprint + (.[0].relays + | map({ + fp: .fingerprint, + country: (.country // "??"), + nickname: (.nickname // "UnknownRelay") + }) + | INDEX(.fp) + ) as $d + + # Iterate over bandwidth relays + | .[1].relays[] + | .fingerprint as $fp + | ($d[$fp].country) as $cc + | ($d[$fp].nickname) as $nick + | (.write_history["1_month"].factor // 0) as $bw + + # Output: bw fp cc nickname + | "\($bw) \($fp) \($cc) \($nick)" +' "$DETAILS_JSON" "$BANDWIDTH_JSON" > "$MERGED" + +echo " Merged lines: $(wc -l < "$MERGED")" + +# =========================== +# STEP 3: SORT + PRINT +# =========================== + +echo +echo "=== STEP 3: Sorting and printing ===" +echo +echo "=== Top $TOPN Tor Relays (by 1-month write factor) ===" +echo + +sort -nr "$MERGED" | head -n "$TOPN" | while read -r BW FP CC NICK; do + CC_UP=$(echo "$CC" | tr '[:lower:]' '[:upper:]') + FULL="${COUNTRY_NAME[$CC_UP]}" + [[ -z "$FULL" ]] && FULL="$NICK" + + printf "%-40s | %12.2f | %-20s | %2s (%s)\n" "$FP" "$BW" "$NICK" "$CC_UP" "$FULL" + +done + +echo +echo "Done." diff --git a/scripts/monitoring/tor_stats_per_country.sh b/scripts/monitoring/tor_stats_per_country.sh new file mode 100755 index 0000000..b7e99cc --- /dev/null +++ b/scripts/monitoring/tor_stats_per_country.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +PORT=8080 +DB="/usr/share/GeoIP/GeoLite2-Country.mmdb" +LOGFILE="/dev/null" +COUNTRY_FILE="countries.txt" + +declare -A COUNTRY_COUNT +declare -A SEEN +declare -A COUNTRY_NAME + +# --- Parse -N flag (e.g. -10 means show top 10) --- +TOPN=0 +if [[ "$1" =~ ^-([0-9]+)$ ]]; then + TOPN="${BASH_REMATCH[1]}" +fi + +# --- Load external country list --- +while IFS='=' read -r ISO NAME; do + [[ -z "$ISO" ]] && continue + COUNTRY_NAME["$ISO"]="$NAME" +done < "$COUNTRY_FILE" + +echo "Monitoring port $PORT..." +echo "Updating table every 5 seconds." +[[ $TOPN -gt 0 ]] && echo "Showing only Top $TOPN countries." + +LAST_REFRESH=0 + +while true; do + # FAST LOOP: collect new connections + IPS=$(ss -tn sport = :$PORT | awk 'NR>1 {print $5}' | cut -d: -f1) + + for IP in $IPS; do + [[ "$IP" == "127.0.0.1" ]] && continue + + if [[ -z "${SEEN[$IP]}" ]]; then + SEEN[$IP]=1 + + RAW=$(mmdblookup --file "$DB" --ip "$IP" country iso_code 2>/dev/null) + ISO=$(echo "$RAW" | grep -oE '[A-Z]{2}') + [[ -z "$ISO" ]] && ISO="UNKNOWN" + + COUNTRY_COUNT["$ISO"]=$(( COUNTRY_COUNT["$ISO"] + 1 )) + + NAME="${COUNTRY_NAME[$ISO]}" + [[ -z "$NAME" ]] && NAME="Unknown Country" + + echo "$(date '+%F %T') - $IP - $ISO ($NAME)" >> "$LOGFILE" + fi + done + + # SLOW LOOP: refresh display every 5 seconds + NOW=$(date +%s) + if (( NOW - LAST_REFRESH >= 5 )); then + LAST_REFRESH=$NOW + + clear + echo "=== Live GeoIP Stats (Port $PORT) ===" + echo "(Updated: $(date '+%H:%M:%S'))" + echo + + # Sort by count (descending) + SORTED=$(for ISO in "${!COUNTRY_COUNT[@]}"; do + echo "${COUNTRY_COUNT[$ISO]} $ISO" + done | sort -rn) + + COUNT=0 + while read -r LINE; do + NUM=$(echo "$LINE" | awk '{print $1}') + ISO=$(echo "$LINE" | awk '{print $2}') + NAME="${COUNTRY_NAME[$ISO]}" + [[ -z "$NAME" ]] && NAME="Unknown Country" + + echo "$ISO ($NAME): $NUM" + + ((COUNT++)) + [[ $TOPN -gt 0 && $COUNT -ge $TOPN ]] && break + done <<< "$SORTED" + fi + + sleep 0.1 +done diff --git a/scripts/taler-bank/README.md b/scripts/taler-bank/README.md new file mode 100644 index 0000000..df29e4b --- /dev/null +++ b/scripts/taler-bank/README.md @@ -0,0 +1,71 @@ +# taler-bank scripts + +Container: **`taler-hacktivism-bank`** (libeufin-bank, GOA, **no IBAN**). + +| File | Container path | User | +|------|----------------|------| +| `start_base_services_for_taler_bank.sh` | `/root/` | **root** | +| `start_bank.sh` | `/usr/local/bin/` | **libeufin-bank** | +| `check_bank-health.sh` | `/usr/local/bin/` | libeufin-bank / any | +| `landing-stats.sh` | `/usr/local/bin/` | root (in container) — writes `/var/www/bank-landing/stats.json` | +| `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 | +| `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 | + +## Usage + +```bash +# root in container +./start_base_services_for_taler_bank.sh +# then as libeufin-bank in /usr/local/bin: +./start_bank.sh --restart +``` + +## Landing stats (inside container) + +```bash +# on koopa host — copy + run (writes /var/www/bank-landing/stats.json) +./landing-stats-install.sh +./landing-stats-install.sh --cron # every minute inside container (* * * * *) +./landing-stats-install.sh --run-only +``` + +Details + JSON schema: `configs/bank-landing/README.md`. + +## Demo withdraw + auto-account API + +`demo-withdraw-api.py` listens on **127.0.0.1:19096** (proxied by nginx on the landing): + +| Path | Behaviour | +|------|-----------| +| `GET /demo-withdraw.json` | Mint one-shot withdraw from shared **`explorer`** pool; write `withdraw.uri` + watch ids | +| `GET /auto-account.json` | Public `POST /accounts` with generated **`goa-account-<random>`** user + password containing **pleasechangeme**; **balance GOA:0**; return credentials once | + +Install / restart: + +```bash +./install-demo-withdraw-api.sh +# Public checks: +curl -sS https://bank.hacktivism.ch/intro/demo-withdraw.json | head +curl -sS https://bank.hacktivism.ch/intro/auto-account.json | head # creates a real account +``` + +Requires **python3** in the bank container. Env: `BANK_URL`, `BANK_USER`/`BANK_PASS` +(or `/root/bank-explorer-password.txt`), `AMOUNT` (default `GOA:10` for shared withdraws). + +### Auto-confirm (explorer only) + +```bash +# loop inside container — refuses non-explorer unless ALLOW_NON_EXPLORER=1 +auto-confirm-withdrawals.sh --loop 4 +``` + +Only confirms withdrawals owned by **`explorer`** when status is `selected` +(community demo path). Does not confirm arbitrary customer withdraws. + +## 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 new file mode 100755 index 0000000..3b9dc85 --- /dev/null +++ b/scripts/taler-bank/auto-confirm-withdrawals.sh @@ -0,0 +1,143 @@ +#!/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). +# +# 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_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) +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}" +LOOP=0 +SLEEP=5 +if [ "${1:-}" = "--loop" ]; then + LOOP=1 + SLEEP="${2:-5}" +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 +fi + +PASS="${BANK_PASS:-}" +if [ -z "$PASS" ]; then + for f in "/root/bank-${USER}-password.txt" /root/bank-explorer-password.txt; do + if [ -f "$f" ]; then PASS=$(tr -d '\n' <"$f"); break; fi + done +fi +[ -n "$PASS" ] || { echo "no password for $USER" >&2; exit 1; } + +# 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 +} + +token() { + curl -sS -m 12 -u "${USER}:${PASS}" \ + -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite","refreshable":true}' \ + "${BANK}/accounts/${USER}/token" +} + +# IDs created for the community pool (landing + watch list only) +known_ids() { + if [ -f "${LANDING_DIR}/withdraw.uri" ]; then + basename "$(tr -d '\n' <"${LANDING_DIR}/withdraw.uri")" + 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 + fi +} + +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) + [ -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) + + # 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:-?}" + 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 +} + +once() { + local tjson tok ids + 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) + if [ -z "$ids" ]; then + echo "no withdrawal ids to watch (landing withdraw.uri / withdraw-watch.ids)" + return 0 + fi + while read -r wid; do + [ -n "$wid" ] || continue + confirm_one "$wid" "$tok" + done <<<"$ids" +} + +if [ "$LOOP" -eq 1 ]; then + echo "auto-confirm loop every ${SLEEP}s for pool user=$USER only" + while true; do + once || true + sleep "$SLEEP" + done +else + once +fi diff --git a/scripts/taler-bank/check_bank-health.sh b/scripts/taler-bank/check_bank-health.sh new file mode 100755 index 0000000..c01ead2 --- /dev/null +++ b/scripts/taler-bank/check_bank-health.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Health check for manual libeufin-bank (same style as merchant/exchange health). +# Container: taler-hacktivism-bank + +CONF=/etc/libeufin/libeufin-bank.conf +PORT=$(grep -E '^\s*PORT\s*=' /etc/libeufin/bank-overrides.conf 2>/dev/null | tail -1 | awk -F= '{gsub(/ /,"",$2); print $2}') +PORT=${PORT:-9012} + +green() { echo -e "\e[32m$1\e[0m"; } +red() { echo -e "\e[31m$1\e[0m"; } +yellow() { echo -e "\e[33m$1\e[0m"; } + +fail=0 +ok() { green "[OK] $1"; } +bad() { red "[FAIL] $1"; fail=1; } + +echo "=== Taler Bank (libeufin-bank) Health Check ===" + +if pgrep -f 'libeufin-bank serve|MainKt serve|tech.libeufin.bank.MainKt' >/dev/null 2>&1; then + ok "process libeufin-bank serve" +else + bad "process libeufin-bank serve is NOT running" +fi + +if curl -sf -m 3 "http://127.0.0.1:${PORT}/config" >/dev/null 2>&1; then + ok "HTTP /config on 127.0.0.1:${PORT}" +else + bad "no HTTP response on 127.0.0.1:${PORT}/config" +fi + +if curl -sf -m 3 "http://127.0.0.1:${PORT}/taler-integration/config" >/dev/null 2>&1; then + ok "HTTP /taler-integration/config on 127.0.0.1:${PORT}" +else + yellow "[WARN] no HTTP response on /taler-integration/config" +fi + +if pg_isready >/dev/null 2>&1; then + ok "postgresql accepting connections" +else + yellow "[WARN] postgresql not ready (or pg_isready missing)" +fi + +if [ "$fail" -eq 0 ]; then + green "=== ALL CRITICAL CHECKS PASSED ===" + exit 0 +fi +red "=== SOME CHECKS FAILED ===" +exit 1 diff --git a/scripts/taler-bank/credit-account.sh b/scripts/taler-bank/credit-account.sh new file mode 100755 index 0000000..fd3ac15 --- /dev/null +++ b/scripts/taler-bank/credit-account.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Credit a bank user by transferring from admin (creates regional GOA via admin debit). +# Run as root on koopa host (bank on 127.0.0.1:9012). +# +# Usage: +# credit-account.sh [USERNAME] [AMOUNT] +# credit-account.sh explorer GOA:1000 +set -euo pipefail + +BANK="${BANK_URL:-http://127.0.0.1:9012}" +TO_USER="${1:-explorer}" +AMOUNT="${2:-GOA:1000}" +ADMIN_PASS="${BANK_ADMIN_PASS:-}" +if [ -z "$ADMIN_PASS" ] && [ -f /root/bank-admin-password.txt ]; then + ADMIN_PASS=$(tr -d '\n' </root/bank-admin-password.txt) +fi +[ -n "$ADMIN_PASS" ] || { echo "Need BANK_ADMIN_PASS or /root/bank-admin-password.txt" >&2; exit 1; } + +W=$(mktemp -d) +trap 'rm -rf "$W"' EXIT + +echo '{"scope":"readwrite"}' >"$W/tok.json" +curl -sS -m 15 -u "admin:${ADMIN_PASS}" \ + -H 'Content-Type: application/json' \ + -d @"$W/tok.json" \ + "${BANK}/accounts/admin/token" >"$W/tok.out" +TOKEN=$(python3 -c "import json;print(json.load(open('$W/tok.out')).get('access_token',''))") +[ -n "$TOKEN" ] || { echo "admin token failed:"; cat "$W/tok.out"; exit 1; } + +# payto for x-taler-bank regional accounts +PAYTO="payto://x-taler-bank/bank.hacktivism.ch/${TO_USER}?receiver-name=${TO_USER}&message=credit-from-admin" + +python3 - "$AMOUNT" "$PAYTO" "$W/tx.json" <<'PY' +import json, sys, os +amount, payto, out = sys.argv[1], sys.argv[2], sys.argv[3] +# ShortHashCode: Crockford base32 of 32 random bytes (52 chars) +_ALPH = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +def crockford32(data: bytes) -> str: + n = int.from_bytes(data, "big") + bits = len(data) * 8 + outc = [] + while bits > 0: + bits -= 5 + outc.append(_ALPH[(n >> bits) & 31] if bits >= 0 else _ALPH[(n << (-bits)) & 31]) + if bits <= 0: + break + # pad to full groups + s = "".join(outc) + # simpler bit stream + return None +def crock32(b: bytes) -> str: + bits = 0 + val = 0 + outc = [] + for byte in b: + val = (val << 8) | byte + bits += 8 + while bits >= 5: + bits -= 5 + outc.append(_ALPH[(val >> bits) & 31]) + if bits: + outc.append(_ALPH[(val << (5 - bits)) & 31]) + return "".join(outc) +uid = crock32(os.urandom(32)) +json.dump({"payto_uri": payto, "amount": amount, "request_uid": uid}, open(out, "w")) +print("request_uid", uid, "len", len(uid)) +PY + +echo "POST admin -> $TO_USER amount=$AMOUNT" +curl -sS -m 15 \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d @"$W/tx.json" \ + "${BANK}/accounts/admin/transactions" | tee "$W/tx.out" +echo + +# show balances +for u in admin "$TO_USER"; do + curl -sS -m 10 -u "admin:${ADMIN_PASS}" \ + -H 'Content-Type: application/json' -d '{"scope":"readonly"}' \ + "${BANK}/accounts/admin/token" >"$W/rtok" 2>/dev/null || true +done + +# re-token readonly and print target balance +echo '{"scope":"readonly"}' >"$W/rtok.json" +# admin can GET any account +curl -sS -m 10 -H "Authorization: Bearer ${TOKEN}" \ + "${BANK}/accounts/${TO_USER}" | tee "$W/acc.out" +echo +python3 -c " +import json +d=json.load(open('$W/acc.out')) +b=d.get('balance') or {} +print('RESULT', '${TO_USER}', 'balance=', b.get('amount'), b.get('credit_debit_indicator')) +print('debit_threshold=', d.get('debit_threshold')) +" diff --git a/scripts/taler-bank/demo-withdraw-api.py b/scripts/taler-bank/demo-withdraw-api.py new file mode 100755 index 0000000..b0ff9bb --- /dev/null +++ b/scripts/taler-bank/demo-withdraw-api.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +""" +HTTP helper for bank landing: + - GET /demo-withdraw.json — mint shared-pool (explorer) demo withdraw + - GET /auto-account.json — create a personal bank account (balance 0) + and return one-time credentials for the user to copy + +Listens on 127.0.0.1:19096 (only inside bank container / localhost). +Nginx proxies /intro/*.json → this service. + +Env: + BANK_URL default http://127.0.0.1:9012 + BANK_USER default explorer + BANK_PASS or /root/bank-explorer-password.txt + AMOUNT default GOA:10 + LANDING_DIR default /var/www/bank-landing +""" +from __future__ import annotations + +import json +import os +import re +import secrets +import ssl +import string +import time +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +BANK = os.environ.get("BANK_URL", "http://127.0.0.1:9012").rstrip("/") +# Public HTTPS base (Caddy) — absolute webui / login links +BANK_PUBLIC = os.environ.get("BANK_PUBLIC", "https://bank.hacktivism.ch").rstrip("/") +USER = os.environ.get("BANK_USER", "explorer") +AMOUNT = os.environ.get("AMOUNT", "GOA:10") +LANDING = Path(os.environ.get("LANDING_DIR", "/var/www/bank-landing")) +LISTEN = ("127.0.0.1", int(os.environ.get("DEMO_WITHDRAW_PORT", "19096"))) + + +def public_webui_url() -> str: + return f"{BANK_PUBLIC}/webui/" + + +def load_pass() -> str: + p = os.environ.get("BANK_PASS", "").strip() + if p: + return p + for f in ( + Path(f"/root/bank-{USER}-password.txt"), + Path("/root/bank-explorer-password.txt"), + ): + if f.is_file(): + return f.read_text().strip() + raise RuntimeError("no BANK_PASS / explorer password file") + + +def http_json(method: str, url: str, body=None, headers=None, auth=None): + data = None if body is None else json.dumps(body).encode() + h = dict(headers or {}) + if body is not None: + h["Content-Type"] = "application/json" + if auth: + import base64 + + token = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode() + h["Authorization"] = f"Basic {token}" + req = urllib.request.Request(url, data=data, method=method, headers=h) + ctx = ssl.create_default_context() + try: + with urllib.request.urlopen(req, context=ctx, timeout=20) as r: + raw = r.read().decode() + return r.status, json.loads(raw) if raw.strip() else {} + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + return e.code, json.loads(raw) + except Exception: + return e.code, {"raw": raw[:500]} + + +def mint_withdraw() -> dict: + pw = load_pass() + code, tok = http_json( + "POST", + f"{BANK}/accounts/{USER}/token", + {"scope": "readwrite", "refreshable": True}, + auth=(USER, pw), + ) + if code != 200 or not tok.get("access_token"): + raise RuntimeError(f"token failed HTTP {code}: {tok}") + access = tok["access_token"] + code, wd = http_json( + "POST", + f"{BANK}/accounts/{USER}/withdrawals", + {"suggested_amount": AMOUNT}, + headers={"Authorization": f"Bearer {access}"}, + ) + if code not in (200, 201): + raise RuntimeError(f"withdrawal create HTTP {code}: {wd}") + uri = wd.get("taler_withdraw_uri") or "" + wid = wd.get("withdrawal_id") or "" + if not uri: + 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() + LANDING.mkdir(parents=True, exist_ok=True) + (LANDING / "withdraw.uri").write_text(uri + "\n") + (LANDING / "withdraw.amount").write_text(AMOUNT + "\n") + (LANDING / "withdraw.created").write_text( + time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()) + "\n" + ) + watch_withdrawal(wid) + return { + "ok": True, + "taler_withdraw_uri": uri, + "withdrawal_id": wid, + "amount": AMOUNT, + "pool_account": USER, + "taler_integration_base": f"{BANK_PUBLIC}/taler-integration/", + "hint": "Open in GNU Taler Wallet (iOS/Android/desktop). No bank registration.", + "created": time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()), + } + + +def watch_withdrawal(wid: str) -> None: + """Queue withdrawal_id for auto-confirm loop (shared pool + personal).""" + if not wid: + return + LANDING.mkdir(parents=True, exist_ok=True) + watch = LANDING / "withdraw-watch.ids" + ids = set() + if watch.is_file(): + ids = {ln.strip() for ln in watch.read_text().splitlines() if ln.strip()} + ids.add(str(wid).strip()) + watch.write_text("\n".join(sorted(ids)) + "\n") + + + +# Funny stems for usernames (bank-safe [a-z0-9-]). +# Source: hand-curated in this file only — not scraped from the web. +# Keep culture-neutral: light space / physics wordplay, no animals, foods, +# body parts, religion, politics, or slang that can offend. +_FUNNY_STEMS = ( + "nebula-nudge", + "orbit-echo", + "voidwave-vibe", + "comet-crumb", + "quark-pulse", + "plasma-spark", + "astro-glint", + "lunar-loop", + "warp-ripple", + "photon-bloom", + "galaxy-drift", + "rocket-ribbon", + "satellite-swirl", + "meteor-mint", + "stardust-swirl", + "hyperdrive-hum", + "cosmic-coral", + "space-spark", + "nova-nibble", + "aurora-arc", + "solar-swish", + "pulsar-pop", + "comet-cloud", + "orbit-opal", + "zenith-zip", + "eclipse-echo", + "horizon-hum", + "starlight-step", +) + + +def _rand_username_and_name() -> tuple[str, str]: + # Shown as goa-account-<funnypiece>-<tag> (e.g. goa-account-space-potato-k3m9x) + stem = secrets.choice(_FUNNY_STEMS) + alphabet = string.ascii_lowercase + string.digits + tag = "".join(secrets.choice(alphabet) for _ in range(5)) + username = f"goa-account-{stem}-{tag}" + name = username + return username, name + + +def _rand_password() -> str: + # Embed "pleasechangeme" with random material before and after. + alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789" + prefix = "".join(secrets.choice(alphabet) for _ in range(4)) + suffix = "".join(secrets.choice(alphabet) for _ in range(6)) + return prefix + "pleasechangeme" + suffix + + +def create_personal_account() -> dict: + """Public registration: auto username/password, balance starts at 0.""" + username, name = _rand_username_and_name() + password = _rand_password() + code, body = http_json( + "POST", + f"{BANK}/accounts", + { + "username": username, + "password": password, + "name": name, + }, + ) + if code not in (200, 201, 204): + # retry once on conflict + if code in (409, 400): + username, name = _rand_username_and_name() + code, body = http_json( + "POST", + f"{BANK}/accounts", + { + "username": username, + "password": password, + "name": name, + }, + ) + if code not in (200, 201, 204): + raise RuntimeError(f"register failed HTTP {code}: {body}") + webui = public_webui_url() + # Same shared-pool withdraw as step 2 (explorer + auto-confirm) + wd = mint_withdraw() + withdraw_uri = wd["taler_withdraw_uri"] + return { + "ok": True, + "created_for_you": True, + "username": username, + "password": password, + "name": name, + "display_name": name, + "balance": "GOA:0", + "balance_note": "Starts at zero — not the shared community pool.", + "taler_withdraw_uri": withdraw_uri, + "withdrawal_id": wd["withdrawal_id"], + "withdraw_amount": wd.get("amount") or AMOUNT, + "pool_account": wd.get("pool_account") or USER, + "qr_payload": withdraw_uri, + "webui": webui, + "account_url": webui, + "login_url": webui, + "hint": ( + f"Login at {webui} with username {username} and the password shown " + "(not stored for recovery). Wallet QR is taler://withdraw/… from the " + f"shared pool ({USER}), same as step 2." + ), + "created": time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()), + "created_human": time.strftime("%Y-%m-%d %H:%M %Z", time.localtime()), + } + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + sys_stderr = __import__("sys").stderr + sys_stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) + + def _cors(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.send_header("Cache-Control", "no-store") + + def do_OPTIONS(self): + self.send_response(204) + self._cors() + self.end_headers() + + def do_GET(self): + path = self.path.split("?", 1)[0] + try: + if path in ( + "/", + "/demo-withdraw.json", + "/intro/demo-withdraw.json", + ): + body = mint_withdraw() + elif path in ( + "/auto-account.json", + "/intro/auto-account.json", + ): + body = create_personal_account() + else: + self.send_response(404) + self._cors() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"ok":false,"error":"not found"}') + return + raw = json.dumps(body).encode() + self.send_response(200) + self._cors() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + except Exception as e: + raw = json.dumps({"ok": False, "error": str(e)}).encode() + self.send_response(500) + self._cors() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + +def main(): + httpd = ThreadingHTTPServer(LISTEN, Handler) + print(f"demo-withdraw-api on http://{LISTEN[0]}:{LISTEN[1]}/", flush=True) + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/taler-bank/install-demo-withdraw-api.sh b/scripts/taler-bank/install-demo-withdraw-api.sh new file mode 100755 index 0000000..5366486 --- /dev/null +++ b/scripts/taler-bank/install-demo-withdraw-api.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Install + start demo-withdraw API + auto-confirm loop inside bank container. +# Host: +# ./scripts/taler-bank/install-demo-withdraw-api.sh +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +CTR="${BANK_CONTAINER:-taler-hacktivism-bank}" + +podman cp "$ROOT/demo-withdraw-api.py" "$CTR:/usr/local/bin/demo-withdraw-api.py" +podman cp "$ROOT/auto-confirm-withdrawals.sh" "$CTR:/usr/local/bin/auto-confirm-withdrawals.sh" +podman cp "$ROOT/refresh-demo-withdraw.sh" "$CTR:/usr/local/bin/refresh-demo-withdraw.sh" +podman exec -u root "$CTR" chmod 755 \ + /usr/local/bin/demo-withdraw-api.py \ + /usr/local/bin/auto-confirm-withdrawals.sh \ + /usr/local/bin/refresh-demo-withdraw.sh + +# nginx: proxy demo-withdraw.json +NGX=/etc/nginx/sites-available/bank-landing +podman exec -u root "$CTR" bash -lc ' +set -e +f=/etc/nginx/sites-available/bank-landing +if ! grep -q demo-withdraw.json "$f"; then + # insert before location /intro/ + python3 - <<PY +from pathlib import Path +p=Path("/etc/nginx/sites-available/bank-landing") +t=p.read_text() +block=""" location = /intro/demo-withdraw.json { + proxy_pass http://127.0.0.1:19096/demo-withdraw.json; + proxy_http_version 1.1; + proxy_set_header Host \$host; + add_header Cache-Control "no-store" always; + add_header Access-Control-Allow-Origin * always; + } +""" +if "demo-withdraw.json" not in t: + t=t.replace(" location /intro/ {", block+" location /intro/ {", 1) + p.write_text(t) + print("nginx location added") +else: + print("nginx already has demo-withdraw") +if "auto-account.json" not in t: + t=p.read_text() + block2=""" location = /intro/auto-account.json { + proxy_pass http://127.0.0.1:19096/auto-account.json; + proxy_http_version 1.1; + proxy_set_header Host \$host; + add_header Cache-Control "no-store" always; + add_header Access-Control-Allow-Origin * always; + } +""" + t=t.replace(" location /intro/ {", block2+" location /intro/ {", 1) + p.write_text(t) + print("nginx auto-account location added") +else: + print("nginx already has auto-account") +PY + nginx -t && nginx -s reload || true +else + echo "nginx already configured" +fi +' + +# start/restart API +podman exec -u root "$CTR" bash -lc ' +pkill -f "demo-withdraw-api.py" 2>/dev/null || true +nohup python3 /usr/local/bin/demo-withdraw-api.py \ + >>/var/log/demo-withdraw-api.log 2>&1 </dev/null & +echo "api pid $!" +# auto-confirm loop +pkill -f "auto-confirm-withdrawals.sh --loop" 2>/dev/null || true +nohup /usr/local/bin/auto-confirm-withdrawals.sh --loop 4 \ + >>/var/log/auto-confirm-withdrawals.log 2>&1 </dev/null & +echo "auto-confirm pid $!" +sleep 1 +curl -sS -m 8 http://127.0.0.1:19096/demo-withdraw.json | head -c 300; echo +' + +echo "OK: demo-withdraw API + auto-confirm in $CTR" +echo "Public: https://bank.hacktivism.ch/intro/demo-withdraw.json" diff --git a/scripts/taler-bank/landing-stats-install.sh b/scripts/taler-bank/landing-stats-install.sh new file mode 100644 index 0000000..8086558 --- /dev/null +++ b/scripts/taler-bank/landing-stats-install.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Install + run landing-stats.sh *inside* the bank container, and optionally +# install a cron entry there. Documented in configs/bank-landing/README.md. +# +# Run on koopa host (root or user in podman group): +# ./landing-stats-install.sh # copy + one-shot run +# ./landing-stats-install.sh --cron # also install *minutely* cron inside container +# ./landing-stats-install.sh --run-only # only exec existing script +set -euo pipefail + +DO_CRON=0 +RUN_ONLY=0 +# cron schedule (default: every minute) +: "${LANDING_STATS_CRON:=* * * * *}" +for a in "$@"; do + case "$a" in + --cron) DO_CRON=1 ;; + --run-only) RUN_ONLY=1 ;; + -h|--help) + sed -n '2,12p' "$0" | sed 's/^# \?//' + exit 0 + ;; + esac +done + +ROOT=$(cd "$(dirname "$0")" && pwd) +SRC="$ROOT/landing-stats.sh" +[ -f "$SRC" ] || { echo "missing $SRC" >&2; exit 1; } + +# Prefer the known names from ops docs +C="" +for name in taler-hacktivism-bank taler-bank-hacktivism; do + if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then + C=$name + break + fi +done +if [ -z "$C" ]; then + C=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -i bank | head -1 || true) +fi +[ -n "$C" ] || { echo "no bank container running" >&2; exit 1; } +echo "container=$C" + +# Host password → container /root (if not already there) +if [ -f /root/bank-explorer-password.txt ]; then + podman cp /root/bank-explorer-password.txt "$C:/root/bank-explorer-password.txt" 2>/dev/null || true +fi + +if [ "$RUN_ONLY" != "1" ]; then + podman exec "$C" mkdir -p /usr/local/bin /var/www/bank-landing + podman cp "$SRC" "$C:/usr/local/bin/landing-stats.sh" + podman exec "$C" chmod 755 /usr/local/bin/landing-stats.sh + echo "installed /usr/local/bin/landing-stats.sh" +fi + +# Ensure landing dir exists for nginx intro +podman exec "$C" mkdir -p /var/www/bank-landing + +echo "running landing-stats.sh inside $C …" +podman exec \ + -e LANDING_DIR=/var/www/bank-landing \ + -e BANK_USER=explorer \ + "$C" /usr/local/bin/landing-stats.sh + +if [ "$DO_CRON" = "1" ]; then + # default: every minute (* * * * *); override with LANDING_STATS_CRON='*/5 * * * *' + podman exec -e LANDING_STATS_CRON="$LANDING_STATS_CRON" "$C" bash -c ' + sched="${LANDING_STATS_CRON:-* * * * *}" + line="$sched TZ=Europe/Zurich LANDING_DIR=/var/www/bank-landing /usr/local/bin/landing-stats.sh >>/var/log/landing-stats.log 2>&1" + (crontab -l 2>/dev/null | grep -v landing-stats.sh; echo "$line") | crontab - + echo "cron installed:"; crontab -l | grep landing-stats + ' +fi + +echo "public check: curl -sS https://bank.hacktivism.ch/intro/stats.json | head" +# If host maps 9013 → container landing, show local file +podman exec "$C" head -c 400 /var/www/bank-landing/stats.json 2>/dev/null || true +echo diff --git a/scripts/taler-bank/landing-stats.sh b/scripts/taler-bank/landing-stats.sh new file mode 100755 index 0000000..c226e5c --- /dev/null +++ b/scripts/taler-bank/landing-stats.sh @@ -0,0 +1,612 @@ +#!/bin/bash +# Generate public landing stats JSON for bank.hacktivism.ch intro page. +# +# *** Run INSIDE the bank container *** (taler-hacktivism-bank). +# Writes: $LANDING_DIR/stats.json → https://bank.hacktivism.ch/intro/stats.json +# +# Pure bash + curl + awk (no python). Times in Europe/Zurich (CET/CEST). +# +# Documented: configs/bank-landing/README.md +set -euo pipefail + +LANDING_DIR="${LANDING_DIR:-/var/www/bank-landing}" +# Demo funding account (for recent withdraw list / flow) +BANK_USER="${BANK_USER:-explorer}" +# Admin lists all accounts + can read other accounts' txs +ADMIN_USER="${ADMIN_USER:-admin}" +# Per-account transaction window (libeufin delta). Was -100 → systematically +# undercounted credits/withdraws on active accounts (broken public stats). +TX_DELTA="${TX_DELTA:--50000}" +# Max accounts to list + scan (was 80 → missed later accounts; bank has 100+). +MAX_SCAN_ACCOUNTS="${MAX_SCAN_ACCOUNTS:-500}" +# Account-list page size for GET /accounts?delta=… (must cover all users) +ACCOUNTS_DELTA="${ACCOUNTS_DELTA:--500}" +# curl timeout per account (deeper history needs more headroom) +TX_CURL_TIMEOUT="${TX_CURL_TIMEOUT:-25}" +export TZ="${TZ:-Europe/Zurich}" + +PASS="${BANK_PASS:-}" +ADMIN_PASS="${BANK_ADMIN_PASS:-}" + +detect_bank() { + if [ -n "${BANK_URL:-}" ]; then + echo "${BANK_URL%/}" + return + fi + local port="" conf u code + for conf in /etc/libeufin/bank-overrides.conf /etc/libeufin/libeufin-bank.conf; do + if [ -f "$conf" ]; then + port=$(grep -E '^\s*PORT\s*=' "$conf" 2>/dev/null | tail -1 | awk -F= '{gsub(/ /,"",$2); print $2}') + [ -n "$port" ] && break + fi + done + port="${port:-9012}" + for u in "http://127.0.0.1:${port}" "http://127.0.0.1:9012" "http://127.0.0.1:8080"; do + code=$(curl -sS -m 2 -o /dev/null -w '%{http_code}' "$u/config" 2>/dev/null || echo 000) + if [ "$code" = "200" ]; then + echo "$u" + return + fi + done + echo "http://127.0.0.1:${port}" +} + +BANK="$(detect_bank)" +BANK="${BANK%/}" + +read_pass_file() { + local f + for f in "$@"; do + if [ -f "$f" ] && [ -r "$f" ]; then + tr -d '\n' <"$f" + return 0 + fi + done + return 1 +} + +if [ -z "$PASS" ]; then + PASS=$(read_pass_file \ + "/root/bank-${BANK_USER}-password.txt" \ + /root/bank-explorer-password.txt \ + "/etc/libeufin/secrets/bank-${BANK_USER}-password.txt" || true) +fi +if [ -z "$ADMIN_PASS" ]; then + ADMIN_PASS=$(read_pass_file \ + /root/bank-admin-password.txt \ + /etc/libeufin/secrets/bank-admin-password.txt || true) +fi + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT +OUT="$LANDING_DIR/stats.json" +RUN="$LANDING_DIR/stats-run.json" +TMP="${OUT}.tmp.$$" +mkdir -p "$LANDING_DIR" + +now_iso() { date +%Y-%m-%dT%H:%M%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/'; } +now_unix() { date +%s; } +now_human() { date +"%Y-%m-%d %H:%M %Z"; } +iso_from_unix() { + local u="$1" + date -d "@${u}" +%Y-%m-%dT%H:%M%z 2>/dev/null | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/' \ + || date -r "${u}" +%Y-%m-%dT%H:%M%z 2>/dev/null | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/' \ + || echo "" +} +# Human CEST/CET label for display: "2026-07-09 20:49 CEST" +human_from_unix() { + local u="$1" + date -d "@${u}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null \ + || date -r "${u}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null \ + || echo "" +} + +json_str() { + printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr '\n' ' ')" +} + +# Public run status for intro page. Never wipe stats.json on failure. +write_run() { + local ok_json="$1" msg="${2:-}" + cat >"$RUN" <<EOF +{ + "ok": ${ok_json}, + "at": $(json_str "$(now_iso)"), + "at_human": $(json_str "$(now_human)"), + "error": $( [ -n "$msg" ] && json_str "$msg" || echo null ) +} +EOF +} + +write_err() { + local msg="$1" + write_run false "$msg" + rm -f "$TMP" 2>/dev/null || true + echo "error: $msg (stats.json left unchanged; stats-run.json updated)" >&2 +} + +token_for() { + local user="$1" pass="$2" out="$3" + echo '{"scope":"readonly"}' >"$WORKDIR/tok-req.json" + curl -sS -m 12 -u "${user}:${pass}" \ + -H 'Content-Type: application/json' \ + -d @"$WORKDIR/tok-req.json" \ + "${BANK}/accounts/${user}/token" >"$out" || true + awk -F'"' '/access_token/ { + for (i=1;i<=NF;i++) if ($i=="access_token") { print $(i+2); exit } + }' "$out" 2>/dev/null || true +} + +extract_field() { + # extract "key":"value" or "key": number from a JSON blob (first hit) + local key="$1" file="$2" + awk -v key="$key" ' + function between(s, k, p, rest, q2, r) { + p = index(s, "\"" k "\"") + if (p == 0) return "" + rest = substr(s, p + length(k) + 2) + while (rest ~ /^[[:space:]:]/) rest = substr(rest, 2) + if (substr(rest, 1, 1) == "\"") { + rest = substr(rest, 2) + q2 = index(rest, "\"") + if (q2 == 0) return "" + return substr(rest, 1, q2 - 1) + } + r = "" + while (rest ~ /^[0-9]/) { + r = r substr(rest, 1, 1) + rest = substr(rest, 2) + } + return r + } + { print between($0, key); exit } + ' "$file" 2>/dev/null +} + +# --- tokens --- +if [ -z "$PASS" ] && [ -z "$ADMIN_PASS" ]; then + write_err "no bank password (explorer or admin) for stats" + exit 1 +fi + +ADMIN_TOKEN="" +if [ -n "$ADMIN_PASS" ]; then + ADMIN_TOKEN=$(token_for "$ADMIN_USER" "$ADMIN_PASS" "$WORKDIR/admin-tok.json") +fi +USER_TOKEN="" +if [ -n "$PASS" ]; then + USER_TOKEN=$(token_for "$BANK_USER" "$PASS" "$WORKDIR/user-tok.json") +fi +# Prefer admin for everything when available +AUTH_TOKEN="${ADMIN_TOKEN:-$USER_TOKEN}" +if [ -z "$AUTH_TOKEN" ]; then + write_err "token failed (admin/explorer)" + exit 1 +fi + +# --- account list --- +ACCOUNTS_N=0 +ACCOUNTS_USERS=0 +: >"$WORKDIR/usernames.txt" +if [ -n "$ADMIN_TOKEN" ]; then + # Use a large negative delta so we list *all* accounts (delta=-80 truncated + # the roster and undercounted bank_accounts + flow). + curl -sS -m 30 -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + "${BANK}/accounts?delta=${ACCOUNTS_DELTA}" >"$WORKDIR/accounts.json" || true + # usernames from "username":"..." + tr -d '\n' <"$WORKDIR/accounts.json" \ + | sed 's/},{/}\n{/g' \ + | while IFS= read -r line; do + u=$(printf '%s' "$line" | sed -n 's/.*"username"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + [ -n "$u" ] && echo "$u" + done >"$WORKDIR/usernames.txt" || true + ACCOUNTS_N=$(grep -cve '^\s*$' "$WORKDIR/usernames.txt" 2>/dev/null || echo 0) + ACCOUNTS_USERS=$(grep -Eve '^(admin|exchange)$' "$WORKDIR/usernames.txt" 2>/dev/null | grep -cve '^\s*$' || echo 0) +else + echo "$BANK_USER" >"$WORKDIR/usernames.txt" + ACCOUNTS_N=1 + ACCOUNTS_USERS=1 +fi + +# --- scan txs: distinguish incoming (credit) vs withdraw (Taler debit) --- +# all-wd.tsv: amount \t t_s \t subject \t username \t reserve +# all-in.tsv: amount \t t_s \t subject \t username +# flow.tsv: kind \t amount_num kind = incoming | withdraw | other_out +: >"$WORKDIR/all-wd.tsv" +: >"$WORKDIR/all-in.tsv" +: >"$WORKDIR/flow.tsv" +SCAN_OK=0 +SCAN_EMPTY=0 +SCAN_FAIL=0 +# Scan customer accounts only (not exchange/admin — exchange credits would double-count) +{ + echo "$BANK_USER" + grep -Eve "^(admin|exchange|${BANK_USER})$" "$WORKDIR/usernames.txt" 2>/dev/null || true +} | awk 'NF && !seen[$0]++' | head -n "$MAX_SCAN_ACCOUNTS" >"$WORKDIR/scan-users.txt" + +while IFS= read -r uname; do + [ -n "$uname" ] || continue + case "$uname" in admin|exchange) continue ;; esac + # Safe filename (usernames are mostly [A-Za-z0-9_-]) + safe=$(printf '%s' "$uname" | tr -c 'A-Za-z0-9._-' '_') + code=$(curl -sS -m "${TX_CURL_TIMEOUT}" -o "$WORKDIR/tx-${safe}.json" -w '%{http_code}' \ + -H "Authorization: Bearer ${AUTH_TOKEN}" \ + "${BANK}/accounts/${uname}/transactions?delta=${TX_DELTA}" 2>/dev/null || echo 000) + # 204 / empty body: account with no transactions — normal, not an error + if [ "$code" = "204" ] || [ ! -s "$WORKDIR/tx-${safe}.json" ]; then + SCAN_EMPTY=$((SCAN_EMPTY + 1)) + continue + fi + if [ "$code" != "200" ]; then + SCAN_FAIL=$((SCAN_FAIL + 1)) + continue + fi + SCAN_OK=$((SCAN_OK + 1)) + tr -d '\n' <"$WORKDIR/tx-${safe}.json" \ + | sed 's/},{/}\n{/g' \ + >"$WORKDIR/tx-lines.txt" 2>/dev/null || continue + awk -v uname="$uname" -v flowf="$WORKDIR/flow.tsv" -v inf="$WORKDIR/all-in.tsv" ' + function between(s, key, p, rest, q2, r) { + p = index(s, "\"" key "\"") + if (p == 0) return "" + rest = substr(s, p + length(key) + 2) + while (rest ~ /^[[:space:]:]/) rest = substr(rest, 2) + if (substr(rest, 1, 1) == "\"") { + rest = substr(rest, 2) + q2 = index(rest, "\"") + if (q2 == 0) return "" + return substr(rest, 1, q2 - 1) + } + r = "" + while (rest ~ /^[0-9]/) { + r = r substr(rest, 1, 1) + rest = substr(rest, 2) + } + return r + } + function amt_n(a, p) { + p = index(a, ":") + if (p == 0) return 0 + return substr(a, p + 1) + 0 + } + { + line = $0 + dir = between(line, "direction") + amt = between(line, "amount") + ts = between(line, "t_s") + subj = between(line, "subject") + low = tolower(subj) + if (amt == "") next + n = amt_n(amt) + if (dir == "credit") { + # Incoming: bank credits into customer accounts (admin top-up, transfers in) + print "incoming\t" n >> flowf + printf "%s\t%s\t%s\t%s\n", amt, ts, subj, uname >> inf + } else if (dir == "debit" && index(low, "withdraw") > 0) { + # Withdraw: Taler withdrawal debit → exchange → wallet coins + print "withdraw\t" n >> flowf + res = subj + sub(/^.*[Ww]ithdrawal[ ]+/, "", res) + gsub(/[^A-Za-z0-9]/, "", res) + printf "%s\t%s\t%s\t%s\t%s\n", amt, ts, subj, uname, res + } else if (dir == "debit") { + # Other debits (non-withdraw transfers) + print "other_out\t" n >> flowf + } + } + ' "$WORKDIR/tx-lines.txt" >>"$WORKDIR/all-wd.tsv" 2>/dev/null || true +done <"$WORKDIR/scan-users.txt" + +# Sum by kind +TOTAL_IN_N=$(awk -F'\t' '$1=="incoming"{s+=$2} END{printf "%.8f", s+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0) +TOTAL_WD_FLOW_N=$(awk -F'\t' '$1=="withdraw"{s+=$2} END{printf "%.8f", s+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0) +TOTAL_OTHER_OUT_N=$(awk -F'\t' '$1=="other_out"{s+=$2} END{printf "%.8f", s+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0) +N_INCOMING=$(awk -F'\t' '$1=="incoming"{c++} END{print c+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0) +# total out = withdraw + other debits +TOTAL_OUT_N=$(awk -v a="${TOTAL_WD_FLOW_N:-0}" -v b="${TOTAL_OTHER_OUT_N:-0}" 'BEGIN{printf "%.8f", a+b}') + +# Sort by t_s descending +sort -t$'\t' -k2,2nr "$WORKDIR/all-wd.tsv" -o "$WORKDIR/all-wd-sorted.tsv" 2>/dev/null \ + || cp "$WORKDIR/all-wd.tsv" "$WORKDIR/all-wd-sorted.tsv" +sort -t$'\t' -k2,2nr "$WORKDIR/all-in.tsv" -o "$WORKDIR/all-in-sorted.tsv" 2>/dev/null \ + || cp "$WORKDIR/all-in.tsv" "$WORKDIR/all-in-sorted.tsv" + +# Unique reserves = individual wallet withdraws (each wallet reserve_pub) +WALLETS_N=$(awk -F'\t' '$5!=""{print $5}' "$WORKDIR/all-wd-sorted.tsv" | sort -u | grep -cve '^\s*$' || echo 0) +# Accounts that funded at least one withdraw +ACCOUNTS_WITH_WD=$(awk -F'\t' '$4!=""{print $4}' "$WORKDIR/all-wd-sorted.tsv" | sort -u | grep -cve '^\s*$' || echo 0) + +# Aggregates +NOW=$(now_unix) +GEN_ISO=$(now_iso) +GEN_HUMAN=$(date +"%Y-%m-%d %H:%M %Z") + +# Build recent withdraws JSON array (up to 10) with CEST times — landing shows all 10 +RECENT_WD_N="${RECENT_WD_N:-10}" +: >"$WORKDIR/recent.jsonl" +N_WD=0 +TOTAL_WD=0 +W24=0; N24=0; W7=0; N7=0 +DAY=$((NOW - 86400)) +WEEK=$((NOW - 7 * 86400)) +LAST_AMT=""; LAST_TS=""; LAST_SUBJ="" + +while IFS=$'\t' read -r amt ts subj uname res; do + [ -n "$amt" ] || continue + n=$(printf '%s' "$amt" | awk -F: '{print $2+0}') + N_WD=$((N_WD + 1)) + TOTAL_WD=$(awk -v a="$TOTAL_WD" -v b="$n" 'BEGIN{printf "%.8f", a+b}') + ts_n=${ts:-0} + if [ "$ts_n" -ge "$DAY" ] 2>/dev/null; then + W24=$(awk -v a="$W24" -v b="$n" 'BEGIN{printf "%.8f", a+b}') + N24=$((N24 + 1)) + fi + if [ "$ts_n" -ge "$WEEK" ] 2>/dev/null; then + W7=$(awk -v a="$W7" -v b="$n" 'BEGIN{printf "%.8f", a+b}') + N7=$((N7 + 1)) + fi + if [ -z "$LAST_AMT" ]; then + LAST_AMT=$amt + LAST_TS=$ts_n + LAST_SUBJ=$subj + fi + if [ "$N_WD" -le "$RECENT_WD_N" ]; then + at_h=""; at_iso="" + if [ -n "$ts_n" ] && [ "$ts_n" != "0" ]; then + at_h=$(human_from_unix "$ts_n") + at_iso=$(iso_from_unix "$ts_n") + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$amt" "$ts_n" "$at_h" "$at_iso" "$uname" "$res" >>"$WORKDIR/recent.jsonl" + fi +done <"$WORKDIR/all-wd-sorted.tsv" + +fmt_goa() { + awk -v n="$1" 'BEGIN{ + if (n+0 == int(n+0)) printf "GOA:%d", int(n+0); + else printf "GOA:%.8g", n+0; + }' +} + +TOTAL_AMT=$(fmt_goa "$TOTAL_WD") +TOTAL_IN_AMT=$(fmt_goa "${TOTAL_IN_N:-0}") +TOTAL_OUT_AMT=$(fmt_goa "${TOTAL_OUT_N:-0}") +TOTAL_WD_FLOW_AMT=$(fmt_goa "${TOTAL_WD_FLOW_N:-0}") +TOTAL_OTHER_OUT_AMT=$(fmt_goa "${TOTAL_OTHER_OUT_N:-0}") +W24_AMT=$(fmt_goa "$W24") +W7_AMT=$(fmt_goa "$W7") + +LAST_AT_H=""; LAST_AT_ISO="" +if [ -n "${LAST_TS:-}" ] && [ "$LAST_TS" != "0" ]; then + LAST_AT_H=$(human_from_unix "$LAST_TS") + LAST_AT_ISO=$(iso_from_unix "$LAST_TS") +fi + +# recent_withdraws JSON +RECENT_JSON="[" +first=1 +while IFS=$'\t' read -r amt ts_n at_h at_iso uname res; do + [ -n "$amt" ] || continue + if [ "$first" = 1 ]; then first=0; else RECENT_JSON="${RECENT_JSON},"; fi + RECENT_JSON="${RECENT_JSON} + { + \"kind\": \"withdraw\", + \"amount\": $(json_str "$amt"), + \"at\": $(json_str "$at_h"), + \"at_iso\": $(json_str "$at_iso"), + \"at_unix\": ${ts_n:-null}, + \"account\": $(json_str "$uname"), + \"reserve\": $(json_str "$res") + }" +done <"$WORKDIR/recent.jsonl" +RECENT_JSON="${RECENT_JSON} + ]" + +# recent incoming (up to 5) +: >"$WORKDIR/recent-in.jsonl" +N_IN_LIST=0 +while IFS=$'\t' read -r amt ts subj uname; do + [ -n "$amt" ] || continue + N_IN_LIST=$((N_IN_LIST + 1)) + [ "$N_IN_LIST" -le 5 ] || break + ts_n=${ts:-0} + at_h=""; at_iso="" + if [ -n "$ts_n" ] && [ "$ts_n" != "0" ]; then + at_h=$(human_from_unix "$ts_n") + at_iso=$(iso_from_unix "$ts_n") + fi + printf '%s\t%s\t%s\t%s\t%s\n' "$amt" "$ts_n" "$at_h" "$at_iso" "$uname" >>"$WORKDIR/recent-in.jsonl" +done <"$WORKDIR/all-in-sorted.tsv" + +RECENT_IN_JSON="[" +first=1 +while IFS=$'\t' read -r amt ts_n at_h at_iso uname; do + [ -n "$amt" ] || continue + if [ "$first" = 1 ]; then first=0; else RECENT_IN_JSON="${RECENT_IN_JSON},"; fi + RECENT_IN_JSON="${RECENT_IN_JSON} + { + \"kind\": \"incoming\", + \"amount\": $(json_str "$amt"), + \"at\": $(json_str "$at_h"), + \"at_iso\": $(json_str "$at_iso"), + \"at_unix\": ${ts_n:-null}, + \"account\": $(json_str "$uname") + }" +done <"$WORKDIR/recent-in.jsonl" +RECENT_IN_JSON="${RECENT_IN_JSON} + ]" + +# Balance of explorer (optional display not required in footer) +BALANCE="GOA:0" +if [ -n "${USER_TOKEN:-$AUTH_TOKEN}" ]; then + curl -sS -m 10 -H "Authorization: Bearer ${USER_TOKEN:-$AUTH_TOKEN}" \ + "${BANK}/accounts/${BANK_USER}" >"$WORKDIR/acct.json" || true + BALANCE=$(awk -F'"' '/"amount"/ { + for (i=1;i<=NF;i++) if ($i=="amount") { print $(i+2); exit } + }' "$WORKDIR/acct.json" 2>/dev/null || echo "GOA:0") +fi + +# Demo block kept for page QR logic only (not shown in footer) +DEMO_URI=""; DEMO_AMT=""; DEMO_CREATED=""; DEMO_WID=""; DEMO_STATUS="" +[ -f "$LANDING_DIR/withdraw.uri" ] && DEMO_URI=$(tr -d '\n' <"$LANDING_DIR/withdraw.uri") +[ -f "$LANDING_DIR/withdraw.amount" ] && DEMO_AMT=$(tr -d '\n' <"$LANDING_DIR/withdraw.amount") +[ -f "$LANDING_DIR/withdraw.created" ] && DEMO_CREATED=$(tr -d '\n' <"$LANDING_DIR/withdraw.created") +if [ -n "$DEMO_URI" ]; then + DEMO_WID=$(basename "$DEMO_URI") + curl -sS -m 8 \ + "${BANK}/taler-integration/withdrawal-operation/${DEMO_WID}" \ + >"$WORKDIR/demo-wd.json" 2>/dev/null || true + DEMO_STATUS=$(awk -F'"' '/"status"/ { + for (i=1;i<=NF;i++) if ($i=="status") { print $(i+2); exit } + }' "$WORKDIR/demo-wd.json" 2>/dev/null || true) +fi +DEMO_READY=false +case "$DEMO_STATUS" in + pending|selected) DEMO_READY=true ;; + "") [ -n "$DEMO_URI" ] && DEMO_READY=true ;; +esac +[ "$DEMO_STATUS" = "confirmed" ] && DEMO_READY=false +[ "$DEMO_STATUS" = "aborted" ] && DEMO_READY=false + +# Sanity: never publish an empty-looking success if admin scan should have accounts +if [ -n "$ADMIN_TOKEN" ] && [ "${ACCOUNTS_N:-0}" = "0" ]; then + write_err "accounts list empty after admin scan" + exit 1 +fi + +# Live performance probes (same idea as exchange landing-stats) +# Milliseconds as integer, rounded (not truncated) so sub-ms loopback does not +# always show 0 when we fall back to in-container URLs. +measure_ms() { + local url="$1" t + t=$(curl -sS -o /dev/null -m 8 -w '%{time_total}' "$url" 2>/dev/null || echo "") + [ -z "$t" ] && { echo "null"; return; } + awk -v t="$t" 'BEGIN{ + ms = (t+0)*1000 + if (ms > 0 && ms < 1) ms = 1 + printf "%d", int(ms + 0.5) + }' +} +num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; } +# Prefer public URL for real client latency (Caddy → bank); fall back to loopback. +# Integration used to probe only $BANK (127.0.0.1:9012) → ~0–1 ms and not comparable +# to /config which already used the public host. +BANK_PUBLIC_BASE="${BANK_PUBLIC_URL:-https://bank.hacktivism.ch}" +CONFIG_MS=$(measure_ms "${BANK_PUBLIC_BASE}/config") +CONFIG_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK_PUBLIC_BASE}/config" 2>/dev/null || echo "000") +if [ "$CONFIG_HTTP" != "200" ]; then + CONFIG_MS=$(measure_ms "${BANK}/config") + CONFIG_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK}/config" 2>/dev/null || echo "000") +fi +INT_MS=$(measure_ms "${BANK_PUBLIC_BASE}/taler-integration/config") +INT_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK_PUBLIC_BASE}/taler-integration/config" 2>/dev/null || echo "000") +if [ "$INT_HTTP" != "200" ]; then + INT_MS=$(measure_ms "${BANK}/taler-integration/config") + INT_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK}/taler-integration/config" 2>/dev/null || echo "000") +fi +WEBUI_MS=$(measure_ms "${BANK_PUBLIC_BASE}/webui/") +WEBUI_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK_PUBLIC_BASE}/webui/" 2>/dev/null || echo "000") +LOADAVG="" +[ -r /proc/loadavg ] && LOADAVG=$(awk '{print $1","$2","$3}' /proc/loadavg) + +MEM_JSON='"container_rss_human": "—"' +MEM_HELPER="${MEM_HELPER:-/usr/local/lib/landing-mem-snapshot.sh}" +if [ -f "$MEM_HELPER" ]; then + # shellcheck disable=SC1090 + . "$MEM_HELPER" + mem_snapshot_json || true +fi + +cat >"$TMP" <<EOF +{ + "ok": true, + "currency": "GOA", + "timezone": $(json_str "$TZ"), + "generated_at": $(json_str "$GEN_ISO"), + "generated_at_human": $(json_str "$GEN_HUMAN"), + "generated_at_unix": $NOW, + "source": "in-container landing-stats.sh", + "bank_url": $(json_str "$BANK"), + "scan": { + "tx_delta": $(json_str "$TX_DELTA"), + "accounts_delta": $(json_str "$ACCOUNTS_DELTA"), + "max_scan_accounts": ${MAX_SCAN_ACCOUNTS:-0}, + "accounts_listed": ${ACCOUNTS_N:-0}, + "accounts_scanned_ok": ${SCAN_OK:-0}, + "accounts_empty_tx": ${SCAN_EMPTY:-0}, + "accounts_scan_fail": ${SCAN_FAIL:-0}, + "note": "empty_tx includes HTTP 204 (no ledger rows) — normal for new auto-accounts" + }, + "bank_accounts": { + "total": ${ACCOUNTS_N:-0}, + "users": ${ACCOUNTS_USERS:-0}, + "with_withdraws": ${ACCOUNTS_WITH_WD:-0} + }, + "wallets": { + "unique_reserves": ${WALLETS_N:-0}, + "note": "unique reserve pubs from Taler withdrawals (one per wallet withdraw)" + }, + "balance_explorer": $(json_str "$BALANCE"), + "flow": { + "incoming": { + "label": "Incoming bank credits", + "count": ${N_INCOMING:-0}, + "amount": $(json_str "$TOTAL_IN_AMT"), + "value": ${TOTAL_IN_N:-0} + }, + "withdraw": { + "label": "Taler withdrawals to wallets", + "count": ${N_WD:-0}, + "amount": $(json_str "$TOTAL_WD_FLOW_AMT"), + "value": ${TOTAL_WD_FLOW_N:-0} + }, + "other_out": { + "label": "Other debits (non-withdraw)", + "amount": $(json_str "$TOTAL_OTHER_OUT_AMT"), + "value": ${TOTAL_OTHER_OUT_N:-0} + }, + "total_in": $(json_str "$TOTAL_IN_AMT"), + "total_in_value": ${TOTAL_IN_N:-0}, + "total_out": $(json_str "$TOTAL_OUT_AMT"), + "total_out_value": ${TOTAL_OUT_N:-0}, + "note": "incoming=credits; withdraw=Taler withdrawal debits; excl. admin+exchange accounts" + }, + "withdraws": { + "count": ${N_WD:-0}, + "total_amount": $(json_str "$TOTAL_AMT"), + "total_value": ${TOTAL_WD:-0}, + "last_amount": $( [ -n "$LAST_AMT" ] && json_str "$LAST_AMT" || echo null ), + "last_at": $( [ -n "$LAST_AT_H" ] && json_str "$LAST_AT_H" || echo null ), + "last_at_iso": $( [ -n "$LAST_AT_ISO" ] && json_str "$LAST_AT_ISO" || echo null ), + "last_at_unix": ${LAST_TS:-null}, + "last_subject": $( [ -n "$LAST_SUBJ" ] && json_str "$LAST_SUBJ" || echo null ), + "last_24h": { "count": ${N24:-0}, "amount": $(json_str "$W24_AMT"), "value": ${W24:-0} }, + "last_7d": { "count": ${N7:-0}, "amount": $(json_str "$W7_AMT"), "value": ${W7:-0} } + }, + "recent_withdraws": $RECENT_JSON, + "recent_incoming": $RECENT_IN_JSON, + "demo": { + "uri": $( [ -n "$DEMO_URI" ] && json_str "$DEMO_URI" || echo null ), + "amount": $( [ -n "$DEMO_AMT" ] && json_str "$DEMO_AMT" || echo null ), + "created": $( [ -n "$DEMO_CREATED" ] && json_str "$DEMO_CREATED" || echo null ), + "withdrawal_id": $( [ -n "$DEMO_WID" ] && json_str "$DEMO_WID" || echo null ), + "status": $( [ -n "$DEMO_STATUS" ] && json_str "$DEMO_STATUS" || echo null ), + "ready": $DEMO_READY + }, + "performance": { + "config_http": $(json_str "$CONFIG_HTTP"), + "config_ms": $(num_or_null "$CONFIG_MS"), + "integration_http": $(json_str "$INT_HTTP"), + "integration_ms": $(num_or_null "$INT_MS"), + "webui_http": $(json_str "$WEBUI_HTTP"), + "webui_ms": $(num_or_null "$WEBUI_MS"), + "loadavg": $(json_str "${LOADAVG:-}"), + "memory": { +${MEM_JSON} + } + } +} +EOF +grep -q '"ok": true' "$TMP" || { write_err "tmp json missing ok:true"; exit 1; } +mv -f "$TMP" "$OUT" +write_run true +echo "ok accounts=${ACCOUNTS_N} wallets=${WALLETS_N} withdraws=${N_WD} total=${TOTAL_AMT} tz=${TZ} -> $OUT" diff --git a/scripts/taler-bank/make-demo-withdraw-qr.sh b/scripts/taler-bank/make-demo-withdraw-qr.sh new file mode 100755 index 0000000..f7d96f0 --- /dev/null +++ b/scripts/taler-bank/make-demo-withdraw-qr.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# Create a demo GOA withdrawal for user explorer and write QR assets under LANDING_DIR. +# Run on koopa (host) with bank on 127.0.0.1:9012. +set -euo pipefail + +BANK="${BANK_URL:-http://127.0.0.1:9012}" +USER="${BANK_USER:-explorer}" +PASS="${BANK_PASS:-}" +AMOUNT="${AMOUNT:-GOA:10}" +LANDING_DIR="${LANDING_DIR:-/var/www/bank-landing}" + +if [ -z "$PASS" ]; then + if [ -f /root/bank-explorer-password.txt ]; then + PASS=$(tr -d '\n' </root/bank-explorer-password.txt) + elif [ -f /tmp/bank-caddy-wire.log ]; then + PASS=$(grep -E '^explorer=' /tmp/bank-caddy-wire.log | tail -1 | cut -d= -f2-) + fi +fi +if [ -z "$PASS" ]; then + echo "Set BANK_PASS or put password in /root/bank-explorer-password.txt" >&2 + exit 1 +fi + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +echo "{\"scope\":\"readwrite\",\"refreshable\":true}" >"$WORKDIR/tok.json" +curl -sS -m 15 -u "${USER}:${PASS}" \ + -H "Content-Type: application/json" \ + -d @"$WORKDIR/tok.json" \ + "${BANK}/accounts/${USER}/token" >"$WORKDIR/tok.out" + +TOKEN=$(python3 - "$WORKDIR/tok.out" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +print(d.get("access_token","")) +PY +) +if [ -z "$TOKEN" ]; then + echo "token failed:" >&2 + cat "$WORKDIR/tok.out" >&2 + exit 1 +fi + +echo "{\"suggested_amount\":\"${AMOUNT}\"}" >"$WORKDIR/wd.json" +curl -sS -m 15 \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d @"$WORKDIR/wd.json" \ + "${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") + sys.exit(1) +print(d.get("withdrawal_id",""), file=sys.stderr) +PY +) + +mkdir -p "$LANDING_DIR" +printf '%s\n' "$URI" >"$LANDING_DIR/withdraw.uri" +printf '%s\n' "$AMOUNT" >"$LANDING_DIR/withdraw.amount" +date -u +%Y-%m-%dT%H:%MZ >"$LANDING_DIR/withdraw.created" +# track id for auto-confirm-withdrawals.sh +WID=$(basename "$URI") +echo "$WID" >>"$LANDING_DIR/withdraw-watch.ids" +sort -u "$LANDING_DIR/withdraw-watch.ids" -o "$LANDING_DIR/withdraw-watch.ids" +echo "watch_id=$WID (auto-confirm when wallet selects)" + +# QR as SVG (no external deps) via python qrcode if present, else pure matrix via segno/qrcode +python3 - "$URI" "$LANDING_DIR/withdraw-qr.svg" <<'PY' +import sys +uri, out = sys.argv[1], sys.argv[2] +try: + import qrcode + import qrcode.image.svg + img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage) + img.save(out) + print("qrcode lib ok") +except Exception as e: + # minimal fallback: write HTML with data attribute for client-side QR + open(out, "w").write( + f'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" width="8" height="8">' + f'<!-- QR_FALLBACK uri={uri} --></svg>\n' + ) + open(out + ".uri", "w").write(uri) + print("fallback:", e) +PY + +# Also PNG if pillow/qrcode available +python3 - "$URI" "$LANDING_DIR/withdraw-qr.png" <<'PY' || true +import sys +uri, out = sys.argv[1], sys.argv[2] +import qrcode +img = qrcode.make(uri, box_size=8, border=2) +img.save(out) +print("png ok", out) +PY + +echo "URI=$URI" +echo "wrote under $LANDING_DIR" +ls -la "$LANDING_DIR"/withdraw* 2>/dev/null || true + +# Refresh public stats.json when landing-stats is available (in-container path) +if [ -x /usr/local/bin/landing-stats.sh ]; then + LANDING_DIR="$LANDING_DIR" /usr/local/bin/landing-stats.sh || true +elif [ -x "$(dirname "$0")/landing-stats.sh" ]; then + LANDING_DIR="$LANDING_DIR" "$(dirname "$0")/landing-stats.sh" || true +fi diff --git a/scripts/taler-bank/refresh-demo-withdraw.sh b/scripts/taler-bank/refresh-demo-withdraw.sh new file mode 100755 index 0000000..7e23454 --- /dev/null +++ b/scripts/taler-bank/refresh-demo-withdraw.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Create a fresh demo GOA withdraw for landing QR (no python — runs in bank container). +# Writes under LANDING_DIR (default /var/www/bank-landing). +# +# Inside container: +# /usr/local/bin/refresh-demo-withdraw.sh +# Host: +# podman exec taler-hacktivism-bank /usr/local/bin/refresh-demo-withdraw.sh +set -euo pipefail + +BANK="${BANK_URL:-http://127.0.0.1:9012}" +USER="${BANK_USER:-explorer}" +AMOUNT="${AMOUNT:-GOA:10}" +LANDING_DIR="${LANDING_DIR:-/var/www/bank-landing}" +PASS="${BANK_PASS:-}" + +if [ -z "$PASS" ]; then + for f in "/root/bank-${USER}-password.txt" /root/bank-explorer-password.txt; do + if [ -f "$f" ]; then PASS=$(tr -d '\n' <"$f"); break; fi + done +fi +[ -n "$PASS" ] || { echo "Set BANK_PASS or /root/bank-explorer-password.txt" >&2; exit 1; } + +BANK="${BANK%/}" +TOK=$(curl -sS -m 12 -u "${USER}:${PASS}" \ + -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite","refreshable":true}' \ + "${BANK}/accounts/${USER}/token") +TOKEN=$(printf '%s' "$TOK" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +[ -n "$TOKEN" ] || { echo "token fail: $TOK" >&2; exit 1; } + +WD=$(curl -sS -m 15 \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "{\"suggested_amount\":\"${AMOUNT}\"}" \ + "${BANK}/accounts/${USER}/withdrawals") +URI=$(printf '%s' "$WD" | sed -n 's/.*"taler_withdraw_uri"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +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") + +mkdir -p "$LANDING_DIR" +printf '%s\n' "$URI" >"$LANDING_DIR/withdraw.uri" +printf '%s\n' "$AMOUNT" >"$LANDING_DIR/withdraw.amount" +date -u +%Y-%m-%dT%H:%MZ >"$LANDING_DIR/withdraw.created" +echo "$WID" >>"$LANDING_DIR/withdraw-watch.ids" +sort -u "$LANDING_DIR/withdraw-watch.ids" -o "$LANDING_DIR/withdraw-watch.ids" 2>/dev/null || true + +echo "URI=$URI" +echo "WID=$WID" + +if [ -x /usr/local/bin/landing-stats.sh ]; then + LANDING_DIR="$LANDING_DIR" /usr/local/bin/landing-stats.sh || true +fi diff --git a/scripts/taler-bank/start_bank.sh b/scripts/taler-bank/start_bank.sh new file mode 100755 index 0000000..bbd812d --- /dev/null +++ b/scripts/taler-bank/start_bank.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# Start / restart libeufin-bank serve (manual, no systemd). +# Run as: libeufin-bank +# Same role as start_merchant.sh / start_exchange.sh. +# +# Container: taler-hacktivism-bank +# Prerequisite: /root/start_base_services_for_taler_bank.sh (as root) for postgres. +# +# Usage: +# start_bank.sh +# start_bank.sh --restart | -r +# start_bank.sh --help + +set -u + +usage() { + cat <<'EOF' +Usage: start_bank.sh [--restart|-r] [--help|-h] + + (default) Start libeufin-bank serve if not already running. + --restart Stop live serve process, then start cleanly. + Does not touch postgres (use /root/start_base_services_for_taler_bank.sh). +EOF +} + +DO_RESTART=0 +for arg in "$@"; do + case "$arg" in + --restart|-r) DO_RESTART=1 ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown option: $arg" >&2; usage >&2; exit 2 ;; + esac +done + +if [ "$(id -un)" != "libeufin-bank" ]; then + echo "This script must be run as user libeufin-bank" >&2 + exit 1 +fi + +CONF=/etc/libeufin/libeufin-bank.conf +LOG_DIR=/var/log/libeufin-bank +PORT=$(grep -E '^\s*PORT\s*=' /etc/libeufin/bank-overrides.conf 2>/dev/null | tail -1 | awk -F= '{gsub(/ /,"",$2); print $2}') +PORT=${PORT:-9012} + +# Match both wrapper name and the Java MainKt process that actually listens. +list_serve_pids() { + ps -eo pid=,stat=,args= 2>/dev/null | while read -r pid stat args; do + case "$stat" in Z*) continue ;; esac + case "$args" in + *start_bank.sh*) continue ;; + *check_bank-health*) continue ;; + esac + case "$args" in + *libeufin-bank\ serve*|/usr/bin/libeufin-bank\ serve*|*MainKt\ serve*|*tech.libeufin.bank.MainKt*) + echo "$pid" + ;; + esac + done | sort -u +} + +kill_serve() { + local pids + pids=$(list_serve_pids | tr '\n' ' ') + if [ -z "${pids// }" ]; then + echo "No live libeufin-bank serve to stop." + return 0 + fi + echo "Stopping PIDs: $pids" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 2 + local left + left=$(list_serve_pids | tr '\n' ' ') + if [ -n "${left// }" ]; then + echo "SIGKILL remaining: $left" + # shellcheck disable=SC2086 + kill -KILL $left 2>/dev/null || true + sleep 1 + fi + echo "libeufin-bank serve stopped." +} + +if [ "$DO_RESTART" -eq 1 ]; then + echo "=== restart: kill libeufin-bank serve ===" + kill_serve +fi + +echo "Start libeufin-bank serve (port $PORT):" +LOG_FILE="$LOG_DIR/libeufin-bank-$(date +%Y-%m-%d).log" +mkdir -p "$LOG_DIR" +touch "$LOG_FILE" 2>/dev/null || true + +if [ "$DO_RESTART" -eq 0 ] && [ -n "$(list_serve_pids)" ]; then + echo "libeufin-bank serve already running" +else + nohup libeufin-bank serve -c "$CONF" >>"$LOG_FILE" 2>&1 & + disown 2>/dev/null || true + sleep 2 +fi + +echo "Live processes:" +ps -eo pid,stat,args 2>/dev/null | grep -E 'libeufin-bank serve|MainKt serve' | grep -v grep | grep -v ' Z ' || true + +if [ -x /usr/local/bin/check_bank-health.sh ]; then + /usr/local/bin/check_bank-health.sh || exit 1 +elif [ -x ./check_bank-health.sh ]; then + ./check_bank-health.sh || exit 1 +fi +exit 0 diff --git a/scripts/taler-bank/start_base_services_for_taler_bank.sh b/scripts/taler-bank/start_base_services_for_taler_bank.sh new file mode 100755 index 0000000..e8873bb --- /dev/null +++ b/scripts/taler-bank/start_base_services_for_taler_bank.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# Root: base services for manual libeufin-bank (like merchant/exchange start_base). +# Then interactive shell as libeufin-bank → run start_bank.sh there. +# +# Container: taler-hacktivism-bank +# +# Usage: +# /root/start_base_services_for_taler_bank.sh +# /root/start_base_services_for_taler_bank.sh --no-shell +# /root/start_base_services_for_taler_bank.sh --no-shell --start-bank +# /root/start_base_services_for_taler_bank.sh --no-shell --start-bank --restart + +set -e +CONF=/etc/libeufin/libeufin-bank.conf +LOG_DIR=/var/log/libeufin-bank +PWD_BIN=/usr/local/bin +BANK_STARTER=start_bank.sh +BANK_USER=libeufin-bank + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root" >&2 + exit 1 +fi + +NO_SHELL=0 +START_BANK=0 +BANK_RESTART=0 +for arg in "$@"; do + case "$arg" in + --no-shell|-n) NO_SHELL=1 ;; + --start-bank) START_BANK=1; NO_SHELL=1 ;; + --restart|-r) BANK_RESTART=1 ;; + --help|-h) + cat <<'EOF' +Usage: start_base_services_for_taler_bank.sh [options] + + (default) Start postgres + dirs, then interactive shell as libeufin-bank + --no-shell Start base services only, do not open a shell + --start-bank After base services, run /usr/local/bin/start_bank.sh as libeufin-bank + (implies --no-shell) + --restart With --start-bank: pass --restart to start_bank.sh +EOF + exit 0 + ;; + *) + echo "Unknown option: $arg" >&2 + exit 2 + ;; + esac +done + +# --- Debian postgresql defaults (pg_createcluster layout) --- +ensure_postgresql() { + echo " Debian perms on /etc/postgresql + data/log/run..." + if [ -d /etc/postgresql ]; then + chown -R root:postgres /etc/postgresql + find /etc/postgresql -type d -exec chmod 755 {} \; + find /etc/postgresql -type f -name '*.conf' -exec chmod 640 {} \; + fi + chown -R postgres:postgres /var/lib/postgresql /var/log/postgresql 2>/dev/null || true + mkdir -p /var/run/postgresql + chown postgres:postgres /var/run/postgresql + chmod 2775 /var/run/postgresql 2>/dev/null || chmod 775 /var/run/postgresql + + # Snakeoil TLS key: postgres fails if it cannot read the key (common in rootless images). + # Prefer fixing perms; if still broken, force ssl=off for local-only DB. + if [ -f /etc/ssl/private/ssl-cert-snakeoil.key ]; then + chown root:ssl-cert /etc/ssl/private/ssl-cert-snakeoil.key 2>/dev/null || true + chmod 640 /etc/ssl/private/ssl-cert-snakeoil.key 2>/dev/null || true + fi + for pgconf in /etc/postgresql/*/main/postgresql.conf; do + [ -f "$pgconf" ] || continue + if grep -qE '^\s*ssl\s*=' "$pgconf"; then + sed -i 's/^\s*ssl\s*=.*/ssl = off/' "$pgconf" + else + echo "ssl = off" >>"$pgconf" + fi + done + + if pg_isready -q 2>/dev/null; then + echo " already accepting connections" + pg_isready || true + return 0 + fi + + rm -f /var/run/postgresql/.s.PGSQL.*.lock 2>/dev/null || true + if ! pgrep -u postgres -x postgres >/dev/null 2>&1; then + rm -f /var/lib/postgresql/*/main/postmaster.pid 2>/dev/null || true + fi + + if command -v pg_ctlcluster >/dev/null 2>&1 && command -v pg_lsclusters >/dev/null 2>&1; then + while read -r ver name _rest; do + [ -n "$ver" ] || continue + echo " pg_ctlcluster $ver $name start" + pg_ctlcluster "$ver" "$name" start 2>/dev/null || true + done < <(pg_lsclusters --no-header 2>/dev/null || true) + fi + if ! pg_isready -q 2>/dev/null; then + if [ -x /etc/init.d/postgresql ]; then + /etc/init.d/postgresql start || true + else + service postgresql start || true + fi + fi + sleep 1 + pg_isready || true +} + +echo "Create log + data dirs... giving permission to ${BANK_USER}:" +mkdir -p "$LOG_DIR" /var/lib/libeufin-bank +chown "${BANK_USER}:${BANK_USER}" "$LOG_DIR" /var/lib/libeufin-bank +chmod 755 "$LOG_DIR" /var/lib/libeufin-bank + +echo "Start base services needed for GOA Exploration Bank." +echo "" + +echo "1. postgresql:" +ensure_postgresql +# ensure role + DB (postgres:///libeufin) — Debian createuser/createdb as postgres +su -s /bin/bash postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='libeufin-bank'\" | grep -q 1 || createuser -s libeufin-bank" || true +su -s /bin/bash postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='libeufin'\" | grep -q 1 || createdb -O libeufin-bank libeufin" || true +pg_isready || true + +if [ "$START_BANK" -eq 1 ]; then + echo "" + echo "2. start_bank.sh as ${BANK_USER}:" + if [ ! -x "$PWD_BIN/$BANK_STARTER" ]; then + echo "missing $PWD_BIN/$BANK_STARTER" >&2 + exit 1 + fi + if [ "$BANK_RESTART" -eq 1 ]; then + runuser -u "$BANK_USER" -- "$PWD_BIN/$BANK_STARTER" --restart + else + runuser -u "$BANK_USER" -- "$PWD_BIN/$BANK_STARTER" + fi + exit $? +fi + +if [ "$NO_SHELL" -eq 1 ]; then + echo "Base services started (--no-shell). Next: runuser -u ${BANK_USER} -- $PWD_BIN/$BANK_STARTER [--restart]" + exit 0 +fi + +echo "2. Switching now to user ${BANK_USER}, in $PWD_BIN; find executable $BANK_STARTER there!" +echo "" +cd "$PWD_BIN" +# util-linux: -u and -s are mutually exclusive (same as exchange/merchant) +exec runuser -u "$BANK_USER" -- bash diff --git a/scripts/taler-dns/README.md b/scripts/taler-dns/README.md new file mode 100644 index 0000000..e77a83c --- /dev/null +++ b/scripts/taler-dns/README.md @@ -0,0 +1,48 @@ +# Container DNS pins (hacktivism.ch) + +Pasta networking often has **no usable DNS**. Wirewatch needs: + +```text +https://bank.hacktivism.ch/accounts/exchange/taler-wire-gateway/ +``` + +## Bake into images (preferred) + +On koopa: + +```bash +# copy bake assets, then: +SRC_DIR=/tmp/taler-dns-bake ./bake-hosts-into-images.sh +``` + +This: + +1. Installs `/usr/local/bin/pin-hacktivism-hosts` **inside** each container +2. Hooks `start_base_services_for_taler*.sh` (bank + merchant) +3. Enables systemd `pin-hacktivism-hosts.service` (exchange, before wirewatch) +4. **Commits** images: + - `localhost/taler-hacktivism-banking:live` (+ `:hosts-pinned`) + - `localhost/taler-hacktivism-live:landing` (+ `:hosts-pinned`) + - `localhost/taler-hacktivism-exchange-ansible:landing` (+ `:hosts-pinned`) + +Pin IP default: `212.51.151.254` (`PIN_IP=` to override). + +## One-shot without bake + +```bash +./pin-container-hosts.sh # apply now +./pin-container-hosts.sh --check # report resolve + /config +``` + +## After recreate + +If you start from an **old** image without the bake, run `pin-container-hosts.sh` once. +If you use `:hosts-pinned` / updated `:live`/`:landing` images, start_base / systemd re-pin on boot. + +Optional podman flags when recreating (extra safety): + +```bash +--add-host=bank.hacktivism.ch:212.51.151.254 \ +--add-host=exchange.hacktivism.ch:212.51.151.254 \ +--add-host=taler.hacktivism.ch:212.51.151.254 +``` diff --git a/scripts/taler-dns/bake-hosts-into-images.sh b/scripts/taler-dns/bake-hosts-into-images.sh new file mode 100644 index 0000000..8323ed5 --- /dev/null +++ b/scripts/taler-dns/bake-hosts-into-images.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# Install in-container pin script (+ systemd on exchange), hook start_base scripts, +# commit images so recreate keeps the bake. +# +# Run on koopa: +# ./bake-hosts-into-images.sh +set -euo pipefail + +PIN_IP="${PIN_IP:-212.51.151.254}" +SRC_DIR="${SRC_DIR:-/tmp/taler-dns-bake}" +mkdir -p "$SRC_DIR" + +# expect these next to us or in SRC_DIR +SCRIPT_IN="${SCRIPT_IN:-$SRC_DIR/pin-hacktivism-hosts.in-container.sh}" +UNIT_IN="${UNIT_IN:-$SRC_DIR/pin-hacktivism-hosts.service}" + +[ -f "$SCRIPT_IN" ] || { echo "missing $SCRIPT_IN" >&2; exit 1; } + +install_into() { + local C="$1" + if ! podman ps --format '{{.Names}}' | grep -qx "$C"; then + echo "SKIP $C (not running)" + return 0 + fi + echo "=== install into $C ===" + podman cp "$SCRIPT_IN" "$C:/usr/local/bin/pin-hacktivism-hosts" + podman exec "$C" chmod 755 /usr/local/bin/pin-hacktivism-hosts + # run once now + podman exec -e PIN_IP="$PIN_IP" "$C" /usr/local/bin/pin-hacktivism-hosts + podman exec "$C" grep hacktivism /etc/hosts || true +} + +hook_start_base() { + local C="$1" base="$2" + podman exec "$C" bash -lc " + set -e + f='$base' + if [ ! -f \"\$f\" ]; then echo \"no \$f\"; exit 0; fi + if grep -q pin-hacktivism-hosts \"\$f\"; then + echo \"already hooked: \$f\" + exit 0 + fi + # insert right after shebang / first line block + cp \"\$f\" \"\$f.bak-before-pin\" + { + head -n 1 \"\$f\" + echo '' + echo '# Pin public GOA hostnames (pasta DNS). Baked by bake-hosts-into-images.sh' + echo 'if [ -x /usr/local/bin/pin-hacktivism-hosts ]; then' + echo ' PIN_IP=${PIN_IP} /usr/local/bin/pin-hacktivism-hosts || true' + echo 'fi' + tail -n +2 \"\$f\" + } > \"\$f.new\" + mv \"\$f.new\" \"\$f\" + chmod +x \"\$f\" + echo \"hooked \$f\" + " +} + +# --- bank --- +install_into taler-hacktivism-bank +hook_start_base taler-hacktivism-bank /root/start_base_services_for_taler_bank.sh + +# --- merchant --- +install_into taler-hacktivism +hook_start_base taler-hacktivism /root/start_base_services_for_taler.sh + +# --- exchange (systemd) --- +install_into taler-hacktivism-exchange-ansible +if [ -f "$UNIT_IN" ]; then + podman cp "$UNIT_IN" taler-hacktivism-exchange-ansible:/etc/systemd/system/pin-hacktivism-hosts.service + podman exec taler-hacktivism-exchange-ansible bash -lc ' + sed -i "s/PIN_IP=212.51.151.254/PIN_IP='"${PIN_IP}"'/" /etc/systemd/system/pin-hacktivism-hosts.service + systemctl daemon-reload + systemctl enable pin-hacktivism-hosts.service + systemctl start pin-hacktivism-hosts.service + systemctl is-enabled pin-hacktivism-hosts.service + # ensure wirewatch starts after pin + mkdir -p /etc/systemd/system/taler-exchange-wirewatch.service.d + cat > /etc/systemd/system/taler-exchange-wirewatch.service.d/pin-hosts.conf <<EOF +[Unit] +After=pin-hacktivism-hosts.service +Requires=pin-hacktivism-hosts.service +EOF + systemctl daemon-reload + systemctl restart taler-exchange-wirewatch 2>/dev/null || true + ' +fi + +# --- commit images --- +echo "=== commit images ===" +podman commit taler-hacktivism-bank \ + localhost/taler-hacktivism-banking:live +podman commit taler-hacktivism-bank \ + localhost/taler-hacktivism-banking:hosts-pinned + +podman commit taler-hacktivism \ + localhost/taler-hacktivism-live:landing +podman commit taler-hacktivism \ + localhost/taler-hacktivism-live:hosts-pinned + +podman commit taler-hacktivism-exchange-ansible \ + localhost/taler-hacktivism-exchange-ansible:landing +podman commit taler-hacktivism-exchange-ansible \ + localhost/taler-hacktivism-exchange-ansible:hosts-pinned + +echo +echo "Images updated:" +podman images | grep -E 'hacktivism|hosts-pinned' | head -20 +echo +echo "Verify:" +for C in taler-hacktivism-bank taler-hacktivism-exchange-ansible taler-hacktivism; do + echo -n "$C: " + podman exec "$C" getent ahostsv4 bank.hacktivism.ch 2>/dev/null | head -1 || echo FAIL +done +echo "done." diff --git a/scripts/taler-dns/pin-container-hosts.sh b/scripts/taler-dns/pin-container-hosts.sh new file mode 100755 index 0000000..619e5ac --- /dev/null +++ b/scripts/taler-dns/pin-container-hosts.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Pin public GOA hostnames inside Taler containers (pasta often has no useful DNS). +# Required so wirewatch can reach https://bank.hacktivism.ch/... wire-gateway. +# +# Run on koopa (host): +# ./pin-container-hosts.sh # apply + show resolve +# ./pin-container-hosts.sh --check # only show +# PIN_IP=212.51.151.254 ./pin-container-hosts.sh +set -euo pipefail + +CHECK_ONLY=0 +[ "${1:-}" = "--check" ] && CHECK_ONLY=1 + +DOMAINS=(bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch) +CONTAINERS=( + taler-hacktivism-bank + taler-hacktivism-exchange-ansible + taler-hacktivism +) + +PIN_IP="${PIN_IP:-}" +if [ -z "$PIN_IP" ]; then + PIN_IP=$(getent ahostsv4 bank.hacktivism.ch 2>/dev/null | awk '{print $1; exit}' || true) +fi +[ -n "$PIN_IP" ] || PIN_IP=212.51.151.254 + +echo "PIN_IP=$PIN_IP" +echo "domains: ${DOMAINS[*]}" +echo + +pin_one() { + local C="$1" + if ! podman ps --format '{{.Names}}' | grep -qx "$C"; then + echo "=== $C === SKIP (not running)" + return 0 + fi + if [ "$CHECK_ONLY" != "1" ]; then + # Keep localhost lines; drop previous hacktivism pins; append ours + podman exec "$C" bash -lc " + set -e + TMP=\$(mktemp) + # keep non-hacktivism lines + if [ -f /etc/hosts ]; then + grep -vE 'hacktivism\\.ch|[[:space:]]bank\\.hacktivism|[[:space:]]exchange\\.hacktivism|[[:space:]]taler\\.hacktivism' /etc/hosts > \"\$TMP\" || true + fi + # ensure localhost + grep -qE '^127\\.0\\.0\\.1[[:space:]]+localhost' \"\$TMP\" 2>/dev/null || echo '127.0.0.1 localhost' >> \"\$TMP\" + grep -qE '^::1[[:space:]]' \"\$TMP\" 2>/dev/null || echo '::1 localhost ip6-localhost ip6-loopback' >> \"\$TMP\" + echo '${PIN_IP} bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch' >> \"\$TMP\" + # de-dupe consecutive blank lines lightly + cat \"\$TMP\" > /etc/hosts + rm -f \"\$TMP\" + " + # wirewatch needs bank DNS after pin + if [ "$C" = "taler-hacktivism-exchange-ansible" ]; then + podman exec "$C" systemctl try-restart taler-exchange-wirewatch 2>/dev/null || true + fi + fi + + echo "=== $C ===" + podman exec "$C" grep -E 'hacktivism|localhost' /etc/hosts 2>/dev/null || true + for h in "${DOMAINS[@]}"; do + # ahostsv4 first (matches what wire tools need) + ip=$(podman exec "$C" getent ahostsv4 "$h" 2>/dev/null | awk '{print $1; exit}' || true) + [ -n "$ip" ] || ip=$(podman exec "$C" getent hosts "$h" 2>/dev/null | awk '{print $1; exit}' || true) + code=$(podman exec "$C" curl -skS -m 4 -o /dev/null -w '%{http_code}' "https://${h}/config" 2>/dev/null || echo 000) + if [ -n "$ip" ] && [ "$code" = "200" ]; then + echo " OK $h → $ip /config=$code" + elif [ -n "$ip" ]; then + echo " WARN $h → $ip /config=$code" + else + echo " FAIL $h → (no resolve) /config=$code" + fi + done + echo +} + +for C in "${CONTAINERS[@]}"; do + pin_one "$C" +done + +echo "done (CHECK_ONLY=$CHECK_ONLY)" diff --git a/scripts/taler-dns/pin-hacktivism-hosts.in-container.sh b/scripts/taler-dns/pin-hacktivism-hosts.in-container.sh new file mode 100644 index 0000000..abffe67 --- /dev/null +++ b/scripts/taler-dns/pin-hacktivism-hosts.in-container.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Runs *inside* a Taler container. Pasta regenerates /etc/hosts on start — +# call this from systemd (exchange) or start_base_*.sh (merchant/bank). +set -euo pipefail + +PIN_IP="${PIN_IP:-212.51.151.254}" +MARKER="# hacktivism-goa-pin" + +TMP=$(mktemp) +if [ -f /etc/hosts ]; then + # drop any previous pin marker lines and lines that name our domains + grep -vE "${MARKER}|hacktivism\\.ch" /etc/hosts >"$TMP" || true +else + : >"$TMP" +fi + +grep -qE '^127\.0\.0\.1[[:space:]]' "$TMP" 2>/dev/null \ + || echo '127.0.0.1 localhost' >>"$TMP" +grep -qE '^::1[[:space:]]' "$TMP" 2>/dev/null \ + || echo '::1 localhost ip6-localhost ip6-loopback' >>"$TMP" + +{ + cat "$TMP" + echo "$MARKER" + echo "${PIN_IP} bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" + echo "$MARKER" +} > /etc/hosts +rm -f "$TMP" + +logger -t pin-hacktivism-hosts "pinned ${PIN_IP} → bank/exchange/taler.hacktivism.ch" 2>/dev/null || true +exit 0 diff --git a/scripts/taler-dns/pin-hacktivism-hosts.service b/scripts/taler-dns/pin-hacktivism-hosts.service new file mode 100644 index 0000000..d944f86 --- /dev/null +++ b/scripts/taler-dns/pin-hacktivism-hosts.service @@ -0,0 +1,16 @@ +[Unit] +Description=Pin bank/exchange/taler.hacktivism.ch in /etc/hosts (pasta DNS) +DefaultDependencies=no +After=local-fs.target +Before=network-online.target taler-exchange-wirewatch.service taler-exchange-httpd.service +Wants=network-pre.target + +[Service] +Type=oneshot +RemainAfterExit=yes +Environment=PIN_IP=212.51.151.254 +ExecStart=/usr/local/bin/pin-hacktivism-hosts + +[Install] +WantedBy=multi-user.target +WantedBy=taler-exchange-wirewatch.service diff --git a/scripts/taler-exchange/archive/README.md b/scripts/taler-exchange/archive/README.md new file mode 100644 index 0000000..b3a2f83 --- /dev/null +++ b/scripts/taler-exchange/archive/README.md @@ -0,0 +1,35 @@ +# Exchange archive / bootstrap + +## `exchange-bootstrap.sh` (greenfield only) + +One-shot bootstrap for a **new** exchange container. **Not** used for daily ops. + +Includes current **GOA exploration currency**: + +- `exchange-overrides.conf` pattern (no `zz-*`) +- `PORT = 9011`, `SERVE = tcp` +- Unit names: Yotta…Atomic-GOA (scale −8…24) +- ATM `common_amounts` 10…1000 +- Full 1–2–5 **coin** ladder (Micro-GOA … 10 GOA) in `conf.d/exchange-coins.conf` + +After bootstrap: + +1. `/root/start_base_services_for_taler_exchange.sh` (root) +2. `./start_exchange.sh` as `taler-exchange-httpd` +3. Offline: wire enable + denom/signkey sign for `/keys` + Live helpers: `../wire-enable-and-upload.sh`, `../offline-sign-upload-keys.sh`, `../start_wire_helpers.sh` + +## Removed + +| Script | Why | +|--------|-----| +| `exchange-start-all.sh` | Redundant with base + `start_exchange.sh` | +| old zz-hacktivism bootstrap | Wrong ports/currency; superseded | + +## Daily layout (merchant model) + +| Path | User | +|------|------| +| `/root/start_base_services_for_taler_exchange.sh` | root | +| `/usr/local/bin/start_exchange.sh` | `taler-exchange-httpd` | +| `/usr/local/bin/check_exchange-health.sh` | httpd / any | diff --git a/scripts/taler-exchange/archive/exchange-bootstrap.sh b/scripts/taler-exchange/archive/exchange-bootstrap.sh new file mode 100755 index 0000000..96c0dc1 --- /dev/null +++ b/scripts/taler-exchange/archive/exchange-bootstrap.sh @@ -0,0 +1,451 @@ +#!/bin/bash +# Greenfield bootstrap for exchange.hacktivism.ch (GOA exploration currency). +# Run as root inside container. Not for daily ops — use start_base + start_exchange. +# +# Writes: +# /etc/taler-exchange/exchange-overrides.conf +# /etc/taler-exchange/conf.d/exchange-coins.conf +# Ensures main conf inlines exchange-overrides.conf (merchant pattern). +# Does NOT use conf.d/zz-*. +# +# After this: offline denom sign + wire still required for /keys withdraw. + +set -euo pipefail +CONF=/etc/taler-exchange/taler-exchange.conf +OV=/etc/taler-exchange/exchange-overrides.conf +COINS=/etc/taler-exchange/conf.d/exchange-coins.conf +export DEBIAN_FRONTEND=noninteractive + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root" >&2 + exit 1 +fi + +echo "=== 1. postgres ===" +/etc/init.d/postgresql start +sleep 2 +pg_isready || true + +echo "=== 2. master key (offline user) ===" +mkdir -p /var/lib/taler-exchange/offline +chown -R taler-exchange-offline:taler-exchange-offline /var/lib/taler-exchange/offline +chown taler-exchange-offline:taler-exchange-offline /var/lib/taler-exchange 2>/dev/null || true +OUT=$(runuser -u taler-exchange-offline -- taler-exchange-offline -c "$CONF" setup 2>&1) || true +echo "$OUT" +MASTER_PUB=$(echo "$OUT" | grep -oE '[A-Z0-9]{40,}' | tail -1) +if [ -z "${MASTER_PUB:-}" ]; then + echo "ERROR: no master public key from offline setup" >&2 + exit 1 +fi +echo "MASTER_PUBLIC_KEY=$MASTER_PUB" +ATTR_KEY=$(openssl rand -hex 32) +SECRETS_DIR=/etc/taler-exchange/secrets +mkdir -p "$SECRETS_DIR" +cat >"$SECRETS_DIR/exchange-attribute-encryption.secret.conf" <<SEOF +# generated by exchange-bootstrap.sh +[exchange] +ATTRIBUTE_ENCRYPTION_KEY = ${ATTR_KEY} +SEOF +chmod 640 "$SECRETS_DIR/exchange-attribute-encryption.secret.conf" +chown root:taler-exchange-httpd "$SECRETS_DIR/exchange-attribute-encryption.secret.conf" 2>/dev/null || true + +echo "=== 3. exchange-overrides.conf (site; not zz-*) ===" +# Ensure main conf inlines overrides (merchant pattern) +if ! grep -q 'exchange-overrides.conf' "$CONF" 2>/dev/null; then + printf '\n# Manual site overrides (merchant pattern)\n@inline@ exchange-overrides.conf\n' >>"$CONF" +fi +# Remove legacy zz drop-ins if present +rm -f /etc/taler-exchange/conf.d/zz-hacktivism.conf \ + /etc/taler-exchange/conf.d/zz-hacktivism-coins.conf 2>/dev/null || true + +cat >"$OV" <<EOF +# Manual site overrides for exchange.hacktivism.ch (GOA exploration currency). +# Same role as /etc/taler-merchant/merchant-overrides.conf on taler-hacktivism. +# Do not edit overrides.conf (tooling). Do not edit package conf.d defaults. +# No conf.d/zz-* drop-ins. + +[exchange] +CURRENCY = GOA +CURRENCY_ROUND_UNIT = GOA:0.00000001 +TINY_AMOUNT = GOA:0.00000001 +DEFAULT_P2P_EXPIRATION = 14 days +BASE_URL = https://exchange.hacktivism.ch/ +SERVE = tcp +PORT = 9011 +MASTER_PUBLIC_KEY = ${MASTER_PUB} +# ATTRIBUTE_ENCRYPTION_KEY via @inline-secret@ (see secrets/) +@inline-secret@ exchange-attribute-encryption ../secrets/exchange-attribute-encryption.secret.conf + +[currency-goa] +ENABLED = YES +name = "GOA exploration currency" +code = GOA +# SI display units. taler-exchange rejects scale keys outside [-8, 24] (no Ronna/Quetta). +fractional_input_digits = 8 +fractional_normal_digits = 0 +fractional_trailing_zero_digits = 0 +alt_unit_names_are_symbols = NO +alt_unit_names = {"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"} +common_amounts = "GOA:10 GOA:20 GOA:50 GOA:100 GOA:200 GOA:1000" + +### Disable package demonstrator currencies (like merchant-overrides) +[currency-kudos] +ENABLED = NO + +[currency-testkudos] +ENABLED = NO +EOF +echo "Wrote $OV" +grep -E '^(name|code|PORT|CURRENCY|alt_unit|fractional_input|common_)' "$OV" || true + +echo "=== 4. GOA coin denominations (1–2–5 ladder) ===" +if [ -f "$COINS" ] && ! grep -q 'VALUE = GOA' "$COINS" 2>/dev/null; then + mv "$COINS" "${COINS}.package-kudos" + echo "Moved package coins -> ${COINS}.package-kudos" +fi + +cat >"$COINS" << 'COINS_EOF' +# GOA denominations for exchange.hacktivism.ch +# Units: 1 GOA = 1000 mGOA = 1_000_000 uGOA +# VALUE uses base currency; coin sections cover 1–2–5 ladders at u/m/whole scale. + +# --- uGOA (10^-6 GOA) --- +[coin_goa_0_000001] +VALUE = GOA:0.000001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_000002] +VALUE = GOA:0.000002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_000005] +VALUE = GOA:0.000005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_00001] +VALUE = GOA:0.00001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_00002] +VALUE = GOA:0.00002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_00005] +VALUE = GOA:0.00005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_0001] +VALUE = GOA:0.0001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_0002] +VALUE = GOA:0.0002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_0005] +VALUE = GOA:0.0005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +# --- mGOA (10^-3 GOA) --- +[coin_goa_0_001] +VALUE = GOA:0.001 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_002] +VALUE = GOA:0.002 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_005] +VALUE = GOA:0.005 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_01] +VALUE = GOA:0.01 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_02] +VALUE = GOA:0.02 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_05] +VALUE = GOA:0.05 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_1] +VALUE = GOA:0.1 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_2] +VALUE = GOA:0.2 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_0_5] +VALUE = GOA:0.5 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +# --- whole GOA --- +[coin_goa_1_0] +VALUE = GOA:1 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_2_0] +VALUE = GOA:2 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_5_0] +VALUE = GOA:5 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_10_0] +VALUE = GOA:10 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_20_0] +VALUE = GOA:20 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_50_0] +VALUE = GOA:50 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_100_0] +VALUE = GOA:100 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_200_0] +VALUE = GOA:200 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA + +[coin_goa_1000_0] +VALUE = GOA:1000 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = GOA:0 +FEE_DEPOSIT = GOA:0 +FEE_REFRESH = GOA:0 +FEE_REFUND = GOA:0 +RSA_KEYSIZE = 2048 +CIPHER = RSA +COINS_EOF + +echo "Wrote $COINS ($(grep -c '^\[coin_' "$COINS") coin sections)" + +echo "=== 5. database ===" +taler-exchange-dbconfig 2>&1 || true +runuser -u taler-exchange-httpd -- taler-exchange-dbinit -c "$CONF" 2>&1 || true + +echo "=== 6. next steps (manual) ===" +echo " - /root/start_base_services_for_taler_exchange.sh # root: secmods + shell as httpd" +echo " - ./start_exchange.sh # as taler-exchange-httpd" +echo " - offline: sign denominations + wire accounts for /keys" +echo " - MASTER_PUBLIC_KEY=$MASTER_PUB" +echo "DONE bootstrap" diff --git a/scripts/taler-exchange/check_exchange-health.sh b/scripts/taler-exchange/check_exchange-health.sh new file mode 100755 index 0000000..3214745 --- /dev/null +++ b/scripts/taler-exchange/check_exchange-health.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# Health check for manual taler-exchange (same style as check_merchant-health.sh). +# Runnable as taler-exchange-httpd (or any user that can see processes + curl localhost). + +CONF=/etc/taler-exchange/taler-exchange.conf +SOCK=/run/taler-exchange/httpd/exchange-http.sock + +green() { echo -e "\e[32m$1\e[0m"; } +red() { echo -e "\e[31m$1\e[0m"; } +yellow() { echo -e "\e[33m$1\e[0m"; } + +fail=0 +ok() { green "[OK] $1"; } +bad() { red "[FAIL] $1"; fail=1; } +warn() { yellow "[WARN] $1"; } + +echo "=== Taler Exchange Health Check ===" + +# 1. secmods (started by root base script) +for p in taler-exchange-secmod-rsa taler-exchange-secmod-cs taler-exchange-secmod-eddsa; do + if pgrep -f "$p" >/dev/null 2>&1; then + ok "process $p" + else + bad "process $p not running" + fi +done + +# 2. httpd +if pgrep -f taler-exchange-httpd >/dev/null 2>&1; then + ok "process taler-exchange-httpd" +else + bad "process taler-exchange-httpd is NOT running" +fi + +# 3. serving mode +SERVE=$(taler-exchange-config -c "$CONF" -s exchange -o SERVE 2>/dev/null || echo unix) +PORT=$(taler-exchange-config -c "$CONF" -s exchange -o PORT 2>/dev/null || echo 9011) + +if [ "$SERVE" = "tcp" ]; then + if curl -sf -m 3 "http://127.0.0.1:${PORT}/config" >/dev/null 2>&1; then + ok "HTTP /config on 127.0.0.1:${PORT}" + elif curl -sf -m 3 "http://127.0.0.1:${PORT}/keys" >/dev/null 2>&1; then + ok "HTTP /keys on 127.0.0.1:${PORT}" + else + bad "no HTTP response on 127.0.0.1:${PORT} (/config|/keys)" + fi +else + if [ -S "$SOCK" ]; then + ok "socket $SOCK" + else + bad "socket does NOT exist: $SOCK" + fi +fi + +# 4. wire helpers — ensure (no systemd) then require +if [ "${SKIP_ENSURE:-0}" != "1" ]; then + if [ -x /usr/local/bin/ensure_exchange_helpers.sh ]; then + echo "--- ensure_exchange_helpers ---" + /usr/local/bin/ensure_exchange_helpers.sh || warn "ensure_exchange_helpers exited non-zero" + elif [ -x "$(dirname "$0")/ensure_exchange_helpers.sh" ]; then + echo "--- ensure_exchange_helpers ---" + "$(dirname "$0")/ensure_exchange_helpers.sh" || warn "ensure_exchange_helpers exited non-zero" + elif [ -x /root/ensure_exchange_helpers.sh ]; then + echo "--- ensure_exchange_helpers ---" + /root/ensure_exchange_helpers.sh || warn "ensure_exchange_helpers exited non-zero" + fi +fi + +live_helper() { + local p="$1" + # COMM is 15 chars; use full cmdline match + pgrep -f "(^|/)(${p})( |$)" >/dev/null 2>&1 +} + +for p in taler-exchange-aggregator taler-exchange-wirewatch taler-exchange-transfer taler-exchange-closer; do + if live_helper "$p"; then + ok "process $p" + else + bad "process $p not running (settlement needs transfer+aggregator)" + fi +done + +if [ "$fail" -eq 0 ]; then + green "=== ALL CRITICAL CHECKS PASSED ===" + exit 0 +fi +red "=== SOME CHECKS FAILED ===" +exit 1 diff --git a/scripts/taler-exchange/ensure_exchange_helpers.sh b/scripts/taler-exchange/ensure_exchange_helpers.sh new file mode 100755 index 0000000..91d8c57 --- /dev/null +++ b/scripts/taler-exchange/ensure_exchange_helpers.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Ensure exchange wire/db helper processes are running (no systemd). +# Root inside taler-exchange container (or host if packages installed there). +# +# Usage: +# ensure_exchange_helpers.sh +# ENSURE_ONLY=1 ensure_exchange_helpers.sh # start missing, no health summary +set -euo pipefail + +CONF="${TALER_EXCHANGE_CONFIG:-/etc/taler-exchange/taler-exchange.conf}" +LOG_DIR="${TALER_EXCHANGE_LOG_DIR:-/var/log/taler-exchange}" + +if [ "$(id -u)" -ne 0 ]; then + echo "root only" >&2 + exit 1 +fi + +mkdir -p "$LOG_DIR" +chmod 755 "$LOG_DIR" 2>/dev/null || true + +# Live process under user. Note: Linux COMM is 15 chars — never use pgrep -x +# for long names like taler-exchange-aggregator / taler-exchange-wirewatch. +is_running() { + local user="$1" + local bin="$2" + local base + base=$(basename "$bin") + # [t] trick avoids matching this grep/pgrep itself + ps -u "$user" -o args= 2>/dev/null | grep -qE "(^|/)[${base:0:1}]${base:1}( |$)" \ + || pgrep -u "$user" -f "(^|/)(${base})( |$)" >/dev/null 2>&1 +} + +# Start with nohup so SIGHUP from parent shell exit does not kill helpers. +start_one() { + local user="$1" + local name="$2" + local bin="$3" + shift 3 + if is_running "$user" "$bin"; then + echo "already: $name ($user)" + return 0 + fi + echo "start: $name as $user" + # shellcheck disable=SC2086 + nohup runuser -u "$user" -- "$bin" "$@" >>"$LOG_DIR/${name}.log" 2>&1 </dev/null & + disown 2>/dev/null || true + local i + for i in 1 2 3 4 5 6; do + sleep 0.4 + if is_running "$user" "$bin"; then + echo " ok: $name" + return 0 + fi + done + echo " FAIL: $name did not stay up (see $LOG_DIR/${name}.log)" >&2 + tail -15 "$LOG_DIR/${name}.log" 2>/dev/null || true + return 1 +} + +ec=0 +start_one taler-exchange-aggregator taler-exchange-aggregator \ + /usr/bin/taler-exchange-aggregator -c "$CONF" -L INFO || ec=1 +start_one taler-exchange-closer taler-exchange-closer \ + /usr/bin/taler-exchange-closer -c "$CONF" -L INFO || ec=1 +start_one taler-exchange-wire taler-exchange-wirewatch \ + /usr/bin/taler-exchange-wirewatch -c "$CONF" -L INFO || ec=1 +start_one taler-exchange-wire taler-exchange-transfer \ + /usr/bin/taler-exchange-transfer -c "$CONF" -L INFO || ec=1 + +echo "--- live helpers ---" +ps -eo pid,user,stat,etime,args 2>/dev/null \ + | grep -E 'taler-exchange-(aggregator|closer|wirewatch|transfer)' \ + | grep -vE 'grep| Z |ensure_exchange' || true + +exit "$ec" diff --git a/scripts/taler-exchange/install_no_terms.sh b/scripts/taler-exchange/install_no_terms.sh new file mode 100755 index 0000000..55c0191 --- /dev/null +++ b/scripts/taler-exchange/install_no_terms.sh @@ -0,0 +1,263 @@ +#!/bin/bash +# Install "No Terms Required" ToS + privacy for the exchange (styled like merchant terms). +# Run as root inside the exchange container. +set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" + +CONF="${TALER_EXCHANGE_CONFIG:-/etc/taler-exchange/taler-exchange.conf}" +DATA_HOME=$(taler-exchange-config -c "$CONF" -f -s PATHS -o TALER_DATA_HOME 2>/dev/null || true) +DATA_HOME="${DATA_HOME:-/var/lib/taler-exchange/}" +TERMS_DIR="${DATA_HOME%/}/terms" +LANG_DIR="$TERMS_DIR/en" +mkdir -p "$LANG_DIR" + +# Bump when HTML/style changes (long Cache-Control on /terms). +TOS_ETAG="${TERMS_ETAG:-no-terms-v2}" +# Prefer Swiss FADP privacy install (install_swiss_privacy.sh); fallback etag here +PP_ETAG="${PRIVACY_ETAG:-exchange-pp-swiss-v0}" + +# Shared palette with merchant-tos-dual / bank terms pages +COMMON_CSS=' + :root { color-scheme: dark light; } + body { + font-family: system-ui, -apple-system, sans-serif; + max-width: 40rem; margin: 2rem auto; padding: 0 1.1rem 3rem; + line-height: 1.5; color: #e8e6e3; background: #1a1520; + } + h1 { font-size: 1.35rem; font-weight: 800; margin: 0 0 1rem; color: #f5f0ea; } + h2 { font-size: 1.05rem; margin: 1.4rem 0 0.5rem; color: #e8c878; } + p, li { font-size: 0.98rem; } + ul { padding-left: 1.2rem; } + code, a { color: #5eead4; } + a { text-decoration: none; } + a:hover { text-decoration: underline; } + .badge { + display: inline-block; font-size: 0.72rem; font-weight: 700; + letter-spacing: 0.06em; text-transform: uppercase; + color: #c4b5fd; border: 1px solid rgba(196,181,253,0.35); + border-radius: 999px; padding: 0.2rem 0.65rem; margin-bottom: 0.85rem; + } + .cur { + border-radius: 12px; padding: 0.75rem 0.9rem; margin: 0.85rem 0 1rem; + border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); + } + .cur strong { display: block; font-size: 1.05rem; margin-bottom: 0.25rem; color: #5eead4; } + .muted { color: #a39e98; font-size: 0.88rem; } + footer { margin-top: 2rem; font-size: 0.85rem; color: #a39e98; } +' + +write_tos() { + local base="$1" + local title="No Terms Required" + local body_txt body_md + + body_txt='No Terms Required. + +This is an experimental / exploration GNU Taler exchange for the currency GOA (hacktivism.ch). + +No formal terms of service are required to use this service. + +By withdrawing or using GOA coins you acknowledge that: +- This service is for exploration and testing only. +- GOA is not legal tender and has no guaranteed real-world value or redemption. +- There is no guaranteed availability, support, or uptime. +- Operators may reset balances or change configuration without notice. +- Do not use real money you cannot afford to lose. + +If you do not agree, do not use this exchange. + +Related: +- Bank: https://bank.hacktivism.ch/intro/ +- Merchant terms: https://taler.hacktivism.ch/terms +- Bank terms: https://bank.hacktivism.ch/terms + +Privacy: +Processing under Swiss FADP (revDSG). What data is retained is listed at +https://exchange.hacktivism.ch/privacy +' + + body_md='# No Terms Required + +This is an **experimental / exploration** GNU Taler exchange for the currency **GOA** (hacktivism.ch). + +**No formal terms of service** are required to use this service. + +By withdrawing or using GOA coins you acknowledge that: + +- This service is for exploration and testing only. +- GOA is not legal tender and has no guaranteed real-world value or redemption. +- There is no guaranteed availability, support, or uptime. +- Operators may reset balances or change configuration without notice. +- Do not use real money you cannot afford to lose. + +If you do not agree, do not use this exchange. + +## Related + +- [Bank intro](https://bank.hacktivism.ch/intro/) +- [Bank terms](https://bank.hacktivism.ch/terms) +- [Merchant terms](https://taler.hacktivism.ch/terms) +- [Exchange privacy](https://exchange.hacktivism.ch/privacy) + +## Privacy + +Processing under Swiss FADP (revDSG). What data is retained is listed on +[exchange.hacktivism.ch/privacy](https://exchange.hacktivism.ch/privacy). +' + + printf '%s\n' "$body_txt" >"$LANG_DIR/${base}.txt" + printf '%s\n' "$body_md" >"$LANG_DIR/${base}.md" + cat >"$LANG_DIR/${base}.html" <<HTML +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"/> + <meta name="viewport" content="width=device-width, initial-scale=1"/> + <title>${title} + + + +
exchange.hacktivism.ch · GOA
+

${title}

+

This is an experimental / exploration GNU Taler exchange for the + currency GOA at exchange.hacktivism.ch (hacktivism.ch).

+

No formal terms of service are required to use this service.

+ +
+ GOA · explorational + Not legal tender. No guaranteed real-world value, redemption, or convertibility. + Issued only for exploration and testing on this stack. +
+ +

By withdrawing or using GOA coins you acknowledge

+
    +
  • This service is for exploration and testing only.
  • +
  • There is no guaranteed availability, support, or uptime.
  • +
  • Operators may reset balances or change configuration without notice.
  • +
  • Do not use real money you cannot afford to lose.
  • +
  • Software is provided as-is, without warranty.
  • +
+

If you do not agree, do not use this exchange.

+ +

Related

+ + +

Privacy

+

Processing under Swiss FADP (revDSG). What data is retained + (reserves, wire-in, coins, logs, …) is listed on + /privacy.

+
Version ${base} · hacktivism.ch
+ + +HTML +} + +write_pp() { + # Legacy short PP only if not using install_swiss_privacy.sh etag + local base="$1" + local title="Privacy notice · GOA Exchange" + local body_txt body_md + + body_txt='No Privacy Policy Required. + +This is an experimental / exploration GNU Taler exchange for GOA. + +No formal privacy policy is required for this demo service. + +High-level notes: +- Wire transfers via the regional bank may identify bank account holders. +- The exchange processes withdrawals, deposits, and related protocol operations. +- Logs may be kept for operation and debugging. + +Do not use this service if that is unacceptable. + +Related: +- Exchange terms: https://exchange.hacktivism.ch/terms +' + + body_md='# No Privacy Policy Required + +This is an **experimental / exploration** GNU Taler exchange for **GOA**. + +**No formal privacy policy** is required for this demo service. + +## High-level notes + +- Wire transfers via the regional bank may identify bank account holders. +- The exchange processes withdrawals, deposits, and related protocol operations. +- Logs may be kept for operation and debugging. + +Do not use this service if that is unacceptable. + +## Related + +- [Exchange terms](https://exchange.hacktivism.ch/terms) +' + + printf '%s\n' "$body_txt" >"$LANG_DIR/${base}.txt" + printf '%s\n' "$body_md" >"$LANG_DIR/${base}.md" + cat >"$LANG_DIR/${base}.html" < + + + + + ${title} + + + +
exchange.hacktivism.ch · privacy
+

${title}

+

This is an experimental / exploration GNU Taler exchange for + GOA at exchange.hacktivism.ch.

+

No formal privacy policy is required for this demo service.

+ +

High-level notes

+
    +
  • Wire transfers via the regional bank may identify bank account holders.
  • +
  • The exchange processes withdrawals, deposits, and related protocol operations.
  • +
  • Logs may be kept for operation and debugging.
  • +
+

Do not use this service if that is unacceptable.

+ +

Related

+ +
Version ${base}
+ + +HTML +} + +write_tos "$TOS_ETAG" +# Prefer Swiss FADP privacy installer when present (precise retention tables) +if [ -x /usr/local/bin/install_swiss_privacy.sh ]; then + PRIVACY_ETAG="$PP_ETAG" /usr/local/bin/install_swiss_privacy.sh +else + write_pp "$PP_ETAG" +fi + +chmod -R a+rX "$TERMS_DIR" +if id taler-exchange-httpd >/dev/null 2>&1; then + chown -R taler-exchange-httpd: "$TERMS_DIR" 2>/dev/null || true +fi + +echo "Installed under $LANG_DIR:" +ls -la "$LANG_DIR"/${TOS_ETAG}.* "$LANG_DIR"/${PP_ETAG}.* 2>/dev/null || ls -la "$LANG_DIR" +echo "Config should set:" +echo " TERMS_ETAG = ${TOS_ETAG}" +echo " PRIVACY_ETAG = ${PP_ETAG}" +echo " TERMS_DIR / PRIVACY_DIR = \${TALER_DATA_HOME}terms/" diff --git a/scripts/taler-exchange/install_swiss_privacy.sh b/scripts/taler-exchange/install_swiss_privacy.sh new file mode 100644 index 0000000..0519d50 --- /dev/null +++ b/scripts/taler-exchange/install_swiss_privacy.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# Swiss FADP privacy for exchange.hacktivism.ch — run inside exchange container. +set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" + +ETAG="${PRIVACY_ETAG:-exchange-pp-swiss-v0}" +CONF="${TALER_EXCHANGE_CONFIG:-/etc/taler-exchange/taler-exchange.conf}" +DATA_HOME=$(taler-exchange-config -c "$CONF" -f -s PATHS -o TALER_DATA_HOME 2>/dev/null || true) +DATA_HOME="${DATA_HOME:-/var/lib/taler-exchange/}" +DIR="${PRIVACY_DIR:-${DATA_HOME%/}/terms}/en" +mkdir -p "$DIR" + +cat >"$DIR/${ETAG}.txt" <<'EOF' +Privacy notice — exchange.hacktivism.ch (Swiss FADP / revDSG) + +Controller: operators of the hacktivism.ch GNU Taler stack. +Service: experimental GOA exchange (not a licensed Swiss bank or e-money institution). + +Data retained (precise): +1) Reserves — reserve public keys, current remaining amounts, creation/close times: for service life or wipe. +2) Wire-in (reserves_in) — credit amounts, timestamps, bank-side account reference as provided by the bank/wire gateway: for service life; links reserve funding to bank accounts. +3) Coins — known coin public keys, denomination serials, remaining value, spend/deposit linkage as required by protocol: for service life. +4) Withdraw / refresh / recoup / refund / deposit tables — protocol operation records and timestamps: for service life. +5) Wire-out — transfers to merchant accounts (amounts, payto/target as stored, times): for service life. +6) Keys / denominations — public denomination and signing keys (not personal data by themselves). +7) Technical logs — HTTP and process logs (IP, path, status): days–weeks via rotation. +8) Terms acceptance markers if stored by clients — as required by wallet software. + +Not retained by the exchange as plaintext identity of wallet holders: GNU Taler is designed so coin spend is unlinkable to customer bank identity at the exchange when used as intended. Funding reserves via the bank may still identify the bank account holder on the bank side. + +Purposes: operate GOA exchange protocol, settle with bank/merchants, security, operations. +Legal basis (FADP): provision of requested exchange service; proportionate operation of experimental stack. +Recipients: bank.hacktivism.ch (wire); merchants depositing GOA; host operators. No sale of data. +Rights: access, rectification, deletion, objection (FADP); complaint to Swiss FDPIC (EDÖB). +Security: TLS; experimental stack. + +Related: https://exchange.hacktivism.ch/terms · https://bank.hacktivism.ch/intro/privacy.html · https://taler.hacktivism.ch/privacy +EOF + +cat >"$DIR/${ETAG}.md" <<'EOF' +# Privacy notice · GOA Exchange · Swiss FADP + +Controller: operators of **hacktivism.ch**. Service: experimental **GOA** exchange at `exchange.hacktivism.ch`. + +Swiss Federal Act on Data Protection (**FADP / revDSG**, since 1 Sep 2023). + +## Data retained + +| Data | Examples | Retention | +|------|----------|-----------| +| Reserves | Reserve pubs, remaining amount, times | Service life or wipe | +| Wire-in | Amounts, timestamps, bank account ref from wire path | Service life | +| Coins | Coin pubs, denoms, remaining, deposit links | Service life | +| Protocol ops | Withdraw, refresh, deposit, refund, recoup | Service life | +| Wire-out | Merchant settlement amounts / targets | Service life | +| Technical logs | IP, path, status | Days–weeks (rotation) | + +**Design note:** Coin spend is intended to be unlinkable to the customer at the exchange. Funding a reserve via the bank may identify the **bank** account holder on the bank system. + +## Purposes + +Run the GOA exchange protocol; wire settlement; security and operations. + +## Rights + +FADP access, correction, deletion, objection. Complaint: Swiss **FDPIC / EDÖB**. + +## Related + +- [Exchange terms](https://exchange.hacktivism.ch/terms) +- [Bank privacy](https://bank.hacktivism.ch/intro/privacy.html) +- [Merchant privacy](https://taler.hacktivism.ch/privacy) +EOF + +cat >"$DIR/${ETAG}.html" <<'HTML' + + + + + + Privacy notice · GOA Exchange · Swiss FADP + + + +
exchange.hacktivism.ch · privacy · CH
+

Privacy notice · GOA Exchange

+

+ Swiss Federal Act on Data Protection (FADP / revDSG, since 1 Sep 2023). + Experimental GOA exchange at exchange.hacktivism.ch — not a licensed bank. +

+ +

1. Controller

+

Operators of the hacktivism.ch GNU Taler stack. No separate DPO for this experimental service.

+ +

2. Data retained

+ + + + + + + + + + +
DataExamplesTypical retention
ReservesReserve public keys, remaining amount, timesService life or wipe
Wire-inAmounts, timestamps, bank account reference from wire pathService life
CoinsCoin pubs, denominations, remaining value, deposit linksService life
Protocol operationsWithdraw, refresh, deposit, refund, recoupService life
Wire-outMerchant settlement amounts / targetsService life
Technical logsIP, path, statusDays–weeks (rotation)
+

Design note: Coin spend is intended to be unlinkable to the customer + at the exchange. Funding a reserve via the bank may identify the bank account + holder on the bank system.

+ +

3. Purposes

+
    +
  • Operate the GOA GNU Taler exchange protocol
  • +
  • Wire settlement with bank and merchants
  • +
  • Security, debugging, capacity monitoring
  • +
+ +

4. Your rights

+

Access, rectification, deletion, objection under the FADP. Complaint: + Swiss FDPIC / EDÖB.

+ +

Related

+ +
exchange-pp-swiss-v0 · Swiss FADP (revDSG)
+ + +HTML + +chmod -R a+rX "${DATA_HOME%/}/terms" +if id taler-exchange-httpd >/dev/null 2>&1; then + chown -R taler-exchange-httpd: "${DATA_HOME%/}/terms" 2>/dev/null || true +fi +echo "ok exchange privacy $ETAG -> $DIR" +ls -la "$DIR"/${ETAG}.* diff --git a/scripts/taler-exchange/landing-stats-exchange.sh b/scripts/taler-exchange/landing-stats-exchange.sh new file mode 100644 index 0000000..35452d4 --- /dev/null +++ b/scripts/taler-exchange/landing-stats-exchange.sh @@ -0,0 +1,300 @@ +#!/bin/bash +# Run INSIDE taler-hacktivism-exchange-ansible. +# Writes /var/www/exchange-landing/stats.json +# +# Data lives in Postgres DB taler-exchange, schema exchange.* +# (reserves, reserves_in, known_coins, withdraw, denominations, …) +# +# IMPORTANT: never use psql -F$'\t' -v ON_ERROR_STOP=1 +# If the tab arg is lost, -F eats -v and ON_ERROR_STOP=1 becomes the +# *username* → peer auth fails → silent empty counts (all zeros). +# Never overwrite stats.json on failure — write stats-run.json instead. +set -euo pipefail +export TZ="${TZ:-Europe/Zurich}" +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" +LANDING_DIR="${LANDING_DIR:-/var/www/exchange-landing}" +OUT="$LANDING_DIR/stats.json" +RUN="$LANDING_DIR/stats-run.json" +TMP="${OUT}.tmp.$$" +DB="${EXCHANGE_DB:-taler-exchange}" +BASE_URL="${EXCHANGE_BASE_URL:-https://exchange.hacktivism.ch}" +ERRLOG="${LANDING_STATS_ERRLOG:-/var/log/landing-stats-exchange.err}" +mkdir -p "$LANDING_DIR" + +now_iso() { date +%Y-%m-%dT%H:%M%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/'; } +now_human() { date +"%Y-%m-%d %H:%M %Z"; } +json_str() { + printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n\r\t' ' ')" +} + +write_run() { + local ok_json="$1" msg="${2:-}" + cat >"$RUN" <&2 + rm -f "$TMP" 2>/dev/null || true + exit 1 +} + +# Prefer runuser; fall back to su. Always absolute-ish via PATH. +as_postgres() { + if command -v runuser >/dev/null 2>&1; then + runuser -u postgres -- "$@" + elif command -v su >/dev/null 2>&1; then + su -s /bin/bash postgres -c "$*" + else + return 127 + fi +} + +# Single value or one row with '|' separators (no -F, no -v flags). +psqlq() { + local sql="$1" out ec + set +e + out=$(as_postgres psql -d "$DB" -At -c "$sql" 2>>"$ERRLOG") + ec=$? + set -e + if [ "$ec" -ne 0 ]; then + abort "psql failed (ec=$ec): ${sql:0:120}" + fi + printf '%s' "$out" +} + +fmt_goa() { + local v="${1:-0}" f="${2:-0}" + v=${v//[^0-9-]/}; f=${f//[^0-9-]/}; v=${v:-0}; f=${f:-0} + if [ "$f" = "0" ] || [ -z "$f" ]; then + printf 'GOA:%s' "$v" + return + fi + awk -v v="$v" -v f="$f" 'BEGIN{ + frac = sprintf("%08d", f+0); sub(/0+$/, "", frac) + if (frac == "") printf "GOA:%d", v+0 + else printf "GOA:%d.%s", v+0, frac + }' +} + +q_count() { + local n + n=$(psqlq "SELECT count(*)::text FROM $1;") + # strip whitespace/newlines + n=$(printf '%s' "$n" | tr -d '[:space:]') + [[ "$n" =~ ^[0-9]+$ ]] || abort "bad count for $1: '$n'" + printf '%s' "$n" +} + +# --- preflight --- +command -v psql >/dev/null 2>&1 || abort "psql not in PATH ($PATH)" +command -v runuser >/dev/null 2>&1 || command -v su >/dev/null 2>&1 || abort "neither runuser nor su in PATH" +PROBE=$(psqlq "SELECT 1;") +[ "$PROBE" = "1" ] || abort "postgres not reachable (SELECT 1 → '$PROBE')" + +# --- coin lifecycle --- +KNOWN_COINS=$(q_count "exchange.known_coins") +COINS_LIVE=$(psqlq "SELECT count(*)::text FROM exchange.known_coins WHERE (remaining).val > 0 OR (remaining).frac > 0;") +COINS_LIVE=$(printf '%s' "${COINS_LIVE:-0}" | tr -d '[:space:]') +COINS_LIVE=${COINS_LIVE:-0} +COINS_SPENT=$(( KNOWN_COINS - COINS_LIVE )) + +REM_ROW=$(psqlq "SELECT coalesce(sum((remaining).val),0)::text || '|' || coalesce(sum((remaining).frac),0)::text FROM exchange.known_coins;") +IFS='|' read -r REM_VAL REM_FRAC <<<"${REM_ROW:-0|0}" +REMAINING_AMT=$(fmt_goa "${REM_VAL:-0}" "${REM_FRAC:-0}") + +DENOMS_TOTAL=$(q_count "exchange.denominations") +DENOM_VALUES=$(psqlq "SELECT count(DISTINCT ((coin).val, (coin).frac))::text FROM exchange.denominations;") +DENOM_VALUES=$(printf '%s' "${DENOM_VALUES:-0}" | tr -d '[:space:]') + +NOW_US=$(date +%s)000000 +DENOMS_WITHDRAWABLE=$(psqlq "SELECT count(*)::text FROM exchange.denominations WHERE valid_from <= ${NOW_US} AND expire_withdraw > ${NOW_US};") +DENOMS_WITHDRAWABLE=$(printf '%s' "${DENOMS_WITHDRAWABLE:-0}" | tr -d '[:space:]') + +RESERVES=$(q_count "exchange.reserves") +RESERVES_IN=$(q_count "exchange.reserves_in") +WIN_ROW=$(psqlq "SELECT coalesce(sum((credit).val),0)::text || '|' || coalesce(sum((credit).frac),0)::text FROM exchange.reserves_in;") +IFS='|' read -r WIN_VAL WIN_FRAC <<<"${WIN_ROW:-0|0}" +WIRE_IN_AMT=$(fmt_goa "${WIN_VAL:-0}" "${WIN_FRAC:-0}") + +WITHDRAW_OPS=$(q_count "exchange.withdraw") +WOUT_ROW=$(psqlq "SELECT coalesce(sum((amount_with_fee).val),0)::text || '|' || coalesce(sum((amount_with_fee).frac),0)::text FROM exchange.withdraw;") +IFS='|' read -r WO_VAL WO_FRAC <<<"${WOUT_ROW:-0|0}" +WITHDRAW_AMT=$(fmt_goa "${WO_VAL:-0}" "${WO_FRAC:-0}") + +REFRESH_OPS=$(q_count "exchange.refresh") +RECOUP=$(q_count "exchange.recoup") +REFUNDS=$(q_count "exchange.refunds") +COIN_DEPOSITS=$(q_count "exchange.coin_deposits") +BATCH_DEPOSITS=$(q_count "exchange.batch_deposits") +WIRE_OUT=$(q_count "exchange.wire_out") +COIN_HISTORY=$(q_count "exchange.coin_history") +WIRE_ACCTS=$(q_count "exchange.wire_accounts") + +# known coins by denom value (pipe-separated) +BY_DENOM_TSV=$(psqlq " +SELECT (d.coin).val::text || '|' || (d.coin).frac::text || '|' || count(*)::text || '|' || + count(*) FILTER (WHERE (k.remaining).val > 0 OR (k.remaining).frac > 0)::text +FROM exchange.known_coins k +JOIN exchange.denominations d ON d.denominations_serial = k.denominations_serial +GROUP BY (d.coin).val, (d.coin).frac +ORDER BY (d.coin).val, (d.coin).frac; +") + +BY_DENOM_JSON="[" +bf=1 +while IFS='|' read -r dv df cnt live; do + [ -z "${dv:-}" ] && continue + amt=$(fmt_goa "$dv" "$df") + if [ "$bf" = 1 ]; then bf=0; else BY_DENOM_JSON="${BY_DENOM_JSON},"; fi + BY_DENOM_JSON="${BY_DENOM_JSON} + {\"value\": $(json_str "$amt"), \"coins\": ${cnt:-0}, \"live\": ${live:-0}}" +done <<<"$BY_DENOM_TSV" +BY_DENOM_JSON="${BY_DENOM_JSON} + ]" + +LADDER_TSV=$(psqlq " +SELECT (coin).val::text || '|' || (coin).frac::text || '|' || count(*)::text +FROM exchange.denominations +GROUP BY (coin).val, (coin).frac +ORDER BY (coin).val, (coin).frac; +") +LADDER_JSON="[" +lf=1 +while IFS='|' read -r dv df nkeys; do + [ -z "${dv:-}" ] && continue + amt=$(fmt_goa "$dv" "$df") + if [ "$lf" = 1 ]; then lf=0; else LADDER_JSON="${LADDER_JSON},"; fi + LADDER_JSON="${LADDER_JSON} + {\"value\": $(json_str "$amt"), \"keys\": ${nkeys:-0}}" +done <<<"$LADDER_TSV" +LADDER_JSON="${LADDER_JSON} + ]" + +# recent wire-in / withdraw activity (no personal names — reserve_pub hex truncated) +RECENT_JSON="[" +rf=1 +while IFS='|' read -r ts val frac; do + [ -z "${ts:-}" ] && continue + sec=$(awk -v t="$ts" 'BEGIN{printf "%d", int(t/1000000)}') + human=$(date -d "@${sec}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$sec") + amt=$(fmt_goa "${val:-0}" "${frac:-0}") + if [ "$rf" = 1 ]; then rf=0; else RECENT_JSON="${RECENT_JSON},"; fi + RECENT_JSON="${RECENT_JSON} + {\"kind\": \"wire_in\", \"amount\": $(json_str "$amt"), \"ts_human\": $(json_str "$human"), \"ts_us\": ${ts:-0}}" +done < <(psqlq " +SELECT execution_date::text || '|' || (credit).val::text || '|' || (credit).frac::text +FROM exchange.reserves_in +ORDER BY execution_date DESC +LIMIT 8; +") +while IFS='|' read -r ts val frac; do + [ -z "${ts:-}" ] && continue + sec=$(awk -v t="$ts" 'BEGIN{printf "%d", int(t/1000000)}') + human=$(date -d "@${sec}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$sec") + amt=$(fmt_goa "${val:-0}" "${frac:-0}") + if [ "$rf" = 1 ]; then rf=0; else RECENT_JSON="${RECENT_JSON},"; fi + RECENT_JSON="${RECENT_JSON} + {\"kind\": \"withdraw\", \"amount\": $(json_str "$amt"), \"ts_human\": $(json_str "$human"), \"ts_us\": ${ts:-0}}" +done < <(psqlq " +SELECT execution_date::text || '|' || (amount_with_fee).val::text || '|' || (amount_with_fee).frac::text +FROM exchange.withdraw +ORDER BY execution_date DESC +LIMIT 6; +") +RECENT_JSON="${RECENT_JSON} + ]" + +# live performance +measure_ms() { + local url="$1" t + t=$(curl -sS -o /dev/null -m 8 -w '%{time_total}' "$url" 2>/dev/null || echo "") + [ -z "$t" ] && { echo "null"; return; } + awk -v t="$t" 'BEGIN{printf "%d", (t+0)*1000}' +} +KEYS_MS=$(measure_ms "${BASE_URL}/keys") +CONFIG_MS=$(measure_ms "${BASE_URL}/config") +KEYS_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BASE_URL}/keys" 2>/dev/null || echo "000") +CONFIG_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BASE_URL}/config" 2>/dev/null || echo "000") +LOADAVG="" +[ -r /proc/loadavg ] && LOADAVG=$(awk '{print $1","$2","$3}' /proc/loadavg) + +WW="false" +pgrep -f taler-exchange-wirewatch >/dev/null 2>&1 && WW="true" + +STAT_EVENTS=$(q_count "exchange.exchange_statistic_counter_event") + +# Fail closed: zero denoms usually means broken query path (table always has keys) +if [ "${DENOMS_TOTAL:-0}" = "0" ]; then + abort "denominations count is 0 — refusing to publish (check peer auth / PATH)" +fi + +GEN=$(now_iso) +HUMAN=$(now_human) +num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; } + +MEM_JSON='"container_rss_human": "—"' +MEM_HELPER="${MEM_HELPER:-/usr/local/lib/landing-mem-snapshot.sh}" +if [ -f "$MEM_HELPER" ]; then + # shellcheck disable=SC1090 + . "$MEM_HELPER" + mem_snapshot_json || true +fi + +cat >"$TMP" < $OUT" diff --git a/scripts/taler-exchange/offline-sign-upload-keys.sh b/scripts/taler-exchange/offline-sign-upload-keys.sh new file mode 100755 index 0000000..0268d9b --- /dev/null +++ b/scripts/taler-exchange/offline-sign-upload-keys.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Offline: download future keys from local exchange, sign, upload. Restore public BASE_URL. +# Run as root inside taler-hacktivism-exchange-ansible. +set -euo pipefail +CONF=/etc/taler-exchange/taler-exchange.conf +OV=/etc/taler-exchange/exchange-overrides.conf +PUBLIC='https://exchange.hacktivism.ch/' +LOCAL='http://127.0.0.1:9011/' + +[ "$(id -u)" -eq 0 ] || { echo "root only"; exit 1; } + +sed -i "s|^BASE_URL = .*|BASE_URL = ${LOCAL}|" "$OV" +echo "BASE_URL=$(grep '^BASE_URL' "$OV")" + +echo "=== download ===" +if ! runuser -u taler-exchange-offline -- \ + taler-exchange-offline -c "$CONF" -L INFO download > /tmp/future-keys.json 2>/tmp/dl.err; then + echo "download failed:"; cat /tmp/dl.err + # still show size +fi +echo "dl_err:"; cat /tmp/dl.err | tail -30 +echo "dl_size=$(wc -c /dev/null || echo 0)" +head -c 400 /tmp/future-keys.json 2>/dev/null; echo + +if [ ! -s /tmp/future-keys.json ]; then + echo "empty download — check secmod connectivity / management API" + sed -i "s|^BASE_URL = .*|BASE_URL = ${PUBLIC}|" "$OV" + exit 1 +fi + +echo "=== sign ===" +runuser -u taler-exchange-offline -- \ + taler-exchange-offline -c "$CONF" -L INFO sign < /tmp/future-keys.json > /tmp/signed-keys.json 2>/tmp/sg.err +echo "sg_err:"; cat /tmp/sg.err | tail -20 +echo "sg_size=$(wc -c /tmp/up.err +echo "up_err:"; cat /tmp/up.err | tail -20 + +sed -i "s|^BASE_URL = .*|BASE_URL = ${PUBLIC}|" "$OV" +echo "BASE_URL restored=$(grep '^BASE_URL' "$OV")" + +echo "=== /keys probe ===" +sleep 1 +# wake suspended handlers with a tiny delay +for i in 1 2 3 4 5 6; do + code=$(curl -sS -m 12 -o /tmp/keys.json -w '%{http_code}' http://127.0.0.1:9011/keys || true) + sz=$(wc -c /dev/null || echo 0) + echo "try $i code=$code size=$sz" + if [ "$code" = "200" ] && [ "$sz" -gt 200 ]; then + echo KEYS_OK + head -c 300 /tmp/keys.json; echo + grep -oE '"master_public_key"[[:space:]]*:[[:space:]]*"[^"]+"' /tmp/keys.json | head -1 || true + exit 0 + fi + sleep 2 +done +echo KEYS_FAIL +tail -25 /var/log/taler-exchange/taler-exchange-httpd-*.log 2>/dev/null | tail -25 +exit 1 diff --git a/scripts/taler-exchange/start_base_services_for_taler_exchange.sh b/scripts/taler-exchange/start_base_services_for_taler_exchange.sh new file mode 100755 index 0000000..ad247e4 --- /dev/null +++ b/scripts/taler-exchange/start_base_services_for_taler_exchange.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# Root: base services for manual exchange (like merchant start_base_services_for_taler.sh). +# Then interactive shell as taler-exchange-httpd → run start_exchange.sh there. +# +# Secmods run as dedicated users (not httpd) — started here as root. +# httpd is started by /usr/local/bin/start_exchange.sh as taler-exchange-httpd. +# +# Usage: +# /root/start_base_services_for_taler_exchange.sh +# /root/start_base_services_for_taler_exchange.sh --no-shell + +set -e +CONF=/etc/taler-exchange/taler-exchange.conf +LOG_DIR=/var/log/taler-exchange +PWD_BIN=/usr/local/bin +EXCHANGE_STARTER=start_exchange.sh + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root" >&2 + exit 1 +fi + +NO_SHELL=0 +for arg in "$@"; do + case "$arg" in + --no-shell|-n) NO_SHELL=1 ;; + --help|-h) + echo "Usage: $0 [--no-shell]" + exit 0 + ;; + esac +done + +# --- Debian postgresql defaults (pg_createcluster layout) --- +ensure_postgresql() { + echo " Debian perms on /etc/postgresql + data/log/run..." + if [ -d /etc/postgresql ]; then + chown -R root:postgres /etc/postgresql + find /etc/postgresql -type d -exec chmod 755 {} \; + find /etc/postgresql -type f -name '*.conf' -exec chmod 640 {} \; + fi + chown -R postgres:postgres /var/lib/postgresql /var/log/postgresql 2>/dev/null || true + mkdir -p /var/run/postgresql + chown postgres:postgres /var/run/postgresql + chmod 2775 /var/run/postgresql 2>/dev/null || chmod 775 /var/run/postgresql + + if pg_isready -q 2>/dev/null; then + echo " already accepting connections" + pg_isready || true + return 0 + fi + + rm -f /var/run/postgresql/.s.PGSQL.*.lock 2>/dev/null || true + if ! pgrep -u postgres -x postgres >/dev/null 2>&1; then + rm -f /var/lib/postgresql/*/main/postmaster.pid 2>/dev/null || true + fi + + if command -v pg_ctlcluster >/dev/null 2>&1 && command -v pg_lsclusters >/dev/null 2>&1; then + while read -r ver name _rest; do + [ -n "$ver" ] || continue + echo " pg_ctlcluster $ver $name start" + pg_ctlcluster "$ver" "$name" start 2>/dev/null || true + done < <(pg_lsclusters --no-header 2>/dev/null || true) + fi + if ! pg_isready -q 2>/dev/null; then + if [ -x /etc/init.d/postgresql ]; then + /etc/init.d/postgresql start || true + else + service postgresql start || true + fi + fi + sleep 1 + pg_isready || true +} + +echo "Create log + runtime dirs... giving permission to taler-exchange users:" +mkdir -p "$LOG_DIR" +mkdir -p /run/taler-exchange/secmod-rsa /run/taler-exchange/secmod-cs \ + /run/taler-exchange/secmod-eddsa /run/taler-exchange/httpd +chown root:root /run/taler-exchange +chmod 755 /run/taler-exchange +chown taler-exchange-secmod-rsa:taler-exchange-secmod /run/taler-exchange/secmod-rsa +chown taler-exchange-secmod-cs:taler-exchange-secmod /run/taler-exchange/secmod-cs +chown taler-exchange-secmod-eddsa:taler-exchange-secmod /run/taler-exchange/secmod-eddsa +chown taler-exchange-httpd:www-data /run/taler-exchange/httpd +chmod 755 /run/taler-exchange/secmod-rsa /run/taler-exchange/secmod-cs /run/taler-exchange/secmod-eddsa +chmod 750 /run/taler-exchange/httpd +chown taler-exchange-httpd: "$LOG_DIR" +chmod 755 "$LOG_DIR" + +# Package default: each secmod user owns its tree (keys/ must not be root-owned). +mkdir -p /var/lib/taler-exchange/secmod-rsa /var/lib/taler-exchange/secmod-cs \ + /var/lib/taler-exchange/secmod-eddsa +chown -R taler-exchange-secmod-rsa:taler-exchange-secmod /var/lib/taler-exchange/secmod-rsa +chown -R taler-exchange-secmod-cs:taler-exchange-secmod /var/lib/taler-exchange/secmod-cs +chown -R taler-exchange-secmod-eddsa:taler-exchange-secmod /var/lib/taler-exchange/secmod-eddsa +chmod 700 /var/lib/taler-exchange/secmod-rsa /var/lib/taler-exchange/secmod-cs \ + /var/lib/taler-exchange/secmod-eddsa + +echo "Start base services needed for Taler Exchange." +echo "" + +echo "1. postgresql:" +ensure_postgresql + +# Linux COMM is 15 chars — never pgrep -x for long names; match full path in args. +start_bg() { + local user="$1"; shift + local name="$1"; shift + local bin="$1" + if ps -eo args= 2>/dev/null | grep -F "$bin" | grep -v grep >/dev/null 2>&1; then + echo " already running: $name" + return 0 + fi + echo " start $name as $user" + nohup runuser -u "$user" -- "$@" >>"$LOG_DIR/${name}.log" 2>&1 /dev/null || true + sleep 0.3 +} + +echo "2. crypto secmods:" +start_bg taler-exchange-secmod-rsa taler-exchange-secmod-rsa \ + /usr/bin/taler-exchange-secmod-rsa -c "$CONF" -L INFO +start_bg taler-exchange-secmod-cs taler-exchange-secmod-cs \ + /usr/bin/taler-exchange-secmod-cs -c "$CONF" -L INFO +start_bg taler-exchange-secmod-eddsa taler-exchange-secmod-eddsa \ + /usr/bin/taler-exchange-secmod-eddsa -c "$CONF" -L INFO + +echo "3. wire/db helpers (need wire config to stay up):" +start_bg taler-exchange-aggregator taler-exchange-aggregator \ + /usr/bin/taler-exchange-aggregator -c "$CONF" -L INFO || true +start_bg taler-exchange-closer taler-exchange-closer \ + /usr/bin/taler-exchange-closer -c "$CONF" -L INFO || true +start_bg taler-exchange-wire taler-exchange-wirewatch \ + /usr/bin/taler-exchange-wirewatch -c "$CONF" -L INFO || true +start_bg taler-exchange-wire taler-exchange-transfer \ + /usr/bin/taler-exchange-transfer -c "$CONF" -L INFO || true + +if [ "$NO_SHELL" -eq 1 ]; then + echo "Base services started (--no-shell). Next: runuser -u taler-exchange-httpd -- $PWD_BIN/$EXCHANGE_STARTER [--restart]" + exit 0 +fi + +echo "4. Switching now to user taler-exchange-httpd, in $PWD_BIN; find executable $EXCHANGE_STARTER there!" +echo "" +cd "$PWD_BIN" +# same pattern as merchant: -u and -s/--shell are mutually exclusive on util-linux runuser +exec runuser -u taler-exchange-httpd -- bash diff --git a/scripts/taler-exchange/start_exchange.sh b/scripts/taler-exchange/start_exchange.sh new file mode 100755 index 0000000..48f8171 --- /dev/null +++ b/scripts/taler-exchange/start_exchange.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Start / restart taler-exchange-httpd (manual, no systemd). +# Run as: taler-exchange-httpd +# Same role as start_merchant.sh for the merchant. +# +# Usage: +# start_exchange.sh +# start_exchange.sh --restart | -r +# start_exchange.sh --help + +set -u + +usage() { + cat <<'EOF' +Usage: start_exchange.sh [--restart|-r] [--help|-h] + + (default) Start taler-exchange-httpd if not already running. + --restart Stop live taler-exchange-httpd, then start cleanly. + Does not touch postgres/secmods/wire helpers + (those come from /root/start_base_services_for_taler_exchange.sh). +EOF +} + +DO_RESTART=0 +for arg in "$@"; do + case "$arg" in + --restart|-r) DO_RESTART=1 ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown option: $arg" >&2; usage >&2; exit 2 ;; + esac +done + +if [ "$(id -un)" != "taler-exchange-httpd" ]; then + echo "This script must be run as user taler-exchange-httpd" >&2 + exit 1 +fi + +CONF=/etc/taler-exchange/taler-exchange.conf +LOG_DIR=/var/log/taler-exchange +PORT=$(taler-exchange-config -c "$CONF" -s exchange -o PORT 2>/dev/null || echo 9011) + +list_httpd_pids() { + ps -eo pid=,stat=,args= 2>/dev/null | while read -r pid stat args; do + case "$stat" in Z*) continue ;; esac + case "$args" in + *start_exchange.sh*) continue ;; + esac + case "$args" in + *taler-exchange-httpd\ *|taler-exchange-httpd\ *|/usr/bin/taler-exchange-httpd\ *) + echo "$pid" + ;; + esac + done | sort -u +} + +kill_httpd() { + local pids + pids=$(list_httpd_pids | tr '\n' ' ') + if [ -z "${pids// }" ]; then + echo "No live taler-exchange-httpd processes to stop." + return 0 + fi + echo "Stopping PIDs: $pids" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 2 + local left + left=$(list_httpd_pids | tr '\n' ' ') + if [ -n "${left// }" ]; then + echo "SIGKILL remaining: $left" + # shellcheck disable=SC2086 + kill -KILL $left 2>/dev/null || true + sleep 1 + fi + echo "taler-exchange-httpd stopped." +} + +if [ "$DO_RESTART" -eq 1 ]; then + echo "=== restart: kill taler-exchange-httpd ===" + kill_httpd +fi + +echo "Start taler-exchange-httpd:" +LOG_FILE="$LOG_DIR/taler-exchange-httpd-$(date +%Y-%m-%d).log" +mkdir -p "$LOG_DIR" +touch "$LOG_FILE" 2>/dev/null || true + +if [ "$DO_RESTART" -eq 0 ] && [ -n "$(list_httpd_pids)" ]; then + echo "taler-exchange-httpd already running" +else + nohup taler-exchange-httpd -c "$CONF" -L INFO >>"$LOG_FILE" 2>&1 & + disown 2>/dev/null || true + sleep 2 +fi + +echo "Live processes:" +ps -eo pid,stat,args 2>/dev/null | grep taler-exchange-httpd | grep -v grep | grep -v ' Z ' || true + +if [ -x /usr/local/bin/check_exchange-health.sh ]; then + /usr/local/bin/check_exchange-health.sh || exit 1 +elif [ -x ./check_exchange-health.sh ]; then + ./check_exchange-health.sh || exit 1 +fi +exit 0 diff --git a/scripts/taler-exchange/start_wire_helpers.sh b/scripts/taler-exchange/start_wire_helpers.sh new file mode 100755 index 0000000..4343267 --- /dev/null +++ b/scripts/taler-exchange/start_wire_helpers.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Start wire/db helpers only (root, no interactive shell, no systemd). +# Uses nohup so helpers survive the launching shell. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +if [ -x "$ROOT/ensure_exchange_helpers.sh" ]; then + exec "$ROOT/ensure_exchange_helpers.sh" +fi +if [ -x /usr/local/bin/ensure_exchange_helpers.sh ]; then + exec /usr/local/bin/ensure_exchange_helpers.sh +fi +# Fallback if ensure not installed yet +CONF=/etc/taler-exchange/taler-exchange.conf +LOG_DIR=/var/log/taler-exchange +[ "$(id -u)" -eq 0 ] || { echo "root only"; exit 1; } +mkdir -p "$LOG_DIR" + +start_bg() { + local user="$1"; shift + local name="$1"; shift + local bin="$1" + if pgrep -u "$user" -x "$(basename "$bin")" >/dev/null 2>&1; then + echo "already: $name" + return 0 + fi + echo "start: $name as $user" + nohup runuser -u "$user" -- "$@" >>"$LOG_DIR/${name}.log" 2>&1 /dev/null || true + sleep 0.4 +} + +start_bg taler-exchange-aggregator taler-exchange-aggregator \ + /usr/bin/taler-exchange-aggregator -c "$CONF" -L INFO +start_bg taler-exchange-closer taler-exchange-closer \ + /usr/bin/taler-exchange-closer -c "$CONF" -L INFO +start_bg taler-exchange-wire taler-exchange-wirewatch \ + /usr/bin/taler-exchange-wirewatch -c "$CONF" -L INFO +start_bg taler-exchange-wire taler-exchange-transfer \ + /usr/bin/taler-exchange-transfer -c "$CONF" -L INFO + +sleep 1 +pgrep -af 'taler-exchange-(aggregator|closer|wirewatch|transfer|httpd|secmod)' || true +[ -x /usr/local/bin/check_exchange-health.sh ] && /usr/local/bin/check_exchange-health.sh || true diff --git a/scripts/taler-exchange/wire-enable-and-upload.sh b/scripts/taler-exchange/wire-enable-and-upload.sh new file mode 100755 index 0000000..fd44646 --- /dev/null +++ b/scripts/taler-exchange/wire-enable-and-upload.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# One-shot: offline enable wire account + fees, upload to local exchange, verify /keys. +# Run as root inside container taler-hacktivism-exchange-ansible. +set -euo pipefail + +CONF=/etc/taler-exchange/taler-exchange.conf +OV=/etc/taler-exchange/exchange-overrides.conf +PAYTO='payto://x-taler-bank/bank.hacktivism.ch/exchange?receiver-name=GOA%20Exchange' +LOCAL_BASE='http://127.0.0.1:9011/' +OPS=/tmp/offline-wire-ops-$$.json +OFFLINE_USER=taler-exchange-offline + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root in exchange container" >&2 + exit 1 +fi + +echo "=== 1. Ensure wire account stanza in exchange-overrides ===" +if ! grep -q '\[exchange-account-1\]' "$OV" 2>/dev/null; then + cat >>"$OV" <<'EOF' + +### Regional bank (libeufin, x-taler-bank) — no IBAN +[exchange-account-1] +PAYTO_URI = payto://x-taler-bank/bank.hacktivism.ch/exchange?receiver-name=GOA%20Exchange +ENABLE_CREDIT = YES +ENABLE_DEBIT = YES +@inline-secret@ exchange-accountcredentials-1 ../secrets/exchange-accountcredentials-1.secret.conf +EOF + echo "appended exchange-account-1" +else + echo "exchange-account-1 already present" +fi + +# credentials must be readable by wire helpers + httpd +if [ -f /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf ]; then + chown root:taler-exchange-wire /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf 2>/dev/null \ + || chown root:root /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf + chmod 640 /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf +fi + +echo "=== 2. Point BASE_URL at local httpd for offline upload ===" +# save public URL +PUBLIC_BASE=$(taler-config -c "$CONF" -s exchange -o BASE_URL 2>/dev/null || echo 'https://exchange.hacktivism.ch/') +# temporary override file (highest priority if inlined last — patch OV) +if grep -q '^BASE_URL' "$OV"; then + sed -i "s|^BASE_URL = .*|BASE_URL = ${LOCAL_BASE}|" "$OV" +else + echo "BASE_URL = ${LOCAL_BASE}" >>"$OV" +fi +echo "BASE_URL now: $(taler-config -c "$CONF" -s exchange -o BASE_URL)" + +echo "=== 3. Sign enable-account + wire-fee + global-fee (as offline user) ===" +# global-fee: year, history, account, purse, purse_timeout, history_expiration, max_free_purses +runuser -u "$OFFLINE_USER" -- taler-exchange-offline -c "$CONF" \ + enable-account "$PAYTO" \ + wire-fee now x-taler-bank GOA:0 GOA:0 \ + global-fee now GOA:0 GOA:0 GOA:0 '1 day' '1 year' 5 \ + >"$OPS" +echo "ops bytes: $(wc -c <"$OPS")" +head -c 200 "$OPS"; echo + +echo "=== 4. Upload ops to exchange ===" +runuser -u "$OFFLINE_USER" -- taler-exchange-offline -c "$CONF" upload <"$OPS" +echo "upload exit: $?" + +echo "=== 5. Restore public BASE_URL ===" +sed -i "s|^BASE_URL = .*|BASE_URL = ${PUBLIC_BASE}|" "$OV" +# ensure trailing slash style +if ! grep -q "BASE_URL = https://exchange.hacktivism.ch" "$OV"; then + sed -i "s|^BASE_URL = .*|BASE_URL = https://exchange.hacktivism.ch/|" "$OV" +fi +echo "BASE_URL restored: $(taler-config -c "$CONF" -s exchange -o BASE_URL)" + +echo "=== 6. Probe /keys (may need httpd already running) ===" +for i in 1 2 3 4 5; do + code=$(curl -sS -m 8 -o /tmp/keys-out.json -w '%{http_code}' http://127.0.0.1:9011/keys || echo fail) + sz=$(wc -c /dev/null || echo 0) + echo "try $i: http=$code size=$sz" + if [ "$code" = "200" ] && [ "${sz:-0}" -gt 100 ]; then + echo "KEYS_OK" + head -c 250 /tmp/keys-out.json; echo + # show accounts if present + grep -oE '"payto_uri"[^,}]+|"master_public_key"[^,}]+' /tmp/keys-out.json | head -10 || true + exit 0 + fi + sleep 2 +done + +echo "KEYS_NOT_YET — check logs" +tail -30 /var/log/taler-exchange/taler-exchange-httpd-*.log 2>/dev/null | tail -30 +exit 1 diff --git a/scripts/taler-landing/deploy-landings.sh b/scripts/taler-landing/deploy-landings.sh new file mode 100644 index 0000000..fe3eb07 --- /dev/null +++ b/scripts/taler-landing/deploy-landings.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Install exchange + merchant landing pages inside containers and ensure nginx +# listens on 9014 (exchange) / 9015 (merchant). +# +# Port map (wire these in Caddy / podman -p): +# 9013 bank landing (already on taler-hacktivism-bank) +# 9014 exchange landing (taler-hacktivism-exchange-ansible) +# 9015 merchant landing (taler-hacktivism) +# +# Run on koopa (host) with podman access. +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +# allow override when files already on host /tmp +SRC_EX="${SRC_EX:-$ROOT/configs/exchange-landing}" +SRC_MER="${SRC_MER:-$ROOT/configs/merchant-landing}" +SRC_QR="${SRC_QR:-$ROOT/configs/bank-landing/qrcode.min.js}" + +C_EX=taler-hacktivism-exchange-ansible +C_MER=taler-hacktivism + +echo "=== exchange landing → $C_EX :9014 ===" +# nginx may be missing on exchange image +if ! podman exec "$C_EX" command -v nginx >/dev/null 2>&1; then + echo "installing nginx in $C_EX …" + podman exec "$C_EX" bash -c 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq nginx' +fi +podman exec "$C_EX" mkdir -p /var/www/exchange-landing /etc/nginx/sites-available /etc/nginx/sites-enabled +podman cp "$SRC_EX/index.html" "$C_EX:/var/www/exchange-landing/index.html" +if [ -f "$SRC_QR" ]; then + podman cp "$SRC_QR" "$C_EX:/var/www/exchange-landing/qrcode.min.js" +fi +podman cp "$SRC_EX/nginx-landing.conf" "$C_EX:/etc/nginx/sites-available/exchange-landing" +podman exec "$C_EX" bash -c ' + ln -sfn /etc/nginx/sites-available/exchange-landing /etc/nginx/sites-enabled/exchange-landing + # ensure sites-enabled is included + if ! grep -q sites-enabled /etc/nginx/nginx.conf 2>/dev/null; then + sed -i "/http {/a\\ include /etc/nginx/sites-enabled/*;" /etc/nginx/nginx.conf 2>/dev/null || true + fi + nginx -t + if command -v systemctl >/dev/null && systemctl is-system-running >/dev/null 2>&1; then + systemctl enable nginx 2>/dev/null || true + systemctl restart nginx || nginx + else + nginx -s reload 2>/dev/null || nginx + fi + ss -lntp | grep 9014 || netstat -lntp 2>/dev/null | grep 9014 || true +' + +echo "=== merchant landing → $C_MER :9015 ===" +podman exec "$C_MER" mkdir -p /var/www/merchant-landing +podman cp "$SRC_MER/index.html" "$C_MER:/var/www/merchant-landing/index.html" +podman cp "$SRC_MER/nginx-landing.conf" "$C_MER:/etc/nginx/sites-available/merchant-landing" +podman exec "$C_MER" bash -c ' + ln -sfn /etc/nginx/sites-available/merchant-landing /etc/nginx/sites-enabled/merchant-landing + nginx -t + nginx -s reload 2>/dev/null || systemctl reload nginx 2>/dev/null || true + ss -lntp | grep 9015 || true +' + +echo +echo "=== inside-container checks ===" +podman exec "$C_EX" curl -sS -m 3 -o /dev/null -w "exchange_landing=%{http_code}\n" http://127.0.0.1:9014/intro/ || true +podman exec "$C_MER" curl -sS -m 3 -o /dev/null -w "merchant_landing=%{http_code}\n" http://127.0.0.1:9015/intro/ || true + +echo +echo "PORTS TO WIRE (host → Caddy / firewall / podman -p):" +echo " 9013 bank landing (taler-hacktivism-bank) already published" +echo " 9014 exchange landing (taler-hacktivism-exchange-ansible) NEEDS -p 9014:9014" +echo " 9015 merchant landing (taler-hacktivism) NEEDS -p 9015:9015" +echo +echo "If host curl to :9014/:9015 fails, republish pasta ports (commit+replace) — see README." diff --git a/scripts/taler-merchant/README.md b/scripts/taler-merchant/README.md new file mode 100644 index 0000000..339d693 --- /dev/null +++ b/scripts/taler-merchant/README.md @@ -0,0 +1,56 @@ +# taler-merchant scripts + +Mirrored from podman `taler-hacktivism`. + +| File | Location in container | User | +|------|----------------------|------| +| `start_base_services_for_taler.sh` | `/root/` | root | +| `start_merchant.sh` | `/usr/local/bin/` | `taler-merchant-httpd` | +| `ensure_merchant_helpers.sh` | `/usr/local/bin/` | root → `taler-merchant-httpd` | +| `setup_credit_facade.sh` | host root (`/root/koopa-admin-log/…`) | root on koopa | +| `check_merchant-health.sh` | `/usr/local/bin/` | any | +| `certbot_renew.sh` | `/root/scripts/` | root (bg from base) | +| `stats--merchant-payments.sh` | `/usr/local/bin/` | ops | +| `taler-hacktivism-email-helper.sh` | `/usr/local/bin/` | merchant (SMTP password via env in git mirror) | +| `taler-hacktivism-sms-helper-wrapper.sh` | `/usr/local/bin/` | merchant | + +### Settlement (wired / transfers) + +Automatic import needs: + +1. **`credit_facade_url`** = `…/accounts/$BANK_USER/taler-revenue/` (not `taler-wire-gateway/`) +2. **Bearer** bank token in `credit_facade_credentials` (Basic fails on history) +3. Running **`taler-merchant-wirewatch`** + **`taler-merchant-depositcheck`** + +```bash +# on koopa as root +./setup_credit_facade.sh +./ensure_merchant_helpers.sh # inside container or via start_merchant +``` + +SMS backends are symlinks into `/var/taler-src/...` (not copied). + +## Usage + +```bash +# root in container +./start_base_services_for_taler.sh +# then as taler-merchant-httpd in /usr/local/bin: +./start_merchant.sh --restart +# nginx (TLS frontend) if not already up — root: +# /etc/init.d/nginx start +./check_merchant-health.sh +``` + +### `check_merchant-health.sh` + +| Check | Severity | +|-------|----------| +| socket + listener + httpd + nginx | FAIL | +| merchant `/config` (unix sock or `:9010`) | FAIL | +| each enabled `[merchant-exchange-*]`: `GET …/keys` | FAIL | +| `MASTER_KEY` matches `master_public_key` in `/keys` | FAIL | +| helpers (webhook, exchangekeyupdate, depositcheck) | WARN | + +Disabled exchanges (`DISABLED = YES`) are skipped. +If public exchange URL is unreachable from the container, falls back to `http://127.0.0.1:9011/keys` (and pasta host IPs). diff --git a/scripts/taler-merchant/certbot_renew.sh b/scripts/taler-merchant/certbot_renew.sh new file mode 100755 index 0000000..f34007f --- /dev/null +++ b/scripts/taler-merchant/certbot_renew.sh @@ -0,0 +1,5 @@ +#!/bin/sh +while true; do + certbot renew --quiet + sleep 12h +done diff --git a/scripts/taler-merchant/check_merchant-health.sh b/scripts/taler-merchant/check_merchant-health.sh new file mode 100755 index 0000000..18dfc24 --- /dev/null +++ b/scripts/taler-merchant/check_merchant-health.sh @@ -0,0 +1,222 @@ +#!/bin/bash +# Health check for manual taler-merchant (container taler-hacktivism). +# Style: check_exchange-health.sh — [OK] / [FAIL] / [WARN], exit 1 on critical fail. +# +# Keys checks: for each enabled [merchant-exchange-*] with EXCHANGE_BASE_URL, +# GET …/keys and (if set) verify MASTER_KEY appears in the response. + +CONF="${TALER_MERCHANT_CONFIG:-/etc/taler-merchant/taler-merchant.conf}" +OVERRIDES=/etc/taler-merchant/merchant-overrides.conf +SOCK="/var/run/taler-merchant/httpd/merchant-http.sock" +SS_BIN=$(command -v ss) +CURL_BIN=$(command -v curl) + +green() { echo -e "\e[32m$1\e[0m"; } +red() { echo -e "\e[31m$1\e[0m"; } +yellow() { echo -e "\e[33m$1\e[0m"; } + +fail=0 +ok() { green "[OK] $1"; } +bad() { red "[FAIL] $1"; fail=1; } +warn() { yellow "[WARN] $1"; } + +is_url() { + case "$1" in + http://*|https://*) return 0 ;; + *) return 1 ;; + esac +} + +# GET /keys; try primary URL then optional host-local fallbacks for same exchange. +# Writes body to $1 (path). Returns 0 on HTTP success with non-empty body. +fetch_keys() { + local out="$1" + local primary="$2" + shift 2 + local url + for url in "$primary" "$@"; do + [ -z "$url" ] && continue + if $CURL_BIN -skf -m 6 "$url" -o "$out" 2>/dev/null \ + || $CURL_BIN -sf -m 6 "$url" -o "$out" 2>/dev/null; then + if [ -s "$out" ] && grep -qE 'master_public_key|"currency"' "$out" 2>/dev/null; then + echo "$url" + return 0 + fi + fi + done + return 1 +} + +echo "=== Taler Merchant Health Check ===" + +# --- 1–4: local service --- +if [ -S "$SOCK" ]; then + ok "socket exists: $SOCK" +else + bad "socket does NOT exist: $SOCK" +fi + +if [ -n "$SS_BIN" ] && $SS_BIN -xl 2>/dev/null | grep -q "$SOCK"; then + ok "Merchant-HTTPD listening on socket" +elif [ -n "$SS_BIN" ]; then + bad "no listener on socket" +else + warn "ss not available — skip socket listener check" +fi + +if pgrep -f taler-merchant-httpd >/dev/null 2>&1; then + ok "process taler-merchant-httpd" +else + bad "process taler-merchant-httpd is NOT running" +fi + +if pgrep -f "nginx: master" >/dev/null 2>&1; then + ok "nginx master process" +else + bad "nginx master process is NOT running" +fi + +# --- 5: merchant /config --- +if [ -n "$CURL_BIN" ]; then + cfg_ok=0 + if [ -S "$SOCK" ] && $CURL_BIN -sf -m 3 --unix-socket "$SOCK" "http://localhost/config" >/dev/null 2>&1; then + ok "merchant /config via unix socket" + cfg_ok=1 + elif $CURL_BIN -skf -m 3 "https://127.0.0.1:9010/config" >/dev/null 2>&1; then + ok "merchant /config via https://127.0.0.1:9010/config" + cfg_ok=1 + elif $CURL_BIN -sf -m 3 "http://127.0.0.1:9010/config" >/dev/null 2>&1; then + ok "merchant /config via http://127.0.0.1:9010/config" + cfg_ok=1 + fi + [ "$cfg_ok" -eq 0 ] && bad "merchant /config unreachable (socket and :9010)" +else + warn "curl missing — skip /config" +fi + +# --- 6: exchange /keys for configured exchanges --- +echo "--- configured exchanges (/keys) ---" + +# Emit lines: SECTION|DISABLED|BASE|MASTER (from overrides + optional taler-config) +list_exchanges() { + # Prefer site overrides file (authoritative for this host) + if [ -f "$OVERRIDES" ]; then + awk ' + BEGIN { sec=""; dis="NO"; base=""; master="" } + /^\[merchant-exchange-/ { + if (sec != "") printf "%s|%s|%s|%s\n", sec, dis, base, master + sec=$0; gsub(/[\[\] \t\r]/, "", sec) + dis="NO"; base=""; master="" + next + } + /^\[/ { + if (sec != "") printf "%s|%s|%s|%s\n", sec, dis, base, master + sec=""; next + } + sec=="" { next } + /^[ \t]*#/ { next } + { + line=$0 + sub(/[ \t]*#.*$/, "", line) + if (match(line, /^[ \t]*DISABLED[ \t]*=[ \t]*/)) { + dis=substr(line, RSTART+RLENGTH); gsub(/^[ \t]+|[ \t]+$/, "", dis) + } else if (match(line, /^[ \t]*EXCHANGE_BASE_URL[ \t]*=[ \t]*/)) { + base=substr(line, RSTART+RLENGTH); gsub(/^[ \t]+|[ \t]+$/, "", base) + } else if (match(line, /^[ \t]*MASTER_KEY[ \t]*=[ \t]*/)) { + master=substr(line, RSTART+RLENGTH); gsub(/^[ \t]+|[ \t]+$/, "", master) + } + } + END { if (sec != "") printf "%s|%s|%s|%s\n", sec, dis, base, master } + ' "$OVERRIDES" + fi +} + +if [ -z "$CURL_BIN" ]; then + bad "curl missing — cannot check exchange /keys" +else + exch_count=0 + while IFS='|' read -r sec dis base master; do + [ -z "$sec" ] && continue + case "${dis:-NO}" in + YES|yes|true|True|1) + warn "exchange $sec: DISABLED — skip /keys" + continue + ;; + esac + if ! is_url "$base"; then + # package stubs without URL (e.g. kudos only DISABLED) + warn "exchange $sec: no EXCHANGE_BASE_URL — skip" + continue + fi + exch_count=$((exch_count + 1)) + base_slash="${base%/}/" + primary="${base_slash}keys" + keys_tmp=$(mktemp 2>/dev/null || echo "/tmp/m-keys-$$-${exch_count}.json") + + # Fallbacks when public name is not reachable from container: + # host loopback ports published by podman (exchange :9011). + got_url= + if got_url=$(fetch_keys "$keys_tmp" "$primary" \ + "http://127.0.0.1:9011/keys" \ + "http://host.containers.internal:9011/keys" \ + "http://10.0.2.2:9011/keys"); then + ok "exchange $sec: /keys reachable ($got_url)" + if [ -n "$master" ]; then + if grep -qF "$master" "$keys_tmp" 2>/dev/null; then + ok "exchange $sec: MASTER_KEY matches /keys" + else + mpks=$(grep -oE '"master_public_key"[[:space:]]*:[[:space:]]*"[^"]+"' "$keys_tmp" 2>/dev/null | head -2) + bad "exchange $sec: MASTER_KEY does not match /keys (config MASTER_KEY=$master)" + [ -n "$mpks" ] && warn " seen in /keys: $mpks" + fi + else + warn "exchange $sec: no MASTER_KEY in config — skip key match" + fi + else + bad "exchange $sec: no /keys from $primary (and local :9011 fallbacks)" + fi + rm -f "$keys_tmp" 2>/dev/null || true + done </dev/null 2>&1 +} + +# Settlement needs wirewatch + depositcheck; others needed for ops. +for p in \ + taler-merchant-webhook \ + taler-merchant-kyccheck \ + taler-merchant-wirewatch \ + taler-merchant-depositcheck \ + taler-merchant-exchangekeyupdate \ + taler-merchant-reconciliation +do + if live_helper "$p"; then + ok "process $p" + else + bad "process $p not running" + fi +done + +if [ "$fail" -eq 0 ]; then + green "=== ALL CRITICAL CHECKS PASSED ===" + exit 0 +fi +red "=== SOME CHECKS FAILED ===" +exit 1 diff --git a/scripts/taler-merchant/ensure_merchant_helpers.sh b/scripts/taler-merchant/ensure_merchant_helpers.sh new file mode 100755 index 0000000..b626dd4 --- /dev/null +++ b/scripts/taler-merchant/ensure_merchant_helpers.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Ensure merchant helper processes are running (no systemd). +# Prefer run as taler-merchant-httpd; root may use runuser. +# +# Usage: +# ensure_merchant_helpers.sh +# (as root) ensure_merchant_helpers.sh # re-exec as taler-merchant-httpd +set -euo pipefail + +CONF="${TALER_MERCHANT_CONFIG:-/etc/taler-merchant/taler-merchant.conf}" +LOG_DIR="${TALER_MERCHANT_LOG_DIR:-/var/log/taler-merchant}" + +# As root: start wirewatch supervisor (needs root for runuser), then re-exec as httpd. +if [ "$(id -un)" = "root" ]; then + mkdir -p "$LOG_DIR" + if [ -x /usr/local/bin/taler-merchant-wirewatch-supervise.sh ]; then + if ! ps -eo args= 2>/dev/null | grep -q 'taler-merchant-wirewatch-supervise\.sh'; then + echo "start: taler-merchant-wirewatch-supervise" + nohup /usr/local/bin/taler-merchant-wirewatch-supervise.sh \ + >>"$LOG_DIR/wirewatch-supervise.nohup" 2>&1 /dev/null || true + else + echo "already: taler-merchant-wirewatch-supervise" + fi + fi + exec runuser -u taler-merchant-httpd -- "$0" "$@" +fi + +if [ "$(id -un)" != "taler-merchant-httpd" ]; then + echo "run as taler-merchant-httpd or root" >&2 + exit 1 +fi + +mkdir -p "$LOG_DIR" +chmod 755 "$LOG_DIR" 2>/dev/null || true + +# Linux COMM is 15 chars — do not use pgrep -x for taler-merchant-wirewatch etc. +is_running() { + local bin="$1" + local base + base=$(basename "$bin") + ps -u "$(id -un)" -o args= 2>/dev/null | grep -qE "(^|/)[${base:0:1}]${base:1}( |$)" \ + || pgrep -u "$(id -un)" -f "(^|/)(${base})( |$)" >/dev/null 2>&1 +} + +start_one() { + local name="$1" + local bin="$2" + shift 2 + if is_running "$bin"; then + echo "already: $name" + return 0 + fi + echo "start: $name" + nohup "$bin" "$@" >>"$LOG_DIR/${name}.log" 2>&1 /dev/null || true + local i + for i in 1 2 3 4 5 6; do + sleep 0.4 + if is_running "$bin"; then + echo " ok: $name" + return 0 + fi + done + echo " FAIL: $name did not stay up (see $LOG_DIR/${name}.log)" >&2 + tail -20 "$LOG_DIR/${name}.log" 2>/dev/null || true + return 1 +} + +ec=0 +# Helpers needed for settlement / ops (not only httpd). +start_one taler-merchant-webhook /usr/bin/taler-merchant-webhook || ec=1 +start_one taler-merchant-kyccheck /usr/bin/taler-merchant-kyccheck || ec=1 +# Prefer already-running supervisor (started as root above); else bare wirewatch. +if ps -eo args= 2>/dev/null | grep -q 'taler-merchant-wirewatch-supervise\.sh'; then + echo "already: taler-merchant-wirewatch (via supervise)" +elif is_running /usr/bin/taler-merchant-wirewatch; then + echo "already: taler-merchant-wirewatch" +else + start_one taler-merchant-wirewatch /usr/bin/taler-merchant-wirewatch \ + -c "$CONF" -L INFO || ec=1 +fi +start_one taler-merchant-depositcheck /usr/bin/taler-merchant-depositcheck || ec=1 +start_one taler-merchant-exchangekeyupdate /usr/bin/taler-merchant-exchangekeyupdate || ec=1 +start_one taler-merchant-reconciliation /usr/bin/taler-merchant-reconciliation || ec=1 + +if ! is_running taler-merchant-httpd; then + echo "start: taler-merchant-httpd" + nohup /usr/bin/taler-merchant-httpd --log=info \ + >>"$LOG_DIR/taler-merchant-httpd-$(date +%Y-%m-%d).log" 2>&1 /dev/null || true + sleep 1 + is_running taler-merchant-httpd || ec=1 +fi + +echo "--- live merchant ---" +ps -eo pid,user,stat,etime,args 2>/dev/null \ + | grep taler-merchant | grep -vE 'grep| Z |ensure_merchant' || true + +exit "$ec" diff --git a/scripts/taler-merchant/install_dual_terms.sh b/scripts/taler-merchant/install_dual_terms.sh new file mode 100644 index 0000000..dbb464d --- /dev/null +++ b/scripts/taler-merchant/install_dual_terms.sh @@ -0,0 +1,232 @@ +#!/bin/bash +# Install dual-currency "No Formal Terms" ToS for the merchant backend. +# Run as root inside the merchant container (taler-hacktivism). +# +# Sets files under TERMS_DIR/en/ for TERMS_ETAG = merchant-tos-dual-v0 +# (see configs/taler-hacktivism/merchant-overrides.conf). +set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" + +ETAG="${TERMS_ETAG:-merchant-tos-dual-v2}" +CONF="${TALER_MERCHANT_CONFIG:-/etc/taler-merchant/taler-merchant.conf}" + +DATA_HOME="" +if command -v taler-config >/dev/null 2>&1; then + DATA_HOME=$(taler-config -c "$CONF" -f -s PATHS -o TALER_DATA_HOME 2>/dev/null || true) +fi +# common fallbacks +for d in \ + "$DATA_HOME" \ + /var/lib/taler-merchant/ \ + /var/lib/taler-merchant +do + [ -n "${d:-}" ] || continue + d="${d%/}/" + if [ -d "$d" ] || mkdir -p "$d" 2>/dev/null; then + DATA_HOME="$d" + break + fi +done +DATA_HOME="${DATA_HOME:-/var/lib/taler-merchant/}" +TERMS_DIR="${TERMS_DIR:-${DATA_HOME%/}/terms}" +LANG_DIR="$TERMS_DIR/en" +mkdir -p "$LANG_DIR" + +TITLE="No Formal Terms · Dual Currency Notice" + +BODY_MD='# No Formal Terms · Dual Currency Notice + +This is a **self-hosted GNU Taler merchant backend** at `taler.hacktivism.ch` (hacktivism.ch). + +**No formal terms of service** from Taler Operations AG (or any other third-party portal operator) apply to this instance. This short notice is the site policy for using the backend. + +## Dual currency + +This backend is configured for **two currencies at once**: + +- **GOA** — explorational / experimental currency of the local stack (`exchange.hacktivism.ch`, `bank.hacktivism.ch`). GOA is **not** legal tender. It has **no guaranteed real-world value**, redemption, or convertibility. +- **CHF** — Swiss francs, a **real** currency. CHF amounts are real money settled via the CHF exchange configured on this host (taler-ops / TOPS infrastructure as deployed). Treat CHF with the seriousness of ordinary payments. + +By creating a merchant instance, accepting payments, or otherwise using this service you acknowledge that: + +- GOA is for exploration and testing only. +- CHF involves real money — only use funds you control and can afford to risk on a self-hosted experimental stack. +- There is no guaranteed availability, support, or uptime. +- Operators may reset GOA state, change configuration, delete instance data, or interrupt service without notice. +- Software is provided as-is, without warranty. + +If you do not agree, do not use this merchant backend. + +## Related + +- Exchange (GOA): https://exchange.hacktivism.ch/terms +- Bank intro: https://bank.hacktivism.ch/intro/ +- Merchant intro: https://taler.hacktivism.ch/intro/ +' + +BODY_TXT='No Formal Terms · Dual Currency Notice + +This is a self-hosted GNU Taler merchant backend at taler.hacktivism.ch (hacktivism.ch). + +No formal terms of service from Taler Operations AG (or any other third-party portal operator) apply to this instance. This short notice is the site policy for using the backend. + +Dual currency +------------- +This backend is configured for two currencies at once: + +- GOA — explorational / experimental currency of the local stack + (exchange.hacktivism.ch, bank.hacktivism.ch). GOA is not legal tender. + It has no guaranteed real-world value, redemption, or convertibility. +- CHF — Swiss francs, a real currency. CHF amounts are real money + settled via the CHF exchange configured on this host (taler-ops / TOPS + infrastructure as deployed). Treat CHF with the seriousness of + ordinary payments. + +By creating a merchant instance, accepting payments, or otherwise using +this service you acknowledge that: + +- GOA is for exploration and testing only. +- CHF involves real money — only use funds you control and can afford + to risk on a self-hosted experimental stack. +- There is no guaranteed availability, support, or uptime. +- Operators may reset GOA state, change configuration, delete instance + data, or interrupt service without notice. +- Software is provided as-is, without warranty. + +If you do not agree, do not use this merchant backend. + +Related +------- +- Exchange (GOA): https://exchange.hacktivism.ch/terms +- Bank intro: https://bank.hacktivism.ch/intro/ +- Merchant intro: https://taler.hacktivism.ch/intro/ +' + +printf '%s\n' "$BODY_TXT" >"$LANG_DIR/${ETAG}.txt" +printf '%s\n' "$BODY_MD" >"$LANG_DIR/${ETAG}.md" + +# HTML (browser-friendly; style close to exchange short terms) +cat >"$LANG_DIR/${ETAG}.html" < + + + + + ${TITLE} + + + +
taler.hacktivism.ch · merchant
+

${TITLE}

+

This is a self-hosted GNU Taler merchant backend at + taler.hacktivism.ch (hacktivism.ch).

+

No formal terms of service from Taler Operations AG + (or any other third-party portal operator) apply to this instance. + This short notice is the site policy for using the backend.

+ +

Dual currency

+

This backend is configured for two currencies at once:

+
+
+ GOA · explorational + Local stack currency + (exchange, + bank). + Not legal tender. No guaranteed real-world value, redemption, or convertibility. +
+
+ CHF · real + Swiss francs. Real money, settled via the CHF exchange configured + on this host (taler-ops / TOPS infrastructure as deployed). + Treat CHF with the seriousness of ordinary payments. +
+
+ +

By using this service you acknowledge

+
    +
  • GOA is for exploration and testing only.
  • +
  • CHF involves real money — only use funds you control and can afford to risk on a self-hosted experimental stack.
  • +
  • There is no guaranteed availability, support, or uptime.
  • +
  • Operators may reset GOA state, change configuration, delete instance data, or interrupt service without notice.
  • +
  • Software is provided as-is, without warranty.
  • +
+

If you do not agree, do not use this merchant backend.

+ +

Related

+ + +

Privacy

+

Processing under Swiss FADP (revDSG). What data is retained + (instances, orders, deposits, logs, …) is listed on + /privacy.

+
Version ${ETAG} · hacktivism.ch
+ + +HTML + +# Optional PDF so Accept: */* / wallets preferring PDF still get content +PDF="$LANG_DIR/${ETAG}.pdf" +if command -v pandoc >/dev/null 2>&1; then + if pandoc -f markdown -t pdf -o "$PDF" "$LANG_DIR/${ETAG}.md" 2>/dev/null; then + echo "pdf via pandoc: $PDF" + elif command -v wkhtmltopdf >/dev/null 2>&1; then + wkhtmltopdf "$LANG_DIR/${ETAG}.html" "$PDF" 2>/dev/null && echo "pdf via wkhtmltopdf" || true + fi +elif command -v wkhtmltopdf >/dev/null 2>&1; then + wkhtmltopdf "$LANG_DIR/${ETAG}.html" "$PDF" 2>/dev/null && echo "pdf via wkhtmltopdf" || true +fi +# If no PDF toolchain: leave absent — httpd will serve md/html/txt by Accept + +chmod -R a+rX "$TERMS_DIR" +if id taler-merchant-httpd >/dev/null 2>&1; then + chown -R taler-merchant-httpd: "$TERMS_DIR" 2>/dev/null \ + || chown -R taler-merchant-httpd:www-data "$TERMS_DIR" 2>/dev/null \ + || true +fi + +echo "Installed under $LANG_DIR:" +ls -la "$LANG_DIR"/${ETAG}.* 2>/dev/null || ls -la "$LANG_DIR" +echo +echo "Config should set:" +echo " [merchant]" +echo " TERMS_ETAG = ${ETAG}" +echo " TERMS_DIR = \${TALER_DATA_HOME}terms/" +echo "Then restart taler-merchant-httpd." diff --git a/scripts/taler-merchant/install_swiss_privacy.sh b/scripts/taler-merchant/install_swiss_privacy.sh new file mode 100644 index 0000000..f2ca0d7 --- /dev/null +++ b/scripts/taler-merchant/install_swiss_privacy.sh @@ -0,0 +1,195 @@ +#!/bin/bash +# Install Swiss FADP privacy policy for merchant backend (taler.hacktivism.ch). +# Run as root inside taler-hacktivism. Sets files for PRIVACY_ETAG=merchant-pp-swiss-v0 +set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" + +ETAG="${PRIVACY_ETAG:-merchant-pp-swiss-v0}" +CONF="${TALER_MERCHANT_CONFIG:-/etc/taler-merchant/taler-merchant.conf}" +DATA_HOME="" +if command -v taler-config >/dev/null 2>&1; then + DATA_HOME=$(taler-config -c "$CONF" -f -s PATHS -o TALER_DATA_HOME 2>/dev/null || true) +fi +DATA_HOME="${DATA_HOME:-/var/lib/taler-merchant/}" +PRIVACY_DIR="${PRIVACY_DIR:-${DATA_HOME%/}/terms}" +LANG_DIR="$PRIVACY_DIR/en" +mkdir -p "$LANG_DIR" + +TITLE="Privacy notice · GOA/CHF Merchant · Swiss FADP" +BODY_TXT='Privacy notice — taler.hacktivism.ch merchant backend (Swiss FADP / revDSG) + +Controller: operators of the hacktivism.ch GNU Taler stack. +This dual-currency merchant backend accepts GOA (explorational) and CHF (via taler-ops exchange configuration). + +Data retained (precise): +1) Merchant instances — merchant_id, serial, creation metadata, config flags: lifetime of instance + up to 12 months after delete or stack wipe. +2) Authentication — API tokens / login tokens (hashed or bearer secrets as stored by software): until expiry, revoke, or instance delete. +3) Contract terms / orders — order_id, amount (currency:amount), summary (free text), paid/wired flags, creation time: for service lifetime; public landing stats may show anonymised aggregates and recent amounts/summaries. +4) Deposits & refunds — deposit proofs, refund amounts/reasons, timestamps: for service lifetime or until operational wipe. +5) Settlement accounts — payto URIs / account labels configured on instances: while configured. +6) Exchange interaction metadata — which exchange (GOA/CHF) was used for deposits: with related order records. +7) Technical logs — HTTP/access and application logs (IP, path, status): typically days–weeks via rotation. +8) Public stats.json — aggregate counts and recent activity without full account credentials: overwritten minutely. + +Not retained by this merchant backend: wallet private keys; full card PANs; unsolicited government ID documents unless an operator enables separate KYC tooling. + +Purposes: accept GNU Taler payments, refunds, settlement, abuse prevention, operations. +Legal basis (FADP): performance of service requested by merchant operators/customers; proportionate operation of a public experimental stack. +Recipients: configured exchanges (exchange.hacktivism.ch for GOA; exchange.taler-ops.ch for CHF as configured); host operators. No sale of data. +Rights: access, rectification, deletion, objection under FADP; complaint to Swiss FDPIC (EDÖB). +Security: TLS; experimental — no certified ISMS. Do not put sensitive personal data in order summaries. + +Related: https://taler.hacktivism.ch/terms · https://exchange.hacktivism.ch/privacy · https://bank.hacktivism.ch/intro/privacy.html +' + +BODY_MD='# Privacy notice · Merchant · Swiss FADP + +Controller: operators of the **hacktivism.ch** GNU Taler stack (`taler.hacktivism.ch`). +Dual currency: **GOA** (explorational) and **CHF** (taler-ops exchange path). + +Processing under the Swiss Federal Act on Data Protection (**FADP / revDSG**, since 1 Sep 2023). + +## Data retained + +| Data | Examples | Retention | +|------|----------|-----------| +| Instances | merchant_id, serial, config | Instance life + up to 12 months after delete/wipe | +| Auth | API / login tokens | Until expiry, revoke, or instance delete | +| Orders | order_id, amount, summary, paid/wired, time | Service life or wipe; public stats may show recent aggregates | +| Deposits / refunds | proofs, amounts, reasons | Service life or wipe | +| Settlement | payto / account labels | While configured | +| Exchange metadata | GOA/CHF exchange used | With related order records | +| Technical logs | IP, path, status | Days–weeks (rotation) | +| Public stats.json | Aggregates, recent activity | Overwritten continuously | + +**Not retained:** wallet private keys; card PANs; government ID unless separate KYC is enabled. + +## Purposes + +Accept payments, refunds, settlement; security and operations of this experimental backend. + +## Rights + +Access, correction, deletion, objection (FADP). Complaint: Swiss **FDPIC / EDÖB**. + +## Related + +- [Merchant terms](https://taler.hacktivism.ch/terms) +- [Exchange privacy](https://exchange.hacktivism.ch/privacy) +- [Bank privacy](https://bank.hacktivism.ch/intro/privacy.html) +' + +cat >"$LANG_DIR/${ETAG}.txt" <"$LANG_DIR/${ETAG}.md" <"$LANG_DIR/${ETAG}.html" <<'HTML' + + + + + + Privacy notice · GOA/CHF Merchant · Swiss FADP + + + +
taler.hacktivism.ch · privacy · CH
+

Privacy notice · GOA/CHF Merchant

+

+ Swiss Federal Act on Data Protection (FADP / revDSG, since 1 Sep 2023). + Dual-currency merchant backend at taler.hacktivism.ch: + GOA (explorational) and CHF (taler-ops path). +

+ +

1. Controller

+

Operators of the hacktivism.ch GNU Taler stack. Experimental public deployment; + no separate DPO appointed.

+ +

2. Data retained

+ + + + + + + + + + + + +
DataExamplesTypical retention
Instancesmerchant_id, serial, configInstance life + up to 12 months after delete/wipe
AuthenticationAPI / login tokensUntil expiry, revoke, or instance delete
Ordersorder_id, amount, summary, paid/wired, timeService life or wipe; public stats may list recent amounts/summaries
Deposits / refundsproofs, amounts, reasonsService life or wipe
Settlement accountspayto / account labelsWhile configured
Exchange metadataGOA/CHF exchange usedWith related order records
Technical logsIP, path, statusDays–weeks (rotation)
Public stats.jsonAggregates, recent activityOverwritten continuously
+

Not retained: wallet private keys; payment card PANs; government ID documents + unless a separate KYC feature is enabled by operators.

+ +

3. Purposes

+
    +
  • Accept GNU Taler payments (GOA and/or CHF), refunds, and settlement
  • +
  • Authenticate instance operators
  • +
  • Security, abuse prevention, debugging
  • +
+ +

4. Recipients

+
    +
  • Local GOA exchange (exchange.hacktivism.ch)
  • +
  • CHF exchange as configured (e.g. exchange.taler-ops.ch)
  • +
  • Host/infrastructure operators under this deployment
  • +
+

No sale of personal data.

+ +

5. Your rights

+

Access, rectification, deletion, and objection under the FADP (within legal limits). + Complaint: Swiss Federal Data Protection and Information Commissioner + (FDPIC / EDÖB).

+ +

Related

+ +
merchant-pp-swiss-v0 · Swiss FADP (revDSG)
+ + +HTML + +chmod -R a+rX "$PRIVACY_DIR" +if id taler-merchant-httpd >/dev/null 2>&1; then + chown -R taler-merchant-httpd: "$PRIVACY_DIR" 2>/dev/null \ + || chown -R taler-merchant-httpd:www-data "$PRIVACY_DIR" 2>/dev/null || true +fi +echo "ok privacy $ETAG -> $LANG_DIR" +ls -la "$LANG_DIR"/${ETAG}.* diff --git a/scripts/taler-merchant/landing-stats-merchant.sh b/scripts/taler-merchant/landing-stats-merchant.sh new file mode 100644 index 0000000..cee1bc7 --- /dev/null +++ b/scripts/taler-merchant/landing-stats-merchant.sh @@ -0,0 +1,439 @@ +#!/bin/bash +# Run INSIDE taler-hacktivism. Writes /var/www/merchant-landing/stats.json +# +# Current taler-merchant schema: +# merchant.merchant_instances → merchant_serial, merchant_id +# merchant_instance_.* → per-instance tables +# +# IMPORTANT: +# - cron uses a minimal PATH. Without /usr/sbin, runuser is missing and +# every query used to fail silently → all zeros (and overwrote good data). +# - Never write stats.json on failure: leave the previous good file in place. +# - Avoid: psql -F$'\t' -v ON_ERROR_STOP=… (if -F loses the tab arg, -v is +# eaten as fieldsep and ON_ERROR_STOP becomes the *username*). +set -euo pipefail +export TZ="${TZ:-Europe/Zurich}" +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" +LANDING_DIR="${LANDING_DIR:-/var/www/merchant-landing}" +OUT="$LANDING_DIR/stats.json" +RUN="$LANDING_DIR/stats-run.json" +TMP="${OUT}.tmp.$$" +DB="${MERCHANT_DB:-taler-merchant}" +ACTIVITY_LIMIT="${ACTIVITY_LIMIT:-12}" +ERRLOG="${LANDING_STATS_ERRLOG:-/var/log/landing-stats-merchant.err}" +mkdir -p "$LANDING_DIR" + +now_iso() { date +%Y-%m-%dT%H:%M%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/'; } +now_human() { date +"%Y-%m-%d %H:%M %Z"; } +json_str() { + printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n\r\t' ' ' | sed 's/ */ /g')" +} + +# Public run status for the intro page (never wipe stats.json on failure). +write_run() { + local ok_json="$1" msg="${2:-}" + cat >"$RUN" <&2 + rm -f "$TMP" /tmp/merch_cur_$$.tsv /tmp/merch_act_$$.tsv 2>/dev/null || true + exit 1 +} + +as_postgres() { + if command -v runuser >/dev/null 2>&1; then + runuser -u postgres -- "$@" + elif command -v su >/dev/null 2>&1; then + su -s /bin/bash postgres -c "$*" + else + return 127 + fi +} + +# pipe SQL on stdin — never -f on root-owned temps (postgres cannot read them) +psql_pipe() { + local fs=$'\t' ec + set +e + as_postgres psql -d "$DB" -At -F"$fs" 2>>"$ERRLOG" + ec=$? + set -e + return "$ec" +} + +# Required single-value query. Empty/fail → abort (no site write). +psqlq() { + local sql="$1" out ec + set +e + out=$(as_postgres psql -d "$DB" -At -c "$sql" 2>>"$ERRLOG") + ec=$? + set -e + if [ "$ec" -ne 0 ]; then + abort "psql failed (ec=$ec): ${sql:0:120}" + fi + # strip trailing newlines only + printf '%s' "$out" | tr -d '\r' +} + +# "64.03" or "64.03000001" → clean CUR:value using decimal string (no binary float) +fmt_cur_num() { + local c="$1" n="$2" + awk -v c="$c" -v n="$n" 'BEGIN{ + gsub(/ /,"",n) + if (n == "" || n+0 == 0 && n !~ /[1-9]/) { printf "%s:0", c; exit } + if (n ~ /[eE]/) { + x = n+0 + s = sprintf("%.8f", x) + } else { + s = n + } + if (s ~ /\./) { + split(s, a, ".") + whole = a[1]; frac = substr(a[2] "00000000", 1, 8) + extra = substr(a[2], 9, 1) + if (extra != "" && extra+0 >= 5) { + f = frac+0 + 1 + if (f >= 100000000) { whole = whole+1; f = f - 100000000 } + frac = sprintf("%08d", f) + } + sub(/0+$/, "", frac) + if (frac == "") printf "%s:%s", c, whole + else printf "%s:%s.%s", c, whole, frac + } else { + printf "%s:%s", c, s + } + }' +} + +# --- preflight: tools + DB reachability (fail closed) --- +command -v psql >/dev/null 2>&1 || abort "psql not in PATH ($PATH)" +command -v runuser >/dev/null 2>&1 || command -v su >/dev/null 2>&1 || abort "neither runuser nor su in PATH" + +PROBE=$(psqlq "SELECT 1;") +[ "$PROBE" = "1" ] || abort "postgres not reachable (SELECT 1 → '$PROBE')" + +SERIALS=$(psqlq "SELECT merchant_serial::text FROM merchant.merchant_instances ORDER BY merchant_serial;") +# If table exists but we got nothing, still ok (empty install). If table missing, psqlq aborted. + +# Build list of existing instance schemas +SCHEMAS="" +INSTANCES=0 +while read -r serial; do + [ -z "$serial" ] && continue + # only pure integers + [[ "$serial" =~ ^[0-9]+$ ]] || abort "bad merchant_serial='$serial'" + sch="merchant_instance_${serial}" + exists=$(psqlq "SELECT 1 FROM pg_namespace WHERE nspname = '${sch}';") + if [ "$exists" != "1" ]; then + # schema lag — skip, do not abort (instance row without schema yet) + continue + fi + INSTANCES=$((INSTANCES + 1)) + SCHEMAS="${SCHEMAS}${SCHEMAS:+ }$sch:$serial" +done <<<"$SERIALS" + +# Sanity: if instance schemas exist in PG but SERIALS empty → query path broken +NS_COUNT=$(psqlq "SELECT count(*)::text FROM pg_namespace WHERE nspname LIKE 'merchant_instance_%';") +NS_COUNT=$(printf '%s' "$NS_COUNT" | tr -d '[:space:]') +if [ "${NS_COUNT:-0}" -gt 0 ] && [ "$INSTANCES" -eq 0 ]; then + abort "pg has ${NS_COUNT} merchant_instance_* schemas but resolved INSTANCES=0 (query/PATH bug)" +fi + +# --- dual-currency aggregation entirely in PostgreSQL (numeric) --- +{ + echo "SELECT" + echo " coalesce(nullif(split_part(c.contract_terms->>'amount', ':', 1), ''), '?') AS currency," + echo " count(*)::bigint," + echo " count(*) FILTER (WHERE c.paid)::bigint," + echo " count(*) FILTER (WHERE NOT c.paid)::bigint," + echo " count(*) FILTER (WHERE c.wired)::bigint," + echo " round(coalesce(sum(NULLIF(split_part(c.contract_terms->>'amount', ':', 2), '')::numeric), 0), 8)::text," + echo " round(coalesce(sum(NULLIF(split_part(c.contract_terms->>'amount', ':', 2), '')::numeric) FILTER (WHERE c.paid), 0), 8)::text" + echo "FROM (" + first=1 + for item in $SCHEMAS; do + sch=${item%%:*} + if [ "$first" = 1 ]; then first=0; else echo " UNION ALL "; fi + echo "SELECT contract_terms, paid, wired FROM ${sch}.merchant_contract_terms" + done + if [ "$first" = 1 ]; then + echo "SELECT NULL::jsonb AS contract_terms, false AS paid, false AS wired WHERE false" + fi + echo ") c" + echo "WHERE c.contract_terms ? 'amount'" + echo "GROUP BY 1 ORDER BY 1;" +} | psql_pipe >"/tmp/merch_cur_$$.tsv" || abort "currency aggregate query failed" +CUR_TSV=$(cat "/tmp/merch_cur_$$.tsv" 2>/dev/null || true) +rm -f "/tmp/merch_cur_$$.tsv" + +TOTAL_CONTRACTS=0; TOTAL_PAID=0; TOTAL_WIRED=0; TOTAL_UNPAID=0 +declare -A C_CONTRACTS C_PAID C_UNPAID C_WIRED C_AMT C_AMT_PAID + +while IFS=$'\t' read -r cur contracts paid unpaid wired amt amt_paid; do + [ -z "${cur:-}" ] && continue + TOTAL_CONTRACTS=$((TOTAL_CONTRACTS + contracts)) + TOTAL_PAID=$((TOTAL_PAID + paid)) + TOTAL_UNPAID=$((TOTAL_UNPAID + unpaid)) + TOTAL_WIRED=$((TOTAL_WIRED + wired)) + C_CONTRACTS[$cur]=$contracts + C_PAID[$cur]=$paid + C_UNPAID[$cur]=$unpaid + C_WIRED[$cur]=$wired + C_AMT[$cur]=$amt + C_AMT_PAID[$cur]=$amt_paid +done <<<"$CUR_TSV" + +# deposits / refunds +DEPOSITS=0; REFUNDS=0 +for item in $SCHEMAS; do + sch=${item%%:*} + d=$(psqlq "SELECT count(*)::text FROM ${sch}.merchant_deposits;") + r=$(psqlq "SELECT count(*)::text FROM ${sch}.merchant_refunds;") + d=$(printf '%s' "$d" | tr -d '[:space:]') + r=$(printf '%s' "$r" | tr -d '[:space:]') + [[ "$d" =~ ^[0-9]+$ ]] || abort "bad deposits count for $sch: '$d'" + [[ "$r" =~ ^[0-9]+$ ]] || abort "bad refunds count for $sch: '$r'" + DEPOSITS=$((DEPOSITS + d)) + REFUNDS=$((REFUNDS + r)) +done + +# --- recent activity: 5 latest events per currency (GOA + CHF), payments + refunds --- +ACT_PER_CURRENCY="${ACT_PER_CURRENCY:-5}" + +# Query latest ACT_PER_CURRENCY events for one currency code (payments ∪ refunds). +# Writes TSV rows: ts kind order_id amount summary status +query_activity_for_currency() { + local cur="$1" + local out="$2" + { + echo "SELECT * FROM (" + echo "SELECT * FROM (" + first=1 + for item in $SCHEMAS; do + sch=${item%%:*} + if [ "$first" = 1 ]; then first=0; else echo " UNION ALL "; fi + cat <>'amount','') AS amount, + left(translate(coalesce(contract_terms->>'summary',''), E'\t\n\r', ' '), 64) AS summary, + CASE WHEN wired THEN 'wired' ELSE 'paid' END AS status +FROM ${sch}.merchant_contract_terms +WHERE paid + AND upper(split_part(coalesce(contract_terms->>'amount',''), ':', 1)) = upper('${cur}') +SQL + done + if [ "$first" = 1 ]; then + echo "SELECT 0::bigint AS ts, ''::text AS kind, ''::text AS order_id, ''::text AS amount, ''::text AS summary, ''::text AS status WHERE false" + fi + echo " UNION ALL " + first=1 + for item in $SCHEMAS; do + sch=${item%%:*} + if [ "$first" = 1 ]; then first=0; else echo " UNION ALL "; fi + cat <"$out" || abort "activity query failed for $cur" +} + +# Build JSON array of activity items from a TSV file +activity_tsv_to_json() { + local tsv_file="$1" + local json="[" + local af=1 + local ts kind oid amt sum st sec human_fmt iso + while IFS=$'\t' read -r ts kind oid amt sum st; do + [ -z "${ts:-}" ] && continue + [ "$ts" = "0" ] && [ -z "$kind" ] && continue + sec=$(awk -v t="$ts" 'BEGIN{printf "%d", int(t/1000000)}') + human_fmt=$(date -d "@${sec}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$sec") + iso=$(date -d "@${sec}" +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/' || true) + if [ "$af" = 1 ]; then af=0; else json="${json},"; fi + json="${json} + { + \"ts_us\": ${ts:-0}, + \"ts\": $(json_str "$iso"), + \"ts_human\": $(json_str "$human_fmt"), + \"kind\": $(json_str "$kind"), + \"order_id\": $(json_str "$oid"), + \"amount\": $(json_str "$amt"), + \"summary\": $(json_str "$sum"), + \"status\": $(json_str "$st") + }" + done <"$tsv_file" + json="${json} + ]" + printf '%s' "$json" +} + +query_activity_for_currency GOA "/tmp/merch_act_goa_$$.tsv" +query_activity_for_currency CHF "/tmp/merch_act_chf_$$.tsv" +ACT_GOA_JSON=$(activity_tsv_to_json "/tmp/merch_act_goa_$$.tsv") +ACT_CHF_JSON=$(activity_tsv_to_json "/tmp/merch_act_chf_$$.tsv") +rm -f "/tmp/merch_act_goa_$$.tsv" "/tmp/merch_act_chf_$$.tsv" + +# Flat recent_activity: GOA then CHF (compat; landing prefers by_currency) +ACT_JSON="[" +af=1 +for block in "$ACT_GOA_JSON" "$ACT_CHF_JSON"; do + # strip outer [ ] and inject if non-empty + inner=$(printf '%s' "$block" | sed '1s/^\s*\[//; $s/\]\s*$//') + # empty array → skip + if printf '%s' "$inner" | grep -q '"kind"'; then + if [ "$af" = 1 ]; then af=0; else ACT_JSON="${ACT_JSON},"; fi + ACT_JSON="${ACT_JSON}${inner}" + fi +done +ACT_JSON="${ACT_JSON} + ]" + +ACT_BY_CUR_JSON="[ + { + \"currency\": \"GOA\", + \"limit\": ${ACT_PER_CURRENCY}, + \"items\": ${ACT_GOA_JSON} + }, + { + \"currency\": \"CHF\", + \"limit\": ${ACT_PER_CURRENCY}, + \"items\": ${ACT_CHF_JSON} + } + ]" + +# by_currency JSON — GOA then CHF then rest +CUR_JSON="[" +cf=1 +emit_cur() { + local cur="$1" + [ -n "${C_CONTRACTS[$cur]+x}" ] || return 1 + local amt_fmt paid_fmt + amt_fmt=$(fmt_cur_num "$cur" "${C_AMT[$cur]}") + paid_fmt=$(fmt_cur_num "$cur" "${C_AMT_PAID[$cur]}") + if [ "$cf" = 1 ]; then cf=0; else CUR_JSON="${CUR_JSON},"; fi + CUR_JSON="${CUR_JSON} + { + \"currency\": $(json_str "$cur"), + \"contracts\": ${C_CONTRACTS[$cur]:-0}, + \"paid\": ${C_PAID[$cur]:-0}, + \"unpaid\": ${C_UNPAID[$cur]:-0}, + \"wired\": ${C_WIRED[$cur]:-0}, + \"amount_sum\": $(json_str "$amt_fmt"), + \"amount_paid_sum\": $(json_str "$paid_fmt") + }" +} +emit_cur GOA || true +emit_cur CHF || true +for cur in "${!C_CONTRACTS[@]}"; do + case "$cur" in GOA|CHF) continue ;; esac + emit_cur "$cur" || true +done +CUR_JSON="${CUR_JSON} + ]" + +GEN=$(now_iso) +HUMAN=$(now_human) + +# Live performance probes (exchange-style) +measure_ms() { + local url="$1" t + t=$(curl -skS -o /dev/null -m 8 -w '%{time_total}' "$url" 2>/dev/null || echo "") + [ -z "$t" ] && { echo "null"; return; } + awk -v t="$t" 'BEGIN{printf "%d", (t+0)*1000}' +} +num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; } +MERCHANT_PUBLIC_BASE="${MERCHANT_PUBLIC_URL:-https://taler.hacktivism.ch}" +MERCHANT_LOCAL="${MERCHANT_LOCAL_URL:-https://127.0.0.1:9010}" +CONFIG_MS=$(measure_ms "${MERCHANT_PUBLIC_BASE}/config") +CONFIG_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' "${MERCHANT_PUBLIC_BASE}/config" 2>/dev/null || echo "000") +if [ "$CONFIG_HTTP" != "200" ]; then + CONFIG_MS=$(measure_ms "${MERCHANT_LOCAL}/config") + CONFIG_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' "${MERCHANT_LOCAL}/config" 2>/dev/null || echo "000") +fi +TERMS_MS=$(measure_ms "${MERCHANT_PUBLIC_BASE}/terms") +TERMS_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' -H "Accept: text/html" "${MERCHANT_PUBLIC_BASE}/terms" 2>/dev/null || echo "000") +WEBUI_MS=$(measure_ms "${MERCHANT_PUBLIC_BASE}/webui/") +WEBUI_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' "${MERCHANT_PUBLIC_BASE}/webui/" 2>/dev/null || echo "000") +LOADAVG="" +[ -r /proc/loadavg ] && LOADAVG=$(awk '{print $1","$2","$3}' /proc/loadavg) + +MEM_JSON='"container_rss_human": "—"' +MEM_HELPER="${MEM_HELPER:-/usr/local/lib/landing-mem-snapshot.sh}" +if [ -f "$MEM_HELPER" ]; then + # shellcheck disable=SC1090 + . "$MEM_HELPER" + mem_snapshot_json || true +fi + +# Atomic write only after full success +cat >"$TMP" <", + "dual_currency": true, + "currencies_note": "CHF (taler-ops) + GOA (hacktivism)", + "timezone": $(json_str "$TZ"), + "generated_at": $(json_str "$GEN"), + "generated_at_human": $(json_str "$HUMAN"), + "instances": ${INSTANCES:-0}, + "orders": ${TOTAL_CONTRACTS:-0}, + "paid": ${TOTAL_PAID:-0}, + "unpaid": ${TOTAL_UNPAID:-0}, + "wired": ${TOTAL_WIRED:-0}, + "deposits": ${DEPOSITS:-0}, + "refunds": ${REFUNDS:-0}, + "by_currency": $CUR_JSON, + "recent_activity_limit_per_currency": ${ACT_PER_CURRENCY}, + "recent_activity_by_currency": $ACT_BY_CUR_JSON, + "recent_activity": $ACT_JSON, + "performance": { + "config_http": $(json_str "$CONFIG_HTTP"), + "config_ms": $(num_or_null "$CONFIG_MS"), + "terms_http": $(json_str "$TERMS_HTTP"), + "terms_ms": $(num_or_null "$TERMS_MS"), + "webui_http": $(json_str "$WEBUI_HTTP"), + "webui_ms": $(num_or_null "$WEBUI_MS"), + "loadavg": $(json_str "${LOADAVG:-}"), + "memory": { +${MEM_JSON} + } + } +} +EOF + +# basic JSON sanity before publish +grep -q '"ok": true' "$TMP" || abort "tmp json missing ok:true" +mv -f "$TMP" "$OUT" +write_run true +echo "ok merchant instances=$INSTANCES orders=$TOTAL_CONTRACTS paid=$TOTAL_PAID refunds=$REFUNDS -> $OUT" diff --git a/scripts/taler-merchant/setup_credit_facade.sh b/scripts/taler-merchant/setup_credit_facade.sh new file mode 100755 index 0000000..3ab0403 --- /dev/null +++ b/scripts/taler-merchant/setup_credit_facade.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Configure merchant bank account credit facade for automatic settlement import. +# +# Root cause (2026-07-09): merchant wirewatch must call the **Taler Revenue API** +# (`…/taler-revenue/`), not the exchange wire-gateway (`…/taler-wire-gateway/`). +# On this bank, history endpoints accept **Bearer** tokens only (Basic → 401). +# +# Usage (as root on koopa host): +# setup_credit_facade.sh +# MERCHANT_INSTANCE=goa-demo-cp4zqk setup_credit_facade.sh +# BANK_USER=… BANK_PW_FILE=… MERCHANT_PW_FILE=… setup_credit_facade.sh +# +# Effects: +# - PATCH /instances/$INST/private/accounts/$H_WIRE with credit_facade_* +# - long-lived refreshable bank token (1y) +# - restarts taler-merchant-wirewatch (once, after facade is set) +set -euo pipefail + +INST="${MERCHANT_INSTANCE:-goa-demo-cp4zqk}" +BANK_USER="${BANK_USER:-$INST}" +MERCHANT_PW_FILE="${MERCHANT_PW_FILE:-/root/merchant-${INST}-password.txt}" +BANK_PW_FILE="${BANK_PW_FILE:-/root/bank-${BANK_USER}-password.txt}" +MER_URL="${MERCHANT_URL:-https://127.0.0.1:9010}" +BANK_URL="${BANK_URL:-http://127.0.0.1:9012}" +PUBLIC_BANK="${PUBLIC_BANK_BASE:-https://bank.hacktivism.ch}" +FACADE_URL="${CREDIT_FACADE_URL:-${PUBLIC_BANK}/accounts/${BANK_USER}/taler-revenue/}" + +if [ "$(id -un)" != "root" ]; then + echo "run as root on koopa host" >&2 + exit 1 +fi +if [ ! -r "$MERCHANT_PW_FILE" ] || [ ! -r "$BANK_PW_FILE" ]; then + echo "need readable $MERCHANT_PW_FILE and $BANK_PW_FILE" >&2 + exit 1 +fi + +MPW=$(tr -d '\n' <"$MERCHANT_PW_FILE") +BPW=$(tr -d '\n' <"$BANK_PW_FILE") +AUTH="Authorization: Bearer secret-token:${MPW}" + +echo "=== bank token for $BANK_USER ===" +TOK=$(curl -sS -u "${BANK_USER}:${BPW}" -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite","refreshable":true,"duration":{"d_us":31536000000000}}' \ + "${BANK_URL}/accounts/${BANK_USER}/token" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') +echo "tok_len=${#TOK}" + +echo "=== verify revenue history ===" +code=$(curl -sS -m 15 -o /tmp/rev-check.json -w "%{http_code}" \ + -H "Authorization: Bearer ${TOK}" \ + "${BANK_URL}/accounts/${BANK_USER}/taler-revenue/history?delta=-3") +echo "revenue_history_http=$code" +python3 -c 'import json;d=json.load(open("/tmp/rev-check.json"));print("incoming",len(d.get("incoming_transactions")or[]))' +[ "$code" = "200" ] || { echo "FAIL revenue history"; exit 1; } + +echo "=== PATCH credit_facade ($FACADE_URL) ===" +H_WIRE=$(curl -sk -H "$AUTH" "${MER_URL}/instances/${INST}/private/accounts" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["accounts"][0]["h_wire"])') +export FACADE_URL TOK +BODY=$(python3 - <<'PY' +import json, os +print(json.dumps({ + "credit_facade_url": os.environ["FACADE_URL"], + "credit_facade_credentials": {"type": "bearer", "token": os.environ["TOK"]}, +})) +PY +) +curl -sk -X PATCH -H "$AUTH" -H 'Content-Type: application/json' -d "$BODY" \ + "${MER_URL}/instances/${INST}/private/accounts/${H_WIRE}" \ + -w "PATCH_HTTP=%{http_code}\n" -o /dev/null + +echo "=== restart wirewatch in merchant container ===" +su - hernani -c 'podman exec taler-hacktivism bash -c " +set +e +for p in \$(ps -eo pid=,args= | awk \"\\\$0 ~ /\\/usr\\/bin\\/taler-merchant-wirewatch/ {print \\\$1}\"); do + kill \$p 2>/dev/null || true +done +sleep 1 +runuser -u taler-merchant-httpd -- nohup /usr/bin/taler-merchant-wirewatch \ + -c /etc/taler-merchant/taler-merchant.conf -L INFO \ + >>/var/log/taler-merchant/wirewatch.log 2>&1 & +sleep 2 +ps -eo pid,etime,args | grep -E \"[t]aler-merchant-wirewatch\" || echo FAIL_no_wirewatch +tail -8 /var/log/taler-merchant/wirewatch.log +"' + +echo "=== private/transfers (sample) ===" +curl -sk -H "$AUTH" "${MER_URL}/instances/${INST}/private/transfers" \ + | python3 -c 'import sys,json;d=json.load(sys.stdin);t=d.get("transfers")or[];print("transfers",len(t))' + +echo OK_credit_facade diff --git a/scripts/taler-merchant/start_base_services_for_taler.sh b/scripts/taler-merchant/start_base_services_for_taler.sh new file mode 100755 index 0000000..ff26c8d --- /dev/null +++ b/scripts/taler-merchant/start_base_services_for_taler.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# Root: base services for manual merchant (no systemd). +# Pattern (all three stacks): root base → shell as service user → start_*.sh +# +# Container: taler-hacktivism +# Then as taler-merchant-httpd: /usr/local/bin/start_merchant.sh [--restart] +# +# Usage: +# /root/start_base_services_for_taler.sh # interactive shell as service user +# /root/start_base_services_for_taler.sh --no-shell # base only (automation) + +set -e + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root" >&2 + exit 1 +fi + +NO_SHELL=0 +for arg in "$@"; do + case "$arg" in + --no-shell|-n) NO_SHELL=1 ;; + --help|-h) + echo "Usage: $0 [--no-shell]" + exit 0 + ;; + esac +done + +PWD_BIN="/usr/local/bin" +MERCHANT_STARTER="start_merchant.sh" +LOG_DIR=/var/log/taler-merchant +RUN_DIR=/var/run/taler-merchant/httpd + +# --- Debian postgresql defaults (pg_createcluster layout) --- +ensure_postgresql() { + echo " Debian perms on /etc/postgresql + data/log/run..." + if [ -d /etc/postgresql ]; then + chown -R root:postgres /etc/postgresql + find /etc/postgresql -type d -exec chmod 755 {} \; + find /etc/postgresql -type f -name '*.conf' -exec chmod 640 {} \; + fi + chown -R postgres:postgres /var/lib/postgresql /var/log/postgresql 2>/dev/null || true + mkdir -p /var/run/postgresql + chown postgres:postgres /var/run/postgresql + # Debian package uses setgid sticky on run dir + chmod 2775 /var/run/postgresql 2>/dev/null || chmod 775 /var/run/postgresql + + if pg_isready -q 2>/dev/null; then + echo " already accepting connections" + pg_isready || true + return 0 + fi + + # Stale socket lock only when not accepting + rm -f /var/run/postgresql/.s.PGSQL.*.lock 2>/dev/null || true + if ! pgrep -u postgres -x postgres >/dev/null 2>&1; then + rm -f /var/lib/postgresql/*/main/postmaster.pid 2>/dev/null || true + fi + + if command -v pg_ctlcluster >/dev/null 2>&1 && command -v pg_lsclusters >/dev/null 2>&1; then + while read -r ver name _rest; do + [ -n "$ver" ] || continue + echo " pg_ctlcluster $ver $name start" + pg_ctlcluster "$ver" "$name" start 2>/dev/null || true + done < <(pg_lsclusters --no-header 2>/dev/null || true) + fi + if ! pg_isready -q 2>/dev/null; then + if [ -x /etc/init.d/postgresql ]; then + /etc/init.d/postgresql start || true + else + service postgresql start || true + fi + fi + sleep 1 + pg_isready || true +} + +echo "Start certbot renewal (background)..." +if [ -x /root/scripts/certbot_renew.sh ]; then + /root/scripts/certbot_renew.sh & +elif [ -x ./scripts/certbot_renew.sh ]; then + ./scripts/certbot_renew.sh & +fi + +echo "Create log + runtime dirs... permissions for taler-merchant-httpd / www-data:" +mkdir -p "$LOG_DIR" /var/run/taler-merchant "$RUN_DIR" +chown taler-merchant-httpd: "$LOG_DIR" +chmod 755 "$LOG_DIR" +chown taler-merchant-httpd:www-data /var/run/taler-merchant "$RUN_DIR" +chmod 755 /var/run/taler-merchant "$RUN_DIR" +# merchant app data (package default home) +if [ -d /var/lib/taler-merchant ]; then + chown -R taler-merchant-httpd:www-data /var/lib/taler-merchant 2>/dev/null || true +fi + +echo "Start base services needed for Taler Merchant." +echo "" + +if [ -f /root/.taler-secrets-env ]; then + echo -n "Read secrets needed for SMS delivery:" + set -a + # shellcheck disable=SC1091 + source /root/.taler-secrets-env && echo " OK" + set +a +else + echo "No /root/.taler-secrets-env (SMS may fail)" +fi + +echo "1. postgresql:" +ensure_postgresql + +echo "2. nginx:" +if [ -x /etc/init.d/nginx ]; then + /etc/init.d/nginx start 2>/dev/null || nginx || true +else + service nginx start 2>/dev/null || nginx || true +fi + +if [ "$NO_SHELL" -eq 1 ]; then + echo "Base services started (--no-shell). Next: runuser -u taler-merchant-httpd -- $PWD_BIN/$MERCHANT_STARTER [--restart]" + exit 0 +fi + +echo "3. Switching now to user taler-merchant-httpd, in $PWD_BIN; find executable $MERCHANT_STARTER there!" +echo "" +cd "$PWD_BIN" +# util-linux: -u and -s/--shell are mutually exclusive +exec runuser -u taler-merchant-httpd -- env \ + CLICKSEND_API_KEY="${CLICKSEND_API_KEY:-}" \ + CLICKSEND_USERNAME="${CLICKSEND_USERNAME:-}" \ + TELESIGN_AUTH_TOKEN="${TELESIGN_AUTH_TOKEN:-}" \ + bash diff --git a/scripts/taler-merchant/start_merchant.sh b/scripts/taler-merchant/start_merchant.sh new file mode 100755 index 0000000..a6d1d90 --- /dev/null +++ b/scripts/taler-merchant/start_merchant.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# Start / restart taler-merchant (manual, no systemd). +# Run as: taler-merchant-httpd +# +# Usage: +# start_merchant.sh +# start_merchant.sh --restart | -r +# start_merchant.sh --help + +set -u + +usage() { + cat <<'EOF' +Usage: start_merchant.sh [--restart|-r] [--help|-h] + + (default) Start merchant helpers + httpd. + --restart Stop live taler-merchant-* daemons, then start cleanly. + Does not touch postgres/nginx. +EOF +} + +DO_RESTART=0 +for arg in "$@"; do + case "$arg" in + --restart|-r) DO_RESTART=1 ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown option: $arg" >&2; usage >&2; exit 2 ;; + esac +done + +if [ "$(id -un)" != "taler-merchant-httpd" ]; then + echo "This script must be run as user taler-merchant-httpd" >&2 + exit 1 +fi + +list_merchant_pids() { + ps -eo pid=,stat=,args= 2>/dev/null | while read -r pid stat args; do + case "$stat" in Z*) continue ;; esac + case "$args" in + *start_merchant.sh*) continue ;; + esac + case "$args" in + *taler-merchant-httpd\ *|taler-merchant-httpd\ *) + echo "$pid" ;; + *taler-merchant-webhook*|*taler-merchant-kyccheck*|*taler-merchant-wirewatch*) + echo "$pid" ;; + *taler-merchant-depositcheck*|*taler-merchant-exchangekeyupdate*|*taler-merchant-reconciliation*) + echo "$pid" ;; + esac + done | sort -u +} + +kill_taler_merchant() { + local pids + pids=$(list_merchant_pids | tr '\n' ' ') + if [ -z "${pids// }" ]; then + echo "No live taler-merchant processes to stop." + return 0 + fi + echo "Stopping PIDs: $pids" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 2 + local left + left=$(list_merchant_pids | tr '\n' ' ') + if [ -n "${left// }" ]; then + echo "SIGKILL remaining: $left" + # shellcheck disable=SC2086 + kill -KILL $left 2>/dev/null || true + sleep 1 + fi + echo "taler-merchant daemons stopped." +} + +TIMESTAMP=$(date +"%Y%m%d_%H%M") +BACKUP_DIR="/var/taler-backups" +LOG_DIR="/var/log/taler-merchant" + +if [ "$DO_RESTART" -eq 1 ]; then + echo "=== restart: kill taler-merchant-* ===" + kill_taler_merchant +fi + +BACKUP_NAME="taler-merchant-$TIMESTAMP.sql" +echo -n "Backup taler-merchant DB: " +mkdir -p "$BACKUP_DIR" 2>/dev/null || true +if pg_dump taler-merchant >"$BACKUP_DIR/$BACKUP_NAME" 2>/dev/null; then + echo "OK" +else + echo "SKIP/FAIL (postgres?)" +fi +echo "Start taler-merchant components:" + +LOG_FILE="$LOG_DIR/taler-merchant-httpd-$(date +%Y-%m-%d).log" +mkdir -p "$LOG_DIR" +touch "$LOG_FILE" + +# Prefer dedicated ensure script (nohup + logs per helper). +if [ -x /usr/local/bin/ensure_merchant_helpers.sh ]; then + /usr/local/bin/ensure_merchant_helpers.sh || true +elif [ -x "$(dirname "$0")/ensure_merchant_helpers.sh" ]; then + "$(dirname "$0")/ensure_merchant_helpers.sh" || true +else + nohup taler-merchant-httpd --log=info >>"$LOG_FILE" 2>&1 /dev/null || true + sleep 2 + nohup taler-merchant-webhook >>"$LOG_DIR/taler-merchant-webhook.log" 2>&1 >"$LOG_DIR/taler-merchant-kyccheck.log" 2>&1 >"$LOG_DIR/taler-merchant-wirewatch.log" 2>&1 >"$LOG_DIR/taler-merchant-depositcheck.log" 2>&1 >"$LOG_DIR/taler-merchant-exchangekeyupdate.log" 2>&1 >"$LOG_DIR/taler-merchant-reconciliation.log" 2>&1 /dev/null || true + sleep 1 +fi + +echo "Live processes:" +ps -eo pid,stat,args 2>/dev/null | grep taler-merchant | grep -v grep | grep -v ' Z ' || true + +if [ -x /usr/local/bin/check_merchant-health.sh ]; then + /usr/local/bin/check_merchant-health.sh || exit 1 +elif [ -x ./check_merchant-health.sh ]; then + ./check_merchant-health.sh || exit 1 +fi +exit 0 diff --git a/scripts/taler-merchant/stats--merchant-payments.sh b/scripts/taler-merchant/stats--merchant-payments.sh new file mode 100755 index 0000000..ac9fcff --- /dev/null +++ b/scripts/taler-merchant/stats--merchant-payments.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Tested for DB scheme v38: + +set -eu + +runuser -u taler-merchant-httpd -- bash -c ' +printf "%-38s | %8s | %12s\n" "merchant_id" "payments" "total_amount" +printf "%-38s-+-%-8s-+-%-12s\n" "--------------------------------------" "--------" "------------" + +for s in $(psql -t -A taler-merchant -c " + SELECT schemaname FROM pg_tables + WHERE schemaname LIKE '\''merchant_instance_%'\'' + AND tablename = '\''merchant_deposits'\'' +"); do + mid=$(psql -t -A taler-merchant -c " + SELECT merchant_id FROM merchant.merchant_instances + WHERE merchant_serial = ${s#merchant_instance_} + " 2>/dev/null || echo "?") + + psql -t -A taler-merchant -c " + SELECT + '\''$mid'\'', + count(*), + round( + (COALESCE(sum((amount_with_fee).val), 0) + + COALESCE(sum((amount_with_fee).frac), 0)::numeric / 1000000000) + , 2) + FROM $s.merchant_deposits + " 2>/dev/null +done | while read line; do + printf "%-38s | %8s | %12s\n" $(echo "$line" | tr "|" " ") +done +' diff --git a/scripts/taler-merchant/taler-hacktivism-email-helper.sh b/scripts/taler-merchant/taler-hacktivism-email-helper.sh new file mode 100755 index 0000000..dada913 --- /dev/null +++ b/scripts/taler-merchant/taler-hacktivism-email-helper.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Usage: echo "body text" | ./taler-hacktivism-email-helper.sh email@example.com +# Needs ``swaks'' to be installed. +TO="$1" +SUBJECT="Taler Merchant Auth Code" +BODY=$(cat) + +# SMTP password: set SMTP_PASSWORD in the environment (not stored in git). +# Live container may still use a literal in-file password — do not re-commit it. +swaks --server mail.cyon.ch \ + --port 587 \ + --auth LOGIN \ + --auth-user taler-merchant@hacktivism.ch \ + --auth-password "${SMTP_PASSWORD:?set SMTP_PASSWORD}" \ + --tls \ + --from taler-merchant@hacktivism.ch \ + --to "$TO" \ + --header "Subject: $SUBJECT" \ + --body "$BODY" diff --git a/scripts/taler-merchant/taler-hacktivism-sms-helper-wrapper.sh b/scripts/taler-merchant/taler-hacktivism-sms-helper-wrapper.sh new file mode 100755 index 0000000..d42eea7 --- /dev/null +++ b/scripts/taler-merchant/taler-hacktivism-sms-helper-wrapper.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +PHONE_NO="$1" +JSON_STRING="{\"CONTACT_PHONE\":\"${PHONE_NO}\"}" +exec /usr/local/bin/taler-hacktivism-sms-helper.sh $JSON_STRING diff --git a/scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh b/scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh new file mode 100755 index 0000000..467c7c9 --- /dev/null +++ b/scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Run inside merchant container (as root). +# Restarts taler-merchant-wirewatch when it exits. +# Without systemd, wirewatch exits on PG config NOTIFY and would stay down. +set +e +LOG=/var/log/taler-merchant/wirewatch-supervise.log +WW_LOG=/var/log/taler-merchant/wirewatch.log +CONF="${TALER_MERCHANT_CONFIG:-/etc/taler-merchant/taler-merchant.conf}" +mkdir -p /var/log/taler-merchant +echo "$(date -u +%FT%TZ) supervise start" >>"$LOG" +for p in $(ps -eo pid=,args= | awk '$2=="/usr/bin/taler-merchant-wirewatch"{print $1}'); do + kill "$p" 2>/dev/null || true +done +sleep 1 +while true; do + echo "$(date -u +%FT%TZ) start wirewatch" >>"$LOG" + if [ "$(id -un)" = "root" ]; then + runuser -u taler-merchant-httpd -- /usr/bin/taler-merchant-wirewatch -c "$CONF" -L INFO >>"$WW_LOG" 2>&1 + ec=$? + else + /usr/bin/taler-merchant-wirewatch -c "$CONF" -L INFO >>"$WW_LOG" 2>&1 + ec=$? + fi + echo "$(date -u +%FT%TZ) wirewatch exit=$ec; sleep 2" >>"$LOG" + sleep 2 +done diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md new file mode 100644 index 0000000..35edc67 --- /dev/null +++ b/scripts/taler-monitoring/README.md @@ -0,0 +1,100 @@ +# taler-monitoring + +Report for the **GOA** stack with clear severity tags and **per-area test IDs**: + +| Tag | Meaning | +|-----|---------| +| `[OK]` | check passed | +| `[INFO]` | inside status (container, ports, log noise) | +| `[WARN]` | degraded but maybe not fatal | +| `[ERROR]` | component problem | +| `[BLOCKER]` | **withdraw/pay path cannot complete** because of this | + +IDs: **`www-001`**, **`inside-001`**, **`versions-001`**, **`sanity-001`**, **`server-001`**, **`e2e-001`** … +Catalog: **[TESTS.md](./TESTS.md)**. + +```text +[OK] www-001 exchange /config https://exchange…/config +[BLOCKER] e2e-015 prereq: merchant HTTP 502 +``` + +End of each phase: totals + list of **BLOCKERS** and **ERRORS**. + +## Commands + +```bash +# Local GOA stack (may use SSH for inside/e2e) +./taler-monitoring.sh # urls + inside + versions + e2e +./taler-monitoring.sh inside # containers on koopa +./taler-monitoring.sh versions # deb.taler.net + package versions vs trixie +./taler-monitoring.sh sanity +./taler-monitoring.sh e2e + +# Other domains — public HTTPS only, never SSH +./taler-monitoring.sh -d taler.net +./taler-monitoring.sh --domain taler-ops.ch +./taler-monitoring.sh -d demo.taler.net urls +./taler-monitoring.sh taler.net # bare domain = same as -d +``` + +| Domain | Bank | Exchange | Merchant | Currency | +|--------|------|----------|----------|----------| +| `hacktivism.ch` (default) | bank.hacktivism.ch | exchange.hacktivism.ch | taler.hacktivism.ch | GOA | +| `taler.net` / `demo.taler.net` | bank.demo.taler.net | exchange.demo.taler.net | backend.demo.taler.net | KUDOS | +| `taler-ops.ch` | bank.* (probe) | exchange.taler-ops.ch | backend.* (probe) | CHF | +| other | bank.DOMAIN | exchange.DOMAIN | backend/taler/merchant (probe) | any | + +**SSH only for koopa** (`hacktivism.ch` / `-d koopa`). + +Other domains: never SSH. Optional **e2e** aborts cleanly on login/KYC. + +### E2E amounts (variable) + +| | Local (koopa) | Remote | +|--|---------------|--------| +| **ATM withdraw** | 20 · 50 · 100 · 200 | 10 · 20 · 50 | +| **Pay ladder** | 0.01 … 10 | 0.01 … 1 | + +```bash +./taler-monitoring.sh e2e +./taler-monitoring.sh -d taler.net urls e2e +# customize: +E2E_WITHDRAW_VALUES="20 50 100" E2E_PAY_VALUES="0.05 1 5" ./taler-monitoring.sh e2e +E2E_VARIABLE=0 WITHDRAW_AMT=GOA:50 PAY_AMT=GOA:1 ./taler-monitoring.sh e2e # single fixed +# GOA shop catalog (local hacktivism): full list in E2E_SHOP_PRODUCTS; each run +# shuffles and pays E2E_SHOP_PICK_N products (default 2). +# E2E_SHOP_PRODUCTS lines: id|Product name|GOA:amount +# E2E_SHOP_PICK_N=2 +# (landing QR = taler://pay-template/…/{id}; popup = live taler://pay after POST templates/{id}) +# remote secrets: +# E2E_BANK_ADMIN_PASS=… E2E_MERCHANT_TOKEN=… +``` + +## Phases + +| Phase | What | +|-------|------| +| **inside** | SSH koopa: processes, postgres, local /config,/keys, wirewatch, recent log ERRORs | +| **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 | + +```bash +# package suite (default trixie on deb.taler.net) +TALER_APT_SUITE=trixie ./taler-monitoring.sh versions +TALER_PKG_BEHIND=error ./taler-monitoring.sh versions # any behind = ERROR +``` + +E2E maps failures to blockers, e.g.: + +- `bank-confirm HTTP 409` → wallet did not select exchange +- `no GOA balance` → wirewatch / transfer +- `create order failed` → merchant auth/instance +- `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 + +## Needs + +- SSH `koopa` (inside/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 new file mode 100644 index 0000000..51686d4 --- /dev/null +++ b/scripts/taler-monitoring/TESTS.md @@ -0,0 +1,165 @@ +# taler-monitoring — test IDs by area + +Every check line is numbered **per area** as `AREA-NNN` (zero-padded): + +| Area | Phase script | Meaning | +|------|--------------|---------| +| **www** | `check_urls.sh` | public HTTPS (outside-in) | +| **inside** | `check_inside.sh` | containers / processes on koopa | +| **versions** | `check_versions.sh` | deb.taler.net available + packages vs trixie | +| **sanity** | `check_sanity.sh` | public + server per component | +| **server** | `check_server.sh` | SSH host ports / processes | +| **e2e** | `check_e2e.sh` | withdraw + pay cycle | + +Format in output: + +```text +[OK] www-001 exchange /config https://exchange…/config +[ERROR] e2e-012 bank-auth: admin token failed +[BLOCKER] e2e-015 prereq: merchant HTTP 502 +``` + +IDs are assigned **in run order** within the area (`set_area` resets the counter). Optional soft checks still consume a number when they WARN. + +--- + +## www — public URLs (`./taler-monitoring.sh urls`) + +| ID | Check | +|----|--------| +| www-… | exchange `/config`, currency, **alt_unit_names** | +| www-… | exchange `/keys` (+ alt_unit_names soft) | +| www-… | exchange `/intro/`, `/` (302→intro) | +| www-… | **exchange `/terms`** body (not empty / not API error) | +| www-… | **exchange `/privacy`** body | +| www-… | exchange `/terms/` (200 or redirect) | +| www-… | bank `/config`, currency, **alt_unit_names** | +| www-… | bank integration / webui / intro / `/` | +| www-… | **bank `/terms`** body | +| www-… | **bank `/privacy`** (or `/intro/privacy.html` fallback) | +| www-… | merchant `/config`, currency, currencies alt_unit_names | +| www-… | each merchant `exchanges[]` `/config` alt_unit_names | +| www-… | merchant `/intro/`, `/webui/`, `/` | +| www-… | **merchant `/terms`** body (dual-currency notice) | +| www-… | **merchant `/privacy`** body (must not be `not configured`) | +| www-… | merchant `/terms/` redirect | +| 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/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). + +**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. + +**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`. + +(IDs after a failed early check may shift if later soft checks are skipped when body missing — numbering follows **executed** checks.) + +--- + +## inside — koopa SSH (`./taler-monitoring.sh inside`) + +| ID | Check (typical order) | +|----|------------------------| +| inside-001 | ssh koopa | +| inside-002+ | per-component emit: container, ports, libeufin/httpd, postgres, local `/config`/`/keys`, wirewatch, DNS pin, caddy | + +Remote lines `E|comp|LEVEL|key|detail` each become one numbered result. + +--- + +## sanity — bank · exchange · merchant (`./taler-monitoring.sh sanity`) + +| ID | Section | +|----|---------| +| sanity-001… | bank public + server | +| sanity-… | exchange public + server | +| sanity-… | merchant public + server | + +Sequential through the whole script (one `set_area sanity`). + +--- + +## versions — packages vs deb.taler.net (`./taler-monitoring.sh versions`) + +### Outside (runner / public network — no SSH) + +| ID (order) | Check | +|------------|--------| +| versions-… | DNS `deb.taler.net` | +| versions-… | HTTPS portal + apt base URL | +| versions-… | suite `InRelease` / `Release` | +| versions-… | suite `Packages` + `Packages.gz` | +| versions-… | sample pool `.deb` fetchable (Range 200/206) | +| versions-… | suite offers `taler-exchange`, `taler-merchant`, `libeufin-bank` | +| versions-… | optional `trixie-testing` Packages | +| versions-… | TLS verify (soft) | + +### Inside (SSH koopa containers) + +| ID | Check | +|----|--------| +| versions-… | ssh koopa | +| versions-… | each container → `InRelease` (pasta can reach apt repo) | +| versions-… | each container lists `deb.taler.net` in apt sources | +| versions-… | each installed `taler*` / `libeufin*` / `libtaler*` / `libdonau*` vs suite version | +| versions-… | core packages installed (`taler-exchange`, `libeufin-bank`, `taler-merchant`) | + +Outside always runs. Inside skipped with `SKIP_SSH=1` (still reports outside results). + +Compare rules: + +- **match** suite → OK +- **ahead** of suite (often testing/dev) → INFO +- **behind** suite → ERROR for core packages, WARN otherwise (`TALER_PKG_BEHIND=error` forces ERROR) + +Default suite: **trixie** (`TALER_APT_SUITE`, `TALER_APT_BASE=https://deb.taler.net/apt/debian`). + +Without SSH (`SKIP_SSH=1` or remote domain): still runs outside-in repo checks; skips container install compare. + +--- + +## server — SSH ports (`./taler-monitoring.sh server`) + +| ID | Check | +|----|--------| +| server-001 | ssh | +| server-002+ | containers, local pasta ports, processes, caddy | + +--- + +## e2e — payment path (`./taler-monitoring.sh e2e`) + +| ID | Step (approx.) | +|----|----------------| +| e2e-001 | budget info | +| e2e-002 | wallet-cli present | +| e2e-003 | mode / currency info | +| e2e-004… | secrets, reachability gates | +| e2e-… | account, credit, withdraw, confirm, coins, order, pay ladder | +| e2e-… | **GOA shop products** — full catalog list; **random pick of 2** (override `E2E_SHOP_PICK_N`) | +| e2e-… | balances, dig on failure | + +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 +only `E2E_SHOP_PICK_N` products (**default 2**). Flow matches the landing popup +(public POST `/templates/{id}`, not private orders). Report labels use product name. + +Blockers keep the same ID prefix: `[BLOCKER] e2e-0NN step: message`. + +--- + +## Run one area + +```bash +./taler-monitoring.sh urls # www only +./taler-monitoring.sh inside # inside only +./taler-monitoring.sh versions # deb.taler.net + package drift +./taler-monitoring.sh e2e # e2e only +./taler-monitoring.sh -d taler.net urls +``` diff --git a/scripts/taler-monitoring/check_e2e.sh b/scripts/taler-monitoring/check_e2e.sh new file mode 100755 index 0000000..2cbf8c4 --- /dev/null +++ b/scripts/taler-monitoring/check_e2e.sh @@ -0,0 +1,1220 @@ +#!/usr/bin/env bash +# Full withdraw + pay cycle; clear BLOCKERS when the path cannot finish. +# Skips remaining steps if overall budget exceeded (E2E_TIMEOUT, default 90s). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.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) +: "${E2E_SETTLE_ROUNDS:=18}" +: "${E2E_SETTLE_SLEEP:=3}" +E2E_START=$(date +%s) +BAL_BEFORE="(not captured)" +BAL_AFTER="(not captured)" +E2E_REPORTED=0 +e2e_left() { echo $(( E2E_TIMEOUT - ($(date +%s) - E2E_START) )); } +e2e_over() { [ "$(e2e_left)" -le 0 ]; } + +# Fixed short timeout — used even when e2e budget is exhausted / on EXIT +# stdout only for JSON; logs go to stderr file so balance parse stays clean +wcli_bal_snap() { + local out="$1" + if [ -z "${WALLET_CLI:-}" ] || [ ! -f "${WDB:-}" ]; then + echo "(no wallet)" >"$out" + return 1 + fi + local err="${out}.err" + if command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' 10 \ + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \ + >"$out" 2>"$err" || true + else + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \ + >"$out" 2>"$err" || true + fi + [ -s "$out" ] +} + +fmt_bal() { + python3 -c ' +import json,re,sys +raw=open(sys.argv[1]).read() if sys.argv[1] else "" +if not raw.strip() or raw.strip().startswith("(no"): + print(raw.strip() or "(empty)"); sys.exit(0) +# extract first JSON object with "balances" +d=None +for m in re.finditer(r"\{", raw): + try: + d=json.loads(raw[m.start():]) + if isinstance(d, dict) and "balances" in d: + break + except Exception: + d=None +if not isinstance(d, dict): + print(re.sub(r"\s+"," ", raw)[:200]); sys.exit(0) +parts=[] +for b in d.get("balances") or []: + cur=(b.get("scopeInfo") or {}).get("currency") or "?" + parts.append("%s avail=%s pendingIn=%s" % (cur, b.get("available"), b.get("pendingIncoming"))) +print("; ".join(parts) if parts else "(no balances)") +' "${1:-/dev/null}" 2>/dev/null || echo "(unreadable)" +} + +# Numeric available for currency CUR (from balance file or fresh wallet snap) +wallet_avail_num() { + local f="${1:-}" + if [ -z "$f" ] || [ ! -s "$f" ]; then + f="$SCRATCH/bal-live.out" + wcli_bal_snap "$f" || wcli balance >"$f" 2>&1 || true + fi + python3 -c ' +import json,re,sys +cur=sys.argv[2] +raw=open(sys.argv[1]).read() if sys.argv[1] else "" +d=None +for m in re.finditer(r"\{", raw): + try: + d=json.loads(raw[m.start():]) + if isinstance(d, dict) and "balances" in d: break + except Exception: + d=None +if isinstance(d, dict): + for b in d.get("balances") or []: + c=(b.get("scopeInfo") or {}).get("currency") or "" + av=b.get("available") or "" + if c==cur or av.startswith(cur+":"): + try: + print(float(av.split(":",1)[1])); sys.exit(0) + except Exception: + pass +# text fallback +m=re.search(r"%s:([0-9]+(?:\.[0-9]+)?)" % re.escape(cur), raw) +print(float(m.group(1)) if m else 0.0) +' "${f:-/dev/null}" "${CUR:-GOA}" 2>/dev/null || echo 0 +} + +# Wait for spendable balance (settlement / wirewatch lag). Returns 0 if avail > min. +# Emphasizes timing, not hard failure, while waiting. +wait_wallet_balance() { + local min_n="${1:-0}" + local rounds="${2:-15}" + local sleep_s="${3:-3}" + local r av + info "wallet settle wait" "up to ${rounds}×${sleep_s}s for avail>${min_n} ${CUR} (wirewatch lag is common)" + for r in $(seq 1 "$rounds"); do + wcli run-until-done >"$SCRATCH/settle-run.out" 2>&1 || true + wcli_bal_snap "$SCRATCH/bal-live.out" || wcli balance >"$SCRATCH/bal-live.out" 2>&1 || true + av=$(wallet_avail_num "$SCRATCH/bal-live.out") + if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > float(sys.argv[2]) else 1)" "$av" "$min_n" 2>/dev/null; then + ok "wallet balance ready" "avail=${CUR}:${av} after ~$((r * sleep_s))s settle wait" + info "balance" "$(fmt_bal "$SCRATCH/bal-live.out")" + return 0 + fi + if [ "$r" = "1" ] || [ "$((r % 3))" = "0" ]; then + info "settlement lag" "round $r/${rounds}: avail=${CUR}:${av} (still waiting — not an error yet)" + fi + # gentle wirewatch nudge mid-wait (local only) + if [ "$r" = "4" ] || [ "$r" = "8" ]; then + if [ "${E2E_REMOTE:-0}" != "1" ] && koopa_ssh_ok; then + koopa_ssh_run 8 \ + 'podman exec taler-hacktivism-exchange-ansible systemctl restart taler-exchange-wirewatch 2>/dev/null || true' \ + >/dev/null 2>&1 || true + fi + fi + sleep "$sleep_s" + done + av=$(wallet_avail_num "$SCRATCH/bal-live.out") + warn "settlement lag" "after ${rounds}×${sleep_s}s still avail=${CUR}:${av} — coins may still be in flight" + return 1 +} + +print_balances() { + section "e2e · wallet balances" + info "BALANCE before" "$BAL_BEFORE" + # refresh after if we can + if [ -n "${SCRATCH:-}" ]; then + wcli_bal_snap "$SCRATCH/bal-end.out" || true + if [ -s "$SCRATCH/bal-end.out" ]; then + BAL_AFTER=$(fmt_bal "$SCRATCH/bal-end.out") + fi + fi + info "BALANCE after " "$BAL_AFTER" +} + +e2e_finish() { + local code="${1:-1}" + if [ "$E2E_REPORTED" = "1" ]; then + return "$code" + fi + E2E_REPORTED=1 + print_balances + summary || true + return "$code" +} + +e2e_skip_rest() { + local why="$1" + warn "e2e timeout" "budget ${E2E_TIMEOUT}s exceeded — $why" + blocker "e2e-timeout" "skipped remaining steps after ${E2E_TIMEOUT}s ($why)" + section "e2e · report" + info "WITHDRAW" "incomplete (timeout)" + info "PAY" "SKIPPED (timeout)" + # If we already started a withdrawal, dig inside for missing coins + if [ -n "${WID:-}" ]; then + dig_when_no_coins 2>/dev/null || true + fi + e2e_finish 1 + exit 1 +} + +# Area e2e-### — withdraw + pay cycle +set_area e2e +section "e2e · prerequisites" +info "e2e budget" "${E2E_TIMEOUT}s (set E2E_TIMEOUT= to change)" + +# Remote domains (not koopa): public path only, tiny amounts, abort on login/KYC +: "${E2E_REMOTE:=0}" +if [ "${LOCAL_STACK:-1}" != "1" ]; then + E2E_REMOTE=1 + SKIP_SSH=1 + E2E_FAKE_INCOMING=0 +fi + +# Currency for amounts / balance checks +CUR="${EXPECT_CURRENCY:-}" +if [ -z "$CUR" ]; then + CUR=$(curl -skS -m 8 "$BANK_PUBLIC/config" 2>/dev/null | python3 -c 'import sys,json +try: print(json.load(sys.stdin).get("currency") or "") +except Exception: print("")' 2>/dev/null || true) +fi +[ -z "$CUR" ] && CUR=$(curl -skS -m 8 "$EXCHANGE_PUBLIC/config" 2>/dev/null | python3 -c 'import sys,json +try: print(json.load(sys.stdin).get("currency") or "") +except Exception: print("")' 2>/dev/null || true) +[ -z "$CUR" ] && CUR="GOA" + +# --------------------------------------------------------------------------- +# Variable amounts +# Withdraw = typical ATM notes only (Bankomat): 20 / 50 / 100 / 200 … +# Pay = several smaller purchase amounts +# Env: +# E2E_WITHDRAW_VALUES="20 50 100" # bare numbers → prefixed with CUR +# E2E_PAY_VALUES="0.01 0.05 0.5 1 2 5" +# E2E_VARIABLE=0 # single fixed WITHDRAW_AMT / PAY_AMT +# E2E_ATM_MAX=N # try at most N ATM withdraws (budget) +# --------------------------------------------------------------------------- +: "${E2E_VARIABLE:=1}" +: "${E2E_ATM_MAX:=4}" +if [ "$E2E_REMOTE" = "1" ]; then + # remote: still ATM-shaped but smaller notes; keep cheap + : "${E2E_WITHDRAW_VALUES:=10 20 50}" + : "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}" +else + # local GOA: classic ATM denominations + : "${E2E_WITHDRAW_VALUES:=20 50 100 200}" + : "${E2E_PAY_VALUES:=0.01 0.05 0.1 0.5 1 2 5 10}" +fi + +# Build CUR:amount lists +build_amt_list() { + local cur="$1"; shift + local v out="" + for v in "$@"; do + case "$v" in + *:*) out="${out}${out:+ }$v" ;; + *) out="${out}${out:+ }${cur}:${v}" ;; + esac + done + printf '%s' "$out" +} +# shellcheck disable=SC2086 +WITHDRAW_LIST=$(build_amt_list "$CUR" $E2E_WITHDRAW_VALUES) +# shellcheck disable=SC2086 +PAY_LIST=$(build_amt_list "$CUR" $E2E_PAY_VALUES) + +if [ "$E2E_VARIABLE" != "1" ]; then + if [ "$E2E_REMOTE" = "1" ]; then + WITHDRAW_LIST="${WITHDRAW_AMT:-${CUR}:20}" + PAY_LIST="${PAY_AMT:-${CUR}:0.01}" + else + WITHDRAW_LIST="${WITHDRAW_AMT:-${CUR}:20}" + PAY_LIST="${PAY_AMT:-${CUR}:0.01}" + fi +fi + +# Credit = sum of planned ATM withdraws + small buffer for fees +CREDIT_AMT=$(python3 -c ' +import sys +cur=sys.argv[1] +vals=sys.argv[2].split() +s=0.0 +for a in vals: + try: s += float(a.split(":",1)[-1]) + except Exception: pass +buf = max(10.0, s * 0.05) +print("%s:%g" % (cur, s + buf)) +' "$CUR" "$WITHDRAW_LIST" 2>/dev/null || echo "${CUR}:100") + +# First withdraw/pay for legacy single-step labels +WITHDRAW_AMT=$(printf '%s' "$WITHDRAW_LIST" | awk '{print $1}') +PAY_AMT=$(printf '%s' "$PAY_LIST" | awk '{print $1}') + +# More wall time when running ATM ladder +if [ "$E2E_VARIABLE" = "1" ]; then + n_w=$(printf '%s' "$WITHDRAW_LIST" | wc -w | tr -d ' ') + n_p=$(printf '%s' "$PAY_LIST" | wc -w | tr -d ' ') + need=$(( 40 + n_w * 35 + n_p * 20 )) + if [ "${E2E_TIMEOUT}" -lt "$need" ]; then + E2E_TIMEOUT=$need + fi + # cap ATM attempts + WITHDRAW_LIST=$(printf '%s' "$WITHDRAW_LIST" | tr ' ' '\n' | head -n "$E2E_ATM_MAX" | tr '\n' ' ' | sed 's/ *$//') +fi + +BANK_HOST=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$BANK_PUBLIC"'").hostname or "bank")' 2>/dev/null || echo bank) + +SCRATCH=$(mktemp -d) +WDB="$SCRATCH/wallet.sqlite3" +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 + +# Abort cleanly on login / KYC / registration barriers (esp. remote domains) +e2e_abort_auth() { + local step="$1" msg="$2" + warn "$step" "$msg" + blocker "$step" "abort (login/KYC): $msg" + section "e2e · report" + info "ABORTED" "login or KYC blocked further e2e — not retrying" + info "hint" "remote e2e needs open bank registration + credit path; set E2E_BANK_ADMIN_PASS / E2E_MERCHANT_TOKEN if you have them" + e2e_finish 1 + exit 1 +} + +is_auth_kyc_body() { + # stdin or file arg: true if looks like login/KYC barrier + local f="${1:-}" + local t + if [ -n "$f" ] && [ -f "$f" ]; then t=$(head -c 800 "$f" 2>/dev/null || true) + else t=$(cat 2>/dev/null || true); fi + printf '%s' "$t" | grep -qiE 'kyc|legitim|login required|unauthorized|forbidden|captcha|challenge|tan_|not.?allowed|registration.?disabled|admin.?only|permission.?denied|401|403' +} + +WALLET_CLI=$(find_wallet_cli) || { + blocker "prereq" "taler-wallet-cli not found (set WALLET_CLI=)" + exit 1 +} +ok "wallet-cli ($WALLET_CLI)" +info "e2e mode" "$([ "$E2E_REMOTE" = "1" ] && echo "remote/public domain (no SSH)" || echo "local koopa stack")" +info "currency" "$CUR" +info "ATM withdraw ladder" "$WITHDRAW_LIST (credit $CREDIT_AMT)" +info "pay ladder" "$PAY_LIST" +info "e2e budget" "${E2E_TIMEOUT}s" + +# 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:-}" + MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}" +else + ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-$(read_secret "taler-bank/bank-admin-password.txt" || true)}" + MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-$(read_secret "taler-merchant/merchant-goa-demo-cp4zqk-password.txt" || true)}}" +fi +if [ -n "$ADMIN_PASS" ]; then + ok "bank admin secret" +elif [ "$E2E_REMOTE" = "1" ]; then + warn "bank admin secret" "missing — will try public registration only" +else + blocker "prereq" "bank admin password missing (SECRETS_ROOT or koopa /root)" + exit 1 +fi +if [ -n "$MPW" ]; then + ok "merchant secret (instance ${MERCHANT_INSTANCE})" +elif [ "$E2E_REMOTE" = "1" ]; then + warn "merchant secret" "missing — order create may fail (set E2E_MERCHANT_TOKEN)" +else + blocker "prereq" "merchant instance password missing" + exit 1 +fi + +# Public reachability gates (remote: soft-skip if bank/merchant absent) +for pair in \ + "bank|$BANK_PUBLIC/config" \ + "exchange|$EXCHANGE_PUBLIC/config" \ + "exchange-keys|$EXCHANGE_PUBLIC/keys" \ + "bank-integration|$BANK_PUBLIC/taler-integration/config" \ + "merchant|$MERCHANT_PUBLIC/config" +do + IFS='|' read -r name url <<<"$pair" + code=$(http_code "$url") + if [ "$code" = "200" ]; then + ok "reachable $name" + else + if [ "$E2E_REMOTE" = "1" ] && [[ "$name" == bank* || "$name" == merchant ]]; then + warn "reachable $name" "HTTP $code — e2e may abort" + else + blocker "prereq" "$name HTTP $code ($url) — cannot start payment cycle" + fi + fi +done +# Remote: need at least bank+exchange+merchant for a full cycle +if [ "$E2E_REMOTE" = "1" ]; then + bc=$(http_code "$BANK_PUBLIC/config") + ec=$(http_code "$EXCHANGE_PUBLIC/config") + mc=$(http_code "$MERCHANT_PUBLIC/config") + if [ "$bc" != "200" ] || [ "$ec" != "200" ]; then + e2e_abort_auth "prereq" "bank/exchange not publicly usable (bank=$bc exchange=$ec) — skip e2e" + fi + if [ "$mc" != "200" ]; then + warn "prereq" "merchant HTTP $mc — will try withdraw only if possible" + fi +fi +if [ "${#BLOCKERS[@]}" -gt 0 ]; then + exit 1 +fi + +USER="mon$(date +%s | tail -c 6)" +USER_PW=$(python3 -c 'import secrets;print(secrets.token_urlsafe(10))') +info "e2e user" "$USER withdraw=$WITHDRAW_AMT pay=$PAY_AMT" +BANK="$BANK_PUBLIC" +INST="$MERCHANT_INSTANCE" + +# $1 = max seconds for this call (optional); rest = wallet-cli args +wcli() { + local maxc=12 + if [[ "${1:-}" =~ ^[0-9]+$ ]]; then + maxc=$1 + shift + fi + e2e_over && return 124 + local cap + cap=$(e2e_left) + [ "$cap" -gt "$maxc" ] && cap=$maxc + [ "$cap" -lt 3 ] && return 124 + if command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' "$cap" \ + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + else + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + fi +} + +# Pay must not be starved: use reserved seconds even if global budget is tight +wcli_pay() { + local cap=$E2E_PAY_SECS + local left + left=$(e2e_left) + if [ "$left" -gt "$cap" ]; then + cap=$E2E_PAY_SECS + elif [ "$left" -ge 8 ]; then + cap=$left + else + # still try once with floor 12s so Alarm clock is not the blocker + cap=12 + fi + info "pay wallet cap" "${cap}s" + if command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' "$cap" \ + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + else + node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@" + fi +} + +# When coins never become available: run inside + targeted bank/exchange dig +dig_when_no_coins() { + section "e2e · coins missing — inside dig" + info "reason" "wallet empty after withdraw path (often wirewatch timing) — probing bank + exchange" + if [ -n "${WID:-}" ]; then + curl -sS -m 6 -o "$SCRATCH/wd-dig.json" \ + "$BANK_PUBLIC/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || true + info "backend withdrawal-operation" "$(python3 -c ' +import json +try: + d=json.load(open("'"$SCRATCH"'/wd-dig.json")) + print("status=%s transfer_done=%s selection_done=%s amount=%s reserve=%s" % ( + d.get("status"), d.get("transfer_done"), d.get("selection_done"), + d.get("amount"), (d.get("selected_reserve_pub") or "-")[:24])) +except Exception as e: + print("unreadable", e) +' 2>/dev/null)" + fi + # Full component inside report (capped SSH; ignore its exit) + if [ "${SKIP_SSH}" != "1" ] && [ -x "$ROOT/check_inside.sh" ]; then + info "inside" "running check_inside.sh …" + # subshell so its PASS_N/FAIL_N/summary don't poison ours; still print to stdout + ( + # shellcheck disable=SC2030 + "$ROOT/check_inside.sh" || true + ) || true + else + warn "inside" "skipped (SKIP_SSH=1 or check_inside.sh missing)" + fi + # Extra targeted: wirewatch + recent journal + bank→exchange path + if koopa_ssh_ok; then + section "e2e · coins missing — exchange/bank focus" + DIG=$(koopa_ssh_bash 15 <<'REMOTE' || true +set +e +emit() { printf 'D|%s|%s\n' "$1" "$(printf '%s' "$2" | tr '\n' ' ' | head -c 220)"; } +EX=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1) +BANK=$(podman ps --format '{{.Names}}' | grep -iE 'hacktivism-bank|taler-bank' | head -1) +[ -z "$BANK" ] && BANK=$(podman ps --format '{{.Names}}' | grep -i bank | head -1) +if [ -n "$EX" ]; then + podman exec "$EX" pgrep -af taler-exchange-wirewatch 2>/dev/null | head -1 | \ + { read -r l; [ -n "$l" ] && emit OK "wirewatch: $l" || emit ERROR "wirewatch not running"; } + podman exec "$EX" pgrep -af taler-exchange-transfer 2>/dev/null | head -1 | \ + { read -r l; [ -n "$l" ] && emit OK "transfer: $l" || emit WARN "transfer not running"; } + podman exec "$EX" pgrep -af taler-exchange-aggregator 2>/dev/null | head -1 | \ + { read -r l; [ -n "$l" ] && emit OK "aggregator: $l" || emit WARN "aggregator not running"; } + # last wirewatch log lines if journal available + j=$(podman exec "$EX" bash -c 'journalctl -u "taler-exchange-wirewatch*" -n 8 --no-pager 2>/dev/null | tail -5' 2>/dev/null | tr '\n' ';' | head -c 280) + [ -n "$j" ] && emit INFO "wirewatch journal: $j" + # reserve lookup via exchange if tools exist (best-effort) + c=$(curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000) + emit INFO "exchange /keys HTTP $c" +else + emit ERROR "no exchange container" +fi +if [ -n "$BANK" ]; then + podman exec "$BANK" pgrep -af 'MainKt serve|libeufin-bank serve' 2>/dev/null | head -1 | \ + { read -r l; [ -n "$l" ] && emit OK "libeufin: $l" || emit ERROR "libeufin not running"; } + c=$(curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:9012/taler-integration/config 2>/dev/null || echo 000) + emit INFO "bank integration /config HTTP $c" +else + emit ERROR "no bank container" +fi +REMOTE +) + while IFS= read -r line; do + case "$line" in + D\|*) + IFS="|" read -r _ lvl msg <<<"$line" + case "$lvl" in + OK) ok "dig $msg" ;; + ERROR) err "dig" "$msg" ;; + WARN) warn "dig" "$msg" ;; + INFO) info "dig" "$msg" ;; + esac + ;; + esac + done <<<"$DIG" + fi +} + +# Baseline wallet balance (empty DB) — always shown even if we abort later +wcli_bal_snap "$SCRATCH/bal-before.out" || true +BAL_BEFORE=$(fmt_bal "$SCRATCH/bal-before.out") +info "BALANCE before" "$BAL_BEFORE" + +# --------------------------------------------------------------------------- +section "e2e · bank (account · credit · withdraw)" +# --------------------------------------------------------------------------- +AT="" +if [ -n "${ADMIN_PASS:-}" ]; then + AT=$(curl -sS -m 15 -u "admin:${ADMIN_PASS}" -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite"}' \ + "$BANK/accounts/admin/token" 2>"$SCRATCH/at.err" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || true) +fi +if [ -n "$AT" ]; then + ok "admin token" +elif [ "$E2E_REMOTE" = "1" ]; then + warn "admin token" "unavailable — trying open registration" +else + if is_auth_kyc_body "$SCRATCH/at.err"; then + e2e_abort_auth "bank-auth" "admin login blocked (KYC/auth) — $(head -c 80 "$SCRATCH/at.err" 2>/dev/null)" + fi + blocker "bank-auth" "admin token failed — check admin password / bank up ($(head -c 80 "$SCRATCH/at.err" 2>/dev/null))" + exit 1 +fi + +# Create account: with admin bearer if we have it, else unauthenticated registration (demo banks) +if [ -n "$AT" ]; then + code=$(curl -sS -m 15 -o "$SCRATCH/acc.json" -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $AT" -H 'Content-Type: application/json' \ + -d "{\"username\":\"${USER}\",\"password\":\"${USER_PW}\",\"name\":\"Monitoring\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"${CUR}:1000\"}" \ + "$BANK/accounts") +else + code=$(curl -sS -m 15 -o "$SCRATCH/acc.json" -w '%{http_code}' -X POST \ + -H 'Content-Type: application/json' \ + -d "{\"username\":\"${USER}\",\"password\":\"${USER_PW}\",\"name\":\"Monitoring\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"${CUR}:1000\"}" \ + "$BANK/accounts") +fi +if [ "$code" = "200" ] || [ "$code" = "201" ]; then + ok "create account $USER" +elif [ "$code" = "401" ] || [ "$code" = "403" ] || is_auth_kyc_body "$SCRATCH/acc.json"; then + e2e_abort_auth "bank-account" "registration/login blocked HTTP $code — $(head -c 100 "$SCRATCH/acc.json" | tr '\n' ' ')" +else + if [ "$E2E_REMOTE" = "1" ]; then + e2e_abort_auth "bank-account" "POST /accounts HTTP $code — cannot open account on this domain" + fi + blocker "bank-account" "POST /accounts HTTP $code — $(head -c 100 "$SCRATCH/acc.json" | tr '\n' ' ')" + exit 1 +fi + +if [ -n "$AT" ]; then + export USER_NAME="$USER" CREDIT_AMT="$CREDIT_AMT" OUT="$SCRATCH/credit.json" BANK_HOST="$BANK_HOST" + python3 - <<'PY' +import json, os +ALPH = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +def crock32(b): + bits = val = 0; o = [] + for byte in b: + val = (val << 8) | byte; bits += 8 + while bits >= 5: + bits -= 5; o.append(ALPH[(val >> bits) & 31]) + if bits: o.append(ALPH[(val << (5 - bits)) & 31]) + return "".join(o) +uid = crock32(os.urandom(32)) +user = os.environ["USER_NAME"] +host = os.environ.get("BANK_HOST") or "bank" +json.dump({ + "payto_uri": f"payto://x-taler-bank/{host}/{user}?receiver-name={user}&message=mon", + "amount": os.environ["CREDIT_AMT"], + "request_uid": uid, +}, open(os.environ["OUT"], "w")) +PY + curl -sS -m 15 -o "$SCRATCH/credit.out" -H "Authorization: Bearer $AT" \ + -H 'Content-Type: application/json' -d @"$SCRATCH/credit.json" \ + "$BANK/accounts/admin/transactions" >/dev/null || true + if grep -q row_id "$SCRATCH/credit.out" 2>/dev/null; then + ok "credit $CREDIT_AMT → $USER" + else + if is_auth_kyc_body "$SCRATCH/credit.out"; then + e2e_abort_auth "bank-credit" "admin credit blocked (KYC/auth)" + fi + if [ "$E2E_REMOTE" = "1" ]; then + e2e_abort_auth "bank-credit" "admin transfer failed — no credit path on remote ($(head -c 80 "$SCRATCH/credit.out" | tr '\n' ' '))" + fi + blocker "bank-credit" "admin transfer failed — $(head -c 100 "$SCRATCH/credit.out" | tr '\n' ' ')" + exit 1 + fi +else + # No admin: remote demo may start funded or require cash-in — try withdraw later; warn + warn "bank-credit" "skipped (no admin) — withdraw may fail without balance" +fi + +UT=$(curl -sS -m 15 -u "${USER}:${USER_PW}" -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite"}' \ + "$BANK/accounts/${USER}/token" 2>"$SCRATCH/ut.err" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || true) +if [ -n "$UT" ]; then + ok "user token" +elif is_auth_kyc_body "$SCRATCH/ut.err" || is_auth_kyc_body /dev/null; then + e2e_abort_auth "bank-auth" "user token failed (login/KYC) for $USER" +else + e2e_abort_auth "bank-auth" "user token failed for $USER — $(head -c 80 "$SCRATCH/ut.err" 2>/dev/null)" +fi + +# --------------------------------------------------------------------------- +section "e2e · wallet setup (exchange + ToS once)" +# --------------------------------------------------------------------------- +if ! wcli exchanges add "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-add.out" 2>&1; then + if ! grep -qiE 'already|ok' "$SCRATCH/ex-add.out"; then + blocker "wallet-exchange" "exchanges add failed — $(tail -c 160 "$SCRATCH/ex-add.out" | tr '\n' ' ')" + else + ok "wallet exchange known" + fi +else + ok "wallet add exchange" +fi +wcli exchanges update "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-up.out" 2>&1 || true +if wcli exchanges accept-tos "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-tos.out" 2>&1; then + ok "wallet accept ToS" +else + if grep -qiE 'pending|tos|terms' "$SCRATCH/ex-tos.out"; then + blocker "wallet-tos" "accept-tos failed — wallet may refuse withdraw ($(tail -c 120 "$SCRATCH/ex-tos.out" | tr '\n' ' '))" + else + warn "wallet accept ToS" "$(tail -c 80 "$SCRATCH/ex-tos.out" | tr '\n' ' ')" + fi +fi + +wd_status() { + curl -sS -m 8 -o "$SCRATCH/wd-st.json" -w '' \ + "$BANK/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || true + python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("status",""))' 2>/dev/null || true +} +wd_status_detail() { + python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json")) +print("status=%s reserve=%s exchange=%s" % ( + d.get("status"), + (d.get("selected_reserve_pub") or "-")[:20], + (d.get("selected_exchange") or d.get("selected_exchange_account") or "-")[:60], +))' 2>/dev/null || echo "(no status body)" +} + +fake_incoming_speedup() { + [ "${E2E_FAKE_INCOMING}" = "1" ] || return 0 + st=$(wd_status) + RPUB=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("selected_reserve_pub") or "")' 2>/dev/null || true) + AMT=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("amount") or "'"$WITHDRAW_AMT"'")' 2>/dev/null || echo "$WITHDRAW_AMT") + DEBIT=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("sender_wire") or "")' 2>/dev/null || true) + [ -z "$DEBIT" ] && DEBIT="payto://x-taler-bank/${BANK_HOST}/${USER}?receiver-name=${USER}" + [ -z "$RPUB" ] && return 0 + [ -z "${AT:-}" ] && return 0 + WG="${BANK}/accounts/exchange/taler-wire-gateway/admin/add-incoming" + curl -sS -m 10 -o "$SCRATCH/fake-in.json" -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $AT" -H 'Content-Type: application/json' \ + -d "{\"amount\":\"${AMT}\",\"reserve_pub\":\"${RPUB}\",\"debit_account\":\"${DEBIT}\"}" \ + "$WG" >/dev/null || true + if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then + koopa_ssh_run 22 \ + 'podman exec taler-hacktivism-exchange-ansible bash -c "runuser -u taler-exchange-wire -- timeout 15 taler-exchange-wirewatch -c /etc/taler-exchange/taler-exchange.conf -t -L INFO 2>&1 | tail -5"' \ + >/dev/null 2>&1 || true + fi +} + +# One ATM-style withdraw: create → accept → select → confirm → settle +# returns 0 on wallet balance increase, 1 on soft fail (ladder continues) +e2e_one_withdraw() { + WITHDRAW_AMT="$1" + local tag + tag=$(printf '%s' "$WITHDRAW_AMT" | tr '.:' '__') + section "e2e · ATM withdraw $WITHDRAW_AMT" + e2e_over && { warn "ATM withdraw" "budget exhausted — skip $WITHDRAW_AMT"; return 1; } + + curl -sS -m 15 -o "$SCRATCH/wd-$tag.json" -H "Authorization: Bearer $UT" \ + -H 'Content-Type: application/json' \ + -d "{\"amount\":\"${WITHDRAW_AMT}\",\"exchange_url\":\"${EXCHANGE_PUBLIC}/\"}" \ + "$BANK/accounts/${USER}/withdrawals" + WID=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("withdrawal_id") or d.get("id") or "")' 2>/dev/null || true) + URI=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("taler_withdraw_uri") or "")' 2>/dev/null || true) + URI_CLEAN=$(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) + if [ -z "$WID" ] || [ -z "$URI_CLEAN" ]; then + warn "ATM withdraw $WITHDRAW_AMT" "create failed — $(head -c 80 "$SCRATCH/wd-$tag.json" | tr '\n' ' ')" + return 1 + fi + ok "ATM withdrawal created $WITHDRAW_AMT ($WID)" + cp "$SCRATCH/wd-$tag.json" "$SCRATCH/wd.json" + + USE_URI="$URI" + [ -z "$USE_URI" ] && USE_URI="$URI_CLEAN" + if wcli withdraw accept-uri --exchange "${EXCHANGE_PUBLIC}/" "$USE_URI" >"$SCRATCH/accept-$tag.out" 2>&1; then + ok "wallet accept $WITHDRAW_AMT" + elif [ "$USE_URI" != "$URI_CLEAN" ] && [ -n "$URI_CLEAN" ] \ + && wcli withdraw accept-uri --exchange "${EXCHANGE_PUBLIC}/" "$URI_CLEAN" >"$SCRATCH/accept-$tag.out" 2>&1; then + ok "wallet accept $WITHDRAW_AMT (no :443)" + else + warn "ATM withdraw $WITHDRAW_AMT" "accept-uri failed — $(tail -c 100 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')" + return 1 + fi + cp "$SCRATCH/accept-$tag.out" "$SCRATCH/accept.out" + + st="" + for i in 1 2 3 4; do + e2e_over && break + wcli run-until-done >"$SCRATCH/sel-$tag-$i.out" 2>&1 || true + st=$(wd_status) + case "$st" in selected|confirmed|aborted) break ;; esac + done + if [ "$st" != "selected" ] && [ "$st" != "confirmed" ]; then + wcli transactions >"$SCRATCH/tx-pre.json" 2>&1 || true + RPUB=$(python3 -c ' +import re +t=open("'"$SCRATCH"'/accept.out").read()+open("'"$SCRATCH"'/tx-pre.json").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 "") +' 2>/dev/null || true) + EPAYTO=$(curl -sS -m 12 "$EXCHANGE_PUBLIC/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/sel.json" -X POST \ + -H 'Content-Type: application/json' \ + -d "{\"reserve_pub\":\"$RPUB\",\"selected_exchange\":\"$EPAYTO\"}" \ + "$BANK/taler-integration/withdrawal-operation/${WID}" >/dev/null || true + st=$(wd_status) + fi + fi + if [ "$st" != "selected" ] && [ "$st" != "confirmed" ]; then + warn "ATM withdraw $WITHDRAW_AMT" "not selected (status=${st:-?})" + return 1 + fi + if [ "$st" != "confirmed" ]; then + code=$(curl -sS -m 15 -o "$SCRATCH/conf-$tag.json" -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $UT" -H 'Content-Type: application/json' -d '{}' \ + "$BANK/accounts/${USER}/withdrawals/${WID}/confirm") + case "$code" in + 200|204) ok "bank confirm $WITHDRAW_AMT" ;; + *) warn "ATM withdraw $WITHDRAW_AMT" "confirm HTTP $code"; return 1 ;; + esac + fi + fake_incoming_speedup + + # Short per-ATM poll; full settle wait happens after the ladder (avoids false FAIL) + local ok_bal=0 r av + for r in 1 2 3 4 5 6; do + wcli run-until-done >"$SCRATCH/run-$tag-$r.out" 2>&1 || true + wcli_bal_snap "$SCRATCH/bal-$tag.out" || wcli balance >"$SCRATCH/bal-$tag.out" 2>&1 || true + av=$(wallet_avail_num "$SCRATCH/bal-$tag.out") + if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > 0 else 1)" "$av" 2>/dev/null; then + ok_bal=1 + ok "wallet funded after ATM $WITHDRAW_AMT" "avail=${CUR}:${av}" + info "balance" "$(fmt_bal "$SCRATCH/bal-$tag.out")" + break + fi + sleep 2 + done + if [ "$ok_bal" = "1" ]; then + return 0 + fi + # Bank side often already confirmed — treat as timing lag, not hard fail + st=$(wd_status) + if [ "$st" = "confirmed" ]; then + warn "ATM withdraw $WITHDRAW_AMT" "bank confirmed; wallet not funded yet (settlement timing — will recheck later)" + return 2 + fi + warn "ATM withdraw $WITHDRAW_AMT" "no wallet balance yet (status=${st:-?})" + return 1 +} + +# One payment: e2e_one_pay AMOUNT [SUMMARY] [TAG] +# SUMMARY defaults to "monitoring pay $AMOUNT"; TAG defaults from amount. +e2e_one_pay() { + PAY_AMT="$1" + local PAY_SUM="${2:-monitoring pay ${PAY_AMT}}" + local tag="${3:-}" + if [ -z "$tag" ]; then + tag=$(printf '%s' "$PAY_AMT" | tr '.:' '__') + fi + # shell-safe tag for files + tag=$(printf '%s' "$tag" | tr -c 'A-Za-z0-9._-' '_') + section "e2e · pay $PAY_AMT · $PAY_SUM" + e2e_over && { warn "pay" "budget exhausted — skip $PAY_AMT ($PAY_SUM)"; return 1; } + if [ -z "${MPW:-}" ]; then + warn "pay $PAY_AMT" "no merchant token" + return 1 + fi + AUTH="Authorization: Bearer secret-token:${MPW}" + # JSON-escape summary for curl -d + local SUM_JSON + SUM_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$PAY_SUM" 2>/dev/null || printf '"%s"' "$PAY_SUM") + curl -skS -m 15 -o "$SCRATCH/ord-$tag.json" -X POST \ + -H "$AUTH" -H 'Content-Type: application/json' \ + -d "{\"order\":{\"summary\":${SUM_JSON},\"amount\":\"${PAY_AMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}" \ + "${MERCHANT_PUBLIC}/instances/${INST}/private/orders" 2>"$SCRATCH/ord-$tag.err" || true + if ! grep -q order_id "$SCRATCH/ord-$tag.json" 2>/dev/null; then + if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then + koopa_ssh_run 20 \ + "curl -skS -m 12 -X POST -H 'Authorization: Bearer secret-token:${MPW}' -H 'Content-Type: application/json' -d '{\"order\":{\"summary\":${SUM_JSON},\"amount\":\"${PAY_AMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}' 'https://127.0.0.1:9010/instances/${INST}/private/orders'" \ + >"$SCRATCH/ord-$tag.json" 2>"$SCRATCH/ord-$tag.err" || true + fi + fi + 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-$tag.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-$tag.json" 2>/dev/null || true) + if [ -z "$OID" ]; then + if is_auth_kyc_body "$SCRATCH/ord-$tag.json"; then + e2e_abort_auth "merchant-order" "merchant auth/KYC on pay $PAY_AMT ($PAY_SUM)" + fi + warn "pay $PAY_AMT ($PAY_SUM)" "order create failed — $(head -c 80 "$SCRATCH/ord-$tag.json" | tr '\n' ' ')" + return 1 + fi + ok "merchant order $OID ($PAY_AMT · $PAY_SUM)" + curl -skS -m 12 -o "$SCRATCH/ord-det-$tag.json" -H "$AUTH" \ + "${MERCHANT_PUBLIC}/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-$tag.json" 2>/dev/null || true) + if [ -z "$PAYURI" ] && [ -n "$OTOK" ]; then + MH=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$MERCHANT_PUBLIC"'").hostname or "taler.hacktivism.ch")' 2>/dev/null || echo "taler.hacktivism.ch") + PAYURI="taler://pay/${MH}/instances/${INST}/${OID}/?c=${OTOK}" + fi + # normalize default HTTPS port (wallets accept both) + PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g') + if [ -z "$PAYURI" ]; then + warn "pay $PAY_AMT ($PAY_SUM)" "no pay URI for $OID" + return 1 + fi + ok "taler_pay_uri ready ($tag)" + if ! wcli_pay handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$tag.out" 2>&1; then + warn "pay $PAY_AMT ($PAY_SUM)" "handle-uri failed — $(tail -c 100 "$SCRATCH/pay-$tag.out" | tr '\n' ' ')" + return 1 + fi + ok "wallet handle pay $PAY_AMT ($PAY_SUM)" + local r + for r in 1 2 3; do + wcli_pay run-until-done >"$SCRATCH/pay-run-$tag.out" 2>&1 || true + wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true + curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \ + "${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true + 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 \ + || 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-$tag.json" 2>/dev/null; then + ok "payment settled $PAY_AMT ($PAY_SUM · order $OID)" + return 0 + fi + done + warn "pay $PAY_AMT ($PAY_SUM)" "not settled for order $OID" + return 1 +} + +# GOA shop catalog (full list) — keep in sync with configs/merchant-landing/index.html +# id|product_name|amount +# Landing QR = taler://pay-template/…/{id}; popup = public POST templates/{id}. +# E2E does not pay every product every run: shuffle + pick E2E_SHOP_PICK_N (default 2). +: "${E2E_SHOP_PRODUCTS:=orbit-sticker|Orbit sticker pack|GOA:2 +nebula-coffee|Nebula coffee|GOA:5 +voidwave-playlist|Voidwave playlist|GOA:8 +comet-cap|Comet cap|GOA:15 +shuttle-pass|Shuttle day pass|GOA:25 +beacon-badge|Beacon badge|GOA:3 +relay-pin|Relay pin|GOA:4 +eclipse-shades|Eclipse shades|GOA:12 +star-chart|Star chart print|GOA:7 +rainbow-pill|Rainbow pill (sample/joke)|GOA:4.2 +blue-or-red-pill|Blue or red pill (sample/joke)|GOA:1.5}" +: "${E2E_SHOP_PICK_N:=2}" +# Public templates live on goa-shop (landing shop-pay.js); not the e2e default demo instance. +: "${E2E_SHOP_INSTANCE:=goa-shop}" + +# Settlement payto shown on landing shop popup (static) +: "${E2E_SHOP_PAYTO:=payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop}" + +# Pay one shop product the way the public landing does: +# POST /instances/{inst}/templates/{id} body {} → order_id+token → taler_pay_uri +# Also checks pay-template URI shape (what qrencode PNGs encode). +# Usage: e2e_one_pay_public_template PRODUCT_ID PRODUCT_NAME AMOUNT +e2e_one_pay_public_template() { + local pid="$1" + local pname="${2:-$1}" + local pamt="$3" + local tag + tag=$(printf 'shop_%s' "$pid" | tr -c 'A-Za-z0-9._-' '_') + section "e2e · shop product · $pname ($pid) · $pamt" + e2e_over && { warn "shop $pname" "budget exhausted — skip"; return 1; } + + 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") + local TPL_URI="taler://pay-template/${MH}/instances/${INST}/${pid}" + local TPL_HTTPS="${MERCHANT_PUBLIC}/instances/${INST}/templates/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$pid")" + info "shop $pname" "pay-template URI $TPL_URI" + info "shop $pname" "public template POST $TPL_HTTPS" + info "shop $pname" "settlement payto $E2E_SHOP_PAYTO" + + # Public create (no merchant secret) — same as shop-pay.js + curl -skS -m 15 -o "$SCRATCH/tpl-$tag.json" -X POST \ + -H 'Content-Type: application/json' \ + -d '{}' \ + "$TPL_HTTPS" 2>"$SCRATCH/tpl-$tag.err" || true + if ! grep -q order_id "$SCRATCH/tpl-$tag.json" 2>/dev/null; then + if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then + koopa_ssh_run 20 \ + "curl -skS -m 12 -X POST -H 'Content-Type: application/json' -d '{}' 'https://127.0.0.1:9010/instances/${INST}/templates/${pid}'" \ + >"$SCRATCH/tpl-$tag.json" 2>"$SCRATCH/tpl-$tag.err" || true + fi + fi + 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/tpl-$tag.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/tpl-$tag.json" 2>/dev/null || true) + if [ -z "$OID" ] || [ -z "$OTOK" ]; then + warn "shop $pname ($pid)" "public template POST failed — $(head -c 100 "$SCRATCH/tpl-$tag.json" 2>/dev/null | tr '\n' ' ')" + return 1 + fi + ok "shop $pname" "public order $OID (template $pid)" + + local statusUrl="${MERCHANT_PUBLIC}/instances/${INST}/orders/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OID")?token=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OTOK")" + curl -skS -m 12 -o "$SCRATCH/tpl-st-$tag.json" "$statusUrl" 2>/dev/null || true + PAYURI=$(python3 -c 'import json,sys +try: + d=json.load(open(sys.argv[1])) + print(d.get("taler_pay_uri") or "") +except Exception: print("") +' "$SCRATCH/tpl-st-$tag.json" 2>/dev/null || true) + if [ -z "$PAYURI" ]; then + PAYURI="taler://pay/${MH}/instances/${INST}/${OID}/?c=${OTOK}" + fi + PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g') + if ! printf '%s' "$PAYURI" | grep -q '^taler://pay/'; then + warn "shop $pname ($pid)" "bad pay URI: $PAYURI" + return 1 + fi + ok "shop $pname" "taler_pay_uri $PAYURI" + + if ! wcli_pay handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$tag.out" 2>&1; then + warn "shop $pname ($pid)" "handle-uri failed — $(tail -c 120 "$SCRATCH/pay-$tag.out" | tr '\n' ' ')" + return 1 + fi + ok "shop $pname" "wallet accepted pay URI" + + local r AUTH="" + if [ -n "${MPW:-}" ]; then + AUTH="Authorization: Bearer secret-token:${MPW}" + fi + for r in 1 2 3; do + wcli_pay run-until-done >"$SCRATCH/pay-run-$tag.out" 2>&1 || true + wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true + if [ -n "$AUTH" ]; then + curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \ + "${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true + if 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-$tag.json" 2>/dev/null; then + ok "shop $pname" "payment settled ($pamt · order $OID)" + 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)" + return 0 + fi + done + warn "shop $pname ($pid)" "not settled for order $OID" + return 1 +} + +# --------------------------------------------------------------------------- +section "e2e · ATM withdraw ladder" +# --------------------------------------------------------------------------- +WITHDRAW_OK=0 +WITHDRAW_OK_N=0 +WITHDRAW_LAG_N=0 +WITHDRAW_FAIL_N=0 +WITHDRAW_REPORT="" +for WITHDRAW_AMT in $WITHDRAW_LIST; do + e2e_over && { warn "ATM ladder" "time budget low — stopping more ATM withdraws (not a protocol error)"; break; } + set +e + e2e_one_withdraw "$WITHDRAW_AMT" + wc=$? + set -e + case "$wc" in + 0) + WITHDRAW_OK=1 + WITHDRAW_OK_N=$((WITHDRAW_OK_N + 1)) + WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=OK" + ;; + 2) + WITHDRAW_LAG_N=$((WITHDRAW_LAG_N + 1)) + WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=LAG" + ;; + *) + WITHDRAW_FAIL_N=$((WITHDRAW_FAIL_N + 1)) + WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=FAIL" + ;; + esac +done +info "ATM withdraw summary" "$WITHDRAW_REPORT (ok=$WITHDRAW_OK_N lag=$WITHDRAW_LAG_N fail=$WITHDRAW_FAIL_N)" + +# Settlement catch-up: bank may have confirmed while wallet was still empty +section "e2e · wallet settlement (timing)" +if ! wait_wallet_balance 0 "${E2E_SETTLE_ROUNDS}" "${E2E_SETTLE_SLEEP}"; then + if [ "$WITHDRAW_LAG_N" -gt 0 ] || [ "$WITHDRAW_OK_N" -gt 0 ]; then + warn "settlement timing" "ATM path reached bank confirm (lag=$WITHDRAW_LAG_N ok=$WITHDRAW_OK_N) but wallet empty after wait — TIME lag, not protocol error" + fi +fi +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 + WITHDRAW_OK=1 + if [ "$WITHDRAW_OK_N" = "0" ]; then + warn "settlement timing" "coins arrived after ATM ladder (${CUR}:${av_now}) — earlier 'FAIL' was timing lag" + WITHDRAW_REPORT="${WITHDRAW_REPORT} → late-OK avail=${CUR}:${av_now}" + fi + ok "spendable balance for payments" "${CUR}:${av_now}" +else + if [ "$WITHDRAW_OK" != "1" ]; then + warn "withdraw" "still no spendable ${CUR} after settle wait" + # diagnostic dig only — do not treat as hard stop if we can still see bank state + if [ "$E2E_REMOTE" != "1" ]; then + dig_when_no_coins || true + fi + # one last balance after dig (dig takes wall time — often enough for wirewatch) + wait_wallet_balance 0 8 3 || true + 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 + WITHDRAW_OK=1 + warn "settlement timing" "coins present after dig wait (${CUR}:${av_now}) — timing, not blocker" + else + blocker "withdraw-settle" "no spendable ${CUR} after ATM ladder + settle wait ($WITHDRAW_LIST)" + section "e2e · report" + info "WITHDRAW" "$WITHDRAW_REPORT — no coins after extended wait" + info "PAY" "SKIPPED (no spendable balance after settle wait)" + exit 1 + fi + fi +fi + +# --------------------------------------------------------------------------- +section "e2e · variable payments (if balance allows)" +# --------------------------------------------------------------------------- +PAY_OK=0 +PAY_OK_N=0 +PAY_FAIL_N=0 +PAY_SKIP_N=0 +PAY_REPORT="" +if [ -z "${MPW:-}" ]; then + if [ "$E2E_REMOTE" = "1" ]; then + e2e_abort_auth "merchant-order" "no merchant token — set E2E_MERCHANT_TOKEN" + fi + blocker "merchant-order" "no merchant token" + exit 1 +fi + +for PAY_AMT in $PAY_LIST; do + e2e_over && { warn "pay ladder" "time budget low — stopping more payments"; break; } + av_now=$(wallet_avail_num) + pay_n=$(python3 -c 'import sys; print(float(sys.argv[1].split(":",1)[-1]))' "$PAY_AMT" 2>/dev/null || echo 0) + if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-12 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_n" 2>/dev/null; then + warn "pay $PAY_AMT" "skip — insufficient avail ${CUR}:${av_now} (need ${pay_n})" + PAY_SKIP_N=$((PAY_SKIP_N + 1)) + PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=SKIP(bal)" + continue + fi + set +e + e2e_one_pay "$PAY_AMT" + pc=$? + set -e + if [ "$pc" = "0" ]; then + PAY_OK=1 + PAY_OK_N=$((PAY_OK_N + 1)) + PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=OK" + else + PAY_FAIL_N=$((PAY_FAIL_N + 1)) + PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=FAIL" + fi +done +info "pay summary" "$PAY_REPORT (ok=$PAY_OK_N fail=$PAY_FAIL_N skip=$PAY_SKIP_N)" +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 + warn "pay" "balance ${CUR}:${av_now} but no pay amount completed — check merchant/timing (not necessarily empty wallet)" + else + blocker "pay-settle" "no payment succeeded and no spendable balance ($PAY_LIST)" + fi +fi + +# --------------------------------------------------------------------------- +section "e2e · GOA shop products (merchant landing catalog)" +# --------------------------------------------------------------------------- +# Public template POST + wallet pay — full catalog list, random pick of N products. +SHOP_OK=0 +SHOP_OK_N=0 +SHOP_FAIL_N=0 +SHOP_SKIP_N=0 +SHOP_REPORT="" +if [ "$E2E_REMOTE" = "1" ] || [ "${CUR:-}" != "GOA" ]; then + info "goa-shop" "SKIPPED (remote or non-GOA currency)" +else + # Build catalog array from E2E_SHOP_PRODUCTS (id|name|amount lines) + SHOP_CATALOG=() + while IFS= read -r line; do + [ -z "$line" ] && continue + case "$line" in \#*) continue ;; esac + SHOP_CATALOG+=("$line") + done </dev/null || echo 0) + if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-12 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_n" 2>/dev/null; then + warn "goa-shop $pname ($pid)" "skip — insufficient avail ${CUR}:${av_now} (need ${pay_n})" + SHOP_SKIP_N=$((SHOP_SKIP_N + 1)) + SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=SKIP(bal)" + continue + fi + set +e + e2e_one_pay_public_template "$pid" "$pname" "$pamt" + pc=$? + set -e + if [ "$pc" = "0" ]; then + SHOP_OK=1 + SHOP_OK_N=$((SHOP_OK_N + 1)) + PAY_OK=1 + PAY_OK_N=$((PAY_OK_N + 1)) + SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=OK" + else + SHOP_FAIL_N=$((SHOP_FAIL_N + 1)) + SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=FAIL" + fi + done </dev/null || echo "(n/a)")" +info "scratch" "$SCRATCH" +# Success if we had coins and at least one pay, OR coins + only pay skips (nothing affordable) +if [ "${#BLOCKERS[@]}" -eq 0 ]; then + if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK" = "1" ]; then + exit 0 + fi + if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK_N" = "0" ] && [ "$PAY_FAIL_N" = "0" ]; then + warn "pay" "no payment tried/completed — withdraw OK" + exit 0 + fi + if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK" != "1" ]; then + # money there, pays failed → soft exit 1 without inventing blockers if already warned + exit 1 + fi +fi +exit 1 diff --git a/scripts/taler-monitoring/check_inside.sh b/scripts/taler-monitoring/check_inside.sh new file mode 100755 index 0000000..aba6115 --- /dev/null +++ b/scripts/taler-monitoring/check_inside.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Inside status for bank / exchange / merchant. Hard-capped SSH — never hang forever. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +# Area inside-### — container / process state on koopa (SSH) +set_area inside +section "inside · collect from koopa" + +if [ "${SKIP_SSH}" = "1" ]; then + warn "ssh" "SKIP_SSH=1 — skipped" + summary + exit 0 +fi + +if ! koopa_ssh_ok; then + err "ssh" "cannot reach ${KOOPA_SSH} in ${SSH_CONNECT_TIMEOUT}s — set SKIP_SSH=1 to skip inside" + summary + exit 1 +fi +ok "ssh ${KOOPA_SSH}" + +# One short remote script (≤ SSH_CMD_TIMEOUT). Every slow step is local curl -m 3 or quick pgrep. +RAW=$( + koopa_ssh_bash "${SSH_CMD_TIMEOUT}" <<'REMOTE' || true +set +e +emit() { printf 'E|%s|%s|%s|%s\n' "$1" "$2" "$3" "$(printf '%s' "${4:-}" | tr '\n\r' ' ' | head -c 200)"; } +# quick curl +hc() { curl -skS -m 3 -o /tmp/mb -w '%{http_code}' "$1" 2>/dev/null || echo 000; } +# quick process check inside container (pgrep only) +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) +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) + +# Domain resolve inside container (wirewatch needs bank.hacktivism.ch → real IP) +# emit: comp LEVEL dns "host → ip" or fail +check_dns() { + local comp="$1" ctr="$2" host="$3" + local line ip code + # Prefer IPv4 (wirewatch/libcurl often happier; avoid dead AAAA) + line=$(podman exec "$ctr" getent ahostsv4 "$host" 2>/dev/null | head -1) + [ -z "$line" ] && line=$(podman exec "$ctr" getent hosts "$host" 2>/dev/null | head -1) + ip=$(echo "$line" | awk '{print $1}') + if [ -z "$ip" ]; then + emit "$comp" ERROR "dns $host" "no resolve — run pin-container-hosts.sh" + return 1 + fi + # 127.0.0.1 is almost always wrong for public bank/exchange from inside pasta + if [ "$ip" = "127.0.0.1" ] || [ "$ip" = "::1" ]; then + emit "$comp" ERROR "dns $host" "$ip (loopback — wirewatch will fail)" + return 1 + fi + code=$(podman exec "$ctr" curl -skS -m 3 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000) + if [ "$code" = "200" ]; then + emit "$comp" OK "dns $host" "→ $ip /config=$code" + else + emit "$comp" WARN "dns $host" "→ $ip /config=$code" + fi +} + +if [ -z "$BANK" ]; then emit bank ERROR container "not running" +else + emit bank INFO container "$(podman ps --filter name=$BANK --format '{{.Names}} {{.Status}}' | head -1)" + emit bank INFO ports "$(podman ps --filter name=$BANK --format '{{.Ports}}' | head -1)" + hasp "$BANK" 'MainKt serve|libeufin-bank serve' && emit bank OK libeufin "running" || emit bank ERROR libeufin "not running — API/withdraw dead" + podman exec "$BANK" pg_isready -q 2>/dev/null && emit bank OK postgres "ready" || emit bank ERROR postgres "not ready" + c=$(hc http://127.0.0.1:9012/config) + [ "$c" = "200" ] && emit bank OK "local /config" "HTTP $c" || emit bank ERROR "local /config" "HTTP $c" + c=$(hc http://127.0.0.1:9012/taler-integration/config) + [ "$c" = "200" ] && emit bank OK "local integration" "HTTP $c" || emit bank ERROR "local integration" "HTTP $c" + hasp "$BANK" 'nginx' && emit bank OK nginx ":9013" || emit bank WARN nginx "not running" + check_dns bank "$BANK" bank.hacktivism.ch || true + check_dns bank "$BANK" exchange.hacktivism.ch || true +fi + +if [ -z "$EX" ]; then emit exchange ERROR container "not running" +else + emit exchange INFO container "$(podman ps --filter name=$EX --format '{{.Names}} {{.Status}}' | head -1)" + c=$(hc http://127.0.0.1:9011/config) + [ "$c" = "200" ] && emit exchange OK "local /config" "HTTP $c" || emit exchange ERROR "local /config" "HTTP $c" + c=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000) + [ "$c" = "200" ] && emit exchange OK "local /keys" "HTTP $c" || emit exchange ERROR "local /keys" "HTTP $c" + hasp "$EX" 'taler-exchange-httpd' && emit exchange OK httpd "running" || emit exchange ERROR httpd "not running" + hasp "$EX" 'taler-exchange-wirewatch' && emit exchange OK wirewatch "running" || emit exchange ERROR wirewatch "not running — withdraw stuck after bank confirm" + hasp "$EX" 'taler-exchange-aggregator' && emit exchange OK aggregator "running" || emit exchange WARN aggregator "not running" + hasp "$EX" 'taler-exchange-transfer' && emit exchange OK transfer "running" || emit exchange WARN transfer "not running" + # critical for wire gateway + check_dns exchange "$EX" bank.hacktivism.ch || true + check_dns exchange "$EX" exchange.hacktivism.ch || true + check_dns exchange "$EX" taler.hacktivism.ch || true +fi + +if [ -z "$MER" ]; then emit merchant ERROR container "not running" +else + emit merchant INFO container "$(podman ps --filter name=$MER --format '{{.Names}} {{.Status}}' | head -1)" + c=$(hc https://127.0.0.1:9010/config) + [ "$c" = "200" ] && emit merchant OK "local /config" "HTTP $c" || emit merchant ERROR "local /config" "HTTP $c" + hasp "$MER" 'taler-merchant-httpd' && emit merchant OK httpd "running" || emit merchant ERROR httpd "not running" + hasp "$MER" 'taler-merchant-wirewatch' && emit merchant OK wirewatch "running" || emit merchant WARN wirewatch "not running" + hasp "$MER" 'taler-merchant-depositcheck' && emit merchant OK depositcheck "running" || emit merchant WARN depositcheck "not running" + check_dns merchant "$MER" bank.hacktivism.ch || true + check_dns merchant "$MER" exchange.hacktivism.ch || true + check_dns merchant "$MER" taler.hacktivism.ch || true +fi + +if systemctl is-active caddy >/dev/null 2>&1 || pgrep -x caddy >/dev/null 2>&1; then + emit caddy OK process "active" +else + emit caddy ERROR process "not active" +fi +echo DONE +REMOTE +) + +if [ -z "$RAW" ] || ! echo "$RAW" | grep -q '^E|'; then + err "ssh" "remote timed out or empty (cap ${SSH_CMD_TIMEOUT}s)" + summary + exit 1 +fi + +while IFS= read -r line; do + case "$line" in + E\|*) + IFS='|' read -r _ comp level key detail <<<"$line" + case "$level" in + OK) ok "[$comp] $key${detail:+ ($detail)}" ;; + ERROR) err "$comp" "$key" "$detail" ;; + WARN) warn "[$comp] $key" "$detail" ;; + INFO) info "[$comp] $key" "$detail" ;; + esac + ;; + esac +done <<<"$RAW" + +summary diff --git a/scripts/taler-monitoring/check_sanity.sh b/scripts/taler-monitoring/check_sanity.sh new file mode 100755 index 0000000..1486e9b --- /dev/null +++ b/scripts/taler-monitoring/check_sanity.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# Sanity checks for bank · exchange · merchant (public + server-side). +# Sections are independent; continues after failures. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +expect_code() { + local label="$1" want="$2" url="$3" + local code + code=$(http_code "$url") + case ",$want," in + *",$code,"*) ok "$label" ;; + *) fail "$label" "HTTP $code (want $want) $url" ;; + esac +} + +json_currency() { + python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("currency") or "")' "$1" 2>/dev/null || true +} + +# Area sanity-### — public + optional server-side per component +set_area sanity + +# --------------------------------------------------------------------------- +section "sanity · bank" +# --------------------------------------------------------------------------- +expect_code "bank public /config" 200 "$BANK_PUBLIC/config" +expect_code "bank public /taler-integration/config" 200 "$BANK_PUBLIC/taler-integration/config" +expect_code "bank public /webui/" 200 "$BANK_PUBLIC/webui/" + +code=$(http_body "$BANK_PUBLIC/config" "$tmp/bank-config.json") +if [ "$code" = "200" ]; then + cur=$(json_currency "$tmp/bank-config.json") + [ "$cur" = "GOA" ] && ok "bank currency GOA" || fail "bank currency" "got ${cur:-?}" + # wire type / name if present + python3 - "$tmp/bank-config.json" <<'PY' 2>/dev/null && ok "bank config JSON object" || fail "bank config JSON" +import json,sys +d=json.load(open(sys.argv[1])) +sys.exit(0 if isinstance(d, dict) and d.get("currency") else 1) +PY + if json_has_alt_unit_names "$tmp/bank-config.json" >/tmp/alt-bank-s.$$ 2>&1; then + ok "bank /config alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000) +echo "LOCAL_CONFIG=$code" +code2=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:9012/taler-integration/config 2>/dev/null || echo 000) +echo "LOCAL_INT=$code2" +if podman exec "$BANK" bash -c 'pgrep -f "MainKt serve|libeufin-bank serve" >/dev/null' 2>/dev/null; then + echo "LIBEUFIN=1" +else + echo "LIBEUFIN=0" +fi +if podman exec "$BANK" bash -c 'pg_isready -q' 2>/dev/null; then + echo "PG=1" +else + echo "PG=0" +fi +# in-container health if present +if podman exec "$BANK" test -x /usr/local/bin/check_bank-health.sh 2>/dev/null; then + podman exec "$BANK" /usr/local/bin/check_bank-health.sh 2>&1 | sed 's/^/HEALTH /' | tail -20 + echo "HEALTH_EC=${PIPESTATUS[0]}" +fi +REMOTE +) + echo "$BOUT" | grep -q '^CTR=.\+' && ok "bank container $(echo "$BOUT" | sed -n 's/^CTR=//p' | head -1)" || fail "bank container" "not found" + echo "$BOUT" | grep -q 'LOCAL_CONFIG=200' && ok "bank local :9012/config" || fail "bank local :9012/config" + echo "$BOUT" | grep -q 'LOCAL_INT=200' && ok "bank local :9012/taler-integration/config" || fail "bank local integration" + echo "$BOUT" | grep -q 'LIBEUFIN=1' && ok "bank libeufin-bank process" || fail "bank libeufin-bank process" + echo "$BOUT" | grep -q 'PG=1' && ok "bank postgres ready" || warn "bank postgres" "pg_isready failed" + if echo "$BOUT" | grep -q 'HEALTH '; then + if echo "$BOUT" | grep -qE 'HEALTH_EC=0|ALL CRITICAL CHECKS PASSED'; then + ok "bank check_bank-health.sh" + else + # health script may false-fail process grep; warn not fail if local config ok + warn "bank check_bank-health.sh" "non-zero or incomplete" + fi + fi +else + warn "bank server-side" "ssh ${KOOPA_SSH} unavailable" +fi + +# --------------------------------------------------------------------------- +section "sanity · exchange" +# --------------------------------------------------------------------------- +expect_code "exchange public /config" 200 "$EXCHANGE_PUBLIC/config" +expect_code "exchange public /keys" 200 "$EXCHANGE_PUBLIC/keys" +expect_code "exchange public /terms" 200 "$EXCHANGE_PUBLIC/terms" + +code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ex-config.json") +if [ "$code" = "200" ]; then + cur=$(json_currency "$tmp/ex-config.json") + [ "$cur" = "GOA" ] && ok "exchange currency GOA" || fail "exchange currency" "got ${cur:-?}" + if json_has_alt_unit_names "$tmp/ex-config.json" "GOA" >/tmp/alt-ex-s.$$ 2>&1; then + ok "exchange /config alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000) +echo "LOCAL_CONFIG=$code" +codek=$(curl -sS -m 8 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000) +echo "LOCAL_KEYS=$codek" +if [ -n "$EX" ]; then + if podman exec "$EX" bash -c 'pgrep -f taler-exchange-httpd >/dev/null' 2>/dev/null; then + echo "HTTPD=1" + else + echo "HTTPD=0" + fi + for p in taler-exchange-secmod-rsa taler-exchange-secmod-eddsa taler-exchange-wirewatch taler-exchange-aggregator; do + if podman exec "$EX" bash -c "pgrep -f $p >/dev/null" 2>/dev/null; then + echo "PROC_$p=1" + else + echo "PROC_$p=0" + fi + done + if podman exec "$EX" test -x /usr/local/bin/check_exchange-health.sh 2>/dev/null; then + SKIP_ENSURE=1 podman exec -e SKIP_ENSURE=1 "$EX" /usr/local/bin/check_exchange-health.sh 2>&1 | sed 's/^/HEALTH /' | tail -25 + fi +fi +REMOTE +) + echo "$EOUT" | grep -q '^CTR=.\+' && ok "exchange container $(echo "$EOUT" | sed -n 's/^CTR=//p' | head -1)" || fail "exchange container" + echo "$EOUT" | grep -q 'LOCAL_CONFIG=200' && ok "exchange local :9011/config" || fail "exchange local :9011/config" + echo "$EOUT" | grep -q 'LOCAL_KEYS=200' && ok "exchange local :9011/keys" || fail "exchange local :9011/keys" + echo "$EOUT" | grep -q 'HTTPD=1' && ok "exchange-httpd process" || warn "exchange-httpd process" "not detected" + echo "$EOUT" | grep -q 'PROC_taler-exchange-wirewatch=1' && ok "exchange wirewatch" || warn "exchange wirewatch" "not running" + echo "$EOUT" | grep -q 'PROC_taler-exchange-aggregator=1' && ok "exchange aggregator" || warn "exchange aggregator" "not running" +else + warn "exchange server-side" "ssh unavailable" +fi + +# --------------------------------------------------------------------------- +section "sanity · merchant" +# --------------------------------------------------------------------------- +expect_code "merchant public /config" 200 "$MERCHANT_PUBLIC/config" +expect_code "merchant public /webui/" 200 "$MERCHANT_PUBLIC/webui/" + +code=$(http_body "$MERCHANT_PUBLIC/config" "$tmp/mer-config.json") +if [ "$code" = "200" ]; then + if python3 - "$tmp/mer-config.json" "$EXCHANGE_PUBLIC" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +want=sys.argv[2].rstrip("/") +curs=list((d.get("currencies") or {}).keys()) +ex=d.get("exchanges") or [] +urls=[] +for e in ex: + if isinstance(e, dict): + u=e.get("base_url") or e.get("url") or e.get("exchange_base_url") or "" + if u: urls.append(u.rstrip("/")) + elif isinstance(e, str): + urls.append(e.rstrip("/")) +ok_goa = "GOA" in curs or any((e.get("currency") if isinstance(e, dict) else None)=="GOA" for e in ex) +ok_ex = any(want in u or "exchange.hacktivism.ch" in u for u in urls) +print("currencies", curs) +print("exchanges", urls[:5]) +sys.exit(0 if ok_goa and ok_ex else 1) +PY + then + ok "merchant config GOA + exchange.hacktivism.ch" + else + fail "merchant config GOA + exchange" "see currencies/exchanges" + fi + if json_has_alt_unit_names "$tmp/mer-config.json" >/tmp/alt-mer-s.$$ 2>&1; then + ok "merchant currencies alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000) +echo "LOCAL_CONFIG=$code" +if [ -n "$MER" ]; then + if podman exec "$MER" bash -c 'pgrep -f taler-merchant-httpd >/dev/null' 2>/dev/null; then + echo "HTTPD=1" + else + echo "HTTPD=0" + fi + if podman exec "$MER" test -x /usr/local/bin/check_merchant-health.sh 2>/dev/null; then + SKIP_ENSURE=1 podman exec -e SKIP_ENSURE=1 "$MER" /usr/local/bin/check_merchant-health.sh 2>&1 | sed 's/^/HEALTH /' | tail -30 + fi +fi +REMOTE +) + echo "$MOUT" | grep -q '^CTR=.\+' && ok "merchant container $(echo "$MOUT" | sed -n 's/^CTR=//p' | head -1)" || fail "merchant container" + echo "$MOUT" | grep -q 'LOCAL_CONFIG=200' && ok "merchant local :9010/config" || fail "merchant local :9010/config" + echo "$MOUT" | grep -q 'HTTPD=1' && ok "merchant-httpd process" || warn "merchant-httpd process" "not detected" + if echo "$MOUT" | grep -q 'HEALTH '; then + if echo "$MOUT" | grep -qiE 'ALL CRITICAL|HEALTH_EC=0|\[OK\]'; then + ok "merchant check_merchant-health.sh (sample OK)" + else + warn "merchant check_merchant-health.sh" "see remote output" + fi + fi +else + warn "merchant server-side" "ssh unavailable" +fi + +summary diff --git a/scripts/taler-monitoring/check_server.sh b/scripts/taler-monitoring/check_server.sh new file mode 100755 index 0000000..961b4c8 --- /dev/null +++ b/scripts/taler-monitoring/check_server.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Server-side component checks on koopa (via SSH). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +# Area server-### — host/container ports via SSH +set_area server +section "server · ssh ${KOOPA_SSH}" + +if ! koopa_ssh_ok; then + fail "ssh ${KOOPA_SSH}" "unreachable within ${SSH_CONNECT_TIMEOUT}s (SKIP_SSH=1 to skip)" + summary + exit 1 +fi +ok "ssh ${KOOPA_SSH}" + +# Run a remote script; collect structured lines +REMOTE=$(koopa_ssh_bash "${SSH_CMD_TIMEOUT}" <<'REMOTE' +set +e +report() { printf 'R|%s|%s|%s\n' "$1" "$2" "$3"; } + +# containers +for name in taler-hacktivism-bank taler-hacktivism-exchange-ansible taler-hacktivism; do + if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then + st=$(podman ps --filter "name=^${name}$" --format '{{.Status}}' | head -1) + report OK "container $name" "$st" + else + # soft match + hit=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E "$name|bank|exchange|hacktivism" | head -3 | tr '\n' ' ') + if podman ps --format '{{.Names}}' 2>/dev/null | grep -q bank && [ "$name" = taler-hacktivism-bank ]; then + bn=$(podman ps --format '{{.Names}}' | grep -i bank | head -1) + report OK "container bank ($bn)" "$(podman ps --filter name="$bn" --format '{{.Status}}' | head -1)" + elif podman ps --format '{{.Names}}' 2>/dev/null | grep -qi exchange && echo "$name" | grep -qi exchange; then + en=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1) + report OK "container exchange ($en)" "$(podman ps --filter name="$en" --format '{{.Status}}' | head -1)" + elif podman ps --format '{{.Names}}' 2>/dev/null | grep -qx taler-hacktivism && [ "$name" = taler-hacktivism ]; then + report OK "container taler-hacktivism" "$(podman inspect -f '{{.State.Status}}' taler-hacktivism 2>/dev/null)" + else + report FAIL "container $name" "not running (seen: $hit)" + fi + fi +done + +# local HTTP on pasta ports +for spec in \ + "bank-api|http://127.0.0.1:9012/config|200" \ + "bank-integration|http://127.0.0.1:9012/taler-integration/config|200" \ + "landing|http://127.0.0.1:9013/intro/|200" \ + "exchange|http://127.0.0.1:9011/config|200" \ + "merchant|https://127.0.0.1:9010/config|200" +do + IFS='|' read -r id url want <<<"$spec" + code=$(curl -skS -m 5 -o /tmp/mon-s.out -w '%{http_code}' "$url" 2>/dev/null || echo 000) + if [ "$code" = "$want" ]; then + report OK "local $id : ${url#*//}" "HTTP $code" + else + report FAIL "local $id" "HTTP $code want $want" + fi +done + +# processes inside bank +BANK=$(podman ps --format '{{.Names}}' | grep -iE 'bank|hacktivism-bank' | head -1) +if [ -n "$BANK" ]; then + if podman exec "$BANK" bash -c 'pgrep -f "MainKt serve|libeufin-bank serve" >/dev/null' 2>/dev/null; then + report OK "libeufin-bank process" "in $BANK" + else + report FAIL "libeufin-bank process" "not running in $BANK" + fi + if podman exec "$BANK" bash -c 'pg_isready -q' 2>/dev/null; then + report OK "postgres (bank)" "ready" + else + report WARN "postgres (bank)" "pg_isready failed" + fi + if podman exec "$BANK" bash -c 'pgrep -x nginx >/dev/null' 2>/dev/null; then + report OK "nginx landing" "in $BANK" + else + report WARN "nginx landing" "not seen in $BANK" + fi +else + report FAIL "bank container" "none" +fi + +# exchange process / systemd if any +EX=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1) +if [ -n "$EX" ]; then + if podman exec "$EX" bash -c 'pgrep -f taler-exchange-httpd >/dev/null || systemctl is-active taler-exchange-httpd 2>/dev/null | grep -q active' 2>/dev/null; then + report OK "exchange-httpd" "in $EX" + else + # config answering is enough + report WARN "exchange-httpd process" "not detected; port check above" + fi +fi + +MER=$(podman ps --format '{{.Names}}' | grep -E '^taler-hacktivism$' | head -1) +[ -z "$MER" ] && MER=$(podman ps --format '{{.Names}}' | grep -i merchant | head -1) +if [ -n "$MER" ]; then + if podman exec "$MER" bash -c 'pgrep -f taler-merchant-httpd >/dev/null || true; curl -sk -m 3 -o /dev/null -w %{http_code} https://127.0.0.1:9010/config' 2>/dev/null | grep -q 200; then + report OK "merchant-httpd" "responds in $MER" + else + report WARN "merchant-httpd" "check manually in $MER" + fi +fi + +# caddy on host +if systemctl is-active caddy >/dev/null 2>&1 || pgrep -x caddy >/dev/null 2>&1; then + report OK "caddy" "active" +else + report WARN "caddy" "not detected as active" +fi +REMOTE +) + +while IFS= read -r line; do + case "$line" in + R\|*) + IFS='|' read -r _ st label detail <<<"$line" + case "$st" in + OK) ok "$label${detail:+ ($detail)}" ;; + FAIL) fail "$label" "$detail" ;; + WARN) warn "$label" "$detail" ;; + esac + ;; + esac +done <<<"$REMOTE" + +summary diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh new file mode 100755 index 0000000..2f7d1de --- /dev/null +++ b/scripts/taler-monitoring/check_urls.sh @@ -0,0 +1,636 @@ +#!/usr/bin/env bash +# Outside-in public HTTPS checks (no SSH). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +# Area www-### — public HTTPS (outside-in) +set_area www +section "www · public URLs · ${TALER_DOMAIN:-?} (outside-in, no SSH)" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +check_url() { + local label="$1" expect="$2" url="$3" + local code + code=$(http_code "$url") + case ",$expect," in + *",$code,"*) ok "$label $url" ;; + *) fail "$label $url" "got $code want $expect" ;; + esac +} + +# Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack +check_url_soft() { + local label="$1" expect="$2" url="$3" + local code + code=$(http_code "$url") + case ",$expect," in + *",$code,"*) ok "$label $url" ;; + *) + if [ "${LOCAL_STACK:-1}" = "0" ]; then + warn "$label $url" "got $code (optional on remote domain)" + else + fail "$label $url" "got $code want $expect" + fi + ;; + esac +} + +expect_currency() { + local label="$1" file="$2" want="${EXPECT_CURRENCY:-}" + local cur + cur=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("currency",""))' "$file" 2>/dev/null || true) + if [ -z "$want" ]; then + info "$label currency" "${cur:-?}" + return + fi + if [ "$cur" = "$want" ]; then + ok "$label currency=$want" + else + fail "$label currency" "got ${cur:-?} want $want" + fi +} + +# Legal docs: /terms and /privacy must be HTTP 200 with a real document body +# (not empty, not "not configured", not JSON API error). +# $1=label $2=url $3=optional needle regex (case-insensitive) for local stack +check_legal_doc() { + local label="$1" url="$2" needle="${3:-}" + local f code soft + soft=0 + [ "${LOCAL_STACK:-1}" = "0" ] && soft=1 + f=$(mktemp) + code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" \ + -H "Accept: text/html,text/markdown,text/plain,*/*" \ + -o "$f" -w '%{http_code}' "$url" 2>/dev/null || echo 000) + if [ "$code" != "200" ]; then + rm -f "$f" + if [ "$soft" = "1" ]; then + warn "$label" "HTTP $code — $url" + else + fail "$label" "HTTP $code — $url" + fi + return + fi + if [ ! -s "$f" ]; then + rm -f "$f" + fail "$label" "empty body — $url" + return + fi + # merchant returns plain "not configured" when PRIVACY_ETAG missing + if grep -qiE '^(not configured)\s*$' "$f" 2>/dev/null \ + || grep -qiE '"code"\s*:\s*21' "$f" 2>/dev/null; then + rm -f "$f" + fail "$label" "not configured / API error — $url" + return + fi + if [ -n "$needle" ] && [ "${LOCAL_STACK:-1}" = "1" ]; then + if ! grep -qiE "$needle" "$f" 2>/dev/null; then + warn "$label content" "missing /$needle/ — $url" + rm -f "$f" + return + fi + fi + ok "$label" "HTTP 200 · $(wc -c <"$f" | tr -d ' ') bytes" + rm -f "$f" +} + +# --- exchange (core; always required) --- www-001 … +check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config" +code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ec.json") +if [ "$code" = "200" ]; then + expect_currency "exchange" "$tmp/ec.json" + # currency_specification.alt_unit_names (wallet codec) + if json_has_alt_unit_names "$tmp/ec.json" "${EXPECT_CURRENCY:-}" >/tmp/alt-ex.$$ 2>&1; then + ok "exchange /config alt_unit_names" "$(tr '\n' '; ' /dev/null || echo "000 0") + code=$(printf '%s' "$out" | awk '{print $1}') + t_s=$(printf '%s' "$out" | awk '{print $2}') + ms=$(awk -v t="${t_s:-0}" 'BEGIN{ + ms=(t+0)*1000 + if (ms>0 && ms<1) ms=1 + printf "%d", int(ms+0.5) + }') + case ",$expect," in + *",$code,"*) + if [ "$ms" -ge "${PERF_FAIL_MS}" ] 2>/dev/null; then + fail "$label" "HTTP $code · ${ms} ms ≥ fail ${PERF_FAIL_MS} ms · $url" + elif [ "$ms" -ge "${PERF_WARN_MS}" ] 2>/dev/null; then + warn "$label" "HTTP $code · ${ms} ms ≥ warn ${PERF_WARN_MS} ms · $url" + else + ok "$label" "HTTP $code · ${ms} ms · $url" + fi + ;; + *) + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "$label" "HTTP $code want $expect · ${ms} ms · $url" + else + warn "$label" "HTTP $code want $expect · ${ms} ms · $url" + fi + ;; + esac +} + +# Bank first (wallet-critical paths before UI chrome) +check_perf "perf bank /taler-integration/config" "$BANK_PUBLIC/taler-integration/config" +check_perf "perf bank /config" "$BANK_PUBLIC/config" +check_perf "perf bank /intro/" "$BANK_PUBLIC/intro/" +check_perf "perf bank /intro/stats.json" "$BANK_PUBLIC/intro/stats.json" 200 +check_perf "perf bank /webui/" "$BANK_PUBLIC/webui/" 200,301,302 + +# Exchange +check_perf "perf exchange /config" "$EXCHANGE_PUBLIC/config" +check_perf "perf exchange /keys" "$EXCHANGE_PUBLIC/keys" +check_perf "perf exchange /intro/" "$EXCHANGE_PUBLIC/intro/" + +# Merchant +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" + + +# Terms + privacy (legal docs) +check_legal_doc "exchange /terms" "$EXCHANGE_PUBLIC/terms" "terms|GOA|exploration|FADP|revDSG|privacy" +check_legal_doc "exchange /privacy" "$EXCHANGE_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|exploration" +# trailing slash: 200 or redirect to bare path +code=$(http_code "$EXCHANGE_PUBLIC/terms/") +case "$code" in + 200|301|302) ok "exchange /terms/" "HTTP $code" ;; + *) + if [ "${LOCAL_STACK:-1}" = "1" ]; then warn "exchange /terms/" "HTTP $code" + else warn "exchange /terms/" "HTTP $code" + fi + ;; +esac + +# --- bank --- +if [ "${LOCAL_STACK:-1}" = "0" ]; then + check_url_soft "bank /config" 200 "$BANK_PUBLIC/config" +else + check_url "bank /config" 200 "$BANK_PUBLIC/config" +fi +code=$(http_body "$BANK_PUBLIC/config" "$tmp/bc.json") +if [ "$code" = "200" ]; then + expect_currency "bank" "$tmp/bc.json" + if json_has_alt_unit_names "$tmp/bc.json" >/tmp/alt-bank.$$ 2>&1; then + ok "bank /config alt_unit_names" "$(tr '\n' '; ' /dev/null || true)" + else + fail "bank /intro/auto-account.json" "invalid withdraw/login (HTTP body bad)" + fi + ;; + 405|501|404|502|503|000) + fail "bank /intro/auto-account.json" "HTTP $aa_code (want 200; 405/501 = broken)" + ;; + *) + fail "bank /intro/auto-account.json" "HTTP $aa_code want 200" + ;; + esac +fi + +# Bank legal docs (landing nginx via Caddy /terms* /privacy* or /intro/*) +check_legal_doc "bank /terms" "$BANK_PUBLIC/terms" "terms|GOA|exploration|bank|FADP|revDSG" +# Prefer /privacy; fall back to /intro/privacy.html for older deploys +code=$(http_code "$BANK_PUBLIC/privacy") +if [ "$code" = "200" ]; then + check_legal_doc "bank /privacy" "$BANK_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|bank" +else + if [ "${LOCAL_STACK:-1}" = "1" ]; then + check_legal_doc "bank /privacy (or /intro/privacy.html)" \ + "$BANK_PUBLIC/intro/privacy.html" "privacy|FADP|revDSG|data|GOA|bank" + # still report bare /privacy failure for local + warn "bank /privacy" "HTTP $code — prefer Caddy handle /privacy* → landing" + else + check_url_soft "bank /privacy" 200 "$BANK_PUBLIC/privacy" + fi +fi + +# --- merchant --- +if [ "${LOCAL_STACK:-1}" = "0" ]; then + check_url_soft "merchant /config" 200 "$MERCHANT_PUBLIC/config" +else + check_url "merchant /config" 200 "$MERCHANT_PUBLIC/config" +fi +code=$(http_body "$MERCHANT_PUBLIC/config" "$tmp/mc.json") +if [ "$code" = "200" ]; then + want="${EXPECT_CURRENCY:-}" + if python3 - "$tmp/mc.json" "$want" <<'PY' +import json,sys +try: + d=json.load(open(sys.argv[1])) +except Exception: + sys.exit(2) +want=sys.argv[2] +if not want: + sys.exit(0) +curs=list((d.get("currencies") or {}).keys()) +ex=d.get("exchanges") or [] +ok = want in curs or any((e.get("currency") if isinstance(e,dict) else None)==want for e in ex) +if not ok and isinstance(d.get("currency"), str): + ok = d["currency"]==want +sys.exit(0 if ok else 1) +PY + then + ok "merchant /config (${want:-currency} ok)" + else + ec=$? + if [ "$ec" = "2" ]; then + warn "merchant /config" "non-JSON body" + elif [ -n "$want" ]; then + fail "merchant /config currency" "want $want" + else + info "merchant /config" "ok" + fi + fi + # merchant-local currency maps (GOA + CHF, …) + if json_has_alt_unit_names "$tmp/mc.json" >/tmp/alt-mer.$$ 2>&1; then + ok "merchant /config currencies alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000) + if [ "$code" = "200" ]; then + ok "$label" "HTTP redirect→200 · $url" + elif [ "$soft" = "1" ]; then + warn "$label" "HTTP $code — $url" + else + fail "$label" "HTTP $code after redirect — $url" + fi + ;; + *) + if [ "$soft" = "1" ]; then + warn "$label" "HTTP $code — $url" + else + fail "$label" "HTTP $code — $url" + fi + ;; + esac +} + +# Soft external (app stores / upstream docs): WARN if down, never ERROR +check_external_soft() { + local label="$1" url="$2" + local code + code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000) + case "$code" in + 200|204|301|302|303|307|308) ok "$label" "HTTP $code · $url" ;; + *) warn "$label" "HTTP $code (external soft) · $url" ;; + esac +} + +# Parse one landing HTML: collect absolute https + root-relative href/src; +# resolve against base; classify own-stack vs external. +# Writes lists: $1.own $1.ext (one URL per line) +extract_landing_urls() { + local base="$1" html="$2" out_prefix="$3" + python3 - "$base" "$html" "$out_prefix" <<'PY' +import re, sys +from urllib.parse import urljoin, urlparse + +base, html_path, out = sys.argv[1], sys.argv[2], sys.argv[3] +html = open(html_path, encoding="utf-8", errors="replace").read() +base = base.rstrip("/") + "/" +parsed_base = urlparse(base) +# Hard-check only the three public Taler hosts for this stack (not git.* etc.) +own_hosts = { + (parsed_base.hostname or "").lower(), + "bank.hacktivism.ch", + "exchange.hacktivism.ch", + "taler.hacktivism.ch", +} +# include configured public hosts when domain differs (demo / ops) +for envu in ( + __import__("os").environ.get("BANK_PUBLIC", ""), + __import__("os").environ.get("EXCHANGE_PUBLIC", ""), + __import__("os").environ.get("MERCHANT_PUBLIC", ""), +): + h = urlparse(envu).hostname if envu else None + if h: + own_hosts.add(h.lower()) + +raw = set() +for m in re.finditer( + r'''(?:href|src|content)=["']([^"'#]+)["']''', html, re.I +): + raw.add(m.group(1).strip()) +# bare absolute URLs in scripts (fetch, template strings) +for m in re.finditer(r'''https://[^\s"'<>\\]+''', html): + u = m.group(0).rstrip("\\).,;'\"") + # strip trailing punctuation leftovers + while u and u[-1] in ".,);]}\"'": + u = u[:-1] + if u.startswith("https://"): + raw.add(u) + +own, ext = set(), set() +skip_prefix = ("data:", "javascript:", "mailto:", "taler://", "blob:") +skip_exact = {"website", "summary_large_image", "image/png", "en_US"} +for r in raw: + if not r or r in skip_exact: + continue + if r.startswith(skip_prefix): + continue + # meta content noise + if re.fullmatch(r"\d+", r) or r.startswith("width="): + continue + if " " in r and not r.startswith("http"): + continue + if r.startswith("//"): + absu = "https:" + r + elif r.startswith("http://") or r.startswith("https://"): + absu = r + elif r.startswith("/"): + absu = urljoin(base, r) + else: + # relative asset + if "/" in r or r.endswith((".js", ".css", ".png", ".svg", ".html", ".json", ".uri")): + absu = urljoin(base + "intro/", r) + else: + continue + # drop query-only noise / anchors already stripped + p = urlparse(absu) + if p.scheme not in ("http", "https"): + continue + # normalize: drop fragment + absu = absu.split("#", 1)[0] + host = (p.hostname or "").lower() + # og image query ok + if host in own_hosts: + own.add(absu) + else: + ext.add(absu) + +open(out + ".own", "w").write("\n".join(sorted(own)) + ("\n" if own else "")) +open(out + ".ext", "w").write("\n".join(sorted(ext)) + ("\n" if ext else "")) +print(f"own={len(own)} ext={len(ext)}") +PY +} + +check_one_landing() { + local name="$1" base="$2" + local html="$tmp/landing-${name}.html" + local pref="$tmp/urls-${name}" + local code n own_n ext_n + code=$(http_body "${base}/intro/" "$html") + if [ "$code" != "200" ]; then + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "landing ${name} /intro/" "HTTP $code" + else + warn "landing ${name} /intro/" "HTTP $code" + fi + return + fi + ok "landing ${name} /intro/" "HTTP 200 · $(wc -c <"$html" | tr -d ' ') bytes" + + # Required static assets (hard on local) + check_landing_asset "landing ${name} qrcode.min.js" "${base}/intro/qrcode.min.js" + check_landing_asset "landing ${name} og-goa-shop.png" "${base}/intro/og-goa-shop.png" + check_landing_asset "landing ${name} qr-logo.png" "${base}/intro/qr-logo.png" 1 + + n=$(extract_landing_urls "$base" "$html" "$pref" 2>/dev/null || echo "own=0 ext=0") + info "landing ${name} link extract" "$n" + own_n=0 + ext_n=0 + [ -f "${pref}.own" ] && own_n=$(grep -c . "${pref}.own" 2>/dev/null || echo 0) + [ -f "${pref}.ext" ] && ext_n=$(grep -c . "${pref}.ext" 2>/dev/null || echo 0) + if [ "${own_n:-0}" -lt 1 ]; then + fail "landing ${name} own-stack links" "none extracted from HTML" + else + ok "landing ${name} own-stack links" "${own_n} URLs to probe" + fi + + # Probe every own-stack URL from the page + if [ -f "${pref}.own" ]; then + while IFS= read -r u; do + [ -n "$u" ] || continue + # skip mint endpoints that create resources on GET if any (auto-account creates accounts) + case "$u" in + */intro/auto-account.json) + # shape checked separately; still require 200 GET + ;; + esac + code=$(http_code "$u") + case "$code" in + 200) ok "landing ${name} link" "HTTP 200 · $u" ;; + 301|302|303|307|308) + code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$u" 2>/dev/null || echo 000) + if [ "$code" = "200" ]; then + ok "landing ${name} link" "redirect→200 · $u" + else + fail "landing ${name} link" "HTTP $code after redirect · $u" + fi + ;; + 405|501) + # some APIs reject wrong method — try GET already failed; soft note + fail "landing ${name} link" "HTTP $code · $u" + ;; + *) + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "landing ${name} link" "HTTP $code · $u" + else + warn "landing ${name} link" "HTTP $code · $u" + fi + ;; + esac + done < "${pref}.own" + fi + + # External store / docs: soft + if [ -f "${pref}.ext" ]; then + while IFS= read -r u; do + [ -n "$u" ] || continue + check_external_soft "landing ${name} external" "$u" + done < "${pref}.ext" + fi +} + +check_one_landing "bank" "$BANK_PUBLIC" +check_one_landing "merchant" "$MERCHANT_PUBLIC" +check_one_landing "exchange" "$EXCHANGE_PUBLIC" + +# Cross-links between the three landings (always on local stack) +if [ "${LOCAL_STACK:-1}" = "1" ]; then + check_landing_asset "cross bank→merchant intro" "$MERCHANT_PUBLIC/intro/" + check_landing_asset "cross bank→exchange intro" "$EXCHANGE_PUBLIC/intro/" + check_landing_asset "cross merchant→bank intro" "$BANK_PUBLIC/intro/" + check_landing_asset "cross exchange→bank intro" "$BANK_PUBLIC/intro/" +fi + +# Bank-only: shared-pool withdraw mint + static withdraw files + shop assets +if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then + check_landing_asset "bank shop-pay.js" "$BANK_PUBLIC/intro/shop-pay.js" 1 + check_landing_asset "bank shop-pay.css" "$BANK_PUBLIC/intro/shop-pay.css" 1 + 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 [ -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" + fi + ;; + 405|501|404|502|503|000) + fail "bank /intro/demo-withdraw.json" "HTTP $dw_code (want 200)" + ;; + *) + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "bank /intro/demo-withdraw.json" "HTTP $dw_code want 200" + else + warn "bank /intro/demo-withdraw.json" "HTTP $dw_code" + fi + ;; + esac +fi + +# Merchant landing shop assets +check_landing_asset "merchant shop-pay.js" "$MERCHANT_PUBLIC/intro/shop-pay.js" 1 +check_landing_asset "merchant shop-pay.css" "$MERCHANT_PUBLIC/intro/shop-pay.css" 1 + +summary diff --git a/scripts/taler-monitoring/check_versions.sh b/scripts/taler-monitoring/check_versions.sh new file mode 100755 index 0000000..eed9e5f --- /dev/null +++ b/scripts/taler-monitoring/check_versions.sh @@ -0,0 +1,469 @@ +#!/usr/bin/env bash +# Area versions-### — Taler packages vs deb.taler.net (trixie) + repo availability. +# +# Checks: +# 1) deb.taler.net apt endpoints reachable (InRelease / Packages / sample .deb) +# 2) containers can reach deb.taler.net (install path from inside) +# 3) installed taler*/libeufin*/libtaler*/libdonau* versions vs suite index +# +# Env: +# TALER_APT_SUITE=trixie +# TALER_APT_BASE=https://deb.taler.net/apt/debian +# TALER_APT_INDEX=…/dists/trixie/main/binary-amd64/Packages +# TALER_APT_TESTING_INDEX=…/dists/trixie-testing/… +# TALER_PKG_BEHIND=warn|error (default: core packages ERROR if behind, else warn) +# SKIP_SSH=1 skip container install checks +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +set_area versions + +SUITE="${TALER_APT_SUITE:-trixie}" +APT_BASE="${TALER_APT_BASE:-https://deb.taler.net/apt/debian}" +APT_BASE="${APT_BASE%/}" +INDEX="${TALER_APT_INDEX:-${APT_BASE}/dists/${SUITE}/main/binary-amd64/Packages}" +INRELEASE="${TALER_APT_INRELEASE:-${APT_BASE}/dists/${SUITE}/InRelease}" +TESTING_INDEX="${TALER_APT_TESTING_INDEX:-${APT_BASE}/dists/${SUITE}-testing/main/binary-amd64/Packages}" +BEHIND_MODE="${TALER_PKG_BEHIND:-warn}" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +http_get() { + # usage: http_get URL [outfile] → prints http_code; body to outfile or /dev/null + local url="$1" out="${2:-/dev/null}" + curl -sS --max-time 30 -L -o "$out" -w '%{http_code}' "$url" 2>/dev/null || echo 000 +} + +# --------------------------------------------------------------------------- +# 1) OUTSIDE — deb.taler.net install source (runner / laptop, no SSH) +# --------------------------------------------------------------------------- +section "versions · outside · deb.taler.net (${SUITE})" + +# DNS +HOST_APT="${APT_BASE#https://}"; HOST_APT="${HOST_APT#http://}"; HOST_APT="${HOST_APT%%/*}" +if getent hosts "$HOST_APT" >/dev/null 2>&1 \ + || python3 -c "import socket; socket.getaddrinfo('${HOST_APT}', 443)" >/dev/null 2>&1; then + ip=$(python3 -c "import socket; print(socket.getaddrinfo('${HOST_APT}',443)[0][4][0])" 2>/dev/null || true) + ok "outside DNS ${HOST_APT}" "${ip:-resolved}" +else + fail "outside DNS ${HOST_APT}" "unresolvable — cannot use as apt source" +fi + +# HTTPS portal / apt root +code=$(http_get "https://${HOST_APT}/") +[ "$code" = "200" ] && ok "outside https://${HOST_APT}/" "HTTP $code" \ + || warn "outside https://${HOST_APT}/" "HTTP $code" + +code=$(http_get "${APT_BASE}/") +case "$code" in + 200|301|302) ok "outside apt base" "${APT_BASE}/ HTTP $code" ;; + *) fail "outside apt base" "${APT_BASE}/ HTTP $code" ;; +esac + +# Suite metadata (what apt update needs) +code=$(http_get "$INRELEASE" "$tmp/InRelease") +if [ "$code" = "200" ] && [ -s "$tmp/InRelease" ]; then + ok "outside InRelease" "${SUITE} HTTP 200 · $(wc -c <"$tmp/InRelease" | tr -d ' ') bytes" +else + fail "outside InRelease" "HTTP $code — $INRELEASE (apt update will fail)" +fi + +RELEASE_URL="${APT_BASE}/dists/${SUITE}/Release" +code=$(http_get "$RELEASE_URL" "$tmp/Release") +[ "$code" = "200" ] && ok "outside Release" "${SUITE} HTTP 200" \ + || warn "outside Release" "HTTP $code — $RELEASE_URL" + +# Packages (plain) — required for version compare +code=$(http_get "$INDEX" "$tmp/Packages") +if [ "$code" = "200" ] && [ -s "$tmp/Packages" ] && grep -q '^Package: ' "$tmp/Packages"; then + n_pkg=$(grep -c '^Package: ' "$tmp/Packages" || true) + ok "outside Packages" "${SUITE} HTTP 200 · ${n_pkg} packages" +else + fail "outside Packages" "HTTP $code — $INDEX" + # still try more probes, but cannot compare versions without index +fi + +# Packages.gz (apt often prefers this) +PKGZ_URL="${APT_BASE}/dists/${SUITE}/main/binary-amd64/Packages.gz" +code=$(http_get "$PKGZ_URL" "$tmp/Packages.gz") +if [ "$code" = "200" ] && [ -s "$tmp/Packages.gz" ]; then + ok "outside Packages.gz" "${SUITE} HTTP 200 · $(wc -c <"$tmp/Packages.gz" | tr -d ' ') bytes" +else + warn "outside Packages.gz" "HTTP $code — apt may still use plain Packages" +fi + +# Sample pool .deb downloadable (proves packages are installable, not just listed) +if [ -s "$tmp/Packages" ]; then + DEB_PATH=$(awk ' + /^Package: taler-exchange$/ { p=1 } + p && /^Filename: / { sub(/^Filename: /,""); print; exit } + p && /^$/ { p=0 } + ' "$tmp/Packages") + [ -z "$DEB_PATH" ] && DEB_PATH=$(awk ' + /^Package: taler-merchant$/ { p=1 } + p && /^Filename: / { sub(/^Filename: /,""); print; exit } + p && /^$/ { p=0 } + ' "$tmp/Packages") + if [ -n "$DEB_PATH" ]; then + DEB_URL="${APT_BASE}/${DEB_PATH}" + code=$(curl -sS --max-time 30 -o /dev/null -w '%{http_code}' -r 0-128 "$DEB_URL" 2>/dev/null || echo 000) + case "$code" in + 200|206) + ok "outside pool .deb" "$(basename "$DEB_PATH") HTTP $code" + ;; + *) + fail "outside pool .deb" "HTTP $code — $DEB_URL (index ok but debs not fetchable)" + ;; + esac + else + warn "outside pool .deb" "no Filename for taler-exchange/merchant in index" + fi + + for pkg in taler-exchange taler-merchant libeufin-bank; do + ver=$(awk -v p="$pkg" ' + $0=="Package: "p { hit=1; next } + hit && /^Version: / { sub(/^Version: /,""); print; exit } + hit && /^$/ { hit=0 } + ' "$tmp/Packages") + if [ -n "$ver" ]; then + ok "outside suite offers ${pkg}" "$ver" + else + fail "outside suite offers ${pkg}" "missing from ${SUITE} Packages" + fi + done +fi + +HAVE_TESTING=0 +code=$(http_get "$TESTING_INDEX" "$tmp/Packages-testing") +if [ "$code" = "200" ] && [ -s "$tmp/Packages-testing" ] && grep -q '^Package: ' "$tmp/Packages-testing"; then + HAVE_TESTING=1 + ok "outside ${SUITE}-testing Packages" "HTTP 200 (optional compare)" +else + info "outside ${SUITE}-testing Packages" "HTTP ${code:-000} (optional)" +fi + +# TLS: certificate verify (curl default) already used above; explicit openssl probe if available +if command -v openssl >/dev/null 2>&1; then + if echo | openssl s_client -servername "$HOST_APT" -connect "${HOST_APT}:443" 2>/dev/null \ + | grep -q 'Verify return code: 0'; then + ok "outside TLS ${HOST_APT}" "verify ok" + else + # curl succeeded with default CA — soft warn only + warn "outside TLS ${HOST_APT}" "openssl verify not clean (curl may still work)" + fi +fi + +# Need Packages index for later compare +if [ ! -s "$tmp/Packages" ] || ! grep -q '^Package: ' "$tmp/Packages"; then + fail "outside Packages usable" "cannot continue version compare without suite index" + summary + exit 1 +fi + +# --------------------------------------------------------------------------- +# 2) INSIDE — containers can reach deb.taler.net (pasta / install path) +# --------------------------------------------------------------------------- +section "versions · inside · containers → deb.taler.net" + +if [ "${SKIP_SSH}" = "1" ]; then + warn "ssh" "SKIP_SSH=1 — skip container reachability + installed package compare" + info "outside-only" "deb.taler.net public checks completed above" + summary + exit 0 +fi +if ! koopa_ssh_ok; then + err "ssh" "cannot reach ${KOOPA_SSH} — set SKIP_SSH=1 to skip container checks" + info "outside-only" "deb.taler.net public checks completed above" + summary + exit 1 +fi +ok "ssh ${KOOPA_SSH}" + +INRELEASE_URL="${APT_BASE}/dists/${SUITE}/InRelease" +# Write remote script to a file (avoids bash 3.2 parse bugs with case/;; inside $(…)</dev/null | grep -Fx "$want" | head -1) + if [ -n "$c" ]; then echo "$c"; return; fi + case "$want" in + *exchange*) podman ps --format '{{.Names}}' 2>/dev/null | grep -i exchange | head -1 ;; + *bank*) podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism-bank|taler-bank' | head -1 ;; + *) + c=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E '^taler-hacktivism$' | head -1) + if [ -n "$c" ]; then echo "$c"; return; fi + podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'merchant|hacktivism' | grep -viE 'bank|exchange' | head -1 + ;; + esac +} +for want in taler-hacktivism-exchange-ansible taler-hacktivism-bank taler-hacktivism; do + c=$(resolve_ctr "$want") + [ -n "$c" ] || continue + role=other + case "$c" in + *exchange*) role=exchange ;; + *bank*) role=bank ;; + *) role=merchant ;; + esac + code=$(podman exec "$c" curl -sS -m 12 -o /dev/null -w '%{http_code}' "$INRELEASE_URL" 2>/dev/null || echo 000) + echo "R|${role}|${c}|${code}" + if podman exec "$c" bash -lc 'grep -Rqs deb.taler.net /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null'; then + echo "S|${role}|${c}|yes" + else + echo "S|${role}|${c}|no" + fi + podman exec "$c" dpkg-query -W -f '${Package}\t${Version}\n' 2>/dev/null \ + | awk -v role="$role" -v ctr="$c" ' + $1 ~ /^(taler-|libeufin-|libtaler|libdonau)/ { + printf "P|%s|%s|%s|%s\n", role, ctr, $1, $2 + }' +done +REMOTE +} >"$tmp/remote-versions.sh" + +REMOTE_OUT=$(koopa_ssh_bash 60 <"$tmp/remote-versions.sh" || true) + +printf '%s\n' "$REMOTE_OUT" | grep -E '^R\|' >"$tmp/reach.tsv" || true +printf '%s\n' "$REMOTE_OUT" | grep -E '^S\|' >"$tmp/sources.tsv" || true +printf '%s\n' "$REMOTE_OUT" | grep -E '^P\|' >"$tmp/installed.tsv" || true + +if [ -s "$tmp/reach.tsv" ]; then + while IFS='|' read -r _ role ctr code; do + if [ "$code" = "200" ]; then + ok "container ${role} → deb.taler.net" "${ctr} InRelease HTTP $code" + else + fail "container ${role} → deb.taler.net" "${ctr} InRelease HTTP $code — apt install/update will fail" + fi + done <"$tmp/reach.tsv" +else + warn "container → deb.taler.net" "no reachability rows" +fi + +if [ -s "$tmp/sources.tsv" ]; then + while IFS='|' read -r _ role ctr has; do + if [ "$has" = "yes" ]; then + ok "container ${role} apt source" "${ctr} lists deb.taler.net" + else + warn "container ${role} apt source" "${ctr} no deb.taler.net in sources.list*" + fi + done <"$tmp/sources.tsv" +fi + +if [ ! -s "$tmp/installed.tsv" ]; then + fail "installed packages" "none found in taler containers" + summary + exit 1 +fi +n_inst=$(wc -l <"$tmp/installed.tsv" | tr -d ' ') +ok "collected installed packages" "${n_inst} rows" + +# --------------------------------------------------------------------------- +# 3) compare installed vs trixie (and note testing) +# --------------------------------------------------------------------------- +section "versions · compare installed vs ${SUITE}" + +export BEHIND_MODE HAVE_TESTING +export TALER_APT_SUITE="$SUITE" +CORE_PKGS="taler-exchange taler-exchange-database taler-merchant taler-merchant-webui libeufin-bank libeufin-common libtalerexchange libtalermerchant taler-terms-generator" +export CORE_PKGS + +python3 - "$tmp/Packages" "$tmp/Packages-testing" "$tmp/installed.tsv" <<'PY' >"$tmp/py.out" +import sys, os +from collections import defaultdict + +main_idx, test_idx, inst_path = sys.argv[1:4] +have_testing = os.environ.get("HAVE_TESTING", "0") == "1" +core = set(os.environ.get("CORE_PKGS", "").split()) +suite = os.environ.get("TALER_APT_SUITE", "trixie") + +def _ord_ch(c): + if c == "~": + return -1 + if c.isdigit(): + return 0 + if c.isalpha(): + return ord(c) + return ord(c) + 256 + +def _split_ver(v): + epoch = 0 + if ":" in v: + e, v = v.split(":", 1) + try: + epoch = int(e) + except ValueError: + epoch = 0 + if "-" in v: + upstream, deb = v.rsplit("-", 1) + else: + upstream, deb = v, "" + return epoch, upstream, deb + +def _cmp_part(a, b): + ia = ib = 0 + while ia < len(a) or ib < len(b): + while True: + ca = a[ia] if ia < len(a) and not a[ia].isdigit() else "" + cb = b[ib] if ib < len(b) and not b[ib].isdigit() else "" + if ca == "" and cb == "": + break + if ca == "" and cb: + return -1 if _ord_ch(cb) > 0 else (1 if cb == "~" else -1) + if cb == "" and ca: + return 1 if _ord_ch(ca) > 0 else (-1 if ca == "~" else 1) + oa, ob = _ord_ch(ca), _ord_ch(cb) + ia += 1 + ib += 1 + if oa != ob: + return (oa > ob) - (oa < ob) + sa = sb = "" + while ia < len(a) and a[ia].isdigit(): + sa += a[ia]; ia += 1 + while ib < len(b) and b[ib].isdigit(): + sb += b[ib]; ib += 1 + na = int(sa) if sa else 0 + nb = int(sb) if sb else 0 + if na != nb: + return (na > nb) - (na < nb) + return 0 + +def deb_cmp(a, b): + if a == b: + return 0 + ea, ua, da = _split_ver(a) + eb, ub, db = _split_ver(b) + if ea != eb: + return (ea > eb) - (ea < eb) + c = _cmp_part(ua, ub) + if c: + return c + return _cmp_part(da, db) + +def parse_packages(path): + pkgs = {} + if not path or not os.path.isfile(path) or os.path.getsize(path) == 0: + return pkgs + cur = ver = None + with open(path, encoding="utf-8", errors="replace") as f: + for line in f: + line = line.rstrip("\n") + if line.startswith("Package: "): + if cur and ver and (cur not in pkgs or deb_cmp(ver, pkgs[cur]) > 0): + pkgs[cur] = ver + cur = line[9:].strip() + ver = None + elif line.startswith("Version: ") and cur: + ver = line[9:].strip() + elif line == "" and cur: + if ver and (cur not in pkgs or deb_cmp(ver, pkgs[cur]) > 0): + pkgs[cur] = ver + cur = ver = None + if cur and ver and (cur not in pkgs or deb_cmp(ver, pkgs[cur]) > 0): + pkgs[cur] = ver + return pkgs + +main = parse_packages(main_idx) +testing = parse_packages(test_idx) if have_testing else {} + +by_pkg = defaultdict(list) +with open(inst_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line.startswith("P|"): + continue + parts = line.split("|") + if len(parts) < 5: + continue + _, role, ctr, pkg, ver = parts[:5] + by_pkg[pkg].append((role, ctr, ver)) + +for pkg in sorted(by_pkg.keys()): + versions = sorted({v for _, _, v in by_pkg[pkg]}, key=lambda v: v) + # pick "highest" via deb_cmp + inst = versions[0] + for v in versions[1:]: + if deb_cmp(v, inst) > 0: + inst = v + roles = ",".join(sorted({r for r, _, _ in by_pkg[pkg]})) + multi = len(set(versions)) > 1 + trixie = main.get(pkg) + testv = testing.get(pkg) + status = "ok" + detail = "" + if multi: + status = "warn" + detail = "multiple installed: " + ", ".join(sorted(set(versions))) + if trixie is None: + if status == "ok": + status = "info" + detail = (detail + "; " if detail else "") + f"not in {suite} main index" + else: + c = deb_cmp(inst, trixie) + if c == 0: + detail = (detail + "; " if detail else "") + f"= {suite} {trixie}" + elif c < 0: + status = "behind" + detail = f"installed {inst} < {suite} {trixie}" + else: + status = "ahead" + detail = f"installed {inst} > {suite} {trixie}" + if testv and deb_cmp(inst, testv) == 0: + detail += f" (={suite}-testing)" + elif testv: + detail += f" (testing has {testv})" + core_flag = "1" if pkg in core else "0" + print(f"{status}|{pkg}|{inst}|{trixie or '-'}|{roles}|{core_flag}|{detail}") +PY + +ok_n=0; ahead_n=0; behind_n=0 +while IFS='|' read -r status pkg inst trixie roles core detail; do + [ -n "${status:-}" ] || continue + label="pkg ${pkg} (${roles})" + case "$status" in + ok) + ok "$label" "${inst}" + ok_n=$((ok_n + 1)) + ;; + ahead) + info "$label" "$detail" + ahead_n=$((ahead_n + 1)) + ;; + behind) + behind_n=$((behind_n + 1)) + if [ "$core" = "1" ] || [ "$BEHIND_MODE" = "error" ]; then + fail "$label" "$detail" + else + warn "$label" "$detail" + fi + ;; + warn) + warn "$label" "$detail" + ;; + info) + info "$label" "installed ${inst}${detail:+ — $detail}" + ;; + *) + info "$label" "$status $detail" + ;; + esac +done <"$tmp/py.out" + +# Core packages must exist somewhere +for need in taler-exchange libeufin-bank taler-merchant; do + if grep -qE "^P\|[^|]+\|[^|]+\|${need}\|" "$tmp/installed.tsv"; then + ok "core installed ${need}" + else + fail "core installed ${need}" "not present in any taler container" + fi +done + +info "suite" "deb.taler.net ${SUITE} (testing_index=${HAVE_TESTING})" +info "tally" "match=${ok_n} ahead=${ahead_n} behind=${behind_n}" + +summary diff --git a/scripts/taler-monitoring/harness-live.txt b/scripts/taler-monitoring/harness-live.txt new file mode 100644 index 0000000..b7d5500 --- /dev/null +++ b/scripts/taler-monitoring/harness-live.txt @@ -0,0 +1,7 @@ +# Optional: taler-harness against *our* exchange (needs a harness version +# that accepts current /config JSON). Older installs often FAIL codec checks +# even when the exchange is fine — trust `./taler-monitoring.sh urls` first. +# +# idcommand + +lint-exchange-goa taler-harness deployment lint-exchange-url https://exchange.hacktivism.ch/ diff --git a/scripts/taler-monitoring/harness-tests.txt b/scripts/taler-monitoring/harness-tests.txt new file mode 100644 index 0000000..1c63e3b --- /dev/null +++ b/scripts/taler-monitoring/harness-tests.txt @@ -0,0 +1,41 @@ +# Real-life–relevant taler-harness integration tests +# (from: taler-harness list-integrationtests) +# +# Criteria: everyday user/merchant/bank paths — withdraw, pay, deposit, refund, +# libeufin bank, templates/paywall. Excludes KYC edge cases, experimental, +# timetravel, perf, backup, mailbox, most fault-injection. +# +# These spin up a local TESTKUDOS stack (same client code paths as production). +# For tests against *public* demos, see harness-live.txt / `./taler-monitoring.sh live`. + +# --- Core path: get money + spend it (the GOA story) --- +simple-payment +withdrawal-bank-integrated +payment + +# --- Withdraw variants users actually hit --- +withdrawal-manual +withdrawal-external +withdrawal-idempotent + +# --- Pay variants shops use --- +payment-template +paywall-flow +payment-idempotency +payment-zero +payment-abort +otp + +# --- After pay: coins / merchant ops --- +deposit +refund +refund-auto +wallet-refresh + +# --- Bank stack we run (libeufin / regional) --- +libeufin-bank +bank-api + +# --- Client hygiene --- +wallet-config +term-of-service-format diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh new file mode 100755 index 0000000..d405a2d --- /dev/null +++ b/scripts/taler-monitoring/lib.sh @@ -0,0 +1,536 @@ +# shellcheck shell=bash +# Shared helpers for taler-monitoring (laptop or koopa). + +# Default stack = GOA / hacktivism (overridden by TALER_DOMAIN / --domain) +: "${TALER_DOMAIN:=hacktivism.ch}" +: "${BANK_PUBLIC:=https://bank.hacktivism.ch}" +: "${EXCHANGE_PUBLIC:=https://exchange.hacktivism.ch}" +: "${MERCHANT_PUBLIC:=https://taler.hacktivism.ch}" +: "${BANK_LOCAL:=http://127.0.0.1:9012}" +: "${EXCHANGE_LOCAL:=http://127.0.0.1:9011}" +: "${MERCHANT_LOCAL:=https://127.0.0.1:9010}" +: "${LANDING_LOCAL:=http://127.0.0.1:9013}" +: "${KOOPA_SSH:=koopa}" +: "${MERCHANT_INSTANCE:=goa-demo-cp4zqk}" +: "${WITHDRAW_AMT:=GOA:20}" # single-shot fallback; e2e ladder uses ATM notes +: "${PAY_AMT:=GOA:0.01}" +: "${CREDIT_AMT:=GOA:400}" # covers ATM ladder 20+50+100+200 +: "${TIMEOUT:=12}" +: "${E2E_TIMEOUT:=55}" # whole e2e budget; skip rest when exceeded +: "${E2E_PAY_SECS:=22}" # dedicated seconds for pay handle-uri (avoid Alarm clock) +# Devtest: inject reserve credit via wire-gateway admin/add-incoming (optional). +# Default off once wirewatch DNS works; set E2E_FAKE_INCOMING=1 to force. +: "${E2E_FAKE_INCOMING:=0}" +# SSH must never hang the monitoring run +: "${SSH_CONNECT_TIMEOUT:=3}" +: "${SSH_CMD_TIMEOUT:=12}" # hard cap for whole remote script (seconds) +: "${SKIP_SSH:=0}" +# Expected currency for public /config checks (empty = report only, don't fail) +: "${EXPECT_CURRENCY:=GOA}" +# 1 = this is the local koopa/hacktivism stack (inside/e2e/SSH make sense) +: "${LOCAL_STACK:=1}" +# Probe merchant host candidates when applying a generic domain (0=off) +: "${TALER_DOMAIN_PROBE:=1}" + +BANK_PUBLIC=${BANK_PUBLIC%/} +EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} +MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} + +# --------------------------------------------------------------------------- +# Domain presets → public bank / exchange / merchant base URLs +# +# TALER_DOMAIN=hacktivism.ch (default, GOA, local stack) +# TALER_DOMAIN=taler.net → demo.taler.net (KUDOS) +# TALER_DOMAIN=demo.taler.net +# TALER_DOMAIN=taler-ops.ch → exchange.taler-ops.ch (CHF; bank/merchant if up) +# TALER_DOMAIN=example.org → bank/exchange/backend|taler|merchant.example.org +# +# Explicit BANK_PUBLIC / EXCHANGE_PUBLIC / MERCHANT_PUBLIC still win if set +# *after* apply_taler_domain, or pass full URLs via --bank/--exchange/--merchant. +# --------------------------------------------------------------------------- +_normalize_domain() { + local d="$1" + d="${d#https://}" + d="${d#http://}" + d="${d%%/*}" + d="${d%%:*}" + # Strip service host prefix only if a real domain remains (has a dot). + # e.g. bank.demo.taler.net → demo.taler.net, taler.hacktivism.ch → hacktivism.ch + # but NOT taler.net → net + case "$d" in + bank.*|exchange.*|taler.*|backend.*|merchant.*|shop.*|libeufin.*) + rest="${d#*.}" + if [[ "$rest" == *.* ]]; then + d="$rest" + fi + ;; + esac + printf '%s' "$d" +} + +_probe_https_config() { + # 0 if https://$1/config returns 200 + local host="$1" code + code=$(curl -skS --max-redirs 0 -m 4 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000) + [ "$code" = "200" ] +} + +apply_taler_domain() { + local raw="${1:-}" + local d + [ -n "$raw" ] || return 0 + d=$(_normalize_domain "$raw") + TALER_DOMAIN="$d" + + case "$d" in + # Local koopa stack only — SSH / inside / e2e allowed + koopa|hacktivism.ch|hacktivism) + BANK_PUBLIC="https://bank.hacktivism.ch" + EXCHANGE_PUBLIC="https://exchange.hacktivism.ch" + MERCHANT_PUBLIC="https://taler.hacktivism.ch" + EXPECT_CURRENCY="GOA" + LOCAL_STACK=1 + SKIP_SSH=0 + TALER_DOMAIN="hacktivism.ch" + : "${WITHDRAW_AMT:=GOA:20}" + : "${PAY_AMT:=GOA:0.01}" + : "${CREDIT_AMT:=GOA:400}" + ;; + taler.net|demo.taler.net) + # Official public demo (KUDOS) — public only, never SSH; tiny e2e amounts + BANK_PUBLIC="https://bank.demo.taler.net" + EXCHANGE_PUBLIC="https://exchange.demo.taler.net" + MERCHANT_PUBLIC="https://backend.demo.taler.net" + EXPECT_CURRENCY="KUDOS" + LOCAL_STACK=0 + SKIP_SSH=1 + MERCHANT_INSTANCE="${MERCHANT_INSTANCE:-sandbox}" + WITHDRAW_AMT="${WITHDRAW_AMT:-KUDOS:20}" + PAY_AMT="${PAY_AMT:-KUDOS:0.01}" + CREDIT_AMT="${CREDIT_AMT:-KUDOS:100}" + TALER_DOMAIN="demo.taler.net" + ;; + taler-ops.ch) + # Public CHF exchange; bank/merchant hosts vary — probe common names + EXCHANGE_PUBLIC="https://exchange.taler-ops.ch" + BANK_PUBLIC="https://bank.taler-ops.ch" + MERCHANT_PUBLIC="https://backend.taler-ops.ch" + EXPECT_CURRENCY="CHF" + LOCAL_STACK=0 + SKIP_SSH=1 + WITHDRAW_AMT="${WITHDRAW_AMT:-CHF:20}" + PAY_AMT="${PAY_AMT:-CHF:0.01}" + CREDIT_AMT="${CREDIT_AMT:-CHF:100}" + if [ "${TALER_DOMAIN_PROBE}" = "1" ]; then + local h + for h in bank.taler-ops.ch bank.demo.taler-ops.ch; do + _probe_https_config "$h" && { BANK_PUBLIC="https://$h"; break; } + done + for h in backend.taler-ops.ch merchant.taler-ops.ch taler.taler-ops.ch shop.taler-ops.ch; do + _probe_https_config "$h" && { MERCHANT_PUBLIC="https://$h"; break; } + done + fi + ;; + *) + # Any other domain — public HTTPS only, never SSH to koopa + BANK_PUBLIC="https://bank.${d}" + EXCHANGE_PUBLIC="https://exchange.${d}" + MERCHANT_PUBLIC="https://backend.${d}" + EXPECT_CURRENCY="${EXPECT_CURRENCY:-}" # unknown — don't hard-fail currency + LOCAL_STACK=0 + SKIP_SSH=1 + if [ "${TALER_DOMAIN_PROBE}" = "1" ]; then + local h + for h in "backend.${d}" "taler.${d}" "merchant.${d}" "shop.${d}"; do + _probe_https_config "$h" && { MERCHANT_PUBLIC="https://$h"; break; } + done + for h in "bank.${d}" "libeufin.${d}"; do + _probe_https_config "$h" && { BANK_PUBLIC="https://$h"; break; } + done + _probe_https_config "exchange.${d}" || true + fi + ;; + esac + + BANK_PUBLIC=${BANK_PUBLIC%/} + EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} + MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} + + # Hard rule: only the local koopa/hacktivism stack may use SSH + if [ "${LOCAL_STACK}" != "1" ]; then + SKIP_SSH=1 + fi +} + +# Apply TALER_DOMAIN from env once (CLI exports TALER_DOMAIN_APPLIED=1 after overrides). +if [ "${TALER_DOMAIN_APPLIED:-0}" != "1" ] \ + && [ -n "${TALER_DOMAIN:-}" ] && [ "${TALER_DOMAIN}" != "hacktivism.ch" ]; then + apply_taler_domain "$TALER_DOMAIN" + TALER_DOMAIN_APPLIED=1 +fi + +# Safe SSH: publickey only, short connect, overall alarm so we never block forever. +SSH_BASE_OPTS=( + -o BatchMode=yes + -o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" + -o ConnectionAttempts=1 + -o ServerAliveInterval=3 + -o ServerAliveCountMax=2 + -o StrictHostKeyChecking=accept-new + -o PreferredAuthentications=publickey + -o PasswordAuthentication=no + -o KbdInteractiveAuthentication=no + -o GSSAPIAuthentication=no + -o NumberOfPasswordPrompts=0 +) + +# Hard wall-clock timeout so ssh/curl never block the monitoring run forever. +with_timeout() { + local secs="$1"; shift + if command -v gtimeout >/dev/null 2>&1; then + gtimeout --kill-after=2 "$secs" "$@" + return $? + fi + if command -v timeout >/dev/null 2>&1; then + timeout -k 2 "$secs" "$@" 2>/dev/null || timeout --kill-after=2 "$secs" "$@" + return $? + fi + # Portable: perl alarm + process group kill + perl -e ' + use strict; use warnings; + my $secs = shift @ARGV; + my $pid = fork(); + die "fork: $!" unless defined $pid; + if ($pid == 0) { + setpgrp(0, 0); + exec @ARGV; + exit 127; + } + $SIG{ALRM} = sub { + kill "TERM", -$pid; + select(undef, undef, undef, 1.0); + kill "KILL", -$pid; + exit 124; + }; + alarm $secs; + waitpid($pid, 0); + my $code = $? >> 8; + alarm 0; + exit $code; + ' "$secs" "$@" +} + +# Probe: 0 if koopa SSH works quickly +koopa_ssh_ok() { + [ "${SKIP_SSH}" = "1" ] && return 1 + with_timeout $((SSH_CONNECT_TIMEOUT + 3)) \ + ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" 'echo ok' >/dev/null 2>&1 +} + +# Run remote bash -s with optional stdin script; hard-capped +# usage: koopa_ssh_bash [timeout_secs] <<'EOF' ... EOF +# or: koopa_ssh_run timeout_secs 'remote command' +koopa_ssh_run() { + local t="${1:-$SSH_CMD_TIMEOUT}" + shift + with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" "$@" +} + +koopa_ssh_bash() { + local t="${1:-$SSH_CMD_TIMEOUT}" + with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" 'bash -s' +} + +if [ "${NO_COLOR:-0}" = "1" ] || [ ! -t 1 ]; then + G= R= Y= C= N= B= +else + G=$'\e[32m'; R=$'\e[31m'; Y=$'\e[33m'; C=$'\e[36m'; B=$'\e[1m'; N=$'\e[0m' +fi + +PASS_N=0 +FAIL_N=0 +WARN_N=0 +INFO_N=0 +BLOCKERS=() # human-readable payment/withdraw blockers +ERRORS=() # all ERROR lines (component scope) + +# Test IDs by area: www-001, e2e-001, inside-001, … +# Usage: set_area www then each ok/fail/warn/info/blocker/err auto-numbers. +TEST_AREA="" +TEST_N=0 +# Last issued id (www-001); set by _take_tid — not via $(…) so TEST_N persists. +LAST_TID="" +set_area() { + TEST_AREA="$1" + TEST_N=0 + LAST_TID="" +} +# Assign next id into LAST_TID (must not run in a subshell). +_take_tid() { + LAST_TID="" + if [ -z "${TEST_AREA:-}" ]; then + return + fi + TEST_N=$((TEST_N + 1)) + LAST_TID=$(printf '%s-%03d' "$TEST_AREA" "$TEST_N") +} +_fmt_tid() { + # prefix "www-001 " or empty + if [ -n "${LAST_TID:-}" ]; then + printf '%s ' "$LAST_TID" + fi +} + +ok() { + local label="$1" + _take_tid + printf '%s[OK]%s %s%s\n' "$G" "$N" "$(_fmt_tid)" "$label" + PASS_N=$((PASS_N + 1)) +} +# component-scoped error: err bank "libeufin down" "detail" +err() { + local comp="$1" msg="$2" detail="${3:-}" + _take_tid + printf '%s[ERROR]%s %s%s: %s%s\n' "$R" "$N" "$(_fmt_tid)" "$comp" "$msg" "${detail:+ — $detail}" + FAIL_N=$((FAIL_N + 1)) + ERRORS+=("${LAST_TID:+$LAST_TID }[$comp] $msg${detail:+ — $detail}") +} +# legacy fail label ... +fail() { + local label="$1" detail="${2:-}" + _take_tid + printf '%s[ERROR]%s %s%s%s\n' "$R" "$N" "$(_fmt_tid)" "$label" "${detail:+ — $detail}" + FAIL_N=$((FAIL_N + 1)) + ERRORS+=("${LAST_TID:+$LAST_TID }$label${detail:+ — $detail}") +} +warn() { + local label="$1" detail="${2:-}" + _take_tid + printf '%s[WARN]%s %s%s%s\n' "$Y" "$N" "$(_fmt_tid)" "$label" "${detail:+ — $detail}" + WARN_N=$((WARN_N + 1)) +} +info() { + local label="$1" detail="${2:-}" + _take_tid + if [ -n "$detail" ]; then + printf '%s[INFO]%s %s%s — %s\n' "$C" "$N" "$(_fmt_tid)" "$label" "$detail" + else + printf '%s[INFO]%s %s%s\n' "$C" "$N" "$(_fmt_tid)" "$label" + fi + INFO_N=$((INFO_N + 1)) +} +blocker() { + # Payment/withdraw path cannot proceed because of this + local step="$1" msg="$2" + _take_tid + printf '%s[BLOCKER]%s %s%s: %s\n' "$R$B" "$N" "$(_fmt_tid)" "$step" "$msg" + BLOCKERS+=("${LAST_TID:+$LAST_TID }[$step] $msg") + FAIL_N=$((FAIL_N + 1)) + ERRORS+=("BLOCKER ${LAST_TID:+$LAST_TID }[$step] $msg") +} +section() { printf '\n%s== %s ==%s\n' "$B" "$*" "$N"; } + +summary() { + echo "" + if [ "${#BLOCKERS[@]}" -gt 0 ]; then + printf '%s--- BLOCKERS (fix/withdraw path) ---%s\n' "$R$B" "$N" + local b + for b in "${BLOCKERS[@]}"; do + printf '%s • %s%s\n' "$R" "$b" "$N" + done + fi + if [ "${#ERRORS[@]}" -gt 0 ] && [ "${#BLOCKERS[@]}" -lt "${#ERRORS[@]}" ]; then + printf '%s--- ERRORS ---%s\n' "$R" "$N" + local e + for e in "${ERRORS[@]}"; do + case "$e" in BLOCKER*) continue ;; esac + printf '%s • %s%s\n' "$R" "$e" "$N" + done + fi + printf 'totals: %s%d OK%s' "$G" "$PASS_N" "$N" + [ "$FAIL_N" -gt 0 ] && printf ', %s%d ERROR%s' "$R" "$FAIL_N" "$N" + [ "$WARN_N" -gt 0 ] && printf ', %s%d WARN%s' "$Y" "$WARN_N" "$N" + [ "$INFO_N" -gt 0 ] && printf ', %d INFO' "$INFO_N" + [ "${#BLOCKERS[@]}" -gt 0 ] && printf ', %s%d BLOCKER%s' "$R" "${#BLOCKERS[@]}" "$N" + printf '\n' + [ "$FAIL_N" -eq 0 ] +} + +http_code() { + local url="$1"; shift + curl -skS --max-redirs 0 -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000 +} + +http_body() { + local url="$1" out="$2"; shift 2 + curl -skS --max-redirs 0 -m "${TIMEOUT}" -o "$out" -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000 +} + +# --------------------------------------------------------------------------- +# Currency unit map checks (wallet codec: alt_unit_names must include "0") +# --------------------------------------------------------------------------- +# Returns 0 if JSON body at $1 has usable alt_unit_names. +# Supports: +# - exchange/bank: currency_specification.alt_unit_names +# - merchant: currencies..alt_unit_names for each code +# Optional $2 = required currency code for currency_specification.currency +json_has_alt_unit_names() { + local file="$1" want_cur="${2:-}" + python3 - "$file" "$want_cur" <<'PY' +import json, sys +path, want = sys.argv[1], sys.argv[2] +try: + d = json.load(open(path)) +except Exception as e: + print(f"json-error: {e}") + sys.exit(2) + +def check_au(au, label): + if not isinstance(au, dict) or not au: + print(f"{label}: missing/empty alt_unit_names") + return False + if "0" not in au or not str(au.get("0") or "").strip(): + print(f"{label}: alt_unit_names missing non-empty key \"0\" (have {sorted(au.keys())})") + return False + print(f"{label}: alt_unit_names ok (0={au.get('0')!r}, n={len(au)})") + return True + +ok = True +cs = d.get("currency_specification") +if isinstance(cs, dict): + if want and cs.get("currency") and cs.get("currency") != want: + print(f"currency_specification.currency={cs.get('currency')!r} want {want!r}") + ok = False + if not check_au(cs.get("alt_unit_names"), "currency_specification"): + ok = False +elif "currency_specification" in d: + print("currency_specification: not an object") + ok = False + +curs = d.get("currencies") +if isinstance(curs, dict) and curs: + for code, spec in curs.items(): + if not isinstance(spec, dict): + print(f"currencies.{code}: not an object") + ok = False + continue + if not check_au(spec.get("alt_unit_names"), f"currencies.{code}"): + ok = False + +if not isinstance(cs, dict) and not (isinstance(curs, dict) and curs): + # neither shape — fail + print("no currency_specification or currencies map") + ok = False + +sys.exit(0 if ok else 1) +PY +} + +# Check one exchange base URL's /config for alt_unit_names. +# $1=label $2=base_url $3=expected currency (optional) $4=strict(1) or soft(0) +check_exchange_alt_units() { + local label="$1" base="$2" want_cur="${3:-}" strict="${4:-1}" + local f code + base="${base%/}" + f=$(mktemp) + code=$(http_body "${base}/config" "$f") + if [ "$code" != "200" ]; then + rm -f "$f" + if [ "$strict" = "1" ]; then + fail "$label /config" "HTTP $code ($base)" + else + warn "$label /config" "HTTP $code ($base)" + fi + return + fi + local out ec + set +e + out=$(json_has_alt_unit_names "$f" "$want_cur" 2>&1) + ec=$? + set -e + rm -f "$f" + if [ "$ec" -eq 0 ]; then + ok "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" + else + if [ "$strict" = "1" ]; then + fail "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" + else + warn "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" + fi + fi +} + +# From merchant /config JSON file: walk exchanges[] and check each base_url/config. +# Local stack hosts (hacktivism.ch or $EXCHANGE_PUBLIC host) are strict; others soft. +check_merchant_listed_exchanges_alt_units() { + local mer_json="$1" + local list + list=$(python3 - "$mer_json" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +for e in d.get("exchanges") or []: + if not isinstance(e, dict): + continue + u = (e.get("base_url") or e.get("url") or "").rstrip("/") + c = e.get("currency") or "" + if u: + print(f"{c}\t{u}") +PY +) + if [ -z "$list" ]; then + fail "merchant exchanges[]" "empty — no exchanges to check for alt_unit_names" + return + fi + local line cur url strict host + while IFS=$'\t' read -r cur url; do + [ -n "$url" ] || continue + host="${url#https://}"; host="${host#http://}"; host="${host%%/*}" + strict=1 + case "$host" in + *hacktivism.ch) strict=1 ;; + *) + # foreign exchange (e.g. taler-ops) — soft unless it is our configured EXCHANGE_PUBLIC + if [ "$url" = "${EXCHANGE_PUBLIC}" ] || [ "$url" = "${EXCHANGE_PUBLIC}/" ]; then + strict=1 + else + strict=0 + fi + ;; + esac + check_exchange_alt_units "exchange ${cur:-?} ${host}" "$url" "$cur" "$strict" + done <<<"$list" +} + +SECRETS_ROOT="${SECRETS_ROOT:-}" +if [ -z "$SECRETS_ROOT" ]; then + for d in \ + "$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)/koopa-admin-secrets/koopa/host-root" \ + "/Users/newkamek/src/koopa/koopa-admin-secrets/koopa/host-root" \ + "$HOME/src/koopa/koopa-admin-secrets/koopa/host-root" + do + if [ -d "$d/taler-bank" ]; then SECRETS_ROOT=$d; break; fi + done +fi + +read_secret() { + local rel="$1" + if [ -n "${SECRETS_ROOT:-}" ] && [ -f "${SECRETS_ROOT}/${rel}" ]; then + tr -d '\n' <"${SECRETS_ROOT}/${rel}" + return 0 + fi + koopa_ssh_ok || return 1 + koopa_ssh_run 10 "tr -d '\\n' /dev/null || true" 2>/dev/null +} + +find_wallet_cli() { + if [ -n "${WALLET_CLI:-}" ] && [ -f "$WALLET_CLI" ]; then + echo "$WALLET_CLI"; return 0 + fi + for c in \ + /Users/newkamek/src/taler/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs \ + "$(command -v taler-wallet-cli 2>/dev/null || true)" + do + [ -n "$c" ] && [ -f "$c" ] && { echo "$c"; return 0; } + done + return 1 +} diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh new file mode 100755 index 0000000..321525b --- /dev/null +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# taler-monitoring — public URL / stack checks for a Taler domain +# +# ./taler-monitoring.sh # local GOA (urls + inside + e2e) +# ./taler-monitoring.sh -d taler.net urls # public demo, no SSH +# ./taler-monitoring.sh --domain taler-ops.ch # public ops, no SSH +# TALER_DOMAIN=demo.taler.net ./taler-monitoring.sh urls +# +# Tags: [OK] [INFO] [WARN] [ERROR] [BLOCKER] +# Exit 0 only if every selected phase exits 0. + +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) + +usage() { + cat <<'EOF' +taler-monitoring — bank / exchange / merchant checks + +Usage: + ./taler-monitoring.sh [options] [phases...] + +Phases: + urls public HTTPS (no SSH) ← default with --domain + inside container status via SSH (local stack only) + versions taler packages vs deb.taler.net trixie + repo availability + sanity public + optional server + server server-side only (SSH) + e2e withdraw + pay (small amounts; remote aborts on login/KYC) + all urls + inside + versions + sanity + e2e (SSH phases only on koopa) + +Options: + -d, --domain DOMAIN target domain + koopa / hacktivism.ch → local stack, SSH ok + everything else → no SSH; e2e optional (tiny amounts) + presets: koopa | hacktivism.ch | taler.net | taler-ops.ch + generic: bank/exchange/backend. + --bank URL override bank base (https://…) + --exchange URL override exchange base + --merchant URL override merchant base + --currency CODE expected currency (GOA, KUDOS, CHF, …); empty = report only + --no-probe do not probe alternate merchant/bank hostnames + -h, --help + +Examples: + ./taler-monitoring.sh -d taler.net + ./taler-monitoring.sh -d taler-ops.ch urls + ./taler-monitoring.sh -d demo.taler.net --currency KUDOS + ./taler-monitoring.sh --exchange https://exchange.taler-ops.ch urls + +Env (same meaning): + TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC EXPECT_CURRENCY + SKIP_SSH=1 NO_COLOR=1 +EOF +} + +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +PHASES=() +DOMAIN_SET=0 +BANK_OVERRIDE="" +EXCHANGE_OVERRIDE="" +MERCHANT_OVERRIDE="" +CURRENCY_OVERRIDE="" +NO_PROBE=0 + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + -d|--domain) + [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; } + apply_taler_domain "$2" + DOMAIN_SET=1 + shift 2 + ;; + --bank) + [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; } + BANK_OVERRIDE="${2%/}"; shift 2 + ;; + --exchange) + [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; } + EXCHANGE_OVERRIDE="${2%/}"; shift 2 + ;; + --merchant) + [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; } + MERCHANT_OVERRIDE="${2%/}"; shift 2 + ;; + --currency) + [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; } + CURRENCY_OVERRIDE="$2"; shift 2 + ;; + --no-probe) NO_PROBE=1; shift ;; + urls|inside|versions|sanity|server|e2e|all) PHASES+=("$1"); shift ;; + *) + # bare domain shorthand: ./taler-monitoring.sh taler.net + if [[ "$1" == *.* && "$1" != *://* && "$1" != -* ]]; then + apply_taler_domain "$1" + DOMAIN_SET=1 + shift + else + echo "Unknown: $1" >&2; usage >&2; exit 2 + fi + ;; + esac +done + +if [ "$NO_PROBE" = "1" ]; then + TALER_DOMAIN_PROBE=0 +fi +if [ -n "$CURRENCY_OVERRIDE" ]; then + EXPECT_CURRENCY="$CURRENCY_OVERRIDE" +fi +if [ -n "$BANK_OVERRIDE" ]; then BANK_PUBLIC="$BANK_OVERRIDE"; fi +if [ -n "$EXCHANGE_OVERRIDE" ]; then EXCHANGE_PUBLIC="$EXCHANGE_OVERRIDE"; fi +if [ -n "$MERCHANT_OVERRIDE" ]; then MERCHANT_PUBLIC="$MERCHANT_OVERRIDE"; fi + +# Only koopa may use SSH. Remote domains: public + optional e2e (no SSH). +if [ "${LOCAL_STACK:-1}" != "1" ]; then + SKIP_SSH=1 +fi + +# Remote: no SSH; ATM withdraw ladder set in check_e2e (smaller notes) +if [ "${LOCAL_STACK}" != "1" ]; then + E2E_FAKE_INCOMING=0 + E2E_REMOTE=1 + : "${E2E_WITHDRAW_VALUES:=10 20 50}" + : "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}" +fi + +# Export so check_*.sh (re-source lib) see the same targets via env +export TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC +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 TALER_DOMAIN_APPLIED=1 + +# Default phases +if [ "${#PHASES[@]}" -eq 0 ]; then + if [ "${LOCAL_STACK}" = "1" ]; then + PHASES=(urls inside versions e2e) # koopa: SSH ok + else + PHASES=(urls) # remote default: public only; pass "e2e" to try + fi +fi + +OUT_PHASES=() +for p in "${PHASES[@]}"; do + if [ "$p" = "all" ]; then + if [ "${LOCAL_STACK}" = "1" ]; then + OUT_PHASES+=(urls inside versions sanity e2e) + else + # remote: repo index check only (no installed pkgs without SSH) + OUT_PHASES+=(urls versions e2e) + fi + else + OUT_PHASES+=("$p") + fi +done +PHASES=() +seen=" " +for p in "${OUT_PHASES[@]}"; do + # Drop SSH-only phases for non-koopa (e2e is allowed — public path only) + # versions still runs: outside-in deb.taler.net checks; container parts soft-skip if no SSH + if [ "${LOCAL_STACK}" != "1" ] || [ "${SKIP_SSH}" = "1" ]; then + case "$p" in inside|server) + echo "[INFO] skip phase '$p' (only koopa uses SSH)" >&2 + continue + ;; + esac + fi + case "$seen" in *" $p "*) ;; *) PHASES+=("$p"); seen="$seen$p " ;; esac +done + +if [ "${#PHASES[@]}" -eq 0 ]; then + PHASES=(urls) +fi + +printf 'target domain=%s\n' "${TALER_DOMAIN}" +printf ' bank %s\n' "$BANK_PUBLIC" +printf ' exchange %s\n' "$EXCHANGE_PUBLIC" +printf ' merchant %s\n' "$MERCHANT_PUBLIC" +printf ' currency expect=%s skip_ssh=%s\n' "${EXPECT_CURRENCY:-any}" "$SKIP_SSH" + +chmod +x "$ROOT"/check_*.sh 2>/dev/null || true + +ec=0 +for p in "${PHASES[@]}"; do + case "$p" in + urls) "$ROOT/check_urls.sh" || ec=1 ;; + inside) "$ROOT/check_inside.sh" || ec=1 ;; + versions) "$ROOT/check_versions.sh" || ec=1 ;; + sanity) "$ROOT/check_sanity.sh" || ec=1 ;; + server) "$ROOT/check_server.sh" || ec=1 ;; + e2e) "$ROOT/check_e2e.sh" || ec=1 ;; + esac +done +exit "$ec" diff --git a/scripts/taler-sanity/README.md b/scripts/taler-sanity/README.md new file mode 100644 index 0000000..ad6a566 --- /dev/null +++ b/scripts/taler-sanity/README.md @@ -0,0 +1,31 @@ +# Taler sanity checks (koopa) + +Run **on koopa as root** (reads `/root/*-password.txt`, may use `podman` as `hernani`). + +| Script | What it checks | +|--------|----------------| +| `check_helpers-running.sh` | **No systemd:** starts missing exchange/merchant helpers (`ensure_*`), then requires transfer/aggregator/wirewatch/… | +| `check_stack-health.sh` | Public + local `/config` and exchange `/keys` HTTP 200; merchant has CHF+GOA | +| `check_merchant-delays.sh` | Global + instance delays are short (pay/refund/wire ≤ 120s) | +| `check_exchange-wirewatch.sh` | Wire gateway reachable with **bearer**; auth method; hosts pin | +| `check_settlement.sh` | Paid orders: `wired`, deposit_total, bank balances/transfers | +| `run_all.sh` | Runs all of the above | + +Helper ensure scripts (in containers, no systemd): + +| Script | Role | +|--------|------| +| `../taler-exchange/ensure_exchange_helpers.sh` | `nohup` aggregator, closer, wirewatch, **transfer** | +| `../taler-merchant/ensure_merchant_helpers.sh` | `nohup` wirewatch, depositcheck, kyccheck, webhook, … | +| Health: `check_*-health.sh` | Calls ensure (unless `SKIP_ENSURE=1`), then **FAIL** if still missing | + +```bash +# on koopa +cd /path/to/koopa-admin-log/scripts/taler-sanity +./run_all.sh +./check_settlement.sh 2026.190-03V36SPAZ8CK6 +``` + +Env overrides: `BANK_URL`, `MERCHANT_URL`, `EXCHANGE_PUBLIC`, `BANK_PUBLIC`, `MERCHANT_PUBLIC`, `MERCHANT_INSTANCE`. + +See also: `../taler-wallet-cli/` for end-to-end withdraw → pay → settlement. diff --git a/scripts/taler-sanity/check_exchange-wirewatch.sh b/scripts/taler-sanity/check_exchange-wirewatch.sh new file mode 100644 index 0000000..b92da39 --- /dev/null +++ b/scripts/taler-sanity/check_exchange-wirewatch.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Sanity: exchange can read bank wire gateway (bearer + public URL). +# Run on koopa as root (needs /root/bank-exchange-password.txt + podman). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" +need_root + +echo "=== Exchange wirewatch / wire gateway ===" + +if ! read_pw EPW /root/bank-exchange-password.txt; then + fail "missing /root/bank-exchange-password.txt" + exit 1 +fi + +ET=$(bank_token exchange "$EPW") +if [ -z "$ET" ]; then + fail "could not get bank token for exchange user" + exit 1 +fi +pass "bank token for exchange user" + +# History must work with Bearer (Basic is rejected by libeufin on history) +tmp=$(mktemp) +code=$(curl -sk -m 15 -o "$tmp" -w '%{http_code}' \ + -H "Authorization: Bearer ${ET}" \ + "${BANK_PUBLIC}/accounts/exchange/taler-wire-gateway/history/incoming?delta=-10") +if [ "$code" = "200" ]; then + pass "wire gateway history via ${BANK_PUBLIC} (HTTP $code)" + python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(" incoming_count", len(d.get("incoming_transactions") or []))' "$tmp" +else + # fallback local + code2=$(curl -sS -m 15 -o "$tmp" -w '%{http_code}' \ + -H "Authorization: Bearer ${ET}" \ + "${BANK_URL}/accounts/exchange/taler-wire-gateway/history/incoming?delta=-10") + if [ "$code2" = "200" ]; then + warn "public wire gateway HTTP $code; local BANK_URL OK ($code2) — check DNS/hosts in exchange container" + else + fail "wire gateway history public=$code local=$code2" + fi +fi +rm -f "$tmp" + +# Basic must fail on history (documents expected auth mode) +code_b=$(curl -sk -m 10 -o /dev/null -w '%{http_code}' \ + -u "exchange:${EPW}" \ + "${BANK_URL}/accounts/exchange/taler-wire-gateway/history/incoming?delta=-5") +if [ "$code_b" = "401" ]; then + pass "Basic auth correctly rejected on history ($code_b) — use bearer TOKEN" +else + warn "Basic on history returned $code_b (expected 401)" +fi + +# Container credential + wirewatch process +if su - hernani -c 'podman exec taler-hacktivism-exchange-ansible true' 2>/dev/null; then + su - hernani -c 'podman exec taler-hacktivism-exchange-ansible bash -c " + set +e + echo \"--- secret (redacted) ---\" + if [ -r /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf ]; then + sed \"s/secret-token:.*/secret-token:***/; s/^TOKEN = .*/TOKEN = ***/; s/^PASSWORD = .*/PASSWORD = ***/\" \ + /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf + else + # root-only file: cat as root via outer exec + true + fi + echo \"--- hosts bank ---\" + grep bank.hacktivism.ch /etc/hosts || echo \"(no hosts pin)\" + echo \"--- wirewatch ---\" + ps -eo pid,stat,args | grep \"[w]irewatch\" || echo \"(no wirewatch process)\" + "' 2>&1 || warn "podman exec exchange failed" + + AUTH_METHOD=$(su - hernani -c 'podman exec -u root taler-hacktivism-exchange-ansible \ + grep -E "^WIRE_GATEWAY_AUTH_METHOD" /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf' 2>/dev/null | awk '{print $3}') + GW_URL=$(su - hernani -c 'podman exec -u root taler-hacktivism-exchange-ansible \ + grep -E "^WIRE_GATEWAY_URL" /etc/taler-exchange/secrets/exchange-accountcredentials-1.secret.conf' 2>/dev/null | awk '{print $3}') + info "WIRE_GATEWAY_URL=$GW_URL" + info "WIRE_GATEWAY_AUTH_METHOD=$AUTH_METHOD" + if [ "$AUTH_METHOD" = "bearer" ]; then pass "auth method bearer" + else fail "auth method is '$AUTH_METHOD' (want bearer + TOKEN=)" + fi + case "$GW_URL" in + https://bank.hacktivism.ch/*) pass "gateway uses public bank URL" ;; + http://127.*|http://host.containers*) warn "gateway uses local URL: $GW_URL" ;; + *) warn "unexpected gateway URL: $GW_URL" ;; + esac +else + warn "exchange container not reachable via podman" +fi + +echo "=== summary fails=$FAILS ===" +exit "$FAILS" diff --git a/scripts/taler-sanity/check_helpers-running.sh b/scripts/taler-sanity/check_helpers-running.sh new file mode 100755 index 0000000..dd28666 --- /dev/null +++ b/scripts/taler-sanity/check_helpers-running.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Sanity: ensure exchange + merchant helpers are up (no systemd). +# Runs on koopa as root; uses podman into both containers. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" +need_root + +fail=0 +pass() { echo "OK $1"; } +warn() { echo "WARN $1"; } +bad() { echo "FAIL $1"; fail=$((fail + 1)); } + +EXC_CT="${EXCHANGE_CONTAINER:-taler-hacktivism-exchange-ansible}" +MER_CT="${MERCHANT_CONTAINER:-taler-hacktivism}" + +echo "=== ensure + check helpers (no systemd) ===" + +# Deploy/ensure scripts if present on host under admin-log mount or /tmp +deploy_into() { + local ct="$1" + local src="$2" + local dst="$3" + if [ -f "$src" ]; then + su - hernani -c "podman cp $(printf %q "$src") ${ct}:${dst}" 2>/dev/null \ + || podman cp "$src" "${ct}:${dst}" 2>/dev/null || true + su - hernani -c "podman exec ${ct} chmod 755 ${dst}" 2>/dev/null || true + fi +} + +ADMIN="${KOOPA_ADMIN_LOG:-/home/hernani/koopa-admin-log}" +# Prefer workspace copy if synced; else scripts next to this file +EX_SCRIPTS="$ROOT/../taler-exchange" +MER_SCRIPTS="$ROOT/../taler-merchant" +[ -d "$ADMIN/scripts/taler-exchange" ] && EX_SCRIPTS="$ADMIN/scripts/taler-exchange" +[ -d "$ADMIN/scripts/taler-merchant" ] && MER_SCRIPTS="$ADMIN/scripts/taler-merchant" + +deploy_into "$EXC_CT" "$EX_SCRIPTS/ensure_exchange_helpers.sh" /usr/local/bin/ensure_exchange_helpers.sh +deploy_into "$EXC_CT" "$EX_SCRIPTS/check_exchange-health.sh" /usr/local/bin/check_exchange-health.sh +deploy_into "$MER_CT" "$MER_SCRIPTS/ensure_merchant_helpers.sh" /usr/local/bin/ensure_merchant_helpers.sh +deploy_into "$MER_CT" "$MER_SCRIPTS/check_merchant-health.sh" /usr/local/bin/check_merchant-health.sh + +echo "--- exchange container: ensure ---" +if su - hernani -c "podman exec $EXC_CT /usr/local/bin/ensure_exchange_helpers.sh"; then + pass "exchange helpers ensure" +else + bad "exchange helpers ensure failed" +fi + +echo "--- merchant container: ensure ---" +if su - hernani -c "podman exec $MER_CT /usr/local/bin/ensure_merchant_helpers.sh"; then + pass "merchant helpers ensure" +else + bad "merchant helpers ensure failed" +fi + +echo "--- process presence ---" +check_ct_proc() { + local ct="$1" + local p="$2" + # COMM is 15 chars — match full cmdline + if su - hernani -c "podman exec $ct pgrep -f '(^|/)${p}( |$)'" >/dev/null 2>&1; then + pass "$ct: $p" + else + bad "$ct: $p missing" + fi +} + +for p in taler-exchange-httpd taler-exchange-aggregator taler-exchange-transfer \ + taler-exchange-wirewatch taler-exchange-closer; do + check_ct_proc "$EXC_CT" "$p" +done +for p in taler-merchant-httpd taler-merchant-wirewatch taler-merchant-depositcheck \ + taler-merchant-webhook taler-merchant-kyccheck \ + taler-merchant-exchangekeyupdate taler-merchant-reconciliation; do + check_ct_proc "$MER_CT" "$p" +done + +echo "=== summary fails=$fail ===" +exit "$fail" diff --git a/scripts/taler-sanity/check_merchant-delays.sh b/scripts/taler-sanity/check_merchant-delays.sh new file mode 100644 index 0000000..10b47ef --- /dev/null +++ b/scripts/taler-sanity/check_merchant-delays.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Sanity: merchant global + demo instance delays are demo-short (pay/refund/wire). +# Run on koopa (network to :9010 / public merchant). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +# Expected upper bounds (seconds) — fail if longer (package defaults 1d/15d/1w) +MAX_PAY_S=${MAX_PAY_S:-120} +MAX_REFUND_S=${MAX_REFUND_S:-120} +MAX_WIRE_S=${MAX_WIRE_S:-120} + +echo "=== Merchant delay defaults (expect short demo values) ===" + +tmp=$(mktemp) +curl -sk -m 12 -o "$tmp" "${MERCHANT_PUBLIC}/config" || curl -sk -m 12 -o "$tmp" "${MERCHANT_URL}/config" +python3 - "$tmp" "$MAX_PAY_S" "$MAX_REFUND_S" "$MAX_WIRE_S" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +max_pay, max_ref, max_wire = map(float, sys.argv[2:5]) +fails=0 +for key, mx in ( + ("default_pay_delay", max_pay), + ("default_refund_delay", max_ref), + ("default_wire_transfer_delay", max_wire), +): + us=d.get(key,{}).get("d_us") + s=(us or 0)/1e6 + ok = us is not None and s <= mx + print(f"{'OK' if ok else 'FAIL'} {key}: {s:.0f}s (max {mx:.0f}s)") + if not ok: fails+=1 +print("currency", d.get("currency")) +sys.exit(fails) +PY +ec=$? +rm -f "$tmp" +FAILS=$((FAILS + ec)) + +# Instance goa-demo if password present +if [ -f /root/merchant-goa-demo-cp4zqk-password.txt ]; then + MPW=$(tr -d '\n' = wd and not d.get("wired"): + print(" FAIL wire deadline passed but wired=false") + sys.exit(2) + if now < wd: + print(" INFO still before wire deadline — settlement not due yet") + if d.get("wired"): + print(" OK wired=true") +sys.exit(0) +PY + ec=$? + [ "$ec" -eq 2 ] && FAILS=$((FAILS + 1)) + rm -f "$ot" +done + +echo "=== private/transfers ===" +tr=$(mktemp) +curl -sk -m 15 -H "$AUTH" -o "$tr" \ + "${MERCHANT_URL}/instances/${INST}/private/transfers" +python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); t=d.get("transfers") or []; print(f" transfers={len(t)}"); +[print(" ", x) for x in t[:10]]' "$tr" +rm -f "$tr" "$tmp" + +# Bank balances +BANK_USER="${BANK_USER:-$INST}" +if read_pw BPW "/root/bank-${BANK_USER}-password.txt" || read_pw BPW /root/bank-goa-demo-cp4zqk-password.txt; then + BT=$(bank_token "$BANK_USER" "$BPW") + if [ -n "$BT" ]; then + curl -sS -m 12 -H "Authorization: Bearer ${BT}" \ + "${BANK_URL}/accounts/${BANK_USER}" | python3 -c 'import sys,json; d=json.load(sys.stdin); print("merchant_bank_balance", d.get("balance"))' + curl -sS -m 12 -H "Authorization: Bearer ${BT}" \ + "${BANK_URL}/accounts/${BANK_USER}/transactions?delta=-10" \ + | python3 -c 'import sys,json; d=json.load(sys.stdin); txs=d.get("transactions") or []; print(f"merchant_bank_tx={len(txs)}"); +[print(f" {t.get(\"direction\")} {t.get(\"amount\")} {t.get(\"subject\",\"\")[:60]}") for t in txs[:8]]' + fi +fi + +if read_pw EPW /root/bank-exchange-password.txt; then + ET=$(bank_token exchange "$EPW") + if [ -n "$ET" ]; then + curl -sS -m 12 -H "Authorization: Bearer ${ET}" \ + "${BANK_URL}/accounts/exchange" | python3 -c 'import sys,json; d=json.load(sys.stdin); print("exchange_bank_balance", d.get("balance"))' + fi +fi + +echo "=== summary fails=$FAILS ===" +exit "$FAILS" diff --git a/scripts/taler-sanity/check_stack-health.sh b/scripts/taler-sanity/check_stack-health.sh new file mode 100644 index 0000000..1d39fe0 --- /dev/null +++ b/scripts/taler-sanity/check_stack-health.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Sanity: public + local Taler endpoints respond (GOA stack). +# Run on koopa as root (or any user with network to services). +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +echo "=== Taler stack health ===" + +# Public HTTPS (via Caddy / VeciGate) +for url in \ + "${EXCHANGE_PUBLIC}/config" \ + "${EXCHANGE_PUBLIC}/keys" \ + "${BANK_PUBLIC}/config" \ + "${MERCHANT_PUBLIC}/config" +do + code=$(http_code "$url") + if [ "$code" = "200" ]; then pass "$url -> $code" + else fail "$url -> $code" + fi +done + +# Local loopback ports (containers via pasta) +for spec in \ + "bank ${BANK_URL}/config" \ + "merchant ${MERCHANT_URL}/config" \ + "exchange-via-public ${EXCHANGE_PUBLIC}/config" +do + name=${spec%% *} + url=${spec#* } + code=$(http_code "$url") + if [ "$code" = "200" ]; then pass "local $name -> $code" + else fail "local $name -> $code" + fi +done + +# Merchant multi-currency +tmp=$(mktemp) +curl -sk -m 12 -o "$tmp" "${MERCHANT_PUBLIC}/config" || true +if python3 - "$tmp" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +ex=[e.get("currency") for e in d.get("exchanges") or []] +cur=list((d.get("currencies") or {}).keys()) +ok = "GOA" in ex and "CHF" in ex and "GOA" in cur +print("currency_default", d.get("currency")) +print("exchanges", ex) +print("currencies", cur) +sys.exit(0 if ok else 1) +PY +then pass "merchant has CHF + GOA exchanges" +else fail "merchant missing CHF/GOA in /config" +fi +rm -f "$tmp" + +# Exchange currency GOA +tmp=$(mktemp) +curl -sk -m 12 -o "$tmp" "${EXCHANGE_PUBLIC}/config" || true +if python3 - "$tmp" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +c=(d.get("currency") or d.get("currency_specification",{}).get("currency")) +print("exchange_currency", c) +sys.exit(0 if c=="GOA" else 1) +PY +then pass "exchange currency GOA" +else fail "exchange currency not GOA" +fi +rm -f "$tmp" + +echo "=== summary fails=$FAILS ===" +exit "$FAILS" diff --git a/scripts/taler-sanity/lib.sh b/scripts/taler-sanity/lib.sh new file mode 100644 index 0000000..851fa9d --- /dev/null +++ b/scripts/taler-sanity/lib.sh @@ -0,0 +1,60 @@ +# shellcheck shell=bash +# Common helpers for Taler sanity checks (run on koopa host as root). +# shellcheck disable=SC2034 + +: "${BANK_URL:=http://127.0.0.1:9012}" +: "${MERCHANT_URL:=https://127.0.0.1:9010}" +: "${EXCHANGE_PUBLIC:=https://exchange.hacktivism.ch}" +: "${BANK_PUBLIC:=https://bank.hacktivism.ch}" +: "${MERCHANT_PUBLIC:=https://taler.hacktivism.ch}" + +pass() { echo "OK $*"; } +fail() { echo "FAIL $*"; FAILS=$((FAILS + 1)); } +warn() { echo "WARN $*"; } +info() { echo "INFO $*"; } + +FAILS=0 + +need_root() { + if [ "$(id -u)" -ne 0 ]; then + echo "This check expects root on koopa (password files under /root)." >&2 + exit 2 + fi +} + +read_pw() { + # read_pw VAR path + local _v="$1" _p="$2" + if [ ! -f "$_p" ]; then + eval "$_v=" + return 1 + fi + eval "$_v=\$(tr -d '\\n' <\"$_p\")" +} + +bank_token() { + # bank_token USER PASS -> prints access_token + local user="$1" pass="$2" + curl -sS -m 15 -u "${user}:${pass}" \ + -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite","refreshable":true}' \ + "${BANK_URL}/accounts/${user}/token" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token",""))' +} + +merchant_auth_header() { + # merchant_auth_header PASSWORD + printf 'Authorization: Bearer secret-token:%s' "$1" +} + +http_code() { + # http_code URL [curl args...] + local url="$1" + shift + curl -sk -m 12 -o /dev/null -w '%{http_code}' "$@" "$url" 2>/dev/null || echo "000" +} + +json_get() { + # json_get FILE python-expr-on-d + python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print($2)" "$1" +} diff --git a/scripts/taler-sanity/run_all.sh b/scripts/taler-sanity/run_all.sh new file mode 100644 index 0000000..e33f68d --- /dev/null +++ b/scripts/taler-sanity/run_all.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Run all sanity checks; exit non-zero if any failed. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +ec=0 +for s in \ + check_helpers-running.sh \ + check_stack-health.sh \ + check_merchant-delays.sh \ + check_exchange-wirewatch.sh \ + check_settlement.sh +do + echo "" + echo "########## $s ##########" + if bash "$ROOT/$s"; then + echo "PASS $s" + else + echo "FAIL $s (exit $?)" + ec=1 + fi +done +exit $ec diff --git a/scripts/taler-shared/install_privacy_docs.sh b/scripts/taler-shared/install_privacy_docs.sh new file mode 100644 index 0000000..9d4fb48 --- /dev/null +++ b/scripts/taler-shared/install_privacy_docs.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Write privacy policy files (txt/md/html) into a TERMS/PRIVACY dir. +# Used by exchange + merchant installers. Style matches dual-currency terms. +# +# Usage (sourced or): install_privacy_docs DIR ETAG TITLE BADGE +# Environment: BODY_MD, BODY_TXT, BODY_HTML_MAIN (inner body HTML after h1) +set -euo pipefail + +install_privacy_docs() { + local dir="$1" etag="$2" title="$3" badge="$4" + local lang="${5:-en}" + local out="$dir/$lang" + mkdir -p "$out" + + printf '%s\n' "${BODY_TXT:?}" >"$out/${etag}.txt" + printf '%s\n' "${BODY_MD:?}" >"$out/${etag}.md" + + cat >"$out/${etag}.html" < + + + + + ${title} + + + +
${badge}
+

${title}

+${BODY_HTML_MAIN} +
Version ${etag} · Swiss FADP (revDSG) · hacktivism.ch
+ + +HTML + chmod -R a+rX "$dir" + echo "privacy docs: $out/${etag}.{txt,md,html}" +} diff --git a/scripts/taler-shared/mem-snapshot.sh b/scripts/taler-shared/mem-snapshot.sh new file mode 100644 index 0000000..b011202 --- /dev/null +++ b/scripts/taler-shared/mem-snapshot.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# Memory snapshot for landing-stats (source after json_str is defined). +# Sets MEM_JSON (fields to embed inside performance.memory). +# IMPORTANT: no pipelines around the /proc loop (bash subshell loses counters). + +mem_fmt_bytes() { + awk -v b="${1:-0}" 'BEGIN{ + b = b + 0 + if (b < 1024) printf "%d B", b + else if (b < 1048576) printf "%.1f KiB", b / 1024 + else if (b < 1073741824) printf "%.1f MiB", b / 1048576 + else printf "%.2f GiB", b / 1073741824 + }' +} + +mem_snapshot_json() { + local rss_kb rss_b pid comm cmd short + local sum_b=0 cgroup_b="" limit_b="" + local postgres_b=0 taler_b=0 nginx_b=0 java_b=0 redis_b=0 other_b=0 + local n_pg=0 n_taler=0 n_nginx=0 n_java=0 n_redis=0 n_other=0 + local allf topf top_json top_n=0 lim_json cg_json hum cjs sjs hjs + + if [ -r /sys/fs/cgroup/memory.current ]; then + cgroup_b=$(tr -d ' \n' /dev/null || true) + if [ -r /sys/fs/cgroup/memory.max ]; then + limit_b=$(tr -d ' \n' /dev/null || true) + [ "$limit_b" = "max" ] && limit_b="" + fi + elif [ -r /sys/fs/cgroup/memory/memory.usage_in_bytes ]; then + cgroup_b=$(tr -d ' \n' /dev/null || true) + fi + + allf=$(mktemp) + topf=$(mktemp) + + for st in /proc/[0-9]*/status; do + [ -f "$st" ] || continue + pid=${st#/proc/} + pid=${pid%/status} + rss_kb=$(awk '/^VmRSS:/{print $2; exit}' "$st" 2>/dev/null || true) + [[ "$rss_kb" =~ ^[0-9]+$ ]] || continue + rss_b=$((rss_kb * 1024)) + sum_b=$((sum_b + rss_b)) + comm=$(awk '/^Name:/{print $2; exit}' "$st" 2>/dev/null || echo "?") + cmd="" + if [ -r "/proc/$pid/cmdline" ]; then + cmd=$(tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | sed 's/[[:space:]]*$//') + fi + [ -n "$cmd" ] || cmd=$comm + short=$(printf '%s' "$cmd" | cut -c1-100) + + case "$comm $cmd" in + *postgres*|*postmaster*) + postgres_b=$((postgres_b + rss_b)); n_pg=$((n_pg + 1)) ;; + *taler-exchange*|*taler-merchant*|*taler-auditor*|*taler-helper*|*taler_*) + taler_b=$((taler_b + rss_b)); n_taler=$((n_taler + 1)) ;; + *nginx*) + nginx_b=$((nginx_b + rss_b)); n_nginx=$((n_nginx + 1)) ;; + *java*|*libeufin*|*MainKt*) + java_b=$((java_b + rss_b)); n_java=$((n_java + 1)) ;; + *redis-server*|*redis*) + redis_b=$((redis_b + rss_b)); n_redis=$((n_redis + 1)) ;; + *) + other_b=$((other_b + rss_b)); n_other=$((n_other + 1)) ;; + esac + printf '%s\t%s\t%s\n' "$rss_b" "$comm" "$short" >>"$allf" + done + + sort -t$'\t' -nr -k1,1 "$allf" 2>/dev/null | head -10 >"$topf" + rm -f "$allf" + + local total_b=$sum_b + if [[ "$cgroup_b" =~ ^[0-9]+$ ]] && [ "$cgroup_b" -gt 0 ]; then + total_b=$cgroup_b + fi + + top_json="[" + top_n=0 + while IFS=$'\t' read -r rss_b comm short; do + [ -z "${rss_b:-}" ] && continue + [ "$top_n" -gt 0 ] && top_json="${top_json}," + top_n=$((top_n + 1)) + hum=$(mem_fmt_bytes "$rss_b") + cjs=$(json_str "$comm") + sjs=$(json_str "$short") + hjs=$(json_str "$hum") + top_json="${top_json} + {\"rss_bytes\": ${rss_b}, \"rss_human\": ${hjs}, \"comm\": ${cjs}, \"cmd\": ${sjs}}" + done <"$topf" + top_json="${top_json} + ]" + rm -f "$topf" + + lim_json="null" + if [[ "$limit_b" =~ ^[0-9]+$ ]] && [ "$limit_b" -gt 0 ]; then + lim_json=$limit_b + fi + cg_json="null" + if [[ "$cgroup_b" =~ ^[0-9]+$ ]]; then + cg_json=$cgroup_b + fi + + MEM_JSON=" + \"container_rss_bytes\": ${total_b}, + \"container_rss_human\": $(json_str "$(mem_fmt_bytes "$total_b")"), + \"proc_sum_rss_bytes\": ${sum_b}, + \"proc_sum_rss_human\": $(json_str "$(mem_fmt_bytes "$sum_b")"), + \"cgroup_bytes\": ${cg_json}, + \"cgroup_limit_bytes\": ${lim_json}, + \"postgres_rss_bytes\": ${postgres_b}, + \"postgres_rss_human\": $(json_str "$(mem_fmt_bytes "$postgres_b")"), + \"postgres_n\": ${n_pg}, + \"taler_rss_bytes\": ${taler_b}, + \"taler_rss_human\": $(json_str "$(mem_fmt_bytes "$taler_b")"), + \"taler_n\": ${n_taler}, + \"nginx_rss_bytes\": ${nginx_b}, + \"nginx_rss_human\": $(json_str "$(mem_fmt_bytes "$nginx_b")"), + \"nginx_n\": ${n_nginx}, + \"java_rss_bytes\": ${java_b}, + \"java_rss_human\": $(json_str "$(mem_fmt_bytes "$java_b")"), + \"java_n\": ${n_java}, + \"redis_rss_bytes\": ${redis_b}, + \"redis_rss_human\": $(json_str "$(mem_fmt_bytes "$redis_b")"), + \"redis_n\": ${n_redis}, + \"other_rss_bytes\": ${other_b}, + \"other_rss_human\": $(json_str "$(mem_fmt_bytes "$other_b")"), + \"other_n\": ${n_other}, + \"top\": ${top_json} +" +} diff --git a/scripts/taler-shared/terms-style.css.fragment b/scripts/taler-shared/terms-style.css.fragment new file mode 100644 index 0000000..ead665c --- /dev/null +++ b/scripts/taler-shared/terms-style.css.fragment @@ -0,0 +1,37 @@ +/* Shared dark palette for hacktivism.ch Taler terms & privacy pages */ +:root { color-scheme: dark light; } +body { + font-family: system-ui, -apple-system, sans-serif; + max-width: 42rem; margin: 2rem auto; padding: 0 1.1rem 3rem; + line-height: 1.5; color: #e8e6e3; background: #1a1520; +} +h1 { font-size: 1.35rem; font-weight: 800; margin: 0 0 1rem; color: #f5f0ea; } +h2 { font-size: 1.05rem; margin: 1.5rem 0 0.5rem; color: #e8c878; } +h3 { font-size: 0.95rem; margin: 1rem 0 0.4rem; color: #c4b5fd; } +p, li, td, th { font-size: 0.95rem; } +ul, ol { padding-left: 1.2rem; } +code, a { color: #5eead4; } +a { text-decoration: none; } +a:hover { text-decoration: underline; } +.badge { + display: inline-block; font-size: 0.72rem; font-weight: 700; + letter-spacing: 0.06em; text-transform: uppercase; + color: #c4b5fd; border: 1px solid rgba(196,181,253,0.35); + border-radius: 999px; padding: 0.2rem 0.65rem; margin-bottom: 0.85rem; +} +.note { + border-radius: 12px; padding: 0.75rem 0.9rem; margin: 0.85rem 0 1rem; + border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); + font-size: 0.9rem; color: #c8c4bf; +} +table { + width: 100%; border-collapse: collapse; margin: 0.6rem 0 1rem; + font-size: 0.88rem; +} +th, td { + border: 1px solid rgba(255,255,255,0.12); padding: 0.45rem 0.55rem; + text-align: left; vertical-align: top; +} +th { background: rgba(0,0,0,0.35); color: #e8c878; font-weight: 700; } +.muted { color: #a39e98; font-size: 0.88rem; } +footer { margin-top: 2rem; font-size: 0.85rem; color: #a39e98; } diff --git a/scripts/taler-wallet-cli/goa-deb-withdraw-pay.sh b/scripts/taler-wallet-cli/goa-deb-withdraw-pay.sh new file mode 100755 index 0000000..7eb8e30 --- /dev/null +++ b/scripts/taler-wallet-cli/goa-deb-withdraw-pay.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Debian/Ubuntu · same as bank landing deb pane (install + run) +set -eu + +# install (deb.taler.net · trixie): keyring, apt source, packages +if ! command -v taler-wallet-cli >/dev/null 2>&1; then + sudo mkdir -p /etc/apt/keyrings && \ + sudo wget -q -O /etc/apt/keyrings/taler-systems.gpg https://taler.net/taler-systems.gpg && \ + echo 'deb [signed-by=/etc/apt/keyrings/taler-systems.gpg] https://deb.taler.net/apt/debian trixie main' | sudo tee /etc/apt/sources.list.d/taler.list && \ + sudo apt-get update && sudo apt-get install -y taler-wallet-cli wget +fi +command -v wget >/dev/null 2>&1 || sudo apt-get install -y wget + +# private wallet DB under /tmp (not the default home DB) +tw() { + taler-wallet-cli \ + --wallet-db="${TMPDIR:-/tmp}/taler-wallet-goa.sqlite3" \ + --no-throttle "$@" +} + +# mint from bank, register exchange, withdraw GOA:10 +wget -q -O demo-withdraw.json https://bank.hacktivism.ch/intro/demo-withdraw.json && \ +tw exchanges add https://exchange.hacktivism.ch/ && \ +tw exchanges accept-tos https://exchange.hacktivism.ch/ && \ +tw withdraw accept-uri --exchange https://exchange.hacktivism.ch/ \ + "$(grep -o 'taler://[^"]*' demo-withdraw.json)" && \ +tw run-until-done && \ + +# pay orbit-sticker (GOA:2); balance ends at GOA:8 +tw handle-uri --yes \ + taler://pay-template/taler.hacktivism.ch/instances/goa-shop/orbit-sticker && \ +tw run-until-done && tw balance diff --git a/scripts/taler-wallet-cli/goa-posix-withdraw-pay.sh b/scripts/taler-wallet-cli/goa-posix-withdraw-pay.sh new file mode 100755 index 0000000..1de3ab7 --- /dev/null +++ b/scripts/taler-wallet-cli/goa-posix-withdraw-pay.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Unix/macOS · monorepo CLI (same flow as bank landing unix pane) +# +# install (once): +# git + node (LTS) + monorepo build deps +# git clone https://git.taler.net/taler-typescript-core.git +# cd taler-typescript-core && ./bootstrap && ./configure && make +# +# run from taler-typescript-core after make (needs: node, wget|curl, grep) +set -eu + +command -v node >/dev/null 2>&1 || { echo "install: node (LTS)" >&2; exit 1; } +[ -f packages/taler-wallet-cli/bin/taler-wallet-cli.mjs ] || { + echo "install/build: run from taler-typescript-core after make" >&2 + exit 1 +} +command -v wget >/dev/null 2>&1 || command -v curl >/dev/null 2>&1 || { + echo "install: wget or curl" >&2 + exit 1 +} + +# monorepo CLI + private wallet DB (not system package / default home DB) +tw() { + node packages/taler-wallet-cli/bin/taler-wallet-cli.mjs \ + --wallet-db="${TMPDIR:-/tmp}/taler-wallet-goa.sqlite3" \ + --no-throttle "$@" +} + +# mint from bank, register exchange, withdraw GOA:10 +(wget -q -O demo-withdraw.json https://bank.hacktivism.ch/intro/demo-withdraw.json || \ + curl -fsS -o demo-withdraw.json https://bank.hacktivism.ch/intro/demo-withdraw.json) && \ +tw exchanges add https://exchange.hacktivism.ch/ && \ +tw exchanges accept-tos https://exchange.hacktivism.ch/ && \ +tw withdraw accept-uri --exchange https://exchange.hacktivism.ch/ \ + "$(grep -o 'taler://[^"]*' demo-withdraw.json)" && \ +tw run-until-done && \ + +# pay orbit-sticker (GOA:2); balance ends at GOA:8 +tw handle-uri --yes \ + taler://pay-template/taler.hacktivism.ch/instances/goa-shop/orbit-sticker && \ +tw run-until-done && tw balance diff --git a/scripts/taler-wallet-cli/run-amount-ladder-bench.sh b/scripts/taler-wallet-cli/run-amount-ladder-bench.sh new file mode 100755 index 0000000..a06aa0e --- /dev/null +++ b/scripts/taler-wallet-cli/run-amount-ladder-bench.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# Compatibility wrapper — canonical benchmark lives under benchmarks/amount-ladder/ +exec "$(cd "$(dirname "$0")/../.." && pwd)/benchmarks/amount-ladder/run.sh" "$@" diff --git a/scripts/taler-wallet-cli/run-extreme-amount-tests.sh b/scripts/taler-wallet-cli/run-extreme-amount-tests.sh new file mode 100755 index 0000000..1774304 --- /dev/null +++ b/scripts/taler-wallet-cli/run-extreme-amount-tests.sh @@ -0,0 +1,350 @@ +#!/bin/bash +# Extreme amount tests for GOA: largest practical price vs atomic/tiny coin. +# +# Live denoms (exchange.hacktivism.ch): GOA:0.000001 … GOA:10 +# Currency TINY / round unit: GOA:0.00000001 (Atomic-GOA) +# +# Run on the local machine (wallet) with ssh BatchMode to koopa for bank/merchant ops. +# Requires: monorepo taler-wallet-cli, passwords on koopa /root/*-password.txt +# +# Usage: +# ./run-extreme-amount-tests.sh +# HIGH_AMOUNT=GOA:500 TINY_AMOUNT=GOA:0.000001 ./run-extreme-amount-tests.sh +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +SCRATCH="${ROOT}/.tmp/extreme-$(date -u +%Y%m%d-%H%M%S)" +mkdir -p "$SCRATCH" +chmod 700 "$SCRATCH" + +MONO="${MONO:-/Users/newkamek/src/taler/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs}" +WDB="${WDB:-$SCRATCH/wallet.sqlite3}" +EX="${EXCHANGE_URL:-https://exchange.hacktivism.ch/}" +KOOPA="${KOOPA_HOST:-koopa}" +INST="${MERCHANT_INSTANCE:-goa-demo-cp4zqk}" +BANK_PUBLIC="${BANK_PUBLIC:-https://bank.hacktivism.ch}" +MER_PUBLIC="${MER_PUBLIC:-https://taler.hacktivism.ch}" + +# High price: many max-denom (GOA:10) coins. Tiny: smallest issued coin. +HIGH_AMOUNT="${HIGH_AMOUNT:-GOA:1000}" +TINY_AMOUNT="${TINY_AMOUNT:-GOA:0.000001}" +# True atomic (round unit) — may fail if no coin below 1e-6 +ATOMIC_AMOUNT="${ATOMIC_AMOUNT:-GOA:0.00000001}" +# Fat bank balance for withdraw buffer +FAT_CREDIT="${FAT_CREDIT:-GOA:50000}" +FAT_USER="${FAT_USER:-extreme-fat}" +# withdraw a bit more than HIGH +WITHDRAW_AMOUNT="${WITHDRAW_AMOUNT:-GOA:1500}" + +wcli() { node "$MONO" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"; } + +log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*"; } +die() { echo "FAIL: $*" >&2; exit 1; } + +command -v node >/dev/null || die "node missing" +[ -f "$MONO" ] || die "wallet missing: $MONO" +ssh -o BatchMode=yes -o ConnectTimeout=10 "$KOOPA" 'echo ok' >/dev/null || die "ssh $KOOPA failed" + +log "scratch=$SCRATCH" +log "HIGH=$HIGH_AMOUNT TINY=$TINY_AMOUNT ATOMIC=$ATOMIC_AMOUNT WITHDRAW=$WITHDRAW_AMOUNT FAT=$FAT_USER+$FAT_CREDIT" + +# --- 1) koopa: bank account + credit + templates + orders --- +log "=== koopa setup (admin credit, templates, orders) ===" +# Generate bank password locally (not committed) +FAT_PW=$(python3 -c 'import secrets; print(secrets.token_urlsafe(12))') +echo "$FAT_PW" >"$SCRATCH/fat-bank.password" +chmod 600 "$SCRATCH/fat-bank.password" + +cat >"$SCRATCH/koopa-setup.sh" < /tmp/extreme-setup.out 2>&1 +set -x +INST=$INST +FAT_USER=$FAT_USER +FAT_PW='$(printf %s "$FAT_PW" | sed "s/'/'\\\\''/g")' +FAT_CREDIT=$FAT_CREDIT +HIGH_AMOUNT=$HIGH_AMOUNT +TINY_AMOUNT=$TINY_AMOUNT +ATOMIC_AMOUNT=$ATOMIC_AMOUNT +BANK=http://127.0.0.1:9012 +MER=https://127.0.0.1:9010 + +ADMIN_PASS=\$(tr -d '\\n' =5: + bits-=5; o.append(a[(v>>bits)&31]) +if bits: o.append(a[(v<<(5-bits))&31]) +print("".join(o))') + curl -sS -H "Authorization: Bearer \${AT}" -H 'Content-Type: application/json' \\ + -d "{\\"payto_uri\\":\\"\${PAYTO}\\",\\"amount\\":\\"\${FAT_CREDIT}\\",\\"request_uid\\":\\"\${UID}\\"}" \\ + "\${BANK}/accounts/admin/transactions" | tee /tmp/credit.out + echo +fi + +# balance +curl -sS -H "Authorization: Bearer \${AT}" "\${BANK}/accounts/\${FAT_USER}" | tee /tmp/fat-bal.json +echo +python3 -c 'import json;d=json.load(open("/tmp/fat-bal.json"));print("FAT_BALANCE", (d.get("balance") or {}).get("amount"), (d.get("balance") or {}).get("credit_debit_indicator"))' + +# store bank pw for host-side withdraw +printf '%s' "\$FAT_PW" > /root/bank-\${FAT_USER}-password.txt +chmod 600 /root/bank-\${FAT_USER}-password.txt + +# templates +for id_summary_amount in \\ + "extreme-high|extreme high price|\${HIGH_AMOUNT}" \\ + "extreme-tiny|extreme tiny coin|\${TINY_AMOUNT}" \\ + "extreme-atomic|true atomic round unit|\${ATOMIC_AMOUNT}" +do + IFS='|' read -r TID SUM AMT <<<"\$id_summary_amount" + body=\$(python3 -c "import json; print(json.dumps({ + 'template_id': '\$TID', + 'template_description': '\$SUM', + 'editable_defaults': False, + 'template_contract': { + 'summary': '\$SUM', + 'amount': '\$AMT', + 'minimum_age': 0, + }, + }))") + # try POST then PATCH + code=\$(curl -sk -o /tmp/tpl.json -w '%{http_code}' -X POST -H "\$AUTH" -H 'Content-Type: application/json' \\ + -d "\$body" "\${MER}/instances/\${INST}/private/templates") + echo "template_\$TID post=\$code \$(head -c 120 /tmp/tpl.json)" + if [ "\$code" = "409" ] || [ "\$code" = "400" ]; then + curl -sk -X PATCH -H "\$AUTH" -H 'Content-Type: application/json' \\ + -d "\$body" "\${MER}/instances/\${INST}/private/templates/\${TID}" -w " patch=%{http_code}\\n" + fi +done + +# also list templates +curl -sk -H "\$AUTH" "\${MER}/instances/\${INST}/private/templates" | python3 -m json.tool | head -80 + +# create two firm orders (high + tiny) for deterministic pay URIs +create_order() { + local sum="\$1" amt="\$2" tag="\$3" + curl -sk -X POST -H "\$AUTH" -H 'Content-Type: application/json' \\ + -d "{\\"order\\":{\\"summary\\":\\"\${sum}\\",\\"amount\\":\\"\${amt}\\",\\"fulfillment_message\\":\\"extreme-ok\\"},\\"create_token\\":true}" \\ + "\${MER}/instances/\${INST}/private/orders" > "/tmp/ord-\${tag}.json" + python3 - "\$tag" <<'PY' +import json,sys +tag=sys.argv[1] +d=json.load(open(f"/tmp/ord-{tag}.json")) +print(f"ORDER_{tag.upper()}", d.get("order_id"), "token", (d.get("token") or "")[:12]) +open(f"/tmp/oid-{tag}.txt","w").write(d.get("order_id") or "") +open(f"/tmp/otok-{tag}.txt","w").write(d.get("token") or "") +PY +} +create_order "extreme-high-order" "\$HIGH_AMOUNT" high +create_order "extreme-tiny-order" "\$TINY_AMOUNT" tiny +create_order "extreme-atomic-order" "\$ATOMIC_AMOUNT" atomic + +# create bank integrated withdrawal for fat user +BT=\$(curl -sS -u "\${FAT_USER}:\${FAT_PW}" -H 'Content-Type: application/json' \\ + -d '{"scope":"readwrite"}' "\${BANK}/accounts/\${FAT_USER}/token" \\ + | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))') +curl -sS -H "Authorization: Bearer \${BT}" -H 'Content-Type: application/json' \\ + -d "{\\"amount\\":\\"\$WITHDRAW_AMOUNT\\",\\"exchange_url\\":\\"https://exchange.hacktivism.ch/\\"}" \\ + "\${BANK}/accounts/\${FAT_USER}/withdrawals" | tee /tmp/wd-create.json +echo +python3 - <<'PY' +import json +d=json.load(open("/tmp/wd-create.json")) +wid=d.get("withdrawal_id") or d.get("id") +uri=d.get("taler_withdraw_uri") +print("WID", wid) +print("WURI", uri) +open("/tmp/wid.txt","w").write(wid or "") +open("/tmp/wuri.txt","w").write(uri or "") +PY + +# publish artifacts for hernani-readable pull +cp /tmp/oid-*.txt /tmp/otok-*.txt /tmp/wuri.txt /tmp/wid.txt /tmp/fat-bal.json /tmp/extreme-setup.out /home/hernani/ 2>/dev/null || true +chmod 644 /home/hernani/oid-*.txt /home/hernani/otok-*.txt /home/hernani/wuri.txt /home/hernani/wid.txt 2>/dev/null || true + +echo SETUP_EXTREME_OK +EOS + +# inject WITHDRAW_AMOUNT into script (heredoc already expanded partially - fix) +# The setup script uses $WITHDRAW_AMOUNT as shell var on koopa - need to export into script +sed -i.bak "s|\$WITHDRAW_AMOUNT|$WITHDRAW_AMOUNT|g" "$SCRATCH/koopa-setup.sh" 2>/dev/null || \ + sed -i '' "s|\$WITHDRAW_AMOUNT|$WITHDRAW_AMOUNT|g" "$SCRATCH/koopa-setup.sh" + +scp -o BatchMode=yes "$SCRATCH/koopa-setup.sh" "${KOOPA}:/tmp/extreme-setup.sh" +# also scp credit-account for reliable credit +scp -o BatchMode=yes "$ROOT/scripts/taler-bank/credit-account.sh" "${KOOPA}:/tmp/credit-account.sh" || true + +# run via screen window 0 (root@koopa) — screen-run cannot source local-machine paths +SESSION=$(screen -ls 2>/dev/null | awk 'match($0, /[0-9]+\.[A-Za-z0-9._-]+/) { print substr($0, RSTART, RLENGTH); exit }') +[ -n "$SESSION" ] || die "no screen session" +screen -S "$SESSION" -p 0 -X stuff $'bash /tmp/extreme-setup.sh; chmod 644 /tmp/extreme-setup.out /home/hernani/*.txt 2>/dev/null\n' + +for i in $(seq 1 90); do + if ssh -o BatchMode=yes "$KOOPA" 'grep -q SETUP_EXTREME_OK /tmp/extreme-setup.out 2>/dev/null'; then + log "setup ready @${i}s" + break + fi + sleep 1 + [ "$i" -eq 90 ] && { ssh -o BatchMode=yes "$KOOPA" 'tail -80 /tmp/extreme-setup.out' || true; die "setup timeout"; } +done +ssh -o BatchMode=yes "$KOOPA" 'cat /tmp/extreme-setup.out' >"$SCRATCH/koopa-setup.out" || true +# pull artifacts (hernani-readable) +scp -o BatchMode=yes "${KOOPA}:/home/hernani/wuri.txt" "${KOOPA}:/home/hernani/wid.txt" \ + "${KOOPA}:/home/hernani/oid-high.txt" "${KOOPA}:/home/hernani/oid-tiny.txt" \ + "${KOOPA}:/home/hernani/oid-atomic.txt" \ + "${KOOPA}:/home/hernani/otok-high.txt" "${KOOPA}:/home/hernani/otok-tiny.txt" \ + "${KOOPA}:/home/hernani/otok-atomic.txt" \ + "$SCRATCH/" 2>/dev/null || true + +WURI=$(tr -d '\n' <"$SCRATCH/wuri.txt" 2>/dev/null || true) +WID=$(tr -d '\n' <"$SCRATCH/wid.txt" 2>/dev/null || true) +OID_HIGH=$(tr -d '\n' <"$SCRATCH/oid-high.txt" 2>/dev/null || true) +OID_TINY=$(tr -d '\n' <"$SCRATCH/oid-tiny.txt" 2>/dev/null || true) +OID_ATOMIC=$(tr -d '\n' <"$SCRATCH/oid-atomic.txt" 2>/dev/null || true) +TOK_HIGH=$(tr -d '\n' <"$SCRATCH/otok-high.txt" 2>/dev/null || true) +TOK_TINY=$(tr -d '\n' <"$SCRATCH/otok-tiny.txt" 2>/dev/null || true) +TOK_ATOMIC=$(tr -d '\n' <"$SCRATCH/otok-atomic.txt" 2>/dev/null || true) + +log "WURI=$WURI" +log "orders high=$OID_HIGH tiny=$OID_TINY atomic=$OID_ATOMIC" +[ -n "$WURI" ] || die "no withdraw URI" +[ -n "$OID_HIGH" ] && [ -n "$OID_TINY" ] || die "orders missing" + +# ensure auto-confirm watches this withdrawal +ssh -o BatchMode=yes "$KOOPA" "echo $WID >> /var/www/bank-landing/withdraw-watch.ids 2>/dev/null; sort -u /var/www/bank-landing/withdraw-watch.ids -o /var/www/bank-landing/withdraw-watch.ids 2>/dev/null; true" +# root confirm via screen +screen -S "$SESSION" -p 0 -X stuff $'BPW=$(tr -d "\\n" &1 | tee "$SCRATCH/ex-add.out" || true +wcli exchanges update "$EX" 2>&1 | tee "$SCRATCH/ex-upd.out" || true +wcli exchanges tos "$EX" 2>&1 | tee "$SCRATCH/ex-tos.out" || true +wcli exchanges accept-tos "$EX" 2>&1 | tee "$SCRATCH/ex-accept.out" || true + +log "=== withdraw accept-uri ===" +wcli withdraw accept-uri --exchange "$EX" "$WURI" 2>&1 | tee "$SCRATCH/accept-uri.out" || true + +for round in $(seq 1 20); do + log "run-until-done round $round" + wcli run-until-done 2>&1 | tee -a "$SCRATCH/rud.out" || true + wcli balance 2>&1 | tee "$SCRATCH/bal-$round.out" + if grep -qE "GOA:" "$SCRATCH/bal-$round.out"; then + # check non-zero available + if python3 -c 'import re,sys; t=open(sys.argv[1]).read(); +m=re.search(r"available[^0-9A-Z]*GOA:([0-9.]+)", t) +print(m.group(1) if m else ""); sys.exit(0 if m and float(m.group(1))>0 else 1)' "$SCRATCH/bal-$round.out" 2>/dev/null; then + log "coins available" + break + fi + fi + # re-confirm withdraw on bank + screen -S "$SESSION" -p 0 -X stuff $'BPW=$(tr -d "\\n" /dev/null; echo\n' + sleep 3 +done + +wcli balance 2>&1 | tee "$SCRATCH/balance-after-withdraw.out" +wcli transactions 2>&1 | tee "$SCRATCH/tx-after-withdraw.out" | head -100 + +# --- 3) pay high + tiny (+ try atomic) --- +pay_order() { + local tag="$1" oid="$2" tok="$3" + local uri="taler://pay/taler.hacktivism.ch/instances/${INST}/${oid}/?c=${tok}" + echo "$uri" >"$SCRATCH/pay-${tag}.uri" + log "=== pay $tag order=$oid ===" + log "uri=$uri" + wcli handle-uri "$uri" 2>&1 | tee "$SCRATCH/pay-${tag}-handle.out" || true + # some wallets use payments prepare/confirm + wcli run-until-done 2>&1 | tee -a "$SCRATCH/pay-${tag}-rud.out" || true + # check merchant + ssh -o BatchMode=yes "$KOOPA" "MPW=\$(tr -d '\\n' "$SCRATCH/order-${tag}-status.json" 2>/dev/null || true + python3 - "$SCRATCH/order-${tag}-status.json" "$tag" <<'PY' || true +import json,sys +p,tag=sys.argv[1],sys.argv[2] +try: + d=json.load(open(p)) +except Exception as e: + print(f"STATUS_{tag} unreadable", e); raise SystemExit +print(f"STATUS_{tag}", "order_status=", d.get("order_status"), "paid=", d.get("paid"), + "wired=", d.get("wired"), "deposit_total=", d.get("deposit_total"), + "amount=", (d.get("contract_terms") or {}).get("amount")) +PY +} + +pay_order high "$OID_HIGH" "$TOK_HIGH" +wcli balance 2>&1 | tee "$SCRATCH/balance-after-high.out" + +pay_order tiny "$OID_TINY" "$TOK_TINY" +wcli balance 2>&1 | tee "$SCRATCH/balance-after-tiny.out" + +if [ -n "$OID_ATOMIC" ] && [ -n "$TOK_ATOMIC" ]; then + pay_order atomic "$OID_ATOMIC" "$TOK_ATOMIC" || true + wcli balance 2>&1 | tee "$SCRATCH/balance-after-atomic.out" || true +fi + +# template URIs for docs +{ + echo "taler://pay-template/taler.hacktivism.ch/instances/${INST}/extreme-high/" + echo "taler://pay-template/taler.hacktivism.ch/instances/${INST}/extreme-tiny/" + echo "taler://pay-template/taler.hacktivism.ch/instances/${INST}/extreme-atomic/" +} | tee "$SCRATCH/template-uris.txt" + +log "=== summary ===" +python3 - <&1 | tee "$SCRATCH/ex-add.out" || true +wcli exchanges update "$EX" 2>&1 | tee "$SCRATCH/ex-upd.out" || true +wcli exchanges tos "$EX" 2>&1 | tee "$SCRATCH/ex-tos.out" || true +wcli exchanges accept-tos "$EX" 2>&1 | tee "$SCRATCH/ex-accept-tos.out" + +if [ -z "$URI" ]; then + URI=$(ssh -o BatchMode=yes koopa 'tr -d "\n" "$SCRATCH/withdraw.uri" + +echo "=== withdraw accept-uri ===" +wcli withdraw accept-uri --exchange "$EX" "$URI" 2>&1 | tee "$SCRATCH/accept-uri.out" + +WID=$(basename "$URI") +echo "=== ensure watch + auto-confirm on koopa ===" +ssh -o BatchMode=yes koopa "echo $WID >> /var/www/bank-landing/withdraw-watch.ids; sort -u /var/www/bank-landing/withdraw-watch.ids -o /var/www/bank-landing/withdraw-watch.ids" +# one-shot confirm as root via ssh if hernani can sudo? usually not — use screen later +# public status +curl -sS "https://bank.hacktivism.ch/taler-integration/withdrawals/${WID}" 2>/dev/null | head -c 500 || true +echo + +for round in 1 2 3 4 5 6 7 8; do + echo "=== run-until-done round $round ===" + wcli run-until-done & + PID=$! + ( sleep 20; kill $PID 2>/dev/null ) & + wait $PID 2>/dev/null || true + wcli balance 2>&1 | tee "$SCRATCH/bal-$round.out" + wcli transactions 2>&1 | tee "$SCRATCH/tx-$round.out" | head -80 + if grep -qE '"available": "GOA:[1-9]' "$SCRATCH/bal-$round.out"; then + echo "OK coins received" + exit 0 + fi + sleep 3 +done +echo "FAIL no balance" >&2 +exit 1 From 7f769aaf8f1910d6730e6e7cde1aa9e5c6161f5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Mon, 13 Jul 2026 11:03:47 +0200 Subject: [PATCH 003/326] configs/paivana: add Containerfile and compose on :9025 --- configs/paivana/Containerfile | 55 +++++++++++++++++++++ configs/paivana/README.md | 13 +++++ configs/paivana/compose.yml | 42 ++++++++++++++++ configs/paivana/conf/paivana.conf.template | 18 +++++++ configs/paivana/entrypoint.sh | 29 +++++++++++ configs/paivana/taler-systems.gpg | Bin 0 -> 1756 bytes configs/paivana/upstream/index.html | 8 +++ configs/paivana/upstream/robots.txt | 2 + 8 files changed, 167 insertions(+) create mode 100644 configs/paivana/Containerfile create mode 100644 configs/paivana/README.md create mode 100644 configs/paivana/compose.yml create mode 100644 configs/paivana/conf/paivana.conf.template create mode 100755 configs/paivana/entrypoint.sh create mode 100644 configs/paivana/taler-systems.gpg create mode 100644 configs/paivana/upstream/index.html create mode 100644 configs/paivana/upstream/robots.txt diff --git a/configs/paivana/Containerfile b/configs/paivana/Containerfile new file mode 100644 index 0000000..20ba0bb --- /dev/null +++ b/configs/paivana/Containerfile @@ -0,0 +1,55 @@ +# Build paivana-httpd from source against Taler libs (deb.taler.net) +FROM docker.io/library/debian:trixie AS build +ENV DEBIAN_FRONTEND=noninteractive +RUN mkdir -p /etc/apt/keyrings +COPY taler-systems.gpg /etc/apt/keyrings/taler-systems.gpg +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + ca-certificates curl gnupg git \ + meson ninja-build pkg-config gcc g++ make \ + zlib1g-dev libmicrohttpd-dev libcurl4-gnutls-dev \ + libjansson-dev libgcrypt20-dev libpq-dev \ + python3 python3-jinja2 \ + && printf "%s\n" "deb [signed-by=/etc/apt/keyrings/taler-systems.gpg] https://deb.taler.net/apt/debian trixie main" \ + > /etc/apt/sources.list.d/taler.list \ + && apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + libgnunet-dev libtalerexchange-dev libtalermerchant-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +RUN git clone --depth 1 https://git.taler.net/paivana.git . \ + && if [ -x ./bootstrap ]; then ./bootstrap; fi \ + && if [ -x ./configure ]; then \ + ./configure --prefix=/usr/local && make -j"$(nproc)" && make install; \ + else \ + meson setup build --prefix=/usr/local && meson compile -C build && meson install -C build; \ + fi + +FROM docker.io/library/debian:trixie-slim +ENV DEBIAN_FRONTEND=noninteractive +RUN mkdir -p /etc/apt/keyrings +COPY taler-systems.gpg /etc/apt/keyrings/taler-systems.gpg +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends ca-certificates curl \ + && printf "%s\n" "deb [signed-by=/etc/apt/keyrings/taler-systems.gpg] https://deb.taler.net/apt/debian trixie main" \ + > /etc/apt/sources.list.d/taler.list \ + && apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + 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 \ + && mkdir -p /etc/paivana /var/lib/paivana \ + && chown -R paivana-httpd:paivana-httpd /var/lib/paivana /etc/paivana + +COPY --from=build /usr/local/ /usr/local/ +RUN ldconfig || true +COPY entrypoint.sh /usr/local/bin/paivana-entrypoint +COPY conf/paivana.conf.template /etc/paivana/paivana.conf.template +RUN chmod 755 /usr/local/bin/paivana-entrypoint \ + && chown paivana-httpd:paivana-httpd /etc/paivana/paivana.conf.template + +USER paivana-httpd +EXPOSE 9967 +ENTRYPOINT ["/usr/local/bin/paivana-entrypoint"] diff --git a/configs/paivana/README.md b/configs/paivana/README.md new file mode 100644 index 0000000..4bc39cd --- /dev/null +++ b/configs/paivana/README.md @@ -0,0 +1,13 @@ +# paivana — `paivana.hacktivism.ch` + +GNU Taler **paivana-httpd** reverse-proxy paywall, GOA payments. + +| Item | Value | +|------|--------| +| Live | `/home/hernani/koopa-paivana/` | +| Containers | `koopa-paivana`, `koopa-paivana-upstream` | +| Host port | **9025** | +| Currency | **GOA** | +| Merchant | `https://taler.hacktivism.ch/instances/goa-shop/` | + +Build: `Containerfile` from `git.taler.net/paivana` + deb.taler.net libs. diff --git a/configs/paivana/compose.yml b/configs/paivana/compose.yml new file mode 100644 index 0000000..016d633 --- /dev/null +++ b/configs/paivana/compose.yml @@ -0,0 +1,42 @@ +# koopa-paivana — host :9025 → Caddy paivana.hacktivism.ch +# Live: /home/hernani/koopa-paivana/ +services: + paivana: + build: + context: . + dockerfile: Containerfile + image: localhost/koopa-paivana:latest + container_name: koopa-paivana + restart: unless-stopped + ports: + - "9025:9967" + extra_hosts: + - "taler.hacktivism.ch:host-gateway" + - "bank.hacktivism.ch:host-gateway" + - "exchange.hacktivism.ch:host-gateway" + volumes: + - ./conf/paivana.conf.template:/etc/paivana/paivana.conf.template:ro + - ./entrypoint.sh:/usr/local/bin/paivana-entrypoint:ro + - ./secrets/merchant-access-token:/run/secrets/merchant-access-token:ro + - ./secrets/paivana-secret:/run/secrets/paivana-secret:ro + depends_on: + - upstream + labels: + org.hacktivism.service: paivana + org.hacktivism.host_port: "9025" + org.hacktivism.site: paivana.hacktivism.ch + org.hacktivism.managed_by: koopa-admin + org.hacktivism.currency: GOA + + upstream: + image: docker.io/library/nginx:1.27-alpine + container_name: koopa-paivana-upstream + restart: unless-stopped + volumes: + - ./upstream:/usr/share/nginx/html:ro + expose: + - "80" + command: ["nginx", "-g", "daemon off;"] + labels: + org.hacktivism.service: paivana-upstream + org.hacktivism.managed_by: koopa-admin diff --git a/configs/paivana/conf/paivana.conf.template b/configs/paivana/conf/paivana.conf.template new file mode 100644 index 0000000..abad54b --- /dev/null +++ b/configs/paivana/conf/paivana.conf.template @@ -0,0 +1,18 @@ +# paivana.hacktivism.ch — paywall reverse proxy +# Secrets injected by entrypoint from /run/secrets/ + +[paivana] +SERVE = tcp +PORT = 9967 +BIND_TO = 0.0.0.0 + +BASE_URL = https://paivana.hacktivism.ch/ + +DESTINATION_BASE_URL = http://upstream:80/ + +MERCHANT_BACKEND_URL = https://taler.hacktivism.ch/instances/goa-shop/ +MERCHANT_ACCESS_TOKEN = @MERCHANT_ACCESS_TOKEN@ + +SECRET = @PAIVANA_SECRET@ + +WHITELIST = ^/(favicon\.ico|robots\.txt)$ diff --git a/configs/paivana/entrypoint.sh b/configs/paivana/entrypoint.sh new file mode 100755 index 0000000..e751b47 --- /dev/null +++ b/configs/paivana/entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu +TOKEN_FILE="${MERCHANT_TOKEN_FILE:-/run/secrets/merchant-access-token}" +SECRET_FILE="${PAIVANA_SECRET_FILE:-/run/secrets/paivana-secret}" +TEMPLATE="${PAIVANA_CONF_TEMPLATE:-/etc/paivana/paivana.conf.template}" +OUT="${PAIVANA_CONF:-/tmp/paivana.conf}" + +if [ ! -r "$TOKEN_FILE" ]; then + echo "missing merchant token: $TOKEN_FILE" >&2 + exit 1 +fi +if [ ! -r "$SECRET_FILE" ]; then + echo "missing paivana secret: $SECRET_FILE" >&2 + exit 1 +fi + +TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE") +SECRET=$(tr -d '\n\r' < "$SECRET_FILE") + +awk -v t="$TOKEN" -v s="$SECRET" ' +{ + gsub(/@MERCHANT_ACCESS_TOKEN@/, t) + gsub(/@PAIVANA_SECRET@/, s) + print +} +' "$TEMPLATE" > "$OUT" +chmod 600 "$OUT" + +exec paivana-httpd -c "$OUT" -g -f -L INFO diff --git a/configs/paivana/taler-systems.gpg b/configs/paivana/taler-systems.gpg new file mode 100644 index 0000000000000000000000000000000000000000..b53bff4d58b269d0c4a5469a19790f4066ae9a5a GIT binary patch literal 1756 zcmV<21|#{I0gVJ-?jDc<3;@1-8OjHmAz046OAhL73Y#$MyG`bNUmjxhxySgyps>?fX~g=kn4C+&OkbTnS;+sBcDtoJP6&zFQR9@hnCQ1e z01l;jeMv;Yv1cC^(@WS{rBO%XNy@K0A`Cfj{-d}IBRs=lyavyShs8yDS4vC!8;ZAr)E(1B(-mTbY6Z6t@=%qP48faIhN0HhtL4WV#oUQ;Xo%2W#{|pv z_rCG(Xqv=2GI7om58dK9PA;C{Ff_=ko9e=DyaE3bZgp zd2@7SZF3+~K_EP1Z*Fv9V{|}tVQgh`E^cLXK8XR;1QP)Y06qd60|g5S2nPZN6$%Lm z3jzcd0s{d89svRufEFPH0EC%5ENtIL8hw8KCgnz@{(b^x1p;S*p;!e88efIT0162Z zCgnz@{(b^xsrd{12$(W6;?3F%Q_3gMYAq6XaO(?}L(>kLEdS-UjRXRMYWJef2Y zrHaB>^mOi6NYI2lYRRhlorqFuxl^}qdU_B9f#h(B(6;+AR5C+5G_RV#XVlFS%innD z4VRgkw9P-W8}hY!lC5I((P%U^Hi$(6WU%G=kMLQ_bwJ|$A0}|B52P9nIMOzA%&Vr_kq~yAFnMs1eJc3{%~`EXF)IuB0)aRu>RtbjDF}Sw2MLq z71AS;y95~199L}}ISk?GNu6Wv+k;%N{}5$x79W?3{Xnq6&%96I!DQxOkVeN5 zL;kgF0h~T#K$QVmzVT#TNIfXzKUr0fqTf9xnIM$IFgl>yr0%>E{)REASOBteG3dg- zh8;Wki!zy@py6Zuw0G?j`d1?bG4if*Q7zG{R`1IoZ$w+=20aNQh1-&^@)N<+!t$P% zn`4Rj9`Itnqx-P{5di=Ji2=L>7y$|ZCITA_79j)xgqb`nY~M#3eSZBWbp*rk1 zM8!vnjfgscFI)n&$GK;pz?uOLUlAd6jOxg8V+d;tM$shp^JKUVHD}A|1L``^HB_au zz0SKiC{(eK9vm(NGRLCOfwMb0fcleT%RQg5J}n1i zCGEe+RU6P`M;}3%6)7FO*X1Zd---D{XD$^Q?gGS)I>56;YvqXmr#RxhagY7UV=aCd z0^G|o^41W*Kemt15)#N95PcLumvA%|8|3muk$Ka*FpQpI=9e5ida>qL#3j;v7dj%f z(R#m;cdzz7^u7Ac-uCRa`G^M; ylq_U!R=g2Whib*o)1{!-!61GRi<*(bc&jUJUAWJF(a$OrGmyda*C)!$i4Z$SB1HB8 literal 0 HcmV?d00001 diff --git a/configs/paivana/upstream/index.html b/configs/paivana/upstream/index.html new file mode 100644 index 0000000..c27ede4 --- /dev/null +++ b/configs/paivana/upstream/index.html @@ -0,0 +1,8 @@ + + +Protected — paivana.hacktivism.ch + +

Payment verified

+

Demo upstream behind Paivana (GOA). Site: https://paivana.hacktivism.ch/

+ + diff --git a/configs/paivana/upstream/robots.txt b/configs/paivana/upstream/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/configs/paivana/upstream/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / From 8e8e89a42caf958504360480ebc7223cd3d51e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Mon, 13 Jul 2026 11:03:47 +0200 Subject: [PATCH 004/326] configs/paivana: add Containerfile and compose on :9025 --- configs/paivana/Containerfile | 55 +++++++++++++++++++++ configs/paivana/README.md | 13 +++++ configs/paivana/compose.yml | 42 ++++++++++++++++ configs/paivana/conf/paivana.conf.template | 18 +++++++ configs/paivana/entrypoint.sh | 29 +++++++++++ configs/paivana/taler-systems.gpg | Bin 0 -> 1756 bytes configs/paivana/upstream/index.html | 8 +++ configs/paivana/upstream/robots.txt | 2 + 8 files changed, 167 insertions(+) create mode 100644 configs/paivana/Containerfile create mode 100644 configs/paivana/README.md create mode 100644 configs/paivana/compose.yml create mode 100644 configs/paivana/conf/paivana.conf.template create mode 100755 configs/paivana/entrypoint.sh create mode 100644 configs/paivana/taler-systems.gpg create mode 100644 configs/paivana/upstream/index.html create mode 100644 configs/paivana/upstream/robots.txt diff --git a/configs/paivana/Containerfile b/configs/paivana/Containerfile new file mode 100644 index 0000000..20ba0bb --- /dev/null +++ b/configs/paivana/Containerfile @@ -0,0 +1,55 @@ +# Build paivana-httpd from source against Taler libs (deb.taler.net) +FROM docker.io/library/debian:trixie AS build +ENV DEBIAN_FRONTEND=noninteractive +RUN mkdir -p /etc/apt/keyrings +COPY taler-systems.gpg /etc/apt/keyrings/taler-systems.gpg +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + ca-certificates curl gnupg git \ + meson ninja-build pkg-config gcc g++ make \ + zlib1g-dev libmicrohttpd-dev libcurl4-gnutls-dev \ + libjansson-dev libgcrypt20-dev libpq-dev \ + python3 python3-jinja2 \ + && printf "%s\n" "deb [signed-by=/etc/apt/keyrings/taler-systems.gpg] https://deb.taler.net/apt/debian trixie main" \ + > /etc/apt/sources.list.d/taler.list \ + && apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + libgnunet-dev libtalerexchange-dev libtalermerchant-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +RUN git clone --depth 1 https://git.taler.net/paivana.git . \ + && if [ -x ./bootstrap ]; then ./bootstrap; fi \ + && if [ -x ./configure ]; then \ + ./configure --prefix=/usr/local && make -j"$(nproc)" && make install; \ + else \ + meson setup build --prefix=/usr/local && meson compile -C build && meson install -C build; \ + fi + +FROM docker.io/library/debian:trixie-slim +ENV DEBIAN_FRONTEND=noninteractive +RUN mkdir -p /etc/apt/keyrings +COPY taler-systems.gpg /etc/apt/keyrings/taler-systems.gpg +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends ca-certificates curl \ + && printf "%s\n" "deb [signed-by=/etc/apt/keyrings/taler-systems.gpg] https://deb.taler.net/apt/debian trixie main" \ + > /etc/apt/sources.list.d/taler.list \ + && apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + 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 \ + && mkdir -p /etc/paivana /var/lib/paivana \ + && chown -R paivana-httpd:paivana-httpd /var/lib/paivana /etc/paivana + +COPY --from=build /usr/local/ /usr/local/ +RUN ldconfig || true +COPY entrypoint.sh /usr/local/bin/paivana-entrypoint +COPY conf/paivana.conf.template /etc/paivana/paivana.conf.template +RUN chmod 755 /usr/local/bin/paivana-entrypoint \ + && chown paivana-httpd:paivana-httpd /etc/paivana/paivana.conf.template + +USER paivana-httpd +EXPOSE 9967 +ENTRYPOINT ["/usr/local/bin/paivana-entrypoint"] diff --git a/configs/paivana/README.md b/configs/paivana/README.md new file mode 100644 index 0000000..4bc39cd --- /dev/null +++ b/configs/paivana/README.md @@ -0,0 +1,13 @@ +# paivana — `paivana.hacktivism.ch` + +GNU Taler **paivana-httpd** reverse-proxy paywall, GOA payments. + +| Item | Value | +|------|--------| +| Live | `/home/hernani/koopa-paivana/` | +| Containers | `koopa-paivana`, `koopa-paivana-upstream` | +| Host port | **9025** | +| Currency | **GOA** | +| Merchant | `https://taler.hacktivism.ch/instances/goa-shop/` | + +Build: `Containerfile` from `git.taler.net/paivana` + deb.taler.net libs. diff --git a/configs/paivana/compose.yml b/configs/paivana/compose.yml new file mode 100644 index 0000000..016d633 --- /dev/null +++ b/configs/paivana/compose.yml @@ -0,0 +1,42 @@ +# koopa-paivana — host :9025 → Caddy paivana.hacktivism.ch +# Live: /home/hernani/koopa-paivana/ +services: + paivana: + build: + context: . + dockerfile: Containerfile + image: localhost/koopa-paivana:latest + container_name: koopa-paivana + restart: unless-stopped + ports: + - "9025:9967" + extra_hosts: + - "taler.hacktivism.ch:host-gateway" + - "bank.hacktivism.ch:host-gateway" + - "exchange.hacktivism.ch:host-gateway" + volumes: + - ./conf/paivana.conf.template:/etc/paivana/paivana.conf.template:ro + - ./entrypoint.sh:/usr/local/bin/paivana-entrypoint:ro + - ./secrets/merchant-access-token:/run/secrets/merchant-access-token:ro + - ./secrets/paivana-secret:/run/secrets/paivana-secret:ro + depends_on: + - upstream + labels: + org.hacktivism.service: paivana + org.hacktivism.host_port: "9025" + org.hacktivism.site: paivana.hacktivism.ch + org.hacktivism.managed_by: koopa-admin + org.hacktivism.currency: GOA + + upstream: + image: docker.io/library/nginx:1.27-alpine + container_name: koopa-paivana-upstream + restart: unless-stopped + volumes: + - ./upstream:/usr/share/nginx/html:ro + expose: + - "80" + command: ["nginx", "-g", "daemon off;"] + labels: + org.hacktivism.service: paivana-upstream + org.hacktivism.managed_by: koopa-admin diff --git a/configs/paivana/conf/paivana.conf.template b/configs/paivana/conf/paivana.conf.template new file mode 100644 index 0000000..abad54b --- /dev/null +++ b/configs/paivana/conf/paivana.conf.template @@ -0,0 +1,18 @@ +# paivana.hacktivism.ch — paywall reverse proxy +# Secrets injected by entrypoint from /run/secrets/ + +[paivana] +SERVE = tcp +PORT = 9967 +BIND_TO = 0.0.0.0 + +BASE_URL = https://paivana.hacktivism.ch/ + +DESTINATION_BASE_URL = http://upstream:80/ + +MERCHANT_BACKEND_URL = https://taler.hacktivism.ch/instances/goa-shop/ +MERCHANT_ACCESS_TOKEN = @MERCHANT_ACCESS_TOKEN@ + +SECRET = @PAIVANA_SECRET@ + +WHITELIST = ^/(favicon\.ico|robots\.txt)$ diff --git a/configs/paivana/entrypoint.sh b/configs/paivana/entrypoint.sh new file mode 100755 index 0000000..e751b47 --- /dev/null +++ b/configs/paivana/entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu +TOKEN_FILE="${MERCHANT_TOKEN_FILE:-/run/secrets/merchant-access-token}" +SECRET_FILE="${PAIVANA_SECRET_FILE:-/run/secrets/paivana-secret}" +TEMPLATE="${PAIVANA_CONF_TEMPLATE:-/etc/paivana/paivana.conf.template}" +OUT="${PAIVANA_CONF:-/tmp/paivana.conf}" + +if [ ! -r "$TOKEN_FILE" ]; then + echo "missing merchant token: $TOKEN_FILE" >&2 + exit 1 +fi +if [ ! -r "$SECRET_FILE" ]; then + echo "missing paivana secret: $SECRET_FILE" >&2 + exit 1 +fi + +TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE") +SECRET=$(tr -d '\n\r' < "$SECRET_FILE") + +awk -v t="$TOKEN" -v s="$SECRET" ' +{ + gsub(/@MERCHANT_ACCESS_TOKEN@/, t) + gsub(/@PAIVANA_SECRET@/, s) + print +} +' "$TEMPLATE" > "$OUT" +chmod 600 "$OUT" + +exec paivana-httpd -c "$OUT" -g -f -L INFO diff --git a/configs/paivana/taler-systems.gpg b/configs/paivana/taler-systems.gpg new file mode 100644 index 0000000000000000000000000000000000000000..b53bff4d58b269d0c4a5469a19790f4066ae9a5a GIT binary patch literal 1756 zcmV<21|#{I0gVJ-?jDc<3;@1-8OjHmAz046OAhL73Y#$MyG`bNUmjxhxySgyps>?fX~g=kn4C+&OkbTnS;+sBcDtoJP6&zFQR9@hnCQ1e z01l;jeMv;Yv1cC^(@WS{rBO%XNy@K0A`Cfj{-d}IBRs=lyavyShs8yDS4vC!8;ZAr)E(1B(-mTbY6Z6t@=%qP48faIhN0HhtL4WV#oUQ;Xo%2W#{|pv z_rCG(Xqv=2GI7om58dK9PA;C{Ff_=ko9e=DyaE3bZgp zd2@7SZF3+~K_EP1Z*Fv9V{|}tVQgh`E^cLXK8XR;1QP)Y06qd60|g5S2nPZN6$%Lm z3jzcd0s{d89svRufEFPH0EC%5ENtIL8hw8KCgnz@{(b^x1p;S*p;!e88efIT0162Z zCgnz@{(b^xsrd{12$(W6;?3F%Q_3gMYAq6XaO(?}L(>kLEdS-UjRXRMYWJef2Y zrHaB>^mOi6NYI2lYRRhlorqFuxl^}qdU_B9f#h(B(6;+AR5C+5G_RV#XVlFS%innD z4VRgkw9P-W8}hY!lC5I((P%U^Hi$(6WU%G=kMLQ_bwJ|$A0}|B52P9nIMOzA%&Vr_kq~yAFnMs1eJc3{%~`EXF)IuB0)aRu>RtbjDF}Sw2MLq z71AS;y95~199L}}ISk?GNu6Wv+k;%N{}5$x79W?3{Xnq6&%96I!DQxOkVeN5 zL;kgF0h~T#K$QVmzVT#TNIfXzKUr0fqTf9xnIM$IFgl>yr0%>E{)REASOBteG3dg- zh8;Wki!zy@py6Zuw0G?j`d1?bG4if*Q7zG{R`1IoZ$w+=20aNQh1-&^@)N<+!t$P% zn`4Rj9`Itnqx-P{5di=Ji2=L>7y$|ZCITA_79j)xgqb`nY~M#3eSZBWbp*rk1 zM8!vnjfgscFI)n&$GK;pz?uOLUlAd6jOxg8V+d;tM$shp^JKUVHD}A|1L``^HB_au zz0SKiC{(eK9vm(NGRLCOfwMb0fcleT%RQg5J}n1i zCGEe+RU6P`M;}3%6)7FO*X1Zd---D{XD$^Q?gGS)I>56;YvqXmr#RxhagY7UV=aCd z0^G|o^41W*Kemt15)#N95PcLumvA%|8|3muk$Ka*FpQpI=9e5ida>qL#3j;v7dj%f z(R#m;cdzz7^u7Ac-uCRa`G^M; ylq_U!R=g2Whib*o)1{!-!61GRi<*(bc&jUJUAWJF(a$OrGmyda*C)!$i4Z$SB1HB8 literal 0 HcmV?d00001 diff --git a/configs/paivana/upstream/index.html b/configs/paivana/upstream/index.html new file mode 100644 index 0000000..c27ede4 --- /dev/null +++ b/configs/paivana/upstream/index.html @@ -0,0 +1,8 @@ + + +Protected — paivana.hacktivism.ch + +

Payment verified

+

Demo upstream behind Paivana (GOA). Site: https://paivana.hacktivism.ch/

+ + diff --git a/configs/paivana/upstream/robots.txt b/configs/paivana/upstream/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/configs/paivana/upstream/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / From ea6520d2a3601319cf37911074b9a5da3b379e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Mon, 13 Jul 2026 11:14:22 +0200 Subject: [PATCH 005/326] configs/paivana: add secret path docs and token example --- SECRETS.md | 1 + configs/paivana/secrets/README.md | 13 +++++++++++++ .../paivana/secrets/merchant-access-token.example | 1 + 3 files changed, 15 insertions(+) create mode 100644 configs/paivana/secrets/README.md create mode 100644 configs/paivana/secrets/merchant-access-token.example diff --git a/SECRETS.md b/SECRETS.md index cf70fa3..e74c10a 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -21,6 +21,7 @@ Live passwords/keys live in sibling **`koopa-admin-secrets`**. | Bonfire (`FEDERATE=true`) | `koopa/home-hernani/koopa-bonfire/{.env,users.env,compose.yml}` | `~/koopa-bonfire/` | | 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/` | ## Rules diff --git a/configs/paivana/secrets/README.md b/configs/paivana/secrets/README.md new file mode 100644 index 0000000..9581cbc --- /dev/null +++ b/configs/paivana/secrets/README.md @@ -0,0 +1,13 @@ +# Secrets — not stored in admin-log + +Live on koopa (`hernani`): + +``` +~/koopa-paivana/secrets/merchant-access-token # secret-token: +~/koopa-paivana/secrets/paivana-secret # openssl rand -hex 32 +``` + +Mode: world-readable to the container user is required under rootless podman +(`chmod 644` is fine; directory stays under hernani home). + +Optional mirror under `koopa-admin-secrets/koopa/home-hernani/koopa-paivana/secrets/`. diff --git a/configs/paivana/secrets/merchant-access-token.example b/configs/paivana/secrets/merchant-access-token.example new file mode 100644 index 0000000..5094f90 --- /dev/null +++ b/configs/paivana/secrets/merchant-access-token.example @@ -0,0 +1 @@ +secret-token:REPLACE-WITH-GOA-SHOP-INSTANCE-PASSWORD From 43bf064e1b6b34eefcce7ab0e7bf7964a6f0cd82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Mon, 13 Jul 2026 11:14:22 +0200 Subject: [PATCH 006/326] configs/paivana: add secret path docs and token example --- SECRETS.md | 1 + configs/paivana/secrets/README.md | 13 +++++++++++++ .../paivana/secrets/merchant-access-token.example | 1 + 3 files changed, 15 insertions(+) create mode 100644 configs/paivana/secrets/README.md create mode 100644 configs/paivana/secrets/merchant-access-token.example diff --git a/SECRETS.md b/SECRETS.md index cf70fa3..e74c10a 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -21,6 +21,7 @@ Live passwords/keys live in sibling **`koopa-admin-secrets`**. | Bonfire (`FEDERATE=true`) | `koopa/home-hernani/koopa-bonfire/{.env,users.env,compose.yml}` | `~/koopa-bonfire/` | | 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/` | ## Rules diff --git a/configs/paivana/secrets/README.md b/configs/paivana/secrets/README.md new file mode 100644 index 0000000..9581cbc --- /dev/null +++ b/configs/paivana/secrets/README.md @@ -0,0 +1,13 @@ +# Secrets — not stored in admin-log + +Live on koopa (`hernani`): + +``` +~/koopa-paivana/secrets/merchant-access-token # secret-token: +~/koopa-paivana/secrets/paivana-secret # openssl rand -hex 32 +``` + +Mode: world-readable to the container user is required under rootless podman +(`chmod 644` is fine; directory stays under hernani home). + +Optional mirror under `koopa-admin-secrets/koopa/home-hernani/koopa-paivana/secrets/`. diff --git a/configs/paivana/secrets/merchant-access-token.example b/configs/paivana/secrets/merchant-access-token.example new file mode 100644 index 0000000..5094f90 --- /dev/null +++ b/configs/paivana/secrets/merchant-access-token.example @@ -0,0 +1 @@ +secret-token:REPLACE-WITH-GOA-SHOP-INSTANCE-PASSWORD From d168df26e30b3186c16b95853a6ad31319364b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Mon, 13 Jul 2026 11:26:09 +0200 Subject: [PATCH 007/326] docs: Caddy and ports for paivana.hacktivism.ch --- configs/README.md | 1 + configs/caddy/Caddyfile | 17 +++++++++++++++-- configs/ports.md | 1 + host/caddy/Caddyfile | 17 +++++++++++++++-- host/caddy/README.md | 1 + host/overview/LIVE.md | 3 +++ host/overview/services.md | 4 ++++ 7 files changed, 40 insertions(+), 4 deletions(-) diff --git a/configs/README.md b/configs/README.md index 82ae124..f1f03fb 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` | +| `paivana/` | **`koopa-paivana`** (+ upstream) | `localhost/koopa-paivana:latest` | **Authoritative running inventory:** `host/overview/LIVE.md`. diff --git a/configs/caddy/Caddyfile b/configs/caddy/Caddyfile index 03f88aa..77b2f34 100644 --- a/configs/caddy/Caddyfile +++ b/configs/caddy/Caddyfile @@ -1,7 +1,7 @@ # Internal only (not in the browser URL): # 9010 merchant API | 9011 exchange API | 9012 bank API # 9013 bank landing | 9014 exchange landing | 9015 merchant landing -# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | | 9200 forgejo-ssh +# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | 9025 paivana | 9200 forgejo-ssh # 9090 tops ng1 | 9091 tops ng2 | 9092 tops ng3 { @@ -174,7 +174,20 @@ tops.ng3.hacktivism.ch { } } -http://taler.hacktivism.ch, http://exchange.hacktivism.ch, http://bank.hacktivism.ch, http://castopod.hacktivism.ch, http://bonfire.hacktivism.ch, http://prime.hacktivism.ch, http://bt.hacktivism.ch, http://git.hacktivism.ch, http://tops.ng1.hacktivism.ch, http://tops.ng2.hacktivism.ch, http://tops.ng3.hacktivism.ch { + +# 9025 paivana-httpd (GOA paywall) +paivana.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9025 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Port 443 + } +} + +http://taler.hacktivism.ch, http://exchange.hacktivism.ch, http://bank.hacktivism.ch, http://castopod.hacktivism.ch, http://bonfire.hacktivism.ch, http://prime.hacktivism.ch, http://bt.hacktivism.ch, http://git.hacktivism.ch, http://paivana.hacktivism.ch, http://tops.ng1.hacktivism.ch, http://tops.ng2.hacktivism.ch, http://tops.ng3.hacktivism.ch { handle /.well-known/acme-challenge/* { root * /var/www/acme file_server diff --git a/configs/ports.md b/configs/ports.md index c49ed7c..24aad5d 100644 --- a/configs/ports.md +++ b/configs/ports.md @@ -16,6 +16,7 @@ | 9022 | podman `koopa-prime` (Jellyfin) → Caddy `prime.hacktivism.ch` | | 9023 | podman qBittorrent → Caddy `bt.hacktivism.ch` | | **9024** | podman **`koopa-forgejo`** (HTTP) → Caddy **`git.hacktivism.ch`** | +| **9025** | podman **`koopa-paivana`** (paivana-httpd) → Caddy **`paivana.hacktivism.ch`** | | **9200** | podman **Forgejo git-SSH** (host-direct; not Caddy) | | 9090–9092 | podman `koopa-tops-ng1` … `ng3` → Caddy `tops.ng{1,2,3}.hacktivism.ch` | | 8080 | Tor ORPort | diff --git a/host/caddy/Caddyfile b/host/caddy/Caddyfile index 03f88aa..77b2f34 100644 --- a/host/caddy/Caddyfile +++ b/host/caddy/Caddyfile @@ -1,7 +1,7 @@ # Internal only (not in the browser URL): # 9010 merchant API | 9011 exchange API | 9012 bank API # 9013 bank landing | 9014 exchange landing | 9015 merchant landing -# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | | 9200 forgejo-ssh +# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | 9025 paivana | 9200 forgejo-ssh # 9090 tops ng1 | 9091 tops ng2 | 9092 tops ng3 { @@ -174,7 +174,20 @@ tops.ng3.hacktivism.ch { } } -http://taler.hacktivism.ch, http://exchange.hacktivism.ch, http://bank.hacktivism.ch, http://castopod.hacktivism.ch, http://bonfire.hacktivism.ch, http://prime.hacktivism.ch, http://bt.hacktivism.ch, http://git.hacktivism.ch, http://tops.ng1.hacktivism.ch, http://tops.ng2.hacktivism.ch, http://tops.ng3.hacktivism.ch { + +# 9025 paivana-httpd (GOA paywall) +paivana.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9025 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Port 443 + } +} + +http://taler.hacktivism.ch, http://exchange.hacktivism.ch, http://bank.hacktivism.ch, http://castopod.hacktivism.ch, http://bonfire.hacktivism.ch, http://prime.hacktivism.ch, http://bt.hacktivism.ch, http://git.hacktivism.ch, http://paivana.hacktivism.ch, http://tops.ng1.hacktivism.ch, http://tops.ng2.hacktivism.ch, http://tops.ng3.hacktivism.ch { handle /.well-known/acme-challenge/* { root * /var/www/acme file_server diff --git a/host/caddy/README.md b/host/caddy/README.md index 92e5219..a38be73 100644 --- a/host/caddy/README.md +++ b/host/caddy/README.md @@ -10,6 +10,7 @@ Canonical mirror in this repo: **`configs/caddy/Caddyfile`** (same content as `h Public sites: `taler` / `exchange` / `bank` / `castopod` / `bonfire` / `prime` / `bt` / **`git`** / **`paivana`** / tops.ng*. Forgejo HTTP: Caddy → `127.0.0.1:9024`. Git SSH is **host :9200** (not Caddy). +Paivana: Caddy → `127.0.0.1:9025`. Validate/reload on host: diff --git a/host/overview/LIVE.md b/host/overview/LIVE.md index 30f810d..f235056 100644 --- a/host/overview/LIVE.md +++ b/host/overview/LIVE.md @@ -33,6 +33,7 @@ ss -lntp | grep -E '90[0-9]{2}|9200|8080' | `koopa-prime-jellyfin` | `linuxserver/jellyfin:10.10.7` | **9022** | `prime.hacktivism.ch` | | `koopa-prime-qbittorrent` | `linuxserver/qbittorrent:5.0.4` | **9023**, **6881** | `bt.hacktivism.ch` | | `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-tops-ng1` | `nginx` | **9090** | `tops.ng1.hacktivism.ch` | | `koopa-tops-ng2` | `nginx` | **9091** | `tops.ng2.hacktivism.ch` | @@ -55,6 +56,7 @@ Managed from **`/home/hernani/ansible-taler-exchange/`** (see `configs/taler-exc | `prime.hacktivism.ch` | 9022 | | `bt.hacktivism.ch` | 9023 | | `git.hacktivism.ch` | 9024 (HTTP); git-SSH **9200** host-direct | +| `paivana.hacktivism.ch` | 9025 (paivana-httpd GOA paywall) | | `tops.ng1.hacktivism.ch` | 9090 | | `tops.ng2.hacktivism.ch` | 9091 | | `tops.ng3.hacktivism.ch` | 9092 | @@ -70,6 +72,7 @@ Config: `/etc/caddy/Caddyfile` (mirror `configs/caddy/Caddyfile`). | `~/koopa-bonfire/` | Bonfire compose + gitbot | | `~/koopa-prime/` | Jellyfin + qBittorrent | | `~/koopa-forgejo/` | Forgejo rootless | +| `~/koopa-paivana/` | Paivana paywall (`koopa-paivana`) | | `~/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) | diff --git a/host/overview/services.md b/host/overview/services.md index c725124..0bff244 100644 --- a/host/overview/services.md +++ b/host/overview/services.md @@ -35,6 +35,7 @@ flowchart TB Prime["koopa-prime jellyfin\n:9022"] BT["qbittorrent\n:9023"] Forgejo["koopa-forgejo ROOTLESS\n:9024 HTTP / :9200 SSH"] + Paivana["koopa-paivana\n:9025"] end Tor["tor ORPort :8080"] @@ -59,6 +60,7 @@ flowchart TB CaddyHTTPS -->|prime.hacktivism.ch| Prime CaddyHTTPS -->|bt.hacktivism.ch| BT CaddyHTTPS -->|git.hacktivism.ch| Forgejo + CaddyHTTPS -->|paivana.hacktivism.ch| Paivana ``` ## Port table @@ -78,6 +80,7 @@ flowchart TB | 9022 | podman | **jellyfin** (prime) | | 9023 | podman | **qbittorrent** (bt) | | **9024** | podman | **forgejo** HTTP → `git.hacktivism.ch` | +| **9025** | podman | **paivana** → `paivana.hacktivism.ch` | | **9200** | podman | **forgejo** git-SSH (host-direct) | | 9090–9092 | podman | **tops** `koopa-tops-ng1`…`ng3` | | 8080 | tor | ORPort | @@ -94,6 +97,7 @@ Full port notes: `configs/ports.md`. | Merchant container | `configs/taler-hacktivism/`, `scripts/taler-merchant/` | | Exchange container | `configs/taler-exchange/`, `scripts/taler-exchange/` | | **Forgejo rootless** | `configs/forgejo/`, `2026/2026-07-10--forgejo-rootless.md` | +| Paivana paywall | `configs/paivana/` | | tops (`koopa-tops-ng*`) | `configs/tops/` | | Tor relay (`koopa-tor-relay` podman) | `configs/tor/`, `host/tor/` | | Tor monitoring scripts | `scripts/monitoring/` | From d8ff437584198bac5aaa472afd33647535e7419a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Mon, 13 Jul 2026 11:26:09 +0200 Subject: [PATCH 008/326] docs: Caddy and ports for paivana.hacktivism.ch --- configs/README.md | 1 + configs/caddy/Caddyfile | 17 +++++++++++++++-- configs/ports.md | 1 + host/caddy/Caddyfile | 17 +++++++++++++++-- host/caddy/README.md | 1 + host/overview/LIVE.md | 3 +++ host/overview/services.md | 4 ++++ 7 files changed, 40 insertions(+), 4 deletions(-) diff --git a/configs/README.md b/configs/README.md index 82ae124..f1f03fb 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` | +| `paivana/` | **`koopa-paivana`** (+ upstream) | `localhost/koopa-paivana:latest` | **Authoritative running inventory:** `host/overview/LIVE.md`. diff --git a/configs/caddy/Caddyfile b/configs/caddy/Caddyfile index 03f88aa..77b2f34 100644 --- a/configs/caddy/Caddyfile +++ b/configs/caddy/Caddyfile @@ -1,7 +1,7 @@ # Internal only (not in the browser URL): # 9010 merchant API | 9011 exchange API | 9012 bank API # 9013 bank landing | 9014 exchange landing | 9015 merchant landing -# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | | 9200 forgejo-ssh +# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | 9025 paivana | 9200 forgejo-ssh # 9090 tops ng1 | 9091 tops ng2 | 9092 tops ng3 { @@ -174,7 +174,20 @@ tops.ng3.hacktivism.ch { } } -http://taler.hacktivism.ch, http://exchange.hacktivism.ch, http://bank.hacktivism.ch, http://castopod.hacktivism.ch, http://bonfire.hacktivism.ch, http://prime.hacktivism.ch, http://bt.hacktivism.ch, http://git.hacktivism.ch, http://tops.ng1.hacktivism.ch, http://tops.ng2.hacktivism.ch, http://tops.ng3.hacktivism.ch { + +# 9025 paivana-httpd (GOA paywall) +paivana.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9025 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Port 443 + } +} + +http://taler.hacktivism.ch, http://exchange.hacktivism.ch, http://bank.hacktivism.ch, http://castopod.hacktivism.ch, http://bonfire.hacktivism.ch, http://prime.hacktivism.ch, http://bt.hacktivism.ch, http://git.hacktivism.ch, http://paivana.hacktivism.ch, http://tops.ng1.hacktivism.ch, http://tops.ng2.hacktivism.ch, http://tops.ng3.hacktivism.ch { handle /.well-known/acme-challenge/* { root * /var/www/acme file_server diff --git a/configs/ports.md b/configs/ports.md index c49ed7c..24aad5d 100644 --- a/configs/ports.md +++ b/configs/ports.md @@ -16,6 +16,7 @@ | 9022 | podman `koopa-prime` (Jellyfin) → Caddy `prime.hacktivism.ch` | | 9023 | podman qBittorrent → Caddy `bt.hacktivism.ch` | | **9024** | podman **`koopa-forgejo`** (HTTP) → Caddy **`git.hacktivism.ch`** | +| **9025** | podman **`koopa-paivana`** (paivana-httpd) → Caddy **`paivana.hacktivism.ch`** | | **9200** | podman **Forgejo git-SSH** (host-direct; not Caddy) | | 9090–9092 | podman `koopa-tops-ng1` … `ng3` → Caddy `tops.ng{1,2,3}.hacktivism.ch` | | 8080 | Tor ORPort | diff --git a/host/caddy/Caddyfile b/host/caddy/Caddyfile index 03f88aa..77b2f34 100644 --- a/host/caddy/Caddyfile +++ b/host/caddy/Caddyfile @@ -1,7 +1,7 @@ # Internal only (not in the browser URL): # 9010 merchant API | 9011 exchange API | 9012 bank API # 9013 bank landing | 9014 exchange landing | 9015 merchant landing -# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | | 9200 forgejo-ssh +# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | 9025 paivana | 9200 forgejo-ssh # 9090 tops ng1 | 9091 tops ng2 | 9092 tops ng3 { @@ -174,7 +174,20 @@ tops.ng3.hacktivism.ch { } } -http://taler.hacktivism.ch, http://exchange.hacktivism.ch, http://bank.hacktivism.ch, http://castopod.hacktivism.ch, http://bonfire.hacktivism.ch, http://prime.hacktivism.ch, http://bt.hacktivism.ch, http://git.hacktivism.ch, http://tops.ng1.hacktivism.ch, http://tops.ng2.hacktivism.ch, http://tops.ng3.hacktivism.ch { + +# 9025 paivana-httpd (GOA paywall) +paivana.hacktivism.ch { + header Alt-Svc "clear" + reverse_proxy 127.0.0.1:9025 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Port 443 + } +} + +http://taler.hacktivism.ch, http://exchange.hacktivism.ch, http://bank.hacktivism.ch, http://castopod.hacktivism.ch, http://bonfire.hacktivism.ch, http://prime.hacktivism.ch, http://bt.hacktivism.ch, http://git.hacktivism.ch, http://paivana.hacktivism.ch, http://tops.ng1.hacktivism.ch, http://tops.ng2.hacktivism.ch, http://tops.ng3.hacktivism.ch { handle /.well-known/acme-challenge/* { root * /var/www/acme file_server diff --git a/host/caddy/README.md b/host/caddy/README.md index 92e5219..a38be73 100644 --- a/host/caddy/README.md +++ b/host/caddy/README.md @@ -10,6 +10,7 @@ Canonical mirror in this repo: **`configs/caddy/Caddyfile`** (same content as `h Public sites: `taler` / `exchange` / `bank` / `castopod` / `bonfire` / `prime` / `bt` / **`git`** / **`paivana`** / tops.ng*. Forgejo HTTP: Caddy → `127.0.0.1:9024`. Git SSH is **host :9200** (not Caddy). +Paivana: Caddy → `127.0.0.1:9025`. Validate/reload on host: diff --git a/host/overview/LIVE.md b/host/overview/LIVE.md index 30f810d..f235056 100644 --- a/host/overview/LIVE.md +++ b/host/overview/LIVE.md @@ -33,6 +33,7 @@ ss -lntp | grep -E '90[0-9]{2}|9200|8080' | `koopa-prime-jellyfin` | `linuxserver/jellyfin:10.10.7` | **9022** | `prime.hacktivism.ch` | | `koopa-prime-qbittorrent` | `linuxserver/qbittorrent:5.0.4` | **9023**, **6881** | `bt.hacktivism.ch` | | `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-tops-ng1` | `nginx` | **9090** | `tops.ng1.hacktivism.ch` | | `koopa-tops-ng2` | `nginx` | **9091** | `tops.ng2.hacktivism.ch` | @@ -55,6 +56,7 @@ Managed from **`/home/hernani/ansible-taler-exchange/`** (see `configs/taler-exc | `prime.hacktivism.ch` | 9022 | | `bt.hacktivism.ch` | 9023 | | `git.hacktivism.ch` | 9024 (HTTP); git-SSH **9200** host-direct | +| `paivana.hacktivism.ch` | 9025 (paivana-httpd GOA paywall) | | `tops.ng1.hacktivism.ch` | 9090 | | `tops.ng2.hacktivism.ch` | 9091 | | `tops.ng3.hacktivism.ch` | 9092 | @@ -70,6 +72,7 @@ Config: `/etc/caddy/Caddyfile` (mirror `configs/caddy/Caddyfile`). | `~/koopa-bonfire/` | Bonfire compose + gitbot | | `~/koopa-prime/` | Jellyfin + qBittorrent | | `~/koopa-forgejo/` | Forgejo rootless | +| `~/koopa-paivana/` | Paivana paywall (`koopa-paivana`) | | `~/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) | diff --git a/host/overview/services.md b/host/overview/services.md index c725124..0bff244 100644 --- a/host/overview/services.md +++ b/host/overview/services.md @@ -35,6 +35,7 @@ flowchart TB Prime["koopa-prime jellyfin\n:9022"] BT["qbittorrent\n:9023"] Forgejo["koopa-forgejo ROOTLESS\n:9024 HTTP / :9200 SSH"] + Paivana["koopa-paivana\n:9025"] end Tor["tor ORPort :8080"] @@ -59,6 +60,7 @@ flowchart TB CaddyHTTPS -->|prime.hacktivism.ch| Prime CaddyHTTPS -->|bt.hacktivism.ch| BT CaddyHTTPS -->|git.hacktivism.ch| Forgejo + CaddyHTTPS -->|paivana.hacktivism.ch| Paivana ``` ## Port table @@ -78,6 +80,7 @@ flowchart TB | 9022 | podman | **jellyfin** (prime) | | 9023 | podman | **qbittorrent** (bt) | | **9024** | podman | **forgejo** HTTP → `git.hacktivism.ch` | +| **9025** | podman | **paivana** → `paivana.hacktivism.ch` | | **9200** | podman | **forgejo** git-SSH (host-direct) | | 9090–9092 | podman | **tops** `koopa-tops-ng1`…`ng3` | | 8080 | tor | ORPort | @@ -94,6 +97,7 @@ Full port notes: `configs/ports.md`. | Merchant container | `configs/taler-hacktivism/`, `scripts/taler-merchant/` | | Exchange container | `configs/taler-exchange/`, `scripts/taler-exchange/` | | **Forgejo rootless** | `configs/forgejo/`, `2026/2026-07-10--forgejo-rootless.md` | +| Paivana paywall | `configs/paivana/` | | tops (`koopa-tops-ng*`) | `configs/tops/` | | Tor relay (`koopa-tor-relay` podman) | `configs/tor/`, `host/tor/` | | Tor monitoring scripts | `scripts/monitoring/` | From 572da9aef992235b8df8f50fe5de81057aac7d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Mon, 13 Jul 2026 11:39:55 +0200 Subject: [PATCH 009/326] configs/paivana: charge GOA:4200 for example site --- 2026/2026-07-13--paivana-goa.md | 20 +++++++ configs/paivana/README.md | 91 ++++++++++++++++++++++++++++- configs/paivana/upstream/index.html | 52 ++++++++++++++++- 3 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 2026/2026-07-13--paivana-goa.md diff --git a/2026/2026-07-13--paivana-goa.md b/2026/2026-07-13--paivana-goa.md new file mode 100644 index 0000000..9606641 --- /dev/null +++ b/2026/2026-07-13--paivana-goa.md @@ -0,0 +1,20 @@ +# 2026-07-13 — Paivana (GOA paywall) on koopa + +## Done + +- Podman stack **`koopa-paivana`** + **`koopa-paivana-upstream`** at `~/koopa-paivana/` +- Host port **9025** +- Image `localhost/koopa-paivana:latest` (built from `git.taler.net/paivana` + deb.taler.net libs; package not on apt yet) +- Merchant: instance **`goa-shop`**, template **`paivana`** (`template_type: paivana`, **GOA:4200**) +- After payment: site-wide cookie (`-g`) → example upstream free to browse +- `extra_hosts: taler.hacktivism.ch:host-gateway` so pasta can reach merchant +- Flags: `-g` (site-wide), `-f` (X-Forwarded-For) +- Smoke: `curl -si http://127.0.0.1:9025/` → **302** to `/.well-known/paivana/templates/paivana#…` +- Config mirror: `configs/paivana/` +- Caddy: `paivana.hacktivism.ch` → `:9025` (`configs/caddy/Caddyfile`) +- User unit: `container-koopa-paivana.service` enabled + +## Refs + +- https://docs.taler.net/taler-paivana-manual.html +- https://docs.taler.net/design-documents/095-captcha-100.html diff --git a/configs/paivana/README.md b/configs/paivana/README.md index 4bc39cd..097c833 100644 --- a/configs/paivana/README.md +++ b/configs/paivana/README.md @@ -1,13 +1,98 @@ # paivana — `paivana.hacktivism.ch` -GNU Taler **paivana-httpd** reverse-proxy paywall, GOA payments. +GNU Taler **paivana-httpd** reverse-proxy paywall (DD 95 / DD 76 style), GOA payments. | Item | Value | |------|--------| | Live | `/home/hernani/koopa-paivana/` | | Containers | `koopa-paivana`, `koopa-paivana-upstream` | -| Host port | **9025** | +| Image | `localhost/koopa-paivana:latest` (built from `Containerfile`) | +| Host port | **9025** → Caddy `paivana.hacktivism.ch` | | Currency | **GOA** | | Merchant | `https://taler.hacktivism.ch/instances/goa-shop/` | +| Template | `paivana` (`template_type: paivana`, amount **`GOA:4200`**, `website_regex: .*`) | +| Access | **`-g`** site-wide: pay once → example upstream free to browse | +| Upstream | example site in `upstream/` (compose service) | -Build: `Containerfile` from `git.taler.net/paivana` + deb.taler.net libs. +## Layout + +| Path | Role | +|------|------| +| `Containerfile` | Multi-stage: build paivana-httpd from `git.taler.net/paivana` + Taler libs | +| `compose.yml` | `koopa-paivana` + nginx upstream | +| `conf/paivana.conf.template` | Config; secrets substituted at start | +| `entrypoint.sh` | Inject secrets → run `paivana-httpd -g -f` | +| `secrets/` | **not in git** — live only | +| `upstream/` | Protected demo content | +| `container-koopa-paivana.service` | systemd --user oneshot compose up | +| `taler-systems.gpg` | Deb signing key (build context) | + +## Secrets (live) + +| File | Role | +|------|------| +| `~/koopa-paivana/secrets/merchant-access-token` | `secret-token:…` for goa-shop | +| `~/koopa-paivana/secrets/paivana-secret` | cookie MAC secret (stable across restarts) | + +Mirror notes: `koopa-admin-secrets` → `koopa/home-hernani/koopa-paivana/secrets/` (paths only / examples). + +## Ops + +```bash +# as hernani on koopa +cd ~/koopa-paivana +podman compose build +podman compose up -d +podman logs -f koopa-paivana +curl -si http://127.0.0.1:9025/ | head # 302 → /.well-known/paivana/templates/paivana#… +``` + +Autostart: + +```bash +mkdir -p ~/.config/systemd/user +cp ~/koopa-paivana/container-koopa-paivana.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now container-koopa-paivana.service +``` + +## Network notes + +Rootless pasta cannot hairpin public DNS for the merchant. Compose sets: + +```yaml +extra_hosts: + - "taler.hacktivism.ch:host-gateway" +``` + +so `paivana-httpd` can load templates from the merchant API. + +## Caddy + +Vhost → `127.0.0.1:9025` (see `configs/caddy/Caddyfile`). +Flags `-g` (site-wide payment) and `-f` (X-Forwarded-For). + +## Merchant template + +```bash +curl -X POST 'https://taler.hacktivism.ch/instances/goa-shop/private/templates' \ + -H "Authorization: Bearer secret-token:…" \ + -H 'Content-Type: application/json' \ + -d '{ + "template_id": "paivana", + "template_description": "Paivana paywall (GOA)", + "template_contract": { + "template_type": "paivana", + "summary": "Access to paivana.hacktivism.ch", + "website_regex": ".*", + "choices": [{ "amount": "GOA:4200", "description": "Unlock example site (then free to browse)" }] + } + }' +``` + +## Docs + +- https://docs.taler.net/taler-paivana-manual.html +- https://docs.taler.net/design-documents/095-captcha-100.html +- https://docs.taler.net/design-documents/076-paywall-proxy.html +- https://docs.taler.net/manpages/paivana.conf.5.html diff --git a/configs/paivana/upstream/index.html b/configs/paivana/upstream/index.html index c27ede4..064d3d7 100644 --- a/configs/paivana/upstream/index.html +++ b/configs/paivana/upstream/index.html @@ -1,8 +1,54 @@ -Protected — paivana.hacktivism.ch + + + + Example site — free after GOA:4200 + + -

Payment verified

-

Demo upstream behind Paivana (GOA). Site: https://paivana.hacktivism.ch/

+ unlocked +

Example site

+

+ You paid GOA:4200 once via GNU Taler / Paivana. + This whole example site is now free for you to browse (site-wide access cookie). +

+
+

What this demonstrates

+
    +
  • Paywall: paivana-httpd in front of a normal upstream HTTP site
  • +
  • Currency: exploration money GOA on exchange.hacktivism.ch
  • +
  • After payment: no further charge for pages behind this proxy
  • +
+

Host: https://paivana.hacktivism.ch/

+
From cf7cb891905b9294fb3038755c87ddffa5d01755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Mon, 13 Jul 2026 11:39:55 +0200 Subject: [PATCH 010/326] configs/paivana: charge GOA:4200 for example site --- 2026/2026-07-13--paivana-goa.md | 20 +++++++ configs/paivana/README.md | 91 ++++++++++++++++++++++++++++- configs/paivana/upstream/index.html | 52 ++++++++++++++++- 3 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 2026/2026-07-13--paivana-goa.md diff --git a/2026/2026-07-13--paivana-goa.md b/2026/2026-07-13--paivana-goa.md new file mode 100644 index 0000000..9606641 --- /dev/null +++ b/2026/2026-07-13--paivana-goa.md @@ -0,0 +1,20 @@ +# 2026-07-13 — Paivana (GOA paywall) on koopa + +## Done + +- Podman stack **`koopa-paivana`** + **`koopa-paivana-upstream`** at `~/koopa-paivana/` +- Host port **9025** +- Image `localhost/koopa-paivana:latest` (built from `git.taler.net/paivana` + deb.taler.net libs; package not on apt yet) +- Merchant: instance **`goa-shop`**, template **`paivana`** (`template_type: paivana`, **GOA:4200**) +- After payment: site-wide cookie (`-g`) → example upstream free to browse +- `extra_hosts: taler.hacktivism.ch:host-gateway` so pasta can reach merchant +- Flags: `-g` (site-wide), `-f` (X-Forwarded-For) +- Smoke: `curl -si http://127.0.0.1:9025/` → **302** to `/.well-known/paivana/templates/paivana#…` +- Config mirror: `configs/paivana/` +- Caddy: `paivana.hacktivism.ch` → `:9025` (`configs/caddy/Caddyfile`) +- User unit: `container-koopa-paivana.service` enabled + +## Refs + +- https://docs.taler.net/taler-paivana-manual.html +- https://docs.taler.net/design-documents/095-captcha-100.html diff --git a/configs/paivana/README.md b/configs/paivana/README.md index 4bc39cd..097c833 100644 --- a/configs/paivana/README.md +++ b/configs/paivana/README.md @@ -1,13 +1,98 @@ # paivana — `paivana.hacktivism.ch` -GNU Taler **paivana-httpd** reverse-proxy paywall, GOA payments. +GNU Taler **paivana-httpd** reverse-proxy paywall (DD 95 / DD 76 style), GOA payments. | Item | Value | |------|--------| | Live | `/home/hernani/koopa-paivana/` | | Containers | `koopa-paivana`, `koopa-paivana-upstream` | -| Host port | **9025** | +| Image | `localhost/koopa-paivana:latest` (built from `Containerfile`) | +| Host port | **9025** → Caddy `paivana.hacktivism.ch` | | Currency | **GOA** | | Merchant | `https://taler.hacktivism.ch/instances/goa-shop/` | +| Template | `paivana` (`template_type: paivana`, amount **`GOA:4200`**, `website_regex: .*`) | +| Access | **`-g`** site-wide: pay once → example upstream free to browse | +| Upstream | example site in `upstream/` (compose service) | -Build: `Containerfile` from `git.taler.net/paivana` + deb.taler.net libs. +## Layout + +| Path | Role | +|------|------| +| `Containerfile` | Multi-stage: build paivana-httpd from `git.taler.net/paivana` + Taler libs | +| `compose.yml` | `koopa-paivana` + nginx upstream | +| `conf/paivana.conf.template` | Config; secrets substituted at start | +| `entrypoint.sh` | Inject secrets → run `paivana-httpd -g -f` | +| `secrets/` | **not in git** — live only | +| `upstream/` | Protected demo content | +| `container-koopa-paivana.service` | systemd --user oneshot compose up | +| `taler-systems.gpg` | Deb signing key (build context) | + +## Secrets (live) + +| File | Role | +|------|------| +| `~/koopa-paivana/secrets/merchant-access-token` | `secret-token:…` for goa-shop | +| `~/koopa-paivana/secrets/paivana-secret` | cookie MAC secret (stable across restarts) | + +Mirror notes: `koopa-admin-secrets` → `koopa/home-hernani/koopa-paivana/secrets/` (paths only / examples). + +## Ops + +```bash +# as hernani on koopa +cd ~/koopa-paivana +podman compose build +podman compose up -d +podman logs -f koopa-paivana +curl -si http://127.0.0.1:9025/ | head # 302 → /.well-known/paivana/templates/paivana#… +``` + +Autostart: + +```bash +mkdir -p ~/.config/systemd/user +cp ~/koopa-paivana/container-koopa-paivana.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now container-koopa-paivana.service +``` + +## Network notes + +Rootless pasta cannot hairpin public DNS for the merchant. Compose sets: + +```yaml +extra_hosts: + - "taler.hacktivism.ch:host-gateway" +``` + +so `paivana-httpd` can load templates from the merchant API. + +## Caddy + +Vhost → `127.0.0.1:9025` (see `configs/caddy/Caddyfile`). +Flags `-g` (site-wide payment) and `-f` (X-Forwarded-For). + +## Merchant template + +```bash +curl -X POST 'https://taler.hacktivism.ch/instances/goa-shop/private/templates' \ + -H "Authorization: Bearer secret-token:…" \ + -H 'Content-Type: application/json' \ + -d '{ + "template_id": "paivana", + "template_description": "Paivana paywall (GOA)", + "template_contract": { + "template_type": "paivana", + "summary": "Access to paivana.hacktivism.ch", + "website_regex": ".*", + "choices": [{ "amount": "GOA:4200", "description": "Unlock example site (then free to browse)" }] + } + }' +``` + +## Docs + +- https://docs.taler.net/taler-paivana-manual.html +- https://docs.taler.net/design-documents/095-captcha-100.html +- https://docs.taler.net/design-documents/076-paywall-proxy.html +- https://docs.taler.net/manpages/paivana.conf.5.html diff --git a/configs/paivana/upstream/index.html b/configs/paivana/upstream/index.html index c27ede4..064d3d7 100644 --- a/configs/paivana/upstream/index.html +++ b/configs/paivana/upstream/index.html @@ -1,8 +1,54 @@ -Protected — paivana.hacktivism.ch + + + + Example site — free after GOA:4200 + + -

Payment verified

-

Demo upstream behind Paivana (GOA). Site: https://paivana.hacktivism.ch/

+ unlocked +

Example site

+

+ You paid GOA:4200 once via GNU Taler / Paivana. + This whole example site is now free for you to browse (site-wide access cookie). +

+
+

What this demonstrates

+
    +
  • Paywall: paivana-httpd in front of a normal upstream HTTP site
  • +
  • Currency: exploration money GOA on exchange.hacktivism.ch
  • +
  • After payment: no further charge for pages behind this proxy
  • +
+

Host: https://paivana.hacktivism.ch/

+
From 0bfd8ebc94641d01e76841aaef64c4e5200e6b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Mon, 13 Jul 2026 11:51:33 +0200 Subject: [PATCH 011/326] configs/paivana: enable compose on reboot --- configs/paivana/container-koopa-paivana.service | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 configs/paivana/container-koopa-paivana.service diff --git a/configs/paivana/container-koopa-paivana.service b/configs/paivana/container-koopa-paivana.service new file mode 100644 index 0000000..c2cafa3 --- /dev/null +++ b/configs/paivana/container-koopa-paivana.service @@ -0,0 +1,15 @@ +[Unit] +Description=koopa-paivana (Paivana HTTP paywall, GOA) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=%h/koopa-paivana +ExecStart=/usr/bin/podman compose -f %h/koopa-paivana/compose.yml up -d +ExecStop=/usr/bin/podman compose -f %h/koopa-paivana/compose.yml down +TimeoutStartSec=600 + +[Install] +WantedBy=default.target From e6126d700d5dfe4b5330097f0794a088cdebbf87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Mon, 13 Jul 2026 11:51:33 +0200 Subject: [PATCH 012/326] configs/paivana: enable compose on reboot --- configs/paivana/container-koopa-paivana.service | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 configs/paivana/container-koopa-paivana.service diff --git a/configs/paivana/container-koopa-paivana.service b/configs/paivana/container-koopa-paivana.service new file mode 100644 index 0000000..c2cafa3 --- /dev/null +++ b/configs/paivana/container-koopa-paivana.service @@ -0,0 +1,15 @@ +[Unit] +Description=koopa-paivana (Paivana HTTP paywall, GOA) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=%h/koopa-paivana +ExecStart=/usr/bin/podman compose -f %h/koopa-paivana/compose.yml up -d +ExecStop=/usr/bin/podman compose -f %h/koopa-paivana/compose.yml down +TimeoutStartSec=600 + +[Install] +WantedBy=default.target 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 013/326] 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 1904ad7ed31dc386412ced1130c5edde3f4b6729 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 014/326] 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 015/326] 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 4903e3f5ced8532f580bf8b954e2b5590e94a39f 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 016/326] 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 017/326] 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 cc8dfe3ef57b96d8a72b95e8e6483e190a2ef82c 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 018/326] 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 019/326] 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 c3543dc38f11337aeb195e63939b009313262e1d 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 020/326] 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 021/326] 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 69cd0f633009de7b246ed5287d4c19082fde126c 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 022/326] 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 023/326] 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 77db2355a14229d0d6f038e5aef23cbd7a77bb0f 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 024/326] 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 025/326] 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 3c3603d769f3d5f2e00f7223255a21e9f36f699f 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 026/326] 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 027/326] 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 c1c8a5191f7440e1632dcfc3a7e6dd58c61f0f7e 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 028/326] 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 029/326] 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 a403999bf2e44c0c12aac0741771d21d101b8c4c 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 030/326] 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 031/326] 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 afb333a4c528357d188906aee20cdcb720e9c1f6 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 032/326] 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 033/326] 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 81e9dbf42b21a8b1f6f2132e5c9e9438c95e2e4a 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 034/326] 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 035/326] 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 84e05861d309c5a4f3b3b79a8b2b536bb068e11a 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 036/326] 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 037/326] 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 d7b2f8bc3c3cf3edb03716a5c7b7b3812cef8889 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 038/326] 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 039/326] 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 f404956790b36dde8c7daac727cae802a6097522 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 040/326] 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 041/326] 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 c19b7dea372dc83172a8d2272371cea6b0b8bd8a 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 042/326] 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 043/326] 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 771e1d2f95cfd72e988f2e82b32dab0919bd884c 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 044/326] 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 045/326] 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 c763425427a2d22de3afbe4c8e3f1be564f76c20 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 046/326] 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 047/326] 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 df7883498c3d3aea2aaf116893814ab151f58b49 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 048/326] 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 049/326] 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 34ad480a5e92e114f1a166e7a2f9aca05ce4f1c2 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 050/326] 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 051/326] 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 c8023e7b5eb0a419a3dab3f179b5c3f21f68a0ed 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 052/326] 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 053/326] 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 3c3dbb59a920d8b1d7ef7acbee1345e000a7dd07 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 054/326] 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 055/326] 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 19ee6bc362bb4f3abafa6f4f76859eb0ed2f5ee4 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 056/326] 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 057/326] 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 058/326] 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 059/326] 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 060/326] 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 061/326] 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 062/326] 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 063/326] 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 064/326] 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 065/326] 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 066/326] 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 067/326] 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 068/326] 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 069/326] 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 070/326] 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 071/326] 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 4e397a5c2787efc2eef8658d629e348ee7b27bb5 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 072/326] 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 0770a65e08d7bb7f42dc7424858dd8f41e10aa82 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 073/326] 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 55782c6ff773020e816ef3f0aad2438cdc968414 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 074/326] 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 17f2af9a1c14c08adfce25c5533d47154992a65a 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 075/326] 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 076/326] 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 6a7c7614bae8dc535103c3d1de9605d009acfcd7 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 077/326] 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 49b4943d9c51e638b58d8e964d453c6423d42bb3 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 078/326] 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 77e43a68bcb381c33760263ef66964adc2862b67 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 079/326] 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 66cb05c1716dd73cb8cbacd92abd7576ccac83f5 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 080/326] 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 33aab532a0867ed439475bbec222131a3e03ea58 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 081/326] 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 badead4e6bf6035b900b4c36c91cbaa5c1d13c89 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 082/326] 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 c5a3b360196e67c099996afa0e320679dfe3ff0f 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 083/326] 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 4bcdfb5dc263426b6e0cd64fff05117772e794a9 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 084/326] 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 3e78a8c0b69788bedabc6eccb37f9739e5c02cbc 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 085/326] 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 d09c411c935a6ec0a59157a5af380639e359f55a 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 086/326] 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 087/326] 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 122cdd23b4cfb7b495169d0a1f205fbfbff58154 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 088/326] 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 089/326] 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 921fb3cc2c9d5640c37baf6179ff262b536af815 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 090/326] 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 091/326] 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 da5f1fc37467103e59fff09f3836185ec3985a4f 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 092/326] 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 093/326] 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 388cc15c9e91eec52fa361b275514488aab6c394 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 094/326] 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 095/326] 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 7550ee57156e5647370c40381a2dab2e87b2e2fb 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 096/326] 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 097/326] 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 4f645587e9203ecfbb8565c1fe9142a0ddc5583f 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 098/326] 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 099/326] 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 982f5ef64f22d7f1a9560b5a463e0ad0f3286dc6 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 100/326] 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 101/326] 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 8418192b050cc461aa9310f17f95b79d88ca0f25 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 102/326] 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 103/326] 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 8c721bb424d7dd6a1918d8c10f3626e3da0de602 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 104/326] 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 105/326] 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 c2683e9031bff77108b638a60d3409828f554e28 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 106/326] 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 107/326] 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 342e16b94fc5c88fb4b3fbe955aefb3c480b37e7 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 108/326] 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 109/326] 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 2471a50eaf64fab2bf10c3162427f9e050951a65 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 110/326] 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 111/326] 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 260f71f8c47f50346e1c1774ee4c574800ab44c9 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 112/326] 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 113/326] 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 3c68d450e5da681f2804f6e94f1d2902c8481c61 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 114/326] 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 115/326] 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 ae84af0de384867f30cc54297b718ae37c306a53 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 116/326] 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 117/326] 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 a03eaf10c3ebd1389a176033b75309b407ed85de 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 118/326] 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 119/326] 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 + + + + + + + + + @@ -1545,6 +1555,13 @@ tw run-until-done && tw balance Privacy: processing under Swiss FADP (revDSG). What is retained is listed on /privacy.

+ +

+ Landing v42 + · content + · aa3c23b +

+ diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index cf671ad..a79a3e9 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -274,7 +274,17 @@ footer { margin-top: 1.75rem; text-align: center; color: var(--muted); font-size: 0.85rem; } footer a { color: #e8c878; } code { font-family: ui-monospace, Menlo, monospace; font-size: 0.86em; background: rgba(0,0,0,0.35); padding: 0.1em 0.35em; border-radius: 4px; color: #f0d090; } - + .landing-rev { + margin: 0.75rem 0 0; + font-size: 0.72rem; + color: var(--muted, #9a8fb0); + line-height: 1.45; + text-align: center; + } + .landing-rev a { color: inherit; text-decoration: underline; text-underline-offset: 2px; opacity: 0.95; } + .landing-rev strong { font-weight: 750; } + +

@@ -479,6 +489,13 @@ sudo apt-get install -y taler-wallet-cli Privacy: processing under Swiss FADP (revDSG). What is retained is listed on /privacy.

+ +

+ Landing v42 + · content + · aa3c23b +

+ diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html index 1a14014..ae66805 100644 --- a/configs/merchant-landing/index.html +++ b/configs/merchant-landing/index.html @@ -517,7 +517,17 @@ .sym-gold path[data-ring], .sym-gold circle[data-ring] { fill: none; stroke: #f0c86a; } .sym-blue path, .sym-blue circle { fill: #3ecfbf; stroke: #3ecfbf; } .sym-blue path[data-ring], .sym-blue circle[data-ring] { fill: none; stroke: #3ecfbf; } - + .landing-rev { + margin: 0.75rem 0 0; + font-size: 0.72rem; + color: var(--muted, #9a8fb0); + line-height: 1.45; + text-align: center; + } + .landing-rev a { color: inherit; text-decoration: underline; text-underline-offset: 2px; opacity: 0.95; } + .landing-rev strong { font-weight: 750; } + +

@@ -822,6 +832,13 @@ sudo apt-get install -y taler-wallet-cli Privacy: processing under Swiss FADP (revDSG). What is retained is listed on /privacy.

+ +

+ Landing v42 + · content + · aa3c23b +

+ diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json new file mode 100644 index 0000000..ee06829 --- /dev/null +++ b/configs/shared/landing-version.json @@ -0,0 +1,9 @@ +{ + "version": 42, + "updated_iso": "2026-07-17T13:31:56+02:00", + "updated_human": "2026-07-17 13:31 CEST", + "commit": "aa3c23be49236ba5c20cd095bb02790f84aeb1e5", + "commit_short": "aa3c23b", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/aa3c23be49236ba5c20cd095bb02790f84aeb1e5", + "note": "Bump with scripts/taler-landing/stamp-landing-version.sh --bump after each landing content change." +} diff --git a/scripts/taler-landing/stamp-landing-version.sh b/scripts/taler-landing/stamp-landing-version.sh new file mode 100755 index 0000000..570636c --- /dev/null +++ b/scripts/taler-landing/stamp-landing-version.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Stamp landing footers with version + last content change + linkified commit. +# +# Usage (from koopa-admin-log root): +# ./scripts/taler-landing/stamp-landing-version.sh # re-stamp with HEAD + current version +# ./scripts/taler-landing/stamp-landing-version.sh --bump # version += 1, then stamp HEAD +# ./scripts/taler-landing/stamp-landing-version.sh --set 42 # force version +# +# Typical flow after editing landing HTML/CSS/JS: +# 1) edit landings +# 2) ./scripts/taler-landing/stamp-landing-version.sh --bump +# 3) git add configs/ && git commit +# 4) ./scripts/taler-landing/stamp-landing-version.sh && git add configs/ && git commit --amend --no-edit +# +# Or one-shot (commits for you if GIT_STAMP_COMMIT=1). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +VER_FILE="$ROOT/configs/shared/landing-version.json" +REPO_WEB="${LANDING_REPO_WEB:-https://git.hacktivism.ch/hernani/koopa-admin-log}" +LANDINGS=( + "$ROOT/configs/bank-landing/index.html" + "$ROOT/configs/exchange-landing/index.html" + "$ROOT/configs/merchant-landing/index.html" +) + +export TZ="${TZ:-Europe/Zurich}" + +bump=0 +set_ver="" +while [ $# -gt 0 ]; do + case "$1" in + --bump) bump=1; shift ;; + --set) set_ver="${2:-}"; shift 2 ;; + -h|--help) + sed -n '2,20p' "$0" + exit 0 + ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +if [ ! -f "$VER_FILE" ]; then + echo '{"version":42,"updated_iso":"","updated_human":"","commit":"","commit_short":""}' >"$VER_FILE" +fi + +version=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version",42))' "$VER_FILE") +if [ -n "$set_ver" ]; then + version="$set_ver" +elif [ "$bump" = 1 ]; then + version=$((version + 1)) +fi + +# Prefer staged/index commit identity after commit; allow OVERRIDE_COMMIT +if [ -n "${OVERRIDE_COMMIT:-}" ]; then + full="$OVERRIDE_COMMIT" +else + full=$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo "unknown") +fi +short=$(git -C "$ROOT" rev-parse --short=7 "$full" 2>/dev/null || echo "${full:0:7}") +iso=$(date +%Y-%m-%dT%H:%M:%S%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/') +human=$(date +"%Y-%m-%d %H:%M %Z") +url="${REPO_WEB}/commit/${full}" + +python3 - "$VER_FILE" "$version" "$iso" "$human" "$full" "$short" "$url" <<'PY' +import json, sys +path, version, iso, human, full, short, url = sys.argv[1:] +data = { + "version": int(version), + "updated_iso": iso, + "updated_human": human, + "commit": full, + "commit_short": short, + "repo_commit_url": url, + "note": "Bump with scripts/taler-landing/stamp-landing-version.sh --bump after each landing content change.", +} +open(path, "w", encoding="utf-8").write(json.dumps(data, indent=2, ensure_ascii=False) + "\n") +print(f"version={version} commit={short} at={human}") +PY + +# HTML block injected into each footer (idempotent via markers) +block=$(cat < +

+ Landing v${version} + · content + · ${short} +

+ +EOF +) + +css_snip=' .landing-rev { + margin: 0.75rem 0 0; + font-size: 0.72rem; + color: var(--muted, #9a8fb0); + line-height: 1.45; + text-align: center; + } + .landing-rev a { color: inherit; text-decoration: underline; text-underline-offset: 2px; opacity: 0.95; } + .landing-rev strong { font-weight: 750; } +' + +for f in "${LANDINGS[@]}"; do + [ -f "$f" ] || { echo "missing $f" >&2; exit 1; } + # ensure CSS once + if ! grep -q '\.landing-rev' "$f"; then + # insert before of first style block + python3 - "$f" "$css_snip" <<'PY' +import sys +path, snip = sys.argv[1], sys.argv[2] +t = open(path, encoding="utf-8").read() +if ".landing-rev" in t: + sys.exit(0) +idx = t.find("") +if idx < 0: + raise SystemExit(f"no in {path}") +t = t[:idx] + snip + "\n" + t[idx:] +open(path, "w", encoding="utf-8").write(t) +print(f"css → {path}") +PY + fi + # replace or insert rev block before + python3 - "$f" "$block" <<'PY' +import sys, re +path, block = sys.argv[1], sys.argv[2] +t = open(path, encoding="utf-8").read() +block = block.rstrip() + "\n" +pat = re.compile(r"[ \t]*.*?\n?", re.S) +if pat.search(t): + t = pat.sub(block, t, count=1) +else: + # before closing footer + t2, n = re.subn(r"(\n)([ \t]*)", r"\1" + block + r"\2", t, count=1) + if n != 1: + raise SystemExit(f"could not insert landing-rev in {path}") + t = t2 +open(path, "w", encoding="utf-8").write(t) +print(f"stamp → {path}") +PY +done + +echo "OK landing v${version} → ${short} (${human})" From 139f117430dcec231507074d6d3b53e0de15772c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 13:31:56 +0200 Subject: [PATCH 166/326] landing: content version stamp v42 with commit link Footer on bank/exchange/merchant shows Landing vN, last content change time, and a linkified git commit. Start at v42; bump with scripts/taler-landing/stamp-landing-version.sh --bump. --- configs/bank-landing/index.html | 19 ++- configs/exchange-landing/index.html | 19 ++- configs/merchant-landing/index.html | 19 ++- configs/shared/landing-version.json | 9 ++ .../taler-landing/stamp-landing-version.sh | 143 ++++++++++++++++++ 5 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 configs/shared/landing-version.json create mode 100755 scripts/taler-landing/stamp-landing-version.sh diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index b9ab241..8efca46 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1102,7 +1102,17 @@ border-radius: 10px; } .shop-foot a:hover { filter: brightness(1.06); } - + .landing-rev { + margin: 0.75rem 0 0; + font-size: 0.72rem; + color: var(--muted, #9a8fb0); + line-height: 1.45; + text-align: center; + } + .landing-rev a { color: inherit; text-decoration: underline; text-underline-offset: 2px; opacity: 0.95; } + .landing-rev strong { font-weight: 750; } + + @@ -1545,6 +1555,13 @@ tw run-until-done && tw balance Privacy: processing under Swiss FADP (revDSG). What is retained is listed on /privacy.

+ +

+ Landing v42 + · content + · aa3c23b +

+ diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index cf671ad..a79a3e9 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -274,7 +274,17 @@ footer { margin-top: 1.75rem; text-align: center; color: var(--muted); font-size: 0.85rem; } footer a { color: #e8c878; } code { font-family: ui-monospace, Menlo, monospace; font-size: 0.86em; background: rgba(0,0,0,0.35); padding: 0.1em 0.35em; border-radius: 4px; color: #f0d090; } - + .landing-rev { + margin: 0.75rem 0 0; + font-size: 0.72rem; + color: var(--muted, #9a8fb0); + line-height: 1.45; + text-align: center; + } + .landing-rev a { color: inherit; text-decoration: underline; text-underline-offset: 2px; opacity: 0.95; } + .landing-rev strong { font-weight: 750; } + +

@@ -479,6 +489,13 @@ sudo apt-get install -y taler-wallet-cli Privacy: processing under Swiss FADP (revDSG). What is retained is listed on /privacy.

+ +

+ Landing v42 + · content + · aa3c23b +

+ diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html index 1a14014..ae66805 100644 --- a/configs/merchant-landing/index.html +++ b/configs/merchant-landing/index.html @@ -517,7 +517,17 @@ .sym-gold path[data-ring], .sym-gold circle[data-ring] { fill: none; stroke: #f0c86a; } .sym-blue path, .sym-blue circle { fill: #3ecfbf; stroke: #3ecfbf; } .sym-blue path[data-ring], .sym-blue circle[data-ring] { fill: none; stroke: #3ecfbf; } - + .landing-rev { + margin: 0.75rem 0 0; + font-size: 0.72rem; + color: var(--muted, #9a8fb0); + line-height: 1.45; + text-align: center; + } + .landing-rev a { color: inherit; text-decoration: underline; text-underline-offset: 2px; opacity: 0.95; } + .landing-rev strong { font-weight: 750; } + +

@@ -822,6 +832,13 @@ sudo apt-get install -y taler-wallet-cli Privacy: processing under Swiss FADP (revDSG). What is retained is listed on /privacy.

+ +

+ Landing v42 + · content + · aa3c23b +

+ diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json new file mode 100644 index 0000000..ee06829 --- /dev/null +++ b/configs/shared/landing-version.json @@ -0,0 +1,9 @@ +{ + "version": 42, + "updated_iso": "2026-07-17T13:31:56+02:00", + "updated_human": "2026-07-17 13:31 CEST", + "commit": "aa3c23be49236ba5c20cd095bb02790f84aeb1e5", + "commit_short": "aa3c23b", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/aa3c23be49236ba5c20cd095bb02790f84aeb1e5", + "note": "Bump with scripts/taler-landing/stamp-landing-version.sh --bump after each landing content change." +} diff --git a/scripts/taler-landing/stamp-landing-version.sh b/scripts/taler-landing/stamp-landing-version.sh new file mode 100755 index 0000000..570636c --- /dev/null +++ b/scripts/taler-landing/stamp-landing-version.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Stamp landing footers with version + last content change + linkified commit. +# +# Usage (from koopa-admin-log root): +# ./scripts/taler-landing/stamp-landing-version.sh # re-stamp with HEAD + current version +# ./scripts/taler-landing/stamp-landing-version.sh --bump # version += 1, then stamp HEAD +# ./scripts/taler-landing/stamp-landing-version.sh --set 42 # force version +# +# Typical flow after editing landing HTML/CSS/JS: +# 1) edit landings +# 2) ./scripts/taler-landing/stamp-landing-version.sh --bump +# 3) git add configs/ && git commit +# 4) ./scripts/taler-landing/stamp-landing-version.sh && git add configs/ && git commit --amend --no-edit +# +# Or one-shot (commits for you if GIT_STAMP_COMMIT=1). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +VER_FILE="$ROOT/configs/shared/landing-version.json" +REPO_WEB="${LANDING_REPO_WEB:-https://git.hacktivism.ch/hernani/koopa-admin-log}" +LANDINGS=( + "$ROOT/configs/bank-landing/index.html" + "$ROOT/configs/exchange-landing/index.html" + "$ROOT/configs/merchant-landing/index.html" +) + +export TZ="${TZ:-Europe/Zurich}" + +bump=0 +set_ver="" +while [ $# -gt 0 ]; do + case "$1" in + --bump) bump=1; shift ;; + --set) set_ver="${2:-}"; shift 2 ;; + -h|--help) + sed -n '2,20p' "$0" + exit 0 + ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +if [ ! -f "$VER_FILE" ]; then + echo '{"version":42,"updated_iso":"","updated_human":"","commit":"","commit_short":""}' >"$VER_FILE" +fi + +version=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version",42))' "$VER_FILE") +if [ -n "$set_ver" ]; then + version="$set_ver" +elif [ "$bump" = 1 ]; then + version=$((version + 1)) +fi + +# Prefer staged/index commit identity after commit; allow OVERRIDE_COMMIT +if [ -n "${OVERRIDE_COMMIT:-}" ]; then + full="$OVERRIDE_COMMIT" +else + full=$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo "unknown") +fi +short=$(git -C "$ROOT" rev-parse --short=7 "$full" 2>/dev/null || echo "${full:0:7}") +iso=$(date +%Y-%m-%dT%H:%M:%S%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/') +human=$(date +"%Y-%m-%d %H:%M %Z") +url="${REPO_WEB}/commit/${full}" + +python3 - "$VER_FILE" "$version" "$iso" "$human" "$full" "$short" "$url" <<'PY' +import json, sys +path, version, iso, human, full, short, url = sys.argv[1:] +data = { + "version": int(version), + "updated_iso": iso, + "updated_human": human, + "commit": full, + "commit_short": short, + "repo_commit_url": url, + "note": "Bump with scripts/taler-landing/stamp-landing-version.sh --bump after each landing content change.", +} +open(path, "w", encoding="utf-8").write(json.dumps(data, indent=2, ensure_ascii=False) + "\n") +print(f"version={version} commit={short} at={human}") +PY + +# HTML block injected into each footer (idempotent via markers) +block=$(cat < +

+ Landing v${version} + · content + · ${short} +

+ +EOF +) + +css_snip=' .landing-rev { + margin: 0.75rem 0 0; + font-size: 0.72rem; + color: var(--muted, #9a8fb0); + line-height: 1.45; + text-align: center; + } + .landing-rev a { color: inherit; text-decoration: underline; text-underline-offset: 2px; opacity: 0.95; } + .landing-rev strong { font-weight: 750; } +' + +for f in "${LANDINGS[@]}"; do + [ -f "$f" ] || { echo "missing $f" >&2; exit 1; } + # ensure CSS once + if ! grep -q '\.landing-rev' "$f"; then + # insert before of first style block + python3 - "$f" "$css_snip" <<'PY' +import sys +path, snip = sys.argv[1], sys.argv[2] +t = open(path, encoding="utf-8").read() +if ".landing-rev" in t: + sys.exit(0) +idx = t.find("") +if idx < 0: + raise SystemExit(f"no in {path}") +t = t[:idx] + snip + "\n" + t[idx:] +open(path, "w", encoding="utf-8").write(t) +print(f"css → {path}") +PY + fi + # replace or insert rev block before + python3 - "$f" "$block" <<'PY' +import sys, re +path, block = sys.argv[1], sys.argv[2] +t = open(path, encoding="utf-8").read() +block = block.rstrip() + "\n" +pat = re.compile(r"[ \t]*.*?\n?", re.S) +if pat.search(t): + t = pat.sub(block, t, count=1) +else: + # before closing footer + t2, n = re.subn(r"(\n)([ \t]*)", r"\1" + block + r"\2", t, count=1) + if n != 1: + raise SystemExit(f"could not insert landing-rev in {path}") + t = t2 +open(path, "w", encoding="utf-8").write(t) +print(f"stamp → {path}") +PY +done + +echo "OK landing v${version} → ${short} (${human})" From d5eba029453b9055267ddabb17a437f4cacefdb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 13:31:57 +0200 Subject: [PATCH 167/326] landing: point v42 footer at content commit 1c9978e --- configs/bank-landing/index.html | 6 +++--- configs/exchange-landing/index.html | 6 +++--- configs/merchant-landing/index.html | 6 +++--- configs/shared/landing-version.json | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index 8efca46..52c3955 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1556,10 +1556,10 @@ tw run-until-done && tw balance /privacy.

-

+

Landing v42 - · content - · aa3c23b + · content + · b01058c

diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index a79a3e9..44991b0 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -490,10 +490,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Landing v42 - · content - · aa3c23b + · content + · b01058c

diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html index ae66805..6797c55 100644 --- a/configs/merchant-landing/index.html +++ b/configs/merchant-landing/index.html @@ -833,10 +833,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Landing v42 - · content - · aa3c23b + · content + · b01058c

diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index ee06829..f3ccae7 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -1,9 +1,9 @@ { "version": 42, - "updated_iso": "2026-07-17T13:31:56+02:00", + "updated_iso": "2026-07-17T13:31:57+02:00", "updated_human": "2026-07-17 13:31 CEST", - "commit": "aa3c23be49236ba5c20cd095bb02790f84aeb1e5", - "commit_short": "aa3c23b", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/aa3c23be49236ba5c20cd095bb02790f84aeb1e5", + "commit": "b01058c6c1f8bf25fa1ddcd1224c65fb12a1debd", + "commit_short": "b01058c", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/b01058c6c1f8bf25fa1ddcd1224c65fb12a1debd", "note": "Bump with scripts/taler-landing/stamp-landing-version.sh --bump after each landing content change." } From 005a1bd31b362aacf81ee0fa4ad09a8da7559498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 13:31:57 +0200 Subject: [PATCH 168/326] landing: point v42 footer at content commit 1c9978e --- configs/bank-landing/index.html | 6 +++--- configs/exchange-landing/index.html | 6 +++--- configs/merchant-landing/index.html | 6 +++--- configs/shared/landing-version.json | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index 8efca46..52c3955 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1556,10 +1556,10 @@ tw run-until-done && tw balance /privacy.

-

+

Landing v42 - · content - · aa3c23b + · content + · b01058c

diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index a79a3e9..44991b0 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -490,10 +490,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Landing v42 - · content - · aa3c23b + · content + · b01058c

diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html index ae66805..6797c55 100644 --- a/configs/merchant-landing/index.html +++ b/configs/merchant-landing/index.html @@ -833,10 +833,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Landing v42 - · content - · aa3c23b + · content + · b01058c

diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index ee06829..f3ccae7 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -1,9 +1,9 @@ { "version": 42, - "updated_iso": "2026-07-17T13:31:56+02:00", + "updated_iso": "2026-07-17T13:31:57+02:00", "updated_human": "2026-07-17 13:31 CEST", - "commit": "aa3c23be49236ba5c20cd095bb02790f84aeb1e5", - "commit_short": "aa3c23b", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/aa3c23be49236ba5c20cd095bb02790f84aeb1e5", + "commit": "b01058c6c1f8bf25fa1ddcd1224c65fb12a1debd", + "commit_short": "b01058c", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/b01058c6c1f8bf25fa1ddcd1224c65fb12a1debd", "note": "Bump with scripts/taler-landing/stamp-landing-version.sh --bump after each landing content change." } From 1bbdc2d1ae00d331d78258d7fc18e4ee5e79dd3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 13:32:12 +0200 Subject: [PATCH 169/326] doc/landing: version stamp workflow (v42+) --- scripts/taler-landing/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/taler-landing/README.md b/scripts/taler-landing/README.md index 4b79f7b..8e70120 100644 --- a/scripts/taler-landing/README.md +++ b/scripts/taler-landing/README.md @@ -146,3 +146,16 @@ podman exec taler-hacktivism-bank crontab -l # inspect curl -sS https://bank.hacktivism.ch/intro/stats.json | python3 -m json.tool | head ``` + +## Landing content version + +Footers show **Landing vN** + last content change + linkified git commit. + +- State: `configs/shared/landing-version.json` (start **v42**) +- After editing any `configs/*-landing/` content: + ```bash + ./scripts/taler-landing/stamp-landing-version.sh --bump + git add configs/ && git commit -m "landing: …" + ./scripts/taler-landing/stamp-landing-version.sh + git add configs/ && git commit --amend --no-edit # optional pin + ``` From 6f5edd67c01b2e681ff70274ab5344c6ad6025f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 13:32:12 +0200 Subject: [PATCH 170/326] doc/landing: version stamp workflow (v42+) --- scripts/taler-landing/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/taler-landing/README.md b/scripts/taler-landing/README.md index 4b79f7b..8e70120 100644 --- a/scripts/taler-landing/README.md +++ b/scripts/taler-landing/README.md @@ -146,3 +146,16 @@ podman exec taler-hacktivism-bank crontab -l # inspect curl -sS https://bank.hacktivism.ch/intro/stats.json | python3 -m json.tool | head ``` + +## Landing content version + +Footers show **Landing vN** + last content change + linkified git commit. + +- State: `configs/shared/landing-version.json` (start **v42**) +- After editing any `configs/*-landing/` content: + ```bash + ./scripts/taler-landing/stamp-landing-version.sh --bump + git add configs/ && git commit -m "landing: …" + ./scripts/taler-landing/stamp-landing-version.sh + git add configs/ && git commit --amend --no-edit # optional pin + ``` From bdfdbfe7c87ca870e4ca819f6f4b2a6291b3252c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 13:32:58 +0200 Subject: [PATCH 171/326] landing: per-site content versions (bank/exchange/merchant v42) Independent version counters per landing footer with last-change time and linkified commit; stamp script takes bank|exchange|merchant|--all. --- configs/bank-landing/index.html | 8 +- configs/exchange-landing/index.html | 8 +- configs/merchant-landing/index.html | 8 +- configs/shared/landing-version.json | 40 +++- scripts/taler-landing/README.md | 16 +- .../taler-landing/stamp-landing-version.sh | 182 +++++++++++------- 6 files changed, 170 insertions(+), 92 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index 52c3955..ba5857d 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1556,10 +1556,10 @@ tw run-until-done && tw balance /privacy.

-

- Landing v42 - · content - · b01058c +

+ Bank landing v42 + · content + · 2504315

diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index 44991b0..6b498ad 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -490,10 +490,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

- Landing v42 - · content - · b01058c +

+ Exchange landing v42 + · content + · 2504315

diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html index 6797c55..21dc6f5 100644 --- a/configs/merchant-landing/index.html +++ b/configs/merchant-landing/index.html @@ -833,10 +833,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

- Landing v42 - · content - · b01058c +

+ Merchant landing v42 + · content + · 2504315

diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index f3ccae7..4aec0ff 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -1,9 +1,35 @@ { - "version": 42, - "updated_iso": "2026-07-17T13:31:57+02:00", - "updated_human": "2026-07-17 13:31 CEST", - "commit": "b01058c6c1f8bf25fa1ddcd1224c65fb12a1debd", - "commit_short": "b01058c", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/b01058c6c1f8bf25fa1ddcd1224c65fb12a1debd", - "note": "Bump with scripts/taler-landing/stamp-landing-version.sh --bump after each landing content change." + "note": "Per-site landing content versions (start 42). Bump: scripts/taler-landing/stamp-landing-version.sh --bump bank|exchange|merchant", + "sites": { + "bank": { + "version": 42, + "label": "Bank landing", + "path": "configs/bank-landing", + "updated_iso": "2026-07-17T13:32:58+02:00", + "updated_human": "2026-07-17 13:32 CEST", + "commit": "250431590ede905cddf79f7390d2267578a5e758", + "commit_short": "2504315", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/250431590ede905cddf79f7390d2267578a5e758" + }, + "exchange": { + "version": 42, + "label": "Exchange landing", + "path": "configs/exchange-landing", + "updated_iso": "2026-07-17T13:32:58+02:00", + "updated_human": "2026-07-17 13:32 CEST", + "commit": "250431590ede905cddf79f7390d2267578a5e758", + "commit_short": "2504315", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/250431590ede905cddf79f7390d2267578a5e758" + }, + "merchant": { + "version": 42, + "label": "Merchant landing", + "path": "configs/merchant-landing", + "updated_iso": "2026-07-17T13:32:58+02:00", + "updated_human": "2026-07-17 13:32 CEST", + "commit": "250431590ede905cddf79f7390d2267578a5e758", + "commit_short": "2504315", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/250431590ede905cddf79f7390d2267578a5e758" + } + } } diff --git a/scripts/taler-landing/README.md b/scripts/taler-landing/README.md index 8e70120..f9e9d62 100644 --- a/scripts/taler-landing/README.md +++ b/scripts/taler-landing/README.md @@ -147,15 +147,17 @@ podman exec taler-hacktivism-bank crontab -l # inspect curl -sS https://bank.hacktivism.ch/intro/stats.json | python3 -m json.tool | head ``` -## Landing content version +## Landing content version (per site) -Footers show **Landing vN** + last content change + linkified git commit. +Each landing footer shows **Bank|Exchange|Merchant landing vN** + last content +change + linkified git commit. Versions are **independent** (start **v42** each). -- State: `configs/shared/landing-version.json` (start **v42**) -- After editing any `configs/*-landing/` content: +- State: `configs/shared/landing-version.json` → `sites.bank|exchange|merchant` +- After editing one site (example bank): ```bash - ./scripts/taler-landing/stamp-landing-version.sh --bump - git add configs/ && git commit -m "landing: …" - ./scripts/taler-landing/stamp-landing-version.sh + ./scripts/taler-landing/stamp-landing-version.sh --bump bank + git add configs/ && git commit -m "landing(bank): …" + ./scripts/taler-landing/stamp-landing-version.sh bank git add configs/ && git commit --amend --no-edit # optional pin ``` +- Re-stamp all without bump: `./scripts/taler-landing/stamp-landing-version.sh --all` diff --git a/scripts/taler-landing/stamp-landing-version.sh b/scripts/taler-landing/stamp-landing-version.sh index 570636c..9616dec 100755 --- a/scripts/taler-landing/stamp-landing-version.sh +++ b/scripts/taler-landing/stamp-landing-version.sh @@ -1,57 +1,83 @@ #!/usr/bin/env bash -# Stamp landing footers with version + last content change + linkified commit. +# Stamp landing footers per site: version + last content change + linkified commit. +# +# Sites: bank | exchange | merchant # # Usage (from koopa-admin-log root): -# ./scripts/taler-landing/stamp-landing-version.sh # re-stamp with HEAD + current version -# ./scripts/taler-landing/stamp-landing-version.sh --bump # version += 1, then stamp HEAD -# ./scripts/taler-landing/stamp-landing-version.sh --set 42 # force version +# ./scripts/taler-landing/stamp-landing-version.sh bank +# re-stamp bank with current version + HEAD +# ./scripts/taler-landing/stamp-landing-version.sh --bump bank +# bank version += 1, then stamp HEAD +# ./scripts/taler-landing/stamp-landing-version.sh --set 42 exchange +# force exchange version to 42 +# ./scripts/taler-landing/stamp-landing-version.sh --all +# re-stamp all sites (no bump) +# ./scripts/taler-landing/stamp-landing-version.sh --bump --all +# bump every site by 1 (rare) # -# Typical flow after editing landing HTML/CSS/JS: -# 1) edit landings -# 2) ./scripts/taler-landing/stamp-landing-version.sh --bump -# 3) git add configs/ && git commit -# 4) ./scripts/taler-landing/stamp-landing-version.sh && git add configs/ && git commit --amend --no-edit -# -# Or one-shot (commits for you if GIT_STAMP_COMMIT=1). +# Typical flow after editing one site: +# 1) edit configs/bank-landing/… +# 2) ./scripts/taler-landing/stamp-landing-version.sh --bump bank +# 3) git add configs/ && git commit -m "landing(bank): …" +# 4) ./scripts/taler-landing/stamp-landing-version.sh bank +# git add configs/ && git commit --amend --no-edit # optional pin set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" VER_FILE="$ROOT/configs/shared/landing-version.json" REPO_WEB="${LANDING_REPO_WEB:-https://git.hacktivism.ch/hernani/koopa-admin-log}" -LANDINGS=( - "$ROOT/configs/bank-landing/index.html" - "$ROOT/configs/exchange-landing/index.html" - "$ROOT/configs/merchant-landing/index.html" + +declare -A SITE_HTML=( + [bank]="$ROOT/configs/bank-landing/index.html" + [exchange]="$ROOT/configs/exchange-landing/index.html" + [merchant]="$ROOT/configs/merchant-landing/index.html" +) +declare -A SITE_LABEL=( + [bank]="Bank landing" + [exchange]="Exchange landing" + [merchant]="Merchant landing" +) +declare -A SITE_PATH=( + [bank]="configs/bank-landing" + [exchange]="configs/exchange-landing" + [merchant]="configs/merchant-landing" ) export TZ="${TZ:-Europe/Zurich}" bump=0 set_ver="" +do_all=0 +sites=() + while [ $# -gt 0 ]; do case "$1" in --bump) bump=1; shift ;; --set) set_ver="${2:-}"; shift 2 ;; + --all) do_all=1; shift ;; + bank|exchange|merchant) sites+=("$1"); shift ;; -h|--help) - sed -n '2,20p' "$0" + sed -n '2,28p' "$0" exit 0 ;; - *) echo "unknown arg: $1" >&2; exit 2 ;; + *) echo "unknown arg: $1 (sites: bank exchange merchant)" >&2; exit 2 ;; esac done +if [ "$do_all" = 1 ]; then + sites=(bank exchange merchant) +fi +if [ "${#sites[@]}" -eq 0 ]; then + echo "usage: $0 [--bump|--set N] bank|exchange|merchant|--all" >&2 + exit 2 +fi + if [ ! -f "$VER_FILE" ]; then - echo '{"version":42,"updated_iso":"","updated_human":"","commit":"","commit_short":""}' >"$VER_FILE" + cat >"$VER_FILE" <<'EOF' +{"note":"per-site","sites":{"bank":{"version":42},"exchange":{"version":42},"merchant":{"version":42}}} +EOF fi -version=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version",42))' "$VER_FILE") -if [ -n "$set_ver" ]; then - version="$set_ver" -elif [ "$bump" = 1 ]; then - version=$((version + 1)) -fi - -# Prefer staged/index commit identity after commit; allow OVERRIDE_COMMIT if [ -n "${OVERRIDE_COMMIT:-}" ]; then full="$OVERRIDE_COMMIT" else @@ -62,34 +88,6 @@ iso=$(date +%Y-%m-%dT%H:%M:%S%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/') human=$(date +"%Y-%m-%d %H:%M %Z") url="${REPO_WEB}/commit/${full}" -python3 - "$VER_FILE" "$version" "$iso" "$human" "$full" "$short" "$url" <<'PY' -import json, sys -path, version, iso, human, full, short, url = sys.argv[1:] -data = { - "version": int(version), - "updated_iso": iso, - "updated_human": human, - "commit": full, - "commit_short": short, - "repo_commit_url": url, - "note": "Bump with scripts/taler-landing/stamp-landing-version.sh --bump after each landing content change.", -} -open(path, "w", encoding="utf-8").write(json.dumps(data, indent=2, ensure_ascii=False) + "\n") -print(f"version={version} commit={short} at={human}") -PY - -# HTML block injected into each footer (idempotent via markers) -block=$(cat < -

- Landing v${version} - · content - · ${short} -

- -EOF -) - css_snip=' .landing-rev { margin: 0.75rem 0 0; font-size: 0.72rem; @@ -101,27 +99,80 @@ css_snip=' .landing-rev { .landing-rev strong { font-weight: 750; } ' -for f in "${LANDINGS[@]}"; do - [ -f "$f" ] || { echo "missing $f" >&2; exit 1; } +for site in "${sites[@]}"; do + html="${SITE_HTML[$site]:-}" + label="${SITE_LABEL[$site]:-}" + relpath="${SITE_PATH[$site]:-}" + [ -n "$html" ] && [ -f "$html" ] || { echo "missing site/html: $site" >&2; exit 1; } + + # resolve version for this site + version=$(python3 - "$VER_FILE" "$site" "$bump" "$set_ver" <<'PY' +import json, sys +path, site, bump, set_ver = sys.argv[1:5] +data = json.load(open(path, encoding="utf-8")) +sites = data.setdefault("sites", {}) +entry = sites.setdefault(site, {"version": 42}) +v = int(entry.get("version", 42)) +if set_ver: + v = int(set_ver) +elif bump == "1": + v += 1 +print(v) +PY +) + + # update JSON entry for this site (preserve others) + python3 - "$VER_FILE" "$site" "$version" "$label" "$relpath" "$iso" "$human" "$full" "$short" "$url" <<'PY' +import json, sys +path, site, version, label, relpath, iso, human, full, short, url = sys.argv[1:] +data = json.load(open(path, encoding="utf-8")) +data["note"] = ( + "Per-site landing content versions (start 42). " + "Bump: scripts/taler-landing/stamp-landing-version.sh --bump bank|exchange|merchant" +) +sites = data.setdefault("sites", {}) +sites[site] = { + "version": int(version), + "label": label, + "path": relpath, + "updated_iso": iso, + "updated_human": human, + "commit": full, + "commit_short": short, + "repo_commit_url": url, +} +open(path, "w", encoding="utf-8").write(json.dumps(data, indent=2, ensure_ascii=False) + "\n") +print(f"{site}: v{version} commit={short} at={human}") +PY + + block=$(cat < +

+ ${label} v${version} + · content + · ${short} +

+ +EOF +) + # ensure CSS once - if ! grep -q '\.landing-rev' "$f"; then - # insert before of first style block - python3 - "$f" "$css_snip" <<'PY' + if ! grep -q '\.landing-rev' "$html"; then + python3 - "$html" "$css_snip" <<'PY' import sys path, snip = sys.argv[1], sys.argv[2] t = open(path, encoding="utf-8").read() if ".landing-rev" in t: - sys.exit(0) + raise SystemExit(0) idx = t.find("") if idx < 0: raise SystemExit(f"no in {path}") -t = t[:idx] + snip + "\n" + t[idx:] -open(path, "w", encoding="utf-8").write(t) +open(path, "w", encoding="utf-8").write(t[:idx] + snip + "\n" + t[idx:]) print(f"css → {path}") PY fi - # replace or insert rev block before - python3 - "$f" "$block" <<'PY' + + python3 - "$html" "$block" <<'PY' import sys, re path, block = sys.argv[1], sys.argv[2] t = open(path, encoding="utf-8").read() @@ -130,7 +181,6 @@ pat = re.compile(r"[ \t]*.*?\n?", re.S) if pat.search(t): t = pat.sub(block, t, count=1) else: - # before closing footer t2, n = re.subn(r"(\n)([ \t]*)", r"\1" + block + r"\2", t, count=1) if n != 1: raise SystemExit(f"could not insert landing-rev in {path}") @@ -140,4 +190,4 @@ print(f"stamp → {path}") PY done -echo "OK landing v${version} → ${short} (${human})" +echo "OK stamped: ${sites[*]}" From 258a10d49594822860b92e85514d2404f23bcf90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 13:32:58 +0200 Subject: [PATCH 172/326] landing: per-site content versions (bank/exchange/merchant v42) Independent version counters per landing footer with last-change time and linkified commit; stamp script takes bank|exchange|merchant|--all. --- configs/bank-landing/index.html | 8 +- configs/exchange-landing/index.html | 8 +- configs/merchant-landing/index.html | 8 +- configs/shared/landing-version.json | 40 +++- scripts/taler-landing/README.md | 16 +- .../taler-landing/stamp-landing-version.sh | 182 +++++++++++------- 6 files changed, 170 insertions(+), 92 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index 52c3955..ba5857d 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1556,10 +1556,10 @@ tw run-until-done && tw balance /privacy.

-

- Landing v42 - · content - · b01058c +

+ Bank landing v42 + · content + · 2504315

diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index 44991b0..6b498ad 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -490,10 +490,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

- Landing v42 - · content - · b01058c +

+ Exchange landing v42 + · content + · 2504315

diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html index 6797c55..21dc6f5 100644 --- a/configs/merchant-landing/index.html +++ b/configs/merchant-landing/index.html @@ -833,10 +833,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

- Landing v42 - · content - · b01058c +

+ Merchant landing v42 + · content + · 2504315

diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index f3ccae7..4aec0ff 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -1,9 +1,35 @@ { - "version": 42, - "updated_iso": "2026-07-17T13:31:57+02:00", - "updated_human": "2026-07-17 13:31 CEST", - "commit": "b01058c6c1f8bf25fa1ddcd1224c65fb12a1debd", - "commit_short": "b01058c", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/b01058c6c1f8bf25fa1ddcd1224c65fb12a1debd", - "note": "Bump with scripts/taler-landing/stamp-landing-version.sh --bump after each landing content change." + "note": "Per-site landing content versions (start 42). Bump: scripts/taler-landing/stamp-landing-version.sh --bump bank|exchange|merchant", + "sites": { + "bank": { + "version": 42, + "label": "Bank landing", + "path": "configs/bank-landing", + "updated_iso": "2026-07-17T13:32:58+02:00", + "updated_human": "2026-07-17 13:32 CEST", + "commit": "250431590ede905cddf79f7390d2267578a5e758", + "commit_short": "2504315", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/250431590ede905cddf79f7390d2267578a5e758" + }, + "exchange": { + "version": 42, + "label": "Exchange landing", + "path": "configs/exchange-landing", + "updated_iso": "2026-07-17T13:32:58+02:00", + "updated_human": "2026-07-17 13:32 CEST", + "commit": "250431590ede905cddf79f7390d2267578a5e758", + "commit_short": "2504315", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/250431590ede905cddf79f7390d2267578a5e758" + }, + "merchant": { + "version": 42, + "label": "Merchant landing", + "path": "configs/merchant-landing", + "updated_iso": "2026-07-17T13:32:58+02:00", + "updated_human": "2026-07-17 13:32 CEST", + "commit": "250431590ede905cddf79f7390d2267578a5e758", + "commit_short": "2504315", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/250431590ede905cddf79f7390d2267578a5e758" + } + } } diff --git a/scripts/taler-landing/README.md b/scripts/taler-landing/README.md index 8e70120..f9e9d62 100644 --- a/scripts/taler-landing/README.md +++ b/scripts/taler-landing/README.md @@ -147,15 +147,17 @@ podman exec taler-hacktivism-bank crontab -l # inspect curl -sS https://bank.hacktivism.ch/intro/stats.json | python3 -m json.tool | head ``` -## Landing content version +## Landing content version (per site) -Footers show **Landing vN** + last content change + linkified git commit. +Each landing footer shows **Bank|Exchange|Merchant landing vN** + last content +change + linkified git commit. Versions are **independent** (start **v42** each). -- State: `configs/shared/landing-version.json` (start **v42**) -- After editing any `configs/*-landing/` content: +- State: `configs/shared/landing-version.json` → `sites.bank|exchange|merchant` +- After editing one site (example bank): ```bash - ./scripts/taler-landing/stamp-landing-version.sh --bump - git add configs/ && git commit -m "landing: …" - ./scripts/taler-landing/stamp-landing-version.sh + ./scripts/taler-landing/stamp-landing-version.sh --bump bank + git add configs/ && git commit -m "landing(bank): …" + ./scripts/taler-landing/stamp-landing-version.sh bank git add configs/ && git commit --amend --no-edit # optional pin ``` +- Re-stamp all without bump: `./scripts/taler-landing/stamp-landing-version.sh --all` diff --git a/scripts/taler-landing/stamp-landing-version.sh b/scripts/taler-landing/stamp-landing-version.sh index 570636c..9616dec 100755 --- a/scripts/taler-landing/stamp-landing-version.sh +++ b/scripts/taler-landing/stamp-landing-version.sh @@ -1,57 +1,83 @@ #!/usr/bin/env bash -# Stamp landing footers with version + last content change + linkified commit. +# Stamp landing footers per site: version + last content change + linkified commit. +# +# Sites: bank | exchange | merchant # # Usage (from koopa-admin-log root): -# ./scripts/taler-landing/stamp-landing-version.sh # re-stamp with HEAD + current version -# ./scripts/taler-landing/stamp-landing-version.sh --bump # version += 1, then stamp HEAD -# ./scripts/taler-landing/stamp-landing-version.sh --set 42 # force version +# ./scripts/taler-landing/stamp-landing-version.sh bank +# re-stamp bank with current version + HEAD +# ./scripts/taler-landing/stamp-landing-version.sh --bump bank +# bank version += 1, then stamp HEAD +# ./scripts/taler-landing/stamp-landing-version.sh --set 42 exchange +# force exchange version to 42 +# ./scripts/taler-landing/stamp-landing-version.sh --all +# re-stamp all sites (no bump) +# ./scripts/taler-landing/stamp-landing-version.sh --bump --all +# bump every site by 1 (rare) # -# Typical flow after editing landing HTML/CSS/JS: -# 1) edit landings -# 2) ./scripts/taler-landing/stamp-landing-version.sh --bump -# 3) git add configs/ && git commit -# 4) ./scripts/taler-landing/stamp-landing-version.sh && git add configs/ && git commit --amend --no-edit -# -# Or one-shot (commits for you if GIT_STAMP_COMMIT=1). +# Typical flow after editing one site: +# 1) edit configs/bank-landing/… +# 2) ./scripts/taler-landing/stamp-landing-version.sh --bump bank +# 3) git add configs/ && git commit -m "landing(bank): …" +# 4) ./scripts/taler-landing/stamp-landing-version.sh bank +# git add configs/ && git commit --amend --no-edit # optional pin set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" VER_FILE="$ROOT/configs/shared/landing-version.json" REPO_WEB="${LANDING_REPO_WEB:-https://git.hacktivism.ch/hernani/koopa-admin-log}" -LANDINGS=( - "$ROOT/configs/bank-landing/index.html" - "$ROOT/configs/exchange-landing/index.html" - "$ROOT/configs/merchant-landing/index.html" + +declare -A SITE_HTML=( + [bank]="$ROOT/configs/bank-landing/index.html" + [exchange]="$ROOT/configs/exchange-landing/index.html" + [merchant]="$ROOT/configs/merchant-landing/index.html" +) +declare -A SITE_LABEL=( + [bank]="Bank landing" + [exchange]="Exchange landing" + [merchant]="Merchant landing" +) +declare -A SITE_PATH=( + [bank]="configs/bank-landing" + [exchange]="configs/exchange-landing" + [merchant]="configs/merchant-landing" ) export TZ="${TZ:-Europe/Zurich}" bump=0 set_ver="" +do_all=0 +sites=() + while [ $# -gt 0 ]; do case "$1" in --bump) bump=1; shift ;; --set) set_ver="${2:-}"; shift 2 ;; + --all) do_all=1; shift ;; + bank|exchange|merchant) sites+=("$1"); shift ;; -h|--help) - sed -n '2,20p' "$0" + sed -n '2,28p' "$0" exit 0 ;; - *) echo "unknown arg: $1" >&2; exit 2 ;; + *) echo "unknown arg: $1 (sites: bank exchange merchant)" >&2; exit 2 ;; esac done +if [ "$do_all" = 1 ]; then + sites=(bank exchange merchant) +fi +if [ "${#sites[@]}" -eq 0 ]; then + echo "usage: $0 [--bump|--set N] bank|exchange|merchant|--all" >&2 + exit 2 +fi + if [ ! -f "$VER_FILE" ]; then - echo '{"version":42,"updated_iso":"","updated_human":"","commit":"","commit_short":""}' >"$VER_FILE" + cat >"$VER_FILE" <<'EOF' +{"note":"per-site","sites":{"bank":{"version":42},"exchange":{"version":42},"merchant":{"version":42}}} +EOF fi -version=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version",42))' "$VER_FILE") -if [ -n "$set_ver" ]; then - version="$set_ver" -elif [ "$bump" = 1 ]; then - version=$((version + 1)) -fi - -# Prefer staged/index commit identity after commit; allow OVERRIDE_COMMIT if [ -n "${OVERRIDE_COMMIT:-}" ]; then full="$OVERRIDE_COMMIT" else @@ -62,34 +88,6 @@ iso=$(date +%Y-%m-%dT%H:%M:%S%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/') human=$(date +"%Y-%m-%d %H:%M %Z") url="${REPO_WEB}/commit/${full}" -python3 - "$VER_FILE" "$version" "$iso" "$human" "$full" "$short" "$url" <<'PY' -import json, sys -path, version, iso, human, full, short, url = sys.argv[1:] -data = { - "version": int(version), - "updated_iso": iso, - "updated_human": human, - "commit": full, - "commit_short": short, - "repo_commit_url": url, - "note": "Bump with scripts/taler-landing/stamp-landing-version.sh --bump after each landing content change.", -} -open(path, "w", encoding="utf-8").write(json.dumps(data, indent=2, ensure_ascii=False) + "\n") -print(f"version={version} commit={short} at={human}") -PY - -# HTML block injected into each footer (idempotent via markers) -block=$(cat < -

- Landing v${version} - · content - · ${short} -

- -EOF -) - css_snip=' .landing-rev { margin: 0.75rem 0 0; font-size: 0.72rem; @@ -101,27 +99,80 @@ css_snip=' .landing-rev { .landing-rev strong { font-weight: 750; } ' -for f in "${LANDINGS[@]}"; do - [ -f "$f" ] || { echo "missing $f" >&2; exit 1; } +for site in "${sites[@]}"; do + html="${SITE_HTML[$site]:-}" + label="${SITE_LABEL[$site]:-}" + relpath="${SITE_PATH[$site]:-}" + [ -n "$html" ] && [ -f "$html" ] || { echo "missing site/html: $site" >&2; exit 1; } + + # resolve version for this site + version=$(python3 - "$VER_FILE" "$site" "$bump" "$set_ver" <<'PY' +import json, sys +path, site, bump, set_ver = sys.argv[1:5] +data = json.load(open(path, encoding="utf-8")) +sites = data.setdefault("sites", {}) +entry = sites.setdefault(site, {"version": 42}) +v = int(entry.get("version", 42)) +if set_ver: + v = int(set_ver) +elif bump == "1": + v += 1 +print(v) +PY +) + + # update JSON entry for this site (preserve others) + python3 - "$VER_FILE" "$site" "$version" "$label" "$relpath" "$iso" "$human" "$full" "$short" "$url" <<'PY' +import json, sys +path, site, version, label, relpath, iso, human, full, short, url = sys.argv[1:] +data = json.load(open(path, encoding="utf-8")) +data["note"] = ( + "Per-site landing content versions (start 42). " + "Bump: scripts/taler-landing/stamp-landing-version.sh --bump bank|exchange|merchant" +) +sites = data.setdefault("sites", {}) +sites[site] = { + "version": int(version), + "label": label, + "path": relpath, + "updated_iso": iso, + "updated_human": human, + "commit": full, + "commit_short": short, + "repo_commit_url": url, +} +open(path, "w", encoding="utf-8").write(json.dumps(data, indent=2, ensure_ascii=False) + "\n") +print(f"{site}: v{version} commit={short} at={human}") +PY + + block=$(cat < +

+ ${label} v${version} + · content + · ${short} +

+ +EOF +) + # ensure CSS once - if ! grep -q '\.landing-rev' "$f"; then - # insert before of first style block - python3 - "$f" "$css_snip" <<'PY' + if ! grep -q '\.landing-rev' "$html"; then + python3 - "$html" "$css_snip" <<'PY' import sys path, snip = sys.argv[1], sys.argv[2] t = open(path, encoding="utf-8").read() if ".landing-rev" in t: - sys.exit(0) + raise SystemExit(0) idx = t.find("") if idx < 0: raise SystemExit(f"no in {path}") -t = t[:idx] + snip + "\n" + t[idx:] -open(path, "w", encoding="utf-8").write(t) +open(path, "w", encoding="utf-8").write(t[:idx] + snip + "\n" + t[idx:]) print(f"css → {path}") PY fi - # replace or insert rev block before - python3 - "$f" "$block" <<'PY' + + python3 - "$html" "$block" <<'PY' import sys, re path, block = sys.argv[1], sys.argv[2] t = open(path, encoding="utf-8").read() @@ -130,7 +181,6 @@ pat = re.compile(r"[ \t]*.*?\n?", re.S) if pat.search(t): t = pat.sub(block, t, count=1) else: - # before closing footer t2, n = re.subn(r"(\n)([ \t]*)", r"\1" + block + r"\2", t, count=1) if n != 1: raise SystemExit(f"could not insert landing-rev in {path}") @@ -140,4 +190,4 @@ print(f"stamp → {path}") PY done -echo "OK landing v${version} → ${short} (${human})" +echo "OK stamped: ${sites[*]}" From 186fdc255c2ff61c07647e5cfa1ff701f1776d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 13:32:59 +0200 Subject: [PATCH 173/326] landing: pin per-site v42 footers to commit --- configs/bank-landing/index.html | 6 +++--- configs/exchange-landing/index.html | 6 +++--- configs/merchant-landing/index.html | 6 +++--- configs/shared/landing-version.json | 24 ++++++++++++------------ 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index ba5857d..3f31226 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1556,10 +1556,10 @@ tw run-until-done && tw balance /privacy.

-

+

Bank landing v42 - · content - · 2504315 + · content + · 921a4f1

diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index 6b498ad..0101601 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -490,10 +490,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Exchange landing v42 - · content - · 2504315 + · content + · 921a4f1

diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html index 21dc6f5..96ab5eb 100644 --- a/configs/merchant-landing/index.html +++ b/configs/merchant-landing/index.html @@ -833,10 +833,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Merchant landing v42 - · content - · 2504315 + · content + · 921a4f1

diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index 4aec0ff..7d1de54 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -5,31 +5,31 @@ "version": 42, "label": "Bank landing", "path": "configs/bank-landing", - "updated_iso": "2026-07-17T13:32:58+02:00", + "updated_iso": "2026-07-17T13:32:59+02:00", "updated_human": "2026-07-17 13:32 CEST", - "commit": "250431590ede905cddf79f7390d2267578a5e758", - "commit_short": "2504315", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/250431590ede905cddf79f7390d2267578a5e758" + "commit": "921a4f1572dbf4391b69899287b4c0280bb0e271", + "commit_short": "921a4f1", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/921a4f1572dbf4391b69899287b4c0280bb0e271" }, "exchange": { "version": 42, "label": "Exchange landing", "path": "configs/exchange-landing", - "updated_iso": "2026-07-17T13:32:58+02:00", + "updated_iso": "2026-07-17T13:32:59+02:00", "updated_human": "2026-07-17 13:32 CEST", - "commit": "250431590ede905cddf79f7390d2267578a5e758", - "commit_short": "2504315", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/250431590ede905cddf79f7390d2267578a5e758" + "commit": "921a4f1572dbf4391b69899287b4c0280bb0e271", + "commit_short": "921a4f1", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/921a4f1572dbf4391b69899287b4c0280bb0e271" }, "merchant": { "version": 42, "label": "Merchant landing", "path": "configs/merchant-landing", - "updated_iso": "2026-07-17T13:32:58+02:00", + "updated_iso": "2026-07-17T13:32:59+02:00", "updated_human": "2026-07-17 13:32 CEST", - "commit": "250431590ede905cddf79f7390d2267578a5e758", - "commit_short": "2504315", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/250431590ede905cddf79f7390d2267578a5e758" + "commit": "921a4f1572dbf4391b69899287b4c0280bb0e271", + "commit_short": "921a4f1", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/921a4f1572dbf4391b69899287b4c0280bb0e271" } } } From 2594b62297da587b79ef801a55b88d527d8216cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 13:32:59 +0200 Subject: [PATCH 174/326] landing: pin per-site v42 footers to commit --- configs/bank-landing/index.html | 6 +++--- configs/exchange-landing/index.html | 6 +++--- configs/merchant-landing/index.html | 6 +++--- configs/shared/landing-version.json | 24 ++++++++++++------------ 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index ba5857d..3f31226 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1556,10 +1556,10 @@ tw run-until-done && tw balance /privacy.

-

+

Bank landing v42 - · content - · 2504315 + · content + · 921a4f1

diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index 6b498ad..0101601 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -490,10 +490,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Exchange landing v42 - · content - · 2504315 + · content + · 921a4f1

diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html index 21dc6f5..96ab5eb 100644 --- a/configs/merchant-landing/index.html +++ b/configs/merchant-landing/index.html @@ -833,10 +833,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Merchant landing v42 - · content - · 2504315 + · content + · 921a4f1

diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index 4aec0ff..7d1de54 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -5,31 +5,31 @@ "version": 42, "label": "Bank landing", "path": "configs/bank-landing", - "updated_iso": "2026-07-17T13:32:58+02:00", + "updated_iso": "2026-07-17T13:32:59+02:00", "updated_human": "2026-07-17 13:32 CEST", - "commit": "250431590ede905cddf79f7390d2267578a5e758", - "commit_short": "2504315", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/250431590ede905cddf79f7390d2267578a5e758" + "commit": "921a4f1572dbf4391b69899287b4c0280bb0e271", + "commit_short": "921a4f1", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/921a4f1572dbf4391b69899287b4c0280bb0e271" }, "exchange": { "version": 42, "label": "Exchange landing", "path": "configs/exchange-landing", - "updated_iso": "2026-07-17T13:32:58+02:00", + "updated_iso": "2026-07-17T13:32:59+02:00", "updated_human": "2026-07-17 13:32 CEST", - "commit": "250431590ede905cddf79f7390d2267578a5e758", - "commit_short": "2504315", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/250431590ede905cddf79f7390d2267578a5e758" + "commit": "921a4f1572dbf4391b69899287b4c0280bb0e271", + "commit_short": "921a4f1", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/921a4f1572dbf4391b69899287b4c0280bb0e271" }, "merchant": { "version": 42, "label": "Merchant landing", "path": "configs/merchant-landing", - "updated_iso": "2026-07-17T13:32:58+02:00", + "updated_iso": "2026-07-17T13:32:59+02:00", "updated_human": "2026-07-17 13:32 CEST", - "commit": "250431590ede905cddf79f7390d2267578a5e758", - "commit_short": "2504315", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/250431590ede905cddf79f7390d2267578a5e758" + "commit": "921a4f1572dbf4391b69899287b4c0280bb0e271", + "commit_short": "921a4f1", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/921a4f1572dbf4391b69899287b4c0280bb0e271" } } } From 4685e370ac662138f3c9a425af6ba54396bb7830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:13:39 +0200 Subject: [PATCH 175/326] docs: I-08 landing-stats standalone migration note --- ISSUES.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/ISSUES.md b/ISSUES.md index 33f4f2a..4e89ea6 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -1,6 +1,6 @@ # Open issues -Stand **2026-07-16** (koopa, live). Keine Secrets. +Stand **2026-07-17** (koopa, live). Keine Secrets. ## Index @@ -13,6 +13,7 @@ Stand **2026-07-16** (koopa, live). Keine Secrets. | [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-08](#i-2026-07-17-08) | medium | open | Landing-stats → standalone `taler-landing-stats` | --- @@ -131,6 +132,24 @@ podman ps --filter name=koopa-tor-relay --- +### I-2026-07-17-08 + +**Landing-stats / Resource-Monitoring: auf standalone `taler-landing-stats` umstellen** +Severity: medium · Host: koopa · Status: open · **TODO (noch nicht umsetzen auf koopa)** + +**Ist:** Collector + Timer leben unter `scripts/taler-landing/` in **diesem** admin-log, installiert nach `~/.local/bin` / user-systemd, Defaults stark **hacktivism.ch**-lastig (Container-Namen, URLs, `ADMIN_LOG`-Pfade). + +**Soll:** Dieselbe **moderne, host-dynamische** Lösung wie geplant/gebaut für andere Stacks: + +- Eigenes Repo **`taler-landing-stats`** auf **git.hacktivism.ch** (`hernani/taler-landing-stats`) +- Profile/env pro Host (bank/exchange/merchant Container, Landing-Pfade, optional bank full-scan) +- User-systemd timer (`taler-landing-stats.service` / `.timer`), Linger +- Resource-Merge (RSS/loadavg via in-container mem-snapshot + podman stats) host-agnostisch + +**Nicht jetzt auf koopa umbauen** — nur merken. Wenn das Repo steht und auf stage/etc. erprobt ist: koopa von `scripts/taler-landing/` auf Checkout+Install aus `taler-landing-stats` migrieren; hier ggf. nur noch Pointer/README. + +--- + ## Kein Issue (Scan) - Prod-Container mit Boot-Autostart: Up From 084962285ded4de0fd918b1f44190b80957ef3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:13:39 +0200 Subject: [PATCH 176/326] docs: I-08 landing-stats standalone migration note --- ISSUES.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/ISSUES.md b/ISSUES.md index 33f4f2a..4e89ea6 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -1,6 +1,6 @@ # Open issues -Stand **2026-07-16** (koopa, live). Keine Secrets. +Stand **2026-07-17** (koopa, live). Keine Secrets. ## Index @@ -13,6 +13,7 @@ Stand **2026-07-16** (koopa, live). Keine Secrets. | [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-08](#i-2026-07-17-08) | medium | open | Landing-stats → standalone `taler-landing-stats` | --- @@ -131,6 +132,24 @@ podman ps --filter name=koopa-tor-relay --- +### I-2026-07-17-08 + +**Landing-stats / Resource-Monitoring: auf standalone `taler-landing-stats` umstellen** +Severity: medium · Host: koopa · Status: open · **TODO (noch nicht umsetzen auf koopa)** + +**Ist:** Collector + Timer leben unter `scripts/taler-landing/` in **diesem** admin-log, installiert nach `~/.local/bin` / user-systemd, Defaults stark **hacktivism.ch**-lastig (Container-Namen, URLs, `ADMIN_LOG`-Pfade). + +**Soll:** Dieselbe **moderne, host-dynamische** Lösung wie geplant/gebaut für andere Stacks: + +- Eigenes Repo **`taler-landing-stats`** auf **git.hacktivism.ch** (`hernani/taler-landing-stats`) +- Profile/env pro Host (bank/exchange/merchant Container, Landing-Pfade, optional bank full-scan) +- User-systemd timer (`taler-landing-stats.service` / `.timer`), Linger +- Resource-Merge (RSS/loadavg via in-container mem-snapshot + podman stats) host-agnostisch + +**Nicht jetzt auf koopa umbauen** — nur merken. Wenn das Repo steht und auf stage/etc. erprobt ist: koopa von `scripts/taler-landing/` auf Checkout+Install aus `taler-landing-stats` migrieren; hier ggf. nur noch Pointer/README. + +--- + ## Kein Issue (Scan) - Prod-Container mit Boot-Autostart: Up From a698ee874aa418c55cc5639d71d77b43d9240e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:13:54 +0200 Subject: [PATCH 177/326] docs: local/* branch sync notes from home clone --- ops/local-branches-sync.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 ops/local-branches-sync.md diff --git a/ops/local-branches-sync.md b/ops/local-branches-sync.md new file mode 100644 index 0000000..0d8314b --- /dev/null +++ b/ops/local-branches-sync.md @@ -0,0 +1,19 @@ +# Local-only copy — /home/hernani/koopa-admin-log +# +# main → Forgejo origin (ok to push) +# local/* → NEVER push to origin; sync only via home/bundle between kisten +# +# Branches: +# local/zusatz-docs ab06ffe (daylog Zusatzübersetzungen) +# local/zusatz 3b67613 (scrubbed tip) +# local/secret-ui-scrub 3b67613 +# +# Sync local/* from this host to other: +# git bundle create /tmp/koopa-local.bundle local/zusatz local/zusatz-docs local/secret-ui-scrub +# scp /tmp/koopa-local.bundle other:/tmp/ +# # on other: +# cd /home/hernani/koopa-admin-log && git fetch /tmp/koopa-local.bundle "+refs/heads/local/*:refs/heads/local/*" +# +# Pull main: +# git fetch origin && git checkout main && git merge --ff-only origin/main + From afd244bc80373fcb23681dd5d7e2e5d2b4da4a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:13:54 +0200 Subject: [PATCH 178/326] docs: local/* branch sync notes from home clone --- ops/local-branches-sync.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 ops/local-branches-sync.md diff --git a/ops/local-branches-sync.md b/ops/local-branches-sync.md new file mode 100644 index 0000000..0d8314b --- /dev/null +++ b/ops/local-branches-sync.md @@ -0,0 +1,19 @@ +# Local-only copy — /home/hernani/koopa-admin-log +# +# main → Forgejo origin (ok to push) +# local/* → NEVER push to origin; sync only via home/bundle between kisten +# +# Branches: +# local/zusatz-docs ab06ffe (daylog Zusatzübersetzungen) +# local/zusatz 3b67613 (scrubbed tip) +# local/secret-ui-scrub 3b67613 +# +# Sync local/* from this host to other: +# git bundle create /tmp/koopa-local.bundle local/zusatz local/zusatz-docs local/secret-ui-scrub +# scp /tmp/koopa-local.bundle other:/tmp/ +# # on other: +# cd /home/hernani/koopa-admin-log && git fetch /tmp/koopa-local.bundle "+refs/heads/local/*:refs/heads/local/*" +# +# Pull main: +# git fetch origin && git checkout main && git merge --ff-only origin/main + From 76bc95f9a965a30ae1e55d10cac4e793b24a7329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:14:48 +0200 Subject: [PATCH 179/326] docs: uncommitted work was in second koopa-admin-log clone --- ops/CLONE-MERGE-2026-07-17.md | 60 +++++++++++++++++++++++++++++++++++ ops/local-branches-sync.md | 6 ++++ 2 files changed, 66 insertions(+) create mode 100644 ops/CLONE-MERGE-2026-07-17.md diff --git a/ops/CLONE-MERGE-2026-07-17.md b/ops/CLONE-MERGE-2026-07-17.md new file mode 100644 index 0000000..01483d1 --- /dev/null +++ b/ops/CLONE-MERGE-2026-07-17.md @@ -0,0 +1,60 @@ +# Clone merge 2026-07-17 — uncommitted work lived in *another* directory + +## Problem + +Two **different working trees** pointed at the same remote +`https://git.hacktivism.ch/hernani/koopa-admin-log.git`: + +| Path | Role | What was wrong | +|------|------|----------------| +| **`~/taler/src/koopa-admin-log`** | Workspace / agent edits | On `origin/main` (monitoring: badges, `www.exchange-NN`, SECRETS_ROOT fix, stage profiles, …) | +| **`~/koopa-admin-log`** | Where e2e was run | **Older** `main` tip + **6 unpushed landing commits** + **large uncommitted** diffs under `scripts/taler-monitoring/` | + +That is easy to miss: same remote name, different folders. Edits and “uncommitted” +state were **not** always in the tree being pushed from `taler/src`. + +## What was recovered / merged + +Into **canonical** `~/taler/src/koopa-admin-log` → pushed to `origin/main`: + +1. **6 commits only on the home clone** (landing v42 stamps, kGOA stats, …) — cherry-picked. +2. **I-08** note from home clone’s dirty `ISSUES.md` (landing-stats → standalone). +3. **`local/*` branches** from the home clone (local only; not pushed to origin by policy). +4. Uncommitted monitoring WIP on the home clone was **mostly already on origin** + (grouped IDs `area.group-NN`, SECRETS_ROOT discovery) — not re-applied as a + second patch. + +## Backups (before deleting the home tree) + +Under `taler/src/koopa-admin-log-backups/`: + +- `home-clone-uncommitted-wip-*.patch` — dirty tree at merge time +- `home-clone-all.bundle` — full home clone refs +- `home-clone-tree-*` — moved entire old directory + +## After merge + +| Path | Now | +|------|-----| +| `~/koopa-admin-log` | **symlink** → `~/taler/src/koopa-admin-log` | +| `~/koopa-admin-secrets` | **symlink** → `~/taler/src/koopa-admin-secrets` | + +One tree only for day-to-day work and push. + +## Still not implemented (explicit gaps) + +These were **requested** but are **not** in the recovered WIP / current tree: + +- Global running test number alongside `www.exchange-01` +- Progress bar / percent complete +- Local `secrets.env` (+ committed **example** only) for e2e passwords + +Grouped IDs (`www.exchange-01`) and SECRETS_ROOT search **are** on `main`. + +## Remotes (this workspace) + +| Repo | Path | Remote | +|------|------|--------| +| koopa-admin-log | `taler/src/koopa-admin-log` | `git.hacktivism.ch/hernani/koopa-admin-log` | +| francpaysan-admin-log | `taler/src/francpaysan-admin-log` | `git.gnunet.org/francpaysan-admin-log` | +| koopa-admin-secrets | `taler/src/koopa-admin-secrets` | local secrets only (no origin push of live secrets) | diff --git a/ops/local-branches-sync.md b/ops/local-branches-sync.md index 0d8314b..60be734 100644 --- a/ops/local-branches-sync.md +++ b/ops/local-branches-sync.md @@ -17,3 +17,9 @@ # Pull main: # git fetch origin && git checkout main && git merge --ff-only origin/main + +## 2026-07-17 clone merge + +See **[CLONE-MERGE-2026-07-17.md](./CLONE-MERGE-2026-07-17.md)**: +uncommitted work lived under a **second** directory `~/koopa-admin-log` +(not under `taler/src/…`); that tree is now a symlink to the canonical clone. From dba191474d0c2f96b8d0f30f7f4d6b8e791e82c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:14:48 +0200 Subject: [PATCH 180/326] docs: uncommitted work was in second koopa-admin-log clone --- ops/CLONE-MERGE-2026-07-17.md | 60 +++++++++++++++++++++++++++++++++++ ops/local-branches-sync.md | 6 ++++ 2 files changed, 66 insertions(+) create mode 100644 ops/CLONE-MERGE-2026-07-17.md diff --git a/ops/CLONE-MERGE-2026-07-17.md b/ops/CLONE-MERGE-2026-07-17.md new file mode 100644 index 0000000..01483d1 --- /dev/null +++ b/ops/CLONE-MERGE-2026-07-17.md @@ -0,0 +1,60 @@ +# Clone merge 2026-07-17 — uncommitted work lived in *another* directory + +## Problem + +Two **different working trees** pointed at the same remote +`https://git.hacktivism.ch/hernani/koopa-admin-log.git`: + +| Path | Role | What was wrong | +|------|------|----------------| +| **`~/taler/src/koopa-admin-log`** | Workspace / agent edits | On `origin/main` (monitoring: badges, `www.exchange-NN`, SECRETS_ROOT fix, stage profiles, …) | +| **`~/koopa-admin-log`** | Where e2e was run | **Older** `main` tip + **6 unpushed landing commits** + **large uncommitted** diffs under `scripts/taler-monitoring/` | + +That is easy to miss: same remote name, different folders. Edits and “uncommitted” +state were **not** always in the tree being pushed from `taler/src`. + +## What was recovered / merged + +Into **canonical** `~/taler/src/koopa-admin-log` → pushed to `origin/main`: + +1. **6 commits only on the home clone** (landing v42 stamps, kGOA stats, …) — cherry-picked. +2. **I-08** note from home clone’s dirty `ISSUES.md` (landing-stats → standalone). +3. **`local/*` branches** from the home clone (local only; not pushed to origin by policy). +4. Uncommitted monitoring WIP on the home clone was **mostly already on origin** + (grouped IDs `area.group-NN`, SECRETS_ROOT discovery) — not re-applied as a + second patch. + +## Backups (before deleting the home tree) + +Under `taler/src/koopa-admin-log-backups/`: + +- `home-clone-uncommitted-wip-*.patch` — dirty tree at merge time +- `home-clone-all.bundle` — full home clone refs +- `home-clone-tree-*` — moved entire old directory + +## After merge + +| Path | Now | +|------|-----| +| `~/koopa-admin-log` | **symlink** → `~/taler/src/koopa-admin-log` | +| `~/koopa-admin-secrets` | **symlink** → `~/taler/src/koopa-admin-secrets` | + +One tree only for day-to-day work and push. + +## Still not implemented (explicit gaps) + +These were **requested** but are **not** in the recovered WIP / current tree: + +- Global running test number alongside `www.exchange-01` +- Progress bar / percent complete +- Local `secrets.env` (+ committed **example** only) for e2e passwords + +Grouped IDs (`www.exchange-01`) and SECRETS_ROOT search **are** on `main`. + +## Remotes (this workspace) + +| Repo | Path | Remote | +|------|------|--------| +| koopa-admin-log | `taler/src/koopa-admin-log` | `git.hacktivism.ch/hernani/koopa-admin-log` | +| francpaysan-admin-log | `taler/src/francpaysan-admin-log` | `git.gnunet.org/francpaysan-admin-log` | +| koopa-admin-secrets | `taler/src/koopa-admin-secrets` | local secrets only (no origin push of live secrets) | diff --git a/ops/local-branches-sync.md b/ops/local-branches-sync.md index 0d8314b..60be734 100644 --- a/ops/local-branches-sync.md +++ b/ops/local-branches-sync.md @@ -17,3 +17,9 @@ # Pull main: # git fetch origin && git checkout main && git merge --ff-only origin/main + +## 2026-07-17 clone merge + +See **[CLONE-MERGE-2026-07-17.md](./CLONE-MERGE-2026-07-17.md)**: +uncommitted work lived under a **second** directory `~/koopa-admin-log` +(not under `taler/src/…`); that tree is now a symlink to the canonical clone. From f48f071debc79b298dbc161a5fb3e73c893447ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:15:35 +0200 Subject: [PATCH 181/326] docs: canonical trees live under ~/src/koopa/ --- ops/CLONE-MERGE-2026-07-17.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ops/CLONE-MERGE-2026-07-17.md b/ops/CLONE-MERGE-2026-07-17.md index 01483d1..836f7c9 100644 --- a/ops/CLONE-MERGE-2026-07-17.md +++ b/ops/CLONE-MERGE-2026-07-17.md @@ -32,14 +32,18 @@ Under `taler/src/koopa-admin-log-backups/`: - `home-clone-all.bundle` — full home clone refs - `home-clone-tree-*` — moved entire old directory -## After merge +## After merge (canonical layout) -| Path | Now | -|------|-----| -| `~/koopa-admin-log` | **symlink** → `~/taler/src/koopa-admin-log` | -| `~/koopa-admin-secrets` | **symlink** → `~/taler/src/koopa-admin-secrets` | +**No** `~/koopa-admin-log` or `~/koopa-admin-secrets` on `$HOME` root. -One tree only for day-to-day work and push. +| Path | Role | +|------|------| +| **`~/src/koopa/koopa-admin-log`** | **Canonical** admin-log (newest / `origin/main`) | +| **`~/src/koopa/koopa-admin-secrets`** | **Canonical** secrets (sibling of admin-log) | +| `~/taler/src/koopa-admin-log` | optional symlink → `~/src/koopa/…` (workspace convenience only) | +| `~/taler/src/koopa-admin-secrets` | optional symlink → `~/src/koopa/…` | + +Work and push from **`~/src/koopa/koopa-admin-log`**. ## Still not implemented (explicit gaps) From fe32e798ca223223753fcc5b415fbbff50255b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:15:35 +0200 Subject: [PATCH 182/326] docs: canonical trees live under ~/src/koopa/ --- ops/CLONE-MERGE-2026-07-17.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ops/CLONE-MERGE-2026-07-17.md b/ops/CLONE-MERGE-2026-07-17.md index 01483d1..836f7c9 100644 --- a/ops/CLONE-MERGE-2026-07-17.md +++ b/ops/CLONE-MERGE-2026-07-17.md @@ -32,14 +32,18 @@ Under `taler/src/koopa-admin-log-backups/`: - `home-clone-all.bundle` — full home clone refs - `home-clone-tree-*` — moved entire old directory -## After merge +## After merge (canonical layout) -| Path | Now | -|------|-----| -| `~/koopa-admin-log` | **symlink** → `~/taler/src/koopa-admin-log` | -| `~/koopa-admin-secrets` | **symlink** → `~/taler/src/koopa-admin-secrets` | +**No** `~/koopa-admin-log` or `~/koopa-admin-secrets` on `$HOME` root. -One tree only for day-to-day work and push. +| Path | Role | +|------|------| +| **`~/src/koopa/koopa-admin-log`** | **Canonical** admin-log (newest / `origin/main`) | +| **`~/src/koopa/koopa-admin-secrets`** | **Canonical** secrets (sibling of admin-log) | +| `~/taler/src/koopa-admin-log` | optional symlink → `~/src/koopa/…` (workspace convenience only) | +| `~/taler/src/koopa-admin-secrets` | optional symlink → `~/src/koopa/…` | + +Work and push from **`~/src/koopa/koopa-admin-log`**. ## Still not implemented (explicit gaps) From 4b19a31a34b1102eb65d8654d171d1949fe7b7c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:16:14 +0200 Subject: [PATCH 183/326] =?UTF-8?q?docs:=20layout=20cleanup=20=E2=80=94=20?= =?UTF-8?q?trees=20under=20~/src/koopa=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ops/CLONE-MERGE-2026-07-17.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ops/CLONE-MERGE-2026-07-17.md b/ops/CLONE-MERGE-2026-07-17.md index 836f7c9..4ff9e54 100644 --- a/ops/CLONE-MERGE-2026-07-17.md +++ b/ops/CLONE-MERGE-2026-07-17.md @@ -26,7 +26,7 @@ Into **canonical** `~/taler/src/koopa-admin-log` → pushed to `origin/main`: ## Backups (before deleting the home tree) -Under `taler/src/koopa-admin-log-backups/`: +Under `~/src/koopa/_backups/koopa-admin-log-backups-20260717/` (moved out of workspace): - `home-clone-uncommitted-wip-*.patch` — dirty tree at merge time - `home-clone-all.bundle` — full home clone refs From cdde2896cd967a9dbb6c5ada7a656e404139394c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:16:14 +0200 Subject: [PATCH 184/326] =?UTF-8?q?docs:=20layout=20cleanup=20=E2=80=94=20?= =?UTF-8?q?trees=20under=20~/src/koopa=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ops/CLONE-MERGE-2026-07-17.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ops/CLONE-MERGE-2026-07-17.md b/ops/CLONE-MERGE-2026-07-17.md index 836f7c9..4ff9e54 100644 --- a/ops/CLONE-MERGE-2026-07-17.md +++ b/ops/CLONE-MERGE-2026-07-17.md @@ -26,7 +26,7 @@ Into **canonical** `~/taler/src/koopa-admin-log` → pushed to `origin/main`: ## Backups (before deleting the home tree) -Under `taler/src/koopa-admin-log-backups/`: +Under `~/src/koopa/_backups/koopa-admin-log-backups-20260717/` (moved out of workspace): - `home-clone-uncommitted-wip-*.patch` — dirty tree at merge time - `home-clone-all.bundle` — full home clone refs From db90ddcce564125afff08bed256aa91ab653c6d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:18:09 +0200 Subject: [PATCH 185/326] monitoring: WARN badge yellow, red only for errors --- scripts/taler-monitoring/lib.sh | 132 +++++++++++++++++++++++++++++--- 1 file changed, 120 insertions(+), 12 deletions(-) diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 938be14..1ef6f9a 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -376,12 +376,13 @@ else B=$'\e[1m' # bold section N=$'\e[0m' # reset # Badge fill: bright text on solid background - BG_OK=$'\e[1;30;42m' # black on green - BG_INFO=$'\e[1;30;46m' # black on cyan - BG_WARN=$'\e[1;30;43m' # black on yellow - BG_ERR=$'\e[1;37;41m' # white on red - BG_BLK=$'\e[1;37;45m' # white on magenta - BG_SEC=$'\e[1;37;44m' # white on blue (section) + # WARN = yellow only (never red). ERROR/BLOCKER use red/magenta. + BG_OK=$'\e[1;30;42m' # black on green + BG_INFO=$'\e[1;30;46m' # black on cyan + BG_WARN=$'\e[1;30;103m' # black on bright yellow (clearly not red) + BG_ERR=$'\e[1;37;41m' # white on red — errors only + BG_BLK=$'\e[1;37;45m' # white on magenta — blockers + BG_SEC=$'\e[1;37;44m' # white on blue (section) BOX=1 fi @@ -393,14 +394,23 @@ BLOCKERS=() # human-readable payment/withdraw blockers ERRORS=() # all ERROR lines (component scope) # Grouped test IDs: area.group-NN (e.g. www.exchange-01, e2e.pay-03) +# plus global run number: #042 (monotonic for the whole ./taler-monitoring.sh run) # set_area www # phase: www | e2e | inside | versions | … -# set_group exchange # resets counter + prints group box → www.exchange-01 +# set_group exchange # → www.exchange-01 # set_group bank # → www.bank-01 # Without set_group: area-01, area-02, … (flat within area) +# Line shape: ┌ OK ┐ #003 www.exchange-02 label · detail TEST_AREA="" TEST_GROUP="" TEST_N=0 LAST_TID="" +GLOBAL_N=0 +LAST_GLOBAL="" +# Progress: set_progress_total N (0 = unknown → bar shows done only) +PROGRESS_DONE=0 +PROGRESS_TOTAL="${PROGRESS_TOTAL:-0}" +PROGRESS_SHOW_EVERY="${PROGRESS_SHOW_EVERY:-8}" +PROGRESS_LAST_SHOWN=0 # Per-group counters so re-entering www.exchange after perf continues NN # (shell vars TEST_CNT__). _tid_key() { @@ -453,13 +463,69 @@ set_group() { else printf -- '-- %s --\n' "$tag" fi + # show progress when entering a new group (if totals known) + _progress_maybe_show 1 +} +set_progress_total() { + # Optional: expected number of numbered checks in this run (or remaining phase). + # PROGRESS_TOTAL=0 → no percent, only "done=N". + PROGRESS_TOTAL="${1:-0}" + PROGRESS_DONE=0 + PROGRESS_LAST_SHOWN=0 +} +add_progress_total() { + local n="${1:-0}" + PROGRESS_TOTAL=$((PROGRESS_TOTAL + n)) +} +_progress_bar_line() { + local done="$1" total="$2" width=24 pct=0 filled empty i bar + if [ "$total" -gt 0 ]; then + pct=$((done * 100 / total)) + [ "$pct" -gt 100 ] && pct=100 + filled=$((pct * width / 100)) + [ "$filled" -gt "$width" ] && filled=$width + empty=$((width - filled)) + bar="" + i=0 + while [ "$i" -lt "$filled" ]; do bar="${bar}█"; i=$((i + 1)); done + i=0 + while [ "$i" -lt "$empty" ]; do bar="${bar}░"; i=$((i + 1)); done + if [ "${BOX:-0}" = "1" ]; then + printf '%s%s┌ PROG ┐%s %s%s%s %s%3d%%%s %s%d/%d%s\n' \ + "$D" "$C" "$N" "$C" "$bar" "$N" "$W" "$pct" "$N" "$D" "$done" "$total" "$N" + else + printf -- '[ PROG ] [%s] %3d%% %d/%d\n' "$bar" "$pct" "$done" "$total" + fi + else + if [ "${BOX:-0}" = "1" ]; then + printf '%s%s┌ PROG ┐%s %sdone=%d%s (total unknown — set PROGRESS_TOTAL=)\n' \ + "$D" "$C" "$N" "$D" "$done" "$N" + else + printf -- '[ PROG ] done=%d (total unknown)\n' "$done" + fi + fi +} +_progress_maybe_show() { + local force="${1:-0}" + [ "${PROGRESS_OFF:-0}" = "1" ] && return 0 + [ "$PROGRESS_DONE" -le 0 ] && [ "$force" != "1" ] && return 0 + if [ "$force" = "1" ] || \ + [ $((PROGRESS_DONE - PROGRESS_LAST_SHOWN)) -ge "$PROGRESS_SHOW_EVERY" ] || \ + { [ "$PROGRESS_TOTAL" -gt 0 ] && [ "$PROGRESS_DONE" -ge "$PROGRESS_TOTAL" ]; }; then + _progress_bar_line "$PROGRESS_DONE" "$PROGRESS_TOTAL" + PROGRESS_LAST_SHOWN=$PROGRESS_DONE + fi } # Assign next id into LAST_TID (must not run in a subshell). _take_tid() { LAST_TID="" + LAST_GLOBAL="" if [ -z "${TEST_AREA:-}" ]; then return fi + # Global monotonic number for the whole monitoring run (#001 …) + GLOBAL_N=$((GLOBAL_N + 1)) + LAST_GLOBAL=$(printf '#%03d' "$GLOBAL_N") TEST_N=$((TEST_N + 1)) if [ -n "${TEST_GROUP:-}" ]; then LAST_TID=$(printf '%s.%s-%02d' "$TEST_AREA" "$TEST_GROUP" "$TEST_N") @@ -467,9 +533,14 @@ _take_tid() { else LAST_TID=$(printf '%s-%02d' "$TEST_AREA" "$TEST_N") fi + PROGRESS_DONE=$((PROGRESS_DONE + 1)) + _progress_maybe_show 0 } -# Dim test id: "www.exchange-01 " or empty +# Dim ids: "#003 www.exchange-01 " or empty _fmt_tid() { + if [ -n "${LAST_GLOBAL:-}" ]; then + printf '%s%s%s ' "$D" "$LAST_GLOBAL" "$N" + fi if [ -n "${LAST_TID:-}" ]; then printf '%s%s%s ' "$D" "$LAST_TID" "$N" fi @@ -537,7 +608,8 @@ warn() { head="$a1" detail="" fi - _msg_line "$BG_WARN" "WARN" "$W" "$head" "$detail" + # Label in yellow text; badge yellow — never red (red = ERROR only) + _msg_line "$BG_WARN" "WARN" "$Y" "$head" "$detail" WARN_N=$((WARN_N + 1)) } info() { @@ -581,6 +653,13 @@ section() { summary() { echo "" + # final progress line (no _take_tid — plain printf) + if [ "${PROGRESS_OFF:-0}" != "1" ] && [ "$PROGRESS_DONE" -gt 0 ]; then + _progress_bar_line "$PROGRESS_DONE" "$PROGRESS_TOTAL" + fi + if [ "$GLOBAL_N" -gt 0 ]; then + printf -- '%s numbered checks this run: #001…#%03d\n' "$D" "$GLOBAL_N" + fi if [ "${#BLOCKERS[@]}" -gt 0 ]; then if [ "${BOX:-0}" = "1" ]; then printf -- '%s┌ BLOCKERS · pay/withdraw cannot finish ┐%s\n' "$BG_BLK" "$N" @@ -832,12 +911,41 @@ read_secret() { # Human hint when secrets missing (e2e prereq) secrets_hint() { cat < Date: Fri, 17 Jul 2026 18:18:09 +0200 Subject: [PATCH 186/326] monitoring: WARN badge yellow, red only for errors --- scripts/taler-monitoring/lib.sh | 132 +++++++++++++++++++++++++++++--- 1 file changed, 120 insertions(+), 12 deletions(-) diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 938be14..1ef6f9a 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -376,12 +376,13 @@ else B=$'\e[1m' # bold section N=$'\e[0m' # reset # Badge fill: bright text on solid background - BG_OK=$'\e[1;30;42m' # black on green - BG_INFO=$'\e[1;30;46m' # black on cyan - BG_WARN=$'\e[1;30;43m' # black on yellow - BG_ERR=$'\e[1;37;41m' # white on red - BG_BLK=$'\e[1;37;45m' # white on magenta - BG_SEC=$'\e[1;37;44m' # white on blue (section) + # WARN = yellow only (never red). ERROR/BLOCKER use red/magenta. + BG_OK=$'\e[1;30;42m' # black on green + BG_INFO=$'\e[1;30;46m' # black on cyan + BG_WARN=$'\e[1;30;103m' # black on bright yellow (clearly not red) + BG_ERR=$'\e[1;37;41m' # white on red — errors only + BG_BLK=$'\e[1;37;45m' # white on magenta — blockers + BG_SEC=$'\e[1;37;44m' # white on blue (section) BOX=1 fi @@ -393,14 +394,23 @@ BLOCKERS=() # human-readable payment/withdraw blockers ERRORS=() # all ERROR lines (component scope) # Grouped test IDs: area.group-NN (e.g. www.exchange-01, e2e.pay-03) +# plus global run number: #042 (monotonic for the whole ./taler-monitoring.sh run) # set_area www # phase: www | e2e | inside | versions | … -# set_group exchange # resets counter + prints group box → www.exchange-01 +# set_group exchange # → www.exchange-01 # set_group bank # → www.bank-01 # Without set_group: area-01, area-02, … (flat within area) +# Line shape: ┌ OK ┐ #003 www.exchange-02 label · detail TEST_AREA="" TEST_GROUP="" TEST_N=0 LAST_TID="" +GLOBAL_N=0 +LAST_GLOBAL="" +# Progress: set_progress_total N (0 = unknown → bar shows done only) +PROGRESS_DONE=0 +PROGRESS_TOTAL="${PROGRESS_TOTAL:-0}" +PROGRESS_SHOW_EVERY="${PROGRESS_SHOW_EVERY:-8}" +PROGRESS_LAST_SHOWN=0 # Per-group counters so re-entering www.exchange after perf continues NN # (shell vars TEST_CNT__). _tid_key() { @@ -453,13 +463,69 @@ set_group() { else printf -- '-- %s --\n' "$tag" fi + # show progress when entering a new group (if totals known) + _progress_maybe_show 1 +} +set_progress_total() { + # Optional: expected number of numbered checks in this run (or remaining phase). + # PROGRESS_TOTAL=0 → no percent, only "done=N". + PROGRESS_TOTAL="${1:-0}" + PROGRESS_DONE=0 + PROGRESS_LAST_SHOWN=0 +} +add_progress_total() { + local n="${1:-0}" + PROGRESS_TOTAL=$((PROGRESS_TOTAL + n)) +} +_progress_bar_line() { + local done="$1" total="$2" width=24 pct=0 filled empty i bar + if [ "$total" -gt 0 ]; then + pct=$((done * 100 / total)) + [ "$pct" -gt 100 ] && pct=100 + filled=$((pct * width / 100)) + [ "$filled" -gt "$width" ] && filled=$width + empty=$((width - filled)) + bar="" + i=0 + while [ "$i" -lt "$filled" ]; do bar="${bar}█"; i=$((i + 1)); done + i=0 + while [ "$i" -lt "$empty" ]; do bar="${bar}░"; i=$((i + 1)); done + if [ "${BOX:-0}" = "1" ]; then + printf '%s%s┌ PROG ┐%s %s%s%s %s%3d%%%s %s%d/%d%s\n' \ + "$D" "$C" "$N" "$C" "$bar" "$N" "$W" "$pct" "$N" "$D" "$done" "$total" "$N" + else + printf -- '[ PROG ] [%s] %3d%% %d/%d\n' "$bar" "$pct" "$done" "$total" + fi + else + if [ "${BOX:-0}" = "1" ]; then + printf '%s%s┌ PROG ┐%s %sdone=%d%s (total unknown — set PROGRESS_TOTAL=)\n' \ + "$D" "$C" "$N" "$D" "$done" "$N" + else + printf -- '[ PROG ] done=%d (total unknown)\n' "$done" + fi + fi +} +_progress_maybe_show() { + local force="${1:-0}" + [ "${PROGRESS_OFF:-0}" = "1" ] && return 0 + [ "$PROGRESS_DONE" -le 0 ] && [ "$force" != "1" ] && return 0 + if [ "$force" = "1" ] || \ + [ $((PROGRESS_DONE - PROGRESS_LAST_SHOWN)) -ge "$PROGRESS_SHOW_EVERY" ] || \ + { [ "$PROGRESS_TOTAL" -gt 0 ] && [ "$PROGRESS_DONE" -ge "$PROGRESS_TOTAL" ]; }; then + _progress_bar_line "$PROGRESS_DONE" "$PROGRESS_TOTAL" + PROGRESS_LAST_SHOWN=$PROGRESS_DONE + fi } # Assign next id into LAST_TID (must not run in a subshell). _take_tid() { LAST_TID="" + LAST_GLOBAL="" if [ -z "${TEST_AREA:-}" ]; then return fi + # Global monotonic number for the whole monitoring run (#001 …) + GLOBAL_N=$((GLOBAL_N + 1)) + LAST_GLOBAL=$(printf '#%03d' "$GLOBAL_N") TEST_N=$((TEST_N + 1)) if [ -n "${TEST_GROUP:-}" ]; then LAST_TID=$(printf '%s.%s-%02d' "$TEST_AREA" "$TEST_GROUP" "$TEST_N") @@ -467,9 +533,14 @@ _take_tid() { else LAST_TID=$(printf '%s-%02d' "$TEST_AREA" "$TEST_N") fi + PROGRESS_DONE=$((PROGRESS_DONE + 1)) + _progress_maybe_show 0 } -# Dim test id: "www.exchange-01 " or empty +# Dim ids: "#003 www.exchange-01 " or empty _fmt_tid() { + if [ -n "${LAST_GLOBAL:-}" ]; then + printf '%s%s%s ' "$D" "$LAST_GLOBAL" "$N" + fi if [ -n "${LAST_TID:-}" ]; then printf '%s%s%s ' "$D" "$LAST_TID" "$N" fi @@ -537,7 +608,8 @@ warn() { head="$a1" detail="" fi - _msg_line "$BG_WARN" "WARN" "$W" "$head" "$detail" + # Label in yellow text; badge yellow — never red (red = ERROR only) + _msg_line "$BG_WARN" "WARN" "$Y" "$head" "$detail" WARN_N=$((WARN_N + 1)) } info() { @@ -581,6 +653,13 @@ section() { summary() { echo "" + # final progress line (no _take_tid — plain printf) + if [ "${PROGRESS_OFF:-0}" != "1" ] && [ "$PROGRESS_DONE" -gt 0 ]; then + _progress_bar_line "$PROGRESS_DONE" "$PROGRESS_TOTAL" + fi + if [ "$GLOBAL_N" -gt 0 ]; then + printf -- '%s numbered checks this run: #001…#%03d\n' "$D" "$GLOBAL_N" + fi if [ "${#BLOCKERS[@]}" -gt 0 ]; then if [ "${BOX:-0}" = "1" ]; then printf -- '%s┌ BLOCKERS · pay/withdraw cannot finish ┐%s\n' "$BG_BLK" "$N" @@ -832,12 +911,41 @@ read_secret() { # Human hint when secrets missing (e2e prereq) secrets_hint() { cat < Date: Fri, 17 Jul 2026 18:19:30 +0200 Subject: [PATCH 187/326] monitoring: global #NNN, progress bar, secrets.env, yellow WARN --- .gitignore | 3 ++ scripts/taler-monitoring/README.md | 46 ++++++++++++++------ scripts/taler-monitoring/TESTS.md | 13 ++++-- scripts/taler-monitoring/lib.sh | 37 ++++++++++++---- scripts/taler-monitoring/secrets.env.example | 44 +++++++++++++++++++ scripts/taler-monitoring/taler-monitoring.sh | 26 +++++++++++ 6 files changed, 144 insertions(+), 25 deletions(-) create mode 100644 scripts/taler-monitoring/secrets.env.example diff --git a/.gitignore b/.gitignore index 9f39d9e..822d63a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ __pycache__/ *~ .tmp/ scripts/taler-monitoring/settings.conf +# Local e2e/overrides — never commit (use secrets.env.example as template) +scripts/taler-monitoring/secrets.env +scripts/taler-monitoring/secrets.env.local diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 7a711d6..3bd5f6a 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -24,25 +24,45 @@ End of each phase: totals + list of **BLOCKERS** and **ERRORS** (ids included). ## Secrets (local GOA e2e) -`check_e2e.sh` loads bank/merchant passwords from **koopa-admin-secrets** -(not this repo). Search order: +**Do not commit passwords.** Canonical trees: `~/src/koopa/koopa-admin-log` + +sibling `~/src/koopa/koopa-admin-secrets`. -1. `SECRETS_ROOT` → `…/taler-bank/bank-admin-password.txt` -2. `KOOPA_ADMIN_SECRETS` → `$KOOPA_ADMIN_SECRETS/koopa/host-root/…` -3. Sibling of this checkout: `../koopa-admin-secrets/koopa/host-root` -4. `$HOME/taler/src/koopa-admin-secrets/koopa/host-root` (common laptop layout) -5. `$HOME/.config/taler-landing/bank-admin-password.txt` -6. SSH koopa: `/root/bank-admin-password.txt` or podman bank container - -If you keep admin-log at `~/koopa-admin-log` but secrets under `~/taler/src/…`: +### Local file (recommended on laptop) ```bash -export SECRETS_ROOT="$HOME/taler/src/koopa-admin-secrets/koopa/host-root" -# or: export KOOPA_ADMIN_SECRETS="$HOME/taler/src/koopa-admin-secrets" +cd ~/src/koopa/koopa-admin-log/scripts/taler-monitoring +cp secrets.env.example secrets.env # gitignored +$EDITOR secrets.env ./taler-monitoring.sh e2e ``` -Or one-shot: `E2E_BANK_ADMIN_PASS=… E2E_MERCHANT_TOKEN=… ./taler-monitoring.sh e2e` +See **[secrets.env.example](./secrets.env.example)** for every variable that +may appear (admin pass, merchant token, SECRETS_ROOT, progress knobs). + +### Search order + +1. Already-exported env (`E2E_BANK_ADMIN_PASS`, …) +2. `secrets.env` next to this suite (`MONITORING_SECRETS_ENV=` override) +3. `SECRETS_ROOT` / `KOOPA_ADMIN_SECRETS` file tree +4. Sibling `../koopa-admin-secrets/koopa/host-root` +5. `$HOME/src/koopa/koopa-admin-secrets/koopa/host-root` +6. `$HOME/.config/taler-landing/bank-admin-password.txt` +7. SSH koopa `/root/…` or bank container + +### Line IDs + progress + +Each check prints **global** and **grouped** ids: + +```text +┌ OK ┐ #014 www.exchange-03 exchange /config · HTTP 200 +┌ WARN ┐ #015 www.stats-02 stats age soft · yellow badge (not red) +┌ PROG ┐ ████████░░░░ 42% 14/55 +``` + +- `#014` — run-wide counter (whole `./taler-monitoring.sh` invocation) +- `www.exchange-03` — area.group-NN +- **WARN** badge = **yellow**; **ERROR** = **red** only +- Progress: auto estimate per phase, or `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1` ## Commands diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index 022de8b..b238c3c 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -1,13 +1,18 @@ # taler-monitoring — grouped test IDs -Every check line is numbered **`area.group-NN`** (two-digit group counter): +Every check line has a **global** run number and a **grouped** id: ```text -┌ OK ┐ www.exchange-01 exchange /config · HTTP 200 -┌ ERROR ┐ e2e.pay-03 order create failed · HTTP 502 -┌ BLOCKER┐ e2e.prereq-02 merchant secret missing +┌ OK ┐ #001 www.exchange-01 exchange /config · HTTP 200 +┌ WARN ┐ #002 www.stats-02 soft age · yellow (never red) +┌ ERROR ┐ #003 e2e.pay-03 order create failed · HTTP 502 +┌ BLOCKER┐ #004 e2e.prereq-02 merchant secret missing +┌ PROG ┐ ████░░░░ 25% 4/16 ``` +- `#NNN` — monotonic for the whole monitoring run +- `area.group-NN` — logical block (resets per group, continues if re-entered) + | Area | Phase script | Groups (examples) | |------|--------------|-------------------| | **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` | diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 1ef6f9a..1e14fe3 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -404,13 +404,28 @@ TEST_AREA="" TEST_GROUP="" TEST_N=0 LAST_TID="" -GLOBAL_N=0 +# Global / progress: may continue across check_*.sh processes via TALER_MON_STATE +GLOBAL_N="${GLOBAL_N:-0}" LAST_GLOBAL="" -# Progress: set_progress_total N (0 = unknown → bar shows done only) -PROGRESS_DONE=0 +PROGRESS_DONE="${PROGRESS_DONE:-0}" PROGRESS_TOTAL="${PROGRESS_TOTAL:-0}" PROGRESS_SHOW_EVERY="${PROGRESS_SHOW_EVERY:-8}" -PROGRESS_LAST_SHOWN=0 +PROGRESS_LAST_SHOWN="${PROGRESS_LAST_SHOWN:-0}" +_mon_state_load() { + [ -n "${TALER_MON_STATE:-}" ] && [ -f "${TALER_MON_STATE}" ] || return 0 + # shellcheck disable=SC1090 + . "${TALER_MON_STATE}" +} +_mon_state_save() { + [ -n "${TALER_MON_STATE:-}" ] || return 0 + { + printf 'GLOBAL_N=%s\n' "$GLOBAL_N" + printf 'PROGRESS_DONE=%s\n' "$PROGRESS_DONE" + printf 'PROGRESS_TOTAL=%s\n' "$PROGRESS_TOTAL" + printf 'PROGRESS_LAST_SHOWN=%s\n' "$PROGRESS_LAST_SHOWN" + } >"${TALER_MON_STATE}" +} +_mon_state_load # Per-group counters so re-entering www.exchange after perf continues NN # (shell vars TEST_CNT__). _tid_key() { @@ -467,15 +482,20 @@ set_group() { _progress_maybe_show 1 } set_progress_total() { - # Optional: expected number of numbered checks in this run (or remaining phase). + # Optional: expected number of numbered checks in this run. # PROGRESS_TOTAL=0 → no percent, only "done=N". - PROGRESS_TOTAL="${1:-0}" - PROGRESS_DONE=0 - PROGRESS_LAST_SHOWN=0 + # Does not reset GLOBAL_N / PROGRESS_DONE if already mid-run (state file). + local n="${1:-0}" + PROGRESS_TOTAL="$n" + if [ "${PROGRESS_DONE:-0}" -eq 0 ]; then + PROGRESS_LAST_SHOWN=0 + fi + _mon_state_save } add_progress_total() { local n="${1:-0}" PROGRESS_TOTAL=$((PROGRESS_TOTAL + n)) + _mon_state_save } _progress_bar_line() { local done="$1" total="$2" width=24 pct=0 filled empty i bar @@ -534,6 +554,7 @@ _take_tid() { LAST_TID=$(printf '%s-%02d' "$TEST_AREA" "$TEST_N") fi PROGRESS_DONE=$((PROGRESS_DONE + 1)) + _mon_state_save _progress_maybe_show 0 } # Dim ids: "#003 www.exchange-01 " or empty diff --git a/scripts/taler-monitoring/secrets.env.example b/scripts/taler-monitoring/secrets.env.example new file mode 100644 index 0000000..2af0ffc --- /dev/null +++ b/scripts/taler-monitoring/secrets.env.example @@ -0,0 +1,44 @@ +# taler-monitoring — local secrets template +# +# Copy to secrets.env (gitignored) and fill values, OR export the same vars: +# cp secrets.env.example secrets.env +# $EDITOR secrets.env +# +# Never commit secrets.env. Prefer file paths under koopa-admin-secrets +# for long-term storage; this file is for laptop one-shots / overrides. +# +# Load order: env already set > secrets.env > SECRETS_ROOT files > SSH koopa + +# --- Preferred: point at secrets tree (no passwords in this file) --- +# Sibling of admin-log on this machine: +# KOOPA_ADMIN_SECRETS=$HOME/src/koopa/koopa-admin-secrets +# Or host-root only: +# SECRETS_ROOT=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root + +# --- Or paste passwords / tokens (local GOA e2e) --- +# Bank admin (libeufin-bank admin) — funds / creates accounts in e2e +# E2E_BANK_ADMIN_PASS= +# BANK_ADMIN_PASS= # alias, same meaning + +# Merchant instance token for private orders (goa-demo-cp4zqk on hacktivism) +# Full "secret-token:…" or raw secret (Bearer prefix added by e2e) +# E2E_MERCHANT_TOKEN= +# MERCHANT_TOKEN= +# MERCHANT_INSTANCE_PASSWORD= + +# Optional overrides +# MERCHANT_INSTANCE=goa-demo-cp4zqk +# EXPECT_CURRENCY=GOA +# E2E_TIMEOUT=340 +# PROGRESS_TOTAL=120 +# PROGRESS_SHOW_EVERY=8 +# PROGRESS_OFF=1 + +# Values that typically appear on a full stack (for orientation only): +# bank-admin-password.txt +# bank-explorer-password.txt +# bank-exchange-password.txt +# bank-goa-demo-cp4zqk-password.txt +# merchant-goa-demo-cp4zqk-password.txt +# exchange-attribute-encryption.secret.conf +# (see ../SECRETS.md and koopa-admin-secrets/MAP.md) diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 52d3d29..04af0b9 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -66,6 +66,13 @@ EOF # shellcheck source=lib.sh source "$ROOT/lib.sh" +# Optional gitignored secrets.env next to this suite (or MONITORING_SECRETS_ENV=) +load_monitoring_secrets_env 2>/dev/null || true + +# Persist #NNN + progress across check_*.sh child processes +TALER_MON_STATE=$(mktemp "${TMPDIR:-/tmp}/taler-mon-state.XXXXXX") +export TALER_MON_STATE +trap 'rm -f "${TALER_MON_STATE:-}"' EXIT PHASES=() DOMAIN_SET=0 @@ -212,11 +219,30 @@ if [ "${#PHASES[@]}" -eq 0 ]; then PHASES=(urls) fi +# Rough expected check counts for progress % (override: PROGRESS_TOTAL=N) +if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then + _pt=0 + for p in "${PHASES[@]}"; do + case "$p" in + urls) _pt=$((_pt + 55)) ;; + inside) _pt=$((_pt + 25)) ;; + versions) _pt=$((_pt + 20)) ;; + sanity) _pt=$((_pt + 30)) ;; + server) _pt=$((_pt + 15)) ;; + e2e) _pt=$((_pt + 90)) ;; + ladder|goa-ladder) _pt=$((_pt + 80)) ;; + esac + done + set_progress_total "$_pt" + unset _pt +fi + printf 'target domain=%s\n' "${TALER_DOMAIN}" printf ' bank %s\n' "$BANK_PUBLIC" printf ' exchange %s\n' "$EXCHANGE_PUBLIC" printf ' merchant %s\n' "$MERCHANT_PUBLIC" printf ' currency expect=%s skip_ssh=%s\n' "${EXPECT_CURRENCY:-any}" "$SKIP_SSH" +printf ' progress total≈%s (set PROGRESS_TOTAL= to override; PROGRESS_OFF=1 to hide)\n' "${PROGRESS_TOTAL:-0}" chmod +x "$ROOT"/check_*.sh 2>/dev/null || true From 70e198950fa7090978aab79bb5a20bda95517542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:19:30 +0200 Subject: [PATCH 188/326] monitoring: global #NNN, progress bar, secrets.env, yellow WARN --- .gitignore | 3 ++ scripts/taler-monitoring/README.md | 46 ++++++++++++++------ scripts/taler-monitoring/TESTS.md | 13 ++++-- scripts/taler-monitoring/lib.sh | 37 ++++++++++++---- scripts/taler-monitoring/secrets.env.example | 44 +++++++++++++++++++ scripts/taler-monitoring/taler-monitoring.sh | 26 +++++++++++ 6 files changed, 144 insertions(+), 25 deletions(-) create mode 100644 scripts/taler-monitoring/secrets.env.example diff --git a/.gitignore b/.gitignore index 9f39d9e..822d63a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ __pycache__/ *~ .tmp/ scripts/taler-monitoring/settings.conf +# Local e2e/overrides — never commit (use secrets.env.example as template) +scripts/taler-monitoring/secrets.env +scripts/taler-monitoring/secrets.env.local diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 7a711d6..3bd5f6a 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -24,25 +24,45 @@ End of each phase: totals + list of **BLOCKERS** and **ERRORS** (ids included). ## Secrets (local GOA e2e) -`check_e2e.sh` loads bank/merchant passwords from **koopa-admin-secrets** -(not this repo). Search order: +**Do not commit passwords.** Canonical trees: `~/src/koopa/koopa-admin-log` + +sibling `~/src/koopa/koopa-admin-secrets`. -1. `SECRETS_ROOT` → `…/taler-bank/bank-admin-password.txt` -2. `KOOPA_ADMIN_SECRETS` → `$KOOPA_ADMIN_SECRETS/koopa/host-root/…` -3. Sibling of this checkout: `../koopa-admin-secrets/koopa/host-root` -4. `$HOME/taler/src/koopa-admin-secrets/koopa/host-root` (common laptop layout) -5. `$HOME/.config/taler-landing/bank-admin-password.txt` -6. SSH koopa: `/root/bank-admin-password.txt` or podman bank container - -If you keep admin-log at `~/koopa-admin-log` but secrets under `~/taler/src/…`: +### Local file (recommended on laptop) ```bash -export SECRETS_ROOT="$HOME/taler/src/koopa-admin-secrets/koopa/host-root" -# or: export KOOPA_ADMIN_SECRETS="$HOME/taler/src/koopa-admin-secrets" +cd ~/src/koopa/koopa-admin-log/scripts/taler-monitoring +cp secrets.env.example secrets.env # gitignored +$EDITOR secrets.env ./taler-monitoring.sh e2e ``` -Or one-shot: `E2E_BANK_ADMIN_PASS=… E2E_MERCHANT_TOKEN=… ./taler-monitoring.sh e2e` +See **[secrets.env.example](./secrets.env.example)** for every variable that +may appear (admin pass, merchant token, SECRETS_ROOT, progress knobs). + +### Search order + +1. Already-exported env (`E2E_BANK_ADMIN_PASS`, …) +2. `secrets.env` next to this suite (`MONITORING_SECRETS_ENV=` override) +3. `SECRETS_ROOT` / `KOOPA_ADMIN_SECRETS` file tree +4. Sibling `../koopa-admin-secrets/koopa/host-root` +5. `$HOME/src/koopa/koopa-admin-secrets/koopa/host-root` +6. `$HOME/.config/taler-landing/bank-admin-password.txt` +7. SSH koopa `/root/…` or bank container + +### Line IDs + progress + +Each check prints **global** and **grouped** ids: + +```text +┌ OK ┐ #014 www.exchange-03 exchange /config · HTTP 200 +┌ WARN ┐ #015 www.stats-02 stats age soft · yellow badge (not red) +┌ PROG ┐ ████████░░░░ 42% 14/55 +``` + +- `#014` — run-wide counter (whole `./taler-monitoring.sh` invocation) +- `www.exchange-03` — area.group-NN +- **WARN** badge = **yellow**; **ERROR** = **red** only +- Progress: auto estimate per phase, or `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1` ## Commands diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index 022de8b..b238c3c 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -1,13 +1,18 @@ # taler-monitoring — grouped test IDs -Every check line is numbered **`area.group-NN`** (two-digit group counter): +Every check line has a **global** run number and a **grouped** id: ```text -┌ OK ┐ www.exchange-01 exchange /config · HTTP 200 -┌ ERROR ┐ e2e.pay-03 order create failed · HTTP 502 -┌ BLOCKER┐ e2e.prereq-02 merchant secret missing +┌ OK ┐ #001 www.exchange-01 exchange /config · HTTP 200 +┌ WARN ┐ #002 www.stats-02 soft age · yellow (never red) +┌ ERROR ┐ #003 e2e.pay-03 order create failed · HTTP 502 +┌ BLOCKER┐ #004 e2e.prereq-02 merchant secret missing +┌ PROG ┐ ████░░░░ 25% 4/16 ``` +- `#NNN` — monotonic for the whole monitoring run +- `area.group-NN` — logical block (resets per group, continues if re-entered) + | Area | Phase script | Groups (examples) | |------|--------------|-------------------| | **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` | diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 1ef6f9a..1e14fe3 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -404,13 +404,28 @@ TEST_AREA="" TEST_GROUP="" TEST_N=0 LAST_TID="" -GLOBAL_N=0 +# Global / progress: may continue across check_*.sh processes via TALER_MON_STATE +GLOBAL_N="${GLOBAL_N:-0}" LAST_GLOBAL="" -# Progress: set_progress_total N (0 = unknown → bar shows done only) -PROGRESS_DONE=0 +PROGRESS_DONE="${PROGRESS_DONE:-0}" PROGRESS_TOTAL="${PROGRESS_TOTAL:-0}" PROGRESS_SHOW_EVERY="${PROGRESS_SHOW_EVERY:-8}" -PROGRESS_LAST_SHOWN=0 +PROGRESS_LAST_SHOWN="${PROGRESS_LAST_SHOWN:-0}" +_mon_state_load() { + [ -n "${TALER_MON_STATE:-}" ] && [ -f "${TALER_MON_STATE}" ] || return 0 + # shellcheck disable=SC1090 + . "${TALER_MON_STATE}" +} +_mon_state_save() { + [ -n "${TALER_MON_STATE:-}" ] || return 0 + { + printf 'GLOBAL_N=%s\n' "$GLOBAL_N" + printf 'PROGRESS_DONE=%s\n' "$PROGRESS_DONE" + printf 'PROGRESS_TOTAL=%s\n' "$PROGRESS_TOTAL" + printf 'PROGRESS_LAST_SHOWN=%s\n' "$PROGRESS_LAST_SHOWN" + } >"${TALER_MON_STATE}" +} +_mon_state_load # Per-group counters so re-entering www.exchange after perf continues NN # (shell vars TEST_CNT__). _tid_key() { @@ -467,15 +482,20 @@ set_group() { _progress_maybe_show 1 } set_progress_total() { - # Optional: expected number of numbered checks in this run (or remaining phase). + # Optional: expected number of numbered checks in this run. # PROGRESS_TOTAL=0 → no percent, only "done=N". - PROGRESS_TOTAL="${1:-0}" - PROGRESS_DONE=0 - PROGRESS_LAST_SHOWN=0 + # Does not reset GLOBAL_N / PROGRESS_DONE if already mid-run (state file). + local n="${1:-0}" + PROGRESS_TOTAL="$n" + if [ "${PROGRESS_DONE:-0}" -eq 0 ]; then + PROGRESS_LAST_SHOWN=0 + fi + _mon_state_save } add_progress_total() { local n="${1:-0}" PROGRESS_TOTAL=$((PROGRESS_TOTAL + n)) + _mon_state_save } _progress_bar_line() { local done="$1" total="$2" width=24 pct=0 filled empty i bar @@ -534,6 +554,7 @@ _take_tid() { LAST_TID=$(printf '%s-%02d' "$TEST_AREA" "$TEST_N") fi PROGRESS_DONE=$((PROGRESS_DONE + 1)) + _mon_state_save _progress_maybe_show 0 } # Dim ids: "#003 www.exchange-01 " or empty diff --git a/scripts/taler-monitoring/secrets.env.example b/scripts/taler-monitoring/secrets.env.example new file mode 100644 index 0000000..2af0ffc --- /dev/null +++ b/scripts/taler-monitoring/secrets.env.example @@ -0,0 +1,44 @@ +# taler-monitoring — local secrets template +# +# Copy to secrets.env (gitignored) and fill values, OR export the same vars: +# cp secrets.env.example secrets.env +# $EDITOR secrets.env +# +# Never commit secrets.env. Prefer file paths under koopa-admin-secrets +# for long-term storage; this file is for laptop one-shots / overrides. +# +# Load order: env already set > secrets.env > SECRETS_ROOT files > SSH koopa + +# --- Preferred: point at secrets tree (no passwords in this file) --- +# Sibling of admin-log on this machine: +# KOOPA_ADMIN_SECRETS=$HOME/src/koopa/koopa-admin-secrets +# Or host-root only: +# SECRETS_ROOT=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root + +# --- Or paste passwords / tokens (local GOA e2e) --- +# Bank admin (libeufin-bank admin) — funds / creates accounts in e2e +# E2E_BANK_ADMIN_PASS= +# BANK_ADMIN_PASS= # alias, same meaning + +# Merchant instance token for private orders (goa-demo-cp4zqk on hacktivism) +# Full "secret-token:…" or raw secret (Bearer prefix added by e2e) +# E2E_MERCHANT_TOKEN= +# MERCHANT_TOKEN= +# MERCHANT_INSTANCE_PASSWORD= + +# Optional overrides +# MERCHANT_INSTANCE=goa-demo-cp4zqk +# EXPECT_CURRENCY=GOA +# E2E_TIMEOUT=340 +# PROGRESS_TOTAL=120 +# PROGRESS_SHOW_EVERY=8 +# PROGRESS_OFF=1 + +# Values that typically appear on a full stack (for orientation only): +# bank-admin-password.txt +# bank-explorer-password.txt +# bank-exchange-password.txt +# bank-goa-demo-cp4zqk-password.txt +# merchant-goa-demo-cp4zqk-password.txt +# exchange-attribute-encryption.secret.conf +# (see ../SECRETS.md and koopa-admin-secrets/MAP.md) diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 52d3d29..04af0b9 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -66,6 +66,13 @@ EOF # shellcheck source=lib.sh source "$ROOT/lib.sh" +# Optional gitignored secrets.env next to this suite (or MONITORING_SECRETS_ENV=) +load_monitoring_secrets_env 2>/dev/null || true + +# Persist #NNN + progress across check_*.sh child processes +TALER_MON_STATE=$(mktemp "${TMPDIR:-/tmp}/taler-mon-state.XXXXXX") +export TALER_MON_STATE +trap 'rm -f "${TALER_MON_STATE:-}"' EXIT PHASES=() DOMAIN_SET=0 @@ -212,11 +219,30 @@ if [ "${#PHASES[@]}" -eq 0 ]; then PHASES=(urls) fi +# Rough expected check counts for progress % (override: PROGRESS_TOTAL=N) +if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then + _pt=0 + for p in "${PHASES[@]}"; do + case "$p" in + urls) _pt=$((_pt + 55)) ;; + inside) _pt=$((_pt + 25)) ;; + versions) _pt=$((_pt + 20)) ;; + sanity) _pt=$((_pt + 30)) ;; + server) _pt=$((_pt + 15)) ;; + e2e) _pt=$((_pt + 90)) ;; + ladder|goa-ladder) _pt=$((_pt + 80)) ;; + esac + done + set_progress_total "$_pt" + unset _pt +fi + printf 'target domain=%s\n' "${TALER_DOMAIN}" printf ' bank %s\n' "$BANK_PUBLIC" printf ' exchange %s\n' "$EXCHANGE_PUBLIC" printf ' merchant %s\n' "$MERCHANT_PUBLIC" printf ' currency expect=%s skip_ssh=%s\n' "${EXPECT_CURRENCY:-any}" "$SKIP_SSH" +printf ' progress total≈%s (set PROGRESS_TOTAL= to override; PROGRESS_OFF=1 to hide)\n' "${PROGRESS_TOTAL:-0}" chmod +x "$ROOT"/check_*.sh 2>/dev/null || true From 7c5ed2887c6e57881ea52c411f067240c6bcd552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:20:41 +0200 Subject: [PATCH 189/326] monitoring: clearer severity colours with #NNN lines --- scripts/taler-monitoring/README.md | 4 +- scripts/taler-monitoring/lib.sh | 68 ++++++++++++++++-------------- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 3bd5f6a..abb55d3 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -61,7 +61,9 @@ Each check prints **global** and **grouped** ids: - `#014` — run-wide counter (whole `./taler-monitoring.sh` invocation) - `www.exchange-03` — area.group-NN -- **WARN** badge = **yellow**; **ERROR** = **red** only +- **Colours (left badge + label):** + - **OK** green · **INFO** blue/cyan · **WARN** yellow (never red) + - **ERROR** red · **BLOCKER** magenta · **PROG** cyan bar - Progress: auto estimate per phase, or `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1` ## Commands diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 1e14fe3..585d223 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -359,30 +359,36 @@ koopa_ssh_python() { with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" python3 - } -# ANSI colours — boxed badges (fg+bg) + body (label bold, detail dim). +# ANSI colours — left badge (clear severity) + body label colour + dim detail. # Off: NO_COLOR=1 or CLICOLOR=0. +# OK green badge · green label +# INFO blue badge · cyan label +# WARN yellow badge · yellow label (never red) +# ERROR red badge · red label +# BLOCKER magenta badge· magenta label +# PROG cyan bar line if [ "${NO_COLOR:-0}" = "1" ] || [ "${CLICOLOR:-1}" = "0" ]; then G= R= Y= C= M= D= W= B= N= - BG_OK= BG_INFO= BG_WARN= BG_ERR= BG_BLK= BG_SEC= + BG_OK= BG_INFO= BG_WARN= BG_ERR= BG_BLK= BG_SEC= BG_PROG= BOX=0 else - G=$'\e[1;32m' # green text (totals) - R=$'\e[1;31m' # red text - Y=$'\e[1;33m' # yellow text - C=$'\e[1;36m' # cyan text - M=$'\e[1;35m' # magenta text - D=$'\e[2m' # dim tid / detail - W=$'\e[1;37m' # bold white label - B=$'\e[1m' # bold section - N=$'\e[0m' # reset - # Badge fill: bright text on solid background - # WARN = yellow only (never red). ERROR/BLOCKER use red/magenta. - BG_OK=$'\e[1;30;42m' # black on green - BG_INFO=$'\e[1;30;46m' # black on cyan - BG_WARN=$'\e[1;30;103m' # black on bright yellow (clearly not red) - BG_ERR=$'\e[1;37;41m' # white on red — errors only - BG_BLK=$'\e[1;37;45m' # white on magenta — blockers - BG_SEC=$'\e[1;37;44m' # white on blue (section) + G=$'\e[1;32m' # green + R=$'\e[1;31m' # red + Y=$'\e[1;33m' # yellow + C=$'\e[1;36m' # cyan + M=$'\e[1;35m' # magenta + D=$'\e[2m' # dim tid / detail + W=$'\e[1;37m' # bold white + B=$'\e[1m' # bold + N=$'\e[0m' # reset + # High-contrast left badges (bright white / black on strong bg) + BG_OK=$'\e[1;97;42m' # white on green + BG_INFO=$'\e[1;97;44m' # white on blue + BG_WARN=$'\e[1;30;103m' # black on bright yellow — not red + BG_ERR=$'\e[1;97;41m' # white on red — errors only + BG_BLK=$'\e[1;97;45m' # white on magenta — blockers + BG_SEC=$'\e[1;97;44m' # white on blue (section) + BG_PROG=$'\e[1;30;106m' # black on bright cyan (progress) BOX=1 fi @@ -511,17 +517,17 @@ _progress_bar_line() { i=0 while [ "$i" -lt "$empty" ]; do bar="${bar}░"; i=$((i + 1)); done if [ "${BOX:-0}" = "1" ]; then - printf '%s%s┌ PROG ┐%s %s%s%s %s%3d%%%s %s%d/%d%s\n' \ - "$D" "$C" "$N" "$C" "$bar" "$N" "$W" "$pct" "$N" "$D" "$done" "$total" "$N" + printf '%s┌ PROG ┐%s %s%s%s %s%3d%%%s %s%d/%d%s\n' \ + "$BG_PROG" "$N" "$C" "$bar" "$N" "$W" "$pct" "$N" "$D" "$done" "$total" "$N" else - printf -- '[ PROG ] [%s] %3d%% %d/%d\n' "$bar" "$pct" "$done" "$total" + printf -- '%s[ PROG ]%s [%s] %3d%% %d/%d\n' "$C" "$N" "$bar" "$pct" "$done" "$total" fi else if [ "${BOX:-0}" = "1" ]; then - printf '%s%s┌ PROG ┐%s %sdone=%d%s (total unknown — set PROGRESS_TOTAL=)\n' \ - "$D" "$C" "$N" "$D" "$done" "$N" + printf '%s┌ PROG ┐%s %sdone=%d%s (total unknown — set PROGRESS_TOTAL=)\n' \ + "$BG_PROG" "$N" "$D" "$done" "$N" else - printf -- '[ PROG ] done=%d (total unknown)\n' "$done" + printf -- '%s[ PROG ]%s done=%d (total unknown)\n' "$C" "$N" "$done" fi fi } @@ -594,14 +600,14 @@ ok() { # ok "what is good" ["concrete evidence: HTTP 200 · 12ms · …"] local label="$1" detail="${2:-}" _take_tid - _msg_line "$BG_OK" "OK" "$W" "$label" "$detail" + _msg_line "$BG_OK" "OK" "$G" "$label" "$detail" PASS_N=$((PASS_N + 1)) } # component-scoped error: err bank "what failed" "why / HTTP / path" err() { local comp="$1" msg="$2" detail="${3:-}" _take_tid - _msg_line "$BG_ERR" "ERROR" "$W" "${comp}: ${msg}" "$detail" + _msg_line "$BG_ERR" "ERROR" "$R" "${comp}: ${msg}" "$detail" FAIL_N=$((FAIL_N + 1)) ERRORS+=("${LAST_TID:+$LAST_TID }[$comp] $msg${detail:+ · $detail}") } @@ -609,13 +615,14 @@ err() { fail() { local label="$1" detail="${2:-}" _take_tid - _msg_line "$BG_ERR" "ERROR" "$W" "$label" "$detail" + _msg_line "$BG_ERR" "ERROR" "$R" "$label" "$detail" FAIL_N=$((FAIL_N + 1)) ERRORS+=("${LAST_TID:+$LAST_TID }$label${detail:+ · $detail}") } warn() { # warn "what is soft-bad" ["why still ok to continue"] # warn component "what" "why" + # Yellow only — never red (red = ERROR / BLOCKER path) local a1="${1:-}" a2="${2:-}" a3="${3:-}" local head detail _take_tid @@ -629,7 +636,6 @@ warn() { head="$a1" detail="" fi - # Label in yellow text; badge yellow — never red (red = ERROR only) _msg_line "$BG_WARN" "WARN" "$Y" "$head" "$detail" WARN_N=$((WARN_N + 1)) } @@ -637,14 +643,14 @@ info() { # info "topic" ["concrete fact / value / next step"] local label="$1" detail="${2:-}" _take_tid - _msg_line "$BG_INFO" "INFO" "$W" "$label" "$detail" + _msg_line "$BG_INFO" "INFO" "$C" "$label" "$detail" INFO_N=$((INFO_N + 1)) } blocker() { # Hard stop on pay/withdraw path: blocker "step" "why it cannot continue" local step="$1" msg="$2" _take_tid - _msg_line "$BG_BLK" "BLOCKER" "$W" "${step}" "$msg" + _msg_line "$BG_BLK" "BLOCKER" "$M" "${step}" "$msg" BLOCKERS+=("${LAST_TID:+$LAST_TID }[$step] $msg") FAIL_N=$((FAIL_N + 1)) ERRORS+=("BLOCKER ${LAST_TID:+$LAST_TID }[$step] $msg") From 55e4472cd3053472e72de3b717b350076ddeb23d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:20:41 +0200 Subject: [PATCH 190/326] monitoring: clearer severity colours with #NNN lines --- scripts/taler-monitoring/README.md | 4 +- scripts/taler-monitoring/lib.sh | 68 ++++++++++++++++-------------- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 3bd5f6a..abb55d3 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -61,7 +61,9 @@ Each check prints **global** and **grouped** ids: - `#014` — run-wide counter (whole `./taler-monitoring.sh` invocation) - `www.exchange-03` — area.group-NN -- **WARN** badge = **yellow**; **ERROR** = **red** only +- **Colours (left badge + label):** + - **OK** green · **INFO** blue/cyan · **WARN** yellow (never red) + - **ERROR** red · **BLOCKER** magenta · **PROG** cyan bar - Progress: auto estimate per phase, or `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1` ## Commands diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 1e14fe3..585d223 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -359,30 +359,36 @@ koopa_ssh_python() { with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "${KOOPA_SSH}" python3 - } -# ANSI colours — boxed badges (fg+bg) + body (label bold, detail dim). +# ANSI colours — left badge (clear severity) + body label colour + dim detail. # Off: NO_COLOR=1 or CLICOLOR=0. +# OK green badge · green label +# INFO blue badge · cyan label +# WARN yellow badge · yellow label (never red) +# ERROR red badge · red label +# BLOCKER magenta badge· magenta label +# PROG cyan bar line if [ "${NO_COLOR:-0}" = "1" ] || [ "${CLICOLOR:-1}" = "0" ]; then G= R= Y= C= M= D= W= B= N= - BG_OK= BG_INFO= BG_WARN= BG_ERR= BG_BLK= BG_SEC= + BG_OK= BG_INFO= BG_WARN= BG_ERR= BG_BLK= BG_SEC= BG_PROG= BOX=0 else - G=$'\e[1;32m' # green text (totals) - R=$'\e[1;31m' # red text - Y=$'\e[1;33m' # yellow text - C=$'\e[1;36m' # cyan text - M=$'\e[1;35m' # magenta text - D=$'\e[2m' # dim tid / detail - W=$'\e[1;37m' # bold white label - B=$'\e[1m' # bold section - N=$'\e[0m' # reset - # Badge fill: bright text on solid background - # WARN = yellow only (never red). ERROR/BLOCKER use red/magenta. - BG_OK=$'\e[1;30;42m' # black on green - BG_INFO=$'\e[1;30;46m' # black on cyan - BG_WARN=$'\e[1;30;103m' # black on bright yellow (clearly not red) - BG_ERR=$'\e[1;37;41m' # white on red — errors only - BG_BLK=$'\e[1;37;45m' # white on magenta — blockers - BG_SEC=$'\e[1;37;44m' # white on blue (section) + G=$'\e[1;32m' # green + R=$'\e[1;31m' # red + Y=$'\e[1;33m' # yellow + C=$'\e[1;36m' # cyan + M=$'\e[1;35m' # magenta + D=$'\e[2m' # dim tid / detail + W=$'\e[1;37m' # bold white + B=$'\e[1m' # bold + N=$'\e[0m' # reset + # High-contrast left badges (bright white / black on strong bg) + BG_OK=$'\e[1;97;42m' # white on green + BG_INFO=$'\e[1;97;44m' # white on blue + BG_WARN=$'\e[1;30;103m' # black on bright yellow — not red + BG_ERR=$'\e[1;97;41m' # white on red — errors only + BG_BLK=$'\e[1;97;45m' # white on magenta — blockers + BG_SEC=$'\e[1;97;44m' # white on blue (section) + BG_PROG=$'\e[1;30;106m' # black on bright cyan (progress) BOX=1 fi @@ -511,17 +517,17 @@ _progress_bar_line() { i=0 while [ "$i" -lt "$empty" ]; do bar="${bar}░"; i=$((i + 1)); done if [ "${BOX:-0}" = "1" ]; then - printf '%s%s┌ PROG ┐%s %s%s%s %s%3d%%%s %s%d/%d%s\n' \ - "$D" "$C" "$N" "$C" "$bar" "$N" "$W" "$pct" "$N" "$D" "$done" "$total" "$N" + printf '%s┌ PROG ┐%s %s%s%s %s%3d%%%s %s%d/%d%s\n' \ + "$BG_PROG" "$N" "$C" "$bar" "$N" "$W" "$pct" "$N" "$D" "$done" "$total" "$N" else - printf -- '[ PROG ] [%s] %3d%% %d/%d\n' "$bar" "$pct" "$done" "$total" + printf -- '%s[ PROG ]%s [%s] %3d%% %d/%d\n' "$C" "$N" "$bar" "$pct" "$done" "$total" fi else if [ "${BOX:-0}" = "1" ]; then - printf '%s%s┌ PROG ┐%s %sdone=%d%s (total unknown — set PROGRESS_TOTAL=)\n' \ - "$D" "$C" "$N" "$D" "$done" "$N" + printf '%s┌ PROG ┐%s %sdone=%d%s (total unknown — set PROGRESS_TOTAL=)\n' \ + "$BG_PROG" "$N" "$D" "$done" "$N" else - printf -- '[ PROG ] done=%d (total unknown)\n' "$done" + printf -- '%s[ PROG ]%s done=%d (total unknown)\n' "$C" "$N" "$done" fi fi } @@ -594,14 +600,14 @@ ok() { # ok "what is good" ["concrete evidence: HTTP 200 · 12ms · …"] local label="$1" detail="${2:-}" _take_tid - _msg_line "$BG_OK" "OK" "$W" "$label" "$detail" + _msg_line "$BG_OK" "OK" "$G" "$label" "$detail" PASS_N=$((PASS_N + 1)) } # component-scoped error: err bank "what failed" "why / HTTP / path" err() { local comp="$1" msg="$2" detail="${3:-}" _take_tid - _msg_line "$BG_ERR" "ERROR" "$W" "${comp}: ${msg}" "$detail" + _msg_line "$BG_ERR" "ERROR" "$R" "${comp}: ${msg}" "$detail" FAIL_N=$((FAIL_N + 1)) ERRORS+=("${LAST_TID:+$LAST_TID }[$comp] $msg${detail:+ · $detail}") } @@ -609,13 +615,14 @@ err() { fail() { local label="$1" detail="${2:-}" _take_tid - _msg_line "$BG_ERR" "ERROR" "$W" "$label" "$detail" + _msg_line "$BG_ERR" "ERROR" "$R" "$label" "$detail" FAIL_N=$((FAIL_N + 1)) ERRORS+=("${LAST_TID:+$LAST_TID }$label${detail:+ · $detail}") } warn() { # warn "what is soft-bad" ["why still ok to continue"] # warn component "what" "why" + # Yellow only — never red (red = ERROR / BLOCKER path) local a1="${1:-}" a2="${2:-}" a3="${3:-}" local head detail _take_tid @@ -629,7 +636,6 @@ warn() { head="$a1" detail="" fi - # Label in yellow text; badge yellow — never red (red = ERROR only) _msg_line "$BG_WARN" "WARN" "$Y" "$head" "$detail" WARN_N=$((WARN_N + 1)) } @@ -637,14 +643,14 @@ info() { # info "topic" ["concrete fact / value / next step"] local label="$1" detail="${2:-}" _take_tid - _msg_line "$BG_INFO" "INFO" "$W" "$label" "$detail" + _msg_line "$BG_INFO" "INFO" "$C" "$label" "$detail" INFO_N=$((INFO_N + 1)) } blocker() { # Hard stop on pay/withdraw path: blocker "step" "why it cannot continue" local step="$1" msg="$2" _take_tid - _msg_line "$BG_BLK" "BLOCKER" "$W" "${step}" "$msg" + _msg_line "$BG_BLK" "BLOCKER" "$M" "${step}" "$msg" BLOCKERS+=("${LAST_TID:+$LAST_TID }[$step] $msg") FAIL_N=$((FAIL_N + 1)) ERRORS+=("BLOCKER ${LAST_TID:+$LAST_TID }[$step] $msg") From 37ea4bb8396691e3ea69ae61374e6623bf6c5ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:27:47 +0200 Subject: [PATCH 191/326] landing(bank): fix monorepo browse/clone links Point CLI docs at git.taler.net browse + git.gnunet.org clone (old .git URL was HTTP 404 for link checks). --- configs/bank-landing/index.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index 3f31226..af1113d 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1260,12 +1260,13 @@ tw run-until-done && tw balance

Unix / macOS · - taler-typescript-core + taler-typescript-core · mint demo-withdraw.json

# install: git + node (LTS) + build tools; then monorepo
-git clone https://git.taler.net/taler-typescript-core.git && cd taler-typescript-core && ./bootstrap && ./configure && make
+# clone: HTTPS git.gnunet.org (browse: git.taler.net/taler/taler-typescript-core)
+git clone https://git.gnunet.org/taler-typescript-core.git && cd taler-typescript-core && ./bootstrap && ./configure && make
 
 # monorepo CLI + private wallet DB (not system package / default home DB)
 alias tw='node packages/taler-wallet-cli/bin/taler-wallet-cli.mjs --wallet-db="${TMPDIR:-/tmp}/taler-wallet-goa.sqlite3" --no-throttle' && \

From 5f86b660e71045fa2514303f911a8d14851c80c2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= 
Date: Fri, 17 Jul 2026 18:27:47 +0200
Subject: [PATCH 192/326] landing(bank): fix monorepo browse/clone links

Point CLI docs at git.taler.net browse + git.gnunet.org clone
(old .git URL was HTTP 404 for link checks).
---
 configs/bank-landing/index.html | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html
index 3f31226..af1113d 100644
--- a/configs/bank-landing/index.html
+++ b/configs/bank-landing/index.html
@@ -1260,12 +1260,13 @@ tw run-until-done && tw balance

Unix / macOS · - taler-typescript-core + taler-typescript-core · mint demo-withdraw.json

# install: git + node (LTS) + build tools; then monorepo
-git clone https://git.taler.net/taler-typescript-core.git && cd taler-typescript-core && ./bootstrap && ./configure && make
+# clone: HTTPS git.gnunet.org (browse: git.taler.net/taler/taler-typescript-core)
+git clone https://git.gnunet.org/taler-typescript-core.git && cd taler-typescript-core && ./bootstrap && ./configure && make
 
 # monorepo CLI + private wallet DB (not system package / default home DB)
 alias tw='node packages/taler-wallet-cli/bin/taler-wallet-cli.mjs --wallet-db="${TMPDIR:-/tmp}/taler-wallet-goa.sqlite3" --no-throttle' && \

From 74a38ac1fb6b02f803b987a02c3118c931ce8c49 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= 
Date: Fri, 17 Jul 2026 18:27:53 +0200
Subject: [PATCH 193/326] landing: stamp footers to 37ea4bb (bank v43)

Refresh commit links so git.hacktivism.ch footer URLs resolve.
---
 configs/bank-landing/index.html     |  8 ++++----
 configs/exchange-landing/index.html |  6 +++---
 configs/merchant-landing/index.html |  6 +++---
 configs/shared/landing-version.json | 32 ++++++++++++++---------------
 4 files changed, 26 insertions(+), 26 deletions(-)

diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html
index af1113d..8c39d96 100644
--- a/configs/bank-landing/index.html
+++ b/configs/bank-landing/index.html
@@ -1557,10 +1557,10 @@ tw run-until-done && tw balance
/privacy.

-

- Bank landing v42 - · content - · 921a4f1 +

+ Bank landing v43 + · content + · 37ea4bb

diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index 0101601..ddeb72c 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -490,10 +490,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Exchange landing v42 - · content - · 921a4f1 + · content + · 37ea4bb

diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html index 96ab5eb..77330f8 100644 --- a/configs/merchant-landing/index.html +++ b/configs/merchant-landing/index.html @@ -833,10 +833,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Merchant landing v42 - · content - · 921a4f1 + · content + · 37ea4bb

diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index 7d1de54..e28682f 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -2,34 +2,34 @@ "note": "Per-site landing content versions (start 42). Bump: scripts/taler-landing/stamp-landing-version.sh --bump bank|exchange|merchant", "sites": { "bank": { - "version": 42, + "version": 43, "label": "Bank landing", "path": "configs/bank-landing", - "updated_iso": "2026-07-17T13:32:59+02:00", - "updated_human": "2026-07-17 13:32 CEST", - "commit": "921a4f1572dbf4391b69899287b4c0280bb0e271", - "commit_short": "921a4f1", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/921a4f1572dbf4391b69899287b4c0280bb0e271" + "updated_iso": "2026-07-17T18:27:48+02:00", + "updated_human": "2026-07-17 18:27 CEST", + "commit": "37ea4bb8396691e3ea69ae61374e6623bf6c5ea7", + "commit_short": "37ea4bb", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/37ea4bb8396691e3ea69ae61374e6623bf6c5ea7" }, "exchange": { "version": 42, "label": "Exchange landing", "path": "configs/exchange-landing", - "updated_iso": "2026-07-17T13:32:59+02:00", - "updated_human": "2026-07-17 13:32 CEST", - "commit": "921a4f1572dbf4391b69899287b4c0280bb0e271", - "commit_short": "921a4f1", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/921a4f1572dbf4391b69899287b4c0280bb0e271" + "updated_iso": "2026-07-17T18:27:48+02:00", + "updated_human": "2026-07-17 18:27 CEST", + "commit": "37ea4bb8396691e3ea69ae61374e6623bf6c5ea7", + "commit_short": "37ea4bb", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/37ea4bb8396691e3ea69ae61374e6623bf6c5ea7" }, "merchant": { "version": 42, "label": "Merchant landing", "path": "configs/merchant-landing", - "updated_iso": "2026-07-17T13:32:59+02:00", - "updated_human": "2026-07-17 13:32 CEST", - "commit": "921a4f1572dbf4391b69899287b4c0280bb0e271", - "commit_short": "921a4f1", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/921a4f1572dbf4391b69899287b4c0280bb0e271" + "updated_iso": "2026-07-17T18:27:48+02:00", + "updated_human": "2026-07-17 18:27 CEST", + "commit": "37ea4bb8396691e3ea69ae61374e6623bf6c5ea7", + "commit_short": "37ea4bb", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/37ea4bb8396691e3ea69ae61374e6623bf6c5ea7" } } } From c7787e361a7c36084ee08a9bbf898f3ded37d205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:27:53 +0200 Subject: [PATCH 194/326] landing: stamp footers to 37ea4bb (bank v43) Refresh commit links so git.hacktivism.ch footer URLs resolve. --- configs/bank-landing/index.html | 8 ++++---- configs/exchange-landing/index.html | 6 +++--- configs/merchant-landing/index.html | 6 +++--- configs/shared/landing-version.json | 32 ++++++++++++++--------------- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index af1113d..8c39d96 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1557,10 +1557,10 @@ tw run-until-done && tw balance /privacy.

-

- Bank landing v42 - · content - · 921a4f1 +

+ Bank landing v43 + · content + · 37ea4bb

diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index 0101601..ddeb72c 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -490,10 +490,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Exchange landing v42 - · content - · 921a4f1 + · content + · 37ea4bb

diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html index 96ab5eb..77330f8 100644 --- a/configs/merchant-landing/index.html +++ b/configs/merchant-landing/index.html @@ -833,10 +833,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Merchant landing v42 - · content - · 921a4f1 + · content + · 37ea4bb

diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index 7d1de54..e28682f 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -2,34 +2,34 @@ "note": "Per-site landing content versions (start 42). Bump: scripts/taler-landing/stamp-landing-version.sh --bump bank|exchange|merchant", "sites": { "bank": { - "version": 42, + "version": 43, "label": "Bank landing", "path": "configs/bank-landing", - "updated_iso": "2026-07-17T13:32:59+02:00", - "updated_human": "2026-07-17 13:32 CEST", - "commit": "921a4f1572dbf4391b69899287b4c0280bb0e271", - "commit_short": "921a4f1", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/921a4f1572dbf4391b69899287b4c0280bb0e271" + "updated_iso": "2026-07-17T18:27:48+02:00", + "updated_human": "2026-07-17 18:27 CEST", + "commit": "37ea4bb8396691e3ea69ae61374e6623bf6c5ea7", + "commit_short": "37ea4bb", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/37ea4bb8396691e3ea69ae61374e6623bf6c5ea7" }, "exchange": { "version": 42, "label": "Exchange landing", "path": "configs/exchange-landing", - "updated_iso": "2026-07-17T13:32:59+02:00", - "updated_human": "2026-07-17 13:32 CEST", - "commit": "921a4f1572dbf4391b69899287b4c0280bb0e271", - "commit_short": "921a4f1", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/921a4f1572dbf4391b69899287b4c0280bb0e271" + "updated_iso": "2026-07-17T18:27:48+02:00", + "updated_human": "2026-07-17 18:27 CEST", + "commit": "37ea4bb8396691e3ea69ae61374e6623bf6c5ea7", + "commit_short": "37ea4bb", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/37ea4bb8396691e3ea69ae61374e6623bf6c5ea7" }, "merchant": { "version": 42, "label": "Merchant landing", "path": "configs/merchant-landing", - "updated_iso": "2026-07-17T13:32:59+02:00", - "updated_human": "2026-07-17 13:32 CEST", - "commit": "921a4f1572dbf4391b69899287b4c0280bb0e271", - "commit_short": "921a4f1", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/921a4f1572dbf4391b69899287b4c0280bb0e271" + "updated_iso": "2026-07-17T18:27:48+02:00", + "updated_human": "2026-07-17 18:27 CEST", + "commit": "37ea4bb8396691e3ea69ae61374e6623bf6c5ea7", + "commit_short": "37ea4bb", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/37ea4bb8396691e3ea69ae61374e6623bf6c5ea7" } } } From 689ee595e08f3880c1e4068aac8e972574e20d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:31:38 +0200 Subject: [PATCH 195/326] landing(bank): clone monorepo via git:// (no HTTP-404 URL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link check scrapes pre blocks; bare https://…/*.git returns 404. --- configs/bank-landing/index.html | 13 +++++++------ configs/shared/landing-version.json | 12 ++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index 8c39d96..a91e82f 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1265,8 +1265,9 @@ tw run-until-done && tw balance demo-withdraw.json

# install: git + node (LTS) + build tools; then monorepo
-# clone: HTTPS git.gnunet.org (browse: git.taler.net/taler/taler-typescript-core)
-git clone https://git.gnunet.org/taler-typescript-core.git && cd taler-typescript-core && ./bootstrap && ./configure && make
+# browse: https://git.taler.net/taler/taler-typescript-core
+# clone (SSH; anonymous git:// also works on gnunet.org):
+git clone git://git.gnunet.org/taler-typescript-core.git && cd taler-typescript-core && ./bootstrap && ./configure && make
 
 # monorepo CLI + private wallet DB (not system package / default home DB)
 alias tw='node packages/taler-wallet-cli/bin/taler-wallet-cli.mjs --wallet-db="${TMPDIR:-/tmp}/taler-wallet-goa.sqlite3" --no-throttle' && \
@@ -1557,10 +1558,10 @@ tw run-until-done && tw balance
/privacy.

-

- Bank landing v43 - · content - · 37ea4bb +

+ Bank landing v44 + · content + · 74a38ac

diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index e28682f..7e837a4 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -2,14 +2,14 @@ "note": "Per-site landing content versions (start 42). Bump: scripts/taler-landing/stamp-landing-version.sh --bump bank|exchange|merchant", "sites": { "bank": { - "version": 43, + "version": 44, "label": "Bank landing", "path": "configs/bank-landing", - "updated_iso": "2026-07-17T18:27:48+02:00", - "updated_human": "2026-07-17 18:27 CEST", - "commit": "37ea4bb8396691e3ea69ae61374e6623bf6c5ea7", - "commit_short": "37ea4bb", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/37ea4bb8396691e3ea69ae61374e6623bf6c5ea7" + "updated_iso": "2026-07-17T18:31:38+02:00", + "updated_human": "2026-07-17 18:31 CEST", + "commit": "74a38ac1fb6b02f803b987a02c3118c931ce8c49", + "commit_short": "74a38ac", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/74a38ac1fb6b02f803b987a02c3118c931ce8c49" }, "exchange": { "version": 42, From bb9a34853abf53c242b4231e78ef7274cb655e56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:31:38 +0200 Subject: [PATCH 196/326] landing(bank): clone monorepo via git:// (no HTTP-404 URL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link check scrapes pre blocks; bare https://…/*.git returns 404. --- configs/bank-landing/index.html | 13 +++++++------ configs/shared/landing-version.json | 12 ++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index 8c39d96..a91e82f 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1265,8 +1265,9 @@ tw run-until-done && tw balance demo-withdraw.json

# install: git + node (LTS) + build tools; then monorepo
-# clone: HTTPS git.gnunet.org (browse: git.taler.net/taler/taler-typescript-core)
-git clone https://git.gnunet.org/taler-typescript-core.git && cd taler-typescript-core && ./bootstrap && ./configure && make
+# browse: https://git.taler.net/taler/taler-typescript-core
+# clone (SSH; anonymous git:// also works on gnunet.org):
+git clone git://git.gnunet.org/taler-typescript-core.git && cd taler-typescript-core && ./bootstrap && ./configure && make
 
 # monorepo CLI + private wallet DB (not system package / default home DB)
 alias tw='node packages/taler-wallet-cli/bin/taler-wallet-cli.mjs --wallet-db="${TMPDIR:-/tmp}/taler-wallet-goa.sqlite3" --no-throttle' && \
@@ -1557,10 +1558,10 @@ tw run-until-done && tw balance
/privacy.

-

- Bank landing v43 - · content - · 37ea4bb +

+ Bank landing v44 + · content + · 74a38ac

diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index e28682f..7e837a4 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -2,14 +2,14 @@ "note": "Per-site landing content versions (start 42). Bump: scripts/taler-landing/stamp-landing-version.sh --bump bank|exchange|merchant", "sites": { "bank": { - "version": 43, + "version": 44, "label": "Bank landing", "path": "configs/bank-landing", - "updated_iso": "2026-07-17T18:27:48+02:00", - "updated_human": "2026-07-17 18:27 CEST", - "commit": "37ea4bb8396691e3ea69ae61374e6623bf6c5ea7", - "commit_short": "37ea4bb", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/37ea4bb8396691e3ea69ae61374e6623bf6c5ea7" + "updated_iso": "2026-07-17T18:31:38+02:00", + "updated_human": "2026-07-17 18:31 CEST", + "commit": "74a38ac1fb6b02f803b987a02c3118c931ce8c49", + "commit_short": "74a38ac", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/74a38ac1fb6b02f803b987a02c3118c931ce8c49" }, "exchange": { "version": 42, From 6fde278a3a9e290e327a6b54df78da7882ae8e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:31:49 +0200 Subject: [PATCH 197/326] landing(bank): restamp footer to 689ee59 --- configs/bank-landing/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index a91e82f..09d1c5d 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1558,10 +1558,10 @@ tw run-until-done && tw balance /privacy.

-

+

Bank landing v44 - · content - · 74a38ac + · content + · 689ee59

From 96bd854d89fb02adc78682c193ae04b8f7a393dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:31:49 +0200 Subject: [PATCH 198/326] landing(bank): restamp footer to 689ee59 --- configs/bank-landing/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html index a91e82f..09d1c5d 100644 --- a/configs/bank-landing/index.html +++ b/configs/bank-landing/index.html @@ -1558,10 +1558,10 @@ tw run-until-done && tw balance /privacy.

-

+

Bank landing v44 - · content - · 74a38ac + · content + · 689ee59

From 5d80bfb905a045bbf2c064234bacfb4433f02b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:32:35 +0200 Subject: [PATCH 199/326] landing: sync landing-version.json (bank v44) --- configs/shared/landing-version.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index 7e837a4..78e7997 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -5,11 +5,11 @@ "version": 44, "label": "Bank landing", "path": "configs/bank-landing", - "updated_iso": "2026-07-17T18:31:38+02:00", + "updated_iso": "2026-07-17T18:31:49+02:00", "updated_human": "2026-07-17 18:31 CEST", - "commit": "74a38ac1fb6b02f803b987a02c3118c931ce8c49", - "commit_short": "74a38ac", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/74a38ac1fb6b02f803b987a02c3118c931ce8c49" + "commit": "689ee595e08f3880c1e4068aac8e972574e20d21", + "commit_short": "689ee59", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/689ee595e08f3880c1e4068aac8e972574e20d21" }, "exchange": { "version": 42, From 106e376ee21d1e86f1f633729e1b2a02ad51aa1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:32:35 +0200 Subject: [PATCH 200/326] landing: sync landing-version.json (bank v44) --- configs/shared/landing-version.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index 7e837a4..78e7997 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -5,11 +5,11 @@ "version": 44, "label": "Bank landing", "path": "configs/bank-landing", - "updated_iso": "2026-07-17T18:31:38+02:00", + "updated_iso": "2026-07-17T18:31:49+02:00", "updated_human": "2026-07-17 18:31 CEST", - "commit": "74a38ac1fb6b02f803b987a02c3118c931ce8c49", - "commit_short": "74a38ac", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/74a38ac1fb6b02f803b987a02c3118c931ce8c49" + "commit": "689ee595e08f3880c1e4068aac8e972574e20d21", + "commit_short": "689ee59", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/689ee595e08f3880c1e4068aac8e972574e20d21" }, "exchange": { "version": 42, From d0594cb44ffb1bec2e009f6812167cbbf216eda9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:33:13 +0200 Subject: [PATCH 201/326] landing(exchange): link one-step withdraw to bank #withdraw Hero, CTA, and footer point at bank.hacktivism.ch/intro/#withdraw. --- configs/exchange-landing/index.html | 28 +++++++++++++++++++--------- configs/shared/landing-version.json | 12 ++++++------ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index ddeb72c..d9bf029 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -64,6 +64,8 @@ -webkit-background-clip: text; background-clip: text; color: transparent; } .sub { color: var(--muted); max-width: 32rem; margin: 0 auto; } + .sub a { color: #5eead4; font-weight: 650; text-decoration: underline; text-underline-offset: 2px; } + .sub a:hover { color: #99f6e4; } .badge { display: inline-block; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.04em; padding: 0.2rem 0.7rem; border-radius: 999px; margin-bottom: 0.75rem; @@ -303,7 +305,10 @@
GOA · Exchange

GOA Exchange

-

Explorational exchange — coins, denoms, one-step withdraw.

+

+ Explorational exchange — coins, denoms, + one-step withdraw. +

@@ -464,12 +469,16 @@ sudo apt-get install -y taler-wallet-cli
-
-

Withdraw GOA

-

Use the bank landing or wallet to add the exchange and withdraw.

+
+

Withdraw GOA

+

+ One-step withdraw from the shared bank account (no registration) — + live QR + link on the bank landing. +

- Bank landing → + One-step withdraw →
diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index 78e7997..dd79fc2 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -12,14 +12,14 @@ "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/689ee595e08f3880c1e4068aac8e972574e20d21" }, "exchange": { - "version": 42, + "version": 43, "label": "Exchange landing", "path": "configs/exchange-landing", - "updated_iso": "2026-07-17T18:27:48+02:00", - "updated_human": "2026-07-17 18:27 CEST", - "commit": "37ea4bb8396691e3ea69ae61374e6623bf6c5ea7", - "commit_short": "37ea4bb", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/37ea4bb8396691e3ea69ae61374e6623bf6c5ea7" + "updated_iso": "2026-07-17T18:33:13+02:00", + "updated_human": "2026-07-17 18:33 CEST", + "commit": "5d80bfb905a045bbf2c064234bacfb4433f02b8e", + "commit_short": "5d80bfb", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/5d80bfb905a045bbf2c064234bacfb4433f02b8e" }, "merchant": { "version": 42, From c4e8fe04228509df432b411cce4e7337cc133acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:33:13 +0200 Subject: [PATCH 202/326] landing(exchange): link one-step withdraw to bank #withdraw Hero, CTA, and footer point at bank.hacktivism.ch/intro/#withdraw. --- configs/exchange-landing/index.html | 28 +++++++++++++++++++--------- configs/shared/landing-version.json | 12 ++++++------ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index ddeb72c..d9bf029 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -64,6 +64,8 @@ -webkit-background-clip: text; background-clip: text; color: transparent; } .sub { color: var(--muted); max-width: 32rem; margin: 0 auto; } + .sub a { color: #5eead4; font-weight: 650; text-decoration: underline; text-underline-offset: 2px; } + .sub a:hover { color: #99f6e4; } .badge { display: inline-block; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.04em; padding: 0.2rem 0.7rem; border-radius: 999px; margin-bottom: 0.75rem; @@ -303,7 +305,10 @@
GOA · Exchange

GOA Exchange

-

Explorational exchange — coins, denoms, one-step withdraw.

+

+ Explorational exchange — coins, denoms, + one-step withdraw. +

@@ -464,12 +469,16 @@ sudo apt-get install -y taler-wallet-cli -
-

Withdraw GOA

-

Use the bank landing or wallet to add the exchange and withdraw.

+
+

Withdraw GOA

+

+ One-step withdraw from the shared bank account (no registration) — + live QR + link on the bank landing. +

- Bank landing → + One-step withdraw →
diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index 78e7997..dd79fc2 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -12,14 +12,14 @@ "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/689ee595e08f3880c1e4068aac8e972574e20d21" }, "exchange": { - "version": 42, + "version": 43, "label": "Exchange landing", "path": "configs/exchange-landing", - "updated_iso": "2026-07-17T18:27:48+02:00", - "updated_human": "2026-07-17 18:27 CEST", - "commit": "37ea4bb8396691e3ea69ae61374e6623bf6c5ea7", - "commit_short": "37ea4bb", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/37ea4bb8396691e3ea69ae61374e6623bf6c5ea7" + "updated_iso": "2026-07-17T18:33:13+02:00", + "updated_human": "2026-07-17 18:33 CEST", + "commit": "5d80bfb905a045bbf2c064234bacfb4433f02b8e", + "commit_short": "5d80bfb", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/5d80bfb905a045bbf2c064234bacfb4433f02b8e" }, "merchant": { "version": 42, From ff9c864a655216eb6ea0ae9fc76857ef690c51ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:33:13 +0200 Subject: [PATCH 203/326] landing(exchange): stamp v43 footer --- configs/exchange-landing/index.html | 4 ++-- configs/shared/landing-version.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index d9bf029..c57b486 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -500,10 +500,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Exchange landing v43 · content - · 5d80bfb + · d0594cb

diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index dd79fc2..722ff4e 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -17,9 +17,9 @@ "path": "configs/exchange-landing", "updated_iso": "2026-07-17T18:33:13+02:00", "updated_human": "2026-07-17 18:33 CEST", - "commit": "5d80bfb905a045bbf2c064234bacfb4433f02b8e", - "commit_short": "5d80bfb", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/5d80bfb905a045bbf2c064234bacfb4433f02b8e" + "commit": "d0594cb44ffb1bec2e009f6812167cbbf216eda9", + "commit_short": "d0594cb", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/d0594cb44ffb1bec2e009f6812167cbbf216eda9" }, "merchant": { "version": 42, From 026f3ac04cf81c9b20afba54be376c9800404e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:33:13 +0200 Subject: [PATCH 204/326] landing(exchange): stamp v43 footer --- configs/exchange-landing/index.html | 4 ++-- configs/shared/landing-version.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html index d9bf029..c57b486 100644 --- a/configs/exchange-landing/index.html +++ b/configs/exchange-landing/index.html @@ -500,10 +500,10 @@ sudo apt-get install -y taler-wallet-cli /privacy.

-

+

Exchange landing v43 · content - · 5d80bfb + · d0594cb

diff --git a/configs/shared/landing-version.json b/configs/shared/landing-version.json index dd79fc2..722ff4e 100644 --- a/configs/shared/landing-version.json +++ b/configs/shared/landing-version.json @@ -17,9 +17,9 @@ "path": "configs/exchange-landing", "updated_iso": "2026-07-17T18:33:13+02:00", "updated_human": "2026-07-17 18:33 CEST", - "commit": "5d80bfb905a045bbf2c064234bacfb4433f02b8e", - "commit_short": "5d80bfb", - "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/5d80bfb905a045bbf2c064234bacfb4433f02b8e" + "commit": "d0594cb44ffb1bec2e009f6812167cbbf216eda9", + "commit_short": "d0594cb", + "repo_commit_url": "https://git.hacktivism.ch/hernani/koopa-admin-log/commit/d0594cb44ffb1bec2e009f6812167cbbf216eda9" }, "merchant": { "version": 42, From 41bf5c786efdd65c5fd0101cb7fb93b1b639d54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:43:04 +0200 Subject: [PATCH 205/326] monitoring: QR form + qrencode/zbarimg roundtrip (www.qr) Harvest taler:// and payto:// (plus app-store links) from landings and mint JSON; validate shape; re-encode PNG and decode with zbarimg. --- scripts/taler-monitoring/README.md | 3 + scripts/taler-monitoring/TESTS.md | 5 +- scripts/taler-monitoring/check_qr_payloads.py | 291 ++++++++++++++++++ scripts/taler-monitoring/check_urls.sh | 228 ++++++++++++++ scripts/taler-monitoring/taler-monitoring.sh | 4 +- 5 files changed, 529 insertions(+), 2 deletions(-) create mode 100755 scripts/taler-monitoring/check_qr_payloads.py diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index abb55d3..878d106 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -65,6 +65,9 @@ Each check prints **global** and **grouped** ids: - **OK** green · **INFO** blue/cyan · **WARN** yellow (never red) - **ERROR** red · **BLOCKER** magenta · **PROG** cyan bar - Progress: auto estimate per phase, or `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1` +- **QR (urls):** harvest `taler://` / `payto://` (+ app store `data-qr-url`) from landings and + `demo-withdraw.json` / `auto-account.json` → validate form → `qrencode` PNG → `zbarimg` + decode must match. Requires packages **`qrencode`** + **`zbar-tools`**. ## Commands diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index b238c3c..7bd51c1 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -15,7 +15,7 @@ Every check line has a **global** run number and a **grouped** id: | Area | Phase script | Groups (examples) | |------|--------------|-------------------| -| **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` | +| **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` `qr` | | **inside** | `check_inside.sh` | `ssh` `bank` `exchange` `merchant` `caddy` `load` | | **versions** | `check_versions.sh` | `outside` `inside` `compare` | | **sanity** | `check_sanity.sh` | `bank` `exchange` `merchant` | @@ -54,6 +54,9 @@ Numbering follows **executed** checks (early skip may shift later NN inside the | **www.merchant-** | `/config` currency + currencies alt_unit_names; listed exchanges alt; webui/intro; **`/terms`**, **`/privacy`** | | **www.paivana-** | local GOA paywall front (redirect to template) | | **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; cross-links local | +| **www.qr-** | QR payloads: harvest `taler://` / `payto://` / app `data-qr-url` from landings + mint JSON; **form** check; **qrencode → zbarimg** exact roundtrip; optional static QR images. Needs `qrencode` + `zbar-tools`. Skip: `QR_CHECK=0` | + +**QR rule:** `taler://withdraw/HOST/taler-integration/UUID` (no default `:443`/`:80`); `taler://pay/` / `pay-template/`; `payto://…` shape OK; decoded PNG must equal payload. Form errors on withdraw/pay/payto are **ERROR** on local/GOA. **Legal docs rule:** HTTP 200, non-empty body, not plain `not configured`, not merchant API JSON `code:21`. Local stack may require content needle (terms/privacy/FADP/GOA…). diff --git a/scripts/taler-monitoring/check_qr_payloads.py b/scripts/taler-monitoring/check_qr_payloads.py new file mode 100755 index 0000000..7cd109d --- /dev/null +++ b/scripts/taler-monitoring/check_qr_payloads.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Collect + validate QR-related payloads for taler-monitoring (www.qr-*). + +Sources (CLI paths / URLs handled by caller writing local files): + - landing HTML (href, data-qr-url, data-qr-taler, raw text) + - demo-withdraw.json / auto-account.json (taler_withdraw_uri / qr_payload) + - optional: already-decoded lines from zbarimg (kind=decoded) + +Stdout TSV: source\\tkind\\turi\\tstatus\\tdetail + kind: withdraw | pay | pay-template | refund | payto | https | other | empty + status: ok | bad + +Exit 0 always (caller tallies ok/bad lines). stderr for hard parse errors only. +""" +from __future__ import annotations + +import json +import re +import sys +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlparse + +# Schemes we care about for wallet / settlement QRs +TALER_RE = re.compile(r"taler://[^\s\"'<>]+", re.I) +PAYTO_RE = re.compile(r"payto://[^\s\"'<>]+", re.I) +HTTPS_RE = re.compile(r"https://[^\s\"'<>]+", re.I) + +WITHDRAW_RE = re.compile( + r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)/?$" +) +# order pay / template — host may include path segments after +PAY_RE = re.compile(r"^taler://pay/([^/?#]+)/", re.I) +PAY_TEMPLATE_RE = re.compile(r"^taler://pay-template/([^/?#]+)/", re.I) +REFUND_RE = re.compile(r"^taler://refund/", re.I) +PAYTO_OK_RE = re.compile(r"^payto://[a-z0-9][a-z0-9+.-]*/", re.I) + + +def looks_like_real_uri(uri: str) -> bool: + """Drop JS regex fragments, docs ellipses, incomplete placeholders.""" + if not uri or len(uri) < 12: + return False + if any(c in uri for c in "[]{}<>\n\r\t"): + return False + if "…" in uri or "..." in uri: + return False + # incomplete withdraw stubs in docs + if re.match(r"^taler://withdraw/?$", uri, re.I): + return False + if re.match(r"^taler://pay/?$", uri, re.I): + return False + # trailing junk from HTML/JS + if uri.endswith((")", "(", "\\", "^", "*", "+", "?")): + return False + if re.search(r"[\\^$*+?]", uri): + return False + return True + + +def strip_default_port_host(host: str) -> str | None: + if not host or host.startswith(":"): + return None + if ":" in host: + h, _, p = host.rpartition(":") + if not h or not p.isdigit(): + return None + if p in ("443", "80"): + return None # must be stripped for wallets + return host + + +def classify_and_validate(uri: str) -> tuple[str, str, str]: + """Return (kind, status, detail).""" + uri = (uri or "").strip() + if not uri: + return "empty", "bad", "empty" + # strip accidental trailing punctuation from HTML scrape + uri = uri.rstrip(").,;]") + low = uri.lower() + + if low.startswith("taler://withdraw/"): + m = WITHDRAW_RE.match(uri) + if not m: + return "withdraw", "bad", "shape need taler://withdraw/HOST/taler-integration/UUID" + host = m.group(1) + if strip_default_port_host(host) is None: + return "withdraw", "bad", "host/port invalid or default :443/:80 not stripped" + return "withdraw", "ok", f"host={host} id={m.group(2)[:12]}…" + + if low.startswith("taler://pay-template/"): + m = PAY_TEMPLATE_RE.match(uri) + if not m: + return "pay-template", "bad", "need taler://pay-template/HOST/…" + host = m.group(1) + if strip_default_port_host(host) is None: + return "pay-template", "bad", "host/port invalid or default port not stripped" + return "pay-template", "ok", f"host={host}" + + if low.startswith("taler://pay/"): + m = PAY_RE.match(uri) + if not m: + return "pay", "bad", "need taler://pay/HOST/…" + host = m.group(1) + if strip_default_port_host(host) is None: + return "pay", "bad", "host/port invalid or default port not stripped" + if "instances" not in uri and "/orders/" not in uri: + # still often valid private order URIs + return "pay", "ok", f"host={host}" + return "pay", "ok", f"host={host}" + + if low.startswith("taler://refund/"): + if not REFUND_RE.match(uri): + return "refund", "bad", "bad refund URI" + return "refund", "ok", "refund" + + if low.startswith("taler://"): + return "other", "ok", "other taler:// (accepted)" + + if low.startswith("payto://"): + if not PAYTO_OK_RE.match(uri): + return "payto", "bad", "need payto://method/…" + # wallet pay QR must not be payto for GOA demo-withdraw (checked elsewhere) + return "payto", "ok", uri.split("?", 1)[0][:72] + + if low.startswith("https://") or low.startswith("http://"): + p = urlparse(uri) + if p.scheme not in ("http", "https") or not p.netloc: + return "https", "bad", "bad URL" + return "https", "ok", p.netloc + (p.path or "/")[:40] + + return "other", "bad", "unsupported scheme" + + +class LinkHarvester(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.uris: list[tuple[str, str]] = [] # (source_hint, uri) + + def handle_starttag(self, tag, attrs): + ad = {k.lower(): (v or "") for k, v in attrs} + for key in ("href", "src", "data-qr-url", "data-qr-taler", "data-uri", "data-payto"): + if key in ad and ad[key]: + self.uris.append((f"attr:{key}", ad[key].strip())) + for k, v in ad.items(): + if k.startswith("data-") and v and ( + "taler://" in v.lower() or "payto://" in v.lower() or k.endswith("qr-url") + ): + self.uris.append((f"attr:{k}", v.strip())) + + def handle_data(self, data): + if not data or "://" not in data: + return + for m in TALER_RE.finditer(data): + self.uris.append(("text", m.group(0))) + for m in PAYTO_RE.finditer(data): + self.uris.append(("text", m.group(0))) + + +def harvest_html(path: Path, source: str) -> list[tuple[str, str, str]]: + raw = path.read_text(encoding="utf-8", errors="replace") + h = LinkHarvester() + try: + h.feed(raw) + except Exception: + pass + out: list[tuple[str, str, str]] = [] + for hint, u in h.uris: + lu = u.lower() + if lu.startswith(("taler://", "payto://", "https://", "http://")): + if looks_like_real_uri(u): + out.append((f"{source}:{hint}", u, "html")) + # raster image paths that look like QR assets (not .js) + elif re.search(r"\.(png|jpe?g|gif)(\?|$)", lu) and "qr" in lu: + out.append((f"{source}:imgpath", u, "imgpath")) + # Prefer attribute/text harvest; light regex only for complete-looking URIs in pre/code + for m in TALER_RE.finditer(raw): + u = m.group(0).rstrip(").,;]'\"") + if looks_like_real_uri(u) and ( + WITHDRAW_RE.match(u) + or PAY_RE.match(u) + or PAY_TEMPLATE_RE.match(u) + or u.lower().startswith("taler://refund/") + ): + out.append((f"{source}:re", u, "html")) + for m in PAYTO_RE.finditer(raw): + u = m.group(0).rstrip(").,;]'\"") + if looks_like_real_uri(u) and PAYTO_OK_RE.match(u): + out.append((f"{source}:re", u, "html")) + return out + + +def harvest_json(path: Path, source: str) -> list[tuple[str, str, str]]: + try: + d = json.loads(path.read_text(encoding="utf-8", errors="replace")) + except Exception as e: + return [(source, "", f"json-error:{e}")] + out: list[tuple[str, str, str]] = [] + if not isinstance(d, dict): + return out + for key in ( + "taler_withdraw_uri", + "qr_payload", + "taler_pay_uri", + "payto_uri", + "payto", + "uri", + ): + v = d.get(key) + if isinstance(v, str) and v.strip(): + out.append((f"{source}:{key}", v.strip(), "json")) + # nested + for k, v in d.items(): + if isinstance(v, str) and ( + v.startswith("taler://") or v.startswith("payto://") + ): + out.append((f"{source}:{k}", v.strip(), "json")) + return out + + +def main(argv: list[str]) -> int: + # args: pairs of source_label path + if len(argv) < 3 or len(argv) % 2 == 0: + print( + "usage: check_qr_payloads.py LABEL path [LABEL path ...]", + file=sys.stderr, + ) + return 2 + seen: set[str] = set() + rows: list[tuple[str, str, str, str, str]] = [] + i = 1 + while i < len(argv): + label, path_s = argv[i], argv[i + 1] + i += 2 + path = Path(path_s) + if not path.is_file(): + rows.append((label, "empty", "", "bad", f"missing file {path_s}")) + continue + if path.suffix.lower() in (".html", ".htm") or "html" in path.name: + items = harvest_html(path, label) + elif path.suffix.lower() == ".json" or path.name.endswith(".json"): + items = harvest_json(path, label) + else: + # plain text: one URI per line or raw + text = path.read_text(encoding="utf-8", errors="replace").strip() + items = [] + for line in text.splitlines(): + line = line.strip() + if line: + items.append((label, line, "text")) + if not items and text: + items.append((label, text, "text")) + + for src, uri, _origin in items: + if not uri: + continue + # skip pure fragment / relative without scheme for validate + if uri.startswith("#") or uri == "/": + continue + lu = uri.lower() + if lu.startswith("/") or "imgpath" in src: + continue + if not looks_like_real_uri(uri): + continue + # https: app-store / wallet / explicit data-qr-* only + if lu.startswith("http"): + if "data-qr" not in src and not any( + x in lu + for x in ( + "play.google", + "apps.apple", + "f-droid", + "wallet.taler", + "taler.net/wallet", + ) + ): + continue + if uri in seen: + continue + seen.add(uri) + kind, status, detail = classify_and_validate(uri) + rows.append((src, kind, uri, status, detail)) + + for src, kind, uri, status, detail in rows: + # TSV — uri may contain tabs rarely; replace + safe = uri.replace("\t", " ").replace("\n", " ") + print(f"{src}\t{kind}\t{safe}\t{status}\t{detail}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index 55ed1b8..0ae5700 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -1193,4 +1193,232 @@ else warn "landing merchant shop assets" "${_ma}/2 present (soft)" fi +# --------------------------------------------------------------------------- +# QR payloads — form + encode/decode roundtrip (qrencode + zbarimg) +# Collect taler:// + payto:// (+ app-store https from data-qr-url) from landings +# and bank mint JSON; re-encode PNG; decode; require exact payload match. +# Env: QR_CHECK=0 to skip; QR_ECC=M (default) for qrencode -l +# --------------------------------------------------------------------------- +if [ "${QR_CHECK:-1}" = "1" ] && [ "${CHECK_LANDING:-1}" = "1" ]; then + set_group qr + section "www · QR payloads (form + encode/decode · taler:// payto://)" + + QR_ECC="${QR_ECC:-M}" + qr_dir="$tmp/qr-check" + mkdir -p "$qr_dir" + _qr_tools=1 + if ! command -v qrencode >/dev/null 2>&1; then + warn "qr tools" "qrencode missing — form checks only (apt install qrencode)" + _qr_tools=0 + fi + if ! command -v zbarimg >/dev/null 2>&1; then + warn "qr tools" "zbarimg missing — form checks only (apt install zbar-tools)" + _qr_tools=0 + fi + if [ "$_qr_tools" = "1" ]; then + ok "qr tools" "qrencode + zbarimg" + fi + + # Fetch sources (HTML landings + mint JSON when present) + _qr_args=() + for pair in \ + "bank-intro|$BANK_PUBLIC/intro/" \ + "merchant-intro|$MERCHANT_PUBLIC/intro/" \ + "exchange-intro|$EXCHANGE_PUBLIC/intro/" + do + _qn="${pair%%|*}" + _qu="${pair#*|}" + _qf="$qr_dir/${_qn}.html" + _qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000) + if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then + _qr_args+=("$_qn" "$_qf") + fi + done + for pair in \ + "demo-withdraw|$BANK_PUBLIC/intro/demo-withdraw.json" \ + "auto-account|$BANK_PUBLIC/intro/auto-account.json" + do + _qn="${pair%%|*}" + _qu="${pair#*|}" + _qf="$qr_dir/${_qn}.json" + _qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000) + if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then + _qr_args+=("$_qn" "$_qf") + fi + done + + if [ "${#_qr_args[@]}" -eq 0 ]; then + warn "qr sources" "no landing HTML / mint JSON fetched — skip payload checks" + else + _qr_tsv="$qr_dir/payloads.tsv" + if ! python3 "$ROOT/check_qr_payloads.py" "${_qr_args[@]}" >"$_qr_tsv" 2>"$qr_dir/harvest.err"; then + warn "qr harvest" "collector failed · $(tr '\n' ' ' <"$qr_dir/harvest.err" | head -c 160)" + fi + + _qr_n=0 + _qr_form_ok=0 + _qr_form_bad=0 + _qr_rt_ok=0 + _qr_rt_bad=0 + _qr_taler=0 + _qr_payto=0 + _qr_bad_sample="" + _qr_i=0 + + while IFS=$'\t' read -r src kind uri status detail || [ -n "${src:-}" ]; do + [ -n "${src:-}" ] || continue + _qr_n=$((_qr_n + 1)) + case "$kind" in + withdraw|pay|pay-template|refund|other) _qr_taler=$((_qr_taler + 1)) ;; + payto) _qr_payto=$((_qr_payto + 1)) ;; + esac + + if [ "$status" = "ok" ]; then + _qr_form_ok=$((_qr_form_ok + 1)) + else + _qr_form_bad=$((_qr_form_bad + 1)) + _qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }form/${kind} ${uri:0:56}" + # taler:// and payto:// form errors are hard on local/GOA + case "$kind" in + withdraw|pay|pay-template|payto) + if [ "${LOCAL_STACK:-1}" = "1" ] || [ "${EXPECT_CURRENCY:-}" = "GOA" ]; then + fail "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}" + else + warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}" + fi + ;; + *) + warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}" + ;; + esac + continue + fi + + # Encode → PNG → zbarimg (exact match) + if [ "$_qr_tools" = "1" ] && [ -n "$uri" ]; then + _qr_i=$((_qr_i + 1)) + _png="$qr_dir/p-${_qr_i}.png" + # ECC: H for wallet-style long URIs, L for long https store links, else QR_ECC + _ecc="$QR_ECC" + case "$kind" in + https) _ecc=L ;; + withdraw|pay|pay-template) _ecc=M ;; + payto) _ecc=M ;; + esac + if ! qrencode -l "$_ecc" -s 4 -m 2 -o "$_png" "$uri" 2>/dev/null; then + _qr_rt_bad=$((_qr_rt_bad + 1)) + fail "qr encode ${kind}" "qrencode failed · ${uri:0:72}" + continue + fi + _got=$(zbarimg --raw -q "$_png" 2>/dev/null | head -n1 | tr -d '\r' || true) + if [ "$_got" = "$uri" ]; then + _qr_rt_ok=$((_qr_rt_ok + 1)) + else + _qr_rt_bad=$((_qr_rt_bad + 1)) + _qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }decode/${kind}" + fail "qr decode ${kind}" "zbar mismatch · want ${uri:0:48}… · got ${_got:0:48}" + fi + fi + done <"$_qr_tsv" + + # Download any obvious static QR images from bank intro and decode (soft) + if [ "$_qr_tools" = "1" ] && [ -f "$qr_dir/bank-intro.html" ]; then + python3 - "$qr_dir/bank-intro.html" "$BANK_PUBLIC" "$qr_dir" <<'PY' >"$qr_dir/img-urls.txt" 2>/dev/null || true +import re, sys +from urllib.parse import urljoin +html = open(sys.argv[1], encoding="utf-8", errors="replace").read() +base = sys.argv[2].rstrip("/") + "/" +out = sys.argv[3] +seen = set() +for m in re.finditer(r'''(?:src|href)=["']([^"']+\.(?:png|jpe?g|gif|svg))["']''', html, re.I): + u = m.group(1) + if "qr" not in u.lower() and "withdraw" not in u.lower(): + continue + if u.startswith("data:"): + continue + absu = urljoin(base + "intro/", u) + if absu in seen: + continue + seen.add(absu) + print(absu) +PY + _img_n=0 + _img_ok=0 + while read -r _img_url; do + [ -n "$_img_url" ] || continue + _img_n=$((_img_n + 1)) + _imgf="$qr_dir/img-${_img_n}.bin" + _ic=$(http_body "$_img_url" "$_imgf" 2>/dev/null || echo 000) + if [ "$_ic" != "200" ] || [ ! -s "$_imgf" ]; then + warn "qr image fetch" "HTTP ${_ic} · ${_img_url}" + continue + fi + # zbarimg needs image format; skip non-raster + if ! file "$_imgf" 2>/dev/null | grep -qiE 'PNG|JPEG|PBM|PGM|PPM|GIF'; then + info "qr image skip" "not raster · ${_img_url}" + continue + fi + _dec=$(zbarimg --raw -q "$_imgf" 2>/dev/null | head -n1 | tr -d '\r' || true) + if [ -z "$_dec" ]; then + warn "qr image decode" "no barcode · ${_img_url}" + continue + fi + _img_ok=$((_img_ok + 1)) + # validate decoded payload form + printf '%s\n' "$_dec" >"$qr_dir/img-decoded.txt" + _line=$(python3 "$ROOT/check_qr_payloads.py" "img" "$qr_dir/img-decoded.txt" 2>/dev/null | head -1 || true) + if [ -n "$_line" ]; then + _st=$(printf '%s' "$_line" | cut -f4) + _kd=$(printf '%s' "$_line" | cut -f2) + if [ "$_st" = "ok" ]; then + ok "qr image ${_kd}" "${_dec:0:64}" + else + case "$_kd" in + withdraw|pay|pay-template|payto) + fail "qr image ${_kd}" "bad form · ${_dec:0:72}" + ;; + *) + warn "qr image ${_kd}" "bad form · ${_dec:0:72}" + ;; + esac + fi + else + ok "qr image decode" "${_dec:0:64}" + fi + done <"$qr_dir/img-urls.txt" + if [ "$_img_n" -gt 0 ]; then + info "qr images" "fetched ${_img_n} · decoded ${_img_ok}" + fi + fi + + # Summaries + if [ "$_qr_n" -eq 0 ]; then + warn "qr payloads" "none found in landings/mint JSON" + else + if [ "$_qr_form_bad" -eq 0 ]; then + ok "qr form" "${_qr_form_ok}/${_qr_n} ok · taler=${_qr_taler} payto=${_qr_payto}" + else + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad${_qr_bad_sample:+ · $_qr_bad_sample}" + else + warn "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad" + fi + fi + if [ "$_qr_tools" = "1" ]; then + if [ "$_qr_rt_bad" -eq 0 ] && [ "$_qr_rt_ok" -gt 0 ]; then + ok "qr encode/decode" "${_qr_rt_ok} roundtrips (ecc default ${QR_ECC})" + elif [ "$_qr_rt_ok" -eq 0 ] && [ "$_qr_rt_bad" -eq 0 ]; then + info "qr encode/decode" "no payloads to encode" + else + fail "qr encode/decode" "${_qr_rt_ok} ok · ${_qr_rt_bad} fail" + fi + fi + fi + fi +else + if [ "${QR_CHECK:-1}" != "1" ]; then + info "qr checks" "skipped (QR_CHECK=0)" + fi +fi + summary diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 04af0b9..4eb90b2 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -59,6 +59,8 @@ Env (same meaning): NO_COLOR=1 / CLICOLOR=0 disable green/yellow/red tags (default: coloured) SKIP_SSH=1 NO_COLOR=1 PERF_WARN_MS PERF_FAIL_MS (urls latency; default 8000 / 20000) + QR_CHECK=0 skip QR form + qrencode/zbarimg (urls phase) + QR_ECC=M qrencode ECC level (default M) KOOPA_SSH KOOPA_SSH_FALLBACKS (default koopa → koopa-external) METRICS_LOAD=0 skip host/container RAM/CPU probes (e2e/ladder/inside) EOF @@ -224,7 +226,7 @@ if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then _pt=0 for p in "${PHASES[@]}"; do case "$p" in - urls) _pt=$((_pt + 55)) ;; + urls) _pt=$((_pt + 70)) ;; inside) _pt=$((_pt + 25)) ;; versions) _pt=$((_pt + 20)) ;; sanity) _pt=$((_pt + 30)) ;; From f7856525f3ec01bfe93d6ce9ef05664bfebf5c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:43:04 +0200 Subject: [PATCH 206/326] monitoring: QR form + qrencode/zbarimg roundtrip (www.qr) Harvest taler:// and payto:// (plus app-store links) from landings and mint JSON; validate shape; re-encode PNG and decode with zbarimg. --- scripts/taler-monitoring/README.md | 3 + scripts/taler-monitoring/TESTS.md | 5 +- scripts/taler-monitoring/check_qr_payloads.py | 291 ++++++++++++++++++ scripts/taler-monitoring/check_urls.sh | 228 ++++++++++++++ scripts/taler-monitoring/taler-monitoring.sh | 4 +- 5 files changed, 529 insertions(+), 2 deletions(-) create mode 100755 scripts/taler-monitoring/check_qr_payloads.py diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index abb55d3..878d106 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -65,6 +65,9 @@ Each check prints **global** and **grouped** ids: - **OK** green · **INFO** blue/cyan · **WARN** yellow (never red) - **ERROR** red · **BLOCKER** magenta · **PROG** cyan bar - Progress: auto estimate per phase, or `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1` +- **QR (urls):** harvest `taler://` / `payto://` (+ app store `data-qr-url`) from landings and + `demo-withdraw.json` / `auto-account.json` → validate form → `qrencode` PNG → `zbarimg` + decode must match. Requires packages **`qrencode`** + **`zbar-tools`**. ## Commands diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index b238c3c..7bd51c1 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -15,7 +15,7 @@ Every check line has a **global** run number and a **grouped** id: | Area | Phase script | Groups (examples) | |------|--------------|-------------------| -| **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` | +| **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` `qr` | | **inside** | `check_inside.sh` | `ssh` `bank` `exchange` `merchant` `caddy` `load` | | **versions** | `check_versions.sh` | `outside` `inside` `compare` | | **sanity** | `check_sanity.sh` | `bank` `exchange` `merchant` | @@ -54,6 +54,9 @@ Numbering follows **executed** checks (early skip may shift later NN inside the | **www.merchant-** | `/config` currency + currencies alt_unit_names; listed exchanges alt; webui/intro; **`/terms`**, **`/privacy`** | | **www.paivana-** | local GOA paywall front (redirect to template) | | **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; cross-links local | +| **www.qr-** | QR payloads: harvest `taler://` / `payto://` / app `data-qr-url` from landings + mint JSON; **form** check; **qrencode → zbarimg** exact roundtrip; optional static QR images. Needs `qrencode` + `zbar-tools`. Skip: `QR_CHECK=0` | + +**QR rule:** `taler://withdraw/HOST/taler-integration/UUID` (no default `:443`/`:80`); `taler://pay/` / `pay-template/`; `payto://…` shape OK; decoded PNG must equal payload. Form errors on withdraw/pay/payto are **ERROR** on local/GOA. **Legal docs rule:** HTTP 200, non-empty body, not plain `not configured`, not merchant API JSON `code:21`. Local stack may require content needle (terms/privacy/FADP/GOA…). diff --git a/scripts/taler-monitoring/check_qr_payloads.py b/scripts/taler-monitoring/check_qr_payloads.py new file mode 100755 index 0000000..7cd109d --- /dev/null +++ b/scripts/taler-monitoring/check_qr_payloads.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Collect + validate QR-related payloads for taler-monitoring (www.qr-*). + +Sources (CLI paths / URLs handled by caller writing local files): + - landing HTML (href, data-qr-url, data-qr-taler, raw text) + - demo-withdraw.json / auto-account.json (taler_withdraw_uri / qr_payload) + - optional: already-decoded lines from zbarimg (kind=decoded) + +Stdout TSV: source\\tkind\\turi\\tstatus\\tdetail + kind: withdraw | pay | pay-template | refund | payto | https | other | empty + status: ok | bad + +Exit 0 always (caller tallies ok/bad lines). stderr for hard parse errors only. +""" +from __future__ import annotations + +import json +import re +import sys +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlparse + +# Schemes we care about for wallet / settlement QRs +TALER_RE = re.compile(r"taler://[^\s\"'<>]+", re.I) +PAYTO_RE = re.compile(r"payto://[^\s\"'<>]+", re.I) +HTTPS_RE = re.compile(r"https://[^\s\"'<>]+", re.I) + +WITHDRAW_RE = re.compile( + r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)/?$" +) +# order pay / template — host may include path segments after +PAY_RE = re.compile(r"^taler://pay/([^/?#]+)/", re.I) +PAY_TEMPLATE_RE = re.compile(r"^taler://pay-template/([^/?#]+)/", re.I) +REFUND_RE = re.compile(r"^taler://refund/", re.I) +PAYTO_OK_RE = re.compile(r"^payto://[a-z0-9][a-z0-9+.-]*/", re.I) + + +def looks_like_real_uri(uri: str) -> bool: + """Drop JS regex fragments, docs ellipses, incomplete placeholders.""" + if not uri or len(uri) < 12: + return False + if any(c in uri for c in "[]{}<>\n\r\t"): + return False + if "…" in uri or "..." in uri: + return False + # incomplete withdraw stubs in docs + if re.match(r"^taler://withdraw/?$", uri, re.I): + return False + if re.match(r"^taler://pay/?$", uri, re.I): + return False + # trailing junk from HTML/JS + if uri.endswith((")", "(", "\\", "^", "*", "+", "?")): + return False + if re.search(r"[\\^$*+?]", uri): + return False + return True + + +def strip_default_port_host(host: str) -> str | None: + if not host or host.startswith(":"): + return None + if ":" in host: + h, _, p = host.rpartition(":") + if not h or not p.isdigit(): + return None + if p in ("443", "80"): + return None # must be stripped for wallets + return host + + +def classify_and_validate(uri: str) -> tuple[str, str, str]: + """Return (kind, status, detail).""" + uri = (uri or "").strip() + if not uri: + return "empty", "bad", "empty" + # strip accidental trailing punctuation from HTML scrape + uri = uri.rstrip(").,;]") + low = uri.lower() + + if low.startswith("taler://withdraw/"): + m = WITHDRAW_RE.match(uri) + if not m: + return "withdraw", "bad", "shape need taler://withdraw/HOST/taler-integration/UUID" + host = m.group(1) + if strip_default_port_host(host) is None: + return "withdraw", "bad", "host/port invalid or default :443/:80 not stripped" + return "withdraw", "ok", f"host={host} id={m.group(2)[:12]}…" + + if low.startswith("taler://pay-template/"): + m = PAY_TEMPLATE_RE.match(uri) + if not m: + return "pay-template", "bad", "need taler://pay-template/HOST/…" + host = m.group(1) + if strip_default_port_host(host) is None: + return "pay-template", "bad", "host/port invalid or default port not stripped" + return "pay-template", "ok", f"host={host}" + + if low.startswith("taler://pay/"): + m = PAY_RE.match(uri) + if not m: + return "pay", "bad", "need taler://pay/HOST/…" + host = m.group(1) + if strip_default_port_host(host) is None: + return "pay", "bad", "host/port invalid or default port not stripped" + if "instances" not in uri and "/orders/" not in uri: + # still often valid private order URIs + return "pay", "ok", f"host={host}" + return "pay", "ok", f"host={host}" + + if low.startswith("taler://refund/"): + if not REFUND_RE.match(uri): + return "refund", "bad", "bad refund URI" + return "refund", "ok", "refund" + + if low.startswith("taler://"): + return "other", "ok", "other taler:// (accepted)" + + if low.startswith("payto://"): + if not PAYTO_OK_RE.match(uri): + return "payto", "bad", "need payto://method/…" + # wallet pay QR must not be payto for GOA demo-withdraw (checked elsewhere) + return "payto", "ok", uri.split("?", 1)[0][:72] + + if low.startswith("https://") or low.startswith("http://"): + p = urlparse(uri) + if p.scheme not in ("http", "https") or not p.netloc: + return "https", "bad", "bad URL" + return "https", "ok", p.netloc + (p.path or "/")[:40] + + return "other", "bad", "unsupported scheme" + + +class LinkHarvester(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.uris: list[tuple[str, str]] = [] # (source_hint, uri) + + def handle_starttag(self, tag, attrs): + ad = {k.lower(): (v or "") for k, v in attrs} + for key in ("href", "src", "data-qr-url", "data-qr-taler", "data-uri", "data-payto"): + if key in ad and ad[key]: + self.uris.append((f"attr:{key}", ad[key].strip())) + for k, v in ad.items(): + if k.startswith("data-") and v and ( + "taler://" in v.lower() or "payto://" in v.lower() or k.endswith("qr-url") + ): + self.uris.append((f"attr:{k}", v.strip())) + + def handle_data(self, data): + if not data or "://" not in data: + return + for m in TALER_RE.finditer(data): + self.uris.append(("text", m.group(0))) + for m in PAYTO_RE.finditer(data): + self.uris.append(("text", m.group(0))) + + +def harvest_html(path: Path, source: str) -> list[tuple[str, str, str]]: + raw = path.read_text(encoding="utf-8", errors="replace") + h = LinkHarvester() + try: + h.feed(raw) + except Exception: + pass + out: list[tuple[str, str, str]] = [] + for hint, u in h.uris: + lu = u.lower() + if lu.startswith(("taler://", "payto://", "https://", "http://")): + if looks_like_real_uri(u): + out.append((f"{source}:{hint}", u, "html")) + # raster image paths that look like QR assets (not .js) + elif re.search(r"\.(png|jpe?g|gif)(\?|$)", lu) and "qr" in lu: + out.append((f"{source}:imgpath", u, "imgpath")) + # Prefer attribute/text harvest; light regex only for complete-looking URIs in pre/code + for m in TALER_RE.finditer(raw): + u = m.group(0).rstrip(").,;]'\"") + if looks_like_real_uri(u) and ( + WITHDRAW_RE.match(u) + or PAY_RE.match(u) + or PAY_TEMPLATE_RE.match(u) + or u.lower().startswith("taler://refund/") + ): + out.append((f"{source}:re", u, "html")) + for m in PAYTO_RE.finditer(raw): + u = m.group(0).rstrip(").,;]'\"") + if looks_like_real_uri(u) and PAYTO_OK_RE.match(u): + out.append((f"{source}:re", u, "html")) + return out + + +def harvest_json(path: Path, source: str) -> list[tuple[str, str, str]]: + try: + d = json.loads(path.read_text(encoding="utf-8", errors="replace")) + except Exception as e: + return [(source, "", f"json-error:{e}")] + out: list[tuple[str, str, str]] = [] + if not isinstance(d, dict): + return out + for key in ( + "taler_withdraw_uri", + "qr_payload", + "taler_pay_uri", + "payto_uri", + "payto", + "uri", + ): + v = d.get(key) + if isinstance(v, str) and v.strip(): + out.append((f"{source}:{key}", v.strip(), "json")) + # nested + for k, v in d.items(): + if isinstance(v, str) and ( + v.startswith("taler://") or v.startswith("payto://") + ): + out.append((f"{source}:{k}", v.strip(), "json")) + return out + + +def main(argv: list[str]) -> int: + # args: pairs of source_label path + if len(argv) < 3 or len(argv) % 2 == 0: + print( + "usage: check_qr_payloads.py LABEL path [LABEL path ...]", + file=sys.stderr, + ) + return 2 + seen: set[str] = set() + rows: list[tuple[str, str, str, str, str]] = [] + i = 1 + while i < len(argv): + label, path_s = argv[i], argv[i + 1] + i += 2 + path = Path(path_s) + if not path.is_file(): + rows.append((label, "empty", "", "bad", f"missing file {path_s}")) + continue + if path.suffix.lower() in (".html", ".htm") or "html" in path.name: + items = harvest_html(path, label) + elif path.suffix.lower() == ".json" or path.name.endswith(".json"): + items = harvest_json(path, label) + else: + # plain text: one URI per line or raw + text = path.read_text(encoding="utf-8", errors="replace").strip() + items = [] + for line in text.splitlines(): + line = line.strip() + if line: + items.append((label, line, "text")) + if not items and text: + items.append((label, text, "text")) + + for src, uri, _origin in items: + if not uri: + continue + # skip pure fragment / relative without scheme for validate + if uri.startswith("#") or uri == "/": + continue + lu = uri.lower() + if lu.startswith("/") or "imgpath" in src: + continue + if not looks_like_real_uri(uri): + continue + # https: app-store / wallet / explicit data-qr-* only + if lu.startswith("http"): + if "data-qr" not in src and not any( + x in lu + for x in ( + "play.google", + "apps.apple", + "f-droid", + "wallet.taler", + "taler.net/wallet", + ) + ): + continue + if uri in seen: + continue + seen.add(uri) + kind, status, detail = classify_and_validate(uri) + rows.append((src, kind, uri, status, detail)) + + for src, kind, uri, status, detail in rows: + # TSV — uri may contain tabs rarely; replace + safe = uri.replace("\t", " ").replace("\n", " ") + print(f"{src}\t{kind}\t{safe}\t{status}\t{detail}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index 55ed1b8..0ae5700 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -1193,4 +1193,232 @@ else warn "landing merchant shop assets" "${_ma}/2 present (soft)" fi +# --------------------------------------------------------------------------- +# QR payloads — form + encode/decode roundtrip (qrencode + zbarimg) +# Collect taler:// + payto:// (+ app-store https from data-qr-url) from landings +# and bank mint JSON; re-encode PNG; decode; require exact payload match. +# Env: QR_CHECK=0 to skip; QR_ECC=M (default) for qrencode -l +# --------------------------------------------------------------------------- +if [ "${QR_CHECK:-1}" = "1" ] && [ "${CHECK_LANDING:-1}" = "1" ]; then + set_group qr + section "www · QR payloads (form + encode/decode · taler:// payto://)" + + QR_ECC="${QR_ECC:-M}" + qr_dir="$tmp/qr-check" + mkdir -p "$qr_dir" + _qr_tools=1 + if ! command -v qrencode >/dev/null 2>&1; then + warn "qr tools" "qrencode missing — form checks only (apt install qrencode)" + _qr_tools=0 + fi + if ! command -v zbarimg >/dev/null 2>&1; then + warn "qr tools" "zbarimg missing — form checks only (apt install zbar-tools)" + _qr_tools=0 + fi + if [ "$_qr_tools" = "1" ]; then + ok "qr tools" "qrencode + zbarimg" + fi + + # Fetch sources (HTML landings + mint JSON when present) + _qr_args=() + for pair in \ + "bank-intro|$BANK_PUBLIC/intro/" \ + "merchant-intro|$MERCHANT_PUBLIC/intro/" \ + "exchange-intro|$EXCHANGE_PUBLIC/intro/" + do + _qn="${pair%%|*}" + _qu="${pair#*|}" + _qf="$qr_dir/${_qn}.html" + _qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000) + if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then + _qr_args+=("$_qn" "$_qf") + fi + done + for pair in \ + "demo-withdraw|$BANK_PUBLIC/intro/demo-withdraw.json" \ + "auto-account|$BANK_PUBLIC/intro/auto-account.json" + do + _qn="${pair%%|*}" + _qu="${pair#*|}" + _qf="$qr_dir/${_qn}.json" + _qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000) + if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then + _qr_args+=("$_qn" "$_qf") + fi + done + + if [ "${#_qr_args[@]}" -eq 0 ]; then + warn "qr sources" "no landing HTML / mint JSON fetched — skip payload checks" + else + _qr_tsv="$qr_dir/payloads.tsv" + if ! python3 "$ROOT/check_qr_payloads.py" "${_qr_args[@]}" >"$_qr_tsv" 2>"$qr_dir/harvest.err"; then + warn "qr harvest" "collector failed · $(tr '\n' ' ' <"$qr_dir/harvest.err" | head -c 160)" + fi + + _qr_n=0 + _qr_form_ok=0 + _qr_form_bad=0 + _qr_rt_ok=0 + _qr_rt_bad=0 + _qr_taler=0 + _qr_payto=0 + _qr_bad_sample="" + _qr_i=0 + + while IFS=$'\t' read -r src kind uri status detail || [ -n "${src:-}" ]; do + [ -n "${src:-}" ] || continue + _qr_n=$((_qr_n + 1)) + case "$kind" in + withdraw|pay|pay-template|refund|other) _qr_taler=$((_qr_taler + 1)) ;; + payto) _qr_payto=$((_qr_payto + 1)) ;; + esac + + if [ "$status" = "ok" ]; then + _qr_form_ok=$((_qr_form_ok + 1)) + else + _qr_form_bad=$((_qr_form_bad + 1)) + _qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }form/${kind} ${uri:0:56}" + # taler:// and payto:// form errors are hard on local/GOA + case "$kind" in + withdraw|pay|pay-template|payto) + if [ "${LOCAL_STACK:-1}" = "1" ] || [ "${EXPECT_CURRENCY:-}" = "GOA" ]; then + fail "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}" + else + warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}" + fi + ;; + *) + warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}" + ;; + esac + continue + fi + + # Encode → PNG → zbarimg (exact match) + if [ "$_qr_tools" = "1" ] && [ -n "$uri" ]; then + _qr_i=$((_qr_i + 1)) + _png="$qr_dir/p-${_qr_i}.png" + # ECC: H for wallet-style long URIs, L for long https store links, else QR_ECC + _ecc="$QR_ECC" + case "$kind" in + https) _ecc=L ;; + withdraw|pay|pay-template) _ecc=M ;; + payto) _ecc=M ;; + esac + if ! qrencode -l "$_ecc" -s 4 -m 2 -o "$_png" "$uri" 2>/dev/null; then + _qr_rt_bad=$((_qr_rt_bad + 1)) + fail "qr encode ${kind}" "qrencode failed · ${uri:0:72}" + continue + fi + _got=$(zbarimg --raw -q "$_png" 2>/dev/null | head -n1 | tr -d '\r' || true) + if [ "$_got" = "$uri" ]; then + _qr_rt_ok=$((_qr_rt_ok + 1)) + else + _qr_rt_bad=$((_qr_rt_bad + 1)) + _qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }decode/${kind}" + fail "qr decode ${kind}" "zbar mismatch · want ${uri:0:48}… · got ${_got:0:48}" + fi + fi + done <"$_qr_tsv" + + # Download any obvious static QR images from bank intro and decode (soft) + if [ "$_qr_tools" = "1" ] && [ -f "$qr_dir/bank-intro.html" ]; then + python3 - "$qr_dir/bank-intro.html" "$BANK_PUBLIC" "$qr_dir" <<'PY' >"$qr_dir/img-urls.txt" 2>/dev/null || true +import re, sys +from urllib.parse import urljoin +html = open(sys.argv[1], encoding="utf-8", errors="replace").read() +base = sys.argv[2].rstrip("/") + "/" +out = sys.argv[3] +seen = set() +for m in re.finditer(r'''(?:src|href)=["']([^"']+\.(?:png|jpe?g|gif|svg))["']''', html, re.I): + u = m.group(1) + if "qr" not in u.lower() and "withdraw" not in u.lower(): + continue + if u.startswith("data:"): + continue + absu = urljoin(base + "intro/", u) + if absu in seen: + continue + seen.add(absu) + print(absu) +PY + _img_n=0 + _img_ok=0 + while read -r _img_url; do + [ -n "$_img_url" ] || continue + _img_n=$((_img_n + 1)) + _imgf="$qr_dir/img-${_img_n}.bin" + _ic=$(http_body "$_img_url" "$_imgf" 2>/dev/null || echo 000) + if [ "$_ic" != "200" ] || [ ! -s "$_imgf" ]; then + warn "qr image fetch" "HTTP ${_ic} · ${_img_url}" + continue + fi + # zbarimg needs image format; skip non-raster + if ! file "$_imgf" 2>/dev/null | grep -qiE 'PNG|JPEG|PBM|PGM|PPM|GIF'; then + info "qr image skip" "not raster · ${_img_url}" + continue + fi + _dec=$(zbarimg --raw -q "$_imgf" 2>/dev/null | head -n1 | tr -d '\r' || true) + if [ -z "$_dec" ]; then + warn "qr image decode" "no barcode · ${_img_url}" + continue + fi + _img_ok=$((_img_ok + 1)) + # validate decoded payload form + printf '%s\n' "$_dec" >"$qr_dir/img-decoded.txt" + _line=$(python3 "$ROOT/check_qr_payloads.py" "img" "$qr_dir/img-decoded.txt" 2>/dev/null | head -1 || true) + if [ -n "$_line" ]; then + _st=$(printf '%s' "$_line" | cut -f4) + _kd=$(printf '%s' "$_line" | cut -f2) + if [ "$_st" = "ok" ]; then + ok "qr image ${_kd}" "${_dec:0:64}" + else + case "$_kd" in + withdraw|pay|pay-template|payto) + fail "qr image ${_kd}" "bad form · ${_dec:0:72}" + ;; + *) + warn "qr image ${_kd}" "bad form · ${_dec:0:72}" + ;; + esac + fi + else + ok "qr image decode" "${_dec:0:64}" + fi + done <"$qr_dir/img-urls.txt" + if [ "$_img_n" -gt 0 ]; then + info "qr images" "fetched ${_img_n} · decoded ${_img_ok}" + fi + fi + + # Summaries + if [ "$_qr_n" -eq 0 ]; then + warn "qr payloads" "none found in landings/mint JSON" + else + if [ "$_qr_form_bad" -eq 0 ]; then + ok "qr form" "${_qr_form_ok}/${_qr_n} ok · taler=${_qr_taler} payto=${_qr_payto}" + else + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad${_qr_bad_sample:+ · $_qr_bad_sample}" + else + warn "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad" + fi + fi + if [ "$_qr_tools" = "1" ]; then + if [ "$_qr_rt_bad" -eq 0 ] && [ "$_qr_rt_ok" -gt 0 ]; then + ok "qr encode/decode" "${_qr_rt_ok} roundtrips (ecc default ${QR_ECC})" + elif [ "$_qr_rt_ok" -eq 0 ] && [ "$_qr_rt_bad" -eq 0 ]; then + info "qr encode/decode" "no payloads to encode" + else + fail "qr encode/decode" "${_qr_rt_ok} ok · ${_qr_rt_bad} fail" + fi + fi + fi + fi +else + if [ "${QR_CHECK:-1}" != "1" ]; then + info "qr checks" "skipped (QR_CHECK=0)" + fi +fi + summary diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 04af0b9..4eb90b2 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -59,6 +59,8 @@ Env (same meaning): NO_COLOR=1 / CLICOLOR=0 disable green/yellow/red tags (default: coloured) SKIP_SSH=1 NO_COLOR=1 PERF_WARN_MS PERF_FAIL_MS (urls latency; default 8000 / 20000) + QR_CHECK=0 skip QR form + qrencode/zbarimg (urls phase) + QR_ECC=M qrencode ECC level (default M) KOOPA_SSH KOOPA_SSH_FALLBACKS (default koopa → koopa-external) METRICS_LOAD=0 skip host/container RAM/CPU probes (e2e/ladder/inside) EOF @@ -224,7 +226,7 @@ if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then _pt=0 for p in "${PHASES[@]}"; do case "$p" in - urls) _pt=$((_pt + 55)) ;; + urls) _pt=$((_pt + 70)) ;; inside) _pt=$((_pt + 25)) ;; versions) _pt=$((_pt + 20)) ;; sanity) _pt=$((_pt + 30)) ;; From 7636040c496c123f055592da96213f5e69b6588f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:43:12 +0200 Subject: [PATCH 207/326] monitoring: allow ? in QR https payloads (Play Store) --- scripts/taler-monitoring/check_qr_payloads.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/taler-monitoring/check_qr_payloads.py b/scripts/taler-monitoring/check_qr_payloads.py index 7cd109d..74efde4 100755 --- a/scripts/taler-monitoring/check_qr_payloads.py +++ b/scripts/taler-monitoring/check_qr_payloads.py @@ -49,10 +49,13 @@ def looks_like_real_uri(uri: str) -> bool: return False if re.match(r"^taler://pay/?$", uri, re.I): return False - # trailing junk from HTML/JS - if uri.endswith((")", "(", "\\", "^", "*", "+", "?")): + # trailing junk / JS regex fragments (allow ? & = in query strings) + if uri.endswith((")", "(", "\\", "^", "*")): return False - if re.search(r"[\\^$*+?]", uri): + if re.search(r"[\\^$*]", uri): + return False + # lone incomplete `taler://…?` without path + if re.match(r"^taler://[^/]+$", uri, re.I): return False return True From 9950bb05ddeff492b38d077ca481428817d879cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:43:12 +0200 Subject: [PATCH 208/326] monitoring: allow ? in QR https payloads (Play Store) --- scripts/taler-monitoring/check_qr_payloads.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/taler-monitoring/check_qr_payloads.py b/scripts/taler-monitoring/check_qr_payloads.py index 7cd109d..74efde4 100755 --- a/scripts/taler-monitoring/check_qr_payloads.py +++ b/scripts/taler-monitoring/check_qr_payloads.py @@ -49,10 +49,13 @@ def looks_like_real_uri(uri: str) -> bool: return False if re.match(r"^taler://pay/?$", uri, re.I): return False - # trailing junk from HTML/JS - if uri.endswith((")", "(", "\\", "^", "*", "+", "?")): + # trailing junk / JS regex fragments (allow ? & = in query strings) + if uri.endswith((")", "(", "\\", "^", "*")): return False - if re.search(r"[\\^$*+?]", uri): + if re.search(r"[\\^$*]", uri): + return False + # lone incomplete `taler://…?` without path + if re.match(r"^taler://[^/]+$", uri, re.I): return False return True From fe2ad4dfe084deeec52a68f544cf3fa83b48921b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:50:16 +0200 Subject: [PATCH 209/326] monitoring: silence stage false WARNs (308, og-goa, shop-ui) Follow privacy redirects; skip GOA-only assets off-GOA; longer external store timeout; TESTPAYSAN shop-ui paths; accept paivana 402. --- scripts/taler-monitoring/check_urls.sh | 81 +++++++++++++++++++------- 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index 0ae5700..c319502 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -24,7 +24,8 @@ check_url() { esac } -# Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack +# Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack. +# Always treats 301/302/303/307/308→200 (follow) as success when 200 is expected. check_url_soft() { local label="$1" expect="$2" url="$3" local code @@ -32,6 +33,15 @@ check_url_soft() { case ",$expect," in *",$code,"*) ok "$label $url" ;; *) + # Follow redirects (308 Permanent Redirect is common for trailing slash) + case "$code" in + 301|302|303|307|308) + code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000) + case ",$expect," in + *",$code,"*) ok "$label $url" "via redirect → HTTP $code"; return ;; + esac + ;; + esac if [ "${LOCAL_STACK:-1}" = "0" ]; then warn "$label $url" "got $code (optional on remote domain)" else @@ -835,8 +845,12 @@ if [ "${LOCAL_STACK:-1}" = "1" ] && [ "${E2E_PAIVANA:-1}" != "0" ]; then fi info "paivana Location" "${loc:0:140}" ;; + 402) + # Paywall may answer Payment Required with body (healthy, not 502) + ok "paivana /" "HTTP 402 Payment Required (paywall up)" + ;; 200) - warn "paivana /" "HTTP 200 (expected paywall redirect to template)" + warn "paivana /" "HTTP 200 (expected paywall redirect/402 to template)" ;; *) warn "paivana /" "HTTP ${pcode:-000} — ${PAIVANA_PUBLIC}/ (e2e pay may still work via template)" @@ -1014,29 +1028,29 @@ check_one_landing() { return fi - # Required static: qrcode.min.js. GOA og image is soft off-GOA (stage has no og-goa-shop.png). + # Required static: qrcode.min.js. og-goa-shop.png only on GOA/local. if _landing_probe "${base}/intro/qrcode.min.js"; then a_ok=$((a_ok + 1)) else a_fail=$((a_fail + 1)) fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} ${base}/intro/qrcode.min.js" fi - if _landing_probe "${base}/intro/og-goa-shop.png"; then - a_ok=$((a_ok + 1)) - else - if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + if _landing_probe "${base}/intro/og-goa-shop.png"; then + a_ok=$((a_ok + 1)) + else a_fail=$((a_fail + 1)) fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} ${base}/intro/og-goa-shop.png" - else - a_soft=$((a_soft + 1)) - soft_sample="${soft_sample}${soft_sample:+; }HTTP ${_landing_code} og-goa-shop.png (GOA asset)" fi fi + # qr-logo: hard on GOA (wallet QR frame); soft elsewhere if missing if _landing_probe "${base}/intro/qr-logo.png"; then a_ok=$((a_ok + 1)) else - a_soft=$((a_soft + 1)) - soft_sample="${soft_sample}${soft_sample:+; }HTTP ${_landing_code} qr-logo.png" + if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + a_soft=$((a_soft + 1)) + soft_sample="${soft_sample}${soft_sample:+; }HTTP ${_landing_code} qr-logo.png" + fi fi n=$(extract_landing_urls "$base" "$html" "$pref" 2>/dev/null || echo "own=0 ext=0") @@ -1066,11 +1080,14 @@ check_one_landing() { done < "${pref}.own" fi - # External: soft counts + # External: soft counts (stores can be slow — longer timeout + browser UA) if [ -f "${pref}.ext" ]; then + local _ext_to="${EXT_TIMEOUT:-25}" while IFS= read -r u; do [ -n "$u" ] || continue - code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$u" 2>/dev/null || echo 000) + code=$(curl -skS --max-redirs 5 -L -m "${_ext_to}" \ + -A "Mozilla/5.0 (compatible; taler-monitoring/1.0)" \ + -o /dev/null -w '%{http_code}' "$u" 2>/dev/null || echo 000) case "$code" in 200|204|301|302|303|307|308) ext_ok=$((ext_ok + 1)) ;; *) @@ -1136,8 +1153,11 @@ if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then _ba_ok=0 _ba_soft=0 _ba_msg="" - if _landing_probe "$BANK_PUBLIC/intro/shop-pay.js"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.js; "; fi - if _landing_probe "$BANK_PUBLIC/intro/shop-pay.css"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.css; "; fi + # GOA landings ship shop-pay.*; stage TESTPAYSAN uses monnaies /assets/shop-ui.js + if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + if _landing_probe "$BANK_PUBLIC/intro/shop-pay.js"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.js; "; fi + if _landing_probe "$BANK_PUBLIC/intro/shop-pay.css"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.css; "; fi + fi dw_code=$(http_body "$BANK_PUBLIC/intro/demo-withdraw.json" "$tmp/dw.json") case "$dw_code" in 200) @@ -1183,14 +1203,31 @@ if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then fi fi -# Merchant shop assets — one soft line +# Merchant shop assets — GOA: shop-pay.*; stage TESTPAYSAN: /assets/shop-ui.js + shops.css _ma=0 -_landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.js" && _ma=$((_ma + 1)) -_landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.css" && _ma=$((_ma + 1)) -if [ "$_ma" -eq 2 ]; then - ok "landing merchant shop assets" "shop-pay.js + .css" +_ma_need=2 +_ma_label="shop-pay.js + .css" +if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + _landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.js" && _ma=$((_ma + 1)) + _landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.css" && _ma=$((_ma + 1)) else - warn "landing merchant shop assets" "${_ma}/2 present (soft)" + _ma_label="shop-ui.js + shops.css" + _landing_probe "$MERCHANT_PUBLIC/assets/shop-ui.js" && _ma=$((_ma + 1)) + _landing_probe "$MERCHANT_PUBLIC/assets/shops.css" && _ma=$((_ma + 1)) +fi +if [ "$_ma" -eq "$_ma_need" ]; then + ok "landing merchant shop assets" "${_ma_label}" +else + if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + warn "landing merchant shop assets" "${_ma}/${_ma_need} present (soft) · want ${_ma_label}" + else + # stage: soft INFO if missing; WARN only if zero + if [ "$_ma" -eq 0 ]; then + warn "landing merchant shop assets" "0/${_ma_need} · want ${_ma_label}" + else + info "landing merchant shop assets" "${_ma}/${_ma_need} · ${_ma_label}" + fi + fi fi # --------------------------------------------------------------------------- From c9fdae9540eb83c13ac5cb13c53f644e0b312657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:50:16 +0200 Subject: [PATCH 210/326] monitoring: silence stage false WARNs (308, og-goa, shop-ui) Follow privacy redirects; skip GOA-only assets off-GOA; longer external store timeout; TESTPAYSAN shop-ui paths; accept paivana 402. --- scripts/taler-monitoring/check_urls.sh | 81 +++++++++++++++++++------- 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index 0ae5700..c319502 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -24,7 +24,8 @@ check_url() { esac } -# Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack +# Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack. +# Always treats 301/302/303/307/308→200 (follow) as success when 200 is expected. check_url_soft() { local label="$1" expect="$2" url="$3" local code @@ -32,6 +33,15 @@ check_url_soft() { case ",$expect," in *",$code,"*) ok "$label $url" ;; *) + # Follow redirects (308 Permanent Redirect is common for trailing slash) + case "$code" in + 301|302|303|307|308) + code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000) + case ",$expect," in + *",$code,"*) ok "$label $url" "via redirect → HTTP $code"; return ;; + esac + ;; + esac if [ "${LOCAL_STACK:-1}" = "0" ]; then warn "$label $url" "got $code (optional on remote domain)" else @@ -835,8 +845,12 @@ if [ "${LOCAL_STACK:-1}" = "1" ] && [ "${E2E_PAIVANA:-1}" != "0" ]; then fi info "paivana Location" "${loc:0:140}" ;; + 402) + # Paywall may answer Payment Required with body (healthy, not 502) + ok "paivana /" "HTTP 402 Payment Required (paywall up)" + ;; 200) - warn "paivana /" "HTTP 200 (expected paywall redirect to template)" + warn "paivana /" "HTTP 200 (expected paywall redirect/402 to template)" ;; *) warn "paivana /" "HTTP ${pcode:-000} — ${PAIVANA_PUBLIC}/ (e2e pay may still work via template)" @@ -1014,29 +1028,29 @@ check_one_landing() { return fi - # Required static: qrcode.min.js. GOA og image is soft off-GOA (stage has no og-goa-shop.png). + # Required static: qrcode.min.js. og-goa-shop.png only on GOA/local. if _landing_probe "${base}/intro/qrcode.min.js"; then a_ok=$((a_ok + 1)) else a_fail=$((a_fail + 1)) fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} ${base}/intro/qrcode.min.js" fi - if _landing_probe "${base}/intro/og-goa-shop.png"; then - a_ok=$((a_ok + 1)) - else - if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + if _landing_probe "${base}/intro/og-goa-shop.png"; then + a_ok=$((a_ok + 1)) + else a_fail=$((a_fail + 1)) fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} ${base}/intro/og-goa-shop.png" - else - a_soft=$((a_soft + 1)) - soft_sample="${soft_sample}${soft_sample:+; }HTTP ${_landing_code} og-goa-shop.png (GOA asset)" fi fi + # qr-logo: hard on GOA (wallet QR frame); soft elsewhere if missing if _landing_probe "${base}/intro/qr-logo.png"; then a_ok=$((a_ok + 1)) else - a_soft=$((a_soft + 1)) - soft_sample="${soft_sample}${soft_sample:+; }HTTP ${_landing_code} qr-logo.png" + if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + a_soft=$((a_soft + 1)) + soft_sample="${soft_sample}${soft_sample:+; }HTTP ${_landing_code} qr-logo.png" + fi fi n=$(extract_landing_urls "$base" "$html" "$pref" 2>/dev/null || echo "own=0 ext=0") @@ -1066,11 +1080,14 @@ check_one_landing() { done < "${pref}.own" fi - # External: soft counts + # External: soft counts (stores can be slow — longer timeout + browser UA) if [ -f "${pref}.ext" ]; then + local _ext_to="${EXT_TIMEOUT:-25}" while IFS= read -r u; do [ -n "$u" ] || continue - code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$u" 2>/dev/null || echo 000) + code=$(curl -skS --max-redirs 5 -L -m "${_ext_to}" \ + -A "Mozilla/5.0 (compatible; taler-monitoring/1.0)" \ + -o /dev/null -w '%{http_code}' "$u" 2>/dev/null || echo 000) case "$code" in 200|204|301|302|303|307|308) ext_ok=$((ext_ok + 1)) ;; *) @@ -1136,8 +1153,11 @@ if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then _ba_ok=0 _ba_soft=0 _ba_msg="" - if _landing_probe "$BANK_PUBLIC/intro/shop-pay.js"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.js; "; fi - if _landing_probe "$BANK_PUBLIC/intro/shop-pay.css"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.css; "; fi + # GOA landings ship shop-pay.*; stage TESTPAYSAN uses monnaies /assets/shop-ui.js + if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + if _landing_probe "$BANK_PUBLIC/intro/shop-pay.js"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.js; "; fi + if _landing_probe "$BANK_PUBLIC/intro/shop-pay.css"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.css; "; fi + fi dw_code=$(http_body "$BANK_PUBLIC/intro/demo-withdraw.json" "$tmp/dw.json") case "$dw_code" in 200) @@ -1183,14 +1203,31 @@ if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then fi fi -# Merchant shop assets — one soft line +# Merchant shop assets — GOA: shop-pay.*; stage TESTPAYSAN: /assets/shop-ui.js + shops.css _ma=0 -_landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.js" && _ma=$((_ma + 1)) -_landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.css" && _ma=$((_ma + 1)) -if [ "$_ma" -eq 2 ]; then - ok "landing merchant shop assets" "shop-pay.js + .css" +_ma_need=2 +_ma_label="shop-pay.js + .css" +if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + _landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.js" && _ma=$((_ma + 1)) + _landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.css" && _ma=$((_ma + 1)) else - warn "landing merchant shop assets" "${_ma}/2 present (soft)" + _ma_label="shop-ui.js + shops.css" + _landing_probe "$MERCHANT_PUBLIC/assets/shop-ui.js" && _ma=$((_ma + 1)) + _landing_probe "$MERCHANT_PUBLIC/assets/shops.css" && _ma=$((_ma + 1)) +fi +if [ "$_ma" -eq "$_ma_need" ]; then + ok "landing merchant shop assets" "${_ma_label}" +else + if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then + warn "landing merchant shop assets" "${_ma}/${_ma_need} present (soft) · want ${_ma_label}" + else + # stage: soft INFO if missing; WARN only if zero + if [ "$_ma" -eq 0 ]; then + warn "landing merchant shop assets" "0/${_ma_need} · want ${_ma_label}" + else + info "landing merchant shop assets" "${_ma}/${_ma_need} · ${_ma_label}" + fi + fi fi # --------------------------------------------------------------------------- From f26bf6a30597a16aaf6d2d6c2a43b2a9d7910ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:50:16 +0200 Subject: [PATCH 211/326] paivana: pin Taler hosts to pasta IPv4 172.17.0.1 Avoid public AAAA timeouts from container; merchant :9010 works. --- configs/paivana/compose.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/configs/paivana/compose.yml b/configs/paivana/compose.yml index 27c8ed9..221ec41 100644 --- a/configs/paivana/compose.yml +++ b/configs/paivana/compose.yml @@ -14,9 +14,11 @@ services: ports: - "9025:9967" extra_hosts: - - "taler.hacktivism.ch:host-gateway" - - "bank.hacktivism.ch:host-gateway" - - "exchange.hacktivism.ch:host-gateway" + # pasta publishes merchant on host :9010. Prefer fixed IPv4 — host-gateway + # alone still loses to public AAAA and times out from the container. + - "taler.hacktivism.ch:172.17.0.1" + - "bank.hacktivism.ch:172.17.0.1" + - "exchange.hacktivism.ch:172.17.0.1" volumes: - ./conf/paivana.conf.template:/etc/paivana/paivana.conf.template:ro - ./entrypoint.sh:/usr/local/bin/paivana-entrypoint:ro From f3a429d69d16c1a1ea7a126a24a2b8c3d22ac865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:50:16 +0200 Subject: [PATCH 212/326] paivana: pin Taler hosts to pasta IPv4 172.17.0.1 Avoid public AAAA timeouts from container; merchant :9010 works. --- configs/paivana/compose.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/configs/paivana/compose.yml b/configs/paivana/compose.yml index 27c8ed9..221ec41 100644 --- a/configs/paivana/compose.yml +++ b/configs/paivana/compose.yml @@ -14,9 +14,11 @@ services: ports: - "9025:9967" extra_hosts: - - "taler.hacktivism.ch:host-gateway" - - "bank.hacktivism.ch:host-gateway" - - "exchange.hacktivism.ch:host-gateway" + # pasta publishes merchant on host :9010. Prefer fixed IPv4 — host-gateway + # alone still loses to public AAAA and times out from the container. + - "taler.hacktivism.ch:172.17.0.1" + - "bank.hacktivism.ch:172.17.0.1" + - "exchange.hacktivism.ch:172.17.0.1" volumes: - ./conf/paivana.conf.template:/etc/paivana/paivana.conf.template:ro - ./entrypoint.sh:/usr/local/bin/paivana-entrypoint:ro From c6d8d16b8ca57a93211ee2cbfa308b747383baaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:54:04 +0200 Subject: [PATCH 213/326] monitoring: pre-launch stacks report INFO not WARN Empty EXPECT_CURRENCY + remote (prod LFP) is not live yet. Preserve empty currency across child scripts (fix := vs =). Exchange /config keys soft on remote stacks. --- scripts/taler-monitoring/check_urls.sh | 53 +++++++++++++++++++++----- scripts/taler-monitoring/lib.sh | 3 +- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index c319502..387d4e2 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -24,8 +24,15 @@ check_url() { esac } +# Pre-launch remote stack: local=0 and no expected currency (e.g. prod LFP not live yet). +# Missing endpoints → INFO, not WARN (infra not supposed to serve yet). +is_prelaunch_stack() { + [ "${LOCAL_STACK:-1}" = "0" ] && [ -z "${EXPECT_CURRENCY:-}" ] +} + # Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack. # Always treats 301/302/303/307/308→200 (follow) as success when 200 is expected. +# Pre-launch (no currency): INFO instead of WARN for missing endpoints. check_url_soft() { local label="$1" expect="$2" url="$3" local code @@ -43,7 +50,11 @@ check_url_soft() { ;; esac if [ "${LOCAL_STACK:-1}" = "0" ]; then - warn "$label $url" "got $code (optional on remote domain)" + if is_prelaunch_stack; then + info "$label $url" "HTTP $code — pre-launch / not serving yet" + else + warn "$label $url" "got $code (optional on remote domain)" + fi else fail "$label $url" "got $code want $expect" fi @@ -81,7 +92,11 @@ check_legal_doc() { if [ "$code" != "200" ]; then rm -f "$f" if [ "$soft" = "1" ]; then - warn "$label" "HTTP $code — $url" + if is_prelaunch_stack; then + info "$label" "HTTP $code — pre-launch / not serving yet · $url" + else + warn "$label" "HTTP $code — $url" + fi else fail "$label" "HTTP $code — $url" fi @@ -181,9 +196,13 @@ sys.exit(0) PY } -# --- exchange (core; always required) --- www.exchange-NN +# --- exchange (core; hard on local stack, soft on remote) --- www.exchange-NN set_group exchange -check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config" +if [ "${LOCAL_STACK:-1}" = "1" ]; then + check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config" +else + check_url_soft "exchange /config" 200 "$EXCHANGE_PUBLIC/config" +fi code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ec.json") if [ "$code" = "200" ]; then expect_currency "exchange" "$tmp/ec.json" @@ -195,7 +214,11 @@ if [ "$code" = "200" ]; then fi rm -f /tmp/alt-ex.$$ fi -check_url "exchange /keys" 200 "$EXCHANGE_PUBLIC/keys" +if [ "${LOCAL_STACK:-1}" = "1" ]; then + check_url "exchange /keys" 200 "$EXCHANGE_PUBLIC/keys" +else + check_url_soft "exchange /keys" 200 "$EXCHANGE_PUBLIC/keys" +fi # /keys should also expose currency_specification.alt_unit_names when present code=$(http_body "$EXCHANGE_PUBLIC/keys" "$tmp/ek.json") if [ "$code" = "200" ]; then @@ -271,6 +294,8 @@ check_perf() { *) if [ "${LOCAL_STACK:-1}" = "1" ]; then fail "$label" "HTTP $code want $expect · ${ms} ms · $url" + elif is_prelaunch_stack; then + info "$label" "HTTP $code · ${ms} ms · pre-launch · $url" else warn "$label" "HTTP $code want $expect · ${ms} ms · $url" fi @@ -682,10 +707,12 @@ check_legal_doc "exchange /privacy" "$EXCHANGE_PUBLIC/privacy" "privacy|FADP|rev # trailing slash: 200 or redirect to bare path code=$(http_code "$EXCHANGE_PUBLIC/terms/") case "$code" in - 200|301|302) ok "exchange /terms/" "HTTP $code" ;; + 200|301|302|303|307|308) ok "exchange /terms/" "HTTP $code" ;; *) - if [ "${LOCAL_STACK:-1}" = "1" ]; then warn "exchange /terms/" "HTTP $code" - else warn "exchange /terms/" "HTTP $code" + if is_prelaunch_stack; then + info "exchange /terms/" "HTTP $code — pre-launch" + else + warn "exchange /terms/" "HTTP $code" fi ;; esac @@ -821,8 +848,14 @@ check_legal_doc "merchant /terms" "$MERCHANT_PUBLIC/terms" "terms|dual|GOA|CHF|e check_legal_doc "merchant /privacy" "$MERCHANT_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|CHF|merchant" code=$(http_code "$MERCHANT_PUBLIC/terms/") case "$code" in - 200|301|302) ok "merchant /terms/" "HTTP $code" ;; - *) warn "merchant /terms/" "HTTP $code (expect 302 → /terms)" ;; + 200|301|302|303|307|308) ok "merchant /terms/" "HTTP $code" ;; + *) + if is_prelaunch_stack; then + info "merchant /terms/" "HTTP $code — pre-launch" + else + warn "merchant /terms/" "HTTP $code (expect 302 → /terms)" + fi + ;; esac # --------------------------------------------------------------------------- diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 585d223..c8ec8e3 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -31,7 +31,8 @@ : "${SSH_CMD_TIMEOUT:=12}" # hard cap for whole remote script (seconds) : "${SKIP_SSH:=0}" # Expected currency for public /config checks (empty = report only, don't fail) -: "${EXPECT_CURRENCY:=GOA}" +# Only if unset (not if intentionally empty = pre-launch / report-only) +: "${EXPECT_CURRENCY=GOA}" # 1 = this is the local koopa/hacktivism stack (inside/e2e/SSH make sense) : "${LOCAL_STACK:=1}" # Probe merchant host candidates when applying a generic domain (0=off) From 4cf923b271743a17a82547d9a9161e5bc517a1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:54:04 +0200 Subject: [PATCH 214/326] monitoring: pre-launch stacks report INFO not WARN Empty EXPECT_CURRENCY + remote (prod LFP) is not live yet. Preserve empty currency across child scripts (fix := vs =). Exchange /config keys soft on remote stacks. --- scripts/taler-monitoring/check_urls.sh | 53 +++++++++++++++++++++----- scripts/taler-monitoring/lib.sh | 3 +- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index c319502..387d4e2 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -24,8 +24,15 @@ check_url() { esac } +# Pre-launch remote stack: local=0 and no expected currency (e.g. prod LFP not live yet). +# Missing endpoints → INFO, not WARN (infra not supposed to serve yet). +is_prelaunch_stack() { + [ "${LOCAL_STACK:-1}" = "0" ] && [ -z "${EXPECT_CURRENCY:-}" ] +} + # Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack. # Always treats 301/302/303/307/308→200 (follow) as success when 200 is expected. +# Pre-launch (no currency): INFO instead of WARN for missing endpoints. check_url_soft() { local label="$1" expect="$2" url="$3" local code @@ -43,7 +50,11 @@ check_url_soft() { ;; esac if [ "${LOCAL_STACK:-1}" = "0" ]; then - warn "$label $url" "got $code (optional on remote domain)" + if is_prelaunch_stack; then + info "$label $url" "HTTP $code — pre-launch / not serving yet" + else + warn "$label $url" "got $code (optional on remote domain)" + fi else fail "$label $url" "got $code want $expect" fi @@ -81,7 +92,11 @@ check_legal_doc() { if [ "$code" != "200" ]; then rm -f "$f" if [ "$soft" = "1" ]; then - warn "$label" "HTTP $code — $url" + if is_prelaunch_stack; then + info "$label" "HTTP $code — pre-launch / not serving yet · $url" + else + warn "$label" "HTTP $code — $url" + fi else fail "$label" "HTTP $code — $url" fi @@ -181,9 +196,13 @@ sys.exit(0) PY } -# --- exchange (core; always required) --- www.exchange-NN +# --- exchange (core; hard on local stack, soft on remote) --- www.exchange-NN set_group exchange -check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config" +if [ "${LOCAL_STACK:-1}" = "1" ]; then + check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config" +else + check_url_soft "exchange /config" 200 "$EXCHANGE_PUBLIC/config" +fi code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ec.json") if [ "$code" = "200" ]; then expect_currency "exchange" "$tmp/ec.json" @@ -195,7 +214,11 @@ if [ "$code" = "200" ]; then fi rm -f /tmp/alt-ex.$$ fi -check_url "exchange /keys" 200 "$EXCHANGE_PUBLIC/keys" +if [ "${LOCAL_STACK:-1}" = "1" ]; then + check_url "exchange /keys" 200 "$EXCHANGE_PUBLIC/keys" +else + check_url_soft "exchange /keys" 200 "$EXCHANGE_PUBLIC/keys" +fi # /keys should also expose currency_specification.alt_unit_names when present code=$(http_body "$EXCHANGE_PUBLIC/keys" "$tmp/ek.json") if [ "$code" = "200" ]; then @@ -271,6 +294,8 @@ check_perf() { *) if [ "${LOCAL_STACK:-1}" = "1" ]; then fail "$label" "HTTP $code want $expect · ${ms} ms · $url" + elif is_prelaunch_stack; then + info "$label" "HTTP $code · ${ms} ms · pre-launch · $url" else warn "$label" "HTTP $code want $expect · ${ms} ms · $url" fi @@ -682,10 +707,12 @@ check_legal_doc "exchange /privacy" "$EXCHANGE_PUBLIC/privacy" "privacy|FADP|rev # trailing slash: 200 or redirect to bare path code=$(http_code "$EXCHANGE_PUBLIC/terms/") case "$code" in - 200|301|302) ok "exchange /terms/" "HTTP $code" ;; + 200|301|302|303|307|308) ok "exchange /terms/" "HTTP $code" ;; *) - if [ "${LOCAL_STACK:-1}" = "1" ]; then warn "exchange /terms/" "HTTP $code" - else warn "exchange /terms/" "HTTP $code" + if is_prelaunch_stack; then + info "exchange /terms/" "HTTP $code — pre-launch" + else + warn "exchange /terms/" "HTTP $code" fi ;; esac @@ -821,8 +848,14 @@ check_legal_doc "merchant /terms" "$MERCHANT_PUBLIC/terms" "terms|dual|GOA|CHF|e check_legal_doc "merchant /privacy" "$MERCHANT_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|CHF|merchant" code=$(http_code "$MERCHANT_PUBLIC/terms/") case "$code" in - 200|301|302) ok "merchant /terms/" "HTTP $code" ;; - *) warn "merchant /terms/" "HTTP $code (expect 302 → /terms)" ;; + 200|301|302|303|307|308) ok "merchant /terms/" "HTTP $code" ;; + *) + if is_prelaunch_stack; then + info "merchant /terms/" "HTTP $code — pre-launch" + else + warn "merchant /terms/" "HTTP $code (expect 302 → /terms)" + fi + ;; esac # --------------------------------------------------------------------------- diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 585d223..c8ec8e3 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -31,7 +31,8 @@ : "${SSH_CMD_TIMEOUT:=12}" # hard cap for whole remote script (seconds) : "${SKIP_SSH:=0}" # Expected currency for public /config checks (empty = report only, don't fail) -: "${EXPECT_CURRENCY:=GOA}" +# Only if unset (not if intentionally empty = pre-launch / report-only) +: "${EXPECT_CURRENCY=GOA}" # 1 = this is the local koopa/hacktivism stack (inside/e2e/SSH make sense) : "${LOCAL_STACK:=1}" # Probe merchant host candidates when applying a generic domain (0=off) From 020bf77cc795e0c54c97ae4adafe0ee5dc4ed1f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:55:08 +0200 Subject: [PATCH 215/326] monitoring: fix progress bar when estimate is short Rebalance PROGRESS_TOTAL if done exceeds it (never print 224/205); raise e2e estimate for ATM ladder INFO volume; final line snaps N/N. --- scripts/taler-monitoring/lib.sh | 30 ++++++++++++++++++++ scripts/taler-monitoring/taler-monitoring.sh | 18 ++++++------ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index c8ec8e3..19764f8 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -493,6 +493,10 @@ set_progress_total() { # PROGRESS_TOTAL=0 → no percent, only "done=N". # Does not reset GLOBAL_N / PROGRESS_DONE if already mid-run (state file). local n="${1:-0}" + # Never shrink below already completed work (re-estimate mid-run safe) + if [ "${PROGRESS_DONE:-0}" -gt 0 ] && [ "$n" -gt 0 ] && [ "$n" -lt "$PROGRESS_DONE" ]; then + n=$PROGRESS_DONE + fi PROGRESS_TOTAL="$n" if [ "${PROGRESS_DONE:-0}" -eq 0 ]; then PROGRESS_LAST_SHOWN=0 @@ -504,11 +508,30 @@ add_progress_total() { PROGRESS_TOTAL=$((PROGRESS_TOTAL + n)) _mon_state_save } +# When estimate was short, grow total so we never print done>total (e.g. 224/205). +# Keeps ~10% headroom so the bar is not stuck at 100% while checks continue. +_progress_rebalance() { + [ "${PROGRESS_TOTAL:-0}" -gt 0 ] || return 0 + [ "${PROGRESS_DONE:-0}" -gt 0 ] || return 0 + if [ "$PROGRESS_DONE" -gt "$PROGRESS_TOTAL" ]; then + local head=$((PROGRESS_DONE / 10)) + [ "$head" -lt 12 ] && head=12 + PROGRESS_TOTAL=$((PROGRESS_DONE + head)) + fi +} _progress_bar_line() { local done="$1" total="$2" width=24 pct=0 filled empty i bar + # Defensive: never display done > total + if [ "$total" -gt 0 ] && [ "$done" -gt "$total" ]; then + total=$done + fi if [ "$total" -gt 0 ]; then pct=$((done * 100 / total)) [ "$pct" -gt 100 ] && pct=100 + # While still running past an estimate, keep bar at most 99% until final rebalance + if [ "$done" -lt "$total" ] && [ "$pct" -ge 100 ]; then + pct=99 + fi filled=$((pct * width / 100)) [ "$filled" -gt "$width" ] && filled=$width empty=$((width - filled)) @@ -536,6 +559,7 @@ _progress_maybe_show() { local force="${1:-0}" [ "${PROGRESS_OFF:-0}" = "1" ] && return 0 [ "$PROGRESS_DONE" -le 0 ] && [ "$force" != "1" ] && return 0 + _progress_rebalance if [ "$force" = "1" ] || \ [ $((PROGRESS_DONE - PROGRESS_LAST_SHOWN)) -ge "$PROGRESS_SHOW_EVERY" ] || \ { [ "$PROGRESS_TOTAL" -gt 0 ] && [ "$PROGRESS_DONE" -ge "$PROGRESS_TOTAL" ]; }; then @@ -561,6 +585,7 @@ _take_tid() { LAST_TID=$(printf '%s-%02d' "$TEST_AREA" "$TEST_N") fi PROGRESS_DONE=$((PROGRESS_DONE + 1)) + _progress_rebalance _mon_state_save _progress_maybe_show 0 } @@ -683,6 +708,11 @@ summary() { echo "" # final progress line (no _take_tid — plain printf) if [ "${PROGRESS_OFF:-0}" != "1" ] && [ "$PROGRESS_DONE" -gt 0 ]; then + # Snap total to actual so last line is exact N/N 100% + if [ "$PROGRESS_TOTAL" -gt 0 ] && [ "$PROGRESS_DONE" -ne "$PROGRESS_TOTAL" ]; then + PROGRESS_TOTAL=$PROGRESS_DONE + _mon_state_save + fi _progress_bar_line "$PROGRESS_DONE" "$PROGRESS_TOTAL" fi if [ "$GLOBAL_N" -gt 0 ]; then diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 4eb90b2..75d07e9 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -221,18 +221,20 @@ if [ "${#PHASES[@]}" -eq 0 ]; then PHASES=(urls) fi -# Rough expected check counts for progress % (override: PROGRESS_TOTAL=N) +# Rough expected check counts for progress % (override: PROGRESS_TOTAL=N). +# Prefer slightly high — lib.sh rebalances if still short (never shows done>total). if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then _pt=0 for p in "${PHASES[@]}"; do case "$p" in - urls) _pt=$((_pt + 70)) ;; - inside) _pt=$((_pt + 25)) ;; - versions) _pt=$((_pt + 20)) ;; - sanity) _pt=$((_pt + 30)) ;; - server) _pt=$((_pt + 15)) ;; - e2e) _pt=$((_pt + 90)) ;; - ladder|goa-ladder) _pt=$((_pt + 80)) ;; + urls) _pt=$((_pt + 85)) ;; # +qr group + inside) _pt=$((_pt + 30)) ;; + versions) _pt=$((_pt + 25)) ;; + sanity) _pt=$((_pt + 35)) ;; + server) _pt=$((_pt + 20)) ;; + # e2e: ATM ladder emits many INFO lines (coins before/after each note) + e2e) _pt=$((_pt + 240)) ;; + ladder|goa-ladder) _pt=$((_pt + 120)) ;; esac done set_progress_total "$_pt" From d08442a90465ee390d8a79d5b973609c7413cbc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 18:55:08 +0200 Subject: [PATCH 216/326] monitoring: fix progress bar when estimate is short Rebalance PROGRESS_TOTAL if done exceeds it (never print 224/205); raise e2e estimate for ATM ladder INFO volume; final line snaps N/N. --- scripts/taler-monitoring/lib.sh | 30 ++++++++++++++++++++ scripts/taler-monitoring/taler-monitoring.sh | 18 ++++++------ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index c8ec8e3..19764f8 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -493,6 +493,10 @@ set_progress_total() { # PROGRESS_TOTAL=0 → no percent, only "done=N". # Does not reset GLOBAL_N / PROGRESS_DONE if already mid-run (state file). local n="${1:-0}" + # Never shrink below already completed work (re-estimate mid-run safe) + if [ "${PROGRESS_DONE:-0}" -gt 0 ] && [ "$n" -gt 0 ] && [ "$n" -lt "$PROGRESS_DONE" ]; then + n=$PROGRESS_DONE + fi PROGRESS_TOTAL="$n" if [ "${PROGRESS_DONE:-0}" -eq 0 ]; then PROGRESS_LAST_SHOWN=0 @@ -504,11 +508,30 @@ add_progress_total() { PROGRESS_TOTAL=$((PROGRESS_TOTAL + n)) _mon_state_save } +# When estimate was short, grow total so we never print done>total (e.g. 224/205). +# Keeps ~10% headroom so the bar is not stuck at 100% while checks continue. +_progress_rebalance() { + [ "${PROGRESS_TOTAL:-0}" -gt 0 ] || return 0 + [ "${PROGRESS_DONE:-0}" -gt 0 ] || return 0 + if [ "$PROGRESS_DONE" -gt "$PROGRESS_TOTAL" ]; then + local head=$((PROGRESS_DONE / 10)) + [ "$head" -lt 12 ] && head=12 + PROGRESS_TOTAL=$((PROGRESS_DONE + head)) + fi +} _progress_bar_line() { local done="$1" total="$2" width=24 pct=0 filled empty i bar + # Defensive: never display done > total + if [ "$total" -gt 0 ] && [ "$done" -gt "$total" ]; then + total=$done + fi if [ "$total" -gt 0 ]; then pct=$((done * 100 / total)) [ "$pct" -gt 100 ] && pct=100 + # While still running past an estimate, keep bar at most 99% until final rebalance + if [ "$done" -lt "$total" ] && [ "$pct" -ge 100 ]; then + pct=99 + fi filled=$((pct * width / 100)) [ "$filled" -gt "$width" ] && filled=$width empty=$((width - filled)) @@ -536,6 +559,7 @@ _progress_maybe_show() { local force="${1:-0}" [ "${PROGRESS_OFF:-0}" = "1" ] && return 0 [ "$PROGRESS_DONE" -le 0 ] && [ "$force" != "1" ] && return 0 + _progress_rebalance if [ "$force" = "1" ] || \ [ $((PROGRESS_DONE - PROGRESS_LAST_SHOWN)) -ge "$PROGRESS_SHOW_EVERY" ] || \ { [ "$PROGRESS_TOTAL" -gt 0 ] && [ "$PROGRESS_DONE" -ge "$PROGRESS_TOTAL" ]; }; then @@ -561,6 +585,7 @@ _take_tid() { LAST_TID=$(printf '%s-%02d' "$TEST_AREA" "$TEST_N") fi PROGRESS_DONE=$((PROGRESS_DONE + 1)) + _progress_rebalance _mon_state_save _progress_maybe_show 0 } @@ -683,6 +708,11 @@ summary() { echo "" # final progress line (no _take_tid — plain printf) if [ "${PROGRESS_OFF:-0}" != "1" ] && [ "$PROGRESS_DONE" -gt 0 ]; then + # Snap total to actual so last line is exact N/N 100% + if [ "$PROGRESS_TOTAL" -gt 0 ] && [ "$PROGRESS_DONE" -ne "$PROGRESS_TOTAL" ]; then + PROGRESS_TOTAL=$PROGRESS_DONE + _mon_state_save + fi _progress_bar_line "$PROGRESS_DONE" "$PROGRESS_TOTAL" fi if [ "$GLOBAL_N" -gt 0 ]; then diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 4eb90b2..75d07e9 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -221,18 +221,20 @@ if [ "${#PHASES[@]}" -eq 0 ]; then PHASES=(urls) fi -# Rough expected check counts for progress % (override: PROGRESS_TOTAL=N) +# Rough expected check counts for progress % (override: PROGRESS_TOTAL=N). +# Prefer slightly high — lib.sh rebalances if still short (never shows done>total). if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then _pt=0 for p in "${PHASES[@]}"; do case "$p" in - urls) _pt=$((_pt + 70)) ;; - inside) _pt=$((_pt + 25)) ;; - versions) _pt=$((_pt + 20)) ;; - sanity) _pt=$((_pt + 30)) ;; - server) _pt=$((_pt + 15)) ;; - e2e) _pt=$((_pt + 90)) ;; - ladder|goa-ladder) _pt=$((_pt + 80)) ;; + urls) _pt=$((_pt + 85)) ;; # +qr group + inside) _pt=$((_pt + 30)) ;; + versions) _pt=$((_pt + 25)) ;; + sanity) _pt=$((_pt + 35)) ;; + server) _pt=$((_pt + 20)) ;; + # e2e: ATM ladder emits many INFO lines (coins before/after each note) + e2e) _pt=$((_pt + 240)) ;; + ladder|goa-ladder) _pt=$((_pt + 120)) ;; esac done set_progress_total "$_pt" From c3b743ed251a02d15e8a2be2045f20b4e88972c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:02:26 +0200 Subject: [PATCH 217/326] =?UTF-8?q?monitoring:=20depositcheck=20crash=20?= =?UTF-8?q?=E2=86=92=20INFO=20when=20wirewatch=20OK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merchant depositcheck aborts on exchange track 404 (assertion); try ensure_helpers once, else INFO not WARN if wirewatch runs. --- scripts/taler-monitoring/check_inside.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/taler-monitoring/check_inside.sh b/scripts/taler-monitoring/check_inside.sh index 2d012ed..b5db3b1 100755 --- a/scripts/taler-monitoring/check_inside.sh +++ b/scripts/taler-monitoring/check_inside.sh @@ -109,7 +109,21 @@ else [ "$c" = "200" ] && emit merchant OK "local /config" "HTTP $c" || emit merchant ERROR "local /config" "HTTP $c" hasp "$MER" 'taler-merchant-httpd' && emit merchant OK httpd "running" || emit merchant ERROR httpd "not running" hasp "$MER" 'taler-merchant-wirewatch' && emit merchant OK wirewatch "running" || emit merchant WARN wirewatch "not running" - hasp "$MER" 'taler-merchant-depositcheck' && emit merchant OK depositcheck "running" || emit merchant WARN depositcheck "not running" + # depositcheck: may abort on exchange track 404 (merchant assertion) — try ensure once + if hasp "$MER" 'taler-merchant-depositcheck'; then + emit merchant OK depositcheck "running" + else + podman exec "$MER" /usr/local/bin/ensure_merchant_helpers.sh >/dev/null 2>&1 || true + sleep 1 + if hasp "$MER" 'taler-merchant-depositcheck'; then + emit merchant OK depositcheck "running (after ensure_merchant_helpers)" + elif hasp "$MER" 'taler-merchant-wirewatch'; then + # Known: depositcheck can SIGABRT on exchange 404 for stale coins — not startable cleanly + emit merchant INFO depositcheck "not running (often aborts on track 404; wirewatch OK · settlement partial)" + else + emit merchant WARN depositcheck "not running" + fi + fi check_dns merchant "$MER" bank.hacktivism.ch || true check_dns merchant "$MER" exchange.hacktivism.ch || true check_dns merchant "$MER" taler.hacktivism.ch || true From 736f1cde88b4d80b312c731a19c397c398f40182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:02:26 +0200 Subject: [PATCH 218/326] =?UTF-8?q?monitoring:=20depositcheck=20crash=20?= =?UTF-8?q?=E2=86=92=20INFO=20when=20wirewatch=20OK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merchant depositcheck aborts on exchange track 404 (assertion); try ensure_helpers once, else INFO not WARN if wirewatch runs. --- scripts/taler-monitoring/check_inside.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/taler-monitoring/check_inside.sh b/scripts/taler-monitoring/check_inside.sh index 2d012ed..b5db3b1 100755 --- a/scripts/taler-monitoring/check_inside.sh +++ b/scripts/taler-monitoring/check_inside.sh @@ -109,7 +109,21 @@ else [ "$c" = "200" ] && emit merchant OK "local /config" "HTTP $c" || emit merchant ERROR "local /config" "HTTP $c" hasp "$MER" 'taler-merchant-httpd' && emit merchant OK httpd "running" || emit merchant ERROR httpd "not running" hasp "$MER" 'taler-merchant-wirewatch' && emit merchant OK wirewatch "running" || emit merchant WARN wirewatch "not running" - hasp "$MER" 'taler-merchant-depositcheck' && emit merchant OK depositcheck "running" || emit merchant WARN depositcheck "not running" + # depositcheck: may abort on exchange track 404 (merchant assertion) — try ensure once + if hasp "$MER" 'taler-merchant-depositcheck'; then + emit merchant OK depositcheck "running" + else + podman exec "$MER" /usr/local/bin/ensure_merchant_helpers.sh >/dev/null 2>&1 || true + sleep 1 + if hasp "$MER" 'taler-merchant-depositcheck'; then + emit merchant OK depositcheck "running (after ensure_merchant_helpers)" + elif hasp "$MER" 'taler-merchant-wirewatch'; then + # Known: depositcheck can SIGABRT on exchange 404 for stale coins — not startable cleanly + emit merchant INFO depositcheck "not running (often aborts on track 404; wirewatch OK · settlement partial)" + else + emit merchant WARN depositcheck "not running" + fi + fi check_dns merchant "$MER" bank.hacktivism.ch || true check_dns merchant "$MER" exchange.hacktivism.ch || true check_dns merchant "$MER" taler.hacktivism.ch || true From 3d4432c2fa6715bc46c92826c7fd72b60214ecf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:03:07 +0200 Subject: [PATCH 219/326] monitoring: progress badge shows percent, bar grows right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace confusing PROG label with " 42%"; fill █ from the left; done/total stays on the right. --- scripts/taler-monitoring/README.md | 8 ++++--- scripts/taler-monitoring/lib.sh | 35 ++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 878d106..9d5422f 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -56,15 +56,17 @@ Each check prints **global** and **grouped** ids: ```text ┌ OK ┐ #014 www.exchange-03 exchange /config · HTTP 200 ┌ WARN ┐ #015 www.stats-02 stats age soft · yellow badge (not red) -┌ PROG ┐ ████████░░░░ 42% 14/55 +┌ 42% ┐ ████████░░░░░░░░░░░░░░░░░░░░ 14/55 ``` - `#014` — run-wide counter (whole `./taler-monitoring.sh` invocation) - `www.exchange-03` — area.group-NN - **Colours (left badge + label):** - **OK** green · **INFO** blue/cyan · **WARN** yellow (never red) - - **ERROR** red · **BLOCKER** magenta · **PROG** cyan bar -- Progress: auto estimate per phase, or `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1` + - **ERROR** red · **BLOCKER** magenta +- Progress: badge shows **percent** (not “PROG”); bar fills **left → right**; + `done/total` on the right. Estimate auto-grows if short. Override: + `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1` - **QR (urls):** harvest `taler://` / `payto://` (+ app store `data-qr-url`) from landings and `demo-withdraw.json` / `auto-account.json` → validate form → `qrencode` PNG → `zbarimg` decode must match. Requires packages **`qrencode`** + **`zbar-tools`**. diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 19764f8..645cf2e 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -367,7 +367,7 @@ koopa_ssh_python() { # WARN yellow badge · yellow label (never red) # ERROR red badge · red label # BLOCKER magenta badge· magenta label -# PROG cyan bar line +# progress cyan badge = " 42%" + bar fills left→right if [ "${NO_COLOR:-0}" = "1" ] || [ "${CLICOLOR:-1}" = "0" ]; then G= R= Y= C= M= D= W= B= N= BG_OK= BG_INFO= BG_WARN= BG_ERR= BG_BLK= BG_SEC= BG_PROG= @@ -520,38 +520,55 @@ _progress_rebalance() { fi } _progress_bar_line() { - local done="$1" total="$2" width=24 pct=0 filled empty i bar + # Format (grows left → right; badge = percent, not "PROG"): + # ┌ 42% ┐ ████████░░░░░░░░░░░░░░░░ 31/74 + local done="$1" total="$2" width=28 pct=0 filled empty i bar pct_lab # Defensive: never display done > total if [ "$total" -gt 0 ] && [ "$done" -gt "$total" ]; then total=$done fi if [ "$total" -gt 0 ]; then + # integer percent; while mid-run past a short estimate, cap display < 100% pct=$((done * 100 / total)) [ "$pct" -gt 100 ] && pct=100 - # While still running past an estimate, keep bar at most 99% until final rebalance if [ "$done" -lt "$total" ] && [ "$pct" -ge 100 ]; then pct=99 fi - filled=$((pct * width / 100)) + # fill cells from the left (progress moves rightward as filled grows) + filled=$((done * width / total)) [ "$filled" -gt "$width" ] && filled=$width + # show at least 1 block once any work done (unless 0%) + if [ "$done" -gt 0 ] && [ "$filled" -eq 0 ]; then + filled=1 + fi + # at 100% fill complete bar + if [ "$done" -ge "$total" ]; then + filled=$width + pct=100 + fi empty=$((width - filled)) bar="" i=0 while [ "$i" -lt "$filled" ]; do bar="${bar}█"; i=$((i + 1)); done i=0 while [ "$i" -lt "$empty" ]; do bar="${bar}░"; i=$((i + 1)); done + pct_lab=$(printf '%3d%%' "$pct") if [ "${BOX:-0}" = "1" ]; then - printf '%s┌ PROG ┐%s %s%s%s %s%3d%%%s %s%d/%d%s\n' \ - "$BG_PROG" "$N" "$C" "$bar" "$N" "$W" "$pct" "$N" "$D" "$done" "$total" "$N" + # badge = percent (clearer than "PROG"); bar grows ░→█ left to right + printf '%s┌%s┐%s %s%s%s %s%d/%d%s\n' \ + "$BG_PROG" "$pct_lab" "$N" \ + "$C" "$bar" "$N" \ + "$D" "$done" "$total" "$N" else - printf -- '%s[ PROG ]%s [%s] %3d%% %d/%d\n' "$C" "$N" "$bar" "$pct" "$done" "$total" + printf -- '%s%4s%s %s %d/%d\n' \ + "$C" "$pct_lab" "$N" "$bar" "$done" "$total" fi else if [ "${BOX:-0}" = "1" ]; then - printf '%s┌ PROG ┐%s %sdone=%d%s (total unknown — set PROGRESS_TOTAL=)\n' \ + printf '%s┌ … ┐%s %sdone=%d%s (set PROGRESS_TOTAL= for %%)\n' \ "$BG_PROG" "$N" "$D" "$done" "$N" else - printf -- '%s[ PROG ]%s done=%d (total unknown)\n' "$C" "$N" "$done" + printf -- '%sdone=%d%s (set PROGRESS_TOTAL= for %%)\n' "$D" "$done" "$N" fi fi } From 6a6300f4ab88f46d86bafb5df24eb642a2738697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:03:07 +0200 Subject: [PATCH 220/326] monitoring: progress badge shows percent, bar grows right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace confusing PROG label with " 42%"; fill █ from the left; done/total stays on the right. --- scripts/taler-monitoring/README.md | 8 ++++--- scripts/taler-monitoring/lib.sh | 35 ++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 878d106..9d5422f 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -56,15 +56,17 @@ Each check prints **global** and **grouped** ids: ```text ┌ OK ┐ #014 www.exchange-03 exchange /config · HTTP 200 ┌ WARN ┐ #015 www.stats-02 stats age soft · yellow badge (not red) -┌ PROG ┐ ████████░░░░ 42% 14/55 +┌ 42% ┐ ████████░░░░░░░░░░░░░░░░░░░░ 14/55 ``` - `#014` — run-wide counter (whole `./taler-monitoring.sh` invocation) - `www.exchange-03` — area.group-NN - **Colours (left badge + label):** - **OK** green · **INFO** blue/cyan · **WARN** yellow (never red) - - **ERROR** red · **BLOCKER** magenta · **PROG** cyan bar -- Progress: auto estimate per phase, or `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1` + - **ERROR** red · **BLOCKER** magenta +- Progress: badge shows **percent** (not “PROG”); bar fills **left → right**; + `done/total` on the right. Estimate auto-grows if short. Override: + `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1` - **QR (urls):** harvest `taler://` / `payto://` (+ app store `data-qr-url`) from landings and `demo-withdraw.json` / `auto-account.json` → validate form → `qrencode` PNG → `zbarimg` decode must match. Requires packages **`qrencode`** + **`zbar-tools`**. diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 19764f8..645cf2e 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -367,7 +367,7 @@ koopa_ssh_python() { # WARN yellow badge · yellow label (never red) # ERROR red badge · red label # BLOCKER magenta badge· magenta label -# PROG cyan bar line +# progress cyan badge = " 42%" + bar fills left→right if [ "${NO_COLOR:-0}" = "1" ] || [ "${CLICOLOR:-1}" = "0" ]; then G= R= Y= C= M= D= W= B= N= BG_OK= BG_INFO= BG_WARN= BG_ERR= BG_BLK= BG_SEC= BG_PROG= @@ -520,38 +520,55 @@ _progress_rebalance() { fi } _progress_bar_line() { - local done="$1" total="$2" width=24 pct=0 filled empty i bar + # Format (grows left → right; badge = percent, not "PROG"): + # ┌ 42% ┐ ████████░░░░░░░░░░░░░░░░ 31/74 + local done="$1" total="$2" width=28 pct=0 filled empty i bar pct_lab # Defensive: never display done > total if [ "$total" -gt 0 ] && [ "$done" -gt "$total" ]; then total=$done fi if [ "$total" -gt 0 ]; then + # integer percent; while mid-run past a short estimate, cap display < 100% pct=$((done * 100 / total)) [ "$pct" -gt 100 ] && pct=100 - # While still running past an estimate, keep bar at most 99% until final rebalance if [ "$done" -lt "$total" ] && [ "$pct" -ge 100 ]; then pct=99 fi - filled=$((pct * width / 100)) + # fill cells from the left (progress moves rightward as filled grows) + filled=$((done * width / total)) [ "$filled" -gt "$width" ] && filled=$width + # show at least 1 block once any work done (unless 0%) + if [ "$done" -gt 0 ] && [ "$filled" -eq 0 ]; then + filled=1 + fi + # at 100% fill complete bar + if [ "$done" -ge "$total" ]; then + filled=$width + pct=100 + fi empty=$((width - filled)) bar="" i=0 while [ "$i" -lt "$filled" ]; do bar="${bar}█"; i=$((i + 1)); done i=0 while [ "$i" -lt "$empty" ]; do bar="${bar}░"; i=$((i + 1)); done + pct_lab=$(printf '%3d%%' "$pct") if [ "${BOX:-0}" = "1" ]; then - printf '%s┌ PROG ┐%s %s%s%s %s%3d%%%s %s%d/%d%s\n' \ - "$BG_PROG" "$N" "$C" "$bar" "$N" "$W" "$pct" "$N" "$D" "$done" "$total" "$N" + # badge = percent (clearer than "PROG"); bar grows ░→█ left to right + printf '%s┌%s┐%s %s%s%s %s%d/%d%s\n' \ + "$BG_PROG" "$pct_lab" "$N" \ + "$C" "$bar" "$N" \ + "$D" "$done" "$total" "$N" else - printf -- '%s[ PROG ]%s [%s] %3d%% %d/%d\n' "$C" "$N" "$bar" "$pct" "$done" "$total" + printf -- '%s%4s%s %s %d/%d\n' \ + "$C" "$pct_lab" "$N" "$bar" "$done" "$total" fi else if [ "${BOX:-0}" = "1" ]; then - printf '%s┌ PROG ┐%s %sdone=%d%s (total unknown — set PROGRESS_TOTAL=)\n' \ + printf '%s┌ … ┐%s %sdone=%d%s (set PROGRESS_TOTAL= for %%)\n' \ "$BG_PROG" "$N" "$D" "$done" "$N" else - printf -- '%s[ PROG ]%s done=%d (total unknown)\n' "$C" "$N" "$done" + printf -- '%sdone=%d%s (set PROGRESS_TOTAL= for %%)\n' "$D" "$done" "$N" fi fi } From 3fa8aa39462c4b82efadfbb1e973bd6178dd4844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:03:49 +0200 Subject: [PATCH 221/326] monitoring: coloured SUMMARY block at end of run Severity badges for OK/ERROR/WARN/INFO/BLOCKER counts plus verdict line (all clear / warnings only / failed). --- scripts/taler-monitoring/lib.sh | 61 ++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 645cf2e..c3cd2b2 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -722,6 +722,7 @@ section() { } summary() { + local blk_n=${#BLOCKERS[@]} echo "" # final progress line (no _take_tid — plain printf) if [ "${PROGRESS_OFF:-0}" != "1" ] && [ "$PROGRESS_DONE" -gt 0 ]; then @@ -733,37 +734,71 @@ summary() { _progress_bar_line "$PROGRESS_DONE" "$PROGRESS_TOTAL" fi if [ "$GLOBAL_N" -gt 0 ]; then - printf -- '%s numbered checks this run: #001…#%03d\n' "$D" "$GLOBAL_N" + printf -- '%s numbered checks this run: #001…#%03d%s\n' "$D" "$GLOBAL_N" "$N" fi - if [ "${#BLOCKERS[@]}" -gt 0 ]; then + if [ "$blk_n" -gt 0 ]; then if [ "${BOX:-0}" = "1" ]; then printf -- '%s┌ BLOCKERS · pay/withdraw cannot finish ┐%s\n' "$BG_BLK" "$N" else - printf -- '--- BLOCKERS (pay/withdraw cannot finish) ---\n' + printf -- '%s--- BLOCKERS (pay/withdraw cannot finish) ---%s\n' "$M" "$N" fi local b for b in "${BLOCKERS[@]}"; do - printf -- '%s •%s %s%s%s\n' "$M" "$N" "$W" "$b" "$N" + printf -- ' %s•%s %s%s%s\n' "$M" "$N" "$W" "$b" "$N" done fi - if [ "${#ERRORS[@]}" -gt 0 ] && [ "${#BLOCKERS[@]}" -lt "${#ERRORS[@]}" ]; then + if [ "${#ERRORS[@]}" -gt 0 ] && [ "$blk_n" -lt "${#ERRORS[@]}" ]; then if [ "${BOX:-0}" = "1" ]; then printf -- '%s┌ ERRORS · failed checks ┐%s\n' "$BG_ERR" "$N" else - printf -- '--- ERRORS (failed checks) ---\n' + printf -- '%s--- ERRORS (failed checks) ---%s\n' "$R" "$N" fi local e for e in "${ERRORS[@]}"; do case "$e" in BLOCKER*) continue ;; esac - printf -- '%s •%s %s%s%s\n' "$R" "$N" "$W" "$e" "$N" + printf -- ' %s•%s %s%s%s\n' "$R" "$N" "$W" "$e" "$N" done fi - printf -- '%stotals:%s %s%d OK%s' "$D" "$N" "$G" "$PASS_N" "$N" - [ "$FAIL_N" -gt 0 ] && printf -- ', %s%d ERROR%s' "$R" "$FAIL_N" "$N" - [ "$WARN_N" -gt 0 ] && printf -- ', %s%d WARN%s' "$Y" "$WARN_N" "$N" - [ "$INFO_N" -gt 0 ] && printf -- ', %s%d INFO%s' "$C" "$INFO_N" "$N" - [ "${#BLOCKERS[@]}" -gt 0 ] && printf -- ', %s%d BLOCKER%s' "$M" "${#BLOCKERS[@]}" "$N" - printf '\n' + + # Coloured totals — same severity badges as check lines + if [ "${BOX:-0}" = "1" ]; then + printf -- '\n%s┌ SUMMARY ┐%s\n' "$BG_SEC" "$N" + printf -- ' %s┌ OK ┐%s %s%4d%s\n' "$BG_OK" "$N" "$G" "$PASS_N" "$N" + if [ "$FAIL_N" -gt 0 ]; then + printf -- ' %s┌ ERROR ┐%s %s%4d%s\n' "$BG_ERR" "$N" "$R" "$FAIL_N" "$N" + fi + if [ "$WARN_N" -gt 0 ]; then + printf -- ' %s┌ WARN ┐%s %s%4d%s\n' "$BG_WARN" "$N" "$Y" "$WARN_N" "$N" + fi + if [ "$INFO_N" -gt 0 ]; then + printf -- ' %s┌ INFO ┐%s %s%4d%s\n' "$BG_INFO" "$N" "$C" "$INFO_N" "$N" + fi + if [ "$blk_n" -gt 0 ]; then + printf -- ' %s┌ BLOCK ┐%s %s%4d%s\n' "$BG_BLK" "$N" "$M" "$blk_n" "$N" + fi + # one-line rollup (bold counts) + printf -- ' %s%s totals:%s %s%d OK%s' "$B" "$D" "$N" "$G" "$PASS_N" "$N" + [ "$FAIL_N" -gt 0 ] && printf -- ' · %s%d ERROR%s' "$R" "$FAIL_N" "$N" + [ "$WARN_N" -gt 0 ] && printf -- ' · %s%d WARN%s' "$Y" "$WARN_N" "$N" + [ "$INFO_N" -gt 0 ] && printf -- ' · %s%d INFO%s' "$C" "$INFO_N" "$N" + [ "$blk_n" -gt 0 ] && printf -- ' · %s%d BLOCKER%s' "$M" "$blk_n" "$N" + printf '\n' + # overall verdict + if [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ] && [ "$WARN_N" -eq 0 ]; then + printf -- ' %s┌ OK ┐%s %sall clear%s\n' "$BG_OK" "$N" "$G" "$N" + elif [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ]; then + printf -- ' %s┌ WARN ┐%s %swarnings only (no hard fail)%s\n' "$BG_WARN" "$N" "$Y" "$N" + else + printf -- ' %s┌ ERROR ┐%s %sfailed — see list above%s\n' "$BG_ERR" "$N" "$R" "$N" + fi + else + printf -- 'totals: %s%d OK%s' "$G" "$PASS_N" "$N" + [ "$FAIL_N" -gt 0 ] && printf -- ', %s%d ERROR%s' "$R" "$FAIL_N" "$N" + [ "$WARN_N" -gt 0 ] && printf -- ', %s%d WARN%s' "$Y" "$WARN_N" "$N" + [ "$INFO_N" -gt 0 ] && printf -- ', %s%d INFO%s' "$C" "$INFO_N" "$N" + [ "$blk_n" -gt 0 ] && printf -- ', %s%d BLOCKER%s' "$M" "$blk_n" "$N" + printf '\n' + fi [ "$FAIL_N" -eq 0 ] } From e82ee9a70b9e50f50b93cd87046f2c5a222bc0df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:03:49 +0200 Subject: [PATCH 222/326] monitoring: coloured SUMMARY block at end of run Severity badges for OK/ERROR/WARN/INFO/BLOCKER counts plus verdict line (all clear / warnings only / failed). --- scripts/taler-monitoring/lib.sh | 61 ++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 645cf2e..c3cd2b2 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -722,6 +722,7 @@ section() { } summary() { + local blk_n=${#BLOCKERS[@]} echo "" # final progress line (no _take_tid — plain printf) if [ "${PROGRESS_OFF:-0}" != "1" ] && [ "$PROGRESS_DONE" -gt 0 ]; then @@ -733,37 +734,71 @@ summary() { _progress_bar_line "$PROGRESS_DONE" "$PROGRESS_TOTAL" fi if [ "$GLOBAL_N" -gt 0 ]; then - printf -- '%s numbered checks this run: #001…#%03d\n' "$D" "$GLOBAL_N" + printf -- '%s numbered checks this run: #001…#%03d%s\n' "$D" "$GLOBAL_N" "$N" fi - if [ "${#BLOCKERS[@]}" -gt 0 ]; then + if [ "$blk_n" -gt 0 ]; then if [ "${BOX:-0}" = "1" ]; then printf -- '%s┌ BLOCKERS · pay/withdraw cannot finish ┐%s\n' "$BG_BLK" "$N" else - printf -- '--- BLOCKERS (pay/withdraw cannot finish) ---\n' + printf -- '%s--- BLOCKERS (pay/withdraw cannot finish) ---%s\n' "$M" "$N" fi local b for b in "${BLOCKERS[@]}"; do - printf -- '%s •%s %s%s%s\n' "$M" "$N" "$W" "$b" "$N" + printf -- ' %s•%s %s%s%s\n' "$M" "$N" "$W" "$b" "$N" done fi - if [ "${#ERRORS[@]}" -gt 0 ] && [ "${#BLOCKERS[@]}" -lt "${#ERRORS[@]}" ]; then + if [ "${#ERRORS[@]}" -gt 0 ] && [ "$blk_n" -lt "${#ERRORS[@]}" ]; then if [ "${BOX:-0}" = "1" ]; then printf -- '%s┌ ERRORS · failed checks ┐%s\n' "$BG_ERR" "$N" else - printf -- '--- ERRORS (failed checks) ---\n' + printf -- '%s--- ERRORS (failed checks) ---%s\n' "$R" "$N" fi local e for e in "${ERRORS[@]}"; do case "$e" in BLOCKER*) continue ;; esac - printf -- '%s •%s %s%s%s\n' "$R" "$N" "$W" "$e" "$N" + printf -- ' %s•%s %s%s%s\n' "$R" "$N" "$W" "$e" "$N" done fi - printf -- '%stotals:%s %s%d OK%s' "$D" "$N" "$G" "$PASS_N" "$N" - [ "$FAIL_N" -gt 0 ] && printf -- ', %s%d ERROR%s' "$R" "$FAIL_N" "$N" - [ "$WARN_N" -gt 0 ] && printf -- ', %s%d WARN%s' "$Y" "$WARN_N" "$N" - [ "$INFO_N" -gt 0 ] && printf -- ', %s%d INFO%s' "$C" "$INFO_N" "$N" - [ "${#BLOCKERS[@]}" -gt 0 ] && printf -- ', %s%d BLOCKER%s' "$M" "${#BLOCKERS[@]}" "$N" - printf '\n' + + # Coloured totals — same severity badges as check lines + if [ "${BOX:-0}" = "1" ]; then + printf -- '\n%s┌ SUMMARY ┐%s\n' "$BG_SEC" "$N" + printf -- ' %s┌ OK ┐%s %s%4d%s\n' "$BG_OK" "$N" "$G" "$PASS_N" "$N" + if [ "$FAIL_N" -gt 0 ]; then + printf -- ' %s┌ ERROR ┐%s %s%4d%s\n' "$BG_ERR" "$N" "$R" "$FAIL_N" "$N" + fi + if [ "$WARN_N" -gt 0 ]; then + printf -- ' %s┌ WARN ┐%s %s%4d%s\n' "$BG_WARN" "$N" "$Y" "$WARN_N" "$N" + fi + if [ "$INFO_N" -gt 0 ]; then + printf -- ' %s┌ INFO ┐%s %s%4d%s\n' "$BG_INFO" "$N" "$C" "$INFO_N" "$N" + fi + if [ "$blk_n" -gt 0 ]; then + printf -- ' %s┌ BLOCK ┐%s %s%4d%s\n' "$BG_BLK" "$N" "$M" "$blk_n" "$N" + fi + # one-line rollup (bold counts) + printf -- ' %s%s totals:%s %s%d OK%s' "$B" "$D" "$N" "$G" "$PASS_N" "$N" + [ "$FAIL_N" -gt 0 ] && printf -- ' · %s%d ERROR%s' "$R" "$FAIL_N" "$N" + [ "$WARN_N" -gt 0 ] && printf -- ' · %s%d WARN%s' "$Y" "$WARN_N" "$N" + [ "$INFO_N" -gt 0 ] && printf -- ' · %s%d INFO%s' "$C" "$INFO_N" "$N" + [ "$blk_n" -gt 0 ] && printf -- ' · %s%d BLOCKER%s' "$M" "$blk_n" "$N" + printf '\n' + # overall verdict + if [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ] && [ "$WARN_N" -eq 0 ]; then + printf -- ' %s┌ OK ┐%s %sall clear%s\n' "$BG_OK" "$N" "$G" "$N" + elif [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ]; then + printf -- ' %s┌ WARN ┐%s %swarnings only (no hard fail)%s\n' "$BG_WARN" "$N" "$Y" "$N" + else + printf -- ' %s┌ ERROR ┐%s %sfailed — see list above%s\n' "$BG_ERR" "$N" "$R" "$N" + fi + else + printf -- 'totals: %s%d OK%s' "$G" "$PASS_N" "$N" + [ "$FAIL_N" -gt 0 ] && printf -- ', %s%d ERROR%s' "$R" "$FAIL_N" "$N" + [ "$WARN_N" -gt 0 ] && printf -- ', %s%d WARN%s' "$Y" "$WARN_N" "$N" + [ "$INFO_N" -gt 0 ] && printf -- ', %s%d INFO%s' "$C" "$INFO_N" "$N" + [ "$blk_n" -gt 0 ] && printf -- ', %s%d BLOCKER%s' "$M" "$blk_n" "$N" + printf '\n' + fi [ "$FAIL_N" -eq 0 ] } From 9b43b14c6b4c83f76534d06ede94f9110682aa82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:04:41 +0200 Subject: [PATCH 223/326] monitoring: always print coloured SUMMARY (not only BOX mode) --- scripts/taler-monitoring/lib.sh | 69 ++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index c3cd2b2..d0d9bce 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -760,44 +760,51 @@ summary() { done fi - # Coloured totals — same severity badges as check lines + # Coloured totals — same severity badges as check lines (always; NO_COLOR blanks ANSI) if [ "${BOX:-0}" = "1" ]; then printf -- '\n%s┌ SUMMARY ┐%s\n' "$BG_SEC" "$N" - printf -- ' %s┌ OK ┐%s %s%4d%s\n' "$BG_OK" "$N" "$G" "$PASS_N" "$N" - if [ "$FAIL_N" -gt 0 ]; then - printf -- ' %s┌ ERROR ┐%s %s%4d%s\n' "$BG_ERR" "$N" "$R" "$FAIL_N" "$N" - fi - if [ "$WARN_N" -gt 0 ]; then - printf -- ' %s┌ WARN ┐%s %s%4d%s\n' "$BG_WARN" "$N" "$Y" "$WARN_N" "$N" - fi - if [ "$INFO_N" -gt 0 ]; then - printf -- ' %s┌ INFO ┐%s %s%4d%s\n' "$BG_INFO" "$N" "$C" "$INFO_N" "$N" - fi - if [ "$blk_n" -gt 0 ]; then - printf -- ' %s┌ BLOCK ┐%s %s%4d%s\n' "$BG_BLK" "$N" "$M" "$blk_n" "$N" - fi - # one-line rollup (bold counts) - printf -- ' %s%s totals:%s %s%d OK%s' "$B" "$D" "$N" "$G" "$PASS_N" "$N" - [ "$FAIL_N" -gt 0 ] && printf -- ' · %s%d ERROR%s' "$R" "$FAIL_N" "$N" - [ "$WARN_N" -gt 0 ] && printf -- ' · %s%d WARN%s' "$Y" "$WARN_N" "$N" - [ "$INFO_N" -gt 0 ] && printf -- ' · %s%d INFO%s' "$C" "$INFO_N" "$N" - [ "$blk_n" -gt 0 ] && printf -- ' · %s%d BLOCKER%s' "$M" "$blk_n" "$N" - printf '\n' - # overall verdict - if [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ] && [ "$WARN_N" -eq 0 ]; then + _sum_badge() { # $1=bg $2=tag $3=fg $4=n + printf -- ' %s┌ %-5s┐%s %s%4d%s\n' "$1" "$2" "$N" "$3" "$4" "$N" + } + _sum_badge "$BG_OK" "OK" "$G" "$PASS_N" + [ "$FAIL_N" -gt 0 ] && _sum_badge "$BG_ERR" "ERROR" "$R" "$FAIL_N" + [ "$WARN_N" -gt 0 ] && _sum_badge "$BG_WARN" "WARN" "$Y" "$WARN_N" + [ "$INFO_N" -gt 0 ] && _sum_badge "$BG_INFO" "INFO" "$C" "$INFO_N" + [ "$blk_n" -gt 0 ] && _sum_badge "$BG_BLK" "BLOCK" "$M" "$blk_n" + else + printf -- '\n[ SUMMARY ]\n' + printf -- ' [ OK ] %s%4d%s\n' "$G" "$PASS_N" "$N" + [ "$FAIL_N" -gt 0 ] && printf -- ' [ ERROR ] %s%4d%s\n' "$R" "$FAIL_N" "$N" + [ "$WARN_N" -gt 0 ] && printf -- ' [ WARN ] %s%4d%s\n' "$Y" "$WARN_N" "$N" + [ "$INFO_N" -gt 0 ] && printf -- ' [ INFO ] %s%4d%s\n' "$C" "$INFO_N" "$N" + [ "$blk_n" -gt 0 ] && printf -- ' [ BLOCK ] %s%4d%s\n' "$M" "$blk_n" "$N" + fi + # one-line rollup (bold label, severity-coloured counts) + printf -- ' %stotals:%s %s%d OK%s' "$B" "$N" "$G" "$PASS_N" "$N" + [ "$FAIL_N" -gt 0 ] && printf -- ' · %s%d ERROR%s' "$R" "$FAIL_N" "$N" + [ "$WARN_N" -gt 0 ] && printf -- ' · %s%d WARN%s' "$Y" "$WARN_N" "$N" + [ "$INFO_N" -gt 0 ] && printf -- ' · %s%d INFO%s' "$C" "$INFO_N" "$N" + [ "$blk_n" -gt 0 ] && printf -- ' · %s%d BLOCKER%s' "$M" "$blk_n" "$N" + printf '\n' + # overall verdict + if [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ] && [ "$WARN_N" -eq 0 ]; then + if [ "${BOX:-0}" = "1" ]; then printf -- ' %s┌ OK ┐%s %sall clear%s\n' "$BG_OK" "$N" "$G" "$N" - elif [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ]; then + else + printf -- ' %s[ OK ] all clear%s\n' "$G" "$N" + fi + elif [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ]; then + if [ "${BOX:-0}" = "1" ]; then printf -- ' %s┌ WARN ┐%s %swarnings only (no hard fail)%s\n' "$BG_WARN" "$N" "$Y" "$N" else - printf -- ' %s┌ ERROR ┐%s %sfailed — see list above%s\n' "$BG_ERR" "$N" "$R" "$N" + printf -- ' %s[ WARN ] warnings only (no hard fail)%s\n' "$Y" "$N" fi else - printf -- 'totals: %s%d OK%s' "$G" "$PASS_N" "$N" - [ "$FAIL_N" -gt 0 ] && printf -- ', %s%d ERROR%s' "$R" "$FAIL_N" "$N" - [ "$WARN_N" -gt 0 ] && printf -- ', %s%d WARN%s' "$Y" "$WARN_N" "$N" - [ "$INFO_N" -gt 0 ] && printf -- ', %s%d INFO%s' "$C" "$INFO_N" "$N" - [ "$blk_n" -gt 0 ] && printf -- ', %s%d BLOCKER%s' "$M" "$blk_n" "$N" - printf '\n' + if [ "${BOX:-0}" = "1" ]; then + printf -- ' %s┌ ERROR ┐%s %sfailed — see list above%s\n' "$BG_ERR" "$N" "$R" "$N" + else + printf -- ' %s[ ERROR ] failed — see list above%s\n' "$R" "$N" + fi fi [ "$FAIL_N" -eq 0 ] } From c09c83672da37030f60ca46a0a165e8977880cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:04:41 +0200 Subject: [PATCH 224/326] monitoring: always print coloured SUMMARY (not only BOX mode) --- scripts/taler-monitoring/lib.sh | 69 ++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index c3cd2b2..d0d9bce 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -760,44 +760,51 @@ summary() { done fi - # Coloured totals — same severity badges as check lines + # Coloured totals — same severity badges as check lines (always; NO_COLOR blanks ANSI) if [ "${BOX:-0}" = "1" ]; then printf -- '\n%s┌ SUMMARY ┐%s\n' "$BG_SEC" "$N" - printf -- ' %s┌ OK ┐%s %s%4d%s\n' "$BG_OK" "$N" "$G" "$PASS_N" "$N" - if [ "$FAIL_N" -gt 0 ]; then - printf -- ' %s┌ ERROR ┐%s %s%4d%s\n' "$BG_ERR" "$N" "$R" "$FAIL_N" "$N" - fi - if [ "$WARN_N" -gt 0 ]; then - printf -- ' %s┌ WARN ┐%s %s%4d%s\n' "$BG_WARN" "$N" "$Y" "$WARN_N" "$N" - fi - if [ "$INFO_N" -gt 0 ]; then - printf -- ' %s┌ INFO ┐%s %s%4d%s\n' "$BG_INFO" "$N" "$C" "$INFO_N" "$N" - fi - if [ "$blk_n" -gt 0 ]; then - printf -- ' %s┌ BLOCK ┐%s %s%4d%s\n' "$BG_BLK" "$N" "$M" "$blk_n" "$N" - fi - # one-line rollup (bold counts) - printf -- ' %s%s totals:%s %s%d OK%s' "$B" "$D" "$N" "$G" "$PASS_N" "$N" - [ "$FAIL_N" -gt 0 ] && printf -- ' · %s%d ERROR%s' "$R" "$FAIL_N" "$N" - [ "$WARN_N" -gt 0 ] && printf -- ' · %s%d WARN%s' "$Y" "$WARN_N" "$N" - [ "$INFO_N" -gt 0 ] && printf -- ' · %s%d INFO%s' "$C" "$INFO_N" "$N" - [ "$blk_n" -gt 0 ] && printf -- ' · %s%d BLOCKER%s' "$M" "$blk_n" "$N" - printf '\n' - # overall verdict - if [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ] && [ "$WARN_N" -eq 0 ]; then + _sum_badge() { # $1=bg $2=tag $3=fg $4=n + printf -- ' %s┌ %-5s┐%s %s%4d%s\n' "$1" "$2" "$N" "$3" "$4" "$N" + } + _sum_badge "$BG_OK" "OK" "$G" "$PASS_N" + [ "$FAIL_N" -gt 0 ] && _sum_badge "$BG_ERR" "ERROR" "$R" "$FAIL_N" + [ "$WARN_N" -gt 0 ] && _sum_badge "$BG_WARN" "WARN" "$Y" "$WARN_N" + [ "$INFO_N" -gt 0 ] && _sum_badge "$BG_INFO" "INFO" "$C" "$INFO_N" + [ "$blk_n" -gt 0 ] && _sum_badge "$BG_BLK" "BLOCK" "$M" "$blk_n" + else + printf -- '\n[ SUMMARY ]\n' + printf -- ' [ OK ] %s%4d%s\n' "$G" "$PASS_N" "$N" + [ "$FAIL_N" -gt 0 ] && printf -- ' [ ERROR ] %s%4d%s\n' "$R" "$FAIL_N" "$N" + [ "$WARN_N" -gt 0 ] && printf -- ' [ WARN ] %s%4d%s\n' "$Y" "$WARN_N" "$N" + [ "$INFO_N" -gt 0 ] && printf -- ' [ INFO ] %s%4d%s\n' "$C" "$INFO_N" "$N" + [ "$blk_n" -gt 0 ] && printf -- ' [ BLOCK ] %s%4d%s\n' "$M" "$blk_n" "$N" + fi + # one-line rollup (bold label, severity-coloured counts) + printf -- ' %stotals:%s %s%d OK%s' "$B" "$N" "$G" "$PASS_N" "$N" + [ "$FAIL_N" -gt 0 ] && printf -- ' · %s%d ERROR%s' "$R" "$FAIL_N" "$N" + [ "$WARN_N" -gt 0 ] && printf -- ' · %s%d WARN%s' "$Y" "$WARN_N" "$N" + [ "$INFO_N" -gt 0 ] && printf -- ' · %s%d INFO%s' "$C" "$INFO_N" "$N" + [ "$blk_n" -gt 0 ] && printf -- ' · %s%d BLOCKER%s' "$M" "$blk_n" "$N" + printf '\n' + # overall verdict + if [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ] && [ "$WARN_N" -eq 0 ]; then + if [ "${BOX:-0}" = "1" ]; then printf -- ' %s┌ OK ┐%s %sall clear%s\n' "$BG_OK" "$N" "$G" "$N" - elif [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ]; then + else + printf -- ' %s[ OK ] all clear%s\n' "$G" "$N" + fi + elif [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ]; then + if [ "${BOX:-0}" = "1" ]; then printf -- ' %s┌ WARN ┐%s %swarnings only (no hard fail)%s\n' "$BG_WARN" "$N" "$Y" "$N" else - printf -- ' %s┌ ERROR ┐%s %sfailed — see list above%s\n' "$BG_ERR" "$N" "$R" "$N" + printf -- ' %s[ WARN ] warnings only (no hard fail)%s\n' "$Y" "$N" fi else - printf -- 'totals: %s%d OK%s' "$G" "$PASS_N" "$N" - [ "$FAIL_N" -gt 0 ] && printf -- ', %s%d ERROR%s' "$R" "$FAIL_N" "$N" - [ "$WARN_N" -gt 0 ] && printf -- ', %s%d WARN%s' "$Y" "$WARN_N" "$N" - [ "$INFO_N" -gt 0 ] && printf -- ', %s%d INFO%s' "$C" "$INFO_N" "$N" - [ "$blk_n" -gt 0 ] && printf -- ', %s%d BLOCKER%s' "$M" "$blk_n" "$N" - printf '\n' + if [ "${BOX:-0}" = "1" ]; then + printf -- ' %s┌ ERROR ┐%s %sfailed — see list above%s\n' "$BG_ERR" "$N" "$R" "$N" + else + printf -- ' %s[ ERROR ] failed — see list above%s\n' "$R" "$N" + fi fi [ "$FAIL_N" -eq 0 ] } From 52630605677d96dcfd82dddf3a75c99dc3cb98da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:09:37 +0200 Subject: [PATCH 225/326] monitoring: default e2e (withdraw+pay) for stage lefrancpaysan -d stage.lefrancpaysan.ch / TESTPAYSAN runs urls+e2e by default. --- scripts/taler-monitoring/README.md | 6 +++++- scripts/taler-monitoring/secrets.env.example | 8 ++++++++ scripts/taler-monitoring/taler-monitoring.sh | 14 +++++++++++--- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 9d5422f..4771c6a 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -81,8 +81,12 @@ Each check prints **global** and **grouped** ids: ./taler-monitoring.sh sanity ./taler-monitoring.sh e2e +# FrancPaysan STAGE (TESTPAYSAN) — default: urls + e2e (withdraw + pay templates) +./taler-monitoring.sh -d stage.lefrancpaysan.ch +# Admin credit: E2E_BANK_ADMIN_PASS=… or SSH francpaysan-host secrets +# (bank-admin-password.txt under /mnt/data/stagepaysan/bank/secrets/) -# Other domains — public HTTPS only, never SSH +# Other domains — public HTTPS only, never SSH (pass e2e explicitly) ./taler-monitoring.sh -d taler.net ./taler-monitoring.sh --domain taler-ops.ch ./taler-monitoring.sh -d my.taler-ops.ch urls diff --git a/scripts/taler-monitoring/secrets.env.example b/scripts/taler-monitoring/secrets.env.example index 2af0ffc..f7e13de 100644 --- a/scripts/taler-monitoring/secrets.env.example +++ b/scripts/taler-monitoring/secrets.env.example @@ -26,6 +26,14 @@ # MERCHANT_TOKEN= # MERCHANT_INSTANCE_PASSWORD= +# --- FrancPaysan STAGE (TESTPAYSAN) e2e --- +# Default phases for -d stage.lefrancpaysan.ch: urls + e2e +# Admin credit (required for ATM withdraw ladder on stage): +# E2E_BANK_ADMIN_PASS=… # or auto-load via SSH francpaysan-host +# FRANCPAYSAN_SSH=francpaysan-host +# FRANCPAYSAN_SECRETS=$HOME/francpaysan-secrets +# Public shop pays use E2E_USE_TEMPLATES=1 (default on TESTPAYSAN) — no merchant token. + # Optional overrides # MERCHANT_INSTANCE=goa-demo-cp4zqk # EXPECT_CURRENCY=GOA diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 75d07e9..05e6f5d 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -152,7 +152,7 @@ export TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC export EXPECT_CURRENCY SKIP_SSH LOCAL_STACK TALER_DOMAIN_PROBE CHECK_LANDING 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 E2E_WITHDRAW_VALUES E2E_PAY_VALUES E2E_USE_TEMPLATES E2E_TEMPLATE_MAP export PAIVANA_PUBLIC E2E_PAIVANA E2E_PAIVANA_TEMPLATE E2E_PAIVANA_AMOUNT E2E_PAIVANA_INSTANCE # 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. @@ -181,11 +181,19 @@ export LADDER_PAY LADDER_WITHDRAW_SCALE LADDER_PAY_SETTLE_ROUNDS export TALER_DOMAIN_APPLIED=1 # Default phases +# - local GOA (koopa): urls + SSH phases + e2e +# - FrancPaysan STAGE (TESTPAYSAN / stage.*lefrancpaysan*): urls + e2e +# (public withdraw + template pay — no SSH) +# - other remote: urls only (pass "e2e" explicitly) if [ "${#PHASES[@]}" -eq 0 ]; then if [ "${LOCAL_STACK}" = "1" ]; then - PHASES=(urls inside versions e2e) # koopa: SSH ok + PHASES=(urls inside versions e2e) + elif [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \ + || [[ "${TALER_DOMAIN:-}" == stage.*lefrancpaysan* ]] \ + || [[ "${TALER_DOMAIN:-}" == *stage.lefrancpaysan* ]]; then + PHASES=(urls e2e) else - PHASES=(urls) # remote default: public only; pass "e2e" to try + PHASES=(urls) fi fi From 7c88abcbc7e1c2f028bb287d31e618a1e7baf21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:09:37 +0200 Subject: [PATCH 226/326] monitoring: default e2e (withdraw+pay) for stage lefrancpaysan -d stage.lefrancpaysan.ch / TESTPAYSAN runs urls+e2e by default. --- scripts/taler-monitoring/README.md | 6 +++++- scripts/taler-monitoring/secrets.env.example | 8 ++++++++ scripts/taler-monitoring/taler-monitoring.sh | 14 +++++++++++--- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 9d5422f..4771c6a 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -81,8 +81,12 @@ Each check prints **global** and **grouped** ids: ./taler-monitoring.sh sanity ./taler-monitoring.sh e2e +# FrancPaysan STAGE (TESTPAYSAN) — default: urls + e2e (withdraw + pay templates) +./taler-monitoring.sh -d stage.lefrancpaysan.ch +# Admin credit: E2E_BANK_ADMIN_PASS=… or SSH francpaysan-host secrets +# (bank-admin-password.txt under /mnt/data/stagepaysan/bank/secrets/) -# Other domains — public HTTPS only, never SSH +# Other domains — public HTTPS only, never SSH (pass e2e explicitly) ./taler-monitoring.sh -d taler.net ./taler-monitoring.sh --domain taler-ops.ch ./taler-monitoring.sh -d my.taler-ops.ch urls diff --git a/scripts/taler-monitoring/secrets.env.example b/scripts/taler-monitoring/secrets.env.example index 2af0ffc..f7e13de 100644 --- a/scripts/taler-monitoring/secrets.env.example +++ b/scripts/taler-monitoring/secrets.env.example @@ -26,6 +26,14 @@ # MERCHANT_TOKEN= # MERCHANT_INSTANCE_PASSWORD= +# --- FrancPaysan STAGE (TESTPAYSAN) e2e --- +# Default phases for -d stage.lefrancpaysan.ch: urls + e2e +# Admin credit (required for ATM withdraw ladder on stage): +# E2E_BANK_ADMIN_PASS=… # or auto-load via SSH francpaysan-host +# FRANCPAYSAN_SSH=francpaysan-host +# FRANCPAYSAN_SECRETS=$HOME/francpaysan-secrets +# Public shop pays use E2E_USE_TEMPLATES=1 (default on TESTPAYSAN) — no merchant token. + # Optional overrides # MERCHANT_INSTANCE=goa-demo-cp4zqk # EXPECT_CURRENCY=GOA diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 75d07e9..05e6f5d 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -152,7 +152,7 @@ export TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC export EXPECT_CURRENCY SKIP_SSH LOCAL_STACK TALER_DOMAIN_PROBE CHECK_LANDING 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 E2E_WITHDRAW_VALUES E2E_PAY_VALUES E2E_USE_TEMPLATES E2E_TEMPLATE_MAP export PAIVANA_PUBLIC E2E_PAIVANA E2E_PAIVANA_TEMPLATE E2E_PAIVANA_AMOUNT E2E_PAIVANA_INSTANCE # 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. @@ -181,11 +181,19 @@ export LADDER_PAY LADDER_WITHDRAW_SCALE LADDER_PAY_SETTLE_ROUNDS export TALER_DOMAIN_APPLIED=1 # Default phases +# - local GOA (koopa): urls + SSH phases + e2e +# - FrancPaysan STAGE (TESTPAYSAN / stage.*lefrancpaysan*): urls + e2e +# (public withdraw + template pay — no SSH) +# - other remote: urls only (pass "e2e" explicitly) if [ "${#PHASES[@]}" -eq 0 ]; then if [ "${LOCAL_STACK}" = "1" ]; then - PHASES=(urls inside versions e2e) # koopa: SSH ok + PHASES=(urls inside versions e2e) + elif [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \ + || [[ "${TALER_DOMAIN:-}" == stage.*lefrancpaysan* ]] \ + || [[ "${TALER_DOMAIN:-}" == *stage.lefrancpaysan* ]]; then + PHASES=(urls e2e) else - PHASES=(urls) # remote default: public only; pass "e2e" to try + PHASES=(urls) fi fi From eac6098b9f9f21f78ebb0b079645a3d836a3b714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:09:37 +0200 Subject: [PATCH 227/326] monitoring: stage e2e uses farmer shop templates + admin secret load Map pays to jardin/fermes instances; load bank-admin via SSH francpaysan-host; larger TESTPAYSAN credit/withdraw for shop ladder. --- scripts/taler-monitoring/check_e2e.sh | 106 ++++++++++++++++++++++---- scripts/taler-monitoring/lib.sh | 21 +++-- 2 files changed, 109 insertions(+), 18 deletions(-) diff --git a/scripts/taler-monitoring/check_e2e.sh b/scripts/taler-monitoring/check_e2e.sh index 905e85d..05cb5f7 100755 --- a/scripts/taler-monitoring/check_e2e.sh +++ b/scripts/taler-monitoring/check_e2e.sh @@ -237,12 +237,13 @@ except Exception: print("")' 2>/dev/null || true) : "${E2E_VARIABLE:=1}" : "${E2E_ATM_MAX:=5}" if [ "$E2E_REMOTE" = "1" ]; then - # remote: still ATM-shaped but smaller notes; keep cheap - : "${E2E_WITHDRAW_VALUES:=10 20 50}" + # remote: still ATM-shaped; TESTPAYSAN needs larger notes for shop pays if [ "$CUR" = "TESTPAYSAN" ]; then - # match fixed public templates e2e-001 / e2e-005 / e2e-1 - : "${E2E_PAY_VALUES:=0.01 0.05 1}" + : "${E2E_WITHDRAW_VALUES:=20 50}" + # farmer shop template face values (oeufs-6=5, fromage=8.5, jus=6) + : "${E2E_PAY_VALUES:=5 8.5 6}" else + : "${E2E_WITHDRAW_VALUES:=10 20 50}" : "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}" fi else @@ -430,11 +431,58 @@ set_group load 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). +# Local stack: koopa secrets. Remote: env first; TESTPAYSAN stage may load francpaysan host secrets. set_group prereq if [ "$E2E_REMOTE" = "1" ]; then - ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-}" + ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-${BANK_ADMIN_PASS:-}}" MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}" + # Stage FrancPaysan: allow admin credit without committing passwords. + # Order: already-set env → local secrets tree → SSH francpaysan-host (ops laptop). + if [ -z "$ADMIN_PASS" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then + _fp_try="" + for _fp_try in \ + "${FRANCPAYSAN_SECRETS:-}/stage/bank-admin-password.txt" \ + "${HOME}/francpaysan-secrets/stage/bank-admin-password.txt" \ + "${HOME}/src/francpaysan-secrets/stage/bank-admin-password.txt" \ + "${HOME}/taler/src/francpaysan-secrets/stage/bank-admin-password.txt" + do + if [ -n "$_fp_try" ] && [ -f "$_fp_try" ]; then + ADMIN_PASS=$(tr -d '\n\r' <"$_fp_try") + info "bank admin secret" "from $_fp_try" + break + fi + done + if [ -z "$ADMIN_PASS" ] && command -v ssh >/dev/null 2>&1; then + for _fp_host in "${FRANCPAYSAN_SSH:-francpaysan-host}" francpaysan-host; do + ADMIN_PASS=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "$_fp_host" \ + 'sudo cat /mnt/data/stagepaysan/bank/secrets/bank-admin-password.txt 2>/dev/null' \ + 2>/dev/null | tr -d '\n\r' || true) + if [ -n "$ADMIN_PASS" ]; then + info "bank admin secret" "from ${_fp_host}:/mnt/data/stagepaysan/bank/secrets/" + break + fi + done + fi + unset _fp_try _fp_host + fi + # Stage merchant: public templates need no token; optional default-instance token + if [ -z "$MPW" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then + for _fp_try in \ + "${FRANCPAYSAN_SECRETS:-}/stage/default-instance-token.txt" \ + "${HOME}/francpaysan-secrets/stage/default-instance-token.txt" + do + if [ -n "$_fp_try" ] && [ -f "$_fp_try" ]; then + MPW=$(tr -d '\n\r' <"$_fp_try") + break + fi + done + if [ -z "$MPW" ] && command -v ssh >/dev/null 2>&1; then + MPW=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "${FRANCPAYSAN_SSH:-francpaysan-host}" \ + 'sudo cat /mnt/data/stagepaysan/merchant/secrets/default-instance-token.txt 2>/dev/null' \ + 2>/dev/null | tr -d '\n\r' || true) + fi + unset _fp_try + fi else ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-$(read_secret "taler-bank/bank-admin-password.txt" || true)}" MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-$(read_secret "taler-merchant/merchant-goa-demo-cp4zqk-password.txt" || true)}}" @@ -977,13 +1025,20 @@ else: return 1 } -# Map CUR:amount → public template id (TESTPAYSAN stage e2e-* templates). -# Override with E2E_TEMPLATE_MAP="0.01=e2e-001 0.05=e2e-005 1=e2e-1" +# Map CUR:amount → public template id. +# GOA default: e2e-* on goa-demo. TESTPAYSAN: farmer shop templates (set in apply_taler_domain). +# Override with E2E_TEMPLATE_MAP="5=oeufs-6 8.5=fromage-chevre" e2e_template_id_for_amount() { local amt="$1" local val map_entry k v val=$(printf '%s' "$amt" | awk -F: '{print $NF}') - : "${E2E_TEMPLATE_MAP:=0.01=e2e-001 0.05=e2e-005 1=e2e-1 1.0=e2e-1}" + if [ -z "${E2E_TEMPLATE_MAP:-}" ]; then + if [ "${CUR:-}" = "TESTPAYSAN" ]; then + E2E_TEMPLATE_MAP="5=oeufs-6 4.5=pain-seigle 6=jus-pomme 8.5=fromage-chevre 12=miel-printemps 25=panier-legumes" + else + E2E_TEMPLATE_MAP="0.01=e2e-001 0.05=e2e-005 1=e2e-1 1.0=e2e-1" + fi + fi for map_entry in $E2E_TEMPLATE_MAP; do k="${map_entry%%=*}" v="${map_entry#*=}" @@ -995,6 +1050,19 @@ e2e_template_id_for_amount() { return 1 } +# Template id → merchant instance (stage farmer shops / GOA goa-shop) +e2e_template_instance_for_id() { + local pid="$1" + case "$pid" in + oeufs-6|pain-seigle|jus-pomme) printf '%s' "jardin-du-creux" ;; + panier-legumes|fromage-chevre|miel-printemps) printf '%s' "fermes-des-collines" ;; + e2e-001|e2e-005|e2e-1|orbit-sticker|nebula-coffee|voidwave-playlist|comet-cap|shuttle-pass|beacon-badge|relay-pin|eclipse-shades|star-chart|rainbow-pill|blue-or-red-pill) + printf '%s' "${E2E_SHOP_INSTANCE:-goa-shop}" + ;; + *) printf '%s' "${MERCHANT_INSTANCE:-default}" ;; + esac +} + # One payment: e2e_one_pay AMOUNT [SUMMARY] [TAG] # SUMMARY defaults to "monitoring pay $AMOUNT"; TAG defaults from amount. # When E2E_USE_TEMPLATES=1 and amount maps to a public template, uses @@ -1155,19 +1223,31 @@ e2e_one_pay_public_template() { local pid="$1" local pname="${2:-$1}" local pamt="$3" + local inst="${4:-}" local tag tag=$(printf 'shop_%s' "$pid" | tr -c 'A-Za-z0-9._-' '_') 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 + # Resolve instance: explicit arg → template map → global INST + if [ -z "$inst" ]; then + inst=$(e2e_template_instance_for_id "$pid") + fi + [ -n "$inst" ] || inst="${INST:-${MERCHANT_INSTANCE:-default}}" + 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") - local TPL_URI="taler://pay-template/${MH}/instances/${INST}/${pid}" - local TPL_HTTPS="${MERCHANT_PUBLIC}/instances/${INST}/templates/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$pid")" - info "shop $pname" "pay-template URI $TPL_URI" + local TPL_URI="taler://pay-template/${MH}/instances/${inst}/${pid}" + local TPL_HTTPS="${MERCHANT_PUBLIC}/instances/${inst}/templates/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$pid")" + info "shop $pname" "instance $inst · pay-template URI $TPL_URI" info "shop $pname" "public template POST $TPL_HTTPS" - info "shop $pname" "settlement payto $E2E_SHOP_PAYTO" + # Stage settlement payto: instance bank account; GOA uses E2E_SHOP_PAYTO + local payto_show="${E2E_SHOP_PAYTO:-}" + if [ "${CUR:-}" = "TESTPAYSAN" ]; then + payto_show="payto://x-taler-bank/stage.bank.lefrancpaysan.ch/${inst}" + fi + info "shop $pname" "settlement payto ${payto_show:-(none)}" # Public create (no merchant secret) — same as shop-pay.js curl -skS -m 15 -o "$SCRATCH/tpl-$tag.json" -X POST \ diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index d0d9bce..3f060a2 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -195,11 +195,22 @@ apply_taler_domain() { CREDIT_AMT="${CREDIT_AMT:-${EXPECT_CURRENCY}:100}" ;; TESTPAYSAN) - # Stage FrancPaysan: default instance + public templates (e2e-001/005/1) - MERCHANT_INSTANCE="${MERCHANT_INSTANCE:-default}" - WITHDRAW_AMT="${WITHDRAW_AMT:-TESTPAYSAN:20}" - PAY_AMT="${PAY_AMT:-TESTPAYSAN:0.01}" - CREDIT_AMT="${CREDIT_AMT:-TESTPAYSAN:100}" + # Stage FrancPaysan: farmer shops (override GOA default goa-demo-cp4zqk) + case "${MERCHANT_INSTANCE:-}" in + ""|goa-demo*|goa-shop) + MERCHANT_INSTANCE=fermes-des-collines + ;; + esac + WITHDRAW_AMT="${WITHDRAW_AMT:-TESTPAYSAN:50}" + PAY_AMT="${PAY_AMT:-TESTPAYSAN:5}" + # Cover oeufs(5)+fromage(8.5)+jus(6)+shop pick(~5–12) + fees + CREDIT_AMT="${CREDIT_AMT:-TESTPAYSAN:200}" + # Public template pays on by default (shops landings) + : "${E2E_USE_TEMPLATES:=1}" + # Map pay-ladder amounts → public shop templates (instance set in e2e) + : "${E2E_TEMPLATE_MAP:=5=oeufs-6 4.5=pain-seigle 6=jus-pomme 8.5=fromage-chevre 12=miel-printemps 25=panier-legumes}" + : "${E2E_PAY_VALUES:=5 8.5 6}" + : "${E2E_WITHDRAW_VALUES:=20 50}" ;; CHF) WITHDRAW_AMT="${WITHDRAW_AMT:-CHF:20}" From 19bbcc09daf35e96c49dc40b58d5015c4361cd92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:09:37 +0200 Subject: [PATCH 228/326] monitoring: stage e2e uses farmer shop templates + admin secret load Map pays to jardin/fermes instances; load bank-admin via SSH francpaysan-host; larger TESTPAYSAN credit/withdraw for shop ladder. --- scripts/taler-monitoring/check_e2e.sh | 106 ++++++++++++++++++++++---- scripts/taler-monitoring/lib.sh | 21 +++-- 2 files changed, 109 insertions(+), 18 deletions(-) diff --git a/scripts/taler-monitoring/check_e2e.sh b/scripts/taler-monitoring/check_e2e.sh index 905e85d..05cb5f7 100755 --- a/scripts/taler-monitoring/check_e2e.sh +++ b/scripts/taler-monitoring/check_e2e.sh @@ -237,12 +237,13 @@ except Exception: print("")' 2>/dev/null || true) : "${E2E_VARIABLE:=1}" : "${E2E_ATM_MAX:=5}" if [ "$E2E_REMOTE" = "1" ]; then - # remote: still ATM-shaped but smaller notes; keep cheap - : "${E2E_WITHDRAW_VALUES:=10 20 50}" + # remote: still ATM-shaped; TESTPAYSAN needs larger notes for shop pays if [ "$CUR" = "TESTPAYSAN" ]; then - # match fixed public templates e2e-001 / e2e-005 / e2e-1 - : "${E2E_PAY_VALUES:=0.01 0.05 1}" + : "${E2E_WITHDRAW_VALUES:=20 50}" + # farmer shop template face values (oeufs-6=5, fromage=8.5, jus=6) + : "${E2E_PAY_VALUES:=5 8.5 6}" else + : "${E2E_WITHDRAW_VALUES:=10 20 50}" : "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}" fi else @@ -430,11 +431,58 @@ set_group load 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). +# Local stack: koopa secrets. Remote: env first; TESTPAYSAN stage may load francpaysan host secrets. set_group prereq if [ "$E2E_REMOTE" = "1" ]; then - ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-}" + ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-${BANK_ADMIN_PASS:-}}" MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}" + # Stage FrancPaysan: allow admin credit without committing passwords. + # Order: already-set env → local secrets tree → SSH francpaysan-host (ops laptop). + if [ -z "$ADMIN_PASS" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then + _fp_try="" + for _fp_try in \ + "${FRANCPAYSAN_SECRETS:-}/stage/bank-admin-password.txt" \ + "${HOME}/francpaysan-secrets/stage/bank-admin-password.txt" \ + "${HOME}/src/francpaysan-secrets/stage/bank-admin-password.txt" \ + "${HOME}/taler/src/francpaysan-secrets/stage/bank-admin-password.txt" + do + if [ -n "$_fp_try" ] && [ -f "$_fp_try" ]; then + ADMIN_PASS=$(tr -d '\n\r' <"$_fp_try") + info "bank admin secret" "from $_fp_try" + break + fi + done + if [ -z "$ADMIN_PASS" ] && command -v ssh >/dev/null 2>&1; then + for _fp_host in "${FRANCPAYSAN_SSH:-francpaysan-host}" francpaysan-host; do + ADMIN_PASS=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "$_fp_host" \ + 'sudo cat /mnt/data/stagepaysan/bank/secrets/bank-admin-password.txt 2>/dev/null' \ + 2>/dev/null | tr -d '\n\r' || true) + if [ -n "$ADMIN_PASS" ]; then + info "bank admin secret" "from ${_fp_host}:/mnt/data/stagepaysan/bank/secrets/" + break + fi + done + fi + unset _fp_try _fp_host + fi + # Stage merchant: public templates need no token; optional default-instance token + if [ -z "$MPW" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then + for _fp_try in \ + "${FRANCPAYSAN_SECRETS:-}/stage/default-instance-token.txt" \ + "${HOME}/francpaysan-secrets/stage/default-instance-token.txt" + do + if [ -n "$_fp_try" ] && [ -f "$_fp_try" ]; then + MPW=$(tr -d '\n\r' <"$_fp_try") + break + fi + done + if [ -z "$MPW" ] && command -v ssh >/dev/null 2>&1; then + MPW=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "${FRANCPAYSAN_SSH:-francpaysan-host}" \ + 'sudo cat /mnt/data/stagepaysan/merchant/secrets/default-instance-token.txt 2>/dev/null' \ + 2>/dev/null | tr -d '\n\r' || true) + fi + unset _fp_try + fi else ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-$(read_secret "taler-bank/bank-admin-password.txt" || true)}" MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-$(read_secret "taler-merchant/merchant-goa-demo-cp4zqk-password.txt" || true)}}" @@ -977,13 +1025,20 @@ else: return 1 } -# Map CUR:amount → public template id (TESTPAYSAN stage e2e-* templates). -# Override with E2E_TEMPLATE_MAP="0.01=e2e-001 0.05=e2e-005 1=e2e-1" +# Map CUR:amount → public template id. +# GOA default: e2e-* on goa-demo. TESTPAYSAN: farmer shop templates (set in apply_taler_domain). +# Override with E2E_TEMPLATE_MAP="5=oeufs-6 8.5=fromage-chevre" e2e_template_id_for_amount() { local amt="$1" local val map_entry k v val=$(printf '%s' "$amt" | awk -F: '{print $NF}') - : "${E2E_TEMPLATE_MAP:=0.01=e2e-001 0.05=e2e-005 1=e2e-1 1.0=e2e-1}" + if [ -z "${E2E_TEMPLATE_MAP:-}" ]; then + if [ "${CUR:-}" = "TESTPAYSAN" ]; then + E2E_TEMPLATE_MAP="5=oeufs-6 4.5=pain-seigle 6=jus-pomme 8.5=fromage-chevre 12=miel-printemps 25=panier-legumes" + else + E2E_TEMPLATE_MAP="0.01=e2e-001 0.05=e2e-005 1=e2e-1 1.0=e2e-1" + fi + fi for map_entry in $E2E_TEMPLATE_MAP; do k="${map_entry%%=*}" v="${map_entry#*=}" @@ -995,6 +1050,19 @@ e2e_template_id_for_amount() { return 1 } +# Template id → merchant instance (stage farmer shops / GOA goa-shop) +e2e_template_instance_for_id() { + local pid="$1" + case "$pid" in + oeufs-6|pain-seigle|jus-pomme) printf '%s' "jardin-du-creux" ;; + panier-legumes|fromage-chevre|miel-printemps) printf '%s' "fermes-des-collines" ;; + e2e-001|e2e-005|e2e-1|orbit-sticker|nebula-coffee|voidwave-playlist|comet-cap|shuttle-pass|beacon-badge|relay-pin|eclipse-shades|star-chart|rainbow-pill|blue-or-red-pill) + printf '%s' "${E2E_SHOP_INSTANCE:-goa-shop}" + ;; + *) printf '%s' "${MERCHANT_INSTANCE:-default}" ;; + esac +} + # One payment: e2e_one_pay AMOUNT [SUMMARY] [TAG] # SUMMARY defaults to "monitoring pay $AMOUNT"; TAG defaults from amount. # When E2E_USE_TEMPLATES=1 and amount maps to a public template, uses @@ -1155,19 +1223,31 @@ e2e_one_pay_public_template() { local pid="$1" local pname="${2:-$1}" local pamt="$3" + local inst="${4:-}" local tag tag=$(printf 'shop_%s' "$pid" | tr -c 'A-Za-z0-9._-' '_') 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 + # Resolve instance: explicit arg → template map → global INST + if [ -z "$inst" ]; then + inst=$(e2e_template_instance_for_id "$pid") + fi + [ -n "$inst" ] || inst="${INST:-${MERCHANT_INSTANCE:-default}}" + 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") - local TPL_URI="taler://pay-template/${MH}/instances/${INST}/${pid}" - local TPL_HTTPS="${MERCHANT_PUBLIC}/instances/${INST}/templates/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$pid")" - info "shop $pname" "pay-template URI $TPL_URI" + local TPL_URI="taler://pay-template/${MH}/instances/${inst}/${pid}" + local TPL_HTTPS="${MERCHANT_PUBLIC}/instances/${inst}/templates/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$pid")" + info "shop $pname" "instance $inst · pay-template URI $TPL_URI" info "shop $pname" "public template POST $TPL_HTTPS" - info "shop $pname" "settlement payto $E2E_SHOP_PAYTO" + # Stage settlement payto: instance bank account; GOA uses E2E_SHOP_PAYTO + local payto_show="${E2E_SHOP_PAYTO:-}" + if [ "${CUR:-}" = "TESTPAYSAN" ]; then + payto_show="payto://x-taler-bank/stage.bank.lefrancpaysan.ch/${inst}" + fi + info "shop $pname" "settlement payto ${payto_show:-(none)}" # Public create (no merchant secret) — same as shop-pay.js curl -skS -m 15 -o "$SCRATCH/tpl-$tag.json" -X POST \ diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index d0d9bce..3f060a2 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -195,11 +195,22 @@ apply_taler_domain() { CREDIT_AMT="${CREDIT_AMT:-${EXPECT_CURRENCY}:100}" ;; TESTPAYSAN) - # Stage FrancPaysan: default instance + public templates (e2e-001/005/1) - MERCHANT_INSTANCE="${MERCHANT_INSTANCE:-default}" - WITHDRAW_AMT="${WITHDRAW_AMT:-TESTPAYSAN:20}" - PAY_AMT="${PAY_AMT:-TESTPAYSAN:0.01}" - CREDIT_AMT="${CREDIT_AMT:-TESTPAYSAN:100}" + # Stage FrancPaysan: farmer shops (override GOA default goa-demo-cp4zqk) + case "${MERCHANT_INSTANCE:-}" in + ""|goa-demo*|goa-shop) + MERCHANT_INSTANCE=fermes-des-collines + ;; + esac + WITHDRAW_AMT="${WITHDRAW_AMT:-TESTPAYSAN:50}" + PAY_AMT="${PAY_AMT:-TESTPAYSAN:5}" + # Cover oeufs(5)+fromage(8.5)+jus(6)+shop pick(~5–12) + fees + CREDIT_AMT="${CREDIT_AMT:-TESTPAYSAN:200}" + # Public template pays on by default (shops landings) + : "${E2E_USE_TEMPLATES:=1}" + # Map pay-ladder amounts → public shop templates (instance set in e2e) + : "${E2E_TEMPLATE_MAP:=5=oeufs-6 4.5=pain-seigle 6=jus-pomme 8.5=fromage-chevre 12=miel-printemps 25=panier-legumes}" + : "${E2E_PAY_VALUES:=5 8.5 6}" + : "${E2E_WITHDRAW_VALUES:=20 50}" ;; CHF) WITHDRAW_AMT="${WITHDRAW_AMT:-CHF:20}" From 80d90f585792a9e3fed4c8974edc6b3127029da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:17:40 +0200 Subject: [PATCH 229/326] monitoring: stage.lefrancpaysan like hacktivism (inside+versions via stagepaysan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand default phases for TESTPAYSAN to urls + inside + versions + e2e. Inside/version/load use low-priv francpaysan-stage-user (stagepaysan) and stage-lfp-* containers on 9030–9032; public stats.json stays outside-in. --- scripts/taler-monitoring/README.md | 21 +- scripts/taler-monitoring/check_inside.sh | 250 ++++++++++++++++++- scripts/taler-monitoring/check_versions.sh | 56 ++++- scripts/taler-monitoring/lib.sh | 33 ++- scripts/taler-monitoring/metrics.sh | 43 +++- scripts/taler-monitoring/secrets.env.example | 3 +- scripts/taler-monitoring/taler-monitoring.sh | 40 +-- 7 files changed, 398 insertions(+), 48 deletions(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 4771c6a..e627a35 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -81,12 +81,18 @@ Each check prints **global** and **grouped** ids: ./taler-monitoring.sh sanity ./taler-monitoring.sh e2e -# FrancPaysan STAGE (TESTPAYSAN) — default: urls + e2e (withdraw + pay templates) +# FrancPaysan STAGE (TESTPAYSAN) — like hacktivism breadth: +# default phases: urls + inside + versions + e2e ./taler-monitoring.sh -d stage.lefrancpaysan.ch -# Admin credit: E2E_BANK_ADMIN_PASS=… or SSH francpaysan-host secrets +./taler-monitoring.sh -d stage.lefrancpaysan.ch inside # stagepaysan only +# inside: low-priv SSH francpaysan-stage-user (User stagepaysan) → podman +# containers stage-lfp-{bank,exchange-ansible,merchant} · ports 9032/9031/9030 +# stats: outside-in public …/intro/stats.json (age via generated_at_unix) +# Admin credit for e2e: E2E_BANK_ADMIN_PASS=… or francpaysan-host secrets # (bank-admin-password.txt under /mnt/data/stagepaysan/bank/secrets/) +# Override: INSIDE_SSH=… INSIDE_PROFILE=stage-lfp -# Other domains — public HTTPS only, never SSH (pass e2e explicitly) +# Other domains — public HTTPS only (pass e2e/inside explicitly) ./taler-monitoring.sh -d taler.net ./taler-monitoring.sh --domain taler-ops.ch ./taler-monitoring.sh -d my.taler-ops.ch urls @@ -165,13 +171,18 @@ Helpers in `lib.sh`: `set_taler_stack`, `load_domain_profile`, `apply_taler_doma | `taler.net` / `demo.taler.net` | bank.demo.taler.net | exchange.demo.taler.net | backend.demo.taler.net | KUDOS | | `test.taler.net` | bank.test… | exchange.test… | backend.test… | **TESTKUDOS** · no landings | | `lefrancpaysan.ch` | bank.… | exchange.… | **monnaie.…** (merchant) | report-only · no landings | +| **`stage.lefrancpaysan.ch`** | stage.bank.… | stage.exchange.… | **stage.monnaie.…** | **TESTPAYSAN** · landings · **inside via stagepaysan** | | `taler-ops.ch` / `my.taler-ops.ch` | bank.taler-ops.ch | exchange.taler-ops.ch | **my.taler-ops.ch** | CHF · **no landings** | | `stage.taler-ops.ch` | bank.stage… | exchange.stage… | **my.stage…** | CHF · **no landings** | | unknown | bank.DOMAIN | exchange.DOMAIN | my.DOMAIN then probe | any · no landings | -**Landings:** only GOA (and optionally demo) use public `/intro` pages. **taler-ops.ch does not** — `CHECK_LANDING=0` in the profile skips intro crawl, shop assets, and demo-withdraw checks. +**Landings:** GOA, stage LFP (`TESTPAYSAN`), and optionally demo use public `/intro` pages. **taler-ops.ch does not** — `CHECK_LANDING=0` skips intro crawl. -**SSH only for koopa** (`hacktivism.ch` / `-d koopa`). +**SSH:** +| Stack | Host alias | User | Scope | +|-------|------------|------|--------| +| koopa / hacktivism | `koopa` | hernani | full host + containers | +| stage LFP | `francpaysan-stage-user` | **stagepaysan** | own podman only (no sudo / no prod data) | Other domains: never SSH. Optional **e2e** aborts cleanly on login/KYC. diff --git a/scripts/taler-monitoring/check_inside.sh b/scripts/taler-monitoring/check_inside.sh index b5db3b1..0584c9b 100755 --- a/scripts/taler-monitoring/check_inside.sh +++ b/scripts/taler-monitoring/check_inside.sh @@ -1,5 +1,9 @@ #!/usr/bin/env bash -# Inside status for bank / exchange / merchant. Hard-capped SSH — never hang forever. +# Inside status for bank / exchange / merchant containers. +# Profiles: +# koopa (default LOCAL_STACK=1) — SSH KOOPA_SSH, containers taler-hacktivism* +# stage-lfp (TESTPAYSAN) — SSH INSIDE_SSH (francpaysan-stage-user / stagepaysan), +# low-priv podman only; ports 9030–9032; stats remain outside-in (urls). set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh @@ -7,14 +11,250 @@ source "$ROOT/lib.sh" # shellcheck source=metrics.sh source "$ROOT/metrics.sh" -# Area inside.* — container / process state on koopa (SSH) -# Groups: inside.ssh / inside.bank / inside.exchange / inside.merchant / inside.caddy set_area inside set_group ssh + +PROFILE="${INSIDE_PROFILE:-}" +if [ -z "$PROFILE" ]; then + if [ "${LOCAL_STACK:-0}" = "1" ]; then + PROFILE=koopa + elif [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] || [ -n "${INSIDE_SSH:-}" ]; then + PROFILE=stage-lfp + else + PROFILE=koopa + fi +fi + +section "inside · collect (${PROFILE})" + +# --------------------------------------------------------------------------- +# stage-lfp: low-priv stagepaysan on francpaysan-host +# --------------------------------------------------------------------------- +if [ "$PROFILE" = "stage-lfp" ]; then + SSH_HOST="${INSIDE_SSH:-francpaysan-stage-user}" + BANK_CTR="${INSIDE_BANK_CTR:-stage-lfp-bank}" + EX_CTR="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" + MER_CTR="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" + BANK_PORT="${INSIDE_BANK_PORT:-9032}" + EX_PORT="${INSIDE_EXCHANGE_PORT:-9031}" + MER_PORT="${INSIDE_MERCHANT_PORT:-9030}" + DNS_BANK="${INSIDE_DNS_BANK:-stage.bank.lefrancpaysan.ch}" + DNS_EX="${INSIDE_DNS_EXCHANGE:-stage.exchange.lefrancpaysan.ch}" + DNS_MER="${INSIDE_DNS_MERCHANT:-stage.monnaie.lefrancpaysan.ch}" + # Stage remote has more podman execs than koopa; allow a bit more wall time + STAGE_SSH_T="${INSIDE_SSH_TIMEOUT:-${SSH_CMD_TIMEOUT:-24}}" + if [ "${STAGE_SSH_T}" -lt 24 ] 2>/dev/null; then STAGE_SSH_T=24; fi + + if ! mon_ssh_ok "$SSH_HOST"; then + err "ssh" "cannot reach ${SSH_HOST} (stagepaysan low-priv) — set INSIDE_SSH= or SKIP" + summary + exit 1 + fi + ok "ssh ${SSH_HOST}" "stagepaysan (podman, no sudo)" + + # Inject names/ports into remote (ssh bash -s does not inherit local env). + RAW=$( + { + printf 'BANK_CTR=%q; EX_CTR=%q; MER_CTR=%q\n' "$BANK_CTR" "$EX_CTR" "$MER_CTR" + printf 'BANK_PORT=%q; EX_PORT=%q; MER_PORT=%q\n' "$BANK_PORT" "$EX_PORT" "$MER_PORT" + printf 'DNS_BANK=%q; DNS_EX=%q; DNS_MER=%q\n' "$DNS_BANK" "$DNS_EX" "$DNS_MER" + cat <<'REMOTE' +set +e +emit() { printf 'E|%s|%s|%s|%s\n' "$1" "$2" "$3" "$(printf '%s' "${4:-}" | tr '\n\r' ' ' | head -c 200)"; } +hc() { curl -skS -m 3 -o /tmp/mb -w '%{http_code}' "$1" 2>/dev/null || echo 000; } +hasp() { podman exec "$1" pgrep -f "$2" >/dev/null 2>&1; } + +BANK="${BANK_CTR}" +EX="${EX_CTR}" +MER="${MER_CTR}" +podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$BANK" || BANK=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*bank|lfp-bank' | head -1) +podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$EX" || EX=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*exchange|lfp-exchange' | head -1) +podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$MER" || MER=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*merchant|lfp-merchant' | head -1) + +check_dns() { + local comp="$1" ctr="$2" host="$3" + local line ip code + [ -n "$host" ] || { emit "$comp" WARN "dns" "empty host"; return 1; } + line=$(podman exec "$ctr" getent ahostsv4 "$host" 2>/dev/null | head -1) + [ -z "$line" ] && line=$(podman exec "$ctr" getent hosts "$host" 2>/dev/null | head -1) + ip=$(echo "$line" | awk '{print $1}') + if [ -z "$ip" ]; then + emit "$comp" WARN "dns $host" "no resolve inside container" + return 1 + fi + if [ "$ip" = "127.0.0.1" ] || [ "$ip" = "::1" ]; then + emit "$comp" WARN "dns $host" "$ip (loopback)" + return 1 + fi + # 169.254.x = pasta host-gateway pin is OK for stage + code=$(podman exec "$ctr" curl -skS -m 3 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000) + if [ "$code" = "200" ]; then + emit "$comp" OK "dns $host" "→ $ip /config=$code" + else + emit "$comp" WARN "dns $host" "→ $ip /config=$code" + fi +} + +if [ -z "$BANK" ]; then emit bank ERROR container "not running" +else + emit bank INFO container "$(podman ps --filter name=^${BANK}$ --format '{{.Names}} {{.Status}}' | head -1)" + emit bank INFO ports "$(podman ps --filter name=^${BANK}$ --format '{{.Ports}}' | head -1)" + hasp "$BANK" 'MainKt serve|libeufin-bank serve' && emit bank OK libeufin "running" || emit bank ERROR libeufin "not running" + podman exec "$BANK" pg_isready -q 2>/dev/null && emit bank OK postgres "ready" || emit bank WARN postgres "pg_isready failed" + c=$(hc "http://127.0.0.1:${BANK_PORT}/config") + [ "$c" = "200" ] && emit bank OK "host :${BANK_PORT}/config" "HTTP $c" || emit bank ERROR "host :${BANK_PORT}/config" "HTTP $c" + c=$(hc "http://127.0.0.1:${BANK_PORT}/taler-integration/config") + [ "$c" = "200" ] && emit bank OK "host integration" "HTTP $c" || emit bank ERROR "host integration" "HTTP $c" + check_dns bank "$BANK" "$DNS_BANK" || true + check_dns bank "$BANK" "$DNS_EX" || true +fi + +if [ -z "$EX" ]; then emit exchange ERROR container "not running" +else + emit exchange INFO container "$(podman ps --filter name=^${EX}$ --format '{{.Names}} {{.Status}}' | head -1)" + c=$(hc "http://127.0.0.1:${EX_PORT}/config") + [ "$c" = "200" ] && emit exchange OK "host :${EX_PORT}/config" "HTTP $c" || emit exchange ERROR "host :${EX_PORT}/config" "HTTP $c" + c=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:${EX_PORT}/keys" 2>/dev/null || echo 000) + [ "$c" = "200" ] && emit exchange OK "host :${EX_PORT}/keys" "HTTP $c" || emit exchange ERROR "host :${EX_PORT}/keys" "HTTP $c" + hasp "$EX" 'taler-exchange-httpd' && emit exchange OK httpd "running" || emit exchange ERROR httpd "not running" + hasp "$EX" 'taler-exchange-wirewatch' && emit exchange OK wirewatch "running" || emit exchange ERROR wirewatch "not running" + hasp "$EX" 'taler-exchange-aggregator' && emit exchange OK aggregator "running" || emit exchange WARN aggregator "not running" + hasp "$EX" 'taler-exchange-transfer' && emit exchange OK transfer "running" || emit exchange WARN transfer "not running" + check_dns exchange "$EX" "$DNS_BANK" || true + check_dns exchange "$EX" "$DNS_EX" || true + check_dns exchange "$EX" "$DNS_MER" || true +fi + +if [ -z "$MER" ]; then emit merchant ERROR container "not running" +else + emit merchant INFO container "$(podman ps --filter name=^${MER}$ --format '{{.Names}} {{.Status}}' | head -1)" + c=$(hc "http://127.0.0.1:${MER_PORT}/config") + [ "$c" = "200" ] && emit merchant OK "host :${MER_PORT}/config" "HTTP $c" || emit merchant ERROR "host :${MER_PORT}/config" "HTTP $c" + hasp "$MER" 'taler-merchant-httpd' && emit merchant OK httpd "running" || emit merchant ERROR httpd "not running" + # Stage merchant often has no wirewatch unit — INFO not WARN + hasp "$MER" 'taler-merchant-wirewatch' && emit merchant OK wirewatch "running" || emit merchant INFO wirewatch "not running (optional on stage)" + hasp "$MER" 'taler-merchant-depositcheck' && emit merchant OK depositcheck "running" || emit merchant INFO depositcheck "not running (optional on stage)" + hasp "$MER" 'taler-merchant-exchangekeyupdate' && emit merchant OK exchangekeyupdate "running" || emit merchant INFO exchangekeyupdate "not running" + check_dns merchant "$MER" "$DNS_BANK" || true + check_dns merchant "$MER" "$DNS_EX" || true + check_dns merchant "$MER" "$DNS_MER" || true +fi + +# Caddy is host-wide (often root); stagepaysan can only see the process +if pgrep -x caddy >/dev/null 2>&1; then + emit caddy OK process "running (host)" +else + emit caddy WARN process "not seen as stagepaysan (may still run as root)" +fi + +echo DONE +REMOTE + } | mon_ssh_bash "$SSH_HOST" "${STAGE_SSH_T}" || true + ) + + if [ -z "$RAW" ] || ! echo "$RAW" | grep -q '^E|'; then + err "ssh" "stage remote timed out or empty (cap ${STAGE_SSH_T}s · host=${SSH_HOST})" + summary + exit 1 + fi + + _last_inside_grp="" + while IFS= read -r line; do + case "$line" in + E\|*) + IFS='|' read -r _ comp level key detail <<<"$line" + case "$comp" in + bank|exchange|merchant|caddy) _g="$comp" ;; + *) _g="ssh" ;; + esac + if [ "$_g" != "$_last_inside_grp" ]; then + set_group "$_g" + _last_inside_grp="$_g" + fi + case "$level" in + OK) ok "[$comp] $key${detail:+ ($detail)}" ;; + ERROR) err "$comp" "$key" "$detail" ;; + WARN) warn "[$comp] $key" "$detail" ;; + INFO) info "[$comp] $key" "$detail" ;; + esac + ;; + esac + done <<<"$RAW" + + # Stats from outside (laptop) — public HTTPS, no stagepaysan write needed + set_group stats + section "inside · public stats (outside-in)" + _probe_public_stats() { + local comp="$1" url="$2" + local body code age unix now + body=$(mktemp) + code=$(curl -skS -m 8 -o "$body" -w '%{http_code}' "$url" 2>/dev/null || echo 000) + if [ "$code" != "200" ]; then + warn "[$comp] public stats.json" "HTTP $code · $url" + rm -f "$body" + return + fi + unix=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("generated_at_unix") or 0)' "$body" 2>/dev/null || echo 0) + now=$(date +%s) + if [ "${unix:-0}" -gt 0 ] 2>/dev/null; then + age=$((now - unix)) + if [ "$age" -lt 0 ]; then age=0; fi + if [ "$age" -le "${STATS_STALE_SECS:-900}" ]; then + ok "[$comp] public stats.json" "HTTP 200 age=${age}s · $url" + elif [ "$age" -le "${STATS_FAIL_SECS:-3600}" ]; then + warn "[$comp] public stats.json" "stale age=${age}s · $url" + else + err "$comp" "public stats.json" "too old age=${age}s · $url" + fi + else + ok "[$comp] public stats.json" "HTTP 200 · $url" + fi + rm -f "$body" + } + _probe_public_stats bank "https://${DNS_BANK}/intro/stats.json" + _probe_public_stats exchange "https://${DNS_EX}/intro/stats.json" + _probe_public_stats merchant "https://${DNS_MER}/intro/stats.json" + + # Host load as stagepaysan (no container RSS from koopa metrics) + set_group load + section "inside · load (stagepaysan host)" + LOAD_LINE=$(mon_ssh_bash "$SSH_HOST" 8 <<'EOF' || true +python3 - <<'PY' +import os +la=os.getloadavg() +print("loadavg=%.2f,%.2f,%.2f" % la) +try: + with open("/proc/meminfo") as f: + d={} + for line in f: + k,v=line.split(":")[0], line.split(":")[1].strip().split()[0] + d[k]=int(v) + total=d.get("MemTotal",0)/1024/1024 + avail=d.get("MemAvailable",0)/1024/1024 + used=total-avail + print("mem_used=%.2fGiB avail=%.2fGiB total=%.2fGiB" % (used, avail, total)) +except Exception: + print("mem=?") +PY +EOF +) + if [ -n "$LOAD_LINE" ]; then + info "stage host" "$(echo "$LOAD_LINE" | tr '\n' ' ')" + else + info "stage host" "load probe empty" + fi + + summary + exit 0 +fi + +# --------------------------------------------------------------------------- +# koopa (default) — existing remote collect +# --------------------------------------------------------------------------- section "inside · collect from koopa" -if [ "${SKIP_SSH}" = "1" ]; then - warn "ssh" "SKIP_SSH=1 — skipped" +if [ "${SKIP_SSH}" = "1" ] && [ "${LOCAL_STACK:-0}" != "1" ]; then + warn "ssh" "SKIP_SSH=1 and not local — skipped" summary exit 0 fi diff --git a/scripts/taler-monitoring/check_versions.sh b/scripts/taler-monitoring/check_versions.sh index ea4df2f..3580732 100755 --- a/scripts/taler-monitoring/check_versions.sh +++ b/scripts/taler-monitoring/check_versions.sh @@ -166,28 +166,56 @@ fi # --------------------------------------------------------------------------- # 2) INSIDE — containers can reach deb.taler.net (pasta / install path) +# koopa: KOOPA_SSH · stage-lfp: INSIDE_SSH (stagepaysan, low-priv) # --------------------------------------------------------------------------- set_group inside section "versions · inside · containers → deb.taler.net" -if [ "${SKIP_SSH}" = "1" ]; then - warn "ssh" "SKIP_SSH=1 — skip container reachability + installed package compare" +_VERS_SSH_HOST="" +_VERS_BANK="taler-hacktivism-bank" +_VERS_EX="taler-hacktivism-exchange-ansible" +_VERS_MER="taler-hacktivism" +if [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \ + || [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \ + || [ -n "${INSIDE_SSH:-}" ]; then + _VERS_SSH_HOST="${INSIDE_SSH:-francpaysan-stage-user}" + _VERS_BANK="${INSIDE_BANK_CTR:-stage-lfp-bank}" + _VERS_EX="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" + _VERS_MER="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" +elif [ "${LOCAL_STACK:-0}" = "1" ] && [ "${SKIP_SSH:-0}" != "1" ]; then + _VERS_SSH_HOST="${KOOPA_SSH}" +fi + +if [ -z "$_VERS_SSH_HOST" ]; then + info "ssh" "no container SSH for this domain — outside-only package index" info "outside-only" "deb.taler.net public checks completed above" summary exit 0 fi -if ! koopa_ssh_ok; then - err "ssh" "cannot reach ${KOOPA_SSH} — set SKIP_SSH=1 to skip container checks" - info "outside-only" "deb.taler.net public checks completed above" - summary - exit 1 + +if [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then + if ! koopa_ssh_ok; then + err "ssh" "cannot reach ${KOOPA_SSH} — set SKIP_SSH=1 to skip container checks" + info "outside-only" "deb.taler.net public checks completed above" + summary + exit 1 + fi + ok "ssh ${KOOPA_SSH}" +else + if ! mon_ssh_ok "$_VERS_SSH_HOST"; then + err "ssh" "cannot reach ${_VERS_SSH_HOST} (stagepaysan) — skip container package compare" + info "outside-only" "deb.taler.net public checks completed above" + summary + exit 1 + fi + ok "ssh ${_VERS_SSH_HOST}" "stagepaysan container package compare" fi -ok "ssh ${KOOPA_SSH}" INRELEASE_URL="${APT_BASE}/dists/${SUITE}/InRelease" # Write remote script to a file (avoids bash 3.2 parse bugs with case/;; inside $(…)</dev/null | grep -i exchange | head -1 ;; - *bank*) podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism-bank|taler-bank' | head -1 ;; + *bank*) podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*bank|hacktivism-bank|taler-bank|lfp-bank' | head -1 ;; *) - c=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E '^taler-hacktivism$' | head -1) + c=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E '^taler-hacktivism$|^stage-lfp-merchant$' | head -1) if [ -n "$c" ]; then echo "$c"; return; fi podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'merchant|hacktivism' | grep -viE 'bank|exchange' | head -1 ;; esac } -for want in taler-hacktivism-exchange-ansible taler-hacktivism-bank taler-hacktivism; do +for want in "$WANT_EX" "$WANT_BANK" "$WANT_MER"; do c=$(resolve_ctr "$want") [ -n "$c" ] || continue role=other @@ -229,7 +257,11 @@ done REMOTE } >"$tmp/remote-versions.sh" -REMOTE_OUT=$(koopa_ssh_bash 60 <"$tmp/remote-versions.sh" || true) +if [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then + REMOTE_OUT=$(koopa_ssh_bash 60 <"$tmp/remote-versions.sh" || true) +else + REMOTE_OUT=$(mon_ssh_bash "$_VERS_SSH_HOST" 60 <"$tmp/remote-versions.sh" || true) +fi printf '%s\n' "$REMOTE_OUT" | grep -E '^R\|' >"$tmp/reach.tsv" || true printf '%s\n' "$REMOTE_OUT" | grep -E '^S\|' >"$tmp/sources.tsv" || true diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 3f060a2..7bde111 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -211,6 +211,19 @@ apply_taler_domain() { : "${E2E_TEMPLATE_MAP:=5=oeufs-6 4.5=pain-seigle 6=jus-pomme 8.5=fromage-chevre 12=miel-printemps 25=panier-legumes}" : "${E2E_PAY_VALUES:=5 8.5 6}" : "${E2E_WITHDRAW_VALUES:=20 50}" + # Inside checks as low-priv stagepaysan (podman, no sudo) — not koopa + INSIDE_PROFILE="${INSIDE_PROFILE:-stage-lfp}" + INSIDE_SSH="${INSIDE_SSH:-francpaysan-stage-user}" + INSIDE_BANK_CTR="${INSIDE_BANK_CTR:-stage-lfp-bank}" + INSIDE_EXCHANGE_CTR="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" + INSIDE_MERCHANT_CTR="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" + # Host pasta ports (127.0.0.1 on francpaysan-host) + INSIDE_BANK_PORT="${INSIDE_BANK_PORT:-9032}" + INSIDE_EXCHANGE_PORT="${INSIDE_EXCHANGE_PORT:-9031}" + INSIDE_MERCHANT_PORT="${INSIDE_MERCHANT_PORT:-9030}" + INSIDE_DNS_BANK="${INSIDE_DNS_BANK:-stage.bank.lefrancpaysan.ch}" + INSIDE_DNS_EXCHANGE="${INSIDE_DNS_EXCHANGE:-stage.exchange.lefrancpaysan.ch}" + INSIDE_DNS_MERCHANT="${INSIDE_DNS_MERCHANT:-stage.monnaie.lefrancpaysan.ch}" ;; CHF) WITHDRAW_AMT="${WITHDRAW_AMT:-CHF:20}" @@ -246,10 +259,14 @@ apply_taler_domain() { EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} - # Hard rule: only the local koopa/hacktivism stack may use SSH + # Koopa SSH only for LOCAL_STACK=1. Stage LFP uses INSIDE_SSH (stagepaysan) separately. if [ "${LOCAL_STACK}" != "1" ]; then SKIP_SSH=1 fi + # Default inside profile for local GOA + if [ "${LOCAL_STACK}" = "1" ]; then + INSIDE_PROFILE="${INSIDE_PROFILE:-koopa}" + fi } # Apply TALER_DOMAIN from env once (CLI exports TALER_DOMAIN_APPLIED=1 after overrides). @@ -310,6 +327,20 @@ with_timeout() { ' "$secs" "$@" } +# Generic short SSH to a named host (low-priv stagepaysan or koopa). +# usage: mon_ssh_bash HOST [timeout] <<'EOF' ... EOF +mon_ssh_bash() { + local host="$1" t="${2:-$SSH_CMD_TIMEOUT}" + [ -n "$host" ] || return 1 + with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "$host" bash -s +} +mon_ssh_ok() { + local host="$1" + [ -n "$host" ] || return 1 + with_timeout $((SSH_CONNECT_TIMEOUT + 5)) \ + ssh "${SSH_BASE_OPTS[@]}" "$host" true >/dev/null 2>&1 +} + # 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 diff --git a/scripts/taler-monitoring/metrics.sh b/scripts/taler-monitoring/metrics.sh index 5df8d76..4c440ea 100644 --- a/scripts/taler-monitoring/metrics.sh +++ b/scripts/taler-monitoring/metrics.sh @@ -489,9 +489,9 @@ print(rep["summary"]) PY } -# --- Taler stack load on koopa (host + bank/exchange/merchant) --- +# --- Taler stack load (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). +# koopa: KOOPA_SSH (+ fallbacks). stage-lfp: INSIDE_SSH (stagepaysan) + stage-lfp-* names. metrics_taler_load() { local out="${1:-$METRICS_DIR/load.json}" local label="${2:-snap}" @@ -502,7 +502,20 @@ metrics_taler_load() { fi local raw="" local remote_py - remote_py=$(cat <<'PY' + local _m_bank _m_mer _m_ex _m_ssh + _m_bank="taler-hacktivism-bank" + _m_mer="taler-hacktivism" + _m_ex="taler-hacktivism-exchange-ansible" + _m_ssh="" + if [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \ + || [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \ + || { [ -n "${INSIDE_SSH:-}" ] && [ "${LOCAL_STACK:-0}" != "1" ]; }; then + _m_bank="${INSIDE_BANK_CTR:-stage-lfp-bank}" + _m_mer="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" + _m_ex="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" + _m_ssh="${INSIDE_SSH:-francpaysan-stage-user}" + fi + remote_py=$(cat </dev/null 2>&1 && koopa_ssh_ok; then + # Prefer stagepaysan (stage-lfp), else koopa SSH, else local podman only on LOCAL_STACK. + if [ -n "${_m_ssh}" ] && type mon_ssh_ok >/dev/null 2>&1 && mon_ssh_ok "${_m_ssh}"; then + raw=$(printf '%s' "$remote_py" | with_timeout "${METRICS_LOAD_SSH_TIMEOUT:-90}" \ + ssh "${SSH_BASE_OPTS[@]}" "${_m_ssh}" python3 - 2>/dev/null || true) + elif [ "${SKIP_SSH:-0}" != "1" ] && type koopa_ssh_ok >/dev/null 2>&1 && koopa_ssh_ok; then 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) + elif [ "${LOCAL_STACK:-0}" = "1" ] && command -v podman >/dev/null 2>&1; then + raw=$(printf '%s' "$remote_py" | python3 - 2>/dev/null || true) + else + printf '%s\n' "{\"ok\":false,\"reason\":\"no-ssh-for-load\",\"label\":\"$label\"}" >"$out" + return 0 fi if [ -z "$raw" ]; then - printf '%s\n' "{\"ok\":false,\"reason\":\"probe-failed\",\"label\":\"$label\",\"ssh\":\"${KOOPA_SSH:-?}\"}" >"$out" + printf '%s\n' "{\"ok\":false,\"reason\":\"probe-failed\",\"label\":\"$label\",\"ssh\":\"${_m_ssh:-${KOOPA_SSH:-?}}\"}" >"$out" return 1 fi printf '%s\n' "$raw" | python3 -c ' diff --git a/scripts/taler-monitoring/secrets.env.example b/scripts/taler-monitoring/secrets.env.example index f7e13de..064b14d 100644 --- a/scripts/taler-monitoring/secrets.env.example +++ b/scripts/taler-monitoring/secrets.env.example @@ -27,7 +27,8 @@ # MERCHANT_INSTANCE_PASSWORD= # --- FrancPaysan STAGE (TESTPAYSAN) e2e --- -# Default phases for -d stage.lefrancpaysan.ch: urls + e2e +# Default phases for -d stage.lefrancpaysan.ch: urls + inside + versions + e2e +# inside uses francpaysan-stage-user (stagepaysan); stats from public HTTPS. # Admin credit (required for ATM withdraw ladder on stage): # E2E_BANK_ADMIN_PASS=… # or auto-load via SSH francpaysan-host # FRANCPAYSAN_SSH=francpaysan-host diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 05e6f5d..d8f6197 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -154,6 +154,10 @@ 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 E2E_USE_TEMPLATES E2E_TEMPLATE_MAP export PAIVANA_PUBLIC E2E_PAIVANA E2E_PAIVANA_TEMPLATE E2E_PAIVANA_AMOUNT E2E_PAIVANA_INSTANCE +export INSIDE_PROFILE INSIDE_SSH +export INSIDE_BANK_CTR INSIDE_EXCHANGE_CTR INSIDE_MERCHANT_CTR +export INSIDE_BANK_PORT INSIDE_EXCHANGE_PORT INSIDE_MERCHANT_PORT +export INSIDE_DNS_BANK INSIDE_DNS_EXCHANGE INSIDE_DNS_MERCHANT # 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}" @@ -181,17 +185,17 @@ export LADDER_PAY LADDER_WITHDRAW_SCALE LADDER_PAY_SETTLE_ROUNDS export TALER_DOMAIN_APPLIED=1 # Default phases -# - local GOA (koopa): urls + SSH phases + e2e -# - FrancPaysan STAGE (TESTPAYSAN / stage.*lefrancpaysan*): urls + e2e -# (public withdraw + template pay — no SSH) -# - other remote: urls only (pass "e2e" explicitly) +# - local GOA (koopa): urls + inside + versions + e2e +# - FrancPaysan STAGE: urls + inside (stagepaysan) + versions + e2e +# - other remote: urls only (pass e2e/inside explicitly) if [ "${#PHASES[@]}" -eq 0 ]; then if [ "${LOCAL_STACK}" = "1" ]; then PHASES=(urls inside versions e2e) elif [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \ || [[ "${TALER_DOMAIN:-}" == stage.*lefrancpaysan* ]] \ || [[ "${TALER_DOMAIN:-}" == *stage.lefrancpaysan* ]]; then - PHASES=(urls e2e) + # Like hacktivism breadth: public urls + inside (stagepaysan) + versions + e2e + PHASES=(urls inside versions e2e) else PHASES=(urls) fi @@ -202,8 +206,10 @@ for p in "${PHASES[@]}"; do if [ "$p" = "all" ]; then if [ "${LOCAL_STACK}" = "1" ]; then OUT_PHASES+=(urls inside versions sanity e2e) + elif [ -n "${INSIDE_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "stage-lfp" ]; then + OUT_PHASES+=(urls inside versions e2e) else - # remote: repo index check only (no installed pkgs without SSH) + # remote without inside SSH: public + optional e2e OUT_PHASES+=(urls versions e2e) fi else @@ -213,15 +219,21 @@ done PHASES=() seen=" " for p in "${OUT_PHASES[@]}"; do - # Drop SSH-only phases for non-koopa (e2e is allowed — public path only) - # versions still runs: outside-in deb.taler.net checks; container parts soft-skip if no SSH - if [ "${LOCAL_STACK}" != "1" ] || [ "${SKIP_SSH}" = "1" ]; then - case "$p" in inside|server) - echo "[INFO] skip phase '$p' (only koopa uses SSH)" >&2 - continue + # server = koopa-only. inside allowed on koopa OR stagepaysan (INSIDE_SSH). + case "$p" in + server) + if [ "${LOCAL_STACK}" != "1" ]; then + echo "[INFO] skip phase 'server' (koopa only)" >&2 + continue + fi ;; - esac - fi + inside) + if [ "${LOCAL_STACK}" != "1" ] && [ -z "${INSIDE_SSH:-}" ] && [ "${INSIDE_PROFILE:-}" != "stage-lfp" ]; then + echo "[INFO] skip phase 'inside' (no INSIDE_SSH / not local)" >&2 + continue + fi + ;; + esac case "$seen" in *" $p "*) ;; *) PHASES+=("$p"); seen="$seen$p " ;; esac done From 08c55e71a727dbc0d2b9b1400b9c95aa5b82d7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:17:40 +0200 Subject: [PATCH 230/326] monitoring: stage.lefrancpaysan like hacktivism (inside+versions via stagepaysan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand default phases for TESTPAYSAN to urls + inside + versions + e2e. Inside/version/load use low-priv francpaysan-stage-user (stagepaysan) and stage-lfp-* containers on 9030–9032; public stats.json stays outside-in. --- scripts/taler-monitoring/README.md | 21 +- scripts/taler-monitoring/check_inside.sh | 250 ++++++++++++++++++- scripts/taler-monitoring/check_versions.sh | 56 ++++- scripts/taler-monitoring/lib.sh | 33 ++- scripts/taler-monitoring/metrics.sh | 43 +++- scripts/taler-monitoring/secrets.env.example | 3 +- scripts/taler-monitoring/taler-monitoring.sh | 40 +-- 7 files changed, 398 insertions(+), 48 deletions(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 4771c6a..e627a35 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -81,12 +81,18 @@ Each check prints **global** and **grouped** ids: ./taler-monitoring.sh sanity ./taler-monitoring.sh e2e -# FrancPaysan STAGE (TESTPAYSAN) — default: urls + e2e (withdraw + pay templates) +# FrancPaysan STAGE (TESTPAYSAN) — like hacktivism breadth: +# default phases: urls + inside + versions + e2e ./taler-monitoring.sh -d stage.lefrancpaysan.ch -# Admin credit: E2E_BANK_ADMIN_PASS=… or SSH francpaysan-host secrets +./taler-monitoring.sh -d stage.lefrancpaysan.ch inside # stagepaysan only +# inside: low-priv SSH francpaysan-stage-user (User stagepaysan) → podman +# containers stage-lfp-{bank,exchange-ansible,merchant} · ports 9032/9031/9030 +# stats: outside-in public …/intro/stats.json (age via generated_at_unix) +# Admin credit for e2e: E2E_BANK_ADMIN_PASS=… or francpaysan-host secrets # (bank-admin-password.txt under /mnt/data/stagepaysan/bank/secrets/) +# Override: INSIDE_SSH=… INSIDE_PROFILE=stage-lfp -# Other domains — public HTTPS only, never SSH (pass e2e explicitly) +# Other domains — public HTTPS only (pass e2e/inside explicitly) ./taler-monitoring.sh -d taler.net ./taler-monitoring.sh --domain taler-ops.ch ./taler-monitoring.sh -d my.taler-ops.ch urls @@ -165,13 +171,18 @@ Helpers in `lib.sh`: `set_taler_stack`, `load_domain_profile`, `apply_taler_doma | `taler.net` / `demo.taler.net` | bank.demo.taler.net | exchange.demo.taler.net | backend.demo.taler.net | KUDOS | | `test.taler.net` | bank.test… | exchange.test… | backend.test… | **TESTKUDOS** · no landings | | `lefrancpaysan.ch` | bank.… | exchange.… | **monnaie.…** (merchant) | report-only · no landings | +| **`stage.lefrancpaysan.ch`** | stage.bank.… | stage.exchange.… | **stage.monnaie.…** | **TESTPAYSAN** · landings · **inside via stagepaysan** | | `taler-ops.ch` / `my.taler-ops.ch` | bank.taler-ops.ch | exchange.taler-ops.ch | **my.taler-ops.ch** | CHF · **no landings** | | `stage.taler-ops.ch` | bank.stage… | exchange.stage… | **my.stage…** | CHF · **no landings** | | unknown | bank.DOMAIN | exchange.DOMAIN | my.DOMAIN then probe | any · no landings | -**Landings:** only GOA (and optionally demo) use public `/intro` pages. **taler-ops.ch does not** — `CHECK_LANDING=0` in the profile skips intro crawl, shop assets, and demo-withdraw checks. +**Landings:** GOA, stage LFP (`TESTPAYSAN`), and optionally demo use public `/intro` pages. **taler-ops.ch does not** — `CHECK_LANDING=0` skips intro crawl. -**SSH only for koopa** (`hacktivism.ch` / `-d koopa`). +**SSH:** +| Stack | Host alias | User | Scope | +|-------|------------|------|--------| +| koopa / hacktivism | `koopa` | hernani | full host + containers | +| stage LFP | `francpaysan-stage-user` | **stagepaysan** | own podman only (no sudo / no prod data) | Other domains: never SSH. Optional **e2e** aborts cleanly on login/KYC. diff --git a/scripts/taler-monitoring/check_inside.sh b/scripts/taler-monitoring/check_inside.sh index b5db3b1..0584c9b 100755 --- a/scripts/taler-monitoring/check_inside.sh +++ b/scripts/taler-monitoring/check_inside.sh @@ -1,5 +1,9 @@ #!/usr/bin/env bash -# Inside status for bank / exchange / merchant. Hard-capped SSH — never hang forever. +# Inside status for bank / exchange / merchant containers. +# Profiles: +# koopa (default LOCAL_STACK=1) — SSH KOOPA_SSH, containers taler-hacktivism* +# stage-lfp (TESTPAYSAN) — SSH INSIDE_SSH (francpaysan-stage-user / stagepaysan), +# low-priv podman only; ports 9030–9032; stats remain outside-in (urls). set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh @@ -7,14 +11,250 @@ source "$ROOT/lib.sh" # shellcheck source=metrics.sh source "$ROOT/metrics.sh" -# Area inside.* — container / process state on koopa (SSH) -# Groups: inside.ssh / inside.bank / inside.exchange / inside.merchant / inside.caddy set_area inside set_group ssh + +PROFILE="${INSIDE_PROFILE:-}" +if [ -z "$PROFILE" ]; then + if [ "${LOCAL_STACK:-0}" = "1" ]; then + PROFILE=koopa + elif [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] || [ -n "${INSIDE_SSH:-}" ]; then + PROFILE=stage-lfp + else + PROFILE=koopa + fi +fi + +section "inside · collect (${PROFILE})" + +# --------------------------------------------------------------------------- +# stage-lfp: low-priv stagepaysan on francpaysan-host +# --------------------------------------------------------------------------- +if [ "$PROFILE" = "stage-lfp" ]; then + SSH_HOST="${INSIDE_SSH:-francpaysan-stage-user}" + BANK_CTR="${INSIDE_BANK_CTR:-stage-lfp-bank}" + EX_CTR="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" + MER_CTR="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" + BANK_PORT="${INSIDE_BANK_PORT:-9032}" + EX_PORT="${INSIDE_EXCHANGE_PORT:-9031}" + MER_PORT="${INSIDE_MERCHANT_PORT:-9030}" + DNS_BANK="${INSIDE_DNS_BANK:-stage.bank.lefrancpaysan.ch}" + DNS_EX="${INSIDE_DNS_EXCHANGE:-stage.exchange.lefrancpaysan.ch}" + DNS_MER="${INSIDE_DNS_MERCHANT:-stage.monnaie.lefrancpaysan.ch}" + # Stage remote has more podman execs than koopa; allow a bit more wall time + STAGE_SSH_T="${INSIDE_SSH_TIMEOUT:-${SSH_CMD_TIMEOUT:-24}}" + if [ "${STAGE_SSH_T}" -lt 24 ] 2>/dev/null; then STAGE_SSH_T=24; fi + + if ! mon_ssh_ok "$SSH_HOST"; then + err "ssh" "cannot reach ${SSH_HOST} (stagepaysan low-priv) — set INSIDE_SSH= or SKIP" + summary + exit 1 + fi + ok "ssh ${SSH_HOST}" "stagepaysan (podman, no sudo)" + + # Inject names/ports into remote (ssh bash -s does not inherit local env). + RAW=$( + { + printf 'BANK_CTR=%q; EX_CTR=%q; MER_CTR=%q\n' "$BANK_CTR" "$EX_CTR" "$MER_CTR" + printf 'BANK_PORT=%q; EX_PORT=%q; MER_PORT=%q\n' "$BANK_PORT" "$EX_PORT" "$MER_PORT" + printf 'DNS_BANK=%q; DNS_EX=%q; DNS_MER=%q\n' "$DNS_BANK" "$DNS_EX" "$DNS_MER" + cat <<'REMOTE' +set +e +emit() { printf 'E|%s|%s|%s|%s\n' "$1" "$2" "$3" "$(printf '%s' "${4:-}" | tr '\n\r' ' ' | head -c 200)"; } +hc() { curl -skS -m 3 -o /tmp/mb -w '%{http_code}' "$1" 2>/dev/null || echo 000; } +hasp() { podman exec "$1" pgrep -f "$2" >/dev/null 2>&1; } + +BANK="${BANK_CTR}" +EX="${EX_CTR}" +MER="${MER_CTR}" +podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$BANK" || BANK=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*bank|lfp-bank' | head -1) +podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$EX" || EX=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*exchange|lfp-exchange' | head -1) +podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$MER" || MER=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*merchant|lfp-merchant' | head -1) + +check_dns() { + local comp="$1" ctr="$2" host="$3" + local line ip code + [ -n "$host" ] || { emit "$comp" WARN "dns" "empty host"; return 1; } + line=$(podman exec "$ctr" getent ahostsv4 "$host" 2>/dev/null | head -1) + [ -z "$line" ] && line=$(podman exec "$ctr" getent hosts "$host" 2>/dev/null | head -1) + ip=$(echo "$line" | awk '{print $1}') + if [ -z "$ip" ]; then + emit "$comp" WARN "dns $host" "no resolve inside container" + return 1 + fi + if [ "$ip" = "127.0.0.1" ] || [ "$ip" = "::1" ]; then + emit "$comp" WARN "dns $host" "$ip (loopback)" + return 1 + fi + # 169.254.x = pasta host-gateway pin is OK for stage + code=$(podman exec "$ctr" curl -skS -m 3 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000) + if [ "$code" = "200" ]; then + emit "$comp" OK "dns $host" "→ $ip /config=$code" + else + emit "$comp" WARN "dns $host" "→ $ip /config=$code" + fi +} + +if [ -z "$BANK" ]; then emit bank ERROR container "not running" +else + emit bank INFO container "$(podman ps --filter name=^${BANK}$ --format '{{.Names}} {{.Status}}' | head -1)" + emit bank INFO ports "$(podman ps --filter name=^${BANK}$ --format '{{.Ports}}' | head -1)" + hasp "$BANK" 'MainKt serve|libeufin-bank serve' && emit bank OK libeufin "running" || emit bank ERROR libeufin "not running" + podman exec "$BANK" pg_isready -q 2>/dev/null && emit bank OK postgres "ready" || emit bank WARN postgres "pg_isready failed" + c=$(hc "http://127.0.0.1:${BANK_PORT}/config") + [ "$c" = "200" ] && emit bank OK "host :${BANK_PORT}/config" "HTTP $c" || emit bank ERROR "host :${BANK_PORT}/config" "HTTP $c" + c=$(hc "http://127.0.0.1:${BANK_PORT}/taler-integration/config") + [ "$c" = "200" ] && emit bank OK "host integration" "HTTP $c" || emit bank ERROR "host integration" "HTTP $c" + check_dns bank "$BANK" "$DNS_BANK" || true + check_dns bank "$BANK" "$DNS_EX" || true +fi + +if [ -z "$EX" ]; then emit exchange ERROR container "not running" +else + emit exchange INFO container "$(podman ps --filter name=^${EX}$ --format '{{.Names}} {{.Status}}' | head -1)" + c=$(hc "http://127.0.0.1:${EX_PORT}/config") + [ "$c" = "200" ] && emit exchange OK "host :${EX_PORT}/config" "HTTP $c" || emit exchange ERROR "host :${EX_PORT}/config" "HTTP $c" + c=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:${EX_PORT}/keys" 2>/dev/null || echo 000) + [ "$c" = "200" ] && emit exchange OK "host :${EX_PORT}/keys" "HTTP $c" || emit exchange ERROR "host :${EX_PORT}/keys" "HTTP $c" + hasp "$EX" 'taler-exchange-httpd' && emit exchange OK httpd "running" || emit exchange ERROR httpd "not running" + hasp "$EX" 'taler-exchange-wirewatch' && emit exchange OK wirewatch "running" || emit exchange ERROR wirewatch "not running" + hasp "$EX" 'taler-exchange-aggregator' && emit exchange OK aggregator "running" || emit exchange WARN aggregator "not running" + hasp "$EX" 'taler-exchange-transfer' && emit exchange OK transfer "running" || emit exchange WARN transfer "not running" + check_dns exchange "$EX" "$DNS_BANK" || true + check_dns exchange "$EX" "$DNS_EX" || true + check_dns exchange "$EX" "$DNS_MER" || true +fi + +if [ -z "$MER" ]; then emit merchant ERROR container "not running" +else + emit merchant INFO container "$(podman ps --filter name=^${MER}$ --format '{{.Names}} {{.Status}}' | head -1)" + c=$(hc "http://127.0.0.1:${MER_PORT}/config") + [ "$c" = "200" ] && emit merchant OK "host :${MER_PORT}/config" "HTTP $c" || emit merchant ERROR "host :${MER_PORT}/config" "HTTP $c" + hasp "$MER" 'taler-merchant-httpd' && emit merchant OK httpd "running" || emit merchant ERROR httpd "not running" + # Stage merchant often has no wirewatch unit — INFO not WARN + hasp "$MER" 'taler-merchant-wirewatch' && emit merchant OK wirewatch "running" || emit merchant INFO wirewatch "not running (optional on stage)" + hasp "$MER" 'taler-merchant-depositcheck' && emit merchant OK depositcheck "running" || emit merchant INFO depositcheck "not running (optional on stage)" + hasp "$MER" 'taler-merchant-exchangekeyupdate' && emit merchant OK exchangekeyupdate "running" || emit merchant INFO exchangekeyupdate "not running" + check_dns merchant "$MER" "$DNS_BANK" || true + check_dns merchant "$MER" "$DNS_EX" || true + check_dns merchant "$MER" "$DNS_MER" || true +fi + +# Caddy is host-wide (often root); stagepaysan can only see the process +if pgrep -x caddy >/dev/null 2>&1; then + emit caddy OK process "running (host)" +else + emit caddy WARN process "not seen as stagepaysan (may still run as root)" +fi + +echo DONE +REMOTE + } | mon_ssh_bash "$SSH_HOST" "${STAGE_SSH_T}" || true + ) + + if [ -z "$RAW" ] || ! echo "$RAW" | grep -q '^E|'; then + err "ssh" "stage remote timed out or empty (cap ${STAGE_SSH_T}s · host=${SSH_HOST})" + summary + exit 1 + fi + + _last_inside_grp="" + while IFS= read -r line; do + case "$line" in + E\|*) + IFS='|' read -r _ comp level key detail <<<"$line" + case "$comp" in + bank|exchange|merchant|caddy) _g="$comp" ;; + *) _g="ssh" ;; + esac + if [ "$_g" != "$_last_inside_grp" ]; then + set_group "$_g" + _last_inside_grp="$_g" + fi + case "$level" in + OK) ok "[$comp] $key${detail:+ ($detail)}" ;; + ERROR) err "$comp" "$key" "$detail" ;; + WARN) warn "[$comp] $key" "$detail" ;; + INFO) info "[$comp] $key" "$detail" ;; + esac + ;; + esac + done <<<"$RAW" + + # Stats from outside (laptop) — public HTTPS, no stagepaysan write needed + set_group stats + section "inside · public stats (outside-in)" + _probe_public_stats() { + local comp="$1" url="$2" + local body code age unix now + body=$(mktemp) + code=$(curl -skS -m 8 -o "$body" -w '%{http_code}' "$url" 2>/dev/null || echo 000) + if [ "$code" != "200" ]; then + warn "[$comp] public stats.json" "HTTP $code · $url" + rm -f "$body" + return + fi + unix=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("generated_at_unix") or 0)' "$body" 2>/dev/null || echo 0) + now=$(date +%s) + if [ "${unix:-0}" -gt 0 ] 2>/dev/null; then + age=$((now - unix)) + if [ "$age" -lt 0 ]; then age=0; fi + if [ "$age" -le "${STATS_STALE_SECS:-900}" ]; then + ok "[$comp] public stats.json" "HTTP 200 age=${age}s · $url" + elif [ "$age" -le "${STATS_FAIL_SECS:-3600}" ]; then + warn "[$comp] public stats.json" "stale age=${age}s · $url" + else + err "$comp" "public stats.json" "too old age=${age}s · $url" + fi + else + ok "[$comp] public stats.json" "HTTP 200 · $url" + fi + rm -f "$body" + } + _probe_public_stats bank "https://${DNS_BANK}/intro/stats.json" + _probe_public_stats exchange "https://${DNS_EX}/intro/stats.json" + _probe_public_stats merchant "https://${DNS_MER}/intro/stats.json" + + # Host load as stagepaysan (no container RSS from koopa metrics) + set_group load + section "inside · load (stagepaysan host)" + LOAD_LINE=$(mon_ssh_bash "$SSH_HOST" 8 <<'EOF' || true +python3 - <<'PY' +import os +la=os.getloadavg() +print("loadavg=%.2f,%.2f,%.2f" % la) +try: + with open("/proc/meminfo") as f: + d={} + for line in f: + k,v=line.split(":")[0], line.split(":")[1].strip().split()[0] + d[k]=int(v) + total=d.get("MemTotal",0)/1024/1024 + avail=d.get("MemAvailable",0)/1024/1024 + used=total-avail + print("mem_used=%.2fGiB avail=%.2fGiB total=%.2fGiB" % (used, avail, total)) +except Exception: + print("mem=?") +PY +EOF +) + if [ -n "$LOAD_LINE" ]; then + info "stage host" "$(echo "$LOAD_LINE" | tr '\n' ' ')" + else + info "stage host" "load probe empty" + fi + + summary + exit 0 +fi + +# --------------------------------------------------------------------------- +# koopa (default) — existing remote collect +# --------------------------------------------------------------------------- section "inside · collect from koopa" -if [ "${SKIP_SSH}" = "1" ]; then - warn "ssh" "SKIP_SSH=1 — skipped" +if [ "${SKIP_SSH}" = "1" ] && [ "${LOCAL_STACK:-0}" != "1" ]; then + warn "ssh" "SKIP_SSH=1 and not local — skipped" summary exit 0 fi diff --git a/scripts/taler-monitoring/check_versions.sh b/scripts/taler-monitoring/check_versions.sh index ea4df2f..3580732 100755 --- a/scripts/taler-monitoring/check_versions.sh +++ b/scripts/taler-monitoring/check_versions.sh @@ -166,28 +166,56 @@ fi # --------------------------------------------------------------------------- # 2) INSIDE — containers can reach deb.taler.net (pasta / install path) +# koopa: KOOPA_SSH · stage-lfp: INSIDE_SSH (stagepaysan, low-priv) # --------------------------------------------------------------------------- set_group inside section "versions · inside · containers → deb.taler.net" -if [ "${SKIP_SSH}" = "1" ]; then - warn "ssh" "SKIP_SSH=1 — skip container reachability + installed package compare" +_VERS_SSH_HOST="" +_VERS_BANK="taler-hacktivism-bank" +_VERS_EX="taler-hacktivism-exchange-ansible" +_VERS_MER="taler-hacktivism" +if [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \ + || [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \ + || [ -n "${INSIDE_SSH:-}" ]; then + _VERS_SSH_HOST="${INSIDE_SSH:-francpaysan-stage-user}" + _VERS_BANK="${INSIDE_BANK_CTR:-stage-lfp-bank}" + _VERS_EX="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" + _VERS_MER="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" +elif [ "${LOCAL_STACK:-0}" = "1" ] && [ "${SKIP_SSH:-0}" != "1" ]; then + _VERS_SSH_HOST="${KOOPA_SSH}" +fi + +if [ -z "$_VERS_SSH_HOST" ]; then + info "ssh" "no container SSH for this domain — outside-only package index" info "outside-only" "deb.taler.net public checks completed above" summary exit 0 fi -if ! koopa_ssh_ok; then - err "ssh" "cannot reach ${KOOPA_SSH} — set SKIP_SSH=1 to skip container checks" - info "outside-only" "deb.taler.net public checks completed above" - summary - exit 1 + +if [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then + if ! koopa_ssh_ok; then + err "ssh" "cannot reach ${KOOPA_SSH} — set SKIP_SSH=1 to skip container checks" + info "outside-only" "deb.taler.net public checks completed above" + summary + exit 1 + fi + ok "ssh ${KOOPA_SSH}" +else + if ! mon_ssh_ok "$_VERS_SSH_HOST"; then + err "ssh" "cannot reach ${_VERS_SSH_HOST} (stagepaysan) — skip container package compare" + info "outside-only" "deb.taler.net public checks completed above" + summary + exit 1 + fi + ok "ssh ${_VERS_SSH_HOST}" "stagepaysan container package compare" fi -ok "ssh ${KOOPA_SSH}" INRELEASE_URL="${APT_BASE}/dists/${SUITE}/InRelease" # Write remote script to a file (avoids bash 3.2 parse bugs with case/;; inside $(…)</dev/null | grep -i exchange | head -1 ;; - *bank*) podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism-bank|taler-bank' | head -1 ;; + *bank*) podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*bank|hacktivism-bank|taler-bank|lfp-bank' | head -1 ;; *) - c=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E '^taler-hacktivism$' | head -1) + c=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E '^taler-hacktivism$|^stage-lfp-merchant$' | head -1) if [ -n "$c" ]; then echo "$c"; return; fi podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'merchant|hacktivism' | grep -viE 'bank|exchange' | head -1 ;; esac } -for want in taler-hacktivism-exchange-ansible taler-hacktivism-bank taler-hacktivism; do +for want in "$WANT_EX" "$WANT_BANK" "$WANT_MER"; do c=$(resolve_ctr "$want") [ -n "$c" ] || continue role=other @@ -229,7 +257,11 @@ done REMOTE } >"$tmp/remote-versions.sh" -REMOTE_OUT=$(koopa_ssh_bash 60 <"$tmp/remote-versions.sh" || true) +if [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then + REMOTE_OUT=$(koopa_ssh_bash 60 <"$tmp/remote-versions.sh" || true) +else + REMOTE_OUT=$(mon_ssh_bash "$_VERS_SSH_HOST" 60 <"$tmp/remote-versions.sh" || true) +fi printf '%s\n' "$REMOTE_OUT" | grep -E '^R\|' >"$tmp/reach.tsv" || true printf '%s\n' "$REMOTE_OUT" | grep -E '^S\|' >"$tmp/sources.tsv" || true diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 3f060a2..7bde111 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -211,6 +211,19 @@ apply_taler_domain() { : "${E2E_TEMPLATE_MAP:=5=oeufs-6 4.5=pain-seigle 6=jus-pomme 8.5=fromage-chevre 12=miel-printemps 25=panier-legumes}" : "${E2E_PAY_VALUES:=5 8.5 6}" : "${E2E_WITHDRAW_VALUES:=20 50}" + # Inside checks as low-priv stagepaysan (podman, no sudo) — not koopa + INSIDE_PROFILE="${INSIDE_PROFILE:-stage-lfp}" + INSIDE_SSH="${INSIDE_SSH:-francpaysan-stage-user}" + INSIDE_BANK_CTR="${INSIDE_BANK_CTR:-stage-lfp-bank}" + INSIDE_EXCHANGE_CTR="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" + INSIDE_MERCHANT_CTR="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" + # Host pasta ports (127.0.0.1 on francpaysan-host) + INSIDE_BANK_PORT="${INSIDE_BANK_PORT:-9032}" + INSIDE_EXCHANGE_PORT="${INSIDE_EXCHANGE_PORT:-9031}" + INSIDE_MERCHANT_PORT="${INSIDE_MERCHANT_PORT:-9030}" + INSIDE_DNS_BANK="${INSIDE_DNS_BANK:-stage.bank.lefrancpaysan.ch}" + INSIDE_DNS_EXCHANGE="${INSIDE_DNS_EXCHANGE:-stage.exchange.lefrancpaysan.ch}" + INSIDE_DNS_MERCHANT="${INSIDE_DNS_MERCHANT:-stage.monnaie.lefrancpaysan.ch}" ;; CHF) WITHDRAW_AMT="${WITHDRAW_AMT:-CHF:20}" @@ -246,10 +259,14 @@ apply_taler_domain() { EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} - # Hard rule: only the local koopa/hacktivism stack may use SSH + # Koopa SSH only for LOCAL_STACK=1. Stage LFP uses INSIDE_SSH (stagepaysan) separately. if [ "${LOCAL_STACK}" != "1" ]; then SKIP_SSH=1 fi + # Default inside profile for local GOA + if [ "${LOCAL_STACK}" = "1" ]; then + INSIDE_PROFILE="${INSIDE_PROFILE:-koopa}" + fi } # Apply TALER_DOMAIN from env once (CLI exports TALER_DOMAIN_APPLIED=1 after overrides). @@ -310,6 +327,20 @@ with_timeout() { ' "$secs" "$@" } +# Generic short SSH to a named host (low-priv stagepaysan or koopa). +# usage: mon_ssh_bash HOST [timeout] <<'EOF' ... EOF +mon_ssh_bash() { + local host="$1" t="${2:-$SSH_CMD_TIMEOUT}" + [ -n "$host" ] || return 1 + with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "$host" bash -s +} +mon_ssh_ok() { + local host="$1" + [ -n "$host" ] || return 1 + with_timeout $((SSH_CONNECT_TIMEOUT + 5)) \ + ssh "${SSH_BASE_OPTS[@]}" "$host" true >/dev/null 2>&1 +} + # 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 diff --git a/scripts/taler-monitoring/metrics.sh b/scripts/taler-monitoring/metrics.sh index 5df8d76..4c440ea 100644 --- a/scripts/taler-monitoring/metrics.sh +++ b/scripts/taler-monitoring/metrics.sh @@ -489,9 +489,9 @@ print(rep["summary"]) PY } -# --- Taler stack load on koopa (host + bank/exchange/merchant) --- +# --- Taler stack load (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). +# koopa: KOOPA_SSH (+ fallbacks). stage-lfp: INSIDE_SSH (stagepaysan) + stage-lfp-* names. metrics_taler_load() { local out="${1:-$METRICS_DIR/load.json}" local label="${2:-snap}" @@ -502,7 +502,20 @@ metrics_taler_load() { fi local raw="" local remote_py - remote_py=$(cat <<'PY' + local _m_bank _m_mer _m_ex _m_ssh + _m_bank="taler-hacktivism-bank" + _m_mer="taler-hacktivism" + _m_ex="taler-hacktivism-exchange-ansible" + _m_ssh="" + if [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \ + || [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \ + || { [ -n "${INSIDE_SSH:-}" ] && [ "${LOCAL_STACK:-0}" != "1" ]; }; then + _m_bank="${INSIDE_BANK_CTR:-stage-lfp-bank}" + _m_mer="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" + _m_ex="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" + _m_ssh="${INSIDE_SSH:-francpaysan-stage-user}" + fi + remote_py=$(cat </dev/null 2>&1 && koopa_ssh_ok; then + # Prefer stagepaysan (stage-lfp), else koopa SSH, else local podman only on LOCAL_STACK. + if [ -n "${_m_ssh}" ] && type mon_ssh_ok >/dev/null 2>&1 && mon_ssh_ok "${_m_ssh}"; then + raw=$(printf '%s' "$remote_py" | with_timeout "${METRICS_LOAD_SSH_TIMEOUT:-90}" \ + ssh "${SSH_BASE_OPTS[@]}" "${_m_ssh}" python3 - 2>/dev/null || true) + elif [ "${SKIP_SSH:-0}" != "1" ] && type koopa_ssh_ok >/dev/null 2>&1 && koopa_ssh_ok; then 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) + elif [ "${LOCAL_STACK:-0}" = "1" ] && command -v podman >/dev/null 2>&1; then + raw=$(printf '%s' "$remote_py" | python3 - 2>/dev/null || true) + else + printf '%s\n' "{\"ok\":false,\"reason\":\"no-ssh-for-load\",\"label\":\"$label\"}" >"$out" + return 0 fi if [ -z "$raw" ]; then - printf '%s\n' "{\"ok\":false,\"reason\":\"probe-failed\",\"label\":\"$label\",\"ssh\":\"${KOOPA_SSH:-?}\"}" >"$out" + printf '%s\n' "{\"ok\":false,\"reason\":\"probe-failed\",\"label\":\"$label\",\"ssh\":\"${_m_ssh:-${KOOPA_SSH:-?}}\"}" >"$out" return 1 fi printf '%s\n' "$raw" | python3 -c ' diff --git a/scripts/taler-monitoring/secrets.env.example b/scripts/taler-monitoring/secrets.env.example index f7e13de..064b14d 100644 --- a/scripts/taler-monitoring/secrets.env.example +++ b/scripts/taler-monitoring/secrets.env.example @@ -27,7 +27,8 @@ # MERCHANT_INSTANCE_PASSWORD= # --- FrancPaysan STAGE (TESTPAYSAN) e2e --- -# Default phases for -d stage.lefrancpaysan.ch: urls + e2e +# Default phases for -d stage.lefrancpaysan.ch: urls + inside + versions + e2e +# inside uses francpaysan-stage-user (stagepaysan); stats from public HTTPS. # Admin credit (required for ATM withdraw ladder on stage): # E2E_BANK_ADMIN_PASS=… # or auto-load via SSH francpaysan-host # FRANCPAYSAN_SSH=francpaysan-host diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 05e6f5d..d8f6197 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -154,6 +154,10 @@ 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 E2E_USE_TEMPLATES E2E_TEMPLATE_MAP export PAIVANA_PUBLIC E2E_PAIVANA E2E_PAIVANA_TEMPLATE E2E_PAIVANA_AMOUNT E2E_PAIVANA_INSTANCE +export INSIDE_PROFILE INSIDE_SSH +export INSIDE_BANK_CTR INSIDE_EXCHANGE_CTR INSIDE_MERCHANT_CTR +export INSIDE_BANK_PORT INSIDE_EXCHANGE_PORT INSIDE_MERCHANT_PORT +export INSIDE_DNS_BANK INSIDE_DNS_EXCHANGE INSIDE_DNS_MERCHANT # 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}" @@ -181,17 +185,17 @@ export LADDER_PAY LADDER_WITHDRAW_SCALE LADDER_PAY_SETTLE_ROUNDS export TALER_DOMAIN_APPLIED=1 # Default phases -# - local GOA (koopa): urls + SSH phases + e2e -# - FrancPaysan STAGE (TESTPAYSAN / stage.*lefrancpaysan*): urls + e2e -# (public withdraw + template pay — no SSH) -# - other remote: urls only (pass "e2e" explicitly) +# - local GOA (koopa): urls + inside + versions + e2e +# - FrancPaysan STAGE: urls + inside (stagepaysan) + versions + e2e +# - other remote: urls only (pass e2e/inside explicitly) if [ "${#PHASES[@]}" -eq 0 ]; then if [ "${LOCAL_STACK}" = "1" ]; then PHASES=(urls inside versions e2e) elif [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \ || [[ "${TALER_DOMAIN:-}" == stage.*lefrancpaysan* ]] \ || [[ "${TALER_DOMAIN:-}" == *stage.lefrancpaysan* ]]; then - PHASES=(urls e2e) + # Like hacktivism breadth: public urls + inside (stagepaysan) + versions + e2e + PHASES=(urls inside versions e2e) else PHASES=(urls) fi @@ -202,8 +206,10 @@ for p in "${PHASES[@]}"; do if [ "$p" = "all" ]; then if [ "${LOCAL_STACK}" = "1" ]; then OUT_PHASES+=(urls inside versions sanity e2e) + elif [ -n "${INSIDE_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "stage-lfp" ]; then + OUT_PHASES+=(urls inside versions e2e) else - # remote: repo index check only (no installed pkgs without SSH) + # remote without inside SSH: public + optional e2e OUT_PHASES+=(urls versions e2e) fi else @@ -213,15 +219,21 @@ done PHASES=() seen=" " for p in "${OUT_PHASES[@]}"; do - # Drop SSH-only phases for non-koopa (e2e is allowed — public path only) - # versions still runs: outside-in deb.taler.net checks; container parts soft-skip if no SSH - if [ "${LOCAL_STACK}" != "1" ] || [ "${SKIP_SSH}" = "1" ]; then - case "$p" in inside|server) - echo "[INFO] skip phase '$p' (only koopa uses SSH)" >&2 - continue + # server = koopa-only. inside allowed on koopa OR stagepaysan (INSIDE_SSH). + case "$p" in + server) + if [ "${LOCAL_STACK}" != "1" ]; then + echo "[INFO] skip phase 'server' (koopa only)" >&2 + continue + fi ;; - esac - fi + inside) + if [ "${LOCAL_STACK}" != "1" ] && [ -z "${INSIDE_SSH:-}" ] && [ "${INSIDE_PROFILE:-}" != "stage-lfp" ]; then + echo "[INFO] skip phase 'inside' (no INSIDE_SSH / not local)" >&2 + continue + fi + ;; + esac case "$seen" in *" $p "*) ;; *) PHASES+=("$p"); seen="$seen$p " ;; esac done From c887617bc74d237922e9810a8ca03a9d2e1892a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:58:10 +0200 Subject: [PATCH 231/326] monitoring: resolve ladder explorer secret without laptop-hardcoded path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ladder used /Users/newkamek/… for bank-explorer password and wallet-cli. Use SECRETS_ROOT/read_secret, EXP_PW/EXP_PW_FILE overrides, and find_wallet_cli. Document Android automation prereqs; stage shop assets also require qrcode.min.js. --- .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 87 +++++++++++++++++++ scripts/taler-monitoring/README.md | 3 +- scripts/taler-monitoring/TESTS.md | 2 +- scripts/taler-monitoring/check_goa_ladder.sh | 65 ++++++++++++-- scripts/taler-monitoring/check_urls.sh | 20 +++-- scripts/taler-monitoring/lib.sh | 3 + scripts/taler-monitoring/secrets.env.example | 8 ++ 7 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md new file mode 100644 index 0000000..3253701 --- /dev/null +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -0,0 +1,87 @@ +# Git / automation notes (taler-monitoring + mobile) + +Notes for **automated checks** around the Taler stacks (GOA, stage +TESTPAYSAN, …). CLI monitoring lives in this directory; **Android wallet +UI automation is separate tooling** and is **not** installed on the +default ops laptop yet. + +## What runs without Android + +| Phase | Needs | +|-------|--------| +| `urls` / `sanity` | public HTTPS only | +| `inside` / `versions` | SSH (`koopa` or `francpaysan-stage-user`) | +| `e2e` / `ladder` | `taler-wallet-cli` + bank/merchant secrets | + +```bash +./taler-monitoring.sh -d stage.lefrancpaysan.ch urls +./taler-monitoring.sh -d stage.lefrancpaysan.ch e2e # after secrets.env +``` + +Mobile app checks (manual withdraw/pay, then automation) are **out of +band** until the software below is present. + +--- + +## Android automation software — **required** (not installed yet) + +To automate **GNU Taler Wallet** on Android (stable + master builds; +manual then auto withdraw; fixed + variable pay) against stage +TESTPAYSAN, the following must be available on the runner host. + +### Status on this host (2026-07-17) + +| Component | Status | +|-----------|--------| +| Java (OpenJDK 21) | present (`/usr/bin/java`) | +| `aapt` / `aapt2` | present | +| **`adb`** (platform-tools) | **missing** (package was removed: `android-tools-adb` rc) | +| **Android SDK** (`ANDROID_HOME` / `ANDROID_SDK_ROOT`) | **missing** / empty | +| **Emulator + AVD** | **missing** | +| Appium / Maestro / `uiautomator2` | **missing** | +| scrcpy | missing (optional) | + +### Must install before mobile automation + +1. **Android platform-tools** — at least `adb` + - Debian: `android-tools-adb` (and preferably full SDK platform-tools) +2. **Android SDK command-line tools** — `sdkmanager`, platforms, build-tools + - set `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) permanently +3. **System image + AVD** — for emulator runs (x86_64 or host-matching ABI) + - `emulator`, `avdmanager`, KVM/acceleration if available +4. **UI automation stack** (pick one, prefer the leaner path for wallet flows): + - **Maestro**, or + - **Appium** + **UiAutomator2** (+ Node/Python bindings as needed) +5. **Optional:** `scrcpy` for live view during manual debugging + +### Explicitly out of scope until freigabe + +- Installing production wallet APKs and driving real stage withdraw/pay + without a human go-ahead +- Committing secrets, device serials, or APK binaries into git + +### Operator still needs to supply + +- Stable / master **APK** (or store/build URLs) for the wallet +- USB debugging consent on a physical device, **or** agreement to use AVD only +- Which flows are in-scope first (withdraw communal QR → shop pay, …) + +### Suggested freigabe prompt (for an agent) + +See session notes: full copy-paste block under “Android-Automatisierung +vollständig einrichten” — install tooling, verify `adb devices` + emulator, +document paths; **no** real wallet payments until approved. + +--- + +## Related docs + +| Doc | Role | +|-----|------| +| [README.md](./README.md) | how to run `taler-monitoring.sh` | +| [TESTS.md](./TESTS.md) | grouped check IDs | +| [secrets.env.example](./secrets.env.example) | e2e secrets layout | +| `francpaysan-admin-log/TODO.md` | stage wallet mobile test backlog | + +When Android tooling is installed, append a short “installed on …” +section here (date, `adb version`, AVD name, `ANDROID_HOME`). diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index e627a35..c04508e 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -13,7 +13,8 @@ Report for the **GOA** stack with boxed severity badges and **grouped test IDs** IDs reset per **group** inside an area — so “too many www tests” become `www.exchange-*`, `www.bank-*`, `www.landing-*`, etc. -Catalog: **[TESTS.md](./TESTS.md)**. +Catalog: **[TESTS.md](./TESTS.md)**. +Android UI automation prerequisites: **[GIT-AUTOMATION-NOTES.md](./GIT-AUTOMATION-NOTES.md)** (tools **not** installed by default). ```text ┌ OK ┐ www.exchange-01 exchange /config · HTTP 200 diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index 7bd51c1..96e4512 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -53,7 +53,7 @@ Numbering follows **executed** checks (early skip may shift later NN inside the | **www.bank-** | `/config`, currency, alt_unit_names; integration/webui/intro; **auto-account.json** (required GOA/local; skip/soft off-GOA e.g. TESTPAYSAN); `/terms`, `/privacy` | | **www.merchant-** | `/config` currency + currencies alt_unit_names; listed exchanges alt; webui/intro; **`/terms`**, **`/privacy`** | | **www.paivana-** | local GOA paywall front (redirect to template) | -| **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; cross-links local | +| **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; stage merchant shop: `shop-ui.js` + `shops.css` + `qrcode.min.js`; cross-links local | | **www.qr-** | QR payloads: harvest `taler://` / `payto://` / app `data-qr-url` from landings + mint JSON; **form** check; **qrencode → zbarimg** exact roundtrip; optional static QR images. Needs `qrencode` + `zbar-tools`. Skip: `QR_CHECK=0` | **QR rule:** `taler://withdraw/HOST/taler-integration/UUID` (no default `:443`/`:80`); `taler://pay/` / `pay-template/`; `payto://…` shape OK; decoded PNG must equal payload. Form errors on withdraw/pay/payto are **ERROR** on local/GOA. diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 8b311ce..90a6f1b 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -21,13 +21,17 @@ # 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, MERCHANT_INSTANCE, … +# EXP_PW / EXP_PW_FILE / EXP_USER — explorer pool (default: secrets tree) +# CLI_JS / WALLET_CLI — wallet-cli .mjs (default: find_wallet_cli) +# LADDER_REPORT_DIR, MERCHANT_INSTANCE, … # # Phase: ./taler-monitoring.sh ladder set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" +# When invoked standalone (not via taler-monitoring.sh), still load secrets.env +load_monitoring_secrets_env 2>/dev/null || true # Area ladder.* — GOA withdraw/pay ladder # Groups: ladder.plan / ladder.load / ladder.withdraw / ladder.pay / ladder.report @@ -44,8 +48,10 @@ elapsed_ms() { : "${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}" +# EXP_PW_FILE / EXP_PW optional overrides; otherwise read_secret + SECRETS_ROOT +: "${EXP_PW_FILE:=}" +: "${EXP_PW:=}" +: "${CLI_JS:=}" : "${LADDER_MAX_AMOUNT:=4503599627370496}" : "${LADDER_STEPS:=23}" : "${LADDER_LOAD:=1}" @@ -58,6 +64,15 @@ elapsed_ms() { : "${LADDER_PAY_SETTLE_ROUNDS:=6}" : "${MERCHANT_INSTANCE:=goa-demo-cp4zqk}" +# Resolve wallet-cli .mjs (no hardcoded laptop path) +if [ -z "${CLI_JS}" ] || [ ! -f "${CLI_JS}" ]; then + CLI_JS=$(find_wallet_cli 2>/dev/null || true) +fi +if [ -z "${CLI_JS}" ] || [ ! -f "${CLI_JS}" ]; then + # allow PATH wrapper as last resort (wcli falls back to taler-wallet-cli) + CLI_JS="" +fi + CUR="${EXPECT_CURRENCY:-GOA}" BANK="${BANK_PUBLIC%/}" EX="${EXCHANGE_PUBLIC%/}/" @@ -82,13 +97,42 @@ ladder_over() { } wcli() { - if [ -f "$CLI_JS" ]; then + if [ -n "${CLI_JS:-}" ] && [ -f "$CLI_JS" ]; then node "$CLI_JS" --wallet-db="$WDB" --no-throttle "$@" else taler-wallet-cli --wallet-db="$WDB" --no-throttle "$@" fi } +# Explorer pool password: env → file override → secrets tree → SSH koopa +resolve_explorer_pw() { + local pw="" f="" + if [ -n "${EXP_PW:-}" ]; then + printf '%s' "$EXP_PW" + return 0 + fi + if [ -n "${EXP_PW_FILE:-}" ] && [ -f "$EXP_PW_FILE" ]; then + tr -d '\n\r' <"$EXP_PW_FILE" + return 0 + fi + # Prefer shared read_secret (SECRETS_ROOT / ~/.config / koopa SSH) + if pw=$(read_secret "taler-bank/bank-explorer-password.txt" 2>/dev/null) && [ -n "$pw" ]; then + printf '%s' "$pw" + return 0 + fi + # Extra local candidates (sibling of admin-log, taler-landing config) + for f in \ + "${SECRETS_ROOT:+${SECRETS_ROOT}/taler-bank/bank-explorer-password.txt}" \ + "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt" \ + "${HOME}/.config/taler-landing/bank-explorer-password.txt" + do + [ -n "$f" ] && [ -f "$f" ] || continue + tr -d '\n\r' <"$f" + return 0 + done + return 1 +} + wallet_avail() { wcli balance 2>/dev/null | python3 -c ' import json,sys @@ -270,11 +314,18 @@ 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" +if ! EXP_PW=$(resolve_explorer_pw); then + err bank "explorer password missing" \ + "set EXP_PW / EXP_PW_FILE or SECRETS_ROOT=…/koopa/host-root (taler-bank/bank-explorer-password.txt)${SECRETS_ROOT:+ · SECRETS_ROOT=${SECRETS_ROOT}}" + secrets_hint 2>/dev/null || true exit 1 fi -EXP_PW=$(tr -d '\n' <"$EXP_PW_FILE") +info "explorer secret" "${SECRETS_ROOT:+SECRETS_ROOT=${SECRETS_ROOT}}${SECRETS_ROOT:-via EXP_PW/EXP_PW_FILE/SSH}" +if [ -n "${CLI_JS:-}" ] && [ -f "${CLI_JS}" ]; then + info "wallet-cli" "$CLI_JS" +else + info "wallet-cli" "PATH taler-wallet-cli ($(command -v taler-wallet-cli 2>/dev/null || echo missing))" +fi # --- auto-account --- t0=$(now_ms) diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index 387d4e2..7191609 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -1236,7 +1236,8 @@ if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then fi fi -# Merchant shop assets — GOA: shop-pay.*; stage TESTPAYSAN: /assets/shop-ui.js + shops.css +# Merchant shop assets — GOA: shop-pay.*; stage TESTPAYSAN: /assets/shop-ui.js + +# shops.css + QR encoder (shop-ui modal needs /intro/qrcode.min.js or /qrcode.min.js). _ma=0 _ma_need=2 _ma_label="shop-pay.js + .css" @@ -1244,9 +1245,16 @@ if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then _landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.js" && _ma=$((_ma + 1)) _landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.css" && _ma=$((_ma + 1)) else - _ma_label="shop-ui.js + shops.css" + # stage farmer shops: UI + stylesheet + qrcode (pay modal QR_Taler) + _ma_need=3 + _ma_label="shop-ui.js + shops.css + qrcode.min.js" _landing_probe "$MERCHANT_PUBLIC/assets/shop-ui.js" && _ma=$((_ma + 1)) _landing_probe "$MERCHANT_PUBLIC/assets/shops.css" && _ma=$((_ma + 1)) + # Prefer /intro/ (always on landings tree); root alias is Caddy-only convenience + if _landing_probe "$MERCHANT_PUBLIC/intro/qrcode.min.js" \ + || _landing_probe "$MERCHANT_PUBLIC/qrcode.min.js"; then + _ma=$((_ma + 1)) + fi fi if [ "$_ma" -eq "$_ma_need" ]; then ok "landing merchant shop assets" "${_ma_label}" @@ -1254,9 +1262,11 @@ else if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then warn "landing merchant shop assets" "${_ma}/${_ma_need} present (soft) · want ${_ma_label}" else - # stage: soft INFO if missing; WARN only if zero - if [ "$_ma" -eq 0 ]; then - warn "landing merchant shop assets" "0/${_ma_need} · want ${_ma_label}" + # stage: hard-ish — missing qrcode broke pay QR («bibliothèque manquante») + if [ "$_ma" -lt 2 ]; then + fail "landing merchant shop assets" "${_ma}/${_ma_need} · want ${_ma_label}" + elif [ "$_ma" -lt "$_ma_need" ]; then + warn "landing merchant shop assets" "${_ma}/${_ma_need} · want ${_ma_label}" else info "landing merchant shop assets" "${_ma}/${_ma_need} · ${_ma_label}" fi diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 7bde111..a113212 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -1115,8 +1115,11 @@ find_wallet_cli() { local c cand for c in \ "${HOME}/.local/bin/taler-wallet-cli.mjs" \ + "${HOME}/taler/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs" \ + "${HOME}/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs" \ "${HOME}/taler/opt/taler-wallet-cli/usr/lib/taler-wallet-cli/node_modules/taler-wallet-cli/bin/taler-wallet-cli.mjs" \ /home/hernani/taler/opt/taler-wallet-cli/usr/lib/taler-wallet-cli/node_modules/taler-wallet-cli/bin/taler-wallet-cli.mjs \ + /home/hernani/taler/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs \ /Users/newkamek/src/taler/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs \ /usr/lib/taler-wallet-cli/node_modules/taler-wallet-cli/bin/taler-wallet-cli.mjs \ /usr/share/taler-wallet-cli/bin/taler-wallet-cli.mjs diff --git a/scripts/taler-monitoring/secrets.env.example b/scripts/taler-monitoring/secrets.env.example index 064b14d..212a7f2 100644 --- a/scripts/taler-monitoring/secrets.env.example +++ b/scripts/taler-monitoring/secrets.env.example @@ -43,6 +43,14 @@ # PROGRESS_SHOW_EVERY=8 # PROGRESS_OFF=1 +# --- Ladder (GOA withdraw ladder · explorer pool) --- +# Prefer SECRETS_ROOT / KOOPA_ADMIN_SECRETS (bank-explorer-password.txt). +# Overrides only if needed: +# EXP_USER=explorer +# EXP_PW= +# EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt +# CLI_JS=$HOME/taler/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs + # Values that typically appear on a full stack (for orientation only): # bank-admin-password.txt # bank-explorer-password.txt From 8c38fbaf59faaf75f4d1f22a95175906b982e1c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 19:58:10 +0200 Subject: [PATCH 232/326] monitoring: resolve ladder explorer secret without laptop-hardcoded path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ladder used /Users/newkamek/… for bank-explorer password and wallet-cli. Use SECRETS_ROOT/read_secret, EXP_PW/EXP_PW_FILE overrides, and find_wallet_cli. Document Android automation prereqs; stage shop assets also require qrcode.min.js. --- .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 87 +++++++++++++++++++ scripts/taler-monitoring/README.md | 3 +- scripts/taler-monitoring/TESTS.md | 2 +- scripts/taler-monitoring/check_goa_ladder.sh | 65 ++++++++++++-- scripts/taler-monitoring/check_urls.sh | 20 +++-- scripts/taler-monitoring/lib.sh | 3 + scripts/taler-monitoring/secrets.env.example | 8 ++ 7 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md new file mode 100644 index 0000000..3253701 --- /dev/null +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -0,0 +1,87 @@ +# Git / automation notes (taler-monitoring + mobile) + +Notes for **automated checks** around the Taler stacks (GOA, stage +TESTPAYSAN, …). CLI monitoring lives in this directory; **Android wallet +UI automation is separate tooling** and is **not** installed on the +default ops laptop yet. + +## What runs without Android + +| Phase | Needs | +|-------|--------| +| `urls` / `sanity` | public HTTPS only | +| `inside` / `versions` | SSH (`koopa` or `francpaysan-stage-user`) | +| `e2e` / `ladder` | `taler-wallet-cli` + bank/merchant secrets | + +```bash +./taler-monitoring.sh -d stage.lefrancpaysan.ch urls +./taler-monitoring.sh -d stage.lefrancpaysan.ch e2e # after secrets.env +``` + +Mobile app checks (manual withdraw/pay, then automation) are **out of +band** until the software below is present. + +--- + +## Android automation software — **required** (not installed yet) + +To automate **GNU Taler Wallet** on Android (stable + master builds; +manual then auto withdraw; fixed + variable pay) against stage +TESTPAYSAN, the following must be available on the runner host. + +### Status on this host (2026-07-17) + +| Component | Status | +|-----------|--------| +| Java (OpenJDK 21) | present (`/usr/bin/java`) | +| `aapt` / `aapt2` | present | +| **`adb`** (platform-tools) | **missing** (package was removed: `android-tools-adb` rc) | +| **Android SDK** (`ANDROID_HOME` / `ANDROID_SDK_ROOT`) | **missing** / empty | +| **Emulator + AVD** | **missing** | +| Appium / Maestro / `uiautomator2` | **missing** | +| scrcpy | missing (optional) | + +### Must install before mobile automation + +1. **Android platform-tools** — at least `adb` + - Debian: `android-tools-adb` (and preferably full SDK platform-tools) +2. **Android SDK command-line tools** — `sdkmanager`, platforms, build-tools + - set `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) permanently +3. **System image + AVD** — for emulator runs (x86_64 or host-matching ABI) + - `emulator`, `avdmanager`, KVM/acceleration if available +4. **UI automation stack** (pick one, prefer the leaner path for wallet flows): + - **Maestro**, or + - **Appium** + **UiAutomator2** (+ Node/Python bindings as needed) +5. **Optional:** `scrcpy` for live view during manual debugging + +### Explicitly out of scope until freigabe + +- Installing production wallet APKs and driving real stage withdraw/pay + without a human go-ahead +- Committing secrets, device serials, or APK binaries into git + +### Operator still needs to supply + +- Stable / master **APK** (or store/build URLs) for the wallet +- USB debugging consent on a physical device, **or** agreement to use AVD only +- Which flows are in-scope first (withdraw communal QR → shop pay, …) + +### Suggested freigabe prompt (for an agent) + +See session notes: full copy-paste block under “Android-Automatisierung +vollständig einrichten” — install tooling, verify `adb devices` + emulator, +document paths; **no** real wallet payments until approved. + +--- + +## Related docs + +| Doc | Role | +|-----|------| +| [README.md](./README.md) | how to run `taler-monitoring.sh` | +| [TESTS.md](./TESTS.md) | grouped check IDs | +| [secrets.env.example](./secrets.env.example) | e2e secrets layout | +| `francpaysan-admin-log/TODO.md` | stage wallet mobile test backlog | + +When Android tooling is installed, append a short “installed on …” +section here (date, `adb version`, AVD name, `ANDROID_HOME`). diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index e627a35..c04508e 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -13,7 +13,8 @@ Report for the **GOA** stack with boxed severity badges and **grouped test IDs** IDs reset per **group** inside an area — so “too many www tests” become `www.exchange-*`, `www.bank-*`, `www.landing-*`, etc. -Catalog: **[TESTS.md](./TESTS.md)**. +Catalog: **[TESTS.md](./TESTS.md)**. +Android UI automation prerequisites: **[GIT-AUTOMATION-NOTES.md](./GIT-AUTOMATION-NOTES.md)** (tools **not** installed by default). ```text ┌ OK ┐ www.exchange-01 exchange /config · HTTP 200 diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index 7bd51c1..96e4512 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -53,7 +53,7 @@ Numbering follows **executed** checks (early skip may shift later NN inside the | **www.bank-** | `/config`, currency, alt_unit_names; integration/webui/intro; **auto-account.json** (required GOA/local; skip/soft off-GOA e.g. TESTPAYSAN); `/terms`, `/privacy` | | **www.merchant-** | `/config` currency + currencies alt_unit_names; listed exchanges alt; webui/intro; **`/terms`**, **`/privacy`** | | **www.paivana-** | local GOA paywall front (redirect to template) | -| **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; cross-links local | +| **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; stage merchant shop: `shop-ui.js` + `shops.css` + `qrcode.min.js`; cross-links local | | **www.qr-** | QR payloads: harvest `taler://` / `payto://` / app `data-qr-url` from landings + mint JSON; **form** check; **qrencode → zbarimg** exact roundtrip; optional static QR images. Needs `qrencode` + `zbar-tools`. Skip: `QR_CHECK=0` | **QR rule:** `taler://withdraw/HOST/taler-integration/UUID` (no default `:443`/`:80`); `taler://pay/` / `pay-template/`; `payto://…` shape OK; decoded PNG must equal payload. Form errors on withdraw/pay/payto are **ERROR** on local/GOA. diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 8b311ce..90a6f1b 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -21,13 +21,17 @@ # 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, MERCHANT_INSTANCE, … +# EXP_PW / EXP_PW_FILE / EXP_USER — explorer pool (default: secrets tree) +# CLI_JS / WALLET_CLI — wallet-cli .mjs (default: find_wallet_cli) +# LADDER_REPORT_DIR, MERCHANT_INSTANCE, … # # Phase: ./taler-monitoring.sh ladder set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" +# When invoked standalone (not via taler-monitoring.sh), still load secrets.env +load_monitoring_secrets_env 2>/dev/null || true # Area ladder.* — GOA withdraw/pay ladder # Groups: ladder.plan / ladder.load / ladder.withdraw / ladder.pay / ladder.report @@ -44,8 +48,10 @@ elapsed_ms() { : "${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}" +# EXP_PW_FILE / EXP_PW optional overrides; otherwise read_secret + SECRETS_ROOT +: "${EXP_PW_FILE:=}" +: "${EXP_PW:=}" +: "${CLI_JS:=}" : "${LADDER_MAX_AMOUNT:=4503599627370496}" : "${LADDER_STEPS:=23}" : "${LADDER_LOAD:=1}" @@ -58,6 +64,15 @@ elapsed_ms() { : "${LADDER_PAY_SETTLE_ROUNDS:=6}" : "${MERCHANT_INSTANCE:=goa-demo-cp4zqk}" +# Resolve wallet-cli .mjs (no hardcoded laptop path) +if [ -z "${CLI_JS}" ] || [ ! -f "${CLI_JS}" ]; then + CLI_JS=$(find_wallet_cli 2>/dev/null || true) +fi +if [ -z "${CLI_JS}" ] || [ ! -f "${CLI_JS}" ]; then + # allow PATH wrapper as last resort (wcli falls back to taler-wallet-cli) + CLI_JS="" +fi + CUR="${EXPECT_CURRENCY:-GOA}" BANK="${BANK_PUBLIC%/}" EX="${EXCHANGE_PUBLIC%/}/" @@ -82,13 +97,42 @@ ladder_over() { } wcli() { - if [ -f "$CLI_JS" ]; then + if [ -n "${CLI_JS:-}" ] && [ -f "$CLI_JS" ]; then node "$CLI_JS" --wallet-db="$WDB" --no-throttle "$@" else taler-wallet-cli --wallet-db="$WDB" --no-throttle "$@" fi } +# Explorer pool password: env → file override → secrets tree → SSH koopa +resolve_explorer_pw() { + local pw="" f="" + if [ -n "${EXP_PW:-}" ]; then + printf '%s' "$EXP_PW" + return 0 + fi + if [ -n "${EXP_PW_FILE:-}" ] && [ -f "$EXP_PW_FILE" ]; then + tr -d '\n\r' <"$EXP_PW_FILE" + return 0 + fi + # Prefer shared read_secret (SECRETS_ROOT / ~/.config / koopa SSH) + if pw=$(read_secret "taler-bank/bank-explorer-password.txt" 2>/dev/null) && [ -n "$pw" ]; then + printf '%s' "$pw" + return 0 + fi + # Extra local candidates (sibling of admin-log, taler-landing config) + for f in \ + "${SECRETS_ROOT:+${SECRETS_ROOT}/taler-bank/bank-explorer-password.txt}" \ + "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt" \ + "${HOME}/.config/taler-landing/bank-explorer-password.txt" + do + [ -n "$f" ] && [ -f "$f" ] || continue + tr -d '\n\r' <"$f" + return 0 + done + return 1 +} + wallet_avail() { wcli balance 2>/dev/null | python3 -c ' import json,sys @@ -270,11 +314,18 @@ 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" +if ! EXP_PW=$(resolve_explorer_pw); then + err bank "explorer password missing" \ + "set EXP_PW / EXP_PW_FILE or SECRETS_ROOT=…/koopa/host-root (taler-bank/bank-explorer-password.txt)${SECRETS_ROOT:+ · SECRETS_ROOT=${SECRETS_ROOT}}" + secrets_hint 2>/dev/null || true exit 1 fi -EXP_PW=$(tr -d '\n' <"$EXP_PW_FILE") +info "explorer secret" "${SECRETS_ROOT:+SECRETS_ROOT=${SECRETS_ROOT}}${SECRETS_ROOT:-via EXP_PW/EXP_PW_FILE/SSH}" +if [ -n "${CLI_JS:-}" ] && [ -f "${CLI_JS}" ]; then + info "wallet-cli" "$CLI_JS" +else + info "wallet-cli" "PATH taler-wallet-cli ($(command -v taler-wallet-cli 2>/dev/null || echo missing))" +fi # --- auto-account --- t0=$(now_ms) diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index 387d4e2..7191609 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -1236,7 +1236,8 @@ if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then fi fi -# Merchant shop assets — GOA: shop-pay.*; stage TESTPAYSAN: /assets/shop-ui.js + shops.css +# Merchant shop assets — GOA: shop-pay.*; stage TESTPAYSAN: /assets/shop-ui.js + +# shops.css + QR encoder (shop-ui modal needs /intro/qrcode.min.js or /qrcode.min.js). _ma=0 _ma_need=2 _ma_label="shop-pay.js + .css" @@ -1244,9 +1245,16 @@ if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then _landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.js" && _ma=$((_ma + 1)) _landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.css" && _ma=$((_ma + 1)) else - _ma_label="shop-ui.js + shops.css" + # stage farmer shops: UI + stylesheet + qrcode (pay modal QR_Taler) + _ma_need=3 + _ma_label="shop-ui.js + shops.css + qrcode.min.js" _landing_probe "$MERCHANT_PUBLIC/assets/shop-ui.js" && _ma=$((_ma + 1)) _landing_probe "$MERCHANT_PUBLIC/assets/shops.css" && _ma=$((_ma + 1)) + # Prefer /intro/ (always on landings tree); root alias is Caddy-only convenience + if _landing_probe "$MERCHANT_PUBLIC/intro/qrcode.min.js" \ + || _landing_probe "$MERCHANT_PUBLIC/qrcode.min.js"; then + _ma=$((_ma + 1)) + fi fi if [ "$_ma" -eq "$_ma_need" ]; then ok "landing merchant shop assets" "${_ma_label}" @@ -1254,9 +1262,11 @@ else if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then warn "landing merchant shop assets" "${_ma}/${_ma_need} present (soft) · want ${_ma_label}" else - # stage: soft INFO if missing; WARN only if zero - if [ "$_ma" -eq 0 ]; then - warn "landing merchant shop assets" "0/${_ma_need} · want ${_ma_label}" + # stage: hard-ish — missing qrcode broke pay QR («bibliothèque manquante») + if [ "$_ma" -lt 2 ]; then + fail "landing merchant shop assets" "${_ma}/${_ma_need} · want ${_ma_label}" + elif [ "$_ma" -lt "$_ma_need" ]; then + warn "landing merchant shop assets" "${_ma}/${_ma_need} · want ${_ma_label}" else info "landing merchant shop assets" "${_ma}/${_ma_need} · ${_ma_label}" fi diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 7bde111..a113212 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -1115,8 +1115,11 @@ find_wallet_cli() { local c cand for c in \ "${HOME}/.local/bin/taler-wallet-cli.mjs" \ + "${HOME}/taler/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs" \ + "${HOME}/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs" \ "${HOME}/taler/opt/taler-wallet-cli/usr/lib/taler-wallet-cli/node_modules/taler-wallet-cli/bin/taler-wallet-cli.mjs" \ /home/hernani/taler/opt/taler-wallet-cli/usr/lib/taler-wallet-cli/node_modules/taler-wallet-cli/bin/taler-wallet-cli.mjs \ + /home/hernani/taler/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs \ /Users/newkamek/src/taler/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs \ /usr/lib/taler-wallet-cli/node_modules/taler-wallet-cli/bin/taler-wallet-cli.mjs \ /usr/share/taler-wallet-cli/bin/taler-wallet-cli.mjs diff --git a/scripts/taler-monitoring/secrets.env.example b/scripts/taler-monitoring/secrets.env.example index 064b14d..212a7f2 100644 --- a/scripts/taler-monitoring/secrets.env.example +++ b/scripts/taler-monitoring/secrets.env.example @@ -43,6 +43,14 @@ # PROGRESS_SHOW_EVERY=8 # PROGRESS_OFF=1 +# --- Ladder (GOA withdraw ladder · explorer pool) --- +# Prefer SECRETS_ROOT / KOOPA_ADMIN_SECRETS (bank-explorer-password.txt). +# Overrides only if needed: +# EXP_USER=explorer +# EXP_PW= +# EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt +# CLI_JS=$HOME/taler/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs + # Values that typically appear on a full stack (for orientation only): # bank-admin-password.txt # bank-explorer-password.txt From c711f14a92055b870515bc6e58375686007b1b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 20:02:34 +0200 Subject: [PATCH 233/326] monitoring: stage TESTPAYSAN amount ladder (bank max_wire maxima) Reuse check_goa_ladder for stage.*lefrancpaysan.ch: auto max from max_wire_transfer_amount (2000), min denom from /keys, explorer secret via stagepaysan SSH, fewer default steps. Document usage in README. --- scripts/taler-monitoring/README.md | 8 +- scripts/taler-monitoring/TESTS.md | 2 +- scripts/taler-monitoring/check_goa_ladder.sh | 214 +++++++++++++++---- scripts/taler-monitoring/taler-monitoring.sh | 2 +- 4 files changed, 187 insertions(+), 39 deletions(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index c04508e..35dba91 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -198,7 +198,13 @@ Other domains: never SSH. Optional **e2e** aborts cleanly on login/KYC. ```bash ./taler-monitoring.sh e2e ./taler-monitoring.sh -d taler.net urls e2e -# customize: +# Amount ladder (withdraw 0 → … → max, then pay): same script for GOA + stage +./taler-monitoring.sh ladder # GOA / current domain +./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder # TESTPAYSAN maxima +# Stage auto-reads bank max_wire_transfer_amount (e.g. 2000) and min denom (0.01). +# Explorer password: francpaysan-stage-user …/bank-explorer-password.txt (or EXP_PW=). +# Override: LADDER_MAX_AMOUNT=100 LADDER_STEPS=7 LADDER_PAY=0 … +# customize e2e: E2E_WITHDRAW_VALUES="20 50 100" E2E_PAY_VALUES="0.05 1 5" ./taler-monitoring.sh e2e E2E_VARIABLE=0 WITHDRAW_AMT=GOA:50 PAY_AMT=GOA:1 ./taler-monitoring.sh e2e # single fixed # GOA shop catalog (local hacktivism): full list in E2E_SHOP_PRODUCTS; each run diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index 96e4512..4ad45e9 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -114,7 +114,7 @@ When filing an issue, quote the full id + label, e.g. | **versions.inside-** | packages in containers | | **versions.compare-** | installed vs suite | | **sanity.bank-** / **.exchange-** / **.merchant-** | public + optional server-side per component | -| **ladder.plan-** / **.load-** / **.withdraw-** / **.pay-** / **.report-** | GOA amount ladder | +| **ladder.plan-** / **.load-** / **.withdraw-** / **.pay-** / **.report-** | amount ladder (GOA ceiling or stage TESTPAYSAN `max_wire`) | | **server-** | SSH host ports / processes (flat unless grouped later) | --- diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 90a6f1b..d7c60ae 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -1,31 +1,27 @@ #!/usr/bin/env bash -# check_goa_ladder.sh — GOA withdraw ladder for taler-monitoring +# check_goa_ladder.sh — withdraw/pay amount ladder (GOA + stage TESTPAYSAN) # -# 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 only (no run-until-done — hangs / developer ban) -# 4) bank confirm when selected; settle = poll balance + bank transfer_done +# Flow (bank landings): +# 1) GET /intro/auto-account.json → personal *account-* (balance 0) +# 2) Mint pool withdrawals as explorer + confirm when selected +# 3) wallet-cli accept-uri only (no run-until-done) +# 4) bank confirm when selected; settle = balance + transfer_done # -# 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 / absolute max (CEILING_REJECT) → WARN and continue. +# Amounts: random strictly increasing; pins 0, max-1, max. +# Phase A: withdraw ladder (cumulative wallet). Phase B: pay ladder. # -# Env: -# 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 / EXP_PW_FILE / EXP_USER — explorer pool (default: secrets tree) -# CLI_JS / WALLET_CLI — wallet-cli .mjs (default: find_wallet_cli) -# LADDER_REPORT_DIR, MERCHANT_INSTANCE, … +# Stacks: +# GOA — LADDER_MAX_AMOUNT = libeufin ceiling; explorer from koopa-admin-secrets +# TESTPAYSAN stage — auto max_wire from bank /config, min denom from /keys; +# explorer from stagepaysan secrets / SSH # -# Phase: ./taler-monitoring.sh ladder +# Usage: +# ./taler-monitoring.sh ladder +# ./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder +# LADDER_MAX_AMOUNT=100 LADDER_STEPS=7 ./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder +# +# Env (see body): LADDER_STEPS, LADDER_MAX_AMOUNT, LADDER_MIN_AMOUNT, LADDER_STACK_AUTO, +# LADDER_PAY, EXP_PW / EXP_PW_FILE, CLI_JS, MERCHANT_INSTANCE, … set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh @@ -33,7 +29,7 @@ source "$ROOT/lib.sh" # When invoked standalone (not via taler-monitoring.sh), still load secrets.env load_monitoring_secrets_env 2>/dev/null || true -# Area ladder.* — GOA withdraw/pay ladder +# Area ladder.* — withdraw/pay amount ladder (GOA + TESTPAYSAN) # Groups: ladder.plan / ladder.load / ladder.withdraw / ladder.pay / ladder.report set_area ladder set_group plan @@ -48,14 +44,16 @@ elapsed_ms() { : "${LADDER_SETTLE_ROUNDS:=18}" : "${LADDER_SETTLE_SLEEP:=2}" : "${EXP_USER:=explorer}" -# EXP_PW_FILE / EXP_PW optional overrides; otherwise read_secret + SECRETS_ROOT +# EXP_PW_FILE / EXP_PW optional overrides; otherwise read_secret + SECRETS_ROOT / stage SSH : "${EXP_PW_FILE:=}" : "${EXP_PW:=}" : "${CLI_JS:=}" +# GOA libeufin amount ceiling (hacktivism). Stage auto-replaces via bank /config. : "${LADDER_MAX_AMOUNT:=4503599627370496}" : "${LADDER_STEPS:=23}" : "${LADDER_LOAD:=1}" : "${LADDER_PAY:=1}" +: "${LADDER_STACK_AUTO:=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) @@ -63,6 +61,11 @@ elapsed_ms() { : "${LADDER_CONFIRM_POLLS:=40}" : "${LADDER_PAY_SETTLE_ROUNDS:=6}" : "${MERCHANT_INSTANCE:=goa-demo-cp4zqk}" +# Public variable template for stage pays (optional; fixed templates used when amount maps) +: "${LADDER_PAY_TEMPLATE:=}" +: "${LADDER_PAY_TEMPLATE_INSTANCE:=fermes-des-collines}" + +GOA_LADDER_CEILING="4503599627370496" # Resolve wallet-cli .mjs (no hardcoded laptop path) if [ -z "${CLI_JS}" ] || [ ! -f "${CLI_JS}" ]; then @@ -78,6 +81,77 @@ BANK="${BANK_PUBLIC%/}" EX="${EXCHANGE_PUBLIC%/}/" MER="${MERCHANT_PUBLIC%/}" INST="${MERCHANT_INSTANCE}" + +# --- Stack-specific ladder maxima (TESTPAYSAN stage) --- +# Uses bank max_wire_transfer_amount and exchange min denom when still on GOA defaults. +apply_ladder_stack_defaults() { + [ "${LADDER_STACK_AUTO}" = "1" ] || return 0 + if [ "${CUR}" != "TESTPAYSAN" ]; then + case "${TALER_DOMAIN:-}" in + stage.lefrancpaysan.ch|stage.bank.lefrancpaysan.ch|stage.exchange.lefrancpaysan.ch|stage.monnaie.lefrancpaysan.ch) + CUR="TESTPAYSAN" + ;; + *) return 0 ;; + esac + fi + + local bank_cfg max_wire min_denom + bank_cfg=$(curl -sS -m 12 "${BANK}/config" 2>/dev/null || true) + max_wire=$(printf '%s' "$bank_cfg" | python3 -c ' +import json,sys +try: + d=json.load(sys.stdin) +except Exception: + print("") + raise SystemExit(0) +a=d.get("max_wire_transfer_amount") or "" +print(a.split(":",1)[-1] if a else "") +' 2>/dev/null || true) + + min_denom=$(curl -sS -m 25 -H 'Accept: application/json' "${EX%/}/keys" 2>/dev/null | python3 -c ' +import json,sys +try: + d=json.load(sys.stdin) +except Exception: + print("") + raise SystemExit(0) +den=d.get("denoms") or d.get("denominations") or [] +vals=[] +for x in den if isinstance(den,list) else []: + v=x.get("value") or x.get("amount") or "" + if not v: continue + try: vals.append(float(str(v).split(":")[-1])) + except Exception: pass +print(min(vals) if vals else "") +' 2>/dev/null || true) + + # Fallback maxima if public config unreachable + [ -n "$max_wire" ] || max_wire="2000" + [ -n "$min_denom" ] || min_denom="0.01" + + # Only rewrite when caller left GOA defaults (explicit LADDER_MAX_AMOUNT=… kept) + if [ -n "$max_wire" ] && { [ "${LADDER_MAX_AMOUNT}" = "${GOA_LADDER_CEILING}" ] || [ "${LADDER_MAX_AMOUNT}" = "4503599627370496" ]; }; then + LADDER_MAX_AMOUNT="$max_wire" + fi + if [ -n "$min_denom" ] && { [ "${LADDER_MIN_AMOUNT}" = "0.000001" ] || [ "${LADDER_MIN_AMOUNT}" = "0.00000001" ]; }; then + LADDER_MIN_AMOUNT="$min_denom" + fi + # Slightly fewer rungs on stage (full GOA 23 still ok if user set LADDER_STEPS) + if [ "${LADDER_STEPS}" = "23" ]; then + LADDER_STEPS=15 + fi + # Stage farmer shops: private goa-demo instance does not exist + if [ "${MERCHANT_INSTANCE}" = "goa-demo-cp4zqk" ]; then + MERCHANT_INSTANCE="${LADDER_PAY_TEMPLATE_INSTANCE:-fermes-des-collines}" + INST="$MERCHANT_INSTANCE" + fi + # Prefer public templates for pays when no merchant token (set later) + : "${E2E_USE_TEMPLATES:=1}" + export E2E_USE_TEMPLATES + export LADDER_MAX_AMOUNT LADDER_MIN_AMOUNT LADDER_STEPS MERCHANT_INSTANCE + info "ladder stack" "TESTPAYSAN · max=${CUR}:${LADDER_MAX_AMOUNT} min=${CUR}:${LADDER_MIN_AMOUNT} steps=${LADDER_STEPS} (from bank max_wire / keys denoms)" +} +apply_ladder_stack_defaults SCRATCH=$(mktemp -d) WDB="$SCRATCH/wallet.sqlite3" REPORT_DIR="${LADDER_REPORT_DIR:-$SCRATCH}" @@ -104,9 +178,9 @@ wcli() { fi } -# Explorer pool password: env → file override → secrets tree → SSH koopa +# Explorer pool password: env → file override → stack secrets → SSH resolve_explorer_pw() { - local pw="" f="" + local pw="" f="" host="" if [ -n "${EXP_PW:-}" ]; then printf '%s' "$EXP_PW" return 0 @@ -115,12 +189,45 @@ resolve_explorer_pw() { tr -d '\n\r' <"$EXP_PW_FILE" return 0 fi - # Prefer shared read_secret (SECRETS_ROOT / ~/.config / koopa SSH) + + # Stage TESTPAYSAN — never use GOA koopa-admin-secrets explorer pw first + if [ "${CUR}" = "TESTPAYSAN" ] \ + || [[ "${TALER_DOMAIN:-}" == stage.*lefrancpaysan* ]] \ + || [[ "${TALER_DOMAIN:-}" == *stage.lefrancpaysan* ]]; then + for f in \ + "${FRANCPAYSAN_SECRETS:-}/stage/bank-explorer-password.txt" \ + "${HOME}/francpaysan-secrets/stage/bank-explorer-password.txt" \ + "${HOME}/src/francpaysan-secrets/stage/bank-explorer-password.txt" \ + "${HOME}/taler/src/francpaysan-secrets/stage/bank-explorer-password.txt" \ + "${HOME}/.config/taler-landing/stage-bank-explorer-password.txt" + do + [ -n "$f" ] && [ -f "$f" ] || continue + tr -d '\n\r' <"$f" + return 0 + done + for host in \ + "${INSIDE_SSH:-}" \ + francpaysan-stage-user \ + francpaysan-host + do + [ -n "$host" ] || continue + pw=$(ssh -o BatchMode=yes -o ConnectTimeout=12 "$host" \ + 'tr -d "\n\r" /dev/null \ + || sudo tr -d "\n\r" /dev/null' \ + 2>/dev/null || true) + if [ -n "$pw" ]; then + printf '%s' "$pw" + return 0 + fi + done + return 1 + fi + + # GOA / default: SECRETS_ROOT / ~/.config / koopa SSH if pw=$(read_secret "taler-bank/bank-explorer-password.txt" 2>/dev/null) && [ -n "$pw" ]; then printf '%s' "$pw" return 0 fi - # Extra local candidates (sibling of admin-log, taler-landing config) for f in \ "${SECRETS_ROOT:+${SECRETS_ROOT}/taler-bank/bank-explorer-password.txt}" \ "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt" \ @@ -292,7 +399,7 @@ if [ "${LADDER_LOAD:-1}" = "0" ]; then export METRICS_LOAD fi -section "ladder · GOA withdraw (0 → random → max · ${LADDER_STEPS} steps)" +section "ladder · ${CUR} withdraw (0 → random → max · ${LADDER_STEPS} steps)" info "bank" "$BANK" info "exchange" "$EX" info "currency" "$CUR" @@ -303,10 +410,11 @@ else warn "alt_unit_names" "using built-in SI fallback ($ALT_UNITS_FILE)" fi info "budget" "${LADDER_TIMEOUT_S}s" -_max_m1=$(python3 -c 'import sys; print(int(sys.argv[1])-1)' "${LADDER_MAX_AMOUNT}" 2>/dev/null || echo "${LADDER_MAX_AMOUNT}-1") +_max_m1=$(python3 -c 'import sys; from decimal import Decimal; m=Decimal(sys.argv[1]); print(m-1 if m>1 else m)' "${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)" +info "currency/domain" "${CUR} · ${TALER_DOMAIN:-?} · bank=${BANK}" set_group load section "ladder · load snapshot (before withdraws)" @@ -320,7 +428,11 @@ if ! EXP_PW=$(resolve_explorer_pw); then secrets_hint 2>/dev/null || true exit 1 fi -info "explorer secret" "${SECRETS_ROOT:+SECRETS_ROOT=${SECRETS_ROOT}}${SECRETS_ROOT:-via EXP_PW/EXP_PW_FILE/SSH}" +if [ -n "${SECRETS_ROOT:-}" ]; then + info "explorer secret" "resolved (SECRETS_ROOT=${SECRETS_ROOT} · stage uses stagepaysan secret first when CUR=TESTPAYSAN)" +else + info "explorer secret" "resolved via EXP_PW / EXP_PW_FILE / stage SSH / koopa" +fi if [ -n "${CLI_JS:-}" ] && [ -f "${CLI_JS}" ]; then info "wallet-cli" "$CLI_JS" else @@ -339,7 +451,7 @@ if ! python3 -c 'import json; d=json.load(open("'"$SCRATCH"'/auto-account.json") 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" +ok "auto-account ${ACCT_USER} (${ms_auto}ms) — personal ${CUR}:0; pool=explorer" info "auto-account password" "(see $SCRATCH/auto-account.json — not logged)" # --- explorer token --- @@ -841,7 +953,7 @@ if [ "${LADDER_PAY}" = "1" ] && [ -n "${PAY_LIST:-}" ] && [ "$FAIL_N_L" -eq 0 ]; set_group pay section "ladder · phase B · pay" metrics_report_coins "before-pay-ladder" || true - # Merchant secret (same as e2e) + # Merchant secret (same as e2e) — stage often has only public shop templates MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}" if [ -z "$MPW" ]; then MPW=$(read_secret "taler-merchant/merchant-${INST}-password.txt" 2>/dev/null || true) @@ -849,11 +961,41 @@ if [ "${LADDER_PAY}" = "1" ] && [ -n "${PAY_LIST:-}" ] && [ "$FAIL_N_L" -eq 0 ]; if [ -z "$MPW" ]; then MPW=$(read_secret "taler-merchant/merchant-goa-demo-cp4zqk-password.txt" 2>/dev/null || true) fi + if [ -z "$MPW" ] && [ "${CUR}" = "TESTPAYSAN" ]; then + for f in \ + "${FRANCPAYSAN_SECRETS:-}/stage/default-instance-token.txt" \ + "${HOME}/francpaysan-secrets/stage/default-instance-token.txt" \ + "${HOME}/src/francpaysan-secrets/stage/default-instance-token.txt" + do + [ -f "$f" ] || continue + MPW=$(tr -d '\n\r' <"$f") + break + done + if [ -z "$MPW" ]; then + for host in "${INSIDE_SSH:-}" francpaysan-stage-user francpaysan-host; do + [ -n "$host" ] || continue + MPW=$(ssh -o BatchMode=yes -o ConnectTimeout=12 "$host" \ + 'tr -d "\n\r" /dev/null || true' \ + 2>/dev/null || true) + [ -n "$MPW" ] && break + done + fi + fi if [ -z "$MPW" ]; then - warn pay "no merchant token — skip pay ladder (set E2E_MERCHANT_TOKEN or secrets)" + if [ "${CUR}" = "TESTPAYSAN" ]; then + # Stage: withdraw ladder is the main probe; pays need instance token or + # public templates for *exact* face values (not continuous ladder amounts). + info pay "no merchant token — skip pay ladder on TESTPAYSAN (withdraw maxima still covered)" + info pay "hint: set E2E_MERCHANT_TOKEN or use ./taler-monitoring.sh -d stage.lefrancpaysan.ch e2e for shop templates" + else + warn pay "no merchant token — skip pay ladder (set E2E_MERCHANT_TOKEN or secrets)" + fi else ok "merchant token" "instance ${INST}" - AUTH="Authorization: Bearer secret-token:${MPW}" + case "$MPW" in + secret-token:*) AUTH="Authorization: Bearer ${MPW}" ;; + *) AUTH="Authorization: Bearer secret-token:${MPW}" ;; + esac wallet_prepare "main" # shellcheck disable=SC2086 set -- $PAY_LIST diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index d8f6197..e63c922 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -26,7 +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) + ladder withdraw/pay amount ladder (GOA ceiling or stage TESTPAYSAN max_wire) all urls + inside + versions + sanity + e2e (SSH phases only on koopa) Options: From 12f34f9ea00c0848c676bf1090132d44629e7b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 20:02:34 +0200 Subject: [PATCH 234/326] monitoring: stage TESTPAYSAN amount ladder (bank max_wire maxima) Reuse check_goa_ladder for stage.*lefrancpaysan.ch: auto max from max_wire_transfer_amount (2000), min denom from /keys, explorer secret via stagepaysan SSH, fewer default steps. Document usage in README. --- scripts/taler-monitoring/README.md | 8 +- scripts/taler-monitoring/TESTS.md | 2 +- scripts/taler-monitoring/check_goa_ladder.sh | 214 +++++++++++++++---- scripts/taler-monitoring/taler-monitoring.sh | 2 +- 4 files changed, 187 insertions(+), 39 deletions(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index c04508e..35dba91 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -198,7 +198,13 @@ Other domains: never SSH. Optional **e2e** aborts cleanly on login/KYC. ```bash ./taler-monitoring.sh e2e ./taler-monitoring.sh -d taler.net urls e2e -# customize: +# Amount ladder (withdraw 0 → … → max, then pay): same script for GOA + stage +./taler-monitoring.sh ladder # GOA / current domain +./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder # TESTPAYSAN maxima +# Stage auto-reads bank max_wire_transfer_amount (e.g. 2000) and min denom (0.01). +# Explorer password: francpaysan-stage-user …/bank-explorer-password.txt (or EXP_PW=). +# Override: LADDER_MAX_AMOUNT=100 LADDER_STEPS=7 LADDER_PAY=0 … +# customize e2e: E2E_WITHDRAW_VALUES="20 50 100" E2E_PAY_VALUES="0.05 1 5" ./taler-monitoring.sh e2e E2E_VARIABLE=0 WITHDRAW_AMT=GOA:50 PAY_AMT=GOA:1 ./taler-monitoring.sh e2e # single fixed # GOA shop catalog (local hacktivism): full list in E2E_SHOP_PRODUCTS; each run diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index 96e4512..4ad45e9 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -114,7 +114,7 @@ When filing an issue, quote the full id + label, e.g. | **versions.inside-** | packages in containers | | **versions.compare-** | installed vs suite | | **sanity.bank-** / **.exchange-** / **.merchant-** | public + optional server-side per component | -| **ladder.plan-** / **.load-** / **.withdraw-** / **.pay-** / **.report-** | GOA amount ladder | +| **ladder.plan-** / **.load-** / **.withdraw-** / **.pay-** / **.report-** | amount ladder (GOA ceiling or stage TESTPAYSAN `max_wire`) | | **server-** | SSH host ports / processes (flat unless grouped later) | --- diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 90a6f1b..d7c60ae 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -1,31 +1,27 @@ #!/usr/bin/env bash -# check_goa_ladder.sh — GOA withdraw ladder for taler-monitoring +# check_goa_ladder.sh — withdraw/pay amount ladder (GOA + stage TESTPAYSAN) # -# 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 only (no run-until-done — hangs / developer ban) -# 4) bank confirm when selected; settle = poll balance + bank transfer_done +# Flow (bank landings): +# 1) GET /intro/auto-account.json → personal *account-* (balance 0) +# 2) Mint pool withdrawals as explorer + confirm when selected +# 3) wallet-cli accept-uri only (no run-until-done) +# 4) bank confirm when selected; settle = balance + transfer_done # -# 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 / absolute max (CEILING_REJECT) → WARN and continue. +# Amounts: random strictly increasing; pins 0, max-1, max. +# Phase A: withdraw ladder (cumulative wallet). Phase B: pay ladder. # -# Env: -# 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 / EXP_PW_FILE / EXP_USER — explorer pool (default: secrets tree) -# CLI_JS / WALLET_CLI — wallet-cli .mjs (default: find_wallet_cli) -# LADDER_REPORT_DIR, MERCHANT_INSTANCE, … +# Stacks: +# GOA — LADDER_MAX_AMOUNT = libeufin ceiling; explorer from koopa-admin-secrets +# TESTPAYSAN stage — auto max_wire from bank /config, min denom from /keys; +# explorer from stagepaysan secrets / SSH # -# Phase: ./taler-monitoring.sh ladder +# Usage: +# ./taler-monitoring.sh ladder +# ./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder +# LADDER_MAX_AMOUNT=100 LADDER_STEPS=7 ./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder +# +# Env (see body): LADDER_STEPS, LADDER_MAX_AMOUNT, LADDER_MIN_AMOUNT, LADDER_STACK_AUTO, +# LADDER_PAY, EXP_PW / EXP_PW_FILE, CLI_JS, MERCHANT_INSTANCE, … set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh @@ -33,7 +29,7 @@ source "$ROOT/lib.sh" # When invoked standalone (not via taler-monitoring.sh), still load secrets.env load_monitoring_secrets_env 2>/dev/null || true -# Area ladder.* — GOA withdraw/pay ladder +# Area ladder.* — withdraw/pay amount ladder (GOA + TESTPAYSAN) # Groups: ladder.plan / ladder.load / ladder.withdraw / ladder.pay / ladder.report set_area ladder set_group plan @@ -48,14 +44,16 @@ elapsed_ms() { : "${LADDER_SETTLE_ROUNDS:=18}" : "${LADDER_SETTLE_SLEEP:=2}" : "${EXP_USER:=explorer}" -# EXP_PW_FILE / EXP_PW optional overrides; otherwise read_secret + SECRETS_ROOT +# EXP_PW_FILE / EXP_PW optional overrides; otherwise read_secret + SECRETS_ROOT / stage SSH : "${EXP_PW_FILE:=}" : "${EXP_PW:=}" : "${CLI_JS:=}" +# GOA libeufin amount ceiling (hacktivism). Stage auto-replaces via bank /config. : "${LADDER_MAX_AMOUNT:=4503599627370496}" : "${LADDER_STEPS:=23}" : "${LADDER_LOAD:=1}" : "${LADDER_PAY:=1}" +: "${LADDER_STACK_AUTO:=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) @@ -63,6 +61,11 @@ elapsed_ms() { : "${LADDER_CONFIRM_POLLS:=40}" : "${LADDER_PAY_SETTLE_ROUNDS:=6}" : "${MERCHANT_INSTANCE:=goa-demo-cp4zqk}" +# Public variable template for stage pays (optional; fixed templates used when amount maps) +: "${LADDER_PAY_TEMPLATE:=}" +: "${LADDER_PAY_TEMPLATE_INSTANCE:=fermes-des-collines}" + +GOA_LADDER_CEILING="4503599627370496" # Resolve wallet-cli .mjs (no hardcoded laptop path) if [ -z "${CLI_JS}" ] || [ ! -f "${CLI_JS}" ]; then @@ -78,6 +81,77 @@ BANK="${BANK_PUBLIC%/}" EX="${EXCHANGE_PUBLIC%/}/" MER="${MERCHANT_PUBLIC%/}" INST="${MERCHANT_INSTANCE}" + +# --- Stack-specific ladder maxima (TESTPAYSAN stage) --- +# Uses bank max_wire_transfer_amount and exchange min denom when still on GOA defaults. +apply_ladder_stack_defaults() { + [ "${LADDER_STACK_AUTO}" = "1" ] || return 0 + if [ "${CUR}" != "TESTPAYSAN" ]; then + case "${TALER_DOMAIN:-}" in + stage.lefrancpaysan.ch|stage.bank.lefrancpaysan.ch|stage.exchange.lefrancpaysan.ch|stage.monnaie.lefrancpaysan.ch) + CUR="TESTPAYSAN" + ;; + *) return 0 ;; + esac + fi + + local bank_cfg max_wire min_denom + bank_cfg=$(curl -sS -m 12 "${BANK}/config" 2>/dev/null || true) + max_wire=$(printf '%s' "$bank_cfg" | python3 -c ' +import json,sys +try: + d=json.load(sys.stdin) +except Exception: + print("") + raise SystemExit(0) +a=d.get("max_wire_transfer_amount") or "" +print(a.split(":",1)[-1] if a else "") +' 2>/dev/null || true) + + min_denom=$(curl -sS -m 25 -H 'Accept: application/json' "${EX%/}/keys" 2>/dev/null | python3 -c ' +import json,sys +try: + d=json.load(sys.stdin) +except Exception: + print("") + raise SystemExit(0) +den=d.get("denoms") or d.get("denominations") or [] +vals=[] +for x in den if isinstance(den,list) else []: + v=x.get("value") or x.get("amount") or "" + if not v: continue + try: vals.append(float(str(v).split(":")[-1])) + except Exception: pass +print(min(vals) if vals else "") +' 2>/dev/null || true) + + # Fallback maxima if public config unreachable + [ -n "$max_wire" ] || max_wire="2000" + [ -n "$min_denom" ] || min_denom="0.01" + + # Only rewrite when caller left GOA defaults (explicit LADDER_MAX_AMOUNT=… kept) + if [ -n "$max_wire" ] && { [ "${LADDER_MAX_AMOUNT}" = "${GOA_LADDER_CEILING}" ] || [ "${LADDER_MAX_AMOUNT}" = "4503599627370496" ]; }; then + LADDER_MAX_AMOUNT="$max_wire" + fi + if [ -n "$min_denom" ] && { [ "${LADDER_MIN_AMOUNT}" = "0.000001" ] || [ "${LADDER_MIN_AMOUNT}" = "0.00000001" ]; }; then + LADDER_MIN_AMOUNT="$min_denom" + fi + # Slightly fewer rungs on stage (full GOA 23 still ok if user set LADDER_STEPS) + if [ "${LADDER_STEPS}" = "23" ]; then + LADDER_STEPS=15 + fi + # Stage farmer shops: private goa-demo instance does not exist + if [ "${MERCHANT_INSTANCE}" = "goa-demo-cp4zqk" ]; then + MERCHANT_INSTANCE="${LADDER_PAY_TEMPLATE_INSTANCE:-fermes-des-collines}" + INST="$MERCHANT_INSTANCE" + fi + # Prefer public templates for pays when no merchant token (set later) + : "${E2E_USE_TEMPLATES:=1}" + export E2E_USE_TEMPLATES + export LADDER_MAX_AMOUNT LADDER_MIN_AMOUNT LADDER_STEPS MERCHANT_INSTANCE + info "ladder stack" "TESTPAYSAN · max=${CUR}:${LADDER_MAX_AMOUNT} min=${CUR}:${LADDER_MIN_AMOUNT} steps=${LADDER_STEPS} (from bank max_wire / keys denoms)" +} +apply_ladder_stack_defaults SCRATCH=$(mktemp -d) WDB="$SCRATCH/wallet.sqlite3" REPORT_DIR="${LADDER_REPORT_DIR:-$SCRATCH}" @@ -104,9 +178,9 @@ wcli() { fi } -# Explorer pool password: env → file override → secrets tree → SSH koopa +# Explorer pool password: env → file override → stack secrets → SSH resolve_explorer_pw() { - local pw="" f="" + local pw="" f="" host="" if [ -n "${EXP_PW:-}" ]; then printf '%s' "$EXP_PW" return 0 @@ -115,12 +189,45 @@ resolve_explorer_pw() { tr -d '\n\r' <"$EXP_PW_FILE" return 0 fi - # Prefer shared read_secret (SECRETS_ROOT / ~/.config / koopa SSH) + + # Stage TESTPAYSAN — never use GOA koopa-admin-secrets explorer pw first + if [ "${CUR}" = "TESTPAYSAN" ] \ + || [[ "${TALER_DOMAIN:-}" == stage.*lefrancpaysan* ]] \ + || [[ "${TALER_DOMAIN:-}" == *stage.lefrancpaysan* ]]; then + for f in \ + "${FRANCPAYSAN_SECRETS:-}/stage/bank-explorer-password.txt" \ + "${HOME}/francpaysan-secrets/stage/bank-explorer-password.txt" \ + "${HOME}/src/francpaysan-secrets/stage/bank-explorer-password.txt" \ + "${HOME}/taler/src/francpaysan-secrets/stage/bank-explorer-password.txt" \ + "${HOME}/.config/taler-landing/stage-bank-explorer-password.txt" + do + [ -n "$f" ] && [ -f "$f" ] || continue + tr -d '\n\r' <"$f" + return 0 + done + for host in \ + "${INSIDE_SSH:-}" \ + francpaysan-stage-user \ + francpaysan-host + do + [ -n "$host" ] || continue + pw=$(ssh -o BatchMode=yes -o ConnectTimeout=12 "$host" \ + 'tr -d "\n\r" /dev/null \ + || sudo tr -d "\n\r" /dev/null' \ + 2>/dev/null || true) + if [ -n "$pw" ]; then + printf '%s' "$pw" + return 0 + fi + done + return 1 + fi + + # GOA / default: SECRETS_ROOT / ~/.config / koopa SSH if pw=$(read_secret "taler-bank/bank-explorer-password.txt" 2>/dev/null) && [ -n "$pw" ]; then printf '%s' "$pw" return 0 fi - # Extra local candidates (sibling of admin-log, taler-landing config) for f in \ "${SECRETS_ROOT:+${SECRETS_ROOT}/taler-bank/bank-explorer-password.txt}" \ "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt" \ @@ -292,7 +399,7 @@ if [ "${LADDER_LOAD:-1}" = "0" ]; then export METRICS_LOAD fi -section "ladder · GOA withdraw (0 → random → max · ${LADDER_STEPS} steps)" +section "ladder · ${CUR} withdraw (0 → random → max · ${LADDER_STEPS} steps)" info "bank" "$BANK" info "exchange" "$EX" info "currency" "$CUR" @@ -303,10 +410,11 @@ else warn "alt_unit_names" "using built-in SI fallback ($ALT_UNITS_FILE)" fi info "budget" "${LADDER_TIMEOUT_S}s" -_max_m1=$(python3 -c 'import sys; print(int(sys.argv[1])-1)' "${LADDER_MAX_AMOUNT}" 2>/dev/null || echo "${LADDER_MAX_AMOUNT}-1") +_max_m1=$(python3 -c 'import sys; from decimal import Decimal; m=Decimal(sys.argv[1]); print(m-1 if m>1 else m)' "${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)" +info "currency/domain" "${CUR} · ${TALER_DOMAIN:-?} · bank=${BANK}" set_group load section "ladder · load snapshot (before withdraws)" @@ -320,7 +428,11 @@ if ! EXP_PW=$(resolve_explorer_pw); then secrets_hint 2>/dev/null || true exit 1 fi -info "explorer secret" "${SECRETS_ROOT:+SECRETS_ROOT=${SECRETS_ROOT}}${SECRETS_ROOT:-via EXP_PW/EXP_PW_FILE/SSH}" +if [ -n "${SECRETS_ROOT:-}" ]; then + info "explorer secret" "resolved (SECRETS_ROOT=${SECRETS_ROOT} · stage uses stagepaysan secret first when CUR=TESTPAYSAN)" +else + info "explorer secret" "resolved via EXP_PW / EXP_PW_FILE / stage SSH / koopa" +fi if [ -n "${CLI_JS:-}" ] && [ -f "${CLI_JS}" ]; then info "wallet-cli" "$CLI_JS" else @@ -339,7 +451,7 @@ if ! python3 -c 'import json; d=json.load(open("'"$SCRATCH"'/auto-account.json") 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" +ok "auto-account ${ACCT_USER} (${ms_auto}ms) — personal ${CUR}:0; pool=explorer" info "auto-account password" "(see $SCRATCH/auto-account.json — not logged)" # --- explorer token --- @@ -841,7 +953,7 @@ if [ "${LADDER_PAY}" = "1" ] && [ -n "${PAY_LIST:-}" ] && [ "$FAIL_N_L" -eq 0 ]; set_group pay section "ladder · phase B · pay" metrics_report_coins "before-pay-ladder" || true - # Merchant secret (same as e2e) + # Merchant secret (same as e2e) — stage often has only public shop templates MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}" if [ -z "$MPW" ]; then MPW=$(read_secret "taler-merchant/merchant-${INST}-password.txt" 2>/dev/null || true) @@ -849,11 +961,41 @@ if [ "${LADDER_PAY}" = "1" ] && [ -n "${PAY_LIST:-}" ] && [ "$FAIL_N_L" -eq 0 ]; if [ -z "$MPW" ]; then MPW=$(read_secret "taler-merchant/merchant-goa-demo-cp4zqk-password.txt" 2>/dev/null || true) fi + if [ -z "$MPW" ] && [ "${CUR}" = "TESTPAYSAN" ]; then + for f in \ + "${FRANCPAYSAN_SECRETS:-}/stage/default-instance-token.txt" \ + "${HOME}/francpaysan-secrets/stage/default-instance-token.txt" \ + "${HOME}/src/francpaysan-secrets/stage/default-instance-token.txt" + do + [ -f "$f" ] || continue + MPW=$(tr -d '\n\r' <"$f") + break + done + if [ -z "$MPW" ]; then + for host in "${INSIDE_SSH:-}" francpaysan-stage-user francpaysan-host; do + [ -n "$host" ] || continue + MPW=$(ssh -o BatchMode=yes -o ConnectTimeout=12 "$host" \ + 'tr -d "\n\r" /dev/null || true' \ + 2>/dev/null || true) + [ -n "$MPW" ] && break + done + fi + fi if [ -z "$MPW" ]; then - warn pay "no merchant token — skip pay ladder (set E2E_MERCHANT_TOKEN or secrets)" + if [ "${CUR}" = "TESTPAYSAN" ]; then + # Stage: withdraw ladder is the main probe; pays need instance token or + # public templates for *exact* face values (not continuous ladder amounts). + info pay "no merchant token — skip pay ladder on TESTPAYSAN (withdraw maxima still covered)" + info pay "hint: set E2E_MERCHANT_TOKEN or use ./taler-monitoring.sh -d stage.lefrancpaysan.ch e2e for shop templates" + else + warn pay "no merchant token — skip pay ladder (set E2E_MERCHANT_TOKEN or secrets)" + fi else ok "merchant token" "instance ${INST}" - AUTH="Authorization: Bearer secret-token:${MPW}" + case "$MPW" in + secret-token:*) AUTH="Authorization: Bearer ${MPW}" ;; + *) AUTH="Authorization: Bearer secret-token:${MPW}" ;; + esac wallet_prepare "main" # shellcheck disable=SC2086 set -- $PAY_LIST diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index d8f6197..e63c922 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -26,7 +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) + ladder withdraw/pay amount ladder (GOA ceiling or stage TESTPAYSAN max_wire) all urls + inside + versions + sanity + e2e (SSH phases only on koopa) Options: From 9a07eb1dfec093cf855d744731470c00cbed3e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 20:03:46 +0200 Subject: [PATCH 235/326] =?UTF-8?q?docs:=20git=20commit/push=20cadence=207?= =?UTF-8?q?=E2=80=9311=20min=20+=20helper=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match historical area: message style; optional cadence commit/push helper. --- scripts/git-cadence-commit-push.sh | 116 ++++++++++++++++++ .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 48 ++++++++ 2 files changed, 164 insertions(+) create mode 100755 scripts/git-cadence-commit-push.sh diff --git a/scripts/git-cadence-commit-push.sh b/scripts/git-cadence-commit-push.sh new file mode 100755 index 0000000..1276fc1 --- /dev/null +++ b/scripts/git-cadence-commit-push.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# git-cadence-commit-push.sh — optional WIP commit + push on the historical cadence +# +# Model (from koopa-admin-log / francpaysan-admin-log histories, 2026-07): +# - message: area: short description (monitoring: … / landings: … / docs: …) +# - size: small logical steps, not giant dumps +# - interval: about **7–11 minutes** while work is in progress (sometimes denser) +# - push: after each commit when the remote accepts credentials +# +# Usage: +# ./scripts/git-cadence-commit-push.sh # this repo +# ./scripts/git-cadence-commit-push.sh /path/to/repo +# CADENCE_MSG='monitoring: wip ladder' ./scripts/git-cadence-commit-push.sh +# CADENCE_DRY=1 ./scripts/git-cadence-commit-push.sh # show only +# +# Cron / agent every ~9 min: +# */9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh +# */9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh \ +# $HOME/taler/src/francpaysan-admin-log +# +set -euo pipefail + +REPO="${1:-}" +if [ -z "$REPO" ]; then + REPO="$(cd "$(dirname "$0")/.." && pwd)" +fi +REPO="$(cd "$REPO" && pwd)" +cd "$REPO" + +CADENCE_DRY="${CADENCE_DRY:-0}" +# Never auto-add secrets +SKIP_GLOBS=( + 'secrets.env' + '**/secrets.env' + '**/*password*' + '**/*secret*' + '**/*.pem' + '**/*token*' +) + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "not a git repo: $REPO" >&2 + exit 1 +fi + +branch=$(git rev-parse --abbrev-ref HEAD) +remote=$(git remote 2>/dev/null | head -1 || true) +: "${remote:=origin}" + +# Dirty? (tracked + untracked, ignoring unreadable) +if git diff --quiet && git diff --cached --quiet \ + && [ -z "$(git ls-files --others --exclude-standard)" ]; then + echo "clean · $REPO · $branch (nothing to commit)" + # still try push if ahead + ahead=$(git rev-list --count "${remote}/${branch}..HEAD" 2>/dev/null || echo 0) + if [ "${ahead:-0}" -gt 0 ]; then + echo "ahead of ${remote}/${branch} by $ahead — push" + if [ "$CADENCE_DRY" = "1" ]; then + echo "DRY: git push $remote $branch" + else + git push "$remote" "$branch" || echo "WARN: push failed (auth?)" >&2 + fi + fi + exit 0 +fi + +# Stage safe paths only +git add -A +for g in "${SKIP_GLOBS[@]}"; do + git reset -q -- "$g" 2>/dev/null || true +done +# unstage anything still matching secret names +while IFS= read -r f; do + case "$f" in + *secret*|*password*|*token*|secrets.env) + git reset -q -- "$f" 2>/dev/null || true + ;; + esac +done < <(git diff --cached --name-only 2>/dev/null || true) + +if git diff --cached --quiet; then + echo "only secret/skip paths dirty — not committing · $REPO" + exit 0 +fi + +# Message from paths (historical area: style) +msg="${CADENCE_MSG:-}" +if [ -z "$msg" ]; then + files=$(git diff --cached --name-only | head -20) + area="wip" + case "$files" in + *taler-monitoring*|*scripts/taler-monitoring*) area="monitoring" ;; + *landing*|*landings*|*configs/*landing*) area="landing" ;; + *docs*|*README*|*TODO*|*NOTES*) area="docs" ;; + *caddy*) area="caddy" ;; + *scripts*) area="scripts" ;; + esac + n=$(git diff --cached --name-only | wc -l | tr -d ' ') + sample=$(git diff --cached --name-only | head -1 | xargs -r basename) + msg="${area}: cadence ${n} file(s) · ${sample}" +fi + +if [ "$CADENCE_DRY" = "1" ]; then + echo "DRY commit: $msg" + git diff --cached --stat + echo "DRY push: $remote $branch" + exit 0 +fi + +git commit -m "$msg" +echo "committed · $msg" +if ! git push "$remote" "$branch"; then + echo "WARN: push failed for $REPO ($remote $branch) — commit is local" >&2 + exit 0 +fi +echo "pushed · $remote $branch" diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md index 3253701..fe6fd1c 100644 --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -5,6 +5,54 @@ TESTPAYSAN, …). CLI monitoring lives in this directory; **Android wallet UI automation is separate tooling** and is **not** installed on the default ops laptop yet. +--- + +## Git commit / push cadence (existing model) + +Observed in `koopa-admin-log` and `francpaysan-admin-log` histories +(active work on 2026-07-17): + +| Rule | Practice | +|------|----------| +| **Interval** | About **7–11 minutes** between publishable steps while a topic is open (bursts of 0–2 min are fine for tiny follow-ups) | +| **Message** | `area: short description` — e.g. `monitoring: …`, `landings: …`, `docs: …`, `scripts: …`, `todo: …` | +| **Size** | One logical change (or a tight bundle), not end-of-day megacommits | +| **Push** | After each commit when the remote accepts auth (`git push origin …`) | + +### Agent / human workflow + +1. Finish a small slice of work. +2. `git status` / diff → commit with the area-prefix style. +3. `git push` (or run the helper below). +4. Continue; next commit window ~**7–11 min** later (or sooner if the next slice is ready). + +### Helper script + +From the admin-log root: + +```bash +# dry-run +CADENCE_DRY=1 ./scripts/git-cadence-commit-push.sh + +# commit + push this repo if dirty (skips secrets.env / *password* / *token*) +./scripts/git-cadence-commit-push.sh + +# other checkout +./scripts/git-cadence-commit-push.sh ~/taler/src/francpaysan-admin-log +``` + +Optional timer (every 9 min ≈ mid of 7–11): + +```bash +# user crontab example +*/9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh >>$HOME/.cache/git-cadence-koopa.log 2>&1 +*/9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh $HOME/taler/src/francpaysan-admin-log >>$HOME/.cache/git-cadence-lfp.log 2>&1 +``` + +**Auth note (this host):** `francpaysan-admin-log` → `ssh://git@git.gnunet.org/…` works. +`koopa-admin-log` → `https://git.hacktivism.ch/…` needs credentials / SSH key for +`git.hacktivism.ch` (push may stay local until that is fixed). + ## What runs without Android | Phase | Needs | From cce1fcbe09076ee7c77f102d16004a8e3f725016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 20:03:46 +0200 Subject: [PATCH 236/326] =?UTF-8?q?docs:=20git=20commit/push=20cadence=207?= =?UTF-8?q?=E2=80=9311=20min=20+=20helper=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match historical area: message style; optional cadence commit/push helper. --- scripts/git-cadence-commit-push.sh | 116 ++++++++++++++++++ .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 48 ++++++++ 2 files changed, 164 insertions(+) create mode 100755 scripts/git-cadence-commit-push.sh diff --git a/scripts/git-cadence-commit-push.sh b/scripts/git-cadence-commit-push.sh new file mode 100755 index 0000000..1276fc1 --- /dev/null +++ b/scripts/git-cadence-commit-push.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# git-cadence-commit-push.sh — optional WIP commit + push on the historical cadence +# +# Model (from koopa-admin-log / francpaysan-admin-log histories, 2026-07): +# - message: area: short description (monitoring: … / landings: … / docs: …) +# - size: small logical steps, not giant dumps +# - interval: about **7–11 minutes** while work is in progress (sometimes denser) +# - push: after each commit when the remote accepts credentials +# +# Usage: +# ./scripts/git-cadence-commit-push.sh # this repo +# ./scripts/git-cadence-commit-push.sh /path/to/repo +# CADENCE_MSG='monitoring: wip ladder' ./scripts/git-cadence-commit-push.sh +# CADENCE_DRY=1 ./scripts/git-cadence-commit-push.sh # show only +# +# Cron / agent every ~9 min: +# */9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh +# */9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh \ +# $HOME/taler/src/francpaysan-admin-log +# +set -euo pipefail + +REPO="${1:-}" +if [ -z "$REPO" ]; then + REPO="$(cd "$(dirname "$0")/.." && pwd)" +fi +REPO="$(cd "$REPO" && pwd)" +cd "$REPO" + +CADENCE_DRY="${CADENCE_DRY:-0}" +# Never auto-add secrets +SKIP_GLOBS=( + 'secrets.env' + '**/secrets.env' + '**/*password*' + '**/*secret*' + '**/*.pem' + '**/*token*' +) + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "not a git repo: $REPO" >&2 + exit 1 +fi + +branch=$(git rev-parse --abbrev-ref HEAD) +remote=$(git remote 2>/dev/null | head -1 || true) +: "${remote:=origin}" + +# Dirty? (tracked + untracked, ignoring unreadable) +if git diff --quiet && git diff --cached --quiet \ + && [ -z "$(git ls-files --others --exclude-standard)" ]; then + echo "clean · $REPO · $branch (nothing to commit)" + # still try push if ahead + ahead=$(git rev-list --count "${remote}/${branch}..HEAD" 2>/dev/null || echo 0) + if [ "${ahead:-0}" -gt 0 ]; then + echo "ahead of ${remote}/${branch} by $ahead — push" + if [ "$CADENCE_DRY" = "1" ]; then + echo "DRY: git push $remote $branch" + else + git push "$remote" "$branch" || echo "WARN: push failed (auth?)" >&2 + fi + fi + exit 0 +fi + +# Stage safe paths only +git add -A +for g in "${SKIP_GLOBS[@]}"; do + git reset -q -- "$g" 2>/dev/null || true +done +# unstage anything still matching secret names +while IFS= read -r f; do + case "$f" in + *secret*|*password*|*token*|secrets.env) + git reset -q -- "$f" 2>/dev/null || true + ;; + esac +done < <(git diff --cached --name-only 2>/dev/null || true) + +if git diff --cached --quiet; then + echo "only secret/skip paths dirty — not committing · $REPO" + exit 0 +fi + +# Message from paths (historical area: style) +msg="${CADENCE_MSG:-}" +if [ -z "$msg" ]; then + files=$(git diff --cached --name-only | head -20) + area="wip" + case "$files" in + *taler-monitoring*|*scripts/taler-monitoring*) area="monitoring" ;; + *landing*|*landings*|*configs/*landing*) area="landing" ;; + *docs*|*README*|*TODO*|*NOTES*) area="docs" ;; + *caddy*) area="caddy" ;; + *scripts*) area="scripts" ;; + esac + n=$(git diff --cached --name-only | wc -l | tr -d ' ') + sample=$(git diff --cached --name-only | head -1 | xargs -r basename) + msg="${area}: cadence ${n} file(s) · ${sample}" +fi + +if [ "$CADENCE_DRY" = "1" ]; then + echo "DRY commit: $msg" + git diff --cached --stat + echo "DRY push: $remote $branch" + exit 0 +fi + +git commit -m "$msg" +echo "committed · $msg" +if ! git push "$remote" "$branch"; then + echo "WARN: push failed for $REPO ($remote $branch) — commit is local" >&2 + exit 0 +fi +echo "pushed · $remote $branch" diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md index 3253701..fe6fd1c 100644 --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -5,6 +5,54 @@ TESTPAYSAN, …). CLI monitoring lives in this directory; **Android wallet UI automation is separate tooling** and is **not** installed on the default ops laptop yet. +--- + +## Git commit / push cadence (existing model) + +Observed in `koopa-admin-log` and `francpaysan-admin-log` histories +(active work on 2026-07-17): + +| Rule | Practice | +|------|----------| +| **Interval** | About **7–11 minutes** between publishable steps while a topic is open (bursts of 0–2 min are fine for tiny follow-ups) | +| **Message** | `area: short description` — e.g. `monitoring: …`, `landings: …`, `docs: …`, `scripts: …`, `todo: …` | +| **Size** | One logical change (or a tight bundle), not end-of-day megacommits | +| **Push** | After each commit when the remote accepts auth (`git push origin …`) | + +### Agent / human workflow + +1. Finish a small slice of work. +2. `git status` / diff → commit with the area-prefix style. +3. `git push` (or run the helper below). +4. Continue; next commit window ~**7–11 min** later (or sooner if the next slice is ready). + +### Helper script + +From the admin-log root: + +```bash +# dry-run +CADENCE_DRY=1 ./scripts/git-cadence-commit-push.sh + +# commit + push this repo if dirty (skips secrets.env / *password* / *token*) +./scripts/git-cadence-commit-push.sh + +# other checkout +./scripts/git-cadence-commit-push.sh ~/taler/src/francpaysan-admin-log +``` + +Optional timer (every 9 min ≈ mid of 7–11): + +```bash +# user crontab example +*/9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh >>$HOME/.cache/git-cadence-koopa.log 2>&1 +*/9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh $HOME/taler/src/francpaysan-admin-log >>$HOME/.cache/git-cadence-lfp.log 2>&1 +``` + +**Auth note (this host):** `francpaysan-admin-log` → `ssh://git@git.gnunet.org/…` works. +`koopa-admin-log` → `https://git.hacktivism.ch/…` needs credentials / SSH key for +`git.hacktivism.ch` (push may stay local until that is fixed). + ## What runs without Android | Phase | Needs | From 93fe5f34f7ac0acd0525474f9435c2ed05984e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 20:04:35 +0200 Subject: [PATCH 237/326] =?UTF-8?q?docs:=20cadence=207=E2=80=9313=20min=20?= =?UTF-8?q?jitter;=20commit=20often,=20push=20only=20at=20end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft range instead of fixed 9m; helper defaults to no push. --- scripts/git-cadence-commit-push.sh | 107 ++++++++++++------ .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 42 ++++--- 2 files changed, 94 insertions(+), 55 deletions(-) diff --git a/scripts/git-cadence-commit-push.sh b/scripts/git-cadence-commit-push.sh index 1276fc1..9775bef 100755 --- a/scripts/git-cadence-commit-push.sh +++ b/scripts/git-cadence-commit-push.sh @@ -1,22 +1,24 @@ #!/usr/bin/env bash -# git-cadence-commit-push.sh — optional WIP commit + push on the historical cadence +# git-cadence-commit.sh — optional local WIP commit on a soft cadence # -# Model (from koopa-admin-log / francpaysan-admin-log histories, 2026-07): -# - message: area: short description (monitoring: … / landings: … / docs: …) -# - size: small logical steps, not giant dumps -# - interval: about **7–11 minutes** while work is in progress (sometimes denser) -# - push: after each commit when the remote accepts credentials +# Model (koopa-admin-log / francpaysan-admin-log histories + ops preference): +# - message: area: short description +# - size: small logical steps; **prefer more commits over few** +# - interval: soft range **7–13 minutes** while work is open (not a strict timer; +# denser bursts of 0–2 min are fine for tiny follow-ups) +# - push: **only at the end of a work session** (CADENCE_PUSH=1), never by default # # Usage: -# ./scripts/git-cadence-commit-push.sh # this repo +# ./scripts/git-cadence-commit-push.sh # commit only if dirty # ./scripts/git-cadence-commit-push.sh /path/to/repo # CADENCE_MSG='monitoring: wip ladder' ./scripts/git-cadence-commit-push.sh -# CADENCE_DRY=1 ./scripts/git-cadence-commit-push.sh # show only +# CADENCE_DRY=1 ./scripts/git-cadence-commit-push.sh +# CADENCE_PUSH=1 ./scripts/git-cadence-commit-push.sh # explicit push (end of work) +# CADENCE_MIN_AGE_S=420 # skip if last commit younger than this (default 0) # -# Cron / agent every ~9 min: -# */9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh -# */9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh \ -# $HOME/taler/src/francpaysan-admin-log +# Soft timer (7–13 min, not fixed): fire often, skip until age ∈ [7,13] min target. +# # agent/cron every 7m — script enforces random target in range when CADENCE_JITTER=1 +# CADENCE_JITTER=1 ./scripts/git-cadence-commit-push.sh # set -euo pipefail @@ -28,7 +30,14 @@ REPO="$(cd "$REPO" && pwd)" cd "$REPO" CADENCE_DRY="${CADENCE_DRY:-0}" -# Never auto-add secrets +# Push only when explicitly requested (end of session) — default OFF +CADENCE_PUSH="${CADENCE_PUSH:-0}" +# Soft range 7–13 minutes (seconds) +CADENCE_RANGE_MIN_S="${CADENCE_RANGE_MIN_S:-420}" # 7 min +CADENCE_RANGE_MAX_S="${CADENCE_RANGE_MAX_S:-780}" # 13 min +CADENCE_JITTER="${CADENCE_JITTER:-0}" +CADENCE_MIN_AGE_S="${CADENCE_MIN_AGE_S:-0}" + SKIP_GLOBS=( 'secrets.env' '**/secrets.env' @@ -47,29 +56,66 @@ branch=$(git rev-parse --abbrev-ref HEAD) remote=$(git remote 2>/dev/null | head -1 || true) : "${remote:=origin}" -# Dirty? (tracked + untracked, ignoring unreadable) +# Jitter: only proceed if last commit is at least a random target in [7,13] min +if [ "$CADENCE_JITTER" = "1" ]; then + last=$(git log -1 --format=%ct 2>/dev/null || echo 0) + now=$(date +%s) + age=$((now - last)) + # stable-ish target per hour so repeated 7m fires don't all commit + target=$(python3 -c " +import random, time +random.seed(int(time.time()) // 60) # changes every minute +print(random.randint(${CADENCE_RANGE_MIN_S}, ${CADENCE_RANGE_MAX_S})) +" 2>/dev/null || echo 600) + if [ "$age" -lt "$target" ]; then + echo "jitter wait · age=${age}s < target=${target}s (range ${CADENCE_RANGE_MIN_S}-${CADENCE_RANGE_MAX_S}s) · $REPO" + exit 0 + fi +elif [ "${CADENCE_MIN_AGE_S}" -gt 0 ] 2>/dev/null; then + last=$(git log -1 --format=%ct 2>/dev/null || echo 0) + now=$(date +%s) + age=$((now - last)) + if [ "$age" -lt "$CADENCE_MIN_AGE_S" ]; then + echo "min-age wait · age=${age}s < ${CADENCE_MIN_AGE_S}s · $REPO" + exit 0 + fi +fi + +do_push() { + local ahead + ahead=$(git rev-list --count "${remote}/${branch}..HEAD" 2>/dev/null || echo 0) + if [ "${ahead:-0}" -le 0 ]; then + echo "nothing to push · $REPO · $branch" + return 0 + fi + echo "ahead of ${remote}/${branch} by $ahead" + if [ "$CADENCE_DRY" = "1" ]; then + echo "DRY: git push $remote $branch" + return 0 + fi + if [ "$CADENCE_PUSH" != "1" ]; then + echo "skip push (CADENCE_PUSH!=1 — push only at end of work)" + return 0 + fi + git push "$remote" "$branch" || { + echo "WARN: push failed for $REPO ($remote $branch)" >&2 + return 0 + } + echo "pushed · $remote $branch" +} + +# Clean working tree → maybe push only if git diff --quiet && git diff --cached --quiet \ && [ -z "$(git ls-files --others --exclude-standard)" ]; then echo "clean · $REPO · $branch (nothing to commit)" - # still try push if ahead - ahead=$(git rev-list --count "${remote}/${branch}..HEAD" 2>/dev/null || echo 0) - if [ "${ahead:-0}" -gt 0 ]; then - echo "ahead of ${remote}/${branch} by $ahead — push" - if [ "$CADENCE_DRY" = "1" ]; then - echo "DRY: git push $remote $branch" - else - git push "$remote" "$branch" || echo "WARN: push failed (auth?)" >&2 - fi - fi + do_push exit 0 fi -# Stage safe paths only git add -A for g in "${SKIP_GLOBS[@]}"; do git reset -q -- "$g" 2>/dev/null || true done -# unstage anything still matching secret names while IFS= read -r f; do case "$f" in *secret*|*password*|*token*|secrets.env) @@ -83,7 +129,6 @@ if git diff --cached --quiet; then exit 0 fi -# Message from paths (historical area: style) msg="${CADENCE_MSG:-}" if [ -z "$msg" ]; then files=$(git diff --cached --name-only | head -20) @@ -103,14 +148,10 @@ fi if [ "$CADENCE_DRY" = "1" ]; then echo "DRY commit: $msg" git diff --cached --stat - echo "DRY push: $remote $branch" + [ "$CADENCE_PUSH" = "1" ] && echo "DRY push: $remote $branch" || echo "DRY: no push" exit 0 fi git commit -m "$msg" echo "committed · $msg" -if ! git push "$remote" "$branch"; then - echo "WARN: push failed for $REPO ($remote $branch) — commit is local" >&2 - exit 0 -fi -echo "pushed · $remote $branch" +do_push diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md index fe6fd1c..c4f11db 100644 --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -9,49 +9,47 @@ default ops laptop yet. ## Git commit / push cadence (existing model) -Observed in `koopa-admin-log` and `francpaysan-admin-log` histories -(active work on 2026-07-17): +Observed in `koopa-admin-log` / `francpaysan-admin-log` histories, plus ops preference: | Rule | Practice | |------|----------| -| **Interval** | About **7–11 minutes** between publishable steps while a topic is open (bursts of 0–2 min are fine for tiny follow-ups) | -| **Message** | `area: short description` — e.g. `monitoring: …`, `landings: …`, `docs: …`, `scripts: …`, `todo: …` | -| **Size** | One logical change (or a tight bundle), not end-of-day megacommits | -| **Push** | After each commit when the remote accepts auth (`git push origin …`) | +| **Commits** | **Prefer frequent** small commits over rare large ones | +| **Interval** | Soft range **7–13 minutes** while a topic is open — **not** a fixed 9‑minute metronome; denser 0–2 min bursts for tiny follow-ups are fine | +| **Message** | `area: short description` — e.g. `monitoring: …`, `landings: …`, `docs: …` | +| **Push** | **Only at the end of a work session** (`CADENCE_PUSH=1` or manual `git push`) — not after every commit | ### Agent / human workflow -1. Finish a small slice of work. -2. `git status` / diff → commit with the area-prefix style. -3. `git push` (or run the helper below). -4. Continue; next commit window ~**7–11 min** later (or sooner if the next slice is ready). +1. Finish a small slice → commit (`area: …`). Repeat often. +2. Do **not** push after each commit. +3. When the topic/session is done → one `git push` (or `CADENCE_PUSH=1`). +4. Soft next-commit window: about **7–13 min** (jitter), sooner if the next slice is ready. ### Helper script -From the admin-log root: - ```bash # dry-run CADENCE_DRY=1 ./scripts/git-cadence-commit-push.sh -# commit + push this repo if dirty (skips secrets.env / *password* / *token*) +# commit only if dirty (default: no push; skips secrets) ./scripts/git-cadence-commit-push.sh - -# other checkout ./scripts/git-cadence-commit-push.sh ~/taler/src/francpaysan-admin-log + +# end of session — push commits that are already local +CADENCE_PUSH=1 ./scripts/git-cadence-commit-push.sh + +# soft timer: only commit if last commit age ≥ random target in 7–13 min +CADENCE_JITTER=1 ./scripts/git-cadence-commit-push.sh ``` -Optional timer (every 9 min ≈ mid of 7–11): +Optional user cron (check every 7 min; **jitter** keeps effective gaps in **7–13 min**): ```bash -# user crontab example -*/9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh >>$HOME/.cache/git-cadence-koopa.log 2>&1 -*/9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh $HOME/taler/src/francpaysan-admin-log >>$HOME/.cache/git-cadence-lfp.log 2>&1 +*/7 * * * * CADENCE_JITTER=1 $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh >>$HOME/.cache/git-cadence-koopa.log 2>&1 ``` -**Auth note (this host):** `francpaysan-admin-log` → `ssh://git@git.gnunet.org/…` works. -`koopa-admin-log` → `https://git.hacktivism.ch/…` needs credentials / SSH key for -`git.hacktivism.ch` (push may stay local until that is fixed). +**Auth note:** `francpaysan-admin-log` (gnunet SSH) pushes OK. +`koopa-admin-log` (hacktivism HTTPS) needs credentials/SSH key before end-of-session push works from this host. ## What runs without Android From 140b3bd16fadb19ecbe25f71213426fdfe939d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 20:04:35 +0200 Subject: [PATCH 238/326] =?UTF-8?q?docs:=20cadence=207=E2=80=9313=20min=20?= =?UTF-8?q?jitter;=20commit=20often,=20push=20only=20at=20end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft range instead of fixed 9m; helper defaults to no push. --- scripts/git-cadence-commit-push.sh | 107 ++++++++++++------ .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 42 ++++--- 2 files changed, 94 insertions(+), 55 deletions(-) diff --git a/scripts/git-cadence-commit-push.sh b/scripts/git-cadence-commit-push.sh index 1276fc1..9775bef 100755 --- a/scripts/git-cadence-commit-push.sh +++ b/scripts/git-cadence-commit-push.sh @@ -1,22 +1,24 @@ #!/usr/bin/env bash -# git-cadence-commit-push.sh — optional WIP commit + push on the historical cadence +# git-cadence-commit.sh — optional local WIP commit on a soft cadence # -# Model (from koopa-admin-log / francpaysan-admin-log histories, 2026-07): -# - message: area: short description (monitoring: … / landings: … / docs: …) -# - size: small logical steps, not giant dumps -# - interval: about **7–11 minutes** while work is in progress (sometimes denser) -# - push: after each commit when the remote accepts credentials +# Model (koopa-admin-log / francpaysan-admin-log histories + ops preference): +# - message: area: short description +# - size: small logical steps; **prefer more commits over few** +# - interval: soft range **7–13 minutes** while work is open (not a strict timer; +# denser bursts of 0–2 min are fine for tiny follow-ups) +# - push: **only at the end of a work session** (CADENCE_PUSH=1), never by default # # Usage: -# ./scripts/git-cadence-commit-push.sh # this repo +# ./scripts/git-cadence-commit-push.sh # commit only if dirty # ./scripts/git-cadence-commit-push.sh /path/to/repo # CADENCE_MSG='monitoring: wip ladder' ./scripts/git-cadence-commit-push.sh -# CADENCE_DRY=1 ./scripts/git-cadence-commit-push.sh # show only +# CADENCE_DRY=1 ./scripts/git-cadence-commit-push.sh +# CADENCE_PUSH=1 ./scripts/git-cadence-commit-push.sh # explicit push (end of work) +# CADENCE_MIN_AGE_S=420 # skip if last commit younger than this (default 0) # -# Cron / agent every ~9 min: -# */9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh -# */9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh \ -# $HOME/taler/src/francpaysan-admin-log +# Soft timer (7–13 min, not fixed): fire often, skip until age ∈ [7,13] min target. +# # agent/cron every 7m — script enforces random target in range when CADENCE_JITTER=1 +# CADENCE_JITTER=1 ./scripts/git-cadence-commit-push.sh # set -euo pipefail @@ -28,7 +30,14 @@ REPO="$(cd "$REPO" && pwd)" cd "$REPO" CADENCE_DRY="${CADENCE_DRY:-0}" -# Never auto-add secrets +# Push only when explicitly requested (end of session) — default OFF +CADENCE_PUSH="${CADENCE_PUSH:-0}" +# Soft range 7–13 minutes (seconds) +CADENCE_RANGE_MIN_S="${CADENCE_RANGE_MIN_S:-420}" # 7 min +CADENCE_RANGE_MAX_S="${CADENCE_RANGE_MAX_S:-780}" # 13 min +CADENCE_JITTER="${CADENCE_JITTER:-0}" +CADENCE_MIN_AGE_S="${CADENCE_MIN_AGE_S:-0}" + SKIP_GLOBS=( 'secrets.env' '**/secrets.env' @@ -47,29 +56,66 @@ branch=$(git rev-parse --abbrev-ref HEAD) remote=$(git remote 2>/dev/null | head -1 || true) : "${remote:=origin}" -# Dirty? (tracked + untracked, ignoring unreadable) +# Jitter: only proceed if last commit is at least a random target in [7,13] min +if [ "$CADENCE_JITTER" = "1" ]; then + last=$(git log -1 --format=%ct 2>/dev/null || echo 0) + now=$(date +%s) + age=$((now - last)) + # stable-ish target per hour so repeated 7m fires don't all commit + target=$(python3 -c " +import random, time +random.seed(int(time.time()) // 60) # changes every minute +print(random.randint(${CADENCE_RANGE_MIN_S}, ${CADENCE_RANGE_MAX_S})) +" 2>/dev/null || echo 600) + if [ "$age" -lt "$target" ]; then + echo "jitter wait · age=${age}s < target=${target}s (range ${CADENCE_RANGE_MIN_S}-${CADENCE_RANGE_MAX_S}s) · $REPO" + exit 0 + fi +elif [ "${CADENCE_MIN_AGE_S}" -gt 0 ] 2>/dev/null; then + last=$(git log -1 --format=%ct 2>/dev/null || echo 0) + now=$(date +%s) + age=$((now - last)) + if [ "$age" -lt "$CADENCE_MIN_AGE_S" ]; then + echo "min-age wait · age=${age}s < ${CADENCE_MIN_AGE_S}s · $REPO" + exit 0 + fi +fi + +do_push() { + local ahead + ahead=$(git rev-list --count "${remote}/${branch}..HEAD" 2>/dev/null || echo 0) + if [ "${ahead:-0}" -le 0 ]; then + echo "nothing to push · $REPO · $branch" + return 0 + fi + echo "ahead of ${remote}/${branch} by $ahead" + if [ "$CADENCE_DRY" = "1" ]; then + echo "DRY: git push $remote $branch" + return 0 + fi + if [ "$CADENCE_PUSH" != "1" ]; then + echo "skip push (CADENCE_PUSH!=1 — push only at end of work)" + return 0 + fi + git push "$remote" "$branch" || { + echo "WARN: push failed for $REPO ($remote $branch)" >&2 + return 0 + } + echo "pushed · $remote $branch" +} + +# Clean working tree → maybe push only if git diff --quiet && git diff --cached --quiet \ && [ -z "$(git ls-files --others --exclude-standard)" ]; then echo "clean · $REPO · $branch (nothing to commit)" - # still try push if ahead - ahead=$(git rev-list --count "${remote}/${branch}..HEAD" 2>/dev/null || echo 0) - if [ "${ahead:-0}" -gt 0 ]; then - echo "ahead of ${remote}/${branch} by $ahead — push" - if [ "$CADENCE_DRY" = "1" ]; then - echo "DRY: git push $remote $branch" - else - git push "$remote" "$branch" || echo "WARN: push failed (auth?)" >&2 - fi - fi + do_push exit 0 fi -# Stage safe paths only git add -A for g in "${SKIP_GLOBS[@]}"; do git reset -q -- "$g" 2>/dev/null || true done -# unstage anything still matching secret names while IFS= read -r f; do case "$f" in *secret*|*password*|*token*|secrets.env) @@ -83,7 +129,6 @@ if git diff --cached --quiet; then exit 0 fi -# Message from paths (historical area: style) msg="${CADENCE_MSG:-}" if [ -z "$msg" ]; then files=$(git diff --cached --name-only | head -20) @@ -103,14 +148,10 @@ fi if [ "$CADENCE_DRY" = "1" ]; then echo "DRY commit: $msg" git diff --cached --stat - echo "DRY push: $remote $branch" + [ "$CADENCE_PUSH" = "1" ] && echo "DRY push: $remote $branch" || echo "DRY: no push" exit 0 fi git commit -m "$msg" echo "committed · $msg" -if ! git push "$remote" "$branch"; then - echo "WARN: push failed for $REPO ($remote $branch) — commit is local" >&2 - exit 0 -fi -echo "pushed · $remote $branch" +do_push diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md index fe6fd1c..c4f11db 100644 --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -9,49 +9,47 @@ default ops laptop yet. ## Git commit / push cadence (existing model) -Observed in `koopa-admin-log` and `francpaysan-admin-log` histories -(active work on 2026-07-17): +Observed in `koopa-admin-log` / `francpaysan-admin-log` histories, plus ops preference: | Rule | Practice | |------|----------| -| **Interval** | About **7–11 minutes** between publishable steps while a topic is open (bursts of 0–2 min are fine for tiny follow-ups) | -| **Message** | `area: short description` — e.g. `monitoring: …`, `landings: …`, `docs: …`, `scripts: …`, `todo: …` | -| **Size** | One logical change (or a tight bundle), not end-of-day megacommits | -| **Push** | After each commit when the remote accepts auth (`git push origin …`) | +| **Commits** | **Prefer frequent** small commits over rare large ones | +| **Interval** | Soft range **7–13 minutes** while a topic is open — **not** a fixed 9‑minute metronome; denser 0–2 min bursts for tiny follow-ups are fine | +| **Message** | `area: short description` — e.g. `monitoring: …`, `landings: …`, `docs: …` | +| **Push** | **Only at the end of a work session** (`CADENCE_PUSH=1` or manual `git push`) — not after every commit | ### Agent / human workflow -1. Finish a small slice of work. -2. `git status` / diff → commit with the area-prefix style. -3. `git push` (or run the helper below). -4. Continue; next commit window ~**7–11 min** later (or sooner if the next slice is ready). +1. Finish a small slice → commit (`area: …`). Repeat often. +2. Do **not** push after each commit. +3. When the topic/session is done → one `git push` (or `CADENCE_PUSH=1`). +4. Soft next-commit window: about **7–13 min** (jitter), sooner if the next slice is ready. ### Helper script -From the admin-log root: - ```bash # dry-run CADENCE_DRY=1 ./scripts/git-cadence-commit-push.sh -# commit + push this repo if dirty (skips secrets.env / *password* / *token*) +# commit only if dirty (default: no push; skips secrets) ./scripts/git-cadence-commit-push.sh - -# other checkout ./scripts/git-cadence-commit-push.sh ~/taler/src/francpaysan-admin-log + +# end of session — push commits that are already local +CADENCE_PUSH=1 ./scripts/git-cadence-commit-push.sh + +# soft timer: only commit if last commit age ≥ random target in 7–13 min +CADENCE_JITTER=1 ./scripts/git-cadence-commit-push.sh ``` -Optional timer (every 9 min ≈ mid of 7–11): +Optional user cron (check every 7 min; **jitter** keeps effective gaps in **7–13 min**): ```bash -# user crontab example -*/9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh >>$HOME/.cache/git-cadence-koopa.log 2>&1 -*/9 * * * * $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh $HOME/taler/src/francpaysan-admin-log >>$HOME/.cache/git-cadence-lfp.log 2>&1 +*/7 * * * * CADENCE_JITTER=1 $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh >>$HOME/.cache/git-cadence-koopa.log 2>&1 ``` -**Auth note (this host):** `francpaysan-admin-log` → `ssh://git@git.gnunet.org/…` works. -`koopa-admin-log` → `https://git.hacktivism.ch/…` needs credentials / SSH key for -`git.hacktivism.ch` (push may stay local until that is fixed). +**Auth note:** `francpaysan-admin-log` (gnunet SSH) pushes OK. +`koopa-admin-log` (hacktivism HTTPS) needs credentials/SSH key before end-of-session push works from this host. ## What runs without Android From 13614f537fc4fbee5fb0712ab4c040f1d963a9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 20:06:16 +0200 Subject: [PATCH 239/326] docs(paivana): disable racey upstream unit; smoke expects 302/402 Compose unit starts both containers; standalone upstream podman-start raced at boot (exit 125). Live stack already healthy on hacktivism. --- configs/paivana/README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/configs/paivana/README.md b/configs/paivana/README.md index e813a82..3b8700a 100644 --- a/configs/paivana/README.md +++ b/configs/paivana/README.md @@ -48,15 +48,31 @@ podman logs -f koopa-paivana curl -si http://127.0.0.1:9025/ | head # 302 → /.well-known/paivana/templates/paivana#… ``` -Autostart: +Autostart (**only** the compose unit — starts **both** `koopa-paivana` and +`koopa-paivana-upstream`): ```bash mkdir -p ~/.config/systemd/user cp ~/koopa-paivana/container-koopa-paivana.service ~/.config/systemd/user/ +# optional: After= merchant (configs/systemd/user/container-koopa-paivana.service.d/) systemctl --user daemon-reload systemctl --user enable --now container-koopa-paivana.service ``` +**Do not** enable a separate `container-koopa-paivana-upstream.service` that only +runs `podman start koopa-paivana-upstream`. At boot that unit races compose +(container not created yet → exit 125 → start-limit-hit). Compose already +starts the upstream. If such a unit exists: +`systemctl --user disable --now container-koopa-paivana-upstream.service`. + +Healthy smoke: + +| Check | Expect | +|-------|--------| +| `curl -si http://127.0.0.1:9025/` | **302** → `/.well-known/paivana/templates/paivana#…` | +| template URL / public `https://paivana.hacktivism.ch/` | **402** paywall HTML + `paivana: taler://pay-template/…` | +| monitoring | `./scripts/taler-monitoring/taler-monitoring.sh -d hacktivism.ch urls` → `www.paivana` OK | + ## Network notes Rootless pasta cannot hairpin public DNS for the merchant. Compose sets: From 17f25d41c9815e5727f659ea6f3f9cb3726fa6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 20:06:16 +0200 Subject: [PATCH 240/326] docs(paivana): disable racey upstream unit; smoke expects 302/402 Compose unit starts both containers; standalone upstream podman-start raced at boot (exit 125). Live stack already healthy on hacktivism. --- configs/paivana/README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/configs/paivana/README.md b/configs/paivana/README.md index e813a82..3b8700a 100644 --- a/configs/paivana/README.md +++ b/configs/paivana/README.md @@ -48,15 +48,31 @@ podman logs -f koopa-paivana curl -si http://127.0.0.1:9025/ | head # 302 → /.well-known/paivana/templates/paivana#… ``` -Autostart: +Autostart (**only** the compose unit — starts **both** `koopa-paivana` and +`koopa-paivana-upstream`): ```bash mkdir -p ~/.config/systemd/user cp ~/koopa-paivana/container-koopa-paivana.service ~/.config/systemd/user/ +# optional: After= merchant (configs/systemd/user/container-koopa-paivana.service.d/) systemctl --user daemon-reload systemctl --user enable --now container-koopa-paivana.service ``` +**Do not** enable a separate `container-koopa-paivana-upstream.service` that only +runs `podman start koopa-paivana-upstream`. At boot that unit races compose +(container not created yet → exit 125 → start-limit-hit). Compose already +starts the upstream. If such a unit exists: +`systemctl --user disable --now container-koopa-paivana-upstream.service`. + +Healthy smoke: + +| Check | Expect | +|-------|--------| +| `curl -si http://127.0.0.1:9025/` | **302** → `/.well-known/paivana/templates/paivana#…` | +| template URL / public `https://paivana.hacktivism.ch/` | **402** paywall HTML + `paivana: taler://pay-template/…` | +| monitoring | `./scripts/taler-monitoring/taler-monitoring.sh -d hacktivism.ch urls` → `www.paivana` OK | + ## Network notes Rootless pasta cannot hairpin public DNS for the merchant. Compose sets: From 6fae59b20c97911c1c59cc25619f43b307c4eb1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 20:31:52 +0200 Subject: [PATCH 241/326] monitoring: Android pay smoke with published F-Droid wallet APK User-local emulator/adb path; install APK, deep-link withdraw and pay-template without root. Document host RAM limits for full UI. --- .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 47 +++++ .../taler-monitoring/android-test/.gitignore | 3 + .../taler-monitoring/android-test/README.md | 13 ++ .../android-test/run-android-pay-smoke.sh | 194 ++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 scripts/taler-monitoring/android-test/.gitignore create mode 100644 scripts/taler-monitoring/android-test/README.md create mode 100755 scripts/taler-monitoring/android-test/run-android-pay-smoke.sh diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md index c4f11db..57be728 100644 --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -131,3 +131,50 @@ document paths; **no** real wallet payments until approved. When Android tooling is installed, append a short “installed on …” section here (date, `adb version`, AVD name, `ANDROID_HOME`). + +--- + +## Android pay smoke (published APK, no root preferred) + +Script: [`android-test/run-android-pay-smoke.sh`](./android-test/run-android-pay-smoke.sh) + +| Step | Detail | +|------|--------| +| APK | F-Droid **Taler Wallet 1.6.1** (`net.taler.wallet.fdroid_854.apk`) | +| Install | `adb install -r` (no root) | +| Withdraw | `taler://withdraw/…` from `…/intro/demo-withdraw.json` (GOA or stage) | +| Pay | stage: shop template order → `taler://pay/…`; GOA: `taler://pay-template/…/goa-shop/paivana` | +| Evidence | screenshots + logcat under `android-test/out/` | + +```bash +# device or emulator already booted +./android-test/run-android-pay-smoke.sh +STACK=goa ./android-test/run-android-pay-smoke.sh +STACK=stage ./android-test/run-android-pay-smoke.sh +``` + +**User-local emulator (no apt root if SDK already under `$HOME/Android/Sdk`):** + +```bash +export ANDROID_HOME=$HOME/Android/Sdk +export PATH=$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH +# AVD e.g. TalerWallet34 (google_apis x86_64) +emulator -avd TalerWallet34 -no-window -gpu swiftshader_indirect -memory 1536 & +adb wait-for-device +./android-test/run-android-pay-smoke.sh +``` + +**Limits (clementine 2026-07-17):** host RAM ~3.7 GiB → emulator often ANRs System UI; +wallet-core still receives withdraw intents (`prepareBankIntegratedWithdrawal` in logcat). +For reliable full UI confirm/pay use a physical phone or a host with **≥6–8 GiB** free RAM. + +### Status snapshot (2026-07-17) + +| Item | Status | +|------|--------| +| `adb` | present (`android-sdk-platform-tools`) | +| F-Droid APK | downloaded under `android-test/apks/` | +| User SDK + emulator + AVD `TalerWallet34` | installed under `$HOME/Android/Sdk` (~5 GiB) | +| `adb install` wallet | **Success** on emulator-5554 | +| Deep-link withdraw | **delivered**; wallet-core started `prepareBankIntegratedWithdrawal` for GOA | +| Unattended UI confirm | flaky (ANR) on this host’s RAM | diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore new file mode 100644 index 0000000..2b9c0a5 --- /dev/null +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -0,0 +1,3 @@ +apks/*.apk +out/ +*.png diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md new file mode 100644 index 0000000..96b760f --- /dev/null +++ b/scripts/taler-monitoring/android-test/README.md @@ -0,0 +1,13 @@ +# Android pay smoke (published wallet) + +Installs the **F-Droid** GNU Taler Wallet APK and deep-links +`taler://withdraw/…` + optional `taler://pay…` against GOA or stage. + +```bash +# needs adb device/emulator +./run-android-pay-smoke.sh +STACK=goa ./run-android-pay-smoke.sh +STACK=stage ./run-android-pay-smoke.sh +``` + +See parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh new file mode 100755 index 0000000..da328b7 --- /dev/null +++ b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# Android wallet smoke: install published F-Droid APK, open withdraw/pay URIs via adb. +# +# No root required if: +# - adb is available (package android-tools-adb / platform-tools) +# - a device or emulator is connected (`adb devices` shows "device") +# - optional user-local SDK under $HOME/Android/Sdk (emulator) +# +# Usage: +# ./android-test/run-android-pay-smoke.sh # auto stack (GOA if local, else stage) +# STACK=goa ./android-test/run-android-pay-smoke.sh +# STACK=stage ./android-test/run-android-pay-smoke.sh +# SERIAL=emulator-5554 ./android-test/run-android-pay-smoke.sh +# +# Published APK (default): F-Droid Taler Wallet 1.6.1 (854) +# https://f-droid.org/packages/net.taler.wallet.fdroid/ +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +APK_DIR="${APK_DIR:-$ROOT/apks}" +APK_URL="${APK_URL:-https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}" +APK_NAME="${APK_NAME:-net.taler.wallet.fdroid_854.apk}" +PKG="${PKG:-net.taler.wallet.fdroid}" +STACK="${STACK:-auto}" # auto | goa | stage +SERIAL="${SERIAL:-}" +OUT_DIR="${OUT_DIR:-$ROOT/out}" +mkdir -p "$APK_DIR" "$OUT_DIR" + +if ! command -v adb >/dev/null 2>&1; then + if [ -x "$HOME/Android/Sdk/platform-tools/adb" ]; then + export PATH="$HOME/Android/Sdk/platform-tools:$PATH" + else + echo "adb missing — install android-tools-adb or user SDK platform-tools" >&2 + exit 2 + fi +fi + +if [ -z "$SERIAL" ]; then + SERIAL=$(adb devices | awk '/\tdevice$/{print $1; exit}') +fi +if [ -z "$SERIAL" ]; then + cat >&2 <<'EOF' +No adb device. Options (no root for user-local emulator if /dev/kvm is usable): + 1) Plug phone with USB debugging + 2) Start AVD: $HOME/Android/Sdk/emulator/emulator -avd TalerWallet34 -no-window + 3) Then re-run this script +EOF + exit 3 +fi +ADB=(adb -s "$SERIAL") +echo "device: $SERIAL" + +# Resolve stack endpoints +case "$STACK" in + auto) + if curl -sfS -m 5 -o /dev/null https://bank.hacktivism.ch/config 2>/dev/null; then + STACK=goa + else + STACK=stage + fi + ;; +esac +case "$STACK" in + goa) + BANK=https://bank.hacktivism.ch + MERCHANT=https://taler.hacktivism.ch + WITHDRAW_JSON="$BANK/intro/demo-withdraw.json" + PAY_HINT="paivana template / goa-shop" + ;; + stage) + BANK=https://stage.bank.lefrancpaysan.ch + MERCHANT=https://stage.monnaie.lefrancpaysan.ch + WITHDRAW_JSON="$BANK/intro/demo-withdraw.json" + PAY_HINT="farmer shop templates" + ;; + *) echo "unknown STACK=$STACK" >&2; exit 2 ;; +esac +echo "stack: $STACK bank: $BANK" + +# APK +APK_PATH="$APK_DIR/$APK_NAME" +if [ ! -f "$APK_PATH" ]; then + echo "downloading published wallet APK…" + curl -fL --retry 3 -o "$APK_PATH" "$APK_URL" +fi +echo "apk: $APK_PATH ($(wc -c <"$APK_PATH") bytes)" + +"${ADB[@]}" wait-for-device +"${ADB[@]}" install -r "$APK_PATH" +"${ADB[@]}" shell settings put global window_animation_scale 0 || true +"${ADB[@]}" shell settings put global transition_animation_scale 0 || true +"${ADB[@]}" shell settings put global animator_duration_scale 0 || true + +# Withdraw URI from live mint +WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")') +if [ -z "$WURI" ]; then + echo "FAIL: no taler_withdraw_uri from $WITHDRAW_JSON" >&2 + exit 4 +fi +echo "withdraw: $WURI" +echo "$WURI" >"$OUT_DIR/last-withdraw.uri" + +"${ADB[@]}" shell am force-stop "$PKG" 2>/dev/null || true +"${ADB[@]}" logcat -c 2>/dev/null || true +"${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" 2>&1 | tee "$OUT_DIR/start-withdraw.txt" +sleep 12 + +"${ADB[@]}" exec-out screencap -p >"$OUT_DIR/01-after-withdraw-uri.png" || true +"${ADB[@]}" shell uiautomator dump /sdcard/ui.xml 2>/dev/null || true +"${ADB[@]}" pull /sdcard/ui.xml "$OUT_DIR/01-ui.xml" 2>/dev/null || true + +# Best-effort: tap common confirm labels +python3 - "$SERIAL" "$OUT_DIR/01-ui.xml" <<'PY' || true +import re, subprocess, sys +serial, path = sys.argv[1], sys.argv[2] +try: + xml = open(path, errors="replace").read() +except FileNotFoundError: + raise SystemExit(0) +labels = [ + "Confirm", "Withdraw", "Accept", "Continue", "OK", + "I accept", "Agree", "Bestätigen", "Abheben", "Akzeptieren", +] +for label in labels: + for pat in ( + r'text="%s"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' % re.escape(label), + r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*text="%s"' % re.escape(label), + ): + for m in re.finditer(pat, xml): + x1, y1, x2, y2 = map(int, m.groups()) + x, y = (x1 + x2) // 2, (y1 + y2) // 2 + print(f"tap {label} @ {x},{y}") + subprocess.run(["adb", "-s", serial, "shell", "input", "tap", str(x), str(y)], check=False) +PY +sleep 8 +"${ADB[@]}" exec-out screencap -p >"$OUT_DIR/02-after-confirm-tap.png" || true + +# Optional public pay template (stage fixed product / GOA shop if amount known) +# Stage: open monnaies shop is browser-side; deep link pay after template POST if available. +PAY_URI="" +if [ "$STACK" = "stage" ]; then + # fixed template panier-legumes → taler://pay/… + if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \ + "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then + PAY_URI=$(printf '%s' "$RESP" | python3 -c ' +import json,sys +d=json.load(sys.stdin) +oid=d.get("order_id") or "" +tok=d.get("token") or "" +if oid and tok: + print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok)) +' 2>/dev/null || true) + fi +elif [ "$STACK" = "goa" ]; then + # Paivana pay-template deep link (wallet prepares order) + PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana" +fi + +if [ -n "$PAY_URI" ]; then + echo "pay: $PAY_URI ($PAY_HINT)" + echo "$PAY_URI" >"$OUT_DIR/last-pay.uri" + "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" 2>&1 | tee "$OUT_DIR/start-pay.txt" + sleep 12 + "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/03-after-pay-uri.png" || true +else + echo "pay: (skipped — no public template order)" +fi + +# Evidence from logcat +"${ADB[@]}" logcat -d -t 300 >"$OUT_DIR/logcat.txt" || true +grep -iE 'taler-wallet|prepareBank|preparePay|confirm|error|Error|success' "$OUT_DIR/logcat.txt" \ + | tail -80 >"$OUT_DIR/logcat-taler.txt" || true + +echo +echo "=== summary ===" +echo "package installed: $PKG" +echo "withdraw intent delivered: $WURI" +if grep -q 'prepareBankIntegratedWithdrawal' "$OUT_DIR/logcat.txt" 2>/dev/null; then + echo "wallet-core: prepareBankIntegratedWithdrawal seen (wallet accepted withdraw URI)" +else + echo "wallet-core: prepareBankIntegratedWithdrawal NOT seen (UI may still show onboarding/ANR on small RAM)" +fi +if [ -n "$PAY_URI" ]; then + if grep -qiE 'preparePay|confirmPay|pay-template|preparePurchase' "$OUT_DIR/logcat.txt" 2>/dev/null; then + echo "wallet-core: pay path activity seen" + else + echo "wallet-core: pay path not confirmed in logcat (check $OUT_DIR/03-after-pay-uri.png)" + fi +fi +echo "artifacts: $OUT_DIR/" +ls -la "$OUT_DIR" | sed 's/^/ /' +echo +echo "NOTE: Full unattended UI payment needs a responsive device/emulator (≥6–8 GiB host RAM recommended)." +echo "This smoke proves: published APK install + deep-link into wallet-core on stack=$STACK." From f79d6350c308550da4de600587732341afe825e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 20:31:52 +0200 Subject: [PATCH 242/326] monitoring: Android pay smoke with published F-Droid wallet APK User-local emulator/adb path; install APK, deep-link withdraw and pay-template without root. Document host RAM limits for full UI. --- .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 47 +++++ .../taler-monitoring/android-test/.gitignore | 3 + .../taler-monitoring/android-test/README.md | 13 ++ .../android-test/run-android-pay-smoke.sh | 194 ++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 scripts/taler-monitoring/android-test/.gitignore create mode 100644 scripts/taler-monitoring/android-test/README.md create mode 100755 scripts/taler-monitoring/android-test/run-android-pay-smoke.sh diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md index c4f11db..57be728 100644 --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -131,3 +131,50 @@ document paths; **no** real wallet payments until approved. When Android tooling is installed, append a short “installed on …” section here (date, `adb version`, AVD name, `ANDROID_HOME`). + +--- + +## Android pay smoke (published APK, no root preferred) + +Script: [`android-test/run-android-pay-smoke.sh`](./android-test/run-android-pay-smoke.sh) + +| Step | Detail | +|------|--------| +| APK | F-Droid **Taler Wallet 1.6.1** (`net.taler.wallet.fdroid_854.apk`) | +| Install | `adb install -r` (no root) | +| Withdraw | `taler://withdraw/…` from `…/intro/demo-withdraw.json` (GOA or stage) | +| Pay | stage: shop template order → `taler://pay/…`; GOA: `taler://pay-template/…/goa-shop/paivana` | +| Evidence | screenshots + logcat under `android-test/out/` | + +```bash +# device or emulator already booted +./android-test/run-android-pay-smoke.sh +STACK=goa ./android-test/run-android-pay-smoke.sh +STACK=stage ./android-test/run-android-pay-smoke.sh +``` + +**User-local emulator (no apt root if SDK already under `$HOME/Android/Sdk`):** + +```bash +export ANDROID_HOME=$HOME/Android/Sdk +export PATH=$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH +# AVD e.g. TalerWallet34 (google_apis x86_64) +emulator -avd TalerWallet34 -no-window -gpu swiftshader_indirect -memory 1536 & +adb wait-for-device +./android-test/run-android-pay-smoke.sh +``` + +**Limits (clementine 2026-07-17):** host RAM ~3.7 GiB → emulator often ANRs System UI; +wallet-core still receives withdraw intents (`prepareBankIntegratedWithdrawal` in logcat). +For reliable full UI confirm/pay use a physical phone or a host with **≥6–8 GiB** free RAM. + +### Status snapshot (2026-07-17) + +| Item | Status | +|------|--------| +| `adb` | present (`android-sdk-platform-tools`) | +| F-Droid APK | downloaded under `android-test/apks/` | +| User SDK + emulator + AVD `TalerWallet34` | installed under `$HOME/Android/Sdk` (~5 GiB) | +| `adb install` wallet | **Success** on emulator-5554 | +| Deep-link withdraw | **delivered**; wallet-core started `prepareBankIntegratedWithdrawal` for GOA | +| Unattended UI confirm | flaky (ANR) on this host’s RAM | diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore new file mode 100644 index 0000000..2b9c0a5 --- /dev/null +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -0,0 +1,3 @@ +apks/*.apk +out/ +*.png diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md new file mode 100644 index 0000000..96b760f --- /dev/null +++ b/scripts/taler-monitoring/android-test/README.md @@ -0,0 +1,13 @@ +# Android pay smoke (published wallet) + +Installs the **F-Droid** GNU Taler Wallet APK and deep-links +`taler://withdraw/…` + optional `taler://pay…` against GOA or stage. + +```bash +# needs adb device/emulator +./run-android-pay-smoke.sh +STACK=goa ./run-android-pay-smoke.sh +STACK=stage ./run-android-pay-smoke.sh +``` + +See parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh new file mode 100755 index 0000000..da328b7 --- /dev/null +++ b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# Android wallet smoke: install published F-Droid APK, open withdraw/pay URIs via adb. +# +# No root required if: +# - adb is available (package android-tools-adb / platform-tools) +# - a device or emulator is connected (`adb devices` shows "device") +# - optional user-local SDK under $HOME/Android/Sdk (emulator) +# +# Usage: +# ./android-test/run-android-pay-smoke.sh # auto stack (GOA if local, else stage) +# STACK=goa ./android-test/run-android-pay-smoke.sh +# STACK=stage ./android-test/run-android-pay-smoke.sh +# SERIAL=emulator-5554 ./android-test/run-android-pay-smoke.sh +# +# Published APK (default): F-Droid Taler Wallet 1.6.1 (854) +# https://f-droid.org/packages/net.taler.wallet.fdroid/ +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +APK_DIR="${APK_DIR:-$ROOT/apks}" +APK_URL="${APK_URL:-https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}" +APK_NAME="${APK_NAME:-net.taler.wallet.fdroid_854.apk}" +PKG="${PKG:-net.taler.wallet.fdroid}" +STACK="${STACK:-auto}" # auto | goa | stage +SERIAL="${SERIAL:-}" +OUT_DIR="${OUT_DIR:-$ROOT/out}" +mkdir -p "$APK_DIR" "$OUT_DIR" + +if ! command -v adb >/dev/null 2>&1; then + if [ -x "$HOME/Android/Sdk/platform-tools/adb" ]; then + export PATH="$HOME/Android/Sdk/platform-tools:$PATH" + else + echo "adb missing — install android-tools-adb or user SDK platform-tools" >&2 + exit 2 + fi +fi + +if [ -z "$SERIAL" ]; then + SERIAL=$(adb devices | awk '/\tdevice$/{print $1; exit}') +fi +if [ -z "$SERIAL" ]; then + cat >&2 <<'EOF' +No adb device. Options (no root for user-local emulator if /dev/kvm is usable): + 1) Plug phone with USB debugging + 2) Start AVD: $HOME/Android/Sdk/emulator/emulator -avd TalerWallet34 -no-window + 3) Then re-run this script +EOF + exit 3 +fi +ADB=(adb -s "$SERIAL") +echo "device: $SERIAL" + +# Resolve stack endpoints +case "$STACK" in + auto) + if curl -sfS -m 5 -o /dev/null https://bank.hacktivism.ch/config 2>/dev/null; then + STACK=goa + else + STACK=stage + fi + ;; +esac +case "$STACK" in + goa) + BANK=https://bank.hacktivism.ch + MERCHANT=https://taler.hacktivism.ch + WITHDRAW_JSON="$BANK/intro/demo-withdraw.json" + PAY_HINT="paivana template / goa-shop" + ;; + stage) + BANK=https://stage.bank.lefrancpaysan.ch + MERCHANT=https://stage.monnaie.lefrancpaysan.ch + WITHDRAW_JSON="$BANK/intro/demo-withdraw.json" + PAY_HINT="farmer shop templates" + ;; + *) echo "unknown STACK=$STACK" >&2; exit 2 ;; +esac +echo "stack: $STACK bank: $BANK" + +# APK +APK_PATH="$APK_DIR/$APK_NAME" +if [ ! -f "$APK_PATH" ]; then + echo "downloading published wallet APK…" + curl -fL --retry 3 -o "$APK_PATH" "$APK_URL" +fi +echo "apk: $APK_PATH ($(wc -c <"$APK_PATH") bytes)" + +"${ADB[@]}" wait-for-device +"${ADB[@]}" install -r "$APK_PATH" +"${ADB[@]}" shell settings put global window_animation_scale 0 || true +"${ADB[@]}" shell settings put global transition_animation_scale 0 || true +"${ADB[@]}" shell settings put global animator_duration_scale 0 || true + +# Withdraw URI from live mint +WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")') +if [ -z "$WURI" ]; then + echo "FAIL: no taler_withdraw_uri from $WITHDRAW_JSON" >&2 + exit 4 +fi +echo "withdraw: $WURI" +echo "$WURI" >"$OUT_DIR/last-withdraw.uri" + +"${ADB[@]}" shell am force-stop "$PKG" 2>/dev/null || true +"${ADB[@]}" logcat -c 2>/dev/null || true +"${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" 2>&1 | tee "$OUT_DIR/start-withdraw.txt" +sleep 12 + +"${ADB[@]}" exec-out screencap -p >"$OUT_DIR/01-after-withdraw-uri.png" || true +"${ADB[@]}" shell uiautomator dump /sdcard/ui.xml 2>/dev/null || true +"${ADB[@]}" pull /sdcard/ui.xml "$OUT_DIR/01-ui.xml" 2>/dev/null || true + +# Best-effort: tap common confirm labels +python3 - "$SERIAL" "$OUT_DIR/01-ui.xml" <<'PY' || true +import re, subprocess, sys +serial, path = sys.argv[1], sys.argv[2] +try: + xml = open(path, errors="replace").read() +except FileNotFoundError: + raise SystemExit(0) +labels = [ + "Confirm", "Withdraw", "Accept", "Continue", "OK", + "I accept", "Agree", "Bestätigen", "Abheben", "Akzeptieren", +] +for label in labels: + for pat in ( + r'text="%s"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' % re.escape(label), + r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*text="%s"' % re.escape(label), + ): + for m in re.finditer(pat, xml): + x1, y1, x2, y2 = map(int, m.groups()) + x, y = (x1 + x2) // 2, (y1 + y2) // 2 + print(f"tap {label} @ {x},{y}") + subprocess.run(["adb", "-s", serial, "shell", "input", "tap", str(x), str(y)], check=False) +PY +sleep 8 +"${ADB[@]}" exec-out screencap -p >"$OUT_DIR/02-after-confirm-tap.png" || true + +# Optional public pay template (stage fixed product / GOA shop if amount known) +# Stage: open monnaies shop is browser-side; deep link pay after template POST if available. +PAY_URI="" +if [ "$STACK" = "stage" ]; then + # fixed template panier-legumes → taler://pay/… + if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \ + "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then + PAY_URI=$(printf '%s' "$RESP" | python3 -c ' +import json,sys +d=json.load(sys.stdin) +oid=d.get("order_id") or "" +tok=d.get("token") or "" +if oid and tok: + print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok)) +' 2>/dev/null || true) + fi +elif [ "$STACK" = "goa" ]; then + # Paivana pay-template deep link (wallet prepares order) + PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana" +fi + +if [ -n "$PAY_URI" ]; then + echo "pay: $PAY_URI ($PAY_HINT)" + echo "$PAY_URI" >"$OUT_DIR/last-pay.uri" + "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" 2>&1 | tee "$OUT_DIR/start-pay.txt" + sleep 12 + "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/03-after-pay-uri.png" || true +else + echo "pay: (skipped — no public template order)" +fi + +# Evidence from logcat +"${ADB[@]}" logcat -d -t 300 >"$OUT_DIR/logcat.txt" || true +grep -iE 'taler-wallet|prepareBank|preparePay|confirm|error|Error|success' "$OUT_DIR/logcat.txt" \ + | tail -80 >"$OUT_DIR/logcat-taler.txt" || true + +echo +echo "=== summary ===" +echo "package installed: $PKG" +echo "withdraw intent delivered: $WURI" +if grep -q 'prepareBankIntegratedWithdrawal' "$OUT_DIR/logcat.txt" 2>/dev/null; then + echo "wallet-core: prepareBankIntegratedWithdrawal seen (wallet accepted withdraw URI)" +else + echo "wallet-core: prepareBankIntegratedWithdrawal NOT seen (UI may still show onboarding/ANR on small RAM)" +fi +if [ -n "$PAY_URI" ]; then + if grep -qiE 'preparePay|confirmPay|pay-template|preparePurchase' "$OUT_DIR/logcat.txt" 2>/dev/null; then + echo "wallet-core: pay path activity seen" + else + echo "wallet-core: pay path not confirmed in logcat (check $OUT_DIR/03-after-pay-uri.png)" + fi +fi +echo "artifacts: $OUT_DIR/" +ls -la "$OUT_DIR" | sed 's/^/ /' +echo +echo "NOTE: Full unattended UI payment needs a responsive device/emulator (≥6–8 GiB host RAM recommended)." +echo "This smoke proves: published APK install + deep-link into wallet-core on stack=$STACK." From 4598aca0830233bc5e81ae49fc83b697b0ac2440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 20:59:31 +0200 Subject: [PATCH 243/326] monitoring: build latest taler-android then same adb pay smoke Add run-android-build-and-smoke.sh (assembleFdroidDebug from taler-android master) and wire APK_PATH/PKG into the existing smoke. --- .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 13 +++ .../taler-monitoring/android-test/.gitignore | 1 + .../taler-monitoring/android-test/README.md | 23 ++++- .../run-android-build-and-smoke.sh | 93 +++++++++++++++++++ .../android-test/run-android-pay-smoke.sh | 22 +++-- 5 files changed, 143 insertions(+), 9 deletions(-) create mode 100755 scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md index 57be728..73e654a 100644 --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -178,3 +178,16 @@ For reliable full UI confirm/pay use a physical phone or a host with **≥6–8 | `adb install` wallet | **Success** on emulator-5554 | | Deep-link withdraw | **delivered**; wallet-core started `prepareBankIntegratedWithdrawal` for GOA | | Unattended UI confirm | flaky (ANR) on this host’s RAM | +| Source build `assembleFdroidDebug` | **OK** (2026-07-17, `taler-android@f65a976c0d4a`, ~12 min, APK ~59 MiB) | + +### From-source smoke (same tests as F-Droid) + +```bash +# build latest master + adb smoke +./android-test/run-android-build-and-smoke.sh +STACK=goa ./android-test/run-android-build-and-smoke.sh +# already built: +APK_PATH=$HOME/taler/taler-android/wallet/build/outputs/apk/fdroid/debug/wallet-fdroid-debug.apk \ + PKG=net.taler.wallet.fdroid.debug \ + ./android-test/run-android-pay-smoke.sh +``` diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore index 2b9c0a5..3119fff 100644 --- a/scripts/taler-monitoring/android-test/.gitignore +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -1,3 +1,4 @@ apks/*.apk out/ *.png +out-source/ diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 96b760f..b943083 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -1,7 +1,6 @@ -# Android pay smoke (published wallet) +# Android pay smoke -Installs the **F-Droid** GNU Taler Wallet APK and deep-links -`taler://withdraw/…` + optional `taler://pay…` against GOA or stage. +## 1) Published F-Droid wallet ```bash # needs adb device/emulator @@ -10,4 +9,22 @@ STACK=goa ./run-android-pay-smoke.sh STACK=stage ./run-android-pay-smoke.sh ``` +APK: F-Droid **1.6.1** (`net.taler.wallet.fdroid`). + +## 2) Latest source build + same tests + +Pulls/builds `taler-android` master (`:wallet:assembleFdroidDebug`), then +runs the **same** smoke (install + withdraw/pay deep-links). + +```bash +# default source: $HOME/taler/taler-android +./run-android-build-and-smoke.sh +STACK=goa ./run-android-build-and-smoke.sh +SKIP_PULL=1 ./run-android-build-and-smoke.sh # use tree as-is +TALER_ANDROID_SRC=/path/to/taler-android ./run-android-build-and-smoke.sh +``` + +Built package id: `net.taler.wallet.fdroid.debug`. +Needs `ANDROID_HOME` (e.g. `$HOME/Android/Sdk`), Java 17+, network for Maven. + See parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). diff --git a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh new file mode 100755 index 0000000..958ca84 --- /dev/null +++ b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Build the **latest** GNU Taler Android wallet from source, then run the same +# adb deep-link smoke as run-android-pay-smoke.sh. +# +# Source (default): $HOME/taler/taler-android (git@git.taler.net:taler-android.git) +# Build: ./gradlew :wallet:assembleFdroidDebug +# Package id: net.taler.wallet.fdroid.debug +# +# No root required if ANDROID_HOME points at a user SDK ($HOME/Android/Sdk). +# +# Usage: +# ./android-test/run-android-build-and-smoke.sh +# STACK=goa ./android-test/run-android-build-and-smoke.sh +# STACK=stage SKIP_PULL=1 ./android-test/run-android-build-and-smoke.sh +# TALER_ANDROID_SRC=/path/to/taler-android ./android-test/run-android-build-and-smoke.sh +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +SMOKE="$ROOT/run-android-pay-smoke.sh" +SRC="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" +SKIP_PULL="${SKIP_PULL:-0}" +VARIANT="${VARIANT:-fdroidDebug}" # assemble task suffix: FdroidDebug / NightlyDebug / … +export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" +export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" + +if [ ! -x "$SRC/gradlew" ]; then + echo "taler-android not found at $SRC (set TALER_ANDROID_SRC=…)" >&2 + exit 2 +fi +if [ ! -d "$ANDROID_HOME" ]; then + echo "ANDROID_HOME missing: $ANDROID_HOME" >&2 + exit 2 +fi + +printf 'sdk.dir=%s\n' "$ANDROID_HOME" >"$SRC/local.properties" + +cd "$SRC" +if [ "$SKIP_PULL" != "1" ] && [ -d .git ]; then + echo "git pull --ff-only origin $(git rev-parse --abbrev-ref HEAD)" + git pull --ff-only origin "$(git rev-parse --abbrev-ref HEAD)" || \ + git fetch origin && git merge --ff-only "origin/$(git rev-parse --abbrev-ref HEAD)" || true +fi +COMMIT=$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown) +echo "source: $SRC @ $COMMIT" +echo "variant: assemble${VARIANT^}" 2>/dev/null || true + +# Map VARIANT → gradle task (fdroidDebug → assembleFdroidDebug) +task="assemble$(printf '%s' "$VARIANT" | python3 -c 'import sys; s=sys.stdin.read().strip(); print(s[:1].upper()+s[1:] if s else "FdroidDebug")')" +# Prefer explicit known tasks +case "$VARIANT" in + fdroidDebug|FdroidDebug) task=assembleFdroidDebug ;; + fdroidRelease|FdroidRelease) task=assembleFdroidRelease ;; + nightlyDebug|NightlyDebug) task=assembleNightlyDebug ;; + googleDebug|GoogleDebug) task=assembleGoogleDebug ;; +esac + +# Low-RAM hosts: cap JVM (override GRADLE_OPTS to raise) +export GRADLE_OPTS="${GRADLE_OPTS:--Xmx1280m -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1}" +echo "gradle :wallet:$task …" +./gradlew ":wallet:$task" --no-daemon --max-workers="${GRADLE_MAX_WORKERS:-1}" \ + -Dorg.gradle.jvmargs="${GRADLE_JVMARGS:--Xmx1280m}" + +# Locate APK +APK=$(find "$SRC/wallet/build/outputs/apk" -name '*.apk' -type f 2>/dev/null | sort | tail -1) +if [ -z "$APK" ] || [ ! -f "$APK" ]; then + echo "no APK under wallet/build/outputs/apk" >&2 + exit 4 +fi +echo "built apk: $APK ($(wc -c <"$APK") bytes)" + +# Package id from aapt if available +PKG="${PKG:-}" +if [ -z "$PKG" ] && command -v aapt >/dev/null 2>&1; then + PKG=$(aapt dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\([^']*\)'.*/\1/p" | head -1) +fi +: "${PKG:=net.taler.wallet.fdroid.debug}" + +mkdir -p "$ROOT/apks" +STAMP_APK="$ROOT/apks/wallet-from-source-${COMMIT}.apk" +cp -f "$APK" "$STAMP_APK" +echo "copied → $STAMP_APK" +echo "$COMMIT" >"$ROOT/out/build-commit.txt" 2>/dev/null || mkdir -p "$ROOT/out" && echo "$COMMIT" >"$ROOT/out/build-commit.txt" +echo "$APK" >"$ROOT/out/build-apk-path.txt" + +# Same smoke as published F-Droid path +export APK_PATH="$STAMP_APK" +export APK_NAME="$(basename "$STAMP_APK")" +export PKG +export OUT_DIR="${OUT_DIR:-$ROOT/out-source}" +mkdir -p "$OUT_DIR" +echo "=== smoke with built APK (PKG=$PKG) ===" +exec env APK_DIR="$ROOT/apks" APK_NAME="$APK_NAME" PKG="$PKG" OUT_DIR="$OUT_DIR" \ + "$SMOKE" diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh index da328b7..907cb07 100755 --- a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Android wallet smoke: install published F-Droid APK, open withdraw/pay URIs via adb. +# Android wallet smoke: install APK (published F-Droid **or** from-source), open +# withdraw/pay URIs via adb. # # No root required if: # - adb is available (package android-tools-adb / platform-tools) @@ -7,10 +8,12 @@ # - optional user-local SDK under $HOME/Android/Sdk (emulator) # # Usage: -# ./android-test/run-android-pay-smoke.sh # auto stack (GOA if local, else stage) +# ./android-test/run-android-pay-smoke.sh # published F-Droid APK # STACK=goa ./android-test/run-android-pay-smoke.sh # STACK=stage ./android-test/run-android-pay-smoke.sh -# SERIAL=emulator-5554 ./android-test/run-android-pay-smoke.sh +# # from-source (built first): +# ./android-test/run-android-build-and-smoke.sh +# APK_PATH=/path/to.apk PKG=net.taler.wallet.fdroid.debug ./run-android-pay-smoke.sh # # Published APK (default): F-Droid Taler Wallet 1.6.1 (854) # https://f-droid.org/packages/net.taler.wallet.fdroid/ @@ -20,6 +23,8 @@ ROOT=$(cd "$(dirname "$0")" && pwd) APK_DIR="${APK_DIR:-$ROOT/apks}" APK_URL="${APK_URL:-https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}" APK_NAME="${APK_NAME:-net.taler.wallet.fdroid_854.apk}" +# Optional override: full path to an already-built APK (from-source) +APK_PATH="${APK_PATH:-}" PKG="${PKG:-net.taler.wallet.fdroid}" STACK="${STACK:-auto}" # auto | goa | stage SERIAL="${SERIAL:-}" @@ -77,13 +82,18 @@ case "$STACK" in esac echo "stack: $STACK bank: $BANK" -# APK -APK_PATH="$APK_DIR/$APK_NAME" -if [ ! -f "$APK_PATH" ]; then +# APK: explicit path, or download published F-Droid into APK_DIR +if [ -n "$APK_PATH" ] && [ -f "$APK_PATH" ]; then + : +elif [ -f "$APK_DIR/$APK_NAME" ]; then + APK_PATH="$APK_DIR/$APK_NAME" +else + APK_PATH="$APK_DIR/$APK_NAME" echo "downloading published wallet APK…" curl -fL --retry 3 -o "$APK_PATH" "$APK_URL" fi echo "apk: $APK_PATH ($(wc -c <"$APK_PATH") bytes)" +echo "pkg: $PKG" "${ADB[@]}" wait-for-device "${ADB[@]}" install -r "$APK_PATH" From b1c8e5ddb2ded6c2b4a25386aaa53a3e0381ee7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 20:59:31 +0200 Subject: [PATCH 244/326] monitoring: build latest taler-android then same adb pay smoke Add run-android-build-and-smoke.sh (assembleFdroidDebug from taler-android master) and wire APK_PATH/PKG into the existing smoke. --- .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 13 +++ .../taler-monitoring/android-test/.gitignore | 1 + .../taler-monitoring/android-test/README.md | 23 ++++- .../run-android-build-and-smoke.sh | 93 +++++++++++++++++++ .../android-test/run-android-pay-smoke.sh | 22 +++-- 5 files changed, 143 insertions(+), 9 deletions(-) create mode 100755 scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md index 57be728..73e654a 100644 --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -178,3 +178,16 @@ For reliable full UI confirm/pay use a physical phone or a host with **≥6–8 | `adb install` wallet | **Success** on emulator-5554 | | Deep-link withdraw | **delivered**; wallet-core started `prepareBankIntegratedWithdrawal` for GOA | | Unattended UI confirm | flaky (ANR) on this host’s RAM | +| Source build `assembleFdroidDebug` | **OK** (2026-07-17, `taler-android@f65a976c0d4a`, ~12 min, APK ~59 MiB) | + +### From-source smoke (same tests as F-Droid) + +```bash +# build latest master + adb smoke +./android-test/run-android-build-and-smoke.sh +STACK=goa ./android-test/run-android-build-and-smoke.sh +# already built: +APK_PATH=$HOME/taler/taler-android/wallet/build/outputs/apk/fdroid/debug/wallet-fdroid-debug.apk \ + PKG=net.taler.wallet.fdroid.debug \ + ./android-test/run-android-pay-smoke.sh +``` diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore index 2b9c0a5..3119fff 100644 --- a/scripts/taler-monitoring/android-test/.gitignore +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -1,3 +1,4 @@ apks/*.apk out/ *.png +out-source/ diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 96b760f..b943083 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -1,7 +1,6 @@ -# Android pay smoke (published wallet) +# Android pay smoke -Installs the **F-Droid** GNU Taler Wallet APK and deep-links -`taler://withdraw/…` + optional `taler://pay…` against GOA or stage. +## 1) Published F-Droid wallet ```bash # needs adb device/emulator @@ -10,4 +9,22 @@ STACK=goa ./run-android-pay-smoke.sh STACK=stage ./run-android-pay-smoke.sh ``` +APK: F-Droid **1.6.1** (`net.taler.wallet.fdroid`). + +## 2) Latest source build + same tests + +Pulls/builds `taler-android` master (`:wallet:assembleFdroidDebug`), then +runs the **same** smoke (install + withdraw/pay deep-links). + +```bash +# default source: $HOME/taler/taler-android +./run-android-build-and-smoke.sh +STACK=goa ./run-android-build-and-smoke.sh +SKIP_PULL=1 ./run-android-build-and-smoke.sh # use tree as-is +TALER_ANDROID_SRC=/path/to/taler-android ./run-android-build-and-smoke.sh +``` + +Built package id: `net.taler.wallet.fdroid.debug`. +Needs `ANDROID_HOME` (e.g. `$HOME/Android/Sdk`), Java 17+, network for Maven. + See parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). diff --git a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh new file mode 100755 index 0000000..958ca84 --- /dev/null +++ b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Build the **latest** GNU Taler Android wallet from source, then run the same +# adb deep-link smoke as run-android-pay-smoke.sh. +# +# Source (default): $HOME/taler/taler-android (git@git.taler.net:taler-android.git) +# Build: ./gradlew :wallet:assembleFdroidDebug +# Package id: net.taler.wallet.fdroid.debug +# +# No root required if ANDROID_HOME points at a user SDK ($HOME/Android/Sdk). +# +# Usage: +# ./android-test/run-android-build-and-smoke.sh +# STACK=goa ./android-test/run-android-build-and-smoke.sh +# STACK=stage SKIP_PULL=1 ./android-test/run-android-build-and-smoke.sh +# TALER_ANDROID_SRC=/path/to/taler-android ./android-test/run-android-build-and-smoke.sh +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +SMOKE="$ROOT/run-android-pay-smoke.sh" +SRC="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" +SKIP_PULL="${SKIP_PULL:-0}" +VARIANT="${VARIANT:-fdroidDebug}" # assemble task suffix: FdroidDebug / NightlyDebug / … +export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" +export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" + +if [ ! -x "$SRC/gradlew" ]; then + echo "taler-android not found at $SRC (set TALER_ANDROID_SRC=…)" >&2 + exit 2 +fi +if [ ! -d "$ANDROID_HOME" ]; then + echo "ANDROID_HOME missing: $ANDROID_HOME" >&2 + exit 2 +fi + +printf 'sdk.dir=%s\n' "$ANDROID_HOME" >"$SRC/local.properties" + +cd "$SRC" +if [ "$SKIP_PULL" != "1" ] && [ -d .git ]; then + echo "git pull --ff-only origin $(git rev-parse --abbrev-ref HEAD)" + git pull --ff-only origin "$(git rev-parse --abbrev-ref HEAD)" || \ + git fetch origin && git merge --ff-only "origin/$(git rev-parse --abbrev-ref HEAD)" || true +fi +COMMIT=$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown) +echo "source: $SRC @ $COMMIT" +echo "variant: assemble${VARIANT^}" 2>/dev/null || true + +# Map VARIANT → gradle task (fdroidDebug → assembleFdroidDebug) +task="assemble$(printf '%s' "$VARIANT" | python3 -c 'import sys; s=sys.stdin.read().strip(); print(s[:1].upper()+s[1:] if s else "FdroidDebug")')" +# Prefer explicit known tasks +case "$VARIANT" in + fdroidDebug|FdroidDebug) task=assembleFdroidDebug ;; + fdroidRelease|FdroidRelease) task=assembleFdroidRelease ;; + nightlyDebug|NightlyDebug) task=assembleNightlyDebug ;; + googleDebug|GoogleDebug) task=assembleGoogleDebug ;; +esac + +# Low-RAM hosts: cap JVM (override GRADLE_OPTS to raise) +export GRADLE_OPTS="${GRADLE_OPTS:--Xmx1280m -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1}" +echo "gradle :wallet:$task …" +./gradlew ":wallet:$task" --no-daemon --max-workers="${GRADLE_MAX_WORKERS:-1}" \ + -Dorg.gradle.jvmargs="${GRADLE_JVMARGS:--Xmx1280m}" + +# Locate APK +APK=$(find "$SRC/wallet/build/outputs/apk" -name '*.apk' -type f 2>/dev/null | sort | tail -1) +if [ -z "$APK" ] || [ ! -f "$APK" ]; then + echo "no APK under wallet/build/outputs/apk" >&2 + exit 4 +fi +echo "built apk: $APK ($(wc -c <"$APK") bytes)" + +# Package id from aapt if available +PKG="${PKG:-}" +if [ -z "$PKG" ] && command -v aapt >/dev/null 2>&1; then + PKG=$(aapt dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\([^']*\)'.*/\1/p" | head -1) +fi +: "${PKG:=net.taler.wallet.fdroid.debug}" + +mkdir -p "$ROOT/apks" +STAMP_APK="$ROOT/apks/wallet-from-source-${COMMIT}.apk" +cp -f "$APK" "$STAMP_APK" +echo "copied → $STAMP_APK" +echo "$COMMIT" >"$ROOT/out/build-commit.txt" 2>/dev/null || mkdir -p "$ROOT/out" && echo "$COMMIT" >"$ROOT/out/build-commit.txt" +echo "$APK" >"$ROOT/out/build-apk-path.txt" + +# Same smoke as published F-Droid path +export APK_PATH="$STAMP_APK" +export APK_NAME="$(basename "$STAMP_APK")" +export PKG +export OUT_DIR="${OUT_DIR:-$ROOT/out-source}" +mkdir -p "$OUT_DIR" +echo "=== smoke with built APK (PKG=$PKG) ===" +exec env APK_DIR="$ROOT/apks" APK_NAME="$APK_NAME" PKG="$PKG" OUT_DIR="$OUT_DIR" \ + "$SMOKE" diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh index da328b7..907cb07 100755 --- a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Android wallet smoke: install published F-Droid APK, open withdraw/pay URIs via adb. +# Android wallet smoke: install APK (published F-Droid **or** from-source), open +# withdraw/pay URIs via adb. # # No root required if: # - adb is available (package android-tools-adb / platform-tools) @@ -7,10 +8,12 @@ # - optional user-local SDK under $HOME/Android/Sdk (emulator) # # Usage: -# ./android-test/run-android-pay-smoke.sh # auto stack (GOA if local, else stage) +# ./android-test/run-android-pay-smoke.sh # published F-Droid APK # STACK=goa ./android-test/run-android-pay-smoke.sh # STACK=stage ./android-test/run-android-pay-smoke.sh -# SERIAL=emulator-5554 ./android-test/run-android-pay-smoke.sh +# # from-source (built first): +# ./android-test/run-android-build-and-smoke.sh +# APK_PATH=/path/to.apk PKG=net.taler.wallet.fdroid.debug ./run-android-pay-smoke.sh # # Published APK (default): F-Droid Taler Wallet 1.6.1 (854) # https://f-droid.org/packages/net.taler.wallet.fdroid/ @@ -20,6 +23,8 @@ ROOT=$(cd "$(dirname "$0")" && pwd) APK_DIR="${APK_DIR:-$ROOT/apks}" APK_URL="${APK_URL:-https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}" APK_NAME="${APK_NAME:-net.taler.wallet.fdroid_854.apk}" +# Optional override: full path to an already-built APK (from-source) +APK_PATH="${APK_PATH:-}" PKG="${PKG:-net.taler.wallet.fdroid}" STACK="${STACK:-auto}" # auto | goa | stage SERIAL="${SERIAL:-}" @@ -77,13 +82,18 @@ case "$STACK" in esac echo "stack: $STACK bank: $BANK" -# APK -APK_PATH="$APK_DIR/$APK_NAME" -if [ ! -f "$APK_PATH" ]; then +# APK: explicit path, or download published F-Droid into APK_DIR +if [ -n "$APK_PATH" ] && [ -f "$APK_PATH" ]; then + : +elif [ -f "$APK_DIR/$APK_NAME" ]; then + APK_PATH="$APK_DIR/$APK_NAME" +else + APK_PATH="$APK_DIR/$APK_NAME" echo "downloading published wallet APK…" curl -fL --retry 3 -o "$APK_PATH" "$APK_URL" fi echo "apk: $APK_PATH ($(wc -c <"$APK_PATH") bytes)" +echo "pkg: $PKG" "${ADB[@]}" wait-for-device "${ADB[@]}" install -r "$APK_PATH" From 2b89526c60bca09927fa078d37e68289b20a779b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:00:56 +0200 Subject: [PATCH 245/326] monitoring: vanilla Android GUI smoke + AUTOMATED-GUI-NOTES uiautomator multi-round taps for goa/stage; document legitimate deep-link shortcuts vs pure CLI install tests. --- .../taler-monitoring/android-test/.gitignore | 2 + .../android-test/AUTOMATED-GUI-NOTES.md | 119 +++++++++++ .../taler-monitoring/android-test/README.md | 15 +- .../taler-monitoring/android-test/lib_ui.py | 192 ++++++++++++++++++ .../run-android-build-and-smoke.sh | 14 +- .../android-test/run-android-gui-smoke.sh | 172 ++++++++++++++++ 6 files changed, 509 insertions(+), 5 deletions(-) create mode 100644 scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md create mode 100755 scripts/taler-monitoring/android-test/lib_ui.py create mode 100755 scripts/taler-monitoring/android-test/run-android-gui-smoke.sh diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore index 3119fff..de62278 100644 --- a/scripts/taler-monitoring/android-test/.gitignore +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -2,3 +2,5 @@ apks/*.apk out/ *.png out-source/ +out-gui/ +out-source-gui/ diff --git a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md new file mode 100644 index 0000000..4013505 --- /dev/null +++ b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md @@ -0,0 +1,119 @@ +# Automated GUI notes — Android wallet (vanilla level) + +Goal: tests for **two systems** (GOA / hacktivism and stage TESTPAYSAN) that +exercise the wallet **graphically in the UI** as far as practical, not only +CLI/`adb` deep-links into wallet-core. + +## Vanilla level (this tree) + +| Layer | What we do | Tooling | +|-------|------------|---------| +| **A — Install** | Install published F-Droid APK **or** APK built from `taler-android` master | `adb install` | +| **B — Entry (shortcut)** | Deliver withdraw / pay as `taler://…` intents | `adb shell am start -a VIEW -d …` | +| **C — Graphical UI** | Dump hierarchy, tap Confirm / ToS / Pay-like buttons, dismiss ANR | `uiautomator dump` + `input tap` (`lib_ui.py`) | +| **D — Evidence** | Screenshots + UI XML + logcat + JSON status | `android-test/out-gui/` | + +Scripts: + +| Script | Role | +|--------|------| +| `run-android-pay-smoke.sh` | Install + deep-link + light taps (hybrid / CLI-heavy) | +| `run-android-gui-smoke.sh` | **Vanilla GUI path**: deep-link entry + multi-round UI driving | +| `run-android-build-and-smoke.sh` | Build latest source APK → same smoke (set `GUI=1` to call GUI script) | +| `lib_ui.py` | Shared dump/tap/ANR helpers | + +```bash +cd scripts/taler-monitoring/android-test + +# Published APK, graphical drive, both stacks +STACK=goa ./run-android-gui-smoke.sh +STACK=stage ./run-android-gui-smoke.sh + +# From-source APK + GUI +APK_PATH=~/taler/taler-android/wallet/build/outputs/apk/fdroid/debug/wallet-fdroid-debug.apk \ + PKG=net.taler.wallet.fdroid.debug \ + STACK=goa ./run-android-gui-smoke.sh +``` + +## Legitimate shortcuts (document, do not hide) + +These are **not** full human UX, but they are the same contracts as QR scan / +paywall open on the landings: + +| Shortcut | Replaces | Why legitimate | +|----------|----------|----------------| +| `adb` `VIEW` `taler://withdraw/…` | Camera QR scan of communal / demo withdraw | Identical URI payload as landings (`demo-withdraw.json`) | +| `adb` `VIEW` `taler://pay…` / `pay-template/…` | Scan shop/paywall QR or open “pay with Taler” | Same URI merchant/backend would show | +| Host-side `POST …/templates/{id}` then pay URI | Browser shop UI to create order | Stage farmer templates are public; order create is API, pay is still wallet UI | +| Multi-language label list for taps | Human reading the button | Same visible buttons; brittle if copy changes | +| Skip full ToS reading | User scrolling long TOS | Tap “Accept” if shown; no legal review automation | + +**Not automated yet (out of vanilla scope):** + +- Typing personal bank passwords into the wallet webview +- NFC +- Real camera / vision-based QR +- Biometric unlock +- Full onboarding wizard every time (we force-stop + re-intent) + +## Two systems under test + +| Stack | Withdraw source | Pay source | +|-------|-----------------|------------| +| **GOA** (`STACK=goa`) | `https://bank.hacktivism.ch/intro/demo-withdraw.json` | `taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana` (Paivana) | +| **stage** (`STACK=stage`) | `https://stage.bank.lefrancpaysan.ch/intro/demo-withdraw.json` | Public template e.g. `fermes-des-collines` / `panier-legumes` → `taler://pay/…` | + +Both use the **same** GUI driver (`lib_ui.py`); only URLs and package under test differ. + +## GUI driver behaviour (`lib_ui.gui_drive`) + +1. Every round: `uiautomator dump` + optional screencap. +2. If **“System UI isn’t responding”** → tap **Wait** (prefer over Close). +3. Else try exact taps on Confirm / Withdraw / Accept / Pay / DE/FR synonyms. +4. Else regex partial match on those words. +5. Repeat `GUI_ROUNDS` times (default 10), `GUI_SLEEP` seconds apart. + +Success signals (layered): + +- **Intent delivered** — `am start` OK +- **wallet-core** — logcat `prepareBankIntegratedWithdrawal` / pay-related ops +- **GUI taps** — non-empty `taps` in `gui-*-status.json` +- **Screenshots** — human-readable progress under `gui-withdraw/` / `gui-pay/` + +A run can be “vanilla-partial” if intents + some taps succeed but final +balance/settlement is not asserted (settlement still better covered by +`taler-monitoring.sh e2e` CLI wallet). + +## Host limits (clementine, 2026-07) + +| Constraint | Effect | +|------------|--------| +| ~3.7 GiB RAM | Emulator ANRs; GUI rounds often only dismiss ANR | +| Prefer physical phone or ≥6–8 GiB host | Real Confirm/Pay taps complete more often | +| No root required | User SDK + adb + F-Droid/source APK | + +## Relation to CLI e2e + +| Suite | Strength | +|-------|----------| +| `taler-monitoring.sh e2e` / `ladder` | Full withdraw+pay **settlement** via `taler-wallet-cli` | +| `run-android-gui-smoke.sh` | **Android app UI** surface for the same stacks | +| Combined | CLI = money path green; GUI = app UX green as far as taps go | + +## Future upgrades (not required for vanilla) + +- Maestro / Appium flows with stable resource-ids +- Explicit wait for text “Withdrawal complete” / balance +- Separate jobs: F-Droid vs nightly source vs Play flavor +- Record video (`adb shell screenrecord`) on failure + +## Checklist — “vanille erreicht?” + +- [x] Two stacks parameterized (goa / stage) +- [x] Published APK path +- [x] Source-build path (same smoke after build) +- [x] Deep-link shortcuts documented +- [x] Multi-round **graphical** taps (uiautomator) +- [x] Screenshots + UI XML + logcat artifacts +- [ ] Fully reliable unattended Confirm→settled on low-RAM emulator (host-limited) +- [ ] Assert final balance in app UI (optional next step) diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index b943083..19df5ff 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -27,4 +27,17 @@ TALER_ANDROID_SRC=/path/to/taler-android ./run-android-build-and-smoke.sh Built package id: `net.taler.wallet.fdroid.debug`. Needs `ANDROID_HOME` (e.g. `$HOME/Android/Sdk`), Java 17+, network for Maven. -See parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). +## 3) Graphical UI (vanilla) + +Deep-link entry (≡ QR) **plus** multi-round uiautomator taps: + +```bash +STACK=goa ./run-android-gui-smoke.sh +STACK=stage ./run-android-gui-smoke.sh +# after source build: +GUI=1 ./run-android-build-and-smoke.sh +``` + +Documented shortcuts and limits: **[AUTOMATED-GUI-NOTES.md](./AUTOMATED-GUI-NOTES.md)**. + +See also parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). diff --git a/scripts/taler-monitoring/android-test/lib_ui.py b/scripts/taler-monitoring/android-test/lib_ui.py new file mode 100755 index 0000000..a7cdad5 --- /dev/null +++ b/scripts/taler-monitoring/android-test/lib_ui.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Minimal adb + uiautomator helpers for Taler wallet GUI smoke (no root). + +Does not replace Maestro/Appium; uses only adb shell + uiautomator dump. +""" +from __future__ import annotations + +import re +import subprocess +import time +from pathlib import Path +from typing import Iterable, List, Optional, Sequence, Tuple + +Bounds = Tuple[int, int, int, int] + + +def adb(serial: str, *args: str, check: bool = False, timeout: Optional[float] = 60) -> subprocess.CompletedProcess: + cmd = ["adb", "-s", serial, *args] + return subprocess.run(cmd, check=check, capture_output=True, text=True, timeout=timeout) + + +def dump_ui(serial: str, dest: Path) -> str: + adb(serial, "shell", "uiautomator", "dump", "/sdcard/ui.xml", timeout=90) + adb(serial, "pull", "/sdcard/ui.xml", str(dest), timeout=30) + return dest.read_text(errors="replace") if dest.is_file() else "" + + +def screencap(serial: str, dest: Path) -> None: + p = subprocess.run( + ["adb", "-s", serial, "exec-out", "screencap", "-p"], + capture_output=True, + timeout=60, + ) + if p.returncode == 0 and p.stdout: + dest.write_bytes(p.stdout) + + +def texts(xml: str) -> List[str]: + return [t for t in re.findall(r'text="([^"]*)"', xml) if t.strip()] + + +def find_bounds(xml: str, label: str) -> List[Bounds]: + """Return list of (x1,y1,x2,y2) for nodes whose text or content-desc matches label (exact).""" + out: List[Bounds] = [] + esc = re.escape(label) + patterns = [ + rf'(?:text|content-desc)="{esc}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', + rf'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*(?:text|content-desc)="{esc}"', + ] + for pat in patterns: + for m in re.finditer(pat, xml, flags=re.I): + out.append(tuple(map(int, m.groups()))) # type: ignore[arg-type] + return out + + +def find_bounds_re(xml: str, pattern: str) -> List[Tuple[str, Bounds]]: + out: List[Tuple[str, Bounds]] = [] + for m in re.finditer( + rf'(?:text|content-desc)="([^"]*{pattern}[^"]*)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', + xml, + flags=re.I, + ): + label = m.group(1) + b = tuple(map(int, m.group(2, 3, 4, 5))) + out.append((label, b)) # type: ignore[arg-type] + return out + + +def center(b: Bounds) -> Tuple[int, int]: + x1, y1, x2, y2 = b + return (x1 + x2) // 2, (y1 + y2) // 2 + + +def tap(serial: str, x: int, y: int) -> None: + adb(serial, "shell", "input", "tap", str(x), str(y)) + + +def tap_bounds(serial: str, b: Bounds) -> None: + x, y = center(b) + tap(serial, x, y) + + +def dismiss_anr(serial: str, xml: str) -> bool: + """If System UI isn't responding, prefer Wait over Close.""" + if "isn't responding" not in xml and "reagiert nicht" not in xml.lower(): + return False + for label in ("Wait", "Warten", "OK"): + bs = find_bounds(xml, label) + if bs: + tap_bounds(serial, bs[0]) + return True + # fallback: right-ish button often Wait + adb(serial, "shell", "input", "keyevent", "22") + adb(serial, "shell", "input", "keyevent", "66") + return True + + +# Labels seen on wallet UI (EN/DE/FR) — expand as needed +CONFIRM_LABELS = [ + "Confirm", + "Confirm withdrawal", + "Withdraw", + "Accept", + "I accept", + "Agree", + "Continue", + "Next", + "OK", + "Pay", + "Pay now", + "Bestätigen", + "Abheben", + "Akzeptieren", + "Zustimmen", + "Weiter", + "Bezahlen", + "Confirmer", + "Retirer", + "Accepter", + "Payer", +] + +TOS_LABELS = [ + "I accept", + "Accept", + "Agree", + "Akzeptieren", + "Zustimmen", + "Accepter", +] + + +def try_tap_any(serial: str, xml: str, labels: Sequence[str]) -> Optional[str]: + for label in labels: + bs = find_bounds(xml, label) + if bs: + tap_bounds(serial, bs[0]) + return label + return None + + +def gui_drive( + serial: str, + out_dir: Path, + *, + rounds: int = 8, + sleep_s: float = 3.0, + phase: str = "gui", +) -> dict: + """Poll UI, dismiss ANR, tap confirm/ToS-like buttons. Returns status dict.""" + out_dir.mkdir(parents=True, exist_ok=True) + seen_texts: List[str] = [] + taps: List[str] = [] + anr = 0 + for i in range(1, rounds + 1): + xml_path = out_dir / f"{phase}-{i:02d}-ui.xml" + png_path = out_dir / f"{phase}-{i:02d}.png" + try: + xml = dump_ui(serial, xml_path) + except Exception as e: + taps.append(f"dump-fail:{e}") + time.sleep(sleep_s) + continue + try: + screencap(serial, png_path) + except Exception: + pass + ts = texts(xml) + seen_texts.extend(ts[:20]) + if dismiss_anr(serial, xml): + anr += 1 + time.sleep(sleep_s) + continue + hit = try_tap_any(serial, xml, CONFIRM_LABELS) + if hit: + taps.append(hit) + time.sleep(sleep_s) + continue + # partial regex for amount confirm rows + for label, b in find_bounds_re(xml, r"Confirm|Withdraw|Pay|Accept|Bestätig|Abheben|Payer"): + tap_bounds(serial, b) + taps.append(f"re:{label}") + time.sleep(sleep_s) + break + else: + time.sleep(sleep_s) + return { + "taps": taps, + "anr_dismissals": anr, + "sample_texts": seen_texts[-40:], + "rounds": rounds, + } diff --git a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh index 958ca84..277ad07 100755 --- a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh @@ -82,12 +82,18 @@ echo "copied → $STAMP_APK" echo "$COMMIT" >"$ROOT/out/build-commit.txt" 2>/dev/null || mkdir -p "$ROOT/out" && echo "$COMMIT" >"$ROOT/out/build-commit.txt" echo "$APK" >"$ROOT/out/build-apk-path.txt" -# Same smoke as published F-Droid path +# Same smoke as published F-Droid path (CLI hybrid or GUI) export APK_PATH="$STAMP_APK" export APK_NAME="$(basename "$STAMP_APK")" export PKG export OUT_DIR="${OUT_DIR:-$ROOT/out-source}" mkdir -p "$OUT_DIR" -echo "=== smoke with built APK (PKG=$PKG) ===" -exec env APK_DIR="$ROOT/apks" APK_NAME="$APK_NAME" PKG="$PKG" OUT_DIR="$OUT_DIR" \ - "$SMOKE" +SMOKE_CMD="$SMOKE" +if [ "${GUI:-0}" = "1" ]; then + SMOKE_CMD="$ROOT/run-android-gui-smoke.sh" + OUT_DIR="${OUT_DIR%-source}-source-gui" + export OUT_DIR +fi +echo "=== smoke with built APK (PKG=$PKG · GUI=${GUI:-0}) ===" +exec env APK_DIR="$ROOT/apks" APK_NAME="$APK_NAME" APK_PATH="$APK_PATH" PKG="$PKG" OUT_DIR="$OUT_DIR" \ + "$SMOKE_CMD" diff --git a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh new file mode 100755 index 0000000..241ab2c --- /dev/null +++ b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# GUI-oriented Android smoke (vanilla level): deep-link entry + UI taps. +# +# Entry shortcut (legitimate — same as scanning a QR on the landing): +# adb VIEW taler://withdraw/… and taler://pay… +# Then drives the **graphical UI** via uiautomator dump + taps (Confirm / ToS / Pay). +# +# Systems: STACK=goa | stage | auto +# APK: published F-Droid or APK_PATH=… (from-source) +# +# See AUTOMATED-GUI-NOTES.md +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=run-android-pay-smoke.sh +# We re-use install/URI resolution by calling common pieces inline. + +export OUT_DIR="${OUT_DIR:-$ROOT/out-gui}" +export GUI_ROUNDS="${GUI_ROUNDS:-10}" +export GUI_SLEEP="${GUI_SLEEP:-3}" + +# Install + deliver intents via existing smoke, but skip its weak single-tap +# by setting GUI_MODE=1 which we implement here more fully. +# Simpler: do install/URI here by sourcing patterns from pay-smoke via env + python. + +# Delegate install + URI start to pay-smoke with GUI_AFTER=1 if we patch it; +# instead run a self-contained flow calling pay-smoke pieces. + +APK_DIR="${APK_DIR:-$ROOT/apks}" +APK_URL="${APK_URL:-https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}" +APK_NAME="${APK_NAME:-net.taler.wallet.fdroid_854.apk}" +APK_PATH="${APK_PATH:-}" +PKG="${PKG:-net.taler.wallet.fdroid}" +STACK="${STACK:-auto}" +SERIAL="${SERIAL:-}" +mkdir -p "$OUT_DIR" + +if ! command -v adb >/dev/null 2>&1; then + [ -x "$HOME/Android/Sdk/platform-tools/adb" ] && export PATH="$HOME/Android/Sdk/platform-tools:$PATH" +fi +command -v adb >/dev/null || { echo "adb missing" >&2; exit 2; } + +if [ -z "$SERIAL" ]; then + SERIAL=$(adb devices | awk '/\tdevice$/{print $1; exit}') +fi +[ -n "$SERIAL" ] || { echo "no adb device" >&2; exit 3; } +ADB=(adb -s "$SERIAL") +echo "device: $SERIAL mode: GUI (uiautomator)" + +case "$STACK" in + auto) + if curl -sfS -m 5 -o /dev/null https://bank.hacktivism.ch/config; then STACK=goa; else STACK=stage; fi + ;; +esac +case "$STACK" in + goa) + BANK=https://bank.hacktivism.ch + MERCHANT=https://taler.hacktivism.ch + WITHDRAW_JSON="$BANK/intro/demo-withdraw.json" + ;; + stage) + BANK=https://stage.bank.lefrancpaysan.ch + MERCHANT=https://stage.monnaie.lefrancpaysan.ch + WITHDRAW_JSON="$BANK/intro/demo-withdraw.json" + ;; + *) echo "unknown STACK" >&2; exit 2 ;; +esac +echo "stack: $STACK" + +if [ -n "$APK_PATH" ] && [ -f "$APK_PATH" ]; then + : +elif [ -f "$APK_DIR/$APK_NAME" ]; then + APK_PATH="$APK_DIR/$APK_NAME" +else + APK_PATH="$APK_DIR/$APK_NAME" + mkdir -p "$APK_DIR" + curl -fL --retry 3 -o "$APK_PATH" "$APK_URL" +fi +echo "apk: $APK_PATH pkg: $PKG" + +"${ADB[@]}" wait-for-device +"${ADB[@]}" install -r "$APK_PATH" +"${ADB[@]}" shell settings put global window_animation_scale 0 || true +"${ADB[@]}" shell settings put global transition_animation_scale 0 || true +"${ADB[@]}" shell settings put global animator_duration_scale 0 || true + +WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")') +[ -n "$WURI" ] || { echo "no withdraw uri" >&2; exit 4; } +echo "withdraw: $WURI" +echo "$WURI" >"$OUT_DIR/last-withdraw.uri" + +"${ADB[@]}" shell am force-stop "$PKG" 2>/dev/null || true +"${ADB[@]}" logcat -c 2>/dev/null || true + +# --- Shortcut A: deep-link withdraw (≡ QR scan on landing) --- +"${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" | tee "$OUT_DIR/start-withdraw.txt" +sleep 5 + +# --- Graphical: drive UI --- +python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY' +import json, sys, importlib.util +from pathlib import Path +lib_path, serial, out, rounds, sleep_s = ( + Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5]) +) +spec = importlib.util.spec_from_file_location("lib_ui", lib_path) +lib = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lib) +st = lib.gui_drive(serial, out / "gui-withdraw", rounds=rounds, sleep_s=sleep_s, phase="wd") +(out / "gui-withdraw-status.json").write_text(json.dumps(st, indent=2)) +print("gui-withdraw taps:", st.get("taps")) +print("gui-withdraw anr:", st.get("anr_dismissals")) +PY + +# Pay URI +PAY_URI="" +if [ "$STACK" = "stage" ]; then + if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \ + "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then + PAY_URI=$(printf '%s' "$RESP" | python3 -c 'import json,sys;d=json.load(sys.stdin);oid=d.get("order_id")or"";tok=d.get("token")or""; +print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok) if oid and tok else "")') + fi +else + PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana" +fi + +if [ -n "$PAY_URI" ]; then + echo "pay: $PAY_URI" + echo "$PAY_URI" >"$OUT_DIR/last-pay.uri" + # Shortcut B: deep-link pay (≡ scan pay QR / open paywall link) + "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" | tee "$OUT_DIR/start-pay.txt" + sleep 5 + python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY' +import json, sys, importlib.util +from pathlib import Path +lib_path, serial, out, rounds, sleep_s = ( + Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5]) +) +spec = importlib.util.spec_from_file_location("lib_ui", lib_path) +lib = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lib) +st = lib.gui_drive(serial, out / "gui-pay", rounds=rounds, sleep_s=sleep_s, phase="pay") +(out / "gui-pay-status.json").write_text(json.dumps(st, indent=2)) +print("gui-pay taps:", st.get("taps")) +print("gui-pay anr:", st.get("anr_dismissals")) +PY +fi + +"${ADB[@]}" logcat -d -t 400 >"$OUT_DIR/logcat.txt" || true +grep -iE 'taler-wallet|prepareBank|preparePay|confirm|Error|success' "$OUT_DIR/logcat.txt" \ + | tail -100 >"$OUT_DIR/logcat-taler.txt" || true + +echo +echo "=== GUI smoke summary ($STACK) ===" +echo "package: $PKG" +echo "shortcut withdraw URI: delivered" +echo "GUI withdraw: see $OUT_DIR/gui-withdraw-status.json + screenshots" +[ -n "$PAY_URI" ] && echo "shortcut pay URI: delivered" +[ -n "$PAY_URI" ] && echo "GUI pay: see $OUT_DIR/gui-pay-status.json + screenshots" +if grep -q prepareBankIntegratedWithdrawal "$OUT_DIR/logcat.txt" 2>/dev/null; then + echo "wallet-core: prepareBankIntegratedWithdrawal YES" +else + echo "wallet-core: prepareBankIntegratedWithdrawal no (ANR/slow UI?)" +fi +if grep -qiE 'preparePay|confirmPay|pay-template|preparePurchase' "$OUT_DIR/logcat.txt" 2>/dev/null; then + echo "wallet-core: pay path YES" +else + echo "wallet-core: pay path not confirmed in logcat" +fi +echo "artifacts: $OUT_DIR/" +ls -la "$OUT_DIR" | sed 's/^/ /' +echo "Documented shortcuts → AUTOMATED-GUI-NOTES.md" From f1b6eb8b459165dad86b6385cd47d486fa66b663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:00:56 +0200 Subject: [PATCH 246/326] monitoring: vanilla Android GUI smoke + AUTOMATED-GUI-NOTES uiautomator multi-round taps for goa/stage; document legitimate deep-link shortcuts vs pure CLI install tests. --- .../taler-monitoring/android-test/.gitignore | 2 + .../android-test/AUTOMATED-GUI-NOTES.md | 119 +++++++++++ .../taler-monitoring/android-test/README.md | 15 +- .../taler-monitoring/android-test/lib_ui.py | 192 ++++++++++++++++++ .../run-android-build-and-smoke.sh | 14 +- .../android-test/run-android-gui-smoke.sh | 172 ++++++++++++++++ 6 files changed, 509 insertions(+), 5 deletions(-) create mode 100644 scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md create mode 100755 scripts/taler-monitoring/android-test/lib_ui.py create mode 100755 scripts/taler-monitoring/android-test/run-android-gui-smoke.sh diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore index 3119fff..de62278 100644 --- a/scripts/taler-monitoring/android-test/.gitignore +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -2,3 +2,5 @@ apks/*.apk out/ *.png out-source/ +out-gui/ +out-source-gui/ diff --git a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md new file mode 100644 index 0000000..4013505 --- /dev/null +++ b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md @@ -0,0 +1,119 @@ +# Automated GUI notes — Android wallet (vanilla level) + +Goal: tests for **two systems** (GOA / hacktivism and stage TESTPAYSAN) that +exercise the wallet **graphically in the UI** as far as practical, not only +CLI/`adb` deep-links into wallet-core. + +## Vanilla level (this tree) + +| Layer | What we do | Tooling | +|-------|------------|---------| +| **A — Install** | Install published F-Droid APK **or** APK built from `taler-android` master | `adb install` | +| **B — Entry (shortcut)** | Deliver withdraw / pay as `taler://…` intents | `adb shell am start -a VIEW -d …` | +| **C — Graphical UI** | Dump hierarchy, tap Confirm / ToS / Pay-like buttons, dismiss ANR | `uiautomator dump` + `input tap` (`lib_ui.py`) | +| **D — Evidence** | Screenshots + UI XML + logcat + JSON status | `android-test/out-gui/` | + +Scripts: + +| Script | Role | +|--------|------| +| `run-android-pay-smoke.sh` | Install + deep-link + light taps (hybrid / CLI-heavy) | +| `run-android-gui-smoke.sh` | **Vanilla GUI path**: deep-link entry + multi-round UI driving | +| `run-android-build-and-smoke.sh` | Build latest source APK → same smoke (set `GUI=1` to call GUI script) | +| `lib_ui.py` | Shared dump/tap/ANR helpers | + +```bash +cd scripts/taler-monitoring/android-test + +# Published APK, graphical drive, both stacks +STACK=goa ./run-android-gui-smoke.sh +STACK=stage ./run-android-gui-smoke.sh + +# From-source APK + GUI +APK_PATH=~/taler/taler-android/wallet/build/outputs/apk/fdroid/debug/wallet-fdroid-debug.apk \ + PKG=net.taler.wallet.fdroid.debug \ + STACK=goa ./run-android-gui-smoke.sh +``` + +## Legitimate shortcuts (document, do not hide) + +These are **not** full human UX, but they are the same contracts as QR scan / +paywall open on the landings: + +| Shortcut | Replaces | Why legitimate | +|----------|----------|----------------| +| `adb` `VIEW` `taler://withdraw/…` | Camera QR scan of communal / demo withdraw | Identical URI payload as landings (`demo-withdraw.json`) | +| `adb` `VIEW` `taler://pay…` / `pay-template/…` | Scan shop/paywall QR or open “pay with Taler” | Same URI merchant/backend would show | +| Host-side `POST …/templates/{id}` then pay URI | Browser shop UI to create order | Stage farmer templates are public; order create is API, pay is still wallet UI | +| Multi-language label list for taps | Human reading the button | Same visible buttons; brittle if copy changes | +| Skip full ToS reading | User scrolling long TOS | Tap “Accept” if shown; no legal review automation | + +**Not automated yet (out of vanilla scope):** + +- Typing personal bank passwords into the wallet webview +- NFC +- Real camera / vision-based QR +- Biometric unlock +- Full onboarding wizard every time (we force-stop + re-intent) + +## Two systems under test + +| Stack | Withdraw source | Pay source | +|-------|-----------------|------------| +| **GOA** (`STACK=goa`) | `https://bank.hacktivism.ch/intro/demo-withdraw.json` | `taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana` (Paivana) | +| **stage** (`STACK=stage`) | `https://stage.bank.lefrancpaysan.ch/intro/demo-withdraw.json` | Public template e.g. `fermes-des-collines` / `panier-legumes` → `taler://pay/…` | + +Both use the **same** GUI driver (`lib_ui.py`); only URLs and package under test differ. + +## GUI driver behaviour (`lib_ui.gui_drive`) + +1. Every round: `uiautomator dump` + optional screencap. +2. If **“System UI isn’t responding”** → tap **Wait** (prefer over Close). +3. Else try exact taps on Confirm / Withdraw / Accept / Pay / DE/FR synonyms. +4. Else regex partial match on those words. +5. Repeat `GUI_ROUNDS` times (default 10), `GUI_SLEEP` seconds apart. + +Success signals (layered): + +- **Intent delivered** — `am start` OK +- **wallet-core** — logcat `prepareBankIntegratedWithdrawal` / pay-related ops +- **GUI taps** — non-empty `taps` in `gui-*-status.json` +- **Screenshots** — human-readable progress under `gui-withdraw/` / `gui-pay/` + +A run can be “vanilla-partial” if intents + some taps succeed but final +balance/settlement is not asserted (settlement still better covered by +`taler-monitoring.sh e2e` CLI wallet). + +## Host limits (clementine, 2026-07) + +| Constraint | Effect | +|------------|--------| +| ~3.7 GiB RAM | Emulator ANRs; GUI rounds often only dismiss ANR | +| Prefer physical phone or ≥6–8 GiB host | Real Confirm/Pay taps complete more often | +| No root required | User SDK + adb + F-Droid/source APK | + +## Relation to CLI e2e + +| Suite | Strength | +|-------|----------| +| `taler-monitoring.sh e2e` / `ladder` | Full withdraw+pay **settlement** via `taler-wallet-cli` | +| `run-android-gui-smoke.sh` | **Android app UI** surface for the same stacks | +| Combined | CLI = money path green; GUI = app UX green as far as taps go | + +## Future upgrades (not required for vanilla) + +- Maestro / Appium flows with stable resource-ids +- Explicit wait for text “Withdrawal complete” / balance +- Separate jobs: F-Droid vs nightly source vs Play flavor +- Record video (`adb shell screenrecord`) on failure + +## Checklist — “vanille erreicht?” + +- [x] Two stacks parameterized (goa / stage) +- [x] Published APK path +- [x] Source-build path (same smoke after build) +- [x] Deep-link shortcuts documented +- [x] Multi-round **graphical** taps (uiautomator) +- [x] Screenshots + UI XML + logcat artifacts +- [ ] Fully reliable unattended Confirm→settled on low-RAM emulator (host-limited) +- [ ] Assert final balance in app UI (optional next step) diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index b943083..19df5ff 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -27,4 +27,17 @@ TALER_ANDROID_SRC=/path/to/taler-android ./run-android-build-and-smoke.sh Built package id: `net.taler.wallet.fdroid.debug`. Needs `ANDROID_HOME` (e.g. `$HOME/Android/Sdk`), Java 17+, network for Maven. -See parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). +## 3) Graphical UI (vanilla) + +Deep-link entry (≡ QR) **plus** multi-round uiautomator taps: + +```bash +STACK=goa ./run-android-gui-smoke.sh +STACK=stage ./run-android-gui-smoke.sh +# after source build: +GUI=1 ./run-android-build-and-smoke.sh +``` + +Documented shortcuts and limits: **[AUTOMATED-GUI-NOTES.md](./AUTOMATED-GUI-NOTES.md)**. + +See also parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). diff --git a/scripts/taler-monitoring/android-test/lib_ui.py b/scripts/taler-monitoring/android-test/lib_ui.py new file mode 100755 index 0000000..a7cdad5 --- /dev/null +++ b/scripts/taler-monitoring/android-test/lib_ui.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Minimal adb + uiautomator helpers for Taler wallet GUI smoke (no root). + +Does not replace Maestro/Appium; uses only adb shell + uiautomator dump. +""" +from __future__ import annotations + +import re +import subprocess +import time +from pathlib import Path +from typing import Iterable, List, Optional, Sequence, Tuple + +Bounds = Tuple[int, int, int, int] + + +def adb(serial: str, *args: str, check: bool = False, timeout: Optional[float] = 60) -> subprocess.CompletedProcess: + cmd = ["adb", "-s", serial, *args] + return subprocess.run(cmd, check=check, capture_output=True, text=True, timeout=timeout) + + +def dump_ui(serial: str, dest: Path) -> str: + adb(serial, "shell", "uiautomator", "dump", "/sdcard/ui.xml", timeout=90) + adb(serial, "pull", "/sdcard/ui.xml", str(dest), timeout=30) + return dest.read_text(errors="replace") if dest.is_file() else "" + + +def screencap(serial: str, dest: Path) -> None: + p = subprocess.run( + ["adb", "-s", serial, "exec-out", "screencap", "-p"], + capture_output=True, + timeout=60, + ) + if p.returncode == 0 and p.stdout: + dest.write_bytes(p.stdout) + + +def texts(xml: str) -> List[str]: + return [t for t in re.findall(r'text="([^"]*)"', xml) if t.strip()] + + +def find_bounds(xml: str, label: str) -> List[Bounds]: + """Return list of (x1,y1,x2,y2) for nodes whose text or content-desc matches label (exact).""" + out: List[Bounds] = [] + esc = re.escape(label) + patterns = [ + rf'(?:text|content-desc)="{esc}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', + rf'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*(?:text|content-desc)="{esc}"', + ] + for pat in patterns: + for m in re.finditer(pat, xml, flags=re.I): + out.append(tuple(map(int, m.groups()))) # type: ignore[arg-type] + return out + + +def find_bounds_re(xml: str, pattern: str) -> List[Tuple[str, Bounds]]: + out: List[Tuple[str, Bounds]] = [] + for m in re.finditer( + rf'(?:text|content-desc)="([^"]*{pattern}[^"]*)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', + xml, + flags=re.I, + ): + label = m.group(1) + b = tuple(map(int, m.group(2, 3, 4, 5))) + out.append((label, b)) # type: ignore[arg-type] + return out + + +def center(b: Bounds) -> Tuple[int, int]: + x1, y1, x2, y2 = b + return (x1 + x2) // 2, (y1 + y2) // 2 + + +def tap(serial: str, x: int, y: int) -> None: + adb(serial, "shell", "input", "tap", str(x), str(y)) + + +def tap_bounds(serial: str, b: Bounds) -> None: + x, y = center(b) + tap(serial, x, y) + + +def dismiss_anr(serial: str, xml: str) -> bool: + """If System UI isn't responding, prefer Wait over Close.""" + if "isn't responding" not in xml and "reagiert nicht" not in xml.lower(): + return False + for label in ("Wait", "Warten", "OK"): + bs = find_bounds(xml, label) + if bs: + tap_bounds(serial, bs[0]) + return True + # fallback: right-ish button often Wait + adb(serial, "shell", "input", "keyevent", "22") + adb(serial, "shell", "input", "keyevent", "66") + return True + + +# Labels seen on wallet UI (EN/DE/FR) — expand as needed +CONFIRM_LABELS = [ + "Confirm", + "Confirm withdrawal", + "Withdraw", + "Accept", + "I accept", + "Agree", + "Continue", + "Next", + "OK", + "Pay", + "Pay now", + "Bestätigen", + "Abheben", + "Akzeptieren", + "Zustimmen", + "Weiter", + "Bezahlen", + "Confirmer", + "Retirer", + "Accepter", + "Payer", +] + +TOS_LABELS = [ + "I accept", + "Accept", + "Agree", + "Akzeptieren", + "Zustimmen", + "Accepter", +] + + +def try_tap_any(serial: str, xml: str, labels: Sequence[str]) -> Optional[str]: + for label in labels: + bs = find_bounds(xml, label) + if bs: + tap_bounds(serial, bs[0]) + return label + return None + + +def gui_drive( + serial: str, + out_dir: Path, + *, + rounds: int = 8, + sleep_s: float = 3.0, + phase: str = "gui", +) -> dict: + """Poll UI, dismiss ANR, tap confirm/ToS-like buttons. Returns status dict.""" + out_dir.mkdir(parents=True, exist_ok=True) + seen_texts: List[str] = [] + taps: List[str] = [] + anr = 0 + for i in range(1, rounds + 1): + xml_path = out_dir / f"{phase}-{i:02d}-ui.xml" + png_path = out_dir / f"{phase}-{i:02d}.png" + try: + xml = dump_ui(serial, xml_path) + except Exception as e: + taps.append(f"dump-fail:{e}") + time.sleep(sleep_s) + continue + try: + screencap(serial, png_path) + except Exception: + pass + ts = texts(xml) + seen_texts.extend(ts[:20]) + if dismiss_anr(serial, xml): + anr += 1 + time.sleep(sleep_s) + continue + hit = try_tap_any(serial, xml, CONFIRM_LABELS) + if hit: + taps.append(hit) + time.sleep(sleep_s) + continue + # partial regex for amount confirm rows + for label, b in find_bounds_re(xml, r"Confirm|Withdraw|Pay|Accept|Bestätig|Abheben|Payer"): + tap_bounds(serial, b) + taps.append(f"re:{label}") + time.sleep(sleep_s) + break + else: + time.sleep(sleep_s) + return { + "taps": taps, + "anr_dismissals": anr, + "sample_texts": seen_texts[-40:], + "rounds": rounds, + } diff --git a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh index 958ca84..277ad07 100755 --- a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh @@ -82,12 +82,18 @@ echo "copied → $STAMP_APK" echo "$COMMIT" >"$ROOT/out/build-commit.txt" 2>/dev/null || mkdir -p "$ROOT/out" && echo "$COMMIT" >"$ROOT/out/build-commit.txt" echo "$APK" >"$ROOT/out/build-apk-path.txt" -# Same smoke as published F-Droid path +# Same smoke as published F-Droid path (CLI hybrid or GUI) export APK_PATH="$STAMP_APK" export APK_NAME="$(basename "$STAMP_APK")" export PKG export OUT_DIR="${OUT_DIR:-$ROOT/out-source}" mkdir -p "$OUT_DIR" -echo "=== smoke with built APK (PKG=$PKG) ===" -exec env APK_DIR="$ROOT/apks" APK_NAME="$APK_NAME" PKG="$PKG" OUT_DIR="$OUT_DIR" \ - "$SMOKE" +SMOKE_CMD="$SMOKE" +if [ "${GUI:-0}" = "1" ]; then + SMOKE_CMD="$ROOT/run-android-gui-smoke.sh" + OUT_DIR="${OUT_DIR%-source}-source-gui" + export OUT_DIR +fi +echo "=== smoke with built APK (PKG=$PKG · GUI=${GUI:-0}) ===" +exec env APK_DIR="$ROOT/apks" APK_NAME="$APK_NAME" APK_PATH="$APK_PATH" PKG="$PKG" OUT_DIR="$OUT_DIR" \ + "$SMOKE_CMD" diff --git a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh new file mode 100755 index 0000000..241ab2c --- /dev/null +++ b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# GUI-oriented Android smoke (vanilla level): deep-link entry + UI taps. +# +# Entry shortcut (legitimate — same as scanning a QR on the landing): +# adb VIEW taler://withdraw/… and taler://pay… +# Then drives the **graphical UI** via uiautomator dump + taps (Confirm / ToS / Pay). +# +# Systems: STACK=goa | stage | auto +# APK: published F-Droid or APK_PATH=… (from-source) +# +# See AUTOMATED-GUI-NOTES.md +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=run-android-pay-smoke.sh +# We re-use install/URI resolution by calling common pieces inline. + +export OUT_DIR="${OUT_DIR:-$ROOT/out-gui}" +export GUI_ROUNDS="${GUI_ROUNDS:-10}" +export GUI_SLEEP="${GUI_SLEEP:-3}" + +# Install + deliver intents via existing smoke, but skip its weak single-tap +# by setting GUI_MODE=1 which we implement here more fully. +# Simpler: do install/URI here by sourcing patterns from pay-smoke via env + python. + +# Delegate install + URI start to pay-smoke with GUI_AFTER=1 if we patch it; +# instead run a self-contained flow calling pay-smoke pieces. + +APK_DIR="${APK_DIR:-$ROOT/apks}" +APK_URL="${APK_URL:-https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}" +APK_NAME="${APK_NAME:-net.taler.wallet.fdroid_854.apk}" +APK_PATH="${APK_PATH:-}" +PKG="${PKG:-net.taler.wallet.fdroid}" +STACK="${STACK:-auto}" +SERIAL="${SERIAL:-}" +mkdir -p "$OUT_DIR" + +if ! command -v adb >/dev/null 2>&1; then + [ -x "$HOME/Android/Sdk/platform-tools/adb" ] && export PATH="$HOME/Android/Sdk/platform-tools:$PATH" +fi +command -v adb >/dev/null || { echo "adb missing" >&2; exit 2; } + +if [ -z "$SERIAL" ]; then + SERIAL=$(adb devices | awk '/\tdevice$/{print $1; exit}') +fi +[ -n "$SERIAL" ] || { echo "no adb device" >&2; exit 3; } +ADB=(adb -s "$SERIAL") +echo "device: $SERIAL mode: GUI (uiautomator)" + +case "$STACK" in + auto) + if curl -sfS -m 5 -o /dev/null https://bank.hacktivism.ch/config; then STACK=goa; else STACK=stage; fi + ;; +esac +case "$STACK" in + goa) + BANK=https://bank.hacktivism.ch + MERCHANT=https://taler.hacktivism.ch + WITHDRAW_JSON="$BANK/intro/demo-withdraw.json" + ;; + stage) + BANK=https://stage.bank.lefrancpaysan.ch + MERCHANT=https://stage.monnaie.lefrancpaysan.ch + WITHDRAW_JSON="$BANK/intro/demo-withdraw.json" + ;; + *) echo "unknown STACK" >&2; exit 2 ;; +esac +echo "stack: $STACK" + +if [ -n "$APK_PATH" ] && [ -f "$APK_PATH" ]; then + : +elif [ -f "$APK_DIR/$APK_NAME" ]; then + APK_PATH="$APK_DIR/$APK_NAME" +else + APK_PATH="$APK_DIR/$APK_NAME" + mkdir -p "$APK_DIR" + curl -fL --retry 3 -o "$APK_PATH" "$APK_URL" +fi +echo "apk: $APK_PATH pkg: $PKG" + +"${ADB[@]}" wait-for-device +"${ADB[@]}" install -r "$APK_PATH" +"${ADB[@]}" shell settings put global window_animation_scale 0 || true +"${ADB[@]}" shell settings put global transition_animation_scale 0 || true +"${ADB[@]}" shell settings put global animator_duration_scale 0 || true + +WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")') +[ -n "$WURI" ] || { echo "no withdraw uri" >&2; exit 4; } +echo "withdraw: $WURI" +echo "$WURI" >"$OUT_DIR/last-withdraw.uri" + +"${ADB[@]}" shell am force-stop "$PKG" 2>/dev/null || true +"${ADB[@]}" logcat -c 2>/dev/null || true + +# --- Shortcut A: deep-link withdraw (≡ QR scan on landing) --- +"${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" | tee "$OUT_DIR/start-withdraw.txt" +sleep 5 + +# --- Graphical: drive UI --- +python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY' +import json, sys, importlib.util +from pathlib import Path +lib_path, serial, out, rounds, sleep_s = ( + Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5]) +) +spec = importlib.util.spec_from_file_location("lib_ui", lib_path) +lib = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lib) +st = lib.gui_drive(serial, out / "gui-withdraw", rounds=rounds, sleep_s=sleep_s, phase="wd") +(out / "gui-withdraw-status.json").write_text(json.dumps(st, indent=2)) +print("gui-withdraw taps:", st.get("taps")) +print("gui-withdraw anr:", st.get("anr_dismissals")) +PY + +# Pay URI +PAY_URI="" +if [ "$STACK" = "stage" ]; then + if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \ + "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then + PAY_URI=$(printf '%s' "$RESP" | python3 -c 'import json,sys;d=json.load(sys.stdin);oid=d.get("order_id")or"";tok=d.get("token")or""; +print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok) if oid and tok else "")') + fi +else + PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana" +fi + +if [ -n "$PAY_URI" ]; then + echo "pay: $PAY_URI" + echo "$PAY_URI" >"$OUT_DIR/last-pay.uri" + # Shortcut B: deep-link pay (≡ scan pay QR / open paywall link) + "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" | tee "$OUT_DIR/start-pay.txt" + sleep 5 + python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY' +import json, sys, importlib.util +from pathlib import Path +lib_path, serial, out, rounds, sleep_s = ( + Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5]) +) +spec = importlib.util.spec_from_file_location("lib_ui", lib_path) +lib = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lib) +st = lib.gui_drive(serial, out / "gui-pay", rounds=rounds, sleep_s=sleep_s, phase="pay") +(out / "gui-pay-status.json").write_text(json.dumps(st, indent=2)) +print("gui-pay taps:", st.get("taps")) +print("gui-pay anr:", st.get("anr_dismissals")) +PY +fi + +"${ADB[@]}" logcat -d -t 400 >"$OUT_DIR/logcat.txt" || true +grep -iE 'taler-wallet|prepareBank|preparePay|confirm|Error|success' "$OUT_DIR/logcat.txt" \ + | tail -100 >"$OUT_DIR/logcat-taler.txt" || true + +echo +echo "=== GUI smoke summary ($STACK) ===" +echo "package: $PKG" +echo "shortcut withdraw URI: delivered" +echo "GUI withdraw: see $OUT_DIR/gui-withdraw-status.json + screenshots" +[ -n "$PAY_URI" ] && echo "shortcut pay URI: delivered" +[ -n "$PAY_URI" ] && echo "GUI pay: see $OUT_DIR/gui-pay-status.json + screenshots" +if grep -q prepareBankIntegratedWithdrawal "$OUT_DIR/logcat.txt" 2>/dev/null; then + echo "wallet-core: prepareBankIntegratedWithdrawal YES" +else + echo "wallet-core: prepareBankIntegratedWithdrawal no (ANR/slow UI?)" +fi +if grep -qiE 'preparePay|confirmPay|pay-template|preparePurchase' "$OUT_DIR/logcat.txt" 2>/dev/null; then + echo "wallet-core: pay path YES" +else + echo "wallet-core: pay path not confirmed in logcat" +fi +echo "artifacts: $OUT_DIR/" +ls -la "$OUT_DIR" | sed 's/^/ /' +echo "Documented shortcuts → AUTOMATED-GUI-NOTES.md" From ed6fca0958bf538943ff75adb18f090c19017309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:02:41 +0200 Subject: [PATCH 247/326] monitoring: port GOA GUI chain from taler-android inference branches Document dev/hernani-inference/gui-workflows and related fix branches; add Linux-capable run-goa-gui-chain.sh (mint + deep-link + UI taps). --- .../android-test/AUTOMATED-GUI-NOTES.md | 55 ++- .../taler-monitoring/android-test/README.md | 15 +- .../android-test/run-goa-gui-chain.sh | 345 ++++++++++++++++++ 3 files changed, 413 insertions(+), 2 deletions(-) create mode 100755 scripts/taler-monitoring/android-test/run-goa-gui-chain.sh diff --git a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md index 4013505..2e3993a 100644 --- a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md +++ b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md @@ -100,11 +100,61 @@ balance/settlement is not asserted (settlement still better covered by | `run-android-gui-smoke.sh` | **Android app UI** surface for the same stacks | | Combined | CLI = money path green; GUI = app UX green as far as taps go | +## Upstream: `taler-android` `dev/hernani-inference/*` + +Branch name is **`hernani-inference`** (not “inferenced”). Remote: +`git@git.taler.net:taler-android.git`. + +| Branch | Relevance for automation | +|--------|---------------------------| +| **`dev/hernani-inference/gui-workflows`** | **Primary:** macOS emulator helpers + **GOA GUI chain** (`scripts/goa-chain-emu.sh`, `docs/gui-workflows.md`, `setup-avds.sh`, `start-emulators.sh`). Deep-link + `uiautomator` text taps + bank API mint + shop templates. | +| **`fix/bank-withdraw-auto-exchange`** | App fix: resolve exchange when bank omits it (needed for GOA withdraw UI). | +| **`fix/withdraw-spinner-fallback`** | App fix: stop endless withdraw spinner (+ OIM UI work). | +| **`experimental-oim` / `experimental-oim-pos`** | Experimental cash denomination UI; optional for demos, not required for chain. | + +### Port into this tree (Linux-capable) + +| Upstream (gui-workflows) | Here (`android-test/`) | +|--------------------------|-------------------------| +| `goa-chain-emu.sh` | **`run-goa-gui-chain.sh`** (goa + stage, Linux paths, explorer secrets as monitoring ladder) | +| `tap_text` / `click_through` | same idea in chain + `lib_ui.py` | +| `mint_withdraw` explorer API | same (Basic → token → POST withdrawals) | +| `mint_pay` public templates | same (goa-shop products / stage fermes templates) | +| `android-env.sh` Homebrew | use `$HOME/Android/Sdk` + system `adb` (see GIT-AUTOMATION-NOTES) | +| macOS-only docs | **Linux supported** for chain/smoke; AVD setup still DIY on Linux | + +Open issues mirrored from upstream `ISSUES.md` (gui-workflows): + +- **I-and-gui-001** — taps still best-effort; ToS/confirm may need human +- **I-and-gui-003** — `EXP_PW_FILE` required for mint +- **I-and-gui-004** — emulator RAM cost (this host ~3.7 GiB) +- **I-and-gui-007** — for reliable withdraw GUI, build/install app from **fix/bank-withdraw-auto-exchange** or **fix/withdraw-spinner-fallback**, not plain master alone + +Recommended demo stack: + +```text +1) Checkout taler-android: fix/withdraw-spinner-fallback (or merge fixes) +2) ./gradlew :wallet:installFdroidDebug +3) Boot emulator / phone +4) android-test/run-goa-gui-chain.sh # STACK=goa|stage +``` + +```bash +# GOA chain (explorer mint + GUI taps) +EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \ + STACK=goa PKG=net.taler.wallet.fdroid.debug \ + ./run-goa-gui-chain.sh + +# Stage TESTPAYSAN (explorer via stagepaysan SSH if EXP_PW_FILE unset) +STACK=stage ./run-goa-gui-chain.sh +``` + ## Future upgrades (not required for vanilla) - Maestro / Appium flows with stable resource-ids - Explicit wait for text “Withdrawal complete” / balance -- Separate jobs: F-Droid vs nightly source vs Play flavor +- Port Paivana unlock scripts (upstream I-and-gui-002) +- Dual AVD wallet+POS (upstream I-and-gui-008) - Record video (`adb shell screenrecord`) on failure ## Checklist — “vanille erreicht?” @@ -114,6 +164,9 @@ balance/settlement is not asserted (settlement still better covered by - [x] Source-build path (same smoke after build) - [x] Deep-link shortcuts documented - [x] Multi-round **graphical** taps (uiautomator) +- [x] GOA-style **chain** (multi withdraw + multi pay) from inference branch - [x] Screenshots + UI XML + logcat artifacts +- [x] Inference branch mapping documented - [ ] Fully reliable unattended Confirm→settled on low-RAM emulator (host-limited) +- [ ] App built from withdraw-fix branch by default in build script (optional) - [ ] Assert final balance in app UI (optional next step) diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 19df5ff..dab2365 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -38,6 +38,19 @@ STACK=stage ./run-android-gui-smoke.sh GUI=1 ./run-android-build-and-smoke.sh ``` -Documented shortcuts and limits: **[AUTOMATED-GUI-NOTES.md](./AUTOMATED-GUI-NOTES.md)**. +## 4) Full GOA/stage chain (from `dev/hernani-inference/gui-workflows`) + +Bank API mint → withdraw deep-link → taps → template pay → taps: + +```bash +# needs explorer password + device +EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \ + STACK=goa PKG=net.taler.wallet.fdroid.debug \ + ./run-goa-gui-chain.sh + +STACK=stage ./run-goa-gui-chain.sh +``` + +Documented shortcuts, inference branches, limits: **[AUTOMATED-GUI-NOTES.md](./AUTOMATED-GUI-NOTES.md)**. See also parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). diff --git a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh new file mode 100755 index 0000000..a1818d4 --- /dev/null +++ b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh @@ -0,0 +1,345 @@ +#!/usr/bin/env bash +# GOA (and optionally stage) GUI chain on Android — adapted from +# taler-android branch **dev/hernani-inference/gui-workflows** +# (`scripts/goa-chain-emu.sh`, docs/gui-workflows.md). +# +# Linux + macOS: uses adb + uiautomator only (no Homebrew hard dependency). +# Upstream scripts are documented as macOS-only for SDK install paths. +# +# Flow: +# 1) API mint withdraw (explorer) → VIEW taler://withdraw → GUI taps +# 2) Merchant template POST → VIEW taler://pay → GUI taps +# +# Usage: +# ./run-goa-gui-chain.sh +# STACK=stage ./run-goa-gui-chain.sh +# WITHDRAW_AMOUNTS='GOA:10' PAY_LIMIT=2 ./run-goa-gui-chain.sh +# PKG=net.taler.wallet.fdroid.debug ./run-goa-gui-chain.sh --no-clear +# +# Env: ANDROID_SERIAL, PKG, EXP_PW_FILE, BANK, MERCHANT, INSTANCE, +# WITHDRAW_AMOUNTS, PRODUCTS, PAY_LIMIT, SHOTDIR, PAUSE +# +# See AUTOMATED-GUI-NOTES.md § "hernani-inference branches" +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) + +CLEAR=1 +for a in "$@"; do + case "$a" in + --no-clear) CLEAR=0 ;; + -h|--help) sed -n '2,28p' "$0"; exit 0 ;; + esac +done + +die() { echo "ERROR: $*" >&2; exit 1; } +step() { echo; echo "======== $* ========"; } +info() { echo " $*"; } +pause() { sleep "${1:-${PAUSE:-0.8}}"; } + +if ! command -v adb >/dev/null 2>&1; then + [ -x "$HOME/Android/Sdk/platform-tools/adb" ] && export PATH="$HOME/Android/Sdk/platform-tools:$PATH" +fi +command -v adb >/dev/null || die "adb not found" +command -v python3 >/dev/null || die "python3 required" + +STACK="${STACK:-goa}" +case "$STACK" in + goa|hacktivism) + PKG="${PKG:-net.taler.wallet.fdroid.debug}" + BANK="${BANK:-https://bank.hacktivism.ch}" + MERCHANT="${MERCHANT:-https://taler.hacktivism.ch}" + INSTANCE="${INSTANCE:-goa-shop}" + DONATE_INSTANCE="${DONATE_INSTANCE:-goa-demo-cp4zqk}" + DONATE_TEMPLATE="${DONATE_TEMPLATE:-goa-free}" + DEFAULT_AMOUNTS=("GOA:10" "GOA:20") + DEFAULT_PAYS=("product:orbit-sticker" "product:nebula-coffee" "product:voidwave-playlist" "donate:GOA:12") + CUR_HINT=GOA + ;; + stage|testpaysan) + PKG="${PKG:-net.taler.wallet.fdroid.debug}" + BANK="${BANK:-https://stage.bank.lefrancpaysan.ch}" + MERCHANT="${MERCHANT:-https://stage.monnaie.lefrancpaysan.ch}" + INSTANCE="${INSTANCE:-fermes-des-collines}" + DONATE_INSTANCE="${DONATE_INSTANCE:-fermes-des-collines}" + DONATE_TEMPLATE="${DONATE_TEMPLATE:-don-panier-libre}" + DEFAULT_AMOUNTS=("TESTPAYSAN:10" "TESTPAYSAN:20") + DEFAULT_PAYS=("product:panier-legumes" "product:fromage-chevre" "product:oeufs-6") + CUR_HINT=TESTPAYSAN + ;; + *) die "STACK must be goa|stage (got $STACK)" ;; +esac + +ACT="${ACT:-$PKG/net.taler.wallet.main.MainActivity}" +EXP_USER="${EXP_USER:-explorer}" +PAY_LIMIT="${PAY_LIMIT:-4}" +PAUSE="${PAUSE:-0.8}" +SHOTDIR="${SHOTDIR:-$ROOT/out-gui-chain/$(date +%Y%m%d-%H%M%S)-$STACK}" +mkdir -p "$SHOTDIR" + +# Explorer password (same search as taler-monitoring ladder) +if [[ -z "${EXP_PW_FILE:-}" ]]; then + for cand in \ + "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt" \ + "${HOME}/.config/taler-landing/bank-explorer-password.txt" + do + [[ -f "$cand" ]] && EXP_PW_FILE="$cand" && break + done +fi +# stage: try stagepaysan secret via ssh if missing +if [[ ! -f "${EXP_PW_FILE:-}" && "$STACK" = "stage" ]]; then + for host in francpaysan-stage-user francpaysan-host; do + tmp=$(mktemp) + if ssh -o BatchMode=yes -o ConnectTimeout=10 "$host" \ + 'tr -d "\n\r" "$tmp" 2>/dev/null && [[ -s "$tmp" ]]; then + EXP_PW_FILE="$tmp" + info "explorer password via $host" + break + fi + rm -f "$tmp" + done +fi +[[ -n "${EXP_PW_FILE:-}" && -f "$EXP_PW_FILE" ]] || die "set EXP_PW_FILE (explorer password)" + +if [[ -z "${ANDROID_SERIAL:-}" ]]; then + n=$(adb devices | awk 'NR>1 && $2=="device"{c++} END{print c+0}') + [[ "$n" -ge 1 ]] || die "no adb device (start emulator or plug phone)" + export ANDROID_SERIAL=$(adb devices | awk 'NR>1 && $2=="device"{print $1; exit}') +fi +info "ANDROID_SERIAL=$ANDROID_SERIAL STACK=$STACK PKG=$PKG SHOTDIR=$SHOTDIR" +ADB=(adb -s "$ANDROID_SERIAL") + +adb_sh() { "${ADB[@]}" shell "$@"; } + +shot() { + local n="$1" + adb_sh screencap -p "/sdcard/demo-$n.png" 2>/dev/null || true + "${ADB[@]}" pull "/sdcard/demo-$n.png" "$SHOTDIR/$n.png" >/dev/null 2>&1 || true + info "[shot] $SHOTDIR/$n.png" +} + +tap_text() { + local label="$1" + adb_sh uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || return 1 + "${ADB[@]}" pull /sdcard/ui.xml "$SHOTDIR/ui.xml" >/dev/null 2>&1 || return 1 + SERIAL="$ANDROID_SERIAL" python3 - "$label" "$SHOTDIR/ui.xml" <<'PY' +import os, re, subprocess, sys +from pathlib import Path +label, path = sys.argv[1], sys.argv[2] +serial = os.environ.get("SERIAL") or os.environ.get("ANDROID_SERIAL") or "" +xml = Path(path).read_text(errors="replace") +# ANR dismiss +if "isn't responding" in xml or "reagiert nicht" in xml.lower(): + for lab in ("Wait", "Warten"): + for m in re.finditer(rf'text="{lab}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml): + x = (int(m.group(1))+int(m.group(3)))//2 + y = (int(m.group(2))+int(m.group(4)))//2 + subprocess.run(["adb","-s",serial,"shell","input","tap",str(x),str(y)], check=False) + print(f"anr-wait @{x},{y}") + sys.exit(0) +for pat in [ + rf'text="{re.escape(label)}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', + rf'text="[^"]*{re.escape(label)}[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', + rf'content-desc="[^"]*{re.escape(label)}[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', +]: + for m in re.finditer(pat, xml, re.I): + chunk = xml[max(0, m.start()-120):m.end()] + if 'enabled="false"' in chunk: + continue + x = (int(m.group(1))+int(m.group(3)))//2 + y = (int(m.group(2))+int(m.group(4)))//2 + print(f"tap {label!r} @{x},{y}") + cmd = ["adb","shell","input","tap",str(x),str(y)] + if serial: + cmd = ["adb","-s",serial,"shell","input","tap",str(x),str(y)] + subprocess.run(cmd, check=False) + sys.exit(0) +print("miss", label) +sys.exit(1) +PY +} + +mint_withdraw() { + local amount="$1" + EXP_USER="$EXP_USER" EXP_PW_FILE="$EXP_PW_FILE" BANK="$BANK" \ + python3 - "$amount" <<'PY' +import base64, json, os, ssl, sys, urllib.request +from pathlib import Path +amount = sys.argv[1] +bank = os.environ["BANK"].rstrip("/") +user = os.environ["EXP_USER"] +pw = Path(os.environ["EXP_PW_FILE"]).read_text().strip() +auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode() +ctx = ssl.create_default_context() + +def req(method, url, data=None, headers=None): + h = dict(headers or {}) + body = None + if data is not None: + body = json.dumps(data).encode() + h["Content-Type"] = "application/json" + r = urllib.request.Request(url, data=body, headers=h, method=method) + with urllib.request.urlopen(r, context=ctx, timeout=25) as resp: + return json.load(resp) + +tok = req( + "POST", + f"{bank}/accounts/{user}/token", + {"scope": "readwrite", "duration": {"d_us": 3600_000_000}}, + {"Authorization": auth}, +)["access_token"] +wd = req( + "POST", + f"{bank}/accounts/{user}/withdrawals", + {"amount": amount}, + {"Authorization": f"Bearer {tok}"}, +) +print(wd.get("taler_withdraw_uri") or wd.get("talerWithdrawUri") or "") +PY +} + +mint_pay() { + local mode="$1" + MERCHANT="$MERCHANT" INSTANCE="$INSTANCE" \ + DONATE_INSTANCE="$DONATE_INSTANCE" DONATE_TEMPLATE="$DONATE_TEMPLATE" \ + python3 - "$mode" <<'PY' +import json, os, ssl, sys, urllib.error, urllib.request +mode = sys.argv[1] +merchant = os.environ["MERCHANT"].rstrip("/") +inst = os.environ["INSTANCE"] +d_inst = os.environ["DONATE_INSTANCE"] +d_tmpl = os.environ["DONATE_TEMPLATE"] +ctx = ssl.create_default_context() + +def post(url, data): + body = json.dumps(data).encode() + r = urllib.request.Request(url, data=body, method="POST", + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(r, context=ctx, timeout=25) as resp: + return resp.status, json.load(resp) + except urllib.error.HTTPError as e: + raw = e.read() if hasattr(e, "read") else b"" + try: + return getattr(e, "code", None), json.loads(raw) + except Exception as err: + raise SystemExit(f"POST {url} failed: {e} body={raw[:200]!r}") from err + +if mode.startswith("product:"): + pid = mode.split(":", 1)[1] + st, d = post(f"{merchant}/instances/{inst}/templates/{pid}", {}) + use_inst = inst +elif mode.startswith("donate:"): + amt = mode.split(":", 1)[1] + st, d = post(f"{merchant}/instances/{d_inst}/templates/{d_tmpl}", {"amount": amt}) + use_inst = d_inst +else: + raise SystemExit(f"bad mode {mode!r}") + +if "taler_pay_uri" in d: + print(d["taler_pay_uri"]); raise SystemExit(0) +oid, tok = d.get("order_id"), d.get("token") +if not oid or not tok: + raise SystemExit(f"no order: {d!r}"[:200]) +host = merchant.replace("https://","").replace("http://","").split("/")[0] +print(f"taler://pay/{host}/instances/{use_inst}/{oid}/?c={tok}") +PY +} + +click_through() { + local labels=("$@") + local i lab + for i in 1 2 3 4 5 6 7 8; do + for lab in "${labels[@]}"; do + if tap_text "$lab" 2>/dev/null; then + pause 0.9 + fi + done + pause 0.4 + done +} + +if [[ -n "${WITHDRAW_AMOUNTS:-}" ]]; then + # shellcheck disable=SC2206 + AMOUNTS=($WITHDRAW_AMOUNTS) +else + AMOUNTS=("${DEFAULT_AMOUNTS[@]}") +fi +if [[ -n "${PRODUCTS:-}" ]]; then + PAYS=() + for p in $PRODUCTS; do PAYS+=("product:$p"); done +else + PAYS=("${DEFAULT_PAYS[@]}") +fi + +############################ +step "0 device + wallet ($STACK / $CUR_HINT)" +adb_sh input keyevent KEYCODE_WAKEUP 2>/dev/null || true +adb_sh wm dismiss-keyguard 2>/dev/null || true +if [[ "$CLEAR" -eq 1 ]]; then + info "pm clear $PKG" + adb_sh pm clear "$PKG" >/dev/null 2>&1 || true +fi +adb_sh am force-stop "$PKG" 2>/dev/null || true +pause 0.4 +# Prefer explicit MainActivity (from gui-workflows); fall back to VIEW launcher +if ! adb_sh am start -n "$ACT" >/dev/null 2>&1; then + info "MainActivity path failed — monkey launch" + adb_sh monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true +fi +pause 2.0 +shot "00-start" + +############################ +step "2 withdraw chain" +idx=0 +for AMT in "${AMOUNTS[@]}"; do + idx=$((idx + 1)) + step "2.$idx withdraw $AMT" + URI="$(mint_withdraw "$AMT" | head -1)" + [[ -n "$URI" ]] || die "mint failed for $AMT" + info "URI=$URI" + echo "$URI" >>"$SHOTDIR/withdraw-uris.txt" + adb_sh am start -a android.intent.action.VIEW -d "$URI" "$PKG" >/dev/null + pause 1.5 + shot "w${idx}-open" + click_through "Accept" "I accept" "Accept terms" "Confirm" "Continue" "Withdraw" "Next" "OK" "Agree" "Bestätigen" "Abheben" "Akzeptieren" + pause 3.0 + shot "w${idx}-after" + adb_sh am start -n "$ACT" >/dev/null 2>&1 || true + pause 0.6 + tap_text "Assets" 2>/dev/null || true + pause 0.4 + shot "w${idx}-assets" +done + +############################ +step "3 pay chain (limit=$PAY_LIMIT)" +pidx=0 +for PAY in "${PAYS[@]}"; do + pidx=$((pidx + 1)) + [[ "$pidx" -gt "$PAY_LIMIT" ]] && break + step "3.$pidx pay $PAY" + if ! PAYURI="$(mint_pay "$PAY" | head -1)"; then + info "mint_pay failed for $PAY — skip" + continue + fi + [[ -n "$PAYURI" ]] || { info "empty pay uri — skip"; continue; } + info "PAYURI=$PAYURI" + echo "$PAYURI" >>"$SHOTDIR/pay-uris.txt" + adb_sh am start -a android.intent.action.VIEW -d "$PAYURI" "$PKG" >/dev/null + pause 1.5 + shot "p${pidx}-open" + click_through "Pay" "Confirm" "Accept" "Continue" "OK" "Next" "Bezahlen" "Bestätigen" "Payer" + pause 1.5 + shot "p${pidx}-after" + adb_sh am start -n "$ACT" >/dev/null 2>&1 || true + pause 0.5 +done + +step "4 done → $SHOTDIR" +ls -la "$SHOTDIR" | tail -40 +adb_sh dumpsys window 2>/dev/null | grep mCurrentFocus | head -1 || true +info "PKG=$PKG amounts=${AMOUNTS[*]} pays(limit)=$PAY_LIMIT" +echo "See AUTOMATED-GUI-NOTES.md (ported from taler-android dev/hernani-inference/gui-workflows)" From 4d49657a63e5d384f51d7e130baf34748316bfc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:02:41 +0200 Subject: [PATCH 248/326] monitoring: port GOA GUI chain from taler-android inference branches Document dev/hernani-inference/gui-workflows and related fix branches; add Linux-capable run-goa-gui-chain.sh (mint + deep-link + UI taps). --- .../android-test/AUTOMATED-GUI-NOTES.md | 55 ++- .../taler-monitoring/android-test/README.md | 15 +- .../android-test/run-goa-gui-chain.sh | 345 ++++++++++++++++++ 3 files changed, 413 insertions(+), 2 deletions(-) create mode 100755 scripts/taler-monitoring/android-test/run-goa-gui-chain.sh diff --git a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md index 4013505..2e3993a 100644 --- a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md +++ b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md @@ -100,11 +100,61 @@ balance/settlement is not asserted (settlement still better covered by | `run-android-gui-smoke.sh` | **Android app UI** surface for the same stacks | | Combined | CLI = money path green; GUI = app UX green as far as taps go | +## Upstream: `taler-android` `dev/hernani-inference/*` + +Branch name is **`hernani-inference`** (not “inferenced”). Remote: +`git@git.taler.net:taler-android.git`. + +| Branch | Relevance for automation | +|--------|---------------------------| +| **`dev/hernani-inference/gui-workflows`** | **Primary:** macOS emulator helpers + **GOA GUI chain** (`scripts/goa-chain-emu.sh`, `docs/gui-workflows.md`, `setup-avds.sh`, `start-emulators.sh`). Deep-link + `uiautomator` text taps + bank API mint + shop templates. | +| **`fix/bank-withdraw-auto-exchange`** | App fix: resolve exchange when bank omits it (needed for GOA withdraw UI). | +| **`fix/withdraw-spinner-fallback`** | App fix: stop endless withdraw spinner (+ OIM UI work). | +| **`experimental-oim` / `experimental-oim-pos`** | Experimental cash denomination UI; optional for demos, not required for chain. | + +### Port into this tree (Linux-capable) + +| Upstream (gui-workflows) | Here (`android-test/`) | +|--------------------------|-------------------------| +| `goa-chain-emu.sh` | **`run-goa-gui-chain.sh`** (goa + stage, Linux paths, explorer secrets as monitoring ladder) | +| `tap_text` / `click_through` | same idea in chain + `lib_ui.py` | +| `mint_withdraw` explorer API | same (Basic → token → POST withdrawals) | +| `mint_pay` public templates | same (goa-shop products / stage fermes templates) | +| `android-env.sh` Homebrew | use `$HOME/Android/Sdk` + system `adb` (see GIT-AUTOMATION-NOTES) | +| macOS-only docs | **Linux supported** for chain/smoke; AVD setup still DIY on Linux | + +Open issues mirrored from upstream `ISSUES.md` (gui-workflows): + +- **I-and-gui-001** — taps still best-effort; ToS/confirm may need human +- **I-and-gui-003** — `EXP_PW_FILE` required for mint +- **I-and-gui-004** — emulator RAM cost (this host ~3.7 GiB) +- **I-and-gui-007** — for reliable withdraw GUI, build/install app from **fix/bank-withdraw-auto-exchange** or **fix/withdraw-spinner-fallback**, not plain master alone + +Recommended demo stack: + +```text +1) Checkout taler-android: fix/withdraw-spinner-fallback (or merge fixes) +2) ./gradlew :wallet:installFdroidDebug +3) Boot emulator / phone +4) android-test/run-goa-gui-chain.sh # STACK=goa|stage +``` + +```bash +# GOA chain (explorer mint + GUI taps) +EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \ + STACK=goa PKG=net.taler.wallet.fdroid.debug \ + ./run-goa-gui-chain.sh + +# Stage TESTPAYSAN (explorer via stagepaysan SSH if EXP_PW_FILE unset) +STACK=stage ./run-goa-gui-chain.sh +``` + ## Future upgrades (not required for vanilla) - Maestro / Appium flows with stable resource-ids - Explicit wait for text “Withdrawal complete” / balance -- Separate jobs: F-Droid vs nightly source vs Play flavor +- Port Paivana unlock scripts (upstream I-and-gui-002) +- Dual AVD wallet+POS (upstream I-and-gui-008) - Record video (`adb shell screenrecord`) on failure ## Checklist — “vanille erreicht?” @@ -114,6 +164,9 @@ balance/settlement is not asserted (settlement still better covered by - [x] Source-build path (same smoke after build) - [x] Deep-link shortcuts documented - [x] Multi-round **graphical** taps (uiautomator) +- [x] GOA-style **chain** (multi withdraw + multi pay) from inference branch - [x] Screenshots + UI XML + logcat artifacts +- [x] Inference branch mapping documented - [ ] Fully reliable unattended Confirm→settled on low-RAM emulator (host-limited) +- [ ] App built from withdraw-fix branch by default in build script (optional) - [ ] Assert final balance in app UI (optional next step) diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 19df5ff..dab2365 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -38,6 +38,19 @@ STACK=stage ./run-android-gui-smoke.sh GUI=1 ./run-android-build-and-smoke.sh ``` -Documented shortcuts and limits: **[AUTOMATED-GUI-NOTES.md](./AUTOMATED-GUI-NOTES.md)**. +## 4) Full GOA/stage chain (from `dev/hernani-inference/gui-workflows`) + +Bank API mint → withdraw deep-link → taps → template pay → taps: + +```bash +# needs explorer password + device +EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \ + STACK=goa PKG=net.taler.wallet.fdroid.debug \ + ./run-goa-gui-chain.sh + +STACK=stage ./run-goa-gui-chain.sh +``` + +Documented shortcuts, inference branches, limits: **[AUTOMATED-GUI-NOTES.md](./AUTOMATED-GUI-NOTES.md)**. See also parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). diff --git a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh new file mode 100755 index 0000000..a1818d4 --- /dev/null +++ b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh @@ -0,0 +1,345 @@ +#!/usr/bin/env bash +# GOA (and optionally stage) GUI chain on Android — adapted from +# taler-android branch **dev/hernani-inference/gui-workflows** +# (`scripts/goa-chain-emu.sh`, docs/gui-workflows.md). +# +# Linux + macOS: uses adb + uiautomator only (no Homebrew hard dependency). +# Upstream scripts are documented as macOS-only for SDK install paths. +# +# Flow: +# 1) API mint withdraw (explorer) → VIEW taler://withdraw → GUI taps +# 2) Merchant template POST → VIEW taler://pay → GUI taps +# +# Usage: +# ./run-goa-gui-chain.sh +# STACK=stage ./run-goa-gui-chain.sh +# WITHDRAW_AMOUNTS='GOA:10' PAY_LIMIT=2 ./run-goa-gui-chain.sh +# PKG=net.taler.wallet.fdroid.debug ./run-goa-gui-chain.sh --no-clear +# +# Env: ANDROID_SERIAL, PKG, EXP_PW_FILE, BANK, MERCHANT, INSTANCE, +# WITHDRAW_AMOUNTS, PRODUCTS, PAY_LIMIT, SHOTDIR, PAUSE +# +# See AUTOMATED-GUI-NOTES.md § "hernani-inference branches" +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) + +CLEAR=1 +for a in "$@"; do + case "$a" in + --no-clear) CLEAR=0 ;; + -h|--help) sed -n '2,28p' "$0"; exit 0 ;; + esac +done + +die() { echo "ERROR: $*" >&2; exit 1; } +step() { echo; echo "======== $* ========"; } +info() { echo " $*"; } +pause() { sleep "${1:-${PAUSE:-0.8}}"; } + +if ! command -v adb >/dev/null 2>&1; then + [ -x "$HOME/Android/Sdk/platform-tools/adb" ] && export PATH="$HOME/Android/Sdk/platform-tools:$PATH" +fi +command -v adb >/dev/null || die "adb not found" +command -v python3 >/dev/null || die "python3 required" + +STACK="${STACK:-goa}" +case "$STACK" in + goa|hacktivism) + PKG="${PKG:-net.taler.wallet.fdroid.debug}" + BANK="${BANK:-https://bank.hacktivism.ch}" + MERCHANT="${MERCHANT:-https://taler.hacktivism.ch}" + INSTANCE="${INSTANCE:-goa-shop}" + DONATE_INSTANCE="${DONATE_INSTANCE:-goa-demo-cp4zqk}" + DONATE_TEMPLATE="${DONATE_TEMPLATE:-goa-free}" + DEFAULT_AMOUNTS=("GOA:10" "GOA:20") + DEFAULT_PAYS=("product:orbit-sticker" "product:nebula-coffee" "product:voidwave-playlist" "donate:GOA:12") + CUR_HINT=GOA + ;; + stage|testpaysan) + PKG="${PKG:-net.taler.wallet.fdroid.debug}" + BANK="${BANK:-https://stage.bank.lefrancpaysan.ch}" + MERCHANT="${MERCHANT:-https://stage.monnaie.lefrancpaysan.ch}" + INSTANCE="${INSTANCE:-fermes-des-collines}" + DONATE_INSTANCE="${DONATE_INSTANCE:-fermes-des-collines}" + DONATE_TEMPLATE="${DONATE_TEMPLATE:-don-panier-libre}" + DEFAULT_AMOUNTS=("TESTPAYSAN:10" "TESTPAYSAN:20") + DEFAULT_PAYS=("product:panier-legumes" "product:fromage-chevre" "product:oeufs-6") + CUR_HINT=TESTPAYSAN + ;; + *) die "STACK must be goa|stage (got $STACK)" ;; +esac + +ACT="${ACT:-$PKG/net.taler.wallet.main.MainActivity}" +EXP_USER="${EXP_USER:-explorer}" +PAY_LIMIT="${PAY_LIMIT:-4}" +PAUSE="${PAUSE:-0.8}" +SHOTDIR="${SHOTDIR:-$ROOT/out-gui-chain/$(date +%Y%m%d-%H%M%S)-$STACK}" +mkdir -p "$SHOTDIR" + +# Explorer password (same search as taler-monitoring ladder) +if [[ -z "${EXP_PW_FILE:-}" ]]; then + for cand in \ + "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt" \ + "${HOME}/.config/taler-landing/bank-explorer-password.txt" + do + [[ -f "$cand" ]] && EXP_PW_FILE="$cand" && break + done +fi +# stage: try stagepaysan secret via ssh if missing +if [[ ! -f "${EXP_PW_FILE:-}" && "$STACK" = "stage" ]]; then + for host in francpaysan-stage-user francpaysan-host; do + tmp=$(mktemp) + if ssh -o BatchMode=yes -o ConnectTimeout=10 "$host" \ + 'tr -d "\n\r" "$tmp" 2>/dev/null && [[ -s "$tmp" ]]; then + EXP_PW_FILE="$tmp" + info "explorer password via $host" + break + fi + rm -f "$tmp" + done +fi +[[ -n "${EXP_PW_FILE:-}" && -f "$EXP_PW_FILE" ]] || die "set EXP_PW_FILE (explorer password)" + +if [[ -z "${ANDROID_SERIAL:-}" ]]; then + n=$(adb devices | awk 'NR>1 && $2=="device"{c++} END{print c+0}') + [[ "$n" -ge 1 ]] || die "no adb device (start emulator or plug phone)" + export ANDROID_SERIAL=$(adb devices | awk 'NR>1 && $2=="device"{print $1; exit}') +fi +info "ANDROID_SERIAL=$ANDROID_SERIAL STACK=$STACK PKG=$PKG SHOTDIR=$SHOTDIR" +ADB=(adb -s "$ANDROID_SERIAL") + +adb_sh() { "${ADB[@]}" shell "$@"; } + +shot() { + local n="$1" + adb_sh screencap -p "/sdcard/demo-$n.png" 2>/dev/null || true + "${ADB[@]}" pull "/sdcard/demo-$n.png" "$SHOTDIR/$n.png" >/dev/null 2>&1 || true + info "[shot] $SHOTDIR/$n.png" +} + +tap_text() { + local label="$1" + adb_sh uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || return 1 + "${ADB[@]}" pull /sdcard/ui.xml "$SHOTDIR/ui.xml" >/dev/null 2>&1 || return 1 + SERIAL="$ANDROID_SERIAL" python3 - "$label" "$SHOTDIR/ui.xml" <<'PY' +import os, re, subprocess, sys +from pathlib import Path +label, path = sys.argv[1], sys.argv[2] +serial = os.environ.get("SERIAL") or os.environ.get("ANDROID_SERIAL") or "" +xml = Path(path).read_text(errors="replace") +# ANR dismiss +if "isn't responding" in xml or "reagiert nicht" in xml.lower(): + for lab in ("Wait", "Warten"): + for m in re.finditer(rf'text="{lab}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml): + x = (int(m.group(1))+int(m.group(3)))//2 + y = (int(m.group(2))+int(m.group(4)))//2 + subprocess.run(["adb","-s",serial,"shell","input","tap",str(x),str(y)], check=False) + print(f"anr-wait @{x},{y}") + sys.exit(0) +for pat in [ + rf'text="{re.escape(label)}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', + rf'text="[^"]*{re.escape(label)}[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', + rf'content-desc="[^"]*{re.escape(label)}[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', +]: + for m in re.finditer(pat, xml, re.I): + chunk = xml[max(0, m.start()-120):m.end()] + if 'enabled="false"' in chunk: + continue + x = (int(m.group(1))+int(m.group(3)))//2 + y = (int(m.group(2))+int(m.group(4)))//2 + print(f"tap {label!r} @{x},{y}") + cmd = ["adb","shell","input","tap",str(x),str(y)] + if serial: + cmd = ["adb","-s",serial,"shell","input","tap",str(x),str(y)] + subprocess.run(cmd, check=False) + sys.exit(0) +print("miss", label) +sys.exit(1) +PY +} + +mint_withdraw() { + local amount="$1" + EXP_USER="$EXP_USER" EXP_PW_FILE="$EXP_PW_FILE" BANK="$BANK" \ + python3 - "$amount" <<'PY' +import base64, json, os, ssl, sys, urllib.request +from pathlib import Path +amount = sys.argv[1] +bank = os.environ["BANK"].rstrip("/") +user = os.environ["EXP_USER"] +pw = Path(os.environ["EXP_PW_FILE"]).read_text().strip() +auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode() +ctx = ssl.create_default_context() + +def req(method, url, data=None, headers=None): + h = dict(headers or {}) + body = None + if data is not None: + body = json.dumps(data).encode() + h["Content-Type"] = "application/json" + r = urllib.request.Request(url, data=body, headers=h, method=method) + with urllib.request.urlopen(r, context=ctx, timeout=25) as resp: + return json.load(resp) + +tok = req( + "POST", + f"{bank}/accounts/{user}/token", + {"scope": "readwrite", "duration": {"d_us": 3600_000_000}}, + {"Authorization": auth}, +)["access_token"] +wd = req( + "POST", + f"{bank}/accounts/{user}/withdrawals", + {"amount": amount}, + {"Authorization": f"Bearer {tok}"}, +) +print(wd.get("taler_withdraw_uri") or wd.get("talerWithdrawUri") or "") +PY +} + +mint_pay() { + local mode="$1" + MERCHANT="$MERCHANT" INSTANCE="$INSTANCE" \ + DONATE_INSTANCE="$DONATE_INSTANCE" DONATE_TEMPLATE="$DONATE_TEMPLATE" \ + python3 - "$mode" <<'PY' +import json, os, ssl, sys, urllib.error, urllib.request +mode = sys.argv[1] +merchant = os.environ["MERCHANT"].rstrip("/") +inst = os.environ["INSTANCE"] +d_inst = os.environ["DONATE_INSTANCE"] +d_tmpl = os.environ["DONATE_TEMPLATE"] +ctx = ssl.create_default_context() + +def post(url, data): + body = json.dumps(data).encode() + r = urllib.request.Request(url, data=body, method="POST", + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(r, context=ctx, timeout=25) as resp: + return resp.status, json.load(resp) + except urllib.error.HTTPError as e: + raw = e.read() if hasattr(e, "read") else b"" + try: + return getattr(e, "code", None), json.loads(raw) + except Exception as err: + raise SystemExit(f"POST {url} failed: {e} body={raw[:200]!r}") from err + +if mode.startswith("product:"): + pid = mode.split(":", 1)[1] + st, d = post(f"{merchant}/instances/{inst}/templates/{pid}", {}) + use_inst = inst +elif mode.startswith("donate:"): + amt = mode.split(":", 1)[1] + st, d = post(f"{merchant}/instances/{d_inst}/templates/{d_tmpl}", {"amount": amt}) + use_inst = d_inst +else: + raise SystemExit(f"bad mode {mode!r}") + +if "taler_pay_uri" in d: + print(d["taler_pay_uri"]); raise SystemExit(0) +oid, tok = d.get("order_id"), d.get("token") +if not oid or not tok: + raise SystemExit(f"no order: {d!r}"[:200]) +host = merchant.replace("https://","").replace("http://","").split("/")[0] +print(f"taler://pay/{host}/instances/{use_inst}/{oid}/?c={tok}") +PY +} + +click_through() { + local labels=("$@") + local i lab + for i in 1 2 3 4 5 6 7 8; do + for lab in "${labels[@]}"; do + if tap_text "$lab" 2>/dev/null; then + pause 0.9 + fi + done + pause 0.4 + done +} + +if [[ -n "${WITHDRAW_AMOUNTS:-}" ]]; then + # shellcheck disable=SC2206 + AMOUNTS=($WITHDRAW_AMOUNTS) +else + AMOUNTS=("${DEFAULT_AMOUNTS[@]}") +fi +if [[ -n "${PRODUCTS:-}" ]]; then + PAYS=() + for p in $PRODUCTS; do PAYS+=("product:$p"); done +else + PAYS=("${DEFAULT_PAYS[@]}") +fi + +############################ +step "0 device + wallet ($STACK / $CUR_HINT)" +adb_sh input keyevent KEYCODE_WAKEUP 2>/dev/null || true +adb_sh wm dismiss-keyguard 2>/dev/null || true +if [[ "$CLEAR" -eq 1 ]]; then + info "pm clear $PKG" + adb_sh pm clear "$PKG" >/dev/null 2>&1 || true +fi +adb_sh am force-stop "$PKG" 2>/dev/null || true +pause 0.4 +# Prefer explicit MainActivity (from gui-workflows); fall back to VIEW launcher +if ! adb_sh am start -n "$ACT" >/dev/null 2>&1; then + info "MainActivity path failed — monkey launch" + adb_sh monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true +fi +pause 2.0 +shot "00-start" + +############################ +step "2 withdraw chain" +idx=0 +for AMT in "${AMOUNTS[@]}"; do + idx=$((idx + 1)) + step "2.$idx withdraw $AMT" + URI="$(mint_withdraw "$AMT" | head -1)" + [[ -n "$URI" ]] || die "mint failed for $AMT" + info "URI=$URI" + echo "$URI" >>"$SHOTDIR/withdraw-uris.txt" + adb_sh am start -a android.intent.action.VIEW -d "$URI" "$PKG" >/dev/null + pause 1.5 + shot "w${idx}-open" + click_through "Accept" "I accept" "Accept terms" "Confirm" "Continue" "Withdraw" "Next" "OK" "Agree" "Bestätigen" "Abheben" "Akzeptieren" + pause 3.0 + shot "w${idx}-after" + adb_sh am start -n "$ACT" >/dev/null 2>&1 || true + pause 0.6 + tap_text "Assets" 2>/dev/null || true + pause 0.4 + shot "w${idx}-assets" +done + +############################ +step "3 pay chain (limit=$PAY_LIMIT)" +pidx=0 +for PAY in "${PAYS[@]}"; do + pidx=$((pidx + 1)) + [[ "$pidx" -gt "$PAY_LIMIT" ]] && break + step "3.$pidx pay $PAY" + if ! PAYURI="$(mint_pay "$PAY" | head -1)"; then + info "mint_pay failed for $PAY — skip" + continue + fi + [[ -n "$PAYURI" ]] || { info "empty pay uri — skip"; continue; } + info "PAYURI=$PAYURI" + echo "$PAYURI" >>"$SHOTDIR/pay-uris.txt" + adb_sh am start -a android.intent.action.VIEW -d "$PAYURI" "$PKG" >/dev/null + pause 1.5 + shot "p${pidx}-open" + click_through "Pay" "Confirm" "Accept" "Continue" "OK" "Next" "Bezahlen" "Bestätigen" "Payer" + pause 1.5 + shot "p${pidx}-after" + adb_sh am start -n "$ACT" >/dev/null 2>&1 || true + pause 0.5 +done + +step "4 done → $SHOTDIR" +ls -la "$SHOTDIR" | tail -40 +adb_sh dumpsys window 2>/dev/null | grep mCurrentFocus | head -1 || true +info "PKG=$PKG amounts=${AMOUNTS[*]} pays(limit)=$PAY_LIMIT" +echo "See AUTOMATED-GUI-NOTES.md (ported from taler-android dev/hernani-inference/gui-workflows)" From db343068c90c0bcc6a8a11de5e8d4e70e4125d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:02:50 +0200 Subject: [PATCH 249/326] monitoring: strip :443 from chain deep-link URIs --- scripts/taler-monitoring/android-test/run-goa-gui-chain.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh index a1818d4..af48da6 100755 --- a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh +++ b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh @@ -299,6 +299,8 @@ for AMT in "${AMOUNTS[@]}"; do step "2.$idx withdraw $AMT" URI="$(mint_withdraw "$AMT" | head -1)" [[ -n "$URI" ]] || die "mint failed for $AMT" + # wallet / monitoring QR rules: strip default ports + URI=$(printf '%s' "$URI" | sed 's/:443\//\//g; s/:443?/?/g; s/:80\//\//g') info "URI=$URI" echo "$URI" >>"$SHOTDIR/withdraw-uris.txt" adb_sh am start -a android.intent.action.VIEW -d "$URI" "$PKG" >/dev/null @@ -326,6 +328,7 @@ for PAY in "${PAYS[@]}"; do continue fi [[ -n "$PAYURI" ]] || { info "empty pay uri — skip"; continue; } + PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g; s/:80\//\//g') info "PAYURI=$PAYURI" echo "$PAYURI" >>"$SHOTDIR/pay-uris.txt" adb_sh am start -a android.intent.action.VIEW -d "$PAYURI" "$PKG" >/dev/null From 2dfd40558ae4757a214e9fb7280ab1346e17d4ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:02:50 +0200 Subject: [PATCH 250/326] monitoring: strip :443 from chain deep-link URIs --- scripts/taler-monitoring/android-test/run-goa-gui-chain.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh index a1818d4..af48da6 100755 --- a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh +++ b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh @@ -299,6 +299,8 @@ for AMT in "${AMOUNTS[@]}"; do step "2.$idx withdraw $AMT" URI="$(mint_withdraw "$AMT" | head -1)" [[ -n "$URI" ]] || die "mint failed for $AMT" + # wallet / monitoring QR rules: strip default ports + URI=$(printf '%s' "$URI" | sed 's/:443\//\//g; s/:443?/?/g; s/:80\//\//g') info "URI=$URI" echo "$URI" >>"$SHOTDIR/withdraw-uris.txt" adb_sh am start -a android.intent.action.VIEW -d "$URI" "$PKG" >/dev/null @@ -326,6 +328,7 @@ for PAY in "${PAYS[@]}"; do continue fi [[ -n "$PAYURI" ]] || { info "empty pay uri — skip"; continue; } + PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g; s/:80\//\//g') info "PAYURI=$PAYURI" echo "$PAYURI" >>"$SHOTDIR/pay-uris.txt" adb_sh am start -a android.intent.action.VIEW -d "$PAYURI" "$PKG" >/dev/null From cf5edc90e7dc40bf7ea03b58212ce199566aee22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:22:45 +0200 Subject: [PATCH 251/326] monitoring: use inference fix branch by default; harden GUI chain ANR wait Default BUILD branch fix-bank-withdraw-auto-exchange; dismiss System UI ANR before confirm taps so low-RAM emulators fail less uselessly. --- .../android-test/AUTOMATED-GUI-NOTES.md | 23 +++++++++++++++---- .../out-gui-chain/run-211324/pay-uris.txt | 1 + .../out-gui-chain/run-211324/ui.xml | 1 + .../run-211324/withdraw-uris.txt | 1 + .../run-android-build-and-smoke.sh | 19 +++++++++++---- .../android-test/run-goa-gui-chain.sh | 22 +++++++++++++++--- 6 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt create mode 100644 scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml create mode 100644 scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt diff --git a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md index 2e3993a..7a9447a 100644 --- a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md +++ b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md @@ -130,15 +130,30 @@ Open issues mirrored from upstream `ISSUES.md` (gui-workflows): - **I-and-gui-004** — emulator RAM cost (this host ~3.7 GiB) - **I-and-gui-007** — for reliable withdraw GUI, build/install app from **fix/bank-withdraw-auto-exchange** or **fix/withdraw-spinner-fallback**, not plain master alone -Recommended demo stack: +### Ground rule (ops) + +1. **Bring it to run** first (scripts + live stack). +2. **App code changes only if needed**, and **minimal**. +3. Prefer **existing** `dev/hernani-inference/*` branches over new ones; extend them rather than forking random trees. + +### Recommended demo stack (2026-07) ```text -1) Checkout taler-android: fix/withdraw-spinner-fallback (or merge fixes) -2) ./gradlew :wallet:installFdroidDebug -3) Boot emulator / phone +1) taler-android checkout: + dev/hernani-inference/fix-bank-withdraw-auto-exchange + (or fix/withdraw-spinner-fallback for spinner + OIM) +2) ./gradlew :wallet:assembleFdroidDebug # package net.taler.wallet.fdroid.debug +3) Boot emulator / phone (prefer ≥6–8 GiB host RAM; 4 GiB → System UI ANR) 4) android-test/run-goa-gui-chain.sh # STACK=goa|stage ``` +`run-android-build-and-smoke.sh` defaults `BRANCH=dev/hernani-inference/fix-bank-withdraw-auto-exchange` +so source smokes pick up the GOA exchange auto-add fix without manual checkout. + +### Runtime note (clementine) + +On ~3.7 GiB hosts the emulator often shows **System UI isn’t responding** before Confirm is tappable. Chain script now prioritizes **Wait** over hunting Confirm. For a green end-to-end GUI path use a physical device or more RAM — the **mint + deep-link + fix-branch APK** path is otherwise ready. + ```bash # GOA chain (explorer mint + GUI taps) EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \ diff --git a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt new file mode 100644 index 0000000..6492537 --- /dev/null +++ b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt @@ -0,0 +1 @@ +taler://pay/taler.hacktivism.ch/instances/goa-shop/2026.198-93H450R000000/?c=1G3C43V1H89J8CQQ42TGDGVGMM diff --git a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml new file mode 100644 index 0000000..afdddb9 --- /dev/null +++ b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt new file mode 100644 index 0000000..e8e1589 --- /dev/null +++ b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt @@ -0,0 +1 @@ +taler://withdraw/bank.hacktivism.ch/taler-integration/39ba4ffc-f0e6-4e12-9e5b-d11fb8a2bcdd diff --git a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh index 277ad07..5779b88 100755 --- a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh @@ -19,6 +19,9 @@ ROOT=$(cd "$(dirname "$0")" && pwd) SMOKE="$ROOT/run-android-pay-smoke.sh" SRC="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" SKIP_PULL="${SKIP_PULL:-0}" +# Prefer existing inference fix branch for GOA withdraw (spinner/exchange resolve). +# Override: BRANCH=master or BRANCH=dev/hernani-inference/fix-withdraw-spinner-fallback +BRANCH="${BRANCH:-dev/hernani-inference/fix-bank-withdraw-auto-exchange}" VARIANT="${VARIANT:-fdroidDebug}" # assemble task suffix: FdroidDebug / NightlyDebug / … export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" @@ -35,13 +38,19 @@ fi printf 'sdk.dir=%s\n' "$ANDROID_HOME" >"$SRC/local.properties" cd "$SRC" -if [ "$SKIP_PULL" != "1" ] && [ -d .git ]; then - echo "git pull --ff-only origin $(git rev-parse --abbrev-ref HEAD)" - git pull --ff-only origin "$(git rev-parse --abbrev-ref HEAD)" || \ - git fetch origin && git merge --ff-only "origin/$(git rev-parse --abbrev-ref HEAD)" || true +if [ -d .git ]; then + git fetch origin 2>/dev/null || true + if [ -n "$BRANCH" ] && git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then + echo "checkout origin/$BRANCH (minimal GOA/automation fixes; override BRANCH=)" + git checkout -B "$BRANCH" "origin/$BRANCH" 2>/dev/null || git checkout "$BRANCH" 2>/dev/null || true + fi + if [ "$SKIP_PULL" != "1" ]; then + br=$(git rev-parse --abbrev-ref HEAD) + git pull --ff-only origin "$br" 2>/dev/null || true + fi fi COMMIT=$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown) -echo "source: $SRC @ $COMMIT" +echo "source: $SRC @ $COMMIT (branch $(git rev-parse --abbrev-ref HEAD 2>/dev/null || ?))" echo "variant: assemble${VARIANT^}" 2>/dev/null || true # Map VARIANT → gradle task (fdroidDebug → assembleFdroidDebug) diff --git a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh index af48da6..571e825 100755 --- a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh +++ b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh @@ -249,14 +249,30 @@ PY click_through() { local labels=("$@") - local i lab - for i in 1 2 3 4 5 6 7 8; do + local i lab misses=0 + # Prefer dismissing ANR before hunting Confirm (low-RAM hosts thrash System UI) + for i in 1 2 3 4 5 6; do + if tap_text "Wait" 2>/dev/null || tap_text "Warten" 2>/dev/null; then + info "dismissed ANR (Wait)" + pause 2.0 + misses=0 + continue + fi + local hit=0 for lab in "${labels[@]}"; do if tap_text "$lab" 2>/dev/null; then + hit=1 + misses=0 pause 0.9 + break fi done - pause 0.4 + if [[ "$hit" -eq 0 ]]; then + misses=$((misses + 1)) + # stop early if hierarchy has nothing useful (avoid burning order deadlines) + [[ "$misses" -ge 3 ]] && break + fi + pause 0.5 done } From 92695c0ee3c808853af88332dec0d84047f46136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:22:45 +0200 Subject: [PATCH 252/326] monitoring: use inference fix branch by default; harden GUI chain ANR wait Default BUILD branch fix-bank-withdraw-auto-exchange; dismiss System UI ANR before confirm taps so low-RAM emulators fail less uselessly. --- .../android-test/AUTOMATED-GUI-NOTES.md | 23 +++++++++++++++---- .../out-gui-chain/run-211324/pay-uris.txt | 1 + .../out-gui-chain/run-211324/ui.xml | 1 + .../run-211324/withdraw-uris.txt | 1 + .../run-android-build-and-smoke.sh | 19 +++++++++++---- .../android-test/run-goa-gui-chain.sh | 22 +++++++++++++++--- 6 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt create mode 100644 scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml create mode 100644 scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt diff --git a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md index 2e3993a..7a9447a 100644 --- a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md +++ b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md @@ -130,15 +130,30 @@ Open issues mirrored from upstream `ISSUES.md` (gui-workflows): - **I-and-gui-004** — emulator RAM cost (this host ~3.7 GiB) - **I-and-gui-007** — for reliable withdraw GUI, build/install app from **fix/bank-withdraw-auto-exchange** or **fix/withdraw-spinner-fallback**, not plain master alone -Recommended demo stack: +### Ground rule (ops) + +1. **Bring it to run** first (scripts + live stack). +2. **App code changes only if needed**, and **minimal**. +3. Prefer **existing** `dev/hernani-inference/*` branches over new ones; extend them rather than forking random trees. + +### Recommended demo stack (2026-07) ```text -1) Checkout taler-android: fix/withdraw-spinner-fallback (or merge fixes) -2) ./gradlew :wallet:installFdroidDebug -3) Boot emulator / phone +1) taler-android checkout: + dev/hernani-inference/fix-bank-withdraw-auto-exchange + (or fix/withdraw-spinner-fallback for spinner + OIM) +2) ./gradlew :wallet:assembleFdroidDebug # package net.taler.wallet.fdroid.debug +3) Boot emulator / phone (prefer ≥6–8 GiB host RAM; 4 GiB → System UI ANR) 4) android-test/run-goa-gui-chain.sh # STACK=goa|stage ``` +`run-android-build-and-smoke.sh` defaults `BRANCH=dev/hernani-inference/fix-bank-withdraw-auto-exchange` +so source smokes pick up the GOA exchange auto-add fix without manual checkout. + +### Runtime note (clementine) + +On ~3.7 GiB hosts the emulator often shows **System UI isn’t responding** before Confirm is tappable. Chain script now prioritizes **Wait** over hunting Confirm. For a green end-to-end GUI path use a physical device or more RAM — the **mint + deep-link + fix-branch APK** path is otherwise ready. + ```bash # GOA chain (explorer mint + GUI taps) EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \ diff --git a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt new file mode 100644 index 0000000..6492537 --- /dev/null +++ b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt @@ -0,0 +1 @@ +taler://pay/taler.hacktivism.ch/instances/goa-shop/2026.198-93H450R000000/?c=1G3C43V1H89J8CQQ42TGDGVGMM diff --git a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml new file mode 100644 index 0000000..afdddb9 --- /dev/null +++ b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt new file mode 100644 index 0000000..e8e1589 --- /dev/null +++ b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt @@ -0,0 +1 @@ +taler://withdraw/bank.hacktivism.ch/taler-integration/39ba4ffc-f0e6-4e12-9e5b-d11fb8a2bcdd diff --git a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh index 277ad07..5779b88 100755 --- a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh @@ -19,6 +19,9 @@ ROOT=$(cd "$(dirname "$0")" && pwd) SMOKE="$ROOT/run-android-pay-smoke.sh" SRC="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" SKIP_PULL="${SKIP_PULL:-0}" +# Prefer existing inference fix branch for GOA withdraw (spinner/exchange resolve). +# Override: BRANCH=master or BRANCH=dev/hernani-inference/fix-withdraw-spinner-fallback +BRANCH="${BRANCH:-dev/hernani-inference/fix-bank-withdraw-auto-exchange}" VARIANT="${VARIANT:-fdroidDebug}" # assemble task suffix: FdroidDebug / NightlyDebug / … export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" @@ -35,13 +38,19 @@ fi printf 'sdk.dir=%s\n' "$ANDROID_HOME" >"$SRC/local.properties" cd "$SRC" -if [ "$SKIP_PULL" != "1" ] && [ -d .git ]; then - echo "git pull --ff-only origin $(git rev-parse --abbrev-ref HEAD)" - git pull --ff-only origin "$(git rev-parse --abbrev-ref HEAD)" || \ - git fetch origin && git merge --ff-only "origin/$(git rev-parse --abbrev-ref HEAD)" || true +if [ -d .git ]; then + git fetch origin 2>/dev/null || true + if [ -n "$BRANCH" ] && git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then + echo "checkout origin/$BRANCH (minimal GOA/automation fixes; override BRANCH=)" + git checkout -B "$BRANCH" "origin/$BRANCH" 2>/dev/null || git checkout "$BRANCH" 2>/dev/null || true + fi + if [ "$SKIP_PULL" != "1" ]; then + br=$(git rev-parse --abbrev-ref HEAD) + git pull --ff-only origin "$br" 2>/dev/null || true + fi fi COMMIT=$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown) -echo "source: $SRC @ $COMMIT" +echo "source: $SRC @ $COMMIT (branch $(git rev-parse --abbrev-ref HEAD 2>/dev/null || ?))" echo "variant: assemble${VARIANT^}" 2>/dev/null || true # Map VARIANT → gradle task (fdroidDebug → assembleFdroidDebug) diff --git a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh index af48da6..571e825 100755 --- a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh +++ b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh @@ -249,14 +249,30 @@ PY click_through() { local labels=("$@") - local i lab - for i in 1 2 3 4 5 6 7 8; do + local i lab misses=0 + # Prefer dismissing ANR before hunting Confirm (low-RAM hosts thrash System UI) + for i in 1 2 3 4 5 6; do + if tap_text "Wait" 2>/dev/null || tap_text "Warten" 2>/dev/null; then + info "dismissed ANR (Wait)" + pause 2.0 + misses=0 + continue + fi + local hit=0 for lab in "${labels[@]}"; do if tap_text "$lab" 2>/dev/null; then + hit=1 + misses=0 pause 0.9 + break fi done - pause 0.4 + if [[ "$hit" -eq 0 ]]; then + misses=$((misses + 1)) + # stop early if hierarchy has nothing useful (avoid burning order deadlines) + [[ "$misses" -ge 3 ]] && break + fi + pause 0.5 done } From e51b3208c2a9176ffa0eb7c42a9a66f01ab48403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:22:53 +0200 Subject: [PATCH 253/326] monitoring: gitignore gui-chain artifacts (do not track run dumps) --- scripts/taler-monitoring/android-test/.gitignore | 1 + .../android-test/out-gui-chain/run-211324/pay-uris.txt | 1 - .../android-test/out-gui-chain/run-211324/ui.xml | 1 - .../android-test/out-gui-chain/run-211324/withdraw-uris.txt | 1 - 4 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt delete mode 100644 scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml delete mode 100644 scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore index de62278..0867ff3 100644 --- a/scripts/taler-monitoring/android-test/.gitignore +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -4,3 +4,4 @@ out/ out-source/ out-gui/ out-source-gui/ +out-gui-chain/ diff --git a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt deleted file mode 100644 index 6492537..0000000 --- a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt +++ /dev/null @@ -1 +0,0 @@ -taler://pay/taler.hacktivism.ch/instances/goa-shop/2026.198-93H450R000000/?c=1G3C43V1H89J8CQQ42TGDGVGMM diff --git a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml deleted file mode 100644 index afdddb9..0000000 --- a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt deleted file mode 100644 index e8e1589..0000000 --- a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt +++ /dev/null @@ -1 +0,0 @@ -taler://withdraw/bank.hacktivism.ch/taler-integration/39ba4ffc-f0e6-4e12-9e5b-d11fb8a2bcdd From cf349bfdb6b9fa120d62eeb900b4d7d8936ff1ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:22:53 +0200 Subject: [PATCH 254/326] monitoring: gitignore gui-chain artifacts (do not track run dumps) --- scripts/taler-monitoring/android-test/.gitignore | 1 + .../android-test/out-gui-chain/run-211324/pay-uris.txt | 1 - .../android-test/out-gui-chain/run-211324/ui.xml | 1 - .../android-test/out-gui-chain/run-211324/withdraw-uris.txt | 1 - 4 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt delete mode 100644 scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml delete mode 100644 scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore index de62278..0867ff3 100644 --- a/scripts/taler-monitoring/android-test/.gitignore +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -4,3 +4,4 @@ out/ out-source/ out-gui/ out-source-gui/ +out-gui-chain/ diff --git a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt deleted file mode 100644 index 6492537..0000000 --- a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/pay-uris.txt +++ /dev/null @@ -1 +0,0 @@ -taler://pay/taler.hacktivism.ch/instances/goa-shop/2026.198-93H450R000000/?c=1G3C43V1H89J8CQQ42TGDGVGMM diff --git a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml deleted file mode 100644 index afdddb9..0000000 --- a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/ui.xml +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt b/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt deleted file mode 100644 index e8e1589..0000000 --- a/scripts/taler-monitoring/android-test/out-gui-chain/run-211324/withdraw-uris.txt +++ /dev/null @@ -1 +0,0 @@ -taler://withdraw/bank.hacktivism.ch/taler-integration/39ba4ffc-f0e6-4e12-9e5b-d11fb8a2bcdd From c57f08b9d692bdbc9674baaaad43ce6f1c84b8c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:24:05 +0200 Subject: [PATCH 255/326] monitoring: GUI-AUTOMATION-NOTES + AUTO_ANDROID/AUTO_GUI flags Document minimal variant; Linux defaults to Android-only automation; macOS can enable both ecosystems later via flags. --- .../android-test/AUTOMATED-GUI-NOTES.md | 189 +----------------- .../android-test/GUI-AUTOMATION-NOTES.md | 179 +++++++++++++++++ .../taler-monitoring/android-test/README.md | 9 +- .../android-test/run-android-gui-smoke.sh | 19 +- .../android-test/run-android-pay-smoke.sh | 14 ++ .../android-test/run-goa-gui-chain.sh | 14 +- 6 files changed, 231 insertions(+), 193 deletions(-) create mode 100644 scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md diff --git a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md index 7a9447a..bfa5196 100644 --- a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md +++ b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md @@ -1,187 +1,4 @@ -# Automated GUI notes — Android wallet (vanilla level) +# Moved -Goal: tests for **two systems** (GOA / hacktivism and stage TESTPAYSAN) that -exercise the wallet **graphically in the UI** as far as practical, not only -CLI/`adb` deep-links into wallet-core. - -## Vanilla level (this tree) - -| Layer | What we do | Tooling | -|-------|------------|---------| -| **A — Install** | Install published F-Droid APK **or** APK built from `taler-android` master | `adb install` | -| **B — Entry (shortcut)** | Deliver withdraw / pay as `taler://…` intents | `adb shell am start -a VIEW -d …` | -| **C — Graphical UI** | Dump hierarchy, tap Confirm / ToS / Pay-like buttons, dismiss ANR | `uiautomator dump` + `input tap` (`lib_ui.py`) | -| **D — Evidence** | Screenshots + UI XML + logcat + JSON status | `android-test/out-gui/` | - -Scripts: - -| Script | Role | -|--------|------| -| `run-android-pay-smoke.sh` | Install + deep-link + light taps (hybrid / CLI-heavy) | -| `run-android-gui-smoke.sh` | **Vanilla GUI path**: deep-link entry + multi-round UI driving | -| `run-android-build-and-smoke.sh` | Build latest source APK → same smoke (set `GUI=1` to call GUI script) | -| `lib_ui.py` | Shared dump/tap/ANR helpers | - -```bash -cd scripts/taler-monitoring/android-test - -# Published APK, graphical drive, both stacks -STACK=goa ./run-android-gui-smoke.sh -STACK=stage ./run-android-gui-smoke.sh - -# From-source APK + GUI -APK_PATH=~/taler/taler-android/wallet/build/outputs/apk/fdroid/debug/wallet-fdroid-debug.apk \ - PKG=net.taler.wallet.fdroid.debug \ - STACK=goa ./run-android-gui-smoke.sh -``` - -## Legitimate shortcuts (document, do not hide) - -These are **not** full human UX, but they are the same contracts as QR scan / -paywall open on the landings: - -| Shortcut | Replaces | Why legitimate | -|----------|----------|----------------| -| `adb` `VIEW` `taler://withdraw/…` | Camera QR scan of communal / demo withdraw | Identical URI payload as landings (`demo-withdraw.json`) | -| `adb` `VIEW` `taler://pay…` / `pay-template/…` | Scan shop/paywall QR or open “pay with Taler” | Same URI merchant/backend would show | -| Host-side `POST …/templates/{id}` then pay URI | Browser shop UI to create order | Stage farmer templates are public; order create is API, pay is still wallet UI | -| Multi-language label list for taps | Human reading the button | Same visible buttons; brittle if copy changes | -| Skip full ToS reading | User scrolling long TOS | Tap “Accept” if shown; no legal review automation | - -**Not automated yet (out of vanilla scope):** - -- Typing personal bank passwords into the wallet webview -- NFC -- Real camera / vision-based QR -- Biometric unlock -- Full onboarding wizard every time (we force-stop + re-intent) - -## Two systems under test - -| Stack | Withdraw source | Pay source | -|-------|-----------------|------------| -| **GOA** (`STACK=goa`) | `https://bank.hacktivism.ch/intro/demo-withdraw.json` | `taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana` (Paivana) | -| **stage** (`STACK=stage`) | `https://stage.bank.lefrancpaysan.ch/intro/demo-withdraw.json` | Public template e.g. `fermes-des-collines` / `panier-legumes` → `taler://pay/…` | - -Both use the **same** GUI driver (`lib_ui.py`); only URLs and package under test differ. - -## GUI driver behaviour (`lib_ui.gui_drive`) - -1. Every round: `uiautomator dump` + optional screencap. -2. If **“System UI isn’t responding”** → tap **Wait** (prefer over Close). -3. Else try exact taps on Confirm / Withdraw / Accept / Pay / DE/FR synonyms. -4. Else regex partial match on those words. -5. Repeat `GUI_ROUNDS` times (default 10), `GUI_SLEEP` seconds apart. - -Success signals (layered): - -- **Intent delivered** — `am start` OK -- **wallet-core** — logcat `prepareBankIntegratedWithdrawal` / pay-related ops -- **GUI taps** — non-empty `taps` in `gui-*-status.json` -- **Screenshots** — human-readable progress under `gui-withdraw/` / `gui-pay/` - -A run can be “vanilla-partial” if intents + some taps succeed but final -balance/settlement is not asserted (settlement still better covered by -`taler-monitoring.sh e2e` CLI wallet). - -## Host limits (clementine, 2026-07) - -| Constraint | Effect | -|------------|--------| -| ~3.7 GiB RAM | Emulator ANRs; GUI rounds often only dismiss ANR | -| Prefer physical phone or ≥6–8 GiB host | Real Confirm/Pay taps complete more often | -| No root required | User SDK + adb + F-Droid/source APK | - -## Relation to CLI e2e - -| Suite | Strength | -|-------|----------| -| `taler-monitoring.sh e2e` / `ladder` | Full withdraw+pay **settlement** via `taler-wallet-cli` | -| `run-android-gui-smoke.sh` | **Android app UI** surface for the same stacks | -| Combined | CLI = money path green; GUI = app UX green as far as taps go | - -## Upstream: `taler-android` `dev/hernani-inference/*` - -Branch name is **`hernani-inference`** (not “inferenced”). Remote: -`git@git.taler.net:taler-android.git`. - -| Branch | Relevance for automation | -|--------|---------------------------| -| **`dev/hernani-inference/gui-workflows`** | **Primary:** macOS emulator helpers + **GOA GUI chain** (`scripts/goa-chain-emu.sh`, `docs/gui-workflows.md`, `setup-avds.sh`, `start-emulators.sh`). Deep-link + `uiautomator` text taps + bank API mint + shop templates. | -| **`fix/bank-withdraw-auto-exchange`** | App fix: resolve exchange when bank omits it (needed for GOA withdraw UI). | -| **`fix/withdraw-spinner-fallback`** | App fix: stop endless withdraw spinner (+ OIM UI work). | -| **`experimental-oim` / `experimental-oim-pos`** | Experimental cash denomination UI; optional for demos, not required for chain. | - -### Port into this tree (Linux-capable) - -| Upstream (gui-workflows) | Here (`android-test/`) | -|--------------------------|-------------------------| -| `goa-chain-emu.sh` | **`run-goa-gui-chain.sh`** (goa + stage, Linux paths, explorer secrets as monitoring ladder) | -| `tap_text` / `click_through` | same idea in chain + `lib_ui.py` | -| `mint_withdraw` explorer API | same (Basic → token → POST withdrawals) | -| `mint_pay` public templates | same (goa-shop products / stage fermes templates) | -| `android-env.sh` Homebrew | use `$HOME/Android/Sdk` + system `adb` (see GIT-AUTOMATION-NOTES) | -| macOS-only docs | **Linux supported** for chain/smoke; AVD setup still DIY on Linux | - -Open issues mirrored from upstream `ISSUES.md` (gui-workflows): - -- **I-and-gui-001** — taps still best-effort; ToS/confirm may need human -- **I-and-gui-003** — `EXP_PW_FILE` required for mint -- **I-and-gui-004** — emulator RAM cost (this host ~3.7 GiB) -- **I-and-gui-007** — for reliable withdraw GUI, build/install app from **fix/bank-withdraw-auto-exchange** or **fix/withdraw-spinner-fallback**, not plain master alone - -### Ground rule (ops) - -1. **Bring it to run** first (scripts + live stack). -2. **App code changes only if needed**, and **minimal**. -3. Prefer **existing** `dev/hernani-inference/*` branches over new ones; extend them rather than forking random trees. - -### Recommended demo stack (2026-07) - -```text -1) taler-android checkout: - dev/hernani-inference/fix-bank-withdraw-auto-exchange - (or fix/withdraw-spinner-fallback for spinner + OIM) -2) ./gradlew :wallet:assembleFdroidDebug # package net.taler.wallet.fdroid.debug -3) Boot emulator / phone (prefer ≥6–8 GiB host RAM; 4 GiB → System UI ANR) -4) android-test/run-goa-gui-chain.sh # STACK=goa|stage -``` - -`run-android-build-and-smoke.sh` defaults `BRANCH=dev/hernani-inference/fix-bank-withdraw-auto-exchange` -so source smokes pick up the GOA exchange auto-add fix without manual checkout. - -### Runtime note (clementine) - -On ~3.7 GiB hosts the emulator often shows **System UI isn’t responding** before Confirm is tappable. Chain script now prioritizes **Wait** over hunting Confirm. For a green end-to-end GUI path use a physical device or more RAM — the **mint + deep-link + fix-branch APK** path is otherwise ready. - -```bash -# GOA chain (explorer mint + GUI taps) -EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \ - STACK=goa PKG=net.taler.wallet.fdroid.debug \ - ./run-goa-gui-chain.sh - -# Stage TESTPAYSAN (explorer via stagepaysan SSH if EXP_PW_FILE unset) -STACK=stage ./run-goa-gui-chain.sh -``` - -## Future upgrades (not required for vanilla) - -- Maestro / Appium flows with stable resource-ids -- Explicit wait for text “Withdrawal complete” / balance -- Port Paivana unlock scripts (upstream I-and-gui-002) -- Dual AVD wallet+POS (upstream I-and-gui-008) -- Record video (`adb shell screenrecord`) on failure - -## Checklist — “vanille erreicht?” - -- [x] Two stacks parameterized (goa / stage) -- [x] Published APK path -- [x] Source-build path (same smoke after build) -- [x] Deep-link shortcuts documented -- [x] Multi-round **graphical** taps (uiautomator) -- [x] GOA-style **chain** (multi withdraw + multi pay) from inference branch -- [x] Screenshots + UI XML + logcat artifacts -- [x] Inference branch mapping documented -- [ ] Fully reliable unattended Confirm→settled on low-RAM emulator (host-limited) -- [ ] App built from withdraw-fix branch by default in build script (optional) -- [ ] Assert final balance in app UI (optional next step) +Canonical document: **[GUI-AUTOMATION-NOTES.md](./GUI-AUTOMATION-NOTES.md)** +(minimal variant, platform flags `AUTO_ANDROID` / `AUTO_GUI`, Linux defaults). diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md new file mode 100644 index 0000000..2fc90af --- /dev/null +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -0,0 +1,179 @@ +# GUI automation notes (Android wallet) + +Canonical notes for **graphical** Android wallet tests against GOA / stage. +Older name `AUTOMATED-GUI-NOTES.md` redirects here. + +--- + +## Minimal variant (implemented first) — status 2026-07 + +This is the **minimal vanilla** path we actually shipped; full dual-platform +GUI is planned via flags (below), not required to use these scripts today. + +| Piece | Status | Notes | +|-------|--------|--------| +| F-Droid published APK install | **done** | `net.taler.wallet.fdroid` 1.6.1 / 854 | +| Source build + same smoke | **done** | `:wallet:assembleFdroidDebug` → `net.taler.wallet.fdroid.debug` | +| Default source branch | **done** | `dev/hernani-inference/fix-bank-withdraw-auto-exchange` (minimal GOA exchange auto-add) | +| Deep-link entry (≡ QR) | **done** | `adb` `VIEW` `taler://withdraw/…` / `taler://pay…` | +| Explorer API mint (chain) | **done** | `run-goa-gui-chain.sh` (GOA + stage) | +| UI taps via uiautomator | **done** | best-effort Confirm/ToS/Pay; ANR → prefer **Wait** | +| Screenshots / logcat artifacts | **done** | under `out*`, `out-gui-chain/` (gitignored) | +| Host CLI e2e settlement | **separate** | `taler-monitoring.sh e2e` / `ladder` (not Android UI) | +| Reliable unattended Confirm on 4 GiB Linux host | **limited** | System UI ANR; use phone or more RAM | +| iOS GUI | **not in this tree** | see taler-ios `dev/hernani-inference/gui-workflows` | +| macOS full dual AVD helpers | **upstream** | taler-android `gui-workflows` (Homebrew); ported ideas here for Linux | + +**Ground rule:** bring it to run; app diffs only if needed and **minimal**; prefer +extending existing `dev/hernani-inference/*` branches over new trees. + +```bash +cd scripts/taler-monitoring/android-test + +# Minimal hybrid (install + deep-link + light taps) +STACK=goa ./run-android-pay-smoke.sh + +# GUI multi-round taps +STACK=goa ./run-android-gui-smoke.sh +STACK=stage ./run-android-gui-smoke.sh + +# Explorer mint + multi withdraw/pay chain (from gui-workflows) +EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \ + STACK=goa PKG=net.taler.wallet.fdroid.debug \ + ./run-goa-gui-chain.sh + +# Build fix-branch APK then smoke (GUI=1 for gui smoke) +./run-android-build-and-smoke.sh +GUI=1 ./run-android-build-and-smoke.sh +``` + +--- + +## Platform capability flags (planned + defaults) + +Automation can run on hosts that support **Android only**, **GUI tooling only** +(conceptual), or **both**. Flags keep that explicit for CI and laptops. + +### Proposed env flags + +| Variable | Values | Meaning | +|----------|--------|---------| +| `AUTO_ANDROID` | `0` / `1` | Run Android wallet automation (adb, APK, emulator/device) | +| `AUTO_GUI` | `0` / `1` | Drive **graphical** UI (uiautomator taps, multi-round). If `0`, deep-link/CLI-only install smoke. | +| `AUTO_PLATFORM` | `auto` / `linux` / `macos` / `ios` | Host family (optional override) | + +**Semantics:** + +| `AUTO_ANDROID` | `AUTO_GUI` | Behaviour | +|----------------|------------|-----------| +| `1` | `0` | Android install + deep-link smoke only (no multi-round taps) | +| `1` | `1` | Android + graphical drive (vanilla GUI / chain) | +| `0` | `1` | Reserved (e.g. future desktop/web GUI); currently **no-op** with a clear message | +| `0` | `0` | Skip mobile automation | + +### Defaults by host (when flags unset) + +| Host (`uname -s`) | Default `AUTO_ANDROID` | Default `AUTO_GUI` | Rationale | +|-------------------|------------------------|--------------------|-----------| +| **Linux** | **`1`** | **`1`** if device/emulator present, else scripts exit 3 | This repo’s day-to-day path; **Android only** (no iOS here) | +| **Darwin (macOS)** | `1` | `1` | Can run Android emulators **and** (separately) taler-ios GUI helpers; both flags on for Android scripts; iOS is out of tree | +| **Other** | `0` | `0` | Fail closed | + +**Linux default = Android** (no second mobile platform in this suite). +**macOS** may enable both ecosystems in the wider monorepo sense; for *these* +scripts only Android is implemented — set `AUTO_ANDROID=1` (default) and keep +iOS under `taler-ios`. + +### Resolution helper (convention for future wrappers) + +```bash +# Example for a future run-all-mobile.sh +os=$(uname -s) +: "${AUTO_PLATFORM:=auto}" +case "$AUTO_PLATFORM" in + auto) case "$os" in Linux) AUTO_PLATFORM=linux ;; Darwin) AUTO_PLATFORM=macos ;; *) AUTO_PLATFORM=other ;; esac ;; +esac +case "$AUTO_PLATFORM" in + linux) + : "${AUTO_ANDROID:=1}" + : "${AUTO_GUI:=1}" + # no iOS + ;; + macos) + : "${AUTO_ANDROID:=1}" + : "${AUTO_GUI:=1}" + # optional later: AUTO_IOS=1 for taler-ios scripts + ;; + *) + : "${AUTO_ANDROID:=0}" + : "${AUTO_GUI:=0}" + ;; +esac +``` + +Scripts **today** implement Android only; they should honour: + +- `AUTO_ANDROID=0` → exit 0 with “skipped (AUTO_ANDROID=0)” +- `AUTO_GUI=0` → call deep-link smoke without multi-round GUI (or set `GUI_ROUNDS=0`) + +--- + +## Vanilla layers (detail) + +| Layer | What | Tooling | +|-------|------|---------| +| **A — Install** | F-Droid or from-source APK | `adb install` | +| **B — Entry (shortcut)** | `taler://withdraw/…` / `taler://pay…` | `adb am start -a VIEW` | +| **C — Graphical UI** | Confirm / ToS / Pay taps; ANR Wait | `uiautomator` + `lib_ui.py` / chain | +| **D — Evidence** | Screenshots, XML, logcat, URIs | `out*/` | + +### Legitimate shortcuts + +| Shortcut | Replaces | +|----------|----------| +| Deep-link withdraw/pay | Camera QR / opening paywall | +| Host `POST` template → pay URI | Browser shop checkout UI | +| Label-list taps | Human reading button text | +| Skip full ToS scroll | Tap Accept if shown | + +### Scripts + +| Script | Role | +|--------|------| +| `run-android-pay-smoke.sh` | Hybrid: install + deep-link + light taps | +| `run-android-gui-smoke.sh` | Multi-round GUI drive | +| `run-goa-gui-chain.sh` | Multi mint/withdraw/pay (gui-workflows port) | +| `run-android-build-and-smoke.sh` | Build inference fix branch + smoke (`GUI=1` optional) | +| `lib_ui.py` | Dump/tap/ANR helpers | + +--- + +## Stacks under test + +| Stack | Withdraw | Pay | +|-------|----------|-----| +| **GOA** | explorer mint / `demo-withdraw.json` @ bank.hacktivism.ch | goa-shop templates / Paivana pay-template | +| **stage** | explorer / demo-withdraw @ stage.bank… | fermes / jardin public templates | + +--- + +## Upstream branches (`taler-android` `dev/hernani-inference/*`) + +| Branch | Role | +|--------|------| +| `gui-workflows` | macOS emulator helpers + original `goa-chain-emu.sh` | +| `fix/bank-withdraw-auto-exchange` | **Minimal** GOA exchange auto-add (default build branch) | +| `fix/withdraw-spinner-fallback` | Spinner + OIM; use if spinner still hangs | +| `experimental-oim*` | Optional cash UI | + +--- + +## Checklist + +- [x] Minimal variant documented +- [x] Two stacks (goa / stage) +- [x] Flags design (`AUTO_ANDROID` / `AUTO_GUI` / platform defaults) +- [x] Linux default = Android suite only +- [x] Inference-branch policy +- [ ] Wrapper enforces flags in every entry script (incremental) +- [ ] Reliable unattended Confirm on low-RAM Linux emulator diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index dab2365..e1ab975 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -51,6 +51,13 @@ EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank- STACK=stage ./run-goa-gui-chain.sh ``` -Documented shortcuts, inference branches, limits: **[AUTOMATED-GUI-NOTES.md](./AUTOMATED-GUI-NOTES.md)**. +Canonical notes (minimal variant, **flags**, Linux defaults): +**[GUI-AUTOMATION-NOTES.md](./GUI-AUTOMATION-NOTES.md)**. + +| Flag | Linux default | Meaning | +|------|---------------|---------| +| `AUTO_ANDROID` | `1` | Run Android adb/APK automation | +| `AUTO_GUI` | `1` | Graphical uiautomator drive (else deep-link only) | +| `AUTO_PLATFORM` | `auto` → `linux` | Host family; macOS can later pair with iOS out of tree | See also parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). diff --git a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh index 241ab2c..de280f5 100755 --- a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh @@ -8,12 +8,23 @@ # Systems: STACK=goa | stage | auto # APK: published F-Droid or APK_PATH=… (from-source) # -# See AUTOMATED-GUI-NOTES.md +# See GUI-AUTOMATION-NOTES.md # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) -# shellcheck source=run-android-pay-smoke.sh -# We re-use install/URI resolution by calling common pieces inline. + +# Platform flags (GUI-AUTOMATION-NOTES.md). Linux → Android only. +os=$(uname -s) +case "${AUTO_PLATFORM:-auto}" in + auto) case "$os" in Linux|Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; esac ;; + linux|macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; +esac +[ "${AUTO_ANDROID}" = "1" ] || { echo "skipped: AUTO_ANDROID=${AUTO_ANDROID}"; exit 0; } +if [ "${AUTO_GUI}" != "1" ]; then + echo "AUTO_GUI=0 → deep-link smoke only" + exec env AUTO_ANDROID=1 "$ROOT/run-android-pay-smoke.sh" +fi export OUT_DIR="${OUT_DIR:-$ROOT/out-gui}" export GUI_ROUNDS="${GUI_ROUNDS:-10}" @@ -169,4 +180,4 @@ else fi echo "artifacts: $OUT_DIR/" ls -la "$OUT_DIR" | sed 's/^/ /' -echo "Documented shortcuts → AUTOMATED-GUI-NOTES.md" +echo "Documented shortcuts → GUI-AUTOMATION-NOTES.md" diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh index 907cb07..690c222 100755 --- a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh @@ -20,6 +20,20 @@ # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) + +# Platform flags (see GUI-AUTOMATION-NOTES.md). Linux default: Android only. +os=$(uname -s) +case "${AUTO_PLATFORM:-auto}" in + auto) case "$os" in Linux) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; esac ;; + linux) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; +esac +if [ "${AUTO_ANDROID}" != "1" ]; then + echo "skipped: AUTO_ANDROID=${AUTO_ANDROID} (platform flags — see GUI-AUTOMATION-NOTES.md)" + exit 0 +fi + APK_DIR="${APK_DIR:-$ROOT/apks}" APK_URL="${APK_URL:-https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}" APK_NAME="${APK_NAME:-net.taler.wallet.fdroid_854.apk}" diff --git a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh index 571e825..52e4328 100755 --- a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh +++ b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh @@ -19,7 +19,7 @@ # Env: ANDROID_SERIAL, PKG, EXP_PW_FILE, BANK, MERCHANT, INSTANCE, # WITHDRAW_AMOUNTS, PRODUCTS, PAY_LIMIT, SHOTDIR, PAUSE # -# See AUTOMATED-GUI-NOTES.md § "hernani-inference branches" +# See GUI-AUTOMATION-NOTES.md # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) @@ -37,6 +37,16 @@ step() { echo; echo "======== $* ========"; } info() { echo " $*"; } pause() { sleep "${1:-${PAUSE:-0.8}}"; } +# Platform flags — Linux default Android+GUI (see GUI-AUTOMATION-NOTES.md) +os=$(uname -s) +case "${AUTO_PLATFORM:-auto}" in + auto) case "$os" in Linux|Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; esac ;; + linux|macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; +esac +[ "${AUTO_ANDROID}" = "1" ] || { echo "skipped: AUTO_ANDROID=${AUTO_ANDROID}"; exit 0; } +[ "${AUTO_GUI}" = "1" ] || { echo "skipped: AUTO_GUI=${AUTO_GUI} (chain is GUI)"; exit 0; } + if ! command -v adb >/dev/null 2>&1; then [ -x "$HOME/Android/Sdk/platform-tools/adb" ] && export PATH="$HOME/Android/Sdk/platform-tools:$PATH" fi @@ -361,4 +371,4 @@ step "4 done → $SHOTDIR" ls -la "$SHOTDIR" | tail -40 adb_sh dumpsys window 2>/dev/null | grep mCurrentFocus | head -1 || true info "PKG=$PKG amounts=${AMOUNTS[*]} pays(limit)=$PAY_LIMIT" -echo "See AUTOMATED-GUI-NOTES.md (ported from taler-android dev/hernani-inference/gui-workflows)" +echo "See GUI-AUTOMATION-NOTES.md (ported from taler-android dev/hernani-inference/gui-workflows)" From 07045ca3886b3ab04a249a5e83359620e32dda76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:24:05 +0200 Subject: [PATCH 256/326] monitoring: GUI-AUTOMATION-NOTES + AUTO_ANDROID/AUTO_GUI flags Document minimal variant; Linux defaults to Android-only automation; macOS can enable both ecosystems later via flags. --- .../android-test/AUTOMATED-GUI-NOTES.md | 189 +----------------- .../android-test/GUI-AUTOMATION-NOTES.md | 179 +++++++++++++++++ .../taler-monitoring/android-test/README.md | 9 +- .../android-test/run-android-gui-smoke.sh | 19 +- .../android-test/run-android-pay-smoke.sh | 14 ++ .../android-test/run-goa-gui-chain.sh | 14 +- 6 files changed, 231 insertions(+), 193 deletions(-) create mode 100644 scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md diff --git a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md index 7a9447a..bfa5196 100644 --- a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md +++ b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md @@ -1,187 +1,4 @@ -# Automated GUI notes — Android wallet (vanilla level) +# Moved -Goal: tests for **two systems** (GOA / hacktivism and stage TESTPAYSAN) that -exercise the wallet **graphically in the UI** as far as practical, not only -CLI/`adb` deep-links into wallet-core. - -## Vanilla level (this tree) - -| Layer | What we do | Tooling | -|-------|------------|---------| -| **A — Install** | Install published F-Droid APK **or** APK built from `taler-android` master | `adb install` | -| **B — Entry (shortcut)** | Deliver withdraw / pay as `taler://…` intents | `adb shell am start -a VIEW -d …` | -| **C — Graphical UI** | Dump hierarchy, tap Confirm / ToS / Pay-like buttons, dismiss ANR | `uiautomator dump` + `input tap` (`lib_ui.py`) | -| **D — Evidence** | Screenshots + UI XML + logcat + JSON status | `android-test/out-gui/` | - -Scripts: - -| Script | Role | -|--------|------| -| `run-android-pay-smoke.sh` | Install + deep-link + light taps (hybrid / CLI-heavy) | -| `run-android-gui-smoke.sh` | **Vanilla GUI path**: deep-link entry + multi-round UI driving | -| `run-android-build-and-smoke.sh` | Build latest source APK → same smoke (set `GUI=1` to call GUI script) | -| `lib_ui.py` | Shared dump/tap/ANR helpers | - -```bash -cd scripts/taler-monitoring/android-test - -# Published APK, graphical drive, both stacks -STACK=goa ./run-android-gui-smoke.sh -STACK=stage ./run-android-gui-smoke.sh - -# From-source APK + GUI -APK_PATH=~/taler/taler-android/wallet/build/outputs/apk/fdroid/debug/wallet-fdroid-debug.apk \ - PKG=net.taler.wallet.fdroid.debug \ - STACK=goa ./run-android-gui-smoke.sh -``` - -## Legitimate shortcuts (document, do not hide) - -These are **not** full human UX, but they are the same contracts as QR scan / -paywall open on the landings: - -| Shortcut | Replaces | Why legitimate | -|----------|----------|----------------| -| `adb` `VIEW` `taler://withdraw/…` | Camera QR scan of communal / demo withdraw | Identical URI payload as landings (`demo-withdraw.json`) | -| `adb` `VIEW` `taler://pay…` / `pay-template/…` | Scan shop/paywall QR or open “pay with Taler” | Same URI merchant/backend would show | -| Host-side `POST …/templates/{id}` then pay URI | Browser shop UI to create order | Stage farmer templates are public; order create is API, pay is still wallet UI | -| Multi-language label list for taps | Human reading the button | Same visible buttons; brittle if copy changes | -| Skip full ToS reading | User scrolling long TOS | Tap “Accept” if shown; no legal review automation | - -**Not automated yet (out of vanilla scope):** - -- Typing personal bank passwords into the wallet webview -- NFC -- Real camera / vision-based QR -- Biometric unlock -- Full onboarding wizard every time (we force-stop + re-intent) - -## Two systems under test - -| Stack | Withdraw source | Pay source | -|-------|-----------------|------------| -| **GOA** (`STACK=goa`) | `https://bank.hacktivism.ch/intro/demo-withdraw.json` | `taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana` (Paivana) | -| **stage** (`STACK=stage`) | `https://stage.bank.lefrancpaysan.ch/intro/demo-withdraw.json` | Public template e.g. `fermes-des-collines` / `panier-legumes` → `taler://pay/…` | - -Both use the **same** GUI driver (`lib_ui.py`); only URLs and package under test differ. - -## GUI driver behaviour (`lib_ui.gui_drive`) - -1. Every round: `uiautomator dump` + optional screencap. -2. If **“System UI isn’t responding”** → tap **Wait** (prefer over Close). -3. Else try exact taps on Confirm / Withdraw / Accept / Pay / DE/FR synonyms. -4. Else regex partial match on those words. -5. Repeat `GUI_ROUNDS` times (default 10), `GUI_SLEEP` seconds apart. - -Success signals (layered): - -- **Intent delivered** — `am start` OK -- **wallet-core** — logcat `prepareBankIntegratedWithdrawal` / pay-related ops -- **GUI taps** — non-empty `taps` in `gui-*-status.json` -- **Screenshots** — human-readable progress under `gui-withdraw/` / `gui-pay/` - -A run can be “vanilla-partial” if intents + some taps succeed but final -balance/settlement is not asserted (settlement still better covered by -`taler-monitoring.sh e2e` CLI wallet). - -## Host limits (clementine, 2026-07) - -| Constraint | Effect | -|------------|--------| -| ~3.7 GiB RAM | Emulator ANRs; GUI rounds often only dismiss ANR | -| Prefer physical phone or ≥6–8 GiB host | Real Confirm/Pay taps complete more often | -| No root required | User SDK + adb + F-Droid/source APK | - -## Relation to CLI e2e - -| Suite | Strength | -|-------|----------| -| `taler-monitoring.sh e2e` / `ladder` | Full withdraw+pay **settlement** via `taler-wallet-cli` | -| `run-android-gui-smoke.sh` | **Android app UI** surface for the same stacks | -| Combined | CLI = money path green; GUI = app UX green as far as taps go | - -## Upstream: `taler-android` `dev/hernani-inference/*` - -Branch name is **`hernani-inference`** (not “inferenced”). Remote: -`git@git.taler.net:taler-android.git`. - -| Branch | Relevance for automation | -|--------|---------------------------| -| **`dev/hernani-inference/gui-workflows`** | **Primary:** macOS emulator helpers + **GOA GUI chain** (`scripts/goa-chain-emu.sh`, `docs/gui-workflows.md`, `setup-avds.sh`, `start-emulators.sh`). Deep-link + `uiautomator` text taps + bank API mint + shop templates. | -| **`fix/bank-withdraw-auto-exchange`** | App fix: resolve exchange when bank omits it (needed for GOA withdraw UI). | -| **`fix/withdraw-spinner-fallback`** | App fix: stop endless withdraw spinner (+ OIM UI work). | -| **`experimental-oim` / `experimental-oim-pos`** | Experimental cash denomination UI; optional for demos, not required for chain. | - -### Port into this tree (Linux-capable) - -| Upstream (gui-workflows) | Here (`android-test/`) | -|--------------------------|-------------------------| -| `goa-chain-emu.sh` | **`run-goa-gui-chain.sh`** (goa + stage, Linux paths, explorer secrets as monitoring ladder) | -| `tap_text` / `click_through` | same idea in chain + `lib_ui.py` | -| `mint_withdraw` explorer API | same (Basic → token → POST withdrawals) | -| `mint_pay` public templates | same (goa-shop products / stage fermes templates) | -| `android-env.sh` Homebrew | use `$HOME/Android/Sdk` + system `adb` (see GIT-AUTOMATION-NOTES) | -| macOS-only docs | **Linux supported** for chain/smoke; AVD setup still DIY on Linux | - -Open issues mirrored from upstream `ISSUES.md` (gui-workflows): - -- **I-and-gui-001** — taps still best-effort; ToS/confirm may need human -- **I-and-gui-003** — `EXP_PW_FILE` required for mint -- **I-and-gui-004** — emulator RAM cost (this host ~3.7 GiB) -- **I-and-gui-007** — for reliable withdraw GUI, build/install app from **fix/bank-withdraw-auto-exchange** or **fix/withdraw-spinner-fallback**, not plain master alone - -### Ground rule (ops) - -1. **Bring it to run** first (scripts + live stack). -2. **App code changes only if needed**, and **minimal**. -3. Prefer **existing** `dev/hernani-inference/*` branches over new ones; extend them rather than forking random trees. - -### Recommended demo stack (2026-07) - -```text -1) taler-android checkout: - dev/hernani-inference/fix-bank-withdraw-auto-exchange - (or fix/withdraw-spinner-fallback for spinner + OIM) -2) ./gradlew :wallet:assembleFdroidDebug # package net.taler.wallet.fdroid.debug -3) Boot emulator / phone (prefer ≥6–8 GiB host RAM; 4 GiB → System UI ANR) -4) android-test/run-goa-gui-chain.sh # STACK=goa|stage -``` - -`run-android-build-and-smoke.sh` defaults `BRANCH=dev/hernani-inference/fix-bank-withdraw-auto-exchange` -so source smokes pick up the GOA exchange auto-add fix without manual checkout. - -### Runtime note (clementine) - -On ~3.7 GiB hosts the emulator often shows **System UI isn’t responding** before Confirm is tappable. Chain script now prioritizes **Wait** over hunting Confirm. For a green end-to-end GUI path use a physical device or more RAM — the **mint + deep-link + fix-branch APK** path is otherwise ready. - -```bash -# GOA chain (explorer mint + GUI taps) -EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \ - STACK=goa PKG=net.taler.wallet.fdroid.debug \ - ./run-goa-gui-chain.sh - -# Stage TESTPAYSAN (explorer via stagepaysan SSH if EXP_PW_FILE unset) -STACK=stage ./run-goa-gui-chain.sh -``` - -## Future upgrades (not required for vanilla) - -- Maestro / Appium flows with stable resource-ids -- Explicit wait for text “Withdrawal complete” / balance -- Port Paivana unlock scripts (upstream I-and-gui-002) -- Dual AVD wallet+POS (upstream I-and-gui-008) -- Record video (`adb shell screenrecord`) on failure - -## Checklist — “vanille erreicht?” - -- [x] Two stacks parameterized (goa / stage) -- [x] Published APK path -- [x] Source-build path (same smoke after build) -- [x] Deep-link shortcuts documented -- [x] Multi-round **graphical** taps (uiautomator) -- [x] GOA-style **chain** (multi withdraw + multi pay) from inference branch -- [x] Screenshots + UI XML + logcat artifacts -- [x] Inference branch mapping documented -- [ ] Fully reliable unattended Confirm→settled on low-RAM emulator (host-limited) -- [ ] App built from withdraw-fix branch by default in build script (optional) -- [ ] Assert final balance in app UI (optional next step) +Canonical document: **[GUI-AUTOMATION-NOTES.md](./GUI-AUTOMATION-NOTES.md)** +(minimal variant, platform flags `AUTO_ANDROID` / `AUTO_GUI`, Linux defaults). diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md new file mode 100644 index 0000000..2fc90af --- /dev/null +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -0,0 +1,179 @@ +# GUI automation notes (Android wallet) + +Canonical notes for **graphical** Android wallet tests against GOA / stage. +Older name `AUTOMATED-GUI-NOTES.md` redirects here. + +--- + +## Minimal variant (implemented first) — status 2026-07 + +This is the **minimal vanilla** path we actually shipped; full dual-platform +GUI is planned via flags (below), not required to use these scripts today. + +| Piece | Status | Notes | +|-------|--------|--------| +| F-Droid published APK install | **done** | `net.taler.wallet.fdroid` 1.6.1 / 854 | +| Source build + same smoke | **done** | `:wallet:assembleFdroidDebug` → `net.taler.wallet.fdroid.debug` | +| Default source branch | **done** | `dev/hernani-inference/fix-bank-withdraw-auto-exchange` (minimal GOA exchange auto-add) | +| Deep-link entry (≡ QR) | **done** | `adb` `VIEW` `taler://withdraw/…` / `taler://pay…` | +| Explorer API mint (chain) | **done** | `run-goa-gui-chain.sh` (GOA + stage) | +| UI taps via uiautomator | **done** | best-effort Confirm/ToS/Pay; ANR → prefer **Wait** | +| Screenshots / logcat artifacts | **done** | under `out*`, `out-gui-chain/` (gitignored) | +| Host CLI e2e settlement | **separate** | `taler-monitoring.sh e2e` / `ladder` (not Android UI) | +| Reliable unattended Confirm on 4 GiB Linux host | **limited** | System UI ANR; use phone or more RAM | +| iOS GUI | **not in this tree** | see taler-ios `dev/hernani-inference/gui-workflows` | +| macOS full dual AVD helpers | **upstream** | taler-android `gui-workflows` (Homebrew); ported ideas here for Linux | + +**Ground rule:** bring it to run; app diffs only if needed and **minimal**; prefer +extending existing `dev/hernani-inference/*` branches over new trees. + +```bash +cd scripts/taler-monitoring/android-test + +# Minimal hybrid (install + deep-link + light taps) +STACK=goa ./run-android-pay-smoke.sh + +# GUI multi-round taps +STACK=goa ./run-android-gui-smoke.sh +STACK=stage ./run-android-gui-smoke.sh + +# Explorer mint + multi withdraw/pay chain (from gui-workflows) +EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \ + STACK=goa PKG=net.taler.wallet.fdroid.debug \ + ./run-goa-gui-chain.sh + +# Build fix-branch APK then smoke (GUI=1 for gui smoke) +./run-android-build-and-smoke.sh +GUI=1 ./run-android-build-and-smoke.sh +``` + +--- + +## Platform capability flags (planned + defaults) + +Automation can run on hosts that support **Android only**, **GUI tooling only** +(conceptual), or **both**. Flags keep that explicit for CI and laptops. + +### Proposed env flags + +| Variable | Values | Meaning | +|----------|--------|---------| +| `AUTO_ANDROID` | `0` / `1` | Run Android wallet automation (adb, APK, emulator/device) | +| `AUTO_GUI` | `0` / `1` | Drive **graphical** UI (uiautomator taps, multi-round). If `0`, deep-link/CLI-only install smoke. | +| `AUTO_PLATFORM` | `auto` / `linux` / `macos` / `ios` | Host family (optional override) | + +**Semantics:** + +| `AUTO_ANDROID` | `AUTO_GUI` | Behaviour | +|----------------|------------|-----------| +| `1` | `0` | Android install + deep-link smoke only (no multi-round taps) | +| `1` | `1` | Android + graphical drive (vanilla GUI / chain) | +| `0` | `1` | Reserved (e.g. future desktop/web GUI); currently **no-op** with a clear message | +| `0` | `0` | Skip mobile automation | + +### Defaults by host (when flags unset) + +| Host (`uname -s`) | Default `AUTO_ANDROID` | Default `AUTO_GUI` | Rationale | +|-------------------|------------------------|--------------------|-----------| +| **Linux** | **`1`** | **`1`** if device/emulator present, else scripts exit 3 | This repo’s day-to-day path; **Android only** (no iOS here) | +| **Darwin (macOS)** | `1` | `1` | Can run Android emulators **and** (separately) taler-ios GUI helpers; both flags on for Android scripts; iOS is out of tree | +| **Other** | `0` | `0` | Fail closed | + +**Linux default = Android** (no second mobile platform in this suite). +**macOS** may enable both ecosystems in the wider monorepo sense; for *these* +scripts only Android is implemented — set `AUTO_ANDROID=1` (default) and keep +iOS under `taler-ios`. + +### Resolution helper (convention for future wrappers) + +```bash +# Example for a future run-all-mobile.sh +os=$(uname -s) +: "${AUTO_PLATFORM:=auto}" +case "$AUTO_PLATFORM" in + auto) case "$os" in Linux) AUTO_PLATFORM=linux ;; Darwin) AUTO_PLATFORM=macos ;; *) AUTO_PLATFORM=other ;; esac ;; +esac +case "$AUTO_PLATFORM" in + linux) + : "${AUTO_ANDROID:=1}" + : "${AUTO_GUI:=1}" + # no iOS + ;; + macos) + : "${AUTO_ANDROID:=1}" + : "${AUTO_GUI:=1}" + # optional later: AUTO_IOS=1 for taler-ios scripts + ;; + *) + : "${AUTO_ANDROID:=0}" + : "${AUTO_GUI:=0}" + ;; +esac +``` + +Scripts **today** implement Android only; they should honour: + +- `AUTO_ANDROID=0` → exit 0 with “skipped (AUTO_ANDROID=0)” +- `AUTO_GUI=0` → call deep-link smoke without multi-round GUI (or set `GUI_ROUNDS=0`) + +--- + +## Vanilla layers (detail) + +| Layer | What | Tooling | +|-------|------|---------| +| **A — Install** | F-Droid or from-source APK | `adb install` | +| **B — Entry (shortcut)** | `taler://withdraw/…` / `taler://pay…` | `adb am start -a VIEW` | +| **C — Graphical UI** | Confirm / ToS / Pay taps; ANR Wait | `uiautomator` + `lib_ui.py` / chain | +| **D — Evidence** | Screenshots, XML, logcat, URIs | `out*/` | + +### Legitimate shortcuts + +| Shortcut | Replaces | +|----------|----------| +| Deep-link withdraw/pay | Camera QR / opening paywall | +| Host `POST` template → pay URI | Browser shop checkout UI | +| Label-list taps | Human reading button text | +| Skip full ToS scroll | Tap Accept if shown | + +### Scripts + +| Script | Role | +|--------|------| +| `run-android-pay-smoke.sh` | Hybrid: install + deep-link + light taps | +| `run-android-gui-smoke.sh` | Multi-round GUI drive | +| `run-goa-gui-chain.sh` | Multi mint/withdraw/pay (gui-workflows port) | +| `run-android-build-and-smoke.sh` | Build inference fix branch + smoke (`GUI=1` optional) | +| `lib_ui.py` | Dump/tap/ANR helpers | + +--- + +## Stacks under test + +| Stack | Withdraw | Pay | +|-------|----------|-----| +| **GOA** | explorer mint / `demo-withdraw.json` @ bank.hacktivism.ch | goa-shop templates / Paivana pay-template | +| **stage** | explorer / demo-withdraw @ stage.bank… | fermes / jardin public templates | + +--- + +## Upstream branches (`taler-android` `dev/hernani-inference/*`) + +| Branch | Role | +|--------|------| +| `gui-workflows` | macOS emulator helpers + original `goa-chain-emu.sh` | +| `fix/bank-withdraw-auto-exchange` | **Minimal** GOA exchange auto-add (default build branch) | +| `fix/withdraw-spinner-fallback` | Spinner + OIM; use if spinner still hangs | +| `experimental-oim*` | Optional cash UI | + +--- + +## Checklist + +- [x] Minimal variant documented +- [x] Two stacks (goa / stage) +- [x] Flags design (`AUTO_ANDROID` / `AUTO_GUI` / platform defaults) +- [x] Linux default = Android suite only +- [x] Inference-branch policy +- [ ] Wrapper enforces flags in every entry script (incremental) +- [ ] Reliable unattended Confirm on low-RAM Linux emulator diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index dab2365..e1ab975 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -51,6 +51,13 @@ EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank- STACK=stage ./run-goa-gui-chain.sh ``` -Documented shortcuts, inference branches, limits: **[AUTOMATED-GUI-NOTES.md](./AUTOMATED-GUI-NOTES.md)**. +Canonical notes (minimal variant, **flags**, Linux defaults): +**[GUI-AUTOMATION-NOTES.md](./GUI-AUTOMATION-NOTES.md)**. + +| Flag | Linux default | Meaning | +|------|---------------|---------| +| `AUTO_ANDROID` | `1` | Run Android adb/APK automation | +| `AUTO_GUI` | `1` | Graphical uiautomator drive (else deep-link only) | +| `AUTO_PLATFORM` | `auto` → `linux` | Host family; macOS can later pair with iOS out of tree | See also parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). diff --git a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh index 241ab2c..de280f5 100755 --- a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh @@ -8,12 +8,23 @@ # Systems: STACK=goa | stage | auto # APK: published F-Droid or APK_PATH=… (from-source) # -# See AUTOMATED-GUI-NOTES.md +# See GUI-AUTOMATION-NOTES.md # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) -# shellcheck source=run-android-pay-smoke.sh -# We re-use install/URI resolution by calling common pieces inline. + +# Platform flags (GUI-AUTOMATION-NOTES.md). Linux → Android only. +os=$(uname -s) +case "${AUTO_PLATFORM:-auto}" in + auto) case "$os" in Linux|Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; esac ;; + linux|macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; +esac +[ "${AUTO_ANDROID}" = "1" ] || { echo "skipped: AUTO_ANDROID=${AUTO_ANDROID}"; exit 0; } +if [ "${AUTO_GUI}" != "1" ]; then + echo "AUTO_GUI=0 → deep-link smoke only" + exec env AUTO_ANDROID=1 "$ROOT/run-android-pay-smoke.sh" +fi export OUT_DIR="${OUT_DIR:-$ROOT/out-gui}" export GUI_ROUNDS="${GUI_ROUNDS:-10}" @@ -169,4 +180,4 @@ else fi echo "artifacts: $OUT_DIR/" ls -la "$OUT_DIR" | sed 's/^/ /' -echo "Documented shortcuts → AUTOMATED-GUI-NOTES.md" +echo "Documented shortcuts → GUI-AUTOMATION-NOTES.md" diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh index 907cb07..690c222 100755 --- a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh @@ -20,6 +20,20 @@ # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) + +# Platform flags (see GUI-AUTOMATION-NOTES.md). Linux default: Android only. +os=$(uname -s) +case "${AUTO_PLATFORM:-auto}" in + auto) case "$os" in Linux) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; esac ;; + linux) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; +esac +if [ "${AUTO_ANDROID}" != "1" ]; then + echo "skipped: AUTO_ANDROID=${AUTO_ANDROID} (platform flags — see GUI-AUTOMATION-NOTES.md)" + exit 0 +fi + APK_DIR="${APK_DIR:-$ROOT/apks}" APK_URL="${APK_URL:-https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}" APK_NAME="${APK_NAME:-net.taler.wallet.fdroid_854.apk}" diff --git a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh index 571e825..52e4328 100755 --- a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh +++ b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh @@ -19,7 +19,7 @@ # Env: ANDROID_SERIAL, PKG, EXP_PW_FILE, BANK, MERCHANT, INSTANCE, # WITHDRAW_AMOUNTS, PRODUCTS, PAY_LIMIT, SHOTDIR, PAUSE # -# See AUTOMATED-GUI-NOTES.md § "hernani-inference branches" +# See GUI-AUTOMATION-NOTES.md # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) @@ -37,6 +37,16 @@ step() { echo; echo "======== $* ========"; } info() { echo " $*"; } pause() { sleep "${1:-${PAUSE:-0.8}}"; } +# Platform flags — Linux default Android+GUI (see GUI-AUTOMATION-NOTES.md) +os=$(uname -s) +case "${AUTO_PLATFORM:-auto}" in + auto) case "$os" in Linux|Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; esac ;; + linux|macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; +esac +[ "${AUTO_ANDROID}" = "1" ] || { echo "skipped: AUTO_ANDROID=${AUTO_ANDROID}"; exit 0; } +[ "${AUTO_GUI}" = "1" ] || { echo "skipped: AUTO_GUI=${AUTO_GUI} (chain is GUI)"; exit 0; } + if ! command -v adb >/dev/null 2>&1; then [ -x "$HOME/Android/Sdk/platform-tools/adb" ] && export PATH="$HOME/Android/Sdk/platform-tools:$PATH" fi @@ -361,4 +371,4 @@ step "4 done → $SHOTDIR" ls -la "$SHOTDIR" | tail -40 adb_sh dumpsys window 2>/dev/null | grep mCurrentFocus | head -1 || true info "PKG=$PKG amounts=${AMOUNTS[*]} pays(limit)=$PAY_LIMIT" -echo "See AUTOMATED-GUI-NOTES.md (ported from taler-android dev/hernani-inference/gui-workflows)" +echo "See GUI-AUTOMATION-NOTES.md (ported from taler-android dev/hernani-inference/gui-workflows)" From c8a7eef8d45a2f1aaa815d04b28c20fd9dba76fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:24:35 +0200 Subject: [PATCH 257/326] monitoring: rename notes to GUI-AUTOMATION-NOTES.md only Wrong filename AUTOMATED-GUI-NOTES was a mistake; drop redirect stub. --- scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md | 4 ---- scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md | 1 - 2 files changed, 5 deletions(-) delete mode 100644 scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md diff --git a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md deleted file mode 100644 index bfa5196..0000000 --- a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md +++ /dev/null @@ -1,4 +0,0 @@ -# Moved - -Canonical document: **[GUI-AUTOMATION-NOTES.md](./GUI-AUTOMATION-NOTES.md)** -(minimal variant, platform flags `AUTO_ANDROID` / `AUTO_GUI`, Linux defaults). diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 2fc90af..95179b5 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -1,7 +1,6 @@ # GUI automation notes (Android wallet) Canonical notes for **graphical** Android wallet tests against GOA / stage. -Older name `AUTOMATED-GUI-NOTES.md` redirects here. --- From 7dc3d9e3d2a05d0c9802b32b0ea8dda3f66972a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:24:35 +0200 Subject: [PATCH 258/326] monitoring: rename notes to GUI-AUTOMATION-NOTES.md only Wrong filename AUTOMATED-GUI-NOTES was a mistake; drop redirect stub. --- scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md | 4 ---- scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md | 1 - 2 files changed, 5 deletions(-) delete mode 100644 scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md diff --git a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md b/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md deleted file mode 100644 index bfa5196..0000000 --- a/scripts/taler-monitoring/android-test/AUTOMATED-GUI-NOTES.md +++ /dev/null @@ -1,4 +0,0 @@ -# Moved - -Canonical document: **[GUI-AUTOMATION-NOTES.md](./GUI-AUTOMATION-NOTES.md)** -(minimal variant, platform flags `AUTO_ANDROID` / `AUTO_GUI`, Linux defaults). diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 2fc90af..95179b5 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -1,7 +1,6 @@ # GUI automation notes (Android wallet) Canonical notes for **graphical** Android wallet tests against GOA / stage. -Older name `AUTOMATED-GUI-NOTES.md` redirects here. --- From 59ee193f9890379db1e50a05b153e445347a970a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:25:37 +0200 Subject: [PATCH 259/326] monitoring: CLI-AUTOMATION-NOTES for recurring wallet-cli issues Prioritised list from e2e/ladder automation (run-until-done, settle, reserve_pub, exchange auto-add, URI ports, JSON) to guide CLI/core fixes. --- .../taler-monitoring/CLI-AUTOMATION-NOTES.md | 228 ++++++++++++++++++ scripts/taler-monitoring/README.md | 4 +- 2 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md diff --git a/scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md new file mode 100644 index 0000000..3cd311a --- /dev/null +++ b/scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md @@ -0,0 +1,228 @@ +# CLI automation notes (`taler-wallet-cli` + monitoring) + +Living list of **recurring issues** seen while driving GOA (hacktivism) and +stage TESTPAYSAN via **CLI** (`taler-wallet-cli`, bank/merchant HTTP, monitoring +e2e/ladder). Ordered roughly by **how often they hurt automation** and how +useful a fix in **wallet-cli / wallet-core** (or clearer APIs) would be. + +Companion: Android GUI → [`android-test/GUI-AUTOMATION-NOTES.md`](./android-test/GUI-AUTOMATION-NOTES.md). + +Scripts: `check_e2e.sh`, `check_goa_ladder.sh`, `taler-monitoring.sh`. + +Legend: + +| Tag | Meaning | +|-----|---------| +| **cli** | wallet-cli UX / flags / hang behaviour | +| **core** | wallet-core protocol / state machine | +| **bank** | libeufin-bank / integration API | +| **ops** | secrets, paths, multi-host | +| **doc** | missing or wrong documentation | + +--- + +## 1. `run-until-done` is unusable for automation + +| | | +|--|--| +| **Seen** | e2e, ladder, Android notes, macOS comments: *hangs / banned in monitoring* | +| **Area** | **cli** / **core** | +| **Problem** | `taler-wallet-cli run-until-done` (or long shepherd runs) often **never returns** or blocks CI-length timeouts. Monitoring **forbids** it and polls balance + bank `transfer_done` instead. | +| **Workaround** | `advanced serve` + socket (`WSOCK`) or pure poll loops (`LADDER_SETTLE_*`, e2e settle wait). | +| **Wanted for wallet-cli** | Bounded wait: e.g. `run-until-done --timeout=Ns --exit-on=withdrawn|paid|idle`; stable non-zero exit on timeout; progress on stdout/JSON. | + +--- + +## 2. Withdraw completion is not observable from CLI alone + +| | | +|--|--| +| **Seen** | e2e “coins missing after withdraw”; ladder `OK_BANK` vs wallet avail | +| **Area** | **cli** / **core** / **bank** | +| **Problem** | After `accept-uri` + bank confirm, coins may lag (wirewatch). CLI has no single “withdraw settled” command with clear success/failure; automation reimplements status from bank integration JSON + balance polls. | +| **Workaround** | Poll `balance` + `…/withdrawal-operation/{id}` (`status`, `transfer_done`, `selected_reserve_pub`). | +| **Wanted** | `wallet-cli withdrawals wait --id=… --timeout=` or JSON event stream: `selected → confirmed → coins-available`. | + +--- + +## 3. `reserve_pub` / force-select is fragile (5114) + +| | | +|--|--| +| **Seen** | ladder force-select, e2e confirm path, Android spinner issues | +| **Area** | **cli** / **core** / **bank** | +| **Problem** | Cumulative wallets re-emit old reserves; binding the wrong `reserve_pub` yields **HTTP 409 / code 5114** (“already bound”). Automation scrapes accept/tx dumps for candidates. | +| **Workaround** | Score pubs by WID/amount; try several; treat 5114 as “stale reserve” not “out of money”. | +| **Wanted** | CLI returns **the** `reserve_pub` for the just-accepted withdraw in structured JSON; `force-select` / bank-side select API documented with idempotency. | + +--- + +## 4. Exchange not auto-known for bank deep links / withdraw URIs + +| | | +|--|--| +| **Seen** | Android endless spinner; GOA `bank.hacktivism.ch` withdraw; fixed in app branch `fix/bank-withdraw-auto-exchange` | +| **Area** | **core** / **cli** | +| **Problem** | Withdraw URI names an exchange the wallet has never added → stuck loading / empty exchange list. CLI requires explicit `exchanges add` + `update` + `accept-tos` every fresh DB. | +| **Workaround** | e2e always adds exchange before ATM ladder; Android needs ensureExchange-style fix. | +| **Wanted** | CLI/core: on `accept-uri` withdraw, **auto-add** exchange from URI/bank details (`allowCompletion`), then surface ToS if needed in one command. | + +--- + +## 5. Default ports in URIs (`:443` / `:80`) break round-trips + +| | | +|--|--| +| **Seen** | ladder/e2e strip ports; QR checks; mint URIs with `:443` | +| **Area** | **cli** / **bank** / **doc** | +| **Problem** | Some bank/wallet outputs include `taler://…host:443/…`. QR validators and some clients reject or double-normalize inconsistently. | +| **Workaround** | `sed 's/:443\//\//g'` everywhere in automation. | +| **Wanted** | Canonical form without default ports in **all** wallet-cli and bank integration outputs; document as invariant. | + +--- + +## 6. No first-class “demo withdraw” / explorer mint in wallet-cli + +| | | +|--|--| +| **Seen** | e2e uses bank admin + ATM create; ladder/gui-chain reimplement explorer mint in Python | +| **Area** | **cli** / **ops** | +| **Problem** | Automation always hand-rolls Basic auth → token → `POST …/withdrawals` → `taler_withdraw_uri`. Easy to get wrong (auth header, amount currency). | +| **Workaround** | Shared Python in `goa-chain` / ladder / e2e; secrets files. | +| **Wanted** | Optional helper: `wallet-cli testing mint-withdraw --bank=URL --user=explorer --password-file=… --amount=GOA:10` (or bank-side only tool in libeufin) emitting a clean URI. | + +--- + +## 7. Long-lived wallet process required for pay/withdraw progress + +| | | +|--|--| +| **Seen** | e2e `advanced serve` / shepherd socket | +| **Area** | **cli** | +| **Problem** | One-shot CLI invocations do not keep background work alive; without serve/shepherd, withdraw/pay may stall mid-flight. | +| **Workaround** | Start serve once per e2e run; route `wcli` through the socket. | +| **Wanted** | Documented, supported “session mode”: start/stop serve; or each mutating command optionally `--background-until=…` with timeout. | + +--- + +## 8. ToS accept is a separate step that fails silently or blocks + +| | | +|--|--| +| **Seen** | e2e `exchanges accept-tos`; ladder after add; Android GUI ToS screens | +| **Area** | **cli** / **core** | +| **Problem** | Fresh exchange → operations need ToS; CLI must call `accept-tos` explicitly. Failures are easy to miss in multi-step scripts. | +| **Workaround** | Always `accept-tos` after `update` in e2e/ladder. | +| **Wanted** | `accept-uri --accept-tos` or auto-prompt with non-interactive `--yes` that covers exchange ToS for that URI’s exchange. | + +--- + +## 9. Pay template / public order path is merchant-shaped, not CLI-shaped + +| | | +|--|--| +| **Seen** | e2e shop templates, stage farmer shops, ladder private orders | +| **Area** | **cli** / **doc** | +| **Problem** | Wallet-cli pays via `handle-uri` on `taler://pay/…`; creating the order is always custom curl (template POST or private order + token). No unified “pay this amount to instance” for public templates. | +| **Workaround** | Monitoring builds URI externally then `handle-uri --yes`. | +| **Wanted** | Documented recipe only, or `wallet-cli testing pay-template --base=… --instance=… --id=…` for demos. | + +--- + +## 10. Amount / currency parsing edge cases + +| | | +|--|--| +| **Seen** | ladder min denom 0.01 vs GOA 1e-6; zero withdraw rejected (HTTP 409 / amount too low); max wire ceilings | +| **Area** | **cli** / **bank** | +| **Problem** | `CURRENCY:0` and sub-min amounts fail differently per stack; CLI error strings are not machine-stable. | +| **Workaround** | Soft-skip zero rung; clamp ladder min to exchange denoms / bank max_wire. | +| **Wanted** | Structured errors (`AMOUNT_too_small`, `currency_unknown`) in JSON mode; `wallet-cli amount validate --exchange=`. | + +--- + +## 11. Finding the right `taler-wallet-cli` binary + +| | | +|--|--| +| **Seen** | `find_wallet_cli` in `lib.sh`; hardcoded laptop paths; wrapper vs `.mjs` | +| **Area** | **ops** / **cli** | +| **Problem** | Debian package is a shell wrapper; some tools need `node …/taler-wallet-cli.mjs`. Hardcoded `/Users/…` paths break other hosts. | +| **Workaround** | `find_wallet_cli` search list; `WALLET_CLI=` override. | +| **Wanted** | Single install story: `wallet-cli --version` JSON with path + libversion; no need to pass `.mjs` to node by hand. | + +--- + +## 12. Secrets and multi-stack confusion + +| | | +|--|--| +| **Seen** | explorer vs admin password; GOA secrets used on stage; ladder EXP_PW | +| **Area** | **ops** | +| **Problem** | CLI does not know “which stack”; wrong password → 401 mid-ladder. Not a wallet-cli bug, but every CLI automation hits it. | +| **Workaround** | `SECRETS_ROOT`, stage SSH, `STACK=` profiles. | +| **Wanted** | Optional `~/.config/taler/stacks.d/goa.env` convention documented next to wallet-cli; still no secrets in repo. | + +--- + +## 13. No stable machine-readable “step result” for scripts + +| | | +|--|--| +| **Seen** | e2e greps accept output; ladder scrapes JSON from mixed stdout | +| **Area** | **cli** | +| **Problem** | Human logs + occasional JSON blobs; hard to parse reliably. | +| **Workaround** | Python scrapers, temp files, `tee`. | +| **Wanted** | Global `--json` / `--ndjson` for all commands; one object per completed operation with `ok`, `op`, `ids`, `amounts`. | + +--- + +## 14. Pay settlement wait is symmetric to withdraw pain + +| | | +|--|--| +| **Seen** | e2e pay settle loops; ladder pay settle rounds | +| **Area** | **cli** / **core** | +| **Problem** | After `handle-uri` pay, success is “order paid” / balance drop / merchant order status — not one CLI wait. | +| **Workaround** | Short poll loops; never run-until-done. | +| **Wanted** | Same as §1–2: bounded wait on transaction id / order id. | + +--- + +## Priority shortlist for `taler-wallet-cli` / core + +If only a few changes land, these unlock the most automation: + +1. **Bounded `run-until-done` / wait-for-state** (§1, §2, §14) +2. **Structured JSON on every command** (§13) +3. **Auto-add exchange + ToS on accept-uri** (§4, §8) +4. **Canonical URIs without :443** (§5) +5. **Emit reserve_pub for last withdraw** (§3) + +--- + +## How we work around today (monitoring) + +| Issue | Monitoring behaviour | +|-------|----------------------| +| run-until-done | Disabled; balance + bank poll | +| serve | Optional long-lived socket in e2e | +| exchange | Explicit add/update/accept-tos | +| ladder explorer | `read_secret` / EXP_PW_FILE | +| stage maxima | bank `max_wire` + keys min denom | +| force-select | multi-rpub try; soft-skip confirm | + +--- + +## How to add an issue + +```markdown +### N. short title +| | | +|--|--| +| **Seen** | where / which stack | +| **Area** | cli / core / bank / ops / doc | +| **Problem** | … | +| **Workaround** | … | +| **Wanted** | concrete CLI/core behaviour | +``` diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 35dba91..eea5e12 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -14,7 +14,9 @@ Report for the **GOA** stack with boxed severity badges and **grouped test IDs** IDs reset per **group** inside an area — so “too many www tests” become `www.exchange-*`, `www.bank-*`, `www.landing-*`, etc. Catalog: **[TESTS.md](./TESTS.md)**. -Android UI automation prerequisites: **[GIT-AUTOMATION-NOTES.md](./GIT-AUTOMATION-NOTES.md)** (tools **not** installed by default). +Android / tooling: **[GIT-AUTOMATION-NOTES.md](./GIT-AUTOMATION-NOTES.md)**. +GUI Android notes: **[android-test/GUI-AUTOMATION-NOTES.md](./android-test/GUI-AUTOMATION-NOTES.md)**. +CLI / wallet-cli recurring issues (for upstream improvements): **[CLI-AUTOMATION-NOTES.md](./CLI-AUTOMATION-NOTES.md)**. ```text ┌ OK ┐ www.exchange-01 exchange /config · HTTP 200 From 24edc8db0ca677a2bf39add3d3d3732cdc2d7b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:25:37 +0200 Subject: [PATCH 260/326] monitoring: CLI-AUTOMATION-NOTES for recurring wallet-cli issues Prioritised list from e2e/ladder automation (run-until-done, settle, reserve_pub, exchange auto-add, URI ports, JSON) to guide CLI/core fixes. --- .../taler-monitoring/CLI-AUTOMATION-NOTES.md | 228 ++++++++++++++++++ scripts/taler-monitoring/README.md | 4 +- 2 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md diff --git a/scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md new file mode 100644 index 0000000..3cd311a --- /dev/null +++ b/scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md @@ -0,0 +1,228 @@ +# CLI automation notes (`taler-wallet-cli` + monitoring) + +Living list of **recurring issues** seen while driving GOA (hacktivism) and +stage TESTPAYSAN via **CLI** (`taler-wallet-cli`, bank/merchant HTTP, monitoring +e2e/ladder). Ordered roughly by **how often they hurt automation** and how +useful a fix in **wallet-cli / wallet-core** (or clearer APIs) would be. + +Companion: Android GUI → [`android-test/GUI-AUTOMATION-NOTES.md`](./android-test/GUI-AUTOMATION-NOTES.md). + +Scripts: `check_e2e.sh`, `check_goa_ladder.sh`, `taler-monitoring.sh`. + +Legend: + +| Tag | Meaning | +|-----|---------| +| **cli** | wallet-cli UX / flags / hang behaviour | +| **core** | wallet-core protocol / state machine | +| **bank** | libeufin-bank / integration API | +| **ops** | secrets, paths, multi-host | +| **doc** | missing or wrong documentation | + +--- + +## 1. `run-until-done` is unusable for automation + +| | | +|--|--| +| **Seen** | e2e, ladder, Android notes, macOS comments: *hangs / banned in monitoring* | +| **Area** | **cli** / **core** | +| **Problem** | `taler-wallet-cli run-until-done` (or long shepherd runs) often **never returns** or blocks CI-length timeouts. Monitoring **forbids** it and polls balance + bank `transfer_done` instead. | +| **Workaround** | `advanced serve` + socket (`WSOCK`) or pure poll loops (`LADDER_SETTLE_*`, e2e settle wait). | +| **Wanted for wallet-cli** | Bounded wait: e.g. `run-until-done --timeout=Ns --exit-on=withdrawn|paid|idle`; stable non-zero exit on timeout; progress on stdout/JSON. | + +--- + +## 2. Withdraw completion is not observable from CLI alone + +| | | +|--|--| +| **Seen** | e2e “coins missing after withdraw”; ladder `OK_BANK` vs wallet avail | +| **Area** | **cli** / **core** / **bank** | +| **Problem** | After `accept-uri` + bank confirm, coins may lag (wirewatch). CLI has no single “withdraw settled” command with clear success/failure; automation reimplements status from bank integration JSON + balance polls. | +| **Workaround** | Poll `balance` + `…/withdrawal-operation/{id}` (`status`, `transfer_done`, `selected_reserve_pub`). | +| **Wanted** | `wallet-cli withdrawals wait --id=… --timeout=` or JSON event stream: `selected → confirmed → coins-available`. | + +--- + +## 3. `reserve_pub` / force-select is fragile (5114) + +| | | +|--|--| +| **Seen** | ladder force-select, e2e confirm path, Android spinner issues | +| **Area** | **cli** / **core** / **bank** | +| **Problem** | Cumulative wallets re-emit old reserves; binding the wrong `reserve_pub` yields **HTTP 409 / code 5114** (“already bound”). Automation scrapes accept/tx dumps for candidates. | +| **Workaround** | Score pubs by WID/amount; try several; treat 5114 as “stale reserve” not “out of money”. | +| **Wanted** | CLI returns **the** `reserve_pub` for the just-accepted withdraw in structured JSON; `force-select` / bank-side select API documented with idempotency. | + +--- + +## 4. Exchange not auto-known for bank deep links / withdraw URIs + +| | | +|--|--| +| **Seen** | Android endless spinner; GOA `bank.hacktivism.ch` withdraw; fixed in app branch `fix/bank-withdraw-auto-exchange` | +| **Area** | **core** / **cli** | +| **Problem** | Withdraw URI names an exchange the wallet has never added → stuck loading / empty exchange list. CLI requires explicit `exchanges add` + `update` + `accept-tos` every fresh DB. | +| **Workaround** | e2e always adds exchange before ATM ladder; Android needs ensureExchange-style fix. | +| **Wanted** | CLI/core: on `accept-uri` withdraw, **auto-add** exchange from URI/bank details (`allowCompletion`), then surface ToS if needed in one command. | + +--- + +## 5. Default ports in URIs (`:443` / `:80`) break round-trips + +| | | +|--|--| +| **Seen** | ladder/e2e strip ports; QR checks; mint URIs with `:443` | +| **Area** | **cli** / **bank** / **doc** | +| **Problem** | Some bank/wallet outputs include `taler://…host:443/…`. QR validators and some clients reject or double-normalize inconsistently. | +| **Workaround** | `sed 's/:443\//\//g'` everywhere in automation. | +| **Wanted** | Canonical form without default ports in **all** wallet-cli and bank integration outputs; document as invariant. | + +--- + +## 6. No first-class “demo withdraw” / explorer mint in wallet-cli + +| | | +|--|--| +| **Seen** | e2e uses bank admin + ATM create; ladder/gui-chain reimplement explorer mint in Python | +| **Area** | **cli** / **ops** | +| **Problem** | Automation always hand-rolls Basic auth → token → `POST …/withdrawals` → `taler_withdraw_uri`. Easy to get wrong (auth header, amount currency). | +| **Workaround** | Shared Python in `goa-chain` / ladder / e2e; secrets files. | +| **Wanted** | Optional helper: `wallet-cli testing mint-withdraw --bank=URL --user=explorer --password-file=… --amount=GOA:10` (or bank-side only tool in libeufin) emitting a clean URI. | + +--- + +## 7. Long-lived wallet process required for pay/withdraw progress + +| | | +|--|--| +| **Seen** | e2e `advanced serve` / shepherd socket | +| **Area** | **cli** | +| **Problem** | One-shot CLI invocations do not keep background work alive; without serve/shepherd, withdraw/pay may stall mid-flight. | +| **Workaround** | Start serve once per e2e run; route `wcli` through the socket. | +| **Wanted** | Documented, supported “session mode”: start/stop serve; or each mutating command optionally `--background-until=…` with timeout. | + +--- + +## 8. ToS accept is a separate step that fails silently or blocks + +| | | +|--|--| +| **Seen** | e2e `exchanges accept-tos`; ladder after add; Android GUI ToS screens | +| **Area** | **cli** / **core** | +| **Problem** | Fresh exchange → operations need ToS; CLI must call `accept-tos` explicitly. Failures are easy to miss in multi-step scripts. | +| **Workaround** | Always `accept-tos` after `update` in e2e/ladder. | +| **Wanted** | `accept-uri --accept-tos` or auto-prompt with non-interactive `--yes` that covers exchange ToS for that URI’s exchange. | + +--- + +## 9. Pay template / public order path is merchant-shaped, not CLI-shaped + +| | | +|--|--| +| **Seen** | e2e shop templates, stage farmer shops, ladder private orders | +| **Area** | **cli** / **doc** | +| **Problem** | Wallet-cli pays via `handle-uri` on `taler://pay/…`; creating the order is always custom curl (template POST or private order + token). No unified “pay this amount to instance” for public templates. | +| **Workaround** | Monitoring builds URI externally then `handle-uri --yes`. | +| **Wanted** | Documented recipe only, or `wallet-cli testing pay-template --base=… --instance=… --id=…` for demos. | + +--- + +## 10. Amount / currency parsing edge cases + +| | | +|--|--| +| **Seen** | ladder min denom 0.01 vs GOA 1e-6; zero withdraw rejected (HTTP 409 / amount too low); max wire ceilings | +| **Area** | **cli** / **bank** | +| **Problem** | `CURRENCY:0` and sub-min amounts fail differently per stack; CLI error strings are not machine-stable. | +| **Workaround** | Soft-skip zero rung; clamp ladder min to exchange denoms / bank max_wire. | +| **Wanted** | Structured errors (`AMOUNT_too_small`, `currency_unknown`) in JSON mode; `wallet-cli amount validate --exchange=`. | + +--- + +## 11. Finding the right `taler-wallet-cli` binary + +| | | +|--|--| +| **Seen** | `find_wallet_cli` in `lib.sh`; hardcoded laptop paths; wrapper vs `.mjs` | +| **Area** | **ops** / **cli** | +| **Problem** | Debian package is a shell wrapper; some tools need `node …/taler-wallet-cli.mjs`. Hardcoded `/Users/…` paths break other hosts. | +| **Workaround** | `find_wallet_cli` search list; `WALLET_CLI=` override. | +| **Wanted** | Single install story: `wallet-cli --version` JSON with path + libversion; no need to pass `.mjs` to node by hand. | + +--- + +## 12. Secrets and multi-stack confusion + +| | | +|--|--| +| **Seen** | explorer vs admin password; GOA secrets used on stage; ladder EXP_PW | +| **Area** | **ops** | +| **Problem** | CLI does not know “which stack”; wrong password → 401 mid-ladder. Not a wallet-cli bug, but every CLI automation hits it. | +| **Workaround** | `SECRETS_ROOT`, stage SSH, `STACK=` profiles. | +| **Wanted** | Optional `~/.config/taler/stacks.d/goa.env` convention documented next to wallet-cli; still no secrets in repo. | + +--- + +## 13. No stable machine-readable “step result” for scripts + +| | | +|--|--| +| **Seen** | e2e greps accept output; ladder scrapes JSON from mixed stdout | +| **Area** | **cli** | +| **Problem** | Human logs + occasional JSON blobs; hard to parse reliably. | +| **Workaround** | Python scrapers, temp files, `tee`. | +| **Wanted** | Global `--json` / `--ndjson` for all commands; one object per completed operation with `ok`, `op`, `ids`, `amounts`. | + +--- + +## 14. Pay settlement wait is symmetric to withdraw pain + +| | | +|--|--| +| **Seen** | e2e pay settle loops; ladder pay settle rounds | +| **Area** | **cli** / **core** | +| **Problem** | After `handle-uri` pay, success is “order paid” / balance drop / merchant order status — not one CLI wait. | +| **Workaround** | Short poll loops; never run-until-done. | +| **Wanted** | Same as §1–2: bounded wait on transaction id / order id. | + +--- + +## Priority shortlist for `taler-wallet-cli` / core + +If only a few changes land, these unlock the most automation: + +1. **Bounded `run-until-done` / wait-for-state** (§1, §2, §14) +2. **Structured JSON on every command** (§13) +3. **Auto-add exchange + ToS on accept-uri** (§4, §8) +4. **Canonical URIs without :443** (§5) +5. **Emit reserve_pub for last withdraw** (§3) + +--- + +## How we work around today (monitoring) + +| Issue | Monitoring behaviour | +|-------|----------------------| +| run-until-done | Disabled; balance + bank poll | +| serve | Optional long-lived socket in e2e | +| exchange | Explicit add/update/accept-tos | +| ladder explorer | `read_secret` / EXP_PW_FILE | +| stage maxima | bank `max_wire` + keys min denom | +| force-select | multi-rpub try; soft-skip confirm | + +--- + +## How to add an issue + +```markdown +### N. short title +| | | +|--|--| +| **Seen** | where / which stack | +| **Area** | cli / core / bank / ops / doc | +| **Problem** | … | +| **Workaround** | … | +| **Wanted** | concrete CLI/core behaviour | +``` diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 35dba91..eea5e12 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -14,7 +14,9 @@ Report for the **GOA** stack with boxed severity badges and **grouped test IDs** IDs reset per **group** inside an area — so “too many www tests” become `www.exchange-*`, `www.bank-*`, `www.landing-*`, etc. Catalog: **[TESTS.md](./TESTS.md)**. -Android UI automation prerequisites: **[GIT-AUTOMATION-NOTES.md](./GIT-AUTOMATION-NOTES.md)** (tools **not** installed by default). +Android / tooling: **[GIT-AUTOMATION-NOTES.md](./GIT-AUTOMATION-NOTES.md)**. +GUI Android notes: **[android-test/GUI-AUTOMATION-NOTES.md](./android-test/GUI-AUTOMATION-NOTES.md)**. +CLI / wallet-cli recurring issues (for upstream improvements): **[CLI-AUTOMATION-NOTES.md](./CLI-AUTOMATION-NOTES.md)**. ```text ┌ OK ┐ www.exchange-01 exchange /config · HTTP 200 From 10054b947b67f4bce86e99fb59f955f60add612f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:26:37 +0200 Subject: [PATCH 261/326] =?UTF-8?q?docs(android-test):=20Minimalvariante?= =?UTF-8?q?=20limits=20=E2=80=94=20was=20geht=20/=20was=20nicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document GUI smoke boundaries: deep-link + best-effort taps work; unattended settlement and full UI E2E on low-RAM emulator do not. --- .../android-test/GUI-AUTOMATION-NOTES.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 95179b5..fce915e 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -26,6 +26,66 @@ GUI is planned via flags (below), not required to use these scripts today. **Ground rule:** bring it to run; app diffs only if needed and **minimal**; prefer extending existing `dev/hernani-inference/*` branches over new trees. +### Minimalvariante: was geht / was nicht + +Einschränkungen der **aktuell implementierten** Minimalvariante (deep-link + +uiautomator best-effort). Kein voller Dual-Platform-GUI-Stack, kein Ersatz für +Host-CLI-Settlement. + +#### Geht (grün) + +| Fähigkeit | Wie / Hinweis | +|-----------|----------------| +| APK installieren | F-Droid `net.taler.wallet.fdroid` **oder** Source `assembleFdroidDebug` (`.debug`) | +| GOA-Exchange ohne manuelles Add | Source-Build von `fix-bank-withdraw-auto-exchange` (Default in `run-android-build-and-smoke.sh`) | +| Withdraw/Pay **starten** | `adb` deep-link `taler://withdraw/…` / `taler://pay…` (Ersatz für QR-Scan) | +| Light UI-Drive | Confirm / Accept / Pay-Taps über Label-Suche (`lib_ui.py`, gui-smoke, chain) | +| Multi-Runden-Taps | `run-android-gui-smoke.sh` / `run-goa-gui-chain.sh` (best-effort) | +| Explorer-Mint (Kette) | `run-goa-gui-chain.sh` + `EXP_PW_FILE` (GOA + stage) | +| Stack-Wahl | `STACK=goa` / `stage` (Bank/Merchant-URLs) | +| Artefakte | Screenshots, UI-XML, logcat unter `out*` / `out-gui-chain/` (gitignored) | +| Hybrid-Smoke | Install + Intent + leichte Taps: `run-android-pay-smoke.sh` | +| Settlement **nachweisen** | **Host-seitig**: `taler-monitoring.sh e2e` / `ladder` (nicht Android-UI) | +| Flags abschalten | `AUTO_ANDROID=0` skip; `AUTO_GUI=0` → nur deep-link/pay-smoke | + +#### Geht **nicht** oder nur eingeschränkt (rot / gelb) + +| Beschränkung | Folge / Workaround | +|--------------|--------------------| +| **Unattended Confirm → settled im Emulator (~4 GiB Host)** | Häufig **System UI ANR**; Taps bleiben hängen. Prefer **Wait** auf ANR-Dialog, echtes Gerät, oder Host mit mehr RAM. Kein zuverlässiges grünes E2E nur über GUI auf dem schwachen Emulator. | +| **Vollständige Wallet-Settlement-Assertion in der GUI** | Minimalvariante prüft **nicht** „Balance final / transfer_done“ in der App-Oberfläche. Proof bleibt CLI/e2e. | +| **Kamera / physischer QR-Scan** | Absichtlich nicht automatisiert; deep-link ist der Shortcut. Landing-QR-Pfade sind **HTTP**-Thema, nicht Android-GUI. | +| **Browser-Shop-Checkout-UI** | Kein Chromium/WebView-Drive; Pay-URI kommt per Template-`POST` + Intent. | +| **Volles ToS-Scroll / alle Dialoge** | Best-effort Accept-Tap; lange ToS oder unerwartete Sheets können hängen bleiben. | +| **Published F-Droid allein auf GOA** | Kann Exchange-Add / Spinner-Probleme zeigen → **fix-branch-APK** bauen; bei Spinner-Hang optional `fix-withdraw-spinner-fallback`. | +| **iOS-GUI** | Nicht in diesem Tree; siehe taler-ios `gui-workflows`. | +| **macOS dual AVD / Homebrew-Helpers** | Upstream `gui-workflows`; hier nur Linux-taugliche Port-Ideen. | +| **`AUTO_*` in jedem Script erzwungen** | Design + teilweise verdrahtet; Wrapper-Checkliste noch offen. | +| **`run-until-done` / CLI-Wallet in GUI-Skripten** | Gehört zu CLI-Automation, nicht GUI; siehe `CLI-AUTOMATION-NOTES.md`. | +| **CI grün = „User hat bezahlt“** | Smoke = Intent + best-effort UI. Wirtschaftlicher Erfolg = Host-e2e / ladder / Explorer. | + +#### Erwartetes Ergebnis pro Schicht + +| Schicht | Erfolgskriterium Minimalvariante | +|---------|----------------------------------| +| A Install | APK installed, App startet | +| B Deep-link | Intent delivered, Wallet öffnet Withdraw/Pay-Flow | +| C GUI taps | Dump+Tap ohne Crash; ANR ggf. mit Wait; **kein** Garant für „Paid“ | +| D Evidence | Artefakte geschrieben | +| Settlement | **Außerhalb** GUI: Host e2e/ladder | + +#### Wann Minimalvariante reicht + +- Smoke nach APK-/Branch-Build (Intent kommt an, App crasht nicht). +- Manuelle Nacharbeit am Gerät nach vorbereiteten URIs. +- GOA/stage **Erreichbarkeit** von Landing/Template + Wallet-Einstieg. + +#### Wann nicht reicht → nächster Schritt + +- Unattended grünes Pay-E2E auf dem Emulator → mehr RAM / physisches Gerät, ggf. `fix-withdraw-spinner-fallback`. +- Beweis „Münzen da / Merchant paid“ → CLI e2e/ladder, nicht GUI-smoke. +- iOS / Dual-Platform → andere Repos/Flags, nicht diese Skripte. + ```bash cd scripts/taler-monitoring/android-test @@ -170,6 +230,7 @@ Scripts **today** implement Android only; they should honour: ## Checklist - [x] Minimal variant documented +- [x] Minimalvariante: was geht / was nicht (Beschränkungen) - [x] Two stacks (goa / stage) - [x] Flags design (`AUTO_ANDROID` / `AUTO_GUI` / platform defaults) - [x] Linux default = Android suite only From 80f0219f1128969ca92a88947b63360f9ae0d739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:26:37 +0200 Subject: [PATCH 262/326] =?UTF-8?q?docs(android-test):=20Minimalvariante?= =?UTF-8?q?=20limits=20=E2=80=94=20was=20geht=20/=20was=20nicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document GUI smoke boundaries: deep-link + best-effort taps work; unattended settlement and full UI E2E on low-RAM emulator do not. --- .../android-test/GUI-AUTOMATION-NOTES.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 95179b5..fce915e 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -26,6 +26,66 @@ GUI is planned via flags (below), not required to use these scripts today. **Ground rule:** bring it to run; app diffs only if needed and **minimal**; prefer extending existing `dev/hernani-inference/*` branches over new trees. +### Minimalvariante: was geht / was nicht + +Einschränkungen der **aktuell implementierten** Minimalvariante (deep-link + +uiautomator best-effort). Kein voller Dual-Platform-GUI-Stack, kein Ersatz für +Host-CLI-Settlement. + +#### Geht (grün) + +| Fähigkeit | Wie / Hinweis | +|-----------|----------------| +| APK installieren | F-Droid `net.taler.wallet.fdroid` **oder** Source `assembleFdroidDebug` (`.debug`) | +| GOA-Exchange ohne manuelles Add | Source-Build von `fix-bank-withdraw-auto-exchange` (Default in `run-android-build-and-smoke.sh`) | +| Withdraw/Pay **starten** | `adb` deep-link `taler://withdraw/…` / `taler://pay…` (Ersatz für QR-Scan) | +| Light UI-Drive | Confirm / Accept / Pay-Taps über Label-Suche (`lib_ui.py`, gui-smoke, chain) | +| Multi-Runden-Taps | `run-android-gui-smoke.sh` / `run-goa-gui-chain.sh` (best-effort) | +| Explorer-Mint (Kette) | `run-goa-gui-chain.sh` + `EXP_PW_FILE` (GOA + stage) | +| Stack-Wahl | `STACK=goa` / `stage` (Bank/Merchant-URLs) | +| Artefakte | Screenshots, UI-XML, logcat unter `out*` / `out-gui-chain/` (gitignored) | +| Hybrid-Smoke | Install + Intent + leichte Taps: `run-android-pay-smoke.sh` | +| Settlement **nachweisen** | **Host-seitig**: `taler-monitoring.sh e2e` / `ladder` (nicht Android-UI) | +| Flags abschalten | `AUTO_ANDROID=0` skip; `AUTO_GUI=0` → nur deep-link/pay-smoke | + +#### Geht **nicht** oder nur eingeschränkt (rot / gelb) + +| Beschränkung | Folge / Workaround | +|--------------|--------------------| +| **Unattended Confirm → settled im Emulator (~4 GiB Host)** | Häufig **System UI ANR**; Taps bleiben hängen. Prefer **Wait** auf ANR-Dialog, echtes Gerät, oder Host mit mehr RAM. Kein zuverlässiges grünes E2E nur über GUI auf dem schwachen Emulator. | +| **Vollständige Wallet-Settlement-Assertion in der GUI** | Minimalvariante prüft **nicht** „Balance final / transfer_done“ in der App-Oberfläche. Proof bleibt CLI/e2e. | +| **Kamera / physischer QR-Scan** | Absichtlich nicht automatisiert; deep-link ist der Shortcut. Landing-QR-Pfade sind **HTTP**-Thema, nicht Android-GUI. | +| **Browser-Shop-Checkout-UI** | Kein Chromium/WebView-Drive; Pay-URI kommt per Template-`POST` + Intent. | +| **Volles ToS-Scroll / alle Dialoge** | Best-effort Accept-Tap; lange ToS oder unerwartete Sheets können hängen bleiben. | +| **Published F-Droid allein auf GOA** | Kann Exchange-Add / Spinner-Probleme zeigen → **fix-branch-APK** bauen; bei Spinner-Hang optional `fix-withdraw-spinner-fallback`. | +| **iOS-GUI** | Nicht in diesem Tree; siehe taler-ios `gui-workflows`. | +| **macOS dual AVD / Homebrew-Helpers** | Upstream `gui-workflows`; hier nur Linux-taugliche Port-Ideen. | +| **`AUTO_*` in jedem Script erzwungen** | Design + teilweise verdrahtet; Wrapper-Checkliste noch offen. | +| **`run-until-done` / CLI-Wallet in GUI-Skripten** | Gehört zu CLI-Automation, nicht GUI; siehe `CLI-AUTOMATION-NOTES.md`. | +| **CI grün = „User hat bezahlt“** | Smoke = Intent + best-effort UI. Wirtschaftlicher Erfolg = Host-e2e / ladder / Explorer. | + +#### Erwartetes Ergebnis pro Schicht + +| Schicht | Erfolgskriterium Minimalvariante | +|---------|----------------------------------| +| A Install | APK installed, App startet | +| B Deep-link | Intent delivered, Wallet öffnet Withdraw/Pay-Flow | +| C GUI taps | Dump+Tap ohne Crash; ANR ggf. mit Wait; **kein** Garant für „Paid“ | +| D Evidence | Artefakte geschrieben | +| Settlement | **Außerhalb** GUI: Host e2e/ladder | + +#### Wann Minimalvariante reicht + +- Smoke nach APK-/Branch-Build (Intent kommt an, App crasht nicht). +- Manuelle Nacharbeit am Gerät nach vorbereiteten URIs. +- GOA/stage **Erreichbarkeit** von Landing/Template + Wallet-Einstieg. + +#### Wann nicht reicht → nächster Schritt + +- Unattended grünes Pay-E2E auf dem Emulator → mehr RAM / physisches Gerät, ggf. `fix-withdraw-spinner-fallback`. +- Beweis „Münzen da / Merchant paid“ → CLI e2e/ladder, nicht GUI-smoke. +- iOS / Dual-Platform → andere Repos/Flags, nicht diese Skripte. + ```bash cd scripts/taler-monitoring/android-test @@ -170,6 +230,7 @@ Scripts **today** implement Android only; they should honour: ## Checklist - [x] Minimal variant documented +- [x] Minimalvariante: was geht / was nicht (Beschränkungen) - [x] Two stacks (goa / stage) - [x] Flags design (`AUTO_ANDROID` / `AUTO_GUI` / platform defaults) - [x] Linux default = Android suite only From a935f69582abae7ab45a605ef53f6116f3d9a9a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:28:14 +0200 Subject: [PATCH 263/326] =?UTF-8?q?docs(paivana):=20clear=20postmortem=20?= =?UTF-8?q?=E2=80=94=20three=20failures=20that=20broke=20paywall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document boot race (upstream unit exit 125), start-before-merchant 502, and pasta AAAA/timeout needing 172.17.0.1 pin. Healthy smoke stays 302/402. --- 2026/2026-07-13--paivana-goa.md | 11 ++++- 2026/2026-07-17.md | 5 ++ configs/paivana/README.md | 86 +++++++++++++++++++++++++-------- 3 files changed, 81 insertions(+), 21 deletions(-) diff --git a/2026/2026-07-13--paivana-goa.md b/2026/2026-07-13--paivana-goa.md index 9606641..0095b40 100644 --- a/2026/2026-07-13--paivana-goa.md +++ b/2026/2026-07-13--paivana-goa.md @@ -7,13 +7,22 @@ - Image `localhost/koopa-paivana:latest` (built from `git.taler.net/paivana` + deb.taler.net libs; package not on apt yet) - Merchant: instance **`goa-shop`**, template **`paivana`** (`template_type: paivana`, **GOA:4200**) - After payment: site-wide cookie (`-g`) → example upstream free to browse -- `extra_hosts: taler.hacktivism.ch:host-gateway` so pasta can reach merchant - Flags: `-g` (site-wide), `-f` (X-Forwarded-For) - Smoke: `curl -si http://127.0.0.1:9025/` → **302** to `/.well-known/paivana/templates/paivana#…` - Config mirror: `configs/paivana/` - Caddy: `paivana.hacktivism.ch` → `:9025` (`configs/caddy/Caddyfile`) - User unit: `container-koopa-paivana.service` enabled +## Was später kaputt / nachgezogen (klar) + +Siehe **`configs/paivana/README.md` → „Was kaputt war“**. Kurz: + +1. **Extra Upstream-systemd-Unit** race mit Compose → exit **125** / start-limit — **nur** Compose-Unit behalten. +2. **Paivana vor Merchant-Apps** → öffentliches **502** — `order.conf` `After=taler-merchant-apps`. +3. **Pasta DNS/AAAA**: `host-gateway` reichte nicht → `extra_hosts` auf **`172.17.0.1`** pin (`f26bf6a`). + +Gesund: **302** lokal / **402** public paywall + monitoring `www.paivana`. + ## Refs - https://docs.taler.net/taler-paivana-manual.html diff --git a/2026/2026-07-17.md b/2026/2026-07-17.md index de35634..33ee314 100644 --- a/2026/2026-07-17.md +++ b/2026/2026-07-17.md @@ -3,3 +3,8 @@ - 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 +- **Paivana — was kaputt war** klar dokumentiert (`configs/paivana/README.md`): + 1. Upstream-only systemd race (exit 125) + 2. Start vor Merchant-Apps → 502 + 3. Pasta AAAA/timeout → pin `172.17.0.1` + Live-Stack war gesund (302/402); Ursache/Fix nicht wiederholen. diff --git a/configs/paivana/README.md b/configs/paivana/README.md index 3b8700a..643fdb4 100644 --- a/configs/paivana/README.md +++ b/configs/paivana/README.md @@ -37,6 +37,65 @@ GNU Taler **paivana-httpd** reverse-proxy paywall (DD 95 / DD 76 style), GOA pay Mirror notes: `koopa-admin-secrets` → `koopa/home-hernani/koopa-paivana/secrets/` (paths only / examples). +## Was kaputt war (klar · 2026-07) + +Drei **unabhängige** Defekte; Symptom oft gleich (Paywall 502 / tot / kein Template). +Stand: behoben auf koopa; nicht wiederholen. + +### 1) Boot-Race: extra systemd-Unit nur für Upstream + +| | | +|--|--| +| **Symptom** | Nach Reboot: `container-koopa-paivana-upstream.service` **failed** / start-limit-hit; Upstream fehlt oder Compose und Unit kämpfen. | +| **Ursache** | Separate User-Unit tat nur `podman start koopa-paivana-upstream` **ohne** dass Compose die Container schon angelegt hatte → **exit 125**. Compose-Unit startet **beide** Services (`paivana` + `upstream`) ohnehin. | +| **Fix** | **Nur** `container-koopa-paivana.service` (compose up). Separate Upstream-Unit: `disable --now`. Nicht neu anlegen. | +| **Nicht verwechseln mit** | Problem 2 (Merchant noch down → 502 trotz laufender Container). | + +### 2) Start-Reihenfolge: Paivana vor Merchant-Apps → **502** + +| | | +|--|--| +| **Symptom** | `paivana.hacktivism.ch` / `:9025` **502** nach Boot; Container laufen, Paywall/Template-Flow tot. | +| **Ursache** | Merchant-Container ist hoch, aber **In-App** (`taler-merchant-httpd` etc.) startet erst über `taler-merchant-apps.service` / `ensure-taler-apps`. Paivana braucht die **private Merchant-API** (Template `paivana` / goa-shop). Zu früh → 502. | +| **Fix** | Drop-in `configs/systemd/user/container-koopa-paivana.service.d/order.conf`: `After=` / `Wants=` `taler-merchant-apps.service` (+ ggf. merchant-Container). Siehe `2026/2026-07-16--reboot-recovery.md`. | +| **Check** | Merchant `https://taler.hacktivism.ch/config` **200**, dann Paivana smoke (unten). | + +### 3) Pasta / DNS: Container erreicht Merchant nicht (Timeout / AAAA) + +| | | +|--|--| +| **Symptom** | Paivana läuft, aber Template-Laden / Merchant-Calls **hängen oder scheitern**; Logs: Timeouts Richtung `taler.hacktivism.ch`. | +| **Ursache** | Rootless **pasta** hairpint öffentliche DNS nicht sauber. `extra_hosts: …:host-gateway` allein reichte nicht: oft gewinnt noch die **öffentliche AAAA**, Connection timeout. Merchant sitzt am Host (**~:9010**), erreichbar über Pasta-IPv4 **`172.17.0.1`**. | +| **Fix** | In `compose.yml` feste IPv4-Hosts (nicht nur host-gateway): | + +```yaml +extra_hosts: + - "taler.hacktivism.ch:172.17.0.1" + - "bank.hacktivism.ch:172.17.0.1" + - "exchange.hacktivism.ch:172.17.0.1" +``` + +| | | +|--|--| +| **Commit** | `f26bf6a` (pin Taler hosts to pasta IPv4) | + +### Kurz: was *nicht* das Problem war + +- Caddy-Vhost an sich (leitet auf **9025**). +- Fehlen des Templates im Merchant (Template `paivana` / GOA:4200 war angelegt; ohne Merchant-Apps oder ohne Routing wirkt es so, als fehlte es). +- „Nur nginx-Upstream“ — Upstream ist unprivileged nginx **:8080**; ohne Problem 1/2/3 ist das stabil. + +### Erwartet gesund + +| Check | Expect | +|-------|--------| +| `curl -si http://127.0.0.1:9025/` | **302** → `/.well-known/paivana/templates/paivana#…` | +| `https://paivana.hacktivism.ch/` | **402** paywall HTML + `paivana: taler://pay-template/…` | +| monitoring `urls` | `www.paivana` OK | +| e2e (GOA, lokal) | `e2e.paivana` Template-Pay **GOA:4200** (wenn `E2E_PAIVANA=1`) | + +--- + ## Ops ```bash @@ -54,35 +113,22 @@ Autostart (**only** the compose unit — starts **both** `koopa-paivana` and ```bash mkdir -p ~/.config/systemd/user cp ~/koopa-paivana/container-koopa-paivana.service ~/.config/systemd/user/ -# optional: After= merchant (configs/systemd/user/container-koopa-paivana.service.d/) +# required on koopa: After= merchant apps +# configs/systemd/user/container-koopa-paivana.service.d/order.conf systemctl --user daemon-reload systemctl --user enable --now container-koopa-paivana.service ``` **Do not** enable a separate `container-koopa-paivana-upstream.service` that only -runs `podman start koopa-paivana-upstream`. At boot that unit races compose -(container not created yet → exit 125 → start-limit-hit). Compose already -starts the upstream. If such a unit exists: +runs `podman start koopa-paivana-upstream` — see failure **#1** above. +If such a unit exists: `systemctl --user disable --now container-koopa-paivana-upstream.service`. -Healthy smoke: - -| Check | Expect | -|-------|--------| -| `curl -si http://127.0.0.1:9025/` | **302** → `/.well-known/paivana/templates/paivana#…` | -| template URL / public `https://paivana.hacktivism.ch/` | **402** paywall HTML + `paivana: taler://pay-template/…` | -| monitoring | `./scripts/taler-monitoring/taler-monitoring.sh -d hacktivism.ch urls` → `www.paivana` OK | - ## Network notes -Rootless pasta cannot hairpin public DNS for the merchant. Compose sets: - -```yaml -extra_hosts: - - "taler.hacktivism.ch:host-gateway" -``` - -so `paivana-httpd` can load templates from the merchant API. +See failure **#3**. Live compose pins Taler hostnames to pasta IPv4 +`172.17.0.1` so `paivana-httpd` reaches the host merchant private API without +public AAAA timeouts. ## Caddy From 4b729663b3dfea3e04b03913605d20a2ff95e156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:28:14 +0200 Subject: [PATCH 264/326] =?UTF-8?q?docs(paivana):=20clear=20postmortem=20?= =?UTF-8?q?=E2=80=94=20three=20failures=20that=20broke=20paywall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document boot race (upstream unit exit 125), start-before-merchant 502, and pasta AAAA/timeout needing 172.17.0.1 pin. Healthy smoke stays 302/402. --- 2026/2026-07-13--paivana-goa.md | 11 ++++- 2026/2026-07-17.md | 5 ++ configs/paivana/README.md | 86 +++++++++++++++++++++++++-------- 3 files changed, 81 insertions(+), 21 deletions(-) diff --git a/2026/2026-07-13--paivana-goa.md b/2026/2026-07-13--paivana-goa.md index 9606641..0095b40 100644 --- a/2026/2026-07-13--paivana-goa.md +++ b/2026/2026-07-13--paivana-goa.md @@ -7,13 +7,22 @@ - Image `localhost/koopa-paivana:latest` (built from `git.taler.net/paivana` + deb.taler.net libs; package not on apt yet) - Merchant: instance **`goa-shop`**, template **`paivana`** (`template_type: paivana`, **GOA:4200**) - After payment: site-wide cookie (`-g`) → example upstream free to browse -- `extra_hosts: taler.hacktivism.ch:host-gateway` so pasta can reach merchant - Flags: `-g` (site-wide), `-f` (X-Forwarded-For) - Smoke: `curl -si http://127.0.0.1:9025/` → **302** to `/.well-known/paivana/templates/paivana#…` - Config mirror: `configs/paivana/` - Caddy: `paivana.hacktivism.ch` → `:9025` (`configs/caddy/Caddyfile`) - User unit: `container-koopa-paivana.service` enabled +## Was später kaputt / nachgezogen (klar) + +Siehe **`configs/paivana/README.md` → „Was kaputt war“**. Kurz: + +1. **Extra Upstream-systemd-Unit** race mit Compose → exit **125** / start-limit — **nur** Compose-Unit behalten. +2. **Paivana vor Merchant-Apps** → öffentliches **502** — `order.conf` `After=taler-merchant-apps`. +3. **Pasta DNS/AAAA**: `host-gateway` reichte nicht → `extra_hosts` auf **`172.17.0.1`** pin (`f26bf6a`). + +Gesund: **302** lokal / **402** public paywall + monitoring `www.paivana`. + ## Refs - https://docs.taler.net/taler-paivana-manual.html diff --git a/2026/2026-07-17.md b/2026/2026-07-17.md index de35634..33ee314 100644 --- a/2026/2026-07-17.md +++ b/2026/2026-07-17.md @@ -3,3 +3,8 @@ - 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 +- **Paivana — was kaputt war** klar dokumentiert (`configs/paivana/README.md`): + 1. Upstream-only systemd race (exit 125) + 2. Start vor Merchant-Apps → 502 + 3. Pasta AAAA/timeout → pin `172.17.0.1` + Live-Stack war gesund (302/402); Ursache/Fix nicht wiederholen. diff --git a/configs/paivana/README.md b/configs/paivana/README.md index 3b8700a..643fdb4 100644 --- a/configs/paivana/README.md +++ b/configs/paivana/README.md @@ -37,6 +37,65 @@ GNU Taler **paivana-httpd** reverse-proxy paywall (DD 95 / DD 76 style), GOA pay Mirror notes: `koopa-admin-secrets` → `koopa/home-hernani/koopa-paivana/secrets/` (paths only / examples). +## Was kaputt war (klar · 2026-07) + +Drei **unabhängige** Defekte; Symptom oft gleich (Paywall 502 / tot / kein Template). +Stand: behoben auf koopa; nicht wiederholen. + +### 1) Boot-Race: extra systemd-Unit nur für Upstream + +| | | +|--|--| +| **Symptom** | Nach Reboot: `container-koopa-paivana-upstream.service` **failed** / start-limit-hit; Upstream fehlt oder Compose und Unit kämpfen. | +| **Ursache** | Separate User-Unit tat nur `podman start koopa-paivana-upstream` **ohne** dass Compose die Container schon angelegt hatte → **exit 125**. Compose-Unit startet **beide** Services (`paivana` + `upstream`) ohnehin. | +| **Fix** | **Nur** `container-koopa-paivana.service` (compose up). Separate Upstream-Unit: `disable --now`. Nicht neu anlegen. | +| **Nicht verwechseln mit** | Problem 2 (Merchant noch down → 502 trotz laufender Container). | + +### 2) Start-Reihenfolge: Paivana vor Merchant-Apps → **502** + +| | | +|--|--| +| **Symptom** | `paivana.hacktivism.ch` / `:9025` **502** nach Boot; Container laufen, Paywall/Template-Flow tot. | +| **Ursache** | Merchant-Container ist hoch, aber **In-App** (`taler-merchant-httpd` etc.) startet erst über `taler-merchant-apps.service` / `ensure-taler-apps`. Paivana braucht die **private Merchant-API** (Template `paivana` / goa-shop). Zu früh → 502. | +| **Fix** | Drop-in `configs/systemd/user/container-koopa-paivana.service.d/order.conf`: `After=` / `Wants=` `taler-merchant-apps.service` (+ ggf. merchant-Container). Siehe `2026/2026-07-16--reboot-recovery.md`. | +| **Check** | Merchant `https://taler.hacktivism.ch/config` **200**, dann Paivana smoke (unten). | + +### 3) Pasta / DNS: Container erreicht Merchant nicht (Timeout / AAAA) + +| | | +|--|--| +| **Symptom** | Paivana läuft, aber Template-Laden / Merchant-Calls **hängen oder scheitern**; Logs: Timeouts Richtung `taler.hacktivism.ch`. | +| **Ursache** | Rootless **pasta** hairpint öffentliche DNS nicht sauber. `extra_hosts: …:host-gateway` allein reichte nicht: oft gewinnt noch die **öffentliche AAAA**, Connection timeout. Merchant sitzt am Host (**~:9010**), erreichbar über Pasta-IPv4 **`172.17.0.1`**. | +| **Fix** | In `compose.yml` feste IPv4-Hosts (nicht nur host-gateway): | + +```yaml +extra_hosts: + - "taler.hacktivism.ch:172.17.0.1" + - "bank.hacktivism.ch:172.17.0.1" + - "exchange.hacktivism.ch:172.17.0.1" +``` + +| | | +|--|--| +| **Commit** | `f26bf6a` (pin Taler hosts to pasta IPv4) | + +### Kurz: was *nicht* das Problem war + +- Caddy-Vhost an sich (leitet auf **9025**). +- Fehlen des Templates im Merchant (Template `paivana` / GOA:4200 war angelegt; ohne Merchant-Apps oder ohne Routing wirkt es so, als fehlte es). +- „Nur nginx-Upstream“ — Upstream ist unprivileged nginx **:8080**; ohne Problem 1/2/3 ist das stabil. + +### Erwartet gesund + +| Check | Expect | +|-------|--------| +| `curl -si http://127.0.0.1:9025/` | **302** → `/.well-known/paivana/templates/paivana#…` | +| `https://paivana.hacktivism.ch/` | **402** paywall HTML + `paivana: taler://pay-template/…` | +| monitoring `urls` | `www.paivana` OK | +| e2e (GOA, lokal) | `e2e.paivana` Template-Pay **GOA:4200** (wenn `E2E_PAIVANA=1`) | + +--- + ## Ops ```bash @@ -54,35 +113,22 @@ Autostart (**only** the compose unit — starts **both** `koopa-paivana` and ```bash mkdir -p ~/.config/systemd/user cp ~/koopa-paivana/container-koopa-paivana.service ~/.config/systemd/user/ -# optional: After= merchant (configs/systemd/user/container-koopa-paivana.service.d/) +# required on koopa: After= merchant apps +# configs/systemd/user/container-koopa-paivana.service.d/order.conf systemctl --user daemon-reload systemctl --user enable --now container-koopa-paivana.service ``` **Do not** enable a separate `container-koopa-paivana-upstream.service` that only -runs `podman start koopa-paivana-upstream`. At boot that unit races compose -(container not created yet → exit 125 → start-limit-hit). Compose already -starts the upstream. If such a unit exists: +runs `podman start koopa-paivana-upstream` — see failure **#1** above. +If such a unit exists: `systemctl --user disable --now container-koopa-paivana-upstream.service`. -Healthy smoke: - -| Check | Expect | -|-------|--------| -| `curl -si http://127.0.0.1:9025/` | **302** → `/.well-known/paivana/templates/paivana#…` | -| template URL / public `https://paivana.hacktivism.ch/` | **402** paywall HTML + `paivana: taler://pay-template/…` | -| monitoring | `./scripts/taler-monitoring/taler-monitoring.sh -d hacktivism.ch urls` → `www.paivana` OK | - ## Network notes -Rootless pasta cannot hairpin public DNS for the merchant. Compose sets: - -```yaml -extra_hosts: - - "taler.hacktivism.ch:host-gateway" -``` - -so `paivana-httpd` can load templates from the merchant API. +See failure **#3**. Live compose pins Taler hostnames to pasta IPv4 +`172.17.0.1` so `paivana-httpd` reaches the host merchant private API without +public AAAA timeouts. ## Caddy From 91b79ab80744ffc16509d33cbda1b8e3caa45c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:29:52 +0200 Subject: [PATCH 265/326] android-test: headless emulator default for GUI smokes Default EMULATOR_HEADLESS=1 (-no-window, QT offscreen) while guest GLES still runs via SwiftShader. Auto-start AVD when no adb device; WINDOWED=1 for a host window on laptops. --- .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 10 +- .../android-test/GUI-AUTOMATION-NOTES.md | 36 +++++ .../taler-monitoring/android-test/README.md | 11 ++ .../android-test/lib_android_env.sh | 153 ++++++++++++++++++ .../android-test/run-android-gui-smoke.sh | 20 +-- .../android-test/run-android-pay-smoke.sh | 35 ++-- .../android-test/run-goa-gui-chain.sh | 24 +-- .../android-test/start-android-emulator.sh | 90 +++++++++++ 8 files changed, 320 insertions(+), 59 deletions(-) create mode 100644 scripts/taler-monitoring/android-test/lib_android_env.sh create mode 100755 scripts/taler-monitoring/android-test/start-android-emulator.sh diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md index 73e654a..f954cfd 100644 --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -156,12 +156,12 @@ STACK=stage ./android-test/run-android-pay-smoke.sh **User-local emulator (no apt root if SDK already under `$HOME/Android/Sdk`):** ```bash -export ANDROID_HOME=$HOME/Android/Sdk -export PATH=$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH -# AVD e.g. TalerWallet34 (google_apis x86_64) -emulator -avd TalerWallet34 -no-window -gpu swiftshader_indirect -memory 1536 & -adb wait-for-device +# Default = headless (no host window); guest GLES via SwiftShader. +# Prefer the helper — same flags smokes use: +./android-test/start-android-emulator.sh --wait ./android-test/run-android-pay-smoke.sh +# Or let smoke auto-start the AVD (AUTO_START_EMULATOR=1). +# Windowed laptop: WINDOWED=1 ./android-test/start-android-emulator.sh --wait ``` **Limits (clementine 2026-07-17):** host RAM ~3.7 GiB → emulator often ANRs System UI; diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index fce915e..27330ca 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -120,6 +120,39 @@ Automation can run on hosts that support **Android only**, **GUI tooling only** | `AUTO_ANDROID` | `0` / `1` | Run Android wallet automation (adb, APK, emulator/device) | | `AUTO_GUI` | `0` / `1` | Drive **graphical** UI (uiautomator taps, multi-round). If `0`, deep-link/CLI-only install smoke. | | `AUTO_PLATFORM` | `auto` / `linux` / `macos` / `ios` | Host family (optional override) | +| `EMULATOR_HEADLESS` | **`1` (default)** / `0` | No host window (`-no-window`). Server/CI-safe. | +| `WINDOWED` | `0` / `1` | Shortcut: `WINDOWED=1` ⇒ show emulator window (`EMULATOR_HEADLESS=0`) | +| `EMULATOR_GPU` | default **`swiftshader_indirect`** | Guest GLES still runs headless (layout/taps/screenshots). Override `host` only if windowed + real GPU. | +| `AUTO_START_EMULATOR` | **`1` (default)** / `0` | If no adb device, start AVD via `start-android-emulator.sh --wait` | + +--- + +## Headless mode (default) + +**Default for all GUI / pay smokes:** no window on the host. Opening an emulator +GUI on servers (or headless CI) is wrong; these scripts therefore start the AVD +with **`-no-window`** and still keep **graphics logic** in the guest: + +| Piece | Headless default | +|-------|------------------| +| Host window | **off** (`-no-window`, `QT_QPA_PLATFORM=offscreen`, no `DISPLAY`) | +| Guest GLES | **on** via **SwiftShader** (`-gpu swiftshader_indirect`) | +| UI automation | **on** — `uiautomator` dump/tap + `screencap` (no X11 needed) | +| Audio / boot anim | off (`-no-audio -no-boot-anim`) | + +```bash +# explicit (same as default) +./start-android-emulator.sh --wait + +# or let smoke auto-start headless AVD when no phone is plugged in +STACK=goa ./run-android-gui-smoke.sh + +# laptop: show the emulator window +WINDOWED=1 ./start-android-emulator.sh --wait +# or: EMULATOR_HEADLESS=0 EMULATOR_GPU=host ./start-android-emulator.sh --wait +``` + +Shared code: `lib_android_env.sh` + `start-android-emulator.sh`. **Semantics:** @@ -203,6 +236,8 @@ Scripts **today** implement Android only; they should honour: | `run-android-gui-smoke.sh` | Multi-round GUI drive | | `run-goa-gui-chain.sh` | Multi mint/withdraw/pay (gui-workflows port) | | `run-android-build-and-smoke.sh` | Build inference fix branch + smoke (`GUI=1` optional) | +| `start-android-emulator.sh` | Start AVD (**headless default**, SwiftShader) | +| `lib_android_env.sh` | Flags, PATH, `android_ensure_device` | | `lib_ui.py` | Dump/tap/ANR helpers | --- @@ -233,6 +268,7 @@ Scripts **today** implement Android only; they should honour: - [x] Minimalvariante: was geht / was nicht (Beschränkungen) - [x] Two stacks (goa / stage) - [x] Flags design (`AUTO_ANDROID` / `AUTO_GUI` / platform defaults) +- [x] **Headless emulator default** (`EMULATOR_HEADLESS=1`, SwiftShader) - [x] Linux default = Android suite only - [x] Inference-branch policy - [ ] Wrapper enforces flags in every entry script (incremental) diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index e1ab975..4c2e5f2 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -59,5 +59,16 @@ Canonical notes (minimal variant, **flags**, Linux defaults): | `AUTO_ANDROID` | `1` | Run Android adb/APK automation | | `AUTO_GUI` | `1` | Graphical uiautomator drive (else deep-link only) | | `AUTO_PLATFORM` | `auto` → `linux` | Host family; macOS can later pair with iOS out of tree | +| **`EMULATOR_HEADLESS`** | **`1`** | **No host window** (`-no-window`). Server-safe. | +| `EMULATOR_GPU` | `swiftshader_indirect` | Guest GLES still active for UI/taps/screenshots | +| `AUTO_START_EMULATOR` | `1` | Auto-start headless AVD if no adb device | +| `WINDOWED` | `0` | `WINDOWED=1` → show emulator window | + +```bash +# headless AVD only (default path for servers) +./start-android-emulator.sh --wait +# windowed (laptops) +WINDOWED=1 ./start-android-emulator.sh --wait +``` See also parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). diff --git a/scripts/taler-monitoring/android-test/lib_android_env.sh b/scripts/taler-monitoring/android-test/lib_android_env.sh new file mode 100644 index 0000000..ad209de --- /dev/null +++ b/scripts/taler-monitoring/android-test/lib_android_env.sh @@ -0,0 +1,153 @@ +# shellcheck shell=bash +# Shared Android env for GUI / adb smokes. +# Source: . "$ROOT/lib_android_env.sh" +# +# Defaults favour **servers / CI**: no host window (headless emulator), while the +# guest still runs GLES via SwiftShader so UI layout / uiautomator work. +# +# See GUI-AUTOMATION-NOTES.md → Headless mode + +# --- SDK / PATH --------------------------------------------------------------- +export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}" +export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" +for d in \ + "$ANDROID_HOME/platform-tools" \ + "$ANDROID_HOME/emulator" \ + "$ANDROID_HOME/cmdline-tools/latest/bin" +do + [ -d "$d" ] && case ":$PATH:" in *":$d:"*) ;; *) PATH="$d:$PATH" ;; esac +done +export PATH + +# AVD path (some hosts keep AVDs under ~/.config/.android) +if [ -z "${ANDROID_AVD_HOME:-}" ]; then + if [ -d "$HOME/.android/avd" ]; then + export ANDROID_AVD_HOME="$HOME/.android/avd" + elif [ -d "$HOME/.config/.android/avd" ]; then + export ANDROID_AVD_HOME="$HOME/.config/.android/avd" + # emulator also looks at ~/.android/avd — soft-link if missing + if [ ! -e "$HOME/.android/avd" ]; then + mkdir -p "$HOME/.android" + ln -sfn "$HOME/.config/.android/avd" "$HOME/.android/avd" 2>/dev/null || true + fi + fi +fi + +# --- Platform / feature flags ------------------------------------------------- +os=$(uname -s) +case "${AUTO_PLATFORM:-auto}" in + auto) + case "$os" in + Linux) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; + esac + ;; + linux|macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; +esac + +# Headless = default (no X11/Wayland window on host). GUI *logic* still runs in +# the guest via software GPU. Set EMULATOR_HEADLESS=0 or WINDOWED=1 for a window. +: "${EMULATOR_HEADLESS:=1}" +if [ "${WINDOWED:-0}" = "1" ]; then + EMULATOR_HEADLESS=0 +fi +export EMULATOR_HEADLESS + +# Soft GLES for headless; windowed may override to host/auto if set by user. +if [ "${EMULATOR_HEADLESS}" = "1" ]; then + : "${EMULATOR_GPU:=swiftshader_indirect}" +else + : "${EMULATOR_GPU:=swiftshader_indirect}" # still safe default; host GPU: EMULATOR_GPU=host +fi +export EMULATOR_GPU + +: "${EMULATOR_AVD:=TalerWallet34}" +: "${EMULATOR_MEMORY:=1536}" +: "${EMULATOR_CORES:=2}" +# If no adb device: start headless AVD (0 = require phone/manual emulator) +: "${AUTO_START_EMULATOR:=1}" +: "${EMULATOR_BOOT_TIMEOUT:=180}" +: "${EMULATOR_LOG:=/tmp/taler-android-emulator.log}" + +android_adb_serial() { + adb devices 2>/dev/null | awk '/\tdevice$/{print $1; exit}' +} + +android_has_device() { + [ -n "$(android_adb_serial)" ] +} + +# Build emulator argv for current headless/windowed mode. +# Prints args; caller exec/nohup. +android_emulator_args() { + local avd="${1:-$EMULATOR_AVD}" + local args=( -avd "$avd" -gpu "$EMULATOR_GPU" -no-audio -no-boot-anim ) + if [ "${EMULATOR_HEADLESS}" = "1" ]; then + args+=( -no-window ) + # Avoid Qt trying to open a display on servers + export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}" + fi + if [ -n "${EMULATOR_MEMORY:-}" ]; then + args+=( -memory "$EMULATOR_MEMORY" ) + fi + if [ -n "${EMULATOR_CORES:-}" ]; then + args+=( -cores "$EMULATOR_CORES" ) + fi + # extra user flags + # shellcheck disable=SC2206 + if [ -n "${EMULATOR_EXTRA_ARGS:-}" ]; then + # intentional word-split for extra flags + args+=( ${EMULATOR_EXTRA_ARGS} ) + fi + printf '%s\n' "${args[@]}" +} + +# Ensure an adb "device" is online. Optionally start headless emulator. +# Sets SERIAL / ANDROID_SERIAL on success. Returns 0 ok, 3 no device. +android_ensure_device() { + local serial + serial="${SERIAL:-${ANDROID_SERIAL:-}}" + if [ -z "$serial" ]; then + serial=$(android_adb_serial || true) + fi + if [ -n "$serial" ]; then + SERIAL="$serial" + export ANDROID_SERIAL="$serial" + return 0 + fi + + if [ "${AUTO_START_EMULATOR}" != "1" ]; then + echo "no adb device (AUTO_START_EMULATOR=0 — plug phone or start AVD manually)" >&2 + return 3 + fi + + if ! command -v emulator >/dev/null 2>&1; then + echo "no adb device and emulator binary missing under $ANDROID_HOME" >&2 + return 3 + fi + + echo "no adb device → starting AVD ${EMULATOR_AVD} (headless=${EMULATOR_HEADLESS} gpu=${EMULATOR_GPU})" + local starter + starter="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/start-android-emulator.sh" + if [ ! -x "$starter" ]; then + echo "missing $starter" >&2 + return 3 + fi + EMULATOR_HEADLESS="$EMULATOR_HEADLESS" EMULATOR_GPU="$EMULATOR_GPU" \ + EMULATOR_AVD="$EMULATOR_AVD" EMULATOR_MEMORY="$EMULATOR_MEMORY" \ + EMULATOR_CORES="$EMULATOR_CORES" EMULATOR_LOG="$EMULATOR_LOG" \ + EMULATOR_BOOT_TIMEOUT="$EMULATOR_BOOT_TIMEOUT" \ + "$starter" --wait || return 3 + + serial=$(android_adb_serial || true) + if [ -z "$serial" ]; then + echo "emulator started but no adb device yet — see $EMULATOR_LOG" >&2 + return 3 + fi + SERIAL="$serial" + export ANDROID_SERIAL="$serial" + echo "device: $SERIAL (headless=${EMULATOR_HEADLESS})" + return 0 +} diff --git a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh index de280f5..f52c783 100755 --- a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh @@ -12,14 +12,9 @@ # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib_android_env.sh +. "$ROOT/lib_android_env.sh" -# Platform flags (GUI-AUTOMATION-NOTES.md). Linux → Android only. -os=$(uname -s) -case "${AUTO_PLATFORM:-auto}" in - auto) case "$os" in Linux|Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; esac ;; - linux|macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; - *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; -esac [ "${AUTO_ANDROID}" = "1" ] || { echo "skipped: AUTO_ANDROID=${AUTO_ANDROID}"; exit 0; } if [ "${AUTO_GUI}" != "1" ]; then echo "AUTO_GUI=0 → deep-link smoke only" @@ -46,17 +41,12 @@ STACK="${STACK:-auto}" SERIAL="${SERIAL:-}" mkdir -p "$OUT_DIR" -if ! command -v adb >/dev/null 2>&1; then - [ -x "$HOME/Android/Sdk/platform-tools/adb" ] && export PATH="$HOME/Android/Sdk/platform-tools:$PATH" -fi command -v adb >/dev/null || { echo "adb missing" >&2; exit 2; } -if [ -z "$SERIAL" ]; then - SERIAL=$(adb devices | awk '/\tdevice$/{print $1; exit}') -fi -[ -n "$SERIAL" ] || { echo "no adb device" >&2; exit 3; } +# Headless default: no host window; guest GLES via SwiftShader (uiautomator). +android_ensure_device || { echo "no adb device (try ./start-android-emulator.sh --wait)" >&2; exit 3; } ADB=(adb -s "$SERIAL") -echo "device: $SERIAL mode: GUI (uiautomator)" +echo "device: $SERIAL mode: GUI (uiautomator) headless=${EMULATOR_HEADLESS} gpu=${EMULATOR_GPU}" case "$STACK" in auto) diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh index 690c222..ef2ca8e 100755 --- a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh @@ -20,15 +20,9 @@ # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib_android_env.sh +. "$ROOT/lib_android_env.sh" -# Platform flags (see GUI-AUTOMATION-NOTES.md). Linux default: Android only. -os=$(uname -s) -case "${AUTO_PLATFORM:-auto}" in - auto) case "$os" in Linux) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; esac ;; - linux) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; - macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; - *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; -esac if [ "${AUTO_ANDROID}" != "1" ]; then echo "skipped: AUTO_ANDROID=${AUTO_ANDROID} (platform flags — see GUI-AUTOMATION-NOTES.md)" exit 0 @@ -46,28 +40,23 @@ OUT_DIR="${OUT_DIR:-$ROOT/out}" mkdir -p "$APK_DIR" "$OUT_DIR" if ! command -v adb >/dev/null 2>&1; then - if [ -x "$HOME/Android/Sdk/platform-tools/adb" ]; then - export PATH="$HOME/Android/Sdk/platform-tools:$PATH" - else - echo "adb missing — install android-tools-adb or user SDK platform-tools" >&2 - exit 2 - fi + echo "adb missing — install android-tools-adb or user SDK platform-tools" >&2 + exit 2 fi -if [ -z "$SERIAL" ]; then - SERIAL=$(adb devices | awk '/\tdevice$/{print $1; exit}') -fi -if [ -z "$SERIAL" ]; then - cat >&2 <<'EOF' -No adb device. Options (no root for user-local emulator if /dev/kvm is usable): +# Default: headless AVD (no host window). WINDOWED=1 / EMULATOR_HEADLESS=0 for UI. +if ! android_ensure_device; then + cat >&2 </dev/null 2>&1; then - [ -x "$HOME/Android/Sdk/platform-tools/adb" ] && export PATH="$HOME/Android/Sdk/platform-tools:$PATH" -fi command -v adb >/dev/null || die "adb not found" command -v python3 >/dev/null || die "python3 required" @@ -112,12 +105,11 @@ if [[ ! -f "${EXP_PW_FILE:-}" && "$STACK" = "stage" ]]; then fi [[ -n "${EXP_PW_FILE:-}" && -f "$EXP_PW_FILE" ]] || die "set EXP_PW_FILE (explorer password)" -if [[ -z "${ANDROID_SERIAL:-}" ]]; then - n=$(adb devices | awk 'NR>1 && $2=="device"{c++} END{print c+0}') - [[ "$n" -ge 1 ]] || die "no adb device (start emulator or plug phone)" - export ANDROID_SERIAL=$(adb devices | awk 'NR>1 && $2=="device"{print $1; exit}') -fi -info "ANDROID_SERIAL=$ANDROID_SERIAL STACK=$STACK PKG=$PKG SHOTDIR=$SHOTDIR" +# Headless AVD by default (no host window). WINDOWED=1 for a visible emulator. +SERIAL="${SERIAL:-${ANDROID_SERIAL:-}}" +android_ensure_device || die "no adb device (./start-android-emulator.sh --wait)" +export ANDROID_SERIAL="$SERIAL" +info "ANDROID_SERIAL=$ANDROID_SERIAL STACK=$STACK PKG=$PKG SHOTDIR=$SHOTDIR headless=$EMULATOR_HEADLESS gpu=$EMULATOR_GPU" ADB=(adb -s "$ANDROID_SERIAL") adb_sh() { "${ADB[@]}" shell "$@"; } diff --git a/scripts/taler-monitoring/android-test/start-android-emulator.sh b/scripts/taler-monitoring/android-test/start-android-emulator.sh new file mode 100755 index 0000000..e62b5f4 --- /dev/null +++ b/scripts/taler-monitoring/android-test/start-android-emulator.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Start the Taler Android AVD. **Default = headless** (no host window). +# Guest GLES still runs via SwiftShader so uiautomator / screenshots work. +# +# Usage: +# ./start-android-emulator.sh # headless, background +# ./start-android-emulator.sh --wait # headless + wait for boot +# ./start-android-emulator.sh --windowed # show emulator window +# EMULATOR_HEADLESS=0 ./start-android-emulator.sh +# EMULATOR_GPU=host WINDOWED=1 ./start-android-emulator.sh +# +# Env: EMULATOR_AVD EMULATOR_MEMORY EMULATOR_CORES EMULATOR_GPU +# EMULATOR_HEADLESS EMULATOR_LOG EMULATOR_BOOT_TIMEOUT EMULATOR_EXTRA_ARGS +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib_android_env.sh +. "$ROOT/lib_android_env.sh" + +WAIT=0 +for a in "$@"; do + case "$a" in + --wait) WAIT=1 ;; + --windowed| --gui) EMULATOR_HEADLESS=0; export EMULATOR_HEADLESS ;; + --headless) EMULATOR_HEADLESS=1; export EMULATOR_HEADLESS ;; + -h|--help) + sed -n '2,18p' "$0" + exit 0 + ;; + esac +done + +command -v emulator >/dev/null || { + echo "emulator not found (ANDROID_HOME=$ANDROID_HOME)" >&2 + exit 2 +} +command -v adb >/dev/null || { + echo "adb not found" >&2 + exit 2 +} + +# Already booted? +if serial=$(android_adb_serial); [ -n "$serial" ]; then + boot=$(adb -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true) + if [ "$boot" = "1" ]; then + echo "already running: $serial (boot_completed=1) — headless default still applies to *new* starts" + exit 0 + fi +fi + +# Avoid pkill patterns that match this wrapper's argv (self-kill). +mapfile -t EMU_ARGS < <(android_emulator_args "$EMULATOR_AVD") +echo "starting: emulator ${EMU_ARGS[*]}" +echo " headless=$EMULATOR_HEADLESS gpu=$EMULATOR_GPU log=$EMULATOR_LOG" + +if [ "${EMULATOR_HEADLESS}" = "1" ]; then + export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}" + # Do not require DISPLAY + unset DISPLAY || true +fi + +mkdir -p "$(dirname "$EMULATOR_LOG")" +# Background; do not use pkill -f emulator from callers matching full cmdline +nohup emulator "${EMU_ARGS[@]}" >"$EMULATOR_LOG" 2>&1 & +echo "emulator pid=$! log=$EMULATOR_LOG" + +if [ "$WAIT" != "1" ]; then + echo "not waiting (pass --wait for boot). adb devices when ready." + exit 0 +fi + +echo "waiting for boot (timeout ${EMULATOR_BOOT_TIMEOUT}s)…" +deadline=$((SECONDS + EMULATOR_BOOT_TIMEOUT)) +serial="" +while (( SECONDS < deadline )); do + adb wait-for-device 2>/dev/null || true + serial=$(android_adb_serial || true) + if [ -n "$serial" ]; then + boot=$(adb -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true) + if [ "$boot" = "1" ]; then + echo "BOOT_OK serial=$serial headless=$EMULATOR_HEADLESS gpu=$EMULATOR_GPU" + exit 0 + fi + fi + sleep 2 +done + +echo "boot timeout — last log:" >&2 +tail -30 "$EMULATOR_LOG" >&2 || true +exit 3 From 0c3019822fcd8ba0ad161ab8bd104d7ce0f84f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:29:52 +0200 Subject: [PATCH 266/326] android-test: headless emulator default for GUI smokes Default EMULATOR_HEADLESS=1 (-no-window, QT offscreen) while guest GLES still runs via SwiftShader. Auto-start AVD when no adb device; WINDOWED=1 for a host window on laptops. --- .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 10 +- .../android-test/GUI-AUTOMATION-NOTES.md | 36 +++++ .../taler-monitoring/android-test/README.md | 11 ++ .../android-test/lib_android_env.sh | 153 ++++++++++++++++++ .../android-test/run-android-gui-smoke.sh | 20 +-- .../android-test/run-android-pay-smoke.sh | 35 ++-- .../android-test/run-goa-gui-chain.sh | 24 +-- .../android-test/start-android-emulator.sh | 90 +++++++++++ 8 files changed, 320 insertions(+), 59 deletions(-) create mode 100644 scripts/taler-monitoring/android-test/lib_android_env.sh create mode 100755 scripts/taler-monitoring/android-test/start-android-emulator.sh diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md index 73e654a..f954cfd 100644 --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -156,12 +156,12 @@ STACK=stage ./android-test/run-android-pay-smoke.sh **User-local emulator (no apt root if SDK already under `$HOME/Android/Sdk`):** ```bash -export ANDROID_HOME=$HOME/Android/Sdk -export PATH=$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH -# AVD e.g. TalerWallet34 (google_apis x86_64) -emulator -avd TalerWallet34 -no-window -gpu swiftshader_indirect -memory 1536 & -adb wait-for-device +# Default = headless (no host window); guest GLES via SwiftShader. +# Prefer the helper — same flags smokes use: +./android-test/start-android-emulator.sh --wait ./android-test/run-android-pay-smoke.sh +# Or let smoke auto-start the AVD (AUTO_START_EMULATOR=1). +# Windowed laptop: WINDOWED=1 ./android-test/start-android-emulator.sh --wait ``` **Limits (clementine 2026-07-17):** host RAM ~3.7 GiB → emulator often ANRs System UI; diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index fce915e..27330ca 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -120,6 +120,39 @@ Automation can run on hosts that support **Android only**, **GUI tooling only** | `AUTO_ANDROID` | `0` / `1` | Run Android wallet automation (adb, APK, emulator/device) | | `AUTO_GUI` | `0` / `1` | Drive **graphical** UI (uiautomator taps, multi-round). If `0`, deep-link/CLI-only install smoke. | | `AUTO_PLATFORM` | `auto` / `linux` / `macos` / `ios` | Host family (optional override) | +| `EMULATOR_HEADLESS` | **`1` (default)** / `0` | No host window (`-no-window`). Server/CI-safe. | +| `WINDOWED` | `0` / `1` | Shortcut: `WINDOWED=1` ⇒ show emulator window (`EMULATOR_HEADLESS=0`) | +| `EMULATOR_GPU` | default **`swiftshader_indirect`** | Guest GLES still runs headless (layout/taps/screenshots). Override `host` only if windowed + real GPU. | +| `AUTO_START_EMULATOR` | **`1` (default)** / `0` | If no adb device, start AVD via `start-android-emulator.sh --wait` | + +--- + +## Headless mode (default) + +**Default for all GUI / pay smokes:** no window on the host. Opening an emulator +GUI on servers (or headless CI) is wrong; these scripts therefore start the AVD +with **`-no-window`** and still keep **graphics logic** in the guest: + +| Piece | Headless default | +|-------|------------------| +| Host window | **off** (`-no-window`, `QT_QPA_PLATFORM=offscreen`, no `DISPLAY`) | +| Guest GLES | **on** via **SwiftShader** (`-gpu swiftshader_indirect`) | +| UI automation | **on** — `uiautomator` dump/tap + `screencap` (no X11 needed) | +| Audio / boot anim | off (`-no-audio -no-boot-anim`) | + +```bash +# explicit (same as default) +./start-android-emulator.sh --wait + +# or let smoke auto-start headless AVD when no phone is plugged in +STACK=goa ./run-android-gui-smoke.sh + +# laptop: show the emulator window +WINDOWED=1 ./start-android-emulator.sh --wait +# or: EMULATOR_HEADLESS=0 EMULATOR_GPU=host ./start-android-emulator.sh --wait +``` + +Shared code: `lib_android_env.sh` + `start-android-emulator.sh`. **Semantics:** @@ -203,6 +236,8 @@ Scripts **today** implement Android only; they should honour: | `run-android-gui-smoke.sh` | Multi-round GUI drive | | `run-goa-gui-chain.sh` | Multi mint/withdraw/pay (gui-workflows port) | | `run-android-build-and-smoke.sh` | Build inference fix branch + smoke (`GUI=1` optional) | +| `start-android-emulator.sh` | Start AVD (**headless default**, SwiftShader) | +| `lib_android_env.sh` | Flags, PATH, `android_ensure_device` | | `lib_ui.py` | Dump/tap/ANR helpers | --- @@ -233,6 +268,7 @@ Scripts **today** implement Android only; they should honour: - [x] Minimalvariante: was geht / was nicht (Beschränkungen) - [x] Two stacks (goa / stage) - [x] Flags design (`AUTO_ANDROID` / `AUTO_GUI` / platform defaults) +- [x] **Headless emulator default** (`EMULATOR_HEADLESS=1`, SwiftShader) - [x] Linux default = Android suite only - [x] Inference-branch policy - [ ] Wrapper enforces flags in every entry script (incremental) diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index e1ab975..4c2e5f2 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -59,5 +59,16 @@ Canonical notes (minimal variant, **flags**, Linux defaults): | `AUTO_ANDROID` | `1` | Run Android adb/APK automation | | `AUTO_GUI` | `1` | Graphical uiautomator drive (else deep-link only) | | `AUTO_PLATFORM` | `auto` → `linux` | Host family; macOS can later pair with iOS out of tree | +| **`EMULATOR_HEADLESS`** | **`1`** | **No host window** (`-no-window`). Server-safe. | +| `EMULATOR_GPU` | `swiftshader_indirect` | Guest GLES still active for UI/taps/screenshots | +| `AUTO_START_EMULATOR` | `1` | Auto-start headless AVD if no adb device | +| `WINDOWED` | `0` | `WINDOWED=1` → show emulator window | + +```bash +# headless AVD only (default path for servers) +./start-android-emulator.sh --wait +# windowed (laptops) +WINDOWED=1 ./start-android-emulator.sh --wait +``` See also parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md). diff --git a/scripts/taler-monitoring/android-test/lib_android_env.sh b/scripts/taler-monitoring/android-test/lib_android_env.sh new file mode 100644 index 0000000..ad209de --- /dev/null +++ b/scripts/taler-monitoring/android-test/lib_android_env.sh @@ -0,0 +1,153 @@ +# shellcheck shell=bash +# Shared Android env for GUI / adb smokes. +# Source: . "$ROOT/lib_android_env.sh" +# +# Defaults favour **servers / CI**: no host window (headless emulator), while the +# guest still runs GLES via SwiftShader so UI layout / uiautomator work. +# +# See GUI-AUTOMATION-NOTES.md → Headless mode + +# --- SDK / PATH --------------------------------------------------------------- +export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}" +export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" +for d in \ + "$ANDROID_HOME/platform-tools" \ + "$ANDROID_HOME/emulator" \ + "$ANDROID_HOME/cmdline-tools/latest/bin" +do + [ -d "$d" ] && case ":$PATH:" in *":$d:"*) ;; *) PATH="$d:$PATH" ;; esac +done +export PATH + +# AVD path (some hosts keep AVDs under ~/.config/.android) +if [ -z "${ANDROID_AVD_HOME:-}" ]; then + if [ -d "$HOME/.android/avd" ]; then + export ANDROID_AVD_HOME="$HOME/.android/avd" + elif [ -d "$HOME/.config/.android/avd" ]; then + export ANDROID_AVD_HOME="$HOME/.config/.android/avd" + # emulator also looks at ~/.android/avd — soft-link if missing + if [ ! -e "$HOME/.android/avd" ]; then + mkdir -p "$HOME/.android" + ln -sfn "$HOME/.config/.android/avd" "$HOME/.android/avd" 2>/dev/null || true + fi + fi +fi + +# --- Platform / feature flags ------------------------------------------------- +os=$(uname -s) +case "${AUTO_PLATFORM:-auto}" in + auto) + case "$os" in + Linux) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; + esac + ;; + linux|macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; + *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; +esac + +# Headless = default (no X11/Wayland window on host). GUI *logic* still runs in +# the guest via software GPU. Set EMULATOR_HEADLESS=0 or WINDOWED=1 for a window. +: "${EMULATOR_HEADLESS:=1}" +if [ "${WINDOWED:-0}" = "1" ]; then + EMULATOR_HEADLESS=0 +fi +export EMULATOR_HEADLESS + +# Soft GLES for headless; windowed may override to host/auto if set by user. +if [ "${EMULATOR_HEADLESS}" = "1" ]; then + : "${EMULATOR_GPU:=swiftshader_indirect}" +else + : "${EMULATOR_GPU:=swiftshader_indirect}" # still safe default; host GPU: EMULATOR_GPU=host +fi +export EMULATOR_GPU + +: "${EMULATOR_AVD:=TalerWallet34}" +: "${EMULATOR_MEMORY:=1536}" +: "${EMULATOR_CORES:=2}" +# If no adb device: start headless AVD (0 = require phone/manual emulator) +: "${AUTO_START_EMULATOR:=1}" +: "${EMULATOR_BOOT_TIMEOUT:=180}" +: "${EMULATOR_LOG:=/tmp/taler-android-emulator.log}" + +android_adb_serial() { + adb devices 2>/dev/null | awk '/\tdevice$/{print $1; exit}' +} + +android_has_device() { + [ -n "$(android_adb_serial)" ] +} + +# Build emulator argv for current headless/windowed mode. +# Prints args; caller exec/nohup. +android_emulator_args() { + local avd="${1:-$EMULATOR_AVD}" + local args=( -avd "$avd" -gpu "$EMULATOR_GPU" -no-audio -no-boot-anim ) + if [ "${EMULATOR_HEADLESS}" = "1" ]; then + args+=( -no-window ) + # Avoid Qt trying to open a display on servers + export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}" + fi + if [ -n "${EMULATOR_MEMORY:-}" ]; then + args+=( -memory "$EMULATOR_MEMORY" ) + fi + if [ -n "${EMULATOR_CORES:-}" ]; then + args+=( -cores "$EMULATOR_CORES" ) + fi + # extra user flags + # shellcheck disable=SC2206 + if [ -n "${EMULATOR_EXTRA_ARGS:-}" ]; then + # intentional word-split for extra flags + args+=( ${EMULATOR_EXTRA_ARGS} ) + fi + printf '%s\n' "${args[@]}" +} + +# Ensure an adb "device" is online. Optionally start headless emulator. +# Sets SERIAL / ANDROID_SERIAL on success. Returns 0 ok, 3 no device. +android_ensure_device() { + local serial + serial="${SERIAL:-${ANDROID_SERIAL:-}}" + if [ -z "$serial" ]; then + serial=$(android_adb_serial || true) + fi + if [ -n "$serial" ]; then + SERIAL="$serial" + export ANDROID_SERIAL="$serial" + return 0 + fi + + if [ "${AUTO_START_EMULATOR}" != "1" ]; then + echo "no adb device (AUTO_START_EMULATOR=0 — plug phone or start AVD manually)" >&2 + return 3 + fi + + if ! command -v emulator >/dev/null 2>&1; then + echo "no adb device and emulator binary missing under $ANDROID_HOME" >&2 + return 3 + fi + + echo "no adb device → starting AVD ${EMULATOR_AVD} (headless=${EMULATOR_HEADLESS} gpu=${EMULATOR_GPU})" + local starter + starter="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/start-android-emulator.sh" + if [ ! -x "$starter" ]; then + echo "missing $starter" >&2 + return 3 + fi + EMULATOR_HEADLESS="$EMULATOR_HEADLESS" EMULATOR_GPU="$EMULATOR_GPU" \ + EMULATOR_AVD="$EMULATOR_AVD" EMULATOR_MEMORY="$EMULATOR_MEMORY" \ + EMULATOR_CORES="$EMULATOR_CORES" EMULATOR_LOG="$EMULATOR_LOG" \ + EMULATOR_BOOT_TIMEOUT="$EMULATOR_BOOT_TIMEOUT" \ + "$starter" --wait || return 3 + + serial=$(android_adb_serial || true) + if [ -z "$serial" ]; then + echo "emulator started but no adb device yet — see $EMULATOR_LOG" >&2 + return 3 + fi + SERIAL="$serial" + export ANDROID_SERIAL="$serial" + echo "device: $SERIAL (headless=${EMULATOR_HEADLESS})" + return 0 +} diff --git a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh index de280f5..f52c783 100755 --- a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh @@ -12,14 +12,9 @@ # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib_android_env.sh +. "$ROOT/lib_android_env.sh" -# Platform flags (GUI-AUTOMATION-NOTES.md). Linux → Android only. -os=$(uname -s) -case "${AUTO_PLATFORM:-auto}" in - auto) case "$os" in Linux|Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; esac ;; - linux|macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; - *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; -esac [ "${AUTO_ANDROID}" = "1" ] || { echo "skipped: AUTO_ANDROID=${AUTO_ANDROID}"; exit 0; } if [ "${AUTO_GUI}" != "1" ]; then echo "AUTO_GUI=0 → deep-link smoke only" @@ -46,17 +41,12 @@ STACK="${STACK:-auto}" SERIAL="${SERIAL:-}" mkdir -p "$OUT_DIR" -if ! command -v adb >/dev/null 2>&1; then - [ -x "$HOME/Android/Sdk/platform-tools/adb" ] && export PATH="$HOME/Android/Sdk/platform-tools:$PATH" -fi command -v adb >/dev/null || { echo "adb missing" >&2; exit 2; } -if [ -z "$SERIAL" ]; then - SERIAL=$(adb devices | awk '/\tdevice$/{print $1; exit}') -fi -[ -n "$SERIAL" ] || { echo "no adb device" >&2; exit 3; } +# Headless default: no host window; guest GLES via SwiftShader (uiautomator). +android_ensure_device || { echo "no adb device (try ./start-android-emulator.sh --wait)" >&2; exit 3; } ADB=(adb -s "$SERIAL") -echo "device: $SERIAL mode: GUI (uiautomator)" +echo "device: $SERIAL mode: GUI (uiautomator) headless=${EMULATOR_HEADLESS} gpu=${EMULATOR_GPU}" case "$STACK" in auto) diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh index 690c222..ef2ca8e 100755 --- a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh @@ -20,15 +20,9 @@ # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib_android_env.sh +. "$ROOT/lib_android_env.sh" -# Platform flags (see GUI-AUTOMATION-NOTES.md). Linux default: Android only. -os=$(uname -s) -case "${AUTO_PLATFORM:-auto}" in - auto) case "$os" in Linux) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; esac ;; - linux) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; - macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;; - *) : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;; -esac if [ "${AUTO_ANDROID}" != "1" ]; then echo "skipped: AUTO_ANDROID=${AUTO_ANDROID} (platform flags — see GUI-AUTOMATION-NOTES.md)" exit 0 @@ -46,28 +40,23 @@ OUT_DIR="${OUT_DIR:-$ROOT/out}" mkdir -p "$APK_DIR" "$OUT_DIR" if ! command -v adb >/dev/null 2>&1; then - if [ -x "$HOME/Android/Sdk/platform-tools/adb" ]; then - export PATH="$HOME/Android/Sdk/platform-tools:$PATH" - else - echo "adb missing — install android-tools-adb or user SDK platform-tools" >&2 - exit 2 - fi + echo "adb missing — install android-tools-adb or user SDK platform-tools" >&2 + exit 2 fi -if [ -z "$SERIAL" ]; then - SERIAL=$(adb devices | awk '/\tdevice$/{print $1; exit}') -fi -if [ -z "$SERIAL" ]; then - cat >&2 <<'EOF' -No adb device. Options (no root for user-local emulator if /dev/kvm is usable): +# Default: headless AVD (no host window). WINDOWED=1 / EMULATOR_HEADLESS=0 for UI. +if ! android_ensure_device; then + cat >&2 </dev/null 2>&1; then - [ -x "$HOME/Android/Sdk/platform-tools/adb" ] && export PATH="$HOME/Android/Sdk/platform-tools:$PATH" -fi command -v adb >/dev/null || die "adb not found" command -v python3 >/dev/null || die "python3 required" @@ -112,12 +105,11 @@ if [[ ! -f "${EXP_PW_FILE:-}" && "$STACK" = "stage" ]]; then fi [[ -n "${EXP_PW_FILE:-}" && -f "$EXP_PW_FILE" ]] || die "set EXP_PW_FILE (explorer password)" -if [[ -z "${ANDROID_SERIAL:-}" ]]; then - n=$(adb devices | awk 'NR>1 && $2=="device"{c++} END{print c+0}') - [[ "$n" -ge 1 ]] || die "no adb device (start emulator or plug phone)" - export ANDROID_SERIAL=$(adb devices | awk 'NR>1 && $2=="device"{print $1; exit}') -fi -info "ANDROID_SERIAL=$ANDROID_SERIAL STACK=$STACK PKG=$PKG SHOTDIR=$SHOTDIR" +# Headless AVD by default (no host window). WINDOWED=1 for a visible emulator. +SERIAL="${SERIAL:-${ANDROID_SERIAL:-}}" +android_ensure_device || die "no adb device (./start-android-emulator.sh --wait)" +export ANDROID_SERIAL="$SERIAL" +info "ANDROID_SERIAL=$ANDROID_SERIAL STACK=$STACK PKG=$PKG SHOTDIR=$SHOTDIR headless=$EMULATOR_HEADLESS gpu=$EMULATOR_GPU" ADB=(adb -s "$ANDROID_SERIAL") adb_sh() { "${ADB[@]}" shell "$@"; } diff --git a/scripts/taler-monitoring/android-test/start-android-emulator.sh b/scripts/taler-monitoring/android-test/start-android-emulator.sh new file mode 100755 index 0000000..e62b5f4 --- /dev/null +++ b/scripts/taler-monitoring/android-test/start-android-emulator.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Start the Taler Android AVD. **Default = headless** (no host window). +# Guest GLES still runs via SwiftShader so uiautomator / screenshots work. +# +# Usage: +# ./start-android-emulator.sh # headless, background +# ./start-android-emulator.sh --wait # headless + wait for boot +# ./start-android-emulator.sh --windowed # show emulator window +# EMULATOR_HEADLESS=0 ./start-android-emulator.sh +# EMULATOR_GPU=host WINDOWED=1 ./start-android-emulator.sh +# +# Env: EMULATOR_AVD EMULATOR_MEMORY EMULATOR_CORES EMULATOR_GPU +# EMULATOR_HEADLESS EMULATOR_LOG EMULATOR_BOOT_TIMEOUT EMULATOR_EXTRA_ARGS +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib_android_env.sh +. "$ROOT/lib_android_env.sh" + +WAIT=0 +for a in "$@"; do + case "$a" in + --wait) WAIT=1 ;; + --windowed| --gui) EMULATOR_HEADLESS=0; export EMULATOR_HEADLESS ;; + --headless) EMULATOR_HEADLESS=1; export EMULATOR_HEADLESS ;; + -h|--help) + sed -n '2,18p' "$0" + exit 0 + ;; + esac +done + +command -v emulator >/dev/null || { + echo "emulator not found (ANDROID_HOME=$ANDROID_HOME)" >&2 + exit 2 +} +command -v adb >/dev/null || { + echo "adb not found" >&2 + exit 2 +} + +# Already booted? +if serial=$(android_adb_serial); [ -n "$serial" ]; then + boot=$(adb -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true) + if [ "$boot" = "1" ]; then + echo "already running: $serial (boot_completed=1) — headless default still applies to *new* starts" + exit 0 + fi +fi + +# Avoid pkill patterns that match this wrapper's argv (self-kill). +mapfile -t EMU_ARGS < <(android_emulator_args "$EMULATOR_AVD") +echo "starting: emulator ${EMU_ARGS[*]}" +echo " headless=$EMULATOR_HEADLESS gpu=$EMULATOR_GPU log=$EMULATOR_LOG" + +if [ "${EMULATOR_HEADLESS}" = "1" ]; then + export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}" + # Do not require DISPLAY + unset DISPLAY || true +fi + +mkdir -p "$(dirname "$EMULATOR_LOG")" +# Background; do not use pkill -f emulator from callers matching full cmdline +nohup emulator "${EMU_ARGS[@]}" >"$EMULATOR_LOG" 2>&1 & +echo "emulator pid=$! log=$EMULATOR_LOG" + +if [ "$WAIT" != "1" ]; then + echo "not waiting (pass --wait for boot). adb devices when ready." + exit 0 +fi + +echo "waiting for boot (timeout ${EMULATOR_BOOT_TIMEOUT}s)…" +deadline=$((SECONDS + EMULATOR_BOOT_TIMEOUT)) +serial="" +while (( SECONDS < deadline )); do + adb wait-for-device 2>/dev/null || true + serial=$(android_adb_serial || true) + if [ -n "$serial" ]; then + boot=$(adb -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true) + if [ "$boot" = "1" ]; then + echo "BOOT_OK serial=$serial headless=$EMULATOR_HEADLESS gpu=$EMULATOR_GPU" + exit 0 + fi + fi + sleep 2 +done + +echo "boot timeout — last log:" >&2 +tail -30 "$EMULATOR_LOG" >&2 || true +exit 3 From c37cc88363976f7c5cd8ef66872de8d0d3854146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:31:30 +0200 Subject: [PATCH 267/326] android-test: matrix published + stable-self + master builds Add run-android-variant-matrix.sh so smoke covers F-Droid stable APK, a self-build of the same release tag (wallet-1.6.1), and origin/master. Build helper accepts tags; avoids relying on a single developer tree. --- .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 7 +- .../taler-monitoring/android-test/.gitignore | 2 + .../android-test/GUI-AUTOMATION-NOTES.md | 15 +- .../taler-monitoring/android-test/README.md | 32 +++- .../run-android-build-and-smoke.sh | 133 ++++++++++---- .../run-android-variant-matrix.sh | 167 ++++++++++++++++++ 6 files changed, 312 insertions(+), 44 deletions(-) create mode 100755 scripts/taler-monitoring/android-test/run-android-variant-matrix.sh diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md index f954cfd..4ad80b3 100644 --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -183,8 +183,13 @@ For reliable full UI confirm/pay use a physical phone or a host with **≥6–8 ### From-source smoke (same tests as F-Droid) ```bash -# build latest master + adb smoke +# single ref (default: inference fix branch) ./android-test/run-android-build-and-smoke.sh +BRANCH=master ./android-test/run-android-build-and-smoke.sh +# self-build published stable tag (parity with F-Droid 1.6.1 source) +BRANCH=wallet-1.6.1 LABEL=stable-self ./android-test/run-android-build-and-smoke.sh +# matrix: published APK + stable self-build + master (anti–weird-build) +./android-test/run-android-variant-matrix.sh STACK=goa ./android-test/run-android-build-and-smoke.sh # already built: APK_PATH=$HOME/taler/taler-android/wallet/build/outputs/apk/fdroid/debug/wallet-fdroid-debug.apk \ diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore index 0867ff3..6d4e62e 100644 --- a/scripts/taler-monitoring/android-test/.gitignore +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -2,6 +2,8 @@ apks/*.apk out/ *.png out-source/ +out-source-*/ out-gui/ out-source-gui/ out-gui-chain/ +out-matrix-*/ diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 27330ca..16023eb 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -235,11 +235,24 @@ Scripts **today** implement Android only; they should honour: | `run-android-pay-smoke.sh` | Hybrid: install + deep-link + light taps | | `run-android-gui-smoke.sh` | Multi-round GUI drive | | `run-goa-gui-chain.sh` | Multi mint/withdraw/pay (gui-workflows port) | -| `run-android-build-and-smoke.sh` | Build inference fix branch + smoke (`GUI=1` optional) | +| `run-android-build-and-smoke.sh` | Build git ref (branch/tag) + smoke (`GUI=1` optional) | +| `run-android-variant-matrix.sh` | **published + stable-self + master** (compare builds) | | `start-android-emulator.sh` | Start AVD (**headless default**, SwiftShader) | | `lib_android_env.sh` | Flags, PATH, `android_ensure_device` | | `lib_ui.py` | Dump/tap/ANR helpers | +### Build variants (anti–“weird local build”) + +| Variant | Source | Package | Purpose | +|---------|--------|---------|---------| +| **published** | F-Droid APK URL | `net.taler.wallet.fdroid` | What users install | +| **stable-self** | tag `wallet-1.6.1` (override `STABLE_TAG`) rebuilt here | `…fdroid.debug` | Same release tree as stable, **our** Gradle — excludes odd publisher-only or dirty-tree builds | +| **master** | `origin/master` rebuilt here | `…fdroid.debug` | Tip of mainline | +| **fix** (optional) | inference fix branch | `…fdroid.debug` | GOA automation fixes | + +Default matrix: `published,stable-self,master`. +`./run-android-variant-matrix.sh` — see README. + --- ## Stacks under test diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 4c2e5f2..16be18c 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -11,22 +11,44 @@ STACK=stage ./run-android-pay-smoke.sh APK: F-Droid **1.6.1** (`net.taler.wallet.fdroid`). -## 2) Latest source build + same tests +## 2) Source build + same tests -Pulls/builds `taler-android` master (`:wallet:assembleFdroidDebug`), then +Builds `taler-android` at a git **ref** (`:wallet:assembleFdroidDebug`), then runs the **same** smoke (install + withdraw/pay deep-links). ```bash -# default source: $HOME/taler/taler-android +# default: inference fix branch (GOA withdraw reliability) ./run-android-build-and-smoke.sh -STACK=goa ./run-android-build-and-smoke.sh -SKIP_PULL=1 ./run-android-build-and-smoke.sh # use tree as-is +# tip of master +BRANCH=master ./run-android-build-and-smoke.sh +# self-build of published stable **tag** (same release as F-Droid 1.6.1) +BRANCH=wallet-1.6.1 LABEL=stable-self ./run-android-build-and-smoke.sh +SKIP_PULL=1 BRANCH=master ./run-android-build-and-smoke.sh TALER_ANDROID_SRC=/path/to/taler-android ./run-android-build-and-smoke.sh ``` Built package id: `net.taler.wallet.fdroid.debug`. Needs `ANDROID_HOME` (e.g. `$HOME/Android/Sdk`), Java 17+, network for Maven. +## 2b) Variant matrix (published + stable self-build + master) + +Runs several smokes so a single odd developer build cannot be the only sample: + +| Variant | What | +|---------|------| +| **published** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | +| **stable-self** | Rebuild tag `wallet-1.6.1` on this host (same source as stable) | +| **master** | Rebuild `origin/master` | + +```bash +./run-android-variant-matrix.sh +STACK=goa ./run-android-variant-matrix.sh +VARIANTS=master,stable-self ./run-android-variant-matrix.sh +VARIANTS=published,stable-self,master,fix GUI=0 ./run-android-variant-matrix.sh +``` + +Artifacts + `SUMMARY.txt` under `out-matrix-/`. + ## 3) Graphical UI (vanilla) Deep-link entry (≡ QR) **plus** multi-round uiautomator taps: diff --git a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh index 5779b88..659b446 100755 --- a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh @@ -1,18 +1,24 @@ #!/usr/bin/env bash -# Build the **latest** GNU Taler Android wallet from source, then run the same -# adb deep-link smoke as run-android-pay-smoke.sh. +# Build GNU Taler Android wallet from source at a git **ref**, then run the same +# adb deep-link (or GUI) smoke as run-android-pay-smoke.sh. # -# Source (default): $HOME/taler/taler-android (git@git.taler.net:taler-android.git) +# Source (default): $HOME/taler/taler-android # Build: ./gradlew :wallet:assembleFdroidDebug # Package id: net.taler.wallet.fdroid.debug # -# No root required if ANDROID_HOME points at a user SDK ($HOME/Android/Sdk). +# Typical refs: +# BRANCH=master # tip of master +# BRANCH=wallet-1.6.1 # published stable **tag** (self-build) +# BRANCH=dev/hernani-inference/fix-… # GOA automation fix branch +# +# For a multi-variant matrix (master + stable self-build + published APK): +# ./run-android-variant-matrix.sh # # Usage: -# ./android-test/run-android-build-and-smoke.sh -# STACK=goa ./android-test/run-android-build-and-smoke.sh -# STACK=stage SKIP_PULL=1 ./android-test/run-android-build-and-smoke.sh -# TALER_ANDROID_SRC=/path/to/taler-android ./android-test/run-android-build-and-smoke.sh +# ./run-android-build-and-smoke.sh +# BRANCH=master STACK=goa ./run-android-build-and-smoke.sh +# BRANCH=wallet-1.6.1 LABEL=stable-self ./run-android-build-and-smoke.sh +# SKIP_PULL=1 BRANCH=master ./run-android-build-and-smoke.sh # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) @@ -20,9 +26,11 @@ SMOKE="$ROOT/run-android-pay-smoke.sh" SRC="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" SKIP_PULL="${SKIP_PULL:-0}" # Prefer existing inference fix branch for GOA withdraw (spinner/exchange resolve). -# Override: BRANCH=master or BRANCH=dev/hernani-inference/fix-withdraw-spinner-fallback +# Matrix uses BRANCH=master and BRANCH=$STABLE_TAG explicitly. BRANCH="${BRANCH:-dev/hernani-inference/fix-bank-withdraw-auto-exchange}" -VARIANT="${VARIANT:-fdroidDebug}" # assemble task suffix: FdroidDebug / NightlyDebug / … +# Optional human label for logs / artifact names (default = sanitized BRANCH) +LABEL="${LABEL:-}" +VARIANT="${VARIANT:-fdroidDebug}" export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" @@ -37,72 +45,123 @@ fi printf 'sdk.dir=%s\n' "$ANDROID_HOME" >"$SRC/local.properties" +# --- checkout REF (branch, origin/branch, or tag) -------------------------------- cd "$SRC" if [ -d .git ]; then - git fetch origin 2>/dev/null || true - if [ -n "$BRANCH" ] && git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then - echo "checkout origin/$BRANCH (minimal GOA/automation fixes; override BRANCH=)" - git checkout -B "$BRANCH" "origin/$BRANCH" 2>/dev/null || git checkout "$BRANCH" 2>/dev/null || true + git fetch origin --tags 2>/dev/null || git fetch origin 2>/dev/null || true + REF="$BRANCH" + checked=0 + if [ -n "$REF" ]; then + if git rev-parse --verify "refs/tags/${REF}" >/dev/null 2>&1; then + echo "checkout tag ${REF} (stable / release self-build)" + git checkout -f "refs/tags/${REF}" + checked=1 + SKIP_PULL=1 + elif git rev-parse --verify "origin/${REF}" >/dev/null 2>&1; then + echo "checkout origin/${REF}" + git checkout -B "$REF" "origin/${REF}" 2>/dev/null || git checkout "$REF" 2>/dev/null || true + checked=1 + elif git rev-parse --verify "${REF}" >/dev/null 2>&1; then + echo "checkout ${REF}" + git checkout -f "$REF" + checked=1 + else + echo "WARN: ref '${REF}' not found locally after fetch — staying on current HEAD" >&2 + fi fi - if [ "$SKIP_PULL" != "1" ]; then - br=$(git rev-parse --abbrev-ref HEAD) - git pull --ff-only origin "$br" 2>/dev/null || true + if [ "$SKIP_PULL" != "1" ] && [ "$checked" = "1" ]; then + # only pull real branches, never tags + if git symbolic-ref -q HEAD >/dev/null 2>&1; then + br=$(git rev-parse --abbrev-ref HEAD) + git pull --ff-only origin "$br" 2>/dev/null || true + fi fi fi -COMMIT=$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown) -echo "source: $SRC @ $COMMIT (branch $(git rev-parse --abbrev-ref HEAD 2>/dev/null || ?))" -echo "variant: assemble${VARIANT^}" 2>/dev/null || true -# Map VARIANT → gradle task (fdroidDebug → assembleFdroidDebug) -task="assemble$(printf '%s' "$VARIANT" | python3 -c 'import sys; s=sys.stdin.read().strip(); print(s[:1].upper()+s[1:] if s else "FdroidDebug")')" -# Prefer explicit known tasks +COMMIT=$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown) +HEAD_DESC=$(git describe --tags --always 2>/dev/null || echo "$COMMIT") +BR_NOW=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached) +if [ -z "$LABEL" ]; then + LABEL=$(printf '%s' "$BRANCH" | tr '/:' '--' | tr -c 'A-Za-z0-9._-' '-' | cut -c1-48) +fi +echo "source: $SRC @ $COMMIT ($HEAD_DESC) ref=$BRANCH label=$LABEL head=$BR_NOW" +echo "variant: $VARIANT" + +# Map VARIANT → gradle task case "$VARIANT" in fdroidDebug|FdroidDebug) task=assembleFdroidDebug ;; fdroidRelease|FdroidRelease) task=assembleFdroidRelease ;; nightlyDebug|NightlyDebug) task=assembleNightlyDebug ;; googleDebug|GoogleDebug) task=assembleGoogleDebug ;; + *) + task="assemble$(printf '%s' "$VARIANT" | python3 -c 'import sys; s=sys.stdin.read().strip(); print(s[:1].upper()+s[1:] if s else "FdroidDebug")')" + ;; esac -# Low-RAM hosts: cap JVM (override GRADLE_OPTS to raise) +# Low-RAM hosts: cap JVM export GRADLE_OPTS="${GRADLE_OPTS:--Xmx1280m -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1}" echo "gradle :wallet:$task …" ./gradlew ":wallet:$task" --no-daemon --max-workers="${GRADLE_MAX_WORKERS:-1}" \ -Dorg.gradle.jvmargs="${GRADLE_JVMARGS:--Xmx1280m}" -# Locate APK -APK=$(find "$SRC/wallet/build/outputs/apk" -name '*.apk' -type f 2>/dev/null | sort | tail -1) +# Prefer fdroid debug APK if multiple exist +APK=$(find "$SRC/wallet/build/outputs/apk" -path '*fdroid*debug*.apk' -type f 2>/dev/null | sort | tail -1) +if [ -z "$APK" ] || [ ! -f "$APK" ]; then + APK=$(find "$SRC/wallet/build/outputs/apk" -name '*.apk' -type f 2>/dev/null | sort | tail -1) +fi if [ -z "$APK" ] || [ ! -f "$APK" ]; then echo "no APK under wallet/build/outputs/apk" >&2 exit 4 fi echo "built apk: $APK ($(wc -c <"$APK") bytes)" -# Package id from aapt if available PKG="${PKG:-}" if [ -z "$PKG" ] && command -v aapt >/dev/null 2>&1; then PKG=$(aapt dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\([^']*\)'.*/\1/p" | head -1) fi +if [ -z "$PKG" ] && command -v aapt2 >/dev/null 2>&1; then + PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\([^']*\)'.*/\1/p" | head -1) +fi : "${PKG:=net.taler.wallet.fdroid.debug}" -mkdir -p "$ROOT/apks" -STAMP_APK="$ROOT/apks/wallet-from-source-${COMMIT}.apk" +mkdir -p "$ROOT/apks" "$ROOT/out" +STAMP_APK="$ROOT/apks/wallet-${LABEL}-${COMMIT}.apk" cp -f "$APK" "$STAMP_APK" echo "copied → $STAMP_APK" -echo "$COMMIT" >"$ROOT/out/build-commit.txt" 2>/dev/null || mkdir -p "$ROOT/out" && echo "$COMMIT" >"$ROOT/out/build-commit.txt" +{ + echo "label=$LABEL" + echo "ref=$BRANCH" + echo "commit=$COMMIT" + echo "describe=$HEAD_DESC" + echo "apk=$STAMP_APK" + echo "pkg=$PKG" + echo "task=$task" +} | tee "$ROOT/out/build-${LABEL}.txt" +echo "$COMMIT" >"$ROOT/out/build-commit.txt" echo "$APK" >"$ROOT/out/build-apk-path.txt" -# Same smoke as published F-Droid path (CLI hybrid or GUI) export APK_PATH="$STAMP_APK" -export APK_NAME="$(basename "$STAMP_APK")" +export APK_NAME +APK_NAME="$(basename "$STAMP_APK")" export PKG -export OUT_DIR="${OUT_DIR:-$ROOT/out-source}" +export OUT_DIR="${OUT_DIR:-$ROOT/out-source-$LABEL}" mkdir -p "$OUT_DIR" +cp -f "$ROOT/out/build-${LABEL}.txt" "$OUT_DIR/build-meta.txt" 2>/dev/null || true + SMOKE_CMD="$SMOKE" if [ "${GUI:-0}" = "1" ]; then SMOKE_CMD="$ROOT/run-android-gui-smoke.sh" - OUT_DIR="${OUT_DIR%-source}-source-gui" - export OUT_DIR + case "$OUT_DIR" in + *-gui) ;; + *) OUT_DIR="${OUT_DIR}-gui"; export OUT_DIR; mkdir -p "$OUT_DIR" ;; + esac fi -echo "=== smoke with built APK (PKG=$PKG · GUI=${GUI:-0}) ===" -exec env APK_DIR="$ROOT/apks" APK_NAME="$APK_NAME" APK_PATH="$APK_PATH" PKG="$PKG" OUT_DIR="$OUT_DIR" \ +echo "=== smoke label=$LABEL PKG=$PKG GUI=${GUI:-0} OUT_DIR=$OUT_DIR ===" +# Do not exec: allow matrix wrapper to run multiple variants +set +e +env APK_DIR="$ROOT/apks" APK_NAME="$APK_NAME" APK_PATH="$APK_PATH" PKG="$PKG" OUT_DIR="$OUT_DIR" \ "$SMOKE_CMD" +rc=$? +set -e +echo "=== smoke label=$LABEL exit=$rc ===" +exit "$rc" diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh new file mode 100755 index 0000000..50d31a8 --- /dev/null +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Run Android wallet smoke against **several build variants** so a single +# "weird" developer tree or a bad local build cannot be the only sample. +# +# Default matrix: +# 1) **published** — F-Droid stable APK (bit-for-bit what users install) +# 2) **stable-self** — rebuild the same release **tag** as published (self-build) +# 3) **master** — rebuild origin/master (tip / CI-style) +# +# Why stable-self in addition to published? +# Rare, but a published APK *or* a one-off developer build can be odd +# (wrong flags, dirty tree, wrong ABI). Rebuilding the release tag on *this* +# machine with the same script path as master isolates "our toolchain" vs +# "F-Droid binary" vs "current master source". +# +# Usage: +# ./run-android-variant-matrix.sh +# STACK=goa ./run-android-variant-matrix.sh +# VARIANTS=master,stable-self ./run-android-variant-matrix.sh +# VARIANTS=published,stable-self,master,fix GUI=1 ./run-android-variant-matrix.sh +# STABLE_TAG=wallet-1.6.1 MASTER_BRANCH=master ./run-android-variant-matrix.sh +# +# Env: +# STABLE_TAG default wallet-1.6.1 (must match published F-Droid release) +# STABLE_APK_URL published APK (same as pay-smoke default) +# MASTER_BRANCH default master +# FIX_BRANCH inference fix branch (variant "fix") +# VARIANTS comma list: published,stable-self,master,fix +# GUI=1 use gui-smoke instead of pay-smoke after each build +# FAIL_FAST=1 stop on first non-zero smoke (default 0 = run all) +# MATRIX_OUT summary dir (default out-matrix-) +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib_android_env.sh +. "$ROOT/lib_android_env.sh" + +: "${STABLE_TAG:=wallet-1.6.1}" +: "${STABLE_APK_URL:=https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}" +: "${STABLE_APK_NAME:=net.taler.wallet.fdroid_854.apk}" +: "${MASTER_BRANCH:=master}" +: "${FIX_BRANCH:=dev/hernani-inference/fix-bank-withdraw-auto-exchange}" +: "${VARIANTS:=published,stable-self,master}" +: "${FAIL_FAST:=0}" +: "${GUI:=0}" +: "${STACK:=auto}" + +TS=$(date +%Y%m%d-%H%M%S) +MATRIX_OUT="${MATRIX_OUT:-$ROOT/out-matrix-$TS}" +mkdir -p "$MATRIX_OUT" +SUMMARY="$MATRIX_OUT/SUMMARY.txt" +: >"$SUMMARY" + +echo "=== Android variant matrix ===" | tee -a "$SUMMARY" +echo "STACK=$STACK GUI=$GUI headless=${EMULATOR_HEADLESS} variants=$VARIANTS" | tee -a "$SUMMARY" +echo "STABLE_TAG=$STABLE_TAG MASTER_BRANCH=$MASTER_BRANCH" | tee -a "$SUMMARY" +echo "out: $MATRIX_OUT" | tee -a "$SUMMARY" + +# Ensure device once (headless AVD by default) +android_ensure_device || { + echo "no adb device — start ./start-android-emulator.sh --wait" >&2 + exit 3 +} +export SERIAL ANDROID_SERIAL +echo "device: $SERIAL" | tee -a "$SUMMARY" + +IFS=',' read -r -a VLIST <<<"$VARIANTS" +declare -a RESULTS=() +overall=0 + +run_published() { + local out="$MATRIX_OUT/published" + mkdir -p "$out" + echo "" | tee -a "$SUMMARY" + echo "-------- variant: published (F-Droid stable binary) --------" | tee -a "$SUMMARY" + local smoke="$ROOT/run-android-pay-smoke.sh" + [ "$GUI" = "1" ] && smoke="$ROOT/run-android-gui-smoke.sh" + set +e + env STACK="$STACK" \ + APK_URL="$STABLE_APK_URL" \ + APK_NAME="$STABLE_APK_NAME" \ + APK_PATH="" \ + PKG=net.taler.wallet.fdroid \ + OUT_DIR="$out" \ + SERIAL="$SERIAL" \ + AUTO_START_EMULATOR=0 \ + "$smoke" + local rc=$? + set -e + echo "published exit=$rc" | tee -a "$SUMMARY" + echo "published $rc" >>"$MATRIX_OUT/results.tsv" + return "$rc" +} + +run_selfbuild() { + local label="$1" + local ref="$2" + local out="$MATRIX_OUT/$label" + mkdir -p "$out" + echo "" | tee -a "$SUMMARY" + echo "-------- variant: $label (self-build ref=$ref) --------" | tee -a "$SUMMARY" + set +e + env STACK="$STACK" \ + BRANCH="$ref" \ + LABEL="$label" \ + GUI="$GUI" \ + OUT_DIR="$out" \ + SERIAL="$SERIAL" \ + ANDROID_SERIAL="$SERIAL" \ + AUTO_START_EMULATOR=0 \ + PKG=net.taler.wallet.fdroid.debug \ + "$ROOT/run-android-build-and-smoke.sh" + local rc=$? + set -e + echo "$label (ref=$ref) exit=$rc" | tee -a "$SUMMARY" + echo "$label $rc $ref" >>"$MATRIX_OUT/results.tsv" + if [ -f "$out/build-meta.txt" ]; then + echo "build-meta:" | tee -a "$SUMMARY" + cat "$out/build-meta.txt" | tee -a "$SUMMARY" + fi + return "$rc" +} + +for raw in "${VLIST[@]}"; do + v=$(echo "$raw" | tr -d '[:space:]') + [ -n "$v" ] || continue + rc=0 + case "$v" in + published|fdroid|stable-published) + run_published || rc=$? + ;; + stable-self|stable|self-stable|release-self) + run_selfbuild "stable-self" "$STABLE_TAG" || rc=$? + ;; + master|main) + run_selfbuild "master" "$MASTER_BRANCH" || rc=$? + ;; + fix|fix-branch|inference) + run_selfbuild "fix" "$FIX_BRANCH" || rc=$? + ;; + *) + # treat as raw git ref / label + run_selfbuild "ref-${v//\//-}" "$v" || rc=$? + ;; + esac + RESULTS+=("$v:$rc") + if [ "$rc" -ne 0 ]; then + overall=1 + if [ "$FAIL_FAST" = "1" ]; then + echo "FAIL_FAST: stopping after $v rc=$rc" | tee -a "$SUMMARY" + break + fi + fi +done + +echo "" | tee -a "$SUMMARY" +echo "=== matrix done (overall=$overall) ===" | tee -a "$SUMMARY" +for r in "${RESULTS[@]}"; do + echo " $r" | tee -a "$SUMMARY" +done +echo "" +echo "Compare OUT_DIR trees under $MATRIX_OUT" +echo "Intent: published vs stable-self should behave similarly;" +echo " master may diverge (new features / fixes)." +echo "A odd stable-self vs published suggests toolchain or unsigned-debug differences;" +echo "a odd master-only failure suggests tip breakage, not F-Droid stable." +exit "$overall" From b0e186f3921a64215d580adf5ed95466f40f8a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:31:30 +0200 Subject: [PATCH 268/326] android-test: matrix published + stable-self + master builds Add run-android-variant-matrix.sh so smoke covers F-Droid stable APK, a self-build of the same release tag (wallet-1.6.1), and origin/master. Build helper accepts tags; avoids relying on a single developer tree. --- .../taler-monitoring/GIT-AUTOMATION-NOTES.md | 7 +- .../taler-monitoring/android-test/.gitignore | 2 + .../android-test/GUI-AUTOMATION-NOTES.md | 15 +- .../taler-monitoring/android-test/README.md | 32 +++- .../run-android-build-and-smoke.sh | 133 ++++++++++---- .../run-android-variant-matrix.sh | 167 ++++++++++++++++++ 6 files changed, 312 insertions(+), 44 deletions(-) create mode 100755 scripts/taler-monitoring/android-test/run-android-variant-matrix.sh diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md index f954cfd..4ad80b3 100644 --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md @@ -183,8 +183,13 @@ For reliable full UI confirm/pay use a physical phone or a host with **≥6–8 ### From-source smoke (same tests as F-Droid) ```bash -# build latest master + adb smoke +# single ref (default: inference fix branch) ./android-test/run-android-build-and-smoke.sh +BRANCH=master ./android-test/run-android-build-and-smoke.sh +# self-build published stable tag (parity with F-Droid 1.6.1 source) +BRANCH=wallet-1.6.1 LABEL=stable-self ./android-test/run-android-build-and-smoke.sh +# matrix: published APK + stable self-build + master (anti–weird-build) +./android-test/run-android-variant-matrix.sh STACK=goa ./android-test/run-android-build-and-smoke.sh # already built: APK_PATH=$HOME/taler/taler-android/wallet/build/outputs/apk/fdroid/debug/wallet-fdroid-debug.apk \ diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore index 0867ff3..6d4e62e 100644 --- a/scripts/taler-monitoring/android-test/.gitignore +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -2,6 +2,8 @@ apks/*.apk out/ *.png out-source/ +out-source-*/ out-gui/ out-source-gui/ out-gui-chain/ +out-matrix-*/ diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 27330ca..16023eb 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -235,11 +235,24 @@ Scripts **today** implement Android only; they should honour: | `run-android-pay-smoke.sh` | Hybrid: install + deep-link + light taps | | `run-android-gui-smoke.sh` | Multi-round GUI drive | | `run-goa-gui-chain.sh` | Multi mint/withdraw/pay (gui-workflows port) | -| `run-android-build-and-smoke.sh` | Build inference fix branch + smoke (`GUI=1` optional) | +| `run-android-build-and-smoke.sh` | Build git ref (branch/tag) + smoke (`GUI=1` optional) | +| `run-android-variant-matrix.sh` | **published + stable-self + master** (compare builds) | | `start-android-emulator.sh` | Start AVD (**headless default**, SwiftShader) | | `lib_android_env.sh` | Flags, PATH, `android_ensure_device` | | `lib_ui.py` | Dump/tap/ANR helpers | +### Build variants (anti–“weird local build”) + +| Variant | Source | Package | Purpose | +|---------|--------|---------|---------| +| **published** | F-Droid APK URL | `net.taler.wallet.fdroid` | What users install | +| **stable-self** | tag `wallet-1.6.1` (override `STABLE_TAG`) rebuilt here | `…fdroid.debug` | Same release tree as stable, **our** Gradle — excludes odd publisher-only or dirty-tree builds | +| **master** | `origin/master` rebuilt here | `…fdroid.debug` | Tip of mainline | +| **fix** (optional) | inference fix branch | `…fdroid.debug` | GOA automation fixes | + +Default matrix: `published,stable-self,master`. +`./run-android-variant-matrix.sh` — see README. + --- ## Stacks under test diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 4c2e5f2..16be18c 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -11,22 +11,44 @@ STACK=stage ./run-android-pay-smoke.sh APK: F-Droid **1.6.1** (`net.taler.wallet.fdroid`). -## 2) Latest source build + same tests +## 2) Source build + same tests -Pulls/builds `taler-android` master (`:wallet:assembleFdroidDebug`), then +Builds `taler-android` at a git **ref** (`:wallet:assembleFdroidDebug`), then runs the **same** smoke (install + withdraw/pay deep-links). ```bash -# default source: $HOME/taler/taler-android +# default: inference fix branch (GOA withdraw reliability) ./run-android-build-and-smoke.sh -STACK=goa ./run-android-build-and-smoke.sh -SKIP_PULL=1 ./run-android-build-and-smoke.sh # use tree as-is +# tip of master +BRANCH=master ./run-android-build-and-smoke.sh +# self-build of published stable **tag** (same release as F-Droid 1.6.1) +BRANCH=wallet-1.6.1 LABEL=stable-self ./run-android-build-and-smoke.sh +SKIP_PULL=1 BRANCH=master ./run-android-build-and-smoke.sh TALER_ANDROID_SRC=/path/to/taler-android ./run-android-build-and-smoke.sh ``` Built package id: `net.taler.wallet.fdroid.debug`. Needs `ANDROID_HOME` (e.g. `$HOME/Android/Sdk`), Java 17+, network for Maven. +## 2b) Variant matrix (published + stable self-build + master) + +Runs several smokes so a single odd developer build cannot be the only sample: + +| Variant | What | +|---------|------| +| **published** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | +| **stable-self** | Rebuild tag `wallet-1.6.1` on this host (same source as stable) | +| **master** | Rebuild `origin/master` | + +```bash +./run-android-variant-matrix.sh +STACK=goa ./run-android-variant-matrix.sh +VARIANTS=master,stable-self ./run-android-variant-matrix.sh +VARIANTS=published,stable-self,master,fix GUI=0 ./run-android-variant-matrix.sh +``` + +Artifacts + `SUMMARY.txt` under `out-matrix-/`. + ## 3) Graphical UI (vanilla) Deep-link entry (≡ QR) **plus** multi-round uiautomator taps: diff --git a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh index 5779b88..659b446 100755 --- a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh @@ -1,18 +1,24 @@ #!/usr/bin/env bash -# Build the **latest** GNU Taler Android wallet from source, then run the same -# adb deep-link smoke as run-android-pay-smoke.sh. +# Build GNU Taler Android wallet from source at a git **ref**, then run the same +# adb deep-link (or GUI) smoke as run-android-pay-smoke.sh. # -# Source (default): $HOME/taler/taler-android (git@git.taler.net:taler-android.git) +# Source (default): $HOME/taler/taler-android # Build: ./gradlew :wallet:assembleFdroidDebug # Package id: net.taler.wallet.fdroid.debug # -# No root required if ANDROID_HOME points at a user SDK ($HOME/Android/Sdk). +# Typical refs: +# BRANCH=master # tip of master +# BRANCH=wallet-1.6.1 # published stable **tag** (self-build) +# BRANCH=dev/hernani-inference/fix-… # GOA automation fix branch +# +# For a multi-variant matrix (master + stable self-build + published APK): +# ./run-android-variant-matrix.sh # # Usage: -# ./android-test/run-android-build-and-smoke.sh -# STACK=goa ./android-test/run-android-build-and-smoke.sh -# STACK=stage SKIP_PULL=1 ./android-test/run-android-build-and-smoke.sh -# TALER_ANDROID_SRC=/path/to/taler-android ./android-test/run-android-build-and-smoke.sh +# ./run-android-build-and-smoke.sh +# BRANCH=master STACK=goa ./run-android-build-and-smoke.sh +# BRANCH=wallet-1.6.1 LABEL=stable-self ./run-android-build-and-smoke.sh +# SKIP_PULL=1 BRANCH=master ./run-android-build-and-smoke.sh # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) @@ -20,9 +26,11 @@ SMOKE="$ROOT/run-android-pay-smoke.sh" SRC="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" SKIP_PULL="${SKIP_PULL:-0}" # Prefer existing inference fix branch for GOA withdraw (spinner/exchange resolve). -# Override: BRANCH=master or BRANCH=dev/hernani-inference/fix-withdraw-spinner-fallback +# Matrix uses BRANCH=master and BRANCH=$STABLE_TAG explicitly. BRANCH="${BRANCH:-dev/hernani-inference/fix-bank-withdraw-auto-exchange}" -VARIANT="${VARIANT:-fdroidDebug}" # assemble task suffix: FdroidDebug / NightlyDebug / … +# Optional human label for logs / artifact names (default = sanitized BRANCH) +LABEL="${LABEL:-}" +VARIANT="${VARIANT:-fdroidDebug}" export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" @@ -37,72 +45,123 @@ fi printf 'sdk.dir=%s\n' "$ANDROID_HOME" >"$SRC/local.properties" +# --- checkout REF (branch, origin/branch, or tag) -------------------------------- cd "$SRC" if [ -d .git ]; then - git fetch origin 2>/dev/null || true - if [ -n "$BRANCH" ] && git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then - echo "checkout origin/$BRANCH (minimal GOA/automation fixes; override BRANCH=)" - git checkout -B "$BRANCH" "origin/$BRANCH" 2>/dev/null || git checkout "$BRANCH" 2>/dev/null || true + git fetch origin --tags 2>/dev/null || git fetch origin 2>/dev/null || true + REF="$BRANCH" + checked=0 + if [ -n "$REF" ]; then + if git rev-parse --verify "refs/tags/${REF}" >/dev/null 2>&1; then + echo "checkout tag ${REF} (stable / release self-build)" + git checkout -f "refs/tags/${REF}" + checked=1 + SKIP_PULL=1 + elif git rev-parse --verify "origin/${REF}" >/dev/null 2>&1; then + echo "checkout origin/${REF}" + git checkout -B "$REF" "origin/${REF}" 2>/dev/null || git checkout "$REF" 2>/dev/null || true + checked=1 + elif git rev-parse --verify "${REF}" >/dev/null 2>&1; then + echo "checkout ${REF}" + git checkout -f "$REF" + checked=1 + else + echo "WARN: ref '${REF}' not found locally after fetch — staying on current HEAD" >&2 + fi fi - if [ "$SKIP_PULL" != "1" ]; then - br=$(git rev-parse --abbrev-ref HEAD) - git pull --ff-only origin "$br" 2>/dev/null || true + if [ "$SKIP_PULL" != "1" ] && [ "$checked" = "1" ]; then + # only pull real branches, never tags + if git symbolic-ref -q HEAD >/dev/null 2>&1; then + br=$(git rev-parse --abbrev-ref HEAD) + git pull --ff-only origin "$br" 2>/dev/null || true + fi fi fi -COMMIT=$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown) -echo "source: $SRC @ $COMMIT (branch $(git rev-parse --abbrev-ref HEAD 2>/dev/null || ?))" -echo "variant: assemble${VARIANT^}" 2>/dev/null || true -# Map VARIANT → gradle task (fdroidDebug → assembleFdroidDebug) -task="assemble$(printf '%s' "$VARIANT" | python3 -c 'import sys; s=sys.stdin.read().strip(); print(s[:1].upper()+s[1:] if s else "FdroidDebug")')" -# Prefer explicit known tasks +COMMIT=$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown) +HEAD_DESC=$(git describe --tags --always 2>/dev/null || echo "$COMMIT") +BR_NOW=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached) +if [ -z "$LABEL" ]; then + LABEL=$(printf '%s' "$BRANCH" | tr '/:' '--' | tr -c 'A-Za-z0-9._-' '-' | cut -c1-48) +fi +echo "source: $SRC @ $COMMIT ($HEAD_DESC) ref=$BRANCH label=$LABEL head=$BR_NOW" +echo "variant: $VARIANT" + +# Map VARIANT → gradle task case "$VARIANT" in fdroidDebug|FdroidDebug) task=assembleFdroidDebug ;; fdroidRelease|FdroidRelease) task=assembleFdroidRelease ;; nightlyDebug|NightlyDebug) task=assembleNightlyDebug ;; googleDebug|GoogleDebug) task=assembleGoogleDebug ;; + *) + task="assemble$(printf '%s' "$VARIANT" | python3 -c 'import sys; s=sys.stdin.read().strip(); print(s[:1].upper()+s[1:] if s else "FdroidDebug")')" + ;; esac -# Low-RAM hosts: cap JVM (override GRADLE_OPTS to raise) +# Low-RAM hosts: cap JVM export GRADLE_OPTS="${GRADLE_OPTS:--Xmx1280m -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1}" echo "gradle :wallet:$task …" ./gradlew ":wallet:$task" --no-daemon --max-workers="${GRADLE_MAX_WORKERS:-1}" \ -Dorg.gradle.jvmargs="${GRADLE_JVMARGS:--Xmx1280m}" -# Locate APK -APK=$(find "$SRC/wallet/build/outputs/apk" -name '*.apk' -type f 2>/dev/null | sort | tail -1) +# Prefer fdroid debug APK if multiple exist +APK=$(find "$SRC/wallet/build/outputs/apk" -path '*fdroid*debug*.apk' -type f 2>/dev/null | sort | tail -1) +if [ -z "$APK" ] || [ ! -f "$APK" ]; then + APK=$(find "$SRC/wallet/build/outputs/apk" -name '*.apk' -type f 2>/dev/null | sort | tail -1) +fi if [ -z "$APK" ] || [ ! -f "$APK" ]; then echo "no APK under wallet/build/outputs/apk" >&2 exit 4 fi echo "built apk: $APK ($(wc -c <"$APK") bytes)" -# Package id from aapt if available PKG="${PKG:-}" if [ -z "$PKG" ] && command -v aapt >/dev/null 2>&1; then PKG=$(aapt dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\([^']*\)'.*/\1/p" | head -1) fi +if [ -z "$PKG" ] && command -v aapt2 >/dev/null 2>&1; then + PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\([^']*\)'.*/\1/p" | head -1) +fi : "${PKG:=net.taler.wallet.fdroid.debug}" -mkdir -p "$ROOT/apks" -STAMP_APK="$ROOT/apks/wallet-from-source-${COMMIT}.apk" +mkdir -p "$ROOT/apks" "$ROOT/out" +STAMP_APK="$ROOT/apks/wallet-${LABEL}-${COMMIT}.apk" cp -f "$APK" "$STAMP_APK" echo "copied → $STAMP_APK" -echo "$COMMIT" >"$ROOT/out/build-commit.txt" 2>/dev/null || mkdir -p "$ROOT/out" && echo "$COMMIT" >"$ROOT/out/build-commit.txt" +{ + echo "label=$LABEL" + echo "ref=$BRANCH" + echo "commit=$COMMIT" + echo "describe=$HEAD_DESC" + echo "apk=$STAMP_APK" + echo "pkg=$PKG" + echo "task=$task" +} | tee "$ROOT/out/build-${LABEL}.txt" +echo "$COMMIT" >"$ROOT/out/build-commit.txt" echo "$APK" >"$ROOT/out/build-apk-path.txt" -# Same smoke as published F-Droid path (CLI hybrid or GUI) export APK_PATH="$STAMP_APK" -export APK_NAME="$(basename "$STAMP_APK")" +export APK_NAME +APK_NAME="$(basename "$STAMP_APK")" export PKG -export OUT_DIR="${OUT_DIR:-$ROOT/out-source}" +export OUT_DIR="${OUT_DIR:-$ROOT/out-source-$LABEL}" mkdir -p "$OUT_DIR" +cp -f "$ROOT/out/build-${LABEL}.txt" "$OUT_DIR/build-meta.txt" 2>/dev/null || true + SMOKE_CMD="$SMOKE" if [ "${GUI:-0}" = "1" ]; then SMOKE_CMD="$ROOT/run-android-gui-smoke.sh" - OUT_DIR="${OUT_DIR%-source}-source-gui" - export OUT_DIR + case "$OUT_DIR" in + *-gui) ;; + *) OUT_DIR="${OUT_DIR}-gui"; export OUT_DIR; mkdir -p "$OUT_DIR" ;; + esac fi -echo "=== smoke with built APK (PKG=$PKG · GUI=${GUI:-0}) ===" -exec env APK_DIR="$ROOT/apks" APK_NAME="$APK_NAME" APK_PATH="$APK_PATH" PKG="$PKG" OUT_DIR="$OUT_DIR" \ +echo "=== smoke label=$LABEL PKG=$PKG GUI=${GUI:-0} OUT_DIR=$OUT_DIR ===" +# Do not exec: allow matrix wrapper to run multiple variants +set +e +env APK_DIR="$ROOT/apks" APK_NAME="$APK_NAME" APK_PATH="$APK_PATH" PKG="$PKG" OUT_DIR="$OUT_DIR" \ "$SMOKE_CMD" +rc=$? +set -e +echo "=== smoke label=$LABEL exit=$rc ===" +exit "$rc" diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh new file mode 100755 index 0000000..50d31a8 --- /dev/null +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Run Android wallet smoke against **several build variants** so a single +# "weird" developer tree or a bad local build cannot be the only sample. +# +# Default matrix: +# 1) **published** — F-Droid stable APK (bit-for-bit what users install) +# 2) **stable-self** — rebuild the same release **tag** as published (self-build) +# 3) **master** — rebuild origin/master (tip / CI-style) +# +# Why stable-self in addition to published? +# Rare, but a published APK *or* a one-off developer build can be odd +# (wrong flags, dirty tree, wrong ABI). Rebuilding the release tag on *this* +# machine with the same script path as master isolates "our toolchain" vs +# "F-Droid binary" vs "current master source". +# +# Usage: +# ./run-android-variant-matrix.sh +# STACK=goa ./run-android-variant-matrix.sh +# VARIANTS=master,stable-self ./run-android-variant-matrix.sh +# VARIANTS=published,stable-self,master,fix GUI=1 ./run-android-variant-matrix.sh +# STABLE_TAG=wallet-1.6.1 MASTER_BRANCH=master ./run-android-variant-matrix.sh +# +# Env: +# STABLE_TAG default wallet-1.6.1 (must match published F-Droid release) +# STABLE_APK_URL published APK (same as pay-smoke default) +# MASTER_BRANCH default master +# FIX_BRANCH inference fix branch (variant "fix") +# VARIANTS comma list: published,stable-self,master,fix +# GUI=1 use gui-smoke instead of pay-smoke after each build +# FAIL_FAST=1 stop on first non-zero smoke (default 0 = run all) +# MATRIX_OUT summary dir (default out-matrix-) +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib_android_env.sh +. "$ROOT/lib_android_env.sh" + +: "${STABLE_TAG:=wallet-1.6.1}" +: "${STABLE_APK_URL:=https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}" +: "${STABLE_APK_NAME:=net.taler.wallet.fdroid_854.apk}" +: "${MASTER_BRANCH:=master}" +: "${FIX_BRANCH:=dev/hernani-inference/fix-bank-withdraw-auto-exchange}" +: "${VARIANTS:=published,stable-self,master}" +: "${FAIL_FAST:=0}" +: "${GUI:=0}" +: "${STACK:=auto}" + +TS=$(date +%Y%m%d-%H%M%S) +MATRIX_OUT="${MATRIX_OUT:-$ROOT/out-matrix-$TS}" +mkdir -p "$MATRIX_OUT" +SUMMARY="$MATRIX_OUT/SUMMARY.txt" +: >"$SUMMARY" + +echo "=== Android variant matrix ===" | tee -a "$SUMMARY" +echo "STACK=$STACK GUI=$GUI headless=${EMULATOR_HEADLESS} variants=$VARIANTS" | tee -a "$SUMMARY" +echo "STABLE_TAG=$STABLE_TAG MASTER_BRANCH=$MASTER_BRANCH" | tee -a "$SUMMARY" +echo "out: $MATRIX_OUT" | tee -a "$SUMMARY" + +# Ensure device once (headless AVD by default) +android_ensure_device || { + echo "no adb device — start ./start-android-emulator.sh --wait" >&2 + exit 3 +} +export SERIAL ANDROID_SERIAL +echo "device: $SERIAL" | tee -a "$SUMMARY" + +IFS=',' read -r -a VLIST <<<"$VARIANTS" +declare -a RESULTS=() +overall=0 + +run_published() { + local out="$MATRIX_OUT/published" + mkdir -p "$out" + echo "" | tee -a "$SUMMARY" + echo "-------- variant: published (F-Droid stable binary) --------" | tee -a "$SUMMARY" + local smoke="$ROOT/run-android-pay-smoke.sh" + [ "$GUI" = "1" ] && smoke="$ROOT/run-android-gui-smoke.sh" + set +e + env STACK="$STACK" \ + APK_URL="$STABLE_APK_URL" \ + APK_NAME="$STABLE_APK_NAME" \ + APK_PATH="" \ + PKG=net.taler.wallet.fdroid \ + OUT_DIR="$out" \ + SERIAL="$SERIAL" \ + AUTO_START_EMULATOR=0 \ + "$smoke" + local rc=$? + set -e + echo "published exit=$rc" | tee -a "$SUMMARY" + echo "published $rc" >>"$MATRIX_OUT/results.tsv" + return "$rc" +} + +run_selfbuild() { + local label="$1" + local ref="$2" + local out="$MATRIX_OUT/$label" + mkdir -p "$out" + echo "" | tee -a "$SUMMARY" + echo "-------- variant: $label (self-build ref=$ref) --------" | tee -a "$SUMMARY" + set +e + env STACK="$STACK" \ + BRANCH="$ref" \ + LABEL="$label" \ + GUI="$GUI" \ + OUT_DIR="$out" \ + SERIAL="$SERIAL" \ + ANDROID_SERIAL="$SERIAL" \ + AUTO_START_EMULATOR=0 \ + PKG=net.taler.wallet.fdroid.debug \ + "$ROOT/run-android-build-and-smoke.sh" + local rc=$? + set -e + echo "$label (ref=$ref) exit=$rc" | tee -a "$SUMMARY" + echo "$label $rc $ref" >>"$MATRIX_OUT/results.tsv" + if [ -f "$out/build-meta.txt" ]; then + echo "build-meta:" | tee -a "$SUMMARY" + cat "$out/build-meta.txt" | tee -a "$SUMMARY" + fi + return "$rc" +} + +for raw in "${VLIST[@]}"; do + v=$(echo "$raw" | tr -d '[:space:]') + [ -n "$v" ] || continue + rc=0 + case "$v" in + published|fdroid|stable-published) + run_published || rc=$? + ;; + stable-self|stable|self-stable|release-self) + run_selfbuild "stable-self" "$STABLE_TAG" || rc=$? + ;; + master|main) + run_selfbuild "master" "$MASTER_BRANCH" || rc=$? + ;; + fix|fix-branch|inference) + run_selfbuild "fix" "$FIX_BRANCH" || rc=$? + ;; + *) + # treat as raw git ref / label + run_selfbuild "ref-${v//\//-}" "$v" || rc=$? + ;; + esac + RESULTS+=("$v:$rc") + if [ "$rc" -ne 0 ]; then + overall=1 + if [ "$FAIL_FAST" = "1" ]; then + echo "FAIL_FAST: stopping after $v rc=$rc" | tee -a "$SUMMARY" + break + fi + fi +done + +echo "" | tee -a "$SUMMARY" +echo "=== matrix done (overall=$overall) ===" | tee -a "$SUMMARY" +for r in "${RESULTS[@]}"; do + echo " $r" | tee -a "$SUMMARY" +done +echo "" +echo "Compare OUT_DIR trees under $MATRIX_OUT" +echo "Intent: published vs stable-self should behave similarly;" +echo " master may diverge (new features / fixes)." +echo "A odd stable-self vs published suggests toolchain or unsigned-debug differences;" +echo "a odd master-only failure suggests tip breakage, not F-Droid stable." +exit "$overall" From 1a8aad08cdb077a1946668e53bbb6cc5822fb44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:52:40 +0200 Subject: [PATCH 269/326] android-test: gate order stable-self then published; FAIL_FAST default Matrix runs release self-build before F-Droid APK, then master. First failure stops. Emulator cold-boots without snapshots by default. --- scripts/taler-monitoring/android-test/README.md | 14 +++++++++----- .../android-test/lib_android_env.sh | 7 ++++++- .../android-test/run-android-variant-matrix.sh | 13 +++++++------ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 16be18c..fc02faf 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -36,15 +36,19 @@ Runs several smokes so a single odd developer build cannot be the only sample: | Variant | What | |---------|------| -| **published** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | -| **stable-self** | Rebuild tag `wallet-1.6.1` on this host (same source as stable) | -| **master** | Rebuild `origin/master` | +| Order | Variant | What | +|------|---------|------| +| **1 (gate)** | **stable-self** | Rebuild tag `wallet-1.6.1` on this host | +| **2 (gate)** | **published** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | +| **3** | **master** | Rebuild `origin/master` (only if gates pass when `FAIL_FAST=1`) | + +**Default:** `FAIL_FAST=1` — first non-zero gate stops the matrix (no master if stable fails). ```bash ./run-android-variant-matrix.sh STACK=goa ./run-android-variant-matrix.sh -VARIANTS=master,stable-self ./run-android-variant-matrix.sh -VARIANTS=published,stable-self,master,fix GUI=0 ./run-android-variant-matrix.sh +# only the hard gates: +VARIANTS=stable-self,published FAIL_FAST=1 ./run-android-variant-matrix.sh ``` Artifacts + `SUMMARY.txt` under `out-matrix-/`. diff --git a/scripts/taler-monitoring/android-test/lib_android_env.sh b/scripts/taler-monitoring/android-test/lib_android_env.sh index ad209de..670d796 100644 --- a/scripts/taler-monitoring/android-test/lib_android_env.sh +++ b/scripts/taler-monitoring/android-test/lib_android_env.sh @@ -68,8 +68,9 @@ export EMULATOR_GPU : "${EMULATOR_CORES:=2}" # If no adb device: start headless AVD (0 = require phone/manual emulator) : "${AUTO_START_EMULATOR:=1}" -: "${EMULATOR_BOOT_TIMEOUT:=180}" +: "${EMULATOR_BOOT_TIMEOUT:=360}" : "${EMULATOR_LOG:=/tmp/taler-android-emulator.log}" +: "${EMULATOR_NO_SNAPSHOT:=1}" android_adb_serial() { adb devices 2>/dev/null | awk '/\tdevice$/{print $1; exit}' @@ -89,6 +90,10 @@ android_emulator_args() { # Avoid Qt trying to open a display on servers export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}" fi + # Broken default_boot snapshots leave adb offline forever — cold boot by default. + if [ "${EMULATOR_NO_SNAPSHOT:-1}" = "1" ]; then + args+=( -no-snapshot -no-snapshot-load -no-snapshot-save ) + fi if [ -n "${EMULATOR_MEMORY:-}" ]; then args+=( -memory "$EMULATOR_MEMORY" ) fi diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index 50d31a8..a92d739 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -2,10 +2,10 @@ # Run Android wallet smoke against **several build variants** so a single # "weird" developer tree or a bad local build cannot be the only sample. # -# Default matrix: -# 1) **published** — F-Droid stable APK (bit-for-bit what users install) -# 2) **stable-self** — rebuild the same release **tag** as published (self-build) -# 3) **master** — rebuild origin/master (tip / CI-style) +# Default matrix (gate order — fail → hard stop when FAIL_FAST=1): +# 1) **stable-self** — rebuild release **tag** (same source as published) +# 2) **published** — F-Droid stable APK (what users install) +# 3) **master** — rebuild origin/master (only if 1–2 pass with FAIL_FAST) # # Why stable-self in addition to published? # Rare, but a published APK *or* a one-off developer build can be odd @@ -40,8 +40,9 @@ ROOT=$(cd "$(dirname "$0")" && pwd) : "${STABLE_APK_NAME:=net.taler.wallet.fdroid_854.apk}" : "${MASTER_BRANCH:=master}" : "${FIX_BRANCH:=dev/hernani-inference/fix-bank-withdraw-auto-exchange}" -: "${VARIANTS:=published,stable-self,master}" -: "${FAIL_FAST:=0}" +# Gate: stable self-build → published F-Droid; then master. FAIL_FAST default on. +: "${VARIANTS:=stable-self,published,master}" +: "${FAIL_FAST:=1}" : "${GUI:=0}" : "${STACK:=auto}" From a05307f6201027b3f848a8a9ac2b2448907febfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:52:40 +0200 Subject: [PATCH 270/326] android-test: gate order stable-self then published; FAIL_FAST default Matrix runs release self-build before F-Droid APK, then master. First failure stops. Emulator cold-boots without snapshots by default. --- scripts/taler-monitoring/android-test/README.md | 14 +++++++++----- .../android-test/lib_android_env.sh | 7 ++++++- .../android-test/run-android-variant-matrix.sh | 13 +++++++------ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 16be18c..fc02faf 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -36,15 +36,19 @@ Runs several smokes so a single odd developer build cannot be the only sample: | Variant | What | |---------|------| -| **published** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | -| **stable-self** | Rebuild tag `wallet-1.6.1` on this host (same source as stable) | -| **master** | Rebuild `origin/master` | +| Order | Variant | What | +|------|---------|------| +| **1 (gate)** | **stable-self** | Rebuild tag `wallet-1.6.1` on this host | +| **2 (gate)** | **published** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | +| **3** | **master** | Rebuild `origin/master` (only if gates pass when `FAIL_FAST=1`) | + +**Default:** `FAIL_FAST=1` — first non-zero gate stops the matrix (no master if stable fails). ```bash ./run-android-variant-matrix.sh STACK=goa ./run-android-variant-matrix.sh -VARIANTS=master,stable-self ./run-android-variant-matrix.sh -VARIANTS=published,stable-self,master,fix GUI=0 ./run-android-variant-matrix.sh +# only the hard gates: +VARIANTS=stable-self,published FAIL_FAST=1 ./run-android-variant-matrix.sh ``` Artifacts + `SUMMARY.txt` under `out-matrix-/`. diff --git a/scripts/taler-monitoring/android-test/lib_android_env.sh b/scripts/taler-monitoring/android-test/lib_android_env.sh index ad209de..670d796 100644 --- a/scripts/taler-monitoring/android-test/lib_android_env.sh +++ b/scripts/taler-monitoring/android-test/lib_android_env.sh @@ -68,8 +68,9 @@ export EMULATOR_GPU : "${EMULATOR_CORES:=2}" # If no adb device: start headless AVD (0 = require phone/manual emulator) : "${AUTO_START_EMULATOR:=1}" -: "${EMULATOR_BOOT_TIMEOUT:=180}" +: "${EMULATOR_BOOT_TIMEOUT:=360}" : "${EMULATOR_LOG:=/tmp/taler-android-emulator.log}" +: "${EMULATOR_NO_SNAPSHOT:=1}" android_adb_serial() { adb devices 2>/dev/null | awk '/\tdevice$/{print $1; exit}' @@ -89,6 +90,10 @@ android_emulator_args() { # Avoid Qt trying to open a display on servers export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}" fi + # Broken default_boot snapshots leave adb offline forever — cold boot by default. + if [ "${EMULATOR_NO_SNAPSHOT:-1}" = "1" ]; then + args+=( -no-snapshot -no-snapshot-load -no-snapshot-save ) + fi if [ -n "${EMULATOR_MEMORY:-}" ]; then args+=( -memory "$EMULATOR_MEMORY" ) fi diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index 50d31a8..a92d739 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -2,10 +2,10 @@ # Run Android wallet smoke against **several build variants** so a single # "weird" developer tree or a bad local build cannot be the only sample. # -# Default matrix: -# 1) **published** — F-Droid stable APK (bit-for-bit what users install) -# 2) **stable-self** — rebuild the same release **tag** as published (self-build) -# 3) **master** — rebuild origin/master (tip / CI-style) +# Default matrix (gate order — fail → hard stop when FAIL_FAST=1): +# 1) **stable-self** — rebuild release **tag** (same source as published) +# 2) **published** — F-Droid stable APK (what users install) +# 3) **master** — rebuild origin/master (only if 1–2 pass with FAIL_FAST) # # Why stable-self in addition to published? # Rare, but a published APK *or* a one-off developer build can be odd @@ -40,8 +40,9 @@ ROOT=$(cd "$(dirname "$0")" && pwd) : "${STABLE_APK_NAME:=net.taler.wallet.fdroid_854.apk}" : "${MASTER_BRANCH:=master}" : "${FIX_BRANCH:=dev/hernani-inference/fix-bank-withdraw-auto-exchange}" -: "${VARIANTS:=published,stable-self,master}" -: "${FAIL_FAST:=0}" +# Gate: stable self-build → published F-Droid; then master. FAIL_FAST default on. +: "${VARIANTS:=stable-self,published,master}" +: "${FAIL_FAST:=1}" : "${GUI:=0}" : "${STACK:=auto}" From 64b7ba2b2a4f196ebd67d00569b24cdd1c81b138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:53:04 +0200 Subject: [PATCH 271/326] android-test: SMOKE_STRICT hard-fail on ANR / missing withdraw core Matrix gates set SMOKE_STRICT=1 so stable-self and published cannot pass on install-only when System UI ANRs or wallet-core never sees the URI. --- .../android-test/run-android-pay-smoke.sh | 15 ++++++++++++++- .../android-test/run-android-variant-matrix.sh | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh index ef2ca8e..a5a85b5 100755 --- a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh @@ -184,14 +184,26 @@ fi grep -iE 'taler-wallet|prepareBank|preparePay|confirm|error|Error|success' "$OUT_DIR/logcat.txt" \ | tail -80 >"$OUT_DIR/logcat-taler.txt" || true +# SMOKE_STRICT=1 (matrix gate default): ANR or missing wallet-core withdraw = hard fail +: "${SMOKE_STRICT:=0}" +rc=0 echo echo "=== summary ===" echo "package installed: $PKG" echo "withdraw intent delivered: $WURI" +if grep -qiE "isn't responding|isn.t responding|System UI isn" "$OUT_DIR/01-ui.xml" 2>/dev/null \ + || grep -qiE "isn't responding|not responding" "$OUT_DIR/logcat.txt" 2>/dev/null; then + echo "FAIL: System UI / app ANR (host too small or emulator hung)" + rc=10 +fi if grep -q 'prepareBankIntegratedWithdrawal' "$OUT_DIR/logcat.txt" 2>/dev/null; then echo "wallet-core: prepareBankIntegratedWithdrawal seen (wallet accepted withdraw URI)" else echo "wallet-core: prepareBankIntegratedWithdrawal NOT seen (UI may still show onboarding/ANR on small RAM)" + if [ "$SMOKE_STRICT" = "1" ]; then + echo "FAIL: SMOKE_STRICT=1 requires prepareBankIntegratedWithdrawal in logcat" + rc=11 + fi fi if [ -n "$PAY_URI" ]; then if grep -qiE 'preparePay|confirmPay|pay-template|preparePurchase' "$OUT_DIR/logcat.txt" 2>/dev/null; then @@ -204,4 +216,5 @@ echo "artifacts: $OUT_DIR/" ls -la "$OUT_DIR" | sed 's/^/ /' echo echo "NOTE: Full unattended UI payment needs a responsive device/emulator (≥6–8 GiB host RAM recommended)." -echo "This smoke proves: published APK install + deep-link into wallet-core on stack=$STACK." +echo "This smoke proves: APK install + deep-link delivery on stack=$STACK (strict=$SMOKE_STRICT rc=$rc)." +exit "$rc" diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index a92d739..3bb3e07 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -85,6 +85,7 @@ run_published() { OUT_DIR="$out" \ SERIAL="$SERIAL" \ AUTO_START_EMULATOR=0 \ + SMOKE_STRICT="${SMOKE_STRICT:-1}" \ "$smoke" local rc=$? set -e @@ -110,6 +111,7 @@ run_selfbuild() { ANDROID_SERIAL="$SERIAL" \ AUTO_START_EMULATOR=0 \ PKG=net.taler.wallet.fdroid.debug \ + SMOKE_STRICT="${SMOKE_STRICT:-1}" \ "$ROOT/run-android-build-and-smoke.sh" local rc=$? set -e From 2a2e4787250dc38f69d796d8eee8424945867ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:53:04 +0200 Subject: [PATCH 272/326] android-test: SMOKE_STRICT hard-fail on ANR / missing withdraw core Matrix gates set SMOKE_STRICT=1 so stable-self and published cannot pass on install-only when System UI ANRs or wallet-core never sees the URI. --- .../android-test/run-android-pay-smoke.sh | 15 ++++++++++++++- .../android-test/run-android-variant-matrix.sh | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh index ef2ca8e..a5a85b5 100755 --- a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh @@ -184,14 +184,26 @@ fi grep -iE 'taler-wallet|prepareBank|preparePay|confirm|error|Error|success' "$OUT_DIR/logcat.txt" \ | tail -80 >"$OUT_DIR/logcat-taler.txt" || true +# SMOKE_STRICT=1 (matrix gate default): ANR or missing wallet-core withdraw = hard fail +: "${SMOKE_STRICT:=0}" +rc=0 echo echo "=== summary ===" echo "package installed: $PKG" echo "withdraw intent delivered: $WURI" +if grep -qiE "isn't responding|isn.t responding|System UI isn" "$OUT_DIR/01-ui.xml" 2>/dev/null \ + || grep -qiE "isn't responding|not responding" "$OUT_DIR/logcat.txt" 2>/dev/null; then + echo "FAIL: System UI / app ANR (host too small or emulator hung)" + rc=10 +fi if grep -q 'prepareBankIntegratedWithdrawal' "$OUT_DIR/logcat.txt" 2>/dev/null; then echo "wallet-core: prepareBankIntegratedWithdrawal seen (wallet accepted withdraw URI)" else echo "wallet-core: prepareBankIntegratedWithdrawal NOT seen (UI may still show onboarding/ANR on small RAM)" + if [ "$SMOKE_STRICT" = "1" ]; then + echo "FAIL: SMOKE_STRICT=1 requires prepareBankIntegratedWithdrawal in logcat" + rc=11 + fi fi if [ -n "$PAY_URI" ]; then if grep -qiE 'preparePay|confirmPay|pay-template|preparePurchase' "$OUT_DIR/logcat.txt" 2>/dev/null; then @@ -204,4 +216,5 @@ echo "artifacts: $OUT_DIR/" ls -la "$OUT_DIR" | sed 's/^/ /' echo echo "NOTE: Full unattended UI payment needs a responsive device/emulator (≥6–8 GiB host RAM recommended)." -echo "This smoke proves: published APK install + deep-link into wallet-core on stack=$STACK." +echo "This smoke proves: APK install + deep-link delivery on stack=$STACK (strict=$SMOKE_STRICT rc=$rc)." +exit "$rc" diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index a92d739..3bb3e07 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -85,6 +85,7 @@ run_published() { OUT_DIR="$out" \ SERIAL="$SERIAL" \ AUTO_START_EMULATOR=0 \ + SMOKE_STRICT="${SMOKE_STRICT:-1}" \ "$smoke" local rc=$? set -e @@ -110,6 +111,7 @@ run_selfbuild() { ANDROID_SERIAL="$SERIAL" \ AUTO_START_EMULATOR=0 \ PKG=net.taler.wallet.fdroid.debug \ + SMOKE_STRICT="${SMOKE_STRICT:-1}" \ "$ROOT/run-android-build-and-smoke.sh" local rc=$? set -e From 910987731b79dc3761b738cb5d15ec2f55930d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:53:53 +0200 Subject: [PATCH 273/326] android-test: stable-self failures are WARN only, not blockers Published F-Droid remains the hard gate (FAIL_FAST). Self-build of the release tag continues the matrix and does not alone fail overall exit. --- .../android-test/GUI-AUTOMATION-NOTES.md | 14 +-- .../taler-monitoring/android-test/README.md | 14 +-- .../run-android-variant-matrix.sh | 107 +++++++++++++----- 3 files changed, 91 insertions(+), 44 deletions(-) diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 16023eb..836526d 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -243,14 +243,14 @@ Scripts **today** implement Android only; they should honour: ### Build variants (anti–“weird local build”) -| Variant | Source | Package | Purpose | -|---------|--------|---------|---------| -| **published** | F-Droid APK URL | `net.taler.wallet.fdroid` | What users install | -| **stable-self** | tag `wallet-1.6.1` (override `STABLE_TAG`) rebuilt here | `…fdroid.debug` | Same release tree as stable, **our** Gradle — excludes odd publisher-only or dirty-tree builds | -| **master** | `origin/master` rebuilt here | `…fdroid.debug` | Tip of mainline | -| **fix** (optional) | inference fix branch | `…fdroid.debug` | GOA automation fixes | +| Variant | Source | Severity | Purpose | +|---------|--------|----------|---------| +| **stable-self** | tag `wallet-1.6.1` rebuilt here | **WARN only** | Same release tree, our Gradle — never hard-blocks the matrix | +| **published** | F-Droid APK URL | **BLOCKER** | What users install; FAIL_FAST stops here | +| **master** | `origin/master` | soft fail | Tip of mainline | +| **fix** (optional) | inference fix branch | soft fail | GOA automation fixes | -Default matrix: `published,stable-self,master`. +Default matrix order: `stable-self,published,master`. `./run-android-variant-matrix.sh` — see README. --- diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index fc02faf..3ab8d6f 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -36,18 +36,18 @@ Runs several smokes so a single odd developer build cannot be the only sample: | Variant | What | |---------|------| -| Order | Variant | What | -|------|---------|------| -| **1 (gate)** | **stable-self** | Rebuild tag `wallet-1.6.1` on this host | -| **2 (gate)** | **published** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | -| **3** | **master** | Rebuild `origin/master` (only if gates pass when `FAIL_FAST=1`) | +| Order | Variant | Severity | What | +|------|---------|----------|------| +| **1** | **stable-self** | **WARN only** (not a blocker) | Rebuild tag `wallet-1.6.1` on this host | +| **2** | **published** | **BLOCKER** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | +| **3** | **master** | fail counts / FAIL_FAST | Rebuild `origin/master` | -**Default:** `FAIL_FAST=1` — first non-zero gate stops the matrix (no master if stable fails). +**Default:** `FAIL_FAST=1` only stops on **blocker** failures (`published`). +`stable-self` failure → warning in `SUMMARY.txt`, matrix continues, exit code unaffected by that warn alone. ```bash ./run-android-variant-matrix.sh STACK=goa ./run-android-variant-matrix.sh -# only the hard gates: VARIANTS=stable-self,published FAIL_FAST=1 ./run-android-variant-matrix.sh ``` diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index 3bb3e07..9f80700 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -2,22 +2,23 @@ # Run Android wallet smoke against **several build variants** so a single # "weird" developer tree or a bad local build cannot be the only sample. # -# Default matrix (gate order — fail → hard stop when FAIL_FAST=1): +# Default matrix order: # 1) **stable-self** — rebuild release **tag** (same source as published) +# Failure → **WARN only** (not a hard blocker; continue matrix) # 2) **published** — F-Droid stable APK (what users install) -# 3) **master** — rebuild origin/master (only if 1–2 pass with FAIL_FAST) +# Failure → **BLOCKER** (matrix exit ≠ 0; FAIL_FAST stops here) +# 3) **master** — rebuild origin/master +# Failure → counts toward overall fail; does not skip earlier gates # # Why stable-self in addition to published? -# Rare, but a published APK *or* a one-off developer build can be odd -# (wrong flags, dirty tree, wrong ABI). Rebuilding the release tag on *this* -# machine with the same script path as master isolates "our toolchain" vs -# "F-Droid binary" vs "current master source". +# Isolates "our toolchain rebuild of the release tag" vs "F-Droid binary". +# Rebuild issues (signing flavor, dirty host, low-RAM ANR on debug) must not +# block the published-stable gate — hence warn-only for stable-self. # # Usage: # ./run-android-variant-matrix.sh # STACK=goa ./run-android-variant-matrix.sh # VARIANTS=master,stable-self ./run-android-variant-matrix.sh -# VARIANTS=published,stable-self,master,fix GUI=1 ./run-android-variant-matrix.sh # STABLE_TAG=wallet-1.6.1 MASTER_BRANCH=master ./run-android-variant-matrix.sh # # Env: @@ -27,7 +28,7 @@ # FIX_BRANCH inference fix branch (variant "fix") # VARIANTS comma list: published,stable-self,master,fix # GUI=1 use gui-smoke instead of pay-smoke after each build -# FAIL_FAST=1 stop on first non-zero smoke (default 0 = run all) +# FAIL_FAST=1 stop on first **blocker** failure (default 1) # MATRIX_OUT summary dir (default out-matrix-) # set -euo pipefail @@ -40,7 +41,7 @@ ROOT=$(cd "$(dirname "$0")" && pwd) : "${STABLE_APK_NAME:=net.taler.wallet.fdroid_854.apk}" : "${MASTER_BRANCH:=master}" : "${FIX_BRANCH:=dev/hernani-inference/fix-bank-withdraw-auto-exchange}" -# Gate: stable self-build → published F-Droid; then master. FAIL_FAST default on. +# Order: stable-self (warn) → published (blocker) → master. : "${VARIANTS:=stable-self,published,master}" : "${FAIL_FAST:=1}" : "${GUI:=0}" @@ -55,6 +56,7 @@ SUMMARY="$MATRIX_OUT/SUMMARY.txt" echo "=== Android variant matrix ===" | tee -a "$SUMMARY" echo "STACK=$STACK GUI=$GUI headless=${EMULATOR_HEADLESS} variants=$VARIANTS" | tee -a "$SUMMARY" echo "STABLE_TAG=$STABLE_TAG MASTER_BRANCH=$MASTER_BRANCH" | tee -a "$SUMMARY" +echo "severity: stable-self=WARN published=BLOCKER master=fail-count" | tee -a "$SUMMARY" echo "out: $MATRIX_OUT" | tee -a "$SUMMARY" # Ensure device once (headless AVD by default) @@ -67,13 +69,26 @@ echo "device: $SERIAL" | tee -a "$SUMMARY" IFS=',' read -r -a VLIST <<<"$VARIANTS" declare -a RESULTS=() +declare -a WARNINGS=() overall=0 +# Severity: warn | blocker | soft +# warn — log WARN, never FAIL_FAST, never overall≠0 +# blocker — FAIL_FAST eligible; overall≠0 +# soft — overall≠0 on fail, but FAIL_FAST only if FAIL_FAST=1 and not warn +variant_severity() { + case "$1" in + stable-self|stable|self-stable|release-self) echo warn ;; + published|fdroid|stable-published) echo blocker ;; + *) echo soft ;; + esac +} + run_published() { local out="$MATRIX_OUT/published" mkdir -p "$out" echo "" | tee -a "$SUMMARY" - echo "-------- variant: published (F-Droid stable binary) --------" | tee -a "$SUMMARY" + echo "-------- variant: published (F-Droid stable binary) [BLOCKER] --------" | tee -a "$SUMMARY" local smoke="$ROOT/run-android-pay-smoke.sh" [ "$GUI" = "1" ] && smoke="$ROOT/run-android-gui-smoke.sh" set +e @@ -90,17 +105,23 @@ run_published() { local rc=$? set -e echo "published exit=$rc" | tee -a "$SUMMARY" - echo "published $rc" >>"$MATRIX_OUT/results.tsv" + echo "published $rc blocker" >>"$MATRIX_OUT/results.tsv" return "$rc" } run_selfbuild() { local label="$1" local ref="$2" + local sev="${3:-soft}" local out="$MATRIX_OUT/$label" mkdir -p "$out" echo "" | tee -a "$SUMMARY" - echo "-------- variant: $label (self-build ref=$ref) --------" | tee -a "$SUMMARY" + echo "-------- variant: $label (self-build ref=$ref) [$(echo "$sev" | tr 'a-z' 'A-Z')] --------" | tee -a "$SUMMARY" + # Warn-only self-build: still collect evidence; SMOKE_STRICT optional (default off for warn) + local strict="${SMOKE_STRICT:-1}" + if [ "$sev" = "warn" ]; then + strict="${SMOKE_STRICT_SELF:-0}" + fi set +e env STACK="$STACK" \ BRANCH="$ref" \ @@ -111,12 +132,12 @@ run_selfbuild() { ANDROID_SERIAL="$SERIAL" \ AUTO_START_EMULATOR=0 \ PKG=net.taler.wallet.fdroid.debug \ - SMOKE_STRICT="${SMOKE_STRICT:-1}" \ + SMOKE_STRICT="$strict" \ "$ROOT/run-android-build-and-smoke.sh" local rc=$? set -e - echo "$label (ref=$ref) exit=$rc" | tee -a "$SUMMARY" - echo "$label $rc $ref" >>"$MATRIX_OUT/results.tsv" + echo "$label (ref=$ref) exit=$rc severity=$sev" | tee -a "$SUMMARY" + echo "$label $rc $ref $sev" >>"$MATRIX_OUT/results.tsv" if [ -f "$out/build-meta.txt" ]; then echo "build-meta:" | tee -a "$SUMMARY" cat "$out/build-meta.txt" | tee -a "$SUMMARY" @@ -128,31 +149,54 @@ for raw in "${VLIST[@]}"; do v=$(echo "$raw" | tr -d '[:space:]') [ -n "$v" ] || continue rc=0 + sev=$(variant_severity "$v") case "$v" in published|fdroid|stable-published) run_published || rc=$? ;; stable-self|stable|self-stable|release-self) - run_selfbuild "stable-self" "$STABLE_TAG" || rc=$? + run_selfbuild "stable-self" "$STABLE_TAG" warn || rc=$? ;; master|main) - run_selfbuild "master" "$MASTER_BRANCH" || rc=$? + run_selfbuild "master" "$MASTER_BRANCH" soft || rc=$? ;; fix|fix-branch|inference) - run_selfbuild "fix" "$FIX_BRANCH" || rc=$? + run_selfbuild "fix" "$FIX_BRANCH" soft || rc=$? ;; *) - # treat as raw git ref / label - run_selfbuild "ref-${v//\//-}" "$v" || rc=$? + run_selfbuild "ref-${v//\//-}" "$v" soft || rc=$? ;; esac - RESULTS+=("$v:$rc") + if [ "$rc" -ne 0 ]; then - overall=1 - if [ "$FAIL_FAST" = "1" ]; then - echo "FAIL_FAST: stopping after $v rc=$rc" | tee -a "$SUMMARY" - break - fi + case "$sev" in + warn) + echo "WARN: variant $v failed rc=$rc — self-build only, NOT a blocker; continuing" | tee -a "$SUMMARY" + WARNINGS+=("$v:$rc") + RESULTS+=("$v:WARN($rc)") + # do not set overall; do not FAIL_FAST + ;; + blocker) + echo "BLOCKER: variant $v failed rc=$rc" | tee -a "$SUMMARY" + RESULTS+=("$v:BLOCKER($rc)") + overall=1 + if [ "$FAIL_FAST" = "1" ]; then + echo "FAIL_FAST: stopping after blocker $v" | tee -a "$SUMMARY" + break + fi + ;; + soft) + echo "FAIL: variant $v rc=$rc (counts toward overall; not stable-self)" | tee -a "$SUMMARY" + RESULTS+=("$v:FAIL($rc)") + overall=1 + if [ "$FAIL_FAST" = "1" ]; then + echo "FAIL_FAST: stopping after $v rc=$rc" | tee -a "$SUMMARY" + break + fi + ;; + esac + else + RESULTS+=("$v:OK") fi done @@ -161,10 +205,13 @@ echo "=== matrix done (overall=$overall) ===" | tee -a "$SUMMARY" for r in "${RESULTS[@]}"; do echo " $r" | tee -a "$SUMMARY" done +if [ "${#WARNINGS[@]}" -gt 0 ]; then + echo "warnings (non-blocking):" | tee -a "$SUMMARY" + for w in "${WARNINGS[@]}"; do + echo " WARN $w" | tee -a "$SUMMARY" + done +fi echo "" echo "Compare OUT_DIR trees under $MATRIX_OUT" -echo "Intent: published vs stable-self should behave similarly;" -echo " master may diverge (new features / fixes)." -echo "A odd stable-self vs published suggests toolchain or unsigned-debug differences;" -echo "a odd master-only failure suggests tip breakage, not F-Droid stable." +echo "stable-self failure = WARN only; published failure = BLOCKER." exit "$overall" From 8a49e51cbdb9b5713e5e71e84c35ce6140dab35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:53:53 +0200 Subject: [PATCH 274/326] android-test: stable-self failures are WARN only, not blockers Published F-Droid remains the hard gate (FAIL_FAST). Self-build of the release tag continues the matrix and does not alone fail overall exit. --- .../android-test/GUI-AUTOMATION-NOTES.md | 14 +-- .../taler-monitoring/android-test/README.md | 14 +-- .../run-android-variant-matrix.sh | 107 +++++++++++++----- 3 files changed, 91 insertions(+), 44 deletions(-) diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 16023eb..836526d 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -243,14 +243,14 @@ Scripts **today** implement Android only; they should honour: ### Build variants (anti–“weird local build”) -| Variant | Source | Package | Purpose | -|---------|--------|---------|---------| -| **published** | F-Droid APK URL | `net.taler.wallet.fdroid` | What users install | -| **stable-self** | tag `wallet-1.6.1` (override `STABLE_TAG`) rebuilt here | `…fdroid.debug` | Same release tree as stable, **our** Gradle — excludes odd publisher-only or dirty-tree builds | -| **master** | `origin/master` rebuilt here | `…fdroid.debug` | Tip of mainline | -| **fix** (optional) | inference fix branch | `…fdroid.debug` | GOA automation fixes | +| Variant | Source | Severity | Purpose | +|---------|--------|----------|---------| +| **stable-self** | tag `wallet-1.6.1` rebuilt here | **WARN only** | Same release tree, our Gradle — never hard-blocks the matrix | +| **published** | F-Droid APK URL | **BLOCKER** | What users install; FAIL_FAST stops here | +| **master** | `origin/master` | soft fail | Tip of mainline | +| **fix** (optional) | inference fix branch | soft fail | GOA automation fixes | -Default matrix: `published,stable-self,master`. +Default matrix order: `stable-self,published,master`. `./run-android-variant-matrix.sh` — see README. --- diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index fc02faf..3ab8d6f 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -36,18 +36,18 @@ Runs several smokes so a single odd developer build cannot be the only sample: | Variant | What | |---------|------| -| Order | Variant | What | -|------|---------|------| -| **1 (gate)** | **stable-self** | Rebuild tag `wallet-1.6.1` on this host | -| **2 (gate)** | **published** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | -| **3** | **master** | Rebuild `origin/master` (only if gates pass when `FAIL_FAST=1`) | +| Order | Variant | Severity | What | +|------|---------|----------|------| +| **1** | **stable-self** | **WARN only** (not a blocker) | Rebuild tag `wallet-1.6.1` on this host | +| **2** | **published** | **BLOCKER** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | +| **3** | **master** | fail counts / FAIL_FAST | Rebuild `origin/master` | -**Default:** `FAIL_FAST=1` — first non-zero gate stops the matrix (no master if stable fails). +**Default:** `FAIL_FAST=1` only stops on **blocker** failures (`published`). +`stable-self` failure → warning in `SUMMARY.txt`, matrix continues, exit code unaffected by that warn alone. ```bash ./run-android-variant-matrix.sh STACK=goa ./run-android-variant-matrix.sh -# only the hard gates: VARIANTS=stable-self,published FAIL_FAST=1 ./run-android-variant-matrix.sh ``` diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index 3bb3e07..9f80700 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -2,22 +2,23 @@ # Run Android wallet smoke against **several build variants** so a single # "weird" developer tree or a bad local build cannot be the only sample. # -# Default matrix (gate order — fail → hard stop when FAIL_FAST=1): +# Default matrix order: # 1) **stable-self** — rebuild release **tag** (same source as published) +# Failure → **WARN only** (not a hard blocker; continue matrix) # 2) **published** — F-Droid stable APK (what users install) -# 3) **master** — rebuild origin/master (only if 1–2 pass with FAIL_FAST) +# Failure → **BLOCKER** (matrix exit ≠ 0; FAIL_FAST stops here) +# 3) **master** — rebuild origin/master +# Failure → counts toward overall fail; does not skip earlier gates # # Why stable-self in addition to published? -# Rare, but a published APK *or* a one-off developer build can be odd -# (wrong flags, dirty tree, wrong ABI). Rebuilding the release tag on *this* -# machine with the same script path as master isolates "our toolchain" vs -# "F-Droid binary" vs "current master source". +# Isolates "our toolchain rebuild of the release tag" vs "F-Droid binary". +# Rebuild issues (signing flavor, dirty host, low-RAM ANR on debug) must not +# block the published-stable gate — hence warn-only for stable-self. # # Usage: # ./run-android-variant-matrix.sh # STACK=goa ./run-android-variant-matrix.sh # VARIANTS=master,stable-self ./run-android-variant-matrix.sh -# VARIANTS=published,stable-self,master,fix GUI=1 ./run-android-variant-matrix.sh # STABLE_TAG=wallet-1.6.1 MASTER_BRANCH=master ./run-android-variant-matrix.sh # # Env: @@ -27,7 +28,7 @@ # FIX_BRANCH inference fix branch (variant "fix") # VARIANTS comma list: published,stable-self,master,fix # GUI=1 use gui-smoke instead of pay-smoke after each build -# FAIL_FAST=1 stop on first non-zero smoke (default 0 = run all) +# FAIL_FAST=1 stop on first **blocker** failure (default 1) # MATRIX_OUT summary dir (default out-matrix-) # set -euo pipefail @@ -40,7 +41,7 @@ ROOT=$(cd "$(dirname "$0")" && pwd) : "${STABLE_APK_NAME:=net.taler.wallet.fdroid_854.apk}" : "${MASTER_BRANCH:=master}" : "${FIX_BRANCH:=dev/hernani-inference/fix-bank-withdraw-auto-exchange}" -# Gate: stable self-build → published F-Droid; then master. FAIL_FAST default on. +# Order: stable-self (warn) → published (blocker) → master. : "${VARIANTS:=stable-self,published,master}" : "${FAIL_FAST:=1}" : "${GUI:=0}" @@ -55,6 +56,7 @@ SUMMARY="$MATRIX_OUT/SUMMARY.txt" echo "=== Android variant matrix ===" | tee -a "$SUMMARY" echo "STACK=$STACK GUI=$GUI headless=${EMULATOR_HEADLESS} variants=$VARIANTS" | tee -a "$SUMMARY" echo "STABLE_TAG=$STABLE_TAG MASTER_BRANCH=$MASTER_BRANCH" | tee -a "$SUMMARY" +echo "severity: stable-self=WARN published=BLOCKER master=fail-count" | tee -a "$SUMMARY" echo "out: $MATRIX_OUT" | tee -a "$SUMMARY" # Ensure device once (headless AVD by default) @@ -67,13 +69,26 @@ echo "device: $SERIAL" | tee -a "$SUMMARY" IFS=',' read -r -a VLIST <<<"$VARIANTS" declare -a RESULTS=() +declare -a WARNINGS=() overall=0 +# Severity: warn | blocker | soft +# warn — log WARN, never FAIL_FAST, never overall≠0 +# blocker — FAIL_FAST eligible; overall≠0 +# soft — overall≠0 on fail, but FAIL_FAST only if FAIL_FAST=1 and not warn +variant_severity() { + case "$1" in + stable-self|stable|self-stable|release-self) echo warn ;; + published|fdroid|stable-published) echo blocker ;; + *) echo soft ;; + esac +} + run_published() { local out="$MATRIX_OUT/published" mkdir -p "$out" echo "" | tee -a "$SUMMARY" - echo "-------- variant: published (F-Droid stable binary) --------" | tee -a "$SUMMARY" + echo "-------- variant: published (F-Droid stable binary) [BLOCKER] --------" | tee -a "$SUMMARY" local smoke="$ROOT/run-android-pay-smoke.sh" [ "$GUI" = "1" ] && smoke="$ROOT/run-android-gui-smoke.sh" set +e @@ -90,17 +105,23 @@ run_published() { local rc=$? set -e echo "published exit=$rc" | tee -a "$SUMMARY" - echo "published $rc" >>"$MATRIX_OUT/results.tsv" + echo "published $rc blocker" >>"$MATRIX_OUT/results.tsv" return "$rc" } run_selfbuild() { local label="$1" local ref="$2" + local sev="${3:-soft}" local out="$MATRIX_OUT/$label" mkdir -p "$out" echo "" | tee -a "$SUMMARY" - echo "-------- variant: $label (self-build ref=$ref) --------" | tee -a "$SUMMARY" + echo "-------- variant: $label (self-build ref=$ref) [$(echo "$sev" | tr 'a-z' 'A-Z')] --------" | tee -a "$SUMMARY" + # Warn-only self-build: still collect evidence; SMOKE_STRICT optional (default off for warn) + local strict="${SMOKE_STRICT:-1}" + if [ "$sev" = "warn" ]; then + strict="${SMOKE_STRICT_SELF:-0}" + fi set +e env STACK="$STACK" \ BRANCH="$ref" \ @@ -111,12 +132,12 @@ run_selfbuild() { ANDROID_SERIAL="$SERIAL" \ AUTO_START_EMULATOR=0 \ PKG=net.taler.wallet.fdroid.debug \ - SMOKE_STRICT="${SMOKE_STRICT:-1}" \ + SMOKE_STRICT="$strict" \ "$ROOT/run-android-build-and-smoke.sh" local rc=$? set -e - echo "$label (ref=$ref) exit=$rc" | tee -a "$SUMMARY" - echo "$label $rc $ref" >>"$MATRIX_OUT/results.tsv" + echo "$label (ref=$ref) exit=$rc severity=$sev" | tee -a "$SUMMARY" + echo "$label $rc $ref $sev" >>"$MATRIX_OUT/results.tsv" if [ -f "$out/build-meta.txt" ]; then echo "build-meta:" | tee -a "$SUMMARY" cat "$out/build-meta.txt" | tee -a "$SUMMARY" @@ -128,31 +149,54 @@ for raw in "${VLIST[@]}"; do v=$(echo "$raw" | tr -d '[:space:]') [ -n "$v" ] || continue rc=0 + sev=$(variant_severity "$v") case "$v" in published|fdroid|stable-published) run_published || rc=$? ;; stable-self|stable|self-stable|release-self) - run_selfbuild "stable-self" "$STABLE_TAG" || rc=$? + run_selfbuild "stable-self" "$STABLE_TAG" warn || rc=$? ;; master|main) - run_selfbuild "master" "$MASTER_BRANCH" || rc=$? + run_selfbuild "master" "$MASTER_BRANCH" soft || rc=$? ;; fix|fix-branch|inference) - run_selfbuild "fix" "$FIX_BRANCH" || rc=$? + run_selfbuild "fix" "$FIX_BRANCH" soft || rc=$? ;; *) - # treat as raw git ref / label - run_selfbuild "ref-${v//\//-}" "$v" || rc=$? + run_selfbuild "ref-${v//\//-}" "$v" soft || rc=$? ;; esac - RESULTS+=("$v:$rc") + if [ "$rc" -ne 0 ]; then - overall=1 - if [ "$FAIL_FAST" = "1" ]; then - echo "FAIL_FAST: stopping after $v rc=$rc" | tee -a "$SUMMARY" - break - fi + case "$sev" in + warn) + echo "WARN: variant $v failed rc=$rc — self-build only, NOT a blocker; continuing" | tee -a "$SUMMARY" + WARNINGS+=("$v:$rc") + RESULTS+=("$v:WARN($rc)") + # do not set overall; do not FAIL_FAST + ;; + blocker) + echo "BLOCKER: variant $v failed rc=$rc" | tee -a "$SUMMARY" + RESULTS+=("$v:BLOCKER($rc)") + overall=1 + if [ "$FAIL_FAST" = "1" ]; then + echo "FAIL_FAST: stopping after blocker $v" | tee -a "$SUMMARY" + break + fi + ;; + soft) + echo "FAIL: variant $v rc=$rc (counts toward overall; not stable-self)" | tee -a "$SUMMARY" + RESULTS+=("$v:FAIL($rc)") + overall=1 + if [ "$FAIL_FAST" = "1" ]; then + echo "FAIL_FAST: stopping after $v rc=$rc" | tee -a "$SUMMARY" + break + fi + ;; + esac + else + RESULTS+=("$v:OK") fi done @@ -161,10 +205,13 @@ echo "=== matrix done (overall=$overall) ===" | tee -a "$SUMMARY" for r in "${RESULTS[@]}"; do echo " $r" | tee -a "$SUMMARY" done +if [ "${#WARNINGS[@]}" -gt 0 ]; then + echo "warnings (non-blocking):" | tee -a "$SUMMARY" + for w in "${WARNINGS[@]}"; do + echo " WARN $w" | tee -a "$SUMMARY" + done +fi echo "" echo "Compare OUT_DIR trees under $MATRIX_OUT" -echo "Intent: published vs stable-self should behave similarly;" -echo " master may diverge (new features / fixes)." -echo "A odd stable-self vs published suggests toolchain or unsigned-debug differences;" -echo "a odd master-only failure suggests tip breakage, not F-Droid stable." +echo "stable-self failure = WARN only; published failure = BLOCKER." exit "$overall" From 379baf73e8e90260b66d21c10a6e96223238fe53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:54:17 +0200 Subject: [PATCH 275/326] android-test: master self-build failures are WARN only too Only published F-Droid remains a matrix blocker; master tip issues warn and continue without failing overall exit alone. --- .../android-test/GUI-AUTOMATION-NOTES.md | 4 ++-- .../taler-monitoring/android-test/README.md | 6 ++--- .../run-android-variant-matrix.sh | 22 ++++++++++--------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 836526d..798bb7d 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -245,9 +245,9 @@ Scripts **today** implement Android only; they should honour: | Variant | Source | Severity | Purpose | |---------|--------|----------|---------| -| **stable-self** | tag `wallet-1.6.1` rebuilt here | **WARN only** | Same release tree, our Gradle — never hard-blocks the matrix | +| **stable-self** | tag `wallet-1.6.1` rebuilt here | **WARN only** | Same release tree, our Gradle — never hard-blocks | | **published** | F-Droid APK URL | **BLOCKER** | What users install; FAIL_FAST stops here | -| **master** | `origin/master` | soft fail | Tip of mainline | +| **master** | `origin/master` | **WARN only** | Tip of mainline — never hard-blocks | | **fix** (optional) | inference fix branch | soft fail | GOA automation fixes | Default matrix order: `stable-self,published,master`. diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 3ab8d6f..595a187 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -38,12 +38,12 @@ Runs several smokes so a single odd developer build cannot be the only sample: |---------|------| | Order | Variant | Severity | What | |------|---------|----------|------| -| **1** | **stable-self** | **WARN only** (not a blocker) | Rebuild tag `wallet-1.6.1` on this host | +| **1** | **stable-self** | **WARN only** | Rebuild tag `wallet-1.6.1` on this host | | **2** | **published** | **BLOCKER** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | -| **3** | **master** | fail counts / FAIL_FAST | Rebuild `origin/master` | +| **3** | **master** | **WARN only** | Rebuild `origin/master` | **Default:** `FAIL_FAST=1` only stops on **blocker** failures (`published`). -`stable-self` failure → warning in `SUMMARY.txt`, matrix continues, exit code unaffected by that warn alone. +`stable-self` / `master` failure → warning in `SUMMARY.txt`, matrix continues, exit code unaffected by those warns alone. ```bash ./run-android-variant-matrix.sh diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index 9f80700..baabbf3 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -8,12 +8,12 @@ # 2) **published** — F-Droid stable APK (what users install) # Failure → **BLOCKER** (matrix exit ≠ 0; FAIL_FAST stops here) # 3) **master** — rebuild origin/master -# Failure → counts toward overall fail; does not skip earlier gates +# Failure → **WARN only** (tip breakage / ANR must not block stable gate) # -# Why stable-self in addition to published? -# Isolates "our toolchain rebuild of the release tag" vs "F-Droid binary". -# Rebuild issues (signing flavor, dirty host, low-RAM ANR on debug) must not -# block the published-stable gate — hence warn-only for stable-self. +# Why self-builds in addition to published? +# Isolates "our toolchain rebuild" vs "F-Droid binary". Rebuild issues +# (signing flavor, dirty host, low-RAM ANR on debug) must not block the +# published-stable gate — hence warn-only for stable-self **and** master. # # Usage: # ./run-android-variant-matrix.sh @@ -56,7 +56,7 @@ SUMMARY="$MATRIX_OUT/SUMMARY.txt" echo "=== Android variant matrix ===" | tee -a "$SUMMARY" echo "STACK=$STACK GUI=$GUI headless=${EMULATOR_HEADLESS} variants=$VARIANTS" | tee -a "$SUMMARY" echo "STABLE_TAG=$STABLE_TAG MASTER_BRANCH=$MASTER_BRANCH" | tee -a "$SUMMARY" -echo "severity: stable-self=WARN published=BLOCKER master=fail-count" | tee -a "$SUMMARY" +echo "severity: stable-self=WARN published=BLOCKER master=WARN" | tee -a "$SUMMARY" echo "out: $MATRIX_OUT" | tee -a "$SUMMARY" # Ensure device once (headless AVD by default) @@ -79,7 +79,9 @@ overall=0 variant_severity() { case "$1" in stable-self|stable|self-stable|release-self) echo warn ;; + master|main) echo warn ;; published|fdroid|stable-published) echo blocker ;; + # optional fix branch etc.: soft fail (counts) unless overridden *) echo soft ;; esac } @@ -158,7 +160,7 @@ for raw in "${VLIST[@]}"; do run_selfbuild "stable-self" "$STABLE_TAG" warn || rc=$? ;; master|main) - run_selfbuild "master" "$MASTER_BRANCH" soft || rc=$? + run_selfbuild "master" "$MASTER_BRANCH" warn || rc=$? ;; fix|fix-branch|inference) run_selfbuild "fix" "$FIX_BRANCH" soft || rc=$? @@ -171,7 +173,7 @@ for raw in "${VLIST[@]}"; do if [ "$rc" -ne 0 ]; then case "$sev" in warn) - echo "WARN: variant $v failed rc=$rc — self-build only, NOT a blocker; continuing" | tee -a "$SUMMARY" + echo "WARN: variant $v failed rc=$rc — self-build, NOT a blocker; continuing" | tee -a "$SUMMARY" WARNINGS+=("$v:$rc") RESULTS+=("$v:WARN($rc)") # do not set overall; do not FAIL_FAST @@ -186,7 +188,7 @@ for raw in "${VLIST[@]}"; do fi ;; soft) - echo "FAIL: variant $v rc=$rc (counts toward overall; not stable-self)" | tee -a "$SUMMARY" + echo "FAIL: variant $v rc=$rc (counts toward overall)" | tee -a "$SUMMARY" RESULTS+=("$v:FAIL($rc)") overall=1 if [ "$FAIL_FAST" = "1" ]; then @@ -213,5 +215,5 @@ if [ "${#WARNINGS[@]}" -gt 0 ]; then fi echo "" echo "Compare OUT_DIR trees under $MATRIX_OUT" -echo "stable-self failure = WARN only; published failure = BLOCKER." +echo "stable-self / master failure = WARN only; published failure = BLOCKER." exit "$overall" From f9df3e20fada7ac472062e9d086aea3f92431967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:54:17 +0200 Subject: [PATCH 276/326] android-test: master self-build failures are WARN only too Only published F-Droid remains a matrix blocker; master tip issues warn and continue without failing overall exit alone. --- .../android-test/GUI-AUTOMATION-NOTES.md | 4 ++-- .../taler-monitoring/android-test/README.md | 6 ++--- .../run-android-variant-matrix.sh | 22 ++++++++++--------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 836526d..798bb7d 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -245,9 +245,9 @@ Scripts **today** implement Android only; they should honour: | Variant | Source | Severity | Purpose | |---------|--------|----------|---------| -| **stable-self** | tag `wallet-1.6.1` rebuilt here | **WARN only** | Same release tree, our Gradle — never hard-blocks the matrix | +| **stable-self** | tag `wallet-1.6.1` rebuilt here | **WARN only** | Same release tree, our Gradle — never hard-blocks | | **published** | F-Droid APK URL | **BLOCKER** | What users install; FAIL_FAST stops here | -| **master** | `origin/master` | soft fail | Tip of mainline | +| **master** | `origin/master` | **WARN only** | Tip of mainline — never hard-blocks | | **fix** (optional) | inference fix branch | soft fail | GOA automation fixes | Default matrix order: `stable-self,published,master`. diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 3ab8d6f..595a187 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -38,12 +38,12 @@ Runs several smokes so a single odd developer build cannot be the only sample: |---------|------| | Order | Variant | Severity | What | |------|---------|----------|------| -| **1** | **stable-self** | **WARN only** (not a blocker) | Rebuild tag `wallet-1.6.1` on this host | +| **1** | **stable-self** | **WARN only** | Rebuild tag `wallet-1.6.1` on this host | | **2** | **published** | **BLOCKER** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | -| **3** | **master** | fail counts / FAIL_FAST | Rebuild `origin/master` | +| **3** | **master** | **WARN only** | Rebuild `origin/master` | **Default:** `FAIL_FAST=1` only stops on **blocker** failures (`published`). -`stable-self` failure → warning in `SUMMARY.txt`, matrix continues, exit code unaffected by that warn alone. +`stable-self` / `master` failure → warning in `SUMMARY.txt`, matrix continues, exit code unaffected by those warns alone. ```bash ./run-android-variant-matrix.sh diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index 9f80700..baabbf3 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -8,12 +8,12 @@ # 2) **published** — F-Droid stable APK (what users install) # Failure → **BLOCKER** (matrix exit ≠ 0; FAIL_FAST stops here) # 3) **master** — rebuild origin/master -# Failure → counts toward overall fail; does not skip earlier gates +# Failure → **WARN only** (tip breakage / ANR must not block stable gate) # -# Why stable-self in addition to published? -# Isolates "our toolchain rebuild of the release tag" vs "F-Droid binary". -# Rebuild issues (signing flavor, dirty host, low-RAM ANR on debug) must not -# block the published-stable gate — hence warn-only for stable-self. +# Why self-builds in addition to published? +# Isolates "our toolchain rebuild" vs "F-Droid binary". Rebuild issues +# (signing flavor, dirty host, low-RAM ANR on debug) must not block the +# published-stable gate — hence warn-only for stable-self **and** master. # # Usage: # ./run-android-variant-matrix.sh @@ -56,7 +56,7 @@ SUMMARY="$MATRIX_OUT/SUMMARY.txt" echo "=== Android variant matrix ===" | tee -a "$SUMMARY" echo "STACK=$STACK GUI=$GUI headless=${EMULATOR_HEADLESS} variants=$VARIANTS" | tee -a "$SUMMARY" echo "STABLE_TAG=$STABLE_TAG MASTER_BRANCH=$MASTER_BRANCH" | tee -a "$SUMMARY" -echo "severity: stable-self=WARN published=BLOCKER master=fail-count" | tee -a "$SUMMARY" +echo "severity: stable-self=WARN published=BLOCKER master=WARN" | tee -a "$SUMMARY" echo "out: $MATRIX_OUT" | tee -a "$SUMMARY" # Ensure device once (headless AVD by default) @@ -79,7 +79,9 @@ overall=0 variant_severity() { case "$1" in stable-self|stable|self-stable|release-self) echo warn ;; + master|main) echo warn ;; published|fdroid|stable-published) echo blocker ;; + # optional fix branch etc.: soft fail (counts) unless overridden *) echo soft ;; esac } @@ -158,7 +160,7 @@ for raw in "${VLIST[@]}"; do run_selfbuild "stable-self" "$STABLE_TAG" warn || rc=$? ;; master|main) - run_selfbuild "master" "$MASTER_BRANCH" soft || rc=$? + run_selfbuild "master" "$MASTER_BRANCH" warn || rc=$? ;; fix|fix-branch|inference) run_selfbuild "fix" "$FIX_BRANCH" soft || rc=$? @@ -171,7 +173,7 @@ for raw in "${VLIST[@]}"; do if [ "$rc" -ne 0 ]; then case "$sev" in warn) - echo "WARN: variant $v failed rc=$rc — self-build only, NOT a blocker; continuing" | tee -a "$SUMMARY" + echo "WARN: variant $v failed rc=$rc — self-build, NOT a blocker; continuing" | tee -a "$SUMMARY" WARNINGS+=("$v:$rc") RESULTS+=("$v:WARN($rc)") # do not set overall; do not FAIL_FAST @@ -186,7 +188,7 @@ for raw in "${VLIST[@]}"; do fi ;; soft) - echo "FAIL: variant $v rc=$rc (counts toward overall; not stable-self)" | tee -a "$SUMMARY" + echo "FAIL: variant $v rc=$rc (counts toward overall)" | tee -a "$SUMMARY" RESULTS+=("$v:FAIL($rc)") overall=1 if [ "$FAIL_FAST" = "1" ]; then @@ -213,5 +215,5 @@ if [ "${#WARNINGS[@]}" -gt 0 ]; then fi echo "" echo "Compare OUT_DIR trees under $MATRIX_OUT" -echo "stable-self failure = WARN only; published failure = BLOCKER." +echo "stable-self / master failure = WARN only; published failure = BLOCKER." exit "$overall" From a4f525e40c1f0003654b6e3d51749acec60d13e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:56:01 +0200 Subject: [PATCH 277/326] =?UTF-8?q?android-test:=20smoke=20older=20wallet?= =?UTF-8?q?=20releases;=20blocker=20only=20if=20=E2=89=A414=20days?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand matrix with previous wallet-* tags (F-Droid APK or self-build). Hard-fail only for released apps younger than BLOCKER_MAX_AGE_DAYS; older releases and master remain warn-only. --- .../android-test/GUI-AUTOMATION-NOTES.md | 13 +- .../taler-monitoring/android-test/README.md | 17 +- .../android-test/lib_release_age.sh | 91 ++++++ .../run-android-variant-matrix.sh | 278 ++++++++++++------ 4 files changed, 294 insertions(+), 105 deletions(-) create mode 100644 scripts/taler-monitoring/android-test/lib_release_age.sh diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 798bb7d..f909ead 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -245,13 +245,14 @@ Scripts **today** implement Android only; they should honour: | Variant | Source | Severity | Purpose | |---------|--------|----------|---------| -| **stable-self** | tag `wallet-1.6.1` rebuilt here | **WARN only** | Same release tree, our Gradle — never hard-blocks | -| **published** | F-Droid APK URL | **BLOCKER** | What users install; FAIL_FAST stops here | -| **master** | `origin/master` | **WARN only** | Tip of mainline — never hard-blocks | -| **fix** (optional) | inference fix branch | soft fail | GOA automation fixes | +| **stable-self** | tag `wallet-1.6.1` rebuilt | WARN | Our rebuild of stable — never hard-blocks | +| **published** | F-Droid current APK | **≤14d → BLOCKER**, else WARN | What users install | +| **older-pub / older-self** | previous `wallet-*` tags | **≤14d → BLOCKER**, else WARN | Regression on recent releases only | +| **master** | `origin/master` | WARN | Tip — never hard-blocks | +| **fix** (optional) | inference branch | soft | GOA automation fixes | -Default matrix order: `stable-self,published,master`. -`./run-android-variant-matrix.sh` — see README. +**Two-week window:** `BLOCKER_MAX_AGE_DAYS=14` (git tag age). Older apps are still tried; only young releases hard-block. +Default matrix: `stable-self,published,older,master`. See README. --- diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 595a187..8e0263f 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -38,17 +38,22 @@ Runs several smokes so a single odd developer build cannot be the only sample: |---------|------| | Order | Variant | Severity | What | |------|---------|----------|------| -| **1** | **stable-self** | **WARN only** | Rebuild tag `wallet-1.6.1` on this host | -| **2** | **published** | **BLOCKER** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | -| **3** | **master** | **WARN only** | Rebuild `origin/master` | +| **1** | **stable-self** | WARN | Rebuild tag `wallet-1.6.1` on this host | +| **2** | **published** | **age rule** | Current F-Droid APK | +| **3** | **older-*** | **age rule** | Previous `wallet-*` tags (APK if on F-Droid, else self-build) | +| **4** | **master** | WARN | Rebuild `origin/master` | -**Default:** `FAIL_FAST=1` only stops on **blocker** failures (`published`). -`stable-self` / `master` failure → warning in `SUMMARY.txt`, matrix continues, exit code unaffected by those warns alone. +**Age rule (released apps):** if git-tag age **≤ 14 days** (`BLOCKER_MAX_AGE_DAYS`) → fail is **BLOCKER** + `FAIL_FAST`. +Older releases are still smoked, but fail only as **WARN**. +Self-build of tip/stable-self is never a hard blocker. ```bash ./run-android-variant-matrix.sh STACK=goa ./run-android-variant-matrix.sh -VARIANTS=stable-self,published FAIL_FAST=1 ./run-android-variant-matrix.sh +# without older history: +INCLUDE_OLDER=0 VARIANTS=stable-self,published,master ./run-android-variant-matrix.sh +# force current published always blocker: +PUBLISHED_FORCE_BLOCKER=1 ./run-android-variant-matrix.sh ``` Artifacts + `SUMMARY.txt` under `out-matrix-/`. diff --git a/scripts/taler-monitoring/android-test/lib_release_age.sh b/scripts/taler-monitoring/android-test/lib_release_age.sh new file mode 100644 index 0000000..52fa2ce --- /dev/null +++ b/scripts/taler-monitoring/android-test/lib_release_age.sh @@ -0,0 +1,91 @@ +# shellcheck shell=bash +# Release age helpers for Android wallet matrix. +# Source after ROOT/lib_android_env.sh (optional). +# +# BLOCKER only when a *released* app is within BLOCKER_MAX_AGE_DAYS (default 14). +# Older releases: try them, but failures are WARN only. + +: "${BLOCKER_MAX_AGE_DAYS:=14}" +: "${TALER_ANDROID_SRC:=$HOME/taler/taler-android}" + +# Days since git tag (tagger/committer date). Empty if unknown. +# Usage: release_age_days wallet-1.6.1 → e.g. 28 +release_age_days() { + local tag="$1" + local src="${2:-$TALER_ANDROID_SRC}" + [ -d "$src/.git" ] || return 1 + local epoch + epoch=$(git -C "$src" log -1 --format=%ct "refs/tags/${tag}" 2>/dev/null || true) + [ -n "$epoch" ] || epoch=$(git -C "$src" log -1 --format=%ct "$tag" 2>/dev/null || true) + [ -n "$epoch" ] || return 1 + local now + now=$(date +%s) + echo $(( (now - epoch) / 86400 )) +} + +# severity from age: blocker if age_days <= BLOCKER_MAX_AGE_DAYS, else warn +# Unknown age → warn (never accidental hard fail on mystery builds) +severity_from_age_days() { + local days="${1:-}" + if [ -z "$days" ] || ! [[ "$days" =~ ^[0-9]+$ ]]; then + echo warn + return + fi + if [ "$days" -le "${BLOCKER_MAX_AGE_DAYS}" ]; then + echo blocker + else + echo warn + fi +} + +severity_for_tag() { + local tag="$1" + local days + days=$(release_age_days "$tag" 2>/dev/null || true) + severity_from_age_days "$days" +} + +# List wallet-* release tags newest first (exclude rc/dev). +# Args: max count (default 5) +list_wallet_release_tags() { + local n="${1:-5}" + local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" + [ -d "$src/.git" ] || return 1 + git -C "$src" fetch --tags origin 2>/dev/null || true + git -C "$src" for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags \ + | grep -E '^wallet-[0-9]+\.[0-9]+(\.[0-9]+)?$' \ + | head -n "$n" +} + +# Guess F-Droid APK URL for a wallet version name (e.g. 1.6.1). +# Uses versionCode from tag's build.gradle if possible, else STABLE mapping. +# Prints: versionCode apk_url or returns 1 +fdroid_apk_for_version_name() { + local vn="$1" # 1.6.1 + local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" + local tag="wallet-${vn}" + local vc="" + if git -C "$src" cat-file -e "refs/tags/${tag}:wallet/build.gradle" 2>/dev/null; then + vc=$(git -C "$src" show "refs/tags/${tag}:wallet/build.gradle" 2>/dev/null \ + | sed -n 's/^[[:space:]]*versionCode[[:space:]]\+\([0-9]\+\).*/\1/p' | head -1) + fi + # ABI x86_64 → +4 in override scheme 10*vc+abi (common F-Droid multi-apk) + # Prefer universal-style code used by F-Droid: for 1.6.1 → 854 = 10*85+4 + local apk_vc="" + if [ -n "$vc" ]; then + apk_vc=$((10 * vc + 4)) + fi + # Known overrides / suggested + case "$vn" in + 1.6.1) apk_vc=854 ;; + esac + [ -n "$apk_vc" ] || return 1 + echo "${apk_vc}|https://f-droid.org/repo/net.taler.wallet.fdroid_${apk_vc}.apk" +} + +# Check HTTP HEAD/GET that APK exists (0 = ok) +fdroid_apk_exists() { + local url="$1" + curl -sfS -m 15 -o /dev/null -r 0-0 "$url" 2>/dev/null \ + || curl -sfS -m 15 -o /dev/null -I "$url" 2>/dev/null +} diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index baabbf3..c658096 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -1,51 +1,49 @@ #!/usr/bin/env bash -# Run Android wallet smoke against **several build variants** so a single -# "weird" developer tree or a bad local build cannot be the only sample. +# Run Android wallet smoke against several build / published variants. # -# Default matrix order: -# 1) **stable-self** — rebuild release **tag** (same source as published) -# Failure → **WARN only** (not a hard blocker; continue matrix) -# 2) **published** — F-Droid stable APK (what users install) -# Failure → **BLOCKER** (matrix exit ≠ 0; FAIL_FAST stops here) -# 3) **master** — rebuild origin/master -# Failure → **WARN only** (tip breakage / ANR must not block stable gate) +# Default order: +# 1) stable-self — rebuild STABLE_TAG (always WARN on fail) +# 2) published — current F-Droid suggested APK +# 3) older-* — previous release tags (APK and/or self-build) if available +# 4) master — tip (always WARN on fail) # -# Why self-builds in addition to published? -# Isolates "our toolchain rebuild" vs "F-Droid binary". Rebuild issues -# (signing flavor, dirty host, low-RAM ANR on debug) must not block the -# published-stable gate — hence warn-only for stable-self **and** master. -# -# Usage: -# ./run-android-variant-matrix.sh -# STACK=goa ./run-android-variant-matrix.sh -# VARIANTS=master,stable-self ./run-android-variant-matrix.sh -# STABLE_TAG=wallet-1.6.1 MASTER_BRANCH=master ./run-android-variant-matrix.sh +# Severity (released apps): +# Age ≤ BLOCKER_MAX_AGE_DAYS (default **14**) → **BLOCKER** on fail (FAIL_FAST) +# Age > 14 days (or unknown) → **WARN** only +# master / pure self-builds without release age → WARN # # Env: -# STABLE_TAG default wallet-1.6.1 (must match published F-Droid release) -# STABLE_APK_URL published APK (same as pay-smoke default) -# MASTER_BRANCH default master -# FIX_BRANCH inference fix branch (variant "fix") -# VARIANTS comma list: published,stable-self,master,fix -# GUI=1 use gui-smoke instead of pay-smoke after each build -# FAIL_FAST=1 stop on first **blocker** failure (default 1) -# MATRIX_OUT summary dir (default out-matrix-) +# STABLE_TAG, STABLE_APK_URL, STABLE_APK_NAME +# MASTER_BRANCH, FIX_BRANCH +# VARIANTS default: stable-self,published,older,master +# INCLUDE_OLDER=1 expand token "older" (default 1) +# OLDER_TAG_COUNT how many recent wallet-* tags to consider (default 4) +# BLOCKER_MAX_AGE_DAYS default 14 +# PUBLISHED_FORCE_BLOCKER=1 always treat current published as blocker (default 0 = age rule) +# FAIL_FAST=1, GUI=0, STACK=… # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib_android_env.sh . "$ROOT/lib_android_env.sh" +# shellcheck source=lib_release_age.sh +. "$ROOT/lib_release_age.sh" : "${STABLE_TAG:=wallet-1.6.1}" : "${STABLE_APK_URL:=https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}" : "${STABLE_APK_NAME:=net.taler.wallet.fdroid_854.apk}" : "${MASTER_BRANCH:=master}" : "${FIX_BRANCH:=dev/hernani-inference/fix-bank-withdraw-auto-exchange}" -# Order: stable-self (warn) → published (blocker) → master. -: "${VARIANTS:=stable-self,published,master}" +: "${VARIANTS:=stable-self,published,older,master}" +: "${INCLUDE_OLDER:=1}" +: "${OLDER_TAG_COUNT:=4}" +: "${BLOCKER_MAX_AGE_DAYS:=14}" +: "${PUBLISHED_FORCE_BLOCKER:=0}" : "${FAIL_FAST:=1}" : "${GUI:=0}" : "${STACK:=auto}" +: "${TALER_ANDROID_SRC:=$HOME/taler/taler-android}" +export TALER_ANDROID_SRC BLOCKER_MAX_AGE_DAYS TS=$(date +%Y%m%d-%H%M%S) MATRIX_OUT="${MATRIX_OUT:-$ROOT/out-matrix-$TS}" @@ -55,11 +53,9 @@ SUMMARY="$MATRIX_OUT/SUMMARY.txt" echo "=== Android variant matrix ===" | tee -a "$SUMMARY" echo "STACK=$STACK GUI=$GUI headless=${EMULATOR_HEADLESS} variants=$VARIANTS" | tee -a "$SUMMARY" -echo "STABLE_TAG=$STABLE_TAG MASTER_BRANCH=$MASTER_BRANCH" | tee -a "$SUMMARY" -echo "severity: stable-self=WARN published=BLOCKER master=WARN" | tee -a "$SUMMARY" +echo "STABLE_TAG=$STABLE_TAG BLOCKER_MAX_AGE_DAYS=$BLOCKER_MAX_AGE_DAYS OLDER_TAG_COUNT=$OLDER_TAG_COUNT" | tee -a "$SUMMARY" echo "out: $MATRIX_OUT" | tee -a "$SUMMARY" -# Ensure device once (headless AVD by default) android_ensure_device || { echo "no adb device — start ./start-android-emulator.sh --wait" >&2 exit 3 @@ -67,63 +63,114 @@ android_ensure_device || { export SERIAL ANDROID_SERIAL echo "device: $SERIAL" | tee -a "$SUMMARY" -IFS=',' read -r -a VLIST <<<"$VARIANTS" +# --- expand VARIANTS (token "older" → concrete older-pub / older-self entries) --- +expand_variants() { + local raw="$1" + local -a out=() + local v tag vn age sev stable_vn + stable_vn="${STABLE_TAG#wallet-}" + + IFS=',' read -r -a toks <<<"$raw" + for v in "${toks[@]}"; do + v=$(echo "$v" | tr -d '[:space:]') + [ -n "$v" ] || continue + if [ "$v" = "older" ] || [ "$v" = "older-releases" ]; then + [ "$INCLUDE_OLDER" = "1" ] || continue + local -a tags=() + mapfile -t tags < <(list_wallet_release_tags "$OLDER_TAG_COUNT" 2>/dev/null || true) + local t + for t in "${tags[@]}"; do + [ -n "$t" ] || continue + # skip current stable tag (already covered by published / stable-self) + [ "$t" = "$STABLE_TAG" ] && continue + vn="${t#wallet-}" + age=$(release_age_days "$t" 2>/dev/null || echo "") + sev=$(severity_from_age_days "$age") + echo " older candidate $t age_days=${age:-?} severity=$sev" | tee -a "$SUMMARY" + # Prefer published APK when downloadable; always also allow self-build token + local meta apk_vc apk_url + meta=$(fdroid_apk_for_version_name "$vn" 2>/dev/null || true) + if [ -n "$meta" ]; then + apk_vc="${meta%%|*}" + apk_url="${meta#*|}" + if fdroid_apk_exists "$apk_url"; then + out+=("older-pub:${vn}:${apk_vc}:${sev}") + else + echo " (no F-Droid APK for $vn at $apk_url — self-build only)" | tee -a "$SUMMARY" + out+=("older-self:${t}:${sev}") + fi + else + out+=("older-self:${t}:${sev}") + fi + done + else + out+=("$v") + fi + done + printf '%s\n' "${out[@]}" +} + +mapfile -t VLIST < <(expand_variants "$VARIANTS") +echo "expanded variants: ${VLIST[*]}" | tee -a "$SUMMARY" + declare -a RESULTS=() declare -a WARNINGS=() overall=0 -# Severity: warn | blocker | soft -# warn — log WARN, never FAIL_FAST, never overall≠0 -# blocker — FAIL_FAST eligible; overall≠0 -# soft — overall≠0 on fail, but FAIL_FAST only if FAIL_FAST=1 and not warn -variant_severity() { - case "$1" in - stable-self|stable|self-stable|release-self) echo warn ;; - master|main) echo warn ;; - published|fdroid|stable-published) echo blocker ;; - # optional fix branch etc.: soft fail (counts) unless overridden - *) echo soft ;; - esac +severity_for_published_current() { + if [ "${PUBLISHED_FORCE_BLOCKER}" = "1" ]; then + echo blocker + return + fi + local days + days=$(release_age_days "$STABLE_TAG" 2>/dev/null || true) + severity_from_age_days "$days" } -run_published() { - local out="$MATRIX_OUT/published" +run_published_apk() { + local label="$1" + local apk_url="$2" + local apk_name="$3" + local sev="$4" + local out="$MATRIX_OUT/$label" mkdir -p "$out" echo "" | tee -a "$SUMMARY" - echo "-------- variant: published (F-Droid stable binary) [BLOCKER] --------" | tee -a "$SUMMARY" + echo "-------- variant: $label (F-Droid APK) [$(echo "$sev" | tr 'a-z' 'A-Z')] --------" | tee -a "$SUMMARY" + echo " url=$apk_url" | tee -a "$SUMMARY" local smoke="$ROOT/run-android-pay-smoke.sh" [ "$GUI" = "1" ] && smoke="$ROOT/run-android-gui-smoke.sh" + local strict=1 + [ "$sev" = "warn" ] && strict="${SMOKE_STRICT_WARN:-0}" + [ "$sev" = "blocker" ] && strict="${SMOKE_STRICT:-1}" set +e env STACK="$STACK" \ - APK_URL="$STABLE_APK_URL" \ - APK_NAME="$STABLE_APK_NAME" \ + APK_URL="$apk_url" \ + APK_NAME="$apk_name" \ APK_PATH="" \ PKG=net.taler.wallet.fdroid \ OUT_DIR="$out" \ SERIAL="$SERIAL" \ AUTO_START_EMULATOR=0 \ - SMOKE_STRICT="${SMOKE_STRICT:-1}" \ + SMOKE_STRICT="$strict" \ "$smoke" local rc=$? set -e - echo "published exit=$rc" | tee -a "$SUMMARY" - echo "published $rc blocker" >>"$MATRIX_OUT/results.tsv" + echo "$label exit=$rc severity=$sev" | tee -a "$SUMMARY" + echo "$label $rc $sev $apk_url" >>"$MATRIX_OUT/results.tsv" return "$rc" } run_selfbuild() { local label="$1" local ref="$2" - local sev="${3:-soft}" + local sev="${3:-warn}" local out="$MATRIX_OUT/$label" mkdir -p "$out" echo "" | tee -a "$SUMMARY" echo "-------- variant: $label (self-build ref=$ref) [$(echo "$sev" | tr 'a-z' 'A-Z')] --------" | tee -a "$SUMMARY" - # Warn-only self-build: still collect evidence; SMOKE_STRICT optional (default off for warn) local strict="${SMOKE_STRICT:-1}" - if [ "$sev" = "warn" ]; then - strict="${SMOKE_STRICT_SELF:-0}" - fi + [ "$sev" = "warn" ] && strict="${SMOKE_STRICT_SELF:-0}" + [ "$sev" = "blocker" ] && strict="${SMOKE_STRICT:-1}" set +e env STACK="$STACK" \ BRANCH="$ref" \ @@ -135,6 +182,7 @@ run_selfbuild() { AUTO_START_EMULATOR=0 \ PKG=net.taler.wallet.fdroid.debug \ SMOKE_STRICT="$strict" \ + TALER_ANDROID_SRC="$TALER_ANDROID_SRC" \ "$ROOT/run-android-build-and-smoke.sh" local rc=$? set -e @@ -147,63 +195,108 @@ run_selfbuild() { return "$rc" } +record_result() { + local v="$1" rc="$2" sev="$3" + if [ "$rc" -eq 0 ]; then + RESULTS+=("$v:OK($sev)") + return + fi + case "$sev" in + warn) + echo "WARN: $v failed rc=$rc — not a blocker; continuing" | tee -a "$SUMMARY" + WARNINGS+=("$v:$rc") + RESULTS+=("$v:WARN($rc)") + ;; + blocker) + echo "BLOCKER: $v failed rc=$rc (release within ${BLOCKER_MAX_AGE_DAYS}d window)" | tee -a "$SUMMARY" + RESULTS+=("$v:BLOCKER($rc)") + overall=1 + if [ "$FAIL_FAST" = "1" ]; then + echo "FAIL_FAST: stopping after blocker $v" | tee -a "$SUMMARY" + return 2 + fi + ;; + soft) + echo "FAIL: $v rc=$rc" | tee -a "$SUMMARY" + RESULTS+=("$v:FAIL($rc)") + overall=1 + if [ "$FAIL_FAST" = "1" ]; then + echo "FAIL_FAST: stopping after $v" | tee -a "$SUMMARY" + return 2 + fi + ;; + esac + return 0 +} + for raw in "${VLIST[@]}"; do v=$(echo "$raw" | tr -d '[:space:]') [ -n "$v" ] || continue rc=0 - sev=$(variant_severity "$v") + sev=warn + stop=0 + case "$v" in published|fdroid|stable-published) - run_published || rc=$? + sev=$(severity_for_published_current) + age=$(release_age_days "$STABLE_TAG" 2>/dev/null || echo "?") + echo "published current: tag=$STABLE_TAG age_days=$age → severity=$sev" | tee -a "$SUMMARY" + run_published_apk "published" "$STABLE_APK_URL" "$STABLE_APK_NAME" "$sev" || rc=$? ;; stable-self|stable|self-stable|release-self) + # rebuild of stable: always warn (toolchain), age does not make self-build a blocker + sev=warn run_selfbuild "stable-self" "$STABLE_TAG" warn || rc=$? ;; master|main) + sev=warn run_selfbuild "master" "$MASTER_BRANCH" warn || rc=$? ;; fix|fix-branch|inference) + sev=soft run_selfbuild "fix" "$FIX_BRANCH" soft || rc=$? ;; + older-pub:*) + # older-pub:VERSION:VC:SEV + IFS=':' read -r _ vn apk_vc sev <<<"$v" + : "${sev:=warn}" + label="older-pub-${vn}" + url="https://f-droid.org/repo/net.taler.wallet.fdroid_${apk_vc}.apk" + name="net.taler.wallet.fdroid_${apk_vc}.apk" + age=$(release_age_days "wallet-${vn}" 2>/dev/null || echo "?") + echo "older published $vn age_days=$age severity=$sev" | tee -a "$SUMMARY" + run_published_apk "$label" "$url" "$name" "$sev" || rc=$? + v="$label" + ;; + older-self:*) + # older-self:TAG:SEV or older-self:TAG + IFS=':' read -r _ tag sev <<<"$v" + : "${sev:=$(severity_for_tag "$tag")}" + label="older-self-${tag}" + age=$(release_age_days "$tag" 2>/dev/null || echo "?") + echo "older self-build $tag age_days=$age severity=$sev" | tee -a "$SUMMARY" + run_selfbuild "$label" "$tag" "$sev" || rc=$? + v="$label" + ;; *) - run_selfbuild "ref-${v//\//-}" "$v" soft || rc=$? + # raw git ref — warn unless looks like wallet-X.Y and young + if [[ "$v" =~ ^wallet-[0-9] ]]; then + sev=$(severity_for_tag "$v") + run_selfbuild "ref-${v}" "$v" "$sev" || rc=$? + else + sev=warn + run_selfbuild "ref-${v//\//-}" "$v" warn || rc=$? + fi ;; esac - if [ "$rc" -ne 0 ]; then - case "$sev" in - warn) - echo "WARN: variant $v failed rc=$rc — self-build, NOT a blocker; continuing" | tee -a "$SUMMARY" - WARNINGS+=("$v:$rc") - RESULTS+=("$v:WARN($rc)") - # do not set overall; do not FAIL_FAST - ;; - blocker) - echo "BLOCKER: variant $v failed rc=$rc" | tee -a "$SUMMARY" - RESULTS+=("$v:BLOCKER($rc)") - overall=1 - if [ "$FAIL_FAST" = "1" ]; then - echo "FAIL_FAST: stopping after blocker $v" | tee -a "$SUMMARY" - break - fi - ;; - soft) - echo "FAIL: variant $v rc=$rc (counts toward overall)" | tee -a "$SUMMARY" - RESULTS+=("$v:FAIL($rc)") - overall=1 - if [ "$FAIL_FAST" = "1" ]; then - echo "FAIL_FAST: stopping after $v rc=$rc" | tee -a "$SUMMARY" - break - fi - ;; - esac - else - RESULTS+=("$v:OK") - fi + record_result "$v" "$rc" "$sev" || stop=$? + [ "$stop" = "2" ] && break done echo "" | tee -a "$SUMMARY" echo "=== matrix done (overall=$overall) ===" | tee -a "$SUMMARY" +echo "rule: released app age ≤ ${BLOCKER_MAX_AGE_DAYS}d → BLOCKER on fail; older → WARN" | tee -a "$SUMMARY" for r in "${RESULTS[@]}"; do echo " $r" | tee -a "$SUMMARY" done @@ -214,6 +307,5 @@ if [ "${#WARNINGS[@]}" -gt 0 ]; then done fi echo "" -echo "Compare OUT_DIR trees under $MATRIX_OUT" -echo "stable-self / master failure = WARN only; published failure = BLOCKER." +echo "Artifacts: $MATRIX_OUT" exit "$overall" From 33a545a12d785efc1d7278ef235af56a975ef656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:56:01 +0200 Subject: [PATCH 278/326] =?UTF-8?q?android-test:=20smoke=20older=20wallet?= =?UTF-8?q?=20releases;=20blocker=20only=20if=20=E2=89=A414=20days?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand matrix with previous wallet-* tags (F-Droid APK or self-build). Hard-fail only for released apps younger than BLOCKER_MAX_AGE_DAYS; older releases and master remain warn-only. --- .../android-test/GUI-AUTOMATION-NOTES.md | 13 +- .../taler-monitoring/android-test/README.md | 17 +- .../android-test/lib_release_age.sh | 91 ++++++ .../run-android-variant-matrix.sh | 278 ++++++++++++------ 4 files changed, 294 insertions(+), 105 deletions(-) create mode 100644 scripts/taler-monitoring/android-test/lib_release_age.sh diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 798bb7d..f909ead 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -245,13 +245,14 @@ Scripts **today** implement Android only; they should honour: | Variant | Source | Severity | Purpose | |---------|--------|----------|---------| -| **stable-self** | tag `wallet-1.6.1` rebuilt here | **WARN only** | Same release tree, our Gradle — never hard-blocks | -| **published** | F-Droid APK URL | **BLOCKER** | What users install; FAIL_FAST stops here | -| **master** | `origin/master` | **WARN only** | Tip of mainline — never hard-blocks | -| **fix** (optional) | inference fix branch | soft fail | GOA automation fixes | +| **stable-self** | tag `wallet-1.6.1` rebuilt | WARN | Our rebuild of stable — never hard-blocks | +| **published** | F-Droid current APK | **≤14d → BLOCKER**, else WARN | What users install | +| **older-pub / older-self** | previous `wallet-*` tags | **≤14d → BLOCKER**, else WARN | Regression on recent releases only | +| **master** | `origin/master` | WARN | Tip — never hard-blocks | +| **fix** (optional) | inference branch | soft | GOA automation fixes | -Default matrix order: `stable-self,published,master`. -`./run-android-variant-matrix.sh` — see README. +**Two-week window:** `BLOCKER_MAX_AGE_DAYS=14` (git tag age). Older apps are still tried; only young releases hard-block. +Default matrix: `stable-self,published,older,master`. See README. --- diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 595a187..8e0263f 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -38,17 +38,22 @@ Runs several smokes so a single odd developer build cannot be the only sample: |---------|------| | Order | Variant | Severity | What | |------|---------|----------|------| -| **1** | **stable-self** | **WARN only** | Rebuild tag `wallet-1.6.1` on this host | -| **2** | **published** | **BLOCKER** | F-Droid stable APK (`net.taler.wallet.fdroid` 1.6.1 / 854) | -| **3** | **master** | **WARN only** | Rebuild `origin/master` | +| **1** | **stable-self** | WARN | Rebuild tag `wallet-1.6.1` on this host | +| **2** | **published** | **age rule** | Current F-Droid APK | +| **3** | **older-*** | **age rule** | Previous `wallet-*` tags (APK if on F-Droid, else self-build) | +| **4** | **master** | WARN | Rebuild `origin/master` | -**Default:** `FAIL_FAST=1` only stops on **blocker** failures (`published`). -`stable-self` / `master` failure → warning in `SUMMARY.txt`, matrix continues, exit code unaffected by those warns alone. +**Age rule (released apps):** if git-tag age **≤ 14 days** (`BLOCKER_MAX_AGE_DAYS`) → fail is **BLOCKER** + `FAIL_FAST`. +Older releases are still smoked, but fail only as **WARN**. +Self-build of tip/stable-self is never a hard blocker. ```bash ./run-android-variant-matrix.sh STACK=goa ./run-android-variant-matrix.sh -VARIANTS=stable-self,published FAIL_FAST=1 ./run-android-variant-matrix.sh +# without older history: +INCLUDE_OLDER=0 VARIANTS=stable-self,published,master ./run-android-variant-matrix.sh +# force current published always blocker: +PUBLISHED_FORCE_BLOCKER=1 ./run-android-variant-matrix.sh ``` Artifacts + `SUMMARY.txt` under `out-matrix-/`. diff --git a/scripts/taler-monitoring/android-test/lib_release_age.sh b/scripts/taler-monitoring/android-test/lib_release_age.sh new file mode 100644 index 0000000..52fa2ce --- /dev/null +++ b/scripts/taler-monitoring/android-test/lib_release_age.sh @@ -0,0 +1,91 @@ +# shellcheck shell=bash +# Release age helpers for Android wallet matrix. +# Source after ROOT/lib_android_env.sh (optional). +# +# BLOCKER only when a *released* app is within BLOCKER_MAX_AGE_DAYS (default 14). +# Older releases: try them, but failures are WARN only. + +: "${BLOCKER_MAX_AGE_DAYS:=14}" +: "${TALER_ANDROID_SRC:=$HOME/taler/taler-android}" + +# Days since git tag (tagger/committer date). Empty if unknown. +# Usage: release_age_days wallet-1.6.1 → e.g. 28 +release_age_days() { + local tag="$1" + local src="${2:-$TALER_ANDROID_SRC}" + [ -d "$src/.git" ] || return 1 + local epoch + epoch=$(git -C "$src" log -1 --format=%ct "refs/tags/${tag}" 2>/dev/null || true) + [ -n "$epoch" ] || epoch=$(git -C "$src" log -1 --format=%ct "$tag" 2>/dev/null || true) + [ -n "$epoch" ] || return 1 + local now + now=$(date +%s) + echo $(( (now - epoch) / 86400 )) +} + +# severity from age: blocker if age_days <= BLOCKER_MAX_AGE_DAYS, else warn +# Unknown age → warn (never accidental hard fail on mystery builds) +severity_from_age_days() { + local days="${1:-}" + if [ -z "$days" ] || ! [[ "$days" =~ ^[0-9]+$ ]]; then + echo warn + return + fi + if [ "$days" -le "${BLOCKER_MAX_AGE_DAYS}" ]; then + echo blocker + else + echo warn + fi +} + +severity_for_tag() { + local tag="$1" + local days + days=$(release_age_days "$tag" 2>/dev/null || true) + severity_from_age_days "$days" +} + +# List wallet-* release tags newest first (exclude rc/dev). +# Args: max count (default 5) +list_wallet_release_tags() { + local n="${1:-5}" + local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" + [ -d "$src/.git" ] || return 1 + git -C "$src" fetch --tags origin 2>/dev/null || true + git -C "$src" for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags \ + | grep -E '^wallet-[0-9]+\.[0-9]+(\.[0-9]+)?$' \ + | head -n "$n" +} + +# Guess F-Droid APK URL for a wallet version name (e.g. 1.6.1). +# Uses versionCode from tag's build.gradle if possible, else STABLE mapping. +# Prints: versionCode apk_url or returns 1 +fdroid_apk_for_version_name() { + local vn="$1" # 1.6.1 + local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" + local tag="wallet-${vn}" + local vc="" + if git -C "$src" cat-file -e "refs/tags/${tag}:wallet/build.gradle" 2>/dev/null; then + vc=$(git -C "$src" show "refs/tags/${tag}:wallet/build.gradle" 2>/dev/null \ + | sed -n 's/^[[:space:]]*versionCode[[:space:]]\+\([0-9]\+\).*/\1/p' | head -1) + fi + # ABI x86_64 → +4 in override scheme 10*vc+abi (common F-Droid multi-apk) + # Prefer universal-style code used by F-Droid: for 1.6.1 → 854 = 10*85+4 + local apk_vc="" + if [ -n "$vc" ]; then + apk_vc=$((10 * vc + 4)) + fi + # Known overrides / suggested + case "$vn" in + 1.6.1) apk_vc=854 ;; + esac + [ -n "$apk_vc" ] || return 1 + echo "${apk_vc}|https://f-droid.org/repo/net.taler.wallet.fdroid_${apk_vc}.apk" +} + +# Check HTTP HEAD/GET that APK exists (0 = ok) +fdroid_apk_exists() { + local url="$1" + curl -sfS -m 15 -o /dev/null -r 0-0 "$url" 2>/dev/null \ + || curl -sfS -m 15 -o /dev/null -I "$url" 2>/dev/null +} diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index baabbf3..c658096 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -1,51 +1,49 @@ #!/usr/bin/env bash -# Run Android wallet smoke against **several build variants** so a single -# "weird" developer tree or a bad local build cannot be the only sample. +# Run Android wallet smoke against several build / published variants. # -# Default matrix order: -# 1) **stable-self** — rebuild release **tag** (same source as published) -# Failure → **WARN only** (not a hard blocker; continue matrix) -# 2) **published** — F-Droid stable APK (what users install) -# Failure → **BLOCKER** (matrix exit ≠ 0; FAIL_FAST stops here) -# 3) **master** — rebuild origin/master -# Failure → **WARN only** (tip breakage / ANR must not block stable gate) +# Default order: +# 1) stable-self — rebuild STABLE_TAG (always WARN on fail) +# 2) published — current F-Droid suggested APK +# 3) older-* — previous release tags (APK and/or self-build) if available +# 4) master — tip (always WARN on fail) # -# Why self-builds in addition to published? -# Isolates "our toolchain rebuild" vs "F-Droid binary". Rebuild issues -# (signing flavor, dirty host, low-RAM ANR on debug) must not block the -# published-stable gate — hence warn-only for stable-self **and** master. -# -# Usage: -# ./run-android-variant-matrix.sh -# STACK=goa ./run-android-variant-matrix.sh -# VARIANTS=master,stable-self ./run-android-variant-matrix.sh -# STABLE_TAG=wallet-1.6.1 MASTER_BRANCH=master ./run-android-variant-matrix.sh +# Severity (released apps): +# Age ≤ BLOCKER_MAX_AGE_DAYS (default **14**) → **BLOCKER** on fail (FAIL_FAST) +# Age > 14 days (or unknown) → **WARN** only +# master / pure self-builds without release age → WARN # # Env: -# STABLE_TAG default wallet-1.6.1 (must match published F-Droid release) -# STABLE_APK_URL published APK (same as pay-smoke default) -# MASTER_BRANCH default master -# FIX_BRANCH inference fix branch (variant "fix") -# VARIANTS comma list: published,stable-self,master,fix -# GUI=1 use gui-smoke instead of pay-smoke after each build -# FAIL_FAST=1 stop on first **blocker** failure (default 1) -# MATRIX_OUT summary dir (default out-matrix-) +# STABLE_TAG, STABLE_APK_URL, STABLE_APK_NAME +# MASTER_BRANCH, FIX_BRANCH +# VARIANTS default: stable-self,published,older,master +# INCLUDE_OLDER=1 expand token "older" (default 1) +# OLDER_TAG_COUNT how many recent wallet-* tags to consider (default 4) +# BLOCKER_MAX_AGE_DAYS default 14 +# PUBLISHED_FORCE_BLOCKER=1 always treat current published as blocker (default 0 = age rule) +# FAIL_FAST=1, GUI=0, STACK=… # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib_android_env.sh . "$ROOT/lib_android_env.sh" +# shellcheck source=lib_release_age.sh +. "$ROOT/lib_release_age.sh" : "${STABLE_TAG:=wallet-1.6.1}" : "${STABLE_APK_URL:=https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}" : "${STABLE_APK_NAME:=net.taler.wallet.fdroid_854.apk}" : "${MASTER_BRANCH:=master}" : "${FIX_BRANCH:=dev/hernani-inference/fix-bank-withdraw-auto-exchange}" -# Order: stable-self (warn) → published (blocker) → master. -: "${VARIANTS:=stable-self,published,master}" +: "${VARIANTS:=stable-self,published,older,master}" +: "${INCLUDE_OLDER:=1}" +: "${OLDER_TAG_COUNT:=4}" +: "${BLOCKER_MAX_AGE_DAYS:=14}" +: "${PUBLISHED_FORCE_BLOCKER:=0}" : "${FAIL_FAST:=1}" : "${GUI:=0}" : "${STACK:=auto}" +: "${TALER_ANDROID_SRC:=$HOME/taler/taler-android}" +export TALER_ANDROID_SRC BLOCKER_MAX_AGE_DAYS TS=$(date +%Y%m%d-%H%M%S) MATRIX_OUT="${MATRIX_OUT:-$ROOT/out-matrix-$TS}" @@ -55,11 +53,9 @@ SUMMARY="$MATRIX_OUT/SUMMARY.txt" echo "=== Android variant matrix ===" | tee -a "$SUMMARY" echo "STACK=$STACK GUI=$GUI headless=${EMULATOR_HEADLESS} variants=$VARIANTS" | tee -a "$SUMMARY" -echo "STABLE_TAG=$STABLE_TAG MASTER_BRANCH=$MASTER_BRANCH" | tee -a "$SUMMARY" -echo "severity: stable-self=WARN published=BLOCKER master=WARN" | tee -a "$SUMMARY" +echo "STABLE_TAG=$STABLE_TAG BLOCKER_MAX_AGE_DAYS=$BLOCKER_MAX_AGE_DAYS OLDER_TAG_COUNT=$OLDER_TAG_COUNT" | tee -a "$SUMMARY" echo "out: $MATRIX_OUT" | tee -a "$SUMMARY" -# Ensure device once (headless AVD by default) android_ensure_device || { echo "no adb device — start ./start-android-emulator.sh --wait" >&2 exit 3 @@ -67,63 +63,114 @@ android_ensure_device || { export SERIAL ANDROID_SERIAL echo "device: $SERIAL" | tee -a "$SUMMARY" -IFS=',' read -r -a VLIST <<<"$VARIANTS" +# --- expand VARIANTS (token "older" → concrete older-pub / older-self entries) --- +expand_variants() { + local raw="$1" + local -a out=() + local v tag vn age sev stable_vn + stable_vn="${STABLE_TAG#wallet-}" + + IFS=',' read -r -a toks <<<"$raw" + for v in "${toks[@]}"; do + v=$(echo "$v" | tr -d '[:space:]') + [ -n "$v" ] || continue + if [ "$v" = "older" ] || [ "$v" = "older-releases" ]; then + [ "$INCLUDE_OLDER" = "1" ] || continue + local -a tags=() + mapfile -t tags < <(list_wallet_release_tags "$OLDER_TAG_COUNT" 2>/dev/null || true) + local t + for t in "${tags[@]}"; do + [ -n "$t" ] || continue + # skip current stable tag (already covered by published / stable-self) + [ "$t" = "$STABLE_TAG" ] && continue + vn="${t#wallet-}" + age=$(release_age_days "$t" 2>/dev/null || echo "") + sev=$(severity_from_age_days "$age") + echo " older candidate $t age_days=${age:-?} severity=$sev" | tee -a "$SUMMARY" + # Prefer published APK when downloadable; always also allow self-build token + local meta apk_vc apk_url + meta=$(fdroid_apk_for_version_name "$vn" 2>/dev/null || true) + if [ -n "$meta" ]; then + apk_vc="${meta%%|*}" + apk_url="${meta#*|}" + if fdroid_apk_exists "$apk_url"; then + out+=("older-pub:${vn}:${apk_vc}:${sev}") + else + echo " (no F-Droid APK for $vn at $apk_url — self-build only)" | tee -a "$SUMMARY" + out+=("older-self:${t}:${sev}") + fi + else + out+=("older-self:${t}:${sev}") + fi + done + else + out+=("$v") + fi + done + printf '%s\n' "${out[@]}" +} + +mapfile -t VLIST < <(expand_variants "$VARIANTS") +echo "expanded variants: ${VLIST[*]}" | tee -a "$SUMMARY" + declare -a RESULTS=() declare -a WARNINGS=() overall=0 -# Severity: warn | blocker | soft -# warn — log WARN, never FAIL_FAST, never overall≠0 -# blocker — FAIL_FAST eligible; overall≠0 -# soft — overall≠0 on fail, but FAIL_FAST only if FAIL_FAST=1 and not warn -variant_severity() { - case "$1" in - stable-self|stable|self-stable|release-self) echo warn ;; - master|main) echo warn ;; - published|fdroid|stable-published) echo blocker ;; - # optional fix branch etc.: soft fail (counts) unless overridden - *) echo soft ;; - esac +severity_for_published_current() { + if [ "${PUBLISHED_FORCE_BLOCKER}" = "1" ]; then + echo blocker + return + fi + local days + days=$(release_age_days "$STABLE_TAG" 2>/dev/null || true) + severity_from_age_days "$days" } -run_published() { - local out="$MATRIX_OUT/published" +run_published_apk() { + local label="$1" + local apk_url="$2" + local apk_name="$3" + local sev="$4" + local out="$MATRIX_OUT/$label" mkdir -p "$out" echo "" | tee -a "$SUMMARY" - echo "-------- variant: published (F-Droid stable binary) [BLOCKER] --------" | tee -a "$SUMMARY" + echo "-------- variant: $label (F-Droid APK) [$(echo "$sev" | tr 'a-z' 'A-Z')] --------" | tee -a "$SUMMARY" + echo " url=$apk_url" | tee -a "$SUMMARY" local smoke="$ROOT/run-android-pay-smoke.sh" [ "$GUI" = "1" ] && smoke="$ROOT/run-android-gui-smoke.sh" + local strict=1 + [ "$sev" = "warn" ] && strict="${SMOKE_STRICT_WARN:-0}" + [ "$sev" = "blocker" ] && strict="${SMOKE_STRICT:-1}" set +e env STACK="$STACK" \ - APK_URL="$STABLE_APK_URL" \ - APK_NAME="$STABLE_APK_NAME" \ + APK_URL="$apk_url" \ + APK_NAME="$apk_name" \ APK_PATH="" \ PKG=net.taler.wallet.fdroid \ OUT_DIR="$out" \ SERIAL="$SERIAL" \ AUTO_START_EMULATOR=0 \ - SMOKE_STRICT="${SMOKE_STRICT:-1}" \ + SMOKE_STRICT="$strict" \ "$smoke" local rc=$? set -e - echo "published exit=$rc" | tee -a "$SUMMARY" - echo "published $rc blocker" >>"$MATRIX_OUT/results.tsv" + echo "$label exit=$rc severity=$sev" | tee -a "$SUMMARY" + echo "$label $rc $sev $apk_url" >>"$MATRIX_OUT/results.tsv" return "$rc" } run_selfbuild() { local label="$1" local ref="$2" - local sev="${3:-soft}" + local sev="${3:-warn}" local out="$MATRIX_OUT/$label" mkdir -p "$out" echo "" | tee -a "$SUMMARY" echo "-------- variant: $label (self-build ref=$ref) [$(echo "$sev" | tr 'a-z' 'A-Z')] --------" | tee -a "$SUMMARY" - # Warn-only self-build: still collect evidence; SMOKE_STRICT optional (default off for warn) local strict="${SMOKE_STRICT:-1}" - if [ "$sev" = "warn" ]; then - strict="${SMOKE_STRICT_SELF:-0}" - fi + [ "$sev" = "warn" ] && strict="${SMOKE_STRICT_SELF:-0}" + [ "$sev" = "blocker" ] && strict="${SMOKE_STRICT:-1}" set +e env STACK="$STACK" \ BRANCH="$ref" \ @@ -135,6 +182,7 @@ run_selfbuild() { AUTO_START_EMULATOR=0 \ PKG=net.taler.wallet.fdroid.debug \ SMOKE_STRICT="$strict" \ + TALER_ANDROID_SRC="$TALER_ANDROID_SRC" \ "$ROOT/run-android-build-and-smoke.sh" local rc=$? set -e @@ -147,63 +195,108 @@ run_selfbuild() { return "$rc" } +record_result() { + local v="$1" rc="$2" sev="$3" + if [ "$rc" -eq 0 ]; then + RESULTS+=("$v:OK($sev)") + return + fi + case "$sev" in + warn) + echo "WARN: $v failed rc=$rc — not a blocker; continuing" | tee -a "$SUMMARY" + WARNINGS+=("$v:$rc") + RESULTS+=("$v:WARN($rc)") + ;; + blocker) + echo "BLOCKER: $v failed rc=$rc (release within ${BLOCKER_MAX_AGE_DAYS}d window)" | tee -a "$SUMMARY" + RESULTS+=("$v:BLOCKER($rc)") + overall=1 + if [ "$FAIL_FAST" = "1" ]; then + echo "FAIL_FAST: stopping after blocker $v" | tee -a "$SUMMARY" + return 2 + fi + ;; + soft) + echo "FAIL: $v rc=$rc" | tee -a "$SUMMARY" + RESULTS+=("$v:FAIL($rc)") + overall=1 + if [ "$FAIL_FAST" = "1" ]; then + echo "FAIL_FAST: stopping after $v" | tee -a "$SUMMARY" + return 2 + fi + ;; + esac + return 0 +} + for raw in "${VLIST[@]}"; do v=$(echo "$raw" | tr -d '[:space:]') [ -n "$v" ] || continue rc=0 - sev=$(variant_severity "$v") + sev=warn + stop=0 + case "$v" in published|fdroid|stable-published) - run_published || rc=$? + sev=$(severity_for_published_current) + age=$(release_age_days "$STABLE_TAG" 2>/dev/null || echo "?") + echo "published current: tag=$STABLE_TAG age_days=$age → severity=$sev" | tee -a "$SUMMARY" + run_published_apk "published" "$STABLE_APK_URL" "$STABLE_APK_NAME" "$sev" || rc=$? ;; stable-self|stable|self-stable|release-self) + # rebuild of stable: always warn (toolchain), age does not make self-build a blocker + sev=warn run_selfbuild "stable-self" "$STABLE_TAG" warn || rc=$? ;; master|main) + sev=warn run_selfbuild "master" "$MASTER_BRANCH" warn || rc=$? ;; fix|fix-branch|inference) + sev=soft run_selfbuild "fix" "$FIX_BRANCH" soft || rc=$? ;; + older-pub:*) + # older-pub:VERSION:VC:SEV + IFS=':' read -r _ vn apk_vc sev <<<"$v" + : "${sev:=warn}" + label="older-pub-${vn}" + url="https://f-droid.org/repo/net.taler.wallet.fdroid_${apk_vc}.apk" + name="net.taler.wallet.fdroid_${apk_vc}.apk" + age=$(release_age_days "wallet-${vn}" 2>/dev/null || echo "?") + echo "older published $vn age_days=$age severity=$sev" | tee -a "$SUMMARY" + run_published_apk "$label" "$url" "$name" "$sev" || rc=$? + v="$label" + ;; + older-self:*) + # older-self:TAG:SEV or older-self:TAG + IFS=':' read -r _ tag sev <<<"$v" + : "${sev:=$(severity_for_tag "$tag")}" + label="older-self-${tag}" + age=$(release_age_days "$tag" 2>/dev/null || echo "?") + echo "older self-build $tag age_days=$age severity=$sev" | tee -a "$SUMMARY" + run_selfbuild "$label" "$tag" "$sev" || rc=$? + v="$label" + ;; *) - run_selfbuild "ref-${v//\//-}" "$v" soft || rc=$? + # raw git ref — warn unless looks like wallet-X.Y and young + if [[ "$v" =~ ^wallet-[0-9] ]]; then + sev=$(severity_for_tag "$v") + run_selfbuild "ref-${v}" "$v" "$sev" || rc=$? + else + sev=warn + run_selfbuild "ref-${v//\//-}" "$v" warn || rc=$? + fi ;; esac - if [ "$rc" -ne 0 ]; then - case "$sev" in - warn) - echo "WARN: variant $v failed rc=$rc — self-build, NOT a blocker; continuing" | tee -a "$SUMMARY" - WARNINGS+=("$v:$rc") - RESULTS+=("$v:WARN($rc)") - # do not set overall; do not FAIL_FAST - ;; - blocker) - echo "BLOCKER: variant $v failed rc=$rc" | tee -a "$SUMMARY" - RESULTS+=("$v:BLOCKER($rc)") - overall=1 - if [ "$FAIL_FAST" = "1" ]; then - echo "FAIL_FAST: stopping after blocker $v" | tee -a "$SUMMARY" - break - fi - ;; - soft) - echo "FAIL: variant $v rc=$rc (counts toward overall)" | tee -a "$SUMMARY" - RESULTS+=("$v:FAIL($rc)") - overall=1 - if [ "$FAIL_FAST" = "1" ]; then - echo "FAIL_FAST: stopping after $v rc=$rc" | tee -a "$SUMMARY" - break - fi - ;; - esac - else - RESULTS+=("$v:OK") - fi + record_result "$v" "$rc" "$sev" || stop=$? + [ "$stop" = "2" ] && break done echo "" | tee -a "$SUMMARY" echo "=== matrix done (overall=$overall) ===" | tee -a "$SUMMARY" +echo "rule: released app age ≤ ${BLOCKER_MAX_AGE_DAYS}d → BLOCKER on fail; older → WARN" | tee -a "$SUMMARY" for r in "${RESULTS[@]}"; do echo " $r" | tee -a "$SUMMARY" done @@ -214,6 +307,5 @@ if [ "${#WARNINGS[@]}" -gt 0 ]; then done fi echo "" -echo "Compare OUT_DIR trees under $MATRIX_OUT" -echo "stable-self / master failure = WARN only; published failure = BLOCKER." +echo "Artifacts: $MATRIX_OUT" exit "$overall" From 984b2d64343aa327e817f989fd16c740602c8db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:57:01 +0200 Subject: [PATCH 279/326] android-test: sample 3/6/9/12-month wallet tags as WARN only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pick nearest release tags to 90/180/270/365 days for the older matrix slot; failures there never hard-block (only ≤14d releases can). --- .../android-test/GUI-AUTOMATION-NOTES.md | 9 +- .../taler-monitoring/android-test/README.md | 17 ++-- .../android-test/lib_release_age.sh | 50 ++++++++++- .../run-android-variant-matrix.sh | 87 ++++++++++--------- 4 files changed, 107 insertions(+), 56 deletions(-) diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index f909ead..0d418be 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -245,14 +245,15 @@ Scripts **today** implement Android only; they should honour: | Variant | Source | Severity | Purpose | |---------|--------|----------|---------| -| **stable-self** | tag `wallet-1.6.1` rebuilt | WARN | Our rebuild of stable — never hard-blocks | +| **stable-self** | stable tag rebuilt | WARN | Our rebuild — never hard-blocks | | **published** | F-Droid current APK | **≤14d → BLOCKER**, else WARN | What users install | -| **older-pub / older-self** | previous `wallet-*` tags | **≤14d → BLOCKER**, else WARN | Regression on recent releases only | +| **older / milestones** | tags ~**3 / 6 / 9 / 12 mo** | **always WARN** | Long-horizon regression, if tags exist | | **master** | `origin/master` | WARN | Tip — never hard-blocks | | **fix** (optional) | inference branch | soft | GOA automation fixes | -**Two-week window:** `BLOCKER_MAX_AGE_DAYS=14` (git tag age). Older apps are still tried; only young releases hard-block. -Default matrix: `stable-self,published,older,master`. See README. +**Blocker window:** only releases ≤ 14 days. +**Milestones:** nearest tags to 90/180/270/365 days (±50d) — warn only. +Default matrix: `stable-self,published,older,master`. --- diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 8e0263f..bb66bce 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -38,22 +38,21 @@ Runs several smokes so a single odd developer build cannot be the only sample: |---------|------| | Order | Variant | Severity | What | |------|---------|----------|------| -| **1** | **stable-self** | WARN | Rebuild tag `wallet-1.6.1` on this host | -| **2** | **published** | **age rule** | Current F-Droid APK | -| **3** | **older-*** | **age rule** | Previous `wallet-*` tags (APK if on F-Droid, else self-build) | +| **1** | **stable-self** | WARN | Rebuild current stable tag | +| **2** | **published** | age ≤14d → **BLOCKER**, else WARN | Current F-Droid APK | +| **3** | **older / milestones** | **always WARN** | Tags nearest **3 / 6 / 9 / 12 months** (if available) | | **4** | **master** | WARN | Rebuild `origin/master` | -**Age rule (released apps):** if git-tag age **≤ 14 days** (`BLOCKER_MAX_AGE_DAYS`) → fail is **BLOCKER** + `FAIL_FAST`. -Older releases are still smoked, but fail only as **WARN**. -Self-build of tip/stable-self is never a hard blocker. +**Milestones:** `MILESTONE_DAYS="90 180 270 365"` (± `MILESTONE_TOLERANCE_DAYS=50`). +Failures there are warnings only — never hard blockers. +**Young releases only** (≤ 14 days): hard blocker on fail. ```bash ./run-android-variant-matrix.sh STACK=goa ./run-android-variant-matrix.sh -# without older history: INCLUDE_OLDER=0 VARIANTS=stable-self,published,master ./run-android-variant-matrix.sh -# force current published always blocker: -PUBLISHED_FORCE_BLOCKER=1 ./run-android-variant-matrix.sh +# custom milestones (days): +MILESTONE_DAYS="90 180 270 365" ./run-android-variant-matrix.sh ``` Artifacts + `SUMMARY.txt` under `out-matrix-/`. diff --git a/scripts/taler-monitoring/android-test/lib_release_age.sh b/scripts/taler-monitoring/android-test/lib_release_age.sh index 52fa2ce..7be935f 100644 --- a/scripts/taler-monitoring/android-test/lib_release_age.sh +++ b/scripts/taler-monitoring/android-test/lib_release_age.sh @@ -46,15 +46,57 @@ severity_for_tag() { } # List wallet-* release tags newest first (exclude rc/dev). -# Args: max count (default 5) +# Args: max count (default 5). Pass 0 or "all" for no limit. list_wallet_release_tags() { local n="${1:-5}" local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" [ -d "$src/.git" ] || return 1 git -C "$src" fetch --tags origin 2>/dev/null || true - git -C "$src" for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags \ - | grep -E '^wallet-[0-9]+\.[0-9]+(\.[0-9]+)?$' \ - | head -n "$n" + local cmd=(git -C "$src" for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags) + if [ "$n" = "all" ] || [ "$n" = "0" ]; then + "${cmd[@]}" | grep -E '^wallet-[0-9]+\.[0-9]+(\.[0-9]+)?$' + else + "${cmd[@]}" | grep -E '^wallet-[0-9]+\.[0-9]+(\.[0-9]+)?$' | head -n "$n" + fi +} + +# Pick one tag closest to each age milestone (days). Always intended as WARN samples. +# Default milestones: 3,6,9,12 months ≈ 90,180,270,365 days. +# Env: MILESTONE_DAYS="90 180 270 365" MILESTONE_TOLERANCE_DAYS=50 +# Prints lines: TAG AGE_DAYS TARGET_DAYS (only when a tag within tolerance exists) +select_milestone_tags() { + local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" + local milestones="${MILESTONE_DAYS:-90 180 270 365}" + local tol="${MILESTONE_TOLERANCE_DAYS:-50}" + local skip="${1:-}" # optional tag to exclude (e.g. current STABLE_TAG) + local -a tags=() + mapfile -t tags < <(list_wallet_release_tags all 2>/dev/null || true) + [ "${#tags[@]}" -gt 0 ] || return 0 + + local target best_tag best_age best_delta age t delta + local -A used=() + for target in $milestones; do + best_tag="" + best_age="" + best_delta=99999 + for t in "${tags[@]}"; do + [ -n "$t" ] || continue + [ "$t" = "$skip" ] && continue + [ -n "${used[$t]:-}" ] && continue + age=$(release_age_days "$t" "$src" 2>/dev/null || true) + [[ "$age" =~ ^[0-9]+$ ]] || continue + delta=$(( age > target ? age - target : target - age )) + if [ "$delta" -le "$tol" ] && [ "$delta" -lt "$best_delta" ]; then + best_delta=$delta + best_tag=$t + best_age=$age + fi + done + if [ -n "$best_tag" ]; then + used[$best_tag]=1 + echo "$best_tag $best_age $target" + fi + done } # Guess F-Droid APK URL for a wallet version name (e.g. 1.6.1). diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index c658096..976df43 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -4,22 +4,23 @@ # Default order: # 1) stable-self — rebuild STABLE_TAG (always WARN on fail) # 2) published — current F-Droid suggested APK -# 3) older-* — previous release tags (APK and/or self-build) if available -# 4) master — tip (always WARN on fail) +# 3) older / milestones — releases near **3 / 6 / 9 / 12 months** (WARN only) +# 4) master — tip (always WARN on fail) # # Severity (released apps): # Age ≤ BLOCKER_MAX_AGE_DAYS (default **14**) → **BLOCKER** on fail (FAIL_FAST) -# Age > 14 days (or unknown) → **WARN** only -# master / pure self-builds without release age → WARN +# Age > 14 days, incl. 3/6/9/12‑month samples → **WARN** only +# master / stable-self → WARN # # Env: # STABLE_TAG, STABLE_APK_URL, STABLE_APK_NAME # MASTER_BRANCH, FIX_BRANCH # VARIANTS default: stable-self,published,older,master -# INCLUDE_OLDER=1 expand token "older" (default 1) -# OLDER_TAG_COUNT how many recent wallet-* tags to consider (default 4) +# INCLUDE_OLDER=1 expand token "older" / "milestones" (default 1) +# MILESTONE_DAYS default "90 180 270 365" (≈ 3,6,9,12 months) +# MILESTONE_TOLERANCE_DAYS default 50 — max distance to accept a tag # BLOCKER_MAX_AGE_DAYS default 14 -# PUBLISHED_FORCE_BLOCKER=1 always treat current published as blocker (default 0 = age rule) +# PUBLISHED_FORCE_BLOCKER=1 always treat current published as blocker # FAIL_FAST=1, GUI=0, STACK=… # set -euo pipefail @@ -36,14 +37,15 @@ ROOT=$(cd "$(dirname "$0")" && pwd) : "${FIX_BRANCH:=dev/hernani-inference/fix-bank-withdraw-auto-exchange}" : "${VARIANTS:=stable-self,published,older,master}" : "${INCLUDE_OLDER:=1}" -: "${OLDER_TAG_COUNT:=4}" +: "${MILESTONE_DAYS:=90 180 270 365}" +: "${MILESTONE_TOLERANCE_DAYS:=50}" : "${BLOCKER_MAX_AGE_DAYS:=14}" : "${PUBLISHED_FORCE_BLOCKER:=0}" : "${FAIL_FAST:=1}" : "${GUI:=0}" : "${STACK:=auto}" : "${TALER_ANDROID_SRC:=$HOME/taler/taler-android}" -export TALER_ANDROID_SRC BLOCKER_MAX_AGE_DAYS +export TALER_ANDROID_SRC BLOCKER_MAX_AGE_DAYS MILESTONE_DAYS MILESTONE_TOLERANCE_DAYS TS=$(date +%Y%m%d-%H%M%S) MATRIX_OUT="${MATRIX_OUT:-$ROOT/out-matrix-$TS}" @@ -53,7 +55,8 @@ SUMMARY="$MATRIX_OUT/SUMMARY.txt" echo "=== Android variant matrix ===" | tee -a "$SUMMARY" echo "STACK=$STACK GUI=$GUI headless=${EMULATOR_HEADLESS} variants=$VARIANTS" | tee -a "$SUMMARY" -echo "STABLE_TAG=$STABLE_TAG BLOCKER_MAX_AGE_DAYS=$BLOCKER_MAX_AGE_DAYS OLDER_TAG_COUNT=$OLDER_TAG_COUNT" | tee -a "$SUMMARY" +echo "STABLE_TAG=$STABLE_TAG BLOCKER_MAX_AGE_DAYS=$BLOCKER_MAX_AGE_DAYS" | tee -a "$SUMMARY" +echo "milestones(days)=$MILESTONE_DAYS tolerance=$MILESTONE_TOLERANCE_DAYS (always WARN)" | tee -a "$SUMMARY" echo "out: $MATRIX_OUT" | tee -a "$SUMMARY" android_ensure_device || { @@ -64,45 +67,51 @@ export SERIAL ANDROID_SERIAL echo "device: $SERIAL" | tee -a "$SUMMARY" # --- expand VARIANTS (token "older" → concrete older-pub / older-self entries) --- +# Append one older release as WARN-only (pub APK if possible, else self-build). +append_older_warn() { + local t="$1" age="$2" target="${3:-}" + local vn meta apk_vc apk_url + vn="${t#wallet-}" + if [ -n "$target" ]; then + echo " milestone ~${target}d → $t (age=${age}d) severity=WARN" | tee -a "$SUMMARY" + else + echo " older $t (age=${age}d) severity=WARN" | tee -a "$SUMMARY" + fi + meta=$(fdroid_apk_for_version_name "$vn" 2>/dev/null || true) + if [ -n "$meta" ]; then + apk_vc="${meta%%|*}" + apk_url="${meta#*|}" + if fdroid_apk_exists "$apk_url"; then + out+=("older-pub:${vn}:${apk_vc}:warn") + return + fi + echo " (no F-Droid APK for $vn — self-build only)" | tee -a "$SUMMARY" + fi + out+=("older-self:${t}:warn") +} + expand_variants() { local raw="$1" local -a out=() - local v tag vn age sev stable_vn - stable_vn="${STABLE_TAG#wallet-}" + local v tag vn age sev line t target IFS=',' read -r -a toks <<<"$raw" for v in "${toks[@]}"; do v=$(echo "$v" | tr -d '[:space:]') [ -n "$v" ] || continue - if [ "$v" = "older" ] || [ "$v" = "older-releases" ]; then + if [ "$v" = "older" ] || [ "$v" = "older-releases" ] || [ "$v" = "milestones" ]; then [ "$INCLUDE_OLDER" = "1" ] || continue - local -a tags=() - mapfile -t tags < <(list_wallet_release_tags "$OLDER_TAG_COUNT" 2>/dev/null || true) - local t - for t in "${tags[@]}"; do + # Prefer 3/6/9/12-month samples (always WARN). Fall back: nothing if no tags nearby. + local found=0 + while read -r t age target; do [ -n "$t" ] || continue - # skip current stable tag (already covered by published / stable-self) [ "$t" = "$STABLE_TAG" ] && continue - vn="${t#wallet-}" - age=$(release_age_days "$t" 2>/dev/null || echo "") - sev=$(severity_from_age_days "$age") - echo " older candidate $t age_days=${age:-?} severity=$sev" | tee -a "$SUMMARY" - # Prefer published APK when downloadable; always also allow self-build token - local meta apk_vc apk_url - meta=$(fdroid_apk_for_version_name "$vn" 2>/dev/null || true) - if [ -n "$meta" ]; then - apk_vc="${meta%%|*}" - apk_url="${meta#*|}" - if fdroid_apk_exists "$apk_url"; then - out+=("older-pub:${vn}:${apk_vc}:${sev}") - else - echo " (no F-Droid APK for $vn at $apk_url — self-build only)" | tee -a "$SUMMARY" - out+=("older-self:${t}:${sev}") - fi - else - out+=("older-self:${t}:${sev}") - fi - done + append_older_warn "$t" "$age" "$target" + found=1 + done < <(select_milestone_tags "$STABLE_TAG" 2>/dev/null || true) + if [ "$found" = "0" ]; then + echo " (no milestone tags within ±${MILESTONE_TOLERANCE_DAYS}d of $MILESTONE_DAYS)" | tee -a "$SUMMARY" + fi else out+=("$v") fi @@ -296,7 +305,7 @@ done echo "" | tee -a "$SUMMARY" echo "=== matrix done (overall=$overall) ===" | tee -a "$SUMMARY" -echo "rule: released app age ≤ ${BLOCKER_MAX_AGE_DAYS}d → BLOCKER on fail; older → WARN" | tee -a "$SUMMARY" +echo "rule: age ≤ ${BLOCKER_MAX_AGE_DAYS}d → BLOCKER; 3/6/9/12mo milestones + older → WARN only" | tee -a "$SUMMARY" for r in "${RESULTS[@]}"; do echo " $r" | tee -a "$SUMMARY" done From e359cf79c28267c2a03e5eddade40e6bab84d059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:57:01 +0200 Subject: [PATCH 280/326] android-test: sample 3/6/9/12-month wallet tags as WARN only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pick nearest release tags to 90/180/270/365 days for the older matrix slot; failures there never hard-block (only ≤14d releases can). --- .../android-test/GUI-AUTOMATION-NOTES.md | 9 +- .../taler-monitoring/android-test/README.md | 17 ++-- .../android-test/lib_release_age.sh | 50 ++++++++++- .../run-android-variant-matrix.sh | 87 ++++++++++--------- 4 files changed, 107 insertions(+), 56 deletions(-) diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index f909ead..0d418be 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -245,14 +245,15 @@ Scripts **today** implement Android only; they should honour: | Variant | Source | Severity | Purpose | |---------|--------|----------|---------| -| **stable-self** | tag `wallet-1.6.1` rebuilt | WARN | Our rebuild of stable — never hard-blocks | +| **stable-self** | stable tag rebuilt | WARN | Our rebuild — never hard-blocks | | **published** | F-Droid current APK | **≤14d → BLOCKER**, else WARN | What users install | -| **older-pub / older-self** | previous `wallet-*` tags | **≤14d → BLOCKER**, else WARN | Regression on recent releases only | +| **older / milestones** | tags ~**3 / 6 / 9 / 12 mo** | **always WARN** | Long-horizon regression, if tags exist | | **master** | `origin/master` | WARN | Tip — never hard-blocks | | **fix** (optional) | inference branch | soft | GOA automation fixes | -**Two-week window:** `BLOCKER_MAX_AGE_DAYS=14` (git tag age). Older apps are still tried; only young releases hard-block. -Default matrix: `stable-self,published,older,master`. See README. +**Blocker window:** only releases ≤ 14 days. +**Milestones:** nearest tags to 90/180/270/365 days (±50d) — warn only. +Default matrix: `stable-self,published,older,master`. --- diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 8e0263f..bb66bce 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -38,22 +38,21 @@ Runs several smokes so a single odd developer build cannot be the only sample: |---------|------| | Order | Variant | Severity | What | |------|---------|----------|------| -| **1** | **stable-self** | WARN | Rebuild tag `wallet-1.6.1` on this host | -| **2** | **published** | **age rule** | Current F-Droid APK | -| **3** | **older-*** | **age rule** | Previous `wallet-*` tags (APK if on F-Droid, else self-build) | +| **1** | **stable-self** | WARN | Rebuild current stable tag | +| **2** | **published** | age ≤14d → **BLOCKER**, else WARN | Current F-Droid APK | +| **3** | **older / milestones** | **always WARN** | Tags nearest **3 / 6 / 9 / 12 months** (if available) | | **4** | **master** | WARN | Rebuild `origin/master` | -**Age rule (released apps):** if git-tag age **≤ 14 days** (`BLOCKER_MAX_AGE_DAYS`) → fail is **BLOCKER** + `FAIL_FAST`. -Older releases are still smoked, but fail only as **WARN**. -Self-build of tip/stable-self is never a hard blocker. +**Milestones:** `MILESTONE_DAYS="90 180 270 365"` (± `MILESTONE_TOLERANCE_DAYS=50`). +Failures there are warnings only — never hard blockers. +**Young releases only** (≤ 14 days): hard blocker on fail. ```bash ./run-android-variant-matrix.sh STACK=goa ./run-android-variant-matrix.sh -# without older history: INCLUDE_OLDER=0 VARIANTS=stable-self,published,master ./run-android-variant-matrix.sh -# force current published always blocker: -PUBLISHED_FORCE_BLOCKER=1 ./run-android-variant-matrix.sh +# custom milestones (days): +MILESTONE_DAYS="90 180 270 365" ./run-android-variant-matrix.sh ``` Artifacts + `SUMMARY.txt` under `out-matrix-/`. diff --git a/scripts/taler-monitoring/android-test/lib_release_age.sh b/scripts/taler-monitoring/android-test/lib_release_age.sh index 52fa2ce..7be935f 100644 --- a/scripts/taler-monitoring/android-test/lib_release_age.sh +++ b/scripts/taler-monitoring/android-test/lib_release_age.sh @@ -46,15 +46,57 @@ severity_for_tag() { } # List wallet-* release tags newest first (exclude rc/dev). -# Args: max count (default 5) +# Args: max count (default 5). Pass 0 or "all" for no limit. list_wallet_release_tags() { local n="${1:-5}" local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" [ -d "$src/.git" ] || return 1 git -C "$src" fetch --tags origin 2>/dev/null || true - git -C "$src" for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags \ - | grep -E '^wallet-[0-9]+\.[0-9]+(\.[0-9]+)?$' \ - | head -n "$n" + local cmd=(git -C "$src" for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags) + if [ "$n" = "all" ] || [ "$n" = "0" ]; then + "${cmd[@]}" | grep -E '^wallet-[0-9]+\.[0-9]+(\.[0-9]+)?$' + else + "${cmd[@]}" | grep -E '^wallet-[0-9]+\.[0-9]+(\.[0-9]+)?$' | head -n "$n" + fi +} + +# Pick one tag closest to each age milestone (days). Always intended as WARN samples. +# Default milestones: 3,6,9,12 months ≈ 90,180,270,365 days. +# Env: MILESTONE_DAYS="90 180 270 365" MILESTONE_TOLERANCE_DAYS=50 +# Prints lines: TAG AGE_DAYS TARGET_DAYS (only when a tag within tolerance exists) +select_milestone_tags() { + local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}" + local milestones="${MILESTONE_DAYS:-90 180 270 365}" + local tol="${MILESTONE_TOLERANCE_DAYS:-50}" + local skip="${1:-}" # optional tag to exclude (e.g. current STABLE_TAG) + local -a tags=() + mapfile -t tags < <(list_wallet_release_tags all 2>/dev/null || true) + [ "${#tags[@]}" -gt 0 ] || return 0 + + local target best_tag best_age best_delta age t delta + local -A used=() + for target in $milestones; do + best_tag="" + best_age="" + best_delta=99999 + for t in "${tags[@]}"; do + [ -n "$t" ] || continue + [ "$t" = "$skip" ] && continue + [ -n "${used[$t]:-}" ] && continue + age=$(release_age_days "$t" "$src" 2>/dev/null || true) + [[ "$age" =~ ^[0-9]+$ ]] || continue + delta=$(( age > target ? age - target : target - age )) + if [ "$delta" -le "$tol" ] && [ "$delta" -lt "$best_delta" ]; then + best_delta=$delta + best_tag=$t + best_age=$age + fi + done + if [ -n "$best_tag" ]; then + used[$best_tag]=1 + echo "$best_tag $best_age $target" + fi + done } # Guess F-Droid APK URL for a wallet version name (e.g. 1.6.1). diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index c658096..976df43 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -4,22 +4,23 @@ # Default order: # 1) stable-self — rebuild STABLE_TAG (always WARN on fail) # 2) published — current F-Droid suggested APK -# 3) older-* — previous release tags (APK and/or self-build) if available -# 4) master — tip (always WARN on fail) +# 3) older / milestones — releases near **3 / 6 / 9 / 12 months** (WARN only) +# 4) master — tip (always WARN on fail) # # Severity (released apps): # Age ≤ BLOCKER_MAX_AGE_DAYS (default **14**) → **BLOCKER** on fail (FAIL_FAST) -# Age > 14 days (or unknown) → **WARN** only -# master / pure self-builds without release age → WARN +# Age > 14 days, incl. 3/6/9/12‑month samples → **WARN** only +# master / stable-self → WARN # # Env: # STABLE_TAG, STABLE_APK_URL, STABLE_APK_NAME # MASTER_BRANCH, FIX_BRANCH # VARIANTS default: stable-self,published,older,master -# INCLUDE_OLDER=1 expand token "older" (default 1) -# OLDER_TAG_COUNT how many recent wallet-* tags to consider (default 4) +# INCLUDE_OLDER=1 expand token "older" / "milestones" (default 1) +# MILESTONE_DAYS default "90 180 270 365" (≈ 3,6,9,12 months) +# MILESTONE_TOLERANCE_DAYS default 50 — max distance to accept a tag # BLOCKER_MAX_AGE_DAYS default 14 -# PUBLISHED_FORCE_BLOCKER=1 always treat current published as blocker (default 0 = age rule) +# PUBLISHED_FORCE_BLOCKER=1 always treat current published as blocker # FAIL_FAST=1, GUI=0, STACK=… # set -euo pipefail @@ -36,14 +37,15 @@ ROOT=$(cd "$(dirname "$0")" && pwd) : "${FIX_BRANCH:=dev/hernani-inference/fix-bank-withdraw-auto-exchange}" : "${VARIANTS:=stable-self,published,older,master}" : "${INCLUDE_OLDER:=1}" -: "${OLDER_TAG_COUNT:=4}" +: "${MILESTONE_DAYS:=90 180 270 365}" +: "${MILESTONE_TOLERANCE_DAYS:=50}" : "${BLOCKER_MAX_AGE_DAYS:=14}" : "${PUBLISHED_FORCE_BLOCKER:=0}" : "${FAIL_FAST:=1}" : "${GUI:=0}" : "${STACK:=auto}" : "${TALER_ANDROID_SRC:=$HOME/taler/taler-android}" -export TALER_ANDROID_SRC BLOCKER_MAX_AGE_DAYS +export TALER_ANDROID_SRC BLOCKER_MAX_AGE_DAYS MILESTONE_DAYS MILESTONE_TOLERANCE_DAYS TS=$(date +%Y%m%d-%H%M%S) MATRIX_OUT="${MATRIX_OUT:-$ROOT/out-matrix-$TS}" @@ -53,7 +55,8 @@ SUMMARY="$MATRIX_OUT/SUMMARY.txt" echo "=== Android variant matrix ===" | tee -a "$SUMMARY" echo "STACK=$STACK GUI=$GUI headless=${EMULATOR_HEADLESS} variants=$VARIANTS" | tee -a "$SUMMARY" -echo "STABLE_TAG=$STABLE_TAG BLOCKER_MAX_AGE_DAYS=$BLOCKER_MAX_AGE_DAYS OLDER_TAG_COUNT=$OLDER_TAG_COUNT" | tee -a "$SUMMARY" +echo "STABLE_TAG=$STABLE_TAG BLOCKER_MAX_AGE_DAYS=$BLOCKER_MAX_AGE_DAYS" | tee -a "$SUMMARY" +echo "milestones(days)=$MILESTONE_DAYS tolerance=$MILESTONE_TOLERANCE_DAYS (always WARN)" | tee -a "$SUMMARY" echo "out: $MATRIX_OUT" | tee -a "$SUMMARY" android_ensure_device || { @@ -64,45 +67,51 @@ export SERIAL ANDROID_SERIAL echo "device: $SERIAL" | tee -a "$SUMMARY" # --- expand VARIANTS (token "older" → concrete older-pub / older-self entries) --- +# Append one older release as WARN-only (pub APK if possible, else self-build). +append_older_warn() { + local t="$1" age="$2" target="${3:-}" + local vn meta apk_vc apk_url + vn="${t#wallet-}" + if [ -n "$target" ]; then + echo " milestone ~${target}d → $t (age=${age}d) severity=WARN" | tee -a "$SUMMARY" + else + echo " older $t (age=${age}d) severity=WARN" | tee -a "$SUMMARY" + fi + meta=$(fdroid_apk_for_version_name "$vn" 2>/dev/null || true) + if [ -n "$meta" ]; then + apk_vc="${meta%%|*}" + apk_url="${meta#*|}" + if fdroid_apk_exists "$apk_url"; then + out+=("older-pub:${vn}:${apk_vc}:warn") + return + fi + echo " (no F-Droid APK for $vn — self-build only)" | tee -a "$SUMMARY" + fi + out+=("older-self:${t}:warn") +} + expand_variants() { local raw="$1" local -a out=() - local v tag vn age sev stable_vn - stable_vn="${STABLE_TAG#wallet-}" + local v tag vn age sev line t target IFS=',' read -r -a toks <<<"$raw" for v in "${toks[@]}"; do v=$(echo "$v" | tr -d '[:space:]') [ -n "$v" ] || continue - if [ "$v" = "older" ] || [ "$v" = "older-releases" ]; then + if [ "$v" = "older" ] || [ "$v" = "older-releases" ] || [ "$v" = "milestones" ]; then [ "$INCLUDE_OLDER" = "1" ] || continue - local -a tags=() - mapfile -t tags < <(list_wallet_release_tags "$OLDER_TAG_COUNT" 2>/dev/null || true) - local t - for t in "${tags[@]}"; do + # Prefer 3/6/9/12-month samples (always WARN). Fall back: nothing if no tags nearby. + local found=0 + while read -r t age target; do [ -n "$t" ] || continue - # skip current stable tag (already covered by published / stable-self) [ "$t" = "$STABLE_TAG" ] && continue - vn="${t#wallet-}" - age=$(release_age_days "$t" 2>/dev/null || echo "") - sev=$(severity_from_age_days "$age") - echo " older candidate $t age_days=${age:-?} severity=$sev" | tee -a "$SUMMARY" - # Prefer published APK when downloadable; always also allow self-build token - local meta apk_vc apk_url - meta=$(fdroid_apk_for_version_name "$vn" 2>/dev/null || true) - if [ -n "$meta" ]; then - apk_vc="${meta%%|*}" - apk_url="${meta#*|}" - if fdroid_apk_exists "$apk_url"; then - out+=("older-pub:${vn}:${apk_vc}:${sev}") - else - echo " (no F-Droid APK for $vn at $apk_url — self-build only)" | tee -a "$SUMMARY" - out+=("older-self:${t}:${sev}") - fi - else - out+=("older-self:${t}:${sev}") - fi - done + append_older_warn "$t" "$age" "$target" + found=1 + done < <(select_milestone_tags "$STABLE_TAG" 2>/dev/null || true) + if [ "$found" = "0" ]; then + echo " (no milestone tags within ±${MILESTONE_TOLERANCE_DAYS}d of $MILESTONE_DAYS)" | tee -a "$SUMMARY" + fi else out+=("$v") fi @@ -296,7 +305,7 @@ done echo "" | tee -a "$SUMMARY" echo "=== matrix done (overall=$overall) ===" | tee -a "$SUMMARY" -echo "rule: released app age ≤ ${BLOCKER_MAX_AGE_DAYS}d → BLOCKER on fail; older → WARN" | tee -a "$SUMMARY" +echo "rule: age ≤ ${BLOCKER_MAX_AGE_DAYS}d → BLOCKER; 3/6/9/12mo milestones + older → WARN only" | tee -a "$SUMMARY" for r in "${RESULTS[@]}"; do echo " $r" | tee -a "$SUMMARY" done From cae26e96b22bcec50084dbf129e45c3110241829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:57:33 +0200 Subject: [PATCH 281/326] android-test: self-build only current stable, not older releases Milestone/older matrix slots use F-Droid APKs when still hosted; skip if missing. Rebuild remains limited to stable-self (and master/fix). --- .../android-test/GUI-AUTOMATION-NOTES.md | 12 ++--- .../taler-monitoring/android-test/README.md | 11 ++--- .../run-android-variant-matrix.sh | 45 ++++++++++--------- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 0d418be..8326ac9 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -245,15 +245,15 @@ Scripts **today** implement Android only; they should honour: | Variant | Source | Severity | Purpose | |---------|--------|----------|---------| -| **stable-self** | stable tag rebuilt | WARN | Our rebuild — never hard-blocks | +| **stable-self** | **only** rebuild of current stable tag | WARN | Self-build vs published for **one** release | | **published** | F-Droid current APK | **≤14d → BLOCKER**, else WARN | What users install | -| **older / milestones** | tags ~**3 / 6 / 9 / 12 mo** | **always WARN** | Long-horizon regression, if tags exist | -| **master** | `origin/master` | WARN | Tip — never hard-blocks | +| **older / milestones** | F-Droid APK ~**3 / 6 / 9 / 12 mo** | **always WARN** | No self-build of old tags | +| **master** | `origin/master` rebuild | WARN | Tip — never hard-blocks | | **fix** (optional) | inference branch | soft | GOA automation fixes | -**Blocker window:** only releases ≤ 14 days. -**Milestones:** nearest tags to 90/180/270/365 days (±50d) — warn only. -Default matrix: `stable-self,published,older,master`. +**Self-build:** current stable only (plus master/fix if requested) — not older releases. +**Blocker window:** releases ≤ 14 days. +**Milestones:** nearest tags to 90/180/270/365d with a still-hosted F-Droid APK. --- diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index bb66bce..7b2b0e3 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -38,14 +38,15 @@ Runs several smokes so a single odd developer build cannot be the only sample: |---------|------| | Order | Variant | Severity | What | |------|---------|----------|------| -| **1** | **stable-self** | WARN | Rebuild current stable tag | +| **1** | **stable-self** | WARN | **Only** self-build: current stable tag | | **2** | **published** | age ≤14d → **BLOCKER**, else WARN | Current F-Droid APK | -| **3** | **older / milestones** | **always WARN** | Tags nearest **3 / 6 / 9 / 12 months** (if available) | +| **3** | **older / milestones** | **always WARN** | F-Droid APKs nearest **3 / 6 / 9 / 12 months** (if still hosted) — **no self-build** | | **4** | **master** | WARN | Rebuild `origin/master` | -**Milestones:** `MILESTONE_DAYS="90 180 270 365"` (± `MILESTONE_TOLERANCE_DAYS=50`). -Failures there are warnings only — never hard blockers. -**Young releases only** (≤ 14 days): hard blocker on fail. +**Self-build policy:** only **current published stable** (`stable-self`) + optional **master**. +Historical versions: published F-Droid APK only; skip if APK gone. +**Milestones:** `MILESTONE_DAYS="90 180 270 365"` (±50d), warn only. +**Young releases** (≤ 14 days): hard blocker on fail. ```bash ./run-android-variant-matrix.sh diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index 976df43..3a2fe30 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -67,7 +67,8 @@ export SERIAL ANDROID_SERIAL echo "device: $SERIAL" | tee -a "$SUMMARY" # --- expand VARIANTS (token "older" → concrete older-pub / older-self entries) --- -# Append one older release as WARN-only (pub APK if possible, else self-build). +# Append one older release as WARN-only — **F-Droid APK only** (no self-build). +# Returns 0 if queued, 1 if skipped (no published APK). append_older_warn() { local t="$1" age="$2" target="${3:-}" local vn meta apk_vc apk_url @@ -83,11 +84,13 @@ append_older_warn() { apk_url="${meta#*|}" if fdroid_apk_exists "$apk_url"; then out+=("older-pub:${vn}:${apk_vc}:warn") - return + return 0 fi - echo " (no F-Droid APK for $vn — self-build only)" | tee -a "$SUMMARY" + echo " SKIP $t — no F-Droid APK at ${apk_url:-?} (self-build only for current stable)" | tee -a "$SUMMARY" + else + echo " SKIP $t — cannot map F-Droid versionCode (self-build only for current stable)" | tee -a "$SUMMARY" fi - out+=("older-self:${t}:warn") + return 1 } expand_variants() { @@ -101,16 +104,20 @@ expand_variants() { [ -n "$v" ] || continue if [ "$v" = "older" ] || [ "$v" = "older-releases" ] || [ "$v" = "milestones" ]; then [ "$INCLUDE_OLDER" = "1" ] || continue - # Prefer 3/6/9/12-month samples (always WARN). Fall back: nothing if no tags nearby. - local found=0 + # 3/6/9/12-month samples: published APK only, always WARN. + local found=0 queued=0 while read -r t age target; do [ -n "$t" ] || continue [ "$t" = "$STABLE_TAG" ] && continue - append_older_warn "$t" "$age" "$target" found=1 + if append_older_warn "$t" "$age" "$target"; then + queued=$((queued + 1)) + fi done < <(select_milestone_tags "$STABLE_TAG" 2>/dev/null || true) if [ "$found" = "0" ]; then echo " (no milestone tags within ±${MILESTONE_TOLERANCE_DAYS}d of $MILESTONE_DAYS)" | tee -a "$SUMMARY" + elif [ "$queued" = "0" ]; then + echo " (milestone tags found but none still on F-Droid — no older APK smokes)" | tee -a "$SUMMARY" fi else out+=("$v") @@ -278,24 +285,20 @@ for raw in "${VLIST[@]}"; do v="$label" ;; older-self:*) - # older-self:TAG:SEV or older-self:TAG - IFS=':' read -r _ tag sev <<<"$v" - : "${sev:=$(severity_for_tag "$tag")}" - label="older-self-${tag}" - age=$(release_age_days "$tag" 2>/dev/null || echo "?") - echo "older self-build $tag age_days=$age severity=$sev" | tee -a "$SUMMARY" - run_selfbuild "$label" "$tag" "$sev" || rc=$? - v="$label" + # Explicit older-self is disabled by policy (self-build = current stable only). + echo "SKIP $v — self-build only for current stable ($STABLE_TAG); use older-pub or published" | tee -a "$SUMMARY" + RESULTS+=("$v:SKIP(no-selfbuild-older)") + continue ;; *) - # raw git ref — warn unless looks like wallet-X.Y and young + # raw git ref: only master-like / fix via named tokens; bare wallet-* tags → no self-build if [[ "$v" =~ ^wallet-[0-9] ]]; then - sev=$(severity_for_tag "$v") - run_selfbuild "ref-${v}" "$v" "$sev" || rc=$? - else - sev=warn - run_selfbuild "ref-${v//\//-}" "$v" warn || rc=$? + echo "SKIP self-build $v — only current stable self-build ($STABLE_TAG) is enabled" | tee -a "$SUMMARY" + RESULTS+=("$v:SKIP(no-selfbuild-older)") + continue fi + sev=warn + run_selfbuild "ref-${v//\//-}" "$v" warn || rc=$? ;; esac From 4296ac946ce806f7b690c877684a249fc5fe14c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:57:33 +0200 Subject: [PATCH 282/326] android-test: self-build only current stable, not older releases Milestone/older matrix slots use F-Droid APKs when still hosted; skip if missing. Rebuild remains limited to stable-self (and master/fix). --- .../android-test/GUI-AUTOMATION-NOTES.md | 12 ++--- .../taler-monitoring/android-test/README.md | 11 ++--- .../run-android-variant-matrix.sh | 45 ++++++++++--------- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 0d418be..8326ac9 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -245,15 +245,15 @@ Scripts **today** implement Android only; they should honour: | Variant | Source | Severity | Purpose | |---------|--------|----------|---------| -| **stable-self** | stable tag rebuilt | WARN | Our rebuild — never hard-blocks | +| **stable-self** | **only** rebuild of current stable tag | WARN | Self-build vs published for **one** release | | **published** | F-Droid current APK | **≤14d → BLOCKER**, else WARN | What users install | -| **older / milestones** | tags ~**3 / 6 / 9 / 12 mo** | **always WARN** | Long-horizon regression, if tags exist | -| **master** | `origin/master` | WARN | Tip — never hard-blocks | +| **older / milestones** | F-Droid APK ~**3 / 6 / 9 / 12 mo** | **always WARN** | No self-build of old tags | +| **master** | `origin/master` rebuild | WARN | Tip — never hard-blocks | | **fix** (optional) | inference branch | soft | GOA automation fixes | -**Blocker window:** only releases ≤ 14 days. -**Milestones:** nearest tags to 90/180/270/365 days (±50d) — warn only. -Default matrix: `stable-self,published,older,master`. +**Self-build:** current stable only (plus master/fix if requested) — not older releases. +**Blocker window:** releases ≤ 14 days. +**Milestones:** nearest tags to 90/180/270/365d with a still-hosted F-Droid APK. --- diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index bb66bce..7b2b0e3 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -38,14 +38,15 @@ Runs several smokes so a single odd developer build cannot be the only sample: |---------|------| | Order | Variant | Severity | What | |------|---------|----------|------| -| **1** | **stable-self** | WARN | Rebuild current stable tag | +| **1** | **stable-self** | WARN | **Only** self-build: current stable tag | | **2** | **published** | age ≤14d → **BLOCKER**, else WARN | Current F-Droid APK | -| **3** | **older / milestones** | **always WARN** | Tags nearest **3 / 6 / 9 / 12 months** (if available) | +| **3** | **older / milestones** | **always WARN** | F-Droid APKs nearest **3 / 6 / 9 / 12 months** (if still hosted) — **no self-build** | | **4** | **master** | WARN | Rebuild `origin/master` | -**Milestones:** `MILESTONE_DAYS="90 180 270 365"` (± `MILESTONE_TOLERANCE_DAYS=50`). -Failures there are warnings only — never hard blockers. -**Young releases only** (≤ 14 days): hard blocker on fail. +**Self-build policy:** only **current published stable** (`stable-self`) + optional **master**. +Historical versions: published F-Droid APK only; skip if APK gone. +**Milestones:** `MILESTONE_DAYS="90 180 270 365"` (±50d), warn only. +**Young releases** (≤ 14 days): hard blocker on fail. ```bash ./run-android-variant-matrix.sh diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index 976df43..3a2fe30 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -67,7 +67,8 @@ export SERIAL ANDROID_SERIAL echo "device: $SERIAL" | tee -a "$SUMMARY" # --- expand VARIANTS (token "older" → concrete older-pub / older-self entries) --- -# Append one older release as WARN-only (pub APK if possible, else self-build). +# Append one older release as WARN-only — **F-Droid APK only** (no self-build). +# Returns 0 if queued, 1 if skipped (no published APK). append_older_warn() { local t="$1" age="$2" target="${3:-}" local vn meta apk_vc apk_url @@ -83,11 +84,13 @@ append_older_warn() { apk_url="${meta#*|}" if fdroid_apk_exists "$apk_url"; then out+=("older-pub:${vn}:${apk_vc}:warn") - return + return 0 fi - echo " (no F-Droid APK for $vn — self-build only)" | tee -a "$SUMMARY" + echo " SKIP $t — no F-Droid APK at ${apk_url:-?} (self-build only for current stable)" | tee -a "$SUMMARY" + else + echo " SKIP $t — cannot map F-Droid versionCode (self-build only for current stable)" | tee -a "$SUMMARY" fi - out+=("older-self:${t}:warn") + return 1 } expand_variants() { @@ -101,16 +104,20 @@ expand_variants() { [ -n "$v" ] || continue if [ "$v" = "older" ] || [ "$v" = "older-releases" ] || [ "$v" = "milestones" ]; then [ "$INCLUDE_OLDER" = "1" ] || continue - # Prefer 3/6/9/12-month samples (always WARN). Fall back: nothing if no tags nearby. - local found=0 + # 3/6/9/12-month samples: published APK only, always WARN. + local found=0 queued=0 while read -r t age target; do [ -n "$t" ] || continue [ "$t" = "$STABLE_TAG" ] && continue - append_older_warn "$t" "$age" "$target" found=1 + if append_older_warn "$t" "$age" "$target"; then + queued=$((queued + 1)) + fi done < <(select_milestone_tags "$STABLE_TAG" 2>/dev/null || true) if [ "$found" = "0" ]; then echo " (no milestone tags within ±${MILESTONE_TOLERANCE_DAYS}d of $MILESTONE_DAYS)" | tee -a "$SUMMARY" + elif [ "$queued" = "0" ]; then + echo " (milestone tags found but none still on F-Droid — no older APK smokes)" | tee -a "$SUMMARY" fi else out+=("$v") @@ -278,24 +285,20 @@ for raw in "${VLIST[@]}"; do v="$label" ;; older-self:*) - # older-self:TAG:SEV or older-self:TAG - IFS=':' read -r _ tag sev <<<"$v" - : "${sev:=$(severity_for_tag "$tag")}" - label="older-self-${tag}" - age=$(release_age_days "$tag" 2>/dev/null || echo "?") - echo "older self-build $tag age_days=$age severity=$sev" | tee -a "$SUMMARY" - run_selfbuild "$label" "$tag" "$sev" || rc=$? - v="$label" + # Explicit older-self is disabled by policy (self-build = current stable only). + echo "SKIP $v — self-build only for current stable ($STABLE_TAG); use older-pub or published" | tee -a "$SUMMARY" + RESULTS+=("$v:SKIP(no-selfbuild-older)") + continue ;; *) - # raw git ref — warn unless looks like wallet-X.Y and young + # raw git ref: only master-like / fix via named tokens; bare wallet-* tags → no self-build if [[ "$v" =~ ^wallet-[0-9] ]]; then - sev=$(severity_for_tag "$v") - run_selfbuild "ref-${v}" "$v" "$sev" || rc=$? - else - sev=warn - run_selfbuild "ref-${v//\//-}" "$v" warn || rc=$? + echo "SKIP self-build $v — only current stable self-build ($STABLE_TAG) is enabled" | tee -a "$SUMMARY" + RESULTS+=("$v:SKIP(no-selfbuild-older)") + continue fi + sev=warn + run_selfbuild "ref-${v//\//-}" "$v" warn || rc=$? ;; esac From a7dd6ee824aac53617b8a1cda0df2c104f5e9564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:57:42 +0200 Subject: [PATCH 283/326] android-test: document self-build-only-for-current-stable policy in header --- .../android-test/run-android-variant-matrix.sh | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index 3a2fe30..54fe5dc 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -2,15 +2,14 @@ # Run Android wallet smoke against several build / published variants. # # Default order: -# 1) stable-self — rebuild STABLE_TAG (always WARN on fail) -# 2) published — current F-Droid suggested APK -# 3) older / milestones — releases near **3 / 6 / 9 / 12 months** (WARN only) -# 4) master — tip (always WARN on fail) +# 1) stable-self — **only** self-build of current published stable (WARN) +# 2) published — current F-Droid APK +# 3) older — F-Droid APKs ~3/6/9/12 mo if still hosted (WARN, no self-build) +# 4) master — tip self-build (WARN) # -# Severity (released apps): -# Age ≤ BLOCKER_MAX_AGE_DAYS (default **14**) → **BLOCKER** on fail (FAIL_FAST) -# Age > 14 days, incl. 3/6/9/12‑month samples → **WARN** only -# master / stable-self → WARN +# Self-build policy: current stable + master/fix only — never rebuild old releases. +# +# Severity: age ≤14d → BLOCKER; milestones/older/self-builds → WARN # # Env: # STABLE_TAG, STABLE_APK_URL, STABLE_APK_NAME From d368b40709ba41272014defea6e471ac0f7c3b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:57:42 +0200 Subject: [PATCH 284/326] android-test: document self-build-only-for-current-stable policy in header --- .../android-test/run-android-variant-matrix.sh | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh index 3a2fe30..54fe5dc 100755 --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh +++ b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh @@ -2,15 +2,14 @@ # Run Android wallet smoke against several build / published variants. # # Default order: -# 1) stable-self — rebuild STABLE_TAG (always WARN on fail) -# 2) published — current F-Droid suggested APK -# 3) older / milestones — releases near **3 / 6 / 9 / 12 months** (WARN only) -# 4) master — tip (always WARN on fail) +# 1) stable-self — **only** self-build of current published stable (WARN) +# 2) published — current F-Droid APK +# 3) older — F-Droid APKs ~3/6/9/12 mo if still hosted (WARN, no self-build) +# 4) master — tip self-build (WARN) # -# Severity (released apps): -# Age ≤ BLOCKER_MAX_AGE_DAYS (default **14**) → **BLOCKER** on fail (FAIL_FAST) -# Age > 14 days, incl. 3/6/9/12‑month samples → **WARN** only -# master / stable-self → WARN +# Self-build policy: current stable + master/fix only — never rebuild old releases. +# +# Severity: age ≤14d → BLOCKER; milestones/older/self-builds → WARN # # Env: # STABLE_TAG, STABLE_APK_URL, STABLE_APK_NAME From 2bc94b471bf3e3265faa97239458be1ecf6e4e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:59:01 +0200 Subject: [PATCH 285/326] android-test: smoke is withdraw then pay, both first-class Structure Abheben before Bezahlen with multi-round UI; SMOKE_STRICT requires wallet-core signals for both legs when enabled. --- .../android-test/GUI-AUTOMATION-NOTES.md | 4 +- .../taler-monitoring/android-test/README.md | 12 +- .../android-test/run-android-gui-smoke.sh | 130 +++++---- .../android-test/run-android-pay-smoke.sh | 254 ++++++++++++------ 4 files changed, 261 insertions(+), 139 deletions(-) diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 8326ac9..3248573 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -14,7 +14,7 @@ GUI is planned via flags (below), not required to use these scripts today. | F-Droid published APK install | **done** | `net.taler.wallet.fdroid` 1.6.1 / 854 | | Source build + same smoke | **done** | `:wallet:assembleFdroidDebug` → `net.taler.wallet.fdroid.debug` | | Default source branch | **done** | `dev/hernani-inference/fix-bank-withdraw-auto-exchange` (minimal GOA exchange auto-add) | -| Deep-link entry (≡ QR) | **done** | `adb` `VIEW` `taler://withdraw/…` / `taler://pay…` | +| Deep-link entry (≡ QR) | **done** | `adb` `VIEW` **withdraw then pay** (`taler://withdraw/…` → `taler://pay…`) | | Explorer API mint (chain) | **done** | `run-goa-gui-chain.sh` (GOA + stage) | | UI taps via uiautomator | **done** | best-effort Confirm/ToS/Pay; ANR → prefer **Wait** | | Screenshots / logcat artifacts | **done** | under `out*`, `out-gui-chain/` (gitignored) | @@ -38,7 +38,7 @@ Host-CLI-Settlement. |-----------|----------------| | APK installieren | F-Droid `net.taler.wallet.fdroid` **oder** Source `assembleFdroidDebug` (`.debug`) | | GOA-Exchange ohne manuelles Add | Source-Build von `fix-bank-withdraw-auto-exchange` (Default in `run-android-build-and-smoke.sh`) | -| Withdraw/Pay **starten** | `adb` deep-link `taler://withdraw/…` / `taler://pay…` (Ersatz für QR-Scan) | +| **Abheben + Bezahlen** | Beide Beine: erst Withdraw-URI + Taps, dann Pay-URI + Taps (`DO_WITHDRAW`/`DO_PAY`) | | Light UI-Drive | Confirm / Accept / Pay-Taps über Label-Suche (`lib_ui.py`, gui-smoke, chain) | | Multi-Runden-Taps | `run-android-gui-smoke.sh` / `run-goa-gui-chain.sh` (best-effort) | | Explorer-Mint (Kette) | `run-goa-gui-chain.sh` + `EXP_PW_FILE` (GOA + stage) | diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 7b2b0e3..d91d82c 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -1,12 +1,18 @@ -# Android pay smoke +# Android wallet smoke (Abheben + Bezahlen) -## 1) Published F-Droid wallet +Smokes always cover **withdraw first**, then **pay** (not pay-only). + +| Leg | Default | What | +|-----|---------|------| +| **1 WITHDRAW** | on | `taler://withdraw/…` + Confirm/Abheben taps | +| **2 PAY** | on | `taler://pay…` / pay-template + Pay taps | ```bash # needs adb device/emulator ./run-android-pay-smoke.sh STACK=goa ./run-android-pay-smoke.sh -STACK=stage ./run-android-pay-smoke.sh +DO_PAY=0 ./run-android-pay-smoke.sh # withdraw only +DO_WITHDRAW=0 DO_PAY=1 … # pay only (unusual) ``` APK: F-Droid **1.6.1** (`net.taler.wallet.fdroid`). diff --git a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh index f52c783..a22f1b8 100755 --- a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh @@ -1,12 +1,9 @@ #!/usr/bin/env bash -# GUI-oriented Android smoke (vanilla level): deep-link entry + UI taps. +# GUI-oriented Android smoke: **withdraw (Abheben)** then **pay**, each with +# deep-link entry + multi-round uiautomator taps. Not pay-only. # -# Entry shortcut (legitimate — same as scanning a QR on the landing): -# adb VIEW taler://withdraw/… and taler://pay… -# Then drives the **graphical UI** via uiautomator dump + taps (Confirm / ToS / Pay). -# -# Systems: STACK=goa | stage | auto -# APK: published F-Droid or APK_PATH=… (from-source) +# 1) taler://withdraw/… + Confirm/Abheben/ToS taps +# 2) taler://pay… + Pay/Confirm taps # # See GUI-AUTOMATION-NOTES.md # @@ -85,20 +82,28 @@ echo "apk: $APK_PATH pkg: $PKG" "${ADB[@]}" shell settings put global transition_animation_scale 0 || true "${ADB[@]}" shell settings put global animator_duration_scale 0 || true -WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")') -[ -n "$WURI" ] || { echo "no withdraw uri" >&2; exit 4; } -echo "withdraw: $WURI" -echo "$WURI" >"$OUT_DIR/last-withdraw.uri" +: "${DO_WITHDRAW:=1}" +: "${DO_PAY:=1}" +: "${SMOKE_STRICT:=0}" + +WURI="" +PAY_URI="" +WITHDRAW_OK=0 +PAY_OK=0 "${ADB[@]}" shell am force-stop "$PKG" 2>/dev/null || true "${ADB[@]}" logcat -c 2>/dev/null || true -# --- Shortcut A: deep-link withdraw (≡ QR scan on landing) --- -"${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" | tee "$OUT_DIR/start-withdraw.txt" -sleep 5 - -# --- Graphical: drive UI --- -python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY' +if [ "$DO_WITHDRAW" = "1" ]; then + echo + echo "======== 1 WITHDRAW (Abheben) ========" + WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")') + [ -n "$WURI" ] || { echo "no withdraw uri" >&2; exit 4; } + echo "withdraw: $WURI" + echo "$WURI" >"$OUT_DIR/last-withdraw.uri" + "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" | tee "$OUT_DIR/start-withdraw.txt" + sleep 5 + python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY' import json, sys, importlib.util from pathlib import Path lib_path, serial, out, rounds, sleep_s = ( @@ -112,26 +117,28 @@ st = lib.gui_drive(serial, out / "gui-withdraw", rounds=rounds, sleep_s=sleep_s, print("gui-withdraw taps:", st.get("taps")) print("gui-withdraw anr:", st.get("anr_dismissals")) PY - -# Pay URI -PAY_URI="" -if [ "$STACK" = "stage" ]; then - if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \ - "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then - PAY_URI=$(printf '%s' "$RESP" | python3 -c 'import json,sys;d=json.load(sys.stdin);oid=d.get("order_id")or"";tok=d.get("token")or""; -print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok) if oid and tok else "")') - fi else - PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana" + echo "======== 1 WITHDRAW skipped (DO_WITHDRAW=0) ========" fi -if [ -n "$PAY_URI" ]; then - echo "pay: $PAY_URI" - echo "$PAY_URI" >"$OUT_DIR/last-pay.uri" - # Shortcut B: deep-link pay (≡ scan pay QR / open paywall link) - "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" | tee "$OUT_DIR/start-pay.txt" - sleep 5 - python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY' +if [ "$DO_PAY" = "1" ]; then + echo + echo "======== 2 PAY (Bezahlen) ========" + if [ "$STACK" = "stage" ]; then + if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \ + "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then + PAY_URI=$(printf '%s' "$RESP" | python3 -c 'import json,sys;d=json.load(sys.stdin);oid=d.get("order_id")or"";tok=d.get("token")or""; +print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok) if oid and tok else "")') + fi + else + PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana" + fi + if [ -n "$PAY_URI" ]; then + echo "pay: $PAY_URI" + echo "$PAY_URI" >"$OUT_DIR/last-pay.uri" + "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" | tee "$OUT_DIR/start-pay.txt" + sleep 5 + python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY' import json, sys, importlib.util from pathlib import Path lib_path, serial, out, rounds, sleep_s = ( @@ -145,29 +152,50 @@ st = lib.gui_drive(serial, out / "gui-pay", rounds=rounds, sleep_s=sleep_s, phas print("gui-pay taps:", st.get("taps")) print("gui-pay anr:", st.get("anr_dismissals")) PY + else + echo "pay: (no URI)" + fi +else + echo "======== 2 PAY skipped (DO_PAY=0) ========" fi -"${ADB[@]}" logcat -d -t 400 >"$OUT_DIR/logcat.txt" || true -grep -iE 'taler-wallet|prepareBank|preparePay|confirm|Error|success' "$OUT_DIR/logcat.txt" \ - | tail -100 >"$OUT_DIR/logcat-taler.txt" || true +"${ADB[@]}" logcat -d -t 500 >"$OUT_DIR/logcat.txt" || true +grep -iE 'taler-wallet|prepareBank|acceptWithdrawal|confirmWithdrawal|preparePay|confirmPay|Error|success|withdraw|pay' \ + "$OUT_DIR/logcat.txt" | tail -100 >"$OUT_DIR/logcat-taler.txt" || true +if [ "$DO_WITHDRAW" = "1" ] && grep -qE 'prepareBankIntegratedWithdrawal|acceptWithdrawal|confirmWithdrawal' \ + "$OUT_DIR/logcat.txt" 2>/dev/null; then + WITHDRAW_OK=1 +fi +if [ "$DO_PAY" = "1" ] && [ -n "$PAY_URI" ] && \ + grep -qiE 'preparePay|confirmPay|preparePurchase|checkPay|pay-template|PayForTemplate' \ + "$OUT_DIR/logcat.txt" 2>/dev/null; then + PAY_OK=1 +fi + +rc=0 echo -echo "=== GUI smoke summary ($STACK) ===" -echo "package: $PKG" -echo "shortcut withdraw URI: delivered" -echo "GUI withdraw: see $OUT_DIR/gui-withdraw-status.json + screenshots" -[ -n "$PAY_URI" ] && echo "shortcut pay URI: delivered" -[ -n "$PAY_URI" ] && echo "GUI pay: see $OUT_DIR/gui-pay-status.json + screenshots" -if grep -q prepareBankIntegratedWithdrawal "$OUT_DIR/logcat.txt" 2>/dev/null; then - echo "wallet-core: prepareBankIntegratedWithdrawal YES" -else - echo "wallet-core: prepareBankIntegratedWithdrawal no (ANR/slow UI?)" +echo "=== GUI smoke summary ($STACK) — withdraw + pay ===" +echo "package: $PKG DO_WITHDRAW=$DO_WITHDRAW DO_PAY=$DO_PAY STRICT=$SMOKE_STRICT" +if [ "$DO_WITHDRAW" = "1" ]; then + echo "withdraw URI: delivered → $OUT_DIR/gui-withdraw-status.json" + echo "withdraw wallet-core: $([ "$WITHDRAW_OK" = 1 ] && echo YES || echo NO)" + if [ "$SMOKE_STRICT" = "1" ] && [ "$WITHDRAW_OK" != "1" ]; then + echo "FAIL: strict withdraw missing"; rc=11 + fi fi -if grep -qiE 'preparePay|confirmPay|pay-template|preparePurchase' "$OUT_DIR/logcat.txt" 2>/dev/null; then - echo "wallet-core: pay path YES" -else - echo "wallet-core: pay path not confirmed in logcat" +if [ "$DO_PAY" = "1" ]; then + echo "pay URI: ${PAY_URI:-none} → $OUT_DIR/gui-pay-status.json" + echo "pay wallet-core: $([ "$PAY_OK" = 1 ] && echo YES || echo NO)" + if [ "$SMOKE_STRICT" = "1" ] && [ "$PAY_OK" != "1" ]; then + echo "FAIL: strict pay missing"; [ "$rc" -eq 0 ] && rc=13 + fi +fi +if grep -qiE "isn't responding|not responding" "$OUT_DIR/logcat.txt" 2>/dev/null \ + || grep -rqiE "isn't responding" "$OUT_DIR/gui-withdraw" "$OUT_DIR/gui-pay" 2>/dev/null; then + echo "FAIL: ANR"; rc=10 fi echo "artifacts: $OUT_DIR/" ls -la "$OUT_DIR" | sed 's/^/ /' echo "Documented shortcuts → GUI-AUTOMATION-NOTES.md" +exit "$rc" diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh index a5a85b5..48ec49f 100755 --- a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh @@ -1,22 +1,20 @@ #!/usr/bin/env bash -# Android wallet smoke: install APK (published F-Droid **or** from-source), open -# withdraw/pay URIs via adb. +# Android wallet smoke: install APK, then **withdraw (Abheben)** and **pay** via +# deep-link + best-effort UI taps. Both legs are first-class (not pay-only). # -# No root required if: -# - adb is available (package android-tools-adb / platform-tools) -# - a device or emulator is connected (`adb devices` shows "device") -# - optional user-local SDK under $HOME/Android/Sdk (emulator) +# Flow (default both on): +# 1) WITHDRAW taler://withdraw/… from bank demo-withdraw.json + confirm taps +# 2) PAY taler://pay… / pay-template + optional taps +# +# Env: +# DO_WITHDRAW=1 (default) DO_PAY=1 (default) +# SMOKE_STRICT=1 → require wallet-core withdraw (+ pay if DO_PAY) + no ANR +# WITHDRAW_GUI_ROUNDS multi-round Abheben taps (default 6) # # Usage: -# ./android-test/run-android-pay-smoke.sh # published F-Droid APK +# ./android-test/run-android-pay-smoke.sh # STACK=goa ./android-test/run-android-pay-smoke.sh -# STACK=stage ./android-test/run-android-pay-smoke.sh -# # from-source (built first): -# ./android-test/run-android-build-and-smoke.sh -# APK_PATH=/path/to.apk PKG=net.taler.wallet.fdroid.debug ./run-android-pay-smoke.sh -# -# Published APK (default): F-Droid Taler Wallet 1.6.1 (854) -# https://f-droid.org/packages/net.taler.wallet.fdroid/ +# DO_PAY=0 ./android-test/run-android-pay-smoke.sh # withdraw-only # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) @@ -98,32 +96,63 @@ fi echo "apk: $APK_PATH ($(wc -c <"$APK_PATH") bytes)" echo "pkg: $PKG" +: "${DO_WITHDRAW:=1}" +: "${DO_PAY:=1}" +: "${WITHDRAW_GUI_ROUNDS:=6}" +: "${PAY_GUI_ROUNDS:=4}" +: "${SMOKE_STRICT:=0}" + "${ADB[@]}" wait-for-device "${ADB[@]}" install -r "$APK_PATH" "${ADB[@]}" shell settings put global window_animation_scale 0 || true "${ADB[@]}" shell settings put global transition_animation_scale 0 || true "${ADB[@]}" shell settings put global animator_duration_scale 0 || true -# Withdraw URI from live mint -WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")') -if [ -z "$WURI" ]; then - echo "FAIL: no taler_withdraw_uri from $WITHDRAW_JSON" >&2 - exit 4 -fi -echo "withdraw: $WURI" -echo "$WURI" >"$OUT_DIR/last-withdraw.uri" - "${ADB[@]}" shell am force-stop "$PKG" 2>/dev/null || true "${ADB[@]}" logcat -c 2>/dev/null || true -"${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" 2>&1 | tee "$OUT_DIR/start-withdraw.txt" -sleep 12 -"${ADB[@]}" exec-out screencap -p >"$OUT_DIR/01-after-withdraw-uri.png" || true -"${ADB[@]}" shell uiautomator dump /sdcard/ui.xml 2>/dev/null || true -"${ADB[@]}" pull /sdcard/ui.xml "$OUT_DIR/01-ui.xml" 2>/dev/null || true +WURI="" +PAY_URI="" +WITHDRAW_OK=0 +PAY_OK=0 -# Best-effort: tap common confirm labels -python3 - "$SERIAL" "$OUT_DIR/01-ui.xml" <<'PY' || true +# ═══════════════════════════════════════════════════════════════ +# 1) WITHDRAW / Abheben (required by default — not pay-only) +# ═══════════════════════════════════════════════════════════════ +if [ "$DO_WITHDRAW" = "1" ]; then + echo + echo "======== 1 WITHDRAW (Abheben) ========" + WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")') + if [ -z "$WURI" ]; then + echo "FAIL: no taler_withdraw_uri from $WITHDRAW_JSON" >&2 + exit 4 + fi + echo "withdraw URI: $WURI" + echo "$WURI" >"$OUT_DIR/last-withdraw.uri" + + "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" 2>&1 | tee "$OUT_DIR/start-withdraw.txt" + sleep 6 + "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/01-after-withdraw-uri.png" || true + "${ADB[@]}" shell uiautomator dump /sdcard/ui.xml 2>/dev/null || true + "${ADB[@]}" pull /sdcard/ui.xml "$OUT_DIR/01-ui.xml" 2>/dev/null || true + + # Multi-round confirm / Abheben / ToS (same idea as GUI smoke) + if [ -f "$ROOT/lib_ui.py" ]; then + python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$WITHDRAW_GUI_ROUNDS" 2 <<'PY' || true +import json, sys, importlib.util +from pathlib import Path +lib_path, serial, out, rounds, sleep_s = ( + Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5]) +) +spec = importlib.util.spec_from_file_location("lib_ui", lib_path) +lib = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lib) +st = lib.gui_drive(serial, out / "gui-withdraw", rounds=rounds, sleep_s=sleep_s, phase="wd") +(out / "gui-withdraw-status.json").write_text(json.dumps(st, indent=2)) +print("withdraw GUI taps:", st.get("taps"), "anr:", st.get("anr_dismissals")) +PY + else + python3 - "$SERIAL" "$OUT_DIR/01-ui.xml" <<'PY' || true import re, subprocess, sys serial, path = sys.argv[1], sys.argv[2] try: @@ -131,31 +160,46 @@ try: except FileNotFoundError: raise SystemExit(0) labels = [ - "Confirm", "Withdraw", "Accept", "Continue", "OK", - "I accept", "Agree", "Bestätigen", "Abheben", "Akzeptieren", + "Confirm", "Confirm withdrawal", "Withdraw", "Accept", "Continue", "OK", + "I accept", "Agree", "Bestätigen", "Abheben", "Akzeptieren", "Retirer", ] for label in labels: for pat in ( r'text="%s"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' % re.escape(label), r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*text="%s"' % re.escape(label), ): - for m in re.finditer(pat, xml): + for m in re.finditer(pat, xml, flags=re.I): x1, y1, x2, y2 = map(int, m.groups()) x, y = (x1 + x2) // 2, (y1 + y2) // 2 print(f"tap {label} @ {x},{y}") subprocess.run(["adb", "-s", serial, "shell", "input", "tap", str(x), str(y)], check=False) PY -sleep 8 -"${ADB[@]}" exec-out screencap -p >"$OUT_DIR/02-after-confirm-tap.png" || true + sleep 8 + fi + "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/02-after-withdraw-taps.png" || true + # Mid-flow logcat: withdraw must register before pay + "${ADB[@]}" logcat -d -t 400 >"$OUT_DIR/logcat-after-withdraw.txt" || true + if grep -qE 'prepareBankIntegratedWithdrawal|acceptWithdrawal|confirmWithdrawal' \ + "$OUT_DIR/logcat-after-withdraw.txt" 2>/dev/null; then + WITHDRAW_OK=1 + echo "withdraw: wallet-core activity YES" + else + echo "withdraw: wallet-core activity NOT seen yet (onboarding/ANR/slow?)" + fi +else + echo "======== 1 WITHDRAW skipped (DO_WITHDRAW=0) ========" +fi -# Optional public pay template (stage fixed product / GOA shop if amount known) -# Stage: open monnaies shop is browser-side; deep link pay after template POST if available. -PAY_URI="" -if [ "$STACK" = "stage" ]; then - # fixed template panier-legumes → taler://pay/… - if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \ - "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then - PAY_URI=$(printf '%s' "$RESP" | python3 -c ' +# ═══════════════════════════════════════════════════════════════ +# 2) PAY (after withdraw; still first-class, but second leg) +# ═══════════════════════════════════════════════════════════════ +if [ "$DO_PAY" = "1" ]; then + echo + echo "======== 2 PAY (Bezahlen) ========" + if [ "$STACK" = "stage" ]; then + if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \ + "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then + PAY_URI=$(printf '%s' "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) oid=d.get("order_id") or "" @@ -163,58 +207,102 @@ tok=d.get("token") or "" if oid and tok: print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok)) ' 2>/dev/null || true) + fi + elif [ "$STACK" = "goa" ]; then + PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana" fi -elif [ "$STACK" = "goa" ]; then - # Paivana pay-template deep link (wallet prepares order) - PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana" -fi -if [ -n "$PAY_URI" ]; then - echo "pay: $PAY_URI ($PAY_HINT)" - echo "$PAY_URI" >"$OUT_DIR/last-pay.uri" - "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" 2>&1 | tee "$OUT_DIR/start-pay.txt" - sleep 12 - "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/03-after-pay-uri.png" || true + if [ -n "$PAY_URI" ]; then + echo "pay URI: $PAY_URI ($PAY_HINT)" + echo "$PAY_URI" >"$OUT_DIR/last-pay.uri" + "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" 2>&1 | tee "$OUT_DIR/start-pay.txt" + sleep 6 + if [ -f "$ROOT/lib_ui.py" ]; then + python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$PAY_GUI_ROUNDS" 2 <<'PY' || true +import json, sys, importlib.util +from pathlib import Path +lib_path, serial, out, rounds, sleep_s = ( + Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5]) +) +spec = importlib.util.spec_from_file_location("lib_ui", lib_path) +lib = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lib) +st = lib.gui_drive(serial, out / "gui-pay", rounds=rounds, sleep_s=sleep_s, phase="pay") +(out / "gui-pay-status.json").write_text(json.dumps(st, indent=2)) +print("pay GUI taps:", st.get("taps"), "anr:", st.get("anr_dismissals")) +PY + fi + "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/03-after-pay-uri.png" || true + else + echo "pay: (no public template URI for stack=$STACK)" + fi else - echo "pay: (skipped — no public template order)" + echo "======== 2 PAY skipped (DO_PAY=0) ========" fi -# Evidence from logcat -"${ADB[@]}" logcat -d -t 300 >"$OUT_DIR/logcat.txt" || true -grep -iE 'taler-wallet|prepareBank|preparePay|confirm|error|Error|success' "$OUT_DIR/logcat.txt" \ - | tail -80 >"$OUT_DIR/logcat-taler.txt" || true +# Final evidence +"${ADB[@]}" logcat -d -t 500 >"$OUT_DIR/logcat.txt" || true +grep -iE 'taler-wallet|prepareBank|acceptWithdrawal|confirmWithdrawal|preparePay|confirmPay|error|Error|success|withdraw|pay' \ + "$OUT_DIR/logcat.txt" | tail -100 >"$OUT_DIR/logcat-taler.txt" || true + +# Re-evaluate withdraw/pay from full logcat +if [ "$DO_WITHDRAW" = "1" ]; then + if grep -qE 'prepareBankIntegratedWithdrawal|acceptWithdrawal|confirmWithdrawal' \ + "$OUT_DIR/logcat.txt" 2>/dev/null; then + WITHDRAW_OK=1 + fi +fi +if [ "$DO_PAY" = "1" ] && [ -n "$PAY_URI" ]; then + if grep -qiE 'preparePay|confirmPay|preparePurchase|checkPay|pay-template|PayForTemplate' \ + "$OUT_DIR/logcat.txt" 2>/dev/null; then + PAY_OK=1 + fi +fi -# SMOKE_STRICT=1 (matrix gate default): ANR or missing wallet-core withdraw = hard fail -: "${SMOKE_STRICT:=0}" rc=0 echo -echo "=== summary ===" -echo "package installed: $PKG" -echo "withdraw intent delivered: $WURI" -if grep -qiE "isn't responding|isn.t responding|System UI isn" "$OUT_DIR/01-ui.xml" 2>/dev/null \ - || grep -qiE "isn't responding|not responding" "$OUT_DIR/logcat.txt" 2>/dev/null; then - echo "FAIL: System UI / app ANR (host too small or emulator hung)" - rc=10 -fi -if grep -q 'prepareBankIntegratedWithdrawal' "$OUT_DIR/logcat.txt" 2>/dev/null; then - echo "wallet-core: prepareBankIntegratedWithdrawal seen (wallet accepted withdraw URI)" -else - echo "wallet-core: prepareBankIntegratedWithdrawal NOT seen (UI may still show onboarding/ANR on small RAM)" - if [ "$SMOKE_STRICT" = "1" ]; then - echo "FAIL: SMOKE_STRICT=1 requires prepareBankIntegratedWithdrawal in logcat" - rc=11 - fi -fi -if [ -n "$PAY_URI" ]; then - if grep -qiE 'preparePay|confirmPay|pay-template|preparePurchase' "$OUT_DIR/logcat.txt" 2>/dev/null; then - echo "wallet-core: pay path activity seen" +echo "=== summary (withdraw + pay) ===" +echo "package: $PKG stack: $STACK" +echo "DO_WITHDRAW=$DO_WITHDRAW DO_PAY=$DO_PAY SMOKE_STRICT=$SMOKE_STRICT" +if [ "$DO_WITHDRAW" = "1" ]; then + echo "withdraw URI: ${WURI:-(none)}" + if [ "$WITHDRAW_OK" = "1" ]; then + echo "withdraw wallet-core: YES" else - echo "wallet-core: pay path not confirmed in logcat (check $OUT_DIR/03-after-pay-uri.png)" + echo "withdraw wallet-core: NO" + if [ "$SMOKE_STRICT" = "1" ]; then + echo "FAIL: SMOKE_STRICT requires withdraw (prepareBank/accept/confirm) in logcat" + rc=11 + fi fi fi +if [ "$DO_PAY" = "1" ]; then + echo "pay URI: ${PAY_URI:-(none)}" + if [ -z "$PAY_URI" ]; then + echo "pay: no URI (template/API)" + if [ "$SMOKE_STRICT" = "1" ]; then + echo "FAIL: SMOKE_STRICT requires a pay URI when DO_PAY=1" + rc=12 + fi + elif [ "$PAY_OK" = "1" ]; then + echo "pay wallet-core: YES" + else + echo "pay wallet-core: NO (intent may still have been delivered)" + if [ "$SMOKE_STRICT" = "1" ]; then + echo "FAIL: SMOKE_STRICT requires pay path activity in logcat" + [ "$rc" -eq 0 ] && rc=13 + fi + fi +fi +if grep -qiE "isn't responding|isn.t responding|System UI isn" "$OUT_DIR/01-ui.xml" 2>/dev/null \ + || grep -qiE "isn't responding|not responding" "$OUT_DIR/logcat.txt" 2>/dev/null \ + || grep -rqiE "isn't responding" "$OUT_DIR/gui-withdraw" 2>/dev/null; then + echo "FAIL: System UI / app ANR" + rc=10 +fi echo "artifacts: $OUT_DIR/" ls -la "$OUT_DIR" | sed 's/^/ /' echo -echo "NOTE: Full unattended UI payment needs a responsive device/emulator (≥6–8 GiB host RAM recommended)." -echo "This smoke proves: APK install + deep-link delivery on stack=$STACK (strict=$SMOKE_STRICT rc=$rc)." +echo "NOTE: Full unattended Abheben+Bezahlen needs responsive device (≥6–8 GiB host RAM)." +echo "Smoke legs: withdraw=$( [ "$DO_WITHDRAW" = 1 ] && echo on || echo off) pay=$( [ "$DO_PAY" = 1 ] && echo on || echo off) rc=$rc" exit "$rc" From 83a3369dd56246594717e40210756610426f81dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 21:59:01 +0200 Subject: [PATCH 286/326] android-test: smoke is withdraw then pay, both first-class Structure Abheben before Bezahlen with multi-round UI; SMOKE_STRICT requires wallet-core signals for both legs when enabled. --- .../android-test/GUI-AUTOMATION-NOTES.md | 4 +- .../taler-monitoring/android-test/README.md | 12 +- .../android-test/run-android-gui-smoke.sh | 130 +++++---- .../android-test/run-android-pay-smoke.sh | 254 ++++++++++++------ 4 files changed, 261 insertions(+), 139 deletions(-) diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 8326ac9..3248573 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -14,7 +14,7 @@ GUI is planned via flags (below), not required to use these scripts today. | F-Droid published APK install | **done** | `net.taler.wallet.fdroid` 1.6.1 / 854 | | Source build + same smoke | **done** | `:wallet:assembleFdroidDebug` → `net.taler.wallet.fdroid.debug` | | Default source branch | **done** | `dev/hernani-inference/fix-bank-withdraw-auto-exchange` (minimal GOA exchange auto-add) | -| Deep-link entry (≡ QR) | **done** | `adb` `VIEW` `taler://withdraw/…` / `taler://pay…` | +| Deep-link entry (≡ QR) | **done** | `adb` `VIEW` **withdraw then pay** (`taler://withdraw/…` → `taler://pay…`) | | Explorer API mint (chain) | **done** | `run-goa-gui-chain.sh` (GOA + stage) | | UI taps via uiautomator | **done** | best-effort Confirm/ToS/Pay; ANR → prefer **Wait** | | Screenshots / logcat artifacts | **done** | under `out*`, `out-gui-chain/` (gitignored) | @@ -38,7 +38,7 @@ Host-CLI-Settlement. |-----------|----------------| | APK installieren | F-Droid `net.taler.wallet.fdroid` **oder** Source `assembleFdroidDebug` (`.debug`) | | GOA-Exchange ohne manuelles Add | Source-Build von `fix-bank-withdraw-auto-exchange` (Default in `run-android-build-and-smoke.sh`) | -| Withdraw/Pay **starten** | `adb` deep-link `taler://withdraw/…` / `taler://pay…` (Ersatz für QR-Scan) | +| **Abheben + Bezahlen** | Beide Beine: erst Withdraw-URI + Taps, dann Pay-URI + Taps (`DO_WITHDRAW`/`DO_PAY`) | | Light UI-Drive | Confirm / Accept / Pay-Taps über Label-Suche (`lib_ui.py`, gui-smoke, chain) | | Multi-Runden-Taps | `run-android-gui-smoke.sh` / `run-goa-gui-chain.sh` (best-effort) | | Explorer-Mint (Kette) | `run-goa-gui-chain.sh` + `EXP_PW_FILE` (GOA + stage) | diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md index 7b2b0e3..d91d82c 100644 --- a/scripts/taler-monitoring/android-test/README.md +++ b/scripts/taler-monitoring/android-test/README.md @@ -1,12 +1,18 @@ -# Android pay smoke +# Android wallet smoke (Abheben + Bezahlen) -## 1) Published F-Droid wallet +Smokes always cover **withdraw first**, then **pay** (not pay-only). + +| Leg | Default | What | +|-----|---------|------| +| **1 WITHDRAW** | on | `taler://withdraw/…` + Confirm/Abheben taps | +| **2 PAY** | on | `taler://pay…` / pay-template + Pay taps | ```bash # needs adb device/emulator ./run-android-pay-smoke.sh STACK=goa ./run-android-pay-smoke.sh -STACK=stage ./run-android-pay-smoke.sh +DO_PAY=0 ./run-android-pay-smoke.sh # withdraw only +DO_WITHDRAW=0 DO_PAY=1 … # pay only (unusual) ``` APK: F-Droid **1.6.1** (`net.taler.wallet.fdroid`). diff --git a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh index f52c783..a22f1b8 100755 --- a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh @@ -1,12 +1,9 @@ #!/usr/bin/env bash -# GUI-oriented Android smoke (vanilla level): deep-link entry + UI taps. +# GUI-oriented Android smoke: **withdraw (Abheben)** then **pay**, each with +# deep-link entry + multi-round uiautomator taps. Not pay-only. # -# Entry shortcut (legitimate — same as scanning a QR on the landing): -# adb VIEW taler://withdraw/… and taler://pay… -# Then drives the **graphical UI** via uiautomator dump + taps (Confirm / ToS / Pay). -# -# Systems: STACK=goa | stage | auto -# APK: published F-Droid or APK_PATH=… (from-source) +# 1) taler://withdraw/… + Confirm/Abheben/ToS taps +# 2) taler://pay… + Pay/Confirm taps # # See GUI-AUTOMATION-NOTES.md # @@ -85,20 +82,28 @@ echo "apk: $APK_PATH pkg: $PKG" "${ADB[@]}" shell settings put global transition_animation_scale 0 || true "${ADB[@]}" shell settings put global animator_duration_scale 0 || true -WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")') -[ -n "$WURI" ] || { echo "no withdraw uri" >&2; exit 4; } -echo "withdraw: $WURI" -echo "$WURI" >"$OUT_DIR/last-withdraw.uri" +: "${DO_WITHDRAW:=1}" +: "${DO_PAY:=1}" +: "${SMOKE_STRICT:=0}" + +WURI="" +PAY_URI="" +WITHDRAW_OK=0 +PAY_OK=0 "${ADB[@]}" shell am force-stop "$PKG" 2>/dev/null || true "${ADB[@]}" logcat -c 2>/dev/null || true -# --- Shortcut A: deep-link withdraw (≡ QR scan on landing) --- -"${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" | tee "$OUT_DIR/start-withdraw.txt" -sleep 5 - -# --- Graphical: drive UI --- -python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY' +if [ "$DO_WITHDRAW" = "1" ]; then + echo + echo "======== 1 WITHDRAW (Abheben) ========" + WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")') + [ -n "$WURI" ] || { echo "no withdraw uri" >&2; exit 4; } + echo "withdraw: $WURI" + echo "$WURI" >"$OUT_DIR/last-withdraw.uri" + "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" | tee "$OUT_DIR/start-withdraw.txt" + sleep 5 + python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY' import json, sys, importlib.util from pathlib import Path lib_path, serial, out, rounds, sleep_s = ( @@ -112,26 +117,28 @@ st = lib.gui_drive(serial, out / "gui-withdraw", rounds=rounds, sleep_s=sleep_s, print("gui-withdraw taps:", st.get("taps")) print("gui-withdraw anr:", st.get("anr_dismissals")) PY - -# Pay URI -PAY_URI="" -if [ "$STACK" = "stage" ]; then - if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \ - "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then - PAY_URI=$(printf '%s' "$RESP" | python3 -c 'import json,sys;d=json.load(sys.stdin);oid=d.get("order_id")or"";tok=d.get("token")or""; -print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok) if oid and tok else "")') - fi else - PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana" + echo "======== 1 WITHDRAW skipped (DO_WITHDRAW=0) ========" fi -if [ -n "$PAY_URI" ]; then - echo "pay: $PAY_URI" - echo "$PAY_URI" >"$OUT_DIR/last-pay.uri" - # Shortcut B: deep-link pay (≡ scan pay QR / open paywall link) - "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" | tee "$OUT_DIR/start-pay.txt" - sleep 5 - python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY' +if [ "$DO_PAY" = "1" ]; then + echo + echo "======== 2 PAY (Bezahlen) ========" + if [ "$STACK" = "stage" ]; then + if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \ + "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then + PAY_URI=$(printf '%s' "$RESP" | python3 -c 'import json,sys;d=json.load(sys.stdin);oid=d.get("order_id")or"";tok=d.get("token")or""; +print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok) if oid and tok else "")') + fi + else + PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana" + fi + if [ -n "$PAY_URI" ]; then + echo "pay: $PAY_URI" + echo "$PAY_URI" >"$OUT_DIR/last-pay.uri" + "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" | tee "$OUT_DIR/start-pay.txt" + sleep 5 + python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY' import json, sys, importlib.util from pathlib import Path lib_path, serial, out, rounds, sleep_s = ( @@ -145,29 +152,50 @@ st = lib.gui_drive(serial, out / "gui-pay", rounds=rounds, sleep_s=sleep_s, phas print("gui-pay taps:", st.get("taps")) print("gui-pay anr:", st.get("anr_dismissals")) PY + else + echo "pay: (no URI)" + fi +else + echo "======== 2 PAY skipped (DO_PAY=0) ========" fi -"${ADB[@]}" logcat -d -t 400 >"$OUT_DIR/logcat.txt" || true -grep -iE 'taler-wallet|prepareBank|preparePay|confirm|Error|success' "$OUT_DIR/logcat.txt" \ - | tail -100 >"$OUT_DIR/logcat-taler.txt" || true +"${ADB[@]}" logcat -d -t 500 >"$OUT_DIR/logcat.txt" || true +grep -iE 'taler-wallet|prepareBank|acceptWithdrawal|confirmWithdrawal|preparePay|confirmPay|Error|success|withdraw|pay' \ + "$OUT_DIR/logcat.txt" | tail -100 >"$OUT_DIR/logcat-taler.txt" || true +if [ "$DO_WITHDRAW" = "1" ] && grep -qE 'prepareBankIntegratedWithdrawal|acceptWithdrawal|confirmWithdrawal' \ + "$OUT_DIR/logcat.txt" 2>/dev/null; then + WITHDRAW_OK=1 +fi +if [ "$DO_PAY" = "1" ] && [ -n "$PAY_URI" ] && \ + grep -qiE 'preparePay|confirmPay|preparePurchase|checkPay|pay-template|PayForTemplate' \ + "$OUT_DIR/logcat.txt" 2>/dev/null; then + PAY_OK=1 +fi + +rc=0 echo -echo "=== GUI smoke summary ($STACK) ===" -echo "package: $PKG" -echo "shortcut withdraw URI: delivered" -echo "GUI withdraw: see $OUT_DIR/gui-withdraw-status.json + screenshots" -[ -n "$PAY_URI" ] && echo "shortcut pay URI: delivered" -[ -n "$PAY_URI" ] && echo "GUI pay: see $OUT_DIR/gui-pay-status.json + screenshots" -if grep -q prepareBankIntegratedWithdrawal "$OUT_DIR/logcat.txt" 2>/dev/null; then - echo "wallet-core: prepareBankIntegratedWithdrawal YES" -else - echo "wallet-core: prepareBankIntegratedWithdrawal no (ANR/slow UI?)" +echo "=== GUI smoke summary ($STACK) — withdraw + pay ===" +echo "package: $PKG DO_WITHDRAW=$DO_WITHDRAW DO_PAY=$DO_PAY STRICT=$SMOKE_STRICT" +if [ "$DO_WITHDRAW" = "1" ]; then + echo "withdraw URI: delivered → $OUT_DIR/gui-withdraw-status.json" + echo "withdraw wallet-core: $([ "$WITHDRAW_OK" = 1 ] && echo YES || echo NO)" + if [ "$SMOKE_STRICT" = "1" ] && [ "$WITHDRAW_OK" != "1" ]; then + echo "FAIL: strict withdraw missing"; rc=11 + fi fi -if grep -qiE 'preparePay|confirmPay|pay-template|preparePurchase' "$OUT_DIR/logcat.txt" 2>/dev/null; then - echo "wallet-core: pay path YES" -else - echo "wallet-core: pay path not confirmed in logcat" +if [ "$DO_PAY" = "1" ]; then + echo "pay URI: ${PAY_URI:-none} → $OUT_DIR/gui-pay-status.json" + echo "pay wallet-core: $([ "$PAY_OK" = 1 ] && echo YES || echo NO)" + if [ "$SMOKE_STRICT" = "1" ] && [ "$PAY_OK" != "1" ]; then + echo "FAIL: strict pay missing"; [ "$rc" -eq 0 ] && rc=13 + fi +fi +if grep -qiE "isn't responding|not responding" "$OUT_DIR/logcat.txt" 2>/dev/null \ + || grep -rqiE "isn't responding" "$OUT_DIR/gui-withdraw" "$OUT_DIR/gui-pay" 2>/dev/null; then + echo "FAIL: ANR"; rc=10 fi echo "artifacts: $OUT_DIR/" ls -la "$OUT_DIR" | sed 's/^/ /' echo "Documented shortcuts → GUI-AUTOMATION-NOTES.md" +exit "$rc" diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh index a5a85b5..48ec49f 100755 --- a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh +++ b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh @@ -1,22 +1,20 @@ #!/usr/bin/env bash -# Android wallet smoke: install APK (published F-Droid **or** from-source), open -# withdraw/pay URIs via adb. +# Android wallet smoke: install APK, then **withdraw (Abheben)** and **pay** via +# deep-link + best-effort UI taps. Both legs are first-class (not pay-only). # -# No root required if: -# - adb is available (package android-tools-adb / platform-tools) -# - a device or emulator is connected (`adb devices` shows "device") -# - optional user-local SDK under $HOME/Android/Sdk (emulator) +# Flow (default both on): +# 1) WITHDRAW taler://withdraw/… from bank demo-withdraw.json + confirm taps +# 2) PAY taler://pay… / pay-template + optional taps +# +# Env: +# DO_WITHDRAW=1 (default) DO_PAY=1 (default) +# SMOKE_STRICT=1 → require wallet-core withdraw (+ pay if DO_PAY) + no ANR +# WITHDRAW_GUI_ROUNDS multi-round Abheben taps (default 6) # # Usage: -# ./android-test/run-android-pay-smoke.sh # published F-Droid APK +# ./android-test/run-android-pay-smoke.sh # STACK=goa ./android-test/run-android-pay-smoke.sh -# STACK=stage ./android-test/run-android-pay-smoke.sh -# # from-source (built first): -# ./android-test/run-android-build-and-smoke.sh -# APK_PATH=/path/to.apk PKG=net.taler.wallet.fdroid.debug ./run-android-pay-smoke.sh -# -# Published APK (default): F-Droid Taler Wallet 1.6.1 (854) -# https://f-droid.org/packages/net.taler.wallet.fdroid/ +# DO_PAY=0 ./android-test/run-android-pay-smoke.sh # withdraw-only # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) @@ -98,32 +96,63 @@ fi echo "apk: $APK_PATH ($(wc -c <"$APK_PATH") bytes)" echo "pkg: $PKG" +: "${DO_WITHDRAW:=1}" +: "${DO_PAY:=1}" +: "${WITHDRAW_GUI_ROUNDS:=6}" +: "${PAY_GUI_ROUNDS:=4}" +: "${SMOKE_STRICT:=0}" + "${ADB[@]}" wait-for-device "${ADB[@]}" install -r "$APK_PATH" "${ADB[@]}" shell settings put global window_animation_scale 0 || true "${ADB[@]}" shell settings put global transition_animation_scale 0 || true "${ADB[@]}" shell settings put global animator_duration_scale 0 || true -# Withdraw URI from live mint -WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")') -if [ -z "$WURI" ]; then - echo "FAIL: no taler_withdraw_uri from $WITHDRAW_JSON" >&2 - exit 4 -fi -echo "withdraw: $WURI" -echo "$WURI" >"$OUT_DIR/last-withdraw.uri" - "${ADB[@]}" shell am force-stop "$PKG" 2>/dev/null || true "${ADB[@]}" logcat -c 2>/dev/null || true -"${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" 2>&1 | tee "$OUT_DIR/start-withdraw.txt" -sleep 12 -"${ADB[@]}" exec-out screencap -p >"$OUT_DIR/01-after-withdraw-uri.png" || true -"${ADB[@]}" shell uiautomator dump /sdcard/ui.xml 2>/dev/null || true -"${ADB[@]}" pull /sdcard/ui.xml "$OUT_DIR/01-ui.xml" 2>/dev/null || true +WURI="" +PAY_URI="" +WITHDRAW_OK=0 +PAY_OK=0 -# Best-effort: tap common confirm labels -python3 - "$SERIAL" "$OUT_DIR/01-ui.xml" <<'PY' || true +# ═══════════════════════════════════════════════════════════════ +# 1) WITHDRAW / Abheben (required by default — not pay-only) +# ═══════════════════════════════════════════════════════════════ +if [ "$DO_WITHDRAW" = "1" ]; then + echo + echo "======== 1 WITHDRAW (Abheben) ========" + WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")') + if [ -z "$WURI" ]; then + echo "FAIL: no taler_withdraw_uri from $WITHDRAW_JSON" >&2 + exit 4 + fi + echo "withdraw URI: $WURI" + echo "$WURI" >"$OUT_DIR/last-withdraw.uri" + + "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" 2>&1 | tee "$OUT_DIR/start-withdraw.txt" + sleep 6 + "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/01-after-withdraw-uri.png" || true + "${ADB[@]}" shell uiautomator dump /sdcard/ui.xml 2>/dev/null || true + "${ADB[@]}" pull /sdcard/ui.xml "$OUT_DIR/01-ui.xml" 2>/dev/null || true + + # Multi-round confirm / Abheben / ToS (same idea as GUI smoke) + if [ -f "$ROOT/lib_ui.py" ]; then + python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$WITHDRAW_GUI_ROUNDS" 2 <<'PY' || true +import json, sys, importlib.util +from pathlib import Path +lib_path, serial, out, rounds, sleep_s = ( + Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5]) +) +spec = importlib.util.spec_from_file_location("lib_ui", lib_path) +lib = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lib) +st = lib.gui_drive(serial, out / "gui-withdraw", rounds=rounds, sleep_s=sleep_s, phase="wd") +(out / "gui-withdraw-status.json").write_text(json.dumps(st, indent=2)) +print("withdraw GUI taps:", st.get("taps"), "anr:", st.get("anr_dismissals")) +PY + else + python3 - "$SERIAL" "$OUT_DIR/01-ui.xml" <<'PY' || true import re, subprocess, sys serial, path = sys.argv[1], sys.argv[2] try: @@ -131,31 +160,46 @@ try: except FileNotFoundError: raise SystemExit(0) labels = [ - "Confirm", "Withdraw", "Accept", "Continue", "OK", - "I accept", "Agree", "Bestätigen", "Abheben", "Akzeptieren", + "Confirm", "Confirm withdrawal", "Withdraw", "Accept", "Continue", "OK", + "I accept", "Agree", "Bestätigen", "Abheben", "Akzeptieren", "Retirer", ] for label in labels: for pat in ( r'text="%s"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' % re.escape(label), r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*text="%s"' % re.escape(label), ): - for m in re.finditer(pat, xml): + for m in re.finditer(pat, xml, flags=re.I): x1, y1, x2, y2 = map(int, m.groups()) x, y = (x1 + x2) // 2, (y1 + y2) // 2 print(f"tap {label} @ {x},{y}") subprocess.run(["adb", "-s", serial, "shell", "input", "tap", str(x), str(y)], check=False) PY -sleep 8 -"${ADB[@]}" exec-out screencap -p >"$OUT_DIR/02-after-confirm-tap.png" || true + sleep 8 + fi + "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/02-after-withdraw-taps.png" || true + # Mid-flow logcat: withdraw must register before pay + "${ADB[@]}" logcat -d -t 400 >"$OUT_DIR/logcat-after-withdraw.txt" || true + if grep -qE 'prepareBankIntegratedWithdrawal|acceptWithdrawal|confirmWithdrawal' \ + "$OUT_DIR/logcat-after-withdraw.txt" 2>/dev/null; then + WITHDRAW_OK=1 + echo "withdraw: wallet-core activity YES" + else + echo "withdraw: wallet-core activity NOT seen yet (onboarding/ANR/slow?)" + fi +else + echo "======== 1 WITHDRAW skipped (DO_WITHDRAW=0) ========" +fi -# Optional public pay template (stage fixed product / GOA shop if amount known) -# Stage: open monnaies shop is browser-side; deep link pay after template POST if available. -PAY_URI="" -if [ "$STACK" = "stage" ]; then - # fixed template panier-legumes → taler://pay/… - if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \ - "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then - PAY_URI=$(printf '%s' "$RESP" | python3 -c ' +# ═══════════════════════════════════════════════════════════════ +# 2) PAY (after withdraw; still first-class, but second leg) +# ═══════════════════════════════════════════════════════════════ +if [ "$DO_PAY" = "1" ]; then + echo + echo "======== 2 PAY (Bezahlen) ========" + if [ "$STACK" = "stage" ]; then + if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \ + "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then + PAY_URI=$(printf '%s' "$RESP" | python3 -c ' import json,sys d=json.load(sys.stdin) oid=d.get("order_id") or "" @@ -163,58 +207,102 @@ tok=d.get("token") or "" if oid and tok: print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok)) ' 2>/dev/null || true) + fi + elif [ "$STACK" = "goa" ]; then + PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana" fi -elif [ "$STACK" = "goa" ]; then - # Paivana pay-template deep link (wallet prepares order) - PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana" -fi -if [ -n "$PAY_URI" ]; then - echo "pay: $PAY_URI ($PAY_HINT)" - echo "$PAY_URI" >"$OUT_DIR/last-pay.uri" - "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" 2>&1 | tee "$OUT_DIR/start-pay.txt" - sleep 12 - "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/03-after-pay-uri.png" || true + if [ -n "$PAY_URI" ]; then + echo "pay URI: $PAY_URI ($PAY_HINT)" + echo "$PAY_URI" >"$OUT_DIR/last-pay.uri" + "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" 2>&1 | tee "$OUT_DIR/start-pay.txt" + sleep 6 + if [ -f "$ROOT/lib_ui.py" ]; then + python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$PAY_GUI_ROUNDS" 2 <<'PY' || true +import json, sys, importlib.util +from pathlib import Path +lib_path, serial, out, rounds, sleep_s = ( + Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5]) +) +spec = importlib.util.spec_from_file_location("lib_ui", lib_path) +lib = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lib) +st = lib.gui_drive(serial, out / "gui-pay", rounds=rounds, sleep_s=sleep_s, phase="pay") +(out / "gui-pay-status.json").write_text(json.dumps(st, indent=2)) +print("pay GUI taps:", st.get("taps"), "anr:", st.get("anr_dismissals")) +PY + fi + "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/03-after-pay-uri.png" || true + else + echo "pay: (no public template URI for stack=$STACK)" + fi else - echo "pay: (skipped — no public template order)" + echo "======== 2 PAY skipped (DO_PAY=0) ========" fi -# Evidence from logcat -"${ADB[@]}" logcat -d -t 300 >"$OUT_DIR/logcat.txt" || true -grep -iE 'taler-wallet|prepareBank|preparePay|confirm|error|Error|success' "$OUT_DIR/logcat.txt" \ - | tail -80 >"$OUT_DIR/logcat-taler.txt" || true +# Final evidence +"${ADB[@]}" logcat -d -t 500 >"$OUT_DIR/logcat.txt" || true +grep -iE 'taler-wallet|prepareBank|acceptWithdrawal|confirmWithdrawal|preparePay|confirmPay|error|Error|success|withdraw|pay' \ + "$OUT_DIR/logcat.txt" | tail -100 >"$OUT_DIR/logcat-taler.txt" || true + +# Re-evaluate withdraw/pay from full logcat +if [ "$DO_WITHDRAW" = "1" ]; then + if grep -qE 'prepareBankIntegratedWithdrawal|acceptWithdrawal|confirmWithdrawal' \ + "$OUT_DIR/logcat.txt" 2>/dev/null; then + WITHDRAW_OK=1 + fi +fi +if [ "$DO_PAY" = "1" ] && [ -n "$PAY_URI" ]; then + if grep -qiE 'preparePay|confirmPay|preparePurchase|checkPay|pay-template|PayForTemplate' \ + "$OUT_DIR/logcat.txt" 2>/dev/null; then + PAY_OK=1 + fi +fi -# SMOKE_STRICT=1 (matrix gate default): ANR or missing wallet-core withdraw = hard fail -: "${SMOKE_STRICT:=0}" rc=0 echo -echo "=== summary ===" -echo "package installed: $PKG" -echo "withdraw intent delivered: $WURI" -if grep -qiE "isn't responding|isn.t responding|System UI isn" "$OUT_DIR/01-ui.xml" 2>/dev/null \ - || grep -qiE "isn't responding|not responding" "$OUT_DIR/logcat.txt" 2>/dev/null; then - echo "FAIL: System UI / app ANR (host too small or emulator hung)" - rc=10 -fi -if grep -q 'prepareBankIntegratedWithdrawal' "$OUT_DIR/logcat.txt" 2>/dev/null; then - echo "wallet-core: prepareBankIntegratedWithdrawal seen (wallet accepted withdraw URI)" -else - echo "wallet-core: prepareBankIntegratedWithdrawal NOT seen (UI may still show onboarding/ANR on small RAM)" - if [ "$SMOKE_STRICT" = "1" ]; then - echo "FAIL: SMOKE_STRICT=1 requires prepareBankIntegratedWithdrawal in logcat" - rc=11 - fi -fi -if [ -n "$PAY_URI" ]; then - if grep -qiE 'preparePay|confirmPay|pay-template|preparePurchase' "$OUT_DIR/logcat.txt" 2>/dev/null; then - echo "wallet-core: pay path activity seen" +echo "=== summary (withdraw + pay) ===" +echo "package: $PKG stack: $STACK" +echo "DO_WITHDRAW=$DO_WITHDRAW DO_PAY=$DO_PAY SMOKE_STRICT=$SMOKE_STRICT" +if [ "$DO_WITHDRAW" = "1" ]; then + echo "withdraw URI: ${WURI:-(none)}" + if [ "$WITHDRAW_OK" = "1" ]; then + echo "withdraw wallet-core: YES" else - echo "wallet-core: pay path not confirmed in logcat (check $OUT_DIR/03-after-pay-uri.png)" + echo "withdraw wallet-core: NO" + if [ "$SMOKE_STRICT" = "1" ]; then + echo "FAIL: SMOKE_STRICT requires withdraw (prepareBank/accept/confirm) in logcat" + rc=11 + fi fi fi +if [ "$DO_PAY" = "1" ]; then + echo "pay URI: ${PAY_URI:-(none)}" + if [ -z "$PAY_URI" ]; then + echo "pay: no URI (template/API)" + if [ "$SMOKE_STRICT" = "1" ]; then + echo "FAIL: SMOKE_STRICT requires a pay URI when DO_PAY=1" + rc=12 + fi + elif [ "$PAY_OK" = "1" ]; then + echo "pay wallet-core: YES" + else + echo "pay wallet-core: NO (intent may still have been delivered)" + if [ "$SMOKE_STRICT" = "1" ]; then + echo "FAIL: SMOKE_STRICT requires pay path activity in logcat" + [ "$rc" -eq 0 ] && rc=13 + fi + fi +fi +if grep -qiE "isn't responding|isn.t responding|System UI isn" "$OUT_DIR/01-ui.xml" 2>/dev/null \ + || grep -qiE "isn't responding|not responding" "$OUT_DIR/logcat.txt" 2>/dev/null \ + || grep -rqiE "isn't responding" "$OUT_DIR/gui-withdraw" 2>/dev/null; then + echo "FAIL: System UI / app ANR" + rc=10 +fi echo "artifacts: $OUT_DIR/" ls -la "$OUT_DIR" | sed 's/^/ /' echo -echo "NOTE: Full unattended UI payment needs a responsive device/emulator (≥6–8 GiB host RAM recommended)." -echo "This smoke proves: APK install + deep-link delivery on stack=$STACK (strict=$SMOKE_STRICT rc=$rc)." +echo "NOTE: Full unattended Abheben+Bezahlen needs responsive device (≥6–8 GiB host RAM)." +echo "Smoke legs: withdraw=$( [ "$DO_WITHDRAW" = 1 ] && echo on || echo off) pay=$( [ "$DO_PAY" = 1 ] && echo on || echo off) rc=$rc" exit "$rc" From b9c6fb81a6c8e06723473bc915eec6ac2cb59391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 22:04:39 +0200 Subject: [PATCH 287/326] docs(android-test): dual-stack GOA/stage run results (ANR fail) Record time-bounded F-Droid smoke on both stacks: landings/URIs OK, unattended withdraw+pay blocked by System UI ANR on low-RAM emulator. --- .../taler-monitoring/android-test/.gitignore | 1 + .../android-test/GUI-AUTOMATION-NOTES.md | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore index 6d4e62e..fc1fb44 100644 --- a/scripts/taler-monitoring/android-test/.gitignore +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -7,3 +7,4 @@ out-gui/ out-source-gui/ out-gui-chain/ out-matrix-*/ +out-stack-run-*/ diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 3248573..b30472b 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -264,6 +264,51 @@ Scripts **today** implement Android only; they should honour: | **GOA** | explorer mint / `demo-withdraw.json` @ bank.hacktivism.ch | goa-shop templates / Paivana pay-template | | **stage** | explorer / demo-withdraw @ stage.bank… | fermes / jardin public templates | +### Dual-stack run result (2026-07-17 · headless AVD) + +**What ran:** published F-Droid wallet **1.6.1** (`net.taler.wallet.fdroid`), +`run-android-pay-smoke.sh` with **Abheben + Bezahlen**, `SMOKE_STRICT=1`, +per-stack wall **480 s** (no full matrix / no Gradle — time-bounded). +Device: `emulator-5554` (TalerWallet34, headless, SwiftShader). +Host RAM ~**3.7 GiB** (~150 MiB free during run) — under recommended ≥6–8 GiB. + +| Stack | Bank / pay entry | Exit | Elapsed | Withdraw URI | Pay URI | wallet-core withdraw | wallet-core pay | ANR | Overall | +|-------|------------------|------|---------|--------------|---------|----------------------|-----------------|-----|---------| +| **goa** | bank.hacktivism.ch · paivana pay-template | **10** | ~106 s | delivered | delivered | **NO** | **NO** | **YES** (System UI) | **FAIL** | +| **stage** (*lefrancpaysan*) | stage.bank… · fermes `panier-legumes` order | **10** | ~176 s | delivered | delivered | **NO** | **NO** | **YES** (Android System) | **FAIL** | + +Neither stack hit the 480 s timeout; both finished under 3 min with hard fail. + +**What worked (infra / landings):** + +- adb install F-Droid APK +- GOA + stage **demo-withdraw.json** returned usable `taler://withdraw/…` +- GOA **pay-template** paivana URI built +- stage merchant **template POST** → `taler://pay/…/fermes-des-collines/…` +- Intent delivery (`am start VIEW`) for withdraw and pay +- Artifacts under `out-stack-run-20260717-215934/{goa,stage}/` (gitignored) + +**What failed (app/GUI on this host):** + +- **System UI / Android System ANR** → only Close/Wait; no Confirm/Abheben/Pay taps +- **wallet-core** never showed `prepareBankIntegratedWithdrawal` / pay prepare in logcat +- Chooser noise: **Taler Wallet** + **Taler Wallet Debug** both installed (“Open with”) +- Unattended **Abheben + Bezahlen** not green on this emulator/RAM + +**Interpretation:** + +| Layer | GOA | stage | +|-------|-----|-------| +| HTTP / bank / merchant reachability for smoke inputs | OK | OK | +| Deep-link plumbing | OK | OK | +| Unattended GUI Confirm → settled | **blocked (ANR)** | **blocked (ANR)** | +| Strict smoke (`SMOKE_STRICT=1`) | fail rc=10 | fail rc=10 | + +**Not a stack-specific regression** between GOA and stage in this run — same failure mode (host/emulator). +Settlement proof remains **host CLI e2e/ladder**, not this Android GUI path, until a device with enough RAM is used. + +**Retry when:** physical phone or host with more free RAM; ideally uninstall one of fdroid / fdroid.debug to avoid “Open with”; optional `WINDOWED=0` stays default (headless). + --- ## Upstream branches (`taler-android` `dev/hernani-inference/*`) @@ -282,6 +327,7 @@ Scripts **today** implement Android only; they should honour: - [x] Minimal variant documented - [x] Minimalvariante: was geht / was nicht (Beschränkungen) - [x] Two stacks (goa / stage) +- [x] Dual-stack run documented (2026-07-17: both FAIL ANR, landings OK) - [x] Flags design (`AUTO_ANDROID` / `AUTO_GUI` / platform defaults) - [x] **Headless emulator default** (`EMULATOR_HEADLESS=1`, SwiftShader) - [x] Linux default = Android suite only From 8f42306db745486bfa60744f776bc45aed9aab15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 22:04:39 +0200 Subject: [PATCH 288/326] docs(android-test): dual-stack GOA/stage run results (ANR fail) Record time-bounded F-Droid smoke on both stacks: landings/URIs OK, unattended withdraw+pay blocked by System UI ANR on low-RAM emulator. --- .../taler-monitoring/android-test/.gitignore | 1 + .../android-test/GUI-AUTOMATION-NOTES.md | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore index 6d4e62e..fc1fb44 100644 --- a/scripts/taler-monitoring/android-test/.gitignore +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -7,3 +7,4 @@ out-gui/ out-source-gui/ out-gui-chain/ out-matrix-*/ +out-stack-run-*/ diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index 3248573..b30472b 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -264,6 +264,51 @@ Scripts **today** implement Android only; they should honour: | **GOA** | explorer mint / `demo-withdraw.json` @ bank.hacktivism.ch | goa-shop templates / Paivana pay-template | | **stage** | explorer / demo-withdraw @ stage.bank… | fermes / jardin public templates | +### Dual-stack run result (2026-07-17 · headless AVD) + +**What ran:** published F-Droid wallet **1.6.1** (`net.taler.wallet.fdroid`), +`run-android-pay-smoke.sh` with **Abheben + Bezahlen**, `SMOKE_STRICT=1`, +per-stack wall **480 s** (no full matrix / no Gradle — time-bounded). +Device: `emulator-5554` (TalerWallet34, headless, SwiftShader). +Host RAM ~**3.7 GiB** (~150 MiB free during run) — under recommended ≥6–8 GiB. + +| Stack | Bank / pay entry | Exit | Elapsed | Withdraw URI | Pay URI | wallet-core withdraw | wallet-core pay | ANR | Overall | +|-------|------------------|------|---------|--------------|---------|----------------------|-----------------|-----|---------| +| **goa** | bank.hacktivism.ch · paivana pay-template | **10** | ~106 s | delivered | delivered | **NO** | **NO** | **YES** (System UI) | **FAIL** | +| **stage** (*lefrancpaysan*) | stage.bank… · fermes `panier-legumes` order | **10** | ~176 s | delivered | delivered | **NO** | **NO** | **YES** (Android System) | **FAIL** | + +Neither stack hit the 480 s timeout; both finished under 3 min with hard fail. + +**What worked (infra / landings):** + +- adb install F-Droid APK +- GOA + stage **demo-withdraw.json** returned usable `taler://withdraw/…` +- GOA **pay-template** paivana URI built +- stage merchant **template POST** → `taler://pay/…/fermes-des-collines/…` +- Intent delivery (`am start VIEW`) for withdraw and pay +- Artifacts under `out-stack-run-20260717-215934/{goa,stage}/` (gitignored) + +**What failed (app/GUI on this host):** + +- **System UI / Android System ANR** → only Close/Wait; no Confirm/Abheben/Pay taps +- **wallet-core** never showed `prepareBankIntegratedWithdrawal` / pay prepare in logcat +- Chooser noise: **Taler Wallet** + **Taler Wallet Debug** both installed (“Open with”) +- Unattended **Abheben + Bezahlen** not green on this emulator/RAM + +**Interpretation:** + +| Layer | GOA | stage | +|-------|-----|-------| +| HTTP / bank / merchant reachability for smoke inputs | OK | OK | +| Deep-link plumbing | OK | OK | +| Unattended GUI Confirm → settled | **blocked (ANR)** | **blocked (ANR)** | +| Strict smoke (`SMOKE_STRICT=1`) | fail rc=10 | fail rc=10 | + +**Not a stack-specific regression** between GOA and stage in this run — same failure mode (host/emulator). +Settlement proof remains **host CLI e2e/ladder**, not this Android GUI path, until a device with enough RAM is used. + +**Retry when:** physical phone or host with more free RAM; ideally uninstall one of fdroid / fdroid.debug to avoid “Open with”; optional `WINDOWED=0` stays default (headless). + --- ## Upstream branches (`taler-android` `dev/hernani-inference/*`) @@ -282,6 +327,7 @@ Scripts **today** implement Android only; they should honour: - [x] Minimal variant documented - [x] Minimalvariante: was geht / was nicht (Beschränkungen) - [x] Two stacks (goa / stage) +- [x] Dual-stack run documented (2026-07-17: both FAIL ANR, landings OK) - [x] Flags design (`AUTO_ANDROID` / `AUTO_GUI` / platform defaults) - [x] **Headless emulator default** (`EMULATOR_HEADLESS=1`, SwiftShader) - [x] Linux default = Android suite only From a2b9908c79e0e94bcf7ba754715157190885481b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 22:18:19 +0200 Subject: [PATCH 289/326] docs(android-test): as-is survey GOA+stage stable/master/inference Record dual-stack overview: landings OK, all app variants ANR-blocked on low-RAM emulator; older F-Droid APKs missing; local master+inference branch built but not greener (not pushed). --- .../taler-monitoring/android-test/.gitignore | 1 + .../android-test/GUI-AUTOMATION-NOTES.md | 92 ++++++++++++------- 2 files changed, 60 insertions(+), 33 deletions(-) diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore index fc1fb44..49b08e5 100644 --- a/scripts/taler-monitoring/android-test/.gitignore +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -8,3 +8,4 @@ out-source-gui/ out-gui-chain/ out-matrix-*/ out-stack-run-*/ +out-survey-*/ diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index b30472b..18fe398 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -264,50 +264,76 @@ Scripts **today** implement Android only; they should honour: | **GOA** | explorer mint / `demo-withdraw.json` @ bank.hacktivism.ch | goa-shop templates / Paivana pay-template | | **stage** | explorer / demo-withdraw @ stage.bank… | fermes / jardin public templates | -### Dual-stack run result (2026-07-17 · headless AVD) +### Lageübersicht GOA + stage (as-is survey · 2026-07-17 ~22:05–22:18) -**What ran:** published F-Droid wallet **1.6.1** (`net.taler.wallet.fdroid`), -`run-android-pay-smoke.sh` with **Abheben + Bezahlen**, `SMOKE_STRICT=1`, -per-stack wall **480 s** (no full matrix / no Gradle — time-bounded). -Device: `emulator-5554` (TalerWallet34, headless, SwiftShader). -Host RAM ~**3.7 GiB** (~150 MiB free during run) — under recommended ≥6–8 GiB. +**Device:** headless `emulator-5554` · host ~3.7 GiB RAM (often <300 MiB free) · timeout per leg ≤420 s. +**Legs:** Abheben + Bezahlen (`DO_WITHDRAW=1 DO_PAY=1`). Artefakte: `out-survey-20260717-220527/` (gitignored). -| Stack | Bank / pay entry | Exit | Elapsed | Withdraw URI | Pay URI | wallet-core withdraw | wallet-core pay | ANR | Overall | -|-------|------------------|------|---------|--------------|---------|----------------------|-----------------|-----|---------| -| **goa** | bank.hacktivism.ch · paivana pay-template | **10** | ~106 s | delivered | delivered | **NO** | **NO** | **YES** (System UI) | **FAIL** | -| **stage** (*lefrancpaysan*) | stage.bank… · fermes `panier-legumes` order | **10** | ~176 s | delivered | delivered | **NO** | **NO** | **YES** (Android System) | **FAIL** | +#### Varianten -Neither stack hit the 480 s timeout; both finished under 3 min with hard fail. +| Variante | Quelle | Bemerkung | +|----------|--------|-----------| +| **stable (published)** | F-Droid `net.taler.wallet.fdroid` **1.6.1 / 854** | as-is | +| **master as-is** | rebuild `origin/master` @ `f65a976c` → `.fdroid.debug` | as-is, no patches | +| **ältere Releases** (3/6/9/12 mon) | F-Droid APKs für `1.5.0`/`1.3.0`/`1.1.0`/`1.0.8` | **nicht auffindbar** (HTTP miss) → **SKIP** (kein Selbstbau älterer Tags) | +| **master + inference** (extra) | local branch `local/master-with-inference` @ `3ebe8b09` | merges `fix-bank-withdraw-auto-exchange` + `fix-withdraw-spinner-fallback` · **not pushed** | -**What worked (infra / landings):** +#### Ergebnis-Matrix -- adb install F-Droid APK -- GOA + stage **demo-withdraw.json** returned usable `taler://withdraw/…` -- GOA **pay-template** paivana URI built -- stage merchant **template POST** → `taler://pay/…/fermes-des-collines/…` -- Intent delivery (`am start VIEW`) for withdraw and pay -- Artifacts under `out-stack-run-20260717-215934/{goa,stage}/` (gitignored) +| Variant | Stack | Install / deep-link | Withdraw URI | Pay URI | wallet-core Abheben | wallet-core Pay | ANR | Exit | +|---------|-------|---------------------|--------------|---------|---------------------|-----------------|-----|------| +| stable published | **goa** (hacktivism) | OK | OK bank.hacktivism.ch | OK paivana template | **NO** | soft/YES once* | **YES** | **10** | +| stable published | **stage** (*.lefrancpaysan) | OK | OK stage.bank… | OK fermes order | **NO** | **NO** | **YES** | **10** | +| master as-is | **goa** | OK | OK | OK paivana | **NO** | **NO** | **YES** | **10** | +| master as-is | **stage** | OK | OK | OK fermes | **NO** | **NO** | **YES** | **10** | +| master+inference | **goa** | OK | OK | OK | **NO** | **NO** | **YES** | **10** | +| master+inference | **stage** | OK | OK | OK | **NO** | **NO** | **YES** | **10** | -**What failed (app/GUI on this host):** +\*stable-goa console once reported pay wallet-core YES mid-run; final score still ANR-dominated / not reliable settlement. -- **System UI / Android System ANR** → only Close/Wait; no Confirm/Abheben/Pay taps -- **wallet-core** never showed `prepareBankIntegratedWithdrawal` / pay prepare in logcat -- Chooser noise: **Taler Wallet** + **Taler Wallet Debug** both installed (“Open with”) -- Unattended **Abheben + Bezahlen** not green on this emulator/RAM +#### Was pro Stack **steht** (Infra) -**Interpretation:** +| Check | **goa / hacktivism** | **stage / \*.lefrancpaysan** | +|-------|----------------------|------------------------------| +| Bank `…/intro/demo-withdraw.json` → `taler://withdraw/…` | **OK** | **OK** | +| Pay entry | paivana `pay-template` **OK** | fermes `panier-legumes` POST → `taler://pay/…` **OK** | +| adb `VIEW` Intent delivery | **OK** | **OK** | +| Unattended Confirm/Abheben/Pay GUI | **FAIL (ANR)** | **FAIL (ANR)** | +| wallet-core prepare/accept withdraw in logcat | **not seen** | **not seen** | -| Layer | GOA | stage | -|-------|-----|-------| -| HTTP / bank / merchant reachability for smoke inputs | OK | OK | -| Deep-link plumbing | OK | OK | -| Unattended GUI Confirm → settled | **blocked (ANR)** | **blocked (ANR)** | -| Strict smoke (`SMOKE_STRICT=1`) | fail rc=10 | fail rc=10 | +**Fazit as-is:** Beide Stacks liefern die **Eingänge** (Withdraw- + Pay-URIs). Die **GUI/wallet-core-Strecke** ist auf diesem Emulator für **alle** getesteten App-Varianten (stable, master, inference) **gleich tot** — System UI / Android System ANR, keine brauchbaren Taps. Das ist **kein Stack-Unterschied** GOA vs stage und **kein** belastbarer App-Versions-Vergleich. -**Not a stack-specific regression** between GOA and stage in this run — same failure mode (host/emulator). -Settlement proof remains **host CLI e2e/ladder**, not this Android GUI path, until a device with enough RAM is used. +#### Inference-Patch-Versuch (lokal, **kein Push**) -**Retry when:** physical phone or host with more free RAM; ideally uninstall one of fdroid / fdroid.debug to avoid “Open with”; optional `WINDOWED=0` stays default (headless). +Weil **nichts** der as-is-Varianten wallet-core-grün war, wurde zusätzlich gebaut: + +```text +taler-android branch: local/master-with-inference # NOT pushed +base: origin/master +merged: origin/dev/hernani-inference/fix-bank-withdraw-auto-exchange + origin/dev/hernani-inference/fix-withdraw-spinner-fallback +commit: 3ebe8b091379 +APK: apks/wallet-master-inference-3ebe8b091379.apk (local) +``` + +| Stack | Inference smoke | Besser als master/stable? | +|-------|-----------------|---------------------------| +| goa | exit 10, ANR, no withdraw core | **Nein** | +| stage | exit 10, ANR, no withdraw core | **Nein** | + +**Kein grünes Inference-Signal** auf diesem Host → wir können **nicht** belegen, dass die Patches Withdraw/Pay „retten“. +Ebenso können wir **nicht** belegen, dass master ohne Patches „schuld“ ist: die Umgebung erreicht wallet-core gar nicht. + +> **Wenn später auf Gerät/mehr RAM die Inference-APK plötzlich grün und master/stable rot ist:** +> das ist der **klare Fix-Hinweis** — Patches aus `dev/hernani-inference/*` upstreamen / in master holen. +> **Heute:** noch **nicht** der Fall; primärer Blocker = **Emulator/RAM/ANR**. + +#### Nächste sinnvolle Schritte (später) + +1. Physisches Gerät oder Host ≥6–8 GiB free RAM +2. Nur **eine** Wallet-Package-ID installiert (kein Open-with) +3. Survey wiederholen: stable · master · `local/master-with-inference` auf **goa + stage** +4. Ältere F-Droid-APKs nur wenn wieder gehostet; kein Selbstbau alter Tags --- From 57cf9aa2a139bbe81b3191f4afdab7105c9b366e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 22:18:19 +0200 Subject: [PATCH 290/326] docs(android-test): as-is survey GOA+stage stable/master/inference Record dual-stack overview: landings OK, all app variants ANR-blocked on low-RAM emulator; older F-Droid APKs missing; local master+inference branch built but not greener (not pushed). --- .../taler-monitoring/android-test/.gitignore | 1 + .../android-test/GUI-AUTOMATION-NOTES.md | 92 ++++++++++++------- 2 files changed, 60 insertions(+), 33 deletions(-) diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore index fc1fb44..49b08e5 100644 --- a/scripts/taler-monitoring/android-test/.gitignore +++ b/scripts/taler-monitoring/android-test/.gitignore @@ -8,3 +8,4 @@ out-source-gui/ out-gui-chain/ out-matrix-*/ out-stack-run-*/ +out-survey-*/ diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md index b30472b..18fe398 100644 --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md +++ b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md @@ -264,50 +264,76 @@ Scripts **today** implement Android only; they should honour: | **GOA** | explorer mint / `demo-withdraw.json` @ bank.hacktivism.ch | goa-shop templates / Paivana pay-template | | **stage** | explorer / demo-withdraw @ stage.bank… | fermes / jardin public templates | -### Dual-stack run result (2026-07-17 · headless AVD) +### Lageübersicht GOA + stage (as-is survey · 2026-07-17 ~22:05–22:18) -**What ran:** published F-Droid wallet **1.6.1** (`net.taler.wallet.fdroid`), -`run-android-pay-smoke.sh` with **Abheben + Bezahlen**, `SMOKE_STRICT=1`, -per-stack wall **480 s** (no full matrix / no Gradle — time-bounded). -Device: `emulator-5554` (TalerWallet34, headless, SwiftShader). -Host RAM ~**3.7 GiB** (~150 MiB free during run) — under recommended ≥6–8 GiB. +**Device:** headless `emulator-5554` · host ~3.7 GiB RAM (often <300 MiB free) · timeout per leg ≤420 s. +**Legs:** Abheben + Bezahlen (`DO_WITHDRAW=1 DO_PAY=1`). Artefakte: `out-survey-20260717-220527/` (gitignored). -| Stack | Bank / pay entry | Exit | Elapsed | Withdraw URI | Pay URI | wallet-core withdraw | wallet-core pay | ANR | Overall | -|-------|------------------|------|---------|--------------|---------|----------------------|-----------------|-----|---------| -| **goa** | bank.hacktivism.ch · paivana pay-template | **10** | ~106 s | delivered | delivered | **NO** | **NO** | **YES** (System UI) | **FAIL** | -| **stage** (*lefrancpaysan*) | stage.bank… · fermes `panier-legumes` order | **10** | ~176 s | delivered | delivered | **NO** | **NO** | **YES** (Android System) | **FAIL** | +#### Varianten -Neither stack hit the 480 s timeout; both finished under 3 min with hard fail. +| Variante | Quelle | Bemerkung | +|----------|--------|-----------| +| **stable (published)** | F-Droid `net.taler.wallet.fdroid` **1.6.1 / 854** | as-is | +| **master as-is** | rebuild `origin/master` @ `f65a976c` → `.fdroid.debug` | as-is, no patches | +| **ältere Releases** (3/6/9/12 mon) | F-Droid APKs für `1.5.0`/`1.3.0`/`1.1.0`/`1.0.8` | **nicht auffindbar** (HTTP miss) → **SKIP** (kein Selbstbau älterer Tags) | +| **master + inference** (extra) | local branch `local/master-with-inference` @ `3ebe8b09` | merges `fix-bank-withdraw-auto-exchange` + `fix-withdraw-spinner-fallback` · **not pushed** | -**What worked (infra / landings):** +#### Ergebnis-Matrix -- adb install F-Droid APK -- GOA + stage **demo-withdraw.json** returned usable `taler://withdraw/…` -- GOA **pay-template** paivana URI built -- stage merchant **template POST** → `taler://pay/…/fermes-des-collines/…` -- Intent delivery (`am start VIEW`) for withdraw and pay -- Artifacts under `out-stack-run-20260717-215934/{goa,stage}/` (gitignored) +| Variant | Stack | Install / deep-link | Withdraw URI | Pay URI | wallet-core Abheben | wallet-core Pay | ANR | Exit | +|---------|-------|---------------------|--------------|---------|---------------------|-----------------|-----|------| +| stable published | **goa** (hacktivism) | OK | OK bank.hacktivism.ch | OK paivana template | **NO** | soft/YES once* | **YES** | **10** | +| stable published | **stage** (*.lefrancpaysan) | OK | OK stage.bank… | OK fermes order | **NO** | **NO** | **YES** | **10** | +| master as-is | **goa** | OK | OK | OK paivana | **NO** | **NO** | **YES** | **10** | +| master as-is | **stage** | OK | OK | OK fermes | **NO** | **NO** | **YES** | **10** | +| master+inference | **goa** | OK | OK | OK | **NO** | **NO** | **YES** | **10** | +| master+inference | **stage** | OK | OK | OK | **NO** | **NO** | **YES** | **10** | -**What failed (app/GUI on this host):** +\*stable-goa console once reported pay wallet-core YES mid-run; final score still ANR-dominated / not reliable settlement. -- **System UI / Android System ANR** → only Close/Wait; no Confirm/Abheben/Pay taps -- **wallet-core** never showed `prepareBankIntegratedWithdrawal` / pay prepare in logcat -- Chooser noise: **Taler Wallet** + **Taler Wallet Debug** both installed (“Open with”) -- Unattended **Abheben + Bezahlen** not green on this emulator/RAM +#### Was pro Stack **steht** (Infra) -**Interpretation:** +| Check | **goa / hacktivism** | **stage / \*.lefrancpaysan** | +|-------|----------------------|------------------------------| +| Bank `…/intro/demo-withdraw.json` → `taler://withdraw/…` | **OK** | **OK** | +| Pay entry | paivana `pay-template` **OK** | fermes `panier-legumes` POST → `taler://pay/…` **OK** | +| adb `VIEW` Intent delivery | **OK** | **OK** | +| Unattended Confirm/Abheben/Pay GUI | **FAIL (ANR)** | **FAIL (ANR)** | +| wallet-core prepare/accept withdraw in logcat | **not seen** | **not seen** | -| Layer | GOA | stage | -|-------|-----|-------| -| HTTP / bank / merchant reachability for smoke inputs | OK | OK | -| Deep-link plumbing | OK | OK | -| Unattended GUI Confirm → settled | **blocked (ANR)** | **blocked (ANR)** | -| Strict smoke (`SMOKE_STRICT=1`) | fail rc=10 | fail rc=10 | +**Fazit as-is:** Beide Stacks liefern die **Eingänge** (Withdraw- + Pay-URIs). Die **GUI/wallet-core-Strecke** ist auf diesem Emulator für **alle** getesteten App-Varianten (stable, master, inference) **gleich tot** — System UI / Android System ANR, keine brauchbaren Taps. Das ist **kein Stack-Unterschied** GOA vs stage und **kein** belastbarer App-Versions-Vergleich. -**Not a stack-specific regression** between GOA and stage in this run — same failure mode (host/emulator). -Settlement proof remains **host CLI e2e/ladder**, not this Android GUI path, until a device with enough RAM is used. +#### Inference-Patch-Versuch (lokal, **kein Push**) -**Retry when:** physical phone or host with more free RAM; ideally uninstall one of fdroid / fdroid.debug to avoid “Open with”; optional `WINDOWED=0` stays default (headless). +Weil **nichts** der as-is-Varianten wallet-core-grün war, wurde zusätzlich gebaut: + +```text +taler-android branch: local/master-with-inference # NOT pushed +base: origin/master +merged: origin/dev/hernani-inference/fix-bank-withdraw-auto-exchange + origin/dev/hernani-inference/fix-withdraw-spinner-fallback +commit: 3ebe8b091379 +APK: apks/wallet-master-inference-3ebe8b091379.apk (local) +``` + +| Stack | Inference smoke | Besser als master/stable? | +|-------|-----------------|---------------------------| +| goa | exit 10, ANR, no withdraw core | **Nein** | +| stage | exit 10, ANR, no withdraw core | **Nein** | + +**Kein grünes Inference-Signal** auf diesem Host → wir können **nicht** belegen, dass die Patches Withdraw/Pay „retten“. +Ebenso können wir **nicht** belegen, dass master ohne Patches „schuld“ ist: die Umgebung erreicht wallet-core gar nicht. + +> **Wenn später auf Gerät/mehr RAM die Inference-APK plötzlich grün und master/stable rot ist:** +> das ist der **klare Fix-Hinweis** — Patches aus `dev/hernani-inference/*` upstreamen / in master holen. +> **Heute:** noch **nicht** der Fall; primärer Blocker = **Emulator/RAM/ANR**. + +#### Nächste sinnvolle Schritte (später) + +1. Physisches Gerät oder Host ≥6–8 GiB free RAM +2. Nur **eine** Wallet-Package-ID installiert (kein Open-with) +3. Survey wiederholen: stable · master · `local/master-with-inference` auf **goa + stage** +4. Ältere F-Droid-APKs nur wenn wieder gehostet; kein Selbstbau alter Tags --- From 6fb8dc6d43005ee9b6d125adb47494b691e5f516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 22:36:54 +0200 Subject: [PATCH 291/326] docs/ops: merchant portal instance ids must be lowercase (avoid 401) Document that mixed-case instance ids yield odd HTTP 401 in webui/API. Lowercase MERCHANT_INSTANCE / shop paths defensively in monitoring and landings. --- 2026/2026-07-17.md | 2 ++ configs/bank-landing/shop-pay.js | 2 +- configs/merchant-landing/shop-pay.js | 5 +++-- configs/taler-hacktivism/README.md | 17 +++++++++++++++++ .../demo-instance-goa-demo-cp4zqk.md | 4 ++++ scripts/taler-merchant/README.md | 7 +++++++ scripts/taler-merchant/setup_credit_facade.sh | 3 ++- .../android-test/run-goa-gui-chain.sh | 8 ++++---- scripts/taler-monitoring/lib.sh | 4 ++++ 9 files changed, 44 insertions(+), 8 deletions(-) diff --git a/2026/2026-07-17.md b/2026/2026-07-17.md index 33ee314..4423c96 100644 --- a/2026/2026-07-17.md +++ b/2026/2026-07-17.md @@ -8,3 +8,5 @@ 2. Start vor Merchant-Apps → 502 3. Pasta AAAA/timeout → pin `172.17.0.1` Live-Stack war gesund (302/402); Ursache/Fix nicht wiederholen. +- **Merchant portal:** instance ids **lowercase only** — capitals → odd **401**; + documented in taler-hacktivism + stagepaysan config READMEs; monitoring lowercases `MERCHANT_INSTANCE`. diff --git a/configs/bank-landing/shop-pay.js b/configs/bank-landing/shop-pay.js index 2c0573b..5ebc439 100644 --- a/configs/bank-landing/shop-pay.js +++ b/configs/bank-landing/shop-pay.js @@ -10,7 +10,7 @@ (function () { var MERCHANT_HOST = "taler.hacktivism.ch"; /* goa-shop: dedicated instance with fixed-order product templates */ - var INSTANCE = "goa-shop"; + var INSTANCE = String("goa-shop").toLowerCase(); var SHOP_PAYTO = "payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop"; var SHOP_PAYTO_HTTPS = "https://bank.hacktivism.ch/webui/"; diff --git a/configs/merchant-landing/shop-pay.js b/configs/merchant-landing/shop-pay.js index 2c0573b..af62a30 100644 --- a/configs/merchant-landing/shop-pay.js +++ b/configs/merchant-landing/shop-pay.js @@ -9,8 +9,9 @@ */ (function () { var MERCHANT_HOST = "taler.hacktivism.ch"; - /* goa-shop: dedicated instance with fixed-order product templates */ - var INSTANCE = "goa-shop"; + /* goa-shop: dedicated instance with fixed-order product templates. + * Instance id must stay lowercase (portal/API: capitals → odd 401s). */ + var INSTANCE = String("goa-shop").toLowerCase(); var SHOP_PAYTO = "payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop"; var SHOP_PAYTO_HTTPS = "https://bank.hacktivism.ch/webui/"; diff --git a/configs/taler-hacktivism/README.md b/configs/taler-hacktivism/README.md index 127cd32..06a110e 100644 --- a/configs/taler-hacktivism/README.md +++ b/configs/taler-hacktivism/README.md @@ -56,6 +56,23 @@ After conf/hosts changes: `taler-merchant-exchangekeyupdate -t` and `taler-merch | 9010 | nginx TLS in container (podman) | | unix sock | `taler-merchant-httpd` | +## Merchant portal (SPA `/webui/`) — instance IDs **lowercase only** + +The merchant **webui** (and private API paths under `/instances/{id}/…`) must use +**only lowercase** instance ids (`a-z`, digits, hyphens). + +| Do | Do not | +|----|--------| +| `goa-shop`, `goa-demo-cp4zqk`, `fermes-des-collines` | `Goa-Shop`, `GOA-DEMO`, mixed case | + +**Why:** uppercase (or mixed-case) ids produce **odd HTTP 401** failures on +login / private API even when the password/token is correct. Auth looks like +“wrong secret” but the real issue is path / instance string case. + +Create instances, login fields, `Authorization` targets, and `taler://pay…` +paths with the **exact lowercase** id. Display names may use capitals; +**ids must not**. + ## Demo instance (2026-07-09) Self-provisioned shop **`goa-demo-cp4zqk`**, bank account same username, payto linked, seed **GOA:5000**. diff --git a/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md b/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md index 95aa009..109c0bb 100644 --- a/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md +++ b/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md @@ -15,6 +15,10 @@ Created **2026-07-09** via self-provisioning (`POST /instances`, no SMS TAN). Login: SPA → instance id + password (or bearer `secret-token:$PASSWORD`). +**Instance id must be all-lowercase** (`goa-demo-cp4zqk`). Typing capitals in +the portal login field (or calling `/instances/Goa-Demo-…/private/…`) yields +**strange 401s** even with the right password — use exact lowercase only. + ## Bank account (linked) | Field | Value | diff --git a/scripts/taler-merchant/README.md b/scripts/taler-merchant/README.md index 339d693..4ddea4a 100644 --- a/scripts/taler-merchant/README.md +++ b/scripts/taler-merchant/README.md @@ -30,6 +30,13 @@ Automatic import needs: SMS backends are symlinks into `/var/taler-src/...` (not copied). +## Portal / instance IDs: **lowercase only** + +Merchant SPA (`/webui/`) and `/instances/{id}/…` require **lowercase** instance +ids. Uppercase or mixed case → **weird HTTP 401** (looks like bad password). +Display names may be mixed case; **ids must be lowercase** (`goa-shop`, not +`Goa-Shop`). See `configs/taler-hacktivism/README.md`. + ## Usage ```bash diff --git a/scripts/taler-merchant/setup_credit_facade.sh b/scripts/taler-merchant/setup_credit_facade.sh index 3ab0403..bf5ffb4 100755 --- a/scripts/taler-merchant/setup_credit_facade.sh +++ b/scripts/taler-merchant/setup_credit_facade.sh @@ -16,7 +16,8 @@ # - restarts taler-merchant-wirewatch (once, after facade is set) set -euo pipefail -INST="${MERCHANT_INSTANCE:-goa-demo-cp4zqk}" +# Instance id must be lowercase (portal/API: capitals → odd 401s). +INST=$(printf '%s' "${MERCHANT_INSTANCE:-goa-demo-cp4zqk}" | tr '[:upper:]' '[:lower:]') BANK_USER="${BANK_USER:-$INST}" MERCHANT_PW_FILE="${MERCHANT_PW_FILE:-/root/merchant-${INST}-password.txt}" BANK_PW_FILE="${BANK_PW_FILE:-/root/bank-${BANK_USER}-password.txt}" diff --git a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh index 82c002d..1c20a63 100755 --- a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh +++ b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh @@ -52,8 +52,8 @@ case "$STACK" in PKG="${PKG:-net.taler.wallet.fdroid.debug}" BANK="${BANK:-https://bank.hacktivism.ch}" MERCHANT="${MERCHANT:-https://taler.hacktivism.ch}" - INSTANCE="${INSTANCE:-goa-shop}" - DONATE_INSTANCE="${DONATE_INSTANCE:-goa-demo-cp4zqk}" + INSTANCE=$(printf '%s' "${INSTANCE:-goa-shop}" | tr '[:upper:]' '[:lower:]') + DONATE_INSTANCE=$(printf '%s' "${DONATE_INSTANCE:-goa-demo-cp4zqk}" | tr '[:upper:]' '[:lower:]') DONATE_TEMPLATE="${DONATE_TEMPLATE:-goa-free}" DEFAULT_AMOUNTS=("GOA:10" "GOA:20") DEFAULT_PAYS=("product:orbit-sticker" "product:nebula-coffee" "product:voidwave-playlist" "donate:GOA:12") @@ -63,8 +63,8 @@ case "$STACK" in PKG="${PKG:-net.taler.wallet.fdroid.debug}" BANK="${BANK:-https://stage.bank.lefrancpaysan.ch}" MERCHANT="${MERCHANT:-https://stage.monnaie.lefrancpaysan.ch}" - INSTANCE="${INSTANCE:-fermes-des-collines}" - DONATE_INSTANCE="${DONATE_INSTANCE:-fermes-des-collines}" + INSTANCE=$(printf '%s' "${INSTANCE:-fermes-des-collines}" | tr '[:upper:]' '[:lower:]') + DONATE_INSTANCE=$(printf '%s' "${DONATE_INSTANCE:-fermes-des-collines}" | tr '[:upper:]' '[:lower:]') DONATE_TEMPLATE="${DONATE_TEMPLATE:-don-panier-libre}" DEFAULT_AMOUNTS=("TESTPAYSAN:10" "TESTPAYSAN:20") DEFAULT_PAYS=("product:panier-legumes" "product:fromage-chevre" "product:oeufs-6") diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index a113212..841d6e8 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -14,6 +14,9 @@ # 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}" +# Merchant portal /private API: instance ids must be lowercase or you get odd 401s. +# (Display names may use capitals; path segment /instances/{id}/ must not.) +MERCHANT_INSTANCE=$(printf '%s' "${MERCHANT_INSTANCE}" | tr '[:upper:]' '[:lower:]') : "${WITHDRAW_AMT:=GOA:20}" # single-shot fallback; e2e ladder uses ATM notes : "${PAY_AMT:=GOA:0.01}" : "${CREDIT_AMT:=GOA:4700}" # covers ATM ladder 20+50+100+200+4200 (paivana) @@ -201,6 +204,7 @@ apply_taler_domain() { MERCHANT_INSTANCE=fermes-des-collines ;; esac + MERCHANT_INSTANCE=$(printf '%s' "${MERCHANT_INSTANCE}" | tr '[:upper:]' '[:lower:]') WITHDRAW_AMT="${WITHDRAW_AMT:-TESTPAYSAN:50}" PAY_AMT="${PAY_AMT:-TESTPAYSAN:5}" # Cover oeufs(5)+fromage(8.5)+jus(6)+shop pick(~5–12) + fees From ba48f334ea77a59a70d5b786ecee1a8c33ac7b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 22:36:54 +0200 Subject: [PATCH 292/326] docs/ops: merchant portal instance ids must be lowercase (avoid 401) Document that mixed-case instance ids yield odd HTTP 401 in webui/API. Lowercase MERCHANT_INSTANCE / shop paths defensively in monitoring and landings. --- 2026/2026-07-17.md | 2 ++ configs/bank-landing/shop-pay.js | 2 +- configs/merchant-landing/shop-pay.js | 5 +++-- configs/taler-hacktivism/README.md | 17 +++++++++++++++++ .../demo-instance-goa-demo-cp4zqk.md | 4 ++++ scripts/taler-merchant/README.md | 7 +++++++ scripts/taler-merchant/setup_credit_facade.sh | 3 ++- .../android-test/run-goa-gui-chain.sh | 8 ++++---- scripts/taler-monitoring/lib.sh | 4 ++++ 9 files changed, 44 insertions(+), 8 deletions(-) diff --git a/2026/2026-07-17.md b/2026/2026-07-17.md index 33ee314..4423c96 100644 --- a/2026/2026-07-17.md +++ b/2026/2026-07-17.md @@ -8,3 +8,5 @@ 2. Start vor Merchant-Apps → 502 3. Pasta AAAA/timeout → pin `172.17.0.1` Live-Stack war gesund (302/402); Ursache/Fix nicht wiederholen. +- **Merchant portal:** instance ids **lowercase only** — capitals → odd **401**; + documented in taler-hacktivism + stagepaysan config READMEs; monitoring lowercases `MERCHANT_INSTANCE`. diff --git a/configs/bank-landing/shop-pay.js b/configs/bank-landing/shop-pay.js index 2c0573b..5ebc439 100644 --- a/configs/bank-landing/shop-pay.js +++ b/configs/bank-landing/shop-pay.js @@ -10,7 +10,7 @@ (function () { var MERCHANT_HOST = "taler.hacktivism.ch"; /* goa-shop: dedicated instance with fixed-order product templates */ - var INSTANCE = "goa-shop"; + var INSTANCE = String("goa-shop").toLowerCase(); var SHOP_PAYTO = "payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop"; var SHOP_PAYTO_HTTPS = "https://bank.hacktivism.ch/webui/"; diff --git a/configs/merchant-landing/shop-pay.js b/configs/merchant-landing/shop-pay.js index 2c0573b..af62a30 100644 --- a/configs/merchant-landing/shop-pay.js +++ b/configs/merchant-landing/shop-pay.js @@ -9,8 +9,9 @@ */ (function () { var MERCHANT_HOST = "taler.hacktivism.ch"; - /* goa-shop: dedicated instance with fixed-order product templates */ - var INSTANCE = "goa-shop"; + /* goa-shop: dedicated instance with fixed-order product templates. + * Instance id must stay lowercase (portal/API: capitals → odd 401s). */ + var INSTANCE = String("goa-shop").toLowerCase(); var SHOP_PAYTO = "payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop"; var SHOP_PAYTO_HTTPS = "https://bank.hacktivism.ch/webui/"; diff --git a/configs/taler-hacktivism/README.md b/configs/taler-hacktivism/README.md index 127cd32..06a110e 100644 --- a/configs/taler-hacktivism/README.md +++ b/configs/taler-hacktivism/README.md @@ -56,6 +56,23 @@ After conf/hosts changes: `taler-merchant-exchangekeyupdate -t` and `taler-merch | 9010 | nginx TLS in container (podman) | | unix sock | `taler-merchant-httpd` | +## Merchant portal (SPA `/webui/`) — instance IDs **lowercase only** + +The merchant **webui** (and private API paths under `/instances/{id}/…`) must use +**only lowercase** instance ids (`a-z`, digits, hyphens). + +| Do | Do not | +|----|--------| +| `goa-shop`, `goa-demo-cp4zqk`, `fermes-des-collines` | `Goa-Shop`, `GOA-DEMO`, mixed case | + +**Why:** uppercase (or mixed-case) ids produce **odd HTTP 401** failures on +login / private API even when the password/token is correct. Auth looks like +“wrong secret” but the real issue is path / instance string case. + +Create instances, login fields, `Authorization` targets, and `taler://pay…` +paths with the **exact lowercase** id. Display names may use capitals; +**ids must not**. + ## Demo instance (2026-07-09) Self-provisioned shop **`goa-demo-cp4zqk`**, bank account same username, payto linked, seed **GOA:5000**. diff --git a/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md b/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md index 95aa009..109c0bb 100644 --- a/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md +++ b/configs/taler-hacktivism/demo-instance-goa-demo-cp4zqk.md @@ -15,6 +15,10 @@ Created **2026-07-09** via self-provisioning (`POST /instances`, no SMS TAN). Login: SPA → instance id + password (or bearer `secret-token:$PASSWORD`). +**Instance id must be all-lowercase** (`goa-demo-cp4zqk`). Typing capitals in +the portal login field (or calling `/instances/Goa-Demo-…/private/…`) yields +**strange 401s** even with the right password — use exact lowercase only. + ## Bank account (linked) | Field | Value | diff --git a/scripts/taler-merchant/README.md b/scripts/taler-merchant/README.md index 339d693..4ddea4a 100644 --- a/scripts/taler-merchant/README.md +++ b/scripts/taler-merchant/README.md @@ -30,6 +30,13 @@ Automatic import needs: SMS backends are symlinks into `/var/taler-src/...` (not copied). +## Portal / instance IDs: **lowercase only** + +Merchant SPA (`/webui/`) and `/instances/{id}/…` require **lowercase** instance +ids. Uppercase or mixed case → **weird HTTP 401** (looks like bad password). +Display names may be mixed case; **ids must be lowercase** (`goa-shop`, not +`Goa-Shop`). See `configs/taler-hacktivism/README.md`. + ## Usage ```bash diff --git a/scripts/taler-merchant/setup_credit_facade.sh b/scripts/taler-merchant/setup_credit_facade.sh index 3ab0403..bf5ffb4 100755 --- a/scripts/taler-merchant/setup_credit_facade.sh +++ b/scripts/taler-merchant/setup_credit_facade.sh @@ -16,7 +16,8 @@ # - restarts taler-merchant-wirewatch (once, after facade is set) set -euo pipefail -INST="${MERCHANT_INSTANCE:-goa-demo-cp4zqk}" +# Instance id must be lowercase (portal/API: capitals → odd 401s). +INST=$(printf '%s' "${MERCHANT_INSTANCE:-goa-demo-cp4zqk}" | tr '[:upper:]' '[:lower:]') BANK_USER="${BANK_USER:-$INST}" MERCHANT_PW_FILE="${MERCHANT_PW_FILE:-/root/merchant-${INST}-password.txt}" BANK_PW_FILE="${BANK_PW_FILE:-/root/bank-${BANK_USER}-password.txt}" diff --git a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh index 82c002d..1c20a63 100755 --- a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh +++ b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh @@ -52,8 +52,8 @@ case "$STACK" in PKG="${PKG:-net.taler.wallet.fdroid.debug}" BANK="${BANK:-https://bank.hacktivism.ch}" MERCHANT="${MERCHANT:-https://taler.hacktivism.ch}" - INSTANCE="${INSTANCE:-goa-shop}" - DONATE_INSTANCE="${DONATE_INSTANCE:-goa-demo-cp4zqk}" + INSTANCE=$(printf '%s' "${INSTANCE:-goa-shop}" | tr '[:upper:]' '[:lower:]') + DONATE_INSTANCE=$(printf '%s' "${DONATE_INSTANCE:-goa-demo-cp4zqk}" | tr '[:upper:]' '[:lower:]') DONATE_TEMPLATE="${DONATE_TEMPLATE:-goa-free}" DEFAULT_AMOUNTS=("GOA:10" "GOA:20") DEFAULT_PAYS=("product:orbit-sticker" "product:nebula-coffee" "product:voidwave-playlist" "donate:GOA:12") @@ -63,8 +63,8 @@ case "$STACK" in PKG="${PKG:-net.taler.wallet.fdroid.debug}" BANK="${BANK:-https://stage.bank.lefrancpaysan.ch}" MERCHANT="${MERCHANT:-https://stage.monnaie.lefrancpaysan.ch}" - INSTANCE="${INSTANCE:-fermes-des-collines}" - DONATE_INSTANCE="${DONATE_INSTANCE:-fermes-des-collines}" + INSTANCE=$(printf '%s' "${INSTANCE:-fermes-des-collines}" | tr '[:upper:]' '[:lower:]') + DONATE_INSTANCE=$(printf '%s' "${DONATE_INSTANCE:-fermes-des-collines}" | tr '[:upper:]' '[:lower:]') DONATE_TEMPLATE="${DONATE_TEMPLATE:-don-panier-libre}" DEFAULT_AMOUNTS=("TESTPAYSAN:10" "TESTPAYSAN:20") DEFAULT_PAYS=("product:panier-legumes" "product:fromage-chevre" "product:oeufs-6") diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index a113212..841d6e8 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -14,6 +14,9 @@ # 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}" +# Merchant portal /private API: instance ids must be lowercase or you get odd 401s. +# (Display names may use capitals; path segment /instances/{id}/ must not.) +MERCHANT_INSTANCE=$(printf '%s' "${MERCHANT_INSTANCE}" | tr '[:upper:]' '[:lower:]') : "${WITHDRAW_AMT:=GOA:20}" # single-shot fallback; e2e ladder uses ATM notes : "${PAY_AMT:=GOA:0.01}" : "${CREDIT_AMT:=GOA:4700}" # covers ATM ladder 20+50+100+200+4200 (paivana) @@ -201,6 +204,7 @@ apply_taler_domain() { MERCHANT_INSTANCE=fermes-des-collines ;; esac + MERCHANT_INSTANCE=$(printf '%s' "${MERCHANT_INSTANCE}" | tr '[:upper:]' '[:lower:]') WITHDRAW_AMT="${WITHDRAW_AMT:-TESTPAYSAN:50}" PAY_AMT="${PAY_AMT:-TESTPAYSAN:5}" # Cover oeufs(5)+fromage(8.5)+jus(6)+shop pick(~5–12) + fees From 7549eea81d4ef3d4c9b4075bf1f170fb4e9e6aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 22:58:04 +0200 Subject: [PATCH 293/326] monitoring: check_auth401 WebUI signup/login/pwchange/case matrix --- scripts/taler-monitoring/check_auth401.sh | 704 ++++++++++++++++++++++ 1 file changed, 704 insertions(+) create mode 100755 scripts/taler-monitoring/check_auth401.sh diff --git a/scripts/taler-monitoring/check_auth401.sh b/scripts/taler-monitoring/check_auth401.sh new file mode 100755 index 0000000..dfca606 --- /dev/null +++ b/scripts/taler-monitoring/check_auth401.sh @@ -0,0 +1,704 @@ +#!/usr/bin/env bash +# auth401 — merchant HTTP 401 / case-sensitivity matrix (WebUI-shaped flows) +# +# Exercises the same APIs the merchant SPA uses for: +# • self-provision signup (POST /instances) — mixed-case id +# • login / createAccessToken (POST …/private/token) — Basic user case +# • password change (POST …/private/auth) +# • admin-create (POST /management/instances) when admin bearer available +# • access-token + private/ with Bearer +# +# Backend fact (#11340): Basic-auth *username* is case-sensitive; URL path is +# usually not. SPA must always lowercase the username (createAccessToken, signup +# id, admin-create, pw change re-login). These tests assert the *raw API* +# behaviour so a missing SPA lowercasing shows up as 401 where 200 is wanted +# only after lowercasing. +# +# ./taler-monitoring.sh -d stage.monnaie.lefrancpaysan.ch auth401 +# ./taler-monitoring.sh -d hacktivism.ch auth401 +# +# Continue through all groups / collect every ERROR (optional): +# AUTH401_CONTINUE=1 ./taler-monitoring.sh -d stage… auth401 +# CONTINUE_ON_ERROR=1 ./taler-monitoring.sh -d stage… auth401 # same +# +# Env: see secrets.env.example (AUTH401_*). +# +# NOTE: we intentionally do NOT use `set -e`. A single failed curl/grep under +# set -e would abort with no ERROR summary — the opposite of what monitoring +# needs. Optional CONTINUE_ON_ERROR still controls hard prereq early-exit. +set -uo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" +load_monitoring_secrets_env 2>/dev/null || true + +tmp=$(mktemp -d) +AUTH401_FINISHED=0 +_auth401_finish() { + [ "${AUTH401_FINISHED}" = "1" ] && return 0 + AUTH401_FINISHED=1 + # summary() returns non-zero when FAIL_N>0 — never let that skip the print + summary || true + rm -rf "${tmp:-}" + if [ "${FAIL_N:-0}" -gt 0 ]; then + exit 1 + fi + exit 0 +} +trap '_auth401_finish' EXIT + +# --------------------------------------------------------------------------- +# Parameters +# --------------------------------------------------------------------------- +: "${AUTH401_TIMEOUT:=${TIMEOUT:-20}}" +: "${AUTH401_CREATE:=1}" +: "${AUTH401_INSTANCE:=}" +: "${AUTH401_PASSWORD:=${MERCHANT_INSTANCE_PASSWORD:-${AUTH401_PASS:-}}}" +: "${AUTH401_ID_PREFIX:=mon401}" +: "${AUTH401_SKIP_IF_MFA:=1}" +: "${AUTH401_BEARER:=${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}}" +: "${AUTH401_CREATE_PASSWORD:=${AUTH401_PASSWORD:-Mon401-Test-Pass!}}" +: "${AUTH401_DURABLE_ID:=mon401}" +# Prefer durable alone (skip throwaway) — default 0 so full matrix always runs +: "${AUTH401_PREFER_DURABLE:=0}" +# Run expanded groups (signup/login/pwchange/admin/webui). Default 1. +: "${AUTH401_FULL:=1}" +# Admin bearer for POST /management/instances (optional) +: "${AUTH401_ADMIN_TOKEN:=${MERCHANT_ADMIN_TOKEN:-}}" +: "${AUTH401_ADMIN_PASSWORD:=}" +: "${AUTH401_ADMIN_USER:=admin}" +# 1 = do not abort on hard prereq fail; run remaining groups (default 0) +# Alias: CONTINUE_ON_ERROR=1 +: "${AUTH401_CONTINUE:=${CONTINUE_ON_ERROR:-0}}" +# Never rotate password on durable account (default 1 — keep mon401.password valid) +: "${AUTH401_PWCHANGE_THROWAY_ONLY:=1}" + +TIMEOUT="$AUTH401_TIMEOUT" +export TIMEOUT + +_auth401_abort_or_continue() { + local why="${1:-prereq failed}" + if [ "${AUTH401_CONTINUE}" = "1" ]; then + warn "continue" "${why} (AUTH401_CONTINUE=1 / CONTINUE_ON_ERROR=1)" + return 0 + fi + # hard stop — EXIT trap prints summary + all ERRORS collected so far + exit 1 +} + +BASE="${MERCHANT_PUBLIC%/}" +if [ -z "$BASE" ]; then + echo "MERCHANT_PUBLIC empty — set -d domain or --merchant" >&2 + exit 2 +fi + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +expect_http() { + local label="$1" want="$2" got="$3" detail="${4:-}" + case ",$want," in + *",$got,"*) ok "$label" "HTTP $got${detail:+ · $detail}" ;; + *) fail "$label" "HTTP $got (want $want)${detail:+ · $detail}" ;; + esac +} + +to_lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } +to_upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]'; } + +# First char upper, rest lower → synthetic MIX for Basic user (always ≠ low if len>0) +synth_mix() { + python3 -c "s='''$1'''; print((s[:1].upper()+s[1:].lower()) if s else s)" +} + +# First 3 upper (partial mix) +synth_partmix() { + python3 -c "s='''$1'''; print(s[:3].upper()+s[3:] if len(s)>=3 else s.upper())" +} + +# Normalize curl status: "200", "000", never "200000" / "000000" +_http_norm() { + local raw="$1" dig + dig=$(printf '%s' "$raw" | tr -cd '0-9') + if [ -z "$dig" ]; then + printf '000' + else + # first 3 digits = real code (curl 200 then || echo 000 → 200000) + printf '%s' "$dig" | head -c 3 + fi +} + +token_http() { + local path_id="$1" user="$2" password="$3" + local b64 raw + b64=$(printf '%s' "${user}:${password}" | base64 -w0 2>/dev/null \ + || printf '%s' "${user}:${password}" | base64) + raw=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/last_token.json" -w '%{http_code}' \ + -X POST "${BASE}/instances/${path_id}/private/token" \ + -H "Authorization: Basic ${b64}" \ + -H 'Content-Type: application/json' \ + -d '{"scope":"write","duration":{"d_us":3600000000},"refreshable":true}' \ + 2>/dev/null || true) + _http_norm "${raw:-}" +} + +json_code_hint() { + python3 - "$tmp/last_token.json" <<'PY' 2>/dev/null || true +import json, sys +try: + d = json.load(open(sys.argv[1])) +except Exception: + print("") + raise SystemExit +c = d.get("code", "") +h = (d.get("hint") or d.get("detail") or "")[:70] +print(f"code={c} {h}".strip()) +PY +} + +extract_token() { + python3 - "$tmp/last_token.json" <<'PY' 2>/dev/null || true +import json, sys +try: + d = json.load(open(sys.argv[1])) +except Exception: + print("") + raise SystemExit(0) +print(d.get("access_token") or d.get("token") or "") +PY +} + +instance_body() { + local id="$1" pass="$2" name="${3:-auth401 probe}" + python3 - "$id" "$pass" "$name" <<'PY' +import json, sys +print(json.dumps({ + "address": {}, + "auth": {"method": "token", "password": sys.argv[2]}, + "default_pay_delay": {"d_us": 60000000}, + "default_wire_transfer_delay": {"d_us": 30000000}, + "default_refund_delay": {"d_us": 30000000}, + "id": sys.argv[1], + "jurisdiction": {}, + "name": sys.argv[3], + "use_stefan": True, +})) +PY +} + +auth_change_http() { + local path_id="$1" bearer="$2" new_pass="$3" + local tok="$bearer" body + case "$tok" in secret-token:*) ;; *) tok="secret-token:${tok}" ;; esac + body=$(NEW_PASS="$new_pass" python3 -c 'import json,os; print(json.dumps({"method":"token","password":os.environ["NEW_PASS"]}))') + curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/auth_change.json" -w '%{http_code}' \ + -X POST "${BASE}/instances/${path_id}/private/auth" \ + -H "Authorization: Bearer ${tok}" \ + -H 'Content-Type: application/json' \ + -d "$body" \ + 2>/dev/null || echo 000 +} + +# --------------------------------------------------------------------------- +# Stage secrets (quiet) +# --------------------------------------------------------------------------- +_auth401_stage_secret_roots() { + printf '%s\n' \ + "${FRANCPAYSAN_SECRETS:-}" \ + "${HOME}/francpaysan-secrets" \ + "${HOME}/src/francpaysan-secrets" \ + "${HOME}/taler/src/francpaysan-secrets" +} + +_auth401_resolve_secrets_quiet() { + local root inst_id pass + for root in $(_auth401_stage_secret_roots); do + [ -n "$root" ] && [ -d "$root" ] || continue + # durable ids: prefer mon401fix when mon401 was rotated by old tests + if [ -z "${DURABLE_PASS:-}" ]; then + for inst_id in "${AUTH401_DURABLE_ID}" mon401fix mon401 "${MERCHANT_INSTANCE:-}"; do + [ -n "$inst_id" ] || continue + pass="" + if [ -f "$root/stage/instances/${inst_id}.password" ]; then + pass=$(tr -d '\n\r' <"$root/stage/instances/${inst_id}.password") + fi + # skip empty password files + if [ -n "$pass" ]; then + # verify file is for this id (mon401.password may be stale copy of mon401fix) + DURABLE_PASS="$pass" + DURABLE_ID=$(to_lower "$inst_id") + AUTH401_SECRET_NOTE="durable ${inst_id} from ${root}/stage/instances/" + # only set AUTH401_PASSWORD if caller did not pin an instance + if [ -z "${AUTH401_PASSWORD}" ] && [ -z "${AUTH401_INSTANCE}" ]; then + AUTH401_PASSWORD="$pass" + fi + break + fi + done + fi + if [ -z "${AUTH401_BEARER}" ] && [ -f "$root/stage/default-instance-token.txt" ]; then + AUTH401_BEARER=$(tr -d '\n\r' <"$root/stage/default-instance-token.txt") + AUTH401_BEARER_NOTE="default-instance-token.txt" + fi + if [ -z "${AUTH401_ADMIN_TOKEN}" ] && [ -f "$root/stage/admin-instance-token.txt" ]; then + AUTH401_ADMIN_TOKEN=$(tr -d '\n\r' <"$root/stage/admin-instance-token.txt") + AUTH401_ADMIN_NOTE="admin-instance-token.txt" + fi + [ -n "${DURABLE_PASS:-}" ] && break + done + # also keep AUTH401_INSTANCE for durable-only mode + if [ "${AUTH401_PREFER_DURABLE}" = "1" ] && [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then + AUTH401_INSTANCE="${AUTH401_INSTANCE:-$DURABLE_ID}" + AUTH401_PASSWORD="${AUTH401_PASSWORD:-$DURABLE_PASS}" + AUTH401_CREATE=0 + AUTH401_MODE_NOTE="PREFER_DURABLE=1 → only durable ${AUTH401_INSTANCE}" + fi +} + +_auth401_resolve_secrets_quiet +: "${AUTH401_CREATE_PASSWORD:=${AUTH401_PASSWORD:-Mon401-Test-Pass!}}" +export AUTH401_INSTANCE AUTH401_PASSWORD AUTH401_BEARER AUTH401_CREATE AUTH401_ADMIN_TOKEN + +# --------------------------------------------------------------------------- +set_area auth401 +section "auth401 · merchant 401 / case matrix (WebUI APIs) · ${BASE}" + +if [ -n "${AUTH401_SECRET_NOTE:-}" ]; then info "stage secret" "$AUTH401_SECRET_NOTE"; fi +if [ -n "${AUTH401_BEARER_NOTE:-}" ]; then info "stage secret" "AUTH401_BEARER from ${AUTH401_BEARER_NOTE}"; fi +if [ -n "${AUTH401_ADMIN_NOTE:-}" ]; then info "stage secret" "AUTH401_ADMIN_TOKEN from ${AUTH401_ADMIN_NOTE}"; fi +if [ -n "${AUTH401_MODE_NOTE:-}" ]; then info "auth401 mode" "$AUTH401_MODE_NOTE"; fi +info "auth401 scope" "FULL=${AUTH401_FULL} CREATE=${AUTH401_CREATE} PREFER_DURABLE=${AUTH401_PREFER_DURABLE} CONTINUE=${AUTH401_CONTINUE}" +if [ "${AUTH401_CONTINUE}" = "1" ]; then + info "continue mode" "CONTINUE_ON_ERROR/AUTH401_CONTINUE=1 — hard prereqs soft; always full ERROR list via EXIT trap" +fi + +# =========================================================================== +set_group config +# =========================================================================== +cfg_code=$(http_body "$BASE/config" "$tmp/mer-config.json") +expect_http "merchant /config" 200 "$cfg_code" "$BASE/config" + +HAVE_SP=0 +MANDATORY_TAN=0 +if [ "$cfg_code" = "200" ]; then + eval "$(python3 - "$tmp/mer-config.json" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +sp = 1 if d.get("have_self_provisioning") else 0 +tans = d.get("mandatory_tan_channels") or [] +print(f"HAVE_SP={sp}") +print(f"MANDATORY_TAN={1 if tans else 0}") +print(f"TAN_CH={','.join(tans) if tans else ''}") +print(f"API_VER={d.get('version','')}") +PY +)" + info "self-provision" "have_self_provisioning=${HAVE_SP} mandatory_tan=${MANDATORY_TAN}${TAN_CH:+ ($TAN_CH)} api=${API_VER:-?}" +else + fail "merchant config required for auth401" "HTTP $cfg_code" + _auth401_abort_or_continue "no /config" +fi + +# =========================================================================== +set_group webui +# SPA surface: /webui/ loads + version stamp; optional toLowerCase in bundle +# =========================================================================== +if [ "${AUTH401_FULL}" = "1" ]; then + wcode=$(http_code "$BASE/webui/") + case "$wcode" in + 200|301|302) ok "webui /" "HTTP $wcode" ;; + *) warn "webui /" "HTTP $wcode" ;; + esac + vcode=$(http_body "$BASE/webui/version.txt" "$tmp/spa-ver.txt" 2>/dev/null || echo 000) + if [ "$vcode" = "200" ] && [ -s "$tmp/spa-ver.txt" ]; then + ok "webui version.txt" "$(tr -d '\n\r' <"$tmp/spa-ver.txt")" + else + warn "webui version.txt" "HTTP ${vcode:-000}" + fi + # Download a slice of index.js — look for lowercasing mitigation (#11340) + jcode=$(curl -skS -m 60 -o "$tmp/spa-index.js" -w '%{http_code}' "$BASE/webui/index.js" 2>/dev/null || echo 000) + jcode=$(printf '%s' "$jcode" | tr -cd '0-9' | head -c 3) + if [ "$jcode" = "200" ] && [ -s "$tmp/spa-index.js" ]; then + ok "webui index.js" "HTTP 200 · $(wc -c <"$tmp/spa-index.js" | tr -d ' ') bytes" + if grep -q 'toLowerCase' "$tmp/spa-index.js" 2>/dev/null; then + ok "webui bundle toLowerCase" "present (SPA may mitigate #11340)" + else + warn "webui bundle toLowerCase" "not found in index.js — login MIX may 401 without lowercasing" + fi + else + warn "webui index.js" "HTTP ${jcode:-000}" + fi +fi + +# =========================================================================== +set_group signup +# WebUI self-provision: POST /instances with mixed-case id (as typed) +# =========================================================================== +CREATED=0 +USE_ID="" +USE_PASS="" +ID_MIX="" +ID_LOW="" +CREATE_PASS="$AUTH401_CREATE_PASSWORD" +stamp=$(date +%H%M%S) +# Mixed-case id like the SPA would send before lowercasing (or if it forgot) +ID_MIX="$(printf '%s' "${AUTH401_ID_PREFIX}" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')X${stamp}" +ID_LOW=$(to_lower "$ID_MIX") + +if [ "${AUTH401_FULL}" = "1" ] && [ "$AUTH401_CREATE" = "1" ] && [ "$HAVE_SP" = "1" ]; then + if [ "$MANDATORY_TAN" = "1" ] && [ "$AUTH401_SKIP_IF_MFA" = "1" ]; then + warn "signup skipped" "mandatory_tan_channels set — set AUTH401_INSTANCE+PASSWORD" + else + body=$(instance_body "$ID_MIX" "$CREATE_PASS" "auth401 signup MIX") + ccode=$(_http_norm "$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/create.json" -w '%{http_code}' \ + -X POST "${BASE}/instances" \ + -H 'Content-Type: application/json' \ + -d "$body" 2>/dev/null || true)") + case "$ccode" in + 200|204) + ok "signup POST /instances id=MIX" "HTTP $ccode id=$ID_MIX (SPA self-provision)" + CREATED=1 + USE_ID="$ID_MIX" + USE_PASS="$CREATE_PASS" + # Immediate login as SPA after signup: must use *lowercase* Basic user + code=$(token_http "$ID_LOW" "$ID_LOW" "$CREATE_PASS") + expect_http "signup→login SPA-sim path=low user=low → 200" 200 "$code" "$(json_code_hint)" + # Bug path: SPA forgot toLowerCase on login after signup with MIX id typed + code=$(token_http "$ID_LOW" "$ID_MIX" "$CREATE_PASS") + expect_http "signup→login RAW user=MIX (no lower) → 401" 401 "$code" "$(json_code_hint)" + ;; + 202) + warn "signup needs MFA" "HTTP 202 — use AUTH401_INSTANCE" + ;; + *) + fail "signup POST /instances" "HTTP $ccode $(head -c 120 "$tmp/create.json" 2>/dev/null | tr '\n' ' ')" + ;; + esac + fi +elif [ -n "${AUTH401_INSTANCE:-}" ] && [ -n "${AUTH401_PASSWORD:-}" ]; then + USE_ID=$(to_lower "$AUTH401_INSTANCE") + USE_PASS="$AUTH401_PASSWORD" + ID_LOW=$(to_lower "$USE_ID") + ID_MIX=$(synth_mix "$ID_LOW") + info "signup" "using existing AUTH401_INSTANCE=$USE_ID (no POST /instances)" + ok "using existing instance" "$USE_ID" +else + warn "signup skipped" "CREATE=${AUTH401_CREATE} HAVE_SP=${HAVE_SP} (need instance for matrix)" +fi + +# Fallback durable +if [ -z "$USE_ID" ] && [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then + USE_ID="$DURABLE_ID" + USE_PASS="$DURABLE_PASS" + ID_LOW=$(to_lower "$USE_ID") + ID_MIX=$(synth_mix "$ID_LOW") + info "fallback durable" "$USE_ID" + ok "using durable instance" "$USE_ID" +fi + +if [ -z "$USE_ID" ]; then + fail "no instance under test" "need signup OK or AUTH401_INSTANCE+PASSWORD or mon401.password" + _auth401_abort_or_continue "no instance under test" + # CONTINUE: synthesize dummy ids so later groups still run (expect many 404/401) + ID_LOW_USE="auth401-missing-instance" + ID_USER_MIX=$(synth_mix "$ID_LOW_USE") + ID_PATH_MIX="$ID_USER_MIX" + ID_UPPER=$(to_upper "$ID_LOW_USE") + ID_PARTMIX=$(synth_partmix "$ID_LOW_USE") + PASS_OK="invalid" + PASS_BAD='auth401-wrong-password-NOT-VALID' + USE_ID="$ID_LOW_USE" + USE_PASS="$PASS_OK" +fi + +ID_LOW_USE=$(to_lower "$USE_ID") +# Always synthetic case variants (even for mon401) so MIX ≠ low +ID_USER_MIX=$(synth_mix "$ID_LOW_USE") +ID_PATH_MIX="$ID_USER_MIX" +ID_UPPER=$(to_upper "$ID_LOW_USE") +ID_PARTMIX=$(synth_partmix "$ID_LOW_USE") +PASS_OK="$USE_PASS" +PASS_BAD='auth401-wrong-password-NOT-VALID' + +info "probe ids" "low=${ID_LOW_USE} MIX_user=${ID_USER_MIX} UPPER=${ID_UPPER} created=${CREATED} pass_len=${#PASS_OK}" + +# =========================================================================== +set_group login +# WebUI login = POST …/private/token with Basic (username, password) +# =========================================================================== +if [ "${AUTH401_FULL}" = "1" ]; then + code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") + expect_http "login SPA-sim user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" + code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$PASS_OK") + expect_http "login RAW user=MIX pwd=OK → 401 (#11340)" 401 "$code" "$(json_code_hint)" + code=$(token_http "$ID_LOW_USE" "$ID_UPPER" "$PASS_OK") + expect_http "login RAW user=UPPER pwd=OK → 401" 401 "$code" "$(json_code_hint)" + code=$(token_http "$ID_PATH_MIX" "$ID_LOW_USE" "$PASS_OK") + expect_http "login path=MIX user=low pwd=OK → 200 (path tolerant)" 200 "$code" "$(json_code_hint)" + code=$(token_http "$ID_UPPER" "$ID_LOW_USE" "$PASS_OK") + expect_http "login path=UPPER user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" +fi + +# =========================================================================== +set_group case +# Full Basic case matrix (always hard expects — synthetic MIX) +# =========================================================================== +code=$(token_http "$ID_PATH_MIX" "$ID_USER_MIX" "$PASS_OK") +expect_http "path=MIX user=MIX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_PATH_MIX" "$ID_LOW_USE" "$PASS_OK") +expect_http "path=MIX user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$PASS_OK") +expect_http "path=low user=MIX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") +expect_http "path=low user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_UPPER" "$ID_LOW_USE" "$PASS_OK") +expect_http "path=UPPER user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_LOW_USE" "$ID_UPPER" "$PASS_OK") +expect_http "path=low user=UPPER pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_LOW_USE" "$ID_PARTMIX" "$PASS_OK") +expect_http "path=low user=MiX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)" + +# =========================================================================== +set_group password +# =========================================================================== +code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_BAD") +expect_http "pwd=BAD → 401" 401 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "") +expect_http "pwd=EMPTY → 401" 401 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_PATH_MIX" "$ID_USER_MIX" "$PASS_BAD") +expect_http "path=MIX user=MIX pwd=BAD → 401" 401 "$code" "$(json_code_hint)" + +# =========================================================================== +set_group pwchange +# WebUI password change: POST private/auth then re-login +# Only on throwaway signup instances — never rotate durable mon401.password +# =========================================================================== +if [ "${AUTH401_FULL}" = "1" ]; then + if [ "${AUTH401_PWCHANGE_THROWAY_ONLY}" = "1" ] && [ "${CREATED:-0}" != "1" ]; then + info "pwchange skipped" "not a throwaway instance (CREATED=0) — refuse to rotate durable password" + else + code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") + if [ "$code" != "200" ]; then + fail "pwchange: login for token" "HTTP $code — skip pwchange" + else + TOK=$(extract_token) + if [ -z "$TOK" ]; then + fail "pwchange: parse token" "empty access_token" + else + ok "pwchange: got bearer" "len=${#TOK}" + NEW_PASS="Auth401-New-${stamp}!" + ac=$(_http_norm "$(auth_change_http "$ID_LOW_USE" "$TOK" "$NEW_PASS")") + case "$ac" in + 200|204) + ok "pwchange POST private/auth" "HTTP $ac (SPA password form)" + code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") + expect_http "pwchange: old pwd → 401" 401 "$code" "$(json_code_hint)" + code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$NEW_PASS") + expect_http "pwchange: new pwd user=low → 200" 200 "$code" "$(json_code_hint)" + code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$NEW_PASS") + expect_http "pwchange: new pwd user=MIX → 401" 401 "$code" "$(json_code_hint)" + PASS_OK="$NEW_PASS" + USE_PASS="$NEW_PASS" + ;; + 202) + warn "pwchange needs MFA" "HTTP 202" + ;; + *) + fail "pwchange POST private/auth" "HTTP $ac $(head -c 100 "$tmp/auth_change.json" 2>/dev/null | tr '\n' ' ')" + ;; + esac + fi + fi + fi +fi + +# =========================================================================== +set_group admin +# Admin UI: POST /management/instances (needs admin bearer) +# =========================================================================== +if [ "${AUTH401_FULL}" = "1" ]; then + ADMIN_TOK="${AUTH401_ADMIN_TOKEN:-}" + # Optional: login as admin user with password + if [ -z "$ADMIN_TOK" ] && [ -n "${AUTH401_ADMIN_PASSWORD}" ]; then + code=$(token_http "$AUTH401_ADMIN_USER" "$AUTH401_ADMIN_USER" "$AUTH401_ADMIN_PASSWORD") + if [ "$code" = "200" ]; then + ADMIN_TOK=$(extract_token) + ok "admin login user=${AUTH401_ADMIN_USER}" "got token" + else + # try lower only + au=$(to_lower "$AUTH401_ADMIN_USER") + code=$(token_http "$au" "$au" "$AUTH401_ADMIN_PASSWORD") + if [ "$code" = "200" ]; then + ADMIN_TOK=$(extract_token) + ok "admin login user=${au}" "got token" + else + warn "admin login" "HTTP $code — set AUTH401_ADMIN_TOKEN" + fi + fi + fi + + if [ -z "$ADMIN_TOK" ]; then + info "admin-create skipped" "no AUTH401_ADMIN_TOKEN / AUTH401_ADMIN_PASSWORD (stage default is not admin)" + else + case "$ADMIN_TOK" in secret-token:*) ;; *) ADMIN_TOK="secret-token:${ADMIN_TOK}" ;; esac + AID_MIX="Adm401X${stamp}" + AID_LOW=$(to_lower "$AID_MIX") + APASS="Adm401-Pass-${stamp}!" + body=$(instance_body "$AID_MIX" "$APASS" "auth401 admin-create MIX") + acode=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/admin_create.json" -w '%{http_code}' \ + -X POST "${BASE}/management/instances" \ + -H "Authorization: Bearer ${ADMIN_TOK}" \ + -H 'Content-Type: application/json' \ + -d "$body" 2>/dev/null || echo 000) + case "$acode" in + 200|204) + ok "admin-create POST /management/instances id=MIX" "HTTP $acode id=$AID_MIX" + code=$(token_http "$AID_LOW" "$AID_LOW" "$APASS") + expect_http "admin-create→login user=low → 200" 200 "$code" "$(json_code_hint)" + code=$(token_http "$AID_LOW" "$AID_MIX" "$APASS") + expect_http "admin-create→login user=MIX → 401" 401 "$code" "$(json_code_hint)" + # admin-set auth on new instance + code=$(token_http "$AID_LOW" "$AID_LOW" "$APASS") + if [ "$code" = "200" ]; then + AT=$(extract_token) + NEW_A="Adm401-New-${stamp}!" + ch=$(auth_change_http "$AID_LOW" "$AT" "$NEW_A") + case "$ch" in + 200|204) + ok "admin-created instance pwchange" "HTTP $ch" + code=$(token_http "$AID_LOW" "$AID_LOW" "$NEW_A") + expect_http "admin-created re-login new pwd → 200" 200 "$code" "$(json_code_hint)" + ;; + *) warn "admin-created pwchange" "HTTP $ch" ;; + esac + fi + ;; + 401|403) + warn "admin-create unauthorized" "HTTP $acode — token not admin (expected on stage default)" + ;; + 202) + warn "admin-create MFA" "HTTP 202" + ;; + *) + fail "admin-create" "HTTP $acode $(head -c 120 "$tmp/admin_create.json" 2>/dev/null | tr '\n' ' ')" + ;; + esac + fi +fi + +# =========================================================================== +set_group missing +# =========================================================================== +code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/last_token.json" -w '%{http_code}' \ + -X POST "${BASE}/instances/${ID_LOW_USE}/private/token" \ + -H 'Content-Type: application/json' \ + -d '{"scope":"write","duration":{"d_us":3600000000},"refreshable":true}' \ + 2>/dev/null || echo 000) +expect_http "private/token NO_AUTH → 401" 401 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_LOW_USE" "auth401-not-a-real-user" "$PASS_OK") +expect_http "user=OTHER pwd=OK → 401" 401 "$code" "$(json_code_hint)" + +code=$(token_http "auth401-does-not-exist-${stamp}" "$ID_LOW_USE" "$PASS_OK") +# Must not look like auth failure (401). Prefer 404; 502 from proxy is soft. +case "$code" in + 404) ok "path=missing → 404 (not 401)" "HTTP 404 · $(json_code_hint)" ;; + 401) fail "path=missing must not be 401" "HTTP 401 · $(json_code_hint)" ;; + 502|503) warn "path=missing" "HTTP $code (want 404; proxy flake, not auth)" ;; + *) fail "path=missing → 404 (not 401)" "HTTP $code (want 404) · $(json_code_hint)" ;; +esac + +# =========================================================================== +set_group bearer +# =========================================================================== +code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") +if [ "$code" = "200" ]; then + TOKEN=$(extract_token) + if [ -n "$TOKEN" ]; then + ok "got access_token" "len=${#TOKEN}" + code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/priv.json" -w '%{http_code}' \ + "${BASE}/instances/${ID_LOW_USE}/private/" \ + -H "Authorization: Bearer ${TOKEN}" 2>/dev/null || echo 000) + expect_http "GET private/ Bearer OK → 200" 200 "$code" + + code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/priv.json" -w '%{http_code}' \ + "${BASE}/instances/${ID_LOW_USE}/private/" \ + -H "Authorization: Bearer secret-token:AUTH401INVALID000" 2>/dev/null || echo 000) + expect_http "GET private/ Bearer BAD → 401" 401 "$code" + + code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/priv.json" -w '%{http_code}' \ + "${BASE}/instances/${ID_LOW_USE}/private/" 2>/dev/null || echo 000) + expect_http "GET private/ NO_AUTH → 401" 401 "$code" + else + fail "parse access_token" "HTTP $code but no token field" + fi +else + fail "token for bearer tests" "HTTP $code" +fi + +code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/mgmt.json" -w '%{http_code}' \ + "${BASE}/management/instances" 2>/dev/null || echo 000) +expect_http "GET /management/instances NO_AUTH → 401" 401 "$code" + +if [ -n "${AUTH401_BEARER:-}" ]; then + if [ -z "${AUTH401_BEARER_INSTANCE:-}" ]; then + case "${AUTH401_BEARER_NOTE:-}" in + *default-instance-token*) AUTH401_BEARER_INSTANCE=default ;; + *) AUTH401_BEARER_INSTANCE="${DURABLE_ID:-default}" ;; + esac + fi + inst=$(to_lower "$AUTH401_BEARER_INSTANCE") + tok="$AUTH401_BEARER" + case "$tok" in secret-token:*) ;; *) tok="secret-token:${tok}" ;; esac + code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/priv2.json" -w '%{http_code}' \ + "${BASE}/instances/${inst}/private/" \ + -H "Authorization: Bearer ${tok}" 2>/dev/null || echo 000) + case "$code" in + 200) ok "GET private/ secrets Bearer" "HTTP 200 instance=$inst" ;; + 401) warn "secrets Bearer rejected" "HTTP 401 on $inst" ;; + *) warn "secrets Bearer" "HTTP $code on $inst" ;; + esac +fi + +# =========================================================================== +set_group durable +# Extra: durable mon401 still works after throwaway tests (if present) +# =========================================================================== +if [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then + dlow=$(to_lower "$DURABLE_ID") + dmix=$(synth_mix "$dlow") + code=$(token_http "$dlow" "$dlow" "$DURABLE_PASS") + expect_http "durable ${dlow} login user=low → 200" 200 "$code" "$(json_code_hint)" + code=$(token_http "$dlow" "$dmix" "$DURABLE_PASS") + expect_http "durable ${dlow} login user=MIX → 401" 401 "$code" "$(json_code_hint)" +else + info "durable" "no mon401.password — skipped" +fi + +# =========================================================================== +set_group report +# =========================================================================== +info "summary matrix" "401: Basic user case≠low | bad/empty pwd | no auth | bad bearer | wrong user · 200: user=low + path any case · 404: missing instance · SPA must toLowerCase username on login/signup/admin/pw-relogin" +if [ "$CREATED" = "1" ]; then + info "throwaway left" "id=${ID_MIX:-?} low=${ID_LOW_USE} (password rotated if pwchange ran)" +fi + +# fall through → EXIT trap runs _auth401_finish (summary + full ERROR list) +: From a1ca5a91ea65616e6c856e212be0141d5ff93d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 22:58:04 +0200 Subject: [PATCH 294/326] monitoring: check_auth401 WebUI signup/login/pwchange/case matrix --- scripts/taler-monitoring/check_auth401.sh | 704 ++++++++++++++++++++++ 1 file changed, 704 insertions(+) create mode 100755 scripts/taler-monitoring/check_auth401.sh diff --git a/scripts/taler-monitoring/check_auth401.sh b/scripts/taler-monitoring/check_auth401.sh new file mode 100755 index 0000000..dfca606 --- /dev/null +++ b/scripts/taler-monitoring/check_auth401.sh @@ -0,0 +1,704 @@ +#!/usr/bin/env bash +# auth401 — merchant HTTP 401 / case-sensitivity matrix (WebUI-shaped flows) +# +# Exercises the same APIs the merchant SPA uses for: +# • self-provision signup (POST /instances) — mixed-case id +# • login / createAccessToken (POST …/private/token) — Basic user case +# • password change (POST …/private/auth) +# • admin-create (POST /management/instances) when admin bearer available +# • access-token + private/ with Bearer +# +# Backend fact (#11340): Basic-auth *username* is case-sensitive; URL path is +# usually not. SPA must always lowercase the username (createAccessToken, signup +# id, admin-create, pw change re-login). These tests assert the *raw API* +# behaviour so a missing SPA lowercasing shows up as 401 where 200 is wanted +# only after lowercasing. +# +# ./taler-monitoring.sh -d stage.monnaie.lefrancpaysan.ch auth401 +# ./taler-monitoring.sh -d hacktivism.ch auth401 +# +# Continue through all groups / collect every ERROR (optional): +# AUTH401_CONTINUE=1 ./taler-monitoring.sh -d stage… auth401 +# CONTINUE_ON_ERROR=1 ./taler-monitoring.sh -d stage… auth401 # same +# +# Env: see secrets.env.example (AUTH401_*). +# +# NOTE: we intentionally do NOT use `set -e`. A single failed curl/grep under +# set -e would abort with no ERROR summary — the opposite of what monitoring +# needs. Optional CONTINUE_ON_ERROR still controls hard prereq early-exit. +set -uo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" +load_monitoring_secrets_env 2>/dev/null || true + +tmp=$(mktemp -d) +AUTH401_FINISHED=0 +_auth401_finish() { + [ "${AUTH401_FINISHED}" = "1" ] && return 0 + AUTH401_FINISHED=1 + # summary() returns non-zero when FAIL_N>0 — never let that skip the print + summary || true + rm -rf "${tmp:-}" + if [ "${FAIL_N:-0}" -gt 0 ]; then + exit 1 + fi + exit 0 +} +trap '_auth401_finish' EXIT + +# --------------------------------------------------------------------------- +# Parameters +# --------------------------------------------------------------------------- +: "${AUTH401_TIMEOUT:=${TIMEOUT:-20}}" +: "${AUTH401_CREATE:=1}" +: "${AUTH401_INSTANCE:=}" +: "${AUTH401_PASSWORD:=${MERCHANT_INSTANCE_PASSWORD:-${AUTH401_PASS:-}}}" +: "${AUTH401_ID_PREFIX:=mon401}" +: "${AUTH401_SKIP_IF_MFA:=1}" +: "${AUTH401_BEARER:=${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}}" +: "${AUTH401_CREATE_PASSWORD:=${AUTH401_PASSWORD:-Mon401-Test-Pass!}}" +: "${AUTH401_DURABLE_ID:=mon401}" +# Prefer durable alone (skip throwaway) — default 0 so full matrix always runs +: "${AUTH401_PREFER_DURABLE:=0}" +# Run expanded groups (signup/login/pwchange/admin/webui). Default 1. +: "${AUTH401_FULL:=1}" +# Admin bearer for POST /management/instances (optional) +: "${AUTH401_ADMIN_TOKEN:=${MERCHANT_ADMIN_TOKEN:-}}" +: "${AUTH401_ADMIN_PASSWORD:=}" +: "${AUTH401_ADMIN_USER:=admin}" +# 1 = do not abort on hard prereq fail; run remaining groups (default 0) +# Alias: CONTINUE_ON_ERROR=1 +: "${AUTH401_CONTINUE:=${CONTINUE_ON_ERROR:-0}}" +# Never rotate password on durable account (default 1 — keep mon401.password valid) +: "${AUTH401_PWCHANGE_THROWAY_ONLY:=1}" + +TIMEOUT="$AUTH401_TIMEOUT" +export TIMEOUT + +_auth401_abort_or_continue() { + local why="${1:-prereq failed}" + if [ "${AUTH401_CONTINUE}" = "1" ]; then + warn "continue" "${why} (AUTH401_CONTINUE=1 / CONTINUE_ON_ERROR=1)" + return 0 + fi + # hard stop — EXIT trap prints summary + all ERRORS collected so far + exit 1 +} + +BASE="${MERCHANT_PUBLIC%/}" +if [ -z "$BASE" ]; then + echo "MERCHANT_PUBLIC empty — set -d domain or --merchant" >&2 + exit 2 +fi + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +expect_http() { + local label="$1" want="$2" got="$3" detail="${4:-}" + case ",$want," in + *",$got,"*) ok "$label" "HTTP $got${detail:+ · $detail}" ;; + *) fail "$label" "HTTP $got (want $want)${detail:+ · $detail}" ;; + esac +} + +to_lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } +to_upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]'; } + +# First char upper, rest lower → synthetic MIX for Basic user (always ≠ low if len>0) +synth_mix() { + python3 -c "s='''$1'''; print((s[:1].upper()+s[1:].lower()) if s else s)" +} + +# First 3 upper (partial mix) +synth_partmix() { + python3 -c "s='''$1'''; print(s[:3].upper()+s[3:] if len(s)>=3 else s.upper())" +} + +# Normalize curl status: "200", "000", never "200000" / "000000" +_http_norm() { + local raw="$1" dig + dig=$(printf '%s' "$raw" | tr -cd '0-9') + if [ -z "$dig" ]; then + printf '000' + else + # first 3 digits = real code (curl 200 then || echo 000 → 200000) + printf '%s' "$dig" | head -c 3 + fi +} + +token_http() { + local path_id="$1" user="$2" password="$3" + local b64 raw + b64=$(printf '%s' "${user}:${password}" | base64 -w0 2>/dev/null \ + || printf '%s' "${user}:${password}" | base64) + raw=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/last_token.json" -w '%{http_code}' \ + -X POST "${BASE}/instances/${path_id}/private/token" \ + -H "Authorization: Basic ${b64}" \ + -H 'Content-Type: application/json' \ + -d '{"scope":"write","duration":{"d_us":3600000000},"refreshable":true}' \ + 2>/dev/null || true) + _http_norm "${raw:-}" +} + +json_code_hint() { + python3 - "$tmp/last_token.json" <<'PY' 2>/dev/null || true +import json, sys +try: + d = json.load(open(sys.argv[1])) +except Exception: + print("") + raise SystemExit +c = d.get("code", "") +h = (d.get("hint") or d.get("detail") or "")[:70] +print(f"code={c} {h}".strip()) +PY +} + +extract_token() { + python3 - "$tmp/last_token.json" <<'PY' 2>/dev/null || true +import json, sys +try: + d = json.load(open(sys.argv[1])) +except Exception: + print("") + raise SystemExit(0) +print(d.get("access_token") or d.get("token") or "") +PY +} + +instance_body() { + local id="$1" pass="$2" name="${3:-auth401 probe}" + python3 - "$id" "$pass" "$name" <<'PY' +import json, sys +print(json.dumps({ + "address": {}, + "auth": {"method": "token", "password": sys.argv[2]}, + "default_pay_delay": {"d_us": 60000000}, + "default_wire_transfer_delay": {"d_us": 30000000}, + "default_refund_delay": {"d_us": 30000000}, + "id": sys.argv[1], + "jurisdiction": {}, + "name": sys.argv[3], + "use_stefan": True, +})) +PY +} + +auth_change_http() { + local path_id="$1" bearer="$2" new_pass="$3" + local tok="$bearer" body + case "$tok" in secret-token:*) ;; *) tok="secret-token:${tok}" ;; esac + body=$(NEW_PASS="$new_pass" python3 -c 'import json,os; print(json.dumps({"method":"token","password":os.environ["NEW_PASS"]}))') + curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/auth_change.json" -w '%{http_code}' \ + -X POST "${BASE}/instances/${path_id}/private/auth" \ + -H "Authorization: Bearer ${tok}" \ + -H 'Content-Type: application/json' \ + -d "$body" \ + 2>/dev/null || echo 000 +} + +# --------------------------------------------------------------------------- +# Stage secrets (quiet) +# --------------------------------------------------------------------------- +_auth401_stage_secret_roots() { + printf '%s\n' \ + "${FRANCPAYSAN_SECRETS:-}" \ + "${HOME}/francpaysan-secrets" \ + "${HOME}/src/francpaysan-secrets" \ + "${HOME}/taler/src/francpaysan-secrets" +} + +_auth401_resolve_secrets_quiet() { + local root inst_id pass + for root in $(_auth401_stage_secret_roots); do + [ -n "$root" ] && [ -d "$root" ] || continue + # durable ids: prefer mon401fix when mon401 was rotated by old tests + if [ -z "${DURABLE_PASS:-}" ]; then + for inst_id in "${AUTH401_DURABLE_ID}" mon401fix mon401 "${MERCHANT_INSTANCE:-}"; do + [ -n "$inst_id" ] || continue + pass="" + if [ -f "$root/stage/instances/${inst_id}.password" ]; then + pass=$(tr -d '\n\r' <"$root/stage/instances/${inst_id}.password") + fi + # skip empty password files + if [ -n "$pass" ]; then + # verify file is for this id (mon401.password may be stale copy of mon401fix) + DURABLE_PASS="$pass" + DURABLE_ID=$(to_lower "$inst_id") + AUTH401_SECRET_NOTE="durable ${inst_id} from ${root}/stage/instances/" + # only set AUTH401_PASSWORD if caller did not pin an instance + if [ -z "${AUTH401_PASSWORD}" ] && [ -z "${AUTH401_INSTANCE}" ]; then + AUTH401_PASSWORD="$pass" + fi + break + fi + done + fi + if [ -z "${AUTH401_BEARER}" ] && [ -f "$root/stage/default-instance-token.txt" ]; then + AUTH401_BEARER=$(tr -d '\n\r' <"$root/stage/default-instance-token.txt") + AUTH401_BEARER_NOTE="default-instance-token.txt" + fi + if [ -z "${AUTH401_ADMIN_TOKEN}" ] && [ -f "$root/stage/admin-instance-token.txt" ]; then + AUTH401_ADMIN_TOKEN=$(tr -d '\n\r' <"$root/stage/admin-instance-token.txt") + AUTH401_ADMIN_NOTE="admin-instance-token.txt" + fi + [ -n "${DURABLE_PASS:-}" ] && break + done + # also keep AUTH401_INSTANCE for durable-only mode + if [ "${AUTH401_PREFER_DURABLE}" = "1" ] && [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then + AUTH401_INSTANCE="${AUTH401_INSTANCE:-$DURABLE_ID}" + AUTH401_PASSWORD="${AUTH401_PASSWORD:-$DURABLE_PASS}" + AUTH401_CREATE=0 + AUTH401_MODE_NOTE="PREFER_DURABLE=1 → only durable ${AUTH401_INSTANCE}" + fi +} + +_auth401_resolve_secrets_quiet +: "${AUTH401_CREATE_PASSWORD:=${AUTH401_PASSWORD:-Mon401-Test-Pass!}}" +export AUTH401_INSTANCE AUTH401_PASSWORD AUTH401_BEARER AUTH401_CREATE AUTH401_ADMIN_TOKEN + +# --------------------------------------------------------------------------- +set_area auth401 +section "auth401 · merchant 401 / case matrix (WebUI APIs) · ${BASE}" + +if [ -n "${AUTH401_SECRET_NOTE:-}" ]; then info "stage secret" "$AUTH401_SECRET_NOTE"; fi +if [ -n "${AUTH401_BEARER_NOTE:-}" ]; then info "stage secret" "AUTH401_BEARER from ${AUTH401_BEARER_NOTE}"; fi +if [ -n "${AUTH401_ADMIN_NOTE:-}" ]; then info "stage secret" "AUTH401_ADMIN_TOKEN from ${AUTH401_ADMIN_NOTE}"; fi +if [ -n "${AUTH401_MODE_NOTE:-}" ]; then info "auth401 mode" "$AUTH401_MODE_NOTE"; fi +info "auth401 scope" "FULL=${AUTH401_FULL} CREATE=${AUTH401_CREATE} PREFER_DURABLE=${AUTH401_PREFER_DURABLE} CONTINUE=${AUTH401_CONTINUE}" +if [ "${AUTH401_CONTINUE}" = "1" ]; then + info "continue mode" "CONTINUE_ON_ERROR/AUTH401_CONTINUE=1 — hard prereqs soft; always full ERROR list via EXIT trap" +fi + +# =========================================================================== +set_group config +# =========================================================================== +cfg_code=$(http_body "$BASE/config" "$tmp/mer-config.json") +expect_http "merchant /config" 200 "$cfg_code" "$BASE/config" + +HAVE_SP=0 +MANDATORY_TAN=0 +if [ "$cfg_code" = "200" ]; then + eval "$(python3 - "$tmp/mer-config.json" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +sp = 1 if d.get("have_self_provisioning") else 0 +tans = d.get("mandatory_tan_channels") or [] +print(f"HAVE_SP={sp}") +print(f"MANDATORY_TAN={1 if tans else 0}") +print(f"TAN_CH={','.join(tans) if tans else ''}") +print(f"API_VER={d.get('version','')}") +PY +)" + info "self-provision" "have_self_provisioning=${HAVE_SP} mandatory_tan=${MANDATORY_TAN}${TAN_CH:+ ($TAN_CH)} api=${API_VER:-?}" +else + fail "merchant config required for auth401" "HTTP $cfg_code" + _auth401_abort_or_continue "no /config" +fi + +# =========================================================================== +set_group webui +# SPA surface: /webui/ loads + version stamp; optional toLowerCase in bundle +# =========================================================================== +if [ "${AUTH401_FULL}" = "1" ]; then + wcode=$(http_code "$BASE/webui/") + case "$wcode" in + 200|301|302) ok "webui /" "HTTP $wcode" ;; + *) warn "webui /" "HTTP $wcode" ;; + esac + vcode=$(http_body "$BASE/webui/version.txt" "$tmp/spa-ver.txt" 2>/dev/null || echo 000) + if [ "$vcode" = "200" ] && [ -s "$tmp/spa-ver.txt" ]; then + ok "webui version.txt" "$(tr -d '\n\r' <"$tmp/spa-ver.txt")" + else + warn "webui version.txt" "HTTP ${vcode:-000}" + fi + # Download a slice of index.js — look for lowercasing mitigation (#11340) + jcode=$(curl -skS -m 60 -o "$tmp/spa-index.js" -w '%{http_code}' "$BASE/webui/index.js" 2>/dev/null || echo 000) + jcode=$(printf '%s' "$jcode" | tr -cd '0-9' | head -c 3) + if [ "$jcode" = "200" ] && [ -s "$tmp/spa-index.js" ]; then + ok "webui index.js" "HTTP 200 · $(wc -c <"$tmp/spa-index.js" | tr -d ' ') bytes" + if grep -q 'toLowerCase' "$tmp/spa-index.js" 2>/dev/null; then + ok "webui bundle toLowerCase" "present (SPA may mitigate #11340)" + else + warn "webui bundle toLowerCase" "not found in index.js — login MIX may 401 without lowercasing" + fi + else + warn "webui index.js" "HTTP ${jcode:-000}" + fi +fi + +# =========================================================================== +set_group signup +# WebUI self-provision: POST /instances with mixed-case id (as typed) +# =========================================================================== +CREATED=0 +USE_ID="" +USE_PASS="" +ID_MIX="" +ID_LOW="" +CREATE_PASS="$AUTH401_CREATE_PASSWORD" +stamp=$(date +%H%M%S) +# Mixed-case id like the SPA would send before lowercasing (or if it forgot) +ID_MIX="$(printf '%s' "${AUTH401_ID_PREFIX}" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')X${stamp}" +ID_LOW=$(to_lower "$ID_MIX") + +if [ "${AUTH401_FULL}" = "1" ] && [ "$AUTH401_CREATE" = "1" ] && [ "$HAVE_SP" = "1" ]; then + if [ "$MANDATORY_TAN" = "1" ] && [ "$AUTH401_SKIP_IF_MFA" = "1" ]; then + warn "signup skipped" "mandatory_tan_channels set — set AUTH401_INSTANCE+PASSWORD" + else + body=$(instance_body "$ID_MIX" "$CREATE_PASS" "auth401 signup MIX") + ccode=$(_http_norm "$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/create.json" -w '%{http_code}' \ + -X POST "${BASE}/instances" \ + -H 'Content-Type: application/json' \ + -d "$body" 2>/dev/null || true)") + case "$ccode" in + 200|204) + ok "signup POST /instances id=MIX" "HTTP $ccode id=$ID_MIX (SPA self-provision)" + CREATED=1 + USE_ID="$ID_MIX" + USE_PASS="$CREATE_PASS" + # Immediate login as SPA after signup: must use *lowercase* Basic user + code=$(token_http "$ID_LOW" "$ID_LOW" "$CREATE_PASS") + expect_http "signup→login SPA-sim path=low user=low → 200" 200 "$code" "$(json_code_hint)" + # Bug path: SPA forgot toLowerCase on login after signup with MIX id typed + code=$(token_http "$ID_LOW" "$ID_MIX" "$CREATE_PASS") + expect_http "signup→login RAW user=MIX (no lower) → 401" 401 "$code" "$(json_code_hint)" + ;; + 202) + warn "signup needs MFA" "HTTP 202 — use AUTH401_INSTANCE" + ;; + *) + fail "signup POST /instances" "HTTP $ccode $(head -c 120 "$tmp/create.json" 2>/dev/null | tr '\n' ' ')" + ;; + esac + fi +elif [ -n "${AUTH401_INSTANCE:-}" ] && [ -n "${AUTH401_PASSWORD:-}" ]; then + USE_ID=$(to_lower "$AUTH401_INSTANCE") + USE_PASS="$AUTH401_PASSWORD" + ID_LOW=$(to_lower "$USE_ID") + ID_MIX=$(synth_mix "$ID_LOW") + info "signup" "using existing AUTH401_INSTANCE=$USE_ID (no POST /instances)" + ok "using existing instance" "$USE_ID" +else + warn "signup skipped" "CREATE=${AUTH401_CREATE} HAVE_SP=${HAVE_SP} (need instance for matrix)" +fi + +# Fallback durable +if [ -z "$USE_ID" ] && [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then + USE_ID="$DURABLE_ID" + USE_PASS="$DURABLE_PASS" + ID_LOW=$(to_lower "$USE_ID") + ID_MIX=$(synth_mix "$ID_LOW") + info "fallback durable" "$USE_ID" + ok "using durable instance" "$USE_ID" +fi + +if [ -z "$USE_ID" ]; then + fail "no instance under test" "need signup OK or AUTH401_INSTANCE+PASSWORD or mon401.password" + _auth401_abort_or_continue "no instance under test" + # CONTINUE: synthesize dummy ids so later groups still run (expect many 404/401) + ID_LOW_USE="auth401-missing-instance" + ID_USER_MIX=$(synth_mix "$ID_LOW_USE") + ID_PATH_MIX="$ID_USER_MIX" + ID_UPPER=$(to_upper "$ID_LOW_USE") + ID_PARTMIX=$(synth_partmix "$ID_LOW_USE") + PASS_OK="invalid" + PASS_BAD='auth401-wrong-password-NOT-VALID' + USE_ID="$ID_LOW_USE" + USE_PASS="$PASS_OK" +fi + +ID_LOW_USE=$(to_lower "$USE_ID") +# Always synthetic case variants (even for mon401) so MIX ≠ low +ID_USER_MIX=$(synth_mix "$ID_LOW_USE") +ID_PATH_MIX="$ID_USER_MIX" +ID_UPPER=$(to_upper "$ID_LOW_USE") +ID_PARTMIX=$(synth_partmix "$ID_LOW_USE") +PASS_OK="$USE_PASS" +PASS_BAD='auth401-wrong-password-NOT-VALID' + +info "probe ids" "low=${ID_LOW_USE} MIX_user=${ID_USER_MIX} UPPER=${ID_UPPER} created=${CREATED} pass_len=${#PASS_OK}" + +# =========================================================================== +set_group login +# WebUI login = POST …/private/token with Basic (username, password) +# =========================================================================== +if [ "${AUTH401_FULL}" = "1" ]; then + code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") + expect_http "login SPA-sim user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" + code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$PASS_OK") + expect_http "login RAW user=MIX pwd=OK → 401 (#11340)" 401 "$code" "$(json_code_hint)" + code=$(token_http "$ID_LOW_USE" "$ID_UPPER" "$PASS_OK") + expect_http "login RAW user=UPPER pwd=OK → 401" 401 "$code" "$(json_code_hint)" + code=$(token_http "$ID_PATH_MIX" "$ID_LOW_USE" "$PASS_OK") + expect_http "login path=MIX user=low pwd=OK → 200 (path tolerant)" 200 "$code" "$(json_code_hint)" + code=$(token_http "$ID_UPPER" "$ID_LOW_USE" "$PASS_OK") + expect_http "login path=UPPER user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" +fi + +# =========================================================================== +set_group case +# Full Basic case matrix (always hard expects — synthetic MIX) +# =========================================================================== +code=$(token_http "$ID_PATH_MIX" "$ID_USER_MIX" "$PASS_OK") +expect_http "path=MIX user=MIX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_PATH_MIX" "$ID_LOW_USE" "$PASS_OK") +expect_http "path=MIX user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$PASS_OK") +expect_http "path=low user=MIX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") +expect_http "path=low user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_UPPER" "$ID_LOW_USE" "$PASS_OK") +expect_http "path=UPPER user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_LOW_USE" "$ID_UPPER" "$PASS_OK") +expect_http "path=low user=UPPER pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_LOW_USE" "$ID_PARTMIX" "$PASS_OK") +expect_http "path=low user=MiX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)" + +# =========================================================================== +set_group password +# =========================================================================== +code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_BAD") +expect_http "pwd=BAD → 401" 401 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "") +expect_http "pwd=EMPTY → 401" 401 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_PATH_MIX" "$ID_USER_MIX" "$PASS_BAD") +expect_http "path=MIX user=MIX pwd=BAD → 401" 401 "$code" "$(json_code_hint)" + +# =========================================================================== +set_group pwchange +# WebUI password change: POST private/auth then re-login +# Only on throwaway signup instances — never rotate durable mon401.password +# =========================================================================== +if [ "${AUTH401_FULL}" = "1" ]; then + if [ "${AUTH401_PWCHANGE_THROWAY_ONLY}" = "1" ] && [ "${CREATED:-0}" != "1" ]; then + info "pwchange skipped" "not a throwaway instance (CREATED=0) — refuse to rotate durable password" + else + code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") + if [ "$code" != "200" ]; then + fail "pwchange: login for token" "HTTP $code — skip pwchange" + else + TOK=$(extract_token) + if [ -z "$TOK" ]; then + fail "pwchange: parse token" "empty access_token" + else + ok "pwchange: got bearer" "len=${#TOK}" + NEW_PASS="Auth401-New-${stamp}!" + ac=$(_http_norm "$(auth_change_http "$ID_LOW_USE" "$TOK" "$NEW_PASS")") + case "$ac" in + 200|204) + ok "pwchange POST private/auth" "HTTP $ac (SPA password form)" + code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") + expect_http "pwchange: old pwd → 401" 401 "$code" "$(json_code_hint)" + code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$NEW_PASS") + expect_http "pwchange: new pwd user=low → 200" 200 "$code" "$(json_code_hint)" + code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$NEW_PASS") + expect_http "pwchange: new pwd user=MIX → 401" 401 "$code" "$(json_code_hint)" + PASS_OK="$NEW_PASS" + USE_PASS="$NEW_PASS" + ;; + 202) + warn "pwchange needs MFA" "HTTP 202" + ;; + *) + fail "pwchange POST private/auth" "HTTP $ac $(head -c 100 "$tmp/auth_change.json" 2>/dev/null | tr '\n' ' ')" + ;; + esac + fi + fi + fi +fi + +# =========================================================================== +set_group admin +# Admin UI: POST /management/instances (needs admin bearer) +# =========================================================================== +if [ "${AUTH401_FULL}" = "1" ]; then + ADMIN_TOK="${AUTH401_ADMIN_TOKEN:-}" + # Optional: login as admin user with password + if [ -z "$ADMIN_TOK" ] && [ -n "${AUTH401_ADMIN_PASSWORD}" ]; then + code=$(token_http "$AUTH401_ADMIN_USER" "$AUTH401_ADMIN_USER" "$AUTH401_ADMIN_PASSWORD") + if [ "$code" = "200" ]; then + ADMIN_TOK=$(extract_token) + ok "admin login user=${AUTH401_ADMIN_USER}" "got token" + else + # try lower only + au=$(to_lower "$AUTH401_ADMIN_USER") + code=$(token_http "$au" "$au" "$AUTH401_ADMIN_PASSWORD") + if [ "$code" = "200" ]; then + ADMIN_TOK=$(extract_token) + ok "admin login user=${au}" "got token" + else + warn "admin login" "HTTP $code — set AUTH401_ADMIN_TOKEN" + fi + fi + fi + + if [ -z "$ADMIN_TOK" ]; then + info "admin-create skipped" "no AUTH401_ADMIN_TOKEN / AUTH401_ADMIN_PASSWORD (stage default is not admin)" + else + case "$ADMIN_TOK" in secret-token:*) ;; *) ADMIN_TOK="secret-token:${ADMIN_TOK}" ;; esac + AID_MIX="Adm401X${stamp}" + AID_LOW=$(to_lower "$AID_MIX") + APASS="Adm401-Pass-${stamp}!" + body=$(instance_body "$AID_MIX" "$APASS" "auth401 admin-create MIX") + acode=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/admin_create.json" -w '%{http_code}' \ + -X POST "${BASE}/management/instances" \ + -H "Authorization: Bearer ${ADMIN_TOK}" \ + -H 'Content-Type: application/json' \ + -d "$body" 2>/dev/null || echo 000) + case "$acode" in + 200|204) + ok "admin-create POST /management/instances id=MIX" "HTTP $acode id=$AID_MIX" + code=$(token_http "$AID_LOW" "$AID_LOW" "$APASS") + expect_http "admin-create→login user=low → 200" 200 "$code" "$(json_code_hint)" + code=$(token_http "$AID_LOW" "$AID_MIX" "$APASS") + expect_http "admin-create→login user=MIX → 401" 401 "$code" "$(json_code_hint)" + # admin-set auth on new instance + code=$(token_http "$AID_LOW" "$AID_LOW" "$APASS") + if [ "$code" = "200" ]; then + AT=$(extract_token) + NEW_A="Adm401-New-${stamp}!" + ch=$(auth_change_http "$AID_LOW" "$AT" "$NEW_A") + case "$ch" in + 200|204) + ok "admin-created instance pwchange" "HTTP $ch" + code=$(token_http "$AID_LOW" "$AID_LOW" "$NEW_A") + expect_http "admin-created re-login new pwd → 200" 200 "$code" "$(json_code_hint)" + ;; + *) warn "admin-created pwchange" "HTTP $ch" ;; + esac + fi + ;; + 401|403) + warn "admin-create unauthorized" "HTTP $acode — token not admin (expected on stage default)" + ;; + 202) + warn "admin-create MFA" "HTTP 202" + ;; + *) + fail "admin-create" "HTTP $acode $(head -c 120 "$tmp/admin_create.json" 2>/dev/null | tr '\n' ' ')" + ;; + esac + fi +fi + +# =========================================================================== +set_group missing +# =========================================================================== +code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/last_token.json" -w '%{http_code}' \ + -X POST "${BASE}/instances/${ID_LOW_USE}/private/token" \ + -H 'Content-Type: application/json' \ + -d '{"scope":"write","duration":{"d_us":3600000000},"refreshable":true}' \ + 2>/dev/null || echo 000) +expect_http "private/token NO_AUTH → 401" 401 "$code" "$(json_code_hint)" + +code=$(token_http "$ID_LOW_USE" "auth401-not-a-real-user" "$PASS_OK") +expect_http "user=OTHER pwd=OK → 401" 401 "$code" "$(json_code_hint)" + +code=$(token_http "auth401-does-not-exist-${stamp}" "$ID_LOW_USE" "$PASS_OK") +# Must not look like auth failure (401). Prefer 404; 502 from proxy is soft. +case "$code" in + 404) ok "path=missing → 404 (not 401)" "HTTP 404 · $(json_code_hint)" ;; + 401) fail "path=missing must not be 401" "HTTP 401 · $(json_code_hint)" ;; + 502|503) warn "path=missing" "HTTP $code (want 404; proxy flake, not auth)" ;; + *) fail "path=missing → 404 (not 401)" "HTTP $code (want 404) · $(json_code_hint)" ;; +esac + +# =========================================================================== +set_group bearer +# =========================================================================== +code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") +if [ "$code" = "200" ]; then + TOKEN=$(extract_token) + if [ -n "$TOKEN" ]; then + ok "got access_token" "len=${#TOKEN}" + code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/priv.json" -w '%{http_code}' \ + "${BASE}/instances/${ID_LOW_USE}/private/" \ + -H "Authorization: Bearer ${TOKEN}" 2>/dev/null || echo 000) + expect_http "GET private/ Bearer OK → 200" 200 "$code" + + code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/priv.json" -w '%{http_code}' \ + "${BASE}/instances/${ID_LOW_USE}/private/" \ + -H "Authorization: Bearer secret-token:AUTH401INVALID000" 2>/dev/null || echo 000) + expect_http "GET private/ Bearer BAD → 401" 401 "$code" + + code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/priv.json" -w '%{http_code}' \ + "${BASE}/instances/${ID_LOW_USE}/private/" 2>/dev/null || echo 000) + expect_http "GET private/ NO_AUTH → 401" 401 "$code" + else + fail "parse access_token" "HTTP $code but no token field" + fi +else + fail "token for bearer tests" "HTTP $code" +fi + +code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/mgmt.json" -w '%{http_code}' \ + "${BASE}/management/instances" 2>/dev/null || echo 000) +expect_http "GET /management/instances NO_AUTH → 401" 401 "$code" + +if [ -n "${AUTH401_BEARER:-}" ]; then + if [ -z "${AUTH401_BEARER_INSTANCE:-}" ]; then + case "${AUTH401_BEARER_NOTE:-}" in + *default-instance-token*) AUTH401_BEARER_INSTANCE=default ;; + *) AUTH401_BEARER_INSTANCE="${DURABLE_ID:-default}" ;; + esac + fi + inst=$(to_lower "$AUTH401_BEARER_INSTANCE") + tok="$AUTH401_BEARER" + case "$tok" in secret-token:*) ;; *) tok="secret-token:${tok}" ;; esac + code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ + -o "$tmp/priv2.json" -w '%{http_code}' \ + "${BASE}/instances/${inst}/private/" \ + -H "Authorization: Bearer ${tok}" 2>/dev/null || echo 000) + case "$code" in + 200) ok "GET private/ secrets Bearer" "HTTP 200 instance=$inst" ;; + 401) warn "secrets Bearer rejected" "HTTP 401 on $inst" ;; + *) warn "secrets Bearer" "HTTP $code on $inst" ;; + esac +fi + +# =========================================================================== +set_group durable +# Extra: durable mon401 still works after throwaway tests (if present) +# =========================================================================== +if [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then + dlow=$(to_lower "$DURABLE_ID") + dmix=$(synth_mix "$dlow") + code=$(token_http "$dlow" "$dlow" "$DURABLE_PASS") + expect_http "durable ${dlow} login user=low → 200" 200 "$code" "$(json_code_hint)" + code=$(token_http "$dlow" "$dmix" "$DURABLE_PASS") + expect_http "durable ${dlow} login user=MIX → 401" 401 "$code" "$(json_code_hint)" +else + info "durable" "no mon401.password — skipped" +fi + +# =========================================================================== +set_group report +# =========================================================================== +info "summary matrix" "401: Basic user case≠low | bad/empty pwd | no auth | bad bearer | wrong user · 200: user=low + path any case · 404: missing instance · SPA must toLowerCase username on login/signup/admin/pw-relogin" +if [ "$CREATED" = "1" ]; then + info "throwaway left" "id=${ID_MIX:-?} low=${ID_LOW_USE} (password rotated if pwchange ran)" +fi + +# fall through → EXIT trap runs _auth401_finish (summary + full ERROR list) +: From 7d24efcb64951138c3f25ebb663ea33461b01b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 23:22:18 +0200 Subject: [PATCH 295/326] monitoring: wire auth401 + full phases; CONTINUE_ON_ERROR export --- scripts/taler-monitoring/taler-monitoring.sh | 39 +++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index e63c922..8b20e72 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -21,13 +21,16 @@ Usage: Phases: urls public HTTPS (no SSH) ← default with --domain + (includes merchant /webui/ SPA fingerprints: version.txt, overlay, assets) inside container status via SSH (local stack only) versions taler packages vs deb.taler.net trixie + repo availability sanity public + optional server server server-side only (SSH) e2e withdraw + pay (small amounts; remote aborts on login/KYC) ladder withdraw/pay amount ladder (GOA ceiling or stage TESTPAYSAN max_wire) + auth401 merchant Basic-auth / case matrix (HTTP 401 paths; may create throwaway instance) all urls + inside + versions + sanity + e2e (SSH phases only on koopa) + full all + server + ladder + auth401 (maximum; long-running, needs secrets) Options: -d, --domain DOMAIN load profile from domains.conf (bank/exchange/merchant) @@ -63,6 +66,23 @@ Env (same meaning): QR_ECC=M qrencode ECC level (default M) KOOPA_SSH KOOPA_SSH_FALLBACKS (default koopa → koopa-external) METRICS_LOAD=0 skip host/container RAM/CPU probes (e2e/ladder/inside) + EXPECT_WEBUI_VERSION=1.6.11 pin merchant /webui/version.txt (urls) + EXPECT_WEBUI_OVERLAY=selfbuild require substr in version-overlay.txt + WEBUI_OVERLAY_DENY='master-11340' fail if overlay matches regex + CHECK_WEBUI_SPA=0 skip SPA fingerprint block in urls + AUTH401_* see check_auth401.sh / secrets.env.example + AUTH401_CONTINUE=1 / CONTINUE_ON_ERROR=1 + auth401: run all groups, collect every ERROR (no mid-run halt) + +Full load (GOA / hacktivism): + ./taler-monitoring.sh -d hacktivism.ch full + # or explicit: + ./taler-monitoring.sh -d hacktivism.ch urls inside versions sanity server e2e ladder auth401 + +SPA pin after selfbuild: + EXPECT_WEBUI_VERSION=1.6.11 EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \\ + WEBUI_OVERLAY_DENY='c778af04|master-11340' \\ + ./taler-monitoring.sh -d hacktivism.ch urls EOF } @@ -110,7 +130,7 @@ while [ $# -gt 0 ]; do CURRENCY_OVERRIDE="$2"; shift 2 ;; --no-probe) NO_PROBE=1; shift ;; - urls|inside|versions|sanity|server|e2e|ladder|goa-ladder|all) PHASES+=("$1"); shift ;; + urls|inside|versions|sanity|server|e2e|ladder|goa-ladder|auth401|all|full) PHASES+=("$1"); shift ;; *) # bare domain shorthand: ./taler-monitoring.sh taler.net if [[ "$1" == *.* && "$1" != *://* && "$1" != -* ]]; then @@ -183,6 +203,9 @@ 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 +# Optional: auth401 and other phases may honor CONTINUE_ON_ERROR +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-}" +export AUTH401_CONTINUE="${AUTH401_CONTINUE:-${CONTINUE_ON_ERROR:-}}" # Default phases # - local GOA (koopa): urls + inside + versions + e2e @@ -212,6 +235,15 @@ for p in "${PHASES[@]}"; do # remote without inside SSH: public + optional e2e OUT_PHASES+=(urls versions e2e) fi + elif [ "$p" = "full" ]; then + # Maximum coverage. Long. Needs secrets for e2e/ladder/auth401. + if [ "${LOCAL_STACK}" = "1" ]; then + OUT_PHASES+=(urls inside versions sanity server e2e ladder auth401) + elif [ -n "${INSIDE_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "stage-lfp" ]; then + OUT_PHASES+=(urls inside versions e2e ladder auth401) + else + OUT_PHASES+=(urls versions e2e auth401) + fi else OUT_PHASES+=("$p") fi @@ -247,7 +279,7 @@ if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then _pt=0 for p in "${PHASES[@]}"; do case "$p" in - urls) _pt=$((_pt + 85)) ;; # +qr group + urls) _pt=$((_pt + 95)) ;; # +qr + webui SPA fingerprints inside) _pt=$((_pt + 30)) ;; versions) _pt=$((_pt + 25)) ;; sanity) _pt=$((_pt + 35)) ;; @@ -255,6 +287,7 @@ if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then # e2e: ATM ladder emits many INFO lines (coins before/after each note) e2e) _pt=$((_pt + 240)) ;; ladder|goa-ladder) _pt=$((_pt + 120)) ;; + auth401) _pt=$((_pt + 70)) ;; esac done set_progress_total "$_pt" @@ -266,6 +299,7 @@ printf ' bank %s\n' "$BANK_PUBLIC" printf ' exchange %s\n' "$EXCHANGE_PUBLIC" printf ' merchant %s\n' "$MERCHANT_PUBLIC" printf ' currency expect=%s skip_ssh=%s\n' "${EXPECT_CURRENCY:-any}" "$SKIP_SSH" +printf ' phases %s\n' "${PHASES[*]}" printf ' progress total≈%s (set PROGRESS_TOTAL= to override; PROGRESS_OFF=1 to hide)\n' "${PROGRESS_TOTAL:-0}" chmod +x "$ROOT"/check_*.sh 2>/dev/null || true @@ -280,6 +314,7 @@ for p in "${PHASES[@]}"; do server) "$ROOT/check_server.sh" || ec=1 ;; e2e) "$ROOT/check_e2e.sh" || ec=1 ;; ladder|goa-ladder) "$ROOT/check_goa_ladder.sh" || ec=1 ;; + auth401) "$ROOT/check_auth401.sh" || ec=1 ;; esac done exit "$ec" From bb09b0d4f2ef8f93de6ccc178a144e4a3bd76039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 23:22:18 +0200 Subject: [PATCH 296/326] monitoring: wire auth401 + full phases; CONTINUE_ON_ERROR export --- scripts/taler-monitoring/taler-monitoring.sh | 39 +++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index e63c922..8b20e72 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -21,13 +21,16 @@ Usage: Phases: urls public HTTPS (no SSH) ← default with --domain + (includes merchant /webui/ SPA fingerprints: version.txt, overlay, assets) inside container status via SSH (local stack only) versions taler packages vs deb.taler.net trixie + repo availability sanity public + optional server server server-side only (SSH) e2e withdraw + pay (small amounts; remote aborts on login/KYC) ladder withdraw/pay amount ladder (GOA ceiling or stage TESTPAYSAN max_wire) + auth401 merchant Basic-auth / case matrix (HTTP 401 paths; may create throwaway instance) all urls + inside + versions + sanity + e2e (SSH phases only on koopa) + full all + server + ladder + auth401 (maximum; long-running, needs secrets) Options: -d, --domain DOMAIN load profile from domains.conf (bank/exchange/merchant) @@ -63,6 +66,23 @@ Env (same meaning): QR_ECC=M qrencode ECC level (default M) KOOPA_SSH KOOPA_SSH_FALLBACKS (default koopa → koopa-external) METRICS_LOAD=0 skip host/container RAM/CPU probes (e2e/ladder/inside) + EXPECT_WEBUI_VERSION=1.6.11 pin merchant /webui/version.txt (urls) + EXPECT_WEBUI_OVERLAY=selfbuild require substr in version-overlay.txt + WEBUI_OVERLAY_DENY='master-11340' fail if overlay matches regex + CHECK_WEBUI_SPA=0 skip SPA fingerprint block in urls + AUTH401_* see check_auth401.sh / secrets.env.example + AUTH401_CONTINUE=1 / CONTINUE_ON_ERROR=1 + auth401: run all groups, collect every ERROR (no mid-run halt) + +Full load (GOA / hacktivism): + ./taler-monitoring.sh -d hacktivism.ch full + # or explicit: + ./taler-monitoring.sh -d hacktivism.ch urls inside versions sanity server e2e ladder auth401 + +SPA pin after selfbuild: + EXPECT_WEBUI_VERSION=1.6.11 EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \\ + WEBUI_OVERLAY_DENY='c778af04|master-11340' \\ + ./taler-monitoring.sh -d hacktivism.ch urls EOF } @@ -110,7 +130,7 @@ while [ $# -gt 0 ]; do CURRENCY_OVERRIDE="$2"; shift 2 ;; --no-probe) NO_PROBE=1; shift ;; - urls|inside|versions|sanity|server|e2e|ladder|goa-ladder|all) PHASES+=("$1"); shift ;; + urls|inside|versions|sanity|server|e2e|ladder|goa-ladder|auth401|all|full) PHASES+=("$1"); shift ;; *) # bare domain shorthand: ./taler-monitoring.sh taler.net if [[ "$1" == *.* && "$1" != *://* && "$1" != -* ]]; then @@ -183,6 +203,9 @@ 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 +# Optional: auth401 and other phases may honor CONTINUE_ON_ERROR +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-}" +export AUTH401_CONTINUE="${AUTH401_CONTINUE:-${CONTINUE_ON_ERROR:-}}" # Default phases # - local GOA (koopa): urls + inside + versions + e2e @@ -212,6 +235,15 @@ for p in "${PHASES[@]}"; do # remote without inside SSH: public + optional e2e OUT_PHASES+=(urls versions e2e) fi + elif [ "$p" = "full" ]; then + # Maximum coverage. Long. Needs secrets for e2e/ladder/auth401. + if [ "${LOCAL_STACK}" = "1" ]; then + OUT_PHASES+=(urls inside versions sanity server e2e ladder auth401) + elif [ -n "${INSIDE_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "stage-lfp" ]; then + OUT_PHASES+=(urls inside versions e2e ladder auth401) + else + OUT_PHASES+=(urls versions e2e auth401) + fi else OUT_PHASES+=("$p") fi @@ -247,7 +279,7 @@ if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then _pt=0 for p in "${PHASES[@]}"; do case "$p" in - urls) _pt=$((_pt + 85)) ;; # +qr group + urls) _pt=$((_pt + 95)) ;; # +qr + webui SPA fingerprints inside) _pt=$((_pt + 30)) ;; versions) _pt=$((_pt + 25)) ;; sanity) _pt=$((_pt + 35)) ;; @@ -255,6 +287,7 @@ if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then # e2e: ATM ladder emits many INFO lines (coins before/after each note) e2e) _pt=$((_pt + 240)) ;; ladder|goa-ladder) _pt=$((_pt + 120)) ;; + auth401) _pt=$((_pt + 70)) ;; esac done set_progress_total "$_pt" @@ -266,6 +299,7 @@ printf ' bank %s\n' "$BANK_PUBLIC" printf ' exchange %s\n' "$EXCHANGE_PUBLIC" printf ' merchant %s\n' "$MERCHANT_PUBLIC" printf ' currency expect=%s skip_ssh=%s\n' "${EXPECT_CURRENCY:-any}" "$SKIP_SSH" +printf ' phases %s\n' "${PHASES[*]}" printf ' progress total≈%s (set PROGRESS_TOTAL= to override; PROGRESS_OFF=1 to hide)\n' "${PROGRESS_TOTAL:-0}" chmod +x "$ROOT"/check_*.sh 2>/dev/null || true @@ -280,6 +314,7 @@ for p in "${PHASES[@]}"; do server) "$ROOT/check_server.sh" || ec=1 ;; e2e) "$ROOT/check_e2e.sh" || ec=1 ;; ladder|goa-ladder) "$ROOT/check_goa_ladder.sh" || ec=1 ;; + auth401) "$ROOT/check_auth401.sh" || ec=1 ;; esac done exit "$ec" From 52b1dc0f38f226309707811ae44710f98ca1f55c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 23:41:31 +0200 Subject: [PATCH 297/326] monitoring: merchant SPA version.txt/overlay fingerprints in urls --- scripts/taler-monitoring/check_urls.sh | 91 ++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index 7191609..36520d4 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -841,6 +841,97 @@ PY fi check_url_soft "merchant /webui/" 200 "$MERCHANT_PUBLIC/webui/" check_url_soft "merchant /" 302,301,200 "$MERCHANT_PUBLIC/" + + # --- merchant SPA fingerprints (selfbuild / package overlay) --- + # version.txt = SPA package version (e.g. 1.6.11) + # version-overlay.txt = optional deploy stamp (selfbuild-v1.6.11-0e4807845) + # Env: + # EXPECT_WEBUI_VERSION=1.6.11 → fail if version.txt ≠ + # EXPECT_WEBUI_OVERLAY=substr → fail if overlay missing that substr + # WEBUI_OVERLAY_DENY=regex → fail if overlay matches (e.g. master-11340) + # CHECK_WEBUI_SPA=0 → skip this block + if [ "${CHECK_WEBUI_SPA:-1}" = "1" ]; then + set_group webui + section "www · merchant SPA /webui/ fingerprints" + vcode=$(http_body "$MERCHANT_PUBLIC/webui/version.txt" "$tmp/webui-version.txt") + if [ "$vcode" = "200" ] && [ -s "$tmp/webui-version.txt" ]; then + got_ver=$(tr -d ' \n\r\t' <"$tmp/webui-version.txt") + ok "merchant /webui/version.txt" "HTTP 200 · ${got_ver}" + if [ -n "${EXPECT_WEBUI_VERSION:-}" ]; then + if [ "$got_ver" = "${EXPECT_WEBUI_VERSION}" ]; then + ok "merchant webui version pin" "EXPECT_WEBUI_VERSION=${EXPECT_WEBUI_VERSION}" + else + fail "merchant webui version pin" "got ${got_ver} want ${EXPECT_WEBUI_VERSION}" + fi + else + info "merchant webui version" "${got_ver} (set EXPECT_WEBUI_VERSION= to pin)" + fi + else + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "merchant /webui/version.txt" "HTTP ${vcode:-000} (SPA missing version stamp)" + else + warn "merchant /webui/version.txt" "HTTP ${vcode:-000}" + fi + fi + + ocode=$(http_body "$MERCHANT_PUBLIC/webui/version-overlay.txt" "$tmp/webui-overlay.txt") + if [ "$ocode" = "200" ] && [ -s "$tmp/webui-overlay.txt" ]; then + got_ov=$(tr -d '\n\r' <"$tmp/webui-overlay.txt" | head -c 200) + ok "merchant /webui/version-overlay.txt" "HTTP 200 · ${got_ov}" + if [ -n "${EXPECT_WEBUI_OVERLAY:-}" ]; then + case "$got_ov" in + *"${EXPECT_WEBUI_OVERLAY}"*) + ok "merchant webui overlay pin" "contains ${EXPECT_WEBUI_OVERLAY}" + ;; + *) + fail "merchant webui overlay pin" "got «${got_ov}» want substr ${EXPECT_WEBUI_OVERLAY}" + ;; + esac + fi + if [ -n "${WEBUI_OVERLAY_DENY:-}" ]; then + if printf '%s' "$got_ov" | grep -qE -- "${WEBUI_OVERLAY_DENY}"; then + fail "merchant webui overlay deny" "«${got_ov}» matches WEBUI_OVERLAY_DENY=${WEBUI_OVERLAY_DENY}" + else + ok "merchant webui overlay deny" "no match for ${WEBUI_OVERLAY_DENY}" + fi + fi + else + # Overlay is optional for pure package installs; warn on local selfbuild stacks + if [ "${LOCAL_STACK:-1}" = "1" ]; then + warn "merchant /webui/version-overlay.txt" "HTTP ${ocode:-000} (optional; selfbuild writes it)" + else + info "merchant /webui/version-overlay.txt" "HTTP ${ocode:-000} (optional)" + fi + fi + + # Core SPA assets (paths used by selfbuild dist/prod). + # index.js is large — longer timeout; normalize curl "200000" (200 + ||000). + _webui_asset_code() { + local url="$1" tmo="${2:-${TIMEOUT}}" + local raw + raw=$(curl -skS --max-redirs 0 -m "$tmo" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true) + # take first 3 digits only (avoid 200000 glue when curl exits non-zero after writing code) + printf '%s' "$raw" | tr -cd '0-9' | head -c 3 + [ -n "$(printf '%s' "$raw" | tr -cd '0-9')" ] || printf '000' + } + for asset in index.html index.js; do + tmo="${TIMEOUT}" + [ "$asset" = "index.js" ] && tmo="${WEBUI_INDEX_JS_TIMEOUT:-60}" + acode=$(_webui_asset_code "$MERCHANT_PUBLIC/webui/${asset}" "$tmo") + case "$acode" in + 200) ok "merchant /webui/${asset}" "HTTP 200" ;; + *) + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "merchant /webui/${asset}" "HTTP ${acode:-000}" + else + warn "merchant /webui/${asset}" "HTTP ${acode:-000}" + fi + ;; + esac + done + # return group for any later merchant checks in this block + set_group merchant + fi fi # Merchant legal docs From ed4d2ab52ffe4af152e334c2f57e0fff4542099e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 23:41:31 +0200 Subject: [PATCH 298/326] monitoring: merchant SPA version.txt/overlay fingerprints in urls --- scripts/taler-monitoring/check_urls.sh | 91 ++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index 7191609..36520d4 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -841,6 +841,97 @@ PY fi check_url_soft "merchant /webui/" 200 "$MERCHANT_PUBLIC/webui/" check_url_soft "merchant /" 302,301,200 "$MERCHANT_PUBLIC/" + + # --- merchant SPA fingerprints (selfbuild / package overlay) --- + # version.txt = SPA package version (e.g. 1.6.11) + # version-overlay.txt = optional deploy stamp (selfbuild-v1.6.11-0e4807845) + # Env: + # EXPECT_WEBUI_VERSION=1.6.11 → fail if version.txt ≠ + # EXPECT_WEBUI_OVERLAY=substr → fail if overlay missing that substr + # WEBUI_OVERLAY_DENY=regex → fail if overlay matches (e.g. master-11340) + # CHECK_WEBUI_SPA=0 → skip this block + if [ "${CHECK_WEBUI_SPA:-1}" = "1" ]; then + set_group webui + section "www · merchant SPA /webui/ fingerprints" + vcode=$(http_body "$MERCHANT_PUBLIC/webui/version.txt" "$tmp/webui-version.txt") + if [ "$vcode" = "200" ] && [ -s "$tmp/webui-version.txt" ]; then + got_ver=$(tr -d ' \n\r\t' <"$tmp/webui-version.txt") + ok "merchant /webui/version.txt" "HTTP 200 · ${got_ver}" + if [ -n "${EXPECT_WEBUI_VERSION:-}" ]; then + if [ "$got_ver" = "${EXPECT_WEBUI_VERSION}" ]; then + ok "merchant webui version pin" "EXPECT_WEBUI_VERSION=${EXPECT_WEBUI_VERSION}" + else + fail "merchant webui version pin" "got ${got_ver} want ${EXPECT_WEBUI_VERSION}" + fi + else + info "merchant webui version" "${got_ver} (set EXPECT_WEBUI_VERSION= to pin)" + fi + else + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "merchant /webui/version.txt" "HTTP ${vcode:-000} (SPA missing version stamp)" + else + warn "merchant /webui/version.txt" "HTTP ${vcode:-000}" + fi + fi + + ocode=$(http_body "$MERCHANT_PUBLIC/webui/version-overlay.txt" "$tmp/webui-overlay.txt") + if [ "$ocode" = "200" ] && [ -s "$tmp/webui-overlay.txt" ]; then + got_ov=$(tr -d '\n\r' <"$tmp/webui-overlay.txt" | head -c 200) + ok "merchant /webui/version-overlay.txt" "HTTP 200 · ${got_ov}" + if [ -n "${EXPECT_WEBUI_OVERLAY:-}" ]; then + case "$got_ov" in + *"${EXPECT_WEBUI_OVERLAY}"*) + ok "merchant webui overlay pin" "contains ${EXPECT_WEBUI_OVERLAY}" + ;; + *) + fail "merchant webui overlay pin" "got «${got_ov}» want substr ${EXPECT_WEBUI_OVERLAY}" + ;; + esac + fi + if [ -n "${WEBUI_OVERLAY_DENY:-}" ]; then + if printf '%s' "$got_ov" | grep -qE -- "${WEBUI_OVERLAY_DENY}"; then + fail "merchant webui overlay deny" "«${got_ov}» matches WEBUI_OVERLAY_DENY=${WEBUI_OVERLAY_DENY}" + else + ok "merchant webui overlay deny" "no match for ${WEBUI_OVERLAY_DENY}" + fi + fi + else + # Overlay is optional for pure package installs; warn on local selfbuild stacks + if [ "${LOCAL_STACK:-1}" = "1" ]; then + warn "merchant /webui/version-overlay.txt" "HTTP ${ocode:-000} (optional; selfbuild writes it)" + else + info "merchant /webui/version-overlay.txt" "HTTP ${ocode:-000} (optional)" + fi + fi + + # Core SPA assets (paths used by selfbuild dist/prod). + # index.js is large — longer timeout; normalize curl "200000" (200 + ||000). + _webui_asset_code() { + local url="$1" tmo="${2:-${TIMEOUT}}" + local raw + raw=$(curl -skS --max-redirs 0 -m "$tmo" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true) + # take first 3 digits only (avoid 200000 glue when curl exits non-zero after writing code) + printf '%s' "$raw" | tr -cd '0-9' | head -c 3 + [ -n "$(printf '%s' "$raw" | tr -cd '0-9')" ] || printf '000' + } + for asset in index.html index.js; do + tmo="${TIMEOUT}" + [ "$asset" = "index.js" ] && tmo="${WEBUI_INDEX_JS_TIMEOUT:-60}" + acode=$(_webui_asset_code "$MERCHANT_PUBLIC/webui/${asset}" "$tmo") + case "$acode" in + 200) ok "merchant /webui/${asset}" "HTTP 200" ;; + *) + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "merchant /webui/${asset}" "HTTP ${acode:-000}" + else + warn "merchant /webui/${asset}" "HTTP ${acode:-000}" + fi + ;; + esac + done + # return group for any later merchant checks in this block + set_group merchant + fi fi # Merchant legal docs From 4f7d8164f8455e8fa53692aa7f1bcd2d02aa6a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Sat, 18 Jul 2026 00:03:29 +0200 Subject: [PATCH 299/326] monitoring: docs + secrets.env for auth401 and webui pins --- scripts/taler-monitoring/README.md | 20 +++++++++++++ scripts/taler-monitoring/TESTS.md | 31 +++++++++++++++++++- scripts/taler-monitoring/secrets.env.example | 26 ++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index eea5e12..578b683 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -109,6 +109,26 @@ Each check prints **global** and **grouped** ids: # Landing stats.json freshness (outside-in; age via generated_at_unix) STATS_STALE_SECS=900 STATS_FAIL_SECS=3600 ./taler-monitoring.sh -d stage.lefrancpaysan.ch urls + +# --- Full load (GOA / hacktivism) --- +# all = urls + inside + versions + sanity + e2e +# full = all + server + ladder + auth401 (long; needs secrets.env) +./taler-monitoring.sh -d hacktivism.ch full +./taler-monitoring.sh -d hacktivism.ch urls inside versions sanity server e2e ladder auth401 + +# SPA pin after selfbuild-webui.sh (container): +EXPECT_WEBUI_VERSION=1.6.11 EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \ + WEBUI_OVERLAY_DENY='c778af04|master-11340' \ + ./taler-monitoring.sh -d hacktivism.ch urls + +# Only 401 matrix (may create throwaway instance): +./taler-monitoring.sh -d hacktivism.ch auth401 + +# Stage FrancPaysan — durable mon401 account (password in francpaysan-secrets): +./taler-monitoring.sh -d stage.monnaie.lefrancpaysan.ch auth401 +# secrets: ~/francpaysan-secrets/stage/instances/mon401.password +# ~/francpaysan-secrets/stage/default-instance-token.txt +# force throwaway create instead: AUTH401_PREFER_DURABLE=0 AUTH401_CREATE=1 … ``` ### Landing `stats.json` (www.stats) diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index 4ad45e9..fb829d0 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -52,6 +52,7 @@ Numbering follows **executed** checks (early skip may shift later NN inside the | **www.stats-** | `/intro/stats.json` **reachable**; **timestamps** (`generated_at_unix` + human/ISO); **freshness** vs wall clock (`STATS_STALE_SECS` WARN, `STATS_FAIL_SECS` ERROR); **display fields** (bank-shape withdraws/accounts, or exchange-db / merchant-db keys); optional performance block; shared-feed equal `gen_unix` when bank collector is published to all three | | **www.bank-** | `/config`, currency, alt_unit_names; integration/webui/intro; **auto-account.json** (required GOA/local; skip/soft off-GOA e.g. TESTPAYSAN); `/terms`, `/privacy` | | **www.merchant-** | `/config` currency + currencies alt_unit_names; listed exchanges alt; webui/intro; **`/terms`**, **`/privacy`** | +| **www.webui-** | SPA fingerprints: **`/webui/version.txt`**, **`/webui/version-overlay.txt`**, `index.html` / `index.js`; optional pin via `EXPECT_WEBUI_VERSION` / `EXPECT_WEBUI_OVERLAY` / `WEBUI_OVERLAY_DENY` | | **www.paivana-** | local GOA paywall front (redirect to template) | | **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; stage merchant shop: `shop-ui.js` + `shops.css` + `qrcode.min.js`; cross-links local | | **www.qr-** | QR payloads: harvest `taler://` / `payto://` / app `data-qr-url` from landings + mint JSON; **form** check; **qrencode → zbarimg** exact roundtrip; optional static QR images. Needs `qrencode` + `zbar-tools`. Skip: `QR_CHECK=0` | @@ -106,7 +107,7 @@ When filing an issue, quote the full id + label, e.g. --- -## versions / sanity / ladder / server +## versions / sanity / ladder / server / auth401 | Area.group | Meaning | |------------|---------| @@ -116,6 +117,34 @@ When filing an issue, quote the full id + label, e.g. | **sanity.bank-** / **.exchange-** / **.merchant-** | public + optional server-side per component | | **ladder.plan-** / **.load-** / **.withdraw-** / **.pay-** / **.report-** | amount ladder (GOA ceiling or stage TESTPAYSAN `max_wire`) | | **server-** | SSH host ports / processes (flat unless grouped later) | +| **auth401.*** | merchant 401 / case matrix (WebUI APIs): **signup** `POST /instances` MIX id, **login** Basic user low vs MIX/UPPER, full **case** matrix, **password** bad/empty, **pwchange** `POST private/auth` + re-login, **admin-create** `POST /management/instances` (if admin token), **bearer**, **durable mon401**, **webui** SPA `toLowerCase` probe (`check_auth401.sh`) | + +### SPA pin (after selfbuild-webui) + +```bash +EXPECT_WEBUI_VERSION=1.6.11 \ +EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \ +WEBUI_OVERLAY_DENY='c778af04|master-11340' \ + ./taler-monitoring.sh -d hacktivism.ch urls +``` + +Skip SPA block: `CHECK_WEBUI_SPA=0`. + +### Full load (maximum phases) + +| Alias | Expands to (local GOA) | +|-------|-------------------------| +| **`all`** | `urls inside versions sanity e2e` | +| **`full`** | `urls inside versions sanity server e2e ladder auth401` | + +```bash +cd ~/src/koopa/koopa-admin-log/scripts/taler-monitoring +# secrets.env with bank admin + merchant tokens (see secrets.env.example) +./taler-monitoring.sh -d hacktivism.ch full +# same, explicit: +./taler-monitoring.sh -d hacktivism.ch \ + urls inside versions sanity server e2e ladder auth401 +``` --- diff --git a/scripts/taler-monitoring/secrets.env.example b/scripts/taler-monitoring/secrets.env.example index 212a7f2..87ec88c 100644 --- a/scripts/taler-monitoring/secrets.env.example +++ b/scripts/taler-monitoring/secrets.env.example @@ -43,6 +43,32 @@ # PROGRESS_SHOW_EVERY=8 # PROGRESS_OFF=1 +# --- Merchant SPA /webui/ fingerprints (urls phase, group www.webui) --- +# EXPECT_WEBUI_VERSION=1.6.11 +# EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 +# WEBUI_OVERLAY_DENY=c778af04|master-11340 +# CHECK_WEBUI_SPA=0 + +# --- auth401 phase (WebUI signup/login/pwchange/admin + case matrix) --- +# Default FULL=1 creates throwaway MIX instance + runs all groups. +# Stage also loads durable mon401 for extra durable.* checks: +# AUTH401_FULL=1 +# AUTH401_CONTINUE=1 # optional: hard prereqs soft; full ERROR list always via EXIT trap +# CONTINUE_ON_ERROR=1 # alias for AUTH401_CONTINUE +# (auth401 no longer uses set -e — silent mid-run abort fixed) +# AUTH401_CREATE=1 +# AUTH401_PREFER_DURABLE=0 # 1 = only mon401, skip throwaway signup +# AUTH401_INSTANCE=mon401 +# AUTH401_PASSWORD= +# AUTH401_DURABLE_ID=mon401 +# AUTH401_CREATE_PASSWORD=Mon401-Test-Pass! +# AUTH401_SKIP_IF_MFA=1 +# AUTH401_BEARER= # default-instance-token (not admin) +# AUTH401_ADMIN_TOKEN= # required for admin-create group +# AUTH401_ADMIN_PASSWORD= # or login as AUTH401_ADMIN_USER=admin +# AUTH401_ADMIN_USER=admin +# FRANCPAYSAN_SECRETS=$HOME/francpaysan-secrets + # --- Ladder (GOA withdraw ladder · explorer pool) --- # Prefer SECRETS_ROOT / KOOPA_ADMIN_SECRETS (bank-explorer-password.txt). # Overrides only if needed: From 8de2e4d6c8fb510d43d5a5a822b9458f29d2cef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Sat, 18 Jul 2026 00:03:29 +0200 Subject: [PATCH 300/326] monitoring: docs + secrets.env for auth401 and webui pins --- scripts/taler-monitoring/README.md | 20 +++++++++++++ scripts/taler-monitoring/TESTS.md | 31 +++++++++++++++++++- scripts/taler-monitoring/secrets.env.example | 26 ++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index eea5e12..578b683 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -109,6 +109,26 @@ Each check prints **global** and **grouped** ids: # Landing stats.json freshness (outside-in; age via generated_at_unix) STATS_STALE_SECS=900 STATS_FAIL_SECS=3600 ./taler-monitoring.sh -d stage.lefrancpaysan.ch urls + +# --- Full load (GOA / hacktivism) --- +# all = urls + inside + versions + sanity + e2e +# full = all + server + ladder + auth401 (long; needs secrets.env) +./taler-monitoring.sh -d hacktivism.ch full +./taler-monitoring.sh -d hacktivism.ch urls inside versions sanity server e2e ladder auth401 + +# SPA pin after selfbuild-webui.sh (container): +EXPECT_WEBUI_VERSION=1.6.11 EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \ + WEBUI_OVERLAY_DENY='c778af04|master-11340' \ + ./taler-monitoring.sh -d hacktivism.ch urls + +# Only 401 matrix (may create throwaway instance): +./taler-monitoring.sh -d hacktivism.ch auth401 + +# Stage FrancPaysan — durable mon401 account (password in francpaysan-secrets): +./taler-monitoring.sh -d stage.monnaie.lefrancpaysan.ch auth401 +# secrets: ~/francpaysan-secrets/stage/instances/mon401.password +# ~/francpaysan-secrets/stage/default-instance-token.txt +# force throwaway create instead: AUTH401_PREFER_DURABLE=0 AUTH401_CREATE=1 … ``` ### Landing `stats.json` (www.stats) diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index 4ad45e9..fb829d0 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -52,6 +52,7 @@ Numbering follows **executed** checks (early skip may shift later NN inside the | **www.stats-** | `/intro/stats.json` **reachable**; **timestamps** (`generated_at_unix` + human/ISO); **freshness** vs wall clock (`STATS_STALE_SECS` WARN, `STATS_FAIL_SECS` ERROR); **display fields** (bank-shape withdraws/accounts, or exchange-db / merchant-db keys); optional performance block; shared-feed equal `gen_unix` when bank collector is published to all three | | **www.bank-** | `/config`, currency, alt_unit_names; integration/webui/intro; **auto-account.json** (required GOA/local; skip/soft off-GOA e.g. TESTPAYSAN); `/terms`, `/privacy` | | **www.merchant-** | `/config` currency + currencies alt_unit_names; listed exchanges alt; webui/intro; **`/terms`**, **`/privacy`** | +| **www.webui-** | SPA fingerprints: **`/webui/version.txt`**, **`/webui/version-overlay.txt`**, `index.html` / `index.js`; optional pin via `EXPECT_WEBUI_VERSION` / `EXPECT_WEBUI_OVERLAY` / `WEBUI_OVERLAY_DENY` | | **www.paivana-** | local GOA paywall front (redirect to template) | | **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; stage merchant shop: `shop-ui.js` + `shops.css` + `qrcode.min.js`; cross-links local | | **www.qr-** | QR payloads: harvest `taler://` / `payto://` / app `data-qr-url` from landings + mint JSON; **form** check; **qrencode → zbarimg** exact roundtrip; optional static QR images. Needs `qrencode` + `zbar-tools`. Skip: `QR_CHECK=0` | @@ -106,7 +107,7 @@ When filing an issue, quote the full id + label, e.g. --- -## versions / sanity / ladder / server +## versions / sanity / ladder / server / auth401 | Area.group | Meaning | |------------|---------| @@ -116,6 +117,34 @@ When filing an issue, quote the full id + label, e.g. | **sanity.bank-** / **.exchange-** / **.merchant-** | public + optional server-side per component | | **ladder.plan-** / **.load-** / **.withdraw-** / **.pay-** / **.report-** | amount ladder (GOA ceiling or stage TESTPAYSAN `max_wire`) | | **server-** | SSH host ports / processes (flat unless grouped later) | +| **auth401.*** | merchant 401 / case matrix (WebUI APIs): **signup** `POST /instances` MIX id, **login** Basic user low vs MIX/UPPER, full **case** matrix, **password** bad/empty, **pwchange** `POST private/auth` + re-login, **admin-create** `POST /management/instances` (if admin token), **bearer**, **durable mon401**, **webui** SPA `toLowerCase` probe (`check_auth401.sh`) | + +### SPA pin (after selfbuild-webui) + +```bash +EXPECT_WEBUI_VERSION=1.6.11 \ +EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \ +WEBUI_OVERLAY_DENY='c778af04|master-11340' \ + ./taler-monitoring.sh -d hacktivism.ch urls +``` + +Skip SPA block: `CHECK_WEBUI_SPA=0`. + +### Full load (maximum phases) + +| Alias | Expands to (local GOA) | +|-------|-------------------------| +| **`all`** | `urls inside versions sanity e2e` | +| **`full`** | `urls inside versions sanity server e2e ladder auth401` | + +```bash +cd ~/src/koopa/koopa-admin-log/scripts/taler-monitoring +# secrets.env with bank admin + merchant tokens (see secrets.env.example) +./taler-monitoring.sh -d hacktivism.ch full +# same, explicit: +./taler-monitoring.sh -d hacktivism.ch \ + urls inside versions sanity server e2e ladder auth401 +``` --- diff --git a/scripts/taler-monitoring/secrets.env.example b/scripts/taler-monitoring/secrets.env.example index 212a7f2..87ec88c 100644 --- a/scripts/taler-monitoring/secrets.env.example +++ b/scripts/taler-monitoring/secrets.env.example @@ -43,6 +43,32 @@ # PROGRESS_SHOW_EVERY=8 # PROGRESS_OFF=1 +# --- Merchant SPA /webui/ fingerprints (urls phase, group www.webui) --- +# EXPECT_WEBUI_VERSION=1.6.11 +# EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 +# WEBUI_OVERLAY_DENY=c778af04|master-11340 +# CHECK_WEBUI_SPA=0 + +# --- auth401 phase (WebUI signup/login/pwchange/admin + case matrix) --- +# Default FULL=1 creates throwaway MIX instance + runs all groups. +# Stage also loads durable mon401 for extra durable.* checks: +# AUTH401_FULL=1 +# AUTH401_CONTINUE=1 # optional: hard prereqs soft; full ERROR list always via EXIT trap +# CONTINUE_ON_ERROR=1 # alias for AUTH401_CONTINUE +# (auth401 no longer uses set -e — silent mid-run abort fixed) +# AUTH401_CREATE=1 +# AUTH401_PREFER_DURABLE=0 # 1 = only mon401, skip throwaway signup +# AUTH401_INSTANCE=mon401 +# AUTH401_PASSWORD= +# AUTH401_DURABLE_ID=mon401 +# AUTH401_CREATE_PASSWORD=Mon401-Test-Pass! +# AUTH401_SKIP_IF_MFA=1 +# AUTH401_BEARER= # default-instance-token (not admin) +# AUTH401_ADMIN_TOKEN= # required for admin-create group +# AUTH401_ADMIN_PASSWORD= # or login as AUTH401_ADMIN_USER=admin +# AUTH401_ADMIN_USER=admin +# FRANCPAYSAN_SECRETS=$HOME/francpaysan-secrets + # --- Ladder (GOA withdraw ladder · explorer pool) --- # Prefer SECRETS_ROOT / KOOPA_ADMIN_SECRETS (bank-explorer-password.txt). # Overrides only if needed: From fb504edc1a5c8fb87f01487280709b33a8ce7d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Sat, 18 Jul 2026 14:19:30 +0200 Subject: [PATCH 301/326] merchant: in-container selfbuild-webui by tag/commit --- scripts/taler-merchant/selfbuild-webui.sh | 197 ++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100755 scripts/taler-merchant/selfbuild-webui.sh diff --git a/scripts/taler-merchant/selfbuild-webui.sh b/scripts/taler-merchant/selfbuild-webui.sh new file mode 100755 index 0000000..bd1cfda --- /dev/null +++ b/scripts/taler-merchant/selfbuild-webui.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Build taler-merchant-webui SPA *inside* this container and install it. +# +# Usage (as root in taler-hacktivism): +# selfbuild-webui.sh [REF] +# selfbuild-webui.sh v1.6.11 +# selfbuild-webui.sh 0e4807845fc06f7c705711974650d67ef30e7919 +# WEBUI_REF=master selfbuild-webui.sh +# +# Env: +# WEBUI_REF tag / branch / commit (default: first arg or v1.6.11) +# TTC_GIT default https://git.taler.net/taler-typescript-core.git +# TTC_DIR default /var/taler-src/taler-typescript-core +# SPA_DEST default /usr/share/taler-merchant-webui +# PNPM_VERSION default 10.33.4 +# SKIP_NODE_INSTALL 1 = do not apt/npm install node/pnpm +# SKIP_DEPLOY 1 = build only, leave dist/prod in tree +# EXPECT_VERSION if set, die unless dist version.txt matches +# +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive +export PATH="/usr/local/bin:/usr/bin:/bin:${HOME}/.local/bin:${PATH:-}" + +WEBUI_REF="${WEBUI_REF:-${1:-v1.6.11}}" +TTC_GIT="${TTC_GIT:-https://git.taler.net/taler-typescript-core.git}" +TTC_DIR="${TTC_DIR:-/var/taler-src/taler-typescript-core}" +SPA_DEST="${SPA_DEST:-/usr/share/taler-merchant-webui}" +PNPM_VERSION="${PNPM_VERSION:-10.33.4}" +SKIP_NODE_INSTALL="${SKIP_NODE_INSTALL:-0}" +SKIP_DEPLOY="${SKIP_DEPLOY:-0}" +EXPECT_VERSION="${EXPECT_VERSION:-}" + +log() { printf '+ %s\n' "$*"; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || die "run as root in the merchant container" + +# --------------------------------------------------------------------------- +# Toolchain: node + pnpm +# --------------------------------------------------------------------------- +ensure_node() { + if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then + log "node $(node -v) npm $(npm -v)" + return 0 + fi + [ "${SKIP_NODE_INSTALL}" = "1" ] && die "node/npm missing and SKIP_NODE_INSTALL=1" + + log "install nodejs + npm via apt" + apt-get update -qq + apt-get install -y --no-install-recommends nodejs npm ca-certificates git \ + python3 curl ca-certificates 2>&1 | tail -20 + command -v node >/dev/null 2>&1 || die "node still missing after apt" + log "node $(node -v) npm $(npm -v)" +} + +ensure_pnpm() { + if command -v pnpm >/dev/null 2>&1; then + log "pnpm $(pnpm -v)" + return 0 + fi + [ "${SKIP_NODE_INSTALL}" = "1" ] && die "pnpm missing and SKIP_NODE_INSTALL=1" + + log "install pnpm@${PNPM_VERSION} globally" + npm install -g "pnpm@${PNPM_VERSION}" 2>&1 | tail -15 + # npm global bin may be /usr/local/bin or under /usr/lib + hash -r 2>/dev/null || true + export PATH="$(npm prefix -g)/bin:${PATH}" + command -v pnpm >/dev/null 2>&1 || die "pnpm still missing after npm install -g" + log "pnpm $(pnpm -v)" +} + +# --------------------------------------------------------------------------- +# Source tree at REF +# --------------------------------------------------------------------------- +checkout_ttc() { + mkdir -p /var/taler-src + if [ -d "${TTC_DIR}/.git" ]; then + log "update existing ${TTC_DIR}" + cd "${TTC_DIR}" + git remote set-url origin "${TTC_GIT}" 2>/dev/null \ + || git remote add origin "${TTC_GIT}" || true + git fetch --tags --force origin + else + log "clone ${TTC_GIT} → ${TTC_DIR}" + rm -rf "${TTC_DIR}" + git clone "${TTC_GIT}" "${TTC_DIR}" + cd "${TTC_DIR}" + fi + + log "checkout ${WEBUI_REF}" + # Accept tag, branch, or raw commit + if git rev-parse --verify --quiet "refs/tags/${WEBUI_REF}" >/dev/null; then + git checkout -f "refs/tags/${WEBUI_REF}" + elif git rev-parse --verify --quiet "origin/${WEBUI_REF}" >/dev/null; then + git checkout -f -B "${WEBUI_REF}" "origin/${WEBUI_REF}" + elif git rev-parse --verify --quiet "${WEBUI_REF}" >/dev/null; then + git checkout -f "${WEBUI_REF}" + else + # maybe short hash only available after fetch + git fetch origin "${WEBUI_REF}" 2>/dev/null || true + git checkout -f "${WEBUI_REF}" || die "cannot checkout WEBUI_REF=${WEBUI_REF}" + fi + + # assets submodule (needed by build.mjs) + if [ -f .gitmodules ] && grep -q 'taler-assets' .gitmodules 2>/dev/null; then + log "submodule contrib/taler-assets" + git submodule update --init contrib/taler-assets \ + || git submodule update --init --recursive contrib/taler-assets \ + || true + fi + if [ ! -f contrib/taler-assets/svg/logo/qr-logo.svg ]; then + # fallback: shallow clone assets next to known relative url + log "assets missing — try direct clone" + rm -rf contrib/taler-assets + mkdir -p contrib + git clone --depth 1 https://git.taler.net/taler-assets.git contrib/taler-assets \ + || die "taler-assets incomplete" + fi + test -f contrib/taler-assets/svg/logo/qr-logo.svg || die "taler-assets still incomplete" + + HEAD_SHORT=$(git rev-parse --short HEAD) + HEAD_FULL=$(git rev-parse HEAD) + PKG_VER=$(python3 -c "import json; print(json.load(open('packages/taler-merchant-webui/package.json'))['version'])") + log "HEAD=${HEAD_SHORT} (${HEAD_FULL})" + log "package.json version=${PKG_VER}" +} + +# --------------------------------------------------------------------------- +# Build + install +# --------------------------------------------------------------------------- +build_spa() { + cd "${TTC_DIR}" + log "pnpm install + compile @gnu-taler/taler-merchant-webui" + pnpm install --frozen-lockfile --filter @gnu-taler/taler-merchant-webui... + pnpm run --filter @gnu-taler/taler-merchant-webui... compile + + PROD="${TTC_DIR}/packages/taler-merchant-webui/dist/prod" + test -f "${PROD}/index.js" || die "dist/prod/index.js missing" + test -f "${PROD}/version.txt" || die "dist/prod/version.txt missing" + GOT=$(tr -d ' \n\r' <"${PROD}/version.txt") + log "built version.txt=${GOT}" + if [ -n "${EXPECT_VERSION}" ] && [ "${GOT}" != "${EXPECT_VERSION}" ]; then + die "expected version ${EXPECT_VERSION}, got ${GOT}" + fi + # sensible default when building known tag + case "${WEBUI_REF}" in + v1.6.11|1.6.11) + [ "${GOT}" = "1.6.11" ] || die "expected 1.6.11 for ref ${WEBUI_REF}, got ${GOT}" + ;; + esac + BUILT_VER="${GOT}" +} + +deploy_spa() { + cd "${TTC_DIR}" + PROD="${TTC_DIR}/packages/taler-merchant-webui/dist/prod" + HEAD_SHORT=$(git rev-parse --short HEAD) + ts=$(date +%Y%m%d-%H%M%S) + + if [ -d "${SPA_DEST}" ] && [ "$(ls -A "${SPA_DEST}" 2>/dev/null || true)" ]; then + bak="${SPA_DEST}.bak-selfbuild-${ts}" + log "backup ${SPA_DEST} → ${bak}" + cp -a "${SPA_DEST}" "${bak}" + fi + mkdir -p "${SPA_DEST}" + find "${SPA_DEST}" -mindepth 1 -maxdepth 1 ! -name 'bak-*' -exec rm -rf {} + + + log "install SPA → ${SPA_DEST}" + cp -a "${PROD}/." "${SPA_DEST}/" + printf 'selfbuild-%s-%s\n' "${WEBUI_REF}" "${HEAD_SHORT}" >"${SPA_DEST}/version-overlay.txt" + chown -R root:root "${SPA_DEST}" + + echo "=== deployed ===" + cat "${SPA_DEST}/version.txt" + echo + cat "${SPA_DEST}/version-overlay.txt" + echo + wc -c "${SPA_DEST}/index.js" +} + +# --------------------------------------------------------------------------- +main() { + log "selfbuild-webui REF=${WEBUI_REF}" + ensure_node + ensure_pnpm + checkout_ttc + build_spa + if [ "${SKIP_DEPLOY}" = "1" ]; then + log "SKIP_DEPLOY=1 — left at ${TTC_DIR}/packages/taler-merchant-webui/dist/prod" + else + deploy_spa + fi + log "DONE webui ${WEBUI_REF} → version ${BUILT_VER:-?} @ ${SPA_DEST}" +} + +main "$@" From cad3044c34bcb5152754779494c8327a126a9500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Sat, 18 Jul 2026 14:19:30 +0200 Subject: [PATCH 302/326] merchant: in-container selfbuild-webui by tag/commit --- scripts/taler-merchant/selfbuild-webui.sh | 197 ++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100755 scripts/taler-merchant/selfbuild-webui.sh diff --git a/scripts/taler-merchant/selfbuild-webui.sh b/scripts/taler-merchant/selfbuild-webui.sh new file mode 100755 index 0000000..bd1cfda --- /dev/null +++ b/scripts/taler-merchant/selfbuild-webui.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Build taler-merchant-webui SPA *inside* this container and install it. +# +# Usage (as root in taler-hacktivism): +# selfbuild-webui.sh [REF] +# selfbuild-webui.sh v1.6.11 +# selfbuild-webui.sh 0e4807845fc06f7c705711974650d67ef30e7919 +# WEBUI_REF=master selfbuild-webui.sh +# +# Env: +# WEBUI_REF tag / branch / commit (default: first arg or v1.6.11) +# TTC_GIT default https://git.taler.net/taler-typescript-core.git +# TTC_DIR default /var/taler-src/taler-typescript-core +# SPA_DEST default /usr/share/taler-merchant-webui +# PNPM_VERSION default 10.33.4 +# SKIP_NODE_INSTALL 1 = do not apt/npm install node/pnpm +# SKIP_DEPLOY 1 = build only, leave dist/prod in tree +# EXPECT_VERSION if set, die unless dist version.txt matches +# +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive +export PATH="/usr/local/bin:/usr/bin:/bin:${HOME}/.local/bin:${PATH:-}" + +WEBUI_REF="${WEBUI_REF:-${1:-v1.6.11}}" +TTC_GIT="${TTC_GIT:-https://git.taler.net/taler-typescript-core.git}" +TTC_DIR="${TTC_DIR:-/var/taler-src/taler-typescript-core}" +SPA_DEST="${SPA_DEST:-/usr/share/taler-merchant-webui}" +PNPM_VERSION="${PNPM_VERSION:-10.33.4}" +SKIP_NODE_INSTALL="${SKIP_NODE_INSTALL:-0}" +SKIP_DEPLOY="${SKIP_DEPLOY:-0}" +EXPECT_VERSION="${EXPECT_VERSION:-}" + +log() { printf '+ %s\n' "$*"; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || die "run as root in the merchant container" + +# --------------------------------------------------------------------------- +# Toolchain: node + pnpm +# --------------------------------------------------------------------------- +ensure_node() { + if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then + log "node $(node -v) npm $(npm -v)" + return 0 + fi + [ "${SKIP_NODE_INSTALL}" = "1" ] && die "node/npm missing and SKIP_NODE_INSTALL=1" + + log "install nodejs + npm via apt" + apt-get update -qq + apt-get install -y --no-install-recommends nodejs npm ca-certificates git \ + python3 curl ca-certificates 2>&1 | tail -20 + command -v node >/dev/null 2>&1 || die "node still missing after apt" + log "node $(node -v) npm $(npm -v)" +} + +ensure_pnpm() { + if command -v pnpm >/dev/null 2>&1; then + log "pnpm $(pnpm -v)" + return 0 + fi + [ "${SKIP_NODE_INSTALL}" = "1" ] && die "pnpm missing and SKIP_NODE_INSTALL=1" + + log "install pnpm@${PNPM_VERSION} globally" + npm install -g "pnpm@${PNPM_VERSION}" 2>&1 | tail -15 + # npm global bin may be /usr/local/bin or under /usr/lib + hash -r 2>/dev/null || true + export PATH="$(npm prefix -g)/bin:${PATH}" + command -v pnpm >/dev/null 2>&1 || die "pnpm still missing after npm install -g" + log "pnpm $(pnpm -v)" +} + +# --------------------------------------------------------------------------- +# Source tree at REF +# --------------------------------------------------------------------------- +checkout_ttc() { + mkdir -p /var/taler-src + if [ -d "${TTC_DIR}/.git" ]; then + log "update existing ${TTC_DIR}" + cd "${TTC_DIR}" + git remote set-url origin "${TTC_GIT}" 2>/dev/null \ + || git remote add origin "${TTC_GIT}" || true + git fetch --tags --force origin + else + log "clone ${TTC_GIT} → ${TTC_DIR}" + rm -rf "${TTC_DIR}" + git clone "${TTC_GIT}" "${TTC_DIR}" + cd "${TTC_DIR}" + fi + + log "checkout ${WEBUI_REF}" + # Accept tag, branch, or raw commit + if git rev-parse --verify --quiet "refs/tags/${WEBUI_REF}" >/dev/null; then + git checkout -f "refs/tags/${WEBUI_REF}" + elif git rev-parse --verify --quiet "origin/${WEBUI_REF}" >/dev/null; then + git checkout -f -B "${WEBUI_REF}" "origin/${WEBUI_REF}" + elif git rev-parse --verify --quiet "${WEBUI_REF}" >/dev/null; then + git checkout -f "${WEBUI_REF}" + else + # maybe short hash only available after fetch + git fetch origin "${WEBUI_REF}" 2>/dev/null || true + git checkout -f "${WEBUI_REF}" || die "cannot checkout WEBUI_REF=${WEBUI_REF}" + fi + + # assets submodule (needed by build.mjs) + if [ -f .gitmodules ] && grep -q 'taler-assets' .gitmodules 2>/dev/null; then + log "submodule contrib/taler-assets" + git submodule update --init contrib/taler-assets \ + || git submodule update --init --recursive contrib/taler-assets \ + || true + fi + if [ ! -f contrib/taler-assets/svg/logo/qr-logo.svg ]; then + # fallback: shallow clone assets next to known relative url + log "assets missing — try direct clone" + rm -rf contrib/taler-assets + mkdir -p contrib + git clone --depth 1 https://git.taler.net/taler-assets.git contrib/taler-assets \ + || die "taler-assets incomplete" + fi + test -f contrib/taler-assets/svg/logo/qr-logo.svg || die "taler-assets still incomplete" + + HEAD_SHORT=$(git rev-parse --short HEAD) + HEAD_FULL=$(git rev-parse HEAD) + PKG_VER=$(python3 -c "import json; print(json.load(open('packages/taler-merchant-webui/package.json'))['version'])") + log "HEAD=${HEAD_SHORT} (${HEAD_FULL})" + log "package.json version=${PKG_VER}" +} + +# --------------------------------------------------------------------------- +# Build + install +# --------------------------------------------------------------------------- +build_spa() { + cd "${TTC_DIR}" + log "pnpm install + compile @gnu-taler/taler-merchant-webui" + pnpm install --frozen-lockfile --filter @gnu-taler/taler-merchant-webui... + pnpm run --filter @gnu-taler/taler-merchant-webui... compile + + PROD="${TTC_DIR}/packages/taler-merchant-webui/dist/prod" + test -f "${PROD}/index.js" || die "dist/prod/index.js missing" + test -f "${PROD}/version.txt" || die "dist/prod/version.txt missing" + GOT=$(tr -d ' \n\r' <"${PROD}/version.txt") + log "built version.txt=${GOT}" + if [ -n "${EXPECT_VERSION}" ] && [ "${GOT}" != "${EXPECT_VERSION}" ]; then + die "expected version ${EXPECT_VERSION}, got ${GOT}" + fi + # sensible default when building known tag + case "${WEBUI_REF}" in + v1.6.11|1.6.11) + [ "${GOT}" = "1.6.11" ] || die "expected 1.6.11 for ref ${WEBUI_REF}, got ${GOT}" + ;; + esac + BUILT_VER="${GOT}" +} + +deploy_spa() { + cd "${TTC_DIR}" + PROD="${TTC_DIR}/packages/taler-merchant-webui/dist/prod" + HEAD_SHORT=$(git rev-parse --short HEAD) + ts=$(date +%Y%m%d-%H%M%S) + + if [ -d "${SPA_DEST}" ] && [ "$(ls -A "${SPA_DEST}" 2>/dev/null || true)" ]; then + bak="${SPA_DEST}.bak-selfbuild-${ts}" + log "backup ${SPA_DEST} → ${bak}" + cp -a "${SPA_DEST}" "${bak}" + fi + mkdir -p "${SPA_DEST}" + find "${SPA_DEST}" -mindepth 1 -maxdepth 1 ! -name 'bak-*' -exec rm -rf {} + + + log "install SPA → ${SPA_DEST}" + cp -a "${PROD}/." "${SPA_DEST}/" + printf 'selfbuild-%s-%s\n' "${WEBUI_REF}" "${HEAD_SHORT}" >"${SPA_DEST}/version-overlay.txt" + chown -R root:root "${SPA_DEST}" + + echo "=== deployed ===" + cat "${SPA_DEST}/version.txt" + echo + cat "${SPA_DEST}/version-overlay.txt" + echo + wc -c "${SPA_DEST}/index.js" +} + +# --------------------------------------------------------------------------- +main() { + log "selfbuild-webui REF=${WEBUI_REF}" + ensure_node + ensure_pnpm + checkout_ttc + build_spa + if [ "${SKIP_DEPLOY}" = "1" ]; then + log "SKIP_DEPLOY=1 — left at ${TTC_DIR}/packages/taler-merchant-webui/dist/prod" + else + deploy_spa + fi + log "DONE webui ${WEBUI_REF} → version ${BUILT_VER:-?} @ ${SPA_DEST}" +} + +main "$@" From 3daf69bd2f0ab27fb7c7d76f5019d2a838173aa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Sat, 18 Jul 2026 14:30:56 +0200 Subject: [PATCH 303/326] merchant: host build-deploy webui tag + merchant master --- ...ild-deploy-webui-1.6.11-merchant-master.sh | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100755 scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh diff --git a/scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh b/scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh new file mode 100755 index 0000000..bfd0b05 --- /dev/null +++ b/scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# Build taler-merchant-webui from tag v1.6.11 + taler-merchant from master, +# deploy into podman container taler-hacktivism on koopa, restart services. +# +# Run from clementine (or any host with: git, node/pnpm, ssh koopa, podman on koopa): +# ./scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh +# +# Env: +# KOOPA_SSH=koopa +# MERCHANT_CTR=taler-hacktivism +# TTC_GIT=git@git.taler.net:taler-typescript-core.git +# MERCHANT_GIT=https://git.taler.net/merchant.git # or git@… +# WEBUI_TAG=v1.6.11 +# MERCHANT_REF=master +# SKIP_WEBUI=0 SKIP_MERCHANT=0 SKIP_RESTART=0 +# PNPM=$HOME/.local/bin/pnpm +# WORK_ROOT=/tmp/taler-selfbuild-$$ +# +set -euo pipefail + +KOOPA_SSH="${KOOPA_SSH:-koopa}" +MERCHANT_CTR="${MERCHANT_CTR:-taler-hacktivism}" +TTC_GIT="${TTC_GIT:-git@git.taler.net:taler-typescript-core.git}" +MERCHANT_GIT="${MERCHANT_GIT:-https://git.taler.net/merchant.git}" +WEBUI_TAG="${WEBUI_TAG:-v1.6.11}" +MERCHANT_REF="${MERCHANT_REF:-master}" +SKIP_WEBUI="${SKIP_WEBUI:-0}" +SKIP_MERCHANT="${SKIP_MERCHANT:-0}" +SKIP_RESTART="${SKIP_RESTART:-0}" +PNPM="${PNPM:-$HOME/.local/bin/pnpm}" +WORK_ROOT="${WORK_ROOT:-/tmp/taler-selfbuild-$$}" +SPA_DIR="${WORK_ROOT}/spa-prod" +TTC_DIR="${WORK_ROOT}/taler-typescript-core" +export PATH="${HOME}/.local/bin:${PATH}" + +log() { printf '+ %s\n' "$*"; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +need() { command -v "$1" >/dev/null 2>&1 || die "missing command: $1"; } + +ssh_koopa() { + ssh -o BatchMode=yes -o ConnectTimeout=15 "${KOOPA_SSH}" "$@" +} + +ctr_root() { + ssh_koopa podman exec -u root "${MERCHANT_CTR}" bash -c "$*" +} + +log "work root: ${WORK_ROOT}" +mkdir -p "${WORK_ROOT}" +need git +need ssh +need tar +need curl + +# --------------------------------------------------------------------------- +# 1) WebUI from tag +# --------------------------------------------------------------------------- +if [ "${SKIP_WEBUI}" != "1" ]; then + need node + if [ ! -x "${PNPM}" ]; then + log "installing pnpm to ~/.local …" + npm install -g pnpm@10.33.4 --prefix "${HOME}/.local" + fi + need pnpm || [ -x "${PNPM}" ] + PNPM_BIN=$(command -v pnpm || echo "${PNPM}") + + log "clone ${TTC_GIT} @ ${WEBUI_TAG} (full clone, not worktree — build.mjs needs real .git)" + rm -rf "${TTC_DIR}" + git clone --branch "${WEBUI_TAG}" --depth 1 "${TTC_GIT}" "${TTC_DIR}" + cd "${TTC_DIR}" + git submodule update --init contrib/taler-assets + test -f contrib/taler-assets/svg/logo/qr-logo.svg || die "taler-assets submodule incomplete" + + ver=$(python3 -c "import json; print(json.load(open('packages/taler-merchant-webui/package.json'))['version'])") + log "package.json version=${ver} HEAD=$(git rev-parse --short HEAD)" + + log "pnpm install + compile merchant-webui …" + "${PNPM_BIN}" install --frozen-lockfile --filter @gnu-taler/taler-merchant-webui... + "${PNPM_BIN}" run --filter @gnu-taler/taler-merchant-webui... compile + + test -f packages/taler-merchant-webui/dist/prod/index.js || die "dist/prod/index.js missing" + test -f packages/taler-merchant-webui/dist/prod/version.txt || die "version.txt missing" + got=$(tr -d '\n' < packages/taler-merchant-webui/dist/prod/version.txt) + log "built SPA version.txt=${got}" + # expect 1.6.11 for default tag + case "${WEBUI_TAG}" in + v1.6.11|1.6.11) + [ "${got}" = "1.6.11" ] || die "expected version 1.6.11, got ${got}" + ;; + esac + + rm -rf "${SPA_DIR}" + mkdir -p "${SPA_DIR}" + cp -a packages/taler-merchant-webui/dist/prod/. "${SPA_DIR}/" + printf 'selfbuild-%s-%s\n' "${WEBUI_TAG}" "$(git rev-parse --short HEAD)" >"${SPA_DIR}/version-overlay.txt" + + log "deploy SPA into ${MERCHANT_CTR}:/usr/share/taler-merchant-webui" + ts=$(date +%Y%m%d-%H%M%S) + ctr_root " + set -e + if [ -d /usr/share/taler-merchant-webui ]; then + cp -a /usr/share/taler-merchant-webui /usr/share/taler-merchant-webui.bak-selfbuild-${ts} + fi + mkdir -p /usr/share/taler-merchant-webui + # clear previous SPA files (keep directory) + find /usr/share/taler-merchant-webui -mindepth 1 -maxdepth 1 ! -name 'bak-*' -exec rm -rf {} + + " + tar -C "${SPA_DIR}" -czf - . | ssh_koopa "podman exec -i -u root ${MERCHANT_CTR} bash -c ' + set -e + cd /usr/share/taler-merchant-webui + tar -xzf - + chown -R root:root /usr/share/taler-merchant-webui + echo deployed: + cat version.txt + cat version-overlay.txt + wc -c index.js + '" + log "webui deploy done" +else + log "SKIP_WEBUI=1" +fi + +# --------------------------------------------------------------------------- +# 2) Merchant backend from master (build inside container) +# --------------------------------------------------------------------------- +if [ "${SKIP_MERCHANT}" != "1" ]; then + log "build taler-merchant ${MERCHANT_REF} inside ${MERCHANT_CTR}" + # Install build deps if missing; update /var/taler-src/merchant; bootstrap; make install + ssh_koopa "podman exec -u root ${MERCHANT_CTR} bash -s" <&1 | tail -30 || true + +# exchange headers sometimes via package names that differ +apt-get install -y --no-install-recommends libtalerutil-dev 2>/dev/null || true + +echo '=== source tree ===' +mkdir -p /var/taler-src +if [ -d "\$SRC/.git" ]; then + cd "\$SRC" + git remote set-url origin "\$GIT_URL" 2>/dev/null || git remote add origin "\$GIT_URL" || true + git fetch --tags origin + git checkout "\$REF" + git pull --ff-only origin "\$REF" || git reset --hard "origin/\$REF" +else + rm -rf "\$SRC" + git clone "\$GIT_URL" "\$SRC" + cd "\$SRC" + git checkout "\$REF" +fi +# submodules if any +git submodule update --init --recursive 2>/dev/null || true +echo "merchant HEAD=\$(git rev-parse --short HEAD) \$(git log -1 --oneline)" + +echo '=== bootstrap / configure / make ===' +if [ ! -x configure ]; then + ./bootstrap +fi +# Prefer /usr prefix to replace package binaries +./configure --prefix=/usr --disable-doc 2>&1 | tail -40 +make -j"\$(nproc)" 2>&1 | tail -50 +make install 2>&1 | tail -30 + +echo '=== installed httpd ===' +ls -la /usr/bin/taler-merchant-httpd +taler-merchant-httpd --help 2>&1 | head -5 || true +# record build id +git rev-parse HEAD > /usr/share/taler-merchant/SELFBUILD_MERCHANT_HEAD 2>/dev/null \\ + || mkdir -p /usr/share/taler-merchant && git rev-parse HEAD > /usr/share/taler-merchant/SELFBUILD_MERCHANT_HEAD +echo "SELFBUILD_MERCHANT_HEAD=\$(cat /usr/share/taler-merchant/SELFBUILD_MERCHANT_HEAD)" +REMOTE + log "merchant build/install done" +else + log "SKIP_MERCHANT=1" +fi + +# --------------------------------------------------------------------------- +# 3) Restart + health +# --------------------------------------------------------------------------- +if [ "${SKIP_RESTART}" != "1" ]; then + log "restart merchant services" + ssh_koopa "podman exec -u root ${MERCHANT_CTR} bash -c ' + set -e + if id taler-merchant-httpd >/dev/null 2>&1; then + runuser -u taler-merchant-httpd -- taler-merchant-dbinit 2>&1 | tail -15 || true + fi + runuser -u taler-merchant-httpd -- /usr/local/bin/start_merchant.sh --restart + '" + log "smoke" + ssh_koopa 'curl -skS -m 8 -o /dev/null -w "merchant_local %{http_code}\n" https://127.0.0.1:9010/config || true' + curl -skS -m 12 -o /dev/null -w "merchant_public %{http_code}\n" https://taler.hacktivism.ch/config || true + curl -skS -m 12 -o /dev/null -w "webui %{http_code}\n" https://taler.hacktivism.ch/webui/ || true + pubver=$(curl -skS -m 12 https://taler.hacktivism.ch/webui/version.txt 2>/dev/null || true) + pubov=$(curl -skS -m 12 https://taler.hacktivism.ch/webui/version-overlay.txt 2>/dev/null || true) + log "public version.txt=${pubver}" + log "public version-overlay.txt=${pubov}" +else + log "SKIP_RESTART=1" +fi + +log "DONE" +log " webui: tag ${WEBUI_TAG} → container /usr/share/taler-merchant-webui" +log " merchant: ${MERCHANT_REF} → /usr/bin/taler-merchant-*" +log " work left at ${WORK_ROOT} (delete when satisfied)" From 14bee9f1eccc92da4c1ae11d88276b4e5c3e455d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Sat, 18 Jul 2026 14:30:56 +0200 Subject: [PATCH 304/326] merchant: host build-deploy webui tag + merchant master --- ...ild-deploy-webui-1.6.11-merchant-master.sh | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100755 scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh diff --git a/scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh b/scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh new file mode 100755 index 0000000..bfd0b05 --- /dev/null +++ b/scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# Build taler-merchant-webui from tag v1.6.11 + taler-merchant from master, +# deploy into podman container taler-hacktivism on koopa, restart services. +# +# Run from clementine (or any host with: git, node/pnpm, ssh koopa, podman on koopa): +# ./scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh +# +# Env: +# KOOPA_SSH=koopa +# MERCHANT_CTR=taler-hacktivism +# TTC_GIT=git@git.taler.net:taler-typescript-core.git +# MERCHANT_GIT=https://git.taler.net/merchant.git # or git@… +# WEBUI_TAG=v1.6.11 +# MERCHANT_REF=master +# SKIP_WEBUI=0 SKIP_MERCHANT=0 SKIP_RESTART=0 +# PNPM=$HOME/.local/bin/pnpm +# WORK_ROOT=/tmp/taler-selfbuild-$$ +# +set -euo pipefail + +KOOPA_SSH="${KOOPA_SSH:-koopa}" +MERCHANT_CTR="${MERCHANT_CTR:-taler-hacktivism}" +TTC_GIT="${TTC_GIT:-git@git.taler.net:taler-typescript-core.git}" +MERCHANT_GIT="${MERCHANT_GIT:-https://git.taler.net/merchant.git}" +WEBUI_TAG="${WEBUI_TAG:-v1.6.11}" +MERCHANT_REF="${MERCHANT_REF:-master}" +SKIP_WEBUI="${SKIP_WEBUI:-0}" +SKIP_MERCHANT="${SKIP_MERCHANT:-0}" +SKIP_RESTART="${SKIP_RESTART:-0}" +PNPM="${PNPM:-$HOME/.local/bin/pnpm}" +WORK_ROOT="${WORK_ROOT:-/tmp/taler-selfbuild-$$}" +SPA_DIR="${WORK_ROOT}/spa-prod" +TTC_DIR="${WORK_ROOT}/taler-typescript-core" +export PATH="${HOME}/.local/bin:${PATH}" + +log() { printf '+ %s\n' "$*"; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +need() { command -v "$1" >/dev/null 2>&1 || die "missing command: $1"; } + +ssh_koopa() { + ssh -o BatchMode=yes -o ConnectTimeout=15 "${KOOPA_SSH}" "$@" +} + +ctr_root() { + ssh_koopa podman exec -u root "${MERCHANT_CTR}" bash -c "$*" +} + +log "work root: ${WORK_ROOT}" +mkdir -p "${WORK_ROOT}" +need git +need ssh +need tar +need curl + +# --------------------------------------------------------------------------- +# 1) WebUI from tag +# --------------------------------------------------------------------------- +if [ "${SKIP_WEBUI}" != "1" ]; then + need node + if [ ! -x "${PNPM}" ]; then + log "installing pnpm to ~/.local …" + npm install -g pnpm@10.33.4 --prefix "${HOME}/.local" + fi + need pnpm || [ -x "${PNPM}" ] + PNPM_BIN=$(command -v pnpm || echo "${PNPM}") + + log "clone ${TTC_GIT} @ ${WEBUI_TAG} (full clone, not worktree — build.mjs needs real .git)" + rm -rf "${TTC_DIR}" + git clone --branch "${WEBUI_TAG}" --depth 1 "${TTC_GIT}" "${TTC_DIR}" + cd "${TTC_DIR}" + git submodule update --init contrib/taler-assets + test -f contrib/taler-assets/svg/logo/qr-logo.svg || die "taler-assets submodule incomplete" + + ver=$(python3 -c "import json; print(json.load(open('packages/taler-merchant-webui/package.json'))['version'])") + log "package.json version=${ver} HEAD=$(git rev-parse --short HEAD)" + + log "pnpm install + compile merchant-webui …" + "${PNPM_BIN}" install --frozen-lockfile --filter @gnu-taler/taler-merchant-webui... + "${PNPM_BIN}" run --filter @gnu-taler/taler-merchant-webui... compile + + test -f packages/taler-merchant-webui/dist/prod/index.js || die "dist/prod/index.js missing" + test -f packages/taler-merchant-webui/dist/prod/version.txt || die "version.txt missing" + got=$(tr -d '\n' < packages/taler-merchant-webui/dist/prod/version.txt) + log "built SPA version.txt=${got}" + # expect 1.6.11 for default tag + case "${WEBUI_TAG}" in + v1.6.11|1.6.11) + [ "${got}" = "1.6.11" ] || die "expected version 1.6.11, got ${got}" + ;; + esac + + rm -rf "${SPA_DIR}" + mkdir -p "${SPA_DIR}" + cp -a packages/taler-merchant-webui/dist/prod/. "${SPA_DIR}/" + printf 'selfbuild-%s-%s\n' "${WEBUI_TAG}" "$(git rev-parse --short HEAD)" >"${SPA_DIR}/version-overlay.txt" + + log "deploy SPA into ${MERCHANT_CTR}:/usr/share/taler-merchant-webui" + ts=$(date +%Y%m%d-%H%M%S) + ctr_root " + set -e + if [ -d /usr/share/taler-merchant-webui ]; then + cp -a /usr/share/taler-merchant-webui /usr/share/taler-merchant-webui.bak-selfbuild-${ts} + fi + mkdir -p /usr/share/taler-merchant-webui + # clear previous SPA files (keep directory) + find /usr/share/taler-merchant-webui -mindepth 1 -maxdepth 1 ! -name 'bak-*' -exec rm -rf {} + + " + tar -C "${SPA_DIR}" -czf - . | ssh_koopa "podman exec -i -u root ${MERCHANT_CTR} bash -c ' + set -e + cd /usr/share/taler-merchant-webui + tar -xzf - + chown -R root:root /usr/share/taler-merchant-webui + echo deployed: + cat version.txt + cat version-overlay.txt + wc -c index.js + '" + log "webui deploy done" +else + log "SKIP_WEBUI=1" +fi + +# --------------------------------------------------------------------------- +# 2) Merchant backend from master (build inside container) +# --------------------------------------------------------------------------- +if [ "${SKIP_MERCHANT}" != "1" ]; then + log "build taler-merchant ${MERCHANT_REF} inside ${MERCHANT_CTR}" + # Install build deps if missing; update /var/taler-src/merchant; bootstrap; make install + ssh_koopa "podman exec -u root ${MERCHANT_CTR} bash -s" <&1 | tail -30 || true + +# exchange headers sometimes via package names that differ +apt-get install -y --no-install-recommends libtalerutil-dev 2>/dev/null || true + +echo '=== source tree ===' +mkdir -p /var/taler-src +if [ -d "\$SRC/.git" ]; then + cd "\$SRC" + git remote set-url origin "\$GIT_URL" 2>/dev/null || git remote add origin "\$GIT_URL" || true + git fetch --tags origin + git checkout "\$REF" + git pull --ff-only origin "\$REF" || git reset --hard "origin/\$REF" +else + rm -rf "\$SRC" + git clone "\$GIT_URL" "\$SRC" + cd "\$SRC" + git checkout "\$REF" +fi +# submodules if any +git submodule update --init --recursive 2>/dev/null || true +echo "merchant HEAD=\$(git rev-parse --short HEAD) \$(git log -1 --oneline)" + +echo '=== bootstrap / configure / make ===' +if [ ! -x configure ]; then + ./bootstrap +fi +# Prefer /usr prefix to replace package binaries +./configure --prefix=/usr --disable-doc 2>&1 | tail -40 +make -j"\$(nproc)" 2>&1 | tail -50 +make install 2>&1 | tail -30 + +echo '=== installed httpd ===' +ls -la /usr/bin/taler-merchant-httpd +taler-merchant-httpd --help 2>&1 | head -5 || true +# record build id +git rev-parse HEAD > /usr/share/taler-merchant/SELFBUILD_MERCHANT_HEAD 2>/dev/null \\ + || mkdir -p /usr/share/taler-merchant && git rev-parse HEAD > /usr/share/taler-merchant/SELFBUILD_MERCHANT_HEAD +echo "SELFBUILD_MERCHANT_HEAD=\$(cat /usr/share/taler-merchant/SELFBUILD_MERCHANT_HEAD)" +REMOTE + log "merchant build/install done" +else + log "SKIP_MERCHANT=1" +fi + +# --------------------------------------------------------------------------- +# 3) Restart + health +# --------------------------------------------------------------------------- +if [ "${SKIP_RESTART}" != "1" ]; then + log "restart merchant services" + ssh_koopa "podman exec -u root ${MERCHANT_CTR} bash -c ' + set -e + if id taler-merchant-httpd >/dev/null 2>&1; then + runuser -u taler-merchant-httpd -- taler-merchant-dbinit 2>&1 | tail -15 || true + fi + runuser -u taler-merchant-httpd -- /usr/local/bin/start_merchant.sh --restart + '" + log "smoke" + ssh_koopa 'curl -skS -m 8 -o /dev/null -w "merchant_local %{http_code}\n" https://127.0.0.1:9010/config || true' + curl -skS -m 12 -o /dev/null -w "merchant_public %{http_code}\n" https://taler.hacktivism.ch/config || true + curl -skS -m 12 -o /dev/null -w "webui %{http_code}\n" https://taler.hacktivism.ch/webui/ || true + pubver=$(curl -skS -m 12 https://taler.hacktivism.ch/webui/version.txt 2>/dev/null || true) + pubov=$(curl -skS -m 12 https://taler.hacktivism.ch/webui/version-overlay.txt 2>/dev/null || true) + log "public version.txt=${pubver}" + log "public version-overlay.txt=${pubov}" +else + log "SKIP_RESTART=1" +fi + +log "DONE" +log " webui: tag ${WEBUI_TAG} → container /usr/share/taler-merchant-webui" +log " merchant: ${MERCHANT_REF} → /usr/bin/taler-merchant-*" +log " work left at ${WORK_ROOT} (delete when satisfied)" From 6b1e8f8662bf5e8dae115c878ce08f364692bc01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Sat, 18 Jul 2026 14:32:29 +0200 Subject: [PATCH 305/326] merchant: document selfbuild webui/merchant deploy --- scripts/taler-merchant/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scripts/taler-merchant/README.md b/scripts/taler-merchant/README.md index 4ddea4a..2173a28 100644 --- a/scripts/taler-merchant/README.md +++ b/scripts/taler-merchant/README.md @@ -30,6 +30,27 @@ Automatic import needs: SMS backends are symlinks into `/var/taler-src/...` (not copied). +## Self-build deploy (webui tag + merchant master) + +From a laptop with `ssh koopa` + `pnpm`/`node` (e.g. clementine): + +```bash +cd ~/src/koopa/koopa-admin-log +chmod +x scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh +./scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh +``` + +What it does: + +1. **Clone** `taler-typescript-core` **tag `v1.6.11`**, build SPA, install into + `taler-hacktivism:/usr/share/taler-merchant-webui` (backup `…bak-selfbuild-*`). +2. **Inside the container:** update `/var/taler-src/merchant` to **master**, + `./bootstrap && ./configure --prefix=/usr && make && make install`. +3. **`start_merchant.sh --restart`** + smoke `/config` + `/webui/`. + +Env knobs: `SKIP_WEBUI=1`, `SKIP_MERCHANT=1`, `SKIP_RESTART=1`, +`WEBUI_TAG=…`, `MERCHANT_REF=master`, `KOOPA_SSH=koopa`. + ## Portal / instance IDs: **lowercase only** Merchant SPA (`/webui/`) and `/instances/{id}/…` require **lowercase** instance From f4b556c3c0c1361eced5fd93118b4e0898f09c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Sat, 18 Jul 2026 14:32:29 +0200 Subject: [PATCH 306/326] merchant: document selfbuild webui/merchant deploy --- scripts/taler-merchant/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scripts/taler-merchant/README.md b/scripts/taler-merchant/README.md index 4ddea4a..2173a28 100644 --- a/scripts/taler-merchant/README.md +++ b/scripts/taler-merchant/README.md @@ -30,6 +30,27 @@ Automatic import needs: SMS backends are symlinks into `/var/taler-src/...` (not copied). +## Self-build deploy (webui tag + merchant master) + +From a laptop with `ssh koopa` + `pnpm`/`node` (e.g. clementine): + +```bash +cd ~/src/koopa/koopa-admin-log +chmod +x scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh +./scripts/taler-merchant/build-deploy-webui-1.6.11-merchant-master.sh +``` + +What it does: + +1. **Clone** `taler-typescript-core` **tag `v1.6.11`**, build SPA, install into + `taler-hacktivism:/usr/share/taler-merchant-webui` (backup `…bak-selfbuild-*`). +2. **Inside the container:** update `/var/taler-src/merchant` to **master**, + `./bootstrap && ./configure --prefix=/usr && make && make install`. +3. **`start_merchant.sh --restart`** + smoke `/config` + `/webui/`. + +Env knobs: `SKIP_WEBUI=1`, `SKIP_MERCHANT=1`, `SKIP_RESTART=1`, +`WEBUI_TAG=…`, `MERCHANT_REF=master`, `KOOPA_SSH=koopa`. + ## Portal / instance IDs: **lowercase only** Merchant SPA (`/webui/`) and `/instances/{id}/…` require **lowercase** instance From b734eead6c27b6385d233a69f048c2571456d2dc Mon Sep 17 00:00:00 2001 From: hernani Date: Sat, 18 Jul 2026 19:57:21 +0200 Subject: [PATCH 307/326] monitoring: surface inventory and apt-deploy check phases --- scripts/taler-monitoring/.gitignore | 6 + scripts/taler-monitoring/DEPENDENCIES.md | 70 ++ scripts/taler-monitoring/README.md | 5 + scripts/taler-monitoring/TESTS.md | 28 +- scripts/taler-monitoring/check_apt_deploy.sh | 279 +++++++ scripts/taler-monitoring/check_inside.sh | 170 ++++- scripts/taler-monitoring/check_server.sh | 29 + scripts/taler-monitoring/check_surface.sh | 692 ++++++++++++++++++ scripts/taler-monitoring/check_versions.sh | 85 ++- scripts/taler-monitoring/lib.sh | 110 +++ scripts/taler-monitoring/surface-catalog.conf | 52 ++ scripts/taler-monitoring/taler-monitoring.sh | 177 ++++- 12 files changed, 1657 insertions(+), 46 deletions(-) create mode 100644 scripts/taler-monitoring/.gitignore create mode 100644 scripts/taler-monitoring/DEPENDENCIES.md create mode 100755 scripts/taler-monitoring/check_apt_deploy.sh create mode 100755 scripts/taler-monitoring/check_surface.sh create mode 100644 scripts/taler-monitoring/surface-catalog.conf diff --git a/scripts/taler-monitoring/.gitignore b/scripts/taler-monitoring/.gitignore new file mode 100644 index 0000000..754bb8e --- /dev/null +++ b/scripts/taler-monitoring/.gitignore @@ -0,0 +1,6 @@ +android-test/apks/ +android-test/out*/ +android-test/artifacts/ +__pycache__/ +secrets.env +*.pyc diff --git a/scripts/taler-monitoring/DEPENDENCIES.md b/scripts/taler-monitoring/DEPENDENCIES.md new file mode 100644 index 0000000..3e7db4c --- /dev/null +++ b/scripts/taler-monitoring/DEPENDENCIES.md @@ -0,0 +1,70 @@ +# taler-monitoring — host package dependencies + +## koopa (openSUSE Tumbleweed, user `hernani`) + +### Required (host-agent / GOA primary: `urls inside versions`) + +| Binary | Package (zypper) | Role | +|--------|------------------|------| +| `bash` | (base) | runner | +| `curl` | `curl` | HTTPS/API probes | +| `python3` | `python313-base` (provides `/usr/bin/python3`) | JSON, HTML site-gen, metrics | +| `podman` | `podman` | **inside**: `podman exec` into taler-hacktivism* | +| `git` | `git` | pin commit SHA in monitoring HTML footer | +| `rsync` | `rsync` | optional: deploy/sync suite | +| `timeout` / `mktemp` / `getent` | coreutils / glibc | helpers | + +**Status on koopa (2026-07-18):** all of the above present; rootless podman owns GOA containers. + +### Recommended (full `urls` QR group) + +| Binary | Package | Role | +|--------|---------|------| +| `qrencode` | **`qrencode`** | encode taler:// / payto:// → PNG | +| `zbarimg` | **`zbar`** | decode PNG; exact payload match | + +Without these, QR checks **WARN** and skip encode/decode (`apt install` messages in code are Debian-oriented; on Tumbleweed use zypper). + +```bash +# as root on koopa +zypper refresh +zypper in qrencode zbar +# verify as hernani: +command -v qrencode zbarimg +``` + +### Optional (not needed for host-agent default) + +| Binary / tool | When | +|---------------|------| +| `jq` | nice-to-have; suite uses python3 for JSON | +| `dig` / `bind-utils` | debug DNS; not required for default phases | +| `ssh` | only for access mode **ssh** (laptop → `KOOPA_SSH` / stage `INSIDE_SSH`); not needed for **host-podman** | +| `node` / `pnpm` + wallet-cli | **e2e** / ladder only (+ secrets) | +| `secrets.env` / admin passwords | e2e only — never system packages | + +### systemd user (no extra packages) + +- `systemd` user session + **`loginctl enable-linger hernani`** (already **Linger=yes** on koopa) +- Units: `scripts/taler-monitoring/host-agent/*.service|path|timer` + +### Caddy HTML publish (separate from monitoring run) + +- Host Caddy already installed (root service). +- Writing `/var/www/monitoring-sites`: either root `rsync` or directory owned/writable by `hernani` (you set this up). + +--- + +## firecuda-external (macOS, outside-only timer) + +| Need | Notes | +|------|--------| +| `bash`, `curl`, `python3`, `rsync`, `git` (optional) | usually Homebrew / Xcode CLT | +| **No** `podman` | phases are `urls versions` only (`SKIP_SSH=1`) | +| `qrencode` / `zbarimg` | optional for QR; `brew install qrencode zbar` if desired | + +--- + +## Franc Paysan hosts (francpaysan / stagepaysan) + +Document package lists under **`francpaysan-admin-log`** when those host-agents are installed (same idea: curl, python3, podman if inside, qrencode/zbar for full urls). diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 578b683..7d3ae2f 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -1,5 +1,10 @@ # taler-monitoring +Host packages (koopa / runners): **[DEPENDENCIES.md](./DEPENDENCIES.md)**. +Host-agents (GOA + FP; shared report pipeline): **[host-agent/README.md](./host-agent/README.md)**. +Public HTML site-gen (same timeout/log/HTML defaults): **[site-gen/README.md](./site-gen/README.md)**. +FP install notes: `francpaysan-admin-log/docs/taler-monitoring-host-agents.md`. + Report for the **GOA** stack with boxed severity badges and **grouped test IDs** `area.group-NN` (e.g. `www.exchange-01`, `e2e.pay-03`): diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index fb829d0..7b667f4 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -18,6 +18,8 @@ Every check line has a **global** run number and a **grouped** id: | **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` `qr` | | **inside** | `check_inside.sh` | `ssh` `bank` `exchange` `merchant` `caddy` `load` | | **versions** | `check_versions.sh` | `outside` `inside` `compare` | +| **aptdeploy** | `check_apt_deploy.sh` | `trixie` `trixie-testing` (koopa podman apt-src merchant smoke) | +| **surface** | `check_surface.sh` | **remote-only** ecosystem / `-d` domain inventory (not in default/all/full) | | **sanity** | `check_sanity.sh` | `bank` `exchange` `merchant` | | **server** | `check_server.sh` | (flat `server-NN` or host groups) | | **e2e** | `check_e2e.sh` | `prereq` `load` `bank` `wallet` `atm` `settle` `pay` `shop` `paivana` `dig` `report` | @@ -73,12 +75,14 @@ Numbering follows **executed** checks (early skip may shift later NN inside the | Group | Checks | |-------|--------| -| **inside.ssh-** | ssh reachability / remote collect | +| **inside.host-** | **host-podman**: reachability via `podman exec` on this machine (GOA host / `INSIDE_PODMAN=1`) — not SSH | +| **inside.ssh-** | **ssh**: from laptop/remote — SSH to `KOOPA_SSH` / `INSIDE_SSH`, then podman there | | **inside.bank-** | container, libeufin, postgres, local `/config`, nginx, DNS pins | | **inside.exchange-** | container, httpd, wirewatch, aggregator, transfer, local keys, DNS | | **inside.merchant-** | container, httpd, wirewatch, depositcheck, DNS | | **inside.caddy-** | host reverse-proxy process | | **inside.load-** | host loadavg + RAM; per-container RSS/CPU | +| **inside.disk-** / **aptdeploy.disk-** / **versions.disk-** / **server.disk-** | free space via `df`: **WARN** if use ≥ `DISK_WARN_USED_PCT` (85), **ERROR** if full or use ≥ `DISK_ERR_USED_PCT` (95); host + containers | Remote lines `E|comp|LEVEL|key|detail` each become one numbered result under that component group. @@ -112,8 +116,28 @@ When filing an issue, quote the full id + label, e.g. | Area.group | Meaning | |------------|---------| | **versions.outside-** | deb.taler.net suite index | -| **versions.inside-** | packages in containers | +| **versions.host-** | host-podman: packages via local `podman exec` (`INSIDE_PODMAN=1`) | +| **versions.ssh-** | ssh: packages via `KOOPA_SSH` / `INSIDE_SSH` from laptop/remote | | **versions.compare-** | installed vs suite | +| **aptdeploy.trixie-fresh-** | fresh rebuild container `…-apt-src-trixie` | +| **aptdeploy.trixie-testing-fresh-** | fresh rebuild `…-trixie-testing` | +| **aptdeploy.trixie-upgrade-** | upgrade-track `…-trixie-upgrade` (install once, then mytops-style upgrade) | +| **aptdeploy.trixie-testing-upgrade-** | upgrade-track `…-trixie-testing-upgrade` | + +Standalone: `host-agent/run-aptdeploy.sh` (on koopa local; else `ssh koopa-external`). + +### surface (remote-only public inventory) + +```bash +./taler-monitoring.sh surface # taler.net / gnunet / taler-systems / mattermost / … +./taler-monitoring.sh -d hacktivism.ch surface # all things under that domain +``` + +- Catalog: `surface-catalog.conf` (extend as hosts are found) +- DNS, optional ICMP, TCP ports, then **protocol** checks (HTTPS/HTTP/SSH) before ERROR +- TLS cert expiry; Server-header / `/config` version hints; else **WARN: version unknown** +- OSV CVE query when package+version known → **ERROR** on hits +- Never SSH / never podman on the *targets* | **sanity.bank-** / **.exchange-** / **.merchant-** | public + optional server-side per component | | **ladder.plan-** / **.load-** / **.withdraw-** / **.pay-** / **.report-** | amount ladder (GOA ceiling or stage TESTPAYSAN `max_wire`) | | **server-** | SSH host ports / processes (flat unless grouped later) | diff --git a/scripts/taler-monitoring/check_apt_deploy.sh b/scripts/taler-monitoring/check_apt_deploy.sh new file mode 100755 index 0000000..5240e82 --- /dev/null +++ b/scripts/taler-monitoring/check_apt_deploy.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +# check_apt_deploy.sh — podman apt-src merchant deploy tests on koopa. +# +# Containers (default): +# Fresh (rebuild when repo versions change): +# koopa-taler-deploy-test-apt-src-trixie +# koopa-taler-deploy-test-apt-src-trixie-testing +# Upgrade track (initial install, then mytops-style apt upgrade): +# koopa-taler-deploy-test-apt-src-trixie-upgrade +# koopa-taler-deploy-test-apt-src-trixie-testing-upgrade +# +# Env: +# APT_DEPLOY_CONTAINERS "name:suite:mode …" mode=fresh|upgrade (optional, default fresh) +# APT_DEPLOY_SKIP=1 +# APT_DEPLOY_PODMAN=podman +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +if [ "${APT_DEPLOY_SKIP:-0}" = "1" ]; then + echo "[INFO] skip phase aptdeploy (APT_DEPLOY_SKIP=1)" + exit 0 +fi + +set_area aptdeploy +section "aptdeploy · podman apt-src merchant (fresh + upgrade tracks)" + +PODMAN_BIN="${APT_DEPLOY_PODMAN:-podman}" +DEFAULT_LIST="\ +koopa-taler-deploy-test-apt-src-trixie:trixie:fresh \ +koopa-taler-deploy-test-apt-src-trixie-testing:trixie-testing:fresh \ +koopa-taler-deploy-test-apt-src-trixie-upgrade:trixie:upgrade \ +koopa-taler-deploy-test-apt-src-trixie-testing-upgrade:trixie-testing:upgrade" +LIST="${APT_DEPLOY_CONTAINERS:-$DEFAULT_LIST}" + +if ! command -v "$PODMAN_BIN" >/dev/null 2>&1; then + err "podman" "podman binary missing ($PODMAN_BIN)" + summary + exit 1 +fi +ok "podman" "$("$PODMAN_BIN" --version 2>/dev/null | head -1)" +info "host" "$(hostname 2>/dev/null || echo unknown) · whoami=$(whoami)" + +fail_any=0 + +print_pkg_table() { + local name=$1 + local rows + rows=$("$PODMAN_BIN" exec "$name" bash -lc ' + echo "| package | version |" + echo "|---------|---------|" + for p in taler-merchant libtalermerchant libtalerexchange libdonau libgnunet \ + taler-merchant-webui taler-merchant-typst taler-terms-generator; do + v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo missing) + echo "| $p | $v |" + done + ' 2>/dev/null || echo "| (query failed) | |") + info "pkg-table" "$name" + # emit as plain lines so HTML log keeps the table + while IFS= read -r line; do + [ -n "$line" ] || continue + info "pkg" "$line" + done <<<"$rows" +} + +check_ldd_deep() { + local name=$1 + local out + out=$("$PODMAN_BIN" exec "$name" bash -lc ' + set +e + bin=$(command -v taler-merchant-httpd) + echo "=== ldd taler-merchant-httpd ===" + ldd "$bin" 2>&1 | grep -E "libtalerutil|libdonau|libgnunet|not found" || true + echo "=== ldd libdonau (if present) ===" + for f in /usr/lib/*/libdonau.so* /usr/lib/*/libdonauutil.so*; do + [ -e "$f" ] || continue + echo "-- $f --" + ldd "$f" 2>&1 | grep -E "libtalerutil|not found" || true + done + echo "=== libtalerutil files ===" + ls -la /usr/lib/*/libtalerutil* 2>/dev/null || echo "(none)" + ' 2>/dev/null || echo "ldd probe failed") + while IFS= read -r line; do + [ -n "$line" ] || continue + info "ldd" "$line" + done <<<"$out" + if echo "$out" | grep -q 'not found'; then + err "ldd" "shared library missing in $name" "see ldd lines above" + return 1 + fi + return 0 +} + +check_systemd() { + local name=$1 + local out active_target active_httpd enabled + out=$("$PODMAN_BIN" exec "$name" bash -lc ' + set +e + echo "system: $(systemctl is-system-running 2>&1)" + echo "target_enabled: $(systemctl is-enabled taler-merchant.target 2>&1)" + echo "target_active: $(systemctl is-active taler-merchant.target 2>&1)" + echo "httpd_active: $(systemctl is-active taler-merchant-httpd.service 2>&1)" + echo "httpd_enabled: $(systemctl is-enabled taler-merchant-httpd.service 2>&1)" + systemctl start taler-merchant.target 2>&1 | tail -5 + sleep 2 + echo "after_start_target: $(systemctl is-active taler-merchant.target 2>&1)" + echo "after_start_httpd: $(systemctl is-active taler-merchant-httpd.service 2>&1)" + systemctl --failed --no-legend 2>/dev/null | grep -i taler || echo "failed_taler: none" + ' 2>/dev/null || echo "systemd probe failed") + while IFS= read -r line; do + [ -n "$line" ] || continue + info "systemd" "$line" + done <<<"$out" + + active_httpd=$(echo "$out" | sed -n 's/^after_start_httpd: //p' | tail -1) + if [ "$active_httpd" = "active" ]; then + ok "systemd httpd" "taler-merchant-httpd active after start taler-merchant.target" + return 0 + fi + # soft if target not shipped as enabled in container — still ERROR for deploy smoke + err "systemd httpd" "taler-merchant-httpd not active" "after_start_httpd=${active_httpd:-?} · see systemd lines" + return 1 +} + +check_merchant_basics() { + local name=$1 + local out code + # unix socket or curl localhost if httpd listens + out=$("$PODMAN_BIN" exec "$name" bash -lc ' + set +e + echo "=== binaries ===" + command -v taler-merchant-httpd taler-merchant-dbinit taler-config 2>/dev/null + echo "=== --version ===" + taler-merchant-httpd --version 2>&1 + echo "=== config probe ===" + # try common paths without TLS + for u in \ + http://127.0.0.1:9966/config \ + http://127.0.0.1:8081/config \ + http://127.0.0.1/config + do + code=$(curl -sS -m 2 -o /tmp/mcfg -w "%{http_code}" "$u" 2>/dev/null || echo 000) + echo "curl $u -> $code" + [ "$code" = "200" ] && head -c 120 /tmp/mcfg && echo + done + sock=$(ls /run/taler-merchant/httpd/*.sock 2>/dev/null | head -1) + if [ -n "$sock" ]; then + echo "socket: $sock" + code=$(curl -sS -m 2 --unix-socket "$sock" -o /tmp/mcfg -w "%{http_code}" http://localhost/config 2>/dev/null || echo 000) + echo "curl --unix-socket -> $code" + [ "$code" = "200" ] && head -c 160 /tmp/mcfg && echo + else + echo "socket: (none under /run/taler-merchant/httpd/)" + fi + ' 2>/dev/null || echo "basics probe failed") + + while IFS= read -r line; do + [ -n "$line" ] || continue + info "basics" "$line" + done <<<"$out" + + if echo "$out" | grep -qi 'error while loading shared libraries'; then + err "httpd" "shared library error on --version" "see basics/ldd" + return 1 + fi + if ! echo "$out" | grep -q 'taler-merchant-httpd --version\|v\.\|taler-merchant-httpd'; then + # version line varies; success if exit was ok — check via separate exec + : + fi + + set +e + "$PODMAN_BIN" exec "$name" taler-merchant-httpd --version >/dev/null 2>&1 + local ec=$? + set -e + if [ "$ec" -eq 0 ]; then + ok "httpd --version" "exit 0" + else + err "httpd --version" "exit $ec" + return 1 + fi + + # /config is nice-to-have if stack is fully configured + if echo "$out" | grep -qE 'curl .* -> 200'; then + ok "merchant /config" "HTTP 200 (local)" + else + warn "merchant /config" "no local HTTP 200 (unit may need BASE_URL/db — still report version/ldd)" + fi + return 0 +} + +check_one() { + local name="$1" expect_suite="$2" mode="${3:-fresh}" + local st suite_line pkgs + + # group ids: aptdeploy.trixie-01 / aptdeploy.trixie-testing-upgrade-01 + set_group "${expect_suite}${mode:+-$mode}" + section "aptdeploy · $name (suite=$expect_suite mode=$mode)" + + if ! "$PODMAN_BIN" container exists "$name" 2>/dev/null; then + err "container" "$name missing" "run ensure-apt-deploy-test-containers.sh" + fail_any=1 + return + fi + st=$("$PODMAN_BIN" inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo unknown) + if [ "$st" != "running" ]; then + err "container" "$name not running" "status=$st" + fail_any=1 + return + fi + ok "container" "$name running (mode=$mode)" + + suite_line=$("$PODMAN_BIN" exec "$name" bash -lc \ + 'grep -h "^Suites:" /etc/apt/sources.list.d/*taler* 2>/dev/null | head -1' 2>/dev/null || true) + if echo "$suite_line" | grep -qE "Suites:[[:space:]]*${expect_suite}([[:space:]]|$)"; then + ok "apt suite" "$expect_suite" + else + err "apt suite" "expected $expect_suite" "got: ${suite_line:-empty}" + fail_any=1 + fi + + print_pkg_table "$name" + + if ! check_merchant_basics "$name"; then + fail_any=1 + check_ldd_deep "$name" || fail_any=1 + else + # still show ldd summary as info when ok + ldd_out=$("$PODMAN_BIN" exec "$name" bash -lc \ + 'ldd "$(command -v taler-merchant-httpd)" 2>&1 | grep -E "libtalerutil|not found" || true' 2>/dev/null || true) + if echo "$ldd_out" | grep -q 'not found'; then + err "libtalerutil" "not found" "$ldd_out" + check_ldd_deep "$name" || true + fail_any=1 + else + info "libtalerutil" "$(echo "$ldd_out" | tr '\n' ' ')" + fi + fi + + if ! check_systemd "$name"; then + fail_any=1 + check_ldd_deep "$name" || true + fi + + # Disk free inside this deploy-test container (+ host once, first container only) + if [ "${_APT_DEPLOY_HOST_DISK_DONE:-0}" != "1" ]; then + set_group disk + mon_disk_check_host "host" || fail_any=1 + _APT_DEPLOY_HOST_DISK_DONE=1 + fi + set_group disk + if ! mon_disk_check_podman "$name" "$PODMAN_BIN"; then + fail_any=1 + fi +} + +for entry in $LIST; do + # name:suite or name:suite:mode + cname=${entry%%:*} + rest=${entry#*:} + case "$rest" in + *:*) + suite=${rest%%:*} + mode=${rest#*:} + ;; + *) + suite=$rest + mode=fresh + ;; + esac + check_one "$cname" "$suite" "$mode" +done + +summary +if [ "$fail_any" -ne 0 ]; then + exit 1 +fi +exit 0 diff --git a/scripts/taler-monitoring/check_inside.sh b/scripts/taler-monitoring/check_inside.sh index 0584c9b..7f62880 100755 --- a/scripts/taler-monitoring/check_inside.sh +++ b/scripts/taler-monitoring/check_inside.sh @@ -1,9 +1,14 @@ #!/usr/bin/env bash # Inside status for bank / exchange / merchant containers. +# +# Access modes (shown in the log as flags): +# host-podman — this host runs podman exec into containers (no SSH). +# INSIDE_PODMAN=1 or INSIDE_MODE=local-podman, or auto on GOA host. +# ssh — from a laptop/remote: SSH to KOOPA_SSH / INSIDE_SSH, then podman there. +# # Profiles: -# koopa (default LOCAL_STACK=1) — SSH KOOPA_SSH, containers taler-hacktivism* -# stage-lfp (TESTPAYSAN) — SSH INSIDE_SSH (francpaysan-stage-user / stagepaysan), -# low-priv podman only; ports 9030–9032; stats remain outside-in (urls). +# koopa (default LOCAL_STACK=1) — taler-hacktivism* containers +# stage-lfp (TESTPAYSAN) — SSH INSIDE_SSH (stagepaysan), low-priv podman set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh @@ -12,7 +17,6 @@ source "$ROOT/lib.sh" source "$ROOT/metrics.sh" set_area inside -set_group ssh PROFILE="${INSIDE_PROFILE:-}" if [ -z "$PROFILE" ]; then @@ -25,7 +29,27 @@ if [ -z "$PROFILE" ]; then fi fi -section "inside · collect (${PROFILE})" +# Resolve host-podman vs ssh before first check ID +_use_local_podman=0 +if [ "$PROFILE" != "stage-lfp" ]; then + if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then + _use_local_podman=1 + elif command -v podman >/dev/null 2>&1 \ + && podman ps --format '{{.Names}}' 2>/dev/null | grep -qE 'taler-hacktivism'; then + _use_local_podman=1 + fi +fi +if [ "$_use_local_podman" = "1" ]; then + INSIDE_ACCESS=host-podman + set_group host +else + INSIDE_ACCESS=ssh + set_group ssh +fi +export INSIDE_ACCESS + +section "inside · collect (${PROFILE} · access=${INSIDE_ACCESS})" +info "flags" "INSIDE_ACCESS=${INSIDE_ACCESS} INSIDE_PODMAN=${INSIDE_PODMAN:-0} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-0} SKIP_SSH=${SKIP_SSH:-0} KOOPA_SSH=${KOOPA_SSH:-} INSIDE_SSH=${INSIDE_SSH:-}" # --------------------------------------------------------------------------- # stage-lfp: low-priv stagepaysan on francpaysan-host @@ -244,31 +268,70 @@ EOF info "stage host" "load probe empty" fi + set_group disk + section "inside · disk free space (stage host + containers)" + _disk_raw=$(mon_ssh_bash "$SSH_HOST" "${STAGE_SSH_T:-24}" <<'DISK' || true +set +e +echo "###HOST###" +df -Pk / /var /home /tmp /mnt/data 2>/dev/null || df -Pk +echo "###CTRS###" +for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do + echo "###CTR $c###" + podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null +done +DISK +) + _host_df=$(printf '%s\n' "$_disk_raw" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d') + mon_disk_check_remote_text "ssh:${SSH_HOST}" "$_host_df" || true + _ctr=""; _buf="" + while IFS= read -r _line || [ -n "$_line" ]; do + case "$_line" in + '###CTR '*) + if [ -n "$_ctr" ]; then mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true; fi + _ctr=${_line####CTR }; _ctr=${_ctr%###}; _buf="" + ;; + '###CTRS###'|'###HOST###') ;; + *) [ -n "$_ctr" ] && _buf="${_buf}${_line}"$'\n' ;; + esac + done <<<"$_disk_raw" + [ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true + summary exit 0 fi # --------------------------------------------------------------------------- -# koopa (default) — existing remote collect +# koopa (default) — host-podman exec (on GOA host) or SSH to KOOPA_SSH (laptop) # --------------------------------------------------------------------------- -section "inside · collect from koopa" +section "inside · collect from koopa (access=${INSIDE_ACCESS})" -if [ "${SKIP_SSH}" = "1" ] && [ "${LOCAL_STACK:-0}" != "1" ]; then - warn "ssh" "SKIP_SSH=1 and not local — skipped" +if [ "$_use_local_podman" = "1" ]; then + set_group host + if ! command -v podman >/dev/null 2>&1; then + err "host" "INSIDE_PODMAN/host-podman but podman missing" + summary + exit 1 + fi + ok "host→container" "podman exec on this host (INSIDE_ACCESS=host-podman · no SSH)" +elif [ "${SKIP_SSH}" = "1" ] && [ "${LOCAL_STACK:-0}" != "1" ]; then + set_group ssh + warn "ssh" "SKIP_SSH=1 and not host-podman — skipped" summary exit 0 -fi - -if ! koopa_ssh_ok; then - err "ssh" "cannot reach ${KOOPA_SSH} in ${SSH_CONNECT_TIMEOUT}s — set SKIP_SSH=1 to skip inside" +elif ! koopa_ssh_ok; then + set_group ssh + err "ssh" "cannot reach ${KOOPA_SSH} — from laptop use KOOPA_SSH=; on host set INSIDE_PODMAN=1" summary exit 1 +else + set_group ssh + ok "ssh ${KOOPA_SSH}" "remote host then podman (INSIDE_ACCESS=ssh · laptop/remote)" fi -ok "ssh ${KOOPA_SSH}" -# One short remote script (≤ SSH_CMD_TIMEOUT). Every slow step is local curl -m 3 or quick pgrep. -RAW=$( - koopa_ssh_bash "${SSH_CMD_TIMEOUT}" <<'REMOTE' || true +# Collect script (file → bash local or ssh bash -s) +_INSIDE_SCRIPT=$(mktemp) +trap 'rm -f "${_INSIDE_SCRIPT:-}"' RETURN +cat >"$_INSIDE_SCRIPT" <<'REMOTE' set +e emit() { printf 'E|%s|%s|%s|%s\n' "$1" "$2" "$3" "$(printf '%s' "${4:-}" | tr '\n\r' ' ' | head -c 200)"; } # quick curl @@ -376,23 +439,37 @@ else fi echo DONE REMOTE -) + +if [ "$_use_local_podman" = "1" ]; then + RAW=$(bash "$_INSIDE_SCRIPT" || true) +else + RAW=$(koopa_ssh_bash "${SSH_CMD_TIMEOUT}" <"$_INSIDE_SCRIPT" || true) +fi +rm -f "$_INSIDE_SCRIPT" if [ -z "$RAW" ] || ! echo "$RAW" | grep -q '^E|'; then - err "ssh" "remote timed out or empty (cap ${SSH_CMD_TIMEOUT}s)" + if [ "$_use_local_podman" = "1" ]; then + err "host" "collect empty (INSIDE_ACCESS=host-podman · podman exec failed)" + else + err "ssh" "collect empty (INSIDE_ACCESS=ssh · cap ${SSH_CMD_TIMEOUT}s · host=${KOOPA_SSH})" + fi summary exit 1 fi +# Fallback group for non-component rows: host (podman) vs ssh (remote) +_access_grp=ssh +[ "$_use_local_podman" = "1" ] && _access_grp=host + _last_inside_grp="" while IFS= read -r line; do case "$line" in E\|*) IFS='|' read -r _ comp level key detail <<<"$line" - # Group IDs by component so issues map cleanly: inside.bank-02, inside.exchange-04 + # Group IDs by component: inside.bank-02, inside.exchange-04; access = host|ssh case "$comp" in bank|exchange|merchant|caddy) _g="$comp" ;; - *) _g="ssh" ;; + *) _g="$_access_grp" ;; esac if [ "$_g" != "$_last_inside_grp" ]; then set_group "$_g" @@ -415,4 +492,55 @@ METRICS_DIR="${METRICS_DIR:-$(mktemp -d)}" export METRICS_DIR metrics_report_load "${METRICS_DIR}/load-inside.json" "inside" || true +# Disk free space: host + taler containers (WARN tight, ERROR full) +set_group disk +section "inside · disk free space" +_disk_ec=0 +if [ "$_use_local_podman" = "1" ]; then + mon_disk_check_host "host" || _disk_ec=1 + while read -r _cname; do + [ -n "$_cname" ] || continue + mon_disk_check_podman "$_cname" || _disk_ec=1 + done < <(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism|taler-|stage-lfp|lfp-' || true) +else + # remote host via SSH: df on host + each container + _disk_raw=$(koopa_ssh_bash "${SSH_CMD_TIMEOUT:-20}" <<'DISK' || true +set +e +echo "###HOST###" +df -Pk / /var /home /tmp 2>/dev/null || df -Pk +echo "###CTRS###" +for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do + echo "###CTR $c###" + podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null +done +DISK +) + _host_df=$(printf '%s\n' "$_disk_raw" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d') + mon_disk_check_remote_text "ssh:${KOOPA_SSH:-remote}" "$_host_df" || _disk_ec=1 + _ctr="" + _buf="" + while IFS= read -r _line || [ -n "$_line" ]; do + case "$_line" in + '###CTR '*) + if [ -n "$_ctr" ]; then + mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || _disk_ec=1 + fi + _ctr=${_line####CTR } + _ctr=${_ctr%###} + _buf="" + ;; + '###CTRS###'|'###HOST###') ;; + *) + if [ -n "$_ctr" ]; then + _buf="${_buf}${_line}"$'\n' + fi + ;; + esac + done <<<"$_disk_raw" + if [ -n "$_ctr" ]; then + mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || _disk_ec=1 + fi +fi +unset _disk_raw _host_df _ctr _buf _line _cname _disk_ec + summary diff --git a/scripts/taler-monitoring/check_server.sh b/scripts/taler-monitoring/check_server.sh index 8412a12..3a9618a 100755 --- a/scripts/taler-monitoring/check_server.sh +++ b/scripts/taler-monitoring/check_server.sh @@ -110,6 +110,15 @@ if systemctl is-active caddy >/dev/null 2>&1 || pgrep -x caddy >/dev/null 2>&1; else report WARN "caddy" "not detected as active" fi + +# disk free (host + containers) — lines for mon_disk_check_remote_text on laptop side +echo "###DISK_HOST###" +df -Pk / /var /home /tmp 2>/dev/null || df -Pk +echo "###DISK_CTRS###" +for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do + echo "###CTR $c###" + podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null +done REMOTE ) @@ -126,4 +135,24 @@ while IFS= read -r line; do esac done <<<"$REMOTE" +set_group disk +section "server · disk free space" +_host_df=$(printf '%s\n' "$REMOTE" | sed -n '/^###DISK_HOST###$/,/^###DISK_CTRS###$/p' | sed '1d;$d') +mon_disk_check_remote_text "ssh:${KOOPA_SSH}" "$_host_df" || true +_ctr=""; _buf="" +while IFS= read -r _line || [ -n "$_line" ]; do + case "$_line" in + '###CTR '*) + if [ -n "$_ctr" ]; then mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true; fi + _ctr=${_line####CTR }; _ctr=${_ctr%###}; _buf="" + ;; + '###DISK_CTRS###'|'###DISK_HOST###') ;; + *) + # only buffer after we entered CTR section + if [ -n "$_ctr" ]; then _buf="${_buf}${_line}"$'\n'; fi + ;; + esac +done <<<"$REMOTE" +[ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true + summary diff --git a/scripts/taler-monitoring/check_surface.sh b/scripts/taler-monitoring/check_surface.sh new file mode 100755 index 0000000..1452d3b --- /dev/null +++ b/scripts/taler-monitoring/check_surface.sh @@ -0,0 +1,692 @@ +#!/usr/bin/env bash +# check_surface.sh — REMOTE-ONLY public surface / ecosystem scan. +# +# NOT in default phases. Explicit: +# ./taler-monitoring.sh surface +# ./taler-monitoring.sh -d hacktivism.ch surface +# +# Without -d / with generic ecosystem: catalog of taler.net, gnunet.org, +# taler-systems.com, taler-ops, mattermost, … +# With -d DOMAIN: expand domains.conf stack hosts + catalog entries matching +# that domain + common subdomain guesses; port/protocol probes only remote. +# +# Rules: +# - no SSH into targets, no local podman on targets +# - ICMP/port alone never proves "down" — confirm via expected protocol +# - catalogued host unreachable via protocol → ERROR +# - version unknown after probes → WARN +# - CVE hit (OSV) for identified software+version → ERROR +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +set_area surface +section "surface · remote ecosystem / domain inventory (outside-in only)" + +CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}" +# Common ports when scanning a host discovered without explicit list +DEFAULT_SCAN_PORTS="${SURFACE_SCAN_PORTS:-22,80,443,993,8443,9418}" +PORT_TIMEOUT="${SURFACE_PORT_TIMEOUT:-2}" +HTTP_TIMEOUT="${SURFACE_HTTP_TIMEOUT:-12}" +# CVE via OSV (public, no key). Disable: SURFACE_CVE=0 +: "${SURFACE_CVE:=1}" +# Extra ports when -d domain mode +DOMAIN_EXTRA_PORTS="${SURFACE_DOMAIN_PORTS:-22,80,443,8443}" + +declare -A HOST_PORTS # host -> comma ports +declare -A HOST_PROTO # host -> expect proto +declare -A HOST_LABEL # host -> short label +declare -A HOST_EXPECT # host -> 1 if catalogued (must respond) +ORDERED_HOSTS=() + +add_host() { + local h="$1" ports="${2:-}" proto="${3:-https}" label="${4:-}" expect="${5:-1}" + h=$(printf '%s' "$h" | tr 'A-Z' 'a-z' | sed 's#^https\?://##;s#/.*##;s/\.$//') + [ -n "$h" ] || return 0 + if [ -z "${HOST_PORTS[$h]+x}" ]; then + ORDERED_HOSTS+=("$h") + HOST_PORTS[$h]="${ports:-443}" + HOST_PROTO[$h]="${proto:-https}" + HOST_LABEL[$h]="${label:-$h}" + HOST_EXPECT[$h]="$expect" + else + # merge ports + local p + for p in ${ports//,/ }; do + case ",${HOST_PORTS[$h]}," in + *",$p,"*) ;; + *) HOST_PORTS[$h]="${HOST_PORTS[$h]},$p" ;; + esac + done + [ "${HOST_EXPECT[$h]}" = "1" ] || HOST_EXPECT[$h]="$expect" + fi +} + +load_catalog() { + local line h ports proto label + [ -f "$CATALOG" ] || { + warn "catalog" "missing $CATALOG — using empty base list" + return 0 + } + while IFS= read -r line || [ -n "$line" ]; do + line=${line%%#*} + line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -z "$line" ] && continue + # host ports proto label + h=$(echo "$line" | awk '{print $1}') + ports=$(echo "$line" | awk '{print $2}') + proto=$(echo "$line" | awk '{print $3}') + label=$(echo "$line" | awk '{print $4}') + [ "$ports" = "-" ] && ports="" + add_host "$h" "$ports" "${proto:-https}" "${label:-}" 1 + done <"$CATALOG" + info "catalog" "loaded $CATALOG · ${#ORDERED_HOSTS[@]} hosts" +} + +# Clear inventory (used when switching to domain-only scope) +clear_hosts() { + ORDERED_HOSTS=() + unset HOST_PORTS HOST_PROTO HOST_LABEL HOST_EXPECT + declare -gA HOST_PORTS HOST_PROTO HOST_LABEL HOST_EXPECT +} + +# Expand for -d domain: ONLY that domain (remote). No whole-ecosystem catalog. +expand_domain_scope() { + local d="$1" h + d=${d#https://}; d=${d%/} + d=${d#http://} + info "scope" "domain mode −d $d (remote only · not full ecosystem catalog)" + + clear_hosts + + # stack endpoints from applied profile (always expected) + for h in \ + "${BANK_PUBLIC:-}" \ + "${EXCHANGE_PUBLIC:-}" \ + "${MERCHANT_PUBLIC:-}" \ + "${PAIVANA_PUBLIC:-}" + do + [ -n "$h" ] || continue + add_host "$h" "80,443,$DOMAIN_EXTRA_PORTS" https "stack" 1 + done + + # apex + common subdomains (expected if DNS exists — set expect after DNS in scan, + # but catalogued stack already expected; guesses start optional) + for sub in "" www bank exchange taler merchant backend shop shops stage \ + git docs www2 api static media landing mon401 + do + if [ -z "$sub" ]; then + h="$d" + else + h="${sub}.${d}" + fi + # stack hosts already added as expect=1; guesses optional until we promote + if [ -z "${HOST_PORTS[$h]+x}" ]; then + add_host "$h" "$DOMAIN_EXTRA_PORTS" https "guess" 0 + fi + done + + # also pick catalog lines that belong to this domain only + if [ -f "$CATALOG" ]; then + local line ports proto label + while IFS= read -r line || [ -n "$line" ]; do + line=${line%%#*} + line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -z "$line" ] && continue + h=$(echo "$line" | awk '{print $1}') + h=$(printf '%s' "$h" | tr 'A-Z' 'a-z' | sed 's#^https\?://##;s#/.*##') + if [ "$h" = "$d" ] || [[ "$h" == *".$d" ]]; then + ports=$(echo "$line" | awk '{print $2}') + proto=$(echo "$line" | awk '{print $3}') + label=$(echo "$line" | awk '{print $4}') + [ "$ports" = "-" ] && ports="" + add_host "$h" "$ports" "${proto:-https}" "${label:-dom}" 1 + fi + done <"$CATALOG" + fi +} + +# Ecosystem mode: full catalog; all entries expected +expand_ecosystem_scope() { + info "scope" "ecosystem mode (taler.net / gnunet.org / taler-systems.com / taler-ops / mattermost, … · remote only)" + local h + for h in "${ORDERED_HOSTS[@]}"; do + HOST_EXPECT[$h]=1 + done +} + +# --- remote probes (no SSH) --- +dns_ok() { + local h="$1" + getent ahosts "$h" >/dev/null 2>&1 || getent hosts "$h" >/dev/null 2>&1 +} + +# TCP connect only (not proof of service) +tcp_open() { + local h="$1" port="$2" + if command -v timeout >/dev/null 2>&1; then + timeout "$PORT_TIMEOUT" bash -c "echo >/dev/tcp/${h}/${port}" 2>/dev/null + else + bash -c "echo >/dev/tcp/${h}/${port}" 2>/dev/null + fi +} + +# ICMP optional — never sole grounds for ERROR +ping_host() { + local h="$1" + ping -c 1 -W 2 "$h" >/dev/null 2>&1 || ping -c 1 -w 2 "$h" >/dev/null 2>&1 +} + +# HTTPS probe: code, server header, version hints, cert subject/dates +probe_https() { + local h="$1" port="${2:-443}" + local url hdr body code server via cert_end subj san + url="https://${h}/" + [ "$port" != "443" ] && url="https://${h}:${port}/" + body=$(mktemp) + hdr=$(mktemp) + code=$(curl -skS -m "$HTTP_TIMEOUT" -D "$hdr" -o "$body" -w '%{http_code}' \ + --connect-timeout "$PORT_TIMEOUT" "$url" 2>/dev/null || echo 000) + server=$(awk 'BEGIN{IGNORECASE=1} /^server:/{sub(/\r$/,""); sub(/^server:[[:space:]]*/,""); print; exit}' "$hdr" 2>/dev/null || true) + via=$(awk 'BEGIN{IGNORECASE=1} /^x-powered-by:/{sub(/\r$/,""); sub(/^[^:]+:[[:space:]]*/,""); print; exit}' "$hdr" 2>/dev/null || true) + # cert + cert_end=$(echo | openssl s_client -servername "$h" -connect "${h}:${port}" 2>/dev/null \ + | openssl x509 -noout -enddate 2>/dev/null | sed 's/notAfter=//') + subj=$(echo | openssl s_client -servername "$h" -connect "${h}:${port}" 2>/dev/null \ + | openssl x509 -noout -subject 2>/dev/null | head -1) + # taler version hints in body + local taler_hint + taler_hint=$(grep -oE 'taler[^"[:space:]]{0,40}|GNU Taler|libeufin|gnunet' "$body" 2>/dev/null | head -3 | tr '\n' ' ' || true) + # config JSON version if /config works + local cfg_ver="" + local ccode + ccode=$(curl -skS -m "$HTTP_TIMEOUT" -o "$body" -w '%{http_code}' \ + "https://${h}/config" 2>/dev/null || echo 000) + if [ "$ccode" = "200" ]; then + cfg_ver=$(python3 -c 'import json,sys +try: + d=json.load(open(sys.argv[1])) + print(d.get("version") or d.get("name") or d.get("currency") or "config-json") +except Exception: + print("")' "$body" 2>/dev/null || true) + fi + rm -f "$hdr" "$body" + printf 'code=%s server=%s powered=%s cert_end=%s cfg=%s hint=%s' \ + "$code" "${server:-}" "${via:-}" "${cert_end:-}" "${cfg_ver:-}" "${taler_hint:-}" + # return 0 if HTTP answered (any 2xx/3xx/4xx — service is up) + case "$code" in + 2??|3??|4??) return 0 ;; + *) return 1 ;; + esac +} + +probe_http() { + local h="$1" port="${2:-80}" + local url code + url="http://${h}/" + [ "$port" != "80" ] && url="http://${h}:${port}/" + code=$(curl -sS -m "$HTTP_TIMEOUT" -o /dev/null -w '%{http_code}' \ + --connect-timeout "$PORT_TIMEOUT" "$url" 2>/dev/null || echo 000) + printf 'code=%s' "$code" + case "$code" in 2??|3??|4??) return 0 ;; *) return 1 ;; esac +} + +probe_ssh_banner() { + local h="$1" port="${2:-22}" + local ban + ban=$(timeout "$PORT_TIMEOUT" bash -c "exec 3<>/dev/tcp/${h}/${port}; dd bs=256 count=1 <&3 2>/dev/null" 2>/dev/null \ + | tr -d '\r' | head -1 || true) + printf 'banner=%s' "${ban:-}" + [ -n "$ban" ] +} + +# Map Server header → package name guess for OSV +guess_package() { + local server="$1" + local s + s=$(printf '%s' "$server" | tr 'A-Z' 'a-z') + case "$s" in + *nginx*) echo "nginx" ;; + *apache*|*httpd*) echo "apache" ;; + *caddy*) echo "caddy" ;; + *openbsd\ httpd*) echo "openbsd-httpd" ;; + *) echo "" ;; + esac +} + +extract_version() { + local server="$1" + # nginx/1.22.1 → 1.22.1 + printf '%s' "$server" | sed -n 's/.*\/\([0-9][0-9.]*\).*/\1/p' | head -1 +} + +# OSV / CVE check for software version taken from Server headers. +# +# Important: headers only expose upstream versions (nginx/1.26.3), never the +# Debian package revision (1.26.3-3+deb13u7). Querying OSV ecosystem=Debian +# with the bare version yields massive false positives (ancient DEBIAN-CVE-* +# with introduced:0 and no fixed event, plus every package revision that +# merely *starts with* 1.26.3). +# +# Policy: +# - bare upstream version → upstream SEMVER signal only (WARN by default) +# - full Debian package version (contains '-') → Debian OSV, filtered +# - SURFACE_CVE=0 disables; SURFACE_CVE_LEVEL=error|warn (default warn for bare) +check_cves() { + local pkg="$1" ver="$2" host="$3" + [ "$SURFACE_CVE" = "1" ] || return 0 + [ -n "$pkg" ] && [ -n "$ver" ] || return 0 + local level out rc + # bare header versions are approximate → WARN unless overridden + if [[ "$ver" == *-* ]] || [[ "$ver" == *+* ]]; then + level="${SURFACE_CVE_LEVEL:-error}" + else + level="${SURFACE_CVE_LEVEL:-warn}" + fi + out=$( + SURFACE_CVE_PKG="$pkg" SURFACE_CVE_VER="$ver" SURFACE_CVE_HOST="$host" python3 - <<'PY' 2>/dev/null || true +import json, os, re, urllib.request + +pkg = os.environ.get("SURFACE_CVE_PKG", "") +ver = os.environ.get("SURFACE_CVE_VER", "") +host = os.environ.get("SURFACE_CVE_HOST", "") + +def ver_tuple(s: str): + nums = [int(x) for x in re.findall(r"\d+", s or "")[:5]] + return tuple(nums) if nums else () + +def vt_cmp(a, b): + n = max(len(a), len(b)) + a = a + (0,) * (n - len(a)) + b = b + (0,) * (n - len(b)) + return (a > b) - (a < b) + +def in_semver_events(ver, events): + """True if ver is in [introduced, fixed) for SEMVER-like event list.""" + vt = ver_tuple(ver) + if not vt: + return False + # process sequential introduced/fixed pairs + intro = None + for e in events or []: + if "introduced" in e: + intro = e.get("introduced") + elif "fixed" in e or "last_affected" in e: + fixed = e.get("fixed") + last = e.get("last_affected") + lo = ver_tuple("0" if intro in (None, "0") else str(intro)) + if vt_cmp(vt, lo) < 0: + intro = None + continue + if fixed is not None: + if vt_cmp(vt, ver_tuple(str(fixed))) < 0: + return True + elif last is not None: + if vt_cmp(vt, ver_tuple(str(last))) <= 0: + return True + intro = None + # open-ended introduced without fixed → ignore (never clears; Debian noise) + return False + +def osv_post(body): + req = urllib.request.Request( + "https://api.osv.dev/v1/query", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=20) as r: + return json.load(r) + except Exception: + return {} + +def osv_get(vid): + try: + with urllib.request.urlopen(f"https://api.osv.dev/v1/vulns/{vid}", timeout=15) as r: + return json.load(r) + except Exception: + return {} + +bare = not (("-" in ver) or ("+" in ver)) +hits = [] + +if bare: + # Upstream signal via CVE records that carry extracted_events for this package + # (Server header has no Debian revision — do NOT use ecosystem=Debian). + seed = { + "nginx": [ + "CVE-2025-23419", + "CVE-2025-53859", + "CVE-2024-7347", + "CVE-2024-34161", + "CVE-2024-32760", + "CVE-2024-31079", + "CVE-2024-24989", + "CVE-2024-24990", + ], + "apache": [ + "CVE-2024-38474", + "CVE-2024-38476", + "CVE-2024-38477", + "CVE-2023-31122", + "CVE-2023-43622", + ], + "caddy": [ + "CVE-2022-29718", + "CVE-2023-50463", + ], + }.get(pkg, []) + for vid in seed: + doc = osv_get(vid) + if not doc: + continue + ok_hit = False + for a in doc.get("affected") or []: + # prefer extracted_events (human SEMVER) on GIT/nginx ranges + for rg in a.get("ranges") or []: + db = rg.get("database_specific") or {} + extracted = db.get("extracted_events") or [] + if extracted and in_semver_events(ver, extracted): + # package name filter when present + cpes = db.get("cpe") or [] + if isinstance(cpes, str): + cpes = [cpes] + blob = json.dumps(a).lower() + json.dumps(cpes).lower() + if pkg == "nginx" and "nginx" not in blob and "f5" not in blob: + continue + ok_hit = True + break + if rg.get("type") == "SEMVER" and in_semver_events(ver, rg.get("events") or []): + ok_hit = True + break + if ok_hit: + break + if ok_hit: + hits.append(vid) +else: + # Full package version — Debian ecosystem is meaningful + data = osv_post({"package": {"name": pkg, "ecosystem": "Debian"}, "version": ver}) + for v in data.get("vulns") or []: + vid = v.get("id") or "?" + actionable = False + for a in v.get("affected") or []: + for rg in a.get("ranges") or []: + events = rg.get("events") or [] + has_end = any(("fixed" in e) or ("last_affected" in e) for e in events) + if not has_end: + continue # open-ended Debian noise + if rg.get("type") in ("ECOSYSTEM", "SEMVER"): + # version string is full deb version; trust OSV query match + # but only if a fixed event exists (actionable) + actionable = True + break + if actionable: + break + # exact version listed + versions = a.get("versions") or [] + if ver in versions: + actionable = True + break + if actionable: + hits.append(vid) + +# de-dup preserve order +seen = set() +uniq = [] +for h in hits: + if h not in seen: + seen.add(h) + uniq.append(h) + +if uniq: + print(f"HIT {len(uniq)} " + ",".join(uniq[:12])) +else: + print("CLEAN") +PY + ) + rc=0 + case "$out" in + HIT\ *) + local n ids + n=$(printf '%s' "$out" | awk '{print $2}') + ids=$(printf '%s' "$out" | cut -d' ' -f3-) + if [ "$level" = "error" ]; then + err "cve" "$host $pkg $ver — $n actionable vuln(s)" "$ids" + rc=1 + else + warn "cve" "$host $pkg $ver — $n actionable vuln(s) (header version · not Debian pkg)" "$ids" + rc=0 + fi + ;; + CLEAN) + if [[ "$ver" == *-* ]] || [[ "$ver" == *+* ]]; then + info "cve" "$host $pkg $ver — OSV clean (Debian package version)" + else + info "cve" "$host $pkg $ver — no actionable upstream CVE for bare Server-header version" + fi + ;; + *) + info "cve" "$host $pkg $ver — CVE probe skipped/unavailable" + ;; + esac + return "$rc" +} + +cert_expiry_check() { + local h="$1" end="$2" + [ -n "$end" ] || return 0 + local end_epoch now left days + end_epoch=$(date -d "$end" +%s 2>/dev/null || date -j -f "%b %e %T %Y %Z" "$end" +%s 2>/dev/null || echo 0) + now=$(date +%s) + [ "$end_epoch" -gt 0 ] || return 0 + left=$((end_epoch - now)) + days=$((left / 86400)) + if [ "$left" -le 0 ]; then + err "tls" "$h certificate EXPIRED" "notAfter=$end" + return 1 + fi + if [ "$days" -le 14 ]; then + warn "tls" "$h certificate expires in ${days}d" "notAfter=$end" + else + info "tls" "$h cert ok · ${days}d left · notAfter=$end" + fi + return 0 +} + +scan_host() { + local h="$1" + local ports proto label expect + local p open_ports=() any_proto_ok=0 ping_ok=0 dns=0 + local probe_detail server_hdr pkg ver + + ports=${HOST_PORTS[$h]:-443} + proto=${HOST_PROTO[$h]:-https} + label=${HOST_LABEL[$h]:-$h} + expect=${HOST_EXPECT[$h]:-0} + + set_group "$label" + + if dns_ok "$h"; then + dns=1 + ok "dns" "$h resolves" + else + if [ "$expect" = "1" ]; then + err "dns" "$h does not resolve (catalogued)" + else + info "dns" "$h no resolve (optional guess) — skip" + fi + return 0 + fi + + if ping_host "$h"; then + ping_ok=1 + info "ping" "$h ICMP ok" + else + info "ping" "$h ICMP no reply (not decisive)" + fi + + # port scan (unique ports) + local _seen_ports=" " + for p in ${ports//,/ }; do + [ -n "$p" ] || continue + case "$_seen_ports" in *" $p "*) continue ;; esac + _seen_ports="$_seen_ports$p " + if tcp_open "$h" "$p"; then + open_ports+=("$p") + info "port" "$h:$p open (TCP)" + else + info "port" "$h:$p closed/filtered (TCP)" + fi + done + + # Protocol verification on open ports (and always try 443/80 for https/http expect) + local try_ports=("${open_ports[@]}") + if [ ${#try_ports[@]} -eq 0 ]; then + # still try expected protocol ports even if scan said closed (scan false negatives) + case "$proto" in + https) try_ports=(443) ;; + http) try_ports=(80) ;; + ssh) try_ports=(22) ;; + *) try_ports=(443 80) ;; + esac + fi + + server_hdr="" + ver="" + for p in "${try_ports[@]}"; do + case "$p" in + 443|8443) + if probe_detail=$(probe_https "$h" "$p"); then + any_proto_ok=1 + ok "https" "$h:$p up · $probe_detail" + server_hdr=$(printf '%s' "$probe_detail" | sed -n 's/.*server=\([^ ]*\).*/\1/p') + # cert + local cend + cend=$(printf '%s' "$probe_detail" | sed -n 's/.*cert_end=\([^ ]*\).*/\1/p') + cert_expiry_check "$h" "$cend" || true + local cfg + cfg=$(printf '%s' "$probe_detail" | sed -n 's/.*cfg=\([^ ]*\).*/\1/p') + if [ -n "$cfg" ]; then + info "version" "$h config/api hint: $cfg" + ver="$cfg" + fi + else + info "https" "$h:$p no HTTP response · ${probe_detail:-}" + fi + ;; + 80|8080) + if probe_detail=$(probe_http "$h" "$p"); then + any_proto_ok=1 + ok "http" "$h:$p up · $probe_detail" + else + info "http" "$h:$p no HTTP response" + fi + ;; + 22) + if probe_detail=$(probe_ssh_banner "$h" "$p"); then + any_proto_ok=1 + ok "ssh" "$h:$p banner · $probe_detail" + ver=$(printf '%s' "$probe_detail" | sed 's/banner=//') + else + info "ssh" "$h:$p no SSH banner" + fi + ;; + *) + if tcp_open "$h" "$p"; then + info "tcp" "$h:$p open · protocol unknown" + fi + ;; + esac + done + + # Version summary + if [ -n "$server_hdr" ]; then + info "server-header" "$h · $server_hdr" + pkg=$(guess_package "$server_hdr") + ver_soft=$(extract_version "$server_hdr") + if [ -n "$pkg" ] && [ -n "$ver_soft" ]; then + info "version" "$h software $pkg $ver_soft (from Server header)" + check_cves "$pkg" "$ver_soft" "$h" || true + elif [ -n "$server_hdr" ]; then + warn "version" "$h Server header present but version unknown · $server_hdr" + fi + elif [ -n "$ver" ]; then + info "version" "$h · $ver" + else + if [ "$any_proto_ok" = "1" ]; then + warn "version" "$h reachable but software version unknown" + fi + fi + + # Expected service must answer protocol (not just ping/port) + # Note: ${array[*]:-x} is NOT valid default syntax (bash treats : as slice). + local ports_txt="${open_ports[*]}" + ports_txt=${ports_txt:-none} + if [ "$expect" = "1" ]; then + if [ "$any_proto_ok" = "1" ]; then + ok "reachability" "$h catalogued service OK (protocol confirmed)" + else + err "reachability" "$h catalogued but not reachable via ${proto}/protocol" \ + "dns=$dns ping=$ping_ok open_ports=${ports_txt} (ICMP/TCP alone not enough; protocol failed)" + fi + else + if [ "$any_proto_ok" = "1" ]; then + info "reachability" "$h optional host responds" + else + info "reachability" "$h optional · no protocol response (ok)" + fi + fi +} + +# --- main --- +# Scope (remote only): +# ./taler-monitoring.sh surface → full ecosystem catalog +# ./taler-monitoring.sh -d hacktivism.ch surface → only that domain +if [ "${SURFACE_SCOPE:-}" = "ecosystem" ]; then + load_catalog + expand_ecosystem_scope +elif [ "${SURFACE_SCOPE:-}" = "domain" ] || [ "${TALER_DOMAIN_FROM_CLI:-0}" = "1" ]; then + # domain mode: do not load whole ecosystem first + CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}" + expand_domain_scope "${TALER_DOMAIN:-hacktivism.ch}" +elif [ "${SURFACE_SCOPE:-auto}" = "auto" ]; then + # no -d → ecosystem; with -d → domain (TALER_DOMAIN_FROM_CLI) + if [ "${TALER_DOMAIN_FROM_CLI:-0}" = "1" ]; then + CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}" + expand_domain_scope "$TALER_DOMAIN" + else + load_catalog + expand_ecosystem_scope + fi +else + load_catalog + expand_ecosystem_scope +fi + +info "inventory" "${#ORDERED_HOSTS[@]} hosts to probe (remote only · no SSH · no local podman on targets)" +info "flags" "SURFACE_CVE=${SURFACE_CVE:-1} PORT_TIMEOUT=${PORT_TIMEOUT:-2} HTTP_TIMEOUT=${HTTP_TIMEOUT:-12}" + +for h in "${ORDERED_HOSTS[@]}"; do + [ -n "$h" ] || continue + scan_host "$h" || true +done + +# mon_disk on the *runner* only (optional) — not remote servers' disks +if [ "${SURFACE_CHECK_RUNNER_DISK:-0}" = "1" ]; then + set_group disk + if declare -F mon_disk_check_host >/dev/null 2>&1; then + mon_disk_check_host "runner" || true + else + warn "disk" "mon_disk_check_host not available" + fi +fi + +# summary returns non-zero when FAIL_N>0; do not trip set -e mid-script +summary || true +if [ "${FAIL_N:-0}" -eq 0 ]; then + exit 0 +fi +exit 1 diff --git a/scripts/taler-monitoring/check_versions.sh b/scripts/taler-monitoring/check_versions.sh index 3580732..8fc7c66 100755 --- a/scripts/taler-monitoring/check_versions.sh +++ b/scripts/taler-monitoring/check_versions.sh @@ -166,16 +166,16 @@ fi # --------------------------------------------------------------------------- # 2) INSIDE — containers can reach deb.taler.net (pasta / install path) -# koopa: KOOPA_SSH · stage-lfp: INSIDE_SSH (stagepaysan, low-priv) +# host-podman: INSIDE_PODMAN=1 on GOA host · ssh: KOOPA_SSH / INSIDE_SSH from laptop # --------------------------------------------------------------------------- -set_group inside -section "versions · inside · containers → deb.taler.net" - +_VERS_LOCAL=0 _VERS_SSH_HOST="" _VERS_BANK="taler-hacktivism-bank" _VERS_EX="taler-hacktivism-exchange-ansible" _VERS_MER="taler-hacktivism" -if [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \ +if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then + _VERS_LOCAL=1 +elif [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \ || [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \ || [ -n "${INSIDE_SSH:-}" ]; then _VERS_SSH_HOST="${INSIDE_SSH:-francpaysan-stage-user}" @@ -183,24 +183,44 @@ if [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \ _VERS_EX="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" _VERS_MER="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" elif [ "${LOCAL_STACK:-0}" = "1" ] && [ "${SKIP_SSH:-0}" != "1" ]; then + # laptop → koopa via SSH (not host-podman) _VERS_SSH_HOST="${KOOPA_SSH}" fi -if [ -z "$_VERS_SSH_HOST" ]; then - info "ssh" "no container SSH for this domain — outside-only package index" +if [ "$_VERS_LOCAL" = "1" ]; then + VERS_ACCESS=host-podman + set_group host +else + VERS_ACCESS=ssh + set_group ssh +fi + +section "versions · inside · containers → deb.taler.net (access=${VERS_ACCESS})" +info "flags" "VERS_ACCESS=${VERS_ACCESS} INSIDE_PODMAN=${INSIDE_PODMAN:-0} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-0} SKIP_SSH=${SKIP_SSH:-0} KOOPA_SSH=${KOOPA_SSH:-} INSIDE_SSH=${INSIDE_SSH:-}" + +if [ "$_VERS_LOCAL" != "1" ] && [ -z "$_VERS_SSH_HOST" ]; then + info "access" "no host-podman and no SSH target — outside-only package index" info "outside-only" "deb.taler.net public checks completed above" summary exit 0 fi -if [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then - if ! koopa_ssh_ok; then - err "ssh" "cannot reach ${KOOPA_SSH} — set SKIP_SSH=1 to skip container checks" +if [ "$_VERS_LOCAL" = "1" ]; then + if ! command -v podman >/dev/null 2>&1; then + err "host" "INSIDE_PODMAN/host-podman but podman missing" info "outside-only" "deb.taler.net public checks completed above" summary exit 1 fi - ok "ssh ${KOOPA_SSH}" + ok "host→container" "podman exec on this host (VERS_ACCESS=host-podman · no SSH)" +elif [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then + if ! koopa_ssh_ok; then + err "ssh" "cannot reach ${KOOPA_SSH} — from laptop set KOOPA_SSH=; on host set INSIDE_PODMAN=1" + info "outside-only" "deb.taler.net public checks completed above" + summary + exit 1 + fi + ok "ssh ${KOOPA_SSH}" "remote then podman (VERS_ACCESS=ssh)" else if ! mon_ssh_ok "$_VERS_SSH_HOST"; then err "ssh" "cannot reach ${_VERS_SSH_HOST} (stagepaysan) — skip container package compare" @@ -208,7 +228,7 @@ else summary exit 1 fi - ok "ssh ${_VERS_SSH_HOST}" "stagepaysan container package compare" + ok "ssh ${_VERS_SSH_HOST}" "stagepaysan (VERS_ACCESS=ssh)" fi INRELEASE_URL="${APT_BASE}/dists/${SUITE}/InRelease" @@ -257,7 +277,9 @@ done REMOTE } >"$tmp/remote-versions.sh" -if [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then +if [ "$_VERS_LOCAL" = "1" ]; then + REMOTE_OUT=$(bash "$tmp/remote-versions.sh" 2>/dev/null || true) +elif [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then REMOTE_OUT=$(koopa_ssh_bash 60 <"$tmp/remote-versions.sh" || true) else REMOTE_OUT=$(mon_ssh_bash "$_VERS_SSH_HOST" 60 <"$tmp/remote-versions.sh" || true) @@ -501,4 +523,41 @@ done info "suite" "deb.taler.net ${SUITE} (testing_index=${HAVE_TESTING})" info "tally" "match=${ok_n} ahead=${ahead_n} behind=${behind_n}" +# Disk free on the machine(s) we just probed (host-podman or SSH target) +set_group disk +section "versions · disk free space" +if [ "${_VERS_LOCAL:-0}" = "1" ]; then + mon_disk_check_host "host" || true + while read -r _vc; do + [ -n "$_vc" ] || continue + mon_disk_check_podman "$_vc" || true + done < <(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism|taler-|stage-lfp|lfp-' || true) +elif [ -n "${_VERS_SSH_HOST:-}" ]; then + _vdisk=$(mon_ssh_bash "$_VERS_SSH_HOST" 20 <<'DISK' || true +set +e +echo "###HOST###" +df -Pk / /var /home /tmp 2>/dev/null || df -Pk +echo "###CTRS###" +for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do + echo "###CTR $c###" + podman exec "$c" df -Pk / /var /tmp 2>/dev/null || true +done +DISK +) + mon_disk_check_remote_text "ssh:${_VERS_SSH_HOST}" \ + "$(printf '%s\n' "$_vdisk" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d')" || true + _ctr=""; _buf="" + while IFS= read -r _line || [ -n "$_line" ]; do + case "$_line" in + '###CTR '*) + [ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true + _ctr=${_line####CTR }; _ctr=${_ctr%###}; _buf="" + ;; + '###CTRS###'|'###HOST###') ;; + *) [ -n "$_ctr" ] && _buf="${_buf}${_line}"$'\n' ;; + esac + done <<<"$_vdisk" + [ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true +fi + summary diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 841d6e8..8455c6b 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -23,6 +23,9 @@ MERCHANT_INSTANCE=$(printf '%s' "${MERCHANT_INSTANCE}" | tr '[:upper:]' '[:lower : "${TIMEOUT:=12}" : "${E2E_TIMEOUT:=55}" # whole e2e budget; skip rest when exceeded (e2e raises as needed) : "${E2E_PAY_SECS:=22}" # dedicated seconds for pay handle-uri (avoid Alarm clock) +# Whole-run wall clock for taler-monitoring.sh (seconds). 0 = unlimited. +# Host-agent default phases (urls/inside/versions) should finish under this. +: "${RUN_TIMEOUT:=600}" # Local GOA: public paivana paywall (https://paivana.hacktivism.ch · template GOA:4200) : "${PAIVANA_PUBLIC:=https://paivana.hacktivism.ch}" : "${E2E_PAIVANA:=1}" # 0 = skip paivana section in e2e @@ -855,6 +858,113 @@ summary() { [ "$FAIL_N" -eq 0 ] } +# --------------------------------------------------------------------------- +# Disk space on hosts / containers under test +# WARN when tight, ERROR when full / critically low +# DISK_WARN_USED_PCT=85 # free below 15% → WARN +# DISK_ERR_USED_PCT=95 # free below 5% → ERROR +# DISK_ERR_FREE_BYTES=0 # free == 0 always ERROR +# --------------------------------------------------------------------------- +: "${DISK_WARN_USED_PCT:=85}" +: "${DISK_ERR_USED_PCT:=95}" + +# Parse one POSIX `df -P` body line (no header): fs size used avail capacity mount +# size/used/avail in 1K-blocks when using df -Pk +_mon_disk_eval_line() { + local where="$1" fs="$2" size="$3" used="$4" avail="$5" cap="$6" mnt="$7" + local pct label detail + pct=${cap%%%} + # non-numeric capacity → skip + case "$pct" in + ''|*[!0-9]*) return 0 ;; + esac + label="disk ${where} ${mnt}" + detail="fs=$fs size_1k=$size used_1k=$used avail_1k=$avail use=${pct}%" + if [ "$avail" = "0" ] || [ "$pct" -ge 100 ]; then + err "disk" "${where} ${mnt} FULL / overflow" "$detail" + return 1 + fi + if [ "$pct" -ge "${DISK_ERR_USED_PCT}" ]; then + err "disk" "${where} ${mnt} critically low free space (≥${DISK_ERR_USED_PCT}% used)" "$detail" + return 1 + fi + if [ "$pct" -ge "${DISK_WARN_USED_PCT}" ]; then + warn "disk" "${where} ${mnt} free space tight (≥${DISK_WARN_USED_PCT}% used)" "$detail" + return 0 + fi + ok "$label" "$detail" + return 0 +} + +# Report all filesystems from `df -P` / `df -Pk` output (with header). +# $1 = where label (host, container:name, ssh:host) +# $2 = full df text +mon_disk_report_df() { + local where="$1" text="$2" line fs size used avail cap mnt rest ec=0 + [ -n "$text" ] || { + warn "disk" "${where}: no df output" + return 0 + } + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + case "$line" in + Filesystem*|Filesystem*) continue ;; + esac + # df -P: Filesystem 1024-blocks Used Available Capacity Mounted on + # shellcheck disable=SC2086 + set -- $line + [ "$#" -ge 6 ] || continue + fs=$1 + size=$2 + used=$3 + avail=$4 + cap=$5 + shift 5 + mnt=$* + # skip special/pseudo if tiny and not root-ish + case "$fs" in + tmpfs|devtmpfs|overlay|shm|nsfs|proc|sysfs|cgroup*) + # still check root-like mounts that fill (overlay / in containers) + case "$mnt" in + /|/var|/var/*|/home|/home/*|/data|/mnt/*) ;; + *) continue ;; + esac + ;; + esac + if ! _mon_disk_eval_line "$where" "$fs" "$size" "$used" "$avail" "$cap" "$mnt"; then + ec=1 + fi + done <<<"$text" + return "$ec" +} + +# Local host: important mounts +mon_disk_check_host() { + local where text + where="${1:-host}" + text="" + text=$(df -Pk / /var /home /tmp 2>/dev/null | awk 'NR==1 || !seen[$1,$6]++' || true) + # fallback whole table + if [ -z "$text" ]; then + text=$(df -Pk 2>/dev/null || true) + fi + mon_disk_report_df "$where" "$text" +} + +# Run df inside podman container +mon_disk_check_podman() { + local cname="$1" text + local bin="${2:-podman}" + text=$("$bin" exec "$cname" df -Pk / /var /tmp 2>/dev/null || "$bin" exec "$cname" df -Pk 2>/dev/null || true) + mon_disk_report_df "ctr:${cname}" "$text" +} + +# df text already collected remotely +mon_disk_check_remote_text() { + local where="$1" text="$2" + mon_disk_report_df "$where" "$text" +} + http_code() { local url="$1"; shift curl -skS --max-redirs 0 -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000 diff --git a/scripts/taler-monitoring/surface-catalog.conf b/scripts/taler-monitoring/surface-catalog.conf new file mode 100644 index 0000000..16d5755 --- /dev/null +++ b/scripts/taler-monitoring/surface-catalog.conf @@ -0,0 +1,52 @@ +# surface-catalog.conf — known public Taler / GNUnet / Taler Systems hosts +# Format (whitespace-separated; # comments): +# host ports expect_proto label +# expect_proto: https | http | ssh | tcp | any +# ports: comma-separated (empty = defaults for proto) +# +# This list is expanded at runtime (DNS discovery, cert SANs, -d domain profile). +# Phase `surface` is NOT in default phases — run explicitly: +# ./taler-monitoring.sh surface +# ./taler-monitoring.sh -d hacktivism.ch surface + +# --- taler.net ecosystem --- +www.taler.net 443 https taler-www +docs.taler.net 443 https taler-docs +git.taler.net 443,22 https taler-git +bugs.taler.net 443 https taler-bugs +lists.taler.net 443 https taler-lists +tutorials.taler.net 443 https taler-tutorials +bank.demo.taler.net 443 https demo-bank +exchange.demo.taler.net 443 https demo-exchange +backend.demo.taler.net 443 https demo-merchant +shop.demo.taler.net 443 https demo-shop +bank.test.taler.net 443 https test-bank +exchange.test.taler.net 443 https test-exchange +backend.test.taler.net 443 https test-merchant + +# --- taler-ops / TOPS --- +www.taler-ops.ch 443 https tops-www +taler-ops.ch 443 https tops-apex +bank.taler-ops.ch 443 https tops-bank +exchange.taler-ops.ch 443 https tops-exchange +my.taler-ops.ch 443 https tops-merchant +stage.taler-ops.ch 443 https tops-stage-www +stage.my.taler-ops.ch 443 https tops-stage-merchant +exchange.stage.taler-ops.ch 443 https tops-stage-exchange +nexus.stage.taler-ops.ch 443 https tops-stage-nexus +shops.taler-ops.ch 443 https tops-shops +mattermost.taler.net 443 https mattermost + +# --- gnunet.org --- +www.gnunet.org 443,80 https gnunet-www +gnunet.org 443,80 https gnunet-apex +git.gnunet.org 443,22 https gnunet-git +bugs.gnunet.org 443 https gnunet-bugs + +# --- taler-systems.com --- +www.taler-systems.com 443 https tsa-www +taler-systems.com 443 https tsa-apex + +# --- common infra hostnames (remote only; may 404/redirect) --- +deb.taler.net 443 https deb-taler +ftp.gnu.org 443 https gnu-ftp diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 8b20e72..557c7a0 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -12,6 +12,12 @@ set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) +# Line-buffered stdout/stderr so redirected logs (tee/host-agent) flush each line. +if [ "${_TALER_MON_STDBUF:-0}" != "1" ] && command -v stdbuf >/dev/null 2>&1; then + export _TALER_MON_STDBUF=1 + exec stdbuf -oL -eL bash "$0" "$@" +fi + usage() { cat <<'EOF' taler-monitoring — bank / exchange / merchant checks @@ -29,8 +35,14 @@ Phases: e2e withdraw + pay (small amounts; remote aborts on login/KYC) ladder withdraw/pay amount ladder (GOA ceiling or stage TESTPAYSAN max_wire) auth401 merchant Basic-auth / case matrix (HTTP 401 paths; may create throwaway instance) + aptdeploy koopa podman apt-src smoke: taler-merchant in + koopa-taler-deploy-test-apt-src-trixie{,-testing} + surface REMOTE-ONLY public inventory (NOT in default/all/full): + ecosystem hosts (taler.net, gnunet.org, taler-systems.com, mattermost, …) + or -d DOMAIN → that domain’s surface; port/protocol/TLS/CVE (OSV) all urls + inside + versions + sanity + e2e (SSH phases only on koopa) full all + server + ladder + auth401 (maximum; long-running, needs secrets) + NOTE: surface is never included in all/full — pass it explicitly Options: -d, --domain DOMAIN load profile from domains.conf (bank/exchange/merchant) @@ -61,6 +73,10 @@ Env (same meaning): TALER_DOMAINS_CONF SKIP_SSH=1 NO_COLOR=1 / CLICOLOR=0 disable green/yellow/red tags (default: coloured) SKIP_SSH=1 NO_COLOR=1 + RUN_TIMEOUT=600 whole-run wall clock seconds (default 600; 0=unlimited) + long phases (ladder/full/e2e) need a higher value or 0 + DISK_WARN_USED_PCT=85 disk free: WARN when used ≥ this % + DISK_ERR_USED_PCT=95 disk free: ERROR when used ≥ this % (or avail=0 / 100%) PERF_WARN_MS PERF_FAIL_MS (urls latency; default 8000 / 20000) QR_CHECK=0 skip QR form + qrencode/zbarimg (urls phase) QR_ECC=M qrencode ECC level (default M) @@ -73,11 +89,23 @@ Env (same meaning): AUTH401_* see check_auth401.sh / secrets.env.example AUTH401_CONTINUE=1 / CONTINUE_ON_ERROR=1 auth401: run all groups, collect every ERROR (no mid-run halt) + APT_DEPLOY_SKIP=1 skip aptdeploy phase + APT_DEPLOY_CONTAINERS="name:suite …" default trixie + trixie-testing deploy-test pods + SURFACE_CVE=0 disable CVE queries in surface phase + SURFACE_CVE_LEVEL=warn|error bare Server-header versions default warn + (Debian package versions default error) + SURFACE_CATALOG=path override surface-catalog.conf Full load (GOA / hacktivism): ./taler-monitoring.sh -d hacktivism.ch full # or explicit: ./taler-monitoring.sh -d hacktivism.ch urls inside versions sanity server e2e ladder auth401 + # hacktivism host-agent also runs aptdeploy (apt-src merchant containers on koopa) + +Remote surface / ecosystem (explicit only — never default): + ./taler-monitoring.sh surface + ./taler-monitoring.sh -d hacktivism.ch surface + ./taler-monitoring.sh -d lefrancpaysan.ch surface SPA pin after selfbuild: EXPECT_WEBUI_VERSION=1.6.11 EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \\ @@ -111,6 +139,7 @@ while [ $# -gt 0 ]; do [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; } apply_taler_domain "$2" DOMAIN_SET=1 + TALER_DOMAIN_FROM_CLI=1 shift 2 ;; --bank) @@ -130,12 +159,13 @@ while [ $# -gt 0 ]; do CURRENCY_OVERRIDE="$2"; shift 2 ;; --no-probe) NO_PROBE=1; shift ;; - urls|inside|versions|sanity|server|e2e|ladder|goa-ladder|auth401|all|full) PHASES+=("$1"); shift ;; + urls|inside|versions|sanity|server|e2e|ladder|goa-ladder|auth401|aptdeploy|apt-deploy|apt_src|surface|ecosystem|all|full) PHASES+=("$1"); shift ;; *) # bare domain shorthand: ./taler-monitoring.sh taler.net if [[ "$1" == *.* && "$1" != *://* && "$1" != -* ]]; then apply_taler_domain "$1" DOMAIN_SET=1 + TALER_DOMAIN_FROM_CLI=1 shift else echo "Unknown: $1" >&2; usage >&2; exit 2 @@ -170,6 +200,8 @@ fi # Export so check_*.sh (re-source lib) see the same targets via env export TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC export EXPECT_CURRENCY SKIP_SSH LOCAL_STACK TALER_DOMAIN_PROBE CHECK_LANDING +export TALER_DOMAIN_FROM_CLI="${TALER_DOMAIN_FROM_CLI:-0}" +export DOMAIN_SET="${DOMAIN_SET:-0}" 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 E2E_USE_TEMPLATES E2E_TEMPLATE_MAP @@ -288,33 +320,158 @@ if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then e2e) _pt=$((_pt + 240)) ;; ladder|goa-ladder) _pt=$((_pt + 120)) ;; auth401) _pt=$((_pt + 70)) ;; + aptdeploy) _pt=$((_pt + 20)) ;; + surface|ecosystem) _pt=$((_pt + 80)) ;; esac done set_progress_total "$_pt" unset _pt fi +# Whole-run wall clock (default 10 min). Override: RUN_TIMEOUT=0 (unlimited). +: "${RUN_TIMEOUT:=600}" +export RUN_TIMEOUT +MON_T0=$(date +%s) +export MON_T0 +RUN_TIMED_OUT=0 +RUN_TIMEOUT_AT_PHASE="" +RUN_SKIPPED_PHASES=() + +mon_seconds_left() { + if [ "${RUN_TIMEOUT:-0}" -eq 0 ]; then + printf '%s' "999999" + return 0 + fi + local now left + now=$(date +%s) + left=$((RUN_TIMEOUT - (now - MON_T0))) + [ "$left" -lt 0 ] && left=0 + printf '%s' "$left" +} + +# Run one phase script under remaining RUN_TIMEOUT budget. +# Exit 124 (timeout utility) → mark RUN_TIMED_OUT. +run_phase() { + local phase="$1" script="$2" left rc + left=$(mon_seconds_left) + if [ "${RUN_TIMEOUT:-0}" -gt 0 ] && [ "$left" -le 0 ]; then + RUN_TIMED_OUT=1 + RUN_TIMEOUT_AT_PHASE="${RUN_TIMEOUT_AT_PHASE:-$phase}" + return 1 + fi + if [ "${RUN_TIMEOUT:-0}" -eq 0 ]; then + "$script" + return $? + fi + # Keep at least 2s so timeout(1) can start the child. + [ "$left" -lt 2 ] && left=2 + set +e + with_timeout "$left" "$script" + rc=$? + set -e + if [ "$rc" -eq 124 ]; then + RUN_TIMED_OUT=1 + RUN_TIMEOUT_AT_PHASE="$phase" + return 1 + fi + return "$rc" +} + +# How inside/versions will reach containers (host-podman vs ssh) +_INSIDE_ACCESS_HINT=ssh +if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then + _INSIDE_ACCESS_HINT=host-podman +elif [ "${SKIP_SSH:-0}" = "1" ]; then + _INSIDE_ACCESS_HINT=skipped +elif [ "${LOCAL_STACK:-0}" = "1" ] && command -v podman >/dev/null 2>&1 \ + && podman ps --format '{{.Names}}' 2>/dev/null | grep -qE 'taler-hacktivism'; then + _INSIDE_ACCESS_HINT=host-podman +fi + printf 'target domain=%s\n' "${TALER_DOMAIN}" printf ' bank %s\n' "$BANK_PUBLIC" printf ' exchange %s\n' "$EXCHANGE_PUBLIC" printf ' merchant %s\n' "$MERCHANT_PUBLIC" -printf ' currency expect=%s skip_ssh=%s\n' "${EXPECT_CURRENCY:-any}" "$SKIP_SSH" +printf ' currency expect=%s\n' "${EXPECT_CURRENCY:-any}" printf ' phases %s\n' "${PHASES[*]}" +printf ' flags LOCAL_STACK=%s SKIP_SSH=%s INSIDE_PODMAN=%s INSIDE_MODE=%s\n' \ + "${LOCAL_STACK:-}" "${SKIP_SSH:-0}" "${INSIDE_PODMAN:-0}" "${INSIDE_MODE:-}" +printf ' flags KOOPA_SSH=%s INSIDE_SSH=%s INSIDE_PROFILE=%s\n' \ + "${KOOPA_SSH:-}" "${INSIDE_SSH:-}" "${INSIDE_PROFILE:-}" +printf ' access inside/versions → %s' "$_INSIDE_ACCESS_HINT" +case "$_INSIDE_ACCESS_HINT" in + host-podman) printf ' (podman exec on this host; IDs inside.host-*)\n' ;; + ssh) printf ' (SSH then podman; IDs inside.ssh-*)\n' ;; + skipped) printf ' (SKIP_SSH=1)\n' ;; + *) printf '\n' ;; +esac +if [ "${RUN_TIMEOUT:-0}" -eq 0 ]; then + printf ' run_timeout unlimited (RUN_TIMEOUT=0)\n' +else + printf ' run_timeout %ss wall clock (RUN_TIMEOUT=; 0=unlimited)\n' "$RUN_TIMEOUT" +fi printf ' progress total≈%s (set PROGRESS_TOTAL= to override; PROGRESS_OFF=1 to hide)\n' "${PROGRESS_TOTAL:-0}" +unset _INSIDE_ACCESS_HINT chmod +x "$ROOT"/check_*.sh 2>/dev/null || true ec=0 for p in "${PHASES[@]}"; do + if [ "$RUN_TIMED_OUT" = "1" ]; then + RUN_SKIPPED_PHASES+=("$p") + continue + fi + left=$(mon_seconds_left) + if [ "${RUN_TIMEOUT:-0}" -gt 0 ] && [ "$left" -le 0 ]; then + RUN_TIMED_OUT=1 + RUN_TIMEOUT_AT_PHASE="${RUN_TIMEOUT_AT_PHASE:-$p}" + RUN_SKIPPED_PHASES+=("$p") + continue + fi case "$p" in - urls) "$ROOT/check_urls.sh" || ec=1 ;; - inside) "$ROOT/check_inside.sh" || ec=1 ;; - versions) "$ROOT/check_versions.sh" || ec=1 ;; - 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 ;; - auth401) "$ROOT/check_auth401.sh" || ec=1 ;; + urls) run_phase urls "$ROOT/check_urls.sh" || ec=1 ;; + inside) run_phase inside "$ROOT/check_inside.sh" || ec=1 ;; + versions) run_phase versions "$ROOT/check_versions.sh" || ec=1 ;; + sanity) run_phase sanity "$ROOT/check_sanity.sh" || ec=1 ;; + server) run_phase server "$ROOT/check_server.sh" || ec=1 ;; + e2e) run_phase e2e "$ROOT/check_e2e.sh" || ec=1 ;; + ladder|goa-ladder) run_phase ladder "$ROOT/check_goa_ladder.sh" || ec=1 ;; + auth401) run_phase auth401 "$ROOT/check_auth401.sh" || ec=1 ;; + aptdeploy|apt-deploy|apt_src) run_phase aptdeploy "$ROOT/check_apt_deploy.sh" || ec=1 ;; + surface|ecosystem) run_phase surface "$ROOT/check_surface.sh" || ec=1 ;; esac done + +# Extraordinary run-budget failure: always report at end; HTML links top → here. +if [ "$RUN_TIMED_OUT" = "1" ]; then + ec=1 + elapsed=$(( $(date +%s) - MON_T0 )) + skipped="${RUN_SKIPPED_PHASES[*]:-}" + # Stable id for jump links (console HTML + err-top banner) + printf '\n' + printf '╔══════════════════════════════════════════════════════════╗\n' + printf '║ RUN TIMEOUT · extraordinary (see jump target below) ║\n' + printf '╚══════════════════════════════════════════════════════════╝\n' + printf '┌ ERROR ┐ #run.timeout-01 RUN_TIMEOUT exceeded · budget %ss · elapsed %ss\n' \ + "$RUN_TIMEOUT" "$elapsed" + printf ' detail: wall-clock limit hit' + if [ -n "${RUN_TIMEOUT_AT_PHASE:-}" ]; then + printf ' during/after phase "%s"' "$RUN_TIMEOUT_AT_PHASE" + fi + if [ -n "$skipped" ]; then + printf ' · skipped: %s' "$skipped" + fi + printf '\n' + printf ' id=run.timeout-01\n' + printf ' hint: raise RUN_TIMEOUT= (seconds) or set RUN_TIMEOUT=0 for unlimited\n' + printf -- '┌ RUN TIMEOUT · extraordinary ┐\n' + printf -- ' • run.timeout-01 [run] RUN_TIMEOUT=%ss exceeded (elapsed %ss)%s%s\n' \ + "$RUN_TIMEOUT" "$elapsed" \ + "${RUN_TIMEOUT_AT_PHASE:+ · phase $RUN_TIMEOUT_AT_PHASE}" \ + "${skipped:+ · skipped $skipped}" + printf -- '--- ERRORS (failed checks) ---\n' + printf -- ' • run.timeout-01 [run] RUN_TIMEOUT=%ss exceeded (elapsed %ss)\n' \ + "$RUN_TIMEOUT" "$elapsed" +fi + exit "$ec" From 0fef40fd638e7f967c031e41ae670c4d51288968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Sat, 18 Jul 2026 19:57:21 +0200 Subject: [PATCH 308/326] monitoring: surface inventory and apt-deploy check phases --- scripts/taler-monitoring/.gitignore | 6 + scripts/taler-monitoring/DEPENDENCIES.md | 70 ++ scripts/taler-monitoring/README.md | 5 + scripts/taler-monitoring/TESTS.md | 28 +- scripts/taler-monitoring/check_apt_deploy.sh | 279 +++++++ scripts/taler-monitoring/check_inside.sh | 170 ++++- scripts/taler-monitoring/check_server.sh | 29 + scripts/taler-monitoring/check_surface.sh | 692 ++++++++++++++++++ scripts/taler-monitoring/check_versions.sh | 85 ++- scripts/taler-monitoring/lib.sh | 110 +++ scripts/taler-monitoring/surface-catalog.conf | 52 ++ scripts/taler-monitoring/taler-monitoring.sh | 177 ++++- 12 files changed, 1657 insertions(+), 46 deletions(-) create mode 100644 scripts/taler-monitoring/.gitignore create mode 100644 scripts/taler-monitoring/DEPENDENCIES.md create mode 100755 scripts/taler-monitoring/check_apt_deploy.sh create mode 100755 scripts/taler-monitoring/check_surface.sh create mode 100644 scripts/taler-monitoring/surface-catalog.conf diff --git a/scripts/taler-monitoring/.gitignore b/scripts/taler-monitoring/.gitignore new file mode 100644 index 0000000..754bb8e --- /dev/null +++ b/scripts/taler-monitoring/.gitignore @@ -0,0 +1,6 @@ +android-test/apks/ +android-test/out*/ +android-test/artifacts/ +__pycache__/ +secrets.env +*.pyc diff --git a/scripts/taler-monitoring/DEPENDENCIES.md b/scripts/taler-monitoring/DEPENDENCIES.md new file mode 100644 index 0000000..3e7db4c --- /dev/null +++ b/scripts/taler-monitoring/DEPENDENCIES.md @@ -0,0 +1,70 @@ +# taler-monitoring — host package dependencies + +## koopa (openSUSE Tumbleweed, user `hernani`) + +### Required (host-agent / GOA primary: `urls inside versions`) + +| Binary | Package (zypper) | Role | +|--------|------------------|------| +| `bash` | (base) | runner | +| `curl` | `curl` | HTTPS/API probes | +| `python3` | `python313-base` (provides `/usr/bin/python3`) | JSON, HTML site-gen, metrics | +| `podman` | `podman` | **inside**: `podman exec` into taler-hacktivism* | +| `git` | `git` | pin commit SHA in monitoring HTML footer | +| `rsync` | `rsync` | optional: deploy/sync suite | +| `timeout` / `mktemp` / `getent` | coreutils / glibc | helpers | + +**Status on koopa (2026-07-18):** all of the above present; rootless podman owns GOA containers. + +### Recommended (full `urls` QR group) + +| Binary | Package | Role | +|--------|---------|------| +| `qrencode` | **`qrencode`** | encode taler:// / payto:// → PNG | +| `zbarimg` | **`zbar`** | decode PNG; exact payload match | + +Without these, QR checks **WARN** and skip encode/decode (`apt install` messages in code are Debian-oriented; on Tumbleweed use zypper). + +```bash +# as root on koopa +zypper refresh +zypper in qrencode zbar +# verify as hernani: +command -v qrencode zbarimg +``` + +### Optional (not needed for host-agent default) + +| Binary / tool | When | +|---------------|------| +| `jq` | nice-to-have; suite uses python3 for JSON | +| `dig` / `bind-utils` | debug DNS; not required for default phases | +| `ssh` | only for access mode **ssh** (laptop → `KOOPA_SSH` / stage `INSIDE_SSH`); not needed for **host-podman** | +| `node` / `pnpm` + wallet-cli | **e2e** / ladder only (+ secrets) | +| `secrets.env` / admin passwords | e2e only — never system packages | + +### systemd user (no extra packages) + +- `systemd` user session + **`loginctl enable-linger hernani`** (already **Linger=yes** on koopa) +- Units: `scripts/taler-monitoring/host-agent/*.service|path|timer` + +### Caddy HTML publish (separate from monitoring run) + +- Host Caddy already installed (root service). +- Writing `/var/www/monitoring-sites`: either root `rsync` or directory owned/writable by `hernani` (you set this up). + +--- + +## firecuda-external (macOS, outside-only timer) + +| Need | Notes | +|------|--------| +| `bash`, `curl`, `python3`, `rsync`, `git` (optional) | usually Homebrew / Xcode CLT | +| **No** `podman` | phases are `urls versions` only (`SKIP_SSH=1`) | +| `qrencode` / `zbarimg` | optional for QR; `brew install qrencode zbar` if desired | + +--- + +## Franc Paysan hosts (francpaysan / stagepaysan) + +Document package lists under **`francpaysan-admin-log`** when those host-agents are installed (same idea: curl, python3, podman if inside, qrencode/zbar for full urls). diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 578b683..7d3ae2f 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -1,5 +1,10 @@ # taler-monitoring +Host packages (koopa / runners): **[DEPENDENCIES.md](./DEPENDENCIES.md)**. +Host-agents (GOA + FP; shared report pipeline): **[host-agent/README.md](./host-agent/README.md)**. +Public HTML site-gen (same timeout/log/HTML defaults): **[site-gen/README.md](./site-gen/README.md)**. +FP install notes: `francpaysan-admin-log/docs/taler-monitoring-host-agents.md`. + Report for the **GOA** stack with boxed severity badges and **grouped test IDs** `area.group-NN` (e.g. `www.exchange-01`, `e2e.pay-03`): diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index fb829d0..7b667f4 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -18,6 +18,8 @@ Every check line has a **global** run number and a **grouped** id: | **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` `qr` | | **inside** | `check_inside.sh` | `ssh` `bank` `exchange` `merchant` `caddy` `load` | | **versions** | `check_versions.sh` | `outside` `inside` `compare` | +| **aptdeploy** | `check_apt_deploy.sh` | `trixie` `trixie-testing` (koopa podman apt-src merchant smoke) | +| **surface** | `check_surface.sh` | **remote-only** ecosystem / `-d` domain inventory (not in default/all/full) | | **sanity** | `check_sanity.sh` | `bank` `exchange` `merchant` | | **server** | `check_server.sh` | (flat `server-NN` or host groups) | | **e2e** | `check_e2e.sh` | `prereq` `load` `bank` `wallet` `atm` `settle` `pay` `shop` `paivana` `dig` `report` | @@ -73,12 +75,14 @@ Numbering follows **executed** checks (early skip may shift later NN inside the | Group | Checks | |-------|--------| -| **inside.ssh-** | ssh reachability / remote collect | +| **inside.host-** | **host-podman**: reachability via `podman exec` on this machine (GOA host / `INSIDE_PODMAN=1`) — not SSH | +| **inside.ssh-** | **ssh**: from laptop/remote — SSH to `KOOPA_SSH` / `INSIDE_SSH`, then podman there | | **inside.bank-** | container, libeufin, postgres, local `/config`, nginx, DNS pins | | **inside.exchange-** | container, httpd, wirewatch, aggregator, transfer, local keys, DNS | | **inside.merchant-** | container, httpd, wirewatch, depositcheck, DNS | | **inside.caddy-** | host reverse-proxy process | | **inside.load-** | host loadavg + RAM; per-container RSS/CPU | +| **inside.disk-** / **aptdeploy.disk-** / **versions.disk-** / **server.disk-** | free space via `df`: **WARN** if use ≥ `DISK_WARN_USED_PCT` (85), **ERROR** if full or use ≥ `DISK_ERR_USED_PCT` (95); host + containers | Remote lines `E|comp|LEVEL|key|detail` each become one numbered result under that component group. @@ -112,8 +116,28 @@ When filing an issue, quote the full id + label, e.g. | Area.group | Meaning | |------------|---------| | **versions.outside-** | deb.taler.net suite index | -| **versions.inside-** | packages in containers | +| **versions.host-** | host-podman: packages via local `podman exec` (`INSIDE_PODMAN=1`) | +| **versions.ssh-** | ssh: packages via `KOOPA_SSH` / `INSIDE_SSH` from laptop/remote | | **versions.compare-** | installed vs suite | +| **aptdeploy.trixie-fresh-** | fresh rebuild container `…-apt-src-trixie` | +| **aptdeploy.trixie-testing-fresh-** | fresh rebuild `…-trixie-testing` | +| **aptdeploy.trixie-upgrade-** | upgrade-track `…-trixie-upgrade` (install once, then mytops-style upgrade) | +| **aptdeploy.trixie-testing-upgrade-** | upgrade-track `…-trixie-testing-upgrade` | + +Standalone: `host-agent/run-aptdeploy.sh` (on koopa local; else `ssh koopa-external`). + +### surface (remote-only public inventory) + +```bash +./taler-monitoring.sh surface # taler.net / gnunet / taler-systems / mattermost / … +./taler-monitoring.sh -d hacktivism.ch surface # all things under that domain +``` + +- Catalog: `surface-catalog.conf` (extend as hosts are found) +- DNS, optional ICMP, TCP ports, then **protocol** checks (HTTPS/HTTP/SSH) before ERROR +- TLS cert expiry; Server-header / `/config` version hints; else **WARN: version unknown** +- OSV CVE query when package+version known → **ERROR** on hits +- Never SSH / never podman on the *targets* | **sanity.bank-** / **.exchange-** / **.merchant-** | public + optional server-side per component | | **ladder.plan-** / **.load-** / **.withdraw-** / **.pay-** / **.report-** | amount ladder (GOA ceiling or stage TESTPAYSAN `max_wire`) | | **server-** | SSH host ports / processes (flat unless grouped later) | diff --git a/scripts/taler-monitoring/check_apt_deploy.sh b/scripts/taler-monitoring/check_apt_deploy.sh new file mode 100755 index 0000000..5240e82 --- /dev/null +++ b/scripts/taler-monitoring/check_apt_deploy.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +# check_apt_deploy.sh — podman apt-src merchant deploy tests on koopa. +# +# Containers (default): +# Fresh (rebuild when repo versions change): +# koopa-taler-deploy-test-apt-src-trixie +# koopa-taler-deploy-test-apt-src-trixie-testing +# Upgrade track (initial install, then mytops-style apt upgrade): +# koopa-taler-deploy-test-apt-src-trixie-upgrade +# koopa-taler-deploy-test-apt-src-trixie-testing-upgrade +# +# Env: +# APT_DEPLOY_CONTAINERS "name:suite:mode …" mode=fresh|upgrade (optional, default fresh) +# APT_DEPLOY_SKIP=1 +# APT_DEPLOY_PODMAN=podman +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +if [ "${APT_DEPLOY_SKIP:-0}" = "1" ]; then + echo "[INFO] skip phase aptdeploy (APT_DEPLOY_SKIP=1)" + exit 0 +fi + +set_area aptdeploy +section "aptdeploy · podman apt-src merchant (fresh + upgrade tracks)" + +PODMAN_BIN="${APT_DEPLOY_PODMAN:-podman}" +DEFAULT_LIST="\ +koopa-taler-deploy-test-apt-src-trixie:trixie:fresh \ +koopa-taler-deploy-test-apt-src-trixie-testing:trixie-testing:fresh \ +koopa-taler-deploy-test-apt-src-trixie-upgrade:trixie:upgrade \ +koopa-taler-deploy-test-apt-src-trixie-testing-upgrade:trixie-testing:upgrade" +LIST="${APT_DEPLOY_CONTAINERS:-$DEFAULT_LIST}" + +if ! command -v "$PODMAN_BIN" >/dev/null 2>&1; then + err "podman" "podman binary missing ($PODMAN_BIN)" + summary + exit 1 +fi +ok "podman" "$("$PODMAN_BIN" --version 2>/dev/null | head -1)" +info "host" "$(hostname 2>/dev/null || echo unknown) · whoami=$(whoami)" + +fail_any=0 + +print_pkg_table() { + local name=$1 + local rows + rows=$("$PODMAN_BIN" exec "$name" bash -lc ' + echo "| package | version |" + echo "|---------|---------|" + for p in taler-merchant libtalermerchant libtalerexchange libdonau libgnunet \ + taler-merchant-webui taler-merchant-typst taler-terms-generator; do + v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo missing) + echo "| $p | $v |" + done + ' 2>/dev/null || echo "| (query failed) | |") + info "pkg-table" "$name" + # emit as plain lines so HTML log keeps the table + while IFS= read -r line; do + [ -n "$line" ] || continue + info "pkg" "$line" + done <<<"$rows" +} + +check_ldd_deep() { + local name=$1 + local out + out=$("$PODMAN_BIN" exec "$name" bash -lc ' + set +e + bin=$(command -v taler-merchant-httpd) + echo "=== ldd taler-merchant-httpd ===" + ldd "$bin" 2>&1 | grep -E "libtalerutil|libdonau|libgnunet|not found" || true + echo "=== ldd libdonau (if present) ===" + for f in /usr/lib/*/libdonau.so* /usr/lib/*/libdonauutil.so*; do + [ -e "$f" ] || continue + echo "-- $f --" + ldd "$f" 2>&1 | grep -E "libtalerutil|not found" || true + done + echo "=== libtalerutil files ===" + ls -la /usr/lib/*/libtalerutil* 2>/dev/null || echo "(none)" + ' 2>/dev/null || echo "ldd probe failed") + while IFS= read -r line; do + [ -n "$line" ] || continue + info "ldd" "$line" + done <<<"$out" + if echo "$out" | grep -q 'not found'; then + err "ldd" "shared library missing in $name" "see ldd lines above" + return 1 + fi + return 0 +} + +check_systemd() { + local name=$1 + local out active_target active_httpd enabled + out=$("$PODMAN_BIN" exec "$name" bash -lc ' + set +e + echo "system: $(systemctl is-system-running 2>&1)" + echo "target_enabled: $(systemctl is-enabled taler-merchant.target 2>&1)" + echo "target_active: $(systemctl is-active taler-merchant.target 2>&1)" + echo "httpd_active: $(systemctl is-active taler-merchant-httpd.service 2>&1)" + echo "httpd_enabled: $(systemctl is-enabled taler-merchant-httpd.service 2>&1)" + systemctl start taler-merchant.target 2>&1 | tail -5 + sleep 2 + echo "after_start_target: $(systemctl is-active taler-merchant.target 2>&1)" + echo "after_start_httpd: $(systemctl is-active taler-merchant-httpd.service 2>&1)" + systemctl --failed --no-legend 2>/dev/null | grep -i taler || echo "failed_taler: none" + ' 2>/dev/null || echo "systemd probe failed") + while IFS= read -r line; do + [ -n "$line" ] || continue + info "systemd" "$line" + done <<<"$out" + + active_httpd=$(echo "$out" | sed -n 's/^after_start_httpd: //p' | tail -1) + if [ "$active_httpd" = "active" ]; then + ok "systemd httpd" "taler-merchant-httpd active after start taler-merchant.target" + return 0 + fi + # soft if target not shipped as enabled in container — still ERROR for deploy smoke + err "systemd httpd" "taler-merchant-httpd not active" "after_start_httpd=${active_httpd:-?} · see systemd lines" + return 1 +} + +check_merchant_basics() { + local name=$1 + local out code + # unix socket or curl localhost if httpd listens + out=$("$PODMAN_BIN" exec "$name" bash -lc ' + set +e + echo "=== binaries ===" + command -v taler-merchant-httpd taler-merchant-dbinit taler-config 2>/dev/null + echo "=== --version ===" + taler-merchant-httpd --version 2>&1 + echo "=== config probe ===" + # try common paths without TLS + for u in \ + http://127.0.0.1:9966/config \ + http://127.0.0.1:8081/config \ + http://127.0.0.1/config + do + code=$(curl -sS -m 2 -o /tmp/mcfg -w "%{http_code}" "$u" 2>/dev/null || echo 000) + echo "curl $u -> $code" + [ "$code" = "200" ] && head -c 120 /tmp/mcfg && echo + done + sock=$(ls /run/taler-merchant/httpd/*.sock 2>/dev/null | head -1) + if [ -n "$sock" ]; then + echo "socket: $sock" + code=$(curl -sS -m 2 --unix-socket "$sock" -o /tmp/mcfg -w "%{http_code}" http://localhost/config 2>/dev/null || echo 000) + echo "curl --unix-socket -> $code" + [ "$code" = "200" ] && head -c 160 /tmp/mcfg && echo + else + echo "socket: (none under /run/taler-merchant/httpd/)" + fi + ' 2>/dev/null || echo "basics probe failed") + + while IFS= read -r line; do + [ -n "$line" ] || continue + info "basics" "$line" + done <<<"$out" + + if echo "$out" | grep -qi 'error while loading shared libraries'; then + err "httpd" "shared library error on --version" "see basics/ldd" + return 1 + fi + if ! echo "$out" | grep -q 'taler-merchant-httpd --version\|v\.\|taler-merchant-httpd'; then + # version line varies; success if exit was ok — check via separate exec + : + fi + + set +e + "$PODMAN_BIN" exec "$name" taler-merchant-httpd --version >/dev/null 2>&1 + local ec=$? + set -e + if [ "$ec" -eq 0 ]; then + ok "httpd --version" "exit 0" + else + err "httpd --version" "exit $ec" + return 1 + fi + + # /config is nice-to-have if stack is fully configured + if echo "$out" | grep -qE 'curl .* -> 200'; then + ok "merchant /config" "HTTP 200 (local)" + else + warn "merchant /config" "no local HTTP 200 (unit may need BASE_URL/db — still report version/ldd)" + fi + return 0 +} + +check_one() { + local name="$1" expect_suite="$2" mode="${3:-fresh}" + local st suite_line pkgs + + # group ids: aptdeploy.trixie-01 / aptdeploy.trixie-testing-upgrade-01 + set_group "${expect_suite}${mode:+-$mode}" + section "aptdeploy · $name (suite=$expect_suite mode=$mode)" + + if ! "$PODMAN_BIN" container exists "$name" 2>/dev/null; then + err "container" "$name missing" "run ensure-apt-deploy-test-containers.sh" + fail_any=1 + return + fi + st=$("$PODMAN_BIN" inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo unknown) + if [ "$st" != "running" ]; then + err "container" "$name not running" "status=$st" + fail_any=1 + return + fi + ok "container" "$name running (mode=$mode)" + + suite_line=$("$PODMAN_BIN" exec "$name" bash -lc \ + 'grep -h "^Suites:" /etc/apt/sources.list.d/*taler* 2>/dev/null | head -1' 2>/dev/null || true) + if echo "$suite_line" | grep -qE "Suites:[[:space:]]*${expect_suite}([[:space:]]|$)"; then + ok "apt suite" "$expect_suite" + else + err "apt suite" "expected $expect_suite" "got: ${suite_line:-empty}" + fail_any=1 + fi + + print_pkg_table "$name" + + if ! check_merchant_basics "$name"; then + fail_any=1 + check_ldd_deep "$name" || fail_any=1 + else + # still show ldd summary as info when ok + ldd_out=$("$PODMAN_BIN" exec "$name" bash -lc \ + 'ldd "$(command -v taler-merchant-httpd)" 2>&1 | grep -E "libtalerutil|not found" || true' 2>/dev/null || true) + if echo "$ldd_out" | grep -q 'not found'; then + err "libtalerutil" "not found" "$ldd_out" + check_ldd_deep "$name" || true + fail_any=1 + else + info "libtalerutil" "$(echo "$ldd_out" | tr '\n' ' ')" + fi + fi + + if ! check_systemd "$name"; then + fail_any=1 + check_ldd_deep "$name" || true + fi + + # Disk free inside this deploy-test container (+ host once, first container only) + if [ "${_APT_DEPLOY_HOST_DISK_DONE:-0}" != "1" ]; then + set_group disk + mon_disk_check_host "host" || fail_any=1 + _APT_DEPLOY_HOST_DISK_DONE=1 + fi + set_group disk + if ! mon_disk_check_podman "$name" "$PODMAN_BIN"; then + fail_any=1 + fi +} + +for entry in $LIST; do + # name:suite or name:suite:mode + cname=${entry%%:*} + rest=${entry#*:} + case "$rest" in + *:*) + suite=${rest%%:*} + mode=${rest#*:} + ;; + *) + suite=$rest + mode=fresh + ;; + esac + check_one "$cname" "$suite" "$mode" +done + +summary +if [ "$fail_any" -ne 0 ]; then + exit 1 +fi +exit 0 diff --git a/scripts/taler-monitoring/check_inside.sh b/scripts/taler-monitoring/check_inside.sh index 0584c9b..7f62880 100755 --- a/scripts/taler-monitoring/check_inside.sh +++ b/scripts/taler-monitoring/check_inside.sh @@ -1,9 +1,14 @@ #!/usr/bin/env bash # Inside status for bank / exchange / merchant containers. +# +# Access modes (shown in the log as flags): +# host-podman — this host runs podman exec into containers (no SSH). +# INSIDE_PODMAN=1 or INSIDE_MODE=local-podman, or auto on GOA host. +# ssh — from a laptop/remote: SSH to KOOPA_SSH / INSIDE_SSH, then podman there. +# # Profiles: -# koopa (default LOCAL_STACK=1) — SSH KOOPA_SSH, containers taler-hacktivism* -# stage-lfp (TESTPAYSAN) — SSH INSIDE_SSH (francpaysan-stage-user / stagepaysan), -# low-priv podman only; ports 9030–9032; stats remain outside-in (urls). +# koopa (default LOCAL_STACK=1) — taler-hacktivism* containers +# stage-lfp (TESTPAYSAN) — SSH INSIDE_SSH (stagepaysan), low-priv podman set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh @@ -12,7 +17,6 @@ source "$ROOT/lib.sh" source "$ROOT/metrics.sh" set_area inside -set_group ssh PROFILE="${INSIDE_PROFILE:-}" if [ -z "$PROFILE" ]; then @@ -25,7 +29,27 @@ if [ -z "$PROFILE" ]; then fi fi -section "inside · collect (${PROFILE})" +# Resolve host-podman vs ssh before first check ID +_use_local_podman=0 +if [ "$PROFILE" != "stage-lfp" ]; then + if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then + _use_local_podman=1 + elif command -v podman >/dev/null 2>&1 \ + && podman ps --format '{{.Names}}' 2>/dev/null | grep -qE 'taler-hacktivism'; then + _use_local_podman=1 + fi +fi +if [ "$_use_local_podman" = "1" ]; then + INSIDE_ACCESS=host-podman + set_group host +else + INSIDE_ACCESS=ssh + set_group ssh +fi +export INSIDE_ACCESS + +section "inside · collect (${PROFILE} · access=${INSIDE_ACCESS})" +info "flags" "INSIDE_ACCESS=${INSIDE_ACCESS} INSIDE_PODMAN=${INSIDE_PODMAN:-0} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-0} SKIP_SSH=${SKIP_SSH:-0} KOOPA_SSH=${KOOPA_SSH:-} INSIDE_SSH=${INSIDE_SSH:-}" # --------------------------------------------------------------------------- # stage-lfp: low-priv stagepaysan on francpaysan-host @@ -244,31 +268,70 @@ EOF info "stage host" "load probe empty" fi + set_group disk + section "inside · disk free space (stage host + containers)" + _disk_raw=$(mon_ssh_bash "$SSH_HOST" "${STAGE_SSH_T:-24}" <<'DISK' || true +set +e +echo "###HOST###" +df -Pk / /var /home /tmp /mnt/data 2>/dev/null || df -Pk +echo "###CTRS###" +for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do + echo "###CTR $c###" + podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null +done +DISK +) + _host_df=$(printf '%s\n' "$_disk_raw" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d') + mon_disk_check_remote_text "ssh:${SSH_HOST}" "$_host_df" || true + _ctr=""; _buf="" + while IFS= read -r _line || [ -n "$_line" ]; do + case "$_line" in + '###CTR '*) + if [ -n "$_ctr" ]; then mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true; fi + _ctr=${_line####CTR }; _ctr=${_ctr%###}; _buf="" + ;; + '###CTRS###'|'###HOST###') ;; + *) [ -n "$_ctr" ] && _buf="${_buf}${_line}"$'\n' ;; + esac + done <<<"$_disk_raw" + [ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true + summary exit 0 fi # --------------------------------------------------------------------------- -# koopa (default) — existing remote collect +# koopa (default) — host-podman exec (on GOA host) or SSH to KOOPA_SSH (laptop) # --------------------------------------------------------------------------- -section "inside · collect from koopa" +section "inside · collect from koopa (access=${INSIDE_ACCESS})" -if [ "${SKIP_SSH}" = "1" ] && [ "${LOCAL_STACK:-0}" != "1" ]; then - warn "ssh" "SKIP_SSH=1 and not local — skipped" +if [ "$_use_local_podman" = "1" ]; then + set_group host + if ! command -v podman >/dev/null 2>&1; then + err "host" "INSIDE_PODMAN/host-podman but podman missing" + summary + exit 1 + fi + ok "host→container" "podman exec on this host (INSIDE_ACCESS=host-podman · no SSH)" +elif [ "${SKIP_SSH}" = "1" ] && [ "${LOCAL_STACK:-0}" != "1" ]; then + set_group ssh + warn "ssh" "SKIP_SSH=1 and not host-podman — skipped" summary exit 0 -fi - -if ! koopa_ssh_ok; then - err "ssh" "cannot reach ${KOOPA_SSH} in ${SSH_CONNECT_TIMEOUT}s — set SKIP_SSH=1 to skip inside" +elif ! koopa_ssh_ok; then + set_group ssh + err "ssh" "cannot reach ${KOOPA_SSH} — from laptop use KOOPA_SSH=; on host set INSIDE_PODMAN=1" summary exit 1 +else + set_group ssh + ok "ssh ${KOOPA_SSH}" "remote host then podman (INSIDE_ACCESS=ssh · laptop/remote)" fi -ok "ssh ${KOOPA_SSH}" -# One short remote script (≤ SSH_CMD_TIMEOUT). Every slow step is local curl -m 3 or quick pgrep. -RAW=$( - koopa_ssh_bash "${SSH_CMD_TIMEOUT}" <<'REMOTE' || true +# Collect script (file → bash local or ssh bash -s) +_INSIDE_SCRIPT=$(mktemp) +trap 'rm -f "${_INSIDE_SCRIPT:-}"' RETURN +cat >"$_INSIDE_SCRIPT" <<'REMOTE' set +e emit() { printf 'E|%s|%s|%s|%s\n' "$1" "$2" "$3" "$(printf '%s' "${4:-}" | tr '\n\r' ' ' | head -c 200)"; } # quick curl @@ -376,23 +439,37 @@ else fi echo DONE REMOTE -) + +if [ "$_use_local_podman" = "1" ]; then + RAW=$(bash "$_INSIDE_SCRIPT" || true) +else + RAW=$(koopa_ssh_bash "${SSH_CMD_TIMEOUT}" <"$_INSIDE_SCRIPT" || true) +fi +rm -f "$_INSIDE_SCRIPT" if [ -z "$RAW" ] || ! echo "$RAW" | grep -q '^E|'; then - err "ssh" "remote timed out or empty (cap ${SSH_CMD_TIMEOUT}s)" + if [ "$_use_local_podman" = "1" ]; then + err "host" "collect empty (INSIDE_ACCESS=host-podman · podman exec failed)" + else + err "ssh" "collect empty (INSIDE_ACCESS=ssh · cap ${SSH_CMD_TIMEOUT}s · host=${KOOPA_SSH})" + fi summary exit 1 fi +# Fallback group for non-component rows: host (podman) vs ssh (remote) +_access_grp=ssh +[ "$_use_local_podman" = "1" ] && _access_grp=host + _last_inside_grp="" while IFS= read -r line; do case "$line" in E\|*) IFS='|' read -r _ comp level key detail <<<"$line" - # Group IDs by component so issues map cleanly: inside.bank-02, inside.exchange-04 + # Group IDs by component: inside.bank-02, inside.exchange-04; access = host|ssh case "$comp" in bank|exchange|merchant|caddy) _g="$comp" ;; - *) _g="ssh" ;; + *) _g="$_access_grp" ;; esac if [ "$_g" != "$_last_inside_grp" ]; then set_group "$_g" @@ -415,4 +492,55 @@ METRICS_DIR="${METRICS_DIR:-$(mktemp -d)}" export METRICS_DIR metrics_report_load "${METRICS_DIR}/load-inside.json" "inside" || true +# Disk free space: host + taler containers (WARN tight, ERROR full) +set_group disk +section "inside · disk free space" +_disk_ec=0 +if [ "$_use_local_podman" = "1" ]; then + mon_disk_check_host "host" || _disk_ec=1 + while read -r _cname; do + [ -n "$_cname" ] || continue + mon_disk_check_podman "$_cname" || _disk_ec=1 + done < <(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism|taler-|stage-lfp|lfp-' || true) +else + # remote host via SSH: df on host + each container + _disk_raw=$(koopa_ssh_bash "${SSH_CMD_TIMEOUT:-20}" <<'DISK' || true +set +e +echo "###HOST###" +df -Pk / /var /home /tmp 2>/dev/null || df -Pk +echo "###CTRS###" +for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do + echo "###CTR $c###" + podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null +done +DISK +) + _host_df=$(printf '%s\n' "$_disk_raw" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d') + mon_disk_check_remote_text "ssh:${KOOPA_SSH:-remote}" "$_host_df" || _disk_ec=1 + _ctr="" + _buf="" + while IFS= read -r _line || [ -n "$_line" ]; do + case "$_line" in + '###CTR '*) + if [ -n "$_ctr" ]; then + mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || _disk_ec=1 + fi + _ctr=${_line####CTR } + _ctr=${_ctr%###} + _buf="" + ;; + '###CTRS###'|'###HOST###') ;; + *) + if [ -n "$_ctr" ]; then + _buf="${_buf}${_line}"$'\n' + fi + ;; + esac + done <<<"$_disk_raw" + if [ -n "$_ctr" ]; then + mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || _disk_ec=1 + fi +fi +unset _disk_raw _host_df _ctr _buf _line _cname _disk_ec + summary diff --git a/scripts/taler-monitoring/check_server.sh b/scripts/taler-monitoring/check_server.sh index 8412a12..3a9618a 100755 --- a/scripts/taler-monitoring/check_server.sh +++ b/scripts/taler-monitoring/check_server.sh @@ -110,6 +110,15 @@ if systemctl is-active caddy >/dev/null 2>&1 || pgrep -x caddy >/dev/null 2>&1; else report WARN "caddy" "not detected as active" fi + +# disk free (host + containers) — lines for mon_disk_check_remote_text on laptop side +echo "###DISK_HOST###" +df -Pk / /var /home /tmp 2>/dev/null || df -Pk +echo "###DISK_CTRS###" +for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do + echo "###CTR $c###" + podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null +done REMOTE ) @@ -126,4 +135,24 @@ while IFS= read -r line; do esac done <<<"$REMOTE" +set_group disk +section "server · disk free space" +_host_df=$(printf '%s\n' "$REMOTE" | sed -n '/^###DISK_HOST###$/,/^###DISK_CTRS###$/p' | sed '1d;$d') +mon_disk_check_remote_text "ssh:${KOOPA_SSH}" "$_host_df" || true +_ctr=""; _buf="" +while IFS= read -r _line || [ -n "$_line" ]; do + case "$_line" in + '###CTR '*) + if [ -n "$_ctr" ]; then mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true; fi + _ctr=${_line####CTR }; _ctr=${_ctr%###}; _buf="" + ;; + '###DISK_CTRS###'|'###DISK_HOST###') ;; + *) + # only buffer after we entered CTR section + if [ -n "$_ctr" ]; then _buf="${_buf}${_line}"$'\n'; fi + ;; + esac +done <<<"$REMOTE" +[ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true + summary diff --git a/scripts/taler-monitoring/check_surface.sh b/scripts/taler-monitoring/check_surface.sh new file mode 100755 index 0000000..1452d3b --- /dev/null +++ b/scripts/taler-monitoring/check_surface.sh @@ -0,0 +1,692 @@ +#!/usr/bin/env bash +# check_surface.sh — REMOTE-ONLY public surface / ecosystem scan. +# +# NOT in default phases. Explicit: +# ./taler-monitoring.sh surface +# ./taler-monitoring.sh -d hacktivism.ch surface +# +# Without -d / with generic ecosystem: catalog of taler.net, gnunet.org, +# taler-systems.com, taler-ops, mattermost, … +# With -d DOMAIN: expand domains.conf stack hosts + catalog entries matching +# that domain + common subdomain guesses; port/protocol probes only remote. +# +# Rules: +# - no SSH into targets, no local podman on targets +# - ICMP/port alone never proves "down" — confirm via expected protocol +# - catalogued host unreachable via protocol → ERROR +# - version unknown after probes → WARN +# - CVE hit (OSV) for identified software+version → ERROR +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=lib.sh +source "$ROOT/lib.sh" + +set_area surface +section "surface · remote ecosystem / domain inventory (outside-in only)" + +CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}" +# Common ports when scanning a host discovered without explicit list +DEFAULT_SCAN_PORTS="${SURFACE_SCAN_PORTS:-22,80,443,993,8443,9418}" +PORT_TIMEOUT="${SURFACE_PORT_TIMEOUT:-2}" +HTTP_TIMEOUT="${SURFACE_HTTP_TIMEOUT:-12}" +# CVE via OSV (public, no key). Disable: SURFACE_CVE=0 +: "${SURFACE_CVE:=1}" +# Extra ports when -d domain mode +DOMAIN_EXTRA_PORTS="${SURFACE_DOMAIN_PORTS:-22,80,443,8443}" + +declare -A HOST_PORTS # host -> comma ports +declare -A HOST_PROTO # host -> expect proto +declare -A HOST_LABEL # host -> short label +declare -A HOST_EXPECT # host -> 1 if catalogued (must respond) +ORDERED_HOSTS=() + +add_host() { + local h="$1" ports="${2:-}" proto="${3:-https}" label="${4:-}" expect="${5:-1}" + h=$(printf '%s' "$h" | tr 'A-Z' 'a-z' | sed 's#^https\?://##;s#/.*##;s/\.$//') + [ -n "$h" ] || return 0 + if [ -z "${HOST_PORTS[$h]+x}" ]; then + ORDERED_HOSTS+=("$h") + HOST_PORTS[$h]="${ports:-443}" + HOST_PROTO[$h]="${proto:-https}" + HOST_LABEL[$h]="${label:-$h}" + HOST_EXPECT[$h]="$expect" + else + # merge ports + local p + for p in ${ports//,/ }; do + case ",${HOST_PORTS[$h]}," in + *",$p,"*) ;; + *) HOST_PORTS[$h]="${HOST_PORTS[$h]},$p" ;; + esac + done + [ "${HOST_EXPECT[$h]}" = "1" ] || HOST_EXPECT[$h]="$expect" + fi +} + +load_catalog() { + local line h ports proto label + [ -f "$CATALOG" ] || { + warn "catalog" "missing $CATALOG — using empty base list" + return 0 + } + while IFS= read -r line || [ -n "$line" ]; do + line=${line%%#*} + line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -z "$line" ] && continue + # host ports proto label + h=$(echo "$line" | awk '{print $1}') + ports=$(echo "$line" | awk '{print $2}') + proto=$(echo "$line" | awk '{print $3}') + label=$(echo "$line" | awk '{print $4}') + [ "$ports" = "-" ] && ports="" + add_host "$h" "$ports" "${proto:-https}" "${label:-}" 1 + done <"$CATALOG" + info "catalog" "loaded $CATALOG · ${#ORDERED_HOSTS[@]} hosts" +} + +# Clear inventory (used when switching to domain-only scope) +clear_hosts() { + ORDERED_HOSTS=() + unset HOST_PORTS HOST_PROTO HOST_LABEL HOST_EXPECT + declare -gA HOST_PORTS HOST_PROTO HOST_LABEL HOST_EXPECT +} + +# Expand for -d domain: ONLY that domain (remote). No whole-ecosystem catalog. +expand_domain_scope() { + local d="$1" h + d=${d#https://}; d=${d%/} + d=${d#http://} + info "scope" "domain mode −d $d (remote only · not full ecosystem catalog)" + + clear_hosts + + # stack endpoints from applied profile (always expected) + for h in \ + "${BANK_PUBLIC:-}" \ + "${EXCHANGE_PUBLIC:-}" \ + "${MERCHANT_PUBLIC:-}" \ + "${PAIVANA_PUBLIC:-}" + do + [ -n "$h" ] || continue + add_host "$h" "80,443,$DOMAIN_EXTRA_PORTS" https "stack" 1 + done + + # apex + common subdomains (expected if DNS exists — set expect after DNS in scan, + # but catalogued stack already expected; guesses start optional) + for sub in "" www bank exchange taler merchant backend shop shops stage \ + git docs www2 api static media landing mon401 + do + if [ -z "$sub" ]; then + h="$d" + else + h="${sub}.${d}" + fi + # stack hosts already added as expect=1; guesses optional until we promote + if [ -z "${HOST_PORTS[$h]+x}" ]; then + add_host "$h" "$DOMAIN_EXTRA_PORTS" https "guess" 0 + fi + done + + # also pick catalog lines that belong to this domain only + if [ -f "$CATALOG" ]; then + local line ports proto label + while IFS= read -r line || [ -n "$line" ]; do + line=${line%%#*} + line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -z "$line" ] && continue + h=$(echo "$line" | awk '{print $1}') + h=$(printf '%s' "$h" | tr 'A-Z' 'a-z' | sed 's#^https\?://##;s#/.*##') + if [ "$h" = "$d" ] || [[ "$h" == *".$d" ]]; then + ports=$(echo "$line" | awk '{print $2}') + proto=$(echo "$line" | awk '{print $3}') + label=$(echo "$line" | awk '{print $4}') + [ "$ports" = "-" ] && ports="" + add_host "$h" "$ports" "${proto:-https}" "${label:-dom}" 1 + fi + done <"$CATALOG" + fi +} + +# Ecosystem mode: full catalog; all entries expected +expand_ecosystem_scope() { + info "scope" "ecosystem mode (taler.net / gnunet.org / taler-systems.com / taler-ops / mattermost, … · remote only)" + local h + for h in "${ORDERED_HOSTS[@]}"; do + HOST_EXPECT[$h]=1 + done +} + +# --- remote probes (no SSH) --- +dns_ok() { + local h="$1" + getent ahosts "$h" >/dev/null 2>&1 || getent hosts "$h" >/dev/null 2>&1 +} + +# TCP connect only (not proof of service) +tcp_open() { + local h="$1" port="$2" + if command -v timeout >/dev/null 2>&1; then + timeout "$PORT_TIMEOUT" bash -c "echo >/dev/tcp/${h}/${port}" 2>/dev/null + else + bash -c "echo >/dev/tcp/${h}/${port}" 2>/dev/null + fi +} + +# ICMP optional — never sole grounds for ERROR +ping_host() { + local h="$1" + ping -c 1 -W 2 "$h" >/dev/null 2>&1 || ping -c 1 -w 2 "$h" >/dev/null 2>&1 +} + +# HTTPS probe: code, server header, version hints, cert subject/dates +probe_https() { + local h="$1" port="${2:-443}" + local url hdr body code server via cert_end subj san + url="https://${h}/" + [ "$port" != "443" ] && url="https://${h}:${port}/" + body=$(mktemp) + hdr=$(mktemp) + code=$(curl -skS -m "$HTTP_TIMEOUT" -D "$hdr" -o "$body" -w '%{http_code}' \ + --connect-timeout "$PORT_TIMEOUT" "$url" 2>/dev/null || echo 000) + server=$(awk 'BEGIN{IGNORECASE=1} /^server:/{sub(/\r$/,""); sub(/^server:[[:space:]]*/,""); print; exit}' "$hdr" 2>/dev/null || true) + via=$(awk 'BEGIN{IGNORECASE=1} /^x-powered-by:/{sub(/\r$/,""); sub(/^[^:]+:[[:space:]]*/,""); print; exit}' "$hdr" 2>/dev/null || true) + # cert + cert_end=$(echo | openssl s_client -servername "$h" -connect "${h}:${port}" 2>/dev/null \ + | openssl x509 -noout -enddate 2>/dev/null | sed 's/notAfter=//') + subj=$(echo | openssl s_client -servername "$h" -connect "${h}:${port}" 2>/dev/null \ + | openssl x509 -noout -subject 2>/dev/null | head -1) + # taler version hints in body + local taler_hint + taler_hint=$(grep -oE 'taler[^"[:space:]]{0,40}|GNU Taler|libeufin|gnunet' "$body" 2>/dev/null | head -3 | tr '\n' ' ' || true) + # config JSON version if /config works + local cfg_ver="" + local ccode + ccode=$(curl -skS -m "$HTTP_TIMEOUT" -o "$body" -w '%{http_code}' \ + "https://${h}/config" 2>/dev/null || echo 000) + if [ "$ccode" = "200" ]; then + cfg_ver=$(python3 -c 'import json,sys +try: + d=json.load(open(sys.argv[1])) + print(d.get("version") or d.get("name") or d.get("currency") or "config-json") +except Exception: + print("")' "$body" 2>/dev/null || true) + fi + rm -f "$hdr" "$body" + printf 'code=%s server=%s powered=%s cert_end=%s cfg=%s hint=%s' \ + "$code" "${server:-}" "${via:-}" "${cert_end:-}" "${cfg_ver:-}" "${taler_hint:-}" + # return 0 if HTTP answered (any 2xx/3xx/4xx — service is up) + case "$code" in + 2??|3??|4??) return 0 ;; + *) return 1 ;; + esac +} + +probe_http() { + local h="$1" port="${2:-80}" + local url code + url="http://${h}/" + [ "$port" != "80" ] && url="http://${h}:${port}/" + code=$(curl -sS -m "$HTTP_TIMEOUT" -o /dev/null -w '%{http_code}' \ + --connect-timeout "$PORT_TIMEOUT" "$url" 2>/dev/null || echo 000) + printf 'code=%s' "$code" + case "$code" in 2??|3??|4??) return 0 ;; *) return 1 ;; esac +} + +probe_ssh_banner() { + local h="$1" port="${2:-22}" + local ban + ban=$(timeout "$PORT_TIMEOUT" bash -c "exec 3<>/dev/tcp/${h}/${port}; dd bs=256 count=1 <&3 2>/dev/null" 2>/dev/null \ + | tr -d '\r' | head -1 || true) + printf 'banner=%s' "${ban:-}" + [ -n "$ban" ] +} + +# Map Server header → package name guess for OSV +guess_package() { + local server="$1" + local s + s=$(printf '%s' "$server" | tr 'A-Z' 'a-z') + case "$s" in + *nginx*) echo "nginx" ;; + *apache*|*httpd*) echo "apache" ;; + *caddy*) echo "caddy" ;; + *openbsd\ httpd*) echo "openbsd-httpd" ;; + *) echo "" ;; + esac +} + +extract_version() { + local server="$1" + # nginx/1.22.1 → 1.22.1 + printf '%s' "$server" | sed -n 's/.*\/\([0-9][0-9.]*\).*/\1/p' | head -1 +} + +# OSV / CVE check for software version taken from Server headers. +# +# Important: headers only expose upstream versions (nginx/1.26.3), never the +# Debian package revision (1.26.3-3+deb13u7). Querying OSV ecosystem=Debian +# with the bare version yields massive false positives (ancient DEBIAN-CVE-* +# with introduced:0 and no fixed event, plus every package revision that +# merely *starts with* 1.26.3). +# +# Policy: +# - bare upstream version → upstream SEMVER signal only (WARN by default) +# - full Debian package version (contains '-') → Debian OSV, filtered +# - SURFACE_CVE=0 disables; SURFACE_CVE_LEVEL=error|warn (default warn for bare) +check_cves() { + local pkg="$1" ver="$2" host="$3" + [ "$SURFACE_CVE" = "1" ] || return 0 + [ -n "$pkg" ] && [ -n "$ver" ] || return 0 + local level out rc + # bare header versions are approximate → WARN unless overridden + if [[ "$ver" == *-* ]] || [[ "$ver" == *+* ]]; then + level="${SURFACE_CVE_LEVEL:-error}" + else + level="${SURFACE_CVE_LEVEL:-warn}" + fi + out=$( + SURFACE_CVE_PKG="$pkg" SURFACE_CVE_VER="$ver" SURFACE_CVE_HOST="$host" python3 - <<'PY' 2>/dev/null || true +import json, os, re, urllib.request + +pkg = os.environ.get("SURFACE_CVE_PKG", "") +ver = os.environ.get("SURFACE_CVE_VER", "") +host = os.environ.get("SURFACE_CVE_HOST", "") + +def ver_tuple(s: str): + nums = [int(x) for x in re.findall(r"\d+", s or "")[:5]] + return tuple(nums) if nums else () + +def vt_cmp(a, b): + n = max(len(a), len(b)) + a = a + (0,) * (n - len(a)) + b = b + (0,) * (n - len(b)) + return (a > b) - (a < b) + +def in_semver_events(ver, events): + """True if ver is in [introduced, fixed) for SEMVER-like event list.""" + vt = ver_tuple(ver) + if not vt: + return False + # process sequential introduced/fixed pairs + intro = None + for e in events or []: + if "introduced" in e: + intro = e.get("introduced") + elif "fixed" in e or "last_affected" in e: + fixed = e.get("fixed") + last = e.get("last_affected") + lo = ver_tuple("0" if intro in (None, "0") else str(intro)) + if vt_cmp(vt, lo) < 0: + intro = None + continue + if fixed is not None: + if vt_cmp(vt, ver_tuple(str(fixed))) < 0: + return True + elif last is not None: + if vt_cmp(vt, ver_tuple(str(last))) <= 0: + return True + intro = None + # open-ended introduced without fixed → ignore (never clears; Debian noise) + return False + +def osv_post(body): + req = urllib.request.Request( + "https://api.osv.dev/v1/query", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=20) as r: + return json.load(r) + except Exception: + return {} + +def osv_get(vid): + try: + with urllib.request.urlopen(f"https://api.osv.dev/v1/vulns/{vid}", timeout=15) as r: + return json.load(r) + except Exception: + return {} + +bare = not (("-" in ver) or ("+" in ver)) +hits = [] + +if bare: + # Upstream signal via CVE records that carry extracted_events for this package + # (Server header has no Debian revision — do NOT use ecosystem=Debian). + seed = { + "nginx": [ + "CVE-2025-23419", + "CVE-2025-53859", + "CVE-2024-7347", + "CVE-2024-34161", + "CVE-2024-32760", + "CVE-2024-31079", + "CVE-2024-24989", + "CVE-2024-24990", + ], + "apache": [ + "CVE-2024-38474", + "CVE-2024-38476", + "CVE-2024-38477", + "CVE-2023-31122", + "CVE-2023-43622", + ], + "caddy": [ + "CVE-2022-29718", + "CVE-2023-50463", + ], + }.get(pkg, []) + for vid in seed: + doc = osv_get(vid) + if not doc: + continue + ok_hit = False + for a in doc.get("affected") or []: + # prefer extracted_events (human SEMVER) on GIT/nginx ranges + for rg in a.get("ranges") or []: + db = rg.get("database_specific") or {} + extracted = db.get("extracted_events") or [] + if extracted and in_semver_events(ver, extracted): + # package name filter when present + cpes = db.get("cpe") or [] + if isinstance(cpes, str): + cpes = [cpes] + blob = json.dumps(a).lower() + json.dumps(cpes).lower() + if pkg == "nginx" and "nginx" not in blob and "f5" not in blob: + continue + ok_hit = True + break + if rg.get("type") == "SEMVER" and in_semver_events(ver, rg.get("events") or []): + ok_hit = True + break + if ok_hit: + break + if ok_hit: + hits.append(vid) +else: + # Full package version — Debian ecosystem is meaningful + data = osv_post({"package": {"name": pkg, "ecosystem": "Debian"}, "version": ver}) + for v in data.get("vulns") or []: + vid = v.get("id") or "?" + actionable = False + for a in v.get("affected") or []: + for rg in a.get("ranges") or []: + events = rg.get("events") or [] + has_end = any(("fixed" in e) or ("last_affected" in e) for e in events) + if not has_end: + continue # open-ended Debian noise + if rg.get("type") in ("ECOSYSTEM", "SEMVER"): + # version string is full deb version; trust OSV query match + # but only if a fixed event exists (actionable) + actionable = True + break + if actionable: + break + # exact version listed + versions = a.get("versions") or [] + if ver in versions: + actionable = True + break + if actionable: + hits.append(vid) + +# de-dup preserve order +seen = set() +uniq = [] +for h in hits: + if h not in seen: + seen.add(h) + uniq.append(h) + +if uniq: + print(f"HIT {len(uniq)} " + ",".join(uniq[:12])) +else: + print("CLEAN") +PY + ) + rc=0 + case "$out" in + HIT\ *) + local n ids + n=$(printf '%s' "$out" | awk '{print $2}') + ids=$(printf '%s' "$out" | cut -d' ' -f3-) + if [ "$level" = "error" ]; then + err "cve" "$host $pkg $ver — $n actionable vuln(s)" "$ids" + rc=1 + else + warn "cve" "$host $pkg $ver — $n actionable vuln(s) (header version · not Debian pkg)" "$ids" + rc=0 + fi + ;; + CLEAN) + if [[ "$ver" == *-* ]] || [[ "$ver" == *+* ]]; then + info "cve" "$host $pkg $ver — OSV clean (Debian package version)" + else + info "cve" "$host $pkg $ver — no actionable upstream CVE for bare Server-header version" + fi + ;; + *) + info "cve" "$host $pkg $ver — CVE probe skipped/unavailable" + ;; + esac + return "$rc" +} + +cert_expiry_check() { + local h="$1" end="$2" + [ -n "$end" ] || return 0 + local end_epoch now left days + end_epoch=$(date -d "$end" +%s 2>/dev/null || date -j -f "%b %e %T %Y %Z" "$end" +%s 2>/dev/null || echo 0) + now=$(date +%s) + [ "$end_epoch" -gt 0 ] || return 0 + left=$((end_epoch - now)) + days=$((left / 86400)) + if [ "$left" -le 0 ]; then + err "tls" "$h certificate EXPIRED" "notAfter=$end" + return 1 + fi + if [ "$days" -le 14 ]; then + warn "tls" "$h certificate expires in ${days}d" "notAfter=$end" + else + info "tls" "$h cert ok · ${days}d left · notAfter=$end" + fi + return 0 +} + +scan_host() { + local h="$1" + local ports proto label expect + local p open_ports=() any_proto_ok=0 ping_ok=0 dns=0 + local probe_detail server_hdr pkg ver + + ports=${HOST_PORTS[$h]:-443} + proto=${HOST_PROTO[$h]:-https} + label=${HOST_LABEL[$h]:-$h} + expect=${HOST_EXPECT[$h]:-0} + + set_group "$label" + + if dns_ok "$h"; then + dns=1 + ok "dns" "$h resolves" + else + if [ "$expect" = "1" ]; then + err "dns" "$h does not resolve (catalogued)" + else + info "dns" "$h no resolve (optional guess) — skip" + fi + return 0 + fi + + if ping_host "$h"; then + ping_ok=1 + info "ping" "$h ICMP ok" + else + info "ping" "$h ICMP no reply (not decisive)" + fi + + # port scan (unique ports) + local _seen_ports=" " + for p in ${ports//,/ }; do + [ -n "$p" ] || continue + case "$_seen_ports" in *" $p "*) continue ;; esac + _seen_ports="$_seen_ports$p " + if tcp_open "$h" "$p"; then + open_ports+=("$p") + info "port" "$h:$p open (TCP)" + else + info "port" "$h:$p closed/filtered (TCP)" + fi + done + + # Protocol verification on open ports (and always try 443/80 for https/http expect) + local try_ports=("${open_ports[@]}") + if [ ${#try_ports[@]} -eq 0 ]; then + # still try expected protocol ports even if scan said closed (scan false negatives) + case "$proto" in + https) try_ports=(443) ;; + http) try_ports=(80) ;; + ssh) try_ports=(22) ;; + *) try_ports=(443 80) ;; + esac + fi + + server_hdr="" + ver="" + for p in "${try_ports[@]}"; do + case "$p" in + 443|8443) + if probe_detail=$(probe_https "$h" "$p"); then + any_proto_ok=1 + ok "https" "$h:$p up · $probe_detail" + server_hdr=$(printf '%s' "$probe_detail" | sed -n 's/.*server=\([^ ]*\).*/\1/p') + # cert + local cend + cend=$(printf '%s' "$probe_detail" | sed -n 's/.*cert_end=\([^ ]*\).*/\1/p') + cert_expiry_check "$h" "$cend" || true + local cfg + cfg=$(printf '%s' "$probe_detail" | sed -n 's/.*cfg=\([^ ]*\).*/\1/p') + if [ -n "$cfg" ]; then + info "version" "$h config/api hint: $cfg" + ver="$cfg" + fi + else + info "https" "$h:$p no HTTP response · ${probe_detail:-}" + fi + ;; + 80|8080) + if probe_detail=$(probe_http "$h" "$p"); then + any_proto_ok=1 + ok "http" "$h:$p up · $probe_detail" + else + info "http" "$h:$p no HTTP response" + fi + ;; + 22) + if probe_detail=$(probe_ssh_banner "$h" "$p"); then + any_proto_ok=1 + ok "ssh" "$h:$p banner · $probe_detail" + ver=$(printf '%s' "$probe_detail" | sed 's/banner=//') + else + info "ssh" "$h:$p no SSH banner" + fi + ;; + *) + if tcp_open "$h" "$p"; then + info "tcp" "$h:$p open · protocol unknown" + fi + ;; + esac + done + + # Version summary + if [ -n "$server_hdr" ]; then + info "server-header" "$h · $server_hdr" + pkg=$(guess_package "$server_hdr") + ver_soft=$(extract_version "$server_hdr") + if [ -n "$pkg" ] && [ -n "$ver_soft" ]; then + info "version" "$h software $pkg $ver_soft (from Server header)" + check_cves "$pkg" "$ver_soft" "$h" || true + elif [ -n "$server_hdr" ]; then + warn "version" "$h Server header present but version unknown · $server_hdr" + fi + elif [ -n "$ver" ]; then + info "version" "$h · $ver" + else + if [ "$any_proto_ok" = "1" ]; then + warn "version" "$h reachable but software version unknown" + fi + fi + + # Expected service must answer protocol (not just ping/port) + # Note: ${array[*]:-x} is NOT valid default syntax (bash treats : as slice). + local ports_txt="${open_ports[*]}" + ports_txt=${ports_txt:-none} + if [ "$expect" = "1" ]; then + if [ "$any_proto_ok" = "1" ]; then + ok "reachability" "$h catalogued service OK (protocol confirmed)" + else + err "reachability" "$h catalogued but not reachable via ${proto}/protocol" \ + "dns=$dns ping=$ping_ok open_ports=${ports_txt} (ICMP/TCP alone not enough; protocol failed)" + fi + else + if [ "$any_proto_ok" = "1" ]; then + info "reachability" "$h optional host responds" + else + info "reachability" "$h optional · no protocol response (ok)" + fi + fi +} + +# --- main --- +# Scope (remote only): +# ./taler-monitoring.sh surface → full ecosystem catalog +# ./taler-monitoring.sh -d hacktivism.ch surface → only that domain +if [ "${SURFACE_SCOPE:-}" = "ecosystem" ]; then + load_catalog + expand_ecosystem_scope +elif [ "${SURFACE_SCOPE:-}" = "domain" ] || [ "${TALER_DOMAIN_FROM_CLI:-0}" = "1" ]; then + # domain mode: do not load whole ecosystem first + CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}" + expand_domain_scope "${TALER_DOMAIN:-hacktivism.ch}" +elif [ "${SURFACE_SCOPE:-auto}" = "auto" ]; then + # no -d → ecosystem; with -d → domain (TALER_DOMAIN_FROM_CLI) + if [ "${TALER_DOMAIN_FROM_CLI:-0}" = "1" ]; then + CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}" + expand_domain_scope "$TALER_DOMAIN" + else + load_catalog + expand_ecosystem_scope + fi +else + load_catalog + expand_ecosystem_scope +fi + +info "inventory" "${#ORDERED_HOSTS[@]} hosts to probe (remote only · no SSH · no local podman on targets)" +info "flags" "SURFACE_CVE=${SURFACE_CVE:-1} PORT_TIMEOUT=${PORT_TIMEOUT:-2} HTTP_TIMEOUT=${HTTP_TIMEOUT:-12}" + +for h in "${ORDERED_HOSTS[@]}"; do + [ -n "$h" ] || continue + scan_host "$h" || true +done + +# mon_disk on the *runner* only (optional) — not remote servers' disks +if [ "${SURFACE_CHECK_RUNNER_DISK:-0}" = "1" ]; then + set_group disk + if declare -F mon_disk_check_host >/dev/null 2>&1; then + mon_disk_check_host "runner" || true + else + warn "disk" "mon_disk_check_host not available" + fi +fi + +# summary returns non-zero when FAIL_N>0; do not trip set -e mid-script +summary || true +if [ "${FAIL_N:-0}" -eq 0 ]; then + exit 0 +fi +exit 1 diff --git a/scripts/taler-monitoring/check_versions.sh b/scripts/taler-monitoring/check_versions.sh index 3580732..8fc7c66 100755 --- a/scripts/taler-monitoring/check_versions.sh +++ b/scripts/taler-monitoring/check_versions.sh @@ -166,16 +166,16 @@ fi # --------------------------------------------------------------------------- # 2) INSIDE — containers can reach deb.taler.net (pasta / install path) -# koopa: KOOPA_SSH · stage-lfp: INSIDE_SSH (stagepaysan, low-priv) +# host-podman: INSIDE_PODMAN=1 on GOA host · ssh: KOOPA_SSH / INSIDE_SSH from laptop # --------------------------------------------------------------------------- -set_group inside -section "versions · inside · containers → deb.taler.net" - +_VERS_LOCAL=0 _VERS_SSH_HOST="" _VERS_BANK="taler-hacktivism-bank" _VERS_EX="taler-hacktivism-exchange-ansible" _VERS_MER="taler-hacktivism" -if [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \ +if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then + _VERS_LOCAL=1 +elif [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \ || [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \ || [ -n "${INSIDE_SSH:-}" ]; then _VERS_SSH_HOST="${INSIDE_SSH:-francpaysan-stage-user}" @@ -183,24 +183,44 @@ if [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \ _VERS_EX="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" _VERS_MER="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" elif [ "${LOCAL_STACK:-0}" = "1" ] && [ "${SKIP_SSH:-0}" != "1" ]; then + # laptop → koopa via SSH (not host-podman) _VERS_SSH_HOST="${KOOPA_SSH}" fi -if [ -z "$_VERS_SSH_HOST" ]; then - info "ssh" "no container SSH for this domain — outside-only package index" +if [ "$_VERS_LOCAL" = "1" ]; then + VERS_ACCESS=host-podman + set_group host +else + VERS_ACCESS=ssh + set_group ssh +fi + +section "versions · inside · containers → deb.taler.net (access=${VERS_ACCESS})" +info "flags" "VERS_ACCESS=${VERS_ACCESS} INSIDE_PODMAN=${INSIDE_PODMAN:-0} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-0} SKIP_SSH=${SKIP_SSH:-0} KOOPA_SSH=${KOOPA_SSH:-} INSIDE_SSH=${INSIDE_SSH:-}" + +if [ "$_VERS_LOCAL" != "1" ] && [ -z "$_VERS_SSH_HOST" ]; then + info "access" "no host-podman and no SSH target — outside-only package index" info "outside-only" "deb.taler.net public checks completed above" summary exit 0 fi -if [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then - if ! koopa_ssh_ok; then - err "ssh" "cannot reach ${KOOPA_SSH} — set SKIP_SSH=1 to skip container checks" +if [ "$_VERS_LOCAL" = "1" ]; then + if ! command -v podman >/dev/null 2>&1; then + err "host" "INSIDE_PODMAN/host-podman but podman missing" info "outside-only" "deb.taler.net public checks completed above" summary exit 1 fi - ok "ssh ${KOOPA_SSH}" + ok "host→container" "podman exec on this host (VERS_ACCESS=host-podman · no SSH)" +elif [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then + if ! koopa_ssh_ok; then + err "ssh" "cannot reach ${KOOPA_SSH} — from laptop set KOOPA_SSH=; on host set INSIDE_PODMAN=1" + info "outside-only" "deb.taler.net public checks completed above" + summary + exit 1 + fi + ok "ssh ${KOOPA_SSH}" "remote then podman (VERS_ACCESS=ssh)" else if ! mon_ssh_ok "$_VERS_SSH_HOST"; then err "ssh" "cannot reach ${_VERS_SSH_HOST} (stagepaysan) — skip container package compare" @@ -208,7 +228,7 @@ else summary exit 1 fi - ok "ssh ${_VERS_SSH_HOST}" "stagepaysan container package compare" + ok "ssh ${_VERS_SSH_HOST}" "stagepaysan (VERS_ACCESS=ssh)" fi INRELEASE_URL="${APT_BASE}/dists/${SUITE}/InRelease" @@ -257,7 +277,9 @@ done REMOTE } >"$tmp/remote-versions.sh" -if [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then +if [ "$_VERS_LOCAL" = "1" ]; then + REMOTE_OUT=$(bash "$tmp/remote-versions.sh" 2>/dev/null || true) +elif [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then REMOTE_OUT=$(koopa_ssh_bash 60 <"$tmp/remote-versions.sh" || true) else REMOTE_OUT=$(mon_ssh_bash "$_VERS_SSH_HOST" 60 <"$tmp/remote-versions.sh" || true) @@ -501,4 +523,41 @@ done info "suite" "deb.taler.net ${SUITE} (testing_index=${HAVE_TESTING})" info "tally" "match=${ok_n} ahead=${ahead_n} behind=${behind_n}" +# Disk free on the machine(s) we just probed (host-podman or SSH target) +set_group disk +section "versions · disk free space" +if [ "${_VERS_LOCAL:-0}" = "1" ]; then + mon_disk_check_host "host" || true + while read -r _vc; do + [ -n "$_vc" ] || continue + mon_disk_check_podman "$_vc" || true + done < <(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism|taler-|stage-lfp|lfp-' || true) +elif [ -n "${_VERS_SSH_HOST:-}" ]; then + _vdisk=$(mon_ssh_bash "$_VERS_SSH_HOST" 20 <<'DISK' || true +set +e +echo "###HOST###" +df -Pk / /var /home /tmp 2>/dev/null || df -Pk +echo "###CTRS###" +for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do + echo "###CTR $c###" + podman exec "$c" df -Pk / /var /tmp 2>/dev/null || true +done +DISK +) + mon_disk_check_remote_text "ssh:${_VERS_SSH_HOST}" \ + "$(printf '%s\n' "$_vdisk" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d')" || true + _ctr=""; _buf="" + while IFS= read -r _line || [ -n "$_line" ]; do + case "$_line" in + '###CTR '*) + [ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true + _ctr=${_line####CTR }; _ctr=${_ctr%###}; _buf="" + ;; + '###CTRS###'|'###HOST###') ;; + *) [ -n "$_ctr" ] && _buf="${_buf}${_line}"$'\n' ;; + esac + done <<<"$_vdisk" + [ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true +fi + summary diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh index 841d6e8..8455c6b 100755 --- a/scripts/taler-monitoring/lib.sh +++ b/scripts/taler-monitoring/lib.sh @@ -23,6 +23,9 @@ MERCHANT_INSTANCE=$(printf '%s' "${MERCHANT_INSTANCE}" | tr '[:upper:]' '[:lower : "${TIMEOUT:=12}" : "${E2E_TIMEOUT:=55}" # whole e2e budget; skip rest when exceeded (e2e raises as needed) : "${E2E_PAY_SECS:=22}" # dedicated seconds for pay handle-uri (avoid Alarm clock) +# Whole-run wall clock for taler-monitoring.sh (seconds). 0 = unlimited. +# Host-agent default phases (urls/inside/versions) should finish under this. +: "${RUN_TIMEOUT:=600}" # Local GOA: public paivana paywall (https://paivana.hacktivism.ch · template GOA:4200) : "${PAIVANA_PUBLIC:=https://paivana.hacktivism.ch}" : "${E2E_PAIVANA:=1}" # 0 = skip paivana section in e2e @@ -855,6 +858,113 @@ summary() { [ "$FAIL_N" -eq 0 ] } +# --------------------------------------------------------------------------- +# Disk space on hosts / containers under test +# WARN when tight, ERROR when full / critically low +# DISK_WARN_USED_PCT=85 # free below 15% → WARN +# DISK_ERR_USED_PCT=95 # free below 5% → ERROR +# DISK_ERR_FREE_BYTES=0 # free == 0 always ERROR +# --------------------------------------------------------------------------- +: "${DISK_WARN_USED_PCT:=85}" +: "${DISK_ERR_USED_PCT:=95}" + +# Parse one POSIX `df -P` body line (no header): fs size used avail capacity mount +# size/used/avail in 1K-blocks when using df -Pk +_mon_disk_eval_line() { + local where="$1" fs="$2" size="$3" used="$4" avail="$5" cap="$6" mnt="$7" + local pct label detail + pct=${cap%%%} + # non-numeric capacity → skip + case "$pct" in + ''|*[!0-9]*) return 0 ;; + esac + label="disk ${where} ${mnt}" + detail="fs=$fs size_1k=$size used_1k=$used avail_1k=$avail use=${pct}%" + if [ "$avail" = "0" ] || [ "$pct" -ge 100 ]; then + err "disk" "${where} ${mnt} FULL / overflow" "$detail" + return 1 + fi + if [ "$pct" -ge "${DISK_ERR_USED_PCT}" ]; then + err "disk" "${where} ${mnt} critically low free space (≥${DISK_ERR_USED_PCT}% used)" "$detail" + return 1 + fi + if [ "$pct" -ge "${DISK_WARN_USED_PCT}" ]; then + warn "disk" "${where} ${mnt} free space tight (≥${DISK_WARN_USED_PCT}% used)" "$detail" + return 0 + fi + ok "$label" "$detail" + return 0 +} + +# Report all filesystems from `df -P` / `df -Pk` output (with header). +# $1 = where label (host, container:name, ssh:host) +# $2 = full df text +mon_disk_report_df() { + local where="$1" text="$2" line fs size used avail cap mnt rest ec=0 + [ -n "$text" ] || { + warn "disk" "${where}: no df output" + return 0 + } + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + case "$line" in + Filesystem*|Filesystem*) continue ;; + esac + # df -P: Filesystem 1024-blocks Used Available Capacity Mounted on + # shellcheck disable=SC2086 + set -- $line + [ "$#" -ge 6 ] || continue + fs=$1 + size=$2 + used=$3 + avail=$4 + cap=$5 + shift 5 + mnt=$* + # skip special/pseudo if tiny and not root-ish + case "$fs" in + tmpfs|devtmpfs|overlay|shm|nsfs|proc|sysfs|cgroup*) + # still check root-like mounts that fill (overlay / in containers) + case "$mnt" in + /|/var|/var/*|/home|/home/*|/data|/mnt/*) ;; + *) continue ;; + esac + ;; + esac + if ! _mon_disk_eval_line "$where" "$fs" "$size" "$used" "$avail" "$cap" "$mnt"; then + ec=1 + fi + done <<<"$text" + return "$ec" +} + +# Local host: important mounts +mon_disk_check_host() { + local where text + where="${1:-host}" + text="" + text=$(df -Pk / /var /home /tmp 2>/dev/null | awk 'NR==1 || !seen[$1,$6]++' || true) + # fallback whole table + if [ -z "$text" ]; then + text=$(df -Pk 2>/dev/null || true) + fi + mon_disk_report_df "$where" "$text" +} + +# Run df inside podman container +mon_disk_check_podman() { + local cname="$1" text + local bin="${2:-podman}" + text=$("$bin" exec "$cname" df -Pk / /var /tmp 2>/dev/null || "$bin" exec "$cname" df -Pk 2>/dev/null || true) + mon_disk_report_df "ctr:${cname}" "$text" +} + +# df text already collected remotely +mon_disk_check_remote_text() { + local where="$1" text="$2" + mon_disk_report_df "$where" "$text" +} + http_code() { local url="$1"; shift curl -skS --max-redirs 0 -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000 diff --git a/scripts/taler-monitoring/surface-catalog.conf b/scripts/taler-monitoring/surface-catalog.conf new file mode 100644 index 0000000..16d5755 --- /dev/null +++ b/scripts/taler-monitoring/surface-catalog.conf @@ -0,0 +1,52 @@ +# surface-catalog.conf — known public Taler / GNUnet / Taler Systems hosts +# Format (whitespace-separated; # comments): +# host ports expect_proto label +# expect_proto: https | http | ssh | tcp | any +# ports: comma-separated (empty = defaults for proto) +# +# This list is expanded at runtime (DNS discovery, cert SANs, -d domain profile). +# Phase `surface` is NOT in default phases — run explicitly: +# ./taler-monitoring.sh surface +# ./taler-monitoring.sh -d hacktivism.ch surface + +# --- taler.net ecosystem --- +www.taler.net 443 https taler-www +docs.taler.net 443 https taler-docs +git.taler.net 443,22 https taler-git +bugs.taler.net 443 https taler-bugs +lists.taler.net 443 https taler-lists +tutorials.taler.net 443 https taler-tutorials +bank.demo.taler.net 443 https demo-bank +exchange.demo.taler.net 443 https demo-exchange +backend.demo.taler.net 443 https demo-merchant +shop.demo.taler.net 443 https demo-shop +bank.test.taler.net 443 https test-bank +exchange.test.taler.net 443 https test-exchange +backend.test.taler.net 443 https test-merchant + +# --- taler-ops / TOPS --- +www.taler-ops.ch 443 https tops-www +taler-ops.ch 443 https tops-apex +bank.taler-ops.ch 443 https tops-bank +exchange.taler-ops.ch 443 https tops-exchange +my.taler-ops.ch 443 https tops-merchant +stage.taler-ops.ch 443 https tops-stage-www +stage.my.taler-ops.ch 443 https tops-stage-merchant +exchange.stage.taler-ops.ch 443 https tops-stage-exchange +nexus.stage.taler-ops.ch 443 https tops-stage-nexus +shops.taler-ops.ch 443 https tops-shops +mattermost.taler.net 443 https mattermost + +# --- gnunet.org --- +www.gnunet.org 443,80 https gnunet-www +gnunet.org 443,80 https gnunet-apex +git.gnunet.org 443,22 https gnunet-git +bugs.gnunet.org 443 https gnunet-bugs + +# --- taler-systems.com --- +www.taler-systems.com 443 https tsa-www +taler-systems.com 443 https tsa-apex + +# --- common infra hostnames (remote only; may 404/redirect) --- +deb.taler.net 443 https deb-taler +ftp.gnu.org 443 https gnu-ftp diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 8b20e72..557c7a0 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -12,6 +12,12 @@ set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) +# Line-buffered stdout/stderr so redirected logs (tee/host-agent) flush each line. +if [ "${_TALER_MON_STDBUF:-0}" != "1" ] && command -v stdbuf >/dev/null 2>&1; then + export _TALER_MON_STDBUF=1 + exec stdbuf -oL -eL bash "$0" "$@" +fi + usage() { cat <<'EOF' taler-monitoring — bank / exchange / merchant checks @@ -29,8 +35,14 @@ Phases: e2e withdraw + pay (small amounts; remote aborts on login/KYC) ladder withdraw/pay amount ladder (GOA ceiling or stage TESTPAYSAN max_wire) auth401 merchant Basic-auth / case matrix (HTTP 401 paths; may create throwaway instance) + aptdeploy koopa podman apt-src smoke: taler-merchant in + koopa-taler-deploy-test-apt-src-trixie{,-testing} + surface REMOTE-ONLY public inventory (NOT in default/all/full): + ecosystem hosts (taler.net, gnunet.org, taler-systems.com, mattermost, …) + or -d DOMAIN → that domain’s surface; port/protocol/TLS/CVE (OSV) all urls + inside + versions + sanity + e2e (SSH phases only on koopa) full all + server + ladder + auth401 (maximum; long-running, needs secrets) + NOTE: surface is never included in all/full — pass it explicitly Options: -d, --domain DOMAIN load profile from domains.conf (bank/exchange/merchant) @@ -61,6 +73,10 @@ Env (same meaning): TALER_DOMAINS_CONF SKIP_SSH=1 NO_COLOR=1 / CLICOLOR=0 disable green/yellow/red tags (default: coloured) SKIP_SSH=1 NO_COLOR=1 + RUN_TIMEOUT=600 whole-run wall clock seconds (default 600; 0=unlimited) + long phases (ladder/full/e2e) need a higher value or 0 + DISK_WARN_USED_PCT=85 disk free: WARN when used ≥ this % + DISK_ERR_USED_PCT=95 disk free: ERROR when used ≥ this % (or avail=0 / 100%) PERF_WARN_MS PERF_FAIL_MS (urls latency; default 8000 / 20000) QR_CHECK=0 skip QR form + qrencode/zbarimg (urls phase) QR_ECC=M qrencode ECC level (default M) @@ -73,11 +89,23 @@ Env (same meaning): AUTH401_* see check_auth401.sh / secrets.env.example AUTH401_CONTINUE=1 / CONTINUE_ON_ERROR=1 auth401: run all groups, collect every ERROR (no mid-run halt) + APT_DEPLOY_SKIP=1 skip aptdeploy phase + APT_DEPLOY_CONTAINERS="name:suite …" default trixie + trixie-testing deploy-test pods + SURFACE_CVE=0 disable CVE queries in surface phase + SURFACE_CVE_LEVEL=warn|error bare Server-header versions default warn + (Debian package versions default error) + SURFACE_CATALOG=path override surface-catalog.conf Full load (GOA / hacktivism): ./taler-monitoring.sh -d hacktivism.ch full # or explicit: ./taler-monitoring.sh -d hacktivism.ch urls inside versions sanity server e2e ladder auth401 + # hacktivism host-agent also runs aptdeploy (apt-src merchant containers on koopa) + +Remote surface / ecosystem (explicit only — never default): + ./taler-monitoring.sh surface + ./taler-monitoring.sh -d hacktivism.ch surface + ./taler-monitoring.sh -d lefrancpaysan.ch surface SPA pin after selfbuild: EXPECT_WEBUI_VERSION=1.6.11 EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \\ @@ -111,6 +139,7 @@ while [ $# -gt 0 ]; do [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; } apply_taler_domain "$2" DOMAIN_SET=1 + TALER_DOMAIN_FROM_CLI=1 shift 2 ;; --bank) @@ -130,12 +159,13 @@ while [ $# -gt 0 ]; do CURRENCY_OVERRIDE="$2"; shift 2 ;; --no-probe) NO_PROBE=1; shift ;; - urls|inside|versions|sanity|server|e2e|ladder|goa-ladder|auth401|all|full) PHASES+=("$1"); shift ;; + urls|inside|versions|sanity|server|e2e|ladder|goa-ladder|auth401|aptdeploy|apt-deploy|apt_src|surface|ecosystem|all|full) PHASES+=("$1"); shift ;; *) # bare domain shorthand: ./taler-monitoring.sh taler.net if [[ "$1" == *.* && "$1" != *://* && "$1" != -* ]]; then apply_taler_domain "$1" DOMAIN_SET=1 + TALER_DOMAIN_FROM_CLI=1 shift else echo "Unknown: $1" >&2; usage >&2; exit 2 @@ -170,6 +200,8 @@ fi # Export so check_*.sh (re-source lib) see the same targets via env export TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC export EXPECT_CURRENCY SKIP_SSH LOCAL_STACK TALER_DOMAIN_PROBE CHECK_LANDING +export TALER_DOMAIN_FROM_CLI="${TALER_DOMAIN_FROM_CLI:-0}" +export DOMAIN_SET="${DOMAIN_SET:-0}" 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 E2E_USE_TEMPLATES E2E_TEMPLATE_MAP @@ -288,33 +320,158 @@ if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then e2e) _pt=$((_pt + 240)) ;; ladder|goa-ladder) _pt=$((_pt + 120)) ;; auth401) _pt=$((_pt + 70)) ;; + aptdeploy) _pt=$((_pt + 20)) ;; + surface|ecosystem) _pt=$((_pt + 80)) ;; esac done set_progress_total "$_pt" unset _pt fi +# Whole-run wall clock (default 10 min). Override: RUN_TIMEOUT=0 (unlimited). +: "${RUN_TIMEOUT:=600}" +export RUN_TIMEOUT +MON_T0=$(date +%s) +export MON_T0 +RUN_TIMED_OUT=0 +RUN_TIMEOUT_AT_PHASE="" +RUN_SKIPPED_PHASES=() + +mon_seconds_left() { + if [ "${RUN_TIMEOUT:-0}" -eq 0 ]; then + printf '%s' "999999" + return 0 + fi + local now left + now=$(date +%s) + left=$((RUN_TIMEOUT - (now - MON_T0))) + [ "$left" -lt 0 ] && left=0 + printf '%s' "$left" +} + +# Run one phase script under remaining RUN_TIMEOUT budget. +# Exit 124 (timeout utility) → mark RUN_TIMED_OUT. +run_phase() { + local phase="$1" script="$2" left rc + left=$(mon_seconds_left) + if [ "${RUN_TIMEOUT:-0}" -gt 0 ] && [ "$left" -le 0 ]; then + RUN_TIMED_OUT=1 + RUN_TIMEOUT_AT_PHASE="${RUN_TIMEOUT_AT_PHASE:-$phase}" + return 1 + fi + if [ "${RUN_TIMEOUT:-0}" -eq 0 ]; then + "$script" + return $? + fi + # Keep at least 2s so timeout(1) can start the child. + [ "$left" -lt 2 ] && left=2 + set +e + with_timeout "$left" "$script" + rc=$? + set -e + if [ "$rc" -eq 124 ]; then + RUN_TIMED_OUT=1 + RUN_TIMEOUT_AT_PHASE="$phase" + return 1 + fi + return "$rc" +} + +# How inside/versions will reach containers (host-podman vs ssh) +_INSIDE_ACCESS_HINT=ssh +if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then + _INSIDE_ACCESS_HINT=host-podman +elif [ "${SKIP_SSH:-0}" = "1" ]; then + _INSIDE_ACCESS_HINT=skipped +elif [ "${LOCAL_STACK:-0}" = "1" ] && command -v podman >/dev/null 2>&1 \ + && podman ps --format '{{.Names}}' 2>/dev/null | grep -qE 'taler-hacktivism'; then + _INSIDE_ACCESS_HINT=host-podman +fi + printf 'target domain=%s\n' "${TALER_DOMAIN}" printf ' bank %s\n' "$BANK_PUBLIC" printf ' exchange %s\n' "$EXCHANGE_PUBLIC" printf ' merchant %s\n' "$MERCHANT_PUBLIC" -printf ' currency expect=%s skip_ssh=%s\n' "${EXPECT_CURRENCY:-any}" "$SKIP_SSH" +printf ' currency expect=%s\n' "${EXPECT_CURRENCY:-any}" printf ' phases %s\n' "${PHASES[*]}" +printf ' flags LOCAL_STACK=%s SKIP_SSH=%s INSIDE_PODMAN=%s INSIDE_MODE=%s\n' \ + "${LOCAL_STACK:-}" "${SKIP_SSH:-0}" "${INSIDE_PODMAN:-0}" "${INSIDE_MODE:-}" +printf ' flags KOOPA_SSH=%s INSIDE_SSH=%s INSIDE_PROFILE=%s\n' \ + "${KOOPA_SSH:-}" "${INSIDE_SSH:-}" "${INSIDE_PROFILE:-}" +printf ' access inside/versions → %s' "$_INSIDE_ACCESS_HINT" +case "$_INSIDE_ACCESS_HINT" in + host-podman) printf ' (podman exec on this host; IDs inside.host-*)\n' ;; + ssh) printf ' (SSH then podman; IDs inside.ssh-*)\n' ;; + skipped) printf ' (SKIP_SSH=1)\n' ;; + *) printf '\n' ;; +esac +if [ "${RUN_TIMEOUT:-0}" -eq 0 ]; then + printf ' run_timeout unlimited (RUN_TIMEOUT=0)\n' +else + printf ' run_timeout %ss wall clock (RUN_TIMEOUT=; 0=unlimited)\n' "$RUN_TIMEOUT" +fi printf ' progress total≈%s (set PROGRESS_TOTAL= to override; PROGRESS_OFF=1 to hide)\n' "${PROGRESS_TOTAL:-0}" +unset _INSIDE_ACCESS_HINT chmod +x "$ROOT"/check_*.sh 2>/dev/null || true ec=0 for p in "${PHASES[@]}"; do + if [ "$RUN_TIMED_OUT" = "1" ]; then + RUN_SKIPPED_PHASES+=("$p") + continue + fi + left=$(mon_seconds_left) + if [ "${RUN_TIMEOUT:-0}" -gt 0 ] && [ "$left" -le 0 ]; then + RUN_TIMED_OUT=1 + RUN_TIMEOUT_AT_PHASE="${RUN_TIMEOUT_AT_PHASE:-$p}" + RUN_SKIPPED_PHASES+=("$p") + continue + fi case "$p" in - urls) "$ROOT/check_urls.sh" || ec=1 ;; - inside) "$ROOT/check_inside.sh" || ec=1 ;; - versions) "$ROOT/check_versions.sh" || ec=1 ;; - 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 ;; - auth401) "$ROOT/check_auth401.sh" || ec=1 ;; + urls) run_phase urls "$ROOT/check_urls.sh" || ec=1 ;; + inside) run_phase inside "$ROOT/check_inside.sh" || ec=1 ;; + versions) run_phase versions "$ROOT/check_versions.sh" || ec=1 ;; + sanity) run_phase sanity "$ROOT/check_sanity.sh" || ec=1 ;; + server) run_phase server "$ROOT/check_server.sh" || ec=1 ;; + e2e) run_phase e2e "$ROOT/check_e2e.sh" || ec=1 ;; + ladder|goa-ladder) run_phase ladder "$ROOT/check_goa_ladder.sh" || ec=1 ;; + auth401) run_phase auth401 "$ROOT/check_auth401.sh" || ec=1 ;; + aptdeploy|apt-deploy|apt_src) run_phase aptdeploy "$ROOT/check_apt_deploy.sh" || ec=1 ;; + surface|ecosystem) run_phase surface "$ROOT/check_surface.sh" || ec=1 ;; esac done + +# Extraordinary run-budget failure: always report at end; HTML links top → here. +if [ "$RUN_TIMED_OUT" = "1" ]; then + ec=1 + elapsed=$(( $(date +%s) - MON_T0 )) + skipped="${RUN_SKIPPED_PHASES[*]:-}" + # Stable id for jump links (console HTML + err-top banner) + printf '\n' + printf '╔══════════════════════════════════════════════════════════╗\n' + printf '║ RUN TIMEOUT · extraordinary (see jump target below) ║\n' + printf '╚══════════════════════════════════════════════════════════╝\n' + printf '┌ ERROR ┐ #run.timeout-01 RUN_TIMEOUT exceeded · budget %ss · elapsed %ss\n' \ + "$RUN_TIMEOUT" "$elapsed" + printf ' detail: wall-clock limit hit' + if [ -n "${RUN_TIMEOUT_AT_PHASE:-}" ]; then + printf ' during/after phase "%s"' "$RUN_TIMEOUT_AT_PHASE" + fi + if [ -n "$skipped" ]; then + printf ' · skipped: %s' "$skipped" + fi + printf '\n' + printf ' id=run.timeout-01\n' + printf ' hint: raise RUN_TIMEOUT= (seconds) or set RUN_TIMEOUT=0 for unlimited\n' + printf -- '┌ RUN TIMEOUT · extraordinary ┐\n' + printf -- ' • run.timeout-01 [run] RUN_TIMEOUT=%ss exceeded (elapsed %ss)%s%s\n' \ + "$RUN_TIMEOUT" "$elapsed" \ + "${RUN_TIMEOUT_AT_PHASE:+ · phase $RUN_TIMEOUT_AT_PHASE}" \ + "${skipped:+ · skipped $skipped}" + printf -- '--- ERRORS (failed checks) ---\n' + printf -- ' • run.timeout-01 [run] RUN_TIMEOUT=%ss exceeded (elapsed %ss)\n' \ + "$RUN_TIMEOUT" "$elapsed" +fi + exit "$ec" From c019000f54e0bc31d2b2ee77fa34b1c49f4a8ea2 Mon Sep 17 00:00:00 2001 From: hernani Date: Sat, 18 Jul 2026 19:57:21 +0200 Subject: [PATCH 309/326] monitoring: host-agent timers and HTML report pipeline --- scripts/taler-monitoring/host-agent/README.md | 183 +++++++++ .../host-agent/ROOT-APPLY-MONITORING.md | 57 +++ .../ensure-apt-deploy-test-containers.sh | 373 ++++++++++++++++++ .../taler-monitoring/host-agent/env.example | 26 ++ .../host-agent/env/francpaysan.env.example | 24 ++ .../host-agent/env/hacktivism.env.example | 24 ++ .../host-agent/env/stagepaysan.env.example | 27 ++ .../host-agent/install-host-agent.sh | 107 +++++ .../host-agent/run-aptdeploy-monitoring.sh | 43 ++ .../host-agent/run-aptdeploy.sh | 150 +++++++ .../host-agent/run-fp-prod-monitoring.sh | 20 + .../host-agent/run-fp-stage-monitoring.sh | 21 + .../host-agent/run-hacktivism-monitoring.sh | 23 ++ .../host-agent/run-host-report.sh | 358 +++++++++++++++++ .../host-agent/run-surface-monitoring.sh | 41 ++ .../taler-monitoring-aptdeploy.service | 18 + .../taler-monitoring-aptdeploy.timer | 14 + .../taler-monitoring-hacktivism.path | 19 + .../taler-monitoring-hacktivism.service | 21 + .../taler-monitoring-hacktivism.timer | 14 + .../taler-monitoring-surface.service | 18 + .../host-agent/taler-monitoring-surface.timer | 14 + .../host-agent/touch-software-stamp.sh | 17 + .../host-agent/update-suite.sh | 109 +++++ 24 files changed, 1721 insertions(+) create mode 100644 scripts/taler-monitoring/host-agent/README.md create mode 100644 scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md create mode 100755 scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh create mode 100644 scripts/taler-monitoring/host-agent/env.example create mode 100644 scripts/taler-monitoring/host-agent/env/francpaysan.env.example create mode 100644 scripts/taler-monitoring/host-agent/env/hacktivism.env.example create mode 100644 scripts/taler-monitoring/host-agent/env/stagepaysan.env.example create mode 100755 scripts/taler-monitoring/host-agent/install-host-agent.sh create mode 100755 scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh create mode 100755 scripts/taler-monitoring/host-agent/run-aptdeploy.sh create mode 100755 scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh create mode 100755 scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh create mode 100755 scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh create mode 100755 scripts/taler-monitoring/host-agent/run-host-report.sh create mode 100755 scripts/taler-monitoring/host-agent/run-surface-monitoring.sh create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-surface.service create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer create mode 100755 scripts/taler-monitoring/host-agent/touch-software-stamp.sh create mode 100755 scripts/taler-monitoring/host-agent/update-suite.sh diff --git a/scripts/taler-monitoring/host-agent/README.md b/scripts/taler-monitoring/host-agent/README.md new file mode 100644 index 0000000..3acfe59 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/README.md @@ -0,0 +1,183 @@ +# host-agent — reporting generation (GOA + FP + any stack) + +**Global pipeline** lives in **`run-host-report.sh`** (all stacks share it): + +| Behaviour | Default | +|-----------|---------| +| Suite refresh | `update-suite.sh` → `origin/main` | +| Wall clock | `RUN_TIMEOUT=600` | +| Log | line-buffered `run-*.log` (continuous write) | +| HTML | always (first run / empty tree too) + commit URL | +| Timeout notice | end of log + top jump on `/monitoring_err/` | + +Thin wrappers only set stack defaults: + +| Wrapper | Stack | Typical user | +|---------|-------|----------------| +| `run-hacktivism-monitoring.sh` | GOA `hacktivism.ch` | `hernani` @ koopa | +| `run-fp-stage-monitoring.sh` | FP stage | `stagepaysan` | +| `run-fp-prod-monitoring.sh` | FP prod | `francpaysan` | +| `run-aptdeploy.sh` | CLI: ensure + **aptdeploy** (no HTML) | local if hostname=koopa, else **ssh koopa-external** | +| `run-aptdeploy-monitoring.sh` | **aptdeploy** → `/taler-monitoring-aptdeploy*` | `hernani` @ koopa (4h timer, taler only) | +| `run-surface-monitoring.sh` | **surface** → `/taler-monitoring-surface*` | `hernani` @ koopa (hourly, taler only) | + +### apt-src deploy tests (hacktivism `/monitoring`) + +| Container | Mode | +|-----------|------| +| `koopa-taler-deploy-test-apt-src-trixie` | **fresh**: recreate when repo package fingerprint changes | +| `koopa-taler-deploy-test-apt-src-trixie-testing` | **fresh** for `trixie-testing` | +| `koopa-taler-deploy-test-apt-src-trixie-upgrade` | **upgrade**: initial install, then mytops-style `apt install` + `upgrade` | +| `koopa-taler-deploy-test-apt-src-trixie-testing-upgrade` | **upgrade** for testing suite | + +```bash +# from laptop / clementine → triggers on koopa-external +./scripts/taler-monitoring/host-agent/run-aptdeploy.sh +./scripts/taler-monitoring/host-agent/run-aptdeploy.sh upgrade +./scripts/taler-monitoring/host-agent/run-aptdeploy.sh fresh +./scripts/taler-monitoring/host-agent/run-aptdeploy.sh check-only + +# on koopa +./taler-monitoring.sh -d hacktivism.ch aptdeploy +``` + +Checks: package table, `httpd --version`, libtalerutil/`ldd` on failure, `systemctl` for +`taler-merchant.target`, basic local `/config` probe. No nginx/HTTPS in containers. + +### surface + aptdeploy HTML (taler.hacktivism.ch only) + +| URL | Role | Timer | +|-----|------|-------| +| `…/taler-monitoring-surface/` | ecosystem surface scan | 1h | +| `…/taler-monitoring-surface_err/` | errors + jump list | ″ | +| `…/taler-monitoring-aptdeploy/` | apt-src container deploy tests | 4h | +| `…/taler-monitoring-aptdeploy_err/` | errors + jump list | ″ | + +Not on bank/exchange. Staging: +`~/monitoring-sites-staging/taler.hacktivism.ch/taler-monitoring-{surface,aptdeploy}*`. + +```bash +systemctl --user enable --now taler-monitoring-surface.timer +systemctl --user enable --now taler-monitoring-aptdeploy.timer +systemctl --user start taler-monitoring-surface.service +systemctl --user start taler-monitoring-aptdeploy.service +# Caddy: handles only in taler.hacktivism.ch block (root) +``` + +Env examples: **`env/*.env.example`**. FP docs: `francpaysan-admin-log/docs/taler-monitoring-host-agents.md`. + +## GOA (koopa) — host-podman + +Runs **as user `hernani`** (systemd user, linger). **Primary** mode: + +- **`INSIDE_PODMAN=1`** / **`INSIDE_MODE=local-podman`**: access mode **`host-podman`** — + `podman exec` on this host into + `taler-hacktivism-bank` / `taler-hacktivism-exchange-ansible` / `taler-hacktivism` + (IDs `inside.host-*`, not `inside.ssh-*`). + From a **laptop**, leave `INSIDE_PODMAN` off → access mode **`ssh`** via `KOOPA_SSH` + (`inside.ssh-*`). +- Public **`urls` + `versions`** for the stack (`-d hacktivism.ch`). +- Writes console HTML for all three sites under + `~/monitoring-sites-staging/{bank,exchange,taler}.hacktivism.ch/`. + +Caddy (host root) can serve that tree at `/monitoring` — you configure Caddy. + +## Latest software + config isolation + +**Source of truth is always Forgejo** (`git.hacktivism.ch`), not a laptop rsync copy. + +Every host-agent / aptdeploy run: + +1. **`update-suite.sh`**: `git fetch` + **`SUITE_UPDATE_MODE=reset`** (default) → `origin/main` + into `SUITE_DIR` (prefer `~/src/koopa-admin-log`). +2. **Re-exec** host-agent scripts from that tree so the running code matches `COMMIT_URL`. +3. Local overrides only in **`~/.config/taler-monitoring/env`** — never overwritten by git. +4. HTML footer = exact commit: + `https://git.hacktivism.ch/hernani/koopa-admin-log/src/commit/` +5. **`RUN_TIMEOUT=600`**, line-buffered logs, HTML always written. + +If fetch fails → previous tree + WARN. +**To ship features (aptdeploy, surface, …): push to `origin/main`.** Local-only edits vanish on the next reset. + +If pull fails (no network / auth), the previous tree is used and a WARN is logged. + +## Install (on koopa as hernani) + +```bash +# once: local config (optional) +mkdir -p ~/.config/taler-monitoring +cp ~/koopa-admin-log/scripts/taler-monitoring/host-agent/env.example \ + ~/.config/taler-monitoring/env + +# laptop: +./scripts/taler-monitoring/host-agent/install-host-agent.sh --remote koopa-external + +# or on koopa: +~/koopa-admin-log/scripts/taler-monitoring/host-agent/install-host-agent.sh +``` + +Units: + +| Unit | Role | +|------|------| +| `taler-monitoring-hacktivism.path` | fires on software stamp + landing/caddy config changes | +| `taler-monitoring-hacktivism.timer` | every **4h** fallback | +| `taler-monitoring-hacktivism.service` | oneshot → `/monitoring*` (urls inside versions) | +| `taler-monitoring-surface.timer` | every **1h** | +| `taler-monitoring-surface.service` | → `/taler-monitoring-surface*` (taler only) | +| `taler-monitoring-aptdeploy.timer` | every **4h** | +| `taler-monitoring-aptdeploy.service` | → `/taler-monitoring-aptdeploy*` (taler only) | + +## After package / image / landing upgrades + +```bash +~/koopa-admin-log/scripts/taler-monitoring/host-agent/touch-software-stamp.sh +# → path unit starts the service +``` + +## Manual + +```bash +systemctl --user start taler-monitoring-hacktivism.service +systemctl --user status taler-monitoring-hacktivism.service --no-pager +journalctl --user -u taler-monitoring-hacktivism.service -n 50 --no-pager +ls ~/monitoring-sites-staging/bank.hacktivism.ch/monitoring_err/ +``` + +## Relation to firecuda outside-only timer + +| | host-agent (koopa) | firecuda site-gen | +|--|--------------------|-------------------| +| Access | **podman on host** | public HTTPS only | +| Phases | urls **inside** versions | urls versions | +| Trigger | software path + 4h | *could* launchd 4h | +| Primary for GOA | **yes** | **off** (optional only) | + +**firecuda-external is not scheduled** right now (launchd disabled). Scripts under +`site-gen/` can still one-shot a public run if needed; see `site-gen/README.md`. + +No secrets required for default phases. `secrets.env` optional for e2e later. + +## Git commit on HTML pages + +Header of each generated page includes a link like: + +`https://git.hacktivism.ch/hernani/koopa-admin-log/src/commit/` + +That SHA is the tree **after** `update-suite.sh` for that run. + +## Packages on koopa + +See **[../DEPENDENCIES.md](../DEPENDENCIES.md)**. + +**Default host-agent needs no new packages** if curl/python3/podman/git are present (they are). + +**Root once** only for full QR checks in `urls`: + +```bash +zypper in qrencode zbar +``` + +Without them, monitoring still runs; QR group WARNs and skips encode/decode. + +Caddy `/var/www/monitoring-sites` is separate (you configured hernani write access). diff --git a/scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md b/scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md new file mode 100644 index 0000000..17dd386 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md @@ -0,0 +1,57 @@ +# Root: make monitoring pages live on koopa (GOA) + +## Symptom if not applied + +Public URLs return **merchant/bank JSON** like: + +```json +{ "code": 21, "hint": "There is no endpoint defined for the URL provided..." } +``` + +or a **404 HTML** page from the app reverse_proxy. + +Cause: live `/etc/caddy/Caddyfile` has **no** `handle /monitoring*` (falls through to +API). Prepared config is only under **`/home/hernani/koopa-caddy/Caddyfile`**. + +## One-shot as root (recommended) + +```bash +sudo /home/hernani/koopa-caddy/apply-monitoring-live.sh +``` + +That script: + +1. `rsync` staging HTML → `/var/www/monitoring-sites/` (incl. surface + aptdeploy) +2. Makes tree writable for `hernani` (group `caddy` + ACL) so later runs self-sync +3. Installs prepared Caddyfile → `/etc/caddy/Caddyfile`, validates, reloads Caddy +4. Smoke-curls all monitoring URLs (must **not** be JSON `code:21`) + +## Paths + +| Path | Host | Source timer | +|------|------|----------------| +| `/monitoring/` + `/monitoring_err/` | bank + exchange + taler | `taler-monitoring-hacktivism.timer` (4h) | +| `/taler-monitoring-surface/` + `/_err/` | **taler.hacktivism.ch only** | `taler-monitoring-surface.timer` (1h) | +| `/taler-monitoring-aptdeploy/` + `/_err/` | **taler.hacktivism.ch only** | `taler-monitoring-aptdeploy.timer` (4h) | + +HTML is generated as **hernani** into +`/home/hernani/monitoring-sites-staging/`. + +## Manual equivalent + +```bash +rsync -a --delete /home/hernani/monitoring-sites-staging/ /var/www/monitoring-sites/ +chown -R hernani:caddy /var/www/monitoring-sites && chmod -R g+rwX /var/www/monitoring-sites +sudo /home/hernani/koopa-caddy/apply.sh # or apply-monitoring-live.sh +``` + +Expect **200** (HTML with sticky bar). Not merchant JSON `code:21`. + +## After each monitoring run (if www is not hernani-writable) + +```bash +rsync -a --delete /home/hernani/monitoring-sites-staging/ /var/www/monitoring-sites/ +chown -R caddy:caddy /var/www/monitoring-sites +``` + +Or grant `hernani` write on `/var/www/monitoring-sites` once so the host-agent can rsync itself. diff --git a/scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh b/scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh new file mode 100755 index 0000000..75fa3e4 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh @@ -0,0 +1,373 @@ +#!/usr/bin/env bash +# ensure-apt-deploy-test-containers.sh +# +# Four rootless podman containers on koopa (no nginx / no HTTPS): +# +# Fresh rebuild when repo fingerprint changes: +# koopa-taler-deploy-test-apt-src-trixie +# koopa-taler-deploy-test-apt-src-trixie-testing +# +# Persistent upgrade path (mytops upgrade.sh style): +# koopa-taler-deploy-test-apt-src-trixie-upgrade +# koopa-taler-deploy-test-apt-src-trixie-testing-upgrade +# +# Run as hernani on koopa. From elsewhere use run-aptdeploy.sh (ssh koopa-external). +# +set -euo pipefail + +PODMAN_BIN="${APT_DEPLOY_PODMAN:-podman}" +IMG="${APT_DEPLOY_BASE_IMAGE:-docker.io/library/debian:trixie}" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/taler-apt-deploy" +URI_BASE="${APT_DEPLOY_URI:-https://deb.taler.net/apt/debian}" +mkdir -p "$STATE_DIR" + +# Packages we track for "new version → rebuild fresh containers" +TRACK_PKGS="${APT_DEPLOY_TRACK_PKGS:-taler-merchant libtalermerchant libtalerexchange libdonau}" + +# Canonical four containers (keep). Everything else matching legacy names is removed. +CANONICAL_CONTAINERS=( + koopa-taler-deploy-test-apt-src-trixie + koopa-taler-deploy-test-apt-src-trixie-testing + koopa-taler-deploy-test-apt-src-trixie-upgrade + koopa-taler-deploy-test-apt-src-trixie-testing-upgrade +) + +# Legacy / typo names from earlier iterations — always delete if present +LEGACY_CONTAINERS=( + koopa-taler-build-test-apt-trixie + koopa-taler-build-test-apt-trixie-testing + koopa-taler-deploy-test-apt-trixie + koopa-taler-deploy-test-apt-trixie-testing + koopa-taler-buidl-test-apt-trixie + koopa-taler-buidl-test-apt-trixie-testing +) + +cleanup_legacy() { + local n img + echo "======== cleanup legacy containers / images ========" + for n in "${LEGACY_CONTAINERS[@]}"; do + if "$PODMAN_BIN" container exists "$n" 2>/dev/null; then + echo "remove legacy container $n" + "$PODMAN_BIN" rm -f "$n" 2>/dev/null || true + fi + # old setup images for that name + if "$PODMAN_BIN" image exists "localhost/${n}:setup" 2>/dev/null; then + echo "remove legacy image localhost/${n}:setup" + "$PODMAN_BIN" rmi -f "localhost/${n}:setup" 2>/dev/null || true + fi + done + # dangling setup images not in canonical set + while read -r img; do + [ -n "$img" ] || continue + local base keep=0 + base=${img#localhost/} + base=${base%:setup} + for n in "${CANONICAL_CONTAINERS[@]}"; do + [ "$base" = "$n" ] && keep=1 && break + done + if [ "$keep" -eq 0 ] && [[ "$img" == localhost/koopa-taler-*-test-apt* ]]; then + echo "remove non-canonical image $img" + "$PODMAN_BIN" rmi -f "$img" 2>/dev/null || true + fi + done < <("$PODMAN_BIN" images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -E 'localhost/koopa-taler-.*-test-apt' || true) +} + +# Prefer single suite tree: if both ~/src/koopa-admin-log and ~/koopa-admin-log are +# full git clones, keep the newer (by HEAD commit time) and replace the older with a symlink. +dedupe_suite_repos() { + local a="$HOME/src/koopa-admin-log" b="$HOME/koopa-admin-log" + local ta tb + [ "${APT_DEPLOY_DEDUPE_REPOS:-1}" = "1" ] || return 0 + if [ -d "$a/.git" ] && [ -d "$b/.git" ] && [ ! -L "$a" ] && [ ! -L "$b" ]; then + ta=$(git -C "$a" log -1 --format=%ct 2>/dev/null || echo 0) + tb=$(git -C "$b" log -1 --format=%ct 2>/dev/null || echo 0) + echo "======== dedupe suite repos (both are git clones) ========" + echo " $a HEAD_ts=$ta" + echo " $b HEAD_ts=$tb" + if [ "$ta" -ge "$tb" ]; then + echo "keep $a · remove $b · symlink $b → $a" + rm -rf "$b" + ln -sfn "$a" "$b" + else + echo "keep $b · remove $a · symlink $a → $b" + rm -rf "$a" + mkdir -p "$(dirname "$a")" + ln -sfn "$b" "$a" + fi + elif [ -d "$a/scripts/taler-monitoring" ] && [ -d "$b/scripts/taler-monitoring" ] \ + && [ ! -d "$a/.git" ] && [ -d "$b/.git" ] && [ ! -L "$a" ]; then + echo "remove non-git tree $a · symlink → $b" + rm -rf "$a" + mkdir -p "$(dirname "$a")" + ln -sfn "$b" "$a" + elif [ -d "$b/scripts/taler-monitoring" ] && [ -d "$a/scripts/taler-monitoring" ] \ + && [ ! -d "$b/.git" ] && [ -d "$a/.git" ] && [ ! -L "$b" ]; then + echo "remove non-git tree $b · symlink → $a" + rm -rf "$b" + ln -sfn "$a" "$b" + fi +} + +cleanup_legacy +dedupe_suite_repos +# --------------------------------------------------------------------------- +# Repo fingerprint: Version: lines from suite Packages index +# --------------------------------------------------------------------------- +repo_pkg_version() { + local suite=$1 pkg=$2 + curl -fsS --max-time 45 \ + "${URI_BASE}/dists/${suite}/main/binary-amd64/Packages" 2>/dev/null \ + | awk -v p="$pkg" ' + $0 == "Package: " p { want=1; next } + /^Package: / { want=0 } + want && /^Version: / { print $2; exit } + ' +} + +repo_fingerprint() { + local suite=$1 pkg v line="" + for pkg in $TRACK_PKGS; do + v=$(repo_pkg_version "$suite" "$pkg" || true) + [ -n "$v" ] || v="?" + line="${line}${pkg}=${v};" + done + printf '%s' "$line" +} + +container_fingerprint() { + local name=$1 + "$PODMAN_BIN" exec "$name" bash -lc ' + for p in '"$TRACK_PKGS"'; do + v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo missing) + printf "%s=%s;" "$p" "$v" + done + ' 2>/dev/null || echo "missing" +} + +# --------------------------------------------------------------------------- +# Bootstrap: install base + taler source + packages (shared) +# --------------------------------------------------------------------------- +install_base_and_merchant() { + local name=$1 suite=$2 srcfile=$3 + "$PODMAN_BIN" exec -u root "$name" bash -lc ' + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq ca-certificates curl wget gnupg systemd systemd-sysv \ + dbus procps iproute2 + ' + "$PODMAN_BIN" exec -u root "$name" bash -lc " + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + mkdir -p /etc/apt/keyrings + wget -q -O /etc/apt/keyrings/taler-systems.gpg https://taler.net/taler-systems.gpg + cat >/etc/apt/sources.list.d/${srcfile} </dev/null + systemctl enable taler-merchant.target 2>/dev/null + systemctl start taler-merchant.target 2>/dev/null + sleep 2 + systemctl is-active taler-merchant-httpd.service 2>/dev/null || true + ' || true +} + +create_from_scratch() { + local name=$1 suite=$2 srcfile=$3 + echo "======== CREATE (fresh) $name Suites: $suite ========" + "$PODMAN_BIN" pull "$IMG" >/dev/null + "$PODMAN_BIN" rm -f "$name" 2>/dev/null || true + # drop previous setup image so we do not accumulate duplicates + "$PODMAN_BIN" rmi -f "localhost/${name}:setup" 2>/dev/null || true + "$PODMAN_BIN" run -d --name "$name" --hostname "$name" "$IMG" sleep infinity + install_base_and_merchant "$name" "$suite" "$srcfile" + commit_and_systemd "$name" + printf '%s\n' "$(repo_fingerprint "$suite")" >"$STATE_DIR/${name}.fp" + printf '%s\n' "$(container_fingerprint "$name")" >"$STATE_DIR/${name}.installed" + echo "created $name fp=$(cat "$STATE_DIR/${name}.fp")" +} + +# --------------------------------------------------------------------------- +# Upgrade path (mytops-admin-log upgrade.sh merchant packages, no restic/nginx) +# Prints a markdown-ish table of before → after versions. +# --------------------------------------------------------------------------- +upgrade_inside() { + local name=$1 + local before after pkg v0 v1 + echo "======== UPGRADE $name (mytops-style apt install + upgrade) ========" + + before=$("$PODMAN_BIN" exec "$name" bash -lc ' + for p in taler-merchant libtalermerchant libtalerexchange libdonau libgnunet \ + taler-merchant-webui taler-merchant-typst taler-terms-generator; do + v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo "-") + printf "%s %s\n" "$p" "$v" + done + ' 2>/dev/null || true) + + # capture upgrade output + set +e + "$PODMAN_BIN" exec -u root "$name" bash -lc ' + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + systemctl stop taler-merchant.target 2>/dev/null || true + systemctl stop taler-merchant-donaukeyupdate.service 2>/dev/null || true + apt-get update -qq + apt-get install -y \ + taler-merchant \ + taler-merchant-typst \ + taler-merchant-webui \ + taler-terms-generator \ + || true + if dpkg -l challenger-httpd 2>/dev/null | grep -q "^ii"; then + apt-get remove -y challenger-httpd || true + fi + apt-get upgrade -y + systemctl daemon-reload 2>/dev/null || true + systemctl enable taler-merchant.target 2>/dev/null || true + # try dbconfig if present (may no-op without full config) + command -v taler-merchant-dbconfig >/dev/null && taler-merchant-dbconfig 2>/dev/null || true + systemctl reset-failed "taler-merchant*" 2>/dev/null || true + systemctl start taler-merchant.target 2>/dev/null || true + sleep 2 + ' 2>&1 | tee "$STATE_DIR/${name}.upgrade.log" | tail -30 + set -e + + after=$("$PODMAN_BIN" exec "$name" bash -lc ' + for p in taler-merchant libtalermerchant libtalerexchange libdonau libgnunet \ + taler-merchant-webui taler-merchant-typst taler-terms-generator; do + v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo "-") + printf "%s %s\n" "$p" "$v" + done + ' 2>/dev/null || true) + + echo + echo "| package | before | after | change |" + echo "|---------|--------|-------|--------|" + while read -r pkg v0; do + [ -n "${pkg:-}" ] || continue + v1=$(printf '%s\n' "$after" | awk -v p="$pkg" '$1==p {print $2; exit}') + v1=${v1:--} + if [ "$v0" = "$v1" ]; then + ch="=" + else + ch="UPGRADED" + fi + echo "| $pkg | $v0 | $v1 | $ch |" + done <<<"$before" + + printf '%s\n' "$(container_fingerprint "$name")" >"$STATE_DIR/${name}.installed" +} + +# --------------------------------------------------------------------------- +# Fresh mode: recreate if missing / not running / repo fp changed +# --------------------------------------------------------------------------- +ensure_fresh() { + local name=$1 suite=$2 srcfile=$3 + local want have running + + want=$(repo_fingerprint "$suite") + echo "repo fingerprint [$suite]: $want" + + if ! "$PODMAN_BIN" container exists "$name" 2>/dev/null; then + create_from_scratch "$name" "$suite" "$srcfile" + return + fi + + running=$("$PODMAN_BIN" inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false) + have=$(container_fingerprint "$name") + echo "installed in $name: $have" + + if [ "$running" != "true" ] \ + || ! "$PODMAN_BIN" exec "$name" bash -lc 'command -v taler-merchant-httpd' >/dev/null 2>&1 \ + || [ "$want" != "$have" ]; then + echo "rebuild $name (running=$running fp_match=$([ "$want" = "$have" ] && echo yes || echo NO))" + create_from_scratch "$name" "$suite" "$srcfile" + else + echo "OK $name up-to-date (fresh container matches repo)" + fi +} + +# --------------------------------------------------------------------------- +# Upgrade mode: create once, then always upgrade_inside +# --------------------------------------------------------------------------- +ensure_upgrade() { + local name=$1 suite=$2 srcfile=$3 + local running + + if ! "$PODMAN_BIN" container exists "$name" 2>/dev/null; then + create_from_scratch "$name" "$suite" "$srcfile" + echo "(initial install for upgrade-track container $name)" + return + fi + + running=$("$PODMAN_BIN" inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false) + if [ "$running" != "true" ]; then + echo "start $name" + "$PODMAN_BIN" start "$name" || { + echo "recreate broken $name" + create_from_scratch "$name" "$suite" "$srcfile" + return + } + sleep 3 + fi + + if ! "$PODMAN_BIN" exec "$name" bash -lc 'command -v taler-merchant-httpd' >/dev/null 2>&1; then + echo "recreate $name (no httpd after start)" + create_from_scratch "$name" "$suite" "$srcfile" + return + fi + + upgrade_inside "$name" +} + +MODE="${1:-all}" # all | fresh | upgrade + +case "$MODE" in + all|fresh) + ensure_fresh koopa-taler-deploy-test-apt-src-trixie trixie taler.sources + ensure_fresh koopa-taler-deploy-test-apt-src-trixie-testing trixie-testing taler-testing.sources + ;;& + all|upgrade) + ensure_upgrade koopa-taler-deploy-test-apt-src-trixie-upgrade trixie taler.sources + ensure_upgrade koopa-taler-deploy-test-apt-src-trixie-testing-upgrade trixie-testing taler-testing.sources + ;; + *) + echo "usage: $0 [all|fresh|upgrade]" >&2 + exit 2 + ;; +esac + +echo "done ensure-apt-deploy-test-containers mode=$MODE" diff --git a/scripts/taler-monitoring/host-agent/env.example b/scripts/taler-monitoring/host-agent/env.example new file mode 100644 index 0000000..15f6bae --- /dev/null +++ b/scripts/taler-monitoring/host-agent/env.example @@ -0,0 +1,26 @@ +# Copy to: ~/.config/taler-monitoring/env +# This file is NOT inside the git tree and is never overwritten by update-suite.sh. +# +# Full stack examples (same reporting generation for all): +# env/hacktivism.env.example +# env/stagepaysan.env.example +# env/francpaysan.env.example +# +# SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git +# SUITE_GIT_REF=main +# SUITE_DIR=$HOME/src/koopa-admin-log +# SUITE_UPDATE_MODE=reset +# +# TALER_DOMAIN=hacktivism.ch +# INSIDE_PODMAN=1 +# INSIDE_MODE=local-podman +# CONTINUE_ON_ERROR=1 +# PHASES for the *default* hacktivism timer only (urls inside versions). +# Dedicated wrappers always override PHASES + HTML paths: +# run-surface-monitoring.sh → /taler-monitoring-surface* +# run-aptdeploy-monitoring.sh → /taler-monitoring-aptdeploy* +# PHASES="urls inside versions" +# RUN_TIMEOUT=600 +# HTML_OUT=$HOME/monitoring-sites-staging +# MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" +# SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log diff --git a/scripts/taler-monitoring/host-agent/env/francpaysan.env.example b/scripts/taler-monitoring/host-agent/env/francpaysan.env.example new file mode 100644 index 0000000..ab1c534 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/env/francpaysan.env.example @@ -0,0 +1,24 @@ +# Copy to: ~/.config/taler-monitoring/env (user francpaysan) +# Never inside the git tree — update-suite never overwrites this file. +# +# Shared reporting generation (run-host-report.sh / run-fp-prod-monitoring.sh): +# RUN_TIMEOUT, line-buffered log, always HTML, commit link on git.hacktivism.ch + +TALER_DOMAIN=lefrancpaysan.ch +INSIDE_PODMAN=1 +INSIDE_MODE=local-podman +LOCAL_STACK=0 +CONTINUE_ON_ERROR=1 +PHASES="urls inside versions" +RUN_TIMEOUT=600 + +HTML_OUT=$HOME/monitoring-sites-staging +MON_HOSTS="bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch" + +SUITE_DIR=$HOME/src/koopa-admin-log +SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git +SUITE_GIT_REF=main +SUITE_UPDATE_MODE=reset +SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log + +# DEPLOY_WWW_ROOT=/var/www/monitoring-sites diff --git a/scripts/taler-monitoring/host-agent/env/hacktivism.env.example b/scripts/taler-monitoring/host-agent/env/hacktivism.env.example new file mode 100644 index 0000000..6b57280 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/env/hacktivism.env.example @@ -0,0 +1,24 @@ +# Copy to: ~/.config/taler-monitoring/env (user hernani on koopa) +# Shared reporting generation: RUN_TIMEOUT, line-buffered log, always HTML. + +TALER_DOMAIN=hacktivism.ch +INSIDE_PODMAN=1 +INSIDE_MODE=local-podman +LOCAL_STACK=1 +CONTINUE_ON_ERROR=1 +PHASES="urls inside versions" +# aptdeploy HTML: run-aptdeploy-monitoring.sh → /taler-monitoring-aptdeploy* +RUN_TIMEOUT=600 +# APT_DEPLOY_ENSURE=1 +# APT_DEPLOY_SKIP=0 + +HTML_OUT=$HOME/monitoring-sites-staging +MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" + +SUITE_DIR=$HOME/src/koopa-admin-log +SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git +SUITE_GIT_REF=main +SUITE_UPDATE_MODE=reset +SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log + +DEPLOY_WWW_ROOT=/var/www/monitoring-sites diff --git a/scripts/taler-monitoring/host-agent/env/stagepaysan.env.example b/scripts/taler-monitoring/host-agent/env/stagepaysan.env.example new file mode 100644 index 0000000..0959ebc --- /dev/null +++ b/scripts/taler-monitoring/host-agent/env/stagepaysan.env.example @@ -0,0 +1,27 @@ +# Copy to: ~/.config/taler-monitoring/env (user stagepaysan) +# Never inside the git tree — update-suite never overwrites this file. +# +# Shared reporting generation (run-host-report.sh / run-fp-stage-monitoring.sh): +# RUN_TIMEOUT, line-buffered log, always HTML, commit link on git.hacktivism.ch + +TALER_DOMAIN=stage.lefrancpaysan.ch +INSIDE_PODMAN=1 +INSIDE_MODE=local-podman +INSIDE_PROFILE=stage-lfp +LOCAL_STACK=0 +CONTINUE_ON_ERROR=1 +PHASES="urls inside versions" +RUN_TIMEOUT=600 + +HTML_OUT=$HOME/monitoring-sites-staging +MON_HOSTS="stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch" + +# Git clone of koopa-admin-log (repo root, not …/scripts/taler-monitoring) +SUITE_DIR=$HOME/src/koopa-admin-log +SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git +SUITE_GIT_REF=main +SUITE_UPDATE_MODE=reset +SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log + +# Optional public docroot if this user can write it +# DEPLOY_WWW_ROOT=/var/www/monitoring-sites diff --git a/scripts/taler-monitoring/host-agent/install-host-agent.sh b/scripts/taler-monitoring/host-agent/install-host-agent.sh new file mode 100755 index 0000000..adcce66 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/install-host-agent.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# install-host-agent.sh — install hernani user systemd units on koopa +# +# # on koopa as hernani: +# ~/koopa-admin-log/scripts/taler-monitoring/host-agent/install-host-agent.sh +# # or from laptop: +# ./install-host-agent.sh --remote koopa-external +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +REMOTE="${1:-}" +[ "${1:-}" = "--remote" ] && REMOTE="${2:-koopa-external}" + +install_local() { + local mon home_unit + mon=$(cd "$ROOT/.." && pwd) + # Prefer ~/koopa-admin-log on live host + if [ -d "$HOME/koopa-admin-log/scripts/taler-monitoring" ]; then + mon="$HOME/koopa-admin-log/scripts/taler-monitoring" + fi + home_unit="$HOME/.config/systemd/user" + mkdir -p "$home_unit" \ + "$HOME/.local/state/taler-hacktivism-monitoring" \ + "$HOME/monitoring-sites-staging" \ + "$HOME/.config/taler-monitoring" \ + "$HOME/src" + + # Local env (never in git) — create from example if missing + if [ ! -f "$HOME/.config/taler-monitoring/env" ] && [ -f "$ROOT/env.example" ]; then + cp "$ROOT/env.example" "$HOME/.config/taler-monitoring/env" + echo "created ~/.config/taler-monitoring/env" + fi + + # Prefer a real git clone for update-suite.sh + if [ ! -d "$HOME/src/koopa-admin-log/.git" ] && [ ! -d "$HOME/koopa-admin-log/.git" ]; then + if [ -d "$HOME/koopa-admin-log/scripts/taler-monitoring" ] && [ ! -d "$HOME/koopa-admin-log/.git" ]; then + echo "note: $HOME/koopa-admin-log has no .git — update-suite will clone to ~/src/koopa-admin-log" + fi + git clone https://git.hacktivism.ch/hernani/koopa-admin-log.git "$HOME/src/koopa-admin-log" 2>/dev/null \ + || echo "WARN: clone failed (auth/network) — will use existing tree if any" + fi + + # Ensure suite is executable + chmod +x "$mon"/taler-monitoring.sh "$mon"/check_*.sh 2>/dev/null || true + chmod +x "$mon/host-agent"/*.sh 2>/dev/null || true + chmod +x "$mon/site-gen"/*.sh "$mon/site-gen/console_to_html.py" 2>/dev/null || true + + # Units reference %h/koopa-admin-log — prefer git clone or symlink + if [ -d "$HOME/src/koopa-admin-log/.git" ] && [ ! -e "$HOME/koopa-admin-log" ]; then + ln -sfn "$HOME/src/koopa-admin-log" "$HOME/koopa-admin-log" + echo "symlink $HOME/koopa-admin-log → $HOME/src/koopa-admin-log" + elif [ ! -e "$HOME/koopa-admin-log" ]; then + admin_root=$(cd "$mon/../.." && pwd) + ln -sfn "$admin_root" "$HOME/koopa-admin-log" + echo "symlink $HOME/koopa-admin-log → $admin_root" + fi + + install -m 644 "$ROOT/taler-monitoring-hacktivism.service" "$home_unit/" + install -m 644 "$ROOT/taler-monitoring-hacktivism.path" "$home_unit/" + install -m 644 "$ROOT/taler-monitoring-hacktivism.timer" "$home_unit/" + install -m 644 "$ROOT/taler-monitoring-surface.service" "$home_unit/" + install -m 644 "$ROOT/taler-monitoring-surface.timer" "$home_unit/" + install -m 644 "$ROOT/taler-monitoring-aptdeploy.service" "$home_unit/" + install -m 644 "$ROOT/taler-monitoring-aptdeploy.timer" "$home_unit/" + + # Fix ExecStart if install path differs (units use %h/koopa-admin-log) + systemctl --user daemon-reload + systemctl --user enable --now taler-monitoring-hacktivism.path + systemctl --user enable --now taler-monitoring-hacktivism.timer + systemctl --user enable --now taler-monitoring-surface.timer + systemctl --user enable --now taler-monitoring-aptdeploy.timer + # initial stamp so path exists + "$ROOT/touch-software-stamp.sh" + systemctl --user start taler-monitoring-hacktivism.service || true + systemctl --user start taler-monitoring-surface.service || true + systemctl --user start taler-monitoring-aptdeploy.service || true + + echo "--- status ---" + systemctl --user status taler-monitoring-hacktivism.path --no-pager -l | head -15 + systemctl --user status taler-monitoring-hacktivism.timer --no-pager -l | head -12 + systemctl --user status taler-monitoring-surface.timer --no-pager -l | head -12 + systemctl --user status taler-monitoring-aptdeploy.timer --no-pager -l | head -12 + systemctl --user list-timers --all | grep taler-monitoring || true + echo "OK host-agent (hacktivism 4h + surface 1h + aptdeploy 4h)" + echo "manual: systemctl --user start taler-monitoring-hacktivism.service" + echo "manual surface: systemctl --user start taler-monitoring-surface.service" + echo "manual aptdeploy: systemctl --user start taler-monitoring-aptdeploy.service" + echo "after upgrades: $ROOT/touch-software-stamp.sh" +} + +if [ -n "$REMOTE" ] && [ "$REMOTE" != "--remote" ]; then + echo "install on $REMOTE …" + rsync -az --delete \ + --exclude secrets.env \ + --exclude 'android-test/artifacts' \ + "$ROOT/../../" \ + "${REMOTE}:koopa-admin-log/scripts/taler-monitoring/" + # ensure full tree for %h/koopa-admin-log if missing configs etc. + ssh -o BatchMode=yes -o ConnectTimeout=20 "$REMOTE" \ + 'test -d $HOME/koopa-admin-log || git clone https://git.hacktivism.ch/hernani/koopa-admin-log.git $HOME/koopa-admin-log 2>/dev/null || true' + rsync -az "$ROOT/" "${REMOTE}:koopa-admin-log/scripts/taler-monitoring/host-agent/" + rsync -az "$ROOT/../site-gen/" "${REMOTE}:koopa-admin-log/scripts/taler-monitoring/site-gen/" 2>/dev/null || true + ssh -o BatchMode=yes "$REMOTE" \ + 'bash $HOME/koopa-admin-log/scripts/taler-monitoring/host-agent/install-host-agent.sh' +else + install_local +fi diff --git a/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh b/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh new file mode 100755 index 0000000..fe81257 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# run-aptdeploy-monitoring.sh — apt-src deploy tests → public HTML +# Public only on taler.hacktivism.ch: +# https://taler.hacktivism.ch/taler-monitoring-aptdeploy/ +# https://taler.hacktivism.ch/taler-monitoring-aptdeploy_err/ +# +# CLI one-shot without HTML remains: run-aptdeploy.sh [all|fresh|upgrade|check-only] +# +set -uo pipefail +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) + +export AGENT_LABEL="${AGENT_LABEL:-aptdeploy-host-agent}" +export STATE_NAME="${STATE_NAME:-taler-aptdeploy-monitoring}" +export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}" +# containers are local on koopa +export INSIDE_PODMAN=1 +export INSIDE_MODE=local-podman +export LOCAL_STACK=1 +export SKIP_SSH=0 +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +# four containers + checks; allow long wall +export RUN_TIMEOUT="${RUN_TIMEOUT:-1800}" +# dedicated job: always aptdeploy only +export PHASES=aptdeploy +# Only merchant public front — not bank/exchange +export MON_HOSTS="${MON_HOSTS:-taler.hacktivism.ch}" +export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}" +export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" + +export HTML_OK_DIR="taler-monitoring-aptdeploy" +export HTML_ERR_DIR="taler-monitoring-aptdeploy_err" +export HTML_URL_OK="/taler-monitoring-aptdeploy/" +export HTML_URL_ERR="/taler-monitoring-aptdeploy_err/" +export PAGE_LABEL="taler-monitoring-aptdeploy" + +# Ensure all four apt-src containers (fresh rebuild + upgrade track) +if [ "${APT_DEPLOY_ENSURE:-1}" = "1" ] \ + && [ -x "$AGENT_DIR/ensure-apt-deploy-test-containers.sh" ]; then + bash "$AGENT_DIR/ensure-apt-deploy-test-containers.sh" all \ + || echo "WARN: ensure-apt-deploy-test-containers failed (aptdeploy may ERROR)" >&2 +fi + +exec bash "$AGENT_DIR/run-host-report.sh" "$@" diff --git a/scripts/taler-monitoring/host-agent/run-aptdeploy.sh b/scripts/taler-monitoring/host-agent/run-aptdeploy.sh new file mode 100755 index 0000000..fbf5afd --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-aptdeploy.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# run-aptdeploy.sh — ensure containers + aptdeploy phase only. +# +# Keep monitoring current: +# 1) Prefer Forgejo origin/main (update-suite reset) +# 2) If Forgejo is behind (no aptdeploy yet), re-apply suite-overlay saved +# from this agent tree before reset (rsync from laptop fills that tree) +# 3) Run ensure + taler-monitoring.sh aptdeploy +# +# Host: on koopa → local; else ssh APT_DEPLOY_SSH + rsync mon tree first. +# +set -euo pipefail + +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) +BOOT_MON=$(cd "$AGENT_DIR/.." && pwd) +MODE="${1:-all}" +APT_DEPLOY_SSH="${APT_DEPLOY_SSH:-koopa-external}" +OVERLAY_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/suite-overlay" + +on_koopa() { + local h + h=$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo "") + case "$h" in koopa|koopa.*) return 0 ;; esac + [ "${KOOPA_FORCE_LOCAL:-0}" = "1" ] && return 0 + return 1 +} + +mon_has_aptdeploy() { + local mon=$1 + [ -f "$mon/taler-monitoring.sh" ] && grep -qE 'aptdeploy\|apt-deploy' "$mon/taler-monitoring.sh" +} + +save_overlay_from() { + local src=$1 + mon_has_aptdeploy "$src" || return 0 + mkdir -p "$OVERLAY_DIR" + rsync -a --delete \ + --exclude secrets.env \ + --exclude 'android-test/artifacts' \ + --exclude 'android-test/apks' \ + --exclude 'android-test/out*' \ + --exclude '__pycache__' \ + "$src/" "$OVERLAY_DIR/" + echo "saved suite-overlay from $src → $OVERLAY_DIR" +} + +apply_overlay_to() { + local dest=$1 + [ -f "$OVERLAY_DIR/taler-monitoring.sh" ] || return 1 + mon_has_aptdeploy "$OVERLAY_DIR" || return 1 + mkdir -p "$dest" + rsync -a \ + --exclude secrets.env \ + --exclude 'android-test/artifacts' \ + --exclude 'android-test/apks' \ + --exclude 'android-test/out*' \ + --exclude '__pycache__' \ + "$OVERLAY_DIR/" "$dest/" + echo "applied suite-overlay → $dest (Forgejo lag; push origin/main when possible)" + return 0 +} + +pick_mon_tree() { + # Preserve working mon before reset wipes tracked files + save_overlay_from "$BOOT_MON" + + # shellcheck source=update-suite.sh + source "$AGENT_DIR/update-suite.sh" + + local suite_mon="$SUITE_DIR/scripts/taler-monitoring" + mkdir -p "$suite_mon" + + if mon_has_aptdeploy "$suite_mon"; then + MON_DIR="$suite_mon" + echo "using Forgejo suite mon @ ${COMMIT_SHORT:-?} · $MON_DIR" + elif apply_overlay_to "$suite_mon" && mon_has_aptdeploy "$suite_mon"; then + MON_DIR="$suite_mon" + echo "using suite mon + overlay @ ${COMMIT_SHORT:-?} · $MON_DIR" + elif mon_has_aptdeploy "$BOOT_MON"; then + MON_DIR="$BOOT_MON" + echo "WARN: using boot mon $MON_DIR (overlay apply failed)" >&2 + else + echo "ERROR: no mon tree with aptdeploy (Forgejo + overlay empty)" >&2 + echo " rsync suite from laptop or push to git.hacktivism.ch" >&2 + exit 2 + fi + + AGENT_DIR="$MON_DIR/host-agent" + export MON_DIR AGENT_DIR SUITE_DIR COMMIT COMMIT_SHORT COMMIT_URL + chmod +x "$AGENT_DIR"/*.sh "$MON_DIR"/taler-monitoring.sh "$MON_DIR"/check_*.sh 2>/dev/null || true +} + +run_local() { + export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" + export PYTHONUNBUFFERED=1 + export PROGRESS_OFF="${PROGRESS_OFF:-0}" + + echo "======== aptdeploy local on $(hostname) mode=$MODE ========" + pick_mon_tree + + if [ "$MODE" != "check-only" ]; then + bash "$AGENT_DIR/ensure-apt-deploy-test-containers.sh" "$MODE" + fi + + cd "$MON_DIR" + export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" + export INSIDE_PODMAN="${INSIDE_PODMAN:-1}" + export LOCAL_STACK="${LOCAL_STACK:-1}" + export RUN_TIMEOUT="${RUN_TIMEOUT:-600}" + echo "run: $MON_DIR/taler-monitoring.sh -d hacktivism.ch aptdeploy · suite=${COMMIT_SHORT:-?}" + ./taler-monitoring.sh -d hacktivism.ch aptdeploy +} + +run_remote() { + echo "======== aptdeploy remote via ssh ${APT_DEPLOY_SSH} mode=$MODE ========" + if [ "${APT_DEPLOY_RSYNC:-1}" = "1" ]; then + echo "rsync mon suite → ${APT_DEPLOY_SSH}:src/koopa-admin-log/scripts/taler-monitoring/" + rsync -az \ + --exclude secrets.env \ + --exclude 'android-test/artifacts' \ + --exclude 'android-test/apks' \ + --exclude 'android-test/out*' \ + --exclude '__pycache__' \ + "$BOOT_MON/" \ + "${APT_DEPLOY_SSH}:src/koopa-admin-log/scripts/taler-monitoring/" \ + || echo "WARN: rsync failed" >&2 + fi + ssh -o BatchMode=yes -o ConnectTimeout=25 "$APT_DEPLOY_SSH" bash -s -- "$MODE" <<'EOS' +set -euo pipefail +MODE=${1:-all} +export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" +export KOOPA_FORCE_LOCAL=1 +AGENT="$HOME/src/koopa-admin-log/scripts/taler-monitoring/host-agent" +if [ ! -x "$AGENT/run-aptdeploy.sh" ]; then + AGENT="$HOME/koopa-admin-log/scripts/taler-monitoring/host-agent" +fi +if [ ! -x "$AGENT/run-aptdeploy.sh" ]; then + echo "ERROR: run-aptdeploy.sh missing on remote" >&2 + exit 1 +fi +echo "remote AGENT=$AGENT" +exec bash "$AGENT/run-aptdeploy.sh" "$MODE" +EOS +} + +if on_koopa; then + run_local +else + run_remote +fi diff --git a/scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh b/scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh new file mode 100755 index 0000000..be876a8 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# FrancPaysan PROD host-agent (user francpaysan on francpaysan-host). +# Shared reporting: run-host-report.sh (timeout, line-buffered log, first-run HTML). +set -uo pipefail +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) + +export AGENT_LABEL="${AGENT_LABEL:-fp-prod-host-agent}" +export STATE_NAME="${STATE_NAME:-taler-monitoring-lfp-prod}" +export TALER_DOMAIN="${TALER_DOMAIN:-lefrancpaysan.ch}" +export INSIDE_PODMAN="${INSIDE_PODMAN:-1}" +export INSIDE_MODE="${INSIDE_MODE:-local-podman}" +export LOCAL_STACK="${LOCAL_STACK:-0}" +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +export RUN_TIMEOUT="${RUN_TIMEOUT:-600}" +export PHASES="${PHASES:-urls inside versions}" +export MON_HOSTS="${MON_HOSTS:-bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch}" +export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}" +export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" + +exec bash "$AGENT_DIR/run-host-report.sh" "$@" diff --git a/scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh b/scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh new file mode 100755 index 0000000..70518f0 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# FrancPaysan STAGE host-agent (user stagepaysan on francpaysan-host). +# Shared reporting: run-host-report.sh (timeout, line-buffered log, first-run HTML). +set -uo pipefail +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) + +export AGENT_LABEL="${AGENT_LABEL:-fp-stage-host-agent}" +export STATE_NAME="${STATE_NAME:-taler-monitoring-stage-lfp}" +export TALER_DOMAIN="${TALER_DOMAIN:-stage.lefrancpaysan.ch}" +export INSIDE_PODMAN="${INSIDE_PODMAN:-1}" +export INSIDE_MODE="${INSIDE_MODE:-local-podman}" +export INSIDE_PROFILE="${INSIDE_PROFILE:-stage-lfp}" +export LOCAL_STACK="${LOCAL_STACK:-0}" +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +export RUN_TIMEOUT="${RUN_TIMEOUT:-600}" +export PHASES="${PHASES:-urls inside versions}" +export MON_HOSTS="${MON_HOSTS:-stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch}" +export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}" +export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" + +exec bash "$AGENT_DIR/run-host-report.sh" "$@" diff --git a/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh b/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh new file mode 100755 index 0000000..8fd98fd --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# GOA / hacktivism host-agent on koopa (hernani). +# Stack defaults only — reporting pipeline is shared: run-host-report.sh +set -uo pipefail +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) + +export AGENT_LABEL="${AGENT_LABEL:-hacktivism-host-agent}" +export STATE_NAME="${STATE_NAME:-taler-hacktivism-monitoring}" +export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}" +export INSIDE_PODMAN="${INSIDE_PODMAN:-1}" +export INSIDE_MODE="${INSIDE_MODE:-local-podman}" +export LOCAL_STACK="${LOCAL_STACK:-1}" +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +export RUN_TIMEOUT="${RUN_TIMEOUT:-600}" +# stack checks only — aptdeploy has its own HTML path + timer: +# run-aptdeploy-monitoring.sh → /taler-monitoring-aptdeploy* (taler.hacktivism.ch) +export PHASES="${PHASES:-urls inside versions}" +export MON_HOSTS="${MON_HOSTS:-bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch}" +export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}" +export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" +export APT_DEPLOY_ENSURE=0 + +exec bash "$AGENT_DIR/run-host-report.sh" "$@" diff --git a/scripts/taler-monitoring/host-agent/run-host-report.sh b/scripts/taler-monitoring/host-agent/run-host-report.sh new file mode 100755 index 0000000..1be1def --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-host-report.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env bash +# run-host-report.sh — global reporting generation for all stacks +# (GOA/hacktivism, FP stage, FP prod, …). +# +# Shared behaviour (always): +# 1) update-suite → latest git.hacktivism.ch (local ~/.config never overwritten) +# 2) line-buffered run log (written continuously) +# 3) RUN_TIMEOUT wall clock for taler-monitoring.sh (default 600s) +# 4) always write HTML (first run / empty staging too) + Forgejo commit link +# 5) optional rsync to DEPLOY_WWW_ROOT if writable +# +# Configure via env file (not in git): +# ${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/env +# or TALER_MONITORING_ENV=/path/to/env +# +# Thin wrappers only set stack defaults then exec this script: +# run-hacktivism-monitoring.sh +# run-fp-stage-monitoring.sh +# run-fp-prod-monitoring.sh +# +set -uo pipefail + +export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" +export PYTHONUNBUFFERED=1 + +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) +AGENT_MON=$(cd "$AGENT_DIR/.." && pwd) +CFG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring" +ENV_FILE="${TALER_MONITORING_ENV:-$CFG_DIR/env}" +mkdir -p "$CFG_DIR" + +# Thin wrappers (surface, aptdeploy-only, …) export stack knobs before exec. +# Snapshot them so ~/.config/taler-monitoring/env cannot clobber dedicated jobs +# (env often has PHASES="urls inside versions aptdeploy" for the main timer). +_wrap_keys=( + PHASES HTML_OUT HTML_OK_DIR HTML_ERR_DIR HTML_URL_OK HTML_URL_ERR PAGE_LABEL + RUN_TIMEOUT STATE_NAME AGENT_LABEL MON_HOSTS TALER_DOMAIN DEPLOY_WWW_ROOT + INSIDE_PODMAN INSIDE_MODE LOCAL_STACK SKIP_SSH CONTINUE_ON_ERROR AUTH401_CONTINUE + APT_DEPLOY_ENSURE SURFACE_SCOPE TALER_DOMAIN_FROM_CLI STRICT_EXIT SOURCE_SUITE_PATH +) +for _k in "${_wrap_keys[@]}"; do + if [ -n "${!_k+x}" ]; then + printf -v "_WRAP_SET_${_k}" '%s' 1 + printf -v "_WRAP_VAL_${_k}" '%s' "${!_k}" + fi +done +unset _k + +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" + set +a +fi +# Tell update-suite.sh not to re-source env (would clobber wrapper PHASES etc.) +export _TALER_MON_ENV_LOADED=1 + +# Restore wrapper overrides (dedicated jobs win over shared env file) +for _k in "${_wrap_keys[@]}"; do + _set_var="_WRAP_SET_${_k}" + _val_var="_WRAP_VAL_${_k}" + if [ "${!_set_var:-}" = "1" ]; then + printf -v "$_k" '%s' "${!_val_var}" + export "$_k" + fi +done +unset _k _set_var _val_var + +# --- global report defaults (env file / wrapper wins if already set) --- +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +export AUTH401_CONTINUE="${AUTH401_CONTINUE:-${CONTINUE_ON_ERROR}}" +export RUN_TIMEOUT="${RUN_TIMEOUT:-600}" +export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}" +PHASES="${PHASES:-urls inside versions}" +HTML_BASE="${HTML_OUT:-$HOME/monitoring-sites-staging}" +MON_HOSTS="${MON_HOSTS:-}" +SUITE_PATH="${SOURCE_SUITE_PATH:-scripts/taler-monitoring}" +AGENT_LABEL="${AGENT_LABEL:-host-report}" +STATE_NAME="${STATE_NAME:-taler-monitoring-${TALER_DOMAIN}}" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/${STATE_NAME}" +LOG_DIR="$STATE_DIR/logs" +DEPLOY_WWW="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" +# HTML path layout under each host (monitoring / surface / …) +HTML_OK_DIR="${HTML_OK_DIR:-monitoring}" +HTML_ERR_DIR="${HTML_ERR_DIR:-monitoring_err}" +HTML_URL_OK="${HTML_URL_OK:-/monitoring/}" +HTML_URL_ERR="${HTML_URL_ERR:-/monitoring_err/}" +PAGE_LABEL="${PAGE_LABEL:-monitoring}" + +# Sensible MON_HOSTS from domain if not set +if [ -z "$MON_HOSTS" ]; then + case "$TALER_DOMAIN" in + hacktivism.ch|koopa) + MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" + ;; + stage.lefrancpaysan.ch|stage.*lefrancpaysan*) + MON_HOSTS="stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch" + ;; + lefrancpaysan.ch) + MON_HOSTS="bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch" + ;; + *) + MON_HOSTS="${TALER_DOMAIN}" + ;; + esac +fi + +mkdir -p "$LOG_DIR" "$HTML_BASE" + +STAMP=$(date +%Y%m%d-%H%M%S) +LOG="$LOG_DIR/run-${STAMP}.log" +: >"$LOG" +if command -v stdbuf >/dev/null 2>&1; then + exec > >(stdbuf -oL -eL tee -a "$LOG") 2>&1 +else + exec > >(tee -a "$LOG") 2>&1 +fi + +echo "======== $(date -Iseconds 2>/dev/null || date) ${AGENT_LABEL} ========" +echo "env_file=$ENV_FILE" +echo "domain=$TALER_DOMAIN · phases=$PHASES · RUN_TIMEOUT=${RUN_TIMEOUT}s" +echo "hosts=$MON_HOSTS" +echo "flags INSIDE_PODMAN=${INSIDE_PODMAN:-} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-} SKIP_SSH=${SKIP_SSH:-} INSIDE_PROFILE=${INSIDE_PROFILE:-}" + +# --- refresh from Forgejo; re-apply suite-overlay if Forgejo lacks needed phases --- +OVERLAY_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/suite-overlay" +# preserve capable mon before reset wipes tracked files +if [ -f "$AGENT_MON/taler-monitoring.sh" ]; then + mkdir -p "$OVERLAY_DIR" + rsync -a \ + --exclude secrets.env --exclude 'android-test/artifacts' --exclude 'android-test/apks' \ + --exclude 'android-test/out*' --exclude '__pycache__' \ + "$AGENT_MON/" "$OVERLAY_DIR/" 2>/dev/null || true +fi + +# shellcheck source=update-suite.sh +source "$AGENT_DIR/update-suite.sh" + +SUITE_MON="${SUITE_DIR}/scripts/taler-monitoring" +ROOT="$SUITE_MON" +_need_overlay=0 +# Detect missing features in Forgejo tree (untracked helper files alone are not enough — +# taler-monitoring.sh must accept the phase name). +case " $PHASES " in + *" aptdeploy "*|*" apt-deploy "*|*" apt_src "*) + if ! grep -qE 'aptdeploy\|apt-deploy' "$SUITE_MON/taler-monitoring.sh" 2>/dev/null; then + _need_overlay=1 + fi + ;; +esac +case " $PHASES " in + *" surface "*|*" ecosystem "*) + if ! grep -qE 'surface\|ecosystem' "$SUITE_MON/taler-monitoring.sh" 2>/dev/null \ + || [ ! -f "$SUITE_MON/check_surface.sh" ]; then + _need_overlay=1 + fi + ;; +esac +# Wrapper-preserve + HTML path knobs may live only in local overlay until pushed +if [ -f "$OVERLAY_DIR/host-agent/run-host-report.sh" ] \ + && ! grep -q '_wrap_keys\|_WRAP_SET_' "$SUITE_MON/host-agent/run-host-report.sh" 2>/dev/null \ + && grep -q '_wrap_keys\|_WRAP_SET_' "$OVERLAY_DIR/host-agent/run-host-report.sh" 2>/dev/null; then + _need_overlay=1 +fi +if [ "$_need_overlay" = "1" ] && [ -f "$OVERLAY_DIR/taler-monitoring.sh" ]; then + echo "WARN: Forgejo @ ${COMMIT_SHORT:-?} lagging local suite-overlay (phases=[$PHASES])" >&2 + echo " re-applying suite-overlay → $SUITE_MON" >&2 + mkdir -p "$SUITE_MON" + rsync -a \ + --exclude secrets.env --exclude 'android-test/artifacts' --exclude 'android-test/apks' \ + --exclude 'android-test/out*' --exclude '__pycache__' \ + "$OVERLAY_DIR/" "$SUITE_MON/" + ROOT="$SUITE_MON" +elif [ "$_need_overlay" = "1" ]; then + echo "WARN: Forgejo missing features and no suite-overlay — using agent mon $AGENT_MON" >&2 + ROOT="$AGENT_MON" +else + echo "using suite mon @ ${COMMIT_SHORT:-?} · $ROOT" +fi +# Always restore host-agent + site-gen from pre-reset snapshot when present +# (systemd ExecStart points at koopa-admin-log which may be the reset suite tree). +if [ -d "$OVERLAY_DIR/host-agent" ]; then + mkdir -p "$SUITE_MON/host-agent" 2>/dev/null || true + rsync -a "$OVERLAY_DIR/host-agent/" "$SUITE_MON/host-agent/" 2>/dev/null || true +fi +if [ -d "$OVERLAY_DIR/site-gen" ]; then + mkdir -p "$SUITE_MON/site-gen" + rsync -a "$OVERLAY_DIR/site-gen/" "$SUITE_MON/site-gen/" 2>/dev/null || true +fi +unset _need_overlay + +if [ -f "$ROOT/site-gen/console_to_html.py" ]; then + SITE_GEN="$ROOT/site-gen" +elif [ -f "$AGENT_MON/site-gen/console_to_html.py" ]; then + SITE_GEN="$AGENT_MON/site-gen" + echo "note: using agent site-gen at $SITE_GEN" +elif [ -f "$SUITE_MON/site-gen/console_to_html.py" ]; then + SITE_GEN="$SUITE_MON/site-gen" +else + SITE_GEN="" +fi + +if [ ! -x "$ROOT/taler-monitoring.sh" ]; then + if [ -x "$AGENT_MON/taler-monitoring.sh" ]; then + ROOT="$AGENT_MON" + echo "note: using agent mon tree $ROOT" + else + echo "ERROR: suite missing at $ROOT" >&2 + COMMIT="${COMMIT:-unknown}" + COMMIT_SHORT="${COMMIT_SHORT:-unknown}" + COMMIT_URL="${COMMIT_URL:-}" + fi +fi + +echo "commit=${COMMIT_SHORT:-?} · ${COMMIT_URL:-}" + +ec=1 +if [ -x "${ROOT:-}/taler-monitoring.sh" ]; then + cd "$ROOT" || exit 1 + chmod +x taler-monitoring.sh check_*.sh host-agent/*.sh site-gen/*.sh site-gen/*.py 2>/dev/null || true + + # Overlay newer inside/versions host-podman bits if suite clone is stale + for f in check_inside.sh check_versions.sh taler-monitoring.sh lib.sh; do + if [ -f "$AGENT_MON/$f" ] && [ -f "$ROOT/$f" ]; then + if ! grep -q 'host-podman\|INSIDE_ACCESS\|RUN_TIMEOUT' "$ROOT/$f" 2>/dev/null \ + && grep -q 'host-podman\|INSIDE_ACCESS\|RUN_TIMEOUT' "$AGENT_MON/$f" 2>/dev/null; then + echo "note: overlay $f from agent install" + cp -f "$AGENT_MON/$f" "$ROOT/$f" + chmod +x "$ROOT/$f" 2>/dev/null || true + fi + fi + done + + # shellcheck disable=SC2086 + ./taler-monitoring.sh -d "$TALER_DOMAIN" $PHASES + ec=$? +fi + +# --- always generate HTML (first run / missing pages / every run) --- +write_bootstrap_html() { + local out="$1" host="$2" mode="$3" + mkdir -p "$(dirname "$out")" + local cshort="${COMMIT_SHORT:-unknown}" + local curl="${COMMIT_URL:-}" + local clink="$cshort" + local gen iso + gen=$(date -u +"%Y-%m-%d %H:%M:%SZ" 2>/dev/null || date) + iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -Iseconds) + if [ -n "$curl" ]; then + clink="$cshort" + fi + cat >"$out" < + + +monitoring ${mode} · ${host} + + +
+

Bootstrap page (converter missing or first install). See host-agent log.

+
$(tail -n 80 "$LOG" 2>/dev/null | sed 's/&/\&/g;s/
+
+ +EOF + echo "bootstrap html → $out" +} + +htmlify_host() { + local host="$1" + local mon="$HTML_BASE/$host/${HTML_OK_DIR}/index.html" + local mon_err="$HTML_BASE/$host/${HTML_ERR_DIR}/index.html" + mkdir -p "$HTML_BASE/$host/${HTML_OK_DIR}" "$HTML_BASE/$host/${HTML_ERR_DIR}" + + if [ -n "$SITE_GEN" ] && [ -f "$SITE_GEN/console_to_html.py" ]; then + python3 "$SITE_GEN/console_to_html.py" \ + --log "$LOG" \ + --out "$mon_err" \ + --hostname "$host" \ + --mode err \ + --commit "${COMMIT:-}" \ + --commit-url "${COMMIT_URL:-}" \ + --suite-path "$SUITE_PATH" \ + --page-label "$PAGE_LABEL" \ + --path-err "$HTML_URL_ERR" \ + --link-other "$HTML_URL_OK" + if [ "$ec" -eq 0 ]; then + python3 "$SITE_GEN/console_to_html.py" \ + --log "$LOG" \ + --out "$mon" \ + --hostname "$host" \ + --mode ok \ + --commit "${COMMIT:-}" \ + --commit-url "${COMMIT_URL:-}" \ + --suite-path "$SUITE_PATH" \ + --page-label "$PAGE_LABEL" \ + --path-err "$HTML_URL_ERR" \ + --link-other "" + rm -rf "$HTML_BASE/$host/${HTML_ERR_DIR}" + echo "html $host → ${HTML_URL_OK} only (clean · ${COMMIT_SHORT:-?})" + else + python3 "$SITE_GEN/console_to_html.py" \ + --log "$LOG" \ + --out "$mon" \ + --hostname "$host" \ + --mode redirect \ + --commit "${COMMIT:-}" \ + --commit-url "${COMMIT_URL:-}" \ + --suite-path "$SUITE_PATH" \ + --page-label "$PAGE_LABEL" \ + --path-err "$HTML_URL_ERR" + echo "html $host → ${HTML_URL_OK} stub + ${HTML_URL_ERR} (ec=$ec · ${COMMIT_SHORT:-?})" + fi + else + echo "WARN: console_to_html.py missing — bootstrap pages" + write_bootstrap_html "$mon_err" "$host" "err" + if [ "$ec" -eq 0 ]; then + write_bootstrap_html "$mon" "$host" "ok" + rm -rf "$HTML_BASE/$host/${HTML_ERR_DIR}" + else + write_bootstrap_html "$mon" "$host" "redirect" + fi + fi + + if [ ! -f "$mon" ]; then + write_bootstrap_html "$mon" "$host" "err" + fi + + printf '%s\n' "${COMMIT:-unknown}" >"$HTML_BASE/$host/COMMIT" + printf '%s\n' "${COMMIT_URL:-}" >"$HTML_BASE/$host/COMMIT_URL" +} +for host in $MON_HOSTS; do + htmlify_host "$host" +done + +if [ -n "$DEPLOY_WWW" ] && [ -w "$DEPLOY_WWW" ] 2>/dev/null; then + rsync -a --delete "$HTML_BASE/" "$DEPLOY_WWW/" && \ + echo "synced → $DEPLOY_WWW/" +fi + +ls -1t "$LOG_DIR"/run-*.log 2>/dev/null | tail -n +30 | xargs rm -f 2>/dev/null || true + +echo "======== done ec=$ec · log=$LOG · ${COMMIT_URL:-} ========" +if [ "${STRICT_EXIT:-0}" = "1" ]; then + exit "$ec" +fi +exit 0 diff --git a/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh b/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh new file mode 100755 index 0000000..1b7a8b8 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# run-surface-monitoring.sh — hourly remote-only ecosystem surface scan +# Public only on taler.hacktivism.ch: +# https://taler.hacktivism.ch/taler-monitoring-surface/ +# https://taler.hacktivism.ch/taler-monitoring-surface_err/ +# +set -uo pipefail +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) + +export AGENT_LABEL="${AGENT_LABEL:-surface-host-agent}" +export STATE_NAME="${STATE_NAME:-taler-surface-monitoring}" +export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}" +# surface is remote-only — force flags (ignore shared env) +export INSIDE_PODMAN=0 +export LOCAL_STACK=0 +export SKIP_SSH=1 +export INSIDE_MODE="${INSIDE_MODE:-none}" +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +# many hosts; allow up to 45 min wall (phase also has RUN_TIMEOUT) +export RUN_TIMEOUT="${RUN_TIMEOUT:-2400}" +# dedicated job: always surface only (never inherit PHASES from shared env) +export PHASES=surface +# Only merchant public front — not bank/exchange +export MON_HOSTS="${MON_HOSTS:-taler.hacktivism.ch}" +export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}" +export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" + +export HTML_OK_DIR="taler-monitoring-surface" +export HTML_ERR_DIR="taler-monitoring-surface_err" +export HTML_URL_OK="/taler-monitoring-surface/" +export HTML_URL_ERR="/taler-monitoring-surface_err/" +export PAGE_LABEL="taler-monitoring-surface" + +# ecosystem catalog by default (not domain-only) +export SURFACE_SCOPE="${SURFACE_SCOPE:-ecosystem}" +export TALER_DOMAIN_FROM_CLI=0 + +# Do not run apt-deploy ensure for surface-only jobs +export APT_DEPLOY_ENSURE=0 + +exec bash "$AGENT_DIR/run-host-report.sh" "$@" diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service b/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service new file mode 100644 index 0000000..3d71eb1 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service @@ -0,0 +1,18 @@ +[Unit] +Description=Taler monitoring aptdeploy (apt-src container deploy tests) +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=CONTINUE_ON_ERROR=1 +Environment=RUN_TIMEOUT=1800 +WorkingDirectory=%h/koopa-admin-log/scripts/taler-monitoring +ExecStart=%h/koopa-admin-log/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh +Nice=10 +TimeoutStartSec=45min + +[Install] +WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer b/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer new file mode 100644 index 0000000..602ce8b --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Periodic taler-monitoring aptdeploy (apt-src deploy tests) +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md + +[Timer] +OnBootSec=35min +OnUnitActiveSec=4h +AccuracySec=5min +Persistent=true +RandomizedDelaySec=10min +Unit=taler-monitoring-aptdeploy.service + +[Install] +WantedBy=timers.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path new file mode 100644 index 0000000..1cbf003 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path @@ -0,0 +1,19 @@ +[Unit] +Description=Watch hacktivism software/config changes → re-run monitoring +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md + +[Path] +# Explicit stamp after package/image upgrades (touch-software-stamp.sh) +PathChanged=%h/.local/state/taler-hacktivism-monitoring/software.stamp +PathModified=%h/.local/state/taler-hacktivism-monitoring/software.stamp +# Landing + caddy config in admin-log (when checked out under ~) +PathChanged=%h/koopa-admin-log/configs/bank-landing +PathChanged=%h/koopa-admin-log/configs/exchange-landing +PathChanged=%h/koopa-admin-log/configs/merchant-landing +PathChanged=%h/koopa-admin-log/configs/caddy +# Debounce: wait for quiet period before firing +TriggerLimitIntervalSec=120 +TriggerLimitBurst=3 + +[Install] +WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service new file mode 100644 index 0000000..712920c --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service @@ -0,0 +1,21 @@ +[Unit] +Description=Taler monitoring GOA/hacktivism (host-podman inside + public urls) +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# Primary: host can podman-exec into taler-hacktivism* containers +Environment=INSIDE_PODMAN=1 +Environment=CONTINUE_ON_ERROR=1 +Environment=RUN_TIMEOUT=600 +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +WorkingDirectory=%h/koopa-admin-log/scripts/taler-monitoring +ExecStart=%h/koopa-admin-log/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh +Nice=10 +# Suite budget is RUN_TIMEOUT (10m) + git/HTML overhead +TimeoutStartSec=15min + +[Install] +WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer new file mode 100644 index 0000000..b65c139 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Periodic hacktivism monitoring (fallback if no path events) +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md + +[Timer] +# Every 4h + boot +OnBootSec=10min +OnUnitActiveSec=4h +AccuracySec=5min +Persistent=true +Unit=taler-monitoring-hacktivism.service + +[Install] +WantedBy=timers.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-surface.service b/scripts/taler-monitoring/host-agent/taler-monitoring-surface.service new file mode 100644 index 0000000..6018dee --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-surface.service @@ -0,0 +1,18 @@ +[Unit] +Description=Taler monitoring surface (remote ecosystem inventory) +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=CONTINUE_ON_ERROR=1 +Environment=RUN_TIMEOUT=2400 +WorkingDirectory=%h/koopa-admin-log/scripts/taler-monitoring +ExecStart=%h/koopa-admin-log/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh +Nice=10 +TimeoutStartSec=50min + +[Install] +WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer b/scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer new file mode 100644 index 0000000..a9ed8c9 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Hourly taler-monitoring surface scan (ecosystem) +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md + +[Timer] +OnBootSec=20min +OnUnitActiveSec=1h +AccuracySec=2min +Persistent=true +RandomizedDelaySec=5min +Unit=taler-monitoring-surface.service + +[Install] +WantedBy=timers.target diff --git a/scripts/taler-monitoring/host-agent/touch-software-stamp.sh b/scripts/taler-monitoring/host-agent/touch-software-stamp.sh new file mode 100755 index 0000000..0aff400 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/touch-software-stamp.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Call after upgrading hacktivism packages / redeploying landings / image rebuilds. +# systemd path unit watches this stamp → re-runs monitoring. +set -euo pipefail +STATE="${XDG_STATE_HOME:-$HOME/.local/state}/taler-hacktivism-monitoring" +mkdir -p "$STATE" +STAMP="$STATE/software.stamp" +date -Iseconds >"$STAMP" +# also record container image ids for debugging +if command -v podman >/dev/null 2>&1; then + { + echo "# images $(date -Iseconds)" + podman images --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.Digest}}' 2>/dev/null | grep -i hacktivism || true + podman ps --format '{{.Names}} {{.ImageID}} {{.Status}}' 2>/dev/null | grep -i hacktivism || true + } >"$STATE/software-images.txt" || true +fi +echo "touched $STAMP" diff --git a/scripts/taler-monitoring/host-agent/update-suite.sh b/scripts/taler-monitoring/host-agent/update-suite.sh new file mode 100755 index 0000000..70d348d --- /dev/null +++ b/scripts/taler-monitoring/host-agent/update-suite.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# update-suite.sh — ensure taler-monitoring code is latest from Forgejo. +# +# Local config (NEVER overwritten by git): +# ${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/env +# +# Env overrides (also settable in that file): +# SUITE_GIT_URL default https://git.hacktivism.ch/hernani/koopa-admin-log.git +# SUITE_GIT_REF default main +# SUITE_DIR default $HOME/src/koopa-admin-log (or $HOME/koopa-admin-log) +# SUITE_UPDATE_MODE ff (default) | reset (reset = hard to origin/REF) +# +set -uo pipefail + +CFG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring" +ENV_FILE="${TALER_MONITORING_ENV:-$CFG_DIR/env}" +# When sourced from run-host-report.sh the env file is already loaded and +# wrapper PHASES/HTML_* must not be clobbered. Only load here if needed. +if [ -f "$ENV_FILE" ] && [ "${_TALER_MON_ENV_LOADED:-0}" != "1" ]; then + # shellcheck disable=SC1090 + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" + set +a +fi + +SUITE_GIT_URL="${SUITE_GIT_URL:-https://git.hacktivism.ch/hernani/koopa-admin-log.git}" +SUITE_GIT_REF="${SUITE_GIT_REF:-main}" +# Host agents default to reset so "always latest from Forgejo" wins over local +# diverged trees. Override with SUITE_UPDATE_MODE=ff for careful pulls. +SUITE_UPDATE_MODE="${SUITE_UPDATE_MODE:-reset}" + +if [ -z "${SUITE_DIR:-}" ]; then + if [ -d "$HOME/src/koopa-admin-log/.git" ]; then + SUITE_DIR="$HOME/src/koopa-admin-log" + elif [ -d "$HOME/koopa-admin-log/.git" ]; then + SUITE_DIR="$HOME/koopa-admin-log" + else + SUITE_DIR="$HOME/src/koopa-admin-log" + fi +fi + +export SUITE_DIR + +mkdir -p "$(dirname "$SUITE_DIR")" "$CFG_DIR" + +if [ ! -d "$SUITE_DIR/.git" ]; then + echo "clone $SUITE_GIT_URL → $SUITE_DIR" + git clone --branch "$SUITE_GIT_REF" "$SUITE_GIT_URL" "$SUITE_DIR" \ + || git clone "$SUITE_GIT_URL" "$SUITE_DIR" +fi + +cd "$SUITE_DIR" || exit 1 +git remote set-url origin "$SUITE_GIT_URL" 2>/dev/null \ + || git remote add origin "$SUITE_GIT_URL" 2>/dev/null || true + +echo "fetch origin ($SUITE_GIT_REF) …" +if ! git fetch --tags origin 2>&1; then + echo "WARN: git fetch failed — using existing tree at $SUITE_DIR" >&2 + git rev-parse HEAD 2>/dev/null || true + return 0 2>/dev/null || exit 0 +fi + +case "$SUITE_UPDATE_MODE" in + reset) + git checkout -B "$SUITE_GIT_REF" "origin/$SUITE_GIT_REF" 2>/dev/null \ + || git checkout -B "$SUITE_GIT_REF" "FETCH_HEAD" + git reset --hard "origin/$SUITE_GIT_REF" 2>/dev/null \ + || git reset --hard FETCH_HEAD + ;; + *) + git checkout "$SUITE_GIT_REF" 2>/dev/null || git checkout -B "$SUITE_GIT_REF" "origin/$SUITE_GIT_REF" 2>/dev/null || true + if ! git pull --ff-only origin "$SUITE_GIT_REF" 2>&1; then + echo "WARN: ff-only pull failed — try SUITE_UPDATE_MODE=reset or fix local commits" >&2 + fi + ;; +esac + +COMMIT=$(git rev-parse HEAD) +COMMIT_SHORT=$(git rev-parse --short=12 HEAD) +# Forgejo web UI +SOURCE_REPO_WEB="${SOURCE_REPO_WEB:-https://git.hacktivism.ch/hernani/koopa-admin-log}" +COMMIT_URL="${SOURCE_REPO_WEB}/src/commit/${COMMIT}" +export COMMIT COMMIT_SHORT COMMIT_URL SOURCE_REPO_WEB + +echo "suite @ $COMMIT_SHORT $COMMIT_URL" +echo " dir=$SUITE_DIR" +echo " config=$ENV_FILE (not touched by git)" +echo " note: always latest = origin/${SUITE_GIT_REF} on git.hacktivism.ch (push local work there)" + +# Symlink convenience path if missing or not a git clone +if [ -d "$SUITE_DIR/.git" ]; then + if [ ! -e "$HOME/koopa-admin-log" ]; then + ln -sfn "$SUITE_DIR" "$HOME/koopa-admin-log" 2>/dev/null || true + elif [ -L "$HOME/koopa-admin-log" ]; then + ln -sfn "$SUITE_DIR" "$HOME/koopa-admin-log" 2>/dev/null || true + fi +fi + +# Make suite scripts executable after reset +if [ -d "$SUITE_DIR/scripts/taler-monitoring" ]; then + chmod +x \ + "$SUITE_DIR"/scripts/taler-monitoring/taler-monitoring.sh \ + "$SUITE_DIR"/scripts/taler-monitoring/check_*.sh \ + "$SUITE_DIR"/scripts/taler-monitoring/host-agent/*.sh \ + "$SUITE_DIR"/scripts/taler-monitoring/site-gen/*.sh \ + "$SUITE_DIR"/scripts/taler-monitoring/site-gen/*.py \ + 2>/dev/null || true +fi From 6436ab0dd90d986948a5b6637dda0f4af5a9a90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Sat, 18 Jul 2026 19:57:21 +0200 Subject: [PATCH 310/326] monitoring: host-agent timers and HTML report pipeline --- scripts/taler-monitoring/host-agent/README.md | 183 +++++++++ .../host-agent/ROOT-APPLY-MONITORING.md | 57 +++ .../ensure-apt-deploy-test-containers.sh | 373 ++++++++++++++++++ .../taler-monitoring/host-agent/env.example | 26 ++ .../host-agent/env/francpaysan.env.example | 24 ++ .../host-agent/env/hacktivism.env.example | 24 ++ .../host-agent/env/stagepaysan.env.example | 27 ++ .../host-agent/install-host-agent.sh | 107 +++++ .../host-agent/run-aptdeploy-monitoring.sh | 43 ++ .../host-agent/run-aptdeploy.sh | 150 +++++++ .../host-agent/run-fp-prod-monitoring.sh | 20 + .../host-agent/run-fp-stage-monitoring.sh | 21 + .../host-agent/run-hacktivism-monitoring.sh | 23 ++ .../host-agent/run-host-report.sh | 358 +++++++++++++++++ .../host-agent/run-surface-monitoring.sh | 41 ++ .../taler-monitoring-aptdeploy.service | 18 + .../taler-monitoring-aptdeploy.timer | 14 + .../taler-monitoring-hacktivism.path | 19 + .../taler-monitoring-hacktivism.service | 21 + .../taler-monitoring-hacktivism.timer | 14 + .../taler-monitoring-surface.service | 18 + .../host-agent/taler-monitoring-surface.timer | 14 + .../host-agent/touch-software-stamp.sh | 17 + .../host-agent/update-suite.sh | 109 +++++ 24 files changed, 1721 insertions(+) create mode 100644 scripts/taler-monitoring/host-agent/README.md create mode 100644 scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md create mode 100755 scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh create mode 100644 scripts/taler-monitoring/host-agent/env.example create mode 100644 scripts/taler-monitoring/host-agent/env/francpaysan.env.example create mode 100644 scripts/taler-monitoring/host-agent/env/hacktivism.env.example create mode 100644 scripts/taler-monitoring/host-agent/env/stagepaysan.env.example create mode 100755 scripts/taler-monitoring/host-agent/install-host-agent.sh create mode 100755 scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh create mode 100755 scripts/taler-monitoring/host-agent/run-aptdeploy.sh create mode 100755 scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh create mode 100755 scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh create mode 100755 scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh create mode 100755 scripts/taler-monitoring/host-agent/run-host-report.sh create mode 100755 scripts/taler-monitoring/host-agent/run-surface-monitoring.sh create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-surface.service create mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer create mode 100755 scripts/taler-monitoring/host-agent/touch-software-stamp.sh create mode 100755 scripts/taler-monitoring/host-agent/update-suite.sh diff --git a/scripts/taler-monitoring/host-agent/README.md b/scripts/taler-monitoring/host-agent/README.md new file mode 100644 index 0000000..3acfe59 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/README.md @@ -0,0 +1,183 @@ +# host-agent — reporting generation (GOA + FP + any stack) + +**Global pipeline** lives in **`run-host-report.sh`** (all stacks share it): + +| Behaviour | Default | +|-----------|---------| +| Suite refresh | `update-suite.sh` → `origin/main` | +| Wall clock | `RUN_TIMEOUT=600` | +| Log | line-buffered `run-*.log` (continuous write) | +| HTML | always (first run / empty tree too) + commit URL | +| Timeout notice | end of log + top jump on `/monitoring_err/` | + +Thin wrappers only set stack defaults: + +| Wrapper | Stack | Typical user | +|---------|-------|----------------| +| `run-hacktivism-monitoring.sh` | GOA `hacktivism.ch` | `hernani` @ koopa | +| `run-fp-stage-monitoring.sh` | FP stage | `stagepaysan` | +| `run-fp-prod-monitoring.sh` | FP prod | `francpaysan` | +| `run-aptdeploy.sh` | CLI: ensure + **aptdeploy** (no HTML) | local if hostname=koopa, else **ssh koopa-external** | +| `run-aptdeploy-monitoring.sh` | **aptdeploy** → `/taler-monitoring-aptdeploy*` | `hernani` @ koopa (4h timer, taler only) | +| `run-surface-monitoring.sh` | **surface** → `/taler-monitoring-surface*` | `hernani` @ koopa (hourly, taler only) | + +### apt-src deploy tests (hacktivism `/monitoring`) + +| Container | Mode | +|-----------|------| +| `koopa-taler-deploy-test-apt-src-trixie` | **fresh**: recreate when repo package fingerprint changes | +| `koopa-taler-deploy-test-apt-src-trixie-testing` | **fresh** for `trixie-testing` | +| `koopa-taler-deploy-test-apt-src-trixie-upgrade` | **upgrade**: initial install, then mytops-style `apt install` + `upgrade` | +| `koopa-taler-deploy-test-apt-src-trixie-testing-upgrade` | **upgrade** for testing suite | + +```bash +# from laptop / clementine → triggers on koopa-external +./scripts/taler-monitoring/host-agent/run-aptdeploy.sh +./scripts/taler-monitoring/host-agent/run-aptdeploy.sh upgrade +./scripts/taler-monitoring/host-agent/run-aptdeploy.sh fresh +./scripts/taler-monitoring/host-agent/run-aptdeploy.sh check-only + +# on koopa +./taler-monitoring.sh -d hacktivism.ch aptdeploy +``` + +Checks: package table, `httpd --version`, libtalerutil/`ldd` on failure, `systemctl` for +`taler-merchant.target`, basic local `/config` probe. No nginx/HTTPS in containers. + +### surface + aptdeploy HTML (taler.hacktivism.ch only) + +| URL | Role | Timer | +|-----|------|-------| +| `…/taler-monitoring-surface/` | ecosystem surface scan | 1h | +| `…/taler-monitoring-surface_err/` | errors + jump list | ″ | +| `…/taler-monitoring-aptdeploy/` | apt-src container deploy tests | 4h | +| `…/taler-monitoring-aptdeploy_err/` | errors + jump list | ″ | + +Not on bank/exchange. Staging: +`~/monitoring-sites-staging/taler.hacktivism.ch/taler-monitoring-{surface,aptdeploy}*`. + +```bash +systemctl --user enable --now taler-monitoring-surface.timer +systemctl --user enable --now taler-monitoring-aptdeploy.timer +systemctl --user start taler-monitoring-surface.service +systemctl --user start taler-monitoring-aptdeploy.service +# Caddy: handles only in taler.hacktivism.ch block (root) +``` + +Env examples: **`env/*.env.example`**. FP docs: `francpaysan-admin-log/docs/taler-monitoring-host-agents.md`. + +## GOA (koopa) — host-podman + +Runs **as user `hernani`** (systemd user, linger). **Primary** mode: + +- **`INSIDE_PODMAN=1`** / **`INSIDE_MODE=local-podman`**: access mode **`host-podman`** — + `podman exec` on this host into + `taler-hacktivism-bank` / `taler-hacktivism-exchange-ansible` / `taler-hacktivism` + (IDs `inside.host-*`, not `inside.ssh-*`). + From a **laptop**, leave `INSIDE_PODMAN` off → access mode **`ssh`** via `KOOPA_SSH` + (`inside.ssh-*`). +- Public **`urls` + `versions`** for the stack (`-d hacktivism.ch`). +- Writes console HTML for all three sites under + `~/monitoring-sites-staging/{bank,exchange,taler}.hacktivism.ch/`. + +Caddy (host root) can serve that tree at `/monitoring` — you configure Caddy. + +## Latest software + config isolation + +**Source of truth is always Forgejo** (`git.hacktivism.ch`), not a laptop rsync copy. + +Every host-agent / aptdeploy run: + +1. **`update-suite.sh`**: `git fetch` + **`SUITE_UPDATE_MODE=reset`** (default) → `origin/main` + into `SUITE_DIR` (prefer `~/src/koopa-admin-log`). +2. **Re-exec** host-agent scripts from that tree so the running code matches `COMMIT_URL`. +3. Local overrides only in **`~/.config/taler-monitoring/env`** — never overwritten by git. +4. HTML footer = exact commit: + `https://git.hacktivism.ch/hernani/koopa-admin-log/src/commit/` +5. **`RUN_TIMEOUT=600`**, line-buffered logs, HTML always written. + +If fetch fails → previous tree + WARN. +**To ship features (aptdeploy, surface, …): push to `origin/main`.** Local-only edits vanish on the next reset. + +If pull fails (no network / auth), the previous tree is used and a WARN is logged. + +## Install (on koopa as hernani) + +```bash +# once: local config (optional) +mkdir -p ~/.config/taler-monitoring +cp ~/koopa-admin-log/scripts/taler-monitoring/host-agent/env.example \ + ~/.config/taler-monitoring/env + +# laptop: +./scripts/taler-monitoring/host-agent/install-host-agent.sh --remote koopa-external + +# or on koopa: +~/koopa-admin-log/scripts/taler-monitoring/host-agent/install-host-agent.sh +``` + +Units: + +| Unit | Role | +|------|------| +| `taler-monitoring-hacktivism.path` | fires on software stamp + landing/caddy config changes | +| `taler-monitoring-hacktivism.timer` | every **4h** fallback | +| `taler-monitoring-hacktivism.service` | oneshot → `/monitoring*` (urls inside versions) | +| `taler-monitoring-surface.timer` | every **1h** | +| `taler-monitoring-surface.service` | → `/taler-monitoring-surface*` (taler only) | +| `taler-monitoring-aptdeploy.timer` | every **4h** | +| `taler-monitoring-aptdeploy.service` | → `/taler-monitoring-aptdeploy*` (taler only) | + +## After package / image / landing upgrades + +```bash +~/koopa-admin-log/scripts/taler-monitoring/host-agent/touch-software-stamp.sh +# → path unit starts the service +``` + +## Manual + +```bash +systemctl --user start taler-monitoring-hacktivism.service +systemctl --user status taler-monitoring-hacktivism.service --no-pager +journalctl --user -u taler-monitoring-hacktivism.service -n 50 --no-pager +ls ~/monitoring-sites-staging/bank.hacktivism.ch/monitoring_err/ +``` + +## Relation to firecuda outside-only timer + +| | host-agent (koopa) | firecuda site-gen | +|--|--------------------|-------------------| +| Access | **podman on host** | public HTTPS only | +| Phases | urls **inside** versions | urls versions | +| Trigger | software path + 4h | *could* launchd 4h | +| Primary for GOA | **yes** | **off** (optional only) | + +**firecuda-external is not scheduled** right now (launchd disabled). Scripts under +`site-gen/` can still one-shot a public run if needed; see `site-gen/README.md`. + +No secrets required for default phases. `secrets.env` optional for e2e later. + +## Git commit on HTML pages + +Header of each generated page includes a link like: + +`https://git.hacktivism.ch/hernani/koopa-admin-log/src/commit/` + +That SHA is the tree **after** `update-suite.sh` for that run. + +## Packages on koopa + +See **[../DEPENDENCIES.md](../DEPENDENCIES.md)**. + +**Default host-agent needs no new packages** if curl/python3/podman/git are present (they are). + +**Root once** only for full QR checks in `urls`: + +```bash +zypper in qrencode zbar +``` + +Without them, monitoring still runs; QR group WARNs and skips encode/decode. + +Caddy `/var/www/monitoring-sites` is separate (you configured hernani write access). diff --git a/scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md b/scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md new file mode 100644 index 0000000..17dd386 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md @@ -0,0 +1,57 @@ +# Root: make monitoring pages live on koopa (GOA) + +## Symptom if not applied + +Public URLs return **merchant/bank JSON** like: + +```json +{ "code": 21, "hint": "There is no endpoint defined for the URL provided..." } +``` + +or a **404 HTML** page from the app reverse_proxy. + +Cause: live `/etc/caddy/Caddyfile` has **no** `handle /monitoring*` (falls through to +API). Prepared config is only under **`/home/hernani/koopa-caddy/Caddyfile`**. + +## One-shot as root (recommended) + +```bash +sudo /home/hernani/koopa-caddy/apply-monitoring-live.sh +``` + +That script: + +1. `rsync` staging HTML → `/var/www/monitoring-sites/` (incl. surface + aptdeploy) +2. Makes tree writable for `hernani` (group `caddy` + ACL) so later runs self-sync +3. Installs prepared Caddyfile → `/etc/caddy/Caddyfile`, validates, reloads Caddy +4. Smoke-curls all monitoring URLs (must **not** be JSON `code:21`) + +## Paths + +| Path | Host | Source timer | +|------|------|----------------| +| `/monitoring/` + `/monitoring_err/` | bank + exchange + taler | `taler-monitoring-hacktivism.timer` (4h) | +| `/taler-monitoring-surface/` + `/_err/` | **taler.hacktivism.ch only** | `taler-monitoring-surface.timer` (1h) | +| `/taler-monitoring-aptdeploy/` + `/_err/` | **taler.hacktivism.ch only** | `taler-monitoring-aptdeploy.timer` (4h) | + +HTML is generated as **hernani** into +`/home/hernani/monitoring-sites-staging/`. + +## Manual equivalent + +```bash +rsync -a --delete /home/hernani/monitoring-sites-staging/ /var/www/monitoring-sites/ +chown -R hernani:caddy /var/www/monitoring-sites && chmod -R g+rwX /var/www/monitoring-sites +sudo /home/hernani/koopa-caddy/apply.sh # or apply-monitoring-live.sh +``` + +Expect **200** (HTML with sticky bar). Not merchant JSON `code:21`. + +## After each monitoring run (if www is not hernani-writable) + +```bash +rsync -a --delete /home/hernani/monitoring-sites-staging/ /var/www/monitoring-sites/ +chown -R caddy:caddy /var/www/monitoring-sites +``` + +Or grant `hernani` write on `/var/www/monitoring-sites` once so the host-agent can rsync itself. diff --git a/scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh b/scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh new file mode 100755 index 0000000..75fa3e4 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh @@ -0,0 +1,373 @@ +#!/usr/bin/env bash +# ensure-apt-deploy-test-containers.sh +# +# Four rootless podman containers on koopa (no nginx / no HTTPS): +# +# Fresh rebuild when repo fingerprint changes: +# koopa-taler-deploy-test-apt-src-trixie +# koopa-taler-deploy-test-apt-src-trixie-testing +# +# Persistent upgrade path (mytops upgrade.sh style): +# koopa-taler-deploy-test-apt-src-trixie-upgrade +# koopa-taler-deploy-test-apt-src-trixie-testing-upgrade +# +# Run as hernani on koopa. From elsewhere use run-aptdeploy.sh (ssh koopa-external). +# +set -euo pipefail + +PODMAN_BIN="${APT_DEPLOY_PODMAN:-podman}" +IMG="${APT_DEPLOY_BASE_IMAGE:-docker.io/library/debian:trixie}" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/taler-apt-deploy" +URI_BASE="${APT_DEPLOY_URI:-https://deb.taler.net/apt/debian}" +mkdir -p "$STATE_DIR" + +# Packages we track for "new version → rebuild fresh containers" +TRACK_PKGS="${APT_DEPLOY_TRACK_PKGS:-taler-merchant libtalermerchant libtalerexchange libdonau}" + +# Canonical four containers (keep). Everything else matching legacy names is removed. +CANONICAL_CONTAINERS=( + koopa-taler-deploy-test-apt-src-trixie + koopa-taler-deploy-test-apt-src-trixie-testing + koopa-taler-deploy-test-apt-src-trixie-upgrade + koopa-taler-deploy-test-apt-src-trixie-testing-upgrade +) + +# Legacy / typo names from earlier iterations — always delete if present +LEGACY_CONTAINERS=( + koopa-taler-build-test-apt-trixie + koopa-taler-build-test-apt-trixie-testing + koopa-taler-deploy-test-apt-trixie + koopa-taler-deploy-test-apt-trixie-testing + koopa-taler-buidl-test-apt-trixie + koopa-taler-buidl-test-apt-trixie-testing +) + +cleanup_legacy() { + local n img + echo "======== cleanup legacy containers / images ========" + for n in "${LEGACY_CONTAINERS[@]}"; do + if "$PODMAN_BIN" container exists "$n" 2>/dev/null; then + echo "remove legacy container $n" + "$PODMAN_BIN" rm -f "$n" 2>/dev/null || true + fi + # old setup images for that name + if "$PODMAN_BIN" image exists "localhost/${n}:setup" 2>/dev/null; then + echo "remove legacy image localhost/${n}:setup" + "$PODMAN_BIN" rmi -f "localhost/${n}:setup" 2>/dev/null || true + fi + done + # dangling setup images not in canonical set + while read -r img; do + [ -n "$img" ] || continue + local base keep=0 + base=${img#localhost/} + base=${base%:setup} + for n in "${CANONICAL_CONTAINERS[@]}"; do + [ "$base" = "$n" ] && keep=1 && break + done + if [ "$keep" -eq 0 ] && [[ "$img" == localhost/koopa-taler-*-test-apt* ]]; then + echo "remove non-canonical image $img" + "$PODMAN_BIN" rmi -f "$img" 2>/dev/null || true + fi + done < <("$PODMAN_BIN" images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -E 'localhost/koopa-taler-.*-test-apt' || true) +} + +# Prefer single suite tree: if both ~/src/koopa-admin-log and ~/koopa-admin-log are +# full git clones, keep the newer (by HEAD commit time) and replace the older with a symlink. +dedupe_suite_repos() { + local a="$HOME/src/koopa-admin-log" b="$HOME/koopa-admin-log" + local ta tb + [ "${APT_DEPLOY_DEDUPE_REPOS:-1}" = "1" ] || return 0 + if [ -d "$a/.git" ] && [ -d "$b/.git" ] && [ ! -L "$a" ] && [ ! -L "$b" ]; then + ta=$(git -C "$a" log -1 --format=%ct 2>/dev/null || echo 0) + tb=$(git -C "$b" log -1 --format=%ct 2>/dev/null || echo 0) + echo "======== dedupe suite repos (both are git clones) ========" + echo " $a HEAD_ts=$ta" + echo " $b HEAD_ts=$tb" + if [ "$ta" -ge "$tb" ]; then + echo "keep $a · remove $b · symlink $b → $a" + rm -rf "$b" + ln -sfn "$a" "$b" + else + echo "keep $b · remove $a · symlink $a → $b" + rm -rf "$a" + mkdir -p "$(dirname "$a")" + ln -sfn "$b" "$a" + fi + elif [ -d "$a/scripts/taler-monitoring" ] && [ -d "$b/scripts/taler-monitoring" ] \ + && [ ! -d "$a/.git" ] && [ -d "$b/.git" ] && [ ! -L "$a" ]; then + echo "remove non-git tree $a · symlink → $b" + rm -rf "$a" + mkdir -p "$(dirname "$a")" + ln -sfn "$b" "$a" + elif [ -d "$b/scripts/taler-monitoring" ] && [ -d "$a/scripts/taler-monitoring" ] \ + && [ ! -d "$b/.git" ] && [ -d "$a/.git" ] && [ ! -L "$b" ]; then + echo "remove non-git tree $b · symlink → $a" + rm -rf "$b" + ln -sfn "$a" "$b" + fi +} + +cleanup_legacy +dedupe_suite_repos +# --------------------------------------------------------------------------- +# Repo fingerprint: Version: lines from suite Packages index +# --------------------------------------------------------------------------- +repo_pkg_version() { + local suite=$1 pkg=$2 + curl -fsS --max-time 45 \ + "${URI_BASE}/dists/${suite}/main/binary-amd64/Packages" 2>/dev/null \ + | awk -v p="$pkg" ' + $0 == "Package: " p { want=1; next } + /^Package: / { want=0 } + want && /^Version: / { print $2; exit } + ' +} + +repo_fingerprint() { + local suite=$1 pkg v line="" + for pkg in $TRACK_PKGS; do + v=$(repo_pkg_version "$suite" "$pkg" || true) + [ -n "$v" ] || v="?" + line="${line}${pkg}=${v};" + done + printf '%s' "$line" +} + +container_fingerprint() { + local name=$1 + "$PODMAN_BIN" exec "$name" bash -lc ' + for p in '"$TRACK_PKGS"'; do + v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo missing) + printf "%s=%s;" "$p" "$v" + done + ' 2>/dev/null || echo "missing" +} + +# --------------------------------------------------------------------------- +# Bootstrap: install base + taler source + packages (shared) +# --------------------------------------------------------------------------- +install_base_and_merchant() { + local name=$1 suite=$2 srcfile=$3 + "$PODMAN_BIN" exec -u root "$name" bash -lc ' + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq ca-certificates curl wget gnupg systemd systemd-sysv \ + dbus procps iproute2 + ' + "$PODMAN_BIN" exec -u root "$name" bash -lc " + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + mkdir -p /etc/apt/keyrings + wget -q -O /etc/apt/keyrings/taler-systems.gpg https://taler.net/taler-systems.gpg + cat >/etc/apt/sources.list.d/${srcfile} </dev/null + systemctl enable taler-merchant.target 2>/dev/null + systemctl start taler-merchant.target 2>/dev/null + sleep 2 + systemctl is-active taler-merchant-httpd.service 2>/dev/null || true + ' || true +} + +create_from_scratch() { + local name=$1 suite=$2 srcfile=$3 + echo "======== CREATE (fresh) $name Suites: $suite ========" + "$PODMAN_BIN" pull "$IMG" >/dev/null + "$PODMAN_BIN" rm -f "$name" 2>/dev/null || true + # drop previous setup image so we do not accumulate duplicates + "$PODMAN_BIN" rmi -f "localhost/${name}:setup" 2>/dev/null || true + "$PODMAN_BIN" run -d --name "$name" --hostname "$name" "$IMG" sleep infinity + install_base_and_merchant "$name" "$suite" "$srcfile" + commit_and_systemd "$name" + printf '%s\n' "$(repo_fingerprint "$suite")" >"$STATE_DIR/${name}.fp" + printf '%s\n' "$(container_fingerprint "$name")" >"$STATE_DIR/${name}.installed" + echo "created $name fp=$(cat "$STATE_DIR/${name}.fp")" +} + +# --------------------------------------------------------------------------- +# Upgrade path (mytops-admin-log upgrade.sh merchant packages, no restic/nginx) +# Prints a markdown-ish table of before → after versions. +# --------------------------------------------------------------------------- +upgrade_inside() { + local name=$1 + local before after pkg v0 v1 + echo "======== UPGRADE $name (mytops-style apt install + upgrade) ========" + + before=$("$PODMAN_BIN" exec "$name" bash -lc ' + for p in taler-merchant libtalermerchant libtalerexchange libdonau libgnunet \ + taler-merchant-webui taler-merchant-typst taler-terms-generator; do + v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo "-") + printf "%s %s\n" "$p" "$v" + done + ' 2>/dev/null || true) + + # capture upgrade output + set +e + "$PODMAN_BIN" exec -u root "$name" bash -lc ' + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + systemctl stop taler-merchant.target 2>/dev/null || true + systemctl stop taler-merchant-donaukeyupdate.service 2>/dev/null || true + apt-get update -qq + apt-get install -y \ + taler-merchant \ + taler-merchant-typst \ + taler-merchant-webui \ + taler-terms-generator \ + || true + if dpkg -l challenger-httpd 2>/dev/null | grep -q "^ii"; then + apt-get remove -y challenger-httpd || true + fi + apt-get upgrade -y + systemctl daemon-reload 2>/dev/null || true + systemctl enable taler-merchant.target 2>/dev/null || true + # try dbconfig if present (may no-op without full config) + command -v taler-merchant-dbconfig >/dev/null && taler-merchant-dbconfig 2>/dev/null || true + systemctl reset-failed "taler-merchant*" 2>/dev/null || true + systemctl start taler-merchant.target 2>/dev/null || true + sleep 2 + ' 2>&1 | tee "$STATE_DIR/${name}.upgrade.log" | tail -30 + set -e + + after=$("$PODMAN_BIN" exec "$name" bash -lc ' + for p in taler-merchant libtalermerchant libtalerexchange libdonau libgnunet \ + taler-merchant-webui taler-merchant-typst taler-terms-generator; do + v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo "-") + printf "%s %s\n" "$p" "$v" + done + ' 2>/dev/null || true) + + echo + echo "| package | before | after | change |" + echo "|---------|--------|-------|--------|" + while read -r pkg v0; do + [ -n "${pkg:-}" ] || continue + v1=$(printf '%s\n' "$after" | awk -v p="$pkg" '$1==p {print $2; exit}') + v1=${v1:--} + if [ "$v0" = "$v1" ]; then + ch="=" + else + ch="UPGRADED" + fi + echo "| $pkg | $v0 | $v1 | $ch |" + done <<<"$before" + + printf '%s\n' "$(container_fingerprint "$name")" >"$STATE_DIR/${name}.installed" +} + +# --------------------------------------------------------------------------- +# Fresh mode: recreate if missing / not running / repo fp changed +# --------------------------------------------------------------------------- +ensure_fresh() { + local name=$1 suite=$2 srcfile=$3 + local want have running + + want=$(repo_fingerprint "$suite") + echo "repo fingerprint [$suite]: $want" + + if ! "$PODMAN_BIN" container exists "$name" 2>/dev/null; then + create_from_scratch "$name" "$suite" "$srcfile" + return + fi + + running=$("$PODMAN_BIN" inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false) + have=$(container_fingerprint "$name") + echo "installed in $name: $have" + + if [ "$running" != "true" ] \ + || ! "$PODMAN_BIN" exec "$name" bash -lc 'command -v taler-merchant-httpd' >/dev/null 2>&1 \ + || [ "$want" != "$have" ]; then + echo "rebuild $name (running=$running fp_match=$([ "$want" = "$have" ] && echo yes || echo NO))" + create_from_scratch "$name" "$suite" "$srcfile" + else + echo "OK $name up-to-date (fresh container matches repo)" + fi +} + +# --------------------------------------------------------------------------- +# Upgrade mode: create once, then always upgrade_inside +# --------------------------------------------------------------------------- +ensure_upgrade() { + local name=$1 suite=$2 srcfile=$3 + local running + + if ! "$PODMAN_BIN" container exists "$name" 2>/dev/null; then + create_from_scratch "$name" "$suite" "$srcfile" + echo "(initial install for upgrade-track container $name)" + return + fi + + running=$("$PODMAN_BIN" inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false) + if [ "$running" != "true" ]; then + echo "start $name" + "$PODMAN_BIN" start "$name" || { + echo "recreate broken $name" + create_from_scratch "$name" "$suite" "$srcfile" + return + } + sleep 3 + fi + + if ! "$PODMAN_BIN" exec "$name" bash -lc 'command -v taler-merchant-httpd' >/dev/null 2>&1; then + echo "recreate $name (no httpd after start)" + create_from_scratch "$name" "$suite" "$srcfile" + return + fi + + upgrade_inside "$name" +} + +MODE="${1:-all}" # all | fresh | upgrade + +case "$MODE" in + all|fresh) + ensure_fresh koopa-taler-deploy-test-apt-src-trixie trixie taler.sources + ensure_fresh koopa-taler-deploy-test-apt-src-trixie-testing trixie-testing taler-testing.sources + ;;& + all|upgrade) + ensure_upgrade koopa-taler-deploy-test-apt-src-trixie-upgrade trixie taler.sources + ensure_upgrade koopa-taler-deploy-test-apt-src-trixie-testing-upgrade trixie-testing taler-testing.sources + ;; + *) + echo "usage: $0 [all|fresh|upgrade]" >&2 + exit 2 + ;; +esac + +echo "done ensure-apt-deploy-test-containers mode=$MODE" diff --git a/scripts/taler-monitoring/host-agent/env.example b/scripts/taler-monitoring/host-agent/env.example new file mode 100644 index 0000000..15f6bae --- /dev/null +++ b/scripts/taler-monitoring/host-agent/env.example @@ -0,0 +1,26 @@ +# Copy to: ~/.config/taler-monitoring/env +# This file is NOT inside the git tree and is never overwritten by update-suite.sh. +# +# Full stack examples (same reporting generation for all): +# env/hacktivism.env.example +# env/stagepaysan.env.example +# env/francpaysan.env.example +# +# SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git +# SUITE_GIT_REF=main +# SUITE_DIR=$HOME/src/koopa-admin-log +# SUITE_UPDATE_MODE=reset +# +# TALER_DOMAIN=hacktivism.ch +# INSIDE_PODMAN=1 +# INSIDE_MODE=local-podman +# CONTINUE_ON_ERROR=1 +# PHASES for the *default* hacktivism timer only (urls inside versions). +# Dedicated wrappers always override PHASES + HTML paths: +# run-surface-monitoring.sh → /taler-monitoring-surface* +# run-aptdeploy-monitoring.sh → /taler-monitoring-aptdeploy* +# PHASES="urls inside versions" +# RUN_TIMEOUT=600 +# HTML_OUT=$HOME/monitoring-sites-staging +# MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" +# SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log diff --git a/scripts/taler-monitoring/host-agent/env/francpaysan.env.example b/scripts/taler-monitoring/host-agent/env/francpaysan.env.example new file mode 100644 index 0000000..ab1c534 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/env/francpaysan.env.example @@ -0,0 +1,24 @@ +# Copy to: ~/.config/taler-monitoring/env (user francpaysan) +# Never inside the git tree — update-suite never overwrites this file. +# +# Shared reporting generation (run-host-report.sh / run-fp-prod-monitoring.sh): +# RUN_TIMEOUT, line-buffered log, always HTML, commit link on git.hacktivism.ch + +TALER_DOMAIN=lefrancpaysan.ch +INSIDE_PODMAN=1 +INSIDE_MODE=local-podman +LOCAL_STACK=0 +CONTINUE_ON_ERROR=1 +PHASES="urls inside versions" +RUN_TIMEOUT=600 + +HTML_OUT=$HOME/monitoring-sites-staging +MON_HOSTS="bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch" + +SUITE_DIR=$HOME/src/koopa-admin-log +SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git +SUITE_GIT_REF=main +SUITE_UPDATE_MODE=reset +SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log + +# DEPLOY_WWW_ROOT=/var/www/monitoring-sites diff --git a/scripts/taler-monitoring/host-agent/env/hacktivism.env.example b/scripts/taler-monitoring/host-agent/env/hacktivism.env.example new file mode 100644 index 0000000..6b57280 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/env/hacktivism.env.example @@ -0,0 +1,24 @@ +# Copy to: ~/.config/taler-monitoring/env (user hernani on koopa) +# Shared reporting generation: RUN_TIMEOUT, line-buffered log, always HTML. + +TALER_DOMAIN=hacktivism.ch +INSIDE_PODMAN=1 +INSIDE_MODE=local-podman +LOCAL_STACK=1 +CONTINUE_ON_ERROR=1 +PHASES="urls inside versions" +# aptdeploy HTML: run-aptdeploy-monitoring.sh → /taler-monitoring-aptdeploy* +RUN_TIMEOUT=600 +# APT_DEPLOY_ENSURE=1 +# APT_DEPLOY_SKIP=0 + +HTML_OUT=$HOME/monitoring-sites-staging +MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" + +SUITE_DIR=$HOME/src/koopa-admin-log +SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git +SUITE_GIT_REF=main +SUITE_UPDATE_MODE=reset +SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log + +DEPLOY_WWW_ROOT=/var/www/monitoring-sites diff --git a/scripts/taler-monitoring/host-agent/env/stagepaysan.env.example b/scripts/taler-monitoring/host-agent/env/stagepaysan.env.example new file mode 100644 index 0000000..0959ebc --- /dev/null +++ b/scripts/taler-monitoring/host-agent/env/stagepaysan.env.example @@ -0,0 +1,27 @@ +# Copy to: ~/.config/taler-monitoring/env (user stagepaysan) +# Never inside the git tree — update-suite never overwrites this file. +# +# Shared reporting generation (run-host-report.sh / run-fp-stage-monitoring.sh): +# RUN_TIMEOUT, line-buffered log, always HTML, commit link on git.hacktivism.ch + +TALER_DOMAIN=stage.lefrancpaysan.ch +INSIDE_PODMAN=1 +INSIDE_MODE=local-podman +INSIDE_PROFILE=stage-lfp +LOCAL_STACK=0 +CONTINUE_ON_ERROR=1 +PHASES="urls inside versions" +RUN_TIMEOUT=600 + +HTML_OUT=$HOME/monitoring-sites-staging +MON_HOSTS="stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch" + +# Git clone of koopa-admin-log (repo root, not …/scripts/taler-monitoring) +SUITE_DIR=$HOME/src/koopa-admin-log +SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git +SUITE_GIT_REF=main +SUITE_UPDATE_MODE=reset +SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log + +# Optional public docroot if this user can write it +# DEPLOY_WWW_ROOT=/var/www/monitoring-sites diff --git a/scripts/taler-monitoring/host-agent/install-host-agent.sh b/scripts/taler-monitoring/host-agent/install-host-agent.sh new file mode 100755 index 0000000..adcce66 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/install-host-agent.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# install-host-agent.sh — install hernani user systemd units on koopa +# +# # on koopa as hernani: +# ~/koopa-admin-log/scripts/taler-monitoring/host-agent/install-host-agent.sh +# # or from laptop: +# ./install-host-agent.sh --remote koopa-external +# +set -euo pipefail +ROOT=$(cd "$(dirname "$0")" && pwd) +REMOTE="${1:-}" +[ "${1:-}" = "--remote" ] && REMOTE="${2:-koopa-external}" + +install_local() { + local mon home_unit + mon=$(cd "$ROOT/.." && pwd) + # Prefer ~/koopa-admin-log on live host + if [ -d "$HOME/koopa-admin-log/scripts/taler-monitoring" ]; then + mon="$HOME/koopa-admin-log/scripts/taler-monitoring" + fi + home_unit="$HOME/.config/systemd/user" + mkdir -p "$home_unit" \ + "$HOME/.local/state/taler-hacktivism-monitoring" \ + "$HOME/monitoring-sites-staging" \ + "$HOME/.config/taler-monitoring" \ + "$HOME/src" + + # Local env (never in git) — create from example if missing + if [ ! -f "$HOME/.config/taler-monitoring/env" ] && [ -f "$ROOT/env.example" ]; then + cp "$ROOT/env.example" "$HOME/.config/taler-monitoring/env" + echo "created ~/.config/taler-monitoring/env" + fi + + # Prefer a real git clone for update-suite.sh + if [ ! -d "$HOME/src/koopa-admin-log/.git" ] && [ ! -d "$HOME/koopa-admin-log/.git" ]; then + if [ -d "$HOME/koopa-admin-log/scripts/taler-monitoring" ] && [ ! -d "$HOME/koopa-admin-log/.git" ]; then + echo "note: $HOME/koopa-admin-log has no .git — update-suite will clone to ~/src/koopa-admin-log" + fi + git clone https://git.hacktivism.ch/hernani/koopa-admin-log.git "$HOME/src/koopa-admin-log" 2>/dev/null \ + || echo "WARN: clone failed (auth/network) — will use existing tree if any" + fi + + # Ensure suite is executable + chmod +x "$mon"/taler-monitoring.sh "$mon"/check_*.sh 2>/dev/null || true + chmod +x "$mon/host-agent"/*.sh 2>/dev/null || true + chmod +x "$mon/site-gen"/*.sh "$mon/site-gen/console_to_html.py" 2>/dev/null || true + + # Units reference %h/koopa-admin-log — prefer git clone or symlink + if [ -d "$HOME/src/koopa-admin-log/.git" ] && [ ! -e "$HOME/koopa-admin-log" ]; then + ln -sfn "$HOME/src/koopa-admin-log" "$HOME/koopa-admin-log" + echo "symlink $HOME/koopa-admin-log → $HOME/src/koopa-admin-log" + elif [ ! -e "$HOME/koopa-admin-log" ]; then + admin_root=$(cd "$mon/../.." && pwd) + ln -sfn "$admin_root" "$HOME/koopa-admin-log" + echo "symlink $HOME/koopa-admin-log → $admin_root" + fi + + install -m 644 "$ROOT/taler-monitoring-hacktivism.service" "$home_unit/" + install -m 644 "$ROOT/taler-monitoring-hacktivism.path" "$home_unit/" + install -m 644 "$ROOT/taler-monitoring-hacktivism.timer" "$home_unit/" + install -m 644 "$ROOT/taler-monitoring-surface.service" "$home_unit/" + install -m 644 "$ROOT/taler-monitoring-surface.timer" "$home_unit/" + install -m 644 "$ROOT/taler-monitoring-aptdeploy.service" "$home_unit/" + install -m 644 "$ROOT/taler-monitoring-aptdeploy.timer" "$home_unit/" + + # Fix ExecStart if install path differs (units use %h/koopa-admin-log) + systemctl --user daemon-reload + systemctl --user enable --now taler-monitoring-hacktivism.path + systemctl --user enable --now taler-monitoring-hacktivism.timer + systemctl --user enable --now taler-monitoring-surface.timer + systemctl --user enable --now taler-monitoring-aptdeploy.timer + # initial stamp so path exists + "$ROOT/touch-software-stamp.sh" + systemctl --user start taler-monitoring-hacktivism.service || true + systemctl --user start taler-monitoring-surface.service || true + systemctl --user start taler-monitoring-aptdeploy.service || true + + echo "--- status ---" + systemctl --user status taler-monitoring-hacktivism.path --no-pager -l | head -15 + systemctl --user status taler-monitoring-hacktivism.timer --no-pager -l | head -12 + systemctl --user status taler-monitoring-surface.timer --no-pager -l | head -12 + systemctl --user status taler-monitoring-aptdeploy.timer --no-pager -l | head -12 + systemctl --user list-timers --all | grep taler-monitoring || true + echo "OK host-agent (hacktivism 4h + surface 1h + aptdeploy 4h)" + echo "manual: systemctl --user start taler-monitoring-hacktivism.service" + echo "manual surface: systemctl --user start taler-monitoring-surface.service" + echo "manual aptdeploy: systemctl --user start taler-monitoring-aptdeploy.service" + echo "after upgrades: $ROOT/touch-software-stamp.sh" +} + +if [ -n "$REMOTE" ] && [ "$REMOTE" != "--remote" ]; then + echo "install on $REMOTE …" + rsync -az --delete \ + --exclude secrets.env \ + --exclude 'android-test/artifacts' \ + "$ROOT/../../" \ + "${REMOTE}:koopa-admin-log/scripts/taler-monitoring/" + # ensure full tree for %h/koopa-admin-log if missing configs etc. + ssh -o BatchMode=yes -o ConnectTimeout=20 "$REMOTE" \ + 'test -d $HOME/koopa-admin-log || git clone https://git.hacktivism.ch/hernani/koopa-admin-log.git $HOME/koopa-admin-log 2>/dev/null || true' + rsync -az "$ROOT/" "${REMOTE}:koopa-admin-log/scripts/taler-monitoring/host-agent/" + rsync -az "$ROOT/../site-gen/" "${REMOTE}:koopa-admin-log/scripts/taler-monitoring/site-gen/" 2>/dev/null || true + ssh -o BatchMode=yes "$REMOTE" \ + 'bash $HOME/koopa-admin-log/scripts/taler-monitoring/host-agent/install-host-agent.sh' +else + install_local +fi diff --git a/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh b/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh new file mode 100755 index 0000000..fe81257 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# run-aptdeploy-monitoring.sh — apt-src deploy tests → public HTML +# Public only on taler.hacktivism.ch: +# https://taler.hacktivism.ch/taler-monitoring-aptdeploy/ +# https://taler.hacktivism.ch/taler-monitoring-aptdeploy_err/ +# +# CLI one-shot without HTML remains: run-aptdeploy.sh [all|fresh|upgrade|check-only] +# +set -uo pipefail +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) + +export AGENT_LABEL="${AGENT_LABEL:-aptdeploy-host-agent}" +export STATE_NAME="${STATE_NAME:-taler-aptdeploy-monitoring}" +export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}" +# containers are local on koopa +export INSIDE_PODMAN=1 +export INSIDE_MODE=local-podman +export LOCAL_STACK=1 +export SKIP_SSH=0 +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +# four containers + checks; allow long wall +export RUN_TIMEOUT="${RUN_TIMEOUT:-1800}" +# dedicated job: always aptdeploy only +export PHASES=aptdeploy +# Only merchant public front — not bank/exchange +export MON_HOSTS="${MON_HOSTS:-taler.hacktivism.ch}" +export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}" +export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" + +export HTML_OK_DIR="taler-monitoring-aptdeploy" +export HTML_ERR_DIR="taler-monitoring-aptdeploy_err" +export HTML_URL_OK="/taler-monitoring-aptdeploy/" +export HTML_URL_ERR="/taler-monitoring-aptdeploy_err/" +export PAGE_LABEL="taler-monitoring-aptdeploy" + +# Ensure all four apt-src containers (fresh rebuild + upgrade track) +if [ "${APT_DEPLOY_ENSURE:-1}" = "1" ] \ + && [ -x "$AGENT_DIR/ensure-apt-deploy-test-containers.sh" ]; then + bash "$AGENT_DIR/ensure-apt-deploy-test-containers.sh" all \ + || echo "WARN: ensure-apt-deploy-test-containers failed (aptdeploy may ERROR)" >&2 +fi + +exec bash "$AGENT_DIR/run-host-report.sh" "$@" diff --git a/scripts/taler-monitoring/host-agent/run-aptdeploy.sh b/scripts/taler-monitoring/host-agent/run-aptdeploy.sh new file mode 100755 index 0000000..fbf5afd --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-aptdeploy.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# run-aptdeploy.sh — ensure containers + aptdeploy phase only. +# +# Keep monitoring current: +# 1) Prefer Forgejo origin/main (update-suite reset) +# 2) If Forgejo is behind (no aptdeploy yet), re-apply suite-overlay saved +# from this agent tree before reset (rsync from laptop fills that tree) +# 3) Run ensure + taler-monitoring.sh aptdeploy +# +# Host: on koopa → local; else ssh APT_DEPLOY_SSH + rsync mon tree first. +# +set -euo pipefail + +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) +BOOT_MON=$(cd "$AGENT_DIR/.." && pwd) +MODE="${1:-all}" +APT_DEPLOY_SSH="${APT_DEPLOY_SSH:-koopa-external}" +OVERLAY_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/suite-overlay" + +on_koopa() { + local h + h=$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo "") + case "$h" in koopa|koopa.*) return 0 ;; esac + [ "${KOOPA_FORCE_LOCAL:-0}" = "1" ] && return 0 + return 1 +} + +mon_has_aptdeploy() { + local mon=$1 + [ -f "$mon/taler-monitoring.sh" ] && grep -qE 'aptdeploy\|apt-deploy' "$mon/taler-monitoring.sh" +} + +save_overlay_from() { + local src=$1 + mon_has_aptdeploy "$src" || return 0 + mkdir -p "$OVERLAY_DIR" + rsync -a --delete \ + --exclude secrets.env \ + --exclude 'android-test/artifacts' \ + --exclude 'android-test/apks' \ + --exclude 'android-test/out*' \ + --exclude '__pycache__' \ + "$src/" "$OVERLAY_DIR/" + echo "saved suite-overlay from $src → $OVERLAY_DIR" +} + +apply_overlay_to() { + local dest=$1 + [ -f "$OVERLAY_DIR/taler-monitoring.sh" ] || return 1 + mon_has_aptdeploy "$OVERLAY_DIR" || return 1 + mkdir -p "$dest" + rsync -a \ + --exclude secrets.env \ + --exclude 'android-test/artifacts' \ + --exclude 'android-test/apks' \ + --exclude 'android-test/out*' \ + --exclude '__pycache__' \ + "$OVERLAY_DIR/" "$dest/" + echo "applied suite-overlay → $dest (Forgejo lag; push origin/main when possible)" + return 0 +} + +pick_mon_tree() { + # Preserve working mon before reset wipes tracked files + save_overlay_from "$BOOT_MON" + + # shellcheck source=update-suite.sh + source "$AGENT_DIR/update-suite.sh" + + local suite_mon="$SUITE_DIR/scripts/taler-monitoring" + mkdir -p "$suite_mon" + + if mon_has_aptdeploy "$suite_mon"; then + MON_DIR="$suite_mon" + echo "using Forgejo suite mon @ ${COMMIT_SHORT:-?} · $MON_DIR" + elif apply_overlay_to "$suite_mon" && mon_has_aptdeploy "$suite_mon"; then + MON_DIR="$suite_mon" + echo "using suite mon + overlay @ ${COMMIT_SHORT:-?} · $MON_DIR" + elif mon_has_aptdeploy "$BOOT_MON"; then + MON_DIR="$BOOT_MON" + echo "WARN: using boot mon $MON_DIR (overlay apply failed)" >&2 + else + echo "ERROR: no mon tree with aptdeploy (Forgejo + overlay empty)" >&2 + echo " rsync suite from laptop or push to git.hacktivism.ch" >&2 + exit 2 + fi + + AGENT_DIR="$MON_DIR/host-agent" + export MON_DIR AGENT_DIR SUITE_DIR COMMIT COMMIT_SHORT COMMIT_URL + chmod +x "$AGENT_DIR"/*.sh "$MON_DIR"/taler-monitoring.sh "$MON_DIR"/check_*.sh 2>/dev/null || true +} + +run_local() { + export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" + export PYTHONUNBUFFERED=1 + export PROGRESS_OFF="${PROGRESS_OFF:-0}" + + echo "======== aptdeploy local on $(hostname) mode=$MODE ========" + pick_mon_tree + + if [ "$MODE" != "check-only" ]; then + bash "$AGENT_DIR/ensure-apt-deploy-test-containers.sh" "$MODE" + fi + + cd "$MON_DIR" + export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" + export INSIDE_PODMAN="${INSIDE_PODMAN:-1}" + export LOCAL_STACK="${LOCAL_STACK:-1}" + export RUN_TIMEOUT="${RUN_TIMEOUT:-600}" + echo "run: $MON_DIR/taler-monitoring.sh -d hacktivism.ch aptdeploy · suite=${COMMIT_SHORT:-?}" + ./taler-monitoring.sh -d hacktivism.ch aptdeploy +} + +run_remote() { + echo "======== aptdeploy remote via ssh ${APT_DEPLOY_SSH} mode=$MODE ========" + if [ "${APT_DEPLOY_RSYNC:-1}" = "1" ]; then + echo "rsync mon suite → ${APT_DEPLOY_SSH}:src/koopa-admin-log/scripts/taler-monitoring/" + rsync -az \ + --exclude secrets.env \ + --exclude 'android-test/artifacts' \ + --exclude 'android-test/apks' \ + --exclude 'android-test/out*' \ + --exclude '__pycache__' \ + "$BOOT_MON/" \ + "${APT_DEPLOY_SSH}:src/koopa-admin-log/scripts/taler-monitoring/" \ + || echo "WARN: rsync failed" >&2 + fi + ssh -o BatchMode=yes -o ConnectTimeout=25 "$APT_DEPLOY_SSH" bash -s -- "$MODE" <<'EOS' +set -euo pipefail +MODE=${1:-all} +export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" +export KOOPA_FORCE_LOCAL=1 +AGENT="$HOME/src/koopa-admin-log/scripts/taler-monitoring/host-agent" +if [ ! -x "$AGENT/run-aptdeploy.sh" ]; then + AGENT="$HOME/koopa-admin-log/scripts/taler-monitoring/host-agent" +fi +if [ ! -x "$AGENT/run-aptdeploy.sh" ]; then + echo "ERROR: run-aptdeploy.sh missing on remote" >&2 + exit 1 +fi +echo "remote AGENT=$AGENT" +exec bash "$AGENT/run-aptdeploy.sh" "$MODE" +EOS +} + +if on_koopa; then + run_local +else + run_remote +fi diff --git a/scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh b/scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh new file mode 100755 index 0000000..be876a8 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# FrancPaysan PROD host-agent (user francpaysan on francpaysan-host). +# Shared reporting: run-host-report.sh (timeout, line-buffered log, first-run HTML). +set -uo pipefail +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) + +export AGENT_LABEL="${AGENT_LABEL:-fp-prod-host-agent}" +export STATE_NAME="${STATE_NAME:-taler-monitoring-lfp-prod}" +export TALER_DOMAIN="${TALER_DOMAIN:-lefrancpaysan.ch}" +export INSIDE_PODMAN="${INSIDE_PODMAN:-1}" +export INSIDE_MODE="${INSIDE_MODE:-local-podman}" +export LOCAL_STACK="${LOCAL_STACK:-0}" +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +export RUN_TIMEOUT="${RUN_TIMEOUT:-600}" +export PHASES="${PHASES:-urls inside versions}" +export MON_HOSTS="${MON_HOSTS:-bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch}" +export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}" +export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" + +exec bash "$AGENT_DIR/run-host-report.sh" "$@" diff --git a/scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh b/scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh new file mode 100755 index 0000000..70518f0 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# FrancPaysan STAGE host-agent (user stagepaysan on francpaysan-host). +# Shared reporting: run-host-report.sh (timeout, line-buffered log, first-run HTML). +set -uo pipefail +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) + +export AGENT_LABEL="${AGENT_LABEL:-fp-stage-host-agent}" +export STATE_NAME="${STATE_NAME:-taler-monitoring-stage-lfp}" +export TALER_DOMAIN="${TALER_DOMAIN:-stage.lefrancpaysan.ch}" +export INSIDE_PODMAN="${INSIDE_PODMAN:-1}" +export INSIDE_MODE="${INSIDE_MODE:-local-podman}" +export INSIDE_PROFILE="${INSIDE_PROFILE:-stage-lfp}" +export LOCAL_STACK="${LOCAL_STACK:-0}" +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +export RUN_TIMEOUT="${RUN_TIMEOUT:-600}" +export PHASES="${PHASES:-urls inside versions}" +export MON_HOSTS="${MON_HOSTS:-stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch}" +export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}" +export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" + +exec bash "$AGENT_DIR/run-host-report.sh" "$@" diff --git a/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh b/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh new file mode 100755 index 0000000..8fd98fd --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# GOA / hacktivism host-agent on koopa (hernani). +# Stack defaults only — reporting pipeline is shared: run-host-report.sh +set -uo pipefail +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) + +export AGENT_LABEL="${AGENT_LABEL:-hacktivism-host-agent}" +export STATE_NAME="${STATE_NAME:-taler-hacktivism-monitoring}" +export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}" +export INSIDE_PODMAN="${INSIDE_PODMAN:-1}" +export INSIDE_MODE="${INSIDE_MODE:-local-podman}" +export LOCAL_STACK="${LOCAL_STACK:-1}" +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +export RUN_TIMEOUT="${RUN_TIMEOUT:-600}" +# stack checks only — aptdeploy has its own HTML path + timer: +# run-aptdeploy-monitoring.sh → /taler-monitoring-aptdeploy* (taler.hacktivism.ch) +export PHASES="${PHASES:-urls inside versions}" +export MON_HOSTS="${MON_HOSTS:-bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch}" +export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}" +export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" +export APT_DEPLOY_ENSURE=0 + +exec bash "$AGENT_DIR/run-host-report.sh" "$@" diff --git a/scripts/taler-monitoring/host-agent/run-host-report.sh b/scripts/taler-monitoring/host-agent/run-host-report.sh new file mode 100755 index 0000000..1be1def --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-host-report.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env bash +# run-host-report.sh — global reporting generation for all stacks +# (GOA/hacktivism, FP stage, FP prod, …). +# +# Shared behaviour (always): +# 1) update-suite → latest git.hacktivism.ch (local ~/.config never overwritten) +# 2) line-buffered run log (written continuously) +# 3) RUN_TIMEOUT wall clock for taler-monitoring.sh (default 600s) +# 4) always write HTML (first run / empty staging too) + Forgejo commit link +# 5) optional rsync to DEPLOY_WWW_ROOT if writable +# +# Configure via env file (not in git): +# ${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/env +# or TALER_MONITORING_ENV=/path/to/env +# +# Thin wrappers only set stack defaults then exec this script: +# run-hacktivism-monitoring.sh +# run-fp-stage-monitoring.sh +# run-fp-prod-monitoring.sh +# +set -uo pipefail + +export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" +export PYTHONUNBUFFERED=1 + +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) +AGENT_MON=$(cd "$AGENT_DIR/.." && pwd) +CFG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring" +ENV_FILE="${TALER_MONITORING_ENV:-$CFG_DIR/env}" +mkdir -p "$CFG_DIR" + +# Thin wrappers (surface, aptdeploy-only, …) export stack knobs before exec. +# Snapshot them so ~/.config/taler-monitoring/env cannot clobber dedicated jobs +# (env often has PHASES="urls inside versions aptdeploy" for the main timer). +_wrap_keys=( + PHASES HTML_OUT HTML_OK_DIR HTML_ERR_DIR HTML_URL_OK HTML_URL_ERR PAGE_LABEL + RUN_TIMEOUT STATE_NAME AGENT_LABEL MON_HOSTS TALER_DOMAIN DEPLOY_WWW_ROOT + INSIDE_PODMAN INSIDE_MODE LOCAL_STACK SKIP_SSH CONTINUE_ON_ERROR AUTH401_CONTINUE + APT_DEPLOY_ENSURE SURFACE_SCOPE TALER_DOMAIN_FROM_CLI STRICT_EXIT SOURCE_SUITE_PATH +) +for _k in "${_wrap_keys[@]}"; do + if [ -n "${!_k+x}" ]; then + printf -v "_WRAP_SET_${_k}" '%s' 1 + printf -v "_WRAP_VAL_${_k}" '%s' "${!_k}" + fi +done +unset _k + +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" + set +a +fi +# Tell update-suite.sh not to re-source env (would clobber wrapper PHASES etc.) +export _TALER_MON_ENV_LOADED=1 + +# Restore wrapper overrides (dedicated jobs win over shared env file) +for _k in "${_wrap_keys[@]}"; do + _set_var="_WRAP_SET_${_k}" + _val_var="_WRAP_VAL_${_k}" + if [ "${!_set_var:-}" = "1" ]; then + printf -v "$_k" '%s' "${!_val_var}" + export "$_k" + fi +done +unset _k _set_var _val_var + +# --- global report defaults (env file / wrapper wins if already set) --- +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +export AUTH401_CONTINUE="${AUTH401_CONTINUE:-${CONTINUE_ON_ERROR}}" +export RUN_TIMEOUT="${RUN_TIMEOUT:-600}" +export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}" +PHASES="${PHASES:-urls inside versions}" +HTML_BASE="${HTML_OUT:-$HOME/monitoring-sites-staging}" +MON_HOSTS="${MON_HOSTS:-}" +SUITE_PATH="${SOURCE_SUITE_PATH:-scripts/taler-monitoring}" +AGENT_LABEL="${AGENT_LABEL:-host-report}" +STATE_NAME="${STATE_NAME:-taler-monitoring-${TALER_DOMAIN}}" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/${STATE_NAME}" +LOG_DIR="$STATE_DIR/logs" +DEPLOY_WWW="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" +# HTML path layout under each host (monitoring / surface / …) +HTML_OK_DIR="${HTML_OK_DIR:-monitoring}" +HTML_ERR_DIR="${HTML_ERR_DIR:-monitoring_err}" +HTML_URL_OK="${HTML_URL_OK:-/monitoring/}" +HTML_URL_ERR="${HTML_URL_ERR:-/monitoring_err/}" +PAGE_LABEL="${PAGE_LABEL:-monitoring}" + +# Sensible MON_HOSTS from domain if not set +if [ -z "$MON_HOSTS" ]; then + case "$TALER_DOMAIN" in + hacktivism.ch|koopa) + MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" + ;; + stage.lefrancpaysan.ch|stage.*lefrancpaysan*) + MON_HOSTS="stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch" + ;; + lefrancpaysan.ch) + MON_HOSTS="bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch" + ;; + *) + MON_HOSTS="${TALER_DOMAIN}" + ;; + esac +fi + +mkdir -p "$LOG_DIR" "$HTML_BASE" + +STAMP=$(date +%Y%m%d-%H%M%S) +LOG="$LOG_DIR/run-${STAMP}.log" +: >"$LOG" +if command -v stdbuf >/dev/null 2>&1; then + exec > >(stdbuf -oL -eL tee -a "$LOG") 2>&1 +else + exec > >(tee -a "$LOG") 2>&1 +fi + +echo "======== $(date -Iseconds 2>/dev/null || date) ${AGENT_LABEL} ========" +echo "env_file=$ENV_FILE" +echo "domain=$TALER_DOMAIN · phases=$PHASES · RUN_TIMEOUT=${RUN_TIMEOUT}s" +echo "hosts=$MON_HOSTS" +echo "flags INSIDE_PODMAN=${INSIDE_PODMAN:-} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-} SKIP_SSH=${SKIP_SSH:-} INSIDE_PROFILE=${INSIDE_PROFILE:-}" + +# --- refresh from Forgejo; re-apply suite-overlay if Forgejo lacks needed phases --- +OVERLAY_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/suite-overlay" +# preserve capable mon before reset wipes tracked files +if [ -f "$AGENT_MON/taler-monitoring.sh" ]; then + mkdir -p "$OVERLAY_DIR" + rsync -a \ + --exclude secrets.env --exclude 'android-test/artifacts' --exclude 'android-test/apks' \ + --exclude 'android-test/out*' --exclude '__pycache__' \ + "$AGENT_MON/" "$OVERLAY_DIR/" 2>/dev/null || true +fi + +# shellcheck source=update-suite.sh +source "$AGENT_DIR/update-suite.sh" + +SUITE_MON="${SUITE_DIR}/scripts/taler-monitoring" +ROOT="$SUITE_MON" +_need_overlay=0 +# Detect missing features in Forgejo tree (untracked helper files alone are not enough — +# taler-monitoring.sh must accept the phase name). +case " $PHASES " in + *" aptdeploy "*|*" apt-deploy "*|*" apt_src "*) + if ! grep -qE 'aptdeploy\|apt-deploy' "$SUITE_MON/taler-monitoring.sh" 2>/dev/null; then + _need_overlay=1 + fi + ;; +esac +case " $PHASES " in + *" surface "*|*" ecosystem "*) + if ! grep -qE 'surface\|ecosystem' "$SUITE_MON/taler-monitoring.sh" 2>/dev/null \ + || [ ! -f "$SUITE_MON/check_surface.sh" ]; then + _need_overlay=1 + fi + ;; +esac +# Wrapper-preserve + HTML path knobs may live only in local overlay until pushed +if [ -f "$OVERLAY_DIR/host-agent/run-host-report.sh" ] \ + && ! grep -q '_wrap_keys\|_WRAP_SET_' "$SUITE_MON/host-agent/run-host-report.sh" 2>/dev/null \ + && grep -q '_wrap_keys\|_WRAP_SET_' "$OVERLAY_DIR/host-agent/run-host-report.sh" 2>/dev/null; then + _need_overlay=1 +fi +if [ "$_need_overlay" = "1" ] && [ -f "$OVERLAY_DIR/taler-monitoring.sh" ]; then + echo "WARN: Forgejo @ ${COMMIT_SHORT:-?} lagging local suite-overlay (phases=[$PHASES])" >&2 + echo " re-applying suite-overlay → $SUITE_MON" >&2 + mkdir -p "$SUITE_MON" + rsync -a \ + --exclude secrets.env --exclude 'android-test/artifacts' --exclude 'android-test/apks' \ + --exclude 'android-test/out*' --exclude '__pycache__' \ + "$OVERLAY_DIR/" "$SUITE_MON/" + ROOT="$SUITE_MON" +elif [ "$_need_overlay" = "1" ]; then + echo "WARN: Forgejo missing features and no suite-overlay — using agent mon $AGENT_MON" >&2 + ROOT="$AGENT_MON" +else + echo "using suite mon @ ${COMMIT_SHORT:-?} · $ROOT" +fi +# Always restore host-agent + site-gen from pre-reset snapshot when present +# (systemd ExecStart points at koopa-admin-log which may be the reset suite tree). +if [ -d "$OVERLAY_DIR/host-agent" ]; then + mkdir -p "$SUITE_MON/host-agent" 2>/dev/null || true + rsync -a "$OVERLAY_DIR/host-agent/" "$SUITE_MON/host-agent/" 2>/dev/null || true +fi +if [ -d "$OVERLAY_DIR/site-gen" ]; then + mkdir -p "$SUITE_MON/site-gen" + rsync -a "$OVERLAY_DIR/site-gen/" "$SUITE_MON/site-gen/" 2>/dev/null || true +fi +unset _need_overlay + +if [ -f "$ROOT/site-gen/console_to_html.py" ]; then + SITE_GEN="$ROOT/site-gen" +elif [ -f "$AGENT_MON/site-gen/console_to_html.py" ]; then + SITE_GEN="$AGENT_MON/site-gen" + echo "note: using agent site-gen at $SITE_GEN" +elif [ -f "$SUITE_MON/site-gen/console_to_html.py" ]; then + SITE_GEN="$SUITE_MON/site-gen" +else + SITE_GEN="" +fi + +if [ ! -x "$ROOT/taler-monitoring.sh" ]; then + if [ -x "$AGENT_MON/taler-monitoring.sh" ]; then + ROOT="$AGENT_MON" + echo "note: using agent mon tree $ROOT" + else + echo "ERROR: suite missing at $ROOT" >&2 + COMMIT="${COMMIT:-unknown}" + COMMIT_SHORT="${COMMIT_SHORT:-unknown}" + COMMIT_URL="${COMMIT_URL:-}" + fi +fi + +echo "commit=${COMMIT_SHORT:-?} · ${COMMIT_URL:-}" + +ec=1 +if [ -x "${ROOT:-}/taler-monitoring.sh" ]; then + cd "$ROOT" || exit 1 + chmod +x taler-monitoring.sh check_*.sh host-agent/*.sh site-gen/*.sh site-gen/*.py 2>/dev/null || true + + # Overlay newer inside/versions host-podman bits if suite clone is stale + for f in check_inside.sh check_versions.sh taler-monitoring.sh lib.sh; do + if [ -f "$AGENT_MON/$f" ] && [ -f "$ROOT/$f" ]; then + if ! grep -q 'host-podman\|INSIDE_ACCESS\|RUN_TIMEOUT' "$ROOT/$f" 2>/dev/null \ + && grep -q 'host-podman\|INSIDE_ACCESS\|RUN_TIMEOUT' "$AGENT_MON/$f" 2>/dev/null; then + echo "note: overlay $f from agent install" + cp -f "$AGENT_MON/$f" "$ROOT/$f" + chmod +x "$ROOT/$f" 2>/dev/null || true + fi + fi + done + + # shellcheck disable=SC2086 + ./taler-monitoring.sh -d "$TALER_DOMAIN" $PHASES + ec=$? +fi + +# --- always generate HTML (first run / missing pages / every run) --- +write_bootstrap_html() { + local out="$1" host="$2" mode="$3" + mkdir -p "$(dirname "$out")" + local cshort="${COMMIT_SHORT:-unknown}" + local curl="${COMMIT_URL:-}" + local clink="$cshort" + local gen iso + gen=$(date -u +"%Y-%m-%d %H:%M:%SZ" 2>/dev/null || date) + iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -Iseconds) + if [ -n "$curl" ]; then + clink="$cshort" + fi + cat >"$out" < + + +monitoring ${mode} · ${host} + + +
+

Bootstrap page (converter missing or first install). See host-agent log.

+
$(tail -n 80 "$LOG" 2>/dev/null | sed 's/&/\&/g;s/
+
+ +EOF + echo "bootstrap html → $out" +} + +htmlify_host() { + local host="$1" + local mon="$HTML_BASE/$host/${HTML_OK_DIR}/index.html" + local mon_err="$HTML_BASE/$host/${HTML_ERR_DIR}/index.html" + mkdir -p "$HTML_BASE/$host/${HTML_OK_DIR}" "$HTML_BASE/$host/${HTML_ERR_DIR}" + + if [ -n "$SITE_GEN" ] && [ -f "$SITE_GEN/console_to_html.py" ]; then + python3 "$SITE_GEN/console_to_html.py" \ + --log "$LOG" \ + --out "$mon_err" \ + --hostname "$host" \ + --mode err \ + --commit "${COMMIT:-}" \ + --commit-url "${COMMIT_URL:-}" \ + --suite-path "$SUITE_PATH" \ + --page-label "$PAGE_LABEL" \ + --path-err "$HTML_URL_ERR" \ + --link-other "$HTML_URL_OK" + if [ "$ec" -eq 0 ]; then + python3 "$SITE_GEN/console_to_html.py" \ + --log "$LOG" \ + --out "$mon" \ + --hostname "$host" \ + --mode ok \ + --commit "${COMMIT:-}" \ + --commit-url "${COMMIT_URL:-}" \ + --suite-path "$SUITE_PATH" \ + --page-label "$PAGE_LABEL" \ + --path-err "$HTML_URL_ERR" \ + --link-other "" + rm -rf "$HTML_BASE/$host/${HTML_ERR_DIR}" + echo "html $host → ${HTML_URL_OK} only (clean · ${COMMIT_SHORT:-?})" + else + python3 "$SITE_GEN/console_to_html.py" \ + --log "$LOG" \ + --out "$mon" \ + --hostname "$host" \ + --mode redirect \ + --commit "${COMMIT:-}" \ + --commit-url "${COMMIT_URL:-}" \ + --suite-path "$SUITE_PATH" \ + --page-label "$PAGE_LABEL" \ + --path-err "$HTML_URL_ERR" + echo "html $host → ${HTML_URL_OK} stub + ${HTML_URL_ERR} (ec=$ec · ${COMMIT_SHORT:-?})" + fi + else + echo "WARN: console_to_html.py missing — bootstrap pages" + write_bootstrap_html "$mon_err" "$host" "err" + if [ "$ec" -eq 0 ]; then + write_bootstrap_html "$mon" "$host" "ok" + rm -rf "$HTML_BASE/$host/${HTML_ERR_DIR}" + else + write_bootstrap_html "$mon" "$host" "redirect" + fi + fi + + if [ ! -f "$mon" ]; then + write_bootstrap_html "$mon" "$host" "err" + fi + + printf '%s\n' "${COMMIT:-unknown}" >"$HTML_BASE/$host/COMMIT" + printf '%s\n' "${COMMIT_URL:-}" >"$HTML_BASE/$host/COMMIT_URL" +} +for host in $MON_HOSTS; do + htmlify_host "$host" +done + +if [ -n "$DEPLOY_WWW" ] && [ -w "$DEPLOY_WWW" ] 2>/dev/null; then + rsync -a --delete "$HTML_BASE/" "$DEPLOY_WWW/" && \ + echo "synced → $DEPLOY_WWW/" +fi + +ls -1t "$LOG_DIR"/run-*.log 2>/dev/null | tail -n +30 | xargs rm -f 2>/dev/null || true + +echo "======== done ec=$ec · log=$LOG · ${COMMIT_URL:-} ========" +if [ "${STRICT_EXIT:-0}" = "1" ]; then + exit "$ec" +fi +exit 0 diff --git a/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh b/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh new file mode 100755 index 0000000..1b7a8b8 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# run-surface-monitoring.sh — hourly remote-only ecosystem surface scan +# Public only on taler.hacktivism.ch: +# https://taler.hacktivism.ch/taler-monitoring-surface/ +# https://taler.hacktivism.ch/taler-monitoring-surface_err/ +# +set -uo pipefail +AGENT_DIR=$(cd "$(dirname "$0")" && pwd) + +export AGENT_LABEL="${AGENT_LABEL:-surface-host-agent}" +export STATE_NAME="${STATE_NAME:-taler-surface-monitoring}" +export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}" +# surface is remote-only — force flags (ignore shared env) +export INSIDE_PODMAN=0 +export LOCAL_STACK=0 +export SKIP_SSH=1 +export INSIDE_MODE="${INSIDE_MODE:-none}" +export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +# many hosts; allow up to 45 min wall (phase also has RUN_TIMEOUT) +export RUN_TIMEOUT="${RUN_TIMEOUT:-2400}" +# dedicated job: always surface only (never inherit PHASES from shared env) +export PHASES=surface +# Only merchant public front — not bank/exchange +export MON_HOSTS="${MON_HOSTS:-taler.hacktivism.ch}" +export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}" +export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" + +export HTML_OK_DIR="taler-monitoring-surface" +export HTML_ERR_DIR="taler-monitoring-surface_err" +export HTML_URL_OK="/taler-monitoring-surface/" +export HTML_URL_ERR="/taler-monitoring-surface_err/" +export PAGE_LABEL="taler-monitoring-surface" + +# ecosystem catalog by default (not domain-only) +export SURFACE_SCOPE="${SURFACE_SCOPE:-ecosystem}" +export TALER_DOMAIN_FROM_CLI=0 + +# Do not run apt-deploy ensure for surface-only jobs +export APT_DEPLOY_ENSURE=0 + +exec bash "$AGENT_DIR/run-host-report.sh" "$@" diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service b/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service new file mode 100644 index 0000000..3d71eb1 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service @@ -0,0 +1,18 @@ +[Unit] +Description=Taler monitoring aptdeploy (apt-src container deploy tests) +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=CONTINUE_ON_ERROR=1 +Environment=RUN_TIMEOUT=1800 +WorkingDirectory=%h/koopa-admin-log/scripts/taler-monitoring +ExecStart=%h/koopa-admin-log/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh +Nice=10 +TimeoutStartSec=45min + +[Install] +WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer b/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer new file mode 100644 index 0000000..602ce8b --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Periodic taler-monitoring aptdeploy (apt-src deploy tests) +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md + +[Timer] +OnBootSec=35min +OnUnitActiveSec=4h +AccuracySec=5min +Persistent=true +RandomizedDelaySec=10min +Unit=taler-monitoring-aptdeploy.service + +[Install] +WantedBy=timers.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path new file mode 100644 index 0000000..1cbf003 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path @@ -0,0 +1,19 @@ +[Unit] +Description=Watch hacktivism software/config changes → re-run monitoring +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md + +[Path] +# Explicit stamp after package/image upgrades (touch-software-stamp.sh) +PathChanged=%h/.local/state/taler-hacktivism-monitoring/software.stamp +PathModified=%h/.local/state/taler-hacktivism-monitoring/software.stamp +# Landing + caddy config in admin-log (when checked out under ~) +PathChanged=%h/koopa-admin-log/configs/bank-landing +PathChanged=%h/koopa-admin-log/configs/exchange-landing +PathChanged=%h/koopa-admin-log/configs/merchant-landing +PathChanged=%h/koopa-admin-log/configs/caddy +# Debounce: wait for quiet period before firing +TriggerLimitIntervalSec=120 +TriggerLimitBurst=3 + +[Install] +WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service new file mode 100644 index 0000000..712920c --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service @@ -0,0 +1,21 @@ +[Unit] +Description=Taler monitoring GOA/hacktivism (host-podman inside + public urls) +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# Primary: host can podman-exec into taler-hacktivism* containers +Environment=INSIDE_PODMAN=1 +Environment=CONTINUE_ON_ERROR=1 +Environment=RUN_TIMEOUT=600 +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +WorkingDirectory=%h/koopa-admin-log/scripts/taler-monitoring +ExecStart=%h/koopa-admin-log/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh +Nice=10 +# Suite budget is RUN_TIMEOUT (10m) + git/HTML overhead +TimeoutStartSec=15min + +[Install] +WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer new file mode 100644 index 0000000..b65c139 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Periodic hacktivism monitoring (fallback if no path events) +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md + +[Timer] +# Every 4h + boot +OnBootSec=10min +OnUnitActiveSec=4h +AccuracySec=5min +Persistent=true +Unit=taler-monitoring-hacktivism.service + +[Install] +WantedBy=timers.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-surface.service b/scripts/taler-monitoring/host-agent/taler-monitoring-surface.service new file mode 100644 index 0000000..6018dee --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-surface.service @@ -0,0 +1,18 @@ +[Unit] +Description=Taler monitoring surface (remote ecosystem inventory) +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=CONTINUE_ON_ERROR=1 +Environment=RUN_TIMEOUT=2400 +WorkingDirectory=%h/koopa-admin-log/scripts/taler-monitoring +ExecStart=%h/koopa-admin-log/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh +Nice=10 +TimeoutStartSec=50min + +[Install] +WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer b/scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer new file mode 100644 index 0000000..a9ed8c9 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Hourly taler-monitoring surface scan (ecosystem) +Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md + +[Timer] +OnBootSec=20min +OnUnitActiveSec=1h +AccuracySec=2min +Persistent=true +RandomizedDelaySec=5min +Unit=taler-monitoring-surface.service + +[Install] +WantedBy=timers.target diff --git a/scripts/taler-monitoring/host-agent/touch-software-stamp.sh b/scripts/taler-monitoring/host-agent/touch-software-stamp.sh new file mode 100755 index 0000000..0aff400 --- /dev/null +++ b/scripts/taler-monitoring/host-agent/touch-software-stamp.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Call after upgrading hacktivism packages / redeploying landings / image rebuilds. +# systemd path unit watches this stamp → re-runs monitoring. +set -euo pipefail +STATE="${XDG_STATE_HOME:-$HOME/.local/state}/taler-hacktivism-monitoring" +mkdir -p "$STATE" +STAMP="$STATE/software.stamp" +date -Iseconds >"$STAMP" +# also record container image ids for debugging +if command -v podman >/dev/null 2>&1; then + { + echo "# images $(date -Iseconds)" + podman images --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.Digest}}' 2>/dev/null | grep -i hacktivism || true + podman ps --format '{{.Names}} {{.ImageID}} {{.Status}}' 2>/dev/null | grep -i hacktivism || true + } >"$STATE/software-images.txt" || true +fi +echo "touched $STAMP" diff --git a/scripts/taler-monitoring/host-agent/update-suite.sh b/scripts/taler-monitoring/host-agent/update-suite.sh new file mode 100755 index 0000000..70d348d --- /dev/null +++ b/scripts/taler-monitoring/host-agent/update-suite.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# update-suite.sh — ensure taler-monitoring code is latest from Forgejo. +# +# Local config (NEVER overwritten by git): +# ${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/env +# +# Env overrides (also settable in that file): +# SUITE_GIT_URL default https://git.hacktivism.ch/hernani/koopa-admin-log.git +# SUITE_GIT_REF default main +# SUITE_DIR default $HOME/src/koopa-admin-log (or $HOME/koopa-admin-log) +# SUITE_UPDATE_MODE ff (default) | reset (reset = hard to origin/REF) +# +set -uo pipefail + +CFG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring" +ENV_FILE="${TALER_MONITORING_ENV:-$CFG_DIR/env}" +# When sourced from run-host-report.sh the env file is already loaded and +# wrapper PHASES/HTML_* must not be clobbered. Only load here if needed. +if [ -f "$ENV_FILE" ] && [ "${_TALER_MON_ENV_LOADED:-0}" != "1" ]; then + # shellcheck disable=SC1090 + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" + set +a +fi + +SUITE_GIT_URL="${SUITE_GIT_URL:-https://git.hacktivism.ch/hernani/koopa-admin-log.git}" +SUITE_GIT_REF="${SUITE_GIT_REF:-main}" +# Host agents default to reset so "always latest from Forgejo" wins over local +# diverged trees. Override with SUITE_UPDATE_MODE=ff for careful pulls. +SUITE_UPDATE_MODE="${SUITE_UPDATE_MODE:-reset}" + +if [ -z "${SUITE_DIR:-}" ]; then + if [ -d "$HOME/src/koopa-admin-log/.git" ]; then + SUITE_DIR="$HOME/src/koopa-admin-log" + elif [ -d "$HOME/koopa-admin-log/.git" ]; then + SUITE_DIR="$HOME/koopa-admin-log" + else + SUITE_DIR="$HOME/src/koopa-admin-log" + fi +fi + +export SUITE_DIR + +mkdir -p "$(dirname "$SUITE_DIR")" "$CFG_DIR" + +if [ ! -d "$SUITE_DIR/.git" ]; then + echo "clone $SUITE_GIT_URL → $SUITE_DIR" + git clone --branch "$SUITE_GIT_REF" "$SUITE_GIT_URL" "$SUITE_DIR" \ + || git clone "$SUITE_GIT_URL" "$SUITE_DIR" +fi + +cd "$SUITE_DIR" || exit 1 +git remote set-url origin "$SUITE_GIT_URL" 2>/dev/null \ + || git remote add origin "$SUITE_GIT_URL" 2>/dev/null || true + +echo "fetch origin ($SUITE_GIT_REF) …" +if ! git fetch --tags origin 2>&1; then + echo "WARN: git fetch failed — using existing tree at $SUITE_DIR" >&2 + git rev-parse HEAD 2>/dev/null || true + return 0 2>/dev/null || exit 0 +fi + +case "$SUITE_UPDATE_MODE" in + reset) + git checkout -B "$SUITE_GIT_REF" "origin/$SUITE_GIT_REF" 2>/dev/null \ + || git checkout -B "$SUITE_GIT_REF" "FETCH_HEAD" + git reset --hard "origin/$SUITE_GIT_REF" 2>/dev/null \ + || git reset --hard FETCH_HEAD + ;; + *) + git checkout "$SUITE_GIT_REF" 2>/dev/null || git checkout -B "$SUITE_GIT_REF" "origin/$SUITE_GIT_REF" 2>/dev/null || true + if ! git pull --ff-only origin "$SUITE_GIT_REF" 2>&1; then + echo "WARN: ff-only pull failed — try SUITE_UPDATE_MODE=reset or fix local commits" >&2 + fi + ;; +esac + +COMMIT=$(git rev-parse HEAD) +COMMIT_SHORT=$(git rev-parse --short=12 HEAD) +# Forgejo web UI +SOURCE_REPO_WEB="${SOURCE_REPO_WEB:-https://git.hacktivism.ch/hernani/koopa-admin-log}" +COMMIT_URL="${SOURCE_REPO_WEB}/src/commit/${COMMIT}" +export COMMIT COMMIT_SHORT COMMIT_URL SOURCE_REPO_WEB + +echo "suite @ $COMMIT_SHORT $COMMIT_URL" +echo " dir=$SUITE_DIR" +echo " config=$ENV_FILE (not touched by git)" +echo " note: always latest = origin/${SUITE_GIT_REF} on git.hacktivism.ch (push local work there)" + +# Symlink convenience path if missing or not a git clone +if [ -d "$SUITE_DIR/.git" ]; then + if [ ! -e "$HOME/koopa-admin-log" ]; then + ln -sfn "$SUITE_DIR" "$HOME/koopa-admin-log" 2>/dev/null || true + elif [ -L "$HOME/koopa-admin-log" ]; then + ln -sfn "$SUITE_DIR" "$HOME/koopa-admin-log" 2>/dev/null || true + fi +fi + +# Make suite scripts executable after reset +if [ -d "$SUITE_DIR/scripts/taler-monitoring" ]; then + chmod +x \ + "$SUITE_DIR"/scripts/taler-monitoring/taler-monitoring.sh \ + "$SUITE_DIR"/scripts/taler-monitoring/check_*.sh \ + "$SUITE_DIR"/scripts/taler-monitoring/host-agent/*.sh \ + "$SUITE_DIR"/scripts/taler-monitoring/site-gen/*.sh \ + "$SUITE_DIR"/scripts/taler-monitoring/site-gen/*.py \ + 2>/dev/null || true +fi From a9fe2b077f654427b7702404098e5597e1484857 Mon Sep 17 00:00:00 2001 From: hernani Date: Sat, 18 Jul 2026 19:57:21 +0200 Subject: [PATCH 311/326] monitoring: site-gen console HTML and sticky status bar --- scripts/taler-monitoring/site-gen/README.md | 81 ++ .../site-gen/ROOT-ON-KOOPA.md | 92 ++ .../site-gen/caddy-monitoring-handles.snippet | 57 ++ ...om.hacktivism.taler-monitoring-sites.plist | 34 + .../site-gen/console_to_html.py | 935 ++++++++++++++++++ .../site-gen/deploy-monitoring-sites.sh | 44 + .../site-gen/generate-monitoring-sites.sh | 273 +++++ .../site-gen/install-firecuda-timer.sh | 79 ++ .../site-gen/pull-firecuda-to-koopa.sh | 15 + .../site-gen/run-on-firecuda.sh | 59 ++ .../site-gen/settings.conf.example | 55 ++ 11 files changed, 1724 insertions(+) create mode 100644 scripts/taler-monitoring/site-gen/README.md create mode 100644 scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md create mode 100644 scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet create mode 100644 scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist create mode 100755 scripts/taler-monitoring/site-gen/console_to_html.py create mode 100755 scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh create mode 100755 scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh create mode 100755 scripts/taler-monitoring/site-gen/install-firecuda-timer.sh create mode 100755 scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh create mode 100755 scripts/taler-monitoring/site-gen/run-on-firecuda.sh create mode 100644 scripts/taler-monitoring/site-gen/settings.conf.example diff --git a/scripts/taler-monitoring/site-gen/README.md b/scripts/taler-monitoring/site-gen/README.md new file mode 100644 index 0000000..82e52d2 --- /dev/null +++ b/scripts/taler-monitoring/site-gen/README.md @@ -0,0 +1,81 @@ +# monitoring site-gen + +Build **console-style HTML** from `taler-monitoring` runs and stage them for +**host Caddy** (`/monitoring`, `/monitoring_err`). + +Same **global reporting defaults** as host-agents (`run-host-report.sh`): + +- **`RUN_TIMEOUT=600`** on each suite run +- **line-buffered** logs (continuous write) +- **always** write HTML (first run / empty logs too) +- Forgejo **commit link** in the page header + +Applies to all nine fronts (GOA + FP stage + FP prod) in the default `SITES` list. + +| Host | Role | +|------|------| +| **firecuda-external** | optional runner (public DNS) | +| **koopa-external** | deploy staging + Caddy (root service) | +| **host-agents** | primary: GOA + FP on-box (`../host-agent/`) | + +No secrets in `settings.conf` — only hosts, paths, site list. + +## firecuda-external (outside-only) — **disabled for now** + +**firecuda-external could** run public-only monitoring (`SKIP_SSH=1`, phases +`urls versions`) on a schedule (launchd every 4h) and write HTML under +`~/var/taler-monitoring-sites-work/html/`. Scripts for that still live here +(`install-firecuda-timer.sh`, `run-on-firecuda.sh`, plist template). + +**Current policy: do not run a real launchd job on firecuda.** +Primary GOA monitoring is **koopa host-agent** (`host-agent/`, user `hernani`, +local `podman exec`). Re-enable firecuda only if you explicitly want a second, +outside-only view. + +```bash +# optional one-shot (no timer): +# ssh firecuda-external '~/taler-monitoring-site-gen/site-gen/run-on-firecuda.sh' + +# if a timer was installed earlier, ensure it is off: +# ssh firecuda-external 'launchctl list | grep taler-monitoring || echo off' +``` + +Disabled plists may remain as `*.plist.disabled-*` under `~/Library/LaunchAgents/`. + +## Quick start (laptop) + +```bash +cd scripts/taler-monitoring/site-gen +cp settings.conf.example settings.conf # optional +chmod +x generate-monitoring-sites.sh deploy-monitoring-sites.sh console_to_html.py + +ONLY_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" \ + SKIP_SSH=1 RUNNER_SSH= \ + ./generate-monitoring-sites.sh + +./deploy-monitoring-sites.sh +``` + +Then on koopa **as root**: see **[ROOT-ON-KOOPA.md](./ROOT-ON-KOOPA.md)**. + +## URL rules + +| Path | When | +|------|------| +| `/monitoring_err/` | always: full console log + error list on top (CONTINUE_ON_ERROR run) | +| `/monitoring/` | **clean** full log if last strict run exit 0; else **stub** linking to `/monitoring_err/` | + +Footer links **Forgejo commit** of the admin-log tree used for that run. + +## Layout + +```text +site-gen/ + settings.conf.example + generate-monitoring-sites.sh + deploy-monitoring-sites.sh + console_to_html.py + caddy-monitoring-handles.snippet + ROOT-ON-KOOPA.md + README.md +``` diff --git a/scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md b/scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md new file mode 100644 index 0000000..604b997 --- /dev/null +++ b/scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md @@ -0,0 +1,92 @@ +# Root on koopa (hacktivism monitoring sites) + +Host Caddy runs as **root/systemd** (`caddy` user). Static monitoring HTML is +**not** put into Taler containers — only host paths + Caddy `handle`. + +After `generate-monitoring-sites.sh` + `deploy-monitoring-sites.sh` (as hernani), +files live in: + +```text +/home/hernani/monitoring-sites-staging//monitoring/index.html +/home/hernani/monitoring-sites-staging//monitoring_err/index.html +``` + +## One-shot as root (copy + Caddy) + +```bash +# on koopa (koopa-external), as root: + +# 1) web root +install -d -o caddy -g caddy -m 755 /var/www/monitoring-sites +rsync -a --delete /home/hernani/monitoring-sites-staging/ /var/www/monitoring-sites/ +chown -R caddy:caddy /var/www/monitoring-sites + +# 2) Caddyfile — merge handles from admin-log mirror, then: +# (either edit /etc/caddy/Caddyfile by hand using snippet below, +# or copy full mirror after review) +# +# install -m 644 /home/hernani/koopa-admin-log/configs/caddy/Caddyfile /etc/caddy/Caddyfile +# # or: ~/koopa-caddy/apply.sh after syncing Caddyfile into ~/koopa-caddy/ + +caddy validate --config /etc/caddy/Caddyfile +systemctl reload caddy +systemctl is-active caddy +``` + +## Snippet per hacktivism site (inside each `*.hacktivism.ch { }` block) + +Place **before** the catch-all `reverse_proxy` (same idea as `/intro*`): + +```caddy + # Public taler-monitoring console HTML (static; host only) + handle /monitoring_err* { + root * /var/www/monitoring-sites/{host} + rewrite * /monitoring_err{uri} + # uri is /monitoring_err or /monitoring_err/ → serve directory index + file_server + } + handle /monitoring* { + root * /var/www/monitoring-sites/{host} + file_server + } +``` + +Simpler layout (recommended): path on disk matches URL under host root: + +```text +/var/www/monitoring-sites/bank.hacktivism.ch/monitoring/index.html +/var/www/monitoring-sites/bank.hacktivism.ch/monitoring_err/index.html +``` + +```caddy + handle_path /monitoring_err/* { + root * /var/www/monitoring-sites/{host}/monitoring_err + file_server + } + handle /monitoring_err { + redir /monitoring_err/ 302 + } + handle_path /monitoring/* { + root * /var/www/monitoring-sites/{host}/monitoring + file_server + } + handle /monitoring { + redir /monitoring/ 302 + } +``` + +Canonical snippet is also in `caddy-monitoring-handles.snippet` and applied +in `configs/caddy/Caddyfile` for the three GOA hosts. + +## Check + +```bash +curl -sS -o /dev/null -w '%{http_code}\n' https://bank.hacktivism.ch/monitoring/ +curl -sS -o /dev/null -w '%{http_code}\n' https://bank.hacktivism.ch/monitoring_err/ +curl -sS https://bank.hacktivism.ch/monitoring_err/ | head +``` + +## Not needed as root + +- Running `taler-monitoring` (use **firecuda-external** / laptop) +- Writing into podman Taler containers diff --git a/scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet b/scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet new file mode 100644 index 0000000..1c09531 --- /dev/null +++ b/scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet @@ -0,0 +1,57 @@ +# Host Caddy on koopa (root service). Paste BEFORE catch-all reverse_proxy. +# +# Disk layout: +# /var/www/monitoring-sites/{host}/monitoring/index.html +# /var/www/monitoring-sites/{host}/monitoring_err/index.html +# /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface*/index.html +# /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy*/index.html +# +# /monitoring* → bank + exchange + taler.hacktivism.ch +# /taler-monitoring-surface* → taler.hacktivism.ch only +# /taler-monitoring-aptdeploy* → taler.hacktivism.ch only + +# --- only inside taler.hacktivism.ch { ... } --- + handle /taler-monitoring-surface_err { + redir /taler-monitoring-surface_err/ 302 + } + handle /taler-monitoring-surface_err/ { + root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface_err + file_server + } + handle /taler-monitoring-surface { + redir /taler-monitoring-surface/ 302 + } + handle /taler-monitoring-surface/ { + root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface + file_server + } + handle /taler-monitoring-aptdeploy_err { + redir /taler-monitoring-aptdeploy_err/ 302 + } + handle /taler-monitoring-aptdeploy_err/ { + root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy_err + file_server + } + handle /taler-monitoring-aptdeploy { + redir /taler-monitoring-aptdeploy/ 302 + } + handle /taler-monitoring-aptdeploy/ { + root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy + file_server + } + +# --- bank + exchange + taler (each site block) --- + handle /monitoring_err { + redir /monitoring_err/ 302 + } + handle /monitoring_err/ { + root * /var/www/monitoring-sites/{host}/monitoring_err + file_server + } + handle /monitoring { + redir /monitoring/ 302 + } + handle /monitoring/ { + root * /var/www/monitoring-sites/{host}/monitoring + file_server + } diff --git a/scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist b/scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist new file mode 100644 index 0000000..0b6f68b --- /dev/null +++ b/scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist @@ -0,0 +1,34 @@ + + + + + + Label + com.hacktivism.taler-monitoring-sites + ProgramArguments + + /bin/bash + __SITE_GEN__/run-on-firecuda.sh + + StartInterval + 14400 + RunAtLoad + + StandardOutPath + __HOME__/Library/Logs/taler-monitoring-sites/launchd.out.log + StandardErrorPath + __HOME__/Library/Logs/taler-monitoring-sites/launchd.err.log + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + SKIP_SSH + 1 + CONTINUE_ON_ERROR + 1 + + + diff --git a/scripts/taler-monitoring/site-gen/console_to_html.py b/scripts/taler-monitoring/site-gen/console_to_html.py new file mode 100755 index 0000000..14aef2a --- /dev/null +++ b/scripts/taler-monitoring/site-gen/console_to_html.py @@ -0,0 +1,935 @@ +#!/usr/bin/env python3 +"""Convert taler-monitoring console log to a console-feel HTML page.""" +from __future__ import annotations + +import argparse +import html +import re +from datetime import datetime, timezone +from pathlib import Path + +# ANSI strip +ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") +ERR_BULLET_RE = re.compile(r"^\s*[•·*-]\s+(?P.+)$") +TID_RE = re.compile(r"\b(?P[a-z0-9_.-]+-\d+)\b", re.I) + + +def strip_ansi(s: str) -> str: + return ANSI_RE.sub("", s) + + +def classify(line: str) -> str: + u = line.upper() + # section / summary headers before badge matching (they contain the word ERROR) + if "SUMMARY" in u or "--- ERRORS" in u or "ERRORS ·" in u or "ERRORS (" in u: + return "meta" + if "numbered checks" in line.lower() or "totals:" in line.lower(): + return "meta" + if "BLOCKER" in u or "┌ BLOCKER" in u: + return "blocker" + if "ERROR" in u or "┌ ERROR" in u: + return "error" + if "WARN" in u or "┌ WARN" in u: + return "warn" + if "INFO" in u or "┌ INFO" in u: + return "info" + if " OK" in u or "[OK" in u or "┌ OK" in u or u.strip().startswith("OK"): + return "ok" + if u.strip().startswith("-- ") or u.strip().startswith("=="): + return "section" + return "plain" + + +def is_run_timeout_text(text: str) -> bool: + u = text.upper() + return "RUN.TIMEOUT" in u or "RUN_TIMEOUT" in u or "RUN TIMEOUT" in u + + +def is_summary_error_line(ln: str) -> bool: + """Skip summary/header lines that contain ERROR but are not checks.""" + low = ln.lower() + if "failed — see" in low or "failed - see" in low: + return True + if "errors · failed" in low or "errors (failed" in low: + return True + if "--- errors" in low: + return True + if "┌ summary" in low or "[ summary" in low: + return True + if "totals:" in low: + return True + # header-ish RUN_TIMEOUT= without error badge + if is_run_timeout_text(ln) and "ERROR" not in ln.upper() and "run.timeout" not in low: + return True + return False + + +def extract_errors(lines: list[str]) -> list[str]: + errs: list[str] = [] + timeout_detail = "" + in_block = False + for ln in lines: + if "--- ERRORS" in ln or "ERRORS (failed" in ln or "RUN TIMEOUT · extraordinary" in ln: + in_block = True + continue + if in_block: + if ln.strip().startswith("---") or "[ SUMMARY" in ln or "totals:" in ln: + in_block = False + continue + m = ERR_BULLET_RE.match(ln) + if m: + body = m.group("body").strip() + if is_run_timeout_text(body): + timeout_detail = body + else: + errs.append(body) + elif "ERROR" in ln.upper() and ln.strip() and not is_run_timeout_text(ln): + errs.append(ln.strip()) + if re.search(r"\bERROR\b", ln, re.I) and "--- ERRORS" not in ln: + if is_run_timeout_text(ln): + if "run.timeout-01" in ln.lower() or "RUN_TIMEOUT exceeded" in ln: + timeout_detail = re.sub(r"\s+", " ", strip_ansi(ln)).strip() + continue + if is_summary_error_line(ln): + continue + body = re.sub(r"^.*\bERROR\b\s*\]?\s*", "", ln, flags=re.I).strip(" ·") + if body and body not in errs and "failed — see" not in body.lower(): + if re.search( + r"#\d+|www\.|e2e\.|auth401\.|versions\.|inside\.|surface\.|aptdeploy\.", + body, + ): + errs.append(body) + seen: set[str] = set() + out: list[str] = [] + for e in errs: + if e in seen or is_run_timeout_text(e): + continue + seen.add(e) + out.append(e) + if timeout_detail or any( + is_run_timeout_text(l) and ("ERROR" in l.upper() or "run.timeout" in l.lower()) + for l in lines + ): + # only if real timeout ERROR badge, not mere RUN_TIMEOUT= header + if timeout_detail or any( + "run.timeout-01" in l.lower() or "RUN_TIMEOUT exceeded" in l for l in lines + ): + canon = timeout_detail or "run.timeout-01 [run] RUN_TIMEOUT exceeded" + out = [canon] + out + return out + + +def count_status(lines: list[str]) -> tuple[int, int, int | None, int | None]: + """Return (error_count, warn_count, first_error_idx, first_warn_idx).""" + n_err = 0 + n_warn = 0 + first_err: int | None = None + first_warn: int | None = None + for i, ln in enumerate(lines): + kind = classify(ln) + if kind in ("error", "blocker"): + if is_summary_error_line(ln): + continue + n_err += 1 + if first_err is None: + first_err = i + elif kind == "warn": + n_warn += 1 + if first_warn is None: + first_warn = i + return n_err, n_warn, first_err, first_warn + + +def slug_error(i: int, text: str) -> str: + if is_run_timeout_text(text): + return "err-run-timeout" + m = TID_RE.search(text) + if m: + return "err-" + re.sub(r"[^a-z0-9_-]+", "-", m.group("tid").lower()) + return f"err-{i:03d}" + + +def render_line(ln: str, err_slugs: dict[str, str], extra_ids: list[str] | None = None) -> str: + kind = classify(ln) + esc = html.escape(ln) + for tid, slug in err_slugs.items(): + if tid in ln: + esc = esc.replace( + html.escape(tid), + f'{html.escape(tid)}', + ) + id_attr = "" + if extra_ids: + id_attr = f' id="{" ".join(extra_ids)}"' # invalid multi-id; use first only + # HTML allows one id — join carefully + id_attr = f' id="{html.escape(extra_ids[0])}"' + if len(extra_ids) > 1: + # secondary anchors as empty spans prepended + spans = "".join(f'' for x in extra_ids[1:]) + return f'{spans}{esc}\n' + return f'{esc}\n' + + +def status_level(n_err: int, n_warn: int) -> str: + if n_err > 0: + return "red" + if n_warn > 0: + return "yellow" + return "green" + + +def extract_phases(log_text: str) -> str: + """Best-effort phases string from host-agent / taler-monitoring log header.""" + for pat in ( + r"phases=([a-z0-9 _.-]+)", + r"· phases=([a-z0-9 _.-]+)", + r"phases\s{2,}([a-z0-9 _.-]+)", + r"^\s*phases\s+([a-z0-9 _.-]+)\s*$", + ): + m = re.search(pat, log_text, re.I | re.M) + if m: + return re.sub(r"\s+", " ", m.group(1)).strip(" ·") + return "" + + +def monitoring_scope( + page_label: str, + hostname: str, + log_text: str = "", +) -> dict[str, object]: + """ + Human-readable description of what this monitoring page covers. + Returned keys: title, summary, items (list[str]), kind + """ + label = (page_label or "monitoring").lower().replace("_", "-") + phases = extract_phases(log_text) + host = hostname or "?" + + if "surface" in label: + return { + "kind": "surface", + "title": "Remote ecosystem surface inventory", + "summary": "Public catalog hosts · DNS / TCP / HTTPS / TLS · Server version · CVE", + "items": [ + "Outside-in only: no SSH and no podman exec on remote targets", + "Catalog (surface-catalog.conf): taler.net, demo/test, gnunet.org, " + "taler-ops.ch, taler-systems.com, deb.taler.net, ftp.gnu.org, …", + "Per host: DNS resolve, ICMP (info), open ports, protocol probes, " + "TLS cert expiry, Server-header software version, optional CVE check", + f"HTML published only on taler.hacktivism.ch " + f"(this page host: {host})", + "Timer: taler-monitoring-surface.timer (hourly)", + ], + } + + if "aptdeploy" in label or "apt-deploy" in label or "apt_src" in label: + return { + "kind": "aptdeploy", + "title": "apt-src merchant deploy tests (podman on koopa)", + "summary": "4 containers · fresh + upgrade tracks · trixie & trixie-testing", + "items": [ + "koopa-taler-deploy-test-apt-src-trixie (fresh, suite trixie)", + "koopa-taler-deploy-test-apt-src-trixie-testing (fresh, trixie-testing)", + "koopa-taler-deploy-test-apt-src-trixie-upgrade (upgrade track)", + "koopa-taler-deploy-test-apt-src-trixie-testing-upgrade (upgrade track)", + "Checks: packages, taler-merchant-httpd --version / ldd, systemd unit, " + "optional local /config probe — not public nginx/HTTPS", + f"HTML published only on taler.hacktivism.ch (this page host: {host})", + "Timer: taler-monitoring-aptdeploy.timer (4h)", + ], + } + + # Default: stack /monitoring pages (bank, exchange, merchant) + phase_txt = phases or "urls · inside · versions" + role = "stack" + if host.startswith("bank."): + role = "bank" + focus = "Libeufin bank public HTTPS + container inside checks" + elif host.startswith("exchange.") or host.startswith("stage.exchange."): + role = "exchange" + focus = "Exchange public HTTPS + container inside checks" + elif host.startswith("taler.") or "merchant" in host or host.startswith("monnaie."): + role = "merchant" + focus = "Merchant backend / SPA public HTTPS + container inside checks" + else: + focus = "Taler stack public + inside checks" + + items = [ + f"Focus: {focus}", + f"Phases this run: {phase_txt}", + "Typical: public URLs (HTTPS, QR, perf), inside (podman/ssh), package versions", + f"HTML host: {host}", + "Timer: taler-monitoring-hacktivism (path + 4h) · /monitoring on bank, exchange, taler", + ] + if phases: + items.insert(2, f"Log header phases= {phases}") + + return { + "kind": role, + "title": f"Stack monitoring · {host}", + "summary": f"{phase_txt} · public + inside", + "items": items, + } + + +def sticky_bar_html( + *, + generated_at: str, + generated_iso: str, + n_err: int, + n_warn: int, + hostname: str, + page_label: str, + mode: str, + commit_html: str, + suite_path: str, + link_other: str | None, + first_err_href: str | None, + first_warn_href: str | None, + scope: dict[str, object] | None = None, +) -> str: + level = status_level(n_err, n_warn) + if level == "red": + status_txt = "ERRORS" + elif level == "yellow": + status_txt = "WARNINGS" + else: + status_txt = "OK" + + if first_err_href and n_err: + err_stat = ( + f'{n_err} error{"s" if n_err != 1 else ""}' + ) + else: + err_stat = f'{n_err} errors' + + if first_warn_href and n_warn: + warn_stat = ( + f'{n_warn} warning{"s" if n_warn != 1 else ""}' + ) + else: + warn_stat = f'{n_warn} warnings' + + other = "" + if link_other: + other = ( + f'' + f"{html.escape(link_other)}" + ) + + if scope is None: + scope = monitoring_scope(page_label, hostname, "") + scope_title = html.escape(str(scope.get("title") or "Monitoring")) + scope_summary = html.escape(str(scope.get("summary") or "")) + scope_kind = html.escape(str(scope.get("kind") or "monitoring")) + lis = "\n".join( + f"
  • {html.escape(str(it))}
  • " for it in (scope.get("items") or []) + ) + suite_bit = ( + f" · {html.escape(suite_path)}" if suite_path else "" + ) + + # Compact sticky row always visible; "Was geprüft wird" expands inside sticky-bar + return f""" + +""" + + +STICKY_CSS = """ +.sticky-bar { + position: sticky; top: 0; z-index: 100; + border-bottom: 2px solid var(--border); + padding: 10px 14px 8px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 2px 12px #0008; +} +.sticky-bar.sticky-green { + background: linear-gradient(180deg, #0d1f14f2, #0a1410ee); + border-bottom-color: #1f6b3a; +} +.sticky-bar.sticky-yellow { + background: linear-gradient(180deg, #2a2210f2, #1a160cee); + border-bottom-color: #a68b2d; +} +.sticky-bar.sticky-red { + background: linear-gradient(180deg, #2a1010f2, #1a0808ee); + border-bottom-color: #a33; +} +.sticky-bar-row { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; + max-width: 1200px; +} +.sticky-bar-row.secondary { + margin-top: 8px; font-size: 12px; color: var(--dim); +} +.sticky-bar-row.secondary a { color: var(--info); text-decoration: none; } +.sticky-bar-row.secondary a:hover { text-decoration: underline; } +.status-pill { + display: inline-block; font-size: 11px; font-weight: 800; + letter-spacing: 0.06em; padding: 2px 10px; border-radius: 3px; + border: 1px solid; +} +.status-pill.sticky-green { color: var(--ok); border-color: #1f6b3a; background: #0a1a10; } +.status-pill.sticky-yellow { color: var(--warn); border-color: #a68b2d; background: #1a1608; } +.status-pill.sticky-red { color: var(--err); border-color: #a33; background: #1a0a0a; } +.host { font-weight: 600; color: #eee; font-size: 13px; } +.sep { color: var(--dim); } +.stat { + font-weight: 700; font-size: 12px; text-decoration: none; + padding: 1px 8px; border-radius: 3px; border: 1px solid transparent; +} +.stat.err { color: var(--err); border-color: #522; background: #1a0a0a; } +.stat.warn { color: var(--warn); border-color: #664; background: #1a1608; } +.stat.muted { opacity: 0.55; font-weight: 600; } +a.stat:hover { text-decoration: underline; filter: brightness(1.15); } +.generated { color: var(--dim); font-size: 12px; } +.generated time { color: #bbb; } +.generated .age { color: #888; margin-left: 4px; } +.generated .age:not(:empty)::before { content: "("; } +.generated .age:not(:empty)::after { content: ")"; } +.badge { + display: inline-block; padding: 1px 8px; border-radius: 3px; + font-size: 11px; font-weight: 700; margin-right: 4px; + border: 1px solid var(--border); +} +.badge.mode-err { color: var(--err); border-color: #522; background: #1a0a0a; } +.badge.mode-ok { color: var(--ok); border-color: #143; background: #0a1a10; } +.badge.mode-redirect { color: var(--warn); border-color: #664; background: #1a1608; } +/* collapsible block inside sticky-bar */ +.scope-toggle { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 8px; + max-width: min(42rem, 100%); + cursor: pointer; + font: inherit; + font-size: 12px; + color: #ddd; + background: #0006; + border: 1px solid #444; + border-radius: 4px; + padding: 4px 10px; + text-align: left; +} +.scope-toggle:hover { border-color: #888; background: #111a; color: #fff; } +.scope-toggle:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; } +.scope-toggle[aria-expanded="true"] { + border-color: #777; + background: #141414; +} +.scope-chevron { + display: inline-block; + width: 0; height: 0; + border-left: 5px solid #aaa; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + transition: transform 0.12s ease; + flex-shrink: 0; +} +.scope-toggle[aria-expanded="true"] .scope-chevron { + transform: rotate(90deg); + border-left-color: #eee; +} +.scope-toggle-label { + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + font-size: 11px; + white-space: nowrap; +} +.scope-toggle-hint { + color: #888; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.sticky-scope-panel { + margin-top: 8px; + max-width: 1200px; + border: 1px solid #333; + border-radius: 4px; + background: #080808e6; + padding: 8px 10px 10px; +} +.sticky-scope-panel[hidden] { display: none !important; } +.scope-body { padding: 4px 4px 2px 6px; } +.scope-title { + margin: 4px 0 4px; + font-size: 13px; + font-weight: 600; + color: #eee; +} +.scope-lead { + margin: 0 0 6px; + font-size: 12px; + color: #999; +} +.scope-list { + margin: 0; + padding-left: 1.15em; + color: #b0b0b0; + font-size: 12px; + line-height: 1.5; +} +.scope-list li { margin: 3px 0; } +#first-error, #first-warn, +[id^="err-"][id$="-line"] { + scroll-margin-top: 120px; +} +@media (max-width: 720px) { + .scope-toggle { margin-left: 0; width: 100%; } + .scope-toggle-hint { display: none; } +} +""" + +STICKY_JS = """ + +""" + + +def build_html( + *, + title: str, + hostname: str, + mode: str, + log_text: str, + commit: str, + commit_url: str, + suite_path: str, + generated_at: str, + generated_iso: str, + link_other: str | None, + page_label: str = "monitoring", +) -> str: + raw_lines = [strip_ansi(l).rstrip("\n") for l in log_text.splitlines()] + while raw_lines and not raw_lines[-1].strip(): + raw_lines.pop() + + n_err, n_warn, first_err_i, first_warn_i = count_status(raw_lines) + errors = extract_errors(raw_lines) + + err_slugs: dict[str, str] = {} + for i, e in enumerate(errors, 1): + slug = slug_error(i, e) + err_slugs[e] = slug + m = TID_RE.search(e) + if m: + err_slugs[m.group("tid")] = slug + + tid_to_slug = { + k: v for k, v in err_slugs.items() if re.match(r"^[a-z0-9_.-]+-\d+$", k, re.I) + } + + # Build body with anchors for first error/warn + per-error slugs + body_lines: list[str] = [] + claimed_err: set[str] = set() + for idx, ln in enumerate(raw_lines): + extra: list[str] = [] + if first_err_i is not None and idx == first_err_i: + extra.append("first-error") + if first_warn_i is not None and idx == first_warn_i: + extra.append("first-warn") + # per-error jump targets (all modes) + if classify(ln) in ("error", "blocker") and not is_summary_error_line(ln): + for i, e in enumerate(errors, 1): + slug = slug_error(i, e) + if slug in claimed_err: + continue + tid_m = TID_RE.search(e) + if is_run_timeout_text(e): + if "run.timeout-01" in ln and "ERROR" in ln.upper(): + extra.append(f"{slug}-line") + claimed_err.add(slug) + break + continue + key = tid_m.group(1) if tid_m else e[:40] + if key and key in ln: + extra.append(f"{slug}-line") + claimed_err.add(slug) + break + body_lines.append(render_line(ln, tid_to_slug, extra or None)) + + err_nav = "" + if errors and mode == "err": + items = [] + timeout_items = [] + for i, e in enumerate(errors, 1): + slug = slug_error(i, e) + li = ( + f'
  • ' + f'{html.escape(e)}
  • ' + ) + if is_run_timeout_text(e): + timeout_items.append(li) + else: + items.append(li) + if timeout_items: + err_nav += ( + '
    \n' + "

    EXTRAORDINARY · RUN TIMEOUT

    \n" + "

    Whole-run wall clock exceeded. " + 'Jump to detail →

    \n' + '
      \n' + + "\n".join(timeout_items[:1]) + + "\n
    \n
    \n" + ) + rest = items if timeout_items else (timeout_items + items) + if rest or not timeout_items: + err_nav += ( + '
    \n' + f"

    ERRORS ({len(errors)}) — jump to detail

    \n" + '
      \n' + + "\n".join(rest if rest else items) + + "\n
    \n
    \n" + ) + + commit_short = commit[:12] if commit else "unknown" + commit_html = ( + f'{html.escape(commit_short)}' + if commit_url + else html.escape(commit_short) + ) + + first_err_href = "#first-error" if first_err_i is not None else None + first_warn_href = "#first-warn" if first_warn_i is not None else None + scope = monitoring_scope(page_label, hostname, log_text) + + bar = sticky_bar_html( + generated_at=generated_at, + generated_iso=generated_iso, + n_err=n_err, + n_warn=n_warn, + hostname=hostname, + page_label=page_label, + mode=mode, + commit_html=commit_html, + suite_path=suite_path, + link_other=link_other, + first_err_href=first_err_href, + first_warn_href=first_warn_href, + scope=scope, + ) + + return f""" + + + + + +{html.escape(title)} + + + +{bar} +
    +{err_nav} +
    +{"".join(body_lines)} +
    +
    + Console-style render of taler-monitoring output. + Sticky bar: green = clean · yellow = warnings · red = errors. + Commit pins the exact tree used for this run. +
    +
    +{STICKY_JS} + + +""" + + +def build_redirect_html( + *, + hostname: str, + commit: str, + commit_url: str, + path_err: str = "/monitoring_err/", + page_label: str = "monitoring", + generated_at: str = "", + generated_iso: str = "", + n_err: int = 0, + n_warn: int = 0, + log_text: str = "", +) -> str: + commit_short = commit[:12] if commit else "unknown" + link = path_err if path_err.endswith("/") else path_err + "/" + c = ( + f'{html.escape(commit_short)}' + if commit_url + else html.escape(commit_short) + ) + if not generated_at: + now = datetime.now(timezone.utc) + generated_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ") + generated_at = now.strftime("%Y-%m-%d %H:%M:%SZ") + # Redirect pages exist only when the run failed → treat as red if n_err unknown + if n_err <= 0: + n_err = max(n_err, 1) + scope = monitoring_scope(page_label, hostname, log_text) + bar = sticky_bar_html( + generated_at=generated_at, + generated_iso=generated_iso, + n_err=n_err, + n_warn=n_warn, + hostname=hostname, + page_label=page_label, + mode="redirect", + commit_html=c, + suite_path="", + link_other=link, + first_err_href=link, + first_warn_href=None, + scope=scope, + ) + return f""" + + + + + + +{html.escape(page_label)} · errors · {html.escape(hostname)} + + + +{bar} +
    +

    {html.escape(page_label)} has failures.

    +

    See {html.escape(link)} for the full console log, + error index, and sticky status bar.

    +

    generated {html.escape(generated_at)} · source {c}

    +
    +{STICKY_JS} + + +""" + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--log", required=True, type=Path) + ap.add_argument("--out", required=True, type=Path) + ap.add_argument("--hostname", required=True) + ap.add_argument("--mode", choices=("ok", "err", "redirect"), required=True) + ap.add_argument("--commit", default="") + ap.add_argument("--commit-url", default="") + ap.add_argument("--suite-path", default="scripts/taler-monitoring") + ap.add_argument("--title", default="") + ap.add_argument("--link-other", default="") + ap.add_argument( + "--path-err", + default="/monitoring_err/", + help="URL path for error page (redirect target)", + ) + ap.add_argument( + "--page-label", + default="monitoring", + help="Short name shown in titles/redirect box", + ) + args = ap.parse_args() + + log_text = args.log.read_text(errors="replace") if args.log.is_file() else "" + now = datetime.now(timezone.utc) + generated_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ") + generated_at = now.strftime("%Y-%m-%d %H:%M:%SZ") + title = args.title or f"{args.page_label} {args.mode} · {args.hostname}" + + raw_for_counts = [strip_ansi(l).rstrip("\n") for l in log_text.splitlines()] + n_err, n_warn, _, _ = count_status(raw_for_counts) + + if args.mode == "redirect": + html_out = build_redirect_html( + hostname=args.hostname, + commit=args.commit, + commit_url=args.commit_url, + path_err=args.path_err, + page_label=args.page_label, + generated_at=generated_at, + generated_iso=generated_iso, + n_err=n_err, + n_warn=n_warn, + log_text=log_text, + ) + else: + html_out = build_html( + title=title, + hostname=args.hostname, + mode=args.mode, + log_text=log_text, + commit=args.commit, + commit_url=args.commit_url, + suite_path=args.suite_path, + generated_at=generated_at, + generated_iso=generated_iso, + link_other=args.link_other or None, + page_label=args.page_label, + ) + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(html_out, encoding="utf-8") + print(f"wrote {args.out} (errors={n_err} warnings={n_warn} level={status_level(n_err, n_warn)})") + + +if __name__ == "__main__": + main() diff --git a/scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh b/scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh new file mode 100755 index 0000000..c5718e1 --- /dev/null +++ b/scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# deploy-monitoring-sites.sh — rsync generated HTML to DEPLOY_WWW_ROOT on koopa. +# Does NOT reload Caddy (needs root — see ROOT-ON-KOOPA.md). +set -uo pipefail + +ROOT=$(cd "$(dirname "$0")" && pwd) +SETTINGS="${SITE_GEN_SETTINGS:-$ROOT/settings.conf}" +[ -f "$SETTINGS" ] || SETTINGS="$ROOT/settings.conf.example" +# shellcheck disable=SC1090 +set -a +source <(grep -v '^\s*#' "$SETTINGS" | grep -v '^\s*$' | sed 's/\r$//') +set +a + +WORK_ROOT="${WORK_ROOT:-/tmp/taler-monitoring-sites-work}" +HTML_DIR="$WORK_ROOT/html" +DEPLOY_SSH="${DEPLOY_SSH:-koopa-external}" +DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" + +if [ ! -d "$HTML_DIR" ]; then + echo "no HTML_DIR $HTML_DIR — run generate-monitoring-sites.sh first" >&2 + exit 1 +fi + +echo "deploy $HTML_DIR → ${DEPLOY_SSH}:${DEPLOY_WWW_ROOT}/" +# Prefer writing to a hernani-owned staging dir if /var/www not writable +STAGE_REMOTE="${DEPLOY_STAGING:-/home/hernani/monitoring-sites-staging}" + +ssh -o BatchMode=yes -o ConnectTimeout=20 "$DEPLOY_SSH" \ + "mkdir -p '$STAGE_REMOTE' '$DEPLOY_WWW_ROOT' 2>/dev/null || mkdir -p '$STAGE_REMOTE'" + +rsync -az --delete "$HTML_DIR/" "${DEPLOY_SSH}:${STAGE_REMOTE}/" +echo "staged on $DEPLOY_SSH:$STAGE_REMOTE" +echo +echo "If $DEPLOY_WWW_ROOT is root-owned, as root on koopa:" +echo " mkdir -p $DEPLOY_WWW_ROOT" +echo " rsync -a --delete $STAGE_REMOTE/ $DEPLOY_WWW_ROOT/" +echo " chown -R caddy:caddy $DEPLOY_WWW_ROOT # or root:caddy — match other www" +echo " # then Caddyfile handles + reload — see ROOT-ON-KOOPA.md" +echo +# try direct rsync to DEPLOY_WWW_ROOT if writable +if ssh -o BatchMode=yes "$DEPLOY_SSH" "test -w '$DEPLOY_WWW_ROOT' 2>/dev/null || test -w \"\$(dirname '$DEPLOY_WWW_ROOT')\""; then + rsync -az --delete "$HTML_DIR/" "${DEPLOY_SSH}:${DEPLOY_WWW_ROOT}/" && \ + echo "also synced directly to $DEPLOY_WWW_ROOT" +fi diff --git a/scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh b/scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh new file mode 100755 index 0000000..f45e5bd --- /dev/null +++ b/scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# generate-monitoring-sites.sh — run taler-monitoring, build console HTML for +# /monitoring and /monitoring_err (static files for host Caddy later). +# +# Usage: +# ./generate-monitoring-sites.sh # all SITES (or ONLY_HOSTS) +# ONLY_HOSTS="bank.hacktivism.ch taler.hacktivism.ch" ./generate-monitoring-sites.sh +# ./generate-monitoring-sites.sh --dry-run # HTML from empty/missing logs only +# ./generate-monitoring-sites.sh --skip-run # only convert existing logs +# +# Settings: settings.conf (from settings.conf.example). No secrets in settings. +# Secrets for e2e: ../secrets.env (optional; default phases are urls versions). +# +set -uo pipefail + +export PYTHONUNBUFFERED=1 +# Global reporting defaults (same as host-agent run-host-report.sh) +: "${RUN_TIMEOUT:=600}" +export RUN_TIMEOUT + +ROOT=$(cd "$(dirname "$0")" && pwd) +SETTINGS="${SITE_GEN_SETTINGS:-$ROOT/settings.conf}" +EXAMPLE="$ROOT/settings.conf.example" + +if [ ! -f "$SETTINGS" ]; then + if [ -f "$EXAMPLE" ]; then + echo "note: no $SETTINGS — using example defaults (copy to settings.conf to customize)" + SETTINGS="$EXAMPLE" + else + echo "missing settings: $EXAMPLE" >&2 + exit 2 + fi +fi + +# Load KEY=value lines; allow SITES already set in environment to win. +# Multiline SITES in the conf file: use SITES_FILE= or env SITES= instead. +while IFS= read -r _line || [ -n "$_line" ]; do + _line=${_line%%#*} + _line=$(echo "$_line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -z "$_line" ] && continue + case "$_line" in + SITES=*|SITES_FILE=*) continue ;; # handled below / env + *=*) + _k=${_line%%=*} + _v=${_line#*=} + # do not override pre-set env + if [ -z "${!_k+x}" ] 2>/dev/null || [ -z "${!_k}" ]; then + export "$_k=$_v" + fi + ;; + esac +done < "$SETTINGS" + +# SITES from dedicated file or keep env; else parse heredoc-style from example list +if [ -n "${SITES_FILE:-}" ] && [ -f "$SITES_FILE" ]; then + SITES=$(grep -v '^\s*#' "$SITES_FILE" | grep -v '^\s*$') + export SITES +elif [ -z "${SITES:-}" ]; then + # default 9 fronts + SITES="bank.hacktivism.ch|hacktivism.ch +exchange.hacktivism.ch|hacktivism.ch +taler.hacktivism.ch|hacktivism.ch +stage.bank.lefrancpaysan.ch|stage.lefrancpaysan.ch +stage.exchange.lefrancpaysan.ch|stage.lefrancpaysan.ch +stage.monnaie.lefrancpaysan.ch|stage.lefrancpaysan.ch +bank.lefrancpaysan.ch|lefrancpaysan.ch +exchange.lefrancpaysan.ch|lefrancpaysan.ch +monnaie.lefrancpaysan.ch|lefrancpaysan.ch" + export SITES +fi + +SKIP_RUN=0 +DRY=0 +while [ $# -gt 0 ]; do + case "$1" in + --skip-run) SKIP_RUN=1; shift ;; + --dry-run) DRY=1; SKIP_RUN=1; shift ;; + -h|--help) sed -n '1,20p' "$0"; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +MONITORING_ROOT=$(cd "$ROOT/${MONITORING_ROOT:-..}" && pwd) +WORK_ROOT="${WORK_ROOT:-/tmp/taler-monitoring-sites-work}" +LOG_DIR="$WORK_ROOT/logs" +HTML_DIR="$WORK_ROOT/html" +mkdir -p "$LOG_DIR" "$HTML_DIR" + +# commit of monitoring suite used for this run +if git -C "$MONITORING_ROOT/../.." rev-parse HEAD >/dev/null 2>&1; then + REPO_ROOT=$(cd "$MONITORING_ROOT/../.." && pwd) +elif git -C "$MONITORING_ROOT" rev-parse HEAD >/dev/null 2>&1; then + REPO_ROOT=$(cd "$MONITORING_ROOT" && pwd) +else + REPO_ROOT=$(cd "$ROOT/../../.." && pwd) +fi +COMMIT=$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown) +COMMIT_SHORT=$(git -C "$REPO_ROOT" rev-parse --short=12 HEAD 2>/dev/null || echo unknown) +SOURCE_REPO_WEB="${SOURCE_REPO_WEB:-https://git.hacktivism.ch/hernani/koopa-admin-log}" +TMPL="${SOURCE_COMMIT_URL_TMPL:-\{repo\}/src/commit/\{commit\}}" +COMMIT_URL=$(printf '%s' "$TMPL" | sed "s|{repo}|$SOURCE_REPO_WEB|g; s|{commit}|$COMMIT|g") +SUITE_PATH="${SOURCE_SUITE_PATH:-scripts/taler-monitoring}" + +# Outside-only public checks (no SSH into koopa/stage containers) +PHASES_ERR="${PHASES_ERR:-urls versions}" +PHASES_OK="${PHASES_OK:-urls versions}" +CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +SKIP_SSH="${SKIP_SSH:-1}" +# Drop SSH phases if someone passes "all" / inside / server / e2e by mistake +filter_phases_outside() { + local out=() p + for p in "$@"; do + case "$p" in + inside|server|e2e|ladder|goa-ladder|auth401) continue ;; + all) out+=(urls versions);; + *) out+=("$p") ;; + esac + done + printf '%s\n' "${out[@]}" +} + +run_mon() { + local domain="$1" phases="$2" logfile="$3" cont="$4" + # shellcheck disable=SC2206 + local ph=( $phases ) + mapfile -t ph < <(filter_phases_outside "${ph[@]}") + [ "${#ph[@]}" -gt 0 ] || ph=(urls versions) + + : >"$logfile" + echo "+ -d $domain · phases=${ph[*]} · CONTINUE=$cont · SKIP_SSH=$SKIP_SSH · RUN_TIMEOUT=$RUN_TIMEOUT" + + if [ -n "${RUNNER_SSH:-}" ]; then + echo "+ via ssh $RUNNER_SSH" + rsync -az --delete \ + --exclude secrets.env \ + --exclude 'android-test/artifacts' \ + --exclude '.git' \ + "$MONITORING_ROOT/" \ + "${RUNNER_SSH}:${RUNNER_REMOTE_WORKDIR}/" \ + || return 1 + # shellcheck disable=SC2029 + if command -v stdbuf >/dev/null 2>&1; then + ssh -o BatchMode=yes -o ConnectTimeout=30 "$RUNNER_SSH" \ + "cd '${RUNNER_REMOTE_WORKDIR}' && chmod +x taler-monitoring.sh check_*.sh 2>/dev/null; \ + export CONTINUE_ON_ERROR='$cont' AUTH401_CONTINUE='$cont' SKIP_SSH='$SKIP_SSH' RUN_TIMEOUT='$RUN_TIMEOUT'; \ + stdbuf -oL -eL ./taler-monitoring.sh -d '$domain' ${ph[*]}" \ + 2>&1 | stdbuf -oL -eL tee -a "$logfile" + else + ssh -o BatchMode=yes -o ConnectTimeout=30 "$RUNNER_SSH" \ + "cd '${RUNNER_REMOTE_WORKDIR}' && chmod +x taler-monitoring.sh check_*.sh 2>/dev/null; \ + export CONTINUE_ON_ERROR='$cont' AUTH401_CONTINUE='$cont' SKIP_SSH='$SKIP_SSH' RUN_TIMEOUT='$RUN_TIMEOUT'; \ + ./taler-monitoring.sh -d '$domain' ${ph[*]}" \ + 2>&1 | tee -a "$logfile" + fi + return "${PIPESTATUS[0]}" + fi + + if command -v stdbuf >/dev/null 2>&1; then + ( cd "$MONITORING_ROOT" && \ + CONTINUE_ON_ERROR="$cont" AUTH401_CONTINUE="$cont" SKIP_SSH="$SKIP_SSH" \ + RUN_TIMEOUT="$RUN_TIMEOUT" \ + stdbuf -oL -eL ./taler-monitoring.sh -d "$domain" "${ph[@]}" ) \ + 2>&1 | stdbuf -oL -eL tee -a "$logfile" + else + ( cd "$MONITORING_ROOT" && \ + CONTINUE_ON_ERROR="$cont" AUTH401_CONTINUE="$cont" SKIP_SSH="$SKIP_SSH" \ + RUN_TIMEOUT="$RUN_TIMEOUT" \ + ./taler-monitoring.sh -d "$domain" "${ph[@]}" ) \ + 2>&1 | tee -a "$logfile" + fi + return "${PIPESTATUS[0]}" +} + +htmlify() { + local host="$1" mode="$2" log="$3" out="$4" other="$5" + mkdir -p "$(dirname "$out")" + if [ ! -f "$ROOT/console_to_html.py" ]; then + echo "WARN: console_to_html.py missing — bootstrap $out" + { + echo "$host $mode" + echo "

    $host · $mode

    commit $COMMIT_SHORT

    " + echo "
    "
    +      tail -n 100 "$log" 2>/dev/null | sed 's/&/\&/g;s/"
    +    } >"$out"
    +    return 0
    +  fi
    +  python3 "$ROOT/console_to_html.py" \
    +    --log "$log" \
    +    --out "$out" \
    +    --hostname "$host" \
    +    --mode "$mode" \
    +    --commit "$COMMIT" \
    +    --commit-url "$COMMIT_URL" \
    +    --suite-path "$SUITE_PATH" \
    +    --link-other "$other"
    +}
    +
    +# Parse SITES
    +mapfile -t SITE_LINES < <(printf '%s\n' "$SITES" | sed '/^\s*$/d' | sed 's/#.*//')
    +ONLY="${ONLY_HOSTS:-}"
    +
    +for line in "${SITE_LINES[@]}"; do
    +  line=$(echo "$line" | tr -d ' \t')
    +  [ -z "$line" ] && continue
    +  host=${line%%|*}
    +  domain=${line#*|}
    +  if [ -n "$ONLY" ]; then
    +    echo " $ONLY " | grep -q " $host " || continue
    +  fi
    +
    +  echo "======== $host  (domain=$domain)  commit=$COMMIT_SHORT ========"
    +  base_html="$HTML_DIR/$host"
    +  mkdir -p "$base_html/monitoring" "$base_html/monitoring_err"
    +  log_err="$LOG_DIR/${host}.err.log"
    +  log_ok="$LOG_DIR/${host}.ok.log"
    +  ec_err=1
    +  ec_ok=1
    +
    +  if [ "$SKIP_RUN" != "1" ]; then
    +    run_mon "$domain" "$PHASES_ERR" "$log_err" "$CONTINUE_ON_ERROR"
    +    ec_err=$?
    +    # ok-run without continue (strict)
    +    run_mon "$domain" "$PHASES_OK" "$log_ok" "0"
    +    ec_ok=$?
    +  else
    +    [ -f "$log_err" ] || : >"$log_err"
    +    [ -f "$log_ok" ] || : >"$log_ok"
    +    # infer exit from log if present
    +    if grep -q '\[ ERROR \]' "$log_err" 2>/dev/null || grep -q 'ERROR' "$log_err" 2>/dev/null; then
    +      ec_err=1
    +    else
    +      ec_err=0
    +    fi
    +    ec_ok=$ec_err
    +  fi
    +
    +  # Always write pages (first run / empty logs too)
    +  [ -f "$log_err" ] || : >"$log_err"
    +  [ -f "$log_ok" ] || : >"$log_ok"
    +
    +  htmlify "$host" "err" "$log_err" \
    +    "$base_html/monitoring_err/index.html" "/monitoring/"
    +
    +  if [ "$ec_ok" -eq 0 ] && [ "$ec_err" -eq 0 ]; then
    +    htmlify "$host" "ok" "$log_ok" \
    +      "$base_html/monitoring/index.html" "/monitoring_err/"
    +    echo "  → /monitoring (clean) + /monitoring_err"
    +  else
    +    htmlify "$host" "redirect" "$log_err" \
    +      "$base_html/monitoring/index.html" "/monitoring_err/"
    +    echo "  → /monitoring (redirect stub) + /monitoring_err (errors)  ec_err=$ec_err ec_ok=$ec_ok"
    +  fi
    +
    +  # first-run safety
    +  if [ ! -f "$base_html/monitoring/index.html" ]; then
    +    htmlify "$host" "err" "$log_err" \
    +      "$base_html/monitoring/index.html" "/monitoring_err/"
    +  fi
    +
    +  printf '%s\n' "$COMMIT" >"$base_html/COMMIT"
    +  printf '%s\n' "$COMMIT_URL" >"$base_html/COMMIT_URL"
    +  printf 'host=%s domain=%s ec_err=%s ec_ok=%s commit=%s run_timeout=%s\n' \
    +    "$host" "$domain" "$ec_err" "$ec_ok" "$COMMIT" "$RUN_TIMEOUT" | tee "$base_html/STATUS.txt"
    +done
    +
    +echo
    +echo "HTML under $HTML_DIR"
    +echo "commit $COMMIT_SHORT  $COMMIT_URL"
    +echo "RUN_TIMEOUT=${RUN_TIMEOUT}s (global; 0=unlimited)"
    +echo "Deploy (as hernani, may need sudo for /var/www):"
    +echo "  ./deploy-monitoring-sites.sh"
    +echo "Root on koopa: see ROOT-ON-KOOPA.md"
    diff --git a/scripts/taler-monitoring/site-gen/install-firecuda-timer.sh b/scripts/taler-monitoring/site-gen/install-firecuda-timer.sh
    new file mode 100755
    index 0000000..9615b96
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/install-firecuda-timer.sh
    @@ -0,0 +1,79 @@
    +#!/usr/bin/env bash
    +# install-firecuda-timer.sh — OPTIONAL: rsync suite to firecuda + launchd every 4h
    +#
    +# POLICY (2026-07): firecuda launchd is **disabled**. Prefer koopa host-agent.
    +# This script remains for documentation / future re-enable only.
    +# Do not run unless you intentionally want a second outside-only scheduler.
    +#
    +#   ./install-firecuda-timer.sh
    +#   ./install-firecuda-timer.sh --run-now
    +#
    +set -euo pipefail
    +
    +ROOT=$(cd "$(dirname "$0")" && pwd)
    +MON_ROOT=$(cd "$ROOT/.." && pwd)
    +FIRECUDA="${FIRECUDA_SSH:-firecuda-external}"
    +REMOTE_BASE="${FIRECUDA_INSTALL_DIR:-taler-monitoring-site-gen}"
    +RUN_NOW=0
    +[ "${1:-}" = "--run-now" ] && RUN_NOW=1
    +
    +echo "install → $FIRECUDA:~/$REMOTE_BASE (outside-only, every 4h)"
    +
    +ssh -o BatchMode=yes -o ConnectTimeout=20 "$FIRECUDA" \
    +  "mkdir -p \"\$HOME/$REMOTE_BASE\" \"\$HOME/Library/Logs/taler-monitoring-sites\" \"\$HOME/var/taler-monitoring-sites-work\" \"\$HOME/Library/LaunchAgents\""
    +
    +# Sync full taler-monitoring suite (no secrets.env)
    +rsync -az --delete \
    +  --exclude secrets.env \
    +  --exclude 'android-test/artifacts' \
    +  --exclude '.git' \
    +  --exclude 'site-gen/settings.conf' \
    +  "$MON_ROOT/" \
    +  "${FIRECUDA}:$REMOTE_BASE/"
    +
    +# settings on firecuda: outside only, run locally on firecuda, deploy to koopa
    +ssh -o BatchMode=yes "$FIRECUDA" "cat > \"\$HOME/$REMOTE_BASE/site-gen/settings.conf\" <<'EOF'
    +# auto-installed by install-firecuda-timer.sh — no secrets
    +RUNNER_SSH=
    +SKIP_SSH=1
    +CONTINUE_ON_ERROR=1
    +PHASES_ERR=urls versions
    +PHASES_OK=urls versions
    +DEPLOY_SSH=koopa-external
    +DEPLOY_STAGING=/home/hernani/monitoring-sites-staging
    +WORK_ROOT=
    +MONITORING_ROOT=..
    +SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    +SOURCE_COMMIT_URL_TMPL={repo}/src/commit/{commit}
    +SOURCE_SUITE_PATH=scripts/taler-monitoring
    +EOF
    +# WORK_ROOT expanded on remote
    +sed -i.bak \"s|^WORK_ROOT=.*|WORK_ROOT=\$HOME/var/taler-monitoring-sites-work|\" \"\$HOME/$REMOTE_BASE/site-gen/settings.conf\" 2>/dev/null || \
    +  sed -i '' \"s|^WORK_ROOT=.*|WORK_ROOT=\$HOME/var/taler-monitoring-sites-work|\" \"\$HOME/$REMOTE_BASE/site-gen/settings.conf\"
    +"
    +
    +# launchd plist with absolute paths
    +ssh -o BatchMode=yes "$FIRECUDA" bash -s < "\$PLIST_DST"
    +chmod +x "\$SITE_GEN"/*.sh "\$SITE_GEN"/console_to_html.py 2>/dev/null || true
    +chmod +x "\$SITE_GEN/run-on-firecuda.sh"
    +launchctl bootout "gui/\$(id -u)/com.hacktivism.taler-monitoring-sites" 2>/dev/null || true
    +launchctl unload "\$PLIST_DST" 2>/dev/null || true
    +launchctl bootstrap "gui/\$(id -u)" "\$PLIST_DST" 2>/dev/null || launchctl load "\$PLIST_DST"
    +launchctl enable "gui/\$(id -u)/com.hacktivism.taler-monitoring-sites" 2>/dev/null || true
    +launchctl print "gui/\$(id -u)/com.hacktivism.taler-monitoring-sites" 2>/dev/null | head -20 || launchctl list | grep taler-monitoring || true
    +echo "OK: launchd com.hacktivism.taler-monitoring-sites (StartInterval=14400)"
    +REMOTE
    +
    +if [ "$RUN_NOW" = "1" ]; then
    +  echo "run once now…"
    +  ssh -o BatchMode=yes "$FIRECUDA" "\"\$HOME/$REMOTE_BASE/site-gen/run-on-firecuda.sh\"" || true
    +fi
    +
    +echo "done. logs: $FIRECUDA:~/Library/Logs/taler-monitoring-sites/"
    +echo "manual: ssh $FIRECUDA '~/taler-monitoring-site-gen/site-gen/run-on-firecuda.sh'"
    diff --git a/scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh b/scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh
    new file mode 100755
    index 0000000..b1b10ec
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh
    @@ -0,0 +1,15 @@
    +#!/usr/bin/env bash
    +# pull-firecuda-to-koopa.sh — copy HTML from firecuda work dir → koopa staging
    +# Run on laptop (hernani) where both SSH aliases work.
    +set -euo pipefail
    +FIRECUDA="${FIRECUDA_SSH:-firecuda-external}"
    +KOOPA="${DEPLOY_SSH:-koopa-external}"
    +REMOTE_HTML="${FIRECUDA_HTML:-var/taler-monitoring-sites-work/html/}"
    +STAGE="${DEPLOY_STAGING:-/home/hernani/monitoring-sites-staging}"
    +
    +echo "firecuda:$REMOTE_HTML → $KOOPA:$STAGE"
    +ssh -o BatchMode=yes -o ConnectTimeout=20 "$KOOPA" "mkdir -p '$STAGE'"
    +rsync -az --delete \
    +  "${FIRECUDA}:${REMOTE_HTML}" \
    +  "${KOOPA}:${STAGE}/"
    +echo "OK staged. Root: rsync to /var/www/monitoring-sites (see ROOT-ON-KOOPA.md)"
    diff --git a/scripts/taler-monitoring/site-gen/run-on-firecuda.sh b/scripts/taler-monitoring/site-gen/run-on-firecuda.sh
    new file mode 100755
    index 0000000..6f77de6
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/run-on-firecuda.sh
    @@ -0,0 +1,59 @@
    +#!/usr/bin/env bash
    +# run-on-firecuda.sh — entrypoint for launchd/cron on firecuda-external.
    +# Outside-only monitoring (SKIP_SSH=1), all 9 sites, then stage HTML to koopa.
    +#
    +# Installed path on firecuda (default):
    +#   ~/taler-monitoring-site-gen/site-gen/run-on-firecuda.sh
    +#
    +set -uo pipefail
    +
    +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"
    +
    +ROOT=$(cd "$(dirname "$0")" && pwd)
    +LOG_DIR="${FIRECUDA_LOG_DIR:-$HOME/Library/Logs/taler-monitoring-sites}"
    +mkdir -p "$LOG_DIR"
    +STAMP=$(date +%Y%m%d-%H%M%S)
    +LOG="$LOG_DIR/run-${STAMP}.log"
    +exec >>"$LOG" 2>&1
    +
    +echo "======== $(date -Iseconds 2>/dev/null || date) firecuda monitoring sites ========"
    +
    +# Always: public DNS only, no SSH checks into stacks
    +export SKIP_SSH=1
    +export CONTINUE_ON_ERROR=1
    +export AUTH401_CONTINUE=1
    +export RUNNER_SSH=
    +export PHASES_ERR="${PHASES_ERR:-urls versions}"
    +export PHASES_OK="${PHASES_OK:-urls versions}"
    +export WORK_ROOT="${WORK_ROOT:-$HOME/var/taler-monitoring-sites-work}"
    +export MONITORING_ROOT="${MONITORING_ROOT:-$ROOT/..}"
    +export DEPLOY_SSH="${DEPLOY_SSH:-koopa-external}"
    +export DEPLOY_STAGING="${DEPLOY_STAGING:-/home/hernani/monitoring-sites-staging}"
    +
    +# Prefer full 9-site list from settings or defaults inside generate script
    +unset ONLY_HOSTS
    +
    +cd "$ROOT" || exit 1
    +chmod +x generate-monitoring-sites.sh deploy-monitoring-sites.sh console_to_html.py 2>/dev/null || true
    +
    +./generate-monitoring-sites.sh
    +ec=$?
    +
    +# HTML stays on firecuda under WORK_ROOT/html.
    +# Optional push to koopa if this host can SSH there (often only from laptop):
    +if [ -n "${DEPLOY_SSH:-}" ] && [ "${DEPLOY_FROM_FIRECUDA:-0}" = "1" ]; then
    +  if [ -x ./deploy-monitoring-sites.sh ]; then
    +    ./deploy-monitoring-sites.sh || echo "WARN: deploy staging failed"
    +  fi
    +else
    +  echo "note: HTML at ${WORK_ROOT}/html — pull from laptop:"
    +  echo "  rsync -az firecuda-external:var/taler-monitoring-sites-work/html/ \\"
    +  echo "    koopa-external:monitoring-sites-staging/"
    +  echo "  # or: ./pull-firecuda-to-koopa.sh"
    +fi
    +
    +# keep last 20 logs
    +ls -1t "$LOG_DIR"/run-*.log 2>/dev/null | tail -n +21 | xargs rm -f 2>/dev/null || true
    +
    +echo "======== done ec=$ec ========"
    +exit "$ec"
    diff --git a/scripts/taler-monitoring/site-gen/settings.conf.example b/scripts/taler-monitoring/site-gen/settings.conf.example
    new file mode 100644
    index 0000000..3d14fc2
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/settings.conf.example
    @@ -0,0 +1,55 @@
    +# monitoring site-gen — non-secret settings (safe as example)
    +# Copy:  cp settings.conf.example settings.conf
    +# Never put passwords/tokens here.
    +
    +# On firecuda the suite runs *locally* (RUNNER_SSH empty). From a laptop you
    +# can set RUNNER_SSH=firecuda-external to drive a one-off remote run instead.
    +RUNNER_SSH=
    +RUNNER_SSH_FALLBACKS=
    +RUNNER_REMOTE_WORKDIR=/tmp/taler-monitoring-site-gen
    +MONITORING_ROOT=..
    +
    +# Outside-only: never SSH into bank/exchange/merchant containers
    +SKIP_SSH=1
    +
    +# Deploy staging on koopa (host Caddy later — root copies to /var/www)
    +DEPLOY_SSH=koopa-external
    +DEPLOY_SSH_FALLBACKS=koopa
    +# Static files for Caddy file_server (created per hostname)
    +DEPLOY_WWW_ROOT=/var/www/monitoring-sites
    +DEPLOY_SUDO=sudo
    +CADDY_CONFIG=/etc/caddy/Caddyfile
    +CADDY_MIRROR_REL=configs/caddy/Caddyfile
    +# Working tree on deploy host for apply (optional)
    +KOOPA_CADDY_DIR=/home/hernani/koopa-caddy
    +
    +# Source link for HTML footer (Forgejo)
    +SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    +SOURCE_COMMIT_URL_TMPL={repo}/src/commit/{commit}
    +SOURCE_SUITE_PATH=scripts/taler-monitoring
    +
    +CONTINUE_ON_ERROR=1
    +PHASES_ERR=urls versions
    +PHASES_OK=urls versions
    +SKIP_E2E=1
    +
    +# Local work dir for logs + HTML
    +WORK_ROOT=/tmp/taler-monitoring-sites-work
    +
    +# Sites: hostname|domain_profile  (9 fronts)
    +# Start with hacktivism; stage + prod LFP follow
    +SITES="
    +bank.hacktivism.ch|hacktivism.ch
    +exchange.hacktivism.ch|hacktivism.ch
    +taler.hacktivism.ch|hacktivism.ch
    +stage.bank.lefrancpaysan.ch|stage.lefrancpaysan.ch
    +stage.exchange.lefrancpaysan.ch|stage.lefrancpaysan.ch
    +stage.monnaie.lefrancpaysan.ch|stage.lefrancpaysan.ch
    +bank.lefrancpaysan.ch|lefrancpaysan.ch
    +exchange.lefrancpaysan.ch|lefrancpaysan.ch
    +monnaie.lefrancpaysan.ch|lefrancpaysan.ch
    +"
    +
    +# Only process this subset (space-separated hostnames). Empty = all SITES.
    +# Example for first deploy: ONLY_HOSTS=bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch
    +ONLY_HOSTS=
    
    From 46efd8a51c07cf185d0b3f19a424fbd92cfbe437 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= 
    Date: Sat, 18 Jul 2026 19:57:21 +0200
    Subject: [PATCH 312/326] monitoring: site-gen console HTML and sticky status
     bar
    
    ---
     scripts/taler-monitoring/site-gen/README.md   |  81 ++
     .../site-gen/ROOT-ON-KOOPA.md                 |  92 ++
     .../site-gen/caddy-monitoring-handles.snippet |  57 ++
     ...om.hacktivism.taler-monitoring-sites.plist |  34 +
     .../site-gen/console_to_html.py               | 935 ++++++++++++++++++
     .../site-gen/deploy-monitoring-sites.sh       |  44 +
     .../site-gen/generate-monitoring-sites.sh     | 273 +++++
     .../site-gen/install-firecuda-timer.sh        |  79 ++
     .../site-gen/pull-firecuda-to-koopa.sh        |  15 +
     .../site-gen/run-on-firecuda.sh               |  59 ++
     .../site-gen/settings.conf.example            |  55 ++
     11 files changed, 1724 insertions(+)
     create mode 100644 scripts/taler-monitoring/site-gen/README.md
     create mode 100644 scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md
     create mode 100644 scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet
     create mode 100644 scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist
     create mode 100755 scripts/taler-monitoring/site-gen/console_to_html.py
     create mode 100755 scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh
     create mode 100755 scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh
     create mode 100755 scripts/taler-monitoring/site-gen/install-firecuda-timer.sh
     create mode 100755 scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh
     create mode 100755 scripts/taler-monitoring/site-gen/run-on-firecuda.sh
     create mode 100644 scripts/taler-monitoring/site-gen/settings.conf.example
    
    diff --git a/scripts/taler-monitoring/site-gen/README.md b/scripts/taler-monitoring/site-gen/README.md
    new file mode 100644
    index 0000000..82e52d2
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/README.md
    @@ -0,0 +1,81 @@
    +# monitoring site-gen
    +
    +Build **console-style HTML** from `taler-monitoring` runs and stage them for
    +**host Caddy** (`/monitoring`, `/monitoring_err`).
    +
    +Same **global reporting defaults** as host-agents (`run-host-report.sh`):
    +
    +- **`RUN_TIMEOUT=600`** on each suite run  
    +- **line-buffered** logs (continuous write)  
    +- **always** write HTML (first run / empty logs too)  
    +- Forgejo **commit link** in the page header  
    +
    +Applies to all nine fronts (GOA + FP stage + FP prod) in the default `SITES` list.
    +
    +| Host | Role |
    +|------|------|
    +| **firecuda-external** | optional runner (public DNS) |
    +| **koopa-external** | deploy staging + Caddy (root service) |
    +| **host-agents** | primary: GOA + FP on-box (`../host-agent/`) |
    +
    +No secrets in `settings.conf` — only hosts, paths, site list.
    +
    +## firecuda-external (outside-only) — **disabled for now**
    +
    +**firecuda-external could** run public-only monitoring (`SKIP_SSH=1`, phases
    +`urls versions`) on a schedule (launchd every 4h) and write HTML under
    +`~/var/taler-monitoring-sites-work/html/`. Scripts for that still live here
    +(`install-firecuda-timer.sh`, `run-on-firecuda.sh`, plist template).
    +
    +**Current policy: do not run a real launchd job on firecuda.**  
    +Primary GOA monitoring is **koopa host-agent** (`host-agent/`, user `hernani`,
    +local `podman exec`). Re-enable firecuda only if you explicitly want a second,
    +outside-only view.
    +
    +```bash
    +# optional one-shot (no timer):
    +# ssh firecuda-external '~/taler-monitoring-site-gen/site-gen/run-on-firecuda.sh'
    +
    +# if a timer was installed earlier, ensure it is off:
    +# ssh firecuda-external 'launchctl list | grep taler-monitoring || echo off'
    +```
    +
    +Disabled plists may remain as `*.plist.disabled-*` under `~/Library/LaunchAgents/`.
    +
    +## Quick start (laptop)
    +
    +```bash
    +cd scripts/taler-monitoring/site-gen
    +cp settings.conf.example settings.conf   # optional
    +chmod +x generate-monitoring-sites.sh deploy-monitoring-sites.sh console_to_html.py
    +
    +ONLY_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" \
    +  SKIP_SSH=1 RUNNER_SSH= \
    +  ./generate-monitoring-sites.sh
    +
    +./deploy-monitoring-sites.sh
    +```
    +
    +Then on koopa **as root**: see **[ROOT-ON-KOOPA.md](./ROOT-ON-KOOPA.md)**.
    +
    +## URL rules
    +
    +| Path | When |
    +|------|------|
    +| `/monitoring_err/` | always: full console log + error list on top (CONTINUE_ON_ERROR run) |
    +| `/monitoring/` | **clean** full log if last strict run exit 0; else **stub** linking to `/monitoring_err/` |
    +
    +Footer links **Forgejo commit** of the admin-log tree used for that run.
    +
    +## Layout
    +
    +```text
    +site-gen/
    +  settings.conf.example
    +  generate-monitoring-sites.sh
    +  deploy-monitoring-sites.sh
    +  console_to_html.py
    +  caddy-monitoring-handles.snippet
    +  ROOT-ON-KOOPA.md
    +  README.md
    +```
    diff --git a/scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md b/scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md
    new file mode 100644
    index 0000000..604b997
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md
    @@ -0,0 +1,92 @@
    +# Root on koopa (hacktivism monitoring sites)
    +
    +Host Caddy runs as **root/systemd** (`caddy` user). Static monitoring HTML is
    +**not** put into Taler containers — only host paths + Caddy `handle`.
    +
    +After `generate-monitoring-sites.sh` + `deploy-monitoring-sites.sh` (as hernani),
    +files live in:
    +
    +```text
    +/home/hernani/monitoring-sites-staging//monitoring/index.html
    +/home/hernani/monitoring-sites-staging//monitoring_err/index.html
    +```
    +
    +## One-shot as root (copy + Caddy)
    +
    +```bash
    +# on koopa (koopa-external), as root:
    +
    +# 1) web root
    +install -d -o caddy -g caddy -m 755 /var/www/monitoring-sites
    +rsync -a --delete /home/hernani/monitoring-sites-staging/ /var/www/monitoring-sites/
    +chown -R caddy:caddy /var/www/monitoring-sites
    +
    +# 2) Caddyfile — merge handles from admin-log mirror, then:
    +#    (either edit /etc/caddy/Caddyfile by hand using snippet below,
    +#     or copy full mirror after review)
    +#
    +#    install -m 644 /home/hernani/koopa-admin-log/configs/caddy/Caddyfile /etc/caddy/Caddyfile
    +#    # or: ~/koopa-caddy/apply.sh  after syncing Caddyfile into ~/koopa-caddy/
    +
    +caddy validate --config /etc/caddy/Caddyfile
    +systemctl reload caddy
    +systemctl is-active caddy
    +```
    +
    +## Snippet per hacktivism site (inside each `*.hacktivism.ch { }` block)
    +
    +Place **before** the catch-all `reverse_proxy` (same idea as `/intro*`):
    +
    +```caddy
    +    # Public taler-monitoring console HTML (static; host only)
    +    handle /monitoring_err* {
    +        root * /var/www/monitoring-sites/{host}
    +        rewrite * /monitoring_err{uri}
    +        # uri is /monitoring_err or /monitoring_err/ → serve directory index
    +        file_server
    +    }
    +    handle /monitoring* {
    +        root * /var/www/monitoring-sites/{host}
    +        file_server
    +    }
    +```
    +
    +Simpler layout (recommended): path on disk matches URL under host root:
    +
    +```text
    +/var/www/monitoring-sites/bank.hacktivism.ch/monitoring/index.html
    +/var/www/monitoring-sites/bank.hacktivism.ch/monitoring_err/index.html
    +```
    +
    +```caddy
    +    handle_path /monitoring_err/* {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle_path /monitoring/* {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +```
    +
    +Canonical snippet is also in `caddy-monitoring-handles.snippet` and applied
    +in `configs/caddy/Caddyfile` for the three GOA hosts.
    +
    +## Check
    +
    +```bash
    +curl -sS -o /dev/null -w '%{http_code}\n' https://bank.hacktivism.ch/monitoring/
    +curl -sS -o /dev/null -w '%{http_code}\n' https://bank.hacktivism.ch/monitoring_err/
    +curl -sS https://bank.hacktivism.ch/monitoring_err/ | head
    +```
    +
    +## Not needed as root
    +
    +- Running `taler-monitoring` (use **firecuda-external** / laptop)
    +- Writing into podman Taler containers
    diff --git a/scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet b/scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet
    new file mode 100644
    index 0000000..1c09531
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet
    @@ -0,0 +1,57 @@
    +# Host Caddy on koopa (root service). Paste BEFORE catch-all reverse_proxy.
    +#
    +# Disk layout:
    +#   /var/www/monitoring-sites/{host}/monitoring/index.html
    +#   /var/www/monitoring-sites/{host}/monitoring_err/index.html
    +#   /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface*/index.html
    +#   /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy*/index.html
    +#
    +# /monitoring*                    → bank + exchange + taler.hacktivism.ch
    +# /taler-monitoring-surface*      → taler.hacktivism.ch only
    +# /taler-monitoring-aptdeploy*    → taler.hacktivism.ch only
    +
    +# --- only inside taler.hacktivism.ch { ... } ---
    +    handle /taler-monitoring-surface_err {
    +        redir /taler-monitoring-surface_err/ 302
    +    }
    +    handle /taler-monitoring-surface_err/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface_err
    +        file_server
    +    }
    +    handle /taler-monitoring-surface {
    +        redir /taler-monitoring-surface/ 302
    +    }
    +    handle /taler-monitoring-surface/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface
    +        file_server
    +    }
    +    handle /taler-monitoring-aptdeploy_err {
    +        redir /taler-monitoring-aptdeploy_err/ 302
    +    }
    +    handle /taler-monitoring-aptdeploy_err/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy_err
    +        file_server
    +    }
    +    handle /taler-monitoring-aptdeploy {
    +        redir /taler-monitoring-aptdeploy/ 302
    +    }
    +    handle /taler-monitoring-aptdeploy/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy
    +        file_server
    +    }
    +
    +# --- bank + exchange + taler (each site block) ---
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    diff --git a/scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist b/scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist
    new file mode 100644
    index 0000000..0b6f68b
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist
    @@ -0,0 +1,34 @@
    +
    +
    +
    +
    +
    +  Label
    +  com.hacktivism.taler-monitoring-sites
    +  ProgramArguments
    +  
    +    /bin/bash
    +    __SITE_GEN__/run-on-firecuda.sh
    +  
    +  StartInterval
    +  14400
    +  RunAtLoad
    +  
    +  StandardOutPath
    +  __HOME__/Library/Logs/taler-monitoring-sites/launchd.out.log
    +  StandardErrorPath
    +  __HOME__/Library/Logs/taler-monitoring-sites/launchd.err.log
    +  EnvironmentVariables
    +  
    +    PATH
    +    /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
    +    SKIP_SSH
    +    1
    +    CONTINUE_ON_ERROR
    +    1
    +  
    +
    +
    diff --git a/scripts/taler-monitoring/site-gen/console_to_html.py b/scripts/taler-monitoring/site-gen/console_to_html.py
    new file mode 100755
    index 0000000..14aef2a
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/console_to_html.py
    @@ -0,0 +1,935 @@
    +#!/usr/bin/env python3
    +"""Convert taler-monitoring console log to a console-feel HTML page."""
    +from __future__ import annotations
    +
    +import argparse
    +import html
    +import re
    +from datetime import datetime, timezone
    +from pathlib import Path
    +
    +# ANSI strip
    +ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
    +ERR_BULLET_RE = re.compile(r"^\s*[•·*-]\s+(?P.+)$")
    +TID_RE = re.compile(r"\b(?P[a-z0-9_.-]+-\d+)\b", re.I)
    +
    +
    +def strip_ansi(s: str) -> str:
    +    return ANSI_RE.sub("", s)
    +
    +
    +def classify(line: str) -> str:
    +    u = line.upper()
    +    # section / summary headers before badge matching (they contain the word ERROR)
    +    if "SUMMARY" in u or "--- ERRORS" in u or "ERRORS ·" in u or "ERRORS (" in u:
    +        return "meta"
    +    if "numbered checks" in line.lower() or "totals:" in line.lower():
    +        return "meta"
    +    if "BLOCKER" in u or "┌ BLOCKER" in u:
    +        return "blocker"
    +    if "ERROR" in u or "┌ ERROR" in u:
    +        return "error"
    +    if "WARN" in u or "┌ WARN" in u:
    +        return "warn"
    +    if "INFO" in u or "┌ INFO" in u:
    +        return "info"
    +    if " OK" in u or "[OK" in u or "┌ OK" in u or u.strip().startswith("OK"):
    +        return "ok"
    +    if u.strip().startswith("-- ") or u.strip().startswith("=="):
    +        return "section"
    +    return "plain"
    +
    +
    +def is_run_timeout_text(text: str) -> bool:
    +    u = text.upper()
    +    return "RUN.TIMEOUT" in u or "RUN_TIMEOUT" in u or "RUN TIMEOUT" in u
    +
    +
    +def is_summary_error_line(ln: str) -> bool:
    +    """Skip summary/header lines that contain ERROR but are not checks."""
    +    low = ln.lower()
    +    if "failed — see" in low or "failed - see" in low:
    +        return True
    +    if "errors · failed" in low or "errors (failed" in low:
    +        return True
    +    if "--- errors" in low:
    +        return True
    +    if "┌ summary" in low or "[ summary" in low:
    +        return True
    +    if "totals:" in low:
    +        return True
    +    # header-ish RUN_TIMEOUT= without error badge
    +    if is_run_timeout_text(ln) and "ERROR" not in ln.upper() and "run.timeout" not in low:
    +        return True
    +    return False
    +
    +
    +def extract_errors(lines: list[str]) -> list[str]:
    +    errs: list[str] = []
    +    timeout_detail = ""
    +    in_block = False
    +    for ln in lines:
    +        if "--- ERRORS" in ln or "ERRORS (failed" in ln or "RUN TIMEOUT · extraordinary" in ln:
    +            in_block = True
    +            continue
    +        if in_block:
    +            if ln.strip().startswith("---") or "[ SUMMARY" in ln or "totals:" in ln:
    +                in_block = False
    +                continue
    +            m = ERR_BULLET_RE.match(ln)
    +            if m:
    +                body = m.group("body").strip()
    +                if is_run_timeout_text(body):
    +                    timeout_detail = body
    +                else:
    +                    errs.append(body)
    +            elif "ERROR" in ln.upper() and ln.strip() and not is_run_timeout_text(ln):
    +                errs.append(ln.strip())
    +        if re.search(r"\bERROR\b", ln, re.I) and "--- ERRORS" not in ln:
    +            if is_run_timeout_text(ln):
    +                if "run.timeout-01" in ln.lower() or "RUN_TIMEOUT exceeded" in ln:
    +                    timeout_detail = re.sub(r"\s+", " ", strip_ansi(ln)).strip()
    +                continue
    +            if is_summary_error_line(ln):
    +                continue
    +            body = re.sub(r"^.*\bERROR\b\s*\]?\s*", "", ln, flags=re.I).strip(" ·")
    +            if body and body not in errs and "failed — see" not in body.lower():
    +                if re.search(
    +                    r"#\d+|www\.|e2e\.|auth401\.|versions\.|inside\.|surface\.|aptdeploy\.",
    +                    body,
    +                ):
    +                    errs.append(body)
    +    seen: set[str] = set()
    +    out: list[str] = []
    +    for e in errs:
    +        if e in seen or is_run_timeout_text(e):
    +            continue
    +        seen.add(e)
    +        out.append(e)
    +    if timeout_detail or any(
    +        is_run_timeout_text(l) and ("ERROR" in l.upper() or "run.timeout" in l.lower())
    +        for l in lines
    +    ):
    +        # only if real timeout ERROR badge, not mere RUN_TIMEOUT= header
    +        if timeout_detail or any(
    +            "run.timeout-01" in l.lower() or "RUN_TIMEOUT exceeded" in l for l in lines
    +        ):
    +            canon = timeout_detail or "run.timeout-01 [run] RUN_TIMEOUT exceeded"
    +            out = [canon] + out
    +    return out
    +
    +
    +def count_status(lines: list[str]) -> tuple[int, int, int | None, int | None]:
    +    """Return (error_count, warn_count, first_error_idx, first_warn_idx)."""
    +    n_err = 0
    +    n_warn = 0
    +    first_err: int | None = None
    +    first_warn: int | None = None
    +    for i, ln in enumerate(lines):
    +        kind = classify(ln)
    +        if kind in ("error", "blocker"):
    +            if is_summary_error_line(ln):
    +                continue
    +            n_err += 1
    +            if first_err is None:
    +                first_err = i
    +        elif kind == "warn":
    +            n_warn += 1
    +            if first_warn is None:
    +                first_warn = i
    +    return n_err, n_warn, first_err, first_warn
    +
    +
    +def slug_error(i: int, text: str) -> str:
    +    if is_run_timeout_text(text):
    +        return "err-run-timeout"
    +    m = TID_RE.search(text)
    +    if m:
    +        return "err-" + re.sub(r"[^a-z0-9_-]+", "-", m.group("tid").lower())
    +    return f"err-{i:03d}"
    +
    +
    +def render_line(ln: str, err_slugs: dict[str, str], extra_ids: list[str] | None = None) -> str:
    +    kind = classify(ln)
    +    esc = html.escape(ln)
    +    for tid, slug in err_slugs.items():
    +        if tid in ln:
    +            esc = esc.replace(
    +                html.escape(tid),
    +                f'{html.escape(tid)}',
    +            )
    +    id_attr = ""
    +    if extra_ids:
    +        id_attr = f' id="{" ".join(extra_ids)}"'  # invalid multi-id; use first only
    +        # HTML allows one id — join carefully
    +        id_attr = f' id="{html.escape(extra_ids[0])}"'
    +        if len(extra_ids) > 1:
    +            # secondary anchors as empty spans prepended
    +            spans = "".join(f'' for x in extra_ids[1:])
    +            return f'{spans}{esc}\n'
    +    return f'{esc}\n'
    +
    +
    +def status_level(n_err: int, n_warn: int) -> str:
    +    if n_err > 0:
    +        return "red"
    +    if n_warn > 0:
    +        return "yellow"
    +    return "green"
    +
    +
    +def extract_phases(log_text: str) -> str:
    +    """Best-effort phases string from host-agent / taler-monitoring log header."""
    +    for pat in (
    +        r"phases=([a-z0-9 _.-]+)",
    +        r"· phases=([a-z0-9 _.-]+)",
    +        r"phases\s{2,}([a-z0-9 _.-]+)",
    +        r"^\s*phases\s+([a-z0-9 _.-]+)\s*$",
    +    ):
    +        m = re.search(pat, log_text, re.I | re.M)
    +        if m:
    +            return re.sub(r"\s+", " ", m.group(1)).strip(" ·")
    +    return ""
    +
    +
    +def monitoring_scope(
    +    page_label: str,
    +    hostname: str,
    +    log_text: str = "",
    +) -> dict[str, object]:
    +    """
    +    Human-readable description of what this monitoring page covers.
    +    Returned keys: title, summary, items (list[str]), kind
    +    """
    +    label = (page_label or "monitoring").lower().replace("_", "-")
    +    phases = extract_phases(log_text)
    +    host = hostname or "?"
    +
    +    if "surface" in label:
    +        return {
    +            "kind": "surface",
    +            "title": "Remote ecosystem surface inventory",
    +            "summary": "Public catalog hosts · DNS / TCP / HTTPS / TLS · Server version · CVE",
    +            "items": [
    +                "Outside-in only: no SSH and no podman exec on remote targets",
    +                "Catalog (surface-catalog.conf): taler.net, demo/test, gnunet.org, "
    +                "taler-ops.ch, taler-systems.com, deb.taler.net, ftp.gnu.org, …",
    +                "Per host: DNS resolve, ICMP (info), open ports, protocol probes, "
    +                "TLS cert expiry, Server-header software version, optional CVE check",
    +                f"HTML published only on taler.hacktivism.ch "
    +                f"(this page host: {host})",
    +                "Timer: taler-monitoring-surface.timer (hourly)",
    +            ],
    +        }
    +
    +    if "aptdeploy" in label or "apt-deploy" in label or "apt_src" in label:
    +        return {
    +            "kind": "aptdeploy",
    +            "title": "apt-src merchant deploy tests (podman on koopa)",
    +            "summary": "4 containers · fresh + upgrade tracks · trixie & trixie-testing",
    +            "items": [
    +                "koopa-taler-deploy-test-apt-src-trixie (fresh, suite trixie)",
    +                "koopa-taler-deploy-test-apt-src-trixie-testing (fresh, trixie-testing)",
    +                "koopa-taler-deploy-test-apt-src-trixie-upgrade (upgrade track)",
    +                "koopa-taler-deploy-test-apt-src-trixie-testing-upgrade (upgrade track)",
    +                "Checks: packages, taler-merchant-httpd --version / ldd, systemd unit, "
    +                "optional local /config probe — not public nginx/HTTPS",
    +                f"HTML published only on taler.hacktivism.ch (this page host: {host})",
    +                "Timer: taler-monitoring-aptdeploy.timer (4h)",
    +            ],
    +        }
    +
    +    # Default: stack /monitoring pages (bank, exchange, merchant)
    +    phase_txt = phases or "urls · inside · versions"
    +    role = "stack"
    +    if host.startswith("bank."):
    +        role = "bank"
    +        focus = "Libeufin bank public HTTPS + container inside checks"
    +    elif host.startswith("exchange.") or host.startswith("stage.exchange."):
    +        role = "exchange"
    +        focus = "Exchange public HTTPS + container inside checks"
    +    elif host.startswith("taler.") or "merchant" in host or host.startswith("monnaie."):
    +        role = "merchant"
    +        focus = "Merchant backend / SPA public HTTPS + container inside checks"
    +    else:
    +        focus = "Taler stack public + inside checks"
    +
    +    items = [
    +        f"Focus: {focus}",
    +        f"Phases this run: {phase_txt}",
    +        "Typical: public URLs (HTTPS, QR, perf), inside (podman/ssh), package versions",
    +        f"HTML host: {host}",
    +        "Timer: taler-monitoring-hacktivism (path + 4h) · /monitoring on bank, exchange, taler",
    +    ]
    +    if phases:
    +        items.insert(2, f"Log header phases= {phases}")
    +
    +    return {
    +        "kind": role,
    +        "title": f"Stack monitoring · {host}",
    +        "summary": f"{phase_txt} · public + inside",
    +        "items": items,
    +    }
    +
    +
    +def sticky_bar_html(
    +    *,
    +    generated_at: str,
    +    generated_iso: str,
    +    n_err: int,
    +    n_warn: int,
    +    hostname: str,
    +    page_label: str,
    +    mode: str,
    +    commit_html: str,
    +    suite_path: str,
    +    link_other: str | None,
    +    first_err_href: str | None,
    +    first_warn_href: str | None,
    +    scope: dict[str, object] | None = None,
    +) -> str:
    +    level = status_level(n_err, n_warn)
    +    if level == "red":
    +        status_txt = "ERRORS"
    +    elif level == "yellow":
    +        status_txt = "WARNINGS"
    +    else:
    +        status_txt = "OK"
    +
    +    if first_err_href and n_err:
    +        err_stat = (
    +            f'{n_err} error{"s" if n_err != 1 else ""}'
    +        )
    +    else:
    +        err_stat = f'{n_err} errors'
    +
    +    if first_warn_href and n_warn:
    +        warn_stat = (
    +            f'{n_warn} warning{"s" if n_warn != 1 else ""}'
    +        )
    +    else:
    +        warn_stat = f'{n_warn} warnings'
    +
    +    other = ""
    +    if link_other:
    +        other = (
    +            f''
    +            f"{html.escape(link_other)}"
    +        )
    +
    +    if scope is None:
    +        scope = monitoring_scope(page_label, hostname, "")
    +    scope_title = html.escape(str(scope.get("title") or "Monitoring"))
    +    scope_summary = html.escape(str(scope.get("summary") or ""))
    +    scope_kind = html.escape(str(scope.get("kind") or "monitoring"))
    +    lis = "\n".join(
    +        f"
  • {html.escape(str(it))}
  • " for it in (scope.get("items") or []) + ) + suite_bit = ( + f" · {html.escape(suite_path)}" if suite_path else "" + ) + + # Compact sticky row always visible; "Was geprüft wird" expands inside sticky-bar + return f""" + +""" + + +STICKY_CSS = """ +.sticky-bar { + position: sticky; top: 0; z-index: 100; + border-bottom: 2px solid var(--border); + padding: 10px 14px 8px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 2px 12px #0008; +} +.sticky-bar.sticky-green { + background: linear-gradient(180deg, #0d1f14f2, #0a1410ee); + border-bottom-color: #1f6b3a; +} +.sticky-bar.sticky-yellow { + background: linear-gradient(180deg, #2a2210f2, #1a160cee); + border-bottom-color: #a68b2d; +} +.sticky-bar.sticky-red { + background: linear-gradient(180deg, #2a1010f2, #1a0808ee); + border-bottom-color: #a33; +} +.sticky-bar-row { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; + max-width: 1200px; +} +.sticky-bar-row.secondary { + margin-top: 8px; font-size: 12px; color: var(--dim); +} +.sticky-bar-row.secondary a { color: var(--info); text-decoration: none; } +.sticky-bar-row.secondary a:hover { text-decoration: underline; } +.status-pill { + display: inline-block; font-size: 11px; font-weight: 800; + letter-spacing: 0.06em; padding: 2px 10px; border-radius: 3px; + border: 1px solid; +} +.status-pill.sticky-green { color: var(--ok); border-color: #1f6b3a; background: #0a1a10; } +.status-pill.sticky-yellow { color: var(--warn); border-color: #a68b2d; background: #1a1608; } +.status-pill.sticky-red { color: var(--err); border-color: #a33; background: #1a0a0a; } +.host { font-weight: 600; color: #eee; font-size: 13px; } +.sep { color: var(--dim); } +.stat { + font-weight: 700; font-size: 12px; text-decoration: none; + padding: 1px 8px; border-radius: 3px; border: 1px solid transparent; +} +.stat.err { color: var(--err); border-color: #522; background: #1a0a0a; } +.stat.warn { color: var(--warn); border-color: #664; background: #1a1608; } +.stat.muted { opacity: 0.55; font-weight: 600; } +a.stat:hover { text-decoration: underline; filter: brightness(1.15); } +.generated { color: var(--dim); font-size: 12px; } +.generated time { color: #bbb; } +.generated .age { color: #888; margin-left: 4px; } +.generated .age:not(:empty)::before { content: "("; } +.generated .age:not(:empty)::after { content: ")"; } +.badge { + display: inline-block; padding: 1px 8px; border-radius: 3px; + font-size: 11px; font-weight: 700; margin-right: 4px; + border: 1px solid var(--border); +} +.badge.mode-err { color: var(--err); border-color: #522; background: #1a0a0a; } +.badge.mode-ok { color: var(--ok); border-color: #143; background: #0a1a10; } +.badge.mode-redirect { color: var(--warn); border-color: #664; background: #1a1608; } +/* collapsible block inside sticky-bar */ +.scope-toggle { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 8px; + max-width: min(42rem, 100%); + cursor: pointer; + font: inherit; + font-size: 12px; + color: #ddd; + background: #0006; + border: 1px solid #444; + border-radius: 4px; + padding: 4px 10px; + text-align: left; +} +.scope-toggle:hover { border-color: #888; background: #111a; color: #fff; } +.scope-toggle:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; } +.scope-toggle[aria-expanded="true"] { + border-color: #777; + background: #141414; +} +.scope-chevron { + display: inline-block; + width: 0; height: 0; + border-left: 5px solid #aaa; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + transition: transform 0.12s ease; + flex-shrink: 0; +} +.scope-toggle[aria-expanded="true"] .scope-chevron { + transform: rotate(90deg); + border-left-color: #eee; +} +.scope-toggle-label { + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + font-size: 11px; + white-space: nowrap; +} +.scope-toggle-hint { + color: #888; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.sticky-scope-panel { + margin-top: 8px; + max-width: 1200px; + border: 1px solid #333; + border-radius: 4px; + background: #080808e6; + padding: 8px 10px 10px; +} +.sticky-scope-panel[hidden] { display: none !important; } +.scope-body { padding: 4px 4px 2px 6px; } +.scope-title { + margin: 4px 0 4px; + font-size: 13px; + font-weight: 600; + color: #eee; +} +.scope-lead { + margin: 0 0 6px; + font-size: 12px; + color: #999; +} +.scope-list { + margin: 0; + padding-left: 1.15em; + color: #b0b0b0; + font-size: 12px; + line-height: 1.5; +} +.scope-list li { margin: 3px 0; } +#first-error, #first-warn, +[id^="err-"][id$="-line"] { + scroll-margin-top: 120px; +} +@media (max-width: 720px) { + .scope-toggle { margin-left: 0; width: 100%; } + .scope-toggle-hint { display: none; } +} +""" + +STICKY_JS = """ + +""" + + +def build_html( + *, + title: str, + hostname: str, + mode: str, + log_text: str, + commit: str, + commit_url: str, + suite_path: str, + generated_at: str, + generated_iso: str, + link_other: str | None, + page_label: str = "monitoring", +) -> str: + raw_lines = [strip_ansi(l).rstrip("\n") for l in log_text.splitlines()] + while raw_lines and not raw_lines[-1].strip(): + raw_lines.pop() + + n_err, n_warn, first_err_i, first_warn_i = count_status(raw_lines) + errors = extract_errors(raw_lines) + + err_slugs: dict[str, str] = {} + for i, e in enumerate(errors, 1): + slug = slug_error(i, e) + err_slugs[e] = slug + m = TID_RE.search(e) + if m: + err_slugs[m.group("tid")] = slug + + tid_to_slug = { + k: v for k, v in err_slugs.items() if re.match(r"^[a-z0-9_.-]+-\d+$", k, re.I) + } + + # Build body with anchors for first error/warn + per-error slugs + body_lines: list[str] = [] + claimed_err: set[str] = set() + for idx, ln in enumerate(raw_lines): + extra: list[str] = [] + if first_err_i is not None and idx == first_err_i: + extra.append("first-error") + if first_warn_i is not None and idx == first_warn_i: + extra.append("first-warn") + # per-error jump targets (all modes) + if classify(ln) in ("error", "blocker") and not is_summary_error_line(ln): + for i, e in enumerate(errors, 1): + slug = slug_error(i, e) + if slug in claimed_err: + continue + tid_m = TID_RE.search(e) + if is_run_timeout_text(e): + if "run.timeout-01" in ln and "ERROR" in ln.upper(): + extra.append(f"{slug}-line") + claimed_err.add(slug) + break + continue + key = tid_m.group(1) if tid_m else e[:40] + if key and key in ln: + extra.append(f"{slug}-line") + claimed_err.add(slug) + break + body_lines.append(render_line(ln, tid_to_slug, extra or None)) + + err_nav = "" + if errors and mode == "err": + items = [] + timeout_items = [] + for i, e in enumerate(errors, 1): + slug = slug_error(i, e) + li = ( + f'
  • ' + f'{html.escape(e)}
  • ' + ) + if is_run_timeout_text(e): + timeout_items.append(li) + else: + items.append(li) + if timeout_items: + err_nav += ( + '
    \n' + "

    EXTRAORDINARY · RUN TIMEOUT

    \n" + "

    Whole-run wall clock exceeded. " + 'Jump to detail →

    \n' + '
      \n' + + "\n".join(timeout_items[:1]) + + "\n
    \n
    \n" + ) + rest = items if timeout_items else (timeout_items + items) + if rest or not timeout_items: + err_nav += ( + '
    \n' + f"

    ERRORS ({len(errors)}) — jump to detail

    \n" + '
      \n' + + "\n".join(rest if rest else items) + + "\n
    \n
    \n" + ) + + commit_short = commit[:12] if commit else "unknown" + commit_html = ( + f'{html.escape(commit_short)}' + if commit_url + else html.escape(commit_short) + ) + + first_err_href = "#first-error" if first_err_i is not None else None + first_warn_href = "#first-warn" if first_warn_i is not None else None + scope = monitoring_scope(page_label, hostname, log_text) + + bar = sticky_bar_html( + generated_at=generated_at, + generated_iso=generated_iso, + n_err=n_err, + n_warn=n_warn, + hostname=hostname, + page_label=page_label, + mode=mode, + commit_html=commit_html, + suite_path=suite_path, + link_other=link_other, + first_err_href=first_err_href, + first_warn_href=first_warn_href, + scope=scope, + ) + + return f""" + + + + + +{html.escape(title)} + + + +{bar} +
    +{err_nav} +
    +{"".join(body_lines)} +
    +
    + Console-style render of taler-monitoring output. + Sticky bar: green = clean · yellow = warnings · red = errors. + Commit pins the exact tree used for this run. +
    +
    +{STICKY_JS} + + +""" + + +def build_redirect_html( + *, + hostname: str, + commit: str, + commit_url: str, + path_err: str = "/monitoring_err/", + page_label: str = "monitoring", + generated_at: str = "", + generated_iso: str = "", + n_err: int = 0, + n_warn: int = 0, + log_text: str = "", +) -> str: + commit_short = commit[:12] if commit else "unknown" + link = path_err if path_err.endswith("/") else path_err + "/" + c = ( + f'{html.escape(commit_short)}' + if commit_url + else html.escape(commit_short) + ) + if not generated_at: + now = datetime.now(timezone.utc) + generated_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ") + generated_at = now.strftime("%Y-%m-%d %H:%M:%SZ") + # Redirect pages exist only when the run failed → treat as red if n_err unknown + if n_err <= 0: + n_err = max(n_err, 1) + scope = monitoring_scope(page_label, hostname, log_text) + bar = sticky_bar_html( + generated_at=generated_at, + generated_iso=generated_iso, + n_err=n_err, + n_warn=n_warn, + hostname=hostname, + page_label=page_label, + mode="redirect", + commit_html=c, + suite_path="", + link_other=link, + first_err_href=link, + first_warn_href=None, + scope=scope, + ) + return f""" + + + + + + +{html.escape(page_label)} · errors · {html.escape(hostname)} + + + +{bar} +
    +

    {html.escape(page_label)} has failures.

    +

    See {html.escape(link)} for the full console log, + error index, and sticky status bar.

    +

    generated {html.escape(generated_at)} · source {c}

    +
    +{STICKY_JS} + + +""" + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--log", required=True, type=Path) + ap.add_argument("--out", required=True, type=Path) + ap.add_argument("--hostname", required=True) + ap.add_argument("--mode", choices=("ok", "err", "redirect"), required=True) + ap.add_argument("--commit", default="") + ap.add_argument("--commit-url", default="") + ap.add_argument("--suite-path", default="scripts/taler-monitoring") + ap.add_argument("--title", default="") + ap.add_argument("--link-other", default="") + ap.add_argument( + "--path-err", + default="/monitoring_err/", + help="URL path for error page (redirect target)", + ) + ap.add_argument( + "--page-label", + default="monitoring", + help="Short name shown in titles/redirect box", + ) + args = ap.parse_args() + + log_text = args.log.read_text(errors="replace") if args.log.is_file() else "" + now = datetime.now(timezone.utc) + generated_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ") + generated_at = now.strftime("%Y-%m-%d %H:%M:%SZ") + title = args.title or f"{args.page_label} {args.mode} · {args.hostname}" + + raw_for_counts = [strip_ansi(l).rstrip("\n") for l in log_text.splitlines()] + n_err, n_warn, _, _ = count_status(raw_for_counts) + + if args.mode == "redirect": + html_out = build_redirect_html( + hostname=args.hostname, + commit=args.commit, + commit_url=args.commit_url, + path_err=args.path_err, + page_label=args.page_label, + generated_at=generated_at, + generated_iso=generated_iso, + n_err=n_err, + n_warn=n_warn, + log_text=log_text, + ) + else: + html_out = build_html( + title=title, + hostname=args.hostname, + mode=args.mode, + log_text=log_text, + commit=args.commit, + commit_url=args.commit_url, + suite_path=args.suite_path, + generated_at=generated_at, + generated_iso=generated_iso, + link_other=args.link_other or None, + page_label=args.page_label, + ) + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(html_out, encoding="utf-8") + print(f"wrote {args.out} (errors={n_err} warnings={n_warn} level={status_level(n_err, n_warn)})") + + +if __name__ == "__main__": + main() diff --git a/scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh b/scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh new file mode 100755 index 0000000..c5718e1 --- /dev/null +++ b/scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# deploy-monitoring-sites.sh — rsync generated HTML to DEPLOY_WWW_ROOT on koopa. +# Does NOT reload Caddy (needs root — see ROOT-ON-KOOPA.md). +set -uo pipefail + +ROOT=$(cd "$(dirname "$0")" && pwd) +SETTINGS="${SITE_GEN_SETTINGS:-$ROOT/settings.conf}" +[ -f "$SETTINGS" ] || SETTINGS="$ROOT/settings.conf.example" +# shellcheck disable=SC1090 +set -a +source <(grep -v '^\s*#' "$SETTINGS" | grep -v '^\s*$' | sed 's/\r$//') +set +a + +WORK_ROOT="${WORK_ROOT:-/tmp/taler-monitoring-sites-work}" +HTML_DIR="$WORK_ROOT/html" +DEPLOY_SSH="${DEPLOY_SSH:-koopa-external}" +DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" + +if [ ! -d "$HTML_DIR" ]; then + echo "no HTML_DIR $HTML_DIR — run generate-monitoring-sites.sh first" >&2 + exit 1 +fi + +echo "deploy $HTML_DIR → ${DEPLOY_SSH}:${DEPLOY_WWW_ROOT}/" +# Prefer writing to a hernani-owned staging dir if /var/www not writable +STAGE_REMOTE="${DEPLOY_STAGING:-/home/hernani/monitoring-sites-staging}" + +ssh -o BatchMode=yes -o ConnectTimeout=20 "$DEPLOY_SSH" \ + "mkdir -p '$STAGE_REMOTE' '$DEPLOY_WWW_ROOT' 2>/dev/null || mkdir -p '$STAGE_REMOTE'" + +rsync -az --delete "$HTML_DIR/" "${DEPLOY_SSH}:${STAGE_REMOTE}/" +echo "staged on $DEPLOY_SSH:$STAGE_REMOTE" +echo +echo "If $DEPLOY_WWW_ROOT is root-owned, as root on koopa:" +echo " mkdir -p $DEPLOY_WWW_ROOT" +echo " rsync -a --delete $STAGE_REMOTE/ $DEPLOY_WWW_ROOT/" +echo " chown -R caddy:caddy $DEPLOY_WWW_ROOT # or root:caddy — match other www" +echo " # then Caddyfile handles + reload — see ROOT-ON-KOOPA.md" +echo +# try direct rsync to DEPLOY_WWW_ROOT if writable +if ssh -o BatchMode=yes "$DEPLOY_SSH" "test -w '$DEPLOY_WWW_ROOT' 2>/dev/null || test -w \"\$(dirname '$DEPLOY_WWW_ROOT')\""; then + rsync -az --delete "$HTML_DIR/" "${DEPLOY_SSH}:${DEPLOY_WWW_ROOT}/" && \ + echo "also synced directly to $DEPLOY_WWW_ROOT" +fi diff --git a/scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh b/scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh new file mode 100755 index 0000000..f45e5bd --- /dev/null +++ b/scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# generate-monitoring-sites.sh — run taler-monitoring, build console HTML for +# /monitoring and /monitoring_err (static files for host Caddy later). +# +# Usage: +# ./generate-monitoring-sites.sh # all SITES (or ONLY_HOSTS) +# ONLY_HOSTS="bank.hacktivism.ch taler.hacktivism.ch" ./generate-monitoring-sites.sh +# ./generate-monitoring-sites.sh --dry-run # HTML from empty/missing logs only +# ./generate-monitoring-sites.sh --skip-run # only convert existing logs +# +# Settings: settings.conf (from settings.conf.example). No secrets in settings. +# Secrets for e2e: ../secrets.env (optional; default phases are urls versions). +# +set -uo pipefail + +export PYTHONUNBUFFERED=1 +# Global reporting defaults (same as host-agent run-host-report.sh) +: "${RUN_TIMEOUT:=600}" +export RUN_TIMEOUT + +ROOT=$(cd "$(dirname "$0")" && pwd) +SETTINGS="${SITE_GEN_SETTINGS:-$ROOT/settings.conf}" +EXAMPLE="$ROOT/settings.conf.example" + +if [ ! -f "$SETTINGS" ]; then + if [ -f "$EXAMPLE" ]; then + echo "note: no $SETTINGS — using example defaults (copy to settings.conf to customize)" + SETTINGS="$EXAMPLE" + else + echo "missing settings: $EXAMPLE" >&2 + exit 2 + fi +fi + +# Load KEY=value lines; allow SITES already set in environment to win. +# Multiline SITES in the conf file: use SITES_FILE= or env SITES= instead. +while IFS= read -r _line || [ -n "$_line" ]; do + _line=${_line%%#*} + _line=$(echo "$_line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -z "$_line" ] && continue + case "$_line" in + SITES=*|SITES_FILE=*) continue ;; # handled below / env + *=*) + _k=${_line%%=*} + _v=${_line#*=} + # do not override pre-set env + if [ -z "${!_k+x}" ] 2>/dev/null || [ -z "${!_k}" ]; then + export "$_k=$_v" + fi + ;; + esac +done < "$SETTINGS" + +# SITES from dedicated file or keep env; else parse heredoc-style from example list +if [ -n "${SITES_FILE:-}" ] && [ -f "$SITES_FILE" ]; then + SITES=$(grep -v '^\s*#' "$SITES_FILE" | grep -v '^\s*$') + export SITES +elif [ -z "${SITES:-}" ]; then + # default 9 fronts + SITES="bank.hacktivism.ch|hacktivism.ch +exchange.hacktivism.ch|hacktivism.ch +taler.hacktivism.ch|hacktivism.ch +stage.bank.lefrancpaysan.ch|stage.lefrancpaysan.ch +stage.exchange.lefrancpaysan.ch|stage.lefrancpaysan.ch +stage.monnaie.lefrancpaysan.ch|stage.lefrancpaysan.ch +bank.lefrancpaysan.ch|lefrancpaysan.ch +exchange.lefrancpaysan.ch|lefrancpaysan.ch +monnaie.lefrancpaysan.ch|lefrancpaysan.ch" + export SITES +fi + +SKIP_RUN=0 +DRY=0 +while [ $# -gt 0 ]; do + case "$1" in + --skip-run) SKIP_RUN=1; shift ;; + --dry-run) DRY=1; SKIP_RUN=1; shift ;; + -h|--help) sed -n '1,20p' "$0"; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +MONITORING_ROOT=$(cd "$ROOT/${MONITORING_ROOT:-..}" && pwd) +WORK_ROOT="${WORK_ROOT:-/tmp/taler-monitoring-sites-work}" +LOG_DIR="$WORK_ROOT/logs" +HTML_DIR="$WORK_ROOT/html" +mkdir -p "$LOG_DIR" "$HTML_DIR" + +# commit of monitoring suite used for this run +if git -C "$MONITORING_ROOT/../.." rev-parse HEAD >/dev/null 2>&1; then + REPO_ROOT=$(cd "$MONITORING_ROOT/../.." && pwd) +elif git -C "$MONITORING_ROOT" rev-parse HEAD >/dev/null 2>&1; then + REPO_ROOT=$(cd "$MONITORING_ROOT" && pwd) +else + REPO_ROOT=$(cd "$ROOT/../../.." && pwd) +fi +COMMIT=$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown) +COMMIT_SHORT=$(git -C "$REPO_ROOT" rev-parse --short=12 HEAD 2>/dev/null || echo unknown) +SOURCE_REPO_WEB="${SOURCE_REPO_WEB:-https://git.hacktivism.ch/hernani/koopa-admin-log}" +TMPL="${SOURCE_COMMIT_URL_TMPL:-\{repo\}/src/commit/\{commit\}}" +COMMIT_URL=$(printf '%s' "$TMPL" | sed "s|{repo}|$SOURCE_REPO_WEB|g; s|{commit}|$COMMIT|g") +SUITE_PATH="${SOURCE_SUITE_PATH:-scripts/taler-monitoring}" + +# Outside-only public checks (no SSH into koopa/stage containers) +PHASES_ERR="${PHASES_ERR:-urls versions}" +PHASES_OK="${PHASES_OK:-urls versions}" +CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" +SKIP_SSH="${SKIP_SSH:-1}" +# Drop SSH phases if someone passes "all" / inside / server / e2e by mistake +filter_phases_outside() { + local out=() p + for p in "$@"; do + case "$p" in + inside|server|e2e|ladder|goa-ladder|auth401) continue ;; + all) out+=(urls versions);; + *) out+=("$p") ;; + esac + done + printf '%s\n' "${out[@]}" +} + +run_mon() { + local domain="$1" phases="$2" logfile="$3" cont="$4" + # shellcheck disable=SC2206 + local ph=( $phases ) + mapfile -t ph < <(filter_phases_outside "${ph[@]}") + [ "${#ph[@]}" -gt 0 ] || ph=(urls versions) + + : >"$logfile" + echo "+ -d $domain · phases=${ph[*]} · CONTINUE=$cont · SKIP_SSH=$SKIP_SSH · RUN_TIMEOUT=$RUN_TIMEOUT" + + if [ -n "${RUNNER_SSH:-}" ]; then + echo "+ via ssh $RUNNER_SSH" + rsync -az --delete \ + --exclude secrets.env \ + --exclude 'android-test/artifacts' \ + --exclude '.git' \ + "$MONITORING_ROOT/" \ + "${RUNNER_SSH}:${RUNNER_REMOTE_WORKDIR}/" \ + || return 1 + # shellcheck disable=SC2029 + if command -v stdbuf >/dev/null 2>&1; then + ssh -o BatchMode=yes -o ConnectTimeout=30 "$RUNNER_SSH" \ + "cd '${RUNNER_REMOTE_WORKDIR}' && chmod +x taler-monitoring.sh check_*.sh 2>/dev/null; \ + export CONTINUE_ON_ERROR='$cont' AUTH401_CONTINUE='$cont' SKIP_SSH='$SKIP_SSH' RUN_TIMEOUT='$RUN_TIMEOUT'; \ + stdbuf -oL -eL ./taler-monitoring.sh -d '$domain' ${ph[*]}" \ + 2>&1 | stdbuf -oL -eL tee -a "$logfile" + else + ssh -o BatchMode=yes -o ConnectTimeout=30 "$RUNNER_SSH" \ + "cd '${RUNNER_REMOTE_WORKDIR}' && chmod +x taler-monitoring.sh check_*.sh 2>/dev/null; \ + export CONTINUE_ON_ERROR='$cont' AUTH401_CONTINUE='$cont' SKIP_SSH='$SKIP_SSH' RUN_TIMEOUT='$RUN_TIMEOUT'; \ + ./taler-monitoring.sh -d '$domain' ${ph[*]}" \ + 2>&1 | tee -a "$logfile" + fi + return "${PIPESTATUS[0]}" + fi + + if command -v stdbuf >/dev/null 2>&1; then + ( cd "$MONITORING_ROOT" && \ + CONTINUE_ON_ERROR="$cont" AUTH401_CONTINUE="$cont" SKIP_SSH="$SKIP_SSH" \ + RUN_TIMEOUT="$RUN_TIMEOUT" \ + stdbuf -oL -eL ./taler-monitoring.sh -d "$domain" "${ph[@]}" ) \ + 2>&1 | stdbuf -oL -eL tee -a "$logfile" + else + ( cd "$MONITORING_ROOT" && \ + CONTINUE_ON_ERROR="$cont" AUTH401_CONTINUE="$cont" SKIP_SSH="$SKIP_SSH" \ + RUN_TIMEOUT="$RUN_TIMEOUT" \ + ./taler-monitoring.sh -d "$domain" "${ph[@]}" ) \ + 2>&1 | tee -a "$logfile" + fi + return "${PIPESTATUS[0]}" +} + +htmlify() { + local host="$1" mode="$2" log="$3" out="$4" other="$5" + mkdir -p "$(dirname "$out")" + if [ ! -f "$ROOT/console_to_html.py" ]; then + echo "WARN: console_to_html.py missing — bootstrap $out" + { + echo "$host $mode" + echo "

    $host · $mode

    commit $COMMIT_SHORT

    " + echo "
    "
    +      tail -n 100 "$log" 2>/dev/null | sed 's/&/\&/g;s/"
    +    } >"$out"
    +    return 0
    +  fi
    +  python3 "$ROOT/console_to_html.py" \
    +    --log "$log" \
    +    --out "$out" \
    +    --hostname "$host" \
    +    --mode "$mode" \
    +    --commit "$COMMIT" \
    +    --commit-url "$COMMIT_URL" \
    +    --suite-path "$SUITE_PATH" \
    +    --link-other "$other"
    +}
    +
    +# Parse SITES
    +mapfile -t SITE_LINES < <(printf '%s\n' "$SITES" | sed '/^\s*$/d' | sed 's/#.*//')
    +ONLY="${ONLY_HOSTS:-}"
    +
    +for line in "${SITE_LINES[@]}"; do
    +  line=$(echo "$line" | tr -d ' \t')
    +  [ -z "$line" ] && continue
    +  host=${line%%|*}
    +  domain=${line#*|}
    +  if [ -n "$ONLY" ]; then
    +    echo " $ONLY " | grep -q " $host " || continue
    +  fi
    +
    +  echo "======== $host  (domain=$domain)  commit=$COMMIT_SHORT ========"
    +  base_html="$HTML_DIR/$host"
    +  mkdir -p "$base_html/monitoring" "$base_html/monitoring_err"
    +  log_err="$LOG_DIR/${host}.err.log"
    +  log_ok="$LOG_DIR/${host}.ok.log"
    +  ec_err=1
    +  ec_ok=1
    +
    +  if [ "$SKIP_RUN" != "1" ]; then
    +    run_mon "$domain" "$PHASES_ERR" "$log_err" "$CONTINUE_ON_ERROR"
    +    ec_err=$?
    +    # ok-run without continue (strict)
    +    run_mon "$domain" "$PHASES_OK" "$log_ok" "0"
    +    ec_ok=$?
    +  else
    +    [ -f "$log_err" ] || : >"$log_err"
    +    [ -f "$log_ok" ] || : >"$log_ok"
    +    # infer exit from log if present
    +    if grep -q '\[ ERROR \]' "$log_err" 2>/dev/null || grep -q 'ERROR' "$log_err" 2>/dev/null; then
    +      ec_err=1
    +    else
    +      ec_err=0
    +    fi
    +    ec_ok=$ec_err
    +  fi
    +
    +  # Always write pages (first run / empty logs too)
    +  [ -f "$log_err" ] || : >"$log_err"
    +  [ -f "$log_ok" ] || : >"$log_ok"
    +
    +  htmlify "$host" "err" "$log_err" \
    +    "$base_html/monitoring_err/index.html" "/monitoring/"
    +
    +  if [ "$ec_ok" -eq 0 ] && [ "$ec_err" -eq 0 ]; then
    +    htmlify "$host" "ok" "$log_ok" \
    +      "$base_html/monitoring/index.html" "/monitoring_err/"
    +    echo "  → /monitoring (clean) + /monitoring_err"
    +  else
    +    htmlify "$host" "redirect" "$log_err" \
    +      "$base_html/monitoring/index.html" "/monitoring_err/"
    +    echo "  → /monitoring (redirect stub) + /monitoring_err (errors)  ec_err=$ec_err ec_ok=$ec_ok"
    +  fi
    +
    +  # first-run safety
    +  if [ ! -f "$base_html/monitoring/index.html" ]; then
    +    htmlify "$host" "err" "$log_err" \
    +      "$base_html/monitoring/index.html" "/monitoring_err/"
    +  fi
    +
    +  printf '%s\n' "$COMMIT" >"$base_html/COMMIT"
    +  printf '%s\n' "$COMMIT_URL" >"$base_html/COMMIT_URL"
    +  printf 'host=%s domain=%s ec_err=%s ec_ok=%s commit=%s run_timeout=%s\n' \
    +    "$host" "$domain" "$ec_err" "$ec_ok" "$COMMIT" "$RUN_TIMEOUT" | tee "$base_html/STATUS.txt"
    +done
    +
    +echo
    +echo "HTML under $HTML_DIR"
    +echo "commit $COMMIT_SHORT  $COMMIT_URL"
    +echo "RUN_TIMEOUT=${RUN_TIMEOUT}s (global; 0=unlimited)"
    +echo "Deploy (as hernani, may need sudo for /var/www):"
    +echo "  ./deploy-monitoring-sites.sh"
    +echo "Root on koopa: see ROOT-ON-KOOPA.md"
    diff --git a/scripts/taler-monitoring/site-gen/install-firecuda-timer.sh b/scripts/taler-monitoring/site-gen/install-firecuda-timer.sh
    new file mode 100755
    index 0000000..9615b96
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/install-firecuda-timer.sh
    @@ -0,0 +1,79 @@
    +#!/usr/bin/env bash
    +# install-firecuda-timer.sh — OPTIONAL: rsync suite to firecuda + launchd every 4h
    +#
    +# POLICY (2026-07): firecuda launchd is **disabled**. Prefer koopa host-agent.
    +# This script remains for documentation / future re-enable only.
    +# Do not run unless you intentionally want a second outside-only scheduler.
    +#
    +#   ./install-firecuda-timer.sh
    +#   ./install-firecuda-timer.sh --run-now
    +#
    +set -euo pipefail
    +
    +ROOT=$(cd "$(dirname "$0")" && pwd)
    +MON_ROOT=$(cd "$ROOT/.." && pwd)
    +FIRECUDA="${FIRECUDA_SSH:-firecuda-external}"
    +REMOTE_BASE="${FIRECUDA_INSTALL_DIR:-taler-monitoring-site-gen}"
    +RUN_NOW=0
    +[ "${1:-}" = "--run-now" ] && RUN_NOW=1
    +
    +echo "install → $FIRECUDA:~/$REMOTE_BASE (outside-only, every 4h)"
    +
    +ssh -o BatchMode=yes -o ConnectTimeout=20 "$FIRECUDA" \
    +  "mkdir -p \"\$HOME/$REMOTE_BASE\" \"\$HOME/Library/Logs/taler-monitoring-sites\" \"\$HOME/var/taler-monitoring-sites-work\" \"\$HOME/Library/LaunchAgents\""
    +
    +# Sync full taler-monitoring suite (no secrets.env)
    +rsync -az --delete \
    +  --exclude secrets.env \
    +  --exclude 'android-test/artifacts' \
    +  --exclude '.git' \
    +  --exclude 'site-gen/settings.conf' \
    +  "$MON_ROOT/" \
    +  "${FIRECUDA}:$REMOTE_BASE/"
    +
    +# settings on firecuda: outside only, run locally on firecuda, deploy to koopa
    +ssh -o BatchMode=yes "$FIRECUDA" "cat > \"\$HOME/$REMOTE_BASE/site-gen/settings.conf\" <<'EOF'
    +# auto-installed by install-firecuda-timer.sh — no secrets
    +RUNNER_SSH=
    +SKIP_SSH=1
    +CONTINUE_ON_ERROR=1
    +PHASES_ERR=urls versions
    +PHASES_OK=urls versions
    +DEPLOY_SSH=koopa-external
    +DEPLOY_STAGING=/home/hernani/monitoring-sites-staging
    +WORK_ROOT=
    +MONITORING_ROOT=..
    +SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    +SOURCE_COMMIT_URL_TMPL={repo}/src/commit/{commit}
    +SOURCE_SUITE_PATH=scripts/taler-monitoring
    +EOF
    +# WORK_ROOT expanded on remote
    +sed -i.bak \"s|^WORK_ROOT=.*|WORK_ROOT=\$HOME/var/taler-monitoring-sites-work|\" \"\$HOME/$REMOTE_BASE/site-gen/settings.conf\" 2>/dev/null || \
    +  sed -i '' \"s|^WORK_ROOT=.*|WORK_ROOT=\$HOME/var/taler-monitoring-sites-work|\" \"\$HOME/$REMOTE_BASE/site-gen/settings.conf\"
    +"
    +
    +# launchd plist with absolute paths
    +ssh -o BatchMode=yes "$FIRECUDA" bash -s < "\$PLIST_DST"
    +chmod +x "\$SITE_GEN"/*.sh "\$SITE_GEN"/console_to_html.py 2>/dev/null || true
    +chmod +x "\$SITE_GEN/run-on-firecuda.sh"
    +launchctl bootout "gui/\$(id -u)/com.hacktivism.taler-monitoring-sites" 2>/dev/null || true
    +launchctl unload "\$PLIST_DST" 2>/dev/null || true
    +launchctl bootstrap "gui/\$(id -u)" "\$PLIST_DST" 2>/dev/null || launchctl load "\$PLIST_DST"
    +launchctl enable "gui/\$(id -u)/com.hacktivism.taler-monitoring-sites" 2>/dev/null || true
    +launchctl print "gui/\$(id -u)/com.hacktivism.taler-monitoring-sites" 2>/dev/null | head -20 || launchctl list | grep taler-monitoring || true
    +echo "OK: launchd com.hacktivism.taler-monitoring-sites (StartInterval=14400)"
    +REMOTE
    +
    +if [ "$RUN_NOW" = "1" ]; then
    +  echo "run once now…"
    +  ssh -o BatchMode=yes "$FIRECUDA" "\"\$HOME/$REMOTE_BASE/site-gen/run-on-firecuda.sh\"" || true
    +fi
    +
    +echo "done. logs: $FIRECUDA:~/Library/Logs/taler-monitoring-sites/"
    +echo "manual: ssh $FIRECUDA '~/taler-monitoring-site-gen/site-gen/run-on-firecuda.sh'"
    diff --git a/scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh b/scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh
    new file mode 100755
    index 0000000..b1b10ec
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh
    @@ -0,0 +1,15 @@
    +#!/usr/bin/env bash
    +# pull-firecuda-to-koopa.sh — copy HTML from firecuda work dir → koopa staging
    +# Run on laptop (hernani) where both SSH aliases work.
    +set -euo pipefail
    +FIRECUDA="${FIRECUDA_SSH:-firecuda-external}"
    +KOOPA="${DEPLOY_SSH:-koopa-external}"
    +REMOTE_HTML="${FIRECUDA_HTML:-var/taler-monitoring-sites-work/html/}"
    +STAGE="${DEPLOY_STAGING:-/home/hernani/monitoring-sites-staging}"
    +
    +echo "firecuda:$REMOTE_HTML → $KOOPA:$STAGE"
    +ssh -o BatchMode=yes -o ConnectTimeout=20 "$KOOPA" "mkdir -p '$STAGE'"
    +rsync -az --delete \
    +  "${FIRECUDA}:${REMOTE_HTML}" \
    +  "${KOOPA}:${STAGE}/"
    +echo "OK staged. Root: rsync to /var/www/monitoring-sites (see ROOT-ON-KOOPA.md)"
    diff --git a/scripts/taler-monitoring/site-gen/run-on-firecuda.sh b/scripts/taler-monitoring/site-gen/run-on-firecuda.sh
    new file mode 100755
    index 0000000..6f77de6
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/run-on-firecuda.sh
    @@ -0,0 +1,59 @@
    +#!/usr/bin/env bash
    +# run-on-firecuda.sh — entrypoint for launchd/cron on firecuda-external.
    +# Outside-only monitoring (SKIP_SSH=1), all 9 sites, then stage HTML to koopa.
    +#
    +# Installed path on firecuda (default):
    +#   ~/taler-monitoring-site-gen/site-gen/run-on-firecuda.sh
    +#
    +set -uo pipefail
    +
    +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"
    +
    +ROOT=$(cd "$(dirname "$0")" && pwd)
    +LOG_DIR="${FIRECUDA_LOG_DIR:-$HOME/Library/Logs/taler-monitoring-sites}"
    +mkdir -p "$LOG_DIR"
    +STAMP=$(date +%Y%m%d-%H%M%S)
    +LOG="$LOG_DIR/run-${STAMP}.log"
    +exec >>"$LOG" 2>&1
    +
    +echo "======== $(date -Iseconds 2>/dev/null || date) firecuda monitoring sites ========"
    +
    +# Always: public DNS only, no SSH checks into stacks
    +export SKIP_SSH=1
    +export CONTINUE_ON_ERROR=1
    +export AUTH401_CONTINUE=1
    +export RUNNER_SSH=
    +export PHASES_ERR="${PHASES_ERR:-urls versions}"
    +export PHASES_OK="${PHASES_OK:-urls versions}"
    +export WORK_ROOT="${WORK_ROOT:-$HOME/var/taler-monitoring-sites-work}"
    +export MONITORING_ROOT="${MONITORING_ROOT:-$ROOT/..}"
    +export DEPLOY_SSH="${DEPLOY_SSH:-koopa-external}"
    +export DEPLOY_STAGING="${DEPLOY_STAGING:-/home/hernani/monitoring-sites-staging}"
    +
    +# Prefer full 9-site list from settings or defaults inside generate script
    +unset ONLY_HOSTS
    +
    +cd "$ROOT" || exit 1
    +chmod +x generate-monitoring-sites.sh deploy-monitoring-sites.sh console_to_html.py 2>/dev/null || true
    +
    +./generate-monitoring-sites.sh
    +ec=$?
    +
    +# HTML stays on firecuda under WORK_ROOT/html.
    +# Optional push to koopa if this host can SSH there (often only from laptop):
    +if [ -n "${DEPLOY_SSH:-}" ] && [ "${DEPLOY_FROM_FIRECUDA:-0}" = "1" ]; then
    +  if [ -x ./deploy-monitoring-sites.sh ]; then
    +    ./deploy-monitoring-sites.sh || echo "WARN: deploy staging failed"
    +  fi
    +else
    +  echo "note: HTML at ${WORK_ROOT}/html — pull from laptop:"
    +  echo "  rsync -az firecuda-external:var/taler-monitoring-sites-work/html/ \\"
    +  echo "    koopa-external:monitoring-sites-staging/"
    +  echo "  # or: ./pull-firecuda-to-koopa.sh"
    +fi
    +
    +# keep last 20 logs
    +ls -1t "$LOG_DIR"/run-*.log 2>/dev/null | tail -n +21 | xargs rm -f 2>/dev/null || true
    +
    +echo "======== done ec=$ec ========"
    +exit "$ec"
    diff --git a/scripts/taler-monitoring/site-gen/settings.conf.example b/scripts/taler-monitoring/site-gen/settings.conf.example
    new file mode 100644
    index 0000000..3d14fc2
    --- /dev/null
    +++ b/scripts/taler-monitoring/site-gen/settings.conf.example
    @@ -0,0 +1,55 @@
    +# monitoring site-gen — non-secret settings (safe as example)
    +# Copy:  cp settings.conf.example settings.conf
    +# Never put passwords/tokens here.
    +
    +# On firecuda the suite runs *locally* (RUNNER_SSH empty). From a laptop you
    +# can set RUNNER_SSH=firecuda-external to drive a one-off remote run instead.
    +RUNNER_SSH=
    +RUNNER_SSH_FALLBACKS=
    +RUNNER_REMOTE_WORKDIR=/tmp/taler-monitoring-site-gen
    +MONITORING_ROOT=..
    +
    +# Outside-only: never SSH into bank/exchange/merchant containers
    +SKIP_SSH=1
    +
    +# Deploy staging on koopa (host Caddy later — root copies to /var/www)
    +DEPLOY_SSH=koopa-external
    +DEPLOY_SSH_FALLBACKS=koopa
    +# Static files for Caddy file_server (created per hostname)
    +DEPLOY_WWW_ROOT=/var/www/monitoring-sites
    +DEPLOY_SUDO=sudo
    +CADDY_CONFIG=/etc/caddy/Caddyfile
    +CADDY_MIRROR_REL=configs/caddy/Caddyfile
    +# Working tree on deploy host for apply (optional)
    +KOOPA_CADDY_DIR=/home/hernani/koopa-caddy
    +
    +# Source link for HTML footer (Forgejo)
    +SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    +SOURCE_COMMIT_URL_TMPL={repo}/src/commit/{commit}
    +SOURCE_SUITE_PATH=scripts/taler-monitoring
    +
    +CONTINUE_ON_ERROR=1
    +PHASES_ERR=urls versions
    +PHASES_OK=urls versions
    +SKIP_E2E=1
    +
    +# Local work dir for logs + HTML
    +WORK_ROOT=/tmp/taler-monitoring-sites-work
    +
    +# Sites: hostname|domain_profile  (9 fronts)
    +# Start with hacktivism; stage + prod LFP follow
    +SITES="
    +bank.hacktivism.ch|hacktivism.ch
    +exchange.hacktivism.ch|hacktivism.ch
    +taler.hacktivism.ch|hacktivism.ch
    +stage.bank.lefrancpaysan.ch|stage.lefrancpaysan.ch
    +stage.exchange.lefrancpaysan.ch|stage.lefrancpaysan.ch
    +stage.monnaie.lefrancpaysan.ch|stage.lefrancpaysan.ch
    +bank.lefrancpaysan.ch|lefrancpaysan.ch
    +exchange.lefrancpaysan.ch|lefrancpaysan.ch
    +monnaie.lefrancpaysan.ch|lefrancpaysan.ch
    +"
    +
    +# Only process this subset (space-separated hostnames). Empty = all SITES.
    +# Example for first deploy: ONLY_HOSTS=bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch
    +ONLY_HOSTS=
    
    From 90372f1af1723ce89d799851d5232ae86c983956 Mon Sep 17 00:00:00 2001
    From: hernani 
    Date: Sat, 18 Jul 2026 19:57:21 +0200
    Subject: [PATCH 313/326] monitoring: caddy handles and ignore rules for
     monitoring sites
    
    ---
     .gitignore              |  1 +
     configs/caddy/Caddyfile | 48 +++++++++++++++++++++++++++++++++++++++++
     host/caddy/Caddyfile    | 48 +++++++++++++++++++++++++++++++++++++++++
     3 files changed, 97 insertions(+)
    
    diff --git a/.gitignore b/.gitignore
    index 822d63a..1fa6cfa 100644
    --- a/.gitignore
    +++ b/.gitignore
    @@ -9,3 +9,4 @@ scripts/taler-monitoring/settings.conf
     # Local e2e/overrides — never commit (use secrets.env.example as template)
     scripts/taler-monitoring/secrets.env
     scripts/taler-monitoring/secrets.env.local
    +scripts/taler-monitoring/site-gen/settings.conf
    diff --git a/configs/caddy/Caddyfile b/configs/caddy/Caddyfile
    index 77b2f34..7b960b9 100644
    --- a/configs/caddy/Caddyfile
    +++ b/configs/caddy/Caddyfile
    @@ -37,6 +37,22 @@ taler.hacktivism.ch {
             }
         }
     
    +    # taler-monitoring console HTML (host static — not in app containers)
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +
         # SPA: /webui → /webui/
         redir /webui /webui/ 302
     
    @@ -62,6 +78,22 @@ exchange.hacktivism.ch {
             }
         }
     
    +    # taler-monitoring console HTML (host static — not in app containers)
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +
         reverse_proxy 127.0.0.1:9011 {
             import proxy_public
         }
    @@ -79,6 +111,22 @@ bank.hacktivism.ch {
             }
         }
     
    +    # taler-monitoring console HTML (host static — not in app containers)
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +
         # Static terms/privacy on landing nginx :9013
         handle /terms* {
             reverse_proxy 127.0.0.1:9013 {
    diff --git a/host/caddy/Caddyfile b/host/caddy/Caddyfile
    index 77b2f34..7b960b9 100644
    --- a/host/caddy/Caddyfile
    +++ b/host/caddy/Caddyfile
    @@ -37,6 +37,22 @@ taler.hacktivism.ch {
             }
         }
     
    +    # taler-monitoring console HTML (host static — not in app containers)
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +
         # SPA: /webui → /webui/
         redir /webui /webui/ 302
     
    @@ -62,6 +78,22 @@ exchange.hacktivism.ch {
             }
         }
     
    +    # taler-monitoring console HTML (host static — not in app containers)
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +
         reverse_proxy 127.0.0.1:9011 {
             import proxy_public
         }
    @@ -79,6 +111,22 @@ bank.hacktivism.ch {
             }
         }
     
    +    # taler-monitoring console HTML (host static — not in app containers)
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +
         # Static terms/privacy on landing nginx :9013
         handle /terms* {
             reverse_proxy 127.0.0.1:9013 {
    
    From e40364349bed5dc9f59cb3b14ddc64f283352c30 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= 
    Date: Sat, 18 Jul 2026 19:57:21 +0200
    Subject: [PATCH 314/326] monitoring: caddy handles and ignore rules for
     monitoring sites
    
    ---
     .gitignore              |  1 +
     configs/caddy/Caddyfile | 48 +++++++++++++++++++++++++++++++++++++++++
     host/caddy/Caddyfile    | 48 +++++++++++++++++++++++++++++++++++++++++
     3 files changed, 97 insertions(+)
    
    diff --git a/.gitignore b/.gitignore
    index 822d63a..1fa6cfa 100644
    --- a/.gitignore
    +++ b/.gitignore
    @@ -9,3 +9,4 @@ scripts/taler-monitoring/settings.conf
     # Local e2e/overrides — never commit (use secrets.env.example as template)
     scripts/taler-monitoring/secrets.env
     scripts/taler-monitoring/secrets.env.local
    +scripts/taler-monitoring/site-gen/settings.conf
    diff --git a/configs/caddy/Caddyfile b/configs/caddy/Caddyfile
    index 77b2f34..7b960b9 100644
    --- a/configs/caddy/Caddyfile
    +++ b/configs/caddy/Caddyfile
    @@ -37,6 +37,22 @@ taler.hacktivism.ch {
             }
         }
     
    +    # taler-monitoring console HTML (host static — not in app containers)
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +
         # SPA: /webui → /webui/
         redir /webui /webui/ 302
     
    @@ -62,6 +78,22 @@ exchange.hacktivism.ch {
             }
         }
     
    +    # taler-monitoring console HTML (host static — not in app containers)
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +
         reverse_proxy 127.0.0.1:9011 {
             import proxy_public
         }
    @@ -79,6 +111,22 @@ bank.hacktivism.ch {
             }
         }
     
    +    # taler-monitoring console HTML (host static — not in app containers)
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +
         # Static terms/privacy on landing nginx :9013
         handle /terms* {
             reverse_proxy 127.0.0.1:9013 {
    diff --git a/host/caddy/Caddyfile b/host/caddy/Caddyfile
    index 77b2f34..7b960b9 100644
    --- a/host/caddy/Caddyfile
    +++ b/host/caddy/Caddyfile
    @@ -37,6 +37,22 @@ taler.hacktivism.ch {
             }
         }
     
    +    # taler-monitoring console HTML (host static — not in app containers)
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +
         # SPA: /webui → /webui/
         redir /webui /webui/ 302
     
    @@ -62,6 +78,22 @@ exchange.hacktivism.ch {
             }
         }
     
    +    # taler-monitoring console HTML (host static — not in app containers)
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +
         reverse_proxy 127.0.0.1:9011 {
             import proxy_public
         }
    @@ -79,6 +111,22 @@ bank.hacktivism.ch {
             }
         }
     
    +    # taler-monitoring console HTML (host static — not in app containers)
    +    handle /monitoring_err {
    +        redir /monitoring_err/ 302
    +    }
    +    handle /monitoring_err/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        file_server
    +    }
    +    handle /monitoring {
    +        redir /monitoring/ 302
    +    }
    +    handle /monitoring/ {
    +        root * /var/www/monitoring-sites/{host}/monitoring
    +        file_server
    +    }
    +
         # Static terms/privacy on landing nginx :9013
         handle /terms* {
             reverse_proxy 127.0.0.1:9013 {
    
    From 2aab44c352f924d12e682fd1246d9f10ff5413f8 Mon Sep 17 00:00:00 2001
    From: hernani 
    Date: Sat, 18 Jul 2026 19:58:22 +0200
    Subject: [PATCH 315/326] monitoring: move suite to standalone taler-monitoring
     repo
    
    Remove scripts/taler-monitoring implementation from koopa-admin-log.
    Leave scripts/taler-monitoring/README.md pointing at
    https://git.hacktivism.ch/hernani/taler-monitoring (v1.0).
    ---
     scripts/README.md                             |    2 +-
     scripts/taler-monitoring/.gitignore           |    6 -
     .../taler-monitoring/CLI-AUTOMATION-NOTES.md  |  228 ---
     scripts/taler-monitoring/DEPENDENCIES.md      |   70 -
     .../taler-monitoring/GIT-AUTOMATION-NOTES.md  |  198 --
     scripts/taler-monitoring/README.md            |  366 +---
     scripts/taler-monitoring/TESTS.md             |  193 --
     .../taler-monitoring/android-test/.gitignore  |   11 -
     .../android-test/GUI-AUTOMATION-NOTES.md      |  362 ----
     .../taler-monitoring/android-test/README.md   |  111 --
     .../android-test/lib_android_env.sh           |  158 --
     .../android-test/lib_release_age.sh           |  133 --
     .../taler-monitoring/android-test/lib_ui.py   |  192 --
     .../run-android-build-and-smoke.sh            |  167 --
     .../android-test/run-android-gui-smoke.sh     |  201 --
     .../android-test/run-android-pay-smoke.sh     |  308 ---
     .../run-android-variant-matrix.sh             |  322 ----
     .../android-test/run-goa-gui-chain.sh         |  366 ----
     .../android-test/start-android-emulator.sh    |   90 -
     scripts/taler-monitoring/check_apt_deploy.sh  |  279 ---
     scripts/taler-monitoring/check_auth401.sh     |  704 -------
     scripts/taler-monitoring/check_e2e.sh         | 1711 -----------------
     scripts/taler-monitoring/check_goa_ladder.sh  | 1364 -------------
     scripts/taler-monitoring/check_inside.sh      |  546 ------
     scripts/taler-monitoring/check_qr_payloads.py |  294 ---
     scripts/taler-monitoring/check_sanity.sh      |  285 ---
     scripts/taler-monitoring/check_server.sh      |  158 --
     scripts/taler-monitoring/check_surface.sh     |  692 -------
     scripts/taler-monitoring/check_urls.sh        | 1595 ---------------
     scripts/taler-monitoring/check_versions.sh    |  563 ------
     scripts/taler-monitoring/domains.conf         |   46 -
     scripts/taler-monitoring/harness-live.txt     |    7 -
     scripts/taler-monitoring/harness-tests.txt    |   41 -
     scripts/taler-monitoring/host-agent/README.md |  183 --
     .../host-agent/ROOT-APPLY-MONITORING.md       |   57 -
     .../ensure-apt-deploy-test-containers.sh      |  373 ----
     .../taler-monitoring/host-agent/env.example   |   26 -
     .../host-agent/env/francpaysan.env.example    |   24 -
     .../host-agent/env/hacktivism.env.example     |   24 -
     .../host-agent/env/stagepaysan.env.example    |   27 -
     .../host-agent/install-host-agent.sh          |  107 --
     .../host-agent/run-aptdeploy-monitoring.sh    |   43 -
     .../host-agent/run-aptdeploy.sh               |  150 --
     .../host-agent/run-fp-prod-monitoring.sh      |   20 -
     .../host-agent/run-fp-stage-monitoring.sh     |   21 -
     .../host-agent/run-hacktivism-monitoring.sh   |   23 -
     .../host-agent/run-host-report.sh             |  358 ----
     .../host-agent/run-surface-monitoring.sh      |   41 -
     .../taler-monitoring-aptdeploy.service        |   18 -
     .../taler-monitoring-aptdeploy.timer          |   14 -
     .../taler-monitoring-hacktivism.path          |   19 -
     .../taler-monitoring-hacktivism.service       |   21 -
     .../taler-monitoring-hacktivism.timer         |   14 -
     .../taler-monitoring-surface.service          |   18 -
     .../host-agent/taler-monitoring-surface.timer |   14 -
     .../host-agent/touch-software-stamp.sh        |   17 -
     .../host-agent/update-suite.sh                |  109 --
     scripts/taler-monitoring/lib.sh               | 1257 ------------
     scripts/taler-monitoring/metrics.sh           | 1431 --------------
     scripts/taler-monitoring/secrets.env.example  |   87 -
     scripts/taler-monitoring/site-gen/README.md   |   81 -
     .../site-gen/ROOT-ON-KOOPA.md                 |   92 -
     .../site-gen/caddy-monitoring-handles.snippet |   57 -
     ...om.hacktivism.taler-monitoring-sites.plist |   34 -
     .../site-gen/console_to_html.py               |  935 ---------
     .../site-gen/deploy-monitoring-sites.sh       |   44 -
     .../site-gen/generate-monitoring-sites.sh     |  273 ---
     .../site-gen/install-firecuda-timer.sh        |   79 -
     .../site-gen/pull-firecuda-to-koopa.sh        |   15 -
     .../site-gen/run-on-firecuda.sh               |   59 -
     .../site-gen/settings.conf.example            |   55 -
     scripts/taler-monitoring/surface-catalog.conf |   52 -
     scripts/taler-monitoring/taler-monitoring.sh  |  477 -----
     73 files changed, 19 insertions(+), 18499 deletions(-)
     delete mode 100644 scripts/taler-monitoring/.gitignore
     delete mode 100644 scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md
     delete mode 100644 scripts/taler-monitoring/DEPENDENCIES.md
     delete mode 100644 scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md
     delete mode 100644 scripts/taler-monitoring/TESTS.md
     delete mode 100644 scripts/taler-monitoring/android-test/.gitignore
     delete mode 100644 scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md
     delete mode 100644 scripts/taler-monitoring/android-test/README.md
     delete mode 100644 scripts/taler-monitoring/android-test/lib_android_env.sh
     delete mode 100644 scripts/taler-monitoring/android-test/lib_release_age.sh
     delete mode 100755 scripts/taler-monitoring/android-test/lib_ui.py
     delete mode 100755 scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh
     delete mode 100755 scripts/taler-monitoring/android-test/run-android-gui-smoke.sh
     delete mode 100755 scripts/taler-monitoring/android-test/run-android-pay-smoke.sh
     delete mode 100755 scripts/taler-monitoring/android-test/run-android-variant-matrix.sh
     delete mode 100755 scripts/taler-monitoring/android-test/run-goa-gui-chain.sh
     delete mode 100755 scripts/taler-monitoring/android-test/start-android-emulator.sh
     delete mode 100755 scripts/taler-monitoring/check_apt_deploy.sh
     delete mode 100755 scripts/taler-monitoring/check_auth401.sh
     delete mode 100755 scripts/taler-monitoring/check_e2e.sh
     delete mode 100755 scripts/taler-monitoring/check_goa_ladder.sh
     delete mode 100755 scripts/taler-monitoring/check_inside.sh
     delete mode 100755 scripts/taler-monitoring/check_qr_payloads.py
     delete mode 100755 scripts/taler-monitoring/check_sanity.sh
     delete mode 100755 scripts/taler-monitoring/check_server.sh
     delete mode 100755 scripts/taler-monitoring/check_surface.sh
     delete mode 100755 scripts/taler-monitoring/check_urls.sh
     delete mode 100755 scripts/taler-monitoring/check_versions.sh
     delete mode 100644 scripts/taler-monitoring/domains.conf
     delete mode 100644 scripts/taler-monitoring/harness-live.txt
     delete mode 100644 scripts/taler-monitoring/harness-tests.txt
     delete mode 100644 scripts/taler-monitoring/host-agent/README.md
     delete mode 100644 scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md
     delete mode 100755 scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh
     delete mode 100644 scripts/taler-monitoring/host-agent/env.example
     delete mode 100644 scripts/taler-monitoring/host-agent/env/francpaysan.env.example
     delete mode 100644 scripts/taler-monitoring/host-agent/env/hacktivism.env.example
     delete mode 100644 scripts/taler-monitoring/host-agent/env/stagepaysan.env.example
     delete mode 100755 scripts/taler-monitoring/host-agent/install-host-agent.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-aptdeploy.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-host-report.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-surface-monitoring.sh
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-surface.service
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer
     delete mode 100755 scripts/taler-monitoring/host-agent/touch-software-stamp.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/update-suite.sh
     delete mode 100755 scripts/taler-monitoring/lib.sh
     delete mode 100644 scripts/taler-monitoring/metrics.sh
     delete mode 100644 scripts/taler-monitoring/secrets.env.example
     delete mode 100644 scripts/taler-monitoring/site-gen/README.md
     delete mode 100644 scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md
     delete mode 100644 scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet
     delete mode 100644 scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist
     delete mode 100755 scripts/taler-monitoring/site-gen/console_to_html.py
     delete mode 100755 scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh
     delete mode 100755 scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh
     delete mode 100755 scripts/taler-monitoring/site-gen/install-firecuda-timer.sh
     delete mode 100755 scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh
     delete mode 100755 scripts/taler-monitoring/site-gen/run-on-firecuda.sh
     delete mode 100644 scripts/taler-monitoring/site-gen/settings.conf.example
     delete mode 100644 scripts/taler-monitoring/surface-catalog.conf
     delete mode 100755 scripts/taler-monitoring/taler-monitoring.sh
    
    diff --git a/scripts/README.md b/scripts/README.md
    index d062c73..5d9a36d 100644
    --- a/scripts/README.md
    +++ b/scripts/README.md
    @@ -8,7 +8,7 @@
     | `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) |
    +| `taler-monitoring/` | **moved** → [hernani/taler-monitoring](https://git.hacktivism.ch/hernani/taler-monitoring) (see `scripts/taler-monitoring/README.md`) |
     | `taler-shared/` | shared helpers (`upgrade-goa-debs.sh` = apt upgrade **inside** bank/exchange/merchant containers) |
     | `taler-landing/` | **hernani user systemd**: central landing `stats.json` (full bank scan + alt units) |
     | `monitoring/` | host `/home/hernani/scripts` (tor relay stats) |
    diff --git a/scripts/taler-monitoring/.gitignore b/scripts/taler-monitoring/.gitignore
    deleted file mode 100644
    index 754bb8e..0000000
    --- a/scripts/taler-monitoring/.gitignore
    +++ /dev/null
    @@ -1,6 +0,0 @@
    -android-test/apks/
    -android-test/out*/
    -android-test/artifacts/
    -__pycache__/
    -secrets.env
    -*.pyc
    diff --git a/scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md
    deleted file mode 100644
    index 3cd311a..0000000
    --- a/scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md
    +++ /dev/null
    @@ -1,228 +0,0 @@
    -# CLI automation notes (`taler-wallet-cli` + monitoring)
    -
    -Living list of **recurring issues** seen while driving GOA (hacktivism) and
    -stage TESTPAYSAN via **CLI** (`taler-wallet-cli`, bank/merchant HTTP, monitoring
    -e2e/ladder). Ordered roughly by **how often they hurt automation** and how
    -useful a fix in **wallet-cli / wallet-core** (or clearer APIs) would be.
    -
    -Companion: Android GUI → [`android-test/GUI-AUTOMATION-NOTES.md`](./android-test/GUI-AUTOMATION-NOTES.md).
    -
    -Scripts: `check_e2e.sh`, `check_goa_ladder.sh`, `taler-monitoring.sh`.
    -
    -Legend:
    -
    -| Tag | Meaning |
    -|-----|---------|
    -| **cli** | wallet-cli UX / flags / hang behaviour |
    -| **core** | wallet-core protocol / state machine |
    -| **bank** | libeufin-bank / integration API |
    -| **ops** | secrets, paths, multi-host |
    -| **doc** | missing or wrong documentation |
    -
    ----
    -
    -## 1. `run-until-done` is unusable for automation
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e, ladder, Android notes, macOS comments: *hangs / banned in monitoring* |
    -| **Area** | **cli** / **core** |
    -| **Problem** | `taler-wallet-cli run-until-done` (or long shepherd runs) often **never returns** or blocks CI-length timeouts. Monitoring **forbids** it and polls balance + bank `transfer_done` instead. |
    -| **Workaround** | `advanced serve` + socket (`WSOCK`) or pure poll loops (`LADDER_SETTLE_*`, e2e settle wait). |
    -| **Wanted for wallet-cli** | Bounded wait: e.g. `run-until-done --timeout=Ns --exit-on=withdrawn|paid|idle`; stable non-zero exit on timeout; progress on stdout/JSON. |
    -
    ----
    -
    -## 2. Withdraw completion is not observable from CLI alone
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e “coins missing after withdraw”; ladder `OK_BANK` vs wallet avail |
    -| **Area** | **cli** / **core** / **bank** |
    -| **Problem** | After `accept-uri` + bank confirm, coins may lag (wirewatch). CLI has no single “withdraw settled” command with clear success/failure; automation reimplements status from bank integration JSON + balance polls. |
    -| **Workaround** | Poll `balance` + `…/withdrawal-operation/{id}` (`status`, `transfer_done`, `selected_reserve_pub`). |
    -| **Wanted** | `wallet-cli withdrawals wait --id=… --timeout=` or JSON event stream: `selected → confirmed → coins-available`. |
    -
    ----
    -
    -## 3. `reserve_pub` / force-select is fragile (5114)
    -
    -| | |
    -|--|--|
    -| **Seen** | ladder force-select, e2e confirm path, Android spinner issues |
    -| **Area** | **cli** / **core** / **bank** |
    -| **Problem** | Cumulative wallets re-emit old reserves; binding the wrong `reserve_pub` yields **HTTP 409 / code 5114** (“already bound”). Automation scrapes accept/tx dumps for candidates. |
    -| **Workaround** | Score pubs by WID/amount; try several; treat 5114 as “stale reserve” not “out of money”. |
    -| **Wanted** | CLI returns **the** `reserve_pub` for the just-accepted withdraw in structured JSON; `force-select` / bank-side select API documented with idempotency. |
    -
    ----
    -
    -## 4. Exchange not auto-known for bank deep links / withdraw URIs
    -
    -| | |
    -|--|--|
    -| **Seen** | Android endless spinner; GOA `bank.hacktivism.ch` withdraw; fixed in app branch `fix/bank-withdraw-auto-exchange` |
    -| **Area** | **core** / **cli** |
    -| **Problem** | Withdraw URI names an exchange the wallet has never added → stuck loading / empty exchange list. CLI requires explicit `exchanges add` + `update` + `accept-tos` every fresh DB. |
    -| **Workaround** | e2e always adds exchange before ATM ladder; Android needs ensureExchange-style fix. |
    -| **Wanted** | CLI/core: on `accept-uri` withdraw, **auto-add** exchange from URI/bank details (`allowCompletion`), then surface ToS if needed in one command. |
    -
    ----
    -
    -## 5. Default ports in URIs (`:443` / `:80`) break round-trips
    -
    -| | |
    -|--|--|
    -| **Seen** | ladder/e2e strip ports; QR checks; mint URIs with `:443` |
    -| **Area** | **cli** / **bank** / **doc** |
    -| **Problem** | Some bank/wallet outputs include `taler://…host:443/…`. QR validators and some clients reject or double-normalize inconsistently. |
    -| **Workaround** | `sed 's/:443\//\//g'` everywhere in automation. |
    -| **Wanted** | Canonical form without default ports in **all** wallet-cli and bank integration outputs; document as invariant. |
    -
    ----
    -
    -## 6. No first-class “demo withdraw” / explorer mint in wallet-cli
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e uses bank admin + ATM create; ladder/gui-chain reimplement explorer mint in Python |
    -| **Area** | **cli** / **ops** |
    -| **Problem** | Automation always hand-rolls Basic auth → token → `POST …/withdrawals` → `taler_withdraw_uri`. Easy to get wrong (auth header, amount currency). |
    -| **Workaround** | Shared Python in `goa-chain` / ladder / e2e; secrets files. |
    -| **Wanted** | Optional helper: `wallet-cli testing mint-withdraw --bank=URL --user=explorer --password-file=… --amount=GOA:10` (or bank-side only tool in libeufin) emitting a clean URI. |
    -
    ----
    -
    -## 7. Long-lived wallet process required for pay/withdraw progress
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e `advanced serve` / shepherd socket |
    -| **Area** | **cli** |
    -| **Problem** | One-shot CLI invocations do not keep background work alive; without serve/shepherd, withdraw/pay may stall mid-flight. |
    -| **Workaround** | Start serve once per e2e run; route `wcli` through the socket. |
    -| **Wanted** | Documented, supported “session mode”: start/stop serve; or each mutating command optionally `--background-until=…` with timeout. |
    -
    ----
    -
    -## 8. ToS accept is a separate step that fails silently or blocks
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e `exchanges accept-tos`; ladder after add; Android GUI ToS screens |
    -| **Area** | **cli** / **core** |
    -| **Problem** | Fresh exchange → operations need ToS; CLI must call `accept-tos` explicitly. Failures are easy to miss in multi-step scripts. |
    -| **Workaround** | Always `accept-tos` after `update` in e2e/ladder. |
    -| **Wanted** | `accept-uri --accept-tos` or auto-prompt with non-interactive `--yes` that covers exchange ToS for that URI’s exchange. |
    -
    ----
    -
    -## 9. Pay template / public order path is merchant-shaped, not CLI-shaped
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e shop templates, stage farmer shops, ladder private orders |
    -| **Area** | **cli** / **doc** |
    -| **Problem** | Wallet-cli pays via `handle-uri` on `taler://pay/…`; creating the order is always custom curl (template POST or private order + token). No unified “pay this amount to instance” for public templates. |
    -| **Workaround** | Monitoring builds URI externally then `handle-uri --yes`. |
    -| **Wanted** | Documented recipe only, or `wallet-cli testing pay-template --base=… --instance=… --id=…` for demos. |
    -
    ----
    -
    -## 10. Amount / currency parsing edge cases
    -
    -| | |
    -|--|--|
    -| **Seen** | ladder min denom 0.01 vs GOA 1e-6; zero withdraw rejected (HTTP 409 / amount too low); max wire ceilings |
    -| **Area** | **cli** / **bank** |
    -| **Problem** | `CURRENCY:0` and sub-min amounts fail differently per stack; CLI error strings are not machine-stable. |
    -| **Workaround** | Soft-skip zero rung; clamp ladder min to exchange denoms / bank max_wire. |
    -| **Wanted** | Structured errors (`AMOUNT_too_small`, `currency_unknown`) in JSON mode; `wallet-cli amount validate --exchange=`. |
    -
    ----
    -
    -## 11. Finding the right `taler-wallet-cli` binary
    -
    -| | |
    -|--|--|
    -| **Seen** | `find_wallet_cli` in `lib.sh`; hardcoded laptop paths; wrapper vs `.mjs` |
    -| **Area** | **ops** / **cli** |
    -| **Problem** | Debian package is a shell wrapper; some tools need `node …/taler-wallet-cli.mjs`. Hardcoded `/Users/…` paths break other hosts. |
    -| **Workaround** | `find_wallet_cli` search list; `WALLET_CLI=` override. |
    -| **Wanted** | Single install story: `wallet-cli --version` JSON with path + libversion; no need to pass `.mjs` to node by hand. |
    -
    ----
    -
    -## 12. Secrets and multi-stack confusion
    -
    -| | |
    -|--|--|
    -| **Seen** | explorer vs admin password; GOA secrets used on stage; ladder EXP_PW |
    -| **Area** | **ops** |
    -| **Problem** | CLI does not know “which stack”; wrong password → 401 mid-ladder. Not a wallet-cli bug, but every CLI automation hits it. |
    -| **Workaround** | `SECRETS_ROOT`, stage SSH, `STACK=` profiles. |
    -| **Wanted** | Optional `~/.config/taler/stacks.d/goa.env` convention documented next to wallet-cli; still no secrets in repo. |
    -
    ----
    -
    -## 13. No stable machine-readable “step result” for scripts
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e greps accept output; ladder scrapes JSON from mixed stdout |
    -| **Area** | **cli** |
    -| **Problem** | Human logs + occasional JSON blobs; hard to parse reliably. |
    -| **Workaround** | Python scrapers, temp files, `tee`. |
    -| **Wanted** | Global `--json` / `--ndjson` for all commands; one object per completed operation with `ok`, `op`, `ids`, `amounts`. |
    -
    ----
    -
    -## 14. Pay settlement wait is symmetric to withdraw pain
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e pay settle loops; ladder pay settle rounds |
    -| **Area** | **cli** / **core** |
    -| **Problem** | After `handle-uri` pay, success is “order paid” / balance drop / merchant order status — not one CLI wait. |
    -| **Workaround** | Short poll loops; never run-until-done. |
    -| **Wanted** | Same as §1–2: bounded wait on transaction id / order id. |
    -
    ----
    -
    -## Priority shortlist for `taler-wallet-cli` / core
    -
    -If only a few changes land, these unlock the most automation:
    -
    -1. **Bounded `run-until-done` / wait-for-state** (§1, §2, §14)  
    -2. **Structured JSON on every command** (§13)  
    -3. **Auto-add exchange + ToS on accept-uri** (§4, §8)  
    -4. **Canonical URIs without :443** (§5)  
    -5. **Emit reserve_pub for last withdraw** (§3)  
    -
    ----
    -
    -## How we work around today (monitoring)
    -
    -| Issue | Monitoring behaviour |
    -|-------|----------------------|
    -| run-until-done | Disabled; balance + bank poll |
    -| serve | Optional long-lived socket in e2e |
    -| exchange | Explicit add/update/accept-tos |
    -| ladder explorer | `read_secret` / EXP_PW_FILE |
    -| stage maxima | bank `max_wire` + keys min denom |
    -| force-select | multi-rpub try; soft-skip confirm |
    -
    ----
    -
    -## How to add an issue
    -
    -```markdown
    -### N. short title
    -| | |
    -|--|--|
    -| **Seen** | where / which stack |
    -| **Area** | cli / core / bank / ops / doc |
    -| **Problem** | … |
    -| **Workaround** | … |
    -| **Wanted** | concrete CLI/core behaviour |
    -```
    diff --git a/scripts/taler-monitoring/DEPENDENCIES.md b/scripts/taler-monitoring/DEPENDENCIES.md
    deleted file mode 100644
    index 3e7db4c..0000000
    --- a/scripts/taler-monitoring/DEPENDENCIES.md
    +++ /dev/null
    @@ -1,70 +0,0 @@
    -# taler-monitoring — host package dependencies
    -
    -## koopa (openSUSE Tumbleweed, user `hernani`)
    -
    -### Required (host-agent / GOA primary: `urls inside versions`)
    -
    -| Binary | Package (zypper) | Role |
    -|--------|------------------|------|
    -| `bash` | (base) | runner |
    -| `curl` | `curl` | HTTPS/API probes |
    -| `python3` | `python313-base` (provides `/usr/bin/python3`) | JSON, HTML site-gen, metrics |
    -| `podman` | `podman` | **inside**: `podman exec` into taler-hacktivism* |
    -| `git` | `git` | pin commit SHA in monitoring HTML footer |
    -| `rsync` | `rsync` | optional: deploy/sync suite |
    -| `timeout` / `mktemp` / `getent` | coreutils / glibc | helpers |
    -
    -**Status on koopa (2026-07-18):** all of the above present; rootless podman owns GOA containers.
    -
    -### Recommended (full `urls` QR group)
    -
    -| Binary | Package | Role |
    -|--------|---------|------|
    -| `qrencode` | **`qrencode`** | encode taler:// / payto:// → PNG |
    -| `zbarimg` | **`zbar`** | decode PNG; exact payload match |
    -
    -Without these, QR checks **WARN** and skip encode/decode (`apt install` messages in code are Debian-oriented; on Tumbleweed use zypper).
    -
    -```bash
    -# as root on koopa
    -zypper refresh
    -zypper in qrencode zbar
    -# verify as hernani:
    -command -v qrencode zbarimg
    -```
    -
    -### Optional (not needed for host-agent default)
    -
    -| Binary / tool | When |
    -|---------------|------|
    -| `jq` | nice-to-have; suite uses python3 for JSON |
    -| `dig` / `bind-utils` | debug DNS; not required for default phases |
    -| `ssh` | only for access mode **ssh** (laptop → `KOOPA_SSH` / stage `INSIDE_SSH`); not needed for **host-podman** |
    -| `node` / `pnpm` + wallet-cli | **e2e** / ladder only (+ secrets) |
    -| `secrets.env` / admin passwords | e2e only — never system packages |
    -
    -### systemd user (no extra packages)
    -
    -- `systemd` user session + **`loginctl enable-linger hernani`** (already **Linger=yes** on koopa)
    -- Units: `scripts/taler-monitoring/host-agent/*.service|path|timer`
    -
    -### Caddy HTML publish (separate from monitoring run)
    -
    -- Host Caddy already installed (root service).
    -- Writing `/var/www/monitoring-sites`: either root `rsync` or directory owned/writable by `hernani` (you set this up).
    -
    ----
    -
    -## firecuda-external (macOS, outside-only timer)
    -
    -| Need | Notes |
    -|------|--------|
    -| `bash`, `curl`, `python3`, `rsync`, `git` (optional) | usually Homebrew / Xcode CLT |
    -| **No** `podman` | phases are `urls versions` only (`SKIP_SSH=1`) |
    -| `qrencode` / `zbarimg` | optional for QR; `brew install qrencode zbar` if desired |
    -
    ----
    -
    -## Franc Paysan hosts (francpaysan / stagepaysan)
    -
    -Document package lists under **`francpaysan-admin-log`** when those host-agents are installed (same idea: curl, python3, podman if inside, qrencode/zbar for full urls).
    diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md
    deleted file mode 100644
    index 4ad80b3..0000000
    --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md
    +++ /dev/null
    @@ -1,198 +0,0 @@
    -# Git / automation notes (taler-monitoring + mobile)
    -
    -Notes for **automated checks** around the Taler stacks (GOA, stage
    -TESTPAYSAN, …). CLI monitoring lives in this directory; **Android wallet
    -UI automation is separate tooling** and is **not** installed on the
    -default ops laptop yet.
    -
    ----
    -
    -## Git commit / push cadence (existing model)
    -
    -Observed in `koopa-admin-log` / `francpaysan-admin-log` histories, plus ops preference:
    -
    -| Rule | Practice |
    -|------|----------|
    -| **Commits** | **Prefer frequent** small commits over rare large ones |
    -| **Interval** | Soft range **7–13 minutes** while a topic is open — **not** a fixed 9‑minute metronome; denser 0–2 min bursts for tiny follow-ups are fine |
    -| **Message** | `area: short description` — e.g. `monitoring: …`, `landings: …`, `docs: …` |
    -| **Push** | **Only at the end of a work session** (`CADENCE_PUSH=1` or manual `git push`) — not after every commit |
    -
    -### Agent / human workflow
    -
    -1. Finish a small slice → commit (`area: …`). Repeat often.  
    -2. Do **not** push after each commit.  
    -3. When the topic/session is done → one `git push` (or `CADENCE_PUSH=1`).  
    -4. Soft next-commit window: about **7–13 min** (jitter), sooner if the next slice is ready.
    -
    -### Helper script
    -
    -```bash
    -# dry-run
    -CADENCE_DRY=1 ./scripts/git-cadence-commit-push.sh
    -
    -# commit only if dirty (default: no push; skips secrets)
    -./scripts/git-cadence-commit-push.sh
    -./scripts/git-cadence-commit-push.sh ~/taler/src/francpaysan-admin-log
    -
    -# end of session — push commits that are already local
    -CADENCE_PUSH=1 ./scripts/git-cadence-commit-push.sh
    -
    -# soft timer: only commit if last commit age ≥ random target in 7–13 min
    -CADENCE_JITTER=1 ./scripts/git-cadence-commit-push.sh
    -```
    -
    -Optional user cron (check every 7 min; **jitter** keeps effective gaps in **7–13 min**):
    -
    -```bash
    -*/7 * * * * CADENCE_JITTER=1 $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh >>$HOME/.cache/git-cadence-koopa.log 2>&1
    -```
    -
    -**Auth note:** `francpaysan-admin-log` (gnunet SSH) pushes OK.  
    -`koopa-admin-log` (hacktivism HTTPS) needs credentials/SSH key before end-of-session push works from this host.
    -
    -## What runs without Android
    -
    -| Phase | Needs |
    -|-------|--------|
    -| `urls` / `sanity` | public HTTPS only |
    -| `inside` / `versions` | SSH (`koopa` or `francpaysan-stage-user`) |
    -| `e2e` / `ladder` | `taler-wallet-cli` + bank/merchant secrets |
    -
    -```bash
    -./taler-monitoring.sh -d stage.lefrancpaysan.ch urls
    -./taler-monitoring.sh -d stage.lefrancpaysan.ch e2e   # after secrets.env
    -```
    -
    -Mobile app checks (manual withdraw/pay, then automation) are **out of
    -band** until the software below is present.
    -
    ----
    -
    -## Android automation software — **required** (not installed yet)
    -
    -To automate **GNU Taler Wallet** on Android (stable + master builds;
    -manual then auto withdraw; fixed + variable pay) against stage
    -TESTPAYSAN, the following must be available on the runner host.
    -
    -### Status on this host (2026-07-17)
    -
    -| Component | Status |
    -|-----------|--------|
    -| Java (OpenJDK 21) | present (`/usr/bin/java`) |
    -| `aapt` / `aapt2` | present |
    -| **`adb`** (platform-tools) | **missing** (package was removed: `android-tools-adb` rc) |
    -| **Android SDK** (`ANDROID_HOME` / `ANDROID_SDK_ROOT`) | **missing** / empty |
    -| **Emulator + AVD** | **missing** |
    -| Appium / Maestro / `uiautomator2` | **missing** |
    -| scrcpy | missing (optional) |
    -
    -### Must install before mobile automation
    -
    -1. **Android platform-tools** — at least `adb`  
    -   - Debian: `android-tools-adb` (and preferably full SDK platform-tools)
    -2. **Android SDK command-line tools** — `sdkmanager`, platforms, build-tools  
    -   - set `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) permanently
    -3. **System image + AVD** — for emulator runs (x86_64 or host-matching ABI)  
    -   - `emulator`, `avdmanager`, KVM/acceleration if available
    -4. **UI automation stack** (pick one, prefer the leaner path for wallet flows):  
    -   - **Maestro**, or  
    -   - **Appium** + **UiAutomator2** (+ Node/Python bindings as needed)
    -5. **Optional:** `scrcpy` for live view during manual debugging
    -
    -### Explicitly out of scope until freigabe
    -
    -- Installing production wallet APKs and driving real stage withdraw/pay  
    -  without a human go-ahead  
    -- Committing secrets, device serials, or APK binaries into git
    -
    -### Operator still needs to supply
    -
    -- Stable / master **APK** (or store/build URLs) for the wallet  
    -- USB debugging consent on a physical device, **or** agreement to use AVD only  
    -- Which flows are in-scope first (withdraw communal QR → shop pay, …)
    -
    -### Suggested freigabe prompt (for an agent)
    -
    -See session notes: full copy-paste block under “Android-Automatisierung
    -vollständig einrichten” — install tooling, verify `adb devices` + emulator,
    -document paths; **no** real wallet payments until approved.
    -
    ----
    -
    -## Related docs
    -
    -| Doc | Role |
    -|-----|------|
    -| [README.md](./README.md) | how to run `taler-monitoring.sh` |
    -| [TESTS.md](./TESTS.md) | grouped check IDs |
    -| [secrets.env.example](./secrets.env.example) | e2e secrets layout |
    -| `francpaysan-admin-log/TODO.md` | stage wallet mobile test backlog |
    -
    -When Android tooling is installed, append a short “installed on …”
    -section here (date, `adb version`, AVD name, `ANDROID_HOME`).
    -
    ----
    -
    -## Android pay smoke (published APK, no root preferred)
    -
    -Script: [`android-test/run-android-pay-smoke.sh`](./android-test/run-android-pay-smoke.sh)
    -
    -| Step | Detail |
    -|------|--------|
    -| APK | F-Droid **Taler Wallet 1.6.1** (`net.taler.wallet.fdroid_854.apk`) |
    -| Install | `adb install -r` (no root) |
    -| Withdraw | `taler://withdraw/…` from `…/intro/demo-withdraw.json` (GOA or stage) |
    -| Pay | stage: shop template order → `taler://pay/…`; GOA: `taler://pay-template/…/goa-shop/paivana` |
    -| Evidence | screenshots + logcat under `android-test/out/` |
    -
    -```bash
    -# device or emulator already booted
    -./android-test/run-android-pay-smoke.sh
    -STACK=goa ./android-test/run-android-pay-smoke.sh
    -STACK=stage ./android-test/run-android-pay-smoke.sh
    -```
    -
    -**User-local emulator (no apt root if SDK already under `$HOME/Android/Sdk`):**
    -
    -```bash
    -# Default = headless (no host window); guest GLES via SwiftShader.
    -# Prefer the helper — same flags smokes use:
    -./android-test/start-android-emulator.sh --wait
    -./android-test/run-android-pay-smoke.sh
    -# Or let smoke auto-start the AVD (AUTO_START_EMULATOR=1).
    -# Windowed laptop: WINDOWED=1 ./android-test/start-android-emulator.sh --wait
    -```
    -
    -**Limits (clementine 2026-07-17):** host RAM ~3.7 GiB → emulator often ANRs System UI;
    -wallet-core still receives withdraw intents (`prepareBankIntegratedWithdrawal` in logcat).
    -For reliable full UI confirm/pay use a physical phone or a host with **≥6–8 GiB** free RAM.
    -
    -### Status snapshot (2026-07-17)
    -
    -| Item | Status |
    -|------|--------|
    -| `adb` | present (`android-sdk-platform-tools`) |
    -| F-Droid APK | downloaded under `android-test/apks/` |
    -| User SDK + emulator + AVD `TalerWallet34` | installed under `$HOME/Android/Sdk` (~5 GiB) |
    -| `adb install` wallet | **Success** on emulator-5554 |
    -| Deep-link withdraw | **delivered**; wallet-core started `prepareBankIntegratedWithdrawal` for GOA |
    -| Unattended UI confirm | flaky (ANR) on this host’s RAM |
    -| Source build `assembleFdroidDebug` | **OK** (2026-07-17, `taler-android@f65a976c0d4a`, ~12 min, APK ~59 MiB) |
    -
    -### From-source smoke (same tests as F-Droid)
    -
    -```bash
    -# single ref (default: inference fix branch)
    -./android-test/run-android-build-and-smoke.sh
    -BRANCH=master ./android-test/run-android-build-and-smoke.sh
    -# self-build published stable tag (parity with F-Droid 1.6.1 source)
    -BRANCH=wallet-1.6.1 LABEL=stable-self ./android-test/run-android-build-and-smoke.sh
    -# matrix: published APK + stable self-build + master (anti–weird-build)
    -./android-test/run-android-variant-matrix.sh
    -STACK=goa ./android-test/run-android-build-and-smoke.sh
    -# already built:
    -APK_PATH=$HOME/taler/taler-android/wallet/build/outputs/apk/fdroid/debug/wallet-fdroid-debug.apk \
    -  PKG=net.taler.wallet.fdroid.debug \
    -  ./android-test/run-android-pay-smoke.sh
    -```
    diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md
    index 7d3ae2f..a0acf02 100644
    --- a/scripts/taler-monitoring/README.md
    +++ b/scripts/taler-monitoring/README.md
    @@ -1,356 +1,26 @@
    -# taler-monitoring
    +# taler-monitoring has moved
     
    -Host packages (koopa / runners): **[DEPENDENCIES.md](./DEPENDENCIES.md)**.  
    -Host-agents (GOA + FP; shared report pipeline): **[host-agent/README.md](./host-agent/README.md)**.  
    -Public HTML site-gen (same timeout/log/HTML defaults): **[site-gen/README.md](./site-gen/README.md)**.  
    -FP install notes: `francpaysan-admin-log/docs/taler-monitoring-host-agents.md`.
    +The monitoring suite is no longer part of **koopa-admin-log**.
     
    -Report for the **GOA** stack with boxed severity badges and **grouped test IDs**
    -`area.group-NN` (e.g. `www.exchange-01`, `e2e.pay-03`):
    +| | |
    +|--|--|
    +| **Repository** | https://git.hacktivism.ch/hernani/taler-monitoring |
    +| **Clone** | `git clone https://git.hacktivism.ch/hernani/taler-monitoring.git ~/src/taler-monitoring` |
    +| **Tag** | `v1.0` (initial standalone release) |
    +| **History** | `VERSIONS.md` in that repo |
     
    -| Tag | Meaning |
    -|-----|---------|
    -| `┌ OK ┐` | check passed |
    -| `┌ INFO ┐` | status / note |
    -| `┌ WARN ┐` | degraded but maybe not fatal |
    -| `┌ ERROR ┐` | component problem |
    -| `┌ BLOCKER ┐` | **withdraw/pay path cannot complete** because of this |
    -
    -IDs reset per **group** inside an area — so “too many www tests” become
    -`www.exchange-*`, `www.bank-*`, `www.landing-*`, etc.  
    -Catalog: **[TESTS.md](./TESTS.md)**.  
    -Android / tooling: **[GIT-AUTOMATION-NOTES.md](./GIT-AUTOMATION-NOTES.md)**.  
    -GUI Android notes: **[android-test/GUI-AUTOMATION-NOTES.md](./android-test/GUI-AUTOMATION-NOTES.md)**.  
    -CLI / wallet-cli recurring issues (for upstream improvements): **[CLI-AUTOMATION-NOTES.md](./CLI-AUTOMATION-NOTES.md)**.
    -
    -```text
    -┌ OK    ┐ www.exchange-01  exchange /config · HTTP 200
    -┌ BLOCKER┐ e2e.prereq-02   merchant HTTP 502
    -```
    -
    -End of each phase: totals + list of **BLOCKERS** and **ERRORS** (ids included).
    -
    -## Secrets (local GOA e2e)
    -
    -**Do not commit passwords.** Canonical trees: `~/src/koopa/koopa-admin-log` +
    -sibling `~/src/koopa/koopa-admin-secrets`.
    -
    -### Local file (recommended on laptop)
    +## Quick start
     
     ```bash
    -cd ~/src/koopa/koopa-admin-log/scripts/taler-monitoring
    -cp secrets.env.example secrets.env   # gitignored
    -$EDITOR secrets.env
    -./taler-monitoring.sh e2e
    +git clone https://git.hacktivism.ch/hernani/taler-monitoring.git ~/src/taler-monitoring
    +cd ~/src/taler-monitoring
    +./taler-monitoring.sh -d hacktivism.ch urls
    +# host-agent (koopa):
    +./host-agent/install-host-agent.sh
     ```
     
    -See **[secrets.env.example](./secrets.env.example)** for every variable that
    -may appear (admin pass, merchant token, SECRETS_ROOT, progress knobs).
    +Landing configs and Caddy site configs for GOA remain in **koopa-admin-log**
    +(`configs/`). The host-agent path unit still watches those paths when present.
     
    -### Search order
    -
    -1. Already-exported env (`E2E_BANK_ADMIN_PASS`, …)
    -2. `secrets.env` next to this suite (`MONITORING_SECRETS_ENV=` override)
    -3. `SECRETS_ROOT` / `KOOPA_ADMIN_SECRETS` file tree
    -4. Sibling `../koopa-admin-secrets/koopa/host-root`
    -5. `$HOME/src/koopa/koopa-admin-secrets/koopa/host-root`
    -6. `$HOME/.config/taler-landing/bank-admin-password.txt`
    -7. SSH koopa `/root/…` or bank container
    -
    -### Line IDs + progress
    -
    -Each check prints **global** and **grouped** ids:
    -
    -```text
    -┌ OK    ┐ #014  www.exchange-03  exchange /config · HTTP 200
    -┌ WARN  ┐ #015  www.stats-02     stats age soft  · yellow badge (not red)
    -┌  42% ┐ ████████░░░░░░░░░░░░░░░░░░░░  14/55
    -```
    -
    -- `#014` — run-wide counter (whole `./taler-monitoring.sh` invocation)  
    -- `www.exchange-03` — area.group-NN  
    -- **Colours (left badge + label):**
    -  - **OK** green · **INFO** blue/cyan · **WARN** yellow (never red)  
    -  - **ERROR** red · **BLOCKER** magenta  
    -- Progress: badge shows **percent** (not “PROG”); bar fills **left → right**;
    -  `done/total` on the right. Estimate auto-grows if short. Override:
    -  `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1`
    -- **QR (urls):** harvest `taler://` / `payto://` (+ app store `data-qr-url`) from landings and
    -  `demo-withdraw.json` / `auto-account.json` → validate form → `qrencode` PNG → `zbarimg`
    -  decode must match. Requires packages **`qrencode`** + **`zbar-tools`**.
    -
    -## Commands
    -
    -```bash
    -# Local GOA stack (may use SSH for inside/e2e)
    -./taler-monitoring.sh              # urls + inside + versions + e2e
    -./taler-monitoring.sh inside       # containers on koopa
    -./taler-monitoring.sh versions     # deb.taler.net + package versions vs trixie
    -./taler-monitoring.sh sanity
    -./taler-monitoring.sh e2e
    -
    -# FrancPaysan STAGE (TESTPAYSAN) — like hacktivism breadth:
    -#   default phases: urls + inside + versions + e2e
    -./taler-monitoring.sh -d stage.lefrancpaysan.ch
    -./taler-monitoring.sh -d stage.lefrancpaysan.ch inside   # stagepaysan only
    -# inside: low-priv SSH francpaysan-stage-user (User stagepaysan) → podman
    -#   containers stage-lfp-{bank,exchange-ansible,merchant} · ports 9032/9031/9030
    -# stats: outside-in public …/intro/stats.json (age via generated_at_unix)
    -# Admin credit for e2e: E2E_BANK_ADMIN_PASS=… or francpaysan-host secrets
    -#   (bank-admin-password.txt under /mnt/data/stagepaysan/bank/secrets/)
    -# Override: INSIDE_SSH=… INSIDE_PROFILE=stage-lfp
    -
    -# Other domains — public HTTPS only (pass e2e/inside explicitly)
    -./taler-monitoring.sh -d taler.net
    -./taler-monitoring.sh --domain taler-ops.ch
    -./taler-monitoring.sh -d my.taler-ops.ch urls
    -./taler-monitoring.sh -d demo.taler.net urls
    -./taler-monitoring.sh taler.net    # bare domain = same as -d
    -
    -# Explicit endpoints (any mix; override profile or skip -d)
    -./taler-monitoring.sh --bank https://bank.demo.taler.net \
    -  --exchange https://exchange.demo.taler.net \
    -  --merchant https://backend.demo.taler.net --currency KUDOS urls
    -
    -# Landing stats.json freshness (outside-in; age via generated_at_unix)
    -STATS_STALE_SECS=900 STATS_FAIL_SECS=3600 ./taler-monitoring.sh -d stage.lefrancpaysan.ch urls
    -
    -# --- Full load (GOA / hacktivism) ---
    -# all  = urls + inside + versions + sanity + e2e
    -# full = all + server + ladder + auth401  (long; needs secrets.env)
    -./taler-monitoring.sh -d hacktivism.ch full
    -./taler-monitoring.sh -d hacktivism.ch urls inside versions sanity server e2e ladder auth401
    -
    -# SPA pin after selfbuild-webui.sh (container):
    -EXPECT_WEBUI_VERSION=1.6.11 EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \
    -  WEBUI_OVERLAY_DENY='c778af04|master-11340' \
    -  ./taler-monitoring.sh -d hacktivism.ch urls
    -
    -# Only 401 matrix (may create throwaway instance):
    -./taler-monitoring.sh -d hacktivism.ch auth401
    -
    -# Stage FrancPaysan — durable mon401 account (password in francpaysan-secrets):
    -./taler-monitoring.sh -d stage.monnaie.lefrancpaysan.ch auth401
    -# secrets: ~/francpaysan-secrets/stage/instances/mon401.password
    -#           ~/francpaysan-secrets/stage/default-instance-token.txt
    -# force throwaway create instead: AUTH401_PREFER_DURABLE=0 AUTH401_CREATE=1 …
    -```
    -
    -### Landing `stats.json` (www.stats)
    -
    -Public `GET …/intro/stats.json` must expose timestamps so monitoring can tell
    -whether the collector is still updating:
    -
    -| Field | Role |
    -|-------|------|
    -| `generated_at_unix` | epoch seconds — **preferred** for age = now − unix |
    -| `generated_at` | ISO-8601 (fallback if unix missing) |
    -| `generated_at_human` | UI “updated” / footer string |
    -
    -Also checks display payloads (bank-shape `withdraws` / accounts, or exchange-db /
    -merchant-db counters). Soft WARN if age > `STATS_STALE_SECS` (default 900);
    -ERROR if age > `STATS_FAIL_SECS` (default 3600; set `0` to disable).
    -Stage shared feed: same `gen_unix` on bank + exchange + monnaies.
    -
    -## Domain profiles (`domains.conf`)
    -
    -Each monitored stack must say **what is bank, exchange, and merchant-backend**.
    -
    -Edit **[domains.conf](./domains.conf)** (or set `TALER_DOMAINS_CONF=`):
    -
    -```text
    -# name   bank   exchange   merchant-backend   currency   local   [canonical]
    -taler-ops.ch  bank.taler-ops.ch  exchange.taler-ops.ch  my.taler-ops.ch  CHF  0
    -```
    -
    -| Field | Meaning |
    -|-------|---------|
    -| **name** | `-d NAME` / `TALER_DOMAIN` match (add alias lines as needed) |
    -| **bank** | libeufin/bank public host or `https://…` |
    -| **exchange** | exchange public host or URL |
    -| **merchant** | merchant **backend** (SPA/API), e.g. `my.taler-ops.ch` |
    -| **currency** | `GOA` / `KUDOS` / `CHF` / … |
    -| **local** | `1` = koopa SSH stack; `0` = public only |
    -| **landing** | `1` = check `/intro` landings + assets; `0` = skip (TOPS / mytops) |
    -| **canonical** | optional label after alias (e.g. `my.taler-ops.ch` → `taler-ops.ch`) |
    -
    -Then:
    -
    -```bash
    -./taler-monitoring.sh -d taler-ops.ch urls
    -```
    -
    -Overrides (always win after profile load):
    -
    -```bash
    -./taler-monitoring.sh -d taler-ops.ch \
    -  --merchant https://my.taler-ops.ch \
    -  --exchange https://exchange.taler-ops.ch \
    -  --bank https://bank.taler-ops.ch urls
    -```
    -
    -Helpers in `lib.sh`: `set_taler_stack`, `load_domain_profile`, `apply_taler_domain`.
    -
    -### Built-in profiles (see `domains.conf`)
    -
    -| Domain / alias | Bank | Exchange | Merchant | Currency |
    -|----------------|------|----------|----------|----------|
    -| `hacktivism.ch` / `koopa` | bank.hacktivism.ch | exchange.hacktivism.ch | taler.hacktivism.ch | GOA |
    -| `taler.net` / `demo.taler.net` | bank.demo.taler.net | exchange.demo.taler.net | backend.demo.taler.net | KUDOS |
    -| `test.taler.net` | bank.test… | exchange.test… | backend.test… | **TESTKUDOS** · no landings |
    -| `lefrancpaysan.ch` | bank.… | exchange.… | **monnaie.…** (merchant) | report-only · no landings |
    -| **`stage.lefrancpaysan.ch`** | stage.bank.… | stage.exchange.… | **stage.monnaie.…** | **TESTPAYSAN** · landings · **inside via stagepaysan** |
    -| `taler-ops.ch` / `my.taler-ops.ch` | bank.taler-ops.ch | exchange.taler-ops.ch | **my.taler-ops.ch** | CHF · **no landings** |
    -| `stage.taler-ops.ch` | bank.stage… | exchange.stage… | **my.stage…** | CHF · **no landings** |
    -| unknown | bank.DOMAIN | exchange.DOMAIN | my.DOMAIN then probe | any · no landings |
    -
    -**Landings:** GOA, stage LFP (`TESTPAYSAN`), and optionally demo use public `/intro` pages. **taler-ops.ch does not** — `CHECK_LANDING=0` skips intro crawl.
    -
    -**SSH:**
    -| Stack | Host alias | User | Scope |
    -|-------|------------|------|--------|
    -| koopa / hacktivism | `koopa` | hernani | full host + containers |
    -| stage LFP | `francpaysan-stage-user` | **stagepaysan** | own podman only (no sudo / no prod data) |
    -
    -Other domains: never SSH. Optional **e2e** aborts cleanly on login/KYC.
    -
    -### E2E amounts (variable)
    -
    -| | Local (koopa) | Remote |
    -|--|---------------|--------|
    -| **ATM withdraw** | 20 · 50 · 100 · 200 · **4200** (paivana) | 10 · 20 · 50 |
    -| **Pay ladder** | 0.01 … 10 | 0.01 … 1 |
    -| **Paivana** | HTTP paywall + template pay **GOA:4200** (`goa-shop` / `paivana`) | skipped |
    -
    -```bash
    -./taler-monitoring.sh e2e
    -./taler-monitoring.sh -d taler.net urls e2e
    -# Amount ladder (withdraw 0 → … → max, then pay): same script for GOA + stage
    -./taler-monitoring.sh ladder                              # GOA / current domain
    -./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder    # TESTPAYSAN maxima
    -# Stage auto-reads bank max_wire_transfer_amount (e.g. 2000) and min denom (0.01).
    -# Explorer password: francpaysan-stage-user …/bank-explorer-password.txt (or EXP_PW=).
    -# Override: LADDER_MAX_AMOUNT=100 LADDER_STEPS=7 LADDER_PAY=0 …
    -# customize e2e:
    -E2E_WITHDRAW_VALUES="20 50 100" E2E_PAY_VALUES="0.05 1 5" ./taler-monitoring.sh e2e
    -E2E_VARIABLE=0 WITHDRAW_AMT=GOA:50 PAY_AMT=GOA:1 ./taler-monitoring.sh e2e   # single fixed
    -# GOA shop catalog (local hacktivism): full list in E2E_SHOP_PRODUCTS; each run
    -#   shuffles and pays E2E_SHOP_PICK_N products (default 2).
    -#   E2E_SHOP_PRODUCTS lines: id|Product name|GOA:amount
    -#   E2E_SHOP_PICK_N=2
    -#   (landing QR = taler://pay-template/…/{id}; popup = live taler://pay after POST templates/{id})
    -# Paivana paywall (local GOA only):
    -#   E2E_PAIVANA=1 (default)  PAIVANA_PUBLIC=https://paivana.hacktivism.ch
    -#   E2E_PAIVANA_TEMPLATE=paivana  E2E_PAIVANA_AMOUNT=GOA:4200  E2E_PAIVANA_INSTANCE=goa-shop
    -#   E2E_PAIVANA=0            # skip
    -# remote secrets:
    -#   E2E_BANK_ADMIN_PASS=…  E2E_MERCHANT_TOKEN=…
    -```
    -
    -## Phases
    -
    -| Phase | What |
    -|-------|------|
    -| **inside** | SSH koopa: processes, postgres, local /config,/keys, wirewatch, recent log ERRORs |
    -| **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)
    -TALER_APT_SUITE=trixie ./taler-monitoring.sh versions
    -TALER_PKG_BEHIND=error ./taler-monitoring.sh versions   # any behind = ERROR
    -```
    -
    -E2E maps failures to blockers, e.g.:
    -
    -- `bank-confirm HTTP 409` → wallet did not select exchange  
    -- `no GOA balance` → wirewatch / transfer  
    -- `create order failed` → merchant auth/instance  
    -- `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
    -```
    -
    -## 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`.
    -
    -### Final statistics dashboard (e2e · ladder)
    -
    -At the end of **e2e** and **ladder**, a boxed **FINAL STATISTICS** block prints:
    -
    -| Section | Content |
    -|---------|---------|
    -| **Coins** | in circulation / spent counts + amounts + denoms (alt names) |
    -| **Money flow** | withdrawn events/amount, spent (paid), net (wd − spent) |
    -| **Tendency** | coin/amount trend over snaps (↑↓→), slope, last history rows |
    -| **Performance** | min / p50 / avg / max per phase; first-half vs second-half latency |
    -| **Load** | host loadavg + mem before/after; per-container RSS/CPU; tendencies |
    -
    -Successful withdraw/pay steps append to `flow-withdrawn.txt` / `flow-spent.txt` (ladder also uses its TSVs).
    -
    -## 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` or `koopa-external` (inside / load / sanity server bits)
    -- secrets under `koopa-admin-secrets/...` for e2e
    -- `taler-wallet-cli` for e2e
    +Do **not** add monitoring scripts back into this directory — develop and push
    +against **taler-monitoring** only.
    diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md
    deleted file mode 100644
    index 7b667f4..0000000
    --- a/scripts/taler-monitoring/TESTS.md
    +++ /dev/null
    @@ -1,193 +0,0 @@
    -# taler-monitoring — grouped test IDs
    -
    -Every check line has a **global** run number and a **grouped** id:
    -
    -```text
    -┌ OK    ┐ #001  www.exchange-01  exchange /config · HTTP 200
    -┌ WARN  ┐ #002  www.stats-02     soft age  · yellow (never red)
    -┌ ERROR ┐ #003  e2e.pay-03       order create failed · HTTP 502
    -┌ BLOCKER┐ #004  e2e.prereq-02   merchant secret missing
    -┌ PROG  ┐ ████░░░░  25%  4/16
    -```
    -
    -- `#NNN` — monotonic for the whole monitoring run  
    -- `area.group-NN` — logical block (resets per group, continues if re-entered)
    -
    -| Area | Phase script | Groups (examples) |
    -|------|--------------|-------------------|
    -| **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` `qr` |
    -| **inside** | `check_inside.sh` | `ssh` `bank` `exchange` `merchant` `caddy` `load` |
    -| **versions** | `check_versions.sh` | `outside` `inside` `compare` |
    -| **aptdeploy** | `check_apt_deploy.sh` | `trixie` `trixie-testing` (koopa podman apt-src merchant smoke) |
    -| **surface** | `check_surface.sh` | **remote-only** ecosystem / `-d` domain inventory (not in default/all/full) |
    -| **sanity** | `check_sanity.sh` | `bank` `exchange` `merchant` |
    -| **server** | `check_server.sh` | (flat `server-NN` or host groups) |
    -| **e2e** | `check_e2e.sh` | `prereq` `load` `bank` `wallet` `atm` `settle` `pay` `shop` `paivana` `dig` `report` |
    -| **ladder** | `check_goa_ladder.sh` | `plan` `load` `withdraw` `pay` `report` |
    -
    -**Why groups:** flat `www-001`…`www-080` was hard to talk about.  
    -`www.bank-04` / `e2e.atm-02` pin the failure to a logical block.
    -
    -Usage in scripts:
    -
    -```bash
    -set_area www
    -set_group exchange   # → www.exchange-01, www.exchange-02, …
    -set_group bank       # counter resets → www.bank-01
    -```
    -
    -`set_group` also prints a small group chip (`┌ www.exchange ┐`) so logs show section boundaries.  
    -Re-entering the same group later (e.g. exchange legal after perf) **continues** NN — `www.exchange-03`, not a second `-01`.  
    -Without `set_group`, IDs stay flat: `area-01`, `area-02`, …
    -
    -Optional soft checks still consume a number when they WARN.  
    -Numbering follows **executed** checks (early skip may shift later NN inside the same group).
    -
    ----
    -
    -## www — public URLs (`./taler-monitoring.sh urls`)
    -
    -| Group | Checks (typical) |
    -|-------|------------------|
    -| **www.exchange-** | `/config`, currency, **alt_unit_names**; `/keys` (+ alt soft); `/intro/`, `/`; **`/terms`**, **`/privacy`**, `/terms/` |
    -| **www.perf-** | outside-in RTT: bank/exchange/merchant `/config`, keys, webui, intro — ms; WARN ≥ `PERF_WARN_MS` (8000); ERROR ≥ `PERF_FAIL_MS` (20000) |
    -| **www.stats-** | `/intro/stats.json` **reachable**; **timestamps** (`generated_at_unix` + human/ISO); **freshness** vs wall clock (`STATS_STALE_SECS` WARN, `STATS_FAIL_SECS` ERROR); **display fields** (bank-shape withdraws/accounts, or exchange-db / merchant-db keys); optional performance block; shared-feed equal `gen_unix` when bank collector is published to all three |
    -| **www.bank-** | `/config`, currency, alt_unit_names; integration/webui/intro; **auto-account.json** (required GOA/local; skip/soft off-GOA e.g. TESTPAYSAN); `/terms`, `/privacy` |
    -| **www.merchant-** | `/config` currency + currencies alt_unit_names; listed exchanges alt; webui/intro; **`/terms`**, **`/privacy`** |
    -| **www.webui-** | SPA fingerprints: **`/webui/version.txt`**, **`/webui/version-overlay.txt`**, `index.html` / `index.js`; optional pin via `EXPECT_WEBUI_VERSION` / `EXPECT_WEBUI_OVERLAY` / `WEBUI_OVERLAY_DENY` |
    -| **www.paivana-** | local GOA paywall front (redirect to template) |
    -| **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; stage merchant shop: `shop-ui.js` + `shops.css` + `qrcode.min.js`; cross-links local |
    -| **www.qr-** | QR payloads: harvest `taler://` / `payto://` / app `data-qr-url` from landings + mint JSON; **form** check; **qrencode → zbarimg** exact roundtrip; optional static QR images. Needs `qrencode` + `zbar-tools`. Skip: `QR_CHECK=0` |
    -
    -**QR rule:** `taler://withdraw/HOST/taler-integration/UUID` (no default `:443`/`:80`); `taler://pay/` / `pay-template/`; `payto://…` shape OK; decoded PNG must equal payload. Form errors on withdraw/pay/payto are **ERROR** on local/GOA.
    -
    -**Legal docs rule:** HTTP 200, non-empty body, not plain `not configured`, not merchant API JSON `code:21`. Local stack may require content needle (terms/privacy/FADP/GOA…).
    -
    -**Performance rule:** Measured from the **monitoring runner** (public via Caddy), not container loopback. Latency (ms) on every perf line + **perf summary** (n / min / p50 / avg / max).
    -
    -**Landing links rule:** Own-stack must be HTTP 200 (or redirect→200). External stores/docs soft WARN. Auto-account wallet link must be `taler://withdraw/HOST/taler-integration/…` (default ports stripped), never payto.
    -
    -**alt_unit_names rule:** non-empty map including scale key `"0"`. Multi-currency merchant: follow each `exchanges[]` public `/config`.
    -
    ----
    -
    -## inside — koopa SSH (`./taler-monitoring.sh inside`)
    -
    -| Group | Checks |
    -|-------|--------|
    -| **inside.host-** | **host-podman**: reachability via `podman exec` on this machine (GOA host / `INSIDE_PODMAN=1`) — not SSH |
    -| **inside.ssh-** | **ssh**: from laptop/remote — SSH to `KOOPA_SSH` / `INSIDE_SSH`, then podman there |
    -| **inside.bank-** | container, libeufin, postgres, local `/config`, nginx, DNS pins |
    -| **inside.exchange-** | container, httpd, wirewatch, aggregator, transfer, local keys, DNS |
    -| **inside.merchant-** | container, httpd, wirewatch, depositcheck, DNS |
    -| **inside.caddy-** | host reverse-proxy process |
    -| **inside.load-** | host loadavg + RAM; per-container RSS/CPU |
    -| **inside.disk-** / **aptdeploy.disk-** / **versions.disk-** / **server.disk-** | free space via `df`: **WARN** if use ≥ `DISK_WARN_USED_PCT` (85), **ERROR** if full or use ≥ `DISK_ERR_USED_PCT` (95); host + containers |
    -
    -Remote lines `E|comp|LEVEL|key|detail` each become one numbered result under that component group.
    -
    -SSH: `KOOPA_SSH` (default `koopa`), then `KOOPA_SSH_FALLBACKS` when LAN is unreachable.
    -
    ----
    -
    -## e2e — withdraw + pay (`./taler-monitoring.sh e2e`)
    -
    -| Group | Checks |
    -|-------|--------|
    -| **e2e.prereq-** | wallet-cli, currency, budgets, secrets, public reachability |
    -| **e2e.load-** | host/container load snapshots (before / after ATM / after pay) |
    -| **e2e.bank-** | account, credit, withdraw ops |
    -| **e2e.wallet-** | exchange + ToS, wallet setup |
    -| **e2e.atm-** | ATM withdraw ladder rungs |
    -| **e2e.settle-** | wallet settlement wait / spendable balance |
    -| **e2e.pay-** | variable payments |
    -| **e2e.shop-** | GOA shop product catalog pays |
    -| **e2e.paivana-** | template paywall pay |
    -| **e2e.dig-** | coins-missing dig (inside + withdrawal-operation) |
    -| **e2e.report-** | final tallies |
    -
    -When filing an issue, quote the full id + label, e.g.  
    -`e2e.atm-03  ATM withdraw 5 GOA · bank confirmed, wallet empty`.
    -
    ----
    -
    -## versions / sanity / ladder / server / auth401
    -
    -| Area.group | Meaning |
    -|------------|---------|
    -| **versions.outside-** | deb.taler.net suite index |
    -| **versions.host-** | host-podman: packages via local `podman exec` (`INSIDE_PODMAN=1`) |
    -| **versions.ssh-** | ssh: packages via `KOOPA_SSH` / `INSIDE_SSH` from laptop/remote |
    -| **versions.compare-** | installed vs suite |
    -| **aptdeploy.trixie-fresh-** | fresh rebuild container `…-apt-src-trixie` |
    -| **aptdeploy.trixie-testing-fresh-** | fresh rebuild `…-trixie-testing` |
    -| **aptdeploy.trixie-upgrade-** | upgrade-track `…-trixie-upgrade` (install once, then mytops-style upgrade) |
    -| **aptdeploy.trixie-testing-upgrade-** | upgrade-track `…-trixie-testing-upgrade` |
    -
    -Standalone: `host-agent/run-aptdeploy.sh` (on koopa local; else `ssh koopa-external`).
    -
    -### surface (remote-only public inventory)
    -
    -```bash
    -./taler-monitoring.sh surface                      # taler.net / gnunet / taler-systems / mattermost / …
    -./taler-monitoring.sh -d hacktivism.ch surface     # all things under that domain
    -```
    -
    -- Catalog: `surface-catalog.conf` (extend as hosts are found)
    -- DNS, optional ICMP, TCP ports, then **protocol** checks (HTTPS/HTTP/SSH) before ERROR
    -- TLS cert expiry; Server-header / `/config` version hints; else **WARN: version unknown**
    -- OSV CVE query when package+version known → **ERROR** on hits
    -- Never SSH / never podman on the *targets*
    -| **sanity.bank-** / **.exchange-** / **.merchant-** | public + optional server-side per component |
    -| **ladder.plan-** / **.load-** / **.withdraw-** / **.pay-** / **.report-** | amount ladder (GOA ceiling or stage TESTPAYSAN `max_wire`) |
    -| **server-** | SSH host ports / processes (flat unless grouped later) |
    -| **auth401.*** | merchant 401 / case matrix (WebUI APIs): **signup** `POST /instances` MIX id, **login** Basic user low vs MIX/UPPER, full **case** matrix, **password** bad/empty, **pwchange** `POST private/auth` + re-login, **admin-create** `POST /management/instances` (if admin token), **bearer**, **durable mon401**, **webui** SPA `toLowerCase` probe (`check_auth401.sh`) |
    -
    -### SPA pin (after selfbuild-webui)
    -
    -```bash
    -EXPECT_WEBUI_VERSION=1.6.11 \
    -EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \
    -WEBUI_OVERLAY_DENY='c778af04|master-11340' \
    -  ./taler-monitoring.sh -d hacktivism.ch urls
    -```
    -
    -Skip SPA block: `CHECK_WEBUI_SPA=0`.
    -
    -### Full load (maximum phases)
    -
    -| Alias | Expands to (local GOA) |
    -|-------|-------------------------|
    -| **`all`** | `urls inside versions sanity e2e` |
    -| **`full`** | `urls inside versions sanity server e2e ladder auth401` |
    -
    -```bash
    -cd ~/src/koopa/koopa-admin-log/scripts/taler-monitoring
    -# secrets.env with bank admin + merchant tokens (see secrets.env.example)
    -./taler-monitoring.sh -d hacktivism.ch full
    -# same, explicit:
    -./taler-monitoring.sh -d hacktivism.ch \
    -  urls inside versions sanity server e2e ladder auth401
    -```
    -
    ----
    -
    -## Visual format (colour on)
    -
    -```text
    -╔══════════════════════════════╗
    -║ www · public URLs · …        ║
    -╚══════════════════════════════╝
    -┌ www.exchange ┐
    -┌ OK    ┐ www.exchange-01  exchange /config · HTTP 200
    -┌ www.bank ┐
    -┌ OK    ┐ www.bank-01  bank /config · HTTP 200
    -┌ ERROR ┐ www.bank-04  bank /intro/auto-account.json · invalid withdraw
    -
    -┌ ERRORS · failed checks ┐
    -  • www.bank-04 bank /intro/auto-account.json · invalid withdraw
    -totals: 40 OK, 1 ERROR, 2 WARN, 5 INFO
    -```
    -
    -`NO_COLOR=1` or `CLICOLOR=0` disables boxes/colours (ASCII `[ OK ]` + `-- group --` headers).  
    -Always use `printf --` friendly plain headers when colour is off (leading `---` is not a printf option).
    diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore
    deleted file mode 100644
    index 49b08e5..0000000
    --- a/scripts/taler-monitoring/android-test/.gitignore
    +++ /dev/null
    @@ -1,11 +0,0 @@
    -apks/*.apk
    -out/
    -*.png
    -out-source/
    -out-source-*/
    -out-gui/
    -out-source-gui/
    -out-gui-chain/
    -out-matrix-*/
    -out-stack-run-*/
    -out-survey-*/
    diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md
    deleted file mode 100644
    index 18fe398..0000000
    --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md
    +++ /dev/null
    @@ -1,362 +0,0 @@
    -# GUI automation notes (Android wallet)
    -
    -Canonical notes for **graphical** Android wallet tests against GOA / stage.
    -
    ----
    -
    -## Minimal variant (implemented first) — status 2026-07
    -
    -This is the **minimal vanilla** path we actually shipped; full dual-platform
    -GUI is planned via flags (below), not required to use these scripts today.
    -
    -| Piece | Status | Notes |
    -|-------|--------|--------|
    -| F-Droid published APK install | **done** | `net.taler.wallet.fdroid` 1.6.1 / 854 |
    -| Source build + same smoke | **done** | `:wallet:assembleFdroidDebug` → `net.taler.wallet.fdroid.debug` |
    -| Default source branch | **done** | `dev/hernani-inference/fix-bank-withdraw-auto-exchange` (minimal GOA exchange auto-add) |
    -| Deep-link entry (≡ QR) | **done** | `adb` `VIEW` **withdraw then pay** (`taler://withdraw/…` → `taler://pay…`) |
    -| Explorer API mint (chain) | **done** | `run-goa-gui-chain.sh` (GOA + stage) |
    -| UI taps via uiautomator | **done** | best-effort Confirm/ToS/Pay; ANR → prefer **Wait** |
    -| Screenshots / logcat artifacts | **done** | under `out*`, `out-gui-chain/` (gitignored) |
    -| Host CLI e2e settlement | **separate** | `taler-monitoring.sh e2e` / `ladder` (not Android UI) |
    -| Reliable unattended Confirm on 4 GiB Linux host | **limited** | System UI ANR; use phone or more RAM |
    -| iOS GUI | **not in this tree** | see taler-ios `dev/hernani-inference/gui-workflows` |
    -| macOS full dual AVD helpers | **upstream** | taler-android `gui-workflows` (Homebrew); ported ideas here for Linux |
    -
    -**Ground rule:** bring it to run; app diffs only if needed and **minimal**; prefer
    -extending existing `dev/hernani-inference/*` branches over new trees.
    -
    -### Minimalvariante: was geht / was nicht
    -
    -Einschränkungen der **aktuell implementierten** Minimalvariante (deep-link +
    -uiautomator best-effort). Kein voller Dual-Platform-GUI-Stack, kein Ersatz für
    -Host-CLI-Settlement.
    -
    -#### Geht (grün)
    -
    -| Fähigkeit | Wie / Hinweis |
    -|-----------|----------------|
    -| APK installieren | F-Droid `net.taler.wallet.fdroid` **oder** Source `assembleFdroidDebug` (`.debug`) |
    -| GOA-Exchange ohne manuelles Add | Source-Build von `fix-bank-withdraw-auto-exchange` (Default in `run-android-build-and-smoke.sh`) |
    -| **Abheben + Bezahlen** | Beide Beine: erst Withdraw-URI + Taps, dann Pay-URI + Taps (`DO_WITHDRAW`/`DO_PAY`) |
    -| Light UI-Drive | Confirm / Accept / Pay-Taps über Label-Suche (`lib_ui.py`, gui-smoke, chain) |
    -| Multi-Runden-Taps | `run-android-gui-smoke.sh` / `run-goa-gui-chain.sh` (best-effort) |
    -| Explorer-Mint (Kette) | `run-goa-gui-chain.sh` + `EXP_PW_FILE` (GOA + stage) |
    -| Stack-Wahl | `STACK=goa` / `stage` (Bank/Merchant-URLs) |
    -| Artefakte | Screenshots, UI-XML, logcat unter `out*` / `out-gui-chain/` (gitignored) |
    -| Hybrid-Smoke | Install + Intent + leichte Taps: `run-android-pay-smoke.sh` |
    -| Settlement **nachweisen** | **Host-seitig**: `taler-monitoring.sh e2e` / `ladder` (nicht Android-UI) |
    -| Flags abschalten | `AUTO_ANDROID=0` skip; `AUTO_GUI=0` → nur deep-link/pay-smoke |
    -
    -#### Geht **nicht** oder nur eingeschränkt (rot / gelb)
    -
    -| Beschränkung | Folge / Workaround |
    -|--------------|--------------------|
    -| **Unattended Confirm → settled im Emulator (~4 GiB Host)** | Häufig **System UI ANR**; Taps bleiben hängen. Prefer **Wait** auf ANR-Dialog, echtes Gerät, oder Host mit mehr RAM. Kein zuverlässiges grünes E2E nur über GUI auf dem schwachen Emulator. |
    -| **Vollständige Wallet-Settlement-Assertion in der GUI** | Minimalvariante prüft **nicht** „Balance final / transfer_done“ in der App-Oberfläche. Proof bleibt CLI/e2e. |
    -| **Kamera / physischer QR-Scan** | Absichtlich nicht automatisiert; deep-link ist der Shortcut. Landing-QR-Pfade sind **HTTP**-Thema, nicht Android-GUI. |
    -| **Browser-Shop-Checkout-UI** | Kein Chromium/WebView-Drive; Pay-URI kommt per Template-`POST` + Intent. |
    -| **Volles ToS-Scroll / alle Dialoge** | Best-effort Accept-Tap; lange ToS oder unerwartete Sheets können hängen bleiben. |
    -| **Published F-Droid allein auf GOA** | Kann Exchange-Add / Spinner-Probleme zeigen → **fix-branch-APK** bauen; bei Spinner-Hang optional `fix-withdraw-spinner-fallback`. |
    -| **iOS-GUI** | Nicht in diesem Tree; siehe taler-ios `gui-workflows`. |
    -| **macOS dual AVD / Homebrew-Helpers** | Upstream `gui-workflows`; hier nur Linux-taugliche Port-Ideen. |
    -| **`AUTO_*` in jedem Script erzwungen** | Design + teilweise verdrahtet; Wrapper-Checkliste noch offen. |
    -| **`run-until-done` / CLI-Wallet in GUI-Skripten** | Gehört zu CLI-Automation, nicht GUI; siehe `CLI-AUTOMATION-NOTES.md`. |
    -| **CI grün = „User hat bezahlt“** | Smoke = Intent + best-effort UI. Wirtschaftlicher Erfolg = Host-e2e / ladder / Explorer. |
    -
    -#### Erwartetes Ergebnis pro Schicht
    -
    -| Schicht | Erfolgskriterium Minimalvariante |
    -|---------|----------------------------------|
    -| A Install | APK installed, App startet |
    -| B Deep-link | Intent delivered, Wallet öffnet Withdraw/Pay-Flow |
    -| C GUI taps | Dump+Tap ohne Crash; ANR ggf. mit Wait; **kein** Garant für „Paid“ |
    -| D Evidence | Artefakte geschrieben |
    -| Settlement | **Außerhalb** GUI: Host e2e/ladder |
    -
    -#### Wann Minimalvariante reicht
    -
    -- Smoke nach APK-/Branch-Build (Intent kommt an, App crasht nicht).
    -- Manuelle Nacharbeit am Gerät nach vorbereiteten URIs.
    -- GOA/stage **Erreichbarkeit** von Landing/Template + Wallet-Einstieg.
    -
    -#### Wann nicht reicht → nächster Schritt
    -
    -- Unattended grünes Pay-E2E auf dem Emulator → mehr RAM / physisches Gerät, ggf. `fix-withdraw-spinner-fallback`.
    -- Beweis „Münzen da / Merchant paid“ → CLI e2e/ladder, nicht GUI-smoke.
    -- iOS / Dual-Platform → andere Repos/Flags, nicht diese Skripte.
    -
    -```bash
    -cd scripts/taler-monitoring/android-test
    -
    -# Minimal hybrid (install + deep-link + light taps)
    -STACK=goa ./run-android-pay-smoke.sh
    -
    -# GUI multi-round taps
    -STACK=goa ./run-android-gui-smoke.sh
    -STACK=stage ./run-android-gui-smoke.sh
    -
    -# Explorer mint + multi withdraw/pay chain (from gui-workflows)
    -EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \
    -  STACK=goa PKG=net.taler.wallet.fdroid.debug \
    -  ./run-goa-gui-chain.sh
    -
    -# Build fix-branch APK then smoke (GUI=1 for gui smoke)
    -./run-android-build-and-smoke.sh
    -GUI=1 ./run-android-build-and-smoke.sh
    -```
    -
    ----
    -
    -## Platform capability flags (planned + defaults)
    -
    -Automation can run on hosts that support **Android only**, **GUI tooling only**
    -(conceptual), or **both**. Flags keep that explicit for CI and laptops.
    -
    -### Proposed env flags
    -
    -| Variable | Values | Meaning |
    -|----------|--------|---------|
    -| `AUTO_ANDROID` | `0` / `1` | Run Android wallet automation (adb, APK, emulator/device) |
    -| `AUTO_GUI` | `0` / `1` | Drive **graphical** UI (uiautomator taps, multi-round). If `0`, deep-link/CLI-only install smoke. |
    -| `AUTO_PLATFORM` | `auto` / `linux` / `macos` / `ios` | Host family (optional override) |
    -| `EMULATOR_HEADLESS` | **`1` (default)** / `0` | No host window (`-no-window`). Server/CI-safe. |
    -| `WINDOWED` | `0` / `1` | Shortcut: `WINDOWED=1` ⇒ show emulator window (`EMULATOR_HEADLESS=0`) |
    -| `EMULATOR_GPU` | default **`swiftshader_indirect`** | Guest GLES still runs headless (layout/taps/screenshots). Override `host` only if windowed + real GPU. |
    -| `AUTO_START_EMULATOR` | **`1` (default)** / `0` | If no adb device, start AVD via `start-android-emulator.sh --wait` |
    -
    ----
    -
    -## Headless mode (default)
    -
    -**Default for all GUI / pay smokes:** no window on the host. Opening an emulator
    -GUI on servers (or headless CI) is wrong; these scripts therefore start the AVD
    -with **`-no-window`** and still keep **graphics logic** in the guest:
    -
    -| Piece | Headless default |
    -|-------|------------------|
    -| Host window | **off** (`-no-window`, `QT_QPA_PLATFORM=offscreen`, no `DISPLAY`) |
    -| Guest GLES | **on** via **SwiftShader** (`-gpu swiftshader_indirect`) |
    -| UI automation | **on** — `uiautomator` dump/tap + `screencap` (no X11 needed) |
    -| Audio / boot anim | off (`-no-audio -no-boot-anim`) |
    -
    -```bash
    -# explicit (same as default)
    -./start-android-emulator.sh --wait
    -
    -# or let smoke auto-start headless AVD when no phone is plugged in
    -STACK=goa ./run-android-gui-smoke.sh
    -
    -# laptop: show the emulator window
    -WINDOWED=1 ./start-android-emulator.sh --wait
    -# or: EMULATOR_HEADLESS=0 EMULATOR_GPU=host ./start-android-emulator.sh --wait
    -```
    -
    -Shared code: `lib_android_env.sh` + `start-android-emulator.sh`.
    -
    -**Semantics:**
    -
    -| `AUTO_ANDROID` | `AUTO_GUI` | Behaviour |
    -|----------------|------------|-----------|
    -| `1` | `0` | Android install + deep-link smoke only (no multi-round taps) |
    -| `1` | `1` | Android + graphical drive (vanilla GUI / chain) |
    -| `0` | `1` | Reserved (e.g. future desktop/web GUI); currently **no-op** with a clear message |
    -| `0` | `0` | Skip mobile automation |
    -
    -### Defaults by host (when flags unset)
    -
    -| Host (`uname -s`) | Default `AUTO_ANDROID` | Default `AUTO_GUI` | Rationale |
    -|-------------------|------------------------|--------------------|-----------|
    -| **Linux** | **`1`** | **`1`** if device/emulator present, else scripts exit 3 | This repo’s day-to-day path; **Android only** (no iOS here) |
    -| **Darwin (macOS)** | `1` | `1` | Can run Android emulators **and** (separately) taler-ios GUI helpers; both flags on for Android scripts; iOS is out of tree |
    -| **Other** | `0` | `0` | Fail closed |
    -
    -**Linux default = Android** (no second mobile platform in this suite).  
    -**macOS** may enable both ecosystems in the wider monorepo sense; for *these*
    -scripts only Android is implemented — set `AUTO_ANDROID=1` (default) and keep
    -iOS under `taler-ios`.
    -
    -### Resolution helper (convention for future wrappers)
    -
    -```bash
    -# Example for a future run-all-mobile.sh
    -os=$(uname -s)
    -: "${AUTO_PLATFORM:=auto}"
    -case "$AUTO_PLATFORM" in
    -  auto) case "$os" in Linux) AUTO_PLATFORM=linux ;; Darwin) AUTO_PLATFORM=macos ;; *) AUTO_PLATFORM=other ;; esac ;;
    -esac
    -case "$AUTO_PLATFORM" in
    -  linux)
    -    : "${AUTO_ANDROID:=1}"
    -    : "${AUTO_GUI:=1}"
    -    # no iOS
    -    ;;
    -  macos)
    -    : "${AUTO_ANDROID:=1}"
    -    : "${AUTO_GUI:=1}"
    -    # optional later: AUTO_IOS=1 for taler-ios scripts
    -    ;;
    -  *)
    -    : "${AUTO_ANDROID:=0}"
    -    : "${AUTO_GUI:=0}"
    -    ;;
    -esac
    -```
    -
    -Scripts **today** implement Android only; they should honour:
    -
    -- `AUTO_ANDROID=0` → exit 0 with “skipped (AUTO_ANDROID=0)”
    -- `AUTO_GUI=0` → call deep-link smoke without multi-round GUI (or set `GUI_ROUNDS=0`)
    -
    ----
    -
    -## Vanilla layers (detail)
    -
    -| Layer | What | Tooling |
    -|-------|------|---------|
    -| **A — Install** | F-Droid or from-source APK | `adb install` |
    -| **B — Entry (shortcut)** | `taler://withdraw/…` / `taler://pay…` | `adb am start -a VIEW` |
    -| **C — Graphical UI** | Confirm / ToS / Pay taps; ANR Wait | `uiautomator` + `lib_ui.py` / chain |
    -| **D — Evidence** | Screenshots, XML, logcat, URIs | `out*/` |
    -
    -### Legitimate shortcuts
    -
    -| Shortcut | Replaces |
    -|----------|----------|
    -| Deep-link withdraw/pay | Camera QR / opening paywall |
    -| Host `POST` template → pay URI | Browser shop checkout UI |
    -| Label-list taps | Human reading button text |
    -| Skip full ToS scroll | Tap Accept if shown |
    -
    -### Scripts
    -
    -| Script | Role |
    -|--------|------|
    -| `run-android-pay-smoke.sh` | Hybrid: install + deep-link + light taps |
    -| `run-android-gui-smoke.sh` | Multi-round GUI drive |
    -| `run-goa-gui-chain.sh` | Multi mint/withdraw/pay (gui-workflows port) |
    -| `run-android-build-and-smoke.sh` | Build git ref (branch/tag) + smoke (`GUI=1` optional) |
    -| `run-android-variant-matrix.sh` | **published + stable-self + master** (compare builds) |
    -| `start-android-emulator.sh` | Start AVD (**headless default**, SwiftShader) |
    -| `lib_android_env.sh` | Flags, PATH, `android_ensure_device` |
    -| `lib_ui.py` | Dump/tap/ANR helpers |
    -
    -### Build variants (anti–“weird local build”)
    -
    -| Variant | Source | Severity | Purpose |
    -|---------|--------|----------|---------|
    -| **stable-self** | **only** rebuild of current stable tag | WARN | Self-build vs published for **one** release |
    -| **published** | F-Droid current APK | **≤14d → BLOCKER**, else WARN | What users install |
    -| **older / milestones** | F-Droid APK ~**3 / 6 / 9 / 12 mo** | **always WARN** | No self-build of old tags |
    -| **master** | `origin/master` rebuild | WARN | Tip — never hard-blocks |
    -| **fix** (optional) | inference branch | soft | GOA automation fixes |
    -
    -**Self-build:** current stable only (plus master/fix if requested) — not older releases.  
    -**Blocker window:** releases ≤ 14 days.  
    -**Milestones:** nearest tags to 90/180/270/365d with a still-hosted F-Droid APK.
    -
    ----
    -
    -## Stacks under test
    -
    -| Stack | Withdraw | Pay |
    -|-------|----------|-----|
    -| **GOA** | explorer mint / `demo-withdraw.json` @ bank.hacktivism.ch | goa-shop templates / Paivana pay-template |
    -| **stage** | explorer / demo-withdraw @ stage.bank… | fermes / jardin public templates |
    -
    -### Lageübersicht GOA + stage (as-is survey · 2026-07-17 ~22:05–22:18)
    -
    -**Device:** headless `emulator-5554` · host ~3.7 GiB RAM (often <300 MiB free) · timeout per leg ≤420 s.  
    -**Legs:** Abheben + Bezahlen (`DO_WITHDRAW=1 DO_PAY=1`). Artefakte: `out-survey-20260717-220527/` (gitignored).
    -
    -#### Varianten
    -
    -| Variante | Quelle | Bemerkung |
    -|----------|--------|-----------|
    -| **stable (published)** | F-Droid `net.taler.wallet.fdroid` **1.6.1 / 854** | as-is |
    -| **master as-is** | rebuild `origin/master` @ `f65a976c` → `.fdroid.debug` | as-is, no patches |
    -| **ältere Releases** (3/6/9/12 mon) | F-Droid APKs für `1.5.0`/`1.3.0`/`1.1.0`/`1.0.8` | **nicht auffindbar** (HTTP miss) → **SKIP** (kein Selbstbau älterer Tags) |
    -| **master + inference** (extra) | local branch `local/master-with-inference` @ `3ebe8b09` | merges `fix-bank-withdraw-auto-exchange` + `fix-withdraw-spinner-fallback` · **not pushed** |
    -
    -#### Ergebnis-Matrix
    -
    -| Variant | Stack | Install / deep-link | Withdraw URI | Pay URI | wallet-core Abheben | wallet-core Pay | ANR | Exit |
    -|---------|-------|---------------------|--------------|---------|---------------------|-----------------|-----|------|
    -| stable published | **goa** (hacktivism) | OK | OK bank.hacktivism.ch | OK paivana template | **NO** | soft/YES once* | **YES** | **10** |
    -| stable published | **stage** (*.lefrancpaysan) | OK | OK stage.bank… | OK fermes order | **NO** | **NO** | **YES** | **10** |
    -| master as-is | **goa** | OK | OK | OK paivana | **NO** | **NO** | **YES** | **10** |
    -| master as-is | **stage** | OK | OK | OK fermes | **NO** | **NO** | **YES** | **10** |
    -| master+inference | **goa** | OK | OK | OK | **NO** | **NO** | **YES** | **10** |
    -| master+inference | **stage** | OK | OK | OK | **NO** | **NO** | **YES** | **10** |
    -
    -\*stable-goa console once reported pay wallet-core YES mid-run; final score still ANR-dominated / not reliable settlement.
    -
    -#### Was pro Stack **steht** (Infra)
    -
    -| Check | **goa / hacktivism** | **stage / \*.lefrancpaysan** |
    -|-------|----------------------|------------------------------|
    -| Bank `…/intro/demo-withdraw.json` → `taler://withdraw/…` | **OK** | **OK** |
    -| Pay entry | paivana `pay-template` **OK** | fermes `panier-legumes` POST → `taler://pay/…` **OK** |
    -| adb `VIEW` Intent delivery | **OK** | **OK** |
    -| Unattended Confirm/Abheben/Pay GUI | **FAIL (ANR)** | **FAIL (ANR)** |
    -| wallet-core prepare/accept withdraw in logcat | **not seen** | **not seen** |
    -
    -**Fazit as-is:** Beide Stacks liefern die **Eingänge** (Withdraw- + Pay-URIs). Die **GUI/wallet-core-Strecke** ist auf diesem Emulator für **alle** getesteten App-Varianten (stable, master, inference) **gleich tot** — System UI / Android System ANR, keine brauchbaren Taps. Das ist **kein Stack-Unterschied** GOA vs stage und **kein** belastbarer App-Versions-Vergleich.
    -
    -#### Inference-Patch-Versuch (lokal, **kein Push**)
    -
    -Weil **nichts** der as-is-Varianten wallet-core-grün war, wurde zusätzlich gebaut:
    -
    -```text
    -taler-android branch: local/master-with-inference   # NOT pushed
    -base:     origin/master
    -merged:   origin/dev/hernani-inference/fix-bank-withdraw-auto-exchange
    -          origin/dev/hernani-inference/fix-withdraw-spinner-fallback
    -commit:   3ebe8b091379
    -APK:      apks/wallet-master-inference-3ebe8b091379.apk (local)
    -```
    -
    -| Stack | Inference smoke | Besser als master/stable? |
    -|-------|-----------------|---------------------------|
    -| goa | exit 10, ANR, no withdraw core | **Nein** |
    -| stage | exit 10, ANR, no withdraw core | **Nein** |
    -
    -**Kein grünes Inference-Signal** auf diesem Host → wir können **nicht** belegen, dass die Patches Withdraw/Pay „retten“.  
    -Ebenso können wir **nicht** belegen, dass master ohne Patches „schuld“ ist: die Umgebung erreicht wallet-core gar nicht.
    -
    -> **Wenn später auf Gerät/mehr RAM die Inference-APK plötzlich grün und master/stable rot ist:**  
    -> das ist der **klare Fix-Hinweis** — Patches aus `dev/hernani-inference/*` upstreamen / in master holen.  
    -> **Heute:** noch **nicht** der Fall; primärer Blocker = **Emulator/RAM/ANR**.
    -
    -#### Nächste sinnvolle Schritte (später)
    -
    -1. Physisches Gerät oder Host ≥6–8 GiB free RAM  
    -2. Nur **eine** Wallet-Package-ID installiert (kein Open-with)  
    -3. Survey wiederholen: stable · master · `local/master-with-inference` auf **goa + stage**  
    -4. Ältere F-Droid-APKs nur wenn wieder gehostet; kein Selbstbau alter Tags  
    -
    ----
    -
    -## Upstream branches (`taler-android` `dev/hernani-inference/*`)
    -
    -| Branch | Role |
    -|--------|------|
    -| `gui-workflows` | macOS emulator helpers + original `goa-chain-emu.sh` |
    -| `fix/bank-withdraw-auto-exchange` | **Minimal** GOA exchange auto-add (default build branch) |
    -| `fix/withdraw-spinner-fallback` | Spinner + OIM; use if spinner still hangs |
    -| `experimental-oim*` | Optional cash UI |
    -
    ----
    -
    -## Checklist
    -
    -- [x] Minimal variant documented  
    -- [x] Minimalvariante: was geht / was nicht (Beschränkungen)  
    -- [x] Two stacks (goa / stage)  
    -- [x] Dual-stack run documented (2026-07-17: both FAIL ANR, landings OK)  
    -- [x] Flags design (`AUTO_ANDROID` / `AUTO_GUI` / platform defaults)  
    -- [x] **Headless emulator default** (`EMULATOR_HEADLESS=1`, SwiftShader)  
    -- [x] Linux default = Android suite only  
    -- [x] Inference-branch policy  
    -- [ ] Wrapper enforces flags in every entry script (incremental)  
    -- [ ] Reliable unattended Confirm on low-RAM Linux emulator  
    diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md
    deleted file mode 100644
    index d91d82c..0000000
    --- a/scripts/taler-monitoring/android-test/README.md
    +++ /dev/null
    @@ -1,111 +0,0 @@
    -# Android wallet smoke (Abheben + Bezahlen)
    -
    -Smokes always cover **withdraw first**, then **pay** (not pay-only).
    -
    -| Leg | Default | What |
    -|-----|---------|------|
    -| **1 WITHDRAW** | on | `taler://withdraw/…` + Confirm/Abheben taps |
    -| **2 PAY** | on | `taler://pay…` / pay-template + Pay taps |
    -
    -```bash
    -# needs adb device/emulator
    -./run-android-pay-smoke.sh
    -STACK=goa ./run-android-pay-smoke.sh
    -DO_PAY=0 ./run-android-pay-smoke.sh          # withdraw only
    -DO_WITHDRAW=0 DO_PAY=1 …                     # pay only (unusual)
    -```
    -
    -APK: F-Droid **1.6.1** (`net.taler.wallet.fdroid`).
    -
    -## 2) Source build + same tests
    -
    -Builds `taler-android` at a git **ref** (`:wallet:assembleFdroidDebug`), then
    -runs the **same** smoke (install + withdraw/pay deep-links).
    -
    -```bash
    -# default: inference fix branch (GOA withdraw reliability)
    -./run-android-build-and-smoke.sh
    -# tip of master
    -BRANCH=master ./run-android-build-and-smoke.sh
    -# self-build of published stable **tag** (same release as F-Droid 1.6.1)
    -BRANCH=wallet-1.6.1 LABEL=stable-self ./run-android-build-and-smoke.sh
    -SKIP_PULL=1 BRANCH=master ./run-android-build-and-smoke.sh
    -TALER_ANDROID_SRC=/path/to/taler-android ./run-android-build-and-smoke.sh
    -```
    -
    -Built package id: `net.taler.wallet.fdroid.debug`.  
    -Needs `ANDROID_HOME` (e.g. `$HOME/Android/Sdk`), Java 17+, network for Maven.
    -
    -## 2b) Variant matrix (published + stable self-build + master)
    -
    -Runs several smokes so a single odd developer build cannot be the only sample:
    -
    -| Variant | What |
    -|---------|------|
    -| Order | Variant | Severity | What |
    -|------|---------|----------|------|
    -| **1** | **stable-self** | WARN | **Only** self-build: current stable tag |
    -| **2** | **published** | age ≤14d → **BLOCKER**, else WARN | Current F-Droid APK |
    -| **3** | **older / milestones** | **always WARN** | F-Droid APKs nearest **3 / 6 / 9 / 12 months** (if still hosted) — **no self-build** |
    -| **4** | **master** | WARN | Rebuild `origin/master` |
    -
    -**Self-build policy:** only **current published stable** (`stable-self`) + optional **master**.  
    -Historical versions: published F-Droid APK only; skip if APK gone.  
    -**Milestones:** `MILESTONE_DAYS="90 180 270 365"` (±50d), warn only.  
    -**Young releases** (≤ 14 days): hard blocker on fail.
    -
    -```bash
    -./run-android-variant-matrix.sh
    -STACK=goa ./run-android-variant-matrix.sh
    -INCLUDE_OLDER=0 VARIANTS=stable-self,published,master ./run-android-variant-matrix.sh
    -# custom milestones (days):
    -MILESTONE_DAYS="90 180 270 365" ./run-android-variant-matrix.sh
    -```
    -
    -Artifacts + `SUMMARY.txt` under `out-matrix-/`.
    -
    -## 3) Graphical UI (vanilla)
    -
    -Deep-link entry (≡ QR) **plus** multi-round uiautomator taps:
    -
    -```bash
    -STACK=goa ./run-android-gui-smoke.sh
    -STACK=stage ./run-android-gui-smoke.sh
    -# after source build:
    -GUI=1 ./run-android-build-and-smoke.sh
    -```
    -
    -## 4) Full GOA/stage chain (from `dev/hernani-inference/gui-workflows`)
    -
    -Bank API mint → withdraw deep-link → taps → template pay → taps:
    -
    -```bash
    -# needs explorer password + device
    -EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \
    -  STACK=goa PKG=net.taler.wallet.fdroid.debug \
    -  ./run-goa-gui-chain.sh
    -
    -STACK=stage ./run-goa-gui-chain.sh
    -```
    -
    -Canonical notes (minimal variant, **flags**, Linux defaults):
    -**[GUI-AUTOMATION-NOTES.md](./GUI-AUTOMATION-NOTES.md)**.
    -
    -| Flag | Linux default | Meaning |
    -|------|---------------|---------|
    -| `AUTO_ANDROID` | `1` | Run Android adb/APK automation |
    -| `AUTO_GUI` | `1` | Graphical uiautomator drive (else deep-link only) |
    -| `AUTO_PLATFORM` | `auto` → `linux` | Host family; macOS can later pair with iOS out of tree |
    -| **`EMULATOR_HEADLESS`** | **`1`** | **No host window** (`-no-window`). Server-safe. |
    -| `EMULATOR_GPU` | `swiftshader_indirect` | Guest GLES still active for UI/taps/screenshots |
    -| `AUTO_START_EMULATOR` | `1` | Auto-start headless AVD if no adb device |
    -| `WINDOWED` | `0` | `WINDOWED=1` → show emulator window |
    -
    -```bash
    -# headless AVD only (default path for servers)
    -./start-android-emulator.sh --wait
    -# windowed (laptops)
    -WINDOWED=1 ./start-android-emulator.sh --wait
    -```
    -
    -See also parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md).
    diff --git a/scripts/taler-monitoring/android-test/lib_android_env.sh b/scripts/taler-monitoring/android-test/lib_android_env.sh
    deleted file mode 100644
    index 670d796..0000000
    --- a/scripts/taler-monitoring/android-test/lib_android_env.sh
    +++ /dev/null
    @@ -1,158 +0,0 @@
    -# shellcheck shell=bash
    -# Shared Android env for GUI / adb smokes.
    -# Source:  . "$ROOT/lib_android_env.sh"
    -#
    -# Defaults favour **servers / CI**: no host window (headless emulator), while the
    -# guest still runs GLES via SwiftShader so UI layout / uiautomator work.
    -#
    -# See GUI-AUTOMATION-NOTES.md → Headless mode
    -
    -# --- SDK / PATH ---------------------------------------------------------------
    -export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
    -export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
    -for d in \
    -  "$ANDROID_HOME/platform-tools" \
    -  "$ANDROID_HOME/emulator" \
    -  "$ANDROID_HOME/cmdline-tools/latest/bin"
    -do
    -  [ -d "$d" ] && case ":$PATH:" in *":$d:"*) ;; *) PATH="$d:$PATH" ;; esac
    -done
    -export PATH
    -
    -# AVD path (some hosts keep AVDs under ~/.config/.android)
    -if [ -z "${ANDROID_AVD_HOME:-}" ]; then
    -  if [ -d "$HOME/.android/avd" ]; then
    -    export ANDROID_AVD_HOME="$HOME/.android/avd"
    -  elif [ -d "$HOME/.config/.android/avd" ]; then
    -    export ANDROID_AVD_HOME="$HOME/.config/.android/avd"
    -    # emulator also looks at ~/.android/avd — soft-link if missing
    -    if [ ! -e "$HOME/.android/avd" ]; then
    -      mkdir -p "$HOME/.android"
    -      ln -sfn "$HOME/.config/.android/avd" "$HOME/.android/avd" 2>/dev/null || true
    -    fi
    -  fi
    -fi
    -
    -# --- Platform / feature flags -------------------------------------------------
    -os=$(uname -s)
    -case "${AUTO_PLATFORM:-auto}" in
    -  auto)
    -    case "$os" in
    -      Linux)  : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;;
    -      Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;;
    -      *)      : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;;
    -    esac
    -    ;;
    -  linux|macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;;
    -  *)           : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;;
    -esac
    -
    -# Headless = default (no X11/Wayland window on host). GUI *logic* still runs in
    -# the guest via software GPU. Set EMULATOR_HEADLESS=0 or WINDOWED=1 for a window.
    -: "${EMULATOR_HEADLESS:=1}"
    -if [ "${WINDOWED:-0}" = "1" ]; then
    -  EMULATOR_HEADLESS=0
    -fi
    -export EMULATOR_HEADLESS
    -
    -# Soft GLES for headless; windowed may override to host/auto if set by user.
    -if [ "${EMULATOR_HEADLESS}" = "1" ]; then
    -  : "${EMULATOR_GPU:=swiftshader_indirect}"
    -else
    -  : "${EMULATOR_GPU:=swiftshader_indirect}"  # still safe default; host GPU: EMULATOR_GPU=host
    -fi
    -export EMULATOR_GPU
    -
    -: "${EMULATOR_AVD:=TalerWallet34}"
    -: "${EMULATOR_MEMORY:=1536}"
    -: "${EMULATOR_CORES:=2}"
    -# If no adb device: start headless AVD (0 = require phone/manual emulator)
    -: "${AUTO_START_EMULATOR:=1}"
    -: "${EMULATOR_BOOT_TIMEOUT:=360}"
    -: "${EMULATOR_LOG:=/tmp/taler-android-emulator.log}"
    -: "${EMULATOR_NO_SNAPSHOT:=1}"
    -
    -android_adb_serial() {
    -  adb devices 2>/dev/null | awk '/\tdevice$/{print $1; exit}'
    -}
    -
    -android_has_device() {
    -  [ -n "$(android_adb_serial)" ]
    -}
    -
    -# Build emulator argv for current headless/windowed mode.
    -# Prints args; caller exec/nohup.
    -android_emulator_args() {
    -  local avd="${1:-$EMULATOR_AVD}"
    -  local args=( -avd "$avd" -gpu "$EMULATOR_GPU" -no-audio -no-boot-anim )
    -  if [ "${EMULATOR_HEADLESS}" = "1" ]; then
    -    args+=( -no-window )
    -    # Avoid Qt trying to open a display on servers
    -    export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}"
    -  fi
    -  # Broken default_boot snapshots leave adb offline forever — cold boot by default.
    -  if [ "${EMULATOR_NO_SNAPSHOT:-1}" = "1" ]; then
    -    args+=( -no-snapshot -no-snapshot-load -no-snapshot-save )
    -  fi
    -  if [ -n "${EMULATOR_MEMORY:-}" ]; then
    -    args+=( -memory "$EMULATOR_MEMORY" )
    -  fi
    -  if [ -n "${EMULATOR_CORES:-}" ]; then
    -    args+=( -cores "$EMULATOR_CORES" )
    -  fi
    -  # extra user flags
    -  # shellcheck disable=SC2206
    -  if [ -n "${EMULATOR_EXTRA_ARGS:-}" ]; then
    -    # intentional word-split for extra flags
    -    args+=( ${EMULATOR_EXTRA_ARGS} )
    -  fi
    -  printf '%s\n' "${args[@]}"
    -}
    -
    -# Ensure an adb "device" is online. Optionally start headless emulator.
    -# Sets SERIAL / ANDROID_SERIAL on success. Returns 0 ok, 3 no device.
    -android_ensure_device() {
    -  local serial
    -  serial="${SERIAL:-${ANDROID_SERIAL:-}}"
    -  if [ -z "$serial" ]; then
    -    serial=$(android_adb_serial || true)
    -  fi
    -  if [ -n "$serial" ]; then
    -    SERIAL="$serial"
    -    export ANDROID_SERIAL="$serial"
    -    return 0
    -  fi
    -
    -  if [ "${AUTO_START_EMULATOR}" != "1" ]; then
    -    echo "no adb device (AUTO_START_EMULATOR=0 — plug phone or start AVD manually)" >&2
    -    return 3
    -  fi
    -
    -  if ! command -v emulator >/dev/null 2>&1; then
    -    echo "no adb device and emulator binary missing under $ANDROID_HOME" >&2
    -    return 3
    -  fi
    -
    -  echo "no adb device → starting AVD ${EMULATOR_AVD} (headless=${EMULATOR_HEADLESS} gpu=${EMULATOR_GPU})"
    -  local starter
    -  starter="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/start-android-emulator.sh"
    -  if [ ! -x "$starter" ]; then
    -    echo "missing $starter" >&2
    -    return 3
    -  fi
    -  EMULATOR_HEADLESS="$EMULATOR_HEADLESS" EMULATOR_GPU="$EMULATOR_GPU" \
    -    EMULATOR_AVD="$EMULATOR_AVD" EMULATOR_MEMORY="$EMULATOR_MEMORY" \
    -    EMULATOR_CORES="$EMULATOR_CORES" EMULATOR_LOG="$EMULATOR_LOG" \
    -    EMULATOR_BOOT_TIMEOUT="$EMULATOR_BOOT_TIMEOUT" \
    -    "$starter" --wait || return 3
    -
    -  serial=$(android_adb_serial || true)
    -  if [ -z "$serial" ]; then
    -    echo "emulator started but no adb device yet — see $EMULATOR_LOG" >&2
    -    return 3
    -  fi
    -  SERIAL="$serial"
    -  export ANDROID_SERIAL="$serial"
    -  echo "device: $SERIAL (headless=${EMULATOR_HEADLESS})"
    -  return 0
    -}
    diff --git a/scripts/taler-monitoring/android-test/lib_release_age.sh b/scripts/taler-monitoring/android-test/lib_release_age.sh
    deleted file mode 100644
    index 7be935f..0000000
    --- a/scripts/taler-monitoring/android-test/lib_release_age.sh
    +++ /dev/null
    @@ -1,133 +0,0 @@
    -# shellcheck shell=bash
    -# Release age helpers for Android wallet matrix.
    -# Source after ROOT/lib_android_env.sh (optional).
    -#
    -# BLOCKER only when a *released* app is within BLOCKER_MAX_AGE_DAYS (default 14).
    -# Older releases: try them, but failures are WARN only.
    -
    -: "${BLOCKER_MAX_AGE_DAYS:=14}"
    -: "${TALER_ANDROID_SRC:=$HOME/taler/taler-android}"
    -
    -# Days since git tag (tagger/committer date). Empty if unknown.
    -# Usage: release_age_days wallet-1.6.1  → e.g. 28
    -release_age_days() {
    -  local tag="$1"
    -  local src="${2:-$TALER_ANDROID_SRC}"
    -  [ -d "$src/.git" ] || return 1
    -  local epoch
    -  epoch=$(git -C "$src" log -1 --format=%ct "refs/tags/${tag}" 2>/dev/null || true)
    -  [ -n "$epoch" ] || epoch=$(git -C "$src" log -1 --format=%ct "$tag" 2>/dev/null || true)
    -  [ -n "$epoch" ] || return 1
    -  local now
    -  now=$(date +%s)
    -  echo $(( (now - epoch) / 86400 ))
    -}
    -
    -# severity from age: blocker if age_days <= BLOCKER_MAX_AGE_DAYS, else warn
    -# Unknown age → warn (never accidental hard fail on mystery builds)
    -severity_from_age_days() {
    -  local days="${1:-}"
    -  if [ -z "$days" ] || ! [[ "$days" =~ ^[0-9]+$ ]]; then
    -    echo warn
    -    return
    -  fi
    -  if [ "$days" -le "${BLOCKER_MAX_AGE_DAYS}" ]; then
    -    echo blocker
    -  else
    -    echo warn
    -  fi
    -}
    -
    -severity_for_tag() {
    -  local tag="$1"
    -  local days
    -  days=$(release_age_days "$tag" 2>/dev/null || true)
    -  severity_from_age_days "$days"
    -}
    -
    -# List wallet-* release tags newest first (exclude rc/dev).
    -# Args: max count (default 5). Pass 0 or "all" for no limit.
    -list_wallet_release_tags() {
    -  local n="${1:-5}"
    -  local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}"
    -  [ -d "$src/.git" ] || return 1
    -  git -C "$src" fetch --tags origin 2>/dev/null || true
    -  local cmd=(git -C "$src" for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags)
    -  if [ "$n" = "all" ] || [ "$n" = "0" ]; then
    -    "${cmd[@]}" | grep -E '^wallet-[0-9]+\.[0-9]+(\.[0-9]+)?$'
    -  else
    -    "${cmd[@]}" | grep -E '^wallet-[0-9]+\.[0-9]+(\.[0-9]+)?$' | head -n "$n"
    -  fi
    -}
    -
    -# Pick one tag closest to each age milestone (days). Always intended as WARN samples.
    -# Default milestones: 3,6,9,12 months ≈ 90,180,270,365 days.
    -# Env: MILESTONE_DAYS="90 180 270 365"  MILESTONE_TOLERANCE_DAYS=50
    -# Prints lines: TAG AGE_DAYS TARGET_DAYS  (only when a tag within tolerance exists)
    -select_milestone_tags() {
    -  local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}"
    -  local milestones="${MILESTONE_DAYS:-90 180 270 365}"
    -  local tol="${MILESTONE_TOLERANCE_DAYS:-50}"
    -  local skip="${1:-}"   # optional tag to exclude (e.g. current STABLE_TAG)
    -  local -a tags=()
    -  mapfile -t tags < <(list_wallet_release_tags all 2>/dev/null || true)
    -  [ "${#tags[@]}" -gt 0 ] || return 0
    -
    -  local target best_tag best_age best_delta age t delta
    -  local -A used=()
    -  for target in $milestones; do
    -    best_tag=""
    -    best_age=""
    -    best_delta=99999
    -    for t in "${tags[@]}"; do
    -      [ -n "$t" ] || continue
    -      [ "$t" = "$skip" ] && continue
    -      [ -n "${used[$t]:-}" ] && continue
    -      age=$(release_age_days "$t" "$src" 2>/dev/null || true)
    -      [[ "$age" =~ ^[0-9]+$ ]] || continue
    -      delta=$(( age > target ? age - target : target - age ))
    -      if [ "$delta" -le "$tol" ] && [ "$delta" -lt "$best_delta" ]; then
    -        best_delta=$delta
    -        best_tag=$t
    -        best_age=$age
    -      fi
    -    done
    -    if [ -n "$best_tag" ]; then
    -      used[$best_tag]=1
    -      echo "$best_tag $best_age $target"
    -    fi
    -  done
    -}
    -
    -# Guess F-Droid APK URL for a wallet version name (e.g. 1.6.1).
    -# Uses versionCode from tag's build.gradle if possible, else STABLE mapping.
    -# Prints: versionCode apk_url  or returns 1
    -fdroid_apk_for_version_name() {
    -  local vn="$1"   # 1.6.1
    -  local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}"
    -  local tag="wallet-${vn}"
    -  local vc=""
    -  if git -C "$src" cat-file -e "refs/tags/${tag}:wallet/build.gradle" 2>/dev/null; then
    -    vc=$(git -C "$src" show "refs/tags/${tag}:wallet/build.gradle" 2>/dev/null \
    -      | sed -n 's/^[[:space:]]*versionCode[[:space:]]\+\([0-9]\+\).*/\1/p' | head -1)
    -  fi
    -  # ABI x86_64 → +4 in override scheme 10*vc+abi (common F-Droid multi-apk)
    -  # Prefer universal-style code used by F-Droid: for 1.6.1 → 854 = 10*85+4
    -  local apk_vc=""
    -  if [ -n "$vc" ]; then
    -    apk_vc=$((10 * vc + 4))
    -  fi
    -  # Known overrides / suggested
    -  case "$vn" in
    -    1.6.1) apk_vc=854 ;;
    -  esac
    -  [ -n "$apk_vc" ] || return 1
    -  echo "${apk_vc}|https://f-droid.org/repo/net.taler.wallet.fdroid_${apk_vc}.apk"
    -}
    -
    -# Check HTTP HEAD/GET that APK exists (0 = ok)
    -fdroid_apk_exists() {
    -  local url="$1"
    -  curl -sfS -m 15 -o /dev/null -r 0-0 "$url" 2>/dev/null \
    -    || curl -sfS -m 15 -o /dev/null -I "$url" 2>/dev/null
    -}
    diff --git a/scripts/taler-monitoring/android-test/lib_ui.py b/scripts/taler-monitoring/android-test/lib_ui.py
    deleted file mode 100755
    index a7cdad5..0000000
    --- a/scripts/taler-monitoring/android-test/lib_ui.py
    +++ /dev/null
    @@ -1,192 +0,0 @@
    -#!/usr/bin/env python3
    -"""Minimal adb + uiautomator helpers for Taler wallet GUI smoke (no root).
    -
    -Does not replace Maestro/Appium; uses only adb shell + uiautomator dump.
    -"""
    -from __future__ import annotations
    -
    -import re
    -import subprocess
    -import time
    -from pathlib import Path
    -from typing import Iterable, List, Optional, Sequence, Tuple
    -
    -Bounds = Tuple[int, int, int, int]
    -
    -
    -def adb(serial: str, *args: str, check: bool = False, timeout: Optional[float] = 60) -> subprocess.CompletedProcess:
    -    cmd = ["adb", "-s", serial, *args]
    -    return subprocess.run(cmd, check=check, capture_output=True, text=True, timeout=timeout)
    -
    -
    -def dump_ui(serial: str, dest: Path) -> str:
    -    adb(serial, "shell", "uiautomator", "dump", "/sdcard/ui.xml", timeout=90)
    -    adb(serial, "pull", "/sdcard/ui.xml", str(dest), timeout=30)
    -    return dest.read_text(errors="replace") if dest.is_file() else ""
    -
    -
    -def screencap(serial: str, dest: Path) -> None:
    -    p = subprocess.run(
    -        ["adb", "-s", serial, "exec-out", "screencap", "-p"],
    -        capture_output=True,
    -        timeout=60,
    -    )
    -    if p.returncode == 0 and p.stdout:
    -        dest.write_bytes(p.stdout)
    -
    -
    -def texts(xml: str) -> List[str]:
    -    return [t for t in re.findall(r'text="([^"]*)"', xml) if t.strip()]
    -
    -
    -def find_bounds(xml: str, label: str) -> List[Bounds]:
    -    """Return list of (x1,y1,x2,y2) for nodes whose text or content-desc matches label (exact)."""
    -    out: List[Bounds] = []
    -    esc = re.escape(label)
    -    patterns = [
    -        rf'(?:text|content-desc)="{esc}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
    -        rf'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*(?:text|content-desc)="{esc}"',
    -    ]
    -    for pat in patterns:
    -        for m in re.finditer(pat, xml, flags=re.I):
    -            out.append(tuple(map(int, m.groups())))  # type: ignore[arg-type]
    -    return out
    -
    -
    -def find_bounds_re(xml: str, pattern: str) -> List[Tuple[str, Bounds]]:
    -    out: List[Tuple[str, Bounds]] = []
    -    for m in re.finditer(
    -        rf'(?:text|content-desc)="([^"]*{pattern}[^"]*)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
    -        xml,
    -        flags=re.I,
    -    ):
    -        label = m.group(1)
    -        b = tuple(map(int, m.group(2, 3, 4, 5)))
    -        out.append((label, b))  # type: ignore[arg-type]
    -    return out
    -
    -
    -def center(b: Bounds) -> Tuple[int, int]:
    -    x1, y1, x2, y2 = b
    -    return (x1 + x2) // 2, (y1 + y2) // 2
    -
    -
    -def tap(serial: str, x: int, y: int) -> None:
    -    adb(serial, "shell", "input", "tap", str(x), str(y))
    -
    -
    -def tap_bounds(serial: str, b: Bounds) -> None:
    -    x, y = center(b)
    -    tap(serial, x, y)
    -
    -
    -def dismiss_anr(serial: str, xml: str) -> bool:
    -    """If System UI isn't responding, prefer Wait over Close."""
    -    if "isn't responding" not in xml and "reagiert nicht" not in xml.lower():
    -        return False
    -    for label in ("Wait", "Warten", "OK"):
    -        bs = find_bounds(xml, label)
    -        if bs:
    -            tap_bounds(serial, bs[0])
    -            return True
    -    # fallback: right-ish button often Wait
    -    adb(serial, "shell", "input", "keyevent", "22")
    -    adb(serial, "shell", "input", "keyevent", "66")
    -    return True
    -
    -
    -# Labels seen on wallet UI (EN/DE/FR) — expand as needed
    -CONFIRM_LABELS = [
    -    "Confirm",
    -    "Confirm withdrawal",
    -    "Withdraw",
    -    "Accept",
    -    "I accept",
    -    "Agree",
    -    "Continue",
    -    "Next",
    -    "OK",
    -    "Pay",
    -    "Pay now",
    -    "Bestätigen",
    -    "Abheben",
    -    "Akzeptieren",
    -    "Zustimmen",
    -    "Weiter",
    -    "Bezahlen",
    -    "Confirmer",
    -    "Retirer",
    -    "Accepter",
    -    "Payer",
    -]
    -
    -TOS_LABELS = [
    -    "I accept",
    -    "Accept",
    -    "Agree",
    -    "Akzeptieren",
    -    "Zustimmen",
    -    "Accepter",
    -]
    -
    -
    -def try_tap_any(serial: str, xml: str, labels: Sequence[str]) -> Optional[str]:
    -    for label in labels:
    -        bs = find_bounds(xml, label)
    -        if bs:
    -            tap_bounds(serial, bs[0])
    -            return label
    -    return None
    -
    -
    -def gui_drive(
    -    serial: str,
    -    out_dir: Path,
    -    *,
    -    rounds: int = 8,
    -    sleep_s: float = 3.0,
    -    phase: str = "gui",
    -) -> dict:
    -    """Poll UI, dismiss ANR, tap confirm/ToS-like buttons. Returns status dict."""
    -    out_dir.mkdir(parents=True, exist_ok=True)
    -    seen_texts: List[str] = []
    -    taps: List[str] = []
    -    anr = 0
    -    for i in range(1, rounds + 1):
    -        xml_path = out_dir / f"{phase}-{i:02d}-ui.xml"
    -        png_path = out_dir / f"{phase}-{i:02d}.png"
    -        try:
    -            xml = dump_ui(serial, xml_path)
    -        except Exception as e:
    -            taps.append(f"dump-fail:{e}")
    -            time.sleep(sleep_s)
    -            continue
    -        try:
    -            screencap(serial, png_path)
    -        except Exception:
    -            pass
    -        ts = texts(xml)
    -        seen_texts.extend(ts[:20])
    -        if dismiss_anr(serial, xml):
    -            anr += 1
    -            time.sleep(sleep_s)
    -            continue
    -        hit = try_tap_any(serial, xml, CONFIRM_LABELS)
    -        if hit:
    -            taps.append(hit)
    -            time.sleep(sleep_s)
    -            continue
    -        # partial regex for amount confirm rows
    -        for label, b in find_bounds_re(xml, r"Confirm|Withdraw|Pay|Accept|Bestätig|Abheben|Payer"):
    -            tap_bounds(serial, b)
    -            taps.append(f"re:{label}")
    -            time.sleep(sleep_s)
    -            break
    -        else:
    -            time.sleep(sleep_s)
    -    return {
    -        "taps": taps,
    -        "anr_dismissals": anr,
    -        "sample_texts": seen_texts[-40:],
    -        "rounds": rounds,
    -    }
    diff --git a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh
    deleted file mode 100755
    index 659b446..0000000
    --- a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh
    +++ /dev/null
    @@ -1,167 +0,0 @@
    -#!/usr/bin/env bash
    -# Build GNU Taler Android wallet from source at a git **ref**, then run the same
    -# adb deep-link (or GUI) smoke as run-android-pay-smoke.sh.
    -#
    -# Source (default): $HOME/taler/taler-android
    -# Build:            ./gradlew :wallet:assembleFdroidDebug
    -# Package id:       net.taler.wallet.fdroid.debug
    -#
    -# Typical refs:
    -#   BRANCH=master                         # tip of master
    -#   BRANCH=wallet-1.6.1                   # published stable **tag** (self-build)
    -#   BRANCH=dev/hernani-inference/fix-…   # GOA automation fix branch
    -#
    -# For a multi-variant matrix (master + stable self-build + published APK):
    -#   ./run-android-variant-matrix.sh
    -#
    -# Usage:
    -#   ./run-android-build-and-smoke.sh
    -#   BRANCH=master STACK=goa ./run-android-build-and-smoke.sh
    -#   BRANCH=wallet-1.6.1 LABEL=stable-self ./run-android-build-and-smoke.sh
    -#   SKIP_PULL=1 BRANCH=master ./run-android-build-and-smoke.sh
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -SMOKE="$ROOT/run-android-pay-smoke.sh"
    -SRC="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}"
    -SKIP_PULL="${SKIP_PULL:-0}"
    -# Prefer existing inference fix branch for GOA withdraw (spinner/exchange resolve).
    -# Matrix uses BRANCH=master and BRANCH=$STABLE_TAG explicitly.
    -BRANCH="${BRANCH:-dev/hernani-inference/fix-bank-withdraw-auto-exchange}"
    -# Optional human label for logs / artifact names (default = sanitized BRANCH)
    -LABEL="${LABEL:-}"
    -VARIANT="${VARIANT:-fdroidDebug}"
    -export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
    -export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
    -
    -if [ ! -x "$SRC/gradlew" ]; then
    -  echo "taler-android not found at $SRC (set TALER_ANDROID_SRC=…)" >&2
    -  exit 2
    -fi
    -if [ ! -d "$ANDROID_HOME" ]; then
    -  echo "ANDROID_HOME missing: $ANDROID_HOME" >&2
    -  exit 2
    -fi
    -
    -printf 'sdk.dir=%s\n' "$ANDROID_HOME" >"$SRC/local.properties"
    -
    -# --- checkout REF (branch, origin/branch, or tag) --------------------------------
    -cd "$SRC"
    -if [ -d .git ]; then
    -  git fetch origin --tags 2>/dev/null || git fetch origin 2>/dev/null || true
    -  REF="$BRANCH"
    -  checked=0
    -  if [ -n "$REF" ]; then
    -    if git rev-parse --verify "refs/tags/${REF}" >/dev/null 2>&1; then
    -      echo "checkout tag ${REF} (stable / release self-build)"
    -      git checkout -f "refs/tags/${REF}"
    -      checked=1
    -      SKIP_PULL=1
    -    elif git rev-parse --verify "origin/${REF}" >/dev/null 2>&1; then
    -      echo "checkout origin/${REF}"
    -      git checkout -B "$REF" "origin/${REF}" 2>/dev/null || git checkout "$REF" 2>/dev/null || true
    -      checked=1
    -    elif git rev-parse --verify "${REF}" >/dev/null 2>&1; then
    -      echo "checkout ${REF}"
    -      git checkout -f "$REF"
    -      checked=1
    -    else
    -      echo "WARN: ref '${REF}' not found locally after fetch — staying on current HEAD" >&2
    -    fi
    -  fi
    -  if [ "$SKIP_PULL" != "1" ] && [ "$checked" = "1" ]; then
    -    # only pull real branches, never tags
    -    if git symbolic-ref -q HEAD >/dev/null 2>&1; then
    -      br=$(git rev-parse --abbrev-ref HEAD)
    -      git pull --ff-only origin "$br" 2>/dev/null || true
    -    fi
    -  fi
    -fi
    -
    -COMMIT=$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)
    -HEAD_DESC=$(git describe --tags --always 2>/dev/null || echo "$COMMIT")
    -BR_NOW=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)
    -if [ -z "$LABEL" ]; then
    -  LABEL=$(printf '%s' "$BRANCH" | tr '/:' '--' | tr -c 'A-Za-z0-9._-' '-' | cut -c1-48)
    -fi
    -echo "source: $SRC @ $COMMIT ($HEAD_DESC)  ref=$BRANCH  label=$LABEL  head=$BR_NOW"
    -echo "variant: $VARIANT"
    -
    -# Map VARIANT → gradle task
    -case "$VARIANT" in
    -  fdroidDebug|FdroidDebug) task=assembleFdroidDebug ;;
    -  fdroidRelease|FdroidRelease) task=assembleFdroidRelease ;;
    -  nightlyDebug|NightlyDebug) task=assembleNightlyDebug ;;
    -  googleDebug|GoogleDebug) task=assembleGoogleDebug ;;
    -  *)
    -    task="assemble$(printf '%s' "$VARIANT" | python3 -c 'import sys; s=sys.stdin.read().strip(); print(s[:1].upper()+s[1:] if s else "FdroidDebug")')"
    -    ;;
    -esac
    -
    -# Low-RAM hosts: cap JVM
    -export GRADLE_OPTS="${GRADLE_OPTS:--Xmx1280m -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1}"
    -echo "gradle :wallet:$task …"
    -./gradlew ":wallet:$task" --no-daemon --max-workers="${GRADLE_MAX_WORKERS:-1}" \
    -  -Dorg.gradle.jvmargs="${GRADLE_JVMARGS:--Xmx1280m}"
    -
    -# Prefer fdroid debug APK if multiple exist
    -APK=$(find "$SRC/wallet/build/outputs/apk" -path '*fdroid*debug*.apk' -type f 2>/dev/null | sort | tail -1)
    -if [ -z "$APK" ] || [ ! -f "$APK" ]; then
    -  APK=$(find "$SRC/wallet/build/outputs/apk" -name '*.apk' -type f 2>/dev/null | sort | tail -1)
    -fi
    -if [ -z "$APK" ] || [ ! -f "$APK" ]; then
    -  echo "no APK under wallet/build/outputs/apk" >&2
    -  exit 4
    -fi
    -echo "built apk: $APK ($(wc -c <"$APK") bytes)"
    -
    -PKG="${PKG:-}"
    -if [ -z "$PKG" ] && command -v aapt >/dev/null 2>&1; then
    -  PKG=$(aapt dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\([^']*\)'.*/\1/p" | head -1)
    -fi
    -if [ -z "$PKG" ] && command -v aapt2 >/dev/null 2>&1; then
    -  PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\([^']*\)'.*/\1/p" | head -1)
    -fi
    -: "${PKG:=net.taler.wallet.fdroid.debug}"
    -
    -mkdir -p "$ROOT/apks" "$ROOT/out"
    -STAMP_APK="$ROOT/apks/wallet-${LABEL}-${COMMIT}.apk"
    -cp -f "$APK" "$STAMP_APK"
    -echo "copied → $STAMP_APK"
    -{
    -  echo "label=$LABEL"
    -  echo "ref=$BRANCH"
    -  echo "commit=$COMMIT"
    -  echo "describe=$HEAD_DESC"
    -  echo "apk=$STAMP_APK"
    -  echo "pkg=$PKG"
    -  echo "task=$task"
    -} | tee "$ROOT/out/build-${LABEL}.txt"
    -echo "$COMMIT" >"$ROOT/out/build-commit.txt"
    -echo "$APK" >"$ROOT/out/build-apk-path.txt"
    -
    -export APK_PATH="$STAMP_APK"
    -export APK_NAME
    -APK_NAME="$(basename "$STAMP_APK")"
    -export PKG
    -export OUT_DIR="${OUT_DIR:-$ROOT/out-source-$LABEL}"
    -mkdir -p "$OUT_DIR"
    -cp -f "$ROOT/out/build-${LABEL}.txt" "$OUT_DIR/build-meta.txt" 2>/dev/null || true
    -
    -SMOKE_CMD="$SMOKE"
    -if [ "${GUI:-0}" = "1" ]; then
    -  SMOKE_CMD="$ROOT/run-android-gui-smoke.sh"
    -  case "$OUT_DIR" in
    -    *-gui) ;;
    -    *) OUT_DIR="${OUT_DIR}-gui"; export OUT_DIR; mkdir -p "$OUT_DIR" ;;
    -  esac
    -fi
    -echo "=== smoke label=$LABEL PKG=$PKG GUI=${GUI:-0} OUT_DIR=$OUT_DIR ==="
    -# Do not exec: allow matrix wrapper to run multiple variants
    -set +e
    -env APK_DIR="$ROOT/apks" APK_NAME="$APK_NAME" APK_PATH="$APK_PATH" PKG="$PKG" OUT_DIR="$OUT_DIR" \
    -  "$SMOKE_CMD"
    -rc=$?
    -set -e
    -echo "=== smoke label=$LABEL exit=$rc ==="
    -exit "$rc"
    diff --git a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh
    deleted file mode 100755
    index a22f1b8..0000000
    --- a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh
    +++ /dev/null
    @@ -1,201 +0,0 @@
    -#!/usr/bin/env bash
    -# GUI-oriented Android smoke: **withdraw (Abheben)** then **pay**, each with
    -# deep-link entry + multi-round uiautomator taps. Not pay-only.
    -#
    -#   1) taler://withdraw/…  + Confirm/Abheben/ToS taps
    -#   2) taler://pay…        + Pay/Confirm taps
    -#
    -# See GUI-AUTOMATION-NOTES.md
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib_android_env.sh
    -. "$ROOT/lib_android_env.sh"
    -
    -[ "${AUTO_ANDROID}" = "1" ] || { echo "skipped: AUTO_ANDROID=${AUTO_ANDROID}"; exit 0; }
    -if [ "${AUTO_GUI}" != "1" ]; then
    -  echo "AUTO_GUI=0 → deep-link smoke only"
    -  exec env AUTO_ANDROID=1 "$ROOT/run-android-pay-smoke.sh"
    -fi
    -
    -export OUT_DIR="${OUT_DIR:-$ROOT/out-gui}"
    -export GUI_ROUNDS="${GUI_ROUNDS:-10}"
    -export GUI_SLEEP="${GUI_SLEEP:-3}"
    -
    -# Install + deliver intents via existing smoke, but skip its weak single-tap
    -# by setting GUI_MODE=1 which we implement here more fully.
    -# Simpler: do install/URI here by sourcing patterns from pay-smoke via env + python.
    -
    -# Delegate install + URI start to pay-smoke with GUI_AFTER=1 if we patch it;
    -# instead run a self-contained flow calling pay-smoke pieces.
    -
    -APK_DIR="${APK_DIR:-$ROOT/apks}"
    -APK_URL="${APK_URL:-https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}"
    -APK_NAME="${APK_NAME:-net.taler.wallet.fdroid_854.apk}"
    -APK_PATH="${APK_PATH:-}"
    -PKG="${PKG:-net.taler.wallet.fdroid}"
    -STACK="${STACK:-auto}"
    -SERIAL="${SERIAL:-}"
    -mkdir -p "$OUT_DIR"
    -
    -command -v adb >/dev/null || { echo "adb missing" >&2; exit 2; }
    -
    -# Headless default: no host window; guest GLES via SwiftShader (uiautomator).
    -android_ensure_device || { echo "no adb device (try ./start-android-emulator.sh --wait)" >&2; exit 3; }
    -ADB=(adb -s "$SERIAL")
    -echo "device: $SERIAL  mode: GUI (uiautomator) headless=${EMULATOR_HEADLESS} gpu=${EMULATOR_GPU}"
    -
    -case "$STACK" in
    -  auto)
    -    if curl -sfS -m 5 -o /dev/null https://bank.hacktivism.ch/config; then STACK=goa; else STACK=stage; fi
    -    ;;
    -esac
    -case "$STACK" in
    -  goa)
    -    BANK=https://bank.hacktivism.ch
    -    MERCHANT=https://taler.hacktivism.ch
    -    WITHDRAW_JSON="$BANK/intro/demo-withdraw.json"
    -    ;;
    -  stage)
    -    BANK=https://stage.bank.lefrancpaysan.ch
    -    MERCHANT=https://stage.monnaie.lefrancpaysan.ch
    -    WITHDRAW_JSON="$BANK/intro/demo-withdraw.json"
    -    ;;
    -  *) echo "unknown STACK" >&2; exit 2 ;;
    -esac
    -echo "stack: $STACK"
    -
    -if [ -n "$APK_PATH" ] && [ -f "$APK_PATH" ]; then
    -  :
    -elif [ -f "$APK_DIR/$APK_NAME" ]; then
    -  APK_PATH="$APK_DIR/$APK_NAME"
    -else
    -  APK_PATH="$APK_DIR/$APK_NAME"
    -  mkdir -p "$APK_DIR"
    -  curl -fL --retry 3 -o "$APK_PATH" "$APK_URL"
    -fi
    -echo "apk: $APK_PATH  pkg: $PKG"
    -
    -"${ADB[@]}" wait-for-device
    -"${ADB[@]}" install -r "$APK_PATH"
    -"${ADB[@]}" shell settings put global window_animation_scale 0 || true
    -"${ADB[@]}" shell settings put global transition_animation_scale 0 || true
    -"${ADB[@]}" shell settings put global animator_duration_scale 0 || true
    -
    -: "${DO_WITHDRAW:=1}"
    -: "${DO_PAY:=1}"
    -: "${SMOKE_STRICT:=0}"
    -
    -WURI=""
    -PAY_URI=""
    -WITHDRAW_OK=0
    -PAY_OK=0
    -
    -"${ADB[@]}" shell am force-stop "$PKG" 2>/dev/null || true
    -"${ADB[@]}" logcat -c 2>/dev/null || true
    -
    -if [ "$DO_WITHDRAW" = "1" ]; then
    -  echo
    -  echo "======== 1 WITHDRAW (Abheben) ========"
    -  WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")')
    -  [ -n "$WURI" ] || { echo "no withdraw uri" >&2; exit 4; }
    -  echo "withdraw: $WURI"
    -  echo "$WURI" >"$OUT_DIR/last-withdraw.uri"
    -  "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" | tee "$OUT_DIR/start-withdraw.txt"
    -  sleep 5
    -  python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY'
    -import json, sys, importlib.util
    -from pathlib import Path
    -lib_path, serial, out, rounds, sleep_s = (
    -    Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5])
    -)
    -spec = importlib.util.spec_from_file_location("lib_ui", lib_path)
    -lib = importlib.util.module_from_spec(spec)
    -spec.loader.exec_module(lib)
    -st = lib.gui_drive(serial, out / "gui-withdraw", rounds=rounds, sleep_s=sleep_s, phase="wd")
    -(out / "gui-withdraw-status.json").write_text(json.dumps(st, indent=2))
    -print("gui-withdraw taps:", st.get("taps"))
    -print("gui-withdraw anr:", st.get("anr_dismissals"))
    -PY
    -else
    -  echo "======== 1 WITHDRAW skipped (DO_WITHDRAW=0) ========"
    -fi
    -
    -if [ "$DO_PAY" = "1" ]; then
    -  echo
    -  echo "======== 2 PAY (Bezahlen) ========"
    -  if [ "$STACK" = "stage" ]; then
    -    if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \
    -      "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then
    -      PAY_URI=$(printf '%s' "$RESP" | python3 -c 'import json,sys;d=json.load(sys.stdin);oid=d.get("order_id")or"";tok=d.get("token")or"";
    -print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok) if oid and tok else "")')
    -    fi
    -  else
    -    PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana"
    -  fi
    -  if [ -n "$PAY_URI" ]; then
    -    echo "pay: $PAY_URI"
    -    echo "$PAY_URI" >"$OUT_DIR/last-pay.uri"
    -    "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" | tee "$OUT_DIR/start-pay.txt"
    -    sleep 5
    -    python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY'
    -import json, sys, importlib.util
    -from pathlib import Path
    -lib_path, serial, out, rounds, sleep_s = (
    -    Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5])
    -)
    -spec = importlib.util.spec_from_file_location("lib_ui", lib_path)
    -lib = importlib.util.module_from_spec(spec)
    -spec.loader.exec_module(lib)
    -st = lib.gui_drive(serial, out / "gui-pay", rounds=rounds, sleep_s=sleep_s, phase="pay")
    -(out / "gui-pay-status.json").write_text(json.dumps(st, indent=2))
    -print("gui-pay taps:", st.get("taps"))
    -print("gui-pay anr:", st.get("anr_dismissals"))
    -PY
    -  else
    -    echo "pay: (no URI)"
    -  fi
    -else
    -  echo "======== 2 PAY skipped (DO_PAY=0) ========"
    -fi
    -
    -"${ADB[@]}" logcat -d -t 500 >"$OUT_DIR/logcat.txt" || true
    -grep -iE 'taler-wallet|prepareBank|acceptWithdrawal|confirmWithdrawal|preparePay|confirmPay|Error|success|withdraw|pay' \
    -  "$OUT_DIR/logcat.txt" | tail -100 >"$OUT_DIR/logcat-taler.txt" || true
    -
    -if [ "$DO_WITHDRAW" = "1" ] && grep -qE 'prepareBankIntegratedWithdrawal|acceptWithdrawal|confirmWithdrawal' \
    -    "$OUT_DIR/logcat.txt" 2>/dev/null; then
    -  WITHDRAW_OK=1
    -fi
    -if [ "$DO_PAY" = "1" ] && [ -n "$PAY_URI" ] && \
    -    grep -qiE 'preparePay|confirmPay|preparePurchase|checkPay|pay-template|PayForTemplate' \
    -    "$OUT_DIR/logcat.txt" 2>/dev/null; then
    -  PAY_OK=1
    -fi
    -
    -rc=0
    -echo
    -echo "=== GUI smoke summary ($STACK) — withdraw + pay ==="
    -echo "package: $PKG  DO_WITHDRAW=$DO_WITHDRAW DO_PAY=$DO_PAY STRICT=$SMOKE_STRICT"
    -if [ "$DO_WITHDRAW" = "1" ]; then
    -  echo "withdraw URI: delivered → $OUT_DIR/gui-withdraw-status.json"
    -  echo "withdraw wallet-core: $([ "$WITHDRAW_OK" = 1 ] && echo YES || echo NO)"
    -  if [ "$SMOKE_STRICT" = "1" ] && [ "$WITHDRAW_OK" != "1" ]; then
    -    echo "FAIL: strict withdraw missing"; rc=11
    -  fi
    -fi
    -if [ "$DO_PAY" = "1" ]; then
    -  echo "pay URI: ${PAY_URI:-none} → $OUT_DIR/gui-pay-status.json"
    -  echo "pay wallet-core: $([ "$PAY_OK" = 1 ] && echo YES || echo NO)"
    -  if [ "$SMOKE_STRICT" = "1" ] && [ "$PAY_OK" != "1" ]; then
    -    echo "FAIL: strict pay missing"; [ "$rc" -eq 0 ] && rc=13
    -  fi
    -fi
    -if grep -qiE "isn't responding|not responding" "$OUT_DIR/logcat.txt" 2>/dev/null \
    -  || grep -rqiE "isn't responding" "$OUT_DIR/gui-withdraw" "$OUT_DIR/gui-pay" 2>/dev/null; then
    -  echo "FAIL: ANR"; rc=10
    -fi
    -echo "artifacts: $OUT_DIR/"
    -ls -la "$OUT_DIR" | sed 's/^/  /'
    -echo "Documented shortcuts → GUI-AUTOMATION-NOTES.md"
    -exit "$rc"
    diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh
    deleted file mode 100755
    index 48ec49f..0000000
    --- a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh
    +++ /dev/null
    @@ -1,308 +0,0 @@
    -#!/usr/bin/env bash
    -# Android wallet smoke: install APK, then **withdraw (Abheben)** and **pay** via
    -# deep-link + best-effort UI taps. Both legs are first-class (not pay-only).
    -#
    -# Flow (default both on):
    -#   1) WITHDRAW  taler://withdraw/…  from bank demo-withdraw.json  + confirm taps
    -#   2) PAY       taler://pay… / pay-template  + optional taps
    -#
    -# Env:
    -#   DO_WITHDRAW=1 (default)  DO_PAY=1 (default)
    -#   SMOKE_STRICT=1 → require wallet-core withdraw (+ pay if DO_PAY) + no ANR
    -#   WITHDRAW_GUI_ROUNDS  multi-round Abheben taps (default 6)
    -#
    -# Usage:
    -#   ./android-test/run-android-pay-smoke.sh
    -#   STACK=goa ./android-test/run-android-pay-smoke.sh
    -#   DO_PAY=0 ./android-test/run-android-pay-smoke.sh   # withdraw-only
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib_android_env.sh
    -. "$ROOT/lib_android_env.sh"
    -
    -if [ "${AUTO_ANDROID}" != "1" ]; then
    -  echo "skipped: AUTO_ANDROID=${AUTO_ANDROID} (platform flags — see GUI-AUTOMATION-NOTES.md)"
    -  exit 0
    -fi
    -
    -APK_DIR="${APK_DIR:-$ROOT/apks}"
    -APK_URL="${APK_URL:-https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}"
    -APK_NAME="${APK_NAME:-net.taler.wallet.fdroid_854.apk}"
    -# Optional override: full path to an already-built APK (from-source)
    -APK_PATH="${APK_PATH:-}"
    -PKG="${PKG:-net.taler.wallet.fdroid}"
    -STACK="${STACK:-auto}"   # auto | goa | stage
    -SERIAL="${SERIAL:-}"
    -OUT_DIR="${OUT_DIR:-$ROOT/out}"
    -mkdir -p "$APK_DIR" "$OUT_DIR"
    -
    -if ! command -v adb >/dev/null 2>&1; then
    -  echo "adb missing — install android-tools-adb or user SDK platform-tools" >&2
    -  exit 2
    -fi
    -
    -# Default: headless AVD (no host window). WINDOWED=1 / EMULATOR_HEADLESS=0 for UI.
    -if ! android_ensure_device; then
    -  cat >&2 </dev/null; then
    -      STACK=goa
    -    else
    -      STACK=stage
    -    fi
    -    ;;
    -esac
    -case "$STACK" in
    -  goa)
    -    BANK=https://bank.hacktivism.ch
    -    MERCHANT=https://taler.hacktivism.ch
    -    WITHDRAW_JSON="$BANK/intro/demo-withdraw.json"
    -    PAY_HINT="paivana template / goa-shop"
    -    ;;
    -  stage)
    -    BANK=https://stage.bank.lefrancpaysan.ch
    -    MERCHANT=https://stage.monnaie.lefrancpaysan.ch
    -    WITHDRAW_JSON="$BANK/intro/demo-withdraw.json"
    -    PAY_HINT="farmer shop templates"
    -    ;;
    -  *) echo "unknown STACK=$STACK" >&2; exit 2 ;;
    -esac
    -echo "stack: $STACK  bank: $BANK"
    -
    -# APK: explicit path, or download published F-Droid into APK_DIR
    -if [ -n "$APK_PATH" ] && [ -f "$APK_PATH" ]; then
    -  :
    -elif [ -f "$APK_DIR/$APK_NAME" ]; then
    -  APK_PATH="$APK_DIR/$APK_NAME"
    -else
    -  APK_PATH="$APK_DIR/$APK_NAME"
    -  echo "downloading published wallet APK…"
    -  curl -fL --retry 3 -o "$APK_PATH" "$APK_URL"
    -fi
    -echo "apk: $APK_PATH ($(wc -c <"$APK_PATH") bytes)"
    -echo "pkg: $PKG"
    -
    -: "${DO_WITHDRAW:=1}"
    -: "${DO_PAY:=1}"
    -: "${WITHDRAW_GUI_ROUNDS:=6}"
    -: "${PAY_GUI_ROUNDS:=4}"
    -: "${SMOKE_STRICT:=0}"
    -
    -"${ADB[@]}" wait-for-device
    -"${ADB[@]}" install -r "$APK_PATH"
    -"${ADB[@]}" shell settings put global window_animation_scale 0 || true
    -"${ADB[@]}" shell settings put global transition_animation_scale 0 || true
    -"${ADB[@]}" shell settings put global animator_duration_scale 0 || true
    -
    -"${ADB[@]}" shell am force-stop "$PKG" 2>/dev/null || true
    -"${ADB[@]}" logcat -c 2>/dev/null || true
    -
    -WURI=""
    -PAY_URI=""
    -WITHDRAW_OK=0
    -PAY_OK=0
    -
    -# ═══════════════════════════════════════════════════════════════
    -# 1) WITHDRAW / Abheben  (required by default — not pay-only)
    -# ═══════════════════════════════════════════════════════════════
    -if [ "$DO_WITHDRAW" = "1" ]; then
    -  echo
    -  echo "======== 1 WITHDRAW (Abheben) ========"
    -  WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")')
    -  if [ -z "$WURI" ]; then
    -    echo "FAIL: no taler_withdraw_uri from $WITHDRAW_JSON" >&2
    -    exit 4
    -  fi
    -  echo "withdraw URI: $WURI"
    -  echo "$WURI" >"$OUT_DIR/last-withdraw.uri"
    -
    -  "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" 2>&1 | tee "$OUT_DIR/start-withdraw.txt"
    -  sleep 6
    -  "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/01-after-withdraw-uri.png" || true
    -  "${ADB[@]}" shell uiautomator dump /sdcard/ui.xml 2>/dev/null || true
    -  "${ADB[@]}" pull /sdcard/ui.xml "$OUT_DIR/01-ui.xml" 2>/dev/null || true
    -
    -  # Multi-round confirm / Abheben / ToS (same idea as GUI smoke)
    -  if [ -f "$ROOT/lib_ui.py" ]; then
    -    python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$WITHDRAW_GUI_ROUNDS" 2 <<'PY' || true
    -import json, sys, importlib.util
    -from pathlib import Path
    -lib_path, serial, out, rounds, sleep_s = (
    -    Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5])
    -)
    -spec = importlib.util.spec_from_file_location("lib_ui", lib_path)
    -lib = importlib.util.module_from_spec(spec)
    -spec.loader.exec_module(lib)
    -st = lib.gui_drive(serial, out / "gui-withdraw", rounds=rounds, sleep_s=sleep_s, phase="wd")
    -(out / "gui-withdraw-status.json").write_text(json.dumps(st, indent=2))
    -print("withdraw GUI taps:", st.get("taps"), "anr:", st.get("anr_dismissals"))
    -PY
    -  else
    -    python3 - "$SERIAL" "$OUT_DIR/01-ui.xml" <<'PY' || true
    -import re, subprocess, sys
    -serial, path = sys.argv[1], sys.argv[2]
    -try:
    -    xml = open(path, errors="replace").read()
    -except FileNotFoundError:
    -    raise SystemExit(0)
    -labels = [
    -    "Confirm", "Confirm withdrawal", "Withdraw", "Accept", "Continue", "OK",
    -    "I accept", "Agree", "Bestätigen", "Abheben", "Akzeptieren", "Retirer",
    -]
    -for label in labels:
    -    for pat in (
    -        r'text="%s"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' % re.escape(label),
    -        r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*text="%s"' % re.escape(label),
    -    ):
    -        for m in re.finditer(pat, xml, flags=re.I):
    -            x1, y1, x2, y2 = map(int, m.groups())
    -            x, y = (x1 + x2) // 2, (y1 + y2) // 2
    -            print(f"tap {label} @ {x},{y}")
    -            subprocess.run(["adb", "-s", serial, "shell", "input", "tap", str(x), str(y)], check=False)
    -PY
    -    sleep 8
    -  fi
    -  "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/02-after-withdraw-taps.png" || true
    -  # Mid-flow logcat: withdraw must register before pay
    -  "${ADB[@]}" logcat -d -t 400 >"$OUT_DIR/logcat-after-withdraw.txt" || true
    -  if grep -qE 'prepareBankIntegratedWithdrawal|acceptWithdrawal|confirmWithdrawal' \
    -      "$OUT_DIR/logcat-after-withdraw.txt" 2>/dev/null; then
    -    WITHDRAW_OK=1
    -    echo "withdraw: wallet-core activity YES"
    -  else
    -    echo "withdraw: wallet-core activity NOT seen yet (onboarding/ANR/slow?)"
    -  fi
    -else
    -  echo "======== 1 WITHDRAW skipped (DO_WITHDRAW=0) ========"
    -fi
    -
    -# ═══════════════════════════════════════════════════════════════
    -# 2) PAY  (after withdraw; still first-class, but second leg)
    -# ═══════════════════════════════════════════════════════════════
    -if [ "$DO_PAY" = "1" ]; then
    -  echo
    -  echo "======== 2 PAY (Bezahlen) ========"
    -  if [ "$STACK" = "stage" ]; then
    -    if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \
    -      "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then
    -      PAY_URI=$(printf '%s' "$RESP" | python3 -c '
    -import json,sys
    -d=json.load(sys.stdin)
    -oid=d.get("order_id") or ""
    -tok=d.get("token") or ""
    -if oid and tok:
    -  print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok))
    -' 2>/dev/null || true)
    -    fi
    -  elif [ "$STACK" = "goa" ]; then
    -    PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana"
    -  fi
    -
    -  if [ -n "$PAY_URI" ]; then
    -    echo "pay URI: $PAY_URI  ($PAY_HINT)"
    -    echo "$PAY_URI" >"$OUT_DIR/last-pay.uri"
    -    "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" 2>&1 | tee "$OUT_DIR/start-pay.txt"
    -    sleep 6
    -    if [ -f "$ROOT/lib_ui.py" ]; then
    -      python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$PAY_GUI_ROUNDS" 2 <<'PY' || true
    -import json, sys, importlib.util
    -from pathlib import Path
    -lib_path, serial, out, rounds, sleep_s = (
    -    Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5])
    -)
    -spec = importlib.util.spec_from_file_location("lib_ui", lib_path)
    -lib = importlib.util.module_from_spec(spec)
    -spec.loader.exec_module(lib)
    -st = lib.gui_drive(serial, out / "gui-pay", rounds=rounds, sleep_s=sleep_s, phase="pay")
    -(out / "gui-pay-status.json").write_text(json.dumps(st, indent=2))
    -print("pay GUI taps:", st.get("taps"), "anr:", st.get("anr_dismissals"))
    -PY
    -    fi
    -    "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/03-after-pay-uri.png" || true
    -  else
    -    echo "pay: (no public template URI for stack=$STACK)"
    -  fi
    -else
    -  echo "======== 2 PAY skipped (DO_PAY=0) ========"
    -fi
    -
    -# Final evidence
    -"${ADB[@]}" logcat -d -t 500 >"$OUT_DIR/logcat.txt" || true
    -grep -iE 'taler-wallet|prepareBank|acceptWithdrawal|confirmWithdrawal|preparePay|confirmPay|error|Error|success|withdraw|pay' \
    -  "$OUT_DIR/logcat.txt" | tail -100 >"$OUT_DIR/logcat-taler.txt" || true
    -
    -# Re-evaluate withdraw/pay from full logcat
    -if [ "$DO_WITHDRAW" = "1" ]; then
    -  if grep -qE 'prepareBankIntegratedWithdrawal|acceptWithdrawal|confirmWithdrawal' \
    -      "$OUT_DIR/logcat.txt" 2>/dev/null; then
    -    WITHDRAW_OK=1
    -  fi
    -fi
    -if [ "$DO_PAY" = "1" ] && [ -n "$PAY_URI" ]; then
    -  if grep -qiE 'preparePay|confirmPay|preparePurchase|checkPay|pay-template|PayForTemplate' \
    -      "$OUT_DIR/logcat.txt" 2>/dev/null; then
    -    PAY_OK=1
    -  fi
    -fi
    -
    -rc=0
    -echo
    -echo "=== summary (withdraw + pay) ==="
    -echo "package: $PKG  stack: $STACK"
    -echo "DO_WITHDRAW=$DO_WITHDRAW  DO_PAY=$DO_PAY  SMOKE_STRICT=$SMOKE_STRICT"
    -if [ "$DO_WITHDRAW" = "1" ]; then
    -  echo "withdraw URI: ${WURI:-(none)}"
    -  if [ "$WITHDRAW_OK" = "1" ]; then
    -    echo "withdraw wallet-core: YES"
    -  else
    -    echo "withdraw wallet-core: NO"
    -    if [ "$SMOKE_STRICT" = "1" ]; then
    -      echo "FAIL: SMOKE_STRICT requires withdraw (prepareBank/accept/confirm) in logcat"
    -      rc=11
    -    fi
    -  fi
    -fi
    -if [ "$DO_PAY" = "1" ]; then
    -  echo "pay URI: ${PAY_URI:-(none)}"
    -  if [ -z "$PAY_URI" ]; then
    -    echo "pay: no URI (template/API)"
    -    if [ "$SMOKE_STRICT" = "1" ]; then
    -      echo "FAIL: SMOKE_STRICT requires a pay URI when DO_PAY=1"
    -      rc=12
    -    fi
    -  elif [ "$PAY_OK" = "1" ]; then
    -    echo "pay wallet-core: YES"
    -  else
    -    echo "pay wallet-core: NO (intent may still have been delivered)"
    -    if [ "$SMOKE_STRICT" = "1" ]; then
    -      echo "FAIL: SMOKE_STRICT requires pay path activity in logcat"
    -      [ "$rc" -eq 0 ] && rc=13
    -    fi
    -  fi
    -fi
    -if grep -qiE "isn't responding|isn.t responding|System UI isn" "$OUT_DIR/01-ui.xml" 2>/dev/null \
    -  || grep -qiE "isn't responding|not responding" "$OUT_DIR/logcat.txt" 2>/dev/null \
    -  || grep -rqiE "isn't responding" "$OUT_DIR/gui-withdraw" 2>/dev/null; then
    -  echo "FAIL: System UI / app ANR"
    -  rc=10
    -fi
    -echo "artifacts: $OUT_DIR/"
    -ls -la "$OUT_DIR" | sed 's/^/  /'
    -echo
    -echo "NOTE: Full unattended Abheben+Bezahlen needs responsive device (≥6–8 GiB host RAM)."
    -echo "Smoke legs: withdraw=$( [ "$DO_WITHDRAW" = 1 ] && echo on || echo off) pay=$( [ "$DO_PAY" = 1 ] && echo on || echo off) rc=$rc"
    -exit "$rc"
    diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh
    deleted file mode 100755
    index 54fe5dc..0000000
    --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh
    +++ /dev/null
    @@ -1,322 +0,0 @@
    -#!/usr/bin/env bash
    -# Run Android wallet smoke against several build / published variants.
    -#
    -# Default order:
    -#   1) stable-self — **only** self-build of current published stable (WARN)
    -#   2) published   — current F-Droid APK
    -#   3) older       — F-Droid APKs ~3/6/9/12 mo if still hosted (WARN, no self-build)
    -#   4) master      — tip self-build (WARN)
    -#
    -# Self-build policy: current stable + master/fix only — never rebuild old releases.
    -#
    -# Severity: age ≤14d → BLOCKER; milestones/older/self-builds → WARN
    -#
    -# Env:
    -#   STABLE_TAG, STABLE_APK_URL, STABLE_APK_NAME
    -#   MASTER_BRANCH, FIX_BRANCH
    -#   VARIANTS          default: stable-self,published,older,master
    -#   INCLUDE_OLDER=1   expand token "older" / "milestones" (default 1)
    -#   MILESTONE_DAYS    default "90 180 270 365" (≈ 3,6,9,12 months)
    -#   MILESTONE_TOLERANCE_DAYS  default 50 — max distance to accept a tag
    -#   BLOCKER_MAX_AGE_DAYS  default 14
    -#   PUBLISHED_FORCE_BLOCKER=1  always treat current published as blocker
    -#   FAIL_FAST=1, GUI=0, STACK=…
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib_android_env.sh
    -. "$ROOT/lib_android_env.sh"
    -# shellcheck source=lib_release_age.sh
    -. "$ROOT/lib_release_age.sh"
    -
    -: "${STABLE_TAG:=wallet-1.6.1}"
    -: "${STABLE_APK_URL:=https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}"
    -: "${STABLE_APK_NAME:=net.taler.wallet.fdroid_854.apk}"
    -: "${MASTER_BRANCH:=master}"
    -: "${FIX_BRANCH:=dev/hernani-inference/fix-bank-withdraw-auto-exchange}"
    -: "${VARIANTS:=stable-self,published,older,master}"
    -: "${INCLUDE_OLDER:=1}"
    -: "${MILESTONE_DAYS:=90 180 270 365}"
    -: "${MILESTONE_TOLERANCE_DAYS:=50}"
    -: "${BLOCKER_MAX_AGE_DAYS:=14}"
    -: "${PUBLISHED_FORCE_BLOCKER:=0}"
    -: "${FAIL_FAST:=1}"
    -: "${GUI:=0}"
    -: "${STACK:=auto}"
    -: "${TALER_ANDROID_SRC:=$HOME/taler/taler-android}"
    -export TALER_ANDROID_SRC BLOCKER_MAX_AGE_DAYS MILESTONE_DAYS MILESTONE_TOLERANCE_DAYS
    -
    -TS=$(date +%Y%m%d-%H%M%S)
    -MATRIX_OUT="${MATRIX_OUT:-$ROOT/out-matrix-$TS}"
    -mkdir -p "$MATRIX_OUT"
    -SUMMARY="$MATRIX_OUT/SUMMARY.txt"
    -: >"$SUMMARY"
    -
    -echo "=== Android variant matrix ===" | tee -a "$SUMMARY"
    -echo "STACK=$STACK  GUI=$GUI  headless=${EMULATOR_HEADLESS}  variants=$VARIANTS" | tee -a "$SUMMARY"
    -echo "STABLE_TAG=$STABLE_TAG  BLOCKER_MAX_AGE_DAYS=$BLOCKER_MAX_AGE_DAYS" | tee -a "$SUMMARY"
    -echo "milestones(days)=$MILESTONE_DAYS  tolerance=$MILESTONE_TOLERANCE_DAYS (always WARN)" | tee -a "$SUMMARY"
    -echo "out: $MATRIX_OUT" | tee -a "$SUMMARY"
    -
    -android_ensure_device || {
    -  echo "no adb device — start ./start-android-emulator.sh --wait" >&2
    -  exit 3
    -}
    -export SERIAL ANDROID_SERIAL
    -echo "device: $SERIAL" | tee -a "$SUMMARY"
    -
    -# --- expand VARIANTS (token "older" → concrete older-pub / older-self entries) ---
    -# Append one older release as WARN-only — **F-Droid APK only** (no self-build).
    -# Returns 0 if queued, 1 if skipped (no published APK).
    -append_older_warn() {
    -  local t="$1" age="$2" target="${3:-}"
    -  local vn meta apk_vc apk_url
    -  vn="${t#wallet-}"
    -  if [ -n "$target" ]; then
    -    echo "  milestone ~${target}d → $t (age=${age}d) severity=WARN" | tee -a "$SUMMARY"
    -  else
    -    echo "  older $t (age=${age}d) severity=WARN" | tee -a "$SUMMARY"
    -  fi
    -  meta=$(fdroid_apk_for_version_name "$vn" 2>/dev/null || true)
    -  if [ -n "$meta" ]; then
    -    apk_vc="${meta%%|*}"
    -    apk_url="${meta#*|}"
    -    if fdroid_apk_exists "$apk_url"; then
    -      out+=("older-pub:${vn}:${apk_vc}:warn")
    -      return 0
    -    fi
    -    echo "  SKIP $t — no F-Droid APK at ${apk_url:-?} (self-build only for current stable)" | tee -a "$SUMMARY"
    -  else
    -    echo "  SKIP $t — cannot map F-Droid versionCode (self-build only for current stable)" | tee -a "$SUMMARY"
    -  fi
    -  return 1
    -}
    -
    -expand_variants() {
    -  local raw="$1"
    -  local -a out=()
    -  local v tag vn age sev line t target
    -
    -  IFS=',' read -r -a toks <<<"$raw"
    -  for v in "${toks[@]}"; do
    -    v=$(echo "$v" | tr -d '[:space:]')
    -    [ -n "$v" ] || continue
    -    if [ "$v" = "older" ] || [ "$v" = "older-releases" ] || [ "$v" = "milestones" ]; then
    -      [ "$INCLUDE_OLDER" = "1" ] || continue
    -      # 3/6/9/12-month samples: published APK only, always WARN.
    -      local found=0 queued=0
    -      while read -r t age target; do
    -        [ -n "$t" ] || continue
    -        [ "$t" = "$STABLE_TAG" ] && continue
    -        found=1
    -        if append_older_warn "$t" "$age" "$target"; then
    -          queued=$((queued + 1))
    -        fi
    -      done < <(select_milestone_tags "$STABLE_TAG" 2>/dev/null || true)
    -      if [ "$found" = "0" ]; then
    -        echo "  (no milestone tags within ±${MILESTONE_TOLERANCE_DAYS}d of $MILESTONE_DAYS)" | tee -a "$SUMMARY"
    -      elif [ "$queued" = "0" ]; then
    -        echo "  (milestone tags found but none still on F-Droid — no older APK smokes)" | tee -a "$SUMMARY"
    -      fi
    -    else
    -      out+=("$v")
    -    fi
    -  done
    -  printf '%s\n' "${out[@]}"
    -}
    -
    -mapfile -t VLIST < <(expand_variants "$VARIANTS")
    -echo "expanded variants: ${VLIST[*]}" | tee -a "$SUMMARY"
    -
    -declare -a RESULTS=()
    -declare -a WARNINGS=()
    -overall=0
    -
    -severity_for_published_current() {
    -  if [ "${PUBLISHED_FORCE_BLOCKER}" = "1" ]; then
    -    echo blocker
    -    return
    -  fi
    -  local days
    -  days=$(release_age_days "$STABLE_TAG" 2>/dev/null || true)
    -  severity_from_age_days "$days"
    -}
    -
    -run_published_apk() {
    -  local label="$1"
    -  local apk_url="$2"
    -  local apk_name="$3"
    -  local sev="$4"
    -  local out="$MATRIX_OUT/$label"
    -  mkdir -p "$out"
    -  echo "" | tee -a "$SUMMARY"
    -  echo "-------- variant: $label (F-Droid APK) [$(echo "$sev" | tr 'a-z' 'A-Z')] --------" | tee -a "$SUMMARY"
    -  echo "  url=$apk_url" | tee -a "$SUMMARY"
    -  local smoke="$ROOT/run-android-pay-smoke.sh"
    -  [ "$GUI" = "1" ] && smoke="$ROOT/run-android-gui-smoke.sh"
    -  local strict=1
    -  [ "$sev" = "warn" ] && strict="${SMOKE_STRICT_WARN:-0}"
    -  [ "$sev" = "blocker" ] && strict="${SMOKE_STRICT:-1}"
    -  set +e
    -  env STACK="$STACK" \
    -    APK_URL="$apk_url" \
    -    APK_NAME="$apk_name" \
    -    APK_PATH="" \
    -    PKG=net.taler.wallet.fdroid \
    -    OUT_DIR="$out" \
    -    SERIAL="$SERIAL" \
    -    AUTO_START_EMULATOR=0 \
    -    SMOKE_STRICT="$strict" \
    -    "$smoke"
    -  local rc=$?
    -  set -e
    -  echo "$label exit=$rc severity=$sev" | tee -a "$SUMMARY"
    -  echo "$label $rc $sev $apk_url" >>"$MATRIX_OUT/results.tsv"
    -  return "$rc"
    -}
    -
    -run_selfbuild() {
    -  local label="$1"
    -  local ref="$2"
    -  local sev="${3:-warn}"
    -  local out="$MATRIX_OUT/$label"
    -  mkdir -p "$out"
    -  echo "" | tee -a "$SUMMARY"
    -  echo "-------- variant: $label (self-build ref=$ref) [$(echo "$sev" | tr 'a-z' 'A-Z')] --------" | tee -a "$SUMMARY"
    -  local strict="${SMOKE_STRICT:-1}"
    -  [ "$sev" = "warn" ] && strict="${SMOKE_STRICT_SELF:-0}"
    -  [ "$sev" = "blocker" ] && strict="${SMOKE_STRICT:-1}"
    -  set +e
    -  env STACK="$STACK" \
    -    BRANCH="$ref" \
    -    LABEL="$label" \
    -    GUI="$GUI" \
    -    OUT_DIR="$out" \
    -    SERIAL="$SERIAL" \
    -    ANDROID_SERIAL="$SERIAL" \
    -    AUTO_START_EMULATOR=0 \
    -    PKG=net.taler.wallet.fdroid.debug \
    -    SMOKE_STRICT="$strict" \
    -    TALER_ANDROID_SRC="$TALER_ANDROID_SRC" \
    -    "$ROOT/run-android-build-and-smoke.sh"
    -  local rc=$?
    -  set -e
    -  echo "$label (ref=$ref) exit=$rc severity=$sev" | tee -a "$SUMMARY"
    -  echo "$label $rc $ref $sev" >>"$MATRIX_OUT/results.tsv"
    -  if [ -f "$out/build-meta.txt" ]; then
    -    echo "build-meta:" | tee -a "$SUMMARY"
    -    cat "$out/build-meta.txt" | tee -a "$SUMMARY"
    -  fi
    -  return "$rc"
    -}
    -
    -record_result() {
    -  local v="$1" rc="$2" sev="$3"
    -  if [ "$rc" -eq 0 ]; then
    -    RESULTS+=("$v:OK($sev)")
    -    return
    -  fi
    -  case "$sev" in
    -    warn)
    -      echo "WARN: $v failed rc=$rc — not a blocker; continuing" | tee -a "$SUMMARY"
    -      WARNINGS+=("$v:$rc")
    -      RESULTS+=("$v:WARN($rc)")
    -      ;;
    -    blocker)
    -      echo "BLOCKER: $v failed rc=$rc (release within ${BLOCKER_MAX_AGE_DAYS}d window)" | tee -a "$SUMMARY"
    -      RESULTS+=("$v:BLOCKER($rc)")
    -      overall=1
    -      if [ "$FAIL_FAST" = "1" ]; then
    -        echo "FAIL_FAST: stopping after blocker $v" | tee -a "$SUMMARY"
    -        return 2
    -      fi
    -      ;;
    -    soft)
    -      echo "FAIL: $v rc=$rc" | tee -a "$SUMMARY"
    -      RESULTS+=("$v:FAIL($rc)")
    -      overall=1
    -      if [ "$FAIL_FAST" = "1" ]; then
    -        echo "FAIL_FAST: stopping after $v" | tee -a "$SUMMARY"
    -        return 2
    -      fi
    -      ;;
    -  esac
    -  return 0
    -}
    -
    -for raw in "${VLIST[@]}"; do
    -  v=$(echo "$raw" | tr -d '[:space:]')
    -  [ -n "$v" ] || continue
    -  rc=0
    -  sev=warn
    -  stop=0
    -
    -  case "$v" in
    -    published|fdroid|stable-published)
    -      sev=$(severity_for_published_current)
    -      age=$(release_age_days "$STABLE_TAG" 2>/dev/null || echo "?")
    -      echo "published current: tag=$STABLE_TAG age_days=$age → severity=$sev" | tee -a "$SUMMARY"
    -      run_published_apk "published" "$STABLE_APK_URL" "$STABLE_APK_NAME" "$sev" || rc=$?
    -      ;;
    -    stable-self|stable|self-stable|release-self)
    -      # rebuild of stable: always warn (toolchain), age does not make self-build a blocker
    -      sev=warn
    -      run_selfbuild "stable-self" "$STABLE_TAG" warn || rc=$?
    -      ;;
    -    master|main)
    -      sev=warn
    -      run_selfbuild "master" "$MASTER_BRANCH" warn || rc=$?
    -      ;;
    -    fix|fix-branch|inference)
    -      sev=soft
    -      run_selfbuild "fix" "$FIX_BRANCH" soft || rc=$?
    -      ;;
    -    older-pub:*)
    -      # older-pub:VERSION:VC:SEV
    -      IFS=':' read -r _ vn apk_vc sev <<<"$v"
    -      : "${sev:=warn}"
    -      label="older-pub-${vn}"
    -      url="https://f-droid.org/repo/net.taler.wallet.fdroid_${apk_vc}.apk"
    -      name="net.taler.wallet.fdroid_${apk_vc}.apk"
    -      age=$(release_age_days "wallet-${vn}" 2>/dev/null || echo "?")
    -      echo "older published $vn age_days=$age severity=$sev" | tee -a "$SUMMARY"
    -      run_published_apk "$label" "$url" "$name" "$sev" || rc=$?
    -      v="$label"
    -      ;;
    -    older-self:*)
    -      # Explicit older-self is disabled by policy (self-build = current stable only).
    -      echo "SKIP $v — self-build only for current stable ($STABLE_TAG); use older-pub or published" | tee -a "$SUMMARY"
    -      RESULTS+=("$v:SKIP(no-selfbuild-older)")
    -      continue
    -      ;;
    -    *)
    -      # raw git ref: only master-like / fix via named tokens; bare wallet-* tags → no self-build
    -      if [[ "$v" =~ ^wallet-[0-9] ]]; then
    -        echo "SKIP self-build $v — only current stable self-build ($STABLE_TAG) is enabled" | tee -a "$SUMMARY"
    -        RESULTS+=("$v:SKIP(no-selfbuild-older)")
    -        continue
    -      fi
    -      sev=warn
    -      run_selfbuild "ref-${v//\//-}" "$v" warn || rc=$?
    -      ;;
    -  esac
    -
    -  record_result "$v" "$rc" "$sev" || stop=$?
    -  [ "$stop" = "2" ] && break
    -done
    -
    -echo "" | tee -a "$SUMMARY"
    -echo "=== matrix done (overall=$overall) ===" | tee -a "$SUMMARY"
    -echo "rule: age ≤ ${BLOCKER_MAX_AGE_DAYS}d → BLOCKER; 3/6/9/12mo milestones + older → WARN only" | tee -a "$SUMMARY"
    -for r in "${RESULTS[@]}"; do
    -  echo "  $r" | tee -a "$SUMMARY"
    -done
    -if [ "${#WARNINGS[@]}" -gt 0 ]; then
    -  echo "warnings (non-blocking):" | tee -a "$SUMMARY"
    -  for w in "${WARNINGS[@]}"; do
    -    echo "  WARN $w" | tee -a "$SUMMARY"
    -  done
    -fi
    -echo ""
    -echo "Artifacts: $MATRIX_OUT"
    -exit "$overall"
    diff --git a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh
    deleted file mode 100755
    index 1c20a63..0000000
    --- a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh
    +++ /dev/null
    @@ -1,366 +0,0 @@
    -#!/usr/bin/env bash
    -# GOA (and optionally stage) GUI chain on Android — adapted from
    -# taler-android branch **dev/hernani-inference/gui-workflows**
    -# (`scripts/goa-chain-emu.sh`, docs/gui-workflows.md).
    -#
    -# Linux + macOS: uses adb + uiautomator only (no Homebrew hard dependency).
    -# Upstream scripts are documented as macOS-only for SDK install paths.
    -#
    -# Flow:
    -#   1) API mint withdraw (explorer) → VIEW taler://withdraw → GUI taps
    -#   2) Merchant template POST → VIEW taler://pay → GUI taps
    -#
    -# Usage:
    -#   ./run-goa-gui-chain.sh
    -#   STACK=stage ./run-goa-gui-chain.sh
    -#   WITHDRAW_AMOUNTS='GOA:10' PAY_LIMIT=2 ./run-goa-gui-chain.sh
    -#   PKG=net.taler.wallet.fdroid.debug ./run-goa-gui-chain.sh --no-clear
    -#
    -# Env: ANDROID_SERIAL, PKG, EXP_PW_FILE, BANK, MERCHANT, INSTANCE,
    -#      WITHDRAW_AMOUNTS, PRODUCTS, PAY_LIMIT, SHOTDIR, PAUSE
    -#
    -# See GUI-AUTOMATION-NOTES.md
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -
    -CLEAR=1
    -for a in "$@"; do
    -  case "$a" in
    -    --no-clear) CLEAR=0 ;;
    -    -h|--help) sed -n '2,28p' "$0"; exit 0 ;;
    -  esac
    -done
    -
    -die() { echo "ERROR: $*" >&2; exit 1; }
    -step() { echo; echo "======== $* ========"; }
    -info() { echo "  $*"; }
    -pause() { sleep "${1:-${PAUSE:-0.8}}"; }
    -
    -# Platform flags + headless emulator defaults (see GUI-AUTOMATION-NOTES.md)
    -# shellcheck source=lib_android_env.sh
    -. "$ROOT/lib_android_env.sh"
    -[ "${AUTO_ANDROID}" = "1" ] || { echo "skipped: AUTO_ANDROID=${AUTO_ANDROID}"; exit 0; }
    -[ "${AUTO_GUI}" = "1" ] || { echo "skipped: AUTO_GUI=${AUTO_GUI} (chain is GUI)"; exit 0; }
    -
    -command -v adb >/dev/null || die "adb not found"
    -command -v python3 >/dev/null || die "python3 required"
    -
    -STACK="${STACK:-goa}"
    -case "$STACK" in
    -  goa|hacktivism)
    -    PKG="${PKG:-net.taler.wallet.fdroid.debug}"
    -    BANK="${BANK:-https://bank.hacktivism.ch}"
    -    MERCHANT="${MERCHANT:-https://taler.hacktivism.ch}"
    -    INSTANCE=$(printf '%s' "${INSTANCE:-goa-shop}" | tr '[:upper:]' '[:lower:]')
    -    DONATE_INSTANCE=$(printf '%s' "${DONATE_INSTANCE:-goa-demo-cp4zqk}" | tr '[:upper:]' '[:lower:]')
    -    DONATE_TEMPLATE="${DONATE_TEMPLATE:-goa-free}"
    -    DEFAULT_AMOUNTS=("GOA:10" "GOA:20")
    -    DEFAULT_PAYS=("product:orbit-sticker" "product:nebula-coffee" "product:voidwave-playlist" "donate:GOA:12")
    -    CUR_HINT=GOA
    -    ;;
    -  stage|testpaysan)
    -    PKG="${PKG:-net.taler.wallet.fdroid.debug}"
    -    BANK="${BANK:-https://stage.bank.lefrancpaysan.ch}"
    -    MERCHANT="${MERCHANT:-https://stage.monnaie.lefrancpaysan.ch}"
    -    INSTANCE=$(printf '%s' "${INSTANCE:-fermes-des-collines}" | tr '[:upper:]' '[:lower:]')
    -    DONATE_INSTANCE=$(printf '%s' "${DONATE_INSTANCE:-fermes-des-collines}" | tr '[:upper:]' '[:lower:]')
    -    DONATE_TEMPLATE="${DONATE_TEMPLATE:-don-panier-libre}"
    -    DEFAULT_AMOUNTS=("TESTPAYSAN:10" "TESTPAYSAN:20")
    -    DEFAULT_PAYS=("product:panier-legumes" "product:fromage-chevre" "product:oeufs-6")
    -    CUR_HINT=TESTPAYSAN
    -    ;;
    -  *) die "STACK must be goa|stage (got $STACK)" ;;
    -esac
    -
    -ACT="${ACT:-$PKG/net.taler.wallet.main.MainActivity}"
    -EXP_USER="${EXP_USER:-explorer}"
    -PAY_LIMIT="${PAY_LIMIT:-4}"
    -PAUSE="${PAUSE:-0.8}"
    -SHOTDIR="${SHOTDIR:-$ROOT/out-gui-chain/$(date +%Y%m%d-%H%M%S)-$STACK}"
    -mkdir -p "$SHOTDIR"
    -
    -# Explorer password (same search as taler-monitoring ladder)
    -if [[ -z "${EXP_PW_FILE:-}" ]]; then
    -  for cand in \
    -    "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt" \
    -    "${HOME}/.config/taler-landing/bank-explorer-password.txt"
    -  do
    -    [[ -f "$cand" ]] && EXP_PW_FILE="$cand" && break
    -  done
    -fi
    -# stage: try stagepaysan secret via ssh if missing
    -if [[ ! -f "${EXP_PW_FILE:-}" && "$STACK" = "stage" ]]; then
    -  for host in francpaysan-stage-user francpaysan-host; do
    -    tmp=$(mktemp)
    -    if ssh -o BatchMode=yes -o ConnectTimeout=10 "$host" \
    -      'tr -d "\n\r" "$tmp" 2>/dev/null && [[ -s "$tmp" ]]; then
    -      EXP_PW_FILE="$tmp"
    -      info "explorer password via $host"
    -      break
    -    fi
    -    rm -f "$tmp"
    -  done
    -fi
    -[[ -n "${EXP_PW_FILE:-}" && -f "$EXP_PW_FILE" ]] || die "set EXP_PW_FILE (explorer password)"
    -
    -# Headless AVD by default (no host window). WINDOWED=1 for a visible emulator.
    -SERIAL="${SERIAL:-${ANDROID_SERIAL:-}}"
    -android_ensure_device || die "no adb device (./start-android-emulator.sh --wait)"
    -export ANDROID_SERIAL="$SERIAL"
    -info "ANDROID_SERIAL=$ANDROID_SERIAL  STACK=$STACK  PKG=$PKG  SHOTDIR=$SHOTDIR  headless=$EMULATOR_HEADLESS gpu=$EMULATOR_GPU"
    -ADB=(adb -s "$ANDROID_SERIAL")
    -
    -adb_sh() { "${ADB[@]}" shell "$@"; }
    -
    -shot() {
    -  local n="$1"
    -  adb_sh screencap -p "/sdcard/demo-$n.png" 2>/dev/null || true
    -  "${ADB[@]}" pull "/sdcard/demo-$n.png" "$SHOTDIR/$n.png" >/dev/null 2>&1 || true
    -  info "[shot] $SHOTDIR/$n.png"
    -}
    -
    -tap_text() {
    -  local label="$1"
    -  adb_sh uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || return 1
    -  "${ADB[@]}" pull /sdcard/ui.xml "$SHOTDIR/ui.xml" >/dev/null 2>&1 || return 1
    -  SERIAL="$ANDROID_SERIAL" python3 - "$label" "$SHOTDIR/ui.xml" <<'PY'
    -import os, re, subprocess, sys
    -from pathlib import Path
    -label, path = sys.argv[1], sys.argv[2]
    -serial = os.environ.get("SERIAL") or os.environ.get("ANDROID_SERIAL") or ""
    -xml = Path(path).read_text(errors="replace")
    -# ANR dismiss
    -if "isn't responding" in xml or "reagiert nicht" in xml.lower():
    -    for lab in ("Wait", "Warten"):
    -        for m in re.finditer(rf'text="{lab}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml):
    -            x = (int(m.group(1))+int(m.group(3)))//2
    -            y = (int(m.group(2))+int(m.group(4)))//2
    -            subprocess.run(["adb","-s",serial,"shell","input","tap",str(x),str(y)], check=False)
    -            print(f"anr-wait @{x},{y}")
    -            sys.exit(0)
    -for pat in [
    -    rf'text="{re.escape(label)}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
    -    rf'text="[^"]*{re.escape(label)}[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
    -    rf'content-desc="[^"]*{re.escape(label)}[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
    -]:
    -    for m in re.finditer(pat, xml, re.I):
    -        chunk = xml[max(0, m.start()-120):m.end()]
    -        if 'enabled="false"' in chunk:
    -            continue
    -        x = (int(m.group(1))+int(m.group(3)))//2
    -        y = (int(m.group(2))+int(m.group(4)))//2
    -        print(f"tap {label!r} @{x},{y}")
    -        cmd = ["adb","shell","input","tap",str(x),str(y)]
    -        if serial:
    -            cmd = ["adb","-s",serial,"shell","input","tap",str(x),str(y)]
    -        subprocess.run(cmd, check=False)
    -        sys.exit(0)
    -print("miss", label)
    -sys.exit(1)
    -PY
    -}
    -
    -mint_withdraw() {
    -  local amount="$1"
    -  EXP_USER="$EXP_USER" EXP_PW_FILE="$EXP_PW_FILE" BANK="$BANK" \
    -  python3 - "$amount" <<'PY'
    -import base64, json, os, ssl, sys, urllib.request
    -from pathlib import Path
    -amount = sys.argv[1]
    -bank = os.environ["BANK"].rstrip("/")
    -user = os.environ["EXP_USER"]
    -pw = Path(os.environ["EXP_PW_FILE"]).read_text().strip()
    -auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
    -ctx = ssl.create_default_context()
    -
    -def req(method, url, data=None, headers=None):
    -    h = dict(headers or {})
    -    body = None
    -    if data is not None:
    -        body = json.dumps(data).encode()
    -        h["Content-Type"] = "application/json"
    -    r = urllib.request.Request(url, data=body, headers=h, method=method)
    -    with urllib.request.urlopen(r, context=ctx, timeout=25) as resp:
    -        return json.load(resp)
    -
    -tok = req(
    -    "POST",
    -    f"{bank}/accounts/{user}/token",
    -    {"scope": "readwrite", "duration": {"d_us": 3600_000_000}},
    -    {"Authorization": auth},
    -)["access_token"]
    -wd = req(
    -    "POST",
    -    f"{bank}/accounts/{user}/withdrawals",
    -    {"amount": amount},
    -    {"Authorization": f"Bearer {tok}"},
    -)
    -print(wd.get("taler_withdraw_uri") or wd.get("talerWithdrawUri") or "")
    -PY
    -}
    -
    -mint_pay() {
    -  local mode="$1"
    -  MERCHANT="$MERCHANT" INSTANCE="$INSTANCE" \
    -  DONATE_INSTANCE="$DONATE_INSTANCE" DONATE_TEMPLATE="$DONATE_TEMPLATE" \
    -  python3 - "$mode" <<'PY'
    -import json, os, ssl, sys, urllib.error, urllib.request
    -mode = sys.argv[1]
    -merchant = os.environ["MERCHANT"].rstrip("/")
    -inst = os.environ["INSTANCE"]
    -d_inst = os.environ["DONATE_INSTANCE"]
    -d_tmpl = os.environ["DONATE_TEMPLATE"]
    -ctx = ssl.create_default_context()
    -
    -def post(url, data):
    -    body = json.dumps(data).encode()
    -    r = urllib.request.Request(url, data=body, method="POST",
    -        headers={"Content-Type": "application/json"})
    -    try:
    -        with urllib.request.urlopen(r, context=ctx, timeout=25) as resp:
    -            return resp.status, json.load(resp)
    -    except urllib.error.HTTPError as e:
    -        raw = e.read() if hasattr(e, "read") else b""
    -        try:
    -            return getattr(e, "code", None), json.loads(raw)
    -        except Exception as err:
    -            raise SystemExit(f"POST {url} failed: {e} body={raw[:200]!r}") from err
    -
    -if mode.startswith("product:"):
    -    pid = mode.split(":", 1)[1]
    -    st, d = post(f"{merchant}/instances/{inst}/templates/{pid}", {})
    -    use_inst = inst
    -elif mode.startswith("donate:"):
    -    amt = mode.split(":", 1)[1]
    -    st, d = post(f"{merchant}/instances/{d_inst}/templates/{d_tmpl}", {"amount": amt})
    -    use_inst = d_inst
    -else:
    -    raise SystemExit(f"bad mode {mode!r}")
    -
    -if "taler_pay_uri" in d:
    -    print(d["taler_pay_uri"]); raise SystemExit(0)
    -oid, tok = d.get("order_id"), d.get("token")
    -if not oid or not tok:
    -    raise SystemExit(f"no order: {d!r}"[:200])
    -host = merchant.replace("https://","").replace("http://","").split("/")[0]
    -print(f"taler://pay/{host}/instances/{use_inst}/{oid}/?c={tok}")
    -PY
    -}
    -
    -click_through() {
    -  local labels=("$@")
    -  local i lab misses=0
    -  # Prefer dismissing ANR before hunting Confirm (low-RAM hosts thrash System UI)
    -  for i in 1 2 3 4 5 6; do
    -    if tap_text "Wait" 2>/dev/null || tap_text "Warten" 2>/dev/null; then
    -      info "dismissed ANR (Wait)"
    -      pause 2.0
    -      misses=0
    -      continue
    -    fi
    -    local hit=0
    -    for lab in "${labels[@]}"; do
    -      if tap_text "$lab" 2>/dev/null; then
    -        hit=1
    -        misses=0
    -        pause 0.9
    -        break
    -      fi
    -    done
    -    if [[ "$hit" -eq 0 ]]; then
    -      misses=$((misses + 1))
    -      # stop early if hierarchy has nothing useful (avoid burning order deadlines)
    -      [[ "$misses" -ge 3 ]] && break
    -    fi
    -    pause 0.5
    -  done
    -}
    -
    -if [[ -n "${WITHDRAW_AMOUNTS:-}" ]]; then
    -  # shellcheck disable=SC2206
    -  AMOUNTS=($WITHDRAW_AMOUNTS)
    -else
    -  AMOUNTS=("${DEFAULT_AMOUNTS[@]}")
    -fi
    -if [[ -n "${PRODUCTS:-}" ]]; then
    -  PAYS=()
    -  for p in $PRODUCTS; do PAYS+=("product:$p"); done
    -else
    -  PAYS=("${DEFAULT_PAYS[@]}")
    -fi
    -
    -############################
    -step "0 device + wallet ($STACK / $CUR_HINT)"
    -adb_sh input keyevent KEYCODE_WAKEUP 2>/dev/null || true
    -adb_sh wm dismiss-keyguard 2>/dev/null || true
    -if [[ "$CLEAR" -eq 1 ]]; then
    -  info "pm clear $PKG"
    -  adb_sh pm clear "$PKG" >/dev/null 2>&1 || true
    -fi
    -adb_sh am force-stop "$PKG" 2>/dev/null || true
    -pause 0.4
    -# Prefer explicit MainActivity (from gui-workflows); fall back to VIEW launcher
    -if ! adb_sh am start -n "$ACT" >/dev/null 2>&1; then
    -  info "MainActivity path failed — monkey launch"
    -  adb_sh monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
    -fi
    -pause 2.0
    -shot "00-start"
    -
    -############################
    -step "2 withdraw chain"
    -idx=0
    -for AMT in "${AMOUNTS[@]}"; do
    -  idx=$((idx + 1))
    -  step "2.$idx withdraw $AMT"
    -  URI="$(mint_withdraw "$AMT" | head -1)"
    -  [[ -n "$URI" ]] || die "mint failed for $AMT"
    -  # wallet / monitoring QR rules: strip default ports
    -  URI=$(printf '%s' "$URI" | sed 's/:443\//\//g; s/:443?/?/g; s/:80\//\//g')
    -  info "URI=$URI"
    -  echo "$URI" >>"$SHOTDIR/withdraw-uris.txt"
    -  adb_sh am start -a android.intent.action.VIEW -d "$URI" "$PKG" >/dev/null
    -  pause 1.5
    -  shot "w${idx}-open"
    -  click_through "Accept" "I accept" "Accept terms" "Confirm" "Continue" "Withdraw" "Next" "OK" "Agree" "Bestätigen" "Abheben" "Akzeptieren"
    -  pause 3.0
    -  shot "w${idx}-after"
    -  adb_sh am start -n "$ACT" >/dev/null 2>&1 || true
    -  pause 0.6
    -  tap_text "Assets" 2>/dev/null || true
    -  pause 0.4
    -  shot "w${idx}-assets"
    -done
    -
    -############################
    -step "3 pay chain (limit=$PAY_LIMIT)"
    -pidx=0
    -for PAY in "${PAYS[@]}"; do
    -  pidx=$((pidx + 1))
    -  [[ "$pidx" -gt "$PAY_LIMIT" ]] && break
    -  step "3.$pidx pay $PAY"
    -  if ! PAYURI="$(mint_pay "$PAY" | head -1)"; then
    -    info "mint_pay failed for $PAY — skip"
    -    continue
    -  fi
    -  [[ -n "$PAYURI" ]] || { info "empty pay uri — skip"; continue; }
    -  PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g; s/:80\//\//g')
    -  info "PAYURI=$PAYURI"
    -  echo "$PAYURI" >>"$SHOTDIR/pay-uris.txt"
    -  adb_sh am start -a android.intent.action.VIEW -d "$PAYURI" "$PKG" >/dev/null
    -  pause 1.5
    -  shot "p${pidx}-open"
    -  click_through "Pay" "Confirm" "Accept" "Continue" "OK" "Next" "Bezahlen" "Bestätigen" "Payer"
    -  pause 1.5
    -  shot "p${pidx}-after"
    -  adb_sh am start -n "$ACT" >/dev/null 2>&1 || true
    -  pause 0.5
    -done
    -
    -step "4 done → $SHOTDIR"
    -ls -la "$SHOTDIR" | tail -40
    -adb_sh dumpsys window 2>/dev/null | grep mCurrentFocus | head -1 || true
    -info "PKG=$PKG amounts=${AMOUNTS[*]} pays(limit)=$PAY_LIMIT"
    -echo "See GUI-AUTOMATION-NOTES.md (ported from taler-android dev/hernani-inference/gui-workflows)"
    diff --git a/scripts/taler-monitoring/android-test/start-android-emulator.sh b/scripts/taler-monitoring/android-test/start-android-emulator.sh
    deleted file mode 100755
    index e62b5f4..0000000
    --- a/scripts/taler-monitoring/android-test/start-android-emulator.sh
    +++ /dev/null
    @@ -1,90 +0,0 @@
    -#!/usr/bin/env bash
    -# Start the Taler Android AVD. **Default = headless** (no host window).
    -# Guest GLES still runs via SwiftShader so uiautomator / screenshots work.
    -#
    -# Usage:
    -#   ./start-android-emulator.sh              # headless, background
    -#   ./start-android-emulator.sh --wait       # headless + wait for boot
    -#   ./start-android-emulator.sh --windowed   # show emulator window
    -#   EMULATOR_HEADLESS=0 ./start-android-emulator.sh
    -#   EMULATOR_GPU=host WINDOWED=1 ./start-android-emulator.sh
    -#
    -# Env: EMULATOR_AVD EMULATOR_MEMORY EMULATOR_CORES EMULATOR_GPU
    -#      EMULATOR_HEADLESS EMULATOR_LOG EMULATOR_BOOT_TIMEOUT EMULATOR_EXTRA_ARGS
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib_android_env.sh
    -. "$ROOT/lib_android_env.sh"
    -
    -WAIT=0
    -for a in "$@"; do
    -  case "$a" in
    -    --wait) WAIT=1 ;;
    -    --windowed| --gui) EMULATOR_HEADLESS=0; export EMULATOR_HEADLESS ;;
    -    --headless) EMULATOR_HEADLESS=1; export EMULATOR_HEADLESS ;;
    -    -h|--help)
    -      sed -n '2,18p' "$0"
    -      exit 0
    -      ;;
    -  esac
    -done
    -
    -command -v emulator >/dev/null || {
    -  echo "emulator not found (ANDROID_HOME=$ANDROID_HOME)" >&2
    -  exit 2
    -}
    -command -v adb >/dev/null || {
    -  echo "adb not found" >&2
    -  exit 2
    -}
    -
    -# Already booted?
    -if serial=$(android_adb_serial); [ -n "$serial" ]; then
    -  boot=$(adb -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)
    -  if [ "$boot" = "1" ]; then
    -    echo "already running: $serial (boot_completed=1) — headless default still applies to *new* starts"
    -    exit 0
    -  fi
    -fi
    -
    -# Avoid pkill patterns that match this wrapper's argv (self-kill).
    -mapfile -t EMU_ARGS < <(android_emulator_args "$EMULATOR_AVD")
    -echo "starting: emulator ${EMU_ARGS[*]}"
    -echo "  headless=$EMULATOR_HEADLESS  gpu=$EMULATOR_GPU  log=$EMULATOR_LOG"
    -
    -if [ "${EMULATOR_HEADLESS}" = "1" ]; then
    -  export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}"
    -  # Do not require DISPLAY
    -  unset DISPLAY || true
    -fi
    -
    -mkdir -p "$(dirname "$EMULATOR_LOG")"
    -# Background; do not use pkill -f emulator from callers matching full cmdline
    -nohup emulator "${EMU_ARGS[@]}" >"$EMULATOR_LOG" 2>&1 &
    -echo "emulator pid=$!  log=$EMULATOR_LOG"
    -
    -if [ "$WAIT" != "1" ]; then
    -  echo "not waiting (pass --wait for boot). adb devices when ready."
    -  exit 0
    -fi
    -
    -echo "waiting for boot (timeout ${EMULATOR_BOOT_TIMEOUT}s)…"
    -deadline=$((SECONDS + EMULATOR_BOOT_TIMEOUT))
    -serial=""
    -while (( SECONDS < deadline )); do
    -  adb wait-for-device 2>/dev/null || true
    -  serial=$(android_adb_serial || true)
    -  if [ -n "$serial" ]; then
    -    boot=$(adb -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)
    -    if [ "$boot" = "1" ]; then
    -      echo "BOOT_OK serial=$serial headless=$EMULATOR_HEADLESS gpu=$EMULATOR_GPU"
    -      exit 0
    -    fi
    -  fi
    -  sleep 2
    -done
    -
    -echo "boot timeout — last log:" >&2
    -tail -30 "$EMULATOR_LOG" >&2 || true
    -exit 3
    diff --git a/scripts/taler-monitoring/check_apt_deploy.sh b/scripts/taler-monitoring/check_apt_deploy.sh
    deleted file mode 100755
    index 5240e82..0000000
    --- a/scripts/taler-monitoring/check_apt_deploy.sh
    +++ /dev/null
    @@ -1,279 +0,0 @@
    -#!/usr/bin/env bash
    -# check_apt_deploy.sh — podman apt-src merchant deploy tests on koopa.
    -#
    -# Containers (default):
    -#   Fresh (rebuild when repo versions change):
    -#     koopa-taler-deploy-test-apt-src-trixie
    -#     koopa-taler-deploy-test-apt-src-trixie-testing
    -#   Upgrade track (initial install, then mytops-style apt upgrade):
    -#     koopa-taler-deploy-test-apt-src-trixie-upgrade
    -#     koopa-taler-deploy-test-apt-src-trixie-testing-upgrade
    -#
    -# Env:
    -#   APT_DEPLOY_CONTAINERS  "name:suite:mode …"  mode=fresh|upgrade (optional, default fresh)
    -#   APT_DEPLOY_SKIP=1
    -#   APT_DEPLOY_PODMAN=podman
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -
    -if [ "${APT_DEPLOY_SKIP:-0}" = "1" ]; then
    -  echo "[INFO]  skip phase aptdeploy (APT_DEPLOY_SKIP=1)"
    -  exit 0
    -fi
    -
    -set_area aptdeploy
    -section "aptdeploy · podman apt-src merchant (fresh + upgrade tracks)"
    -
    -PODMAN_BIN="${APT_DEPLOY_PODMAN:-podman}"
    -DEFAULT_LIST="\
    -koopa-taler-deploy-test-apt-src-trixie:trixie:fresh \
    -koopa-taler-deploy-test-apt-src-trixie-testing:trixie-testing:fresh \
    -koopa-taler-deploy-test-apt-src-trixie-upgrade:trixie:upgrade \
    -koopa-taler-deploy-test-apt-src-trixie-testing-upgrade:trixie-testing:upgrade"
    -LIST="${APT_DEPLOY_CONTAINERS:-$DEFAULT_LIST}"
    -
    -if ! command -v "$PODMAN_BIN" >/dev/null 2>&1; then
    -  err "podman" "podman binary missing ($PODMAN_BIN)"
    -  summary
    -  exit 1
    -fi
    -ok "podman" "$("$PODMAN_BIN" --version 2>/dev/null | head -1)"
    -info "host" "$(hostname 2>/dev/null || echo unknown) · whoami=$(whoami)"
    -
    -fail_any=0
    -
    -print_pkg_table() {
    -  local name=$1
    -  local rows
    -  rows=$("$PODMAN_BIN" exec "$name" bash -lc '
    -    echo "| package | version |"
    -    echo "|---------|---------|"
    -    for p in taler-merchant libtalermerchant libtalerexchange libdonau libgnunet \
    -             taler-merchant-webui taler-merchant-typst taler-terms-generator; do
    -      v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo missing)
    -      echo "| $p | $v |"
    -    done
    -  ' 2>/dev/null || echo "| (query failed) | |")
    -  info "pkg-table" "$name"
    -  # emit as plain lines so HTML log keeps the table
    -  while IFS= read -r line; do
    -    [ -n "$line" ] || continue
    -    info "pkg" "$line"
    -  done <<<"$rows"
    -}
    -
    -check_ldd_deep() {
    -  local name=$1
    -  local out
    -  out=$("$PODMAN_BIN" exec "$name" bash -lc '
    -    set +e
    -    bin=$(command -v taler-merchant-httpd)
    -    echo "=== ldd taler-merchant-httpd ==="
    -    ldd "$bin" 2>&1 | grep -E "libtalerutil|libdonau|libgnunet|not found" || true
    -    echo "=== ldd libdonau (if present) ==="
    -    for f in /usr/lib/*/libdonau.so* /usr/lib/*/libdonauutil.so*; do
    -      [ -e "$f" ] || continue
    -      echo "-- $f --"
    -      ldd "$f" 2>&1 | grep -E "libtalerutil|not found" || true
    -    done
    -    echo "=== libtalerutil files ==="
    -    ls -la /usr/lib/*/libtalerutil* 2>/dev/null || echo "(none)"
    -  ' 2>/dev/null || echo "ldd probe failed")
    -  while IFS= read -r line; do
    -    [ -n "$line" ] || continue
    -    info "ldd" "$line"
    -  done <<<"$out"
    -  if echo "$out" | grep -q 'not found'; then
    -    err "ldd" "shared library missing in $name" "see ldd lines above"
    -    return 1
    -  fi
    -  return 0
    -}
    -
    -check_systemd() {
    -  local name=$1
    -  local out active_target active_httpd enabled
    -  out=$("$PODMAN_BIN" exec "$name" bash -lc '
    -    set +e
    -    echo "system: $(systemctl is-system-running 2>&1)"
    -    echo "target_enabled: $(systemctl is-enabled taler-merchant.target 2>&1)"
    -    echo "target_active: $(systemctl is-active taler-merchant.target 2>&1)"
    -    echo "httpd_active: $(systemctl is-active taler-merchant-httpd.service 2>&1)"
    -    echo "httpd_enabled: $(systemctl is-enabled taler-merchant-httpd.service 2>&1)"
    -    systemctl start taler-merchant.target 2>&1 | tail -5
    -    sleep 2
    -    echo "after_start_target: $(systemctl is-active taler-merchant.target 2>&1)"
    -    echo "after_start_httpd: $(systemctl is-active taler-merchant-httpd.service 2>&1)"
    -    systemctl --failed --no-legend 2>/dev/null | grep -i taler || echo "failed_taler: none"
    -  ' 2>/dev/null || echo "systemd probe failed")
    -  while IFS= read -r line; do
    -    [ -n "$line" ] || continue
    -    info "systemd" "$line"
    -  done <<<"$out"
    -
    -  active_httpd=$(echo "$out" | sed -n 's/^after_start_httpd: //p' | tail -1)
    -  if [ "$active_httpd" = "active" ]; then
    -    ok "systemd httpd" "taler-merchant-httpd active after start taler-merchant.target"
    -    return 0
    -  fi
    -  # soft if target not shipped as enabled in container — still ERROR for deploy smoke
    -  err "systemd httpd" "taler-merchant-httpd not active" "after_start_httpd=${active_httpd:-?} · see systemd lines"
    -  return 1
    -}
    -
    -check_merchant_basics() {
    -  local name=$1
    -  local out code
    -  # unix socket or curl localhost if httpd listens
    -  out=$("$PODMAN_BIN" exec "$name" bash -lc '
    -    set +e
    -    echo "=== binaries ==="
    -    command -v taler-merchant-httpd taler-merchant-dbinit taler-config 2>/dev/null
    -    echo "=== --version ==="
    -    taler-merchant-httpd --version 2>&1
    -    echo "=== config probe ==="
    -    # try common paths without TLS
    -    for u in \
    -      http://127.0.0.1:9966/config \
    -      http://127.0.0.1:8081/config \
    -      http://127.0.0.1/config
    -    do
    -      code=$(curl -sS -m 2 -o /tmp/mcfg -w "%{http_code}" "$u" 2>/dev/null || echo 000)
    -      echo "curl $u -> $code"
    -      [ "$code" = "200" ] && head -c 120 /tmp/mcfg && echo
    -    done
    -    sock=$(ls /run/taler-merchant/httpd/*.sock 2>/dev/null | head -1)
    -    if [ -n "$sock" ]; then
    -      echo "socket: $sock"
    -      code=$(curl -sS -m 2 --unix-socket "$sock" -o /tmp/mcfg -w "%{http_code}" http://localhost/config 2>/dev/null || echo 000)
    -      echo "curl --unix-socket -> $code"
    -      [ "$code" = "200" ] && head -c 160 /tmp/mcfg && echo
    -    else
    -      echo "socket: (none under /run/taler-merchant/httpd/)"
    -    fi
    -  ' 2>/dev/null || echo "basics probe failed")
    -
    -  while IFS= read -r line; do
    -    [ -n "$line" ] || continue
    -    info "basics" "$line"
    -  done <<<"$out"
    -
    -  if echo "$out" | grep -qi 'error while loading shared libraries'; then
    -    err "httpd" "shared library error on --version" "see basics/ldd"
    -    return 1
    -  fi
    -  if ! echo "$out" | grep -q 'taler-merchant-httpd --version\|v\.\|taler-merchant-httpd'; then
    -    # version line varies; success if exit was ok — check via separate exec
    -    :
    -  fi
    -
    -  set +e
    -  "$PODMAN_BIN" exec "$name" taler-merchant-httpd --version >/dev/null 2>&1
    -  local ec=$?
    -  set -e
    -  if [ "$ec" -eq 0 ]; then
    -    ok "httpd --version" "exit 0"
    -  else
    -    err "httpd --version" "exit $ec"
    -    return 1
    -  fi
    -
    -  # /config is nice-to-have if stack is fully configured
    -  if echo "$out" | grep -qE 'curl .* -> 200'; then
    -    ok "merchant /config" "HTTP 200 (local)"
    -  else
    -    warn "merchant /config" "no local HTTP 200 (unit may need BASE_URL/db — still report version/ldd)"
    -  fi
    -  return 0
    -}
    -
    -check_one() {
    -  local name="$1" expect_suite="$2" mode="${3:-fresh}"
    -  local st suite_line pkgs
    -
    -  # group ids: aptdeploy.trixie-01 / aptdeploy.trixie-testing-upgrade-01
    -  set_group "${expect_suite}${mode:+-$mode}"
    -  section "aptdeploy · $name (suite=$expect_suite mode=$mode)"
    -
    -  if ! "$PODMAN_BIN" container exists "$name" 2>/dev/null; then
    -    err "container" "$name missing" "run ensure-apt-deploy-test-containers.sh"
    -    fail_any=1
    -    return
    -  fi
    -  st=$("$PODMAN_BIN" inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo unknown)
    -  if [ "$st" != "running" ]; then
    -    err "container" "$name not running" "status=$st"
    -    fail_any=1
    -    return
    -  fi
    -  ok "container" "$name running (mode=$mode)"
    -
    -  suite_line=$("$PODMAN_BIN" exec "$name" bash -lc \
    -    'grep -h "^Suites:" /etc/apt/sources.list.d/*taler* 2>/dev/null | head -1' 2>/dev/null || true)
    -  if echo "$suite_line" | grep -qE "Suites:[[:space:]]*${expect_suite}([[:space:]]|$)"; then
    -    ok "apt suite" "$expect_suite"
    -  else
    -    err "apt suite" "expected $expect_suite" "got: ${suite_line:-empty}"
    -    fail_any=1
    -  fi
    -
    -  print_pkg_table "$name"
    -
    -  if ! check_merchant_basics "$name"; then
    -    fail_any=1
    -    check_ldd_deep "$name" || fail_any=1
    -  else
    -    # still show ldd summary as info when ok
    -    ldd_out=$("$PODMAN_BIN" exec "$name" bash -lc \
    -      'ldd "$(command -v taler-merchant-httpd)" 2>&1 | grep -E "libtalerutil|not found" || true' 2>/dev/null || true)
    -    if echo "$ldd_out" | grep -q 'not found'; then
    -      err "libtalerutil" "not found" "$ldd_out"
    -      check_ldd_deep "$name" || true
    -      fail_any=1
    -    else
    -      info "libtalerutil" "$(echo "$ldd_out" | tr '\n' ' ')"
    -    fi
    -  fi
    -
    -  if ! check_systemd "$name"; then
    -    fail_any=1
    -    check_ldd_deep "$name" || true
    -  fi
    -
    -  # Disk free inside this deploy-test container (+ host once, first container only)
    -  if [ "${_APT_DEPLOY_HOST_DISK_DONE:-0}" != "1" ]; then
    -    set_group disk
    -    mon_disk_check_host "host" || fail_any=1
    -    _APT_DEPLOY_HOST_DISK_DONE=1
    -  fi
    -  set_group disk
    -  if ! mon_disk_check_podman "$name" "$PODMAN_BIN"; then
    -    fail_any=1
    -  fi
    -}
    -
    -for entry in $LIST; do
    -  # name:suite or name:suite:mode
    -  cname=${entry%%:*}
    -  rest=${entry#*:}
    -  case "$rest" in
    -    *:*)
    -      suite=${rest%%:*}
    -      mode=${rest#*:}
    -      ;;
    -    *)
    -      suite=$rest
    -      mode=fresh
    -      ;;
    -  esac
    -  check_one "$cname" "$suite" "$mode"
    -done
    -
    -summary
    -if [ "$fail_any" -ne 0 ]; then
    -  exit 1
    -fi
    -exit 0
    diff --git a/scripts/taler-monitoring/check_auth401.sh b/scripts/taler-monitoring/check_auth401.sh
    deleted file mode 100755
    index dfca606..0000000
    --- a/scripts/taler-monitoring/check_auth401.sh
    +++ /dev/null
    @@ -1,704 +0,0 @@
    -#!/usr/bin/env bash
    -# auth401 — merchant HTTP 401 / case-sensitivity matrix (WebUI-shaped flows)
    -#
    -# Exercises the same APIs the merchant SPA uses for:
    -#   • self-provision signup (POST /instances) — mixed-case id
    -#   • login / createAccessToken (POST …/private/token) — Basic user case
    -#   • password change (POST …/private/auth)
    -#   • admin-create (POST /management/instances) when admin bearer available
    -#   • access-token + private/ with Bearer
    -#
    -# Backend fact (#11340): Basic-auth *username* is case-sensitive; URL path is
    -# usually not. SPA must always lowercase the username (createAccessToken, signup
    -# id, admin-create, pw change re-login). These tests assert the *raw API*
    -# behaviour so a missing SPA lowercasing shows up as 401 where 200 is wanted
    -# only after lowercasing.
    -#
    -#   ./taler-monitoring.sh -d stage.monnaie.lefrancpaysan.ch auth401
    -#   ./taler-monitoring.sh -d hacktivism.ch auth401
    -#
    -# Continue through all groups / collect every ERROR (optional):
    -#   AUTH401_CONTINUE=1 ./taler-monitoring.sh -d stage… auth401
    -#   CONTINUE_ON_ERROR=1 ./taler-monitoring.sh -d stage… auth401   # same
    -#
    -# Env: see secrets.env.example (AUTH401_*).
    -#
    -# NOTE: we intentionally do NOT use `set -e`. A single failed curl/grep under
    -# set -e would abort with no ERROR summary — the opposite of what monitoring
    -# needs. Optional CONTINUE_ON_ERROR still controls hard prereq early-exit.
    -set -uo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -load_monitoring_secrets_env 2>/dev/null || true
    -
    -tmp=$(mktemp -d)
    -AUTH401_FINISHED=0
    -_auth401_finish() {
    -  [ "${AUTH401_FINISHED}" = "1" ] && return 0
    -  AUTH401_FINISHED=1
    -  # summary() returns non-zero when FAIL_N>0 — never let that skip the print
    -  summary || true
    -  rm -rf "${tmp:-}"
    -  if [ "${FAIL_N:-0}" -gt 0 ]; then
    -    exit 1
    -  fi
    -  exit 0
    -}
    -trap '_auth401_finish' EXIT
    -
    -# ---------------------------------------------------------------------------
    -# Parameters
    -# ---------------------------------------------------------------------------
    -: "${AUTH401_TIMEOUT:=${TIMEOUT:-20}}"
    -: "${AUTH401_CREATE:=1}"
    -: "${AUTH401_INSTANCE:=}"
    -: "${AUTH401_PASSWORD:=${MERCHANT_INSTANCE_PASSWORD:-${AUTH401_PASS:-}}}"
    -: "${AUTH401_ID_PREFIX:=mon401}"
    -: "${AUTH401_SKIP_IF_MFA:=1}"
    -: "${AUTH401_BEARER:=${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}}"
    -: "${AUTH401_CREATE_PASSWORD:=${AUTH401_PASSWORD:-Mon401-Test-Pass!}}"
    -: "${AUTH401_DURABLE_ID:=mon401}"
    -# Prefer durable alone (skip throwaway) — default 0 so full matrix always runs
    -: "${AUTH401_PREFER_DURABLE:=0}"
    -# Run expanded groups (signup/login/pwchange/admin/webui). Default 1.
    -: "${AUTH401_FULL:=1}"
    -# Admin bearer for POST /management/instances (optional)
    -: "${AUTH401_ADMIN_TOKEN:=${MERCHANT_ADMIN_TOKEN:-}}"
    -: "${AUTH401_ADMIN_PASSWORD:=}"
    -: "${AUTH401_ADMIN_USER:=admin}"
    -# 1 = do not abort on hard prereq fail; run remaining groups (default 0)
    -# Alias: CONTINUE_ON_ERROR=1
    -: "${AUTH401_CONTINUE:=${CONTINUE_ON_ERROR:-0}}"
    -# Never rotate password on durable account (default 1 — keep mon401.password valid)
    -: "${AUTH401_PWCHANGE_THROWAY_ONLY:=1}"
    -
    -TIMEOUT="$AUTH401_TIMEOUT"
    -export TIMEOUT
    -
    -_auth401_abort_or_continue() {
    -  local why="${1:-prereq failed}"
    -  if [ "${AUTH401_CONTINUE}" = "1" ]; then
    -    warn "continue" "${why} (AUTH401_CONTINUE=1 / CONTINUE_ON_ERROR=1)"
    -    return 0
    -  fi
    -  # hard stop — EXIT trap prints summary + all ERRORS collected so far
    -  exit 1
    -}
    -
    -BASE="${MERCHANT_PUBLIC%/}"
    -if [ -z "$BASE" ]; then
    -  echo "MERCHANT_PUBLIC empty — set -d domain or --merchant" >&2
    -  exit 2
    -fi
    -
    -# ---------------------------------------------------------------------------
    -# Helpers
    -# ---------------------------------------------------------------------------
    -expect_http() {
    -  local label="$1" want="$2" got="$3" detail="${4:-}"
    -  case ",$want," in
    -    *",$got,"*) ok "$label" "HTTP $got${detail:+ · $detail}" ;;
    -    *) fail "$label" "HTTP $got (want $want)${detail:+ · $detail}" ;;
    -  esac
    -}
    -
    -to_lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
    -to_upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]'; }
    -
    -# First char upper, rest lower → synthetic MIX for Basic user (always ≠ low if len>0)
    -synth_mix() {
    -  python3 -c "s='''$1'''; print((s[:1].upper()+s[1:].lower()) if s else s)"
    -}
    -
    -# First 3 upper (partial mix)
    -synth_partmix() {
    -  python3 -c "s='''$1'''; print(s[:3].upper()+s[3:] if len(s)>=3 else s.upper())"
    -}
    -
    -# Normalize curl status: "200", "000", never "200000" / "000000"
    -_http_norm() {
    -  local raw="$1" dig
    -  dig=$(printf '%s' "$raw" | tr -cd '0-9')
    -  if [ -z "$dig" ]; then
    -    printf '000'
    -  else
    -    # first 3 digits = real code (curl 200 then || echo 000 → 200000)
    -    printf '%s' "$dig" | head -c 3
    -  fi
    -}
    -
    -token_http() {
    -  local path_id="$1" user="$2" password="$3"
    -  local b64 raw
    -  b64=$(printf '%s' "${user}:${password}" | base64 -w0 2>/dev/null \
    -    || printf '%s' "${user}:${password}" | base64)
    -  raw=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -    -o "$tmp/last_token.json" -w '%{http_code}' \
    -    -X POST "${BASE}/instances/${path_id}/private/token" \
    -    -H "Authorization: Basic ${b64}" \
    -    -H 'Content-Type: application/json' \
    -    -d '{"scope":"write","duration":{"d_us":3600000000},"refreshable":true}' \
    -    2>/dev/null || true)
    -  _http_norm "${raw:-}"
    -}
    -
    -json_code_hint() {
    -  python3 - "$tmp/last_token.json" <<'PY' 2>/dev/null || true
    -import json, sys
    -try:
    -    d = json.load(open(sys.argv[1]))
    -except Exception:
    -    print("")
    -    raise SystemExit
    -c = d.get("code", "")
    -h = (d.get("hint") or d.get("detail") or "")[:70]
    -print(f"code={c} {h}".strip())
    -PY
    -}
    -
    -extract_token() {
    -  python3 - "$tmp/last_token.json" <<'PY' 2>/dev/null || true
    -import json, sys
    -try:
    -    d = json.load(open(sys.argv[1]))
    -except Exception:
    -    print("")
    -    raise SystemExit(0)
    -print(d.get("access_token") or d.get("token") or "")
    -PY
    -}
    -
    -instance_body() {
    -  local id="$1" pass="$2" name="${3:-auth401 probe}"
    -  python3 - "$id" "$pass" "$name" <<'PY'
    -import json, sys
    -print(json.dumps({
    -  "address": {},
    -  "auth": {"method": "token", "password": sys.argv[2]},
    -  "default_pay_delay": {"d_us": 60000000},
    -  "default_wire_transfer_delay": {"d_us": 30000000},
    -  "default_refund_delay": {"d_us": 30000000},
    -  "id": sys.argv[1],
    -  "jurisdiction": {},
    -  "name": sys.argv[3],
    -  "use_stefan": True,
    -}))
    -PY
    -}
    -
    -auth_change_http() {
    -  local path_id="$1" bearer="$2" new_pass="$3"
    -  local tok="$bearer" body
    -  case "$tok" in secret-token:*) ;; *) tok="secret-token:${tok}" ;; esac
    -  body=$(NEW_PASS="$new_pass" python3 -c 'import json,os; print(json.dumps({"method":"token","password":os.environ["NEW_PASS"]}))')
    -  curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -    -o "$tmp/auth_change.json" -w '%{http_code}' \
    -    -X POST "${BASE}/instances/${path_id}/private/auth" \
    -    -H "Authorization: Bearer ${tok}" \
    -    -H 'Content-Type: application/json' \
    -    -d "$body" \
    -    2>/dev/null || echo 000
    -}
    -
    -# ---------------------------------------------------------------------------
    -# Stage secrets (quiet)
    -# ---------------------------------------------------------------------------
    -_auth401_stage_secret_roots() {
    -  printf '%s\n' \
    -    "${FRANCPAYSAN_SECRETS:-}" \
    -    "${HOME}/francpaysan-secrets" \
    -    "${HOME}/src/francpaysan-secrets" \
    -    "${HOME}/taler/src/francpaysan-secrets"
    -}
    -
    -_auth401_resolve_secrets_quiet() {
    -  local root inst_id pass
    -  for root in $(_auth401_stage_secret_roots); do
    -    [ -n "$root" ] && [ -d "$root" ] || continue
    -    # durable ids: prefer mon401fix when mon401 was rotated by old tests
    -    if [ -z "${DURABLE_PASS:-}" ]; then
    -      for inst_id in "${AUTH401_DURABLE_ID}" mon401fix mon401 "${MERCHANT_INSTANCE:-}"; do
    -        [ -n "$inst_id" ] || continue
    -        pass=""
    -        if [ -f "$root/stage/instances/${inst_id}.password" ]; then
    -          pass=$(tr -d '\n\r' <"$root/stage/instances/${inst_id}.password")
    -        fi
    -        # skip empty password files
    -        if [ -n "$pass" ]; then
    -          # verify file is for this id (mon401.password may be stale copy of mon401fix)
    -          DURABLE_PASS="$pass"
    -          DURABLE_ID=$(to_lower "$inst_id")
    -          AUTH401_SECRET_NOTE="durable ${inst_id} from ${root}/stage/instances/"
    -          # only set AUTH401_PASSWORD if caller did not pin an instance
    -          if [ -z "${AUTH401_PASSWORD}" ] && [ -z "${AUTH401_INSTANCE}" ]; then
    -            AUTH401_PASSWORD="$pass"
    -          fi
    -          break
    -        fi
    -      done
    -    fi
    -    if [ -z "${AUTH401_BEARER}" ] && [ -f "$root/stage/default-instance-token.txt" ]; then
    -      AUTH401_BEARER=$(tr -d '\n\r' <"$root/stage/default-instance-token.txt")
    -      AUTH401_BEARER_NOTE="default-instance-token.txt"
    -    fi
    -    if [ -z "${AUTH401_ADMIN_TOKEN}" ] && [ -f "$root/stage/admin-instance-token.txt" ]; then
    -      AUTH401_ADMIN_TOKEN=$(tr -d '\n\r' <"$root/stage/admin-instance-token.txt")
    -      AUTH401_ADMIN_NOTE="admin-instance-token.txt"
    -    fi
    -    [ -n "${DURABLE_PASS:-}" ] && break
    -  done
    -  # also keep AUTH401_INSTANCE for durable-only mode
    -  if [ "${AUTH401_PREFER_DURABLE}" = "1" ] && [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then
    -    AUTH401_INSTANCE="${AUTH401_INSTANCE:-$DURABLE_ID}"
    -    AUTH401_PASSWORD="${AUTH401_PASSWORD:-$DURABLE_PASS}"
    -    AUTH401_CREATE=0
    -    AUTH401_MODE_NOTE="PREFER_DURABLE=1 → only durable ${AUTH401_INSTANCE}"
    -  fi
    -}
    -
    -_auth401_resolve_secrets_quiet
    -: "${AUTH401_CREATE_PASSWORD:=${AUTH401_PASSWORD:-Mon401-Test-Pass!}}"
    -export AUTH401_INSTANCE AUTH401_PASSWORD AUTH401_BEARER AUTH401_CREATE AUTH401_ADMIN_TOKEN
    -
    -# ---------------------------------------------------------------------------
    -set_area auth401
    -section "auth401 · merchant 401 / case matrix (WebUI APIs) · ${BASE}"
    -
    -if [ -n "${AUTH401_SECRET_NOTE:-}" ]; then info "stage secret" "$AUTH401_SECRET_NOTE"; fi
    -if [ -n "${AUTH401_BEARER_NOTE:-}" ]; then info "stage secret" "AUTH401_BEARER from ${AUTH401_BEARER_NOTE}"; fi
    -if [ -n "${AUTH401_ADMIN_NOTE:-}" ]; then info "stage secret" "AUTH401_ADMIN_TOKEN from ${AUTH401_ADMIN_NOTE}"; fi
    -if [ -n "${AUTH401_MODE_NOTE:-}" ]; then info "auth401 mode" "$AUTH401_MODE_NOTE"; fi
    -info "auth401 scope" "FULL=${AUTH401_FULL} CREATE=${AUTH401_CREATE} PREFER_DURABLE=${AUTH401_PREFER_DURABLE} CONTINUE=${AUTH401_CONTINUE}"
    -if [ "${AUTH401_CONTINUE}" = "1" ]; then
    -  info "continue mode" "CONTINUE_ON_ERROR/AUTH401_CONTINUE=1 — hard prereqs soft; always full ERROR list via EXIT trap"
    -fi
    -
    -# ===========================================================================
    -set_group config
    -# ===========================================================================
    -cfg_code=$(http_body "$BASE/config" "$tmp/mer-config.json")
    -expect_http "merchant /config" 200 "$cfg_code" "$BASE/config"
    -
    -HAVE_SP=0
    -MANDATORY_TAN=0
    -if [ "$cfg_code" = "200" ]; then
    -  eval "$(python3 - "$tmp/mer-config.json" <<'PY'
    -import json, sys
    -d = json.load(open(sys.argv[1]))
    -sp = 1 if d.get("have_self_provisioning") else 0
    -tans = d.get("mandatory_tan_channels") or []
    -print(f"HAVE_SP={sp}")
    -print(f"MANDATORY_TAN={1 if tans else 0}")
    -print(f"TAN_CH={','.join(tans) if tans else ''}")
    -print(f"API_VER={d.get('version','')}")
    -PY
    -)"
    -  info "self-provision" "have_self_provisioning=${HAVE_SP} mandatory_tan=${MANDATORY_TAN}${TAN_CH:+ ($TAN_CH)} api=${API_VER:-?}"
    -else
    -  fail "merchant config required for auth401" "HTTP $cfg_code"
    -  _auth401_abort_or_continue "no /config"
    -fi
    -
    -# ===========================================================================
    -set_group webui
    -# SPA surface: /webui/ loads + version stamp; optional toLowerCase in bundle
    -# ===========================================================================
    -if [ "${AUTH401_FULL}" = "1" ]; then
    -  wcode=$(http_code "$BASE/webui/")
    -  case "$wcode" in
    -    200|301|302) ok "webui /" "HTTP $wcode" ;;
    -    *) warn "webui /" "HTTP $wcode" ;;
    -  esac
    -  vcode=$(http_body "$BASE/webui/version.txt" "$tmp/spa-ver.txt" 2>/dev/null || echo 000)
    -  if [ "$vcode" = "200" ] && [ -s "$tmp/spa-ver.txt" ]; then
    -    ok "webui version.txt" "$(tr -d '\n\r' <"$tmp/spa-ver.txt")"
    -  else
    -    warn "webui version.txt" "HTTP ${vcode:-000}"
    -  fi
    -  # Download a slice of index.js — look for lowercasing mitigation (#11340)
    -  jcode=$(curl -skS -m 60 -o "$tmp/spa-index.js" -w '%{http_code}' "$BASE/webui/index.js" 2>/dev/null || echo 000)
    -  jcode=$(printf '%s' "$jcode" | tr -cd '0-9' | head -c 3)
    -  if [ "$jcode" = "200" ] && [ -s "$tmp/spa-index.js" ]; then
    -    ok "webui index.js" "HTTP 200 · $(wc -c <"$tmp/spa-index.js" | tr -d ' ') bytes"
    -    if grep -q 'toLowerCase' "$tmp/spa-index.js" 2>/dev/null; then
    -      ok "webui bundle toLowerCase" "present (SPA may mitigate #11340)"
    -    else
    -      warn "webui bundle toLowerCase" "not found in index.js — login MIX may 401 without lowercasing"
    -    fi
    -  else
    -    warn "webui index.js" "HTTP ${jcode:-000}"
    -  fi
    -fi
    -
    -# ===========================================================================
    -set_group signup
    -# WebUI self-provision: POST /instances with mixed-case id (as typed)
    -# ===========================================================================
    -CREATED=0
    -USE_ID=""
    -USE_PASS=""
    -ID_MIX=""
    -ID_LOW=""
    -CREATE_PASS="$AUTH401_CREATE_PASSWORD"
    -stamp=$(date +%H%M%S)
    -# Mixed-case id like the SPA would send before lowercasing (or if it forgot)
    -ID_MIX="$(printf '%s' "${AUTH401_ID_PREFIX}" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')X${stamp}"
    -ID_LOW=$(to_lower "$ID_MIX")
    -
    -if [ "${AUTH401_FULL}" = "1" ] && [ "$AUTH401_CREATE" = "1" ] && [ "$HAVE_SP" = "1" ]; then
    -  if [ "$MANDATORY_TAN" = "1" ] && [ "$AUTH401_SKIP_IF_MFA" = "1" ]; then
    -    warn "signup skipped" "mandatory_tan_channels set — set AUTH401_INSTANCE+PASSWORD"
    -  else
    -    body=$(instance_body "$ID_MIX" "$CREATE_PASS" "auth401 signup MIX")
    -    ccode=$(_http_norm "$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -      -o "$tmp/create.json" -w '%{http_code}' \
    -      -X POST "${BASE}/instances" \
    -      -H 'Content-Type: application/json' \
    -      -d "$body" 2>/dev/null || true)")
    -    case "$ccode" in
    -      200|204)
    -        ok "signup POST /instances id=MIX" "HTTP $ccode id=$ID_MIX (SPA self-provision)"
    -        CREATED=1
    -        USE_ID="$ID_MIX"
    -        USE_PASS="$CREATE_PASS"
    -        # Immediate login as SPA after signup: must use *lowercase* Basic user
    -        code=$(token_http "$ID_LOW" "$ID_LOW" "$CREATE_PASS")
    -        expect_http "signup→login SPA-sim path=low user=low → 200" 200 "$code" "$(json_code_hint)"
    -        # Bug path: SPA forgot toLowerCase on login after signup with MIX id typed
    -        code=$(token_http "$ID_LOW" "$ID_MIX" "$CREATE_PASS")
    -        expect_http "signup→login RAW user=MIX (no lower) → 401" 401 "$code" "$(json_code_hint)"
    -        ;;
    -      202)
    -        warn "signup needs MFA" "HTTP 202 — use AUTH401_INSTANCE"
    -        ;;
    -      *)
    -        fail "signup POST /instances" "HTTP $ccode $(head -c 120 "$tmp/create.json" 2>/dev/null | tr '\n' ' ')"
    -        ;;
    -    esac
    -  fi
    -elif [ -n "${AUTH401_INSTANCE:-}" ] && [ -n "${AUTH401_PASSWORD:-}" ]; then
    -  USE_ID=$(to_lower "$AUTH401_INSTANCE")
    -  USE_PASS="$AUTH401_PASSWORD"
    -  ID_LOW=$(to_lower "$USE_ID")
    -  ID_MIX=$(synth_mix "$ID_LOW")
    -  info "signup" "using existing AUTH401_INSTANCE=$USE_ID (no POST /instances)"
    -  ok "using existing instance" "$USE_ID"
    -else
    -  warn "signup skipped" "CREATE=${AUTH401_CREATE} HAVE_SP=${HAVE_SP} (need instance for matrix)"
    -fi
    -
    -# Fallback durable
    -if [ -z "$USE_ID" ] && [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then
    -  USE_ID="$DURABLE_ID"
    -  USE_PASS="$DURABLE_PASS"
    -  ID_LOW=$(to_lower "$USE_ID")
    -  ID_MIX=$(synth_mix "$ID_LOW")
    -  info "fallback durable" "$USE_ID"
    -  ok "using durable instance" "$USE_ID"
    -fi
    -
    -if [ -z "$USE_ID" ]; then
    -  fail "no instance under test" "need signup OK or AUTH401_INSTANCE+PASSWORD or mon401.password"
    -  _auth401_abort_or_continue "no instance under test"
    -  # CONTINUE: synthesize dummy ids so later groups still run (expect many 404/401)
    -  ID_LOW_USE="auth401-missing-instance"
    -  ID_USER_MIX=$(synth_mix "$ID_LOW_USE")
    -  ID_PATH_MIX="$ID_USER_MIX"
    -  ID_UPPER=$(to_upper "$ID_LOW_USE")
    -  ID_PARTMIX=$(synth_partmix "$ID_LOW_USE")
    -  PASS_OK="invalid"
    -  PASS_BAD='auth401-wrong-password-NOT-VALID'
    -  USE_ID="$ID_LOW_USE"
    -  USE_PASS="$PASS_OK"
    -fi
    -
    -ID_LOW_USE=$(to_lower "$USE_ID")
    -# Always synthetic case variants (even for mon401) so MIX ≠ low
    -ID_USER_MIX=$(synth_mix "$ID_LOW_USE")
    -ID_PATH_MIX="$ID_USER_MIX"
    -ID_UPPER=$(to_upper "$ID_LOW_USE")
    -ID_PARTMIX=$(synth_partmix "$ID_LOW_USE")
    -PASS_OK="$USE_PASS"
    -PASS_BAD='auth401-wrong-password-NOT-VALID'
    -
    -info "probe ids" "low=${ID_LOW_USE} MIX_user=${ID_USER_MIX} UPPER=${ID_UPPER} created=${CREATED} pass_len=${#PASS_OK}"
    -
    -# ===========================================================================
    -set_group login
    -# WebUI login = POST …/private/token with Basic (username, password)
    -# ===========================================================================
    -if [ "${AUTH401_FULL}" = "1" ]; then
    -  code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
    -  expect_http "login SPA-sim user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
    -  code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$PASS_OK")
    -  expect_http "login RAW user=MIX pwd=OK → 401 (#11340)" 401 "$code" "$(json_code_hint)"
    -  code=$(token_http "$ID_LOW_USE" "$ID_UPPER" "$PASS_OK")
    -  expect_http "login RAW user=UPPER pwd=OK → 401" 401 "$code" "$(json_code_hint)"
    -  code=$(token_http "$ID_PATH_MIX" "$ID_LOW_USE" "$PASS_OK")
    -  expect_http "login path=MIX user=low pwd=OK → 200 (path tolerant)" 200 "$code" "$(json_code_hint)"
    -  code=$(token_http "$ID_UPPER" "$ID_LOW_USE" "$PASS_OK")
    -  expect_http "login path=UPPER user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
    -fi
    -
    -# ===========================================================================
    -set_group case
    -# Full Basic case matrix (always hard expects — synthetic MIX)
    -# ===========================================================================
    -code=$(token_http "$ID_PATH_MIX" "$ID_USER_MIX" "$PASS_OK")
    -expect_http "path=MIX user=MIX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_PATH_MIX" "$ID_LOW_USE" "$PASS_OK")
    -expect_http "path=MIX user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$PASS_OK")
    -expect_http "path=low user=MIX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
    -expect_http "path=low user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_UPPER" "$ID_LOW_USE" "$PASS_OK")
    -expect_http "path=UPPER user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_LOW_USE" "$ID_UPPER" "$PASS_OK")
    -expect_http "path=low user=UPPER pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_LOW_USE" "$ID_PARTMIX" "$PASS_OK")
    -expect_http "path=low user=MiX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)"
    -
    -# ===========================================================================
    -set_group password
    -# ===========================================================================
    -code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_BAD")
    -expect_http "pwd=BAD → 401" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "")
    -expect_http "pwd=EMPTY → 401" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_PATH_MIX" "$ID_USER_MIX" "$PASS_BAD")
    -expect_http "path=MIX user=MIX pwd=BAD → 401" 401 "$code" "$(json_code_hint)"
    -
    -# ===========================================================================
    -set_group pwchange
    -# WebUI password change: POST private/auth then re-login
    -# Only on throwaway signup instances — never rotate durable mon401.password
    -# ===========================================================================
    -if [ "${AUTH401_FULL}" = "1" ]; then
    -  if [ "${AUTH401_PWCHANGE_THROWAY_ONLY}" = "1" ] && [ "${CREATED:-0}" != "1" ]; then
    -    info "pwchange skipped" "not a throwaway instance (CREATED=0) — refuse to rotate durable password"
    -  else
    -    code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
    -    if [ "$code" != "200" ]; then
    -      fail "pwchange: login for token" "HTTP $code — skip pwchange"
    -    else
    -      TOK=$(extract_token)
    -      if [ -z "$TOK" ]; then
    -        fail "pwchange: parse token" "empty access_token"
    -      else
    -        ok "pwchange: got bearer" "len=${#TOK}"
    -        NEW_PASS="Auth401-New-${stamp}!"
    -        ac=$(_http_norm "$(auth_change_http "$ID_LOW_USE" "$TOK" "$NEW_PASS")")
    -        case "$ac" in
    -          200|204)
    -            ok "pwchange POST private/auth" "HTTP $ac (SPA password form)"
    -            code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
    -            expect_http "pwchange: old pwd → 401" 401 "$code" "$(json_code_hint)"
    -            code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$NEW_PASS")
    -            expect_http "pwchange: new pwd user=low → 200" 200 "$code" "$(json_code_hint)"
    -            code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$NEW_PASS")
    -            expect_http "pwchange: new pwd user=MIX → 401" 401 "$code" "$(json_code_hint)"
    -            PASS_OK="$NEW_PASS"
    -            USE_PASS="$NEW_PASS"
    -            ;;
    -          202)
    -            warn "pwchange needs MFA" "HTTP 202"
    -            ;;
    -          *)
    -            fail "pwchange POST private/auth" "HTTP $ac $(head -c 100 "$tmp/auth_change.json" 2>/dev/null | tr '\n' ' ')"
    -            ;;
    -        esac
    -      fi
    -    fi
    -  fi
    -fi
    -
    -# ===========================================================================
    -set_group admin
    -# Admin UI: POST /management/instances (needs admin bearer)
    -# ===========================================================================
    -if [ "${AUTH401_FULL}" = "1" ]; then
    -  ADMIN_TOK="${AUTH401_ADMIN_TOKEN:-}"
    -  # Optional: login as admin user with password
    -  if [ -z "$ADMIN_TOK" ] && [ -n "${AUTH401_ADMIN_PASSWORD}" ]; then
    -    code=$(token_http "$AUTH401_ADMIN_USER" "$AUTH401_ADMIN_USER" "$AUTH401_ADMIN_PASSWORD")
    -    if [ "$code" = "200" ]; then
    -      ADMIN_TOK=$(extract_token)
    -      ok "admin login user=${AUTH401_ADMIN_USER}" "got token"
    -    else
    -      # try lower only
    -      au=$(to_lower "$AUTH401_ADMIN_USER")
    -      code=$(token_http "$au" "$au" "$AUTH401_ADMIN_PASSWORD")
    -      if [ "$code" = "200" ]; then
    -        ADMIN_TOK=$(extract_token)
    -        ok "admin login user=${au}" "got token"
    -      else
    -        warn "admin login" "HTTP $code — set AUTH401_ADMIN_TOKEN"
    -      fi
    -    fi
    -  fi
    -
    -  if [ -z "$ADMIN_TOK" ]; then
    -    info "admin-create skipped" "no AUTH401_ADMIN_TOKEN / AUTH401_ADMIN_PASSWORD (stage default is not admin)"
    -  else
    -    case "$ADMIN_TOK" in secret-token:*) ;; *) ADMIN_TOK="secret-token:${ADMIN_TOK}" ;; esac
    -    AID_MIX="Adm401X${stamp}"
    -    AID_LOW=$(to_lower "$AID_MIX")
    -    APASS="Adm401-Pass-${stamp}!"
    -    body=$(instance_body "$AID_MIX" "$APASS" "auth401 admin-create MIX")
    -    acode=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -      -o "$tmp/admin_create.json" -w '%{http_code}' \
    -      -X POST "${BASE}/management/instances" \
    -      -H "Authorization: Bearer ${ADMIN_TOK}" \
    -      -H 'Content-Type: application/json' \
    -      -d "$body" 2>/dev/null || echo 000)
    -    case "$acode" in
    -      200|204)
    -        ok "admin-create POST /management/instances id=MIX" "HTTP $acode id=$AID_MIX"
    -        code=$(token_http "$AID_LOW" "$AID_LOW" "$APASS")
    -        expect_http "admin-create→login user=low → 200" 200 "$code" "$(json_code_hint)"
    -        code=$(token_http "$AID_LOW" "$AID_MIX" "$APASS")
    -        expect_http "admin-create→login user=MIX → 401" 401 "$code" "$(json_code_hint)"
    -        # admin-set auth on new instance
    -        code=$(token_http "$AID_LOW" "$AID_LOW" "$APASS")
    -        if [ "$code" = "200" ]; then
    -          AT=$(extract_token)
    -          NEW_A="Adm401-New-${stamp}!"
    -          ch=$(auth_change_http "$AID_LOW" "$AT" "$NEW_A")
    -          case "$ch" in
    -            200|204)
    -              ok "admin-created instance pwchange" "HTTP $ch"
    -              code=$(token_http "$AID_LOW" "$AID_LOW" "$NEW_A")
    -              expect_http "admin-created re-login new pwd → 200" 200 "$code" "$(json_code_hint)"
    -              ;;
    -            *) warn "admin-created pwchange" "HTTP $ch" ;;
    -          esac
    -        fi
    -        ;;
    -      401|403)
    -        warn "admin-create unauthorized" "HTTP $acode — token not admin (expected on stage default)"
    -        ;;
    -      202)
    -        warn "admin-create MFA" "HTTP 202"
    -        ;;
    -      *)
    -        fail "admin-create" "HTTP $acode $(head -c 120 "$tmp/admin_create.json" 2>/dev/null | tr '\n' ' ')"
    -        ;;
    -    esac
    -  fi
    -fi
    -
    -# ===========================================================================
    -set_group missing
    -# ===========================================================================
    -code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -  -o "$tmp/last_token.json" -w '%{http_code}' \
    -  -X POST "${BASE}/instances/${ID_LOW_USE}/private/token" \
    -  -H 'Content-Type: application/json' \
    -  -d '{"scope":"write","duration":{"d_us":3600000000},"refreshable":true}' \
    -  2>/dev/null || echo 000)
    -expect_http "private/token NO_AUTH → 401" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_LOW_USE" "auth401-not-a-real-user" "$PASS_OK")
    -expect_http "user=OTHER pwd=OK → 401" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "auth401-does-not-exist-${stamp}" "$ID_LOW_USE" "$PASS_OK")
    -# Must not look like auth failure (401). Prefer 404; 502 from proxy is soft.
    -case "$code" in
    -  404) ok "path=missing → 404 (not 401)" "HTTP 404 · $(json_code_hint)" ;;
    -  401) fail "path=missing must not be 401" "HTTP 401 · $(json_code_hint)" ;;
    -  502|503) warn "path=missing" "HTTP $code (want 404; proxy flake, not auth)" ;;
    -  *) fail "path=missing → 404 (not 401)" "HTTP $code (want 404) · $(json_code_hint)" ;;
    -esac
    -
    -# ===========================================================================
    -set_group bearer
    -# ===========================================================================
    -code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
    -if [ "$code" = "200" ]; then
    -  TOKEN=$(extract_token)
    -  if [ -n "$TOKEN" ]; then
    -    ok "got access_token" "len=${#TOKEN}"
    -    code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -      -o "$tmp/priv.json" -w '%{http_code}' \
    -      "${BASE}/instances/${ID_LOW_USE}/private/" \
    -      -H "Authorization: Bearer ${TOKEN}" 2>/dev/null || echo 000)
    -    expect_http "GET private/ Bearer OK → 200" 200 "$code"
    -
    -    code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -      -o "$tmp/priv.json" -w '%{http_code}' \
    -      "${BASE}/instances/${ID_LOW_USE}/private/" \
    -      -H "Authorization: Bearer secret-token:AUTH401INVALID000" 2>/dev/null || echo 000)
    -    expect_http "GET private/ Bearer BAD → 401" 401 "$code"
    -
    -    code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -      -o "$tmp/priv.json" -w '%{http_code}' \
    -      "${BASE}/instances/${ID_LOW_USE}/private/" 2>/dev/null || echo 000)
    -    expect_http "GET private/ NO_AUTH → 401" 401 "$code"
    -  else
    -    fail "parse access_token" "HTTP $code but no token field"
    -  fi
    -else
    -  fail "token for bearer tests" "HTTP $code"
    -fi
    -
    -code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -  -o "$tmp/mgmt.json" -w '%{http_code}' \
    -  "${BASE}/management/instances" 2>/dev/null || echo 000)
    -expect_http "GET /management/instances NO_AUTH → 401" 401 "$code"
    -
    -if [ -n "${AUTH401_BEARER:-}" ]; then
    -  if [ -z "${AUTH401_BEARER_INSTANCE:-}" ]; then
    -    case "${AUTH401_BEARER_NOTE:-}" in
    -      *default-instance-token*) AUTH401_BEARER_INSTANCE=default ;;
    -      *) AUTH401_BEARER_INSTANCE="${DURABLE_ID:-default}" ;;
    -    esac
    -  fi
    -  inst=$(to_lower "$AUTH401_BEARER_INSTANCE")
    -  tok="$AUTH401_BEARER"
    -  case "$tok" in secret-token:*) ;; *) tok="secret-token:${tok}" ;; esac
    -  code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -    -o "$tmp/priv2.json" -w '%{http_code}' \
    -    "${BASE}/instances/${inst}/private/" \
    -    -H "Authorization: Bearer ${tok}" 2>/dev/null || echo 000)
    -  case "$code" in
    -    200) ok "GET private/ secrets Bearer" "HTTP 200 instance=$inst" ;;
    -    401) warn "secrets Bearer rejected" "HTTP 401 on $inst" ;;
    -    *) warn "secrets Bearer" "HTTP $code on $inst" ;;
    -  esac
    -fi
    -
    -# ===========================================================================
    -set_group durable
    -# Extra: durable mon401 still works after throwaway tests (if present)
    -# ===========================================================================
    -if [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then
    -  dlow=$(to_lower "$DURABLE_ID")
    -  dmix=$(synth_mix "$dlow")
    -  code=$(token_http "$dlow" "$dlow" "$DURABLE_PASS")
    -  expect_http "durable ${dlow} login user=low → 200" 200 "$code" "$(json_code_hint)"
    -  code=$(token_http "$dlow" "$dmix" "$DURABLE_PASS")
    -  expect_http "durable ${dlow} login user=MIX → 401" 401 "$code" "$(json_code_hint)"
    -else
    -  info "durable" "no mon401.password — skipped"
    -fi
    -
    -# ===========================================================================
    -set_group report
    -# ===========================================================================
    -info "summary matrix" "401: Basic user case≠low | bad/empty pwd | no auth | bad bearer | wrong user · 200: user=low + path any case · 404: missing instance · SPA must toLowerCase username on login/signup/admin/pw-relogin"
    -if [ "$CREATED" = "1" ]; then
    -  info "throwaway left" "id=${ID_MIX:-?} low=${ID_LOW_USE} (password rotated if pwchange ran)"
    -fi
    -
    -# fall through → EXIT trap runs _auth401_finish (summary + full ERROR list)
    -:
    diff --git a/scripts/taler-monitoring/check_e2e.sh b/scripts/taler-monitoring/check_e2e.sh
    deleted file mode 100755
    index 05cb5f7..0000000
    --- a/scripts/taler-monitoring/check_e2e.sh
    +++ /dev/null
    @@ -1,1711 +0,0 @@
    -#!/usr/bin/env bash
    -# Full withdraw + pay cycle; clear BLOCKERS when the path cannot finish.
    -# Skips remaining steps if overall budget exceeded (E2E_TIMEOUT, default 90s).
    -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)
    -: "${E2E_SETTLE_ROUNDS:=18}"
    -: "${E2E_SETTLE_SLEEP:=3}"
    -E2E_START=$(date +%s)
    -BAL_BEFORE="(not captured)"
    -BAL_AFTER="(not captured)"
    -E2E_REPORTED=0
    -e2e_left() { echo $(( E2E_TIMEOUT - ($(date +%s) - E2E_START) )); }
    -e2e_over() { [ "$(e2e_left)" -le 0 ]; }
    -
    -# Fixed short timeout — used even when e2e budget is exhausted / on EXIT
    -# stdout only for JSON; logs go to stderr file so balance parse stays clean
    -wcli_bal_snap() {
    -  local out="$1"
    -  if [ -z "${WALLET_CLI:-}" ]; then
    -    echo "(no wallet)" >"$out"
    -    return 1
    -  fi
    -  # one-shot needs DB file; serve mode needs socket
    -  if [ -z "${WSERVE_PID:-}" ] || [ ! -S "${WSOCK:-}" ]; then
    -    if [ ! -f "${WDB:-}" ]; then
    -      echo "(no wallet)" >"$out"
    -      return 1
    -    fi
    -  fi
    -  local err="${out}.err"
    -  if command -v perl >/dev/null 2>&1; then
    -    if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then
    -      perl -e 'alarm shift; exec @ARGV' 10 \
    -        node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle balance \
    -        >"$out" 2>"$err" || true
    -    else
    -      perl -e 'alarm shift; exec @ARGV' 10 \
    -        node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \
    -        >"$out" 2>"$err" || true
    -    fi
    -  else
    -    if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then
    -      node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle balance \
    -        >"$out" 2>"$err" || true
    -    else
    -      node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \
    -        >"$out" 2>"$err" || true
    -    fi
    -  fi
    -  [ -s "$out" ]
    -}
    -
    -fmt_bal() {
    -  python3 -c '
    -import json,re,sys
    -raw=open(sys.argv[1]).read() if sys.argv[1] else ""
    -if not raw.strip() or raw.strip().startswith("(no"):
    -  print(raw.strip() or "(empty)"); sys.exit(0)
    -# extract first JSON object with "balances"
    -d=None
    -for m in re.finditer(r"\{", raw):
    -  try:
    -    d=json.loads(raw[m.start():])
    -    if isinstance(d, dict) and "balances" in d:
    -      break
    -  except Exception:
    -    d=None
    -if not isinstance(d, dict):
    -  print(re.sub(r"\s+"," ", raw)[:200]); sys.exit(0)
    -parts=[]
    -for b in d.get("balances") or []:
    -  cur=(b.get("scopeInfo") or {}).get("currency") or "?"
    -  parts.append("%s avail=%s pendingIn=%s" % (cur, b.get("available"), b.get("pendingIncoming")))
    -print("; ".join(parts) if parts else "(no balances)")
    -' "${1:-/dev/null}" 2>/dev/null || echo "(unreadable)"
    -}
    -
    -# Numeric available for currency CUR (from balance file or fresh wallet snap)
    -wallet_avail_num() {
    -  local f="${1:-}"
    -  if [ -z "$f" ] || [ ! -s "$f" ]; then
    -    f="$SCRATCH/bal-live.out"
    -    wcli_bal_snap "$f" || wcli balance >"$f" 2>&1 || true
    -  fi
    -  python3 -c '
    -import json,re,sys
    -cur=sys.argv[2]
    -raw=open(sys.argv[1]).read() if sys.argv[1] else ""
    -d=None
    -for m in re.finditer(r"\{", raw):
    -  try:
    -    d=json.loads(raw[m.start():])
    -    if isinstance(d, dict) and "balances" in d: break
    -  except Exception:
    -    d=None
    -if isinstance(d, dict):
    -  for b in d.get("balances") or []:
    -    c=(b.get("scopeInfo") or {}).get("currency") or ""
    -    av=b.get("available") or ""
    -    if c==cur or av.startswith(cur+":"):
    -      try:
    -        print(float(av.split(":",1)[1])); sys.exit(0)
    -      except Exception:
    -        pass
    -# text fallback
    -m=re.search(r"%s:([0-9]+(?:\.[0-9]+)?)" % re.escape(cur), raw)
    -print(float(m.group(1)) if m else 0.0)
    -' "${f:-/dev/null}" "${CUR:-GOA}" 2>/dev/null || echo 0
    -}
    -
    -# Wait for spendable balance (settlement / wirewatch lag). Returns 0 if avail > min.
    -# Emphasizes timing, not hard failure, while waiting.
    -wait_wallet_balance() {
    -  local min_n="${1:-0}"
    -  local rounds="${2:-15}"
    -  local sleep_s="${3:-3}"
    -  local r av
    -  # Poll balance only — never run-until-done (hangs; banned in monitoring).
    -  info "wallet settle wait" "up to ${rounds}×${sleep_s}s for avail>${min_n} ${CUR} (poll balance only; no run-until-done)"
    -  for r in $(seq 1 "$rounds"); do
    -    wcli_bal_snap "$SCRATCH/bal-live.out" || wcli balance >"$SCRATCH/bal-live.out" 2>&1 || true
    -    av=$(wallet_avail_num "$SCRATCH/bal-live.out")
    -    if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > float(sys.argv[2]) else 1)" "$av" "$min_n" 2>/dev/null; then
    -      ok "wallet balance ready" "avail=${CUR}:${av} after ~$((r * sleep_s))s settle wait"
    -      info "balance" "$(fmt_bal "$SCRATCH/bal-live.out")"
    -      return 0
    -    fi
    -    if [ "$r" = "1" ] || [ "$((r % 3))" = "0" ]; then
    -      info "settlement lag" "round $r/${rounds}: avail=${CUR}:${av} (still waiting — not an error yet)"
    -    fi
    -    # gentle wirewatch nudge mid-wait (local only)
    -    if [ "$r" = "4" ] || [ "$r" = "8" ]; then
    -      if [ "${E2E_REMOTE:-0}" != "1" ] && koopa_ssh_ok; then
    -        koopa_ssh_run 8 \
    -          'podman exec taler-hacktivism-exchange-ansible systemctl restart taler-exchange-wirewatch 2>/dev/null || true' \
    -          >/dev/null 2>&1 || true
    -      fi
    -    fi
    -    sleep "$sleep_s"
    -  done
    -  av=$(wallet_avail_num "$SCRATCH/bal-live.out")
    -  warn "settlement lag" "after ${rounds}×${sleep_s}s still avail=${CUR}:${av} — coins may still be in flight"
    -  return 1
    -}
    -
    -print_balances() {
    -  section "e2e · wallet balances"
    -  info "BALANCE before" "$BAL_BEFORE"
    -  # refresh after if we can
    -  if [ -n "${SCRATCH:-}" ]; then
    -    wcli_bal_snap "$SCRATCH/bal-end.out" || true
    -    if [ -s "$SCRATCH/bal-end.out" ]; then
    -      BAL_AFTER=$(fmt_bal "$SCRATCH/bal-end.out")
    -    fi
    -  fi
    -  info "BALANCE after " "$BAL_AFTER"
    -}
    -
    -e2e_finish() {
    -  local code="${1:-1}"
    -  if [ "$E2E_REPORTED" = "1" ]; then
    -    return "$code"
    -  fi
    -  E2E_REPORTED=1
    -  print_balances
    -  # Final coin inventory + host/container load + statistics dashboard
    -  if [ -n "${METRICS_DIR:-}" ]; then
    -    section "metrics · e2e coins final"
    -    metrics_report_coins "e2e-end" || true
    -    if [ "${METRICS_LOAD:-1}" != "0" ]; then
    -      metrics_report_load "${METRICS_DIR}/load-after.json" "e2e-end" || true
    -    fi
    -    metrics_print_overall "e2e final" || true
    -  fi
    -  summary || true
    -  return "$code"
    -}
    -
    -e2e_skip_rest() {
    -  local why="$1"
    -  warn "e2e timeout" "budget ${E2E_TIMEOUT}s exceeded — $why"
    -  blocker "e2e-timeout" "skipped remaining steps after ${E2E_TIMEOUT}s ($why)"
    -  section "e2e · report"
    -  info "WITHDRAW" "incomplete (timeout)"
    -  info "PAY" "SKIPPED (timeout)"
    -  # If we already started a withdrawal, dig inside for missing coins
    -  if [ -n "${WID:-}" ]; then
    -    dig_when_no_coins 2>/dev/null || true
    -  fi
    -  e2e_finish 1
    -  exit 1
    -}
    -
    -# Area e2e-### — withdraw + pay cycle
    -set_area e2e
    -set_group prereq
    -section "e2e · prerequisites"
    -info "e2e budget" "${E2E_TIMEOUT}s (set E2E_TIMEOUT= to change)"
    -
    -# Remote domains (not koopa): public path only, tiny amounts, abort on login/KYC
    -: "${E2E_REMOTE:=0}"
    -if [ "${LOCAL_STACK:-1}" != "1" ]; then
    -  E2E_REMOTE=1
    -  SKIP_SSH=1
    -  E2E_FAKE_INCOMING=0
    -fi
    -
    -# Currency for amounts / balance checks
    -CUR="${EXPECT_CURRENCY:-}"
    -if [ -z "$CUR" ]; then
    -  CUR=$(curl -skS -m 8 "$BANK_PUBLIC/config" 2>/dev/null | python3 -c 'import sys,json
    -try: print(json.load(sys.stdin).get("currency") or "")
    -except Exception: print("")' 2>/dev/null || true)
    -fi
    -[ -z "$CUR" ] && CUR=$(curl -skS -m 8 "$EXCHANGE_PUBLIC/config" 2>/dev/null | python3 -c 'import sys,json
    -try: print(json.load(sys.stdin).get("currency") or "")
    -except Exception: print("")' 2>/dev/null || true)
    -[ -z "$CUR" ] && CUR="GOA"
    -
    -# ---------------------------------------------------------------------------
    -# Variable amounts
    -#   Withdraw = typical ATM notes only (Bankomat): 20 / 50 / 100 / 200 …
    -#   Pay      = several smaller purchase amounts
    -# Env:
    -#   E2E_WITHDRAW_VALUES="20 50 100"   # bare numbers → prefixed with CUR
    -#   E2E_PAY_VALUES="0.01 0.05 0.5 1 2 5"
    -#   E2E_VARIABLE=0                    # single fixed WITHDRAW_AMT / PAY_AMT
    -#   E2E_ATM_MAX=N                     # try at most N ATM withdraws (budget)
    -# ---------------------------------------------------------------------------
    -: "${E2E_VARIABLE:=1}"
    -: "${E2E_ATM_MAX:=5}"
    -if [ "$E2E_REMOTE" = "1" ]; then
    -  # remote: still ATM-shaped; TESTPAYSAN needs larger notes for shop pays
    -  if [ "$CUR" = "TESTPAYSAN" ]; then
    -    : "${E2E_WITHDRAW_VALUES:=20 50}"
    -    # farmer shop template face values (oeufs-6=5, fromage=8.5, jus=6)
    -    : "${E2E_PAY_VALUES:=5 8.5 6}"
    -  else
    -    : "${E2E_WITHDRAW_VALUES:=10 20 50}"
    -    : "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}"
    -  fi
    -else
    -  # local GOA: classic ATM notes + 4200 for paivana paywall template
    -  : "${E2E_WITHDRAW_VALUES:=20 50 100 200 4200}"
    -  : "${E2E_PAY_VALUES:=0.01 0.05 0.1 0.5 1 2 5 10}"
    -fi
    -# Public template pays (landing/shop style) — default on for TESTPAYSAN stage
    -if [ -z "${E2E_USE_TEMPLATES:-}" ]; then
    -  if [ "$CUR" = "TESTPAYSAN" ]; then E2E_USE_TEMPLATES=1; else E2E_USE_TEMPLATES=0; fi
    -fi
    -: "${E2E_USE_TEMPLATES:=0}"
    -
    -# Build CUR:amount lists
    -build_amt_list() {
    -  local cur="$1"; shift
    -  local v out=""
    -  for v in "$@"; do
    -    case "$v" in
    -      *:*) out="${out}${out:+ }$v" ;;
    -      *)   out="${out}${out:+ }${cur}:${v}" ;;
    -    esac
    -  done
    -  printf '%s' "$out"
    -}
    -# shellcheck disable=SC2086
    -WITHDRAW_LIST=$(build_amt_list "$CUR" $E2E_WITHDRAW_VALUES)
    -# shellcheck disable=SC2086
    -PAY_LIST=$(build_amt_list "$CUR" $E2E_PAY_VALUES)
    -
    -if [ "$E2E_VARIABLE" != "1" ]; then
    -  if [ "$E2E_REMOTE" = "1" ]; then
    -    WITHDRAW_LIST="${WITHDRAW_AMT:-${CUR}:20}"
    -    PAY_LIST="${PAY_AMT:-${CUR}:0.01}"
    -  else
    -    WITHDRAW_LIST="${WITHDRAW_AMT:-${CUR}:20}"
    -    PAY_LIST="${PAY_AMT:-${CUR}:0.01}"
    -  fi
    -fi
    -
    -# Credit = sum of planned ATM withdraws + small buffer for fees
    -CREDIT_AMT=$(python3 -c '
    -import sys
    -cur=sys.argv[1]
    -vals=sys.argv[2].split()
    -s=0.0
    -for a in vals:
    -  try: s += float(a.split(":",1)[-1])
    -  except Exception: pass
    -buf = max(10.0, s * 0.05)
    -print("%s:%g" % (cur, s + buf))
    -' "$CUR" "$WITHDRAW_LIST" 2>/dev/null || echo "${CUR}:100")
    -
    -# First withdraw/pay for legacy single-step labels
    -WITHDRAW_AMT=$(printf '%s' "$WITHDRAW_LIST" | awk '{print $1}')
    -PAY_AMT=$(printf '%s' "$PAY_LIST" | awk '{print $1}')
    -
    -# More wall time when running ATM ladder
    -if [ "$E2E_VARIABLE" = "1" ]; then
    -  n_w=$(printf '%s' "$WITHDRAW_LIST" | wc -w | tr -d ' ')
    -  n_p=$(printf '%s' "$PAY_LIST" | wc -w | tr -d ' ')
    -  need=$(( 50 + n_w * 40 + n_p * 20 + 60 ))  # + room for paivana pay
    -  if [ "${E2E_TIMEOUT}" -lt "$need" ]; then
    -    E2E_TIMEOUT=$need
    -  fi
    -  # cap ATM attempts but always keep CUR:4200 if listed (paivana)
    -  WITHDRAW_LIST=$(
    -    printf '%s' "$WITHDRAW_LIST" | tr ' ' '\n' | awk -v max="$E2E_ATM_MAX" -v big="${CUR}:4200" '
    -      NF==0 { next }
    -      $0==big { has=1; next }
    -      { small[++n]=$0 }
    -      END {
    -        keep = max - (has ? 1 : 0)
    -        if (keep < 0) keep = 0
    -        for (i = 1; i <= n && i <= keep; i++) printf "%s ", small[i]
    -        if (has) printf "%s", big
    -        print ""
    -      }' | sed 's/ *$//'
    -  )
    -fi
    -
    -BANK_HOST=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$BANK_PUBLIC"'").hostname or "bank")' 2>/dev/null || echo bank)
    -
    -SCRATCH=$(mktemp -d)
    -WDB="$SCRATCH/wallet.sqlite3"
    -WSOCK="$SCRATCH/wallet.sock"
    -WSERVE_PID=""
    -# Client/server wallet (advanced serve): long-lived shepherd without run-until-done.
    -# Default on — wallet-cli only completes withdraw/pay while a serve process is up.
    -: "${E2E_WALLET_SERVE:=1}"
    -METRICS_DIR="${METRICS_DIR:-$SCRATCH/metrics}"
    -mkdir -p "$METRICS_DIR"
    -export METRICS_DIR CUR="${CUR:-${EXPECT_CURRENCY:-GOA}}" WDB
    -export PATH="${HOME}/.local/bin:${HOME}/taler/opt/taler-wallet-cli/usr/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
    -
    -stop_wallet_serve() {
    -  if [ -n "${WSERVE_PID:-}" ] && kill -0 "$WSERVE_PID" 2>/dev/null; then
    -    kill "$WSERVE_PID" 2>/dev/null || true
    -    wait "$WSERVE_PID" 2>/dev/null || true
    -  fi
    -  WSERVE_PID=""
    -  rm -f "${WSOCK:-}" 2>/dev/null || true
    -}
    -
    -# Always dump balances on any exit (timeout, blocker, signal, success)
    -trap 'ec=$?; e2e_finish "$ec"; stop_wallet_serve; rm -rf "$SCRATCH"; exit "$ec"' EXIT
    -
    -# Abort cleanly on login / KYC / registration barriers (esp. remote domains)
    -e2e_abort_auth() {
    -  local step="$1" msg="$2"
    -  warn "$step" "$msg"
    -  blocker "$step" "abort (login/KYC): $msg"
    -  section "e2e · report"
    -  info "ABORTED" "login or KYC blocked further e2e — not retrying"
    -  info "hint" "remote e2e needs open bank registration + credit path; set E2E_BANK_ADMIN_PASS / E2E_MERCHANT_TOKEN if you have them"
    -  e2e_finish 1
    -  exit 1
    -}
    -
    -is_auth_kyc_body() {
    -  # stdin or file arg: true if looks like login/KYC barrier
    -  local f="${1:-}"
    -  local t
    -  if [ -n "$f" ] && [ -f "$f" ]; then t=$(head -c 800 "$f" 2>/dev/null || true)
    -  else t=$(cat 2>/dev/null || true); fi
    -  printf '%s' "$t" | grep -qiE 'kyc|legitim|login required|unauthorized|forbidden|captcha|challenge|tan_|not.?allowed|registration.?disabled|admin.?only|permission.?denied|401|403'
    -}
    -
    -WALLET_CLI=$(find_wallet_cli) || {
    -  blocker "prereq" "taler-wallet-cli not found (set WALLET_CLI=)"
    -  exit 1
    -}
    -# Implementation version (--version) + wallet-core API ranges (version command)
    -WALLET_IMPL_VER=$(
    -  node "$WALLET_CLI" --version 2>/dev/null | head -1 | tr -d '\r' || true
    -)
    -WALLET_CORE_VER=$(
    -  _wver_db=$(mktemp "${TMPDIR:-/tmp}/wver.XXXXXX.sqlite3" 2>/dev/null || echo "${TMPDIR:-/tmp}/wver-$$.sqlite3")
    -  node "$WALLET_CLI" --wallet-db="$_wver_db" --no-throttle --skip-defaults version 2>/dev/null \
    -    | python3 -c '
    -import json,re,sys
    -raw=sys.stdin.read()
    -d=None
    -for m in re.finditer(r"\{", raw):
    -  try:
    -    d=json.loads(raw[m.start():])
    -    if isinstance(d, dict) and "version" in d:
    -      break
    -  except Exception:
    -    d=None
    -if not isinstance(d, dict):
    -  sys.exit(0)
    -parts=[]
    -# wallet-core libversion + protocol ranges used against exchange/merchant/bank
    -for k in ("version","implementationSemver","exchange","merchant","bank","bankIntegrationApiRange","corebankApiRange"):
    -  if k in d and d[k] is not None:
    -    parts.append("%s=%s" % (k, d[k]))
    -print(" ".join(parts))
    -' 2>/dev/null || true
    -  rm -f "$_wver_db" "$_wver_db"-* 2>/dev/null || true
    -)
    -ok "wallet-cli" "${WALLET_IMPL_VER:-?} · ${WALLET_CLI}"
    -if [ -n "${WALLET_CORE_VER:-}" ]; then
    -  info "wallet-core" "$WALLET_CORE_VER"
    -else
    -  info "wallet-core" "version details unavailable (impl ${WALLET_IMPL_VER:-?})"
    -fi
    -info "e2e mode" "$([ "$E2E_REMOTE" = "1" ] && echo "remote/public domain (no SSH)" || echo "local koopa stack")"
    -info "currency" "$CUR"
    -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)
    -set_group load
    -section "e2e · load snapshot (before withdraw/pay)"
    -metrics_report_load "${METRICS_DIR}/load-before.json" "e2e-start" || true
    -
    -# Local stack: koopa secrets. Remote: env first; TESTPAYSAN stage may load francpaysan host secrets.
    -set_group prereq
    -if [ "$E2E_REMOTE" = "1" ]; then
    -  ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-${BANK_ADMIN_PASS:-}}"
    -  MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}"
    -  # Stage FrancPaysan: allow admin credit without committing passwords.
    -  # Order: already-set env → local secrets tree → SSH francpaysan-host (ops laptop).
    -  if [ -z "$ADMIN_PASS" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then
    -    _fp_try=""
    -    for _fp_try in \
    -      "${FRANCPAYSAN_SECRETS:-}/stage/bank-admin-password.txt" \
    -      "${HOME}/francpaysan-secrets/stage/bank-admin-password.txt" \
    -      "${HOME}/src/francpaysan-secrets/stage/bank-admin-password.txt" \
    -      "${HOME}/taler/src/francpaysan-secrets/stage/bank-admin-password.txt"
    -    do
    -      if [ -n "$_fp_try" ] && [ -f "$_fp_try" ]; then
    -        ADMIN_PASS=$(tr -d '\n\r' <"$_fp_try")
    -        info "bank admin secret" "from $_fp_try"
    -        break
    -      fi
    -    done
    -    if [ -z "$ADMIN_PASS" ] && command -v ssh >/dev/null 2>&1; then
    -      for _fp_host in "${FRANCPAYSAN_SSH:-francpaysan-host}" francpaysan-host; do
    -        ADMIN_PASS=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "$_fp_host" \
    -          'sudo cat /mnt/data/stagepaysan/bank/secrets/bank-admin-password.txt 2>/dev/null' \
    -          2>/dev/null | tr -d '\n\r' || true)
    -        if [ -n "$ADMIN_PASS" ]; then
    -          info "bank admin secret" "from ${_fp_host}:/mnt/data/stagepaysan/bank/secrets/"
    -          break
    -        fi
    -      done
    -    fi
    -    unset _fp_try _fp_host
    -  fi
    -  # Stage merchant: public templates need no token; optional default-instance token
    -  if [ -z "$MPW" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then
    -    for _fp_try in \
    -      "${FRANCPAYSAN_SECRETS:-}/stage/default-instance-token.txt" \
    -      "${HOME}/francpaysan-secrets/stage/default-instance-token.txt"
    -    do
    -      if [ -n "$_fp_try" ] && [ -f "$_fp_try" ]; then
    -        MPW=$(tr -d '\n\r' <"$_fp_try")
    -        break
    -      fi
    -    done
    -    if [ -z "$MPW" ] && command -v ssh >/dev/null 2>&1; then
    -      MPW=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "${FRANCPAYSAN_SSH:-francpaysan-host}" \
    -        'sudo cat /mnt/data/stagepaysan/merchant/secrets/default-instance-token.txt 2>/dev/null' \
    -        2>/dev/null | tr -d '\n\r' || true)
    -    fi
    -    unset _fp_try
    -  fi
    -else
    -  ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-$(read_secret "taler-bank/bank-admin-password.txt" || true)}"
    -  MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-$(read_secret "taler-merchant/merchant-goa-demo-cp4zqk-password.txt" || true)}}"
    -fi
    -if [ -n "$ADMIN_PASS" ]; then
    -  ok "bank admin secret" "${SECRETS_ROOT:+SECRETS_ROOT=${SECRETS_ROOT}}"
    -elif [ "$E2E_REMOTE" = "1" ]; then
    -  warn "bank admin secret" "missing — will try public registration only"
    -else
    -  blocker "prereq" "bank admin password missing (SECRETS_ROOT or koopa /root)"
    -  info "secrets" "SECRETS_ROOT=${SECRETS_ROOT:-'(unset)'}"
    -  if type secrets_hint >/dev/null 2>&1; then
    -    while IFS= read -r _line; do info "secrets-hint" "$_line"; done < <(secrets_hint)
    -  fi
    -  exit 1
    -fi
    -if [ -n "$MPW" ]; then
    -  # Secrets often store full "secret-token:…"; strip before re-prefixing Bearer.
    -  MPW=$(printf '%s' "$MPW" | tr -d '\n\r')
    -  case "$MPW" in
    -    secret-token:*) MPW_TOKEN="$MPW" ;;
    -    *) MPW_TOKEN="secret-token:${MPW}" ;;
    -  esac
    -  ok "merchant secret (instance ${MERCHANT_INSTANCE})"
    -elif [ "$E2E_REMOTE" = "1" ]; then
    -  MPW_TOKEN=""
    -  warn "merchant secret" "missing — private orders fail unless E2E_USE_TEMPLATES=1 (set E2E_MERCHANT_TOKEN)"
    -else
    -  blocker "prereq" "merchant instance password missing"
    -  info "secrets" "SECRETS_ROOT=${SECRETS_ROOT:-'(unset)'} — need taler-merchant/merchant-*-password.txt"
    -  if type secrets_hint >/dev/null 2>&1; then
    -    while IFS= read -r _line; do info "secrets-hint" "$_line"; done < <(secrets_hint)
    -  fi
    -  exit 1
    -fi
    -# Authorization header for merchant private API (never double secret-token:)
    -merchant_auth_header() {
    -  local t="${1:-${MPW_TOKEN:-}}"
    -  [ -n "$t" ] || return 1
    -  case "$t" in
    -    secret-token:*) printf 'Authorization: Bearer %s' "$t" ;;
    -    *) printf 'Authorization: Bearer secret-token:%s' "$t" ;;
    -  esac
    -}
    -
    -# Public reachability gates (remote: soft-skip if bank/merchant absent)
    -for pair in \
    -  "bank|$BANK_PUBLIC/config" \
    -  "exchange|$EXCHANGE_PUBLIC/config" \
    -  "exchange-keys|$EXCHANGE_PUBLIC/keys" \
    -  "bank-integration|$BANK_PUBLIC/taler-integration/config" \
    -  "merchant|$MERCHANT_PUBLIC/config"
    -do
    -  IFS='|' read -r name url <<<"$pair"
    -  code=$(http_code "$url")
    -  if [ "$code" = "200" ]; then
    -    ok "reachable $name"
    -  else
    -    if [ "$E2E_REMOTE" = "1" ] && [[ "$name" == bank* || "$name" == merchant ]]; then
    -      warn "reachable $name" "HTTP $code — e2e may abort"
    -    else
    -      blocker "prereq" "$name HTTP $code ($url) — cannot start payment cycle"
    -    fi
    -  fi
    -done
    -# Remote: need at least bank+exchange+merchant for a full cycle
    -if [ "$E2E_REMOTE" = "1" ]; then
    -  bc=$(http_code "$BANK_PUBLIC/config")
    -  ec=$(http_code "$EXCHANGE_PUBLIC/config")
    -  mc=$(http_code "$MERCHANT_PUBLIC/config")
    -  if [ "$bc" != "200" ] || [ "$ec" != "200" ]; then
    -    e2e_abort_auth "prereq" "bank/exchange not publicly usable (bank=$bc exchange=$ec) — skip e2e"
    -  fi
    -  if [ "$mc" != "200" ]; then
    -    warn "prereq" "merchant HTTP $mc — will try withdraw only if possible"
    -  fi
    -fi
    -if [ "${#BLOCKERS[@]}" -gt 0 ]; then
    -  exit 1
    -fi
    -
    -USER="mon$(date +%s | tail -c 6)"
    -USER_PW=$(python3 -c 'import secrets;print(secrets.token_urlsafe(10))')
    -info "e2e user" "$USER  withdraw=$WITHDRAW_AMT pay=$PAY_AMT"
    -BANK="$BANK_PUBLIC"
    -INST="$MERCHANT_INSTANCE"
    -
    -start_wallet_serve() {
    -  [ "${E2E_WALLET_SERVE}" = "1" ] || return 0
    -  [ -n "${WALLET_CLI:-}" ] && [ -f "${WALLET_CLI}" ] || return 1
    -  stop_wallet_serve
    -  rm -f "$WSOCK"
    -  # serve holds the DB open; clients use --wallet-connection only
    -  node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults \
    -    advanced serve --unix-path "$WSOCK" \
    -    >"$SCRATCH/wallet-serve.log" 2>&1 &
    -  WSERVE_PID=$!
    -  local i
    -  for i in $(seq 1 40); do
    -    if [ -S "$WSOCK" ] && kill -0 "$WSERVE_PID" 2>/dev/null; then
    -      ok "wallet serve" "pid=$WSERVE_PID sock=$WSOCK (no run-until-done)"
    -      return 0
    -    fi
    -    sleep 0.15
    -  done
    -  warn "wallet serve" "socket not ready — falling back to one-shot CLI (coins may stay pending)"
    -  stop_wallet_serve
    -  return 1
    -}
    -
    -# $1 = max seconds for this call (optional); rest = wallet-cli args
    -# Prefer live serve socket so the shepherd stays up (alternative to run-until-done).
    -wcli() {
    -  local maxc=12
    -  if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
    -    maxc=$1
    -    shift
    -  fi
    -  e2e_over && return 124
    -  local cap
    -  cap=$(e2e_left)
    -  [ "$cap" -gt "$maxc" ] && cap=$maxc
    -  [ "$cap" -lt 3 ] && return 124
    -  if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then
    -    if command -v perl >/dev/null 2>&1; then
    -      perl -e 'alarm shift; exec @ARGV' "$cap" \
    -        node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@"
    -    else
    -      node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@"
    -    fi
    -  else
    -    if command -v perl >/dev/null 2>&1; then
    -      perl -e 'alarm shift; exec @ARGV' "$cap" \
    -        node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"
    -    else
    -      node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"
    -    fi
    -  fi
    -}
    -
    -# Pay must not be starved: use reserved seconds even if global budget is tight
    -wcli_pay() {
    -  local cap=$E2E_PAY_SECS
    -  local left
    -  left=$(e2e_left)
    -  if [ "$left" -gt "$cap" ]; then
    -    cap=$E2E_PAY_SECS
    -  elif [ "$left" -ge 8 ]; then
    -    cap=$left
    -  else
    -    # still try once with floor 12s so Alarm clock is not the blocker
    -    cap=12
    -  fi
    -  info "pay wallet cap" "${cap}s"
    -  if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then
    -    if command -v perl >/dev/null 2>&1; then
    -      perl -e 'alarm shift; exec @ARGV' "$cap" \
    -        node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@"
    -    else
    -      node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@"
    -    fi
    -  else
    -    if command -v perl >/dev/null 2>&1; then
    -      perl -e 'alarm shift; exec @ARGV' "$cap" \
    -        node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"
    -    else
    -      node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"
    -    fi
    -  fi
    -}
    -
    -# When coins never become available: run inside + targeted bank/exchange dig
    -dig_when_no_coins() {
    -  set_group dig
    -  section "e2e · coins missing — inside dig"
    -  info "reason" "wallet empty after withdraw path (often wirewatch timing) — probing bank + exchange"
    -  if [ -n "${WID:-}" ]; then
    -    curl -sS -m 6 -o "$SCRATCH/wd-dig.json" \
    -      "$BANK_PUBLIC/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || true
    -    info "backend withdrawal-operation" "$(python3 -c '
    -import json
    -try:
    - d=json.load(open("'"$SCRATCH"'/wd-dig.json"))
    - print("status=%s transfer_done=%s selection_done=%s amount=%s reserve=%s" % (
    -  d.get("status"), d.get("transfer_done"), d.get("selection_done"),
    -  d.get("amount"), (d.get("selected_reserve_pub") or "-")[:24]))
    -except Exception as e:
    - print("unreadable", e)
    -' 2>/dev/null)"
    -  fi
    -  # Full component inside report (capped SSH; ignore its exit)
    -  if [ "${SKIP_SSH}" != "1" ] && [ -x "$ROOT/check_inside.sh" ]; then
    -    info "inside" "running check_inside.sh …"
    -    # subshell so its PASS_N/FAIL_N/summary don't poison ours; still print to stdout
    -    (
    -      # shellcheck disable=SC2030
    -      "$ROOT/check_inside.sh" || true
    -    ) || true
    -  else
    -    warn "inside" "skipped (SKIP_SSH=1 or check_inside.sh missing)"
    -  fi
    -  # Extra targeted: wirewatch + recent journal + bank→exchange path
    -  if koopa_ssh_ok; then
    -    section "e2e · coins missing — exchange/bank focus"
    -    DIG=$(koopa_ssh_bash 15 <<'REMOTE' || true
    -set +e
    -emit() { printf 'D|%s|%s\n' "$1" "$(printf '%s' "$2" | tr '\n' ' ' | head -c 220)"; }
    -EX=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1)
    -BANK=$(podman ps --format '{{.Names}}' | grep -iE 'hacktivism-bank|taler-bank' | head -1)
    -[ -z "$BANK" ] && BANK=$(podman ps --format '{{.Names}}' | grep -i bank | head -1)
    -if [ -n "$EX" ]; then
    -  podman exec "$EX" pgrep -af taler-exchange-wirewatch 2>/dev/null | head -1 | \
    -    { read -r l; [ -n "$l" ] && emit OK "wirewatch: $l" || emit ERROR "wirewatch not running"; }
    -  podman exec "$EX" pgrep -af taler-exchange-transfer 2>/dev/null | head -1 | \
    -    { read -r l; [ -n "$l" ] && emit OK "transfer: $l" || emit WARN "transfer not running"; }
    -  podman exec "$EX" pgrep -af taler-exchange-aggregator 2>/dev/null | head -1 | \
    -    { read -r l; [ -n "$l" ] && emit OK "aggregator: $l" || emit WARN "aggregator not running"; }
    -  # last wirewatch log lines if journal available
    -  j=$(podman exec "$EX" bash -c 'journalctl -u "taler-exchange-wirewatch*" -n 8 --no-pager 2>/dev/null | tail -5' 2>/dev/null | tr '\n' ';' | head -c 280)
    -  [ -n "$j" ] && emit INFO "wirewatch journal: $j"
    -  # reserve lookup via exchange if tools exist (best-effort)
    -  c=$(curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000)
    -  emit INFO "exchange /keys HTTP $c"
    -else
    -  emit ERROR "no exchange container"
    -fi
    -if [ -n "$BANK" ]; then
    -  podman exec "$BANK" pgrep -af 'MainKt serve|libeufin-bank serve' 2>/dev/null | head -1 | \
    -    { read -r l; [ -n "$l" ] && emit OK "libeufin: $l" || emit ERROR "libeufin not running"; }
    -  c=$(curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:9012/taler-integration/config 2>/dev/null || echo 000)
    -  emit INFO "bank integration /config HTTP $c"
    -else
    -  emit ERROR "no bank container"
    -fi
    -REMOTE
    -)
    -    while IFS= read -r line; do
    -      case "$line" in
    -        D\|*)
    -          IFS="|" read -r _ lvl msg <<<"$line"
    -          case "$lvl" in
    -            OK) ok "dig $msg" ;;
    -            ERROR) err "dig" "$msg" ;;
    -            WARN) warn "dig" "$msg" ;;
    -            INFO) info "dig" "$msg" ;;
    -          esac
    -          ;;
    -      esac
    -    done <<<"$DIG"
    -  fi
    -}
    -
    -# Baseline wallet balance (empty DB) — always shown even if we abort later
    -wcli_bal_snap "$SCRATCH/bal-before.out" || true
    -BAL_BEFORE=$(fmt_bal "$SCRATCH/bal-before.out")
    -info "BALANCE before" "$BAL_BEFORE"
    -
    -# ---------------------------------------------------------------------------
    -set_group bank
    -section "e2e · bank (account · credit · withdraw)"
    -# ---------------------------------------------------------------------------
    -AT=""
    -if [ -n "${ADMIN_PASS:-}" ]; then
    -  AT=$(curl -sS -m 15 -u "admin:${ADMIN_PASS}" -H 'Content-Type: application/json' \
    -    -d '{"scope":"readwrite"}' \
    -    "$BANK/accounts/admin/token" 2>"$SCRATCH/at.err" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || true)
    -fi
    -if [ -n "$AT" ]; then
    -  ok "admin token"
    -elif [ "$E2E_REMOTE" = "1" ]; then
    -  warn "admin token" "unavailable — trying open registration"
    -else
    -  if is_auth_kyc_body "$SCRATCH/at.err"; then
    -    e2e_abort_auth "bank-auth" "admin login blocked (KYC/auth) — $(head -c 80 "$SCRATCH/at.err" 2>/dev/null)"
    -  fi
    -  blocker "bank-auth" "admin token failed — check admin password / bank up ($(head -c 80 "$SCRATCH/at.err" 2>/dev/null))"
    -  exit 1
    -fi
    -
    -# Create account: with admin bearer if we have it, else unauthenticated registration (demo banks)
    -if [ -n "$AT" ]; then
    -  code=$(curl -sS -m 15 -o "$SCRATCH/acc.json" -w '%{http_code}' -X POST \
    -    -H "Authorization: Bearer $AT" -H 'Content-Type: application/json' \
    -    -d "{\"username\":\"${USER}\",\"password\":\"${USER_PW}\",\"name\":\"Monitoring\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"${CUR}:1000\"}" \
    -    "$BANK/accounts")
    -else
    -  code=$(curl -sS -m 15 -o "$SCRATCH/acc.json" -w '%{http_code}' -X POST \
    -    -H 'Content-Type: application/json' \
    -    -d "{\"username\":\"${USER}\",\"password\":\"${USER_PW}\",\"name\":\"Monitoring\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"${CUR}:1000\"}" \
    -    "$BANK/accounts")
    -fi
    -if [ "$code" = "200" ] || [ "$code" = "201" ]; then
    -  ok "create account $USER"
    -elif [ "$code" = "401" ] || [ "$code" = "403" ] || is_auth_kyc_body "$SCRATCH/acc.json"; then
    -  e2e_abort_auth "bank-account" "registration/login blocked HTTP $code — $(head -c 100 "$SCRATCH/acc.json" | tr '\n' ' ')"
    -else
    -  if [ "$E2E_REMOTE" = "1" ]; then
    -    e2e_abort_auth "bank-account" "POST /accounts HTTP $code — cannot open account on this domain"
    -  fi
    -  blocker "bank-account" "POST /accounts HTTP $code — $(head -c 100 "$SCRATCH/acc.json" | tr '\n' ' ')"
    -  exit 1
    -fi
    -
    -if [ -n "$AT" ]; then
    -  export USER_NAME="$USER" CREDIT_AMT="$CREDIT_AMT" OUT="$SCRATCH/credit.json" BANK_HOST="$BANK_HOST"
    -  python3 - <<'PY'
    -import json, os
    -ALPH = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
    -def crock32(b):
    -  bits = val = 0; o = []
    -  for byte in b:
    -    val = (val << 8) | byte; bits += 8
    -    while bits >= 5:
    -      bits -= 5; o.append(ALPH[(val >> bits) & 31])
    -  if bits: o.append(ALPH[(val << (5 - bits)) & 31])
    -  return "".join(o)
    -uid = crock32(os.urandom(32))
    -user = os.environ["USER_NAME"]
    -host = os.environ.get("BANK_HOST") or "bank"
    -json.dump({
    -  "payto_uri": f"payto://x-taler-bank/{host}/{user}?receiver-name={user}&message=mon",
    -  "amount": os.environ["CREDIT_AMT"],
    -  "request_uid": uid,
    -}, open(os.environ["OUT"], "w"))
    -PY
    -  curl -sS -m 15 -o "$SCRATCH/credit.out" -H "Authorization: Bearer $AT" \
    -    -H 'Content-Type: application/json' -d @"$SCRATCH/credit.json" \
    -    "$BANK/accounts/admin/transactions" >/dev/null || true
    -  if grep -q row_id "$SCRATCH/credit.out" 2>/dev/null; then
    -    ok "credit $CREDIT_AMT → $USER"
    -  else
    -    if is_auth_kyc_body "$SCRATCH/credit.out"; then
    -      e2e_abort_auth "bank-credit" "admin credit blocked (KYC/auth)"
    -    fi
    -    if [ "$E2E_REMOTE" = "1" ]; then
    -      e2e_abort_auth "bank-credit" "admin transfer failed — no credit path on remote ($(head -c 80 "$SCRATCH/credit.out" | tr '\n' ' '))"
    -    fi
    -    blocker "bank-credit" "admin transfer failed — $(head -c 100 "$SCRATCH/credit.out" | tr '\n' ' ')"
    -    exit 1
    -  fi
    -else
    -  # No admin: remote demo may start funded or require cash-in — try withdraw later; warn
    -  warn "bank-credit" "skipped (no admin) — withdraw may fail without balance"
    -fi
    -
    -UT=$(curl -sS -m 15 -u "${USER}:${USER_PW}" -H 'Content-Type: application/json' \
    -  -d '{"scope":"readwrite"}' \
    -  "$BANK/accounts/${USER}/token" 2>"$SCRATCH/ut.err" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || true)
    -if [ -n "$UT" ]; then
    -  ok "user token"
    -elif is_auth_kyc_body "$SCRATCH/ut.err" || is_auth_kyc_body /dev/null; then
    -  e2e_abort_auth "bank-auth" "user token failed (login/KYC) for $USER"
    -else
    -  e2e_abort_auth "bank-auth" "user token failed for $USER — $(head -c 80 "$SCRATCH/ut.err" 2>/dev/null)"
    -fi
    -
    -# ---------------------------------------------------------------------------
    -set_group wallet
    -section "e2e · wallet setup (exchange + ToS once)"
    -# ---------------------------------------------------------------------------
    -# Long-lived serve = client/server wallet (shepherd keeps running; no run-until-done).
    -start_wallet_serve || true
    -if ! wcli exchanges add "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-add.out" 2>&1; then
    -  if ! grep -qiE 'already|ok' "$SCRATCH/ex-add.out"; then
    -    blocker "wallet-exchange" "exchanges add failed — $(tail -c 160 "$SCRATCH/ex-add.out" | tr '\n' ' ')"
    -  else
    -    ok "wallet exchange known"
    -  fi
    -else
    -  ok "wallet add exchange"
    -fi
    -wcli exchanges update "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-up.out" 2>&1 || true
    -if wcli exchanges accept-tos "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-tos.out" 2>&1; then
    -  ok "wallet accept ToS"
    -else
    -  if grep -qiE 'pending|tos|terms' "$SCRATCH/ex-tos.out"; then
    -    blocker "wallet-tos" "accept-tos failed — wallet may refuse withdraw ($(tail -c 120 "$SCRATCH/ex-tos.out" | tr '\n' ' '))"
    -  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 '' \
    -    "$BANK/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || true
    -  python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("status",""))' 2>/dev/null || true
    -}
    -wd_status_detail() {
    -  python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"))
    -print("status=%s reserve=%s exchange=%s" % (
    -  d.get("status"),
    -  (d.get("selected_reserve_pub") or "-")[:20],
    -  (d.get("selected_exchange") or d.get("selected_exchange_account") or "-")[:60],
    -))' 2>/dev/null || echo "(no status body)"
    -}
    -
    -fake_incoming_speedup() {
    -  [ "${E2E_FAKE_INCOMING}" = "1" ] || return 0
    -  st=$(wd_status)
    -  RPUB=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("selected_reserve_pub") or "")' 2>/dev/null || true)
    -  AMT=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("amount") or "'"$WITHDRAW_AMT"'")' 2>/dev/null || echo "$WITHDRAW_AMT")
    -  DEBIT=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("sender_wire") or "")' 2>/dev/null || true)
    -  [ -z "$DEBIT" ] && DEBIT="payto://x-taler-bank/${BANK_HOST}/${USER}?receiver-name=${USER}"
    -  [ -z "$RPUB" ] && return 0
    -  [ -z "${AT:-}" ] && return 0
    -  WG="${BANK}/accounts/exchange/taler-wire-gateway/admin/add-incoming"
    -  curl -sS -m 10 -o "$SCRATCH/fake-in.json" -w '%{http_code}' -X POST \
    -    -H "Authorization: Bearer $AT" -H 'Content-Type: application/json' \
    -    -d "{\"amount\":\"${AMT}\",\"reserve_pub\":\"${RPUB}\",\"debit_account\":\"${DEBIT}\"}" \
    -    "$WG" >/dev/null || true
    -  if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then
    -    koopa_ssh_run 22 \
    -      'podman exec taler-hacktivism-exchange-ansible bash -c "runuser -u taler-exchange-wire -- timeout 15 taler-exchange-wirewatch -c /etc/taler-exchange/taler-exchange.conf -t -L INFO 2>&1 | tail -5"' \
    -      >/dev/null 2>&1 || true
    -  fi
    -}
    -
    -# One ATM-style withdraw: create → accept → select → confirm → settle
    -# returns 0 on wallet balance increase, 1 on soft fail (ladder continues)
    -e2e_one_withdraw() {
    -  WITHDRAW_AMT="$1"
    -  local tag
    -  tag=$(printf '%s' "$WITHDRAW_AMT" | tr '.:' '__')
    -  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' \
    -    -d "{\"amount\":\"${WITHDRAW_AMT}\",\"exchange_url\":\"${EXCHANGE_PUBLIC}/\"}" \
    -    "$BANK/accounts/${USER}/withdrawals"
    -  WID=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("withdrawal_id") or d.get("id") or "")' 2>/dev/null || true)
    -  URI=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("taler_withdraw_uri") or "")' 2>/dev/null || true)
    -  URI_CLEAN=$(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)
    -  if [ -z "$WID" ] || [ -z "$URI_CLEAN" ]; then
    -    warn "ATM withdraw $WITHDRAW_AMT" "create failed — $(head -c 80 "$SCRATCH/wd-$tag.json" | tr '\n' ' ')"
    -    return 1
    -  fi
    -  ok "ATM withdrawal created $WITHDRAW_AMT ($WID)"
    -  cp "$SCRATCH/wd-$tag.json" "$SCRATCH/wd.json"
    -
    -  USE_URI="$URI"
    -  [ -z "$USE_URI" ] && USE_URI="$URI_CLEAN"
    -  if wcli withdraw accept-uri --exchange "${EXCHANGE_PUBLIC}/" "$USE_URI" >"$SCRATCH/accept-$tag.out" 2>&1; then
    -    ok "wallet accept $WITHDRAW_AMT"
    -  elif [ "$USE_URI" != "$URI_CLEAN" ] && [ -n "$URI_CLEAN" ] \
    -    && wcli withdraw accept-uri --exchange "${EXCHANGE_PUBLIC}/" "$URI_CLEAN" >"$SCRATCH/accept-$tag.out" 2>&1; then
    -    ok "wallet accept $WITHDRAW_AMT (no :443)"
    -  else
    -    warn "ATM withdraw $WITHDRAW_AMT" "accept-uri failed — $(tail -c 100 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')"
    -    return 1
    -  fi
    -  cp "$SCRATCH/accept-$tag.out" "$SCRATCH/accept.out"
    -
    -  # Poll bank withdrawal status only (no run-until-done). force-select if stuck pending.
    -  st=""
    -  for i in 1 2 3 4 5 6 8 10; do
    -    e2e_over && break
    -    st=$(wd_status)
    -    case "$st" in selected|confirmed|aborted) break ;; esac
    -    sleep 0.4
    -  done
    -  if [ "$st" != "selected" ] && [ "$st" != "confirmed" ]; then
    -    wcli transactions >"$SCRATCH/tx-pre.json" 2>&1 || true
    -    RPUB=$(python3 -c '
    -import re
    -t=open("'"$SCRATCH"'/accept.out").read()+open("'"$SCRATCH"'/tx-pre.json").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 "")
    -' 2>/dev/null || true)
    -    EPAYTO=$(curl -sS -m 12 "$EXCHANGE_PUBLIC/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/sel.json" -X POST \
    -        -H 'Content-Type: application/json' \
    -        -d "{\"reserve_pub\":\"$RPUB\",\"selected_exchange\":\"$EPAYTO\"}" \
    -        "$BANK/taler-integration/withdrawal-operation/${WID}" >/dev/null || true
    -      st=$(wd_status)
    -    fi
    -  fi
    -  if [ "$st" != "selected" ] && [ "$st" != "confirmed" ]; then
    -    warn "ATM withdraw $WITHDRAW_AMT" "not selected (status=${st:-?})"
    -    return 1
    -  fi
    -  if [ "$st" != "confirmed" ]; then
    -    code=$(curl -sS -m 15 -o "$SCRATCH/conf-$tag.json" -w '%{http_code}' -X POST \
    -      -H "Authorization: Bearer $UT" -H 'Content-Type: application/json' -d '{}' \
    -      "$BANK/accounts/${USER}/withdrawals/${WID}/confirm")
    -    case "$code" in
    -      200|204) ok "bank confirm $WITHDRAW_AMT" ;;
    -      *) warn "ATM withdraw $WITHDRAW_AMT" "confirm HTTP $code"; return 1 ;;
    -    esac
    -  fi
    -  fake_incoming_speedup
    -
    -  # Short per-ATM settle: poll balance + bank transfer_done only (never run-until-done)
    -  local ok_bal=0 r av xfer
    -  for r in 1 2 3 4 5 6 8 10; do
    -    wcli_bal_snap "$SCRATCH/bal-$tag.out" || wcli balance >"$SCRATCH/bal-$tag.out" 2>&1 || true
    -    av=$(wallet_avail_num "$SCRATCH/bal-$tag.out")
    -    if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > 0 else 1)" "$av" 2>/dev/null; then
    -      ok_bal=1
    -      ok "wallet funded after ATM $WITHDRAW_AMT" "avail=${CUR}:${av}"
    -      info "balance" "$(fmt_bal "$SCRATCH/bal-$tag.out")"
    -      break
    -    fi
    -    xfer=$(curl -sS -m 8 "$BANK/taler-integration/withdrawal-operation/${WID}" 2>/dev/null \
    -      | 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
    -      ok "ATM $WITHDRAW_AMT bank transfer_done" "wallet avail=${CUR}:${av} ($xfer) — no run-until-done"
    -      ok_bal=1
    -      break
    -    fi
    -    sleep 2
    -  done
    -  metrics_report_coins "after-ATM-${tag}" || true
    -  if [ "$ok_bal" = "1" ]; then
    -    metrics_record_flow withdrawn "$WITHDRAW_AMT" || true
    -    return 0
    -  fi
    -  # Bank side often already confirmed — treat as timing lag, not hard fail
    -  st=$(wd_status)
    -  if [ "$st" = "confirmed" ]; then
    -    warn "ATM withdraw $WITHDRAW_AMT" "bank confirmed; wallet not funded yet (settlement timing — will recheck later)"
    -    return 2
    -  fi
    -  warn "ATM withdraw $WITHDRAW_AMT" "no wallet balance yet (status=${st:-?})"
    -  return 1
    -}
    -
    -# Map CUR:amount → public template id.
    -# GOA default: e2e-* on goa-demo. TESTPAYSAN: farmer shop templates (set in apply_taler_domain).
    -# Override with E2E_TEMPLATE_MAP="5=oeufs-6 8.5=fromage-chevre"
    -e2e_template_id_for_amount() {
    -  local amt="$1"
    -  local val map_entry k v
    -  val=$(printf '%s' "$amt" | awk -F: '{print $NF}')
    -  if [ -z "${E2E_TEMPLATE_MAP:-}" ]; then
    -    if [ "${CUR:-}" = "TESTPAYSAN" ]; then
    -      E2E_TEMPLATE_MAP="5=oeufs-6 4.5=pain-seigle 6=jus-pomme 8.5=fromage-chevre 12=miel-printemps 25=panier-legumes"
    -    else
    -      E2E_TEMPLATE_MAP="0.01=e2e-001 0.05=e2e-005 1=e2e-1 1.0=e2e-1"
    -    fi
    -  fi
    -  for map_entry in $E2E_TEMPLATE_MAP; do
    -    k="${map_entry%%=*}"
    -    v="${map_entry#*=}"
    -    if [ "$k" = "$val" ]; then
    -      printf '%s' "$v"
    -      return 0
    -    fi
    -  done
    -  return 1
    -}
    -
    -# Template id → merchant instance (stage farmer shops / GOA goa-shop)
    -e2e_template_instance_for_id() {
    -  local pid="$1"
    -  case "$pid" in
    -    oeufs-6|pain-seigle|jus-pomme) printf '%s' "jardin-du-creux" ;;
    -    panier-legumes|fromage-chevre|miel-printemps) printf '%s' "fermes-des-collines" ;;
    -    e2e-001|e2e-005|e2e-1|orbit-sticker|nebula-coffee|voidwave-playlist|comet-cap|shuttle-pass|beacon-badge|relay-pin|eclipse-shades|star-chart|rainbow-pill|blue-or-red-pill)
    -      printf '%s' "${E2E_SHOP_INSTANCE:-goa-shop}"
    -      ;;
    -    *) printf '%s' "${MERCHANT_INSTANCE:-default}" ;;
    -  esac
    -}
    -
    -# One payment: e2e_one_pay AMOUNT [SUMMARY] [TAG]
    -# SUMMARY defaults to "monitoring pay $AMOUNT"; TAG defaults from amount.
    -# When E2E_USE_TEMPLATES=1 and amount maps to a public template, uses
    -# POST /instances/{inst}/templates/{id} (same as landings/shops).
    -e2e_one_pay() {
    -  PAY_AMT="$1"
    -  local PAY_SUM="${2:-monitoring pay ${PAY_AMT}}"
    -  local tag="${3:-}"
    -  if [ -z "$tag" ]; then
    -    tag=$(printf '%s' "$PAY_AMT" | tr '.:' '__')
    -  fi
    -  # shell-safe tag for files
    -  tag=$(printf '%s' "$tag" | tr -c 'A-Za-z0-9._-' '_')
    -  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
    -
    -  # Prefer public templates when enabled (stage TESTPAYSAN / shop-style orders)
    -  local tpl_id=""
    -  if [ "${E2E_USE_TEMPLATES:-0}" = "1" ]; then
    -    tpl_id=$(e2e_template_id_for_amount "$PAY_AMT" 2>/dev/null || true)
    -    if [ -n "$tpl_id" ]; then
    -      info "pay $PAY_AMT" "via public template $tpl_id (E2E_USE_TEMPLATES=1)"
    -      e2e_one_pay_public_template "$tpl_id" "$PAY_SUM" "$PAY_AMT"
    -      return $?
    -    fi
    -    warn "pay $PAY_AMT" "no template map for amount — fall back to private order"
    -  fi
    -
    -  if [ -z "${MPW:-}" ] && [ -z "${MPW_TOKEN:-}" ]; then
    -    warn "pay $PAY_AMT" "no merchant token"
    -    return 1
    -  fi
    -  local AUTH
    -  AUTH=$(merchant_auth_header) || {
    -    warn "pay $PAY_AMT" "no merchant token"
    -    return 1
    -  }
    -  # JSON-escape summary for curl -d
    -  local SUM_JSON
    -  SUM_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$PAY_SUM" 2>/dev/null || printf '"%s"' "$PAY_SUM")
    -  curl -skS -m 15 -o "$SCRATCH/ord-$tag.json" -X POST \
    -    -H "$AUTH" -H 'Content-Type: application/json' \
    -    -d "{\"order\":{\"summary\":${SUM_JSON},\"amount\":\"${PAY_AMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}" \
    -    "${MERCHANT_PUBLIC}/instances/${INST}/private/orders" 2>"$SCRATCH/ord-$tag.err" || true
    -  if ! grep -q order_id "$SCRATCH/ord-$tag.json" 2>/dev/null; then
    -    if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then
    -      local mpw_raw="${MPW_TOKEN:-secret-token:${MPW}}"
    -      koopa_ssh_run 20 \
    -        "curl -skS -m 12 -X POST -H 'Authorization: Bearer ${mpw_raw}' -H 'Content-Type: application/json' -d '{\"order\":{\"summary\":${SUM_JSON},\"amount\":\"${PAY_AMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}' 'https://127.0.0.1:9010/instances/${INST}/private/orders'" \
    -        >"$SCRATCH/ord-$tag.json" 2>"$SCRATCH/ord-$tag.err" || true
    -    fi
    -  fi
    -  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-$tag.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-$tag.json" 2>/dev/null || true)
    -  if [ -z "$OID" ]; then
    -    if is_auth_kyc_body "$SCRATCH/ord-$tag.json"; then
    -      e2e_abort_auth "merchant-order" "merchant auth/KYC on pay $PAY_AMT ($PAY_SUM)"
    -    fi
    -    warn "pay $PAY_AMT ($PAY_SUM)" "order create failed — $(head -c 80 "$SCRATCH/ord-$tag.json" | tr '\n' ' ')"
    -    return 1
    -  fi
    -  ok "merchant order $OID ($PAY_AMT · $PAY_SUM)"
    -  curl -skS -m 12 -o "$SCRATCH/ord-det-$tag.json" -H "$AUTH" \
    -    "${MERCHANT_PUBLIC}/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-$tag.json" 2>/dev/null || true)
    -  if [ -z "$PAYURI" ] && [ -n "$OTOK" ]; then
    -    MH=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$MERCHANT_PUBLIC"'").hostname or "taler.hacktivism.ch")' 2>/dev/null || echo "taler.hacktivism.ch")
    -    PAYURI="taler://pay/${MH}/instances/${INST}/${OID}/?c=${OTOK}"
    -  fi
    -  # normalize default HTTPS port (wallets accept both)
    -  PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g')
    -  if [ -z "$PAYURI" ]; then
    -    warn "pay $PAY_AMT ($PAY_SUM)" "no pay URI for $OID"
    -    return 1
    -  fi
    -  ok "taler_pay_uri ready ($tag)"
    -  if ! wcli_pay handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$tag.out" 2>&1; then
    -    warn "pay $PAY_AMT ($PAY_SUM)" "handle-uri failed — $(tail -c 100 "$SCRATCH/pay-$tag.out" | tr '\n' ' ')"
    -    return 1
    -  fi
    -  ok "wallet handle pay $PAY_AMT ($PAY_SUM)"
    -  # Settle: poll merchant order + wallet transactions only (never run-until-done)
    -  local r
    -  for r in 1 2 3 4 5 6; do
    -    wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true
    -    if [ -n "${AUTH:-}" ]; then
    -      curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \
    -        "${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true
    -    fi
    -    if [ -n "${OTOK:-}" ]; then
    -      curl -skS -m 8 -o "$SCRATCH/ord-pub-$tag.json" \
    -        "${MERCHANT_PUBLIC}/instances/${INST}/orders/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OID")?token=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OTOK")" \
    -        2>/dev/null || true
    -    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 \
    -      || python3 -c 'import json,sys
    -for p in sys.argv[1:]:
    -  try:
    -    d=json.load(open(p))
    -    if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid":
    -      sys.exit(0)
    -  except Exception:
    -    pass
    -sys.exit(1)
    -' "$SCRATCH/ord-paid-$tag.json" "$SCRATCH/ord-pub-$tag.json" 2>/dev/null; then
    -      ok "payment settled $PAY_AMT ($PAY_SUM · order $OID)"
    -      metrics_report_coins "after-pay-${tag}" || true
    -      metrics_record_flow spent "$PAY_AMT" || true
    -      return 0
    -    fi
    -    sleep 1
    -  done
    -  metrics_report_coins "after-pay-fail-${tag}" || true
    -  warn "pay $PAY_AMT ($PAY_SUM)" "not settled for order $OID"
    -  return 1
    -}
    -
    -# GOA shop catalog (full list) — keep in sync with configs/merchant-landing/index.html
    -# id|product_name|amount
    -# Landing QR = taler://pay-template/…/{id}; popup = public POST templates/{id}.
    -# E2E does not pay every product every run: shuffle + pick E2E_SHOP_PICK_N (default 2).
    -: "${E2E_SHOP_PRODUCTS:=orbit-sticker|Orbit sticker pack|GOA:2
    -nebula-coffee|Nebula coffee|GOA:5
    -voidwave-playlist|Voidwave playlist|GOA:8
    -comet-cap|Comet cap|GOA:15
    -shuttle-pass|Shuttle day pass|GOA:25
    -beacon-badge|Beacon badge|GOA:3
    -relay-pin|Relay pin|GOA:4
    -eclipse-shades|Eclipse shades|GOA:12
    -star-chart|Star chart print|GOA:7
    -rainbow-pill|Rainbow pill (sample/joke)|GOA:4.2
    -blue-or-red-pill|Blue or red pill (sample/joke)|GOA:1.5}"
    -: "${E2E_SHOP_PICK_N:=2}"
    -# Public templates live on goa-shop (landing shop-pay.js); not the e2e default demo instance.
    -: "${E2E_SHOP_INSTANCE:=goa-shop}"
    -
    -# Settlement payto shown on landing shop popup (static)
    -: "${E2E_SHOP_PAYTO:=payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop}"
    -
    -# Pay one shop product the way the public landing does:
    -#   POST /instances/{inst}/templates/{id} body {} → order_id+token → taler_pay_uri
    -# Also checks pay-template URI shape (what qrencode PNGs encode).
    -# Usage: e2e_one_pay_public_template PRODUCT_ID PRODUCT_NAME AMOUNT
    -e2e_one_pay_public_template() {
    -  local pid="$1"
    -  local pname="${2:-$1}"
    -  local pamt="$3"
    -  local inst="${4:-}"
    -  local tag
    -  tag=$(printf 'shop_%s' "$pid" | tr -c 'A-Za-z0-9._-' '_')
    -  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
    -
    -  # Resolve instance: explicit arg → template map → global INST
    -  if [ -z "$inst" ]; then
    -    inst=$(e2e_template_instance_for_id "$pid")
    -  fi
    -  [ -n "$inst" ] || inst="${INST:-${MERCHANT_INSTANCE:-default}}"
    -
    -  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")
    -  local TPL_URI="taler://pay-template/${MH}/instances/${inst}/${pid}"
    -  local TPL_HTTPS="${MERCHANT_PUBLIC}/instances/${inst}/templates/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$pid")"
    -  info "shop $pname" "instance $inst · pay-template URI $TPL_URI"
    -  info "shop $pname" "public template POST $TPL_HTTPS"
    -  # Stage settlement payto: instance bank account; GOA uses E2E_SHOP_PAYTO
    -  local payto_show="${E2E_SHOP_PAYTO:-}"
    -  if [ "${CUR:-}" = "TESTPAYSAN" ]; then
    -    payto_show="payto://x-taler-bank/stage.bank.lefrancpaysan.ch/${inst}"
    -  fi
    -  info "shop $pname" "settlement payto ${payto_show:-(none)}"
    -
    -  # Public create (no merchant secret) — same as shop-pay.js
    -  curl -skS -m 15 -o "$SCRATCH/tpl-$tag.json" -X POST \
    -    -H 'Content-Type: application/json' \
    -    -d '{}' \
    -    "$TPL_HTTPS" 2>"$SCRATCH/tpl-$tag.err" || true
    -  if ! grep -q order_id "$SCRATCH/tpl-$tag.json" 2>/dev/null; then
    -    if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then
    -      koopa_ssh_run 20 \
    -        "curl -skS -m 12 -X POST -H 'Content-Type: application/json' -d '{}' 'https://127.0.0.1:9010/instances/${INST}/templates/${pid}'" \
    -        >"$SCRATCH/tpl-$tag.json" 2>"$SCRATCH/tpl-$tag.err" || true
    -    fi
    -  fi
    -  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/tpl-$tag.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/tpl-$tag.json" 2>/dev/null || true)
    -  if [ -z "$OID" ] || [ -z "$OTOK" ]; then
    -    warn "shop $pname ($pid)" "public template POST failed — $(head -c 100 "$SCRATCH/tpl-$tag.json" 2>/dev/null | tr '\n' ' ')"
    -    return 1
    -  fi
    -  ok "shop $pname" "public order $OID (template $pid)"
    -
    -  local statusUrl="${MERCHANT_PUBLIC}/instances/${INST}/orders/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OID")?token=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OTOK")"
    -  curl -skS -m 12 -o "$SCRATCH/tpl-st-$tag.json" "$statusUrl" 2>/dev/null || true
    -  PAYURI=$(python3 -c 'import json,sys
    -try:
    - d=json.load(open(sys.argv[1]))
    - print(d.get("taler_pay_uri") or "")
    -except Exception: print("")
    -' "$SCRATCH/tpl-st-$tag.json" 2>/dev/null || true)
    -  if [ -z "$PAYURI" ]; then
    -    PAYURI="taler://pay/${MH}/instances/${INST}/${OID}/?c=${OTOK}"
    -  fi
    -  PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g')
    -  if ! printf '%s' "$PAYURI" | grep -q '^taler://pay/'; then
    -    warn "shop $pname ($pid)" "bad pay URI: $PAYURI"
    -    return 1
    -  fi
    -  ok "shop $pname" "taler_pay_uri $PAYURI"
    -
    -  if ! wcli_pay handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$tag.out" 2>&1; then
    -    warn "shop $pname ($pid)" "handle-uri failed — $(tail -c 120 "$SCRATCH/pay-$tag.out" | tr '\n' ' ')"
    -    return 1
    -  fi
    -  ok "shop $pname" "wallet accepted pay URI"
    -
    -  # Settle: public order status + transactions only (never run-until-done)
    -  local r AUTH=""
    -  AUTH=$(merchant_auth_header 2>/dev/null) || AUTH=""
    -  for r in 1 2 3 4 5 6; do
    -    wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true
    -    if [ -n "$AUTH" ]; then
    -      curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \
    -        "${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true
    -    fi
    -    curl -skS -m 8 -o "$SCRATCH/ord-pub-$tag.json" "$statusUrl" 2>/dev/null || true
    -    if python3 -c 'import json,sys
    -for p in sys.argv[1:]:
    -  try:
    -    d=json.load(open(p))
    -    if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid":
    -      sys.exit(0)
    -  except Exception:
    -    pass
    -sys.exit(1)
    -' "$SCRATCH/ord-paid-$tag.json" "$SCRATCH/ord-pub-$tag.json" 2>/dev/null \
    -      || 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 ($pamt · order $OID)"
    -      metrics_report_coins "after-shop-${tag}" || true
    -      metrics_record_flow spent "$pamt" || true
    -      return 0
    -    fi
    -    sleep 1
    -  done
    -  metrics_report_coins "after-shop-fail-${tag}" || true
    -  warn "shop $pname ($pid)" "not settled for order $OID"
    -  return 1
    -}
    -
    -# ---------------------------------------------------------------------------
    -set_group atm
    -section "e2e · ATM withdraw ladder"
    -# ---------------------------------------------------------------------------
    -WITHDRAW_OK=0
    -WITHDRAW_OK_N=0
    -WITHDRAW_LAG_N=0
    -WITHDRAW_FAIL_N=0
    -WITHDRAW_REPORT=""
    -for WITHDRAW_AMT in $WITHDRAW_LIST; do
    -  e2e_over && { warn "ATM ladder" "time budget low — stopping more ATM withdraws (not a protocol error)"; break; }
    -  set +e
    -  e2e_one_withdraw "$WITHDRAW_AMT"
    -  wc=$?
    -  set -e
    -  case "$wc" in
    -    0)
    -      WITHDRAW_OK=1
    -      WITHDRAW_OK_N=$((WITHDRAW_OK_N + 1))
    -      WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=OK"
    -      ;;
    -    2)
    -      WITHDRAW_LAG_N=$((WITHDRAW_LAG_N + 1))
    -      WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=LAG"
    -      ;;
    -    *)
    -      WITHDRAW_FAIL_N=$((WITHDRAW_FAIL_N + 1))
    -      WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=FAIL"
    -      ;;
    -  esac
    -done
    -info "ATM withdraw summary" "$WITHDRAW_REPORT (ok=$WITHDRAW_OK_N lag=$WITHDRAW_LAG_N fail=$WITHDRAW_FAIL_N)"
    -set_group load
    -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
    -set_group settle
    -section "e2e · wallet settlement (timing)"
    -if ! wait_wallet_balance 0 "${E2E_SETTLE_ROUNDS}" "${E2E_SETTLE_SLEEP}"; then
    -  if [ "$WITHDRAW_LAG_N" -gt 0 ] || [ "$WITHDRAW_OK_N" -gt 0 ]; then
    -    warn "settlement timing" "ATM path reached bank confirm (lag=$WITHDRAW_LAG_N ok=$WITHDRAW_OK_N) but wallet empty after wait — TIME lag, not protocol error"
    -  fi
    -fi
    -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
    -  WITHDRAW_OK=1
    -  if [ "$WITHDRAW_OK_N" = "0" ]; then
    -    warn "settlement timing" "coins arrived after ATM ladder (${CUR}:${av_now}) — earlier 'FAIL' was timing lag"
    -    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"
    -    # diagnostic dig only — do not treat as hard stop if we can still see bank state
    -    if [ "$E2E_REMOTE" != "1" ]; then
    -      dig_when_no_coins || true
    -    fi
    -    # one last balance after dig (dig takes wall time — often enough for wirewatch)
    -    wait_wallet_balance 0 8 3 || true
    -    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
    -      WITHDRAW_OK=1
    -      warn "settlement timing" "coins present after dig wait (${CUR}:${av_now}) — timing, not blocker"
    -    else
    -      blocker "withdraw-settle" "no spendable ${CUR} after ATM ladder + settle wait ($WITHDRAW_LIST)"
    -      section "e2e · report"
    -      info "WITHDRAW" "$WITHDRAW_REPORT — no coins after extended wait"
    -      info "PAY" "SKIPPED (no spendable balance after settle wait)"
    -      exit 1
    -    fi
    -  fi
    -fi
    -
    -# ---------------------------------------------------------------------------
    -set_group pay
    -section "e2e · variable payments (if balance allows)"
    -# ---------------------------------------------------------------------------
    -PAY_OK=0
    -PAY_OK_N=0
    -PAY_FAIL_N=0
    -PAY_SKIP_N=0
    -PAY_REPORT=""
    -if [ -z "${MPW:-}" ] && [ "${E2E_USE_TEMPLATES:-0}" != "1" ]; then
    -  if [ "$E2E_REMOTE" = "1" ]; then
    -    e2e_abort_auth "merchant-order" "no merchant token — set E2E_MERCHANT_TOKEN (or E2E_USE_TEMPLATES=1)"
    -  fi
    -  blocker "merchant-order" "no merchant token"
    -  exit 1
    -fi
    -if [ -z "${MPW:-}" ] && [ "${E2E_USE_TEMPLATES:-0}" = "1" ]; then
    -  info "pay" "no merchant token — public templates only (E2E_USE_TEMPLATES=1)"
    -fi
    -
    -for PAY_AMT in $PAY_LIST; do
    -  e2e_over && { warn "pay ladder" "time budget low — stopping more payments"; break; }
    -  av_now=$(wallet_avail_num)
    -  pay_n=$(python3 -c 'import sys; print(float(sys.argv[1].split(":",1)[-1]))' "$PAY_AMT" 2>/dev/null || echo 0)
    -  if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-12 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_n" 2>/dev/null; then
    -    warn "pay $PAY_AMT" "skip — insufficient avail ${CUR}:${av_now} (need ${pay_n})"
    -    PAY_SKIP_N=$((PAY_SKIP_N + 1))
    -    PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=SKIP(bal)"
    -    continue
    -  fi
    -  set +e
    -  e2e_one_pay "$PAY_AMT"
    -  pc=$?
    -  set -e
    -  if [ "$pc" = "0" ]; then
    -    PAY_OK=1
    -    PAY_OK_N=$((PAY_OK_N + 1))
    -    PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=OK"
    -  else
    -    PAY_FAIL_N=$((PAY_FAIL_N + 1))
    -    PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=FAIL"
    -  fi
    -done
    -info "pay summary" "$PAY_REPORT (ok=$PAY_OK_N fail=$PAY_FAIL_N skip=$PAY_SKIP_N)"
    -set_group load
    -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
    -    warn "pay" "balance ${CUR}:${av_now} but no pay amount completed — check merchant/timing (not necessarily empty wallet)"
    -  else
    -    blocker "pay-settle" "no payment succeeded and no spendable balance ($PAY_LIST)"
    -  fi
    -fi
    -
    -# ---------------------------------------------------------------------------
    -set_group shop
    -section "e2e · shop products (public templates)"
    -# ---------------------------------------------------------------------------
    -# Public template POST + wallet pay — catalog random pick of N products.
    -# GOA: goa-shop on hacktivism. TESTPAYSAN: farmer shops (jardin / fermes).
    -SHOP_OK=0
    -SHOP_OK_N=0
    -SHOP_FAIL_N=0
    -SHOP_SKIP_N=0
    -SHOP_REPORT=""
    -# Auto catalog for stage TESTPAYSAN when not overridden
    -if [ "${CUR:-}" = "TESTPAYSAN" ] && [ -z "${E2E_SHOP_PRODUCTS_SET:-}" ] && [ -z "${E2E_SHOP_FORCE_GOA:-}" ]; then
    -  # id|name|amount|instance — force stage catalog (GOA default is set earlier)
    -  E2E_SHOP_PRODUCTS="oeufs-6|Œufs plein air × 6|TESTPAYSAN:5|jardin-du-creux
    -pain-seigle|Pain de seigle 800 g|TESTPAYSAN:4.5|jardin-du-creux
    -jus-pomme|Jus de pomme 1 L|TESTPAYSAN:6|jardin-du-creux
    -panier-legumes|Panier légumes|TESTPAYSAN:25|fermes-des-collines
    -fromage-chevre|Fromage de chèvre 200 g|TESTPAYSAN:8.5|fermes-des-collines
    -miel-printemps|Miel de printemps 250 g|TESTPAYSAN:12|fermes-des-collines"
    -  # Force stage defaults (GOA goa-shop is already set earlier via :=)
    -  if [ -z "${E2E_SHOP_INSTANCE_SET:-}" ]; then
    -    E2E_SHOP_INSTANCE=jardin-du-creux
    -  fi
    -  if [ -z "${E2E_SHOP_PAYTO_SET:-}" ]; then
    -    E2E_SHOP_PAYTO="payto://x-taler-bank/stage.bank.lefrancpaysan.ch/jardin-du-creux"
    -  fi
    -  E2E_SHOP_PICK_N="${E2E_SHOP_PICK_N:-2}"
    -  E2E_SHOP_ENABLE=1
    -fi
    -# GOA local only by default; remote non-GOA shops need E2E_SHOP_ENABLE=1 or TESTPAYSAN auto above
    -if [ -z "${E2E_SHOP_ENABLE:-}" ]; then
    -  if [ "$E2E_REMOTE" = "1" ] || [ "${CUR:-}" != "GOA" ]; then
    -    E2E_SHOP_ENABLE=0
    -  else
    -    E2E_SHOP_ENABLE=1
    -  fi
    -fi
    -if [ "${E2E_SHOP_ENABLE}" != "1" ]; then
    -  info "shop" "SKIPPED (set E2E_SHOP_ENABLE=1 or use TESTPAYSAN stage catalog)"
    -else
    -  # Build catalog array from E2E_SHOP_PRODUCTS (id|name|amount[|instance] lines)
    -  SHOP_CATALOG=()
    -  while IFS= read -r line; do
    -    [ -z "$line" ] && continue
    -    case "$line" in \#*) continue ;; esac
    -    SHOP_CATALOG+=("$line")
    -  done </dev/null || echo 0)
    -      if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-12 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_n" 2>/dev/null; then
    -        warn "shop $pname ($pid)" "skip — insufficient avail ${CUR}:${av_now} (need ${pay_n})"
    -        SHOP_SKIP_N=$((SHOP_SKIP_N + 1))
    -        SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=SKIP(bal)"
    -        continue
    -      fi
    -      set +e
    -      e2e_one_pay_public_template "$pid" "$pname" "$pamt"
    -      pc=$?
    -      set -e
    -      if [ "$pc" = "0" ]; then
    -        SHOP_OK=1
    -        SHOP_OK_N=$((SHOP_OK_N + 1))
    -        PAY_OK=1
    -        PAY_OK_N=$((PAY_OK_N + 1))
    -        SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=OK"
    -      else
    -        SHOP_FAIL_N=$((SHOP_FAIL_N + 1))
    -        SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=FAIL"
    -      fi
    -    done </dev/null || true
    -  fi
    -fi
    -
    -# ---------------------------------------------------------------------------
    -set_group paivana
    -section "e2e · paivana paywall (template GOA:4200)"
    -# ---------------------------------------------------------------------------
    -# Public paywall: https://paivana.hacktivism.ch → 302 to merchant template "paivana".
    -# Requires spendable ≥ 4200 GOA (ATM ladder includes GOA:4200 by default).
    -: "${PAIVANA_PUBLIC:=https://paivana.hacktivism.ch}"
    -: "${E2E_PAIVANA:=1}"
    -: "${E2E_PAIVANA_TEMPLATE:=paivana}"
    -: "${E2E_PAIVANA_AMOUNT:=GOA:4200}"
    -: "${E2E_PAIVANA_INSTANCE:=goa-shop}"
    -PAIVANA_OK=0
    -PAIVANA_REPORT=""
    -if [ "$E2E_REMOTE" = "1" ] || [ "${CUR:-}" != "GOA" ] || [ "${E2E_PAIVANA}" = "0" ]; then
    -  info "paivana" "SKIPPED (remote, non-GOA, or E2E_PAIVANA=0)"
    -  PAIVANA_REPORT="SKIP"
    -else
    -  PAIVANA_PUBLIC="${PAIVANA_PUBLIC%/}"
    -  # HTTP: paywall front must redirect into template flow (or serve well-known)
    -  hdr=$(curl -skS -m 12 -D - -o /dev/null "$PAIVANA_PUBLIC/" 2>/dev/null || true)
    -  pcode=$(printf '%s' "$hdr" | awk 'BEGIN{c="000"} /^HTTP/{c=$2} END{print c}')
    -  loc=$(printf '%s' "$hdr" | awk 'BEGIN{IGNORECASE=1} /^location:/{sub(/\r$/,""); sub(/^location:[[:space:]]*/,""); print; exit}')
    -  case "$pcode" in
    -    301|302|303|307|308)
    -      if printf '%s' "$loc" | grep -qiE 'paivana|templates|well-known'; then
    -        ok "paivana HTTP" "HTTP $pcode → ${loc:0:120}"
    -      else
    -        ok "paivana HTTP" "HTTP $pcode redirect (Location: ${loc:0:80})"
    -      fi
    -      ;;
    -    200)
    -      warn "paivana HTTP" "HTTP 200 without paywall redirect — unexpected for locked site"
    -      ;;
    -    *)
    -      fail "paivana HTTP" "HTTP ${pcode:-000} on $PAIVANA_PUBLIC/ (want 302 to template)"
    -      PAIVANA_REPORT="HTTP_FAIL"
    -      ;;
    -  esac
    -
    -  # Ensure wallet can cover 4200 (extra ATM if ladder did not fund enough)
    -  pay_need=$(python3 -c 'import sys; print(float(sys.argv[1].split(":",1)[-1]))' "$E2E_PAIVANA_AMOUNT" 2>/dev/null || echo 4200)
    -  av_now=$(wallet_avail_num)
    -  if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-9 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_need" 2>/dev/null; then
    -    info "paivana" "avail ${CUR}:${av_now} < ${pay_need} — ATM withdraw ${E2E_PAIVANA_AMOUNT}"
    -    e2e_over && warn "paivana" "budget low before 4200 withdraw"
    -    set +e
    -    e2e_one_withdraw "$E2E_PAIVANA_AMOUNT"
    -    wc=$?
    -    set -e
    -    if [ "$wc" = "0" ]; then
    -      WITHDRAW_OK=1
    -      WITHDRAW_OK_N=$((WITHDRAW_OK_N + 1))
    -      WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${E2E_PAIVANA_AMOUNT}=OK(paivana)"
    -    elif [ "$wc" = "2" ]; then
    -      WITHDRAW_LAG_N=$((WITHDRAW_LAG_N + 1))
    -      WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${E2E_PAIVANA_AMOUNT}=LAG(paivana)"
    -    else
    -      WITHDRAW_FAIL_N=$((WITHDRAW_FAIL_N + 1))
    -      WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${E2E_PAIVANA_AMOUNT}=FAIL(paivana)"
    -    fi
    -    wait_wallet_balance "$(python3 -c "print(max(0, $pay_need - 1))" 2>/dev/null || echo 4199)" 12 3 || true
    -    av_now=$(wallet_avail_num)
    -  fi
    -
    -  if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-9 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_need" 2>/dev/null; then
    -    warn "paivana" "skip pay — insufficient avail ${CUR}:${av_now} (need ${pay_need})"
    -    PAIVANA_REPORT="${PAIVANA_REPORT:+$PAIVANA_REPORT+}SKIP(bal)"
    -  else
    -    _E2E_INST_SAVE="$INST"
    -    INST="${E2E_PAIVANA_INSTANCE}"
    -    set +e
    -    e2e_one_pay_public_template \
    -      "${E2E_PAIVANA_TEMPLATE}" \
    -      "Paivana paywall" \
    -      "${E2E_PAIVANA_AMOUNT}"
    -    pc=$?
    -    set -e
    -    INST="$_E2E_INST_SAVE"
    -    unset _E2E_INST_SAVE
    -    if [ "$pc" = "0" ]; then
    -      PAIVANA_OK=1
    -      PAY_OK=1
    -      PAY_OK_N=$((PAY_OK_N + 1))
    -      PAIVANA_REPORT="${PAIVANA_REPORT:+$PAIVANA_REPORT+}PAY_OK"
    -      ok "paivana" "template ${E2E_PAIVANA_TEMPLATE} paid ${E2E_PAIVANA_AMOUNT}"
    -    else
    -      PAIVANA_REPORT="${PAIVANA_REPORT:+$PAIVANA_REPORT+}PAY_FAIL"
    -      warn "paivana" "template pay failed (${E2E_PAIVANA_TEMPLATE} · ${E2E_PAIVANA_AMOUNT})"
    -    fi
    -  fi
    -  info "paivana summary" "${PAIVANA_REPORT:-?} · instance ${E2E_PAIVANA_INSTANCE}"
    -fi
    -
    -set_group report
    -section "e2e · report"
    -info "user" "$USER"
    -info "ATM withdraws" "$WITHDRAW_REPORT"
    -info "payments" "$PAY_REPORT"
    -info "shop" "${SHOP_REPORT:-(n/a)}"
    -info "paivana" "${PAIVANA_REPORT:-(n/a)}"
    -info "final balance" "$(fmt_bal "$SCRATCH/bal-live.out" 2>/dev/null || echo "(n/a)")"
    -info "scratch" "$SCRATCH"
    -# Success if we had coins and at least one pay, OR coins + only pay skips (nothing affordable)
    -if [ "${#BLOCKERS[@]}" -eq 0 ]; then
    -  if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK" = "1" ]; then
    -    exit 0
    -  fi
    -  if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK_N" = "0" ] && [ "$PAY_FAIL_N" = "0" ]; then
    -    warn "pay" "no payment tried/completed — withdraw OK"
    -    exit 0
    -  fi
    -  if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK" != "1" ]; then
    -    # money there, pays failed → soft exit 1 without inventing blockers if already warned
    -    exit 1
    -  fi
    -fi
    -exit 1
    diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh
    deleted file mode 100755
    index d7c60ae..0000000
    --- a/scripts/taler-monitoring/check_goa_ladder.sh
    +++ /dev/null
    @@ -1,1364 +0,0 @@
    -#!/usr/bin/env bash
    -# check_goa_ladder.sh — withdraw/pay amount ladder (GOA + stage TESTPAYSAN)
    -#
    -# Flow (bank landings):
    -#   1) GET /intro/auto-account.json  → personal *account-* (balance 0)
    -#   2) Mint pool withdrawals as explorer + confirm when selected
    -#   3) wallet-cli accept-uri only (no run-until-done)
    -#   4) bank confirm when selected; settle = balance + transfer_done
    -#
    -# Amounts: random strictly increasing; pins 0, max-1, max.
    -# Phase A: withdraw ladder (cumulative wallet). Phase B: pay ladder.
    -#
    -# Stacks:
    -#   GOA  — LADDER_MAX_AMOUNT = libeufin ceiling; explorer from koopa-admin-secrets
    -#   TESTPAYSAN stage — auto max_wire from bank /config, min denom from /keys;
    -#                      explorer from stagepaysan secrets / SSH
    -#
    -# Usage:
    -#   ./taler-monitoring.sh ladder
    -#   ./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder
    -#   LADDER_MAX_AMOUNT=100 LADDER_STEPS=7 ./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder
    -#
    -# Env (see body): LADDER_STEPS, LADDER_MAX_AMOUNT, LADDER_MIN_AMOUNT, LADDER_STACK_AUTO,
    -#   LADDER_PAY, EXP_PW / EXP_PW_FILE, CLI_JS, MERCHANT_INSTANCE, …
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -# When invoked standalone (not via taler-monitoring.sh), still load secrets.env
    -load_monitoring_secrets_env 2>/dev/null || true
    -
    -# Area ladder.* — withdraw/pay amount ladder (GOA + TESTPAYSAN)
    -# Groups: ladder.plan / ladder.load / ladder.withdraw / ladder.pay / ladder.report
    -set_area ladder
    -set_group plan
    -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 / EXP_PW optional overrides; otherwise read_secret + SECRETS_ROOT / stage SSH
    -: "${EXP_PW_FILE:=}"
    -: "${EXP_PW:=}"
    -: "${CLI_JS:=}"
    -# GOA libeufin amount ceiling (hacktivism). Stage auto-replaces via bank /config.
    -: "${LADDER_MAX_AMOUNT:=4503599627370496}"
    -: "${LADDER_STEPS:=23}"
    -: "${LADDER_LOAD:=1}"
    -: "${LADDER_PAY:=1}"
    -: "${LADDER_STACK_AUTO:=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}"
    -# Public variable template for stage pays (optional; fixed templates used when amount maps)
    -: "${LADDER_PAY_TEMPLATE:=}"
    -: "${LADDER_PAY_TEMPLATE_INSTANCE:=fermes-des-collines}"
    -
    -GOA_LADDER_CEILING="4503599627370496"
    -
    -# Resolve wallet-cli .mjs (no hardcoded laptop path)
    -if [ -z "${CLI_JS}" ] || [ ! -f "${CLI_JS}" ]; then
    -  CLI_JS=$(find_wallet_cli 2>/dev/null || true)
    -fi
    -if [ -z "${CLI_JS}" ] || [ ! -f "${CLI_JS}" ]; then
    -  # allow PATH wrapper as last resort (wcli falls back to taler-wallet-cli)
    -  CLI_JS=""
    -fi
    -
    -CUR="${EXPECT_CURRENCY:-GOA}"
    -BANK="${BANK_PUBLIC%/}"
    -EX="${EXCHANGE_PUBLIC%/}/"
    -MER="${MERCHANT_PUBLIC%/}"
    -INST="${MERCHANT_INSTANCE}"
    -
    -# --- Stack-specific ladder maxima (TESTPAYSAN stage) ---
    -# Uses bank max_wire_transfer_amount and exchange min denom when still on GOA defaults.
    -apply_ladder_stack_defaults() {
    -  [ "${LADDER_STACK_AUTO}" = "1" ] || return 0
    -  if [ "${CUR}" != "TESTPAYSAN" ]; then
    -    case "${TALER_DOMAIN:-}" in
    -      stage.lefrancpaysan.ch|stage.bank.lefrancpaysan.ch|stage.exchange.lefrancpaysan.ch|stage.monnaie.lefrancpaysan.ch)
    -        CUR="TESTPAYSAN"
    -        ;;
    -      *) return 0 ;;
    -    esac
    -  fi
    -
    -  local bank_cfg max_wire min_denom
    -  bank_cfg=$(curl -sS -m 12 "${BANK}/config" 2>/dev/null || true)
    -  max_wire=$(printf '%s' "$bank_cfg" | python3 -c '
    -import json,sys
    -try:
    -  d=json.load(sys.stdin)
    -except Exception:
    -  print("")
    -  raise SystemExit(0)
    -a=d.get("max_wire_transfer_amount") or ""
    -print(a.split(":",1)[-1] if a else "")
    -' 2>/dev/null || true)
    -
    -  min_denom=$(curl -sS -m 25 -H 'Accept: application/json' "${EX%/}/keys" 2>/dev/null | python3 -c '
    -import json,sys
    -try:
    -  d=json.load(sys.stdin)
    -except Exception:
    -  print("")
    -  raise SystemExit(0)
    -den=d.get("denoms") or d.get("denominations") or []
    -vals=[]
    -for x in den if isinstance(den,list) else []:
    -  v=x.get("value") or x.get("amount") or ""
    -  if not v: continue
    -  try: vals.append(float(str(v).split(":")[-1]))
    -  except Exception: pass
    -print(min(vals) if vals else "")
    -' 2>/dev/null || true)
    -
    -  # Fallback maxima if public config unreachable
    -  [ -n "$max_wire" ] || max_wire="2000"
    -  [ -n "$min_denom" ] || min_denom="0.01"
    -
    -  # Only rewrite when caller left GOA defaults (explicit LADDER_MAX_AMOUNT=… kept)
    -  if [ -n "$max_wire" ] && { [ "${LADDER_MAX_AMOUNT}" = "${GOA_LADDER_CEILING}" ] || [ "${LADDER_MAX_AMOUNT}" = "4503599627370496" ]; }; then
    -    LADDER_MAX_AMOUNT="$max_wire"
    -  fi
    -  if [ -n "$min_denom" ] && { [ "${LADDER_MIN_AMOUNT}" = "0.000001" ] || [ "${LADDER_MIN_AMOUNT}" = "0.00000001" ]; }; then
    -    LADDER_MIN_AMOUNT="$min_denom"
    -  fi
    -  # Slightly fewer rungs on stage (full GOA 23 still ok if user set LADDER_STEPS)
    -  if [ "${LADDER_STEPS}" = "23" ]; then
    -    LADDER_STEPS=15
    -  fi
    -  # Stage farmer shops: private goa-demo instance does not exist
    -  if [ "${MERCHANT_INSTANCE}" = "goa-demo-cp4zqk" ]; then
    -    MERCHANT_INSTANCE="${LADDER_PAY_TEMPLATE_INSTANCE:-fermes-des-collines}"
    -    INST="$MERCHANT_INSTANCE"
    -  fi
    -  # Prefer public templates for pays when no merchant token (set later)
    -  : "${E2E_USE_TEMPLATES:=1}"
    -  export E2E_USE_TEMPLATES
    -  export LADDER_MAX_AMOUNT LADDER_MIN_AMOUNT LADDER_STEPS MERCHANT_INSTANCE
    -  info "ladder stack" "TESTPAYSAN · max=${CUR}:${LADDER_MAX_AMOUNT} min=${CUR}:${LADDER_MIN_AMOUNT} steps=${LADDER_STEPS} (from bank max_wire / keys denoms)"
    -}
    -apply_ladder_stack_defaults
    -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
    -  now=$(date +%s)
    -  [ $((now - SECTION_T0)) -ge "$LADDER_TIMEOUT_S" ]
    -}
    -
    -wcli() {
    -  if [ -n "${CLI_JS:-}" ] && [ -f "$CLI_JS" ]; then
    -    node "$CLI_JS" --wallet-db="$WDB" --no-throttle "$@"
    -  else
    -    taler-wallet-cli --wallet-db="$WDB" --no-throttle "$@"
    -  fi
    -}
    -
    -# Explorer pool password: env → file override → stack secrets → SSH
    -resolve_explorer_pw() {
    -  local pw="" f="" host=""
    -  if [ -n "${EXP_PW:-}" ]; then
    -    printf '%s' "$EXP_PW"
    -    return 0
    -  fi
    -  if [ -n "${EXP_PW_FILE:-}" ] && [ -f "$EXP_PW_FILE" ]; then
    -    tr -d '\n\r' <"$EXP_PW_FILE"
    -    return 0
    -  fi
    -
    -  # Stage TESTPAYSAN — never use GOA koopa-admin-secrets explorer pw first
    -  if [ "${CUR}" = "TESTPAYSAN" ] \
    -    || [[ "${TALER_DOMAIN:-}" == stage.*lefrancpaysan* ]] \
    -    || [[ "${TALER_DOMAIN:-}" == *stage.lefrancpaysan* ]]; then
    -    for f in \
    -      "${FRANCPAYSAN_SECRETS:-}/stage/bank-explorer-password.txt" \
    -      "${HOME}/francpaysan-secrets/stage/bank-explorer-password.txt" \
    -      "${HOME}/src/francpaysan-secrets/stage/bank-explorer-password.txt" \
    -      "${HOME}/taler/src/francpaysan-secrets/stage/bank-explorer-password.txt" \
    -      "${HOME}/.config/taler-landing/stage-bank-explorer-password.txt"
    -    do
    -      [ -n "$f" ] && [ -f "$f" ] || continue
    -      tr -d '\n\r' <"$f"
    -      return 0
    -    done
    -    for host in \
    -      "${INSIDE_SSH:-}" \
    -      francpaysan-stage-user \
    -      francpaysan-host
    -    do
    -      [ -n "$host" ] || continue
    -      pw=$(ssh -o BatchMode=yes -o ConnectTimeout=12 "$host" \
    -        'tr -d "\n\r" /dev/null \
    -         || sudo tr -d "\n\r" /dev/null' \
    -        2>/dev/null || true)
    -      if [ -n "$pw" ]; then
    -        printf '%s' "$pw"
    -        return 0
    -      fi
    -    done
    -    return 1
    -  fi
    -
    -  # GOA / default: SECRETS_ROOT / ~/.config / koopa SSH
    -  if pw=$(read_secret "taler-bank/bank-explorer-password.txt" 2>/dev/null) && [ -n "$pw" ]; then
    -    printf '%s' "$pw"
    -    return 0
    -  fi
    -  for f in \
    -    "${SECRETS_ROOT:+${SECRETS_ROOT}/taler-bank/bank-explorer-password.txt}" \
    -    "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt" \
    -    "${HOME}/.config/taler-landing/bank-explorer-password.txt"
    -  do
    -    [ -n "$f" ] && [ -f "$f" ] || continue
    -    tr -d '\n\r' <"$f"
    -    return 0
    -  done
    -  return 1
    -}
    -
    -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 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(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)
    -    if q == q.to_integral():
    -        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)
    -    if v < min_amt:
    -        return min_amt
    -    return v.quantize(Decimal("0.00000001"), rounding=ROUND_UP)
    -
    -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(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 >= 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(v)
    -        prev = v
    -    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"
    -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 · ${CUR} 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"
    -_max_m1=$(python3 -c 'import sys; from decimal import Decimal; m=Decimal(sys.argv[1]); print(m-1 if m>1 else m)' "${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)"
    -info "currency/domain" "${CUR} · ${TALER_DOMAIN:-?} · bank=${BANK}"
    -
    -set_group load
    -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 ! EXP_PW=$(resolve_explorer_pw); then
    -  err bank "explorer password missing" \
    -    "set EXP_PW / EXP_PW_FILE or SECRETS_ROOT=…/koopa/host-root (taler-bank/bank-explorer-password.txt)${SECRETS_ROOT:+ · SECRETS_ROOT=${SECRETS_ROOT}}"
    -  secrets_hint 2>/dev/null || true
    -  exit 1
    -fi
    -if [ -n "${SECRETS_ROOT:-}" ]; then
    -  info "explorer secret" "resolved (SECRETS_ROOT=${SECRETS_ROOT} · stage uses stagepaysan secret first when CUR=TESTPAYSAN)"
    -else
    -  info "explorer secret" "resolved via EXP_PW / EXP_PW_FILE / stage SSH / koopa"
    -fi
    -if [ -n "${CLI_JS:-}" ] && [ -f "${CLI_JS}" ]; then
    -  info "wallet-cli" "$CLI_JS"
    -else
    -  info "wallet-cli" "PATH taler-wallet-cli ($(command -v taler-wallet-cli 2>/dev/null || echo missing))"
    -fi
    -
    -# --- 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 ${CUR}: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)"
    -
    -# 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
    -  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)
    -rm -f "$SCRATCH/wallet-main.sqlite3"
    -wallet_prepare "main"
    -ms_tos=$(elapsed_ms "$t0")
    -ok "wallet exchange + ToS (${ms_tos}ms) — cumulative DB for withdraw+pay (no run-until-done)"
    -
    -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
    -if [ "$#" -gt "$LADDER_MAX_RUNGS" ]; then
    -  # shellcheck disable=SC2046
    -  set -- $(printf '%s\n' "$@" | head -n "$LADDER_MAX_RUNGS")
    -fi
    -LADDER_N=$#
    -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=()
    -
    -set_group withdraw
    -section "ladder · phase A · withdraw (${LADDER_N} rungs)"
    -rung=0
    -for AMT in "$@"; do
    -  rung=$((rung + 1))
    -  if ladder_over; then
    -    STOP_REASON="timeout budget ${LADDER_TIMEOUT_S}s"
    -    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
    -
    -  section "ladder · rung $rung  $AMT · $(format_amount_alt "$AMT")"
    -  tag=$(printf '%s' "$AMT" | tr '.:' '__')
    -  # 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
    -  t_rung=$(now_ms)
    -  ms_mint=0 ms_accept=0 ms_confirm=0 ms_settle=0
    -  note=""
    -  status="FAIL"
    -  WID="-"
    -  FORCE_SEL_409_LOGGED=0
    -
    -  # keep cumulative main wallet
    -  wallet_prepare "main"
    -
    -  # 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 / 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
    -    # 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
    -      status="ZERO_REJECT"
    -      note="zero-withdraw rejected (expected possible): $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
    -    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"
    -    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 200 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')"
    -    ms_total=$(elapsed_ms "$t_rung")
    -    # 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"
    -        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
    -      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"
    -    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. 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 \
    -      | 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 \
    -      -H "Authorization: Bearer ${TOK}" -H 'Content-Type: application/json' -d '{}' \
    -      "${BANK}/accounts/${EXP_USER}/withdrawals/${WID}/confirm"
    -  }
    -  # Collect reserve_pub candidates for *this* withdrawal (WID + amount).
    -  # Cumulative wallets re-print old reserves in accept/tx dumps — never trust a single "last" blindly.
    -  # Prints unique pubs one per line, preferred order first.
    -  extract_rpubs_for_wid() {
    -    python3 - "$WID" "$AMT" "$SCRATCH/accept-$tag.out" "$SCRATCH/tx-$tag.json" "$SCRATCH/used-rpubs.txt" <<'PY'
    -import json, re, sys
    -from pathlib import Path
    -
    -wid = sys.argv[1]
    -amt = sys.argv[2]
    -paths = sys.argv[3:5]
    -used_path = sys.argv[5]
    -used = set()
    -if Path(used_path).is_file():
    -    used = {ln.strip() for ln in open(used_path) if ln.strip()}
    -
    -def walk_collect(o, bag, ctx=None):
    -    ctx = dict(ctx or {})
    -    if isinstance(o, dict):
    -        for k, v in o.items():
    -            kl = str(k).lower()
    -            if kl in ("withdrawal_id", "withdraw_id", "wopid", "id") and isinstance(v, str):
    -                ctx["id"] = v
    -            if kl in ("amount", "rawamount", "instructedamount") and isinstance(v, str):
    -                ctx["amount"] = v
    -            if kl in ("taler_withdraw_uri", "talerwithdrawuri", "uri") and isinstance(v, str):
    -                ctx["uri"] = v
    -            if kl in ("reserve_pub", "reservepub") and isinstance(v, str) and len(v) >= 40:
    -                bag.append((v, dict(ctx)))
    -            walk_collect(v, bag, ctx)
    -    elif isinstance(o, list):
    -        for i in o:
    -            walk_collect(i, bag, ctx)
    -
    -raw_pubs = []  # ordered as found
    -scored = []    # (score, pub) higher better
    -blob_all = ""
    -for p in paths:
    -    try:
    -        blob_all += open(p, errors="replace").read() + "\n"
    -    except Exception:
    -        pass
    -
    -# 1) full JSON objects in files
    -for p in paths:
    -    try:
    -        t = open(p, errors="replace").read()
    -    except Exception:
    -        continue
    -    # whole-file JSON
    -    for m in re.finditer(r"\{", t):
    -        try:
    -            o = json.loads(t[m.start():])
    -        except Exception:
    -            continue
    -        bag = []
    -        walk_collect(o, bag)
    -        for pub, ctx in bag:
    -            raw_pubs.append(pub)
    -            score = 0
    -            cid = str(ctx.get("id") or "")
    -            camt = str(ctx.get("amount") or "")
    -            curi = str(ctx.get("uri") or "")
    -            if wid and wid in cid:
    -                score += 100
    -            if wid and wid in curi:
    -                score += 80
    -            if amt and (camt == amt or camt.endswith(amt.split(":", 1)[-1])):
    -                score += 40
    -            if pub in used:
    -                score -= 200
    -            scored.append((score, pub))
    -
    -# 2) regex fallback on accept file only (more current)
    -try:
    -    acc = open(paths[0], errors="replace").read()
    -except Exception:
    -    acc = ""
    -for pat in (
    -    r"\"reserve_pub\"\s*:\s*\"([A-Z0-9]{40,})\"",
    -    r"\"reservePub\"\s*:\s*\"([A-Z0-9]{40,})\"",
    -    r"reserve_pub[\"\s:=]+([A-Z0-9]{40,})",
    -):
    -    for m in re.finditer(pat, acc, re.I):
    -        pub = m.group(1)
    -        raw_pubs.append(pub)
    -        score = 10
    -        # proximity to WID in accept output
    -        window = acc[max(0, m.start() - 400) : m.end() + 400]
    -        if wid and wid in window:
    -            score += 100
    -        if amt and amt in window:
    -            score += 30
    -        if pub in used:
    -            score -= 200
    -        scored.append((score, pub))
    -
    -# prefer high score, then later occurrence
    -order = []
    -seen = set()
    -for score, pub in sorted(scored, key=lambda x: (-x[0],), reverse=False):
    -    # sort by score desc: use reverse sorted
    -    pass
    -for score, pub in sorted(scored, key=lambda x: x[0], reverse=True):
    -    if pub in seen or pub in used:
    -        continue
    -    seen.add(pub)
    -    order.append(pub)
    -# append unused raw in reverse (newest-ish)
    -for pub in reversed(raw_pubs):
    -    if pub in seen or pub in used:
    -        continue
    -    seen.add(pub)
    -    order.append(pub)
    -for pub in order:
    -    print(pub)
    -PY
    -  }
    -
    -  mark_rpub_used() {
    -    local p="$1"
    -    [ -n "$p" ] || return 0
    -    mkdir -p "$SCRATCH" 2>/dev/null || true
    -    grep -qxF "$p" "$SCRATCH/used-rpubs.txt" 2>/dev/null || echo "$p" >>"$SCRATCH/used-rpubs.txt"
    -  }
    -
    -  force_select_if_needed() {
    -    local st_now="$1"
    -    [ "$st_now" = "pending" ] || [ -z "$st_now" ] || return 0
    -    local rpub epayto code_fs any=0
    -    # refresh tx dump each try (wallet may attach reserve late)
    -    wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true
    -    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 [ -z "$epayto" ]; then
    -      warn bank "force-select skipped" "problem: exchange payto empty from /keys"
    -      return 0
    -    fi
    -    # Try candidates until bank leaves pending (200/204) or we exhaust
    -    while IFS= read -r rpub; do
    -      [ -n "$rpub" ] || continue
    -      any=1
    -      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}" 2>/dev/null || echo "000")
    -      if [ "$code_fs" = "200" ] || [ "$code_fs" = "204" ]; then
    -        info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}… (ok for WID ${WID:0:8})"
    -        mark_rpub_used "$rpub"
    -        return 0
    -      fi
    -      if [ "$code_fs" = "409" ]; then
    -        # 5114 = this reserve already bound to another op — not "out of money"
    -        info "force-select" "HTTP 409 rpub=${rpub:0:12}… (stale/used reserve — not balance; trying next)"
    -        mark_rpub_used "$rpub"
    -        continue
    -      fi
    -      info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}… body=$(tr '\n' ' ' <"$SCRATCH/force-sel-$tag.json" 2>/dev/null | head -c 120)"
    -      # other errors: still try next candidate
    -    done < <(extract_rpubs_for_wid)
    -    if [ "$any" != "1" ]; then
    -      warn bank "force-select skipped" \
    -        "problem: no reserve_pub for this withdraw (WID=${WID:0:8}…); wallet may not have selected yet"
    -    fi
    -  }
    -
    -  # No run-until-done (hangs / banned). Read transactions only for reserve_pub candidates.
    -  wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true
    -
    -  t0=$(now_ms)
    -  conf_ok=0
    -  st=""
    -  # 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
    -      selected)
    -        ccode=$(do_confirm)
    -        if [ "$ccode" = "204" ] || [ "$ccode" = "200" ]; then
    -          conf_ok=1
    -          info "confirm" "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
    -    # force-select while pending — try alternate rpubs on 5114 (not out-of-money)
    -    if [ "$st" = "pending" ] || [ -z "$st" ]; then
    -      if [ "$i" -eq 1 ] || [ "$i" -eq 2 ] || [ $((i % 3)) -eq 0 ]; then
    -        force_select_if_needed "$st"
    -      fi
    -    fi
    -    sleep 0.35
    -  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: not confirmed — usually stale reserve_pub (5114), NOT empty pool balance
    -    status="SKIP_CONFIRM"
    -    warn bank "confirm $AMT skipped" \
    -      "problem: bank status='${st:-empty}' after ${LADDER_CONFIRM_POLLS} polls (want selected/confirmed). Usually reserve_pub mismatch (5114), not out-of-money — mint/accept already OK. 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
    -  ok "confirm $AMT ${ms_confirm}ms (client, on selected)"
    -
    -  # settle: poll wallet balance + bank transfer_done only — never run-until-done
    -  t0=$(now_ms)
    -  settled=0
    -  xfer="?"
    -  if [ "$IS_ZERO" = "1" ]; then
    -    settled=1
    -    note="zero-amount: no coin delta expected"
    -  else
    -    for r in $(seq 1 "$LADDER_SETTLE_ROUNDS"); do
    -      ladder_over && break
    -      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
    -      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"
    -    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"
    -    metrics_report_coins "ladder-r${rung}-after-${tag}" || true
    -    metrics_record_flow withdrawn "$AMT" || 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
    -    metrics_record_flow withdrawn "$AMT" || true
    -  else
    -    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"
    -    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
    -  set_group pay
    -  section "ladder · phase B · pay"
    -  metrics_report_coins "before-pay-ladder" || true
    -  # Merchant secret (same as e2e) — stage often has only public shop templates
    -  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" ] && [ "${CUR}" = "TESTPAYSAN" ]; then
    -    for f in \
    -      "${FRANCPAYSAN_SECRETS:-}/stage/default-instance-token.txt" \
    -      "${HOME}/francpaysan-secrets/stage/default-instance-token.txt" \
    -      "${HOME}/src/francpaysan-secrets/stage/default-instance-token.txt"
    -    do
    -      [ -f "$f" ] || continue
    -      MPW=$(tr -d '\n\r' <"$f")
    -      break
    -    done
    -    if [ -z "$MPW" ]; then
    -      for host in "${INSIDE_SSH:-}" francpaysan-stage-user francpaysan-host; do
    -        [ -n "$host" ] || continue
    -        MPW=$(ssh -o BatchMode=yes -o ConnectTimeout=12 "$host" \
    -          'tr -d "\n\r" /dev/null || true' \
    -          2>/dev/null || true)
    -        [ -n "$MPW" ] && break
    -      done
    -    fi
    -  fi
    -  if [ -z "$MPW" ]; then
    -    if [ "${CUR}" = "TESTPAYSAN" ]; then
    -      # Stage: withdraw ladder is the main probe; pays need instance token or
    -      # public templates for *exact* face values (not continuous ladder amounts).
    -      info pay "no merchant token — skip pay ladder on TESTPAYSAN (withdraw maxima still covered)"
    -      info pay "hint: set E2E_MERCHANT_TOKEN or use ./taler-monitoring.sh -d stage.lefrancpaysan.ch e2e for shop templates"
    -    else
    -      warn pay "no merchant token — skip pay ladder (set E2E_MERCHANT_TOKEN or secrets)"
    -    fi
    -  else
    -    ok "merchant token" "instance ${INST}"
    -    case "$MPW" in
    -      secret-token:*) AUTH="Authorization: Bearer ${MPW}" ;;
    -      *) AUTH="Authorization: Bearer secret-token:${MPW}" ;;
    -    esac
    -    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: merchant order + wallet tx only — never run-until-done
    -      t0=$(now_ms)
    -      settled=0
    -      r=0
    -      while [ "$r" -lt "${LADDER_PAY_SETTLE_ROUNDS}" ]; do
    -        r=$((r + 1))
    -        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
    -        sleep 0.5
    -      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
    -        metrics_record_flow spent "$PAMT" || 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 ---
    -set_group report
    -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 — 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 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 rs:
    -        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),
    -    "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(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("--- withdraw speed (ms) ---")
    -for k in ("mint", "accept", "confirm", "settle", "rung_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("--- 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("  %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("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
    -
    -set_group load
    -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
    -export METRICS_WITHDRAW_TSV="$TSV"
    -export METRICS_PAY_TSV="$PAY_TSV"
    -metrics_report_coins "ladder-end" || true
    -metrics_print_overall "ladder final" || 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" "$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
    -
    -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 withdraw rungs"
    -  exit 1
    -fi
    -ok "ladder finished withdraw_ok=$OK_N pay_ok=$PAY_OK_N phase=${ms_phase}ms"
    -exit 0
    diff --git a/scripts/taler-monitoring/check_inside.sh b/scripts/taler-monitoring/check_inside.sh
    deleted file mode 100755
    index 7f62880..0000000
    --- a/scripts/taler-monitoring/check_inside.sh
    +++ /dev/null
    @@ -1,546 +0,0 @@
    -#!/usr/bin/env bash
    -# Inside status for bank / exchange / merchant containers.
    -#
    -# Access modes (shown in the log as flags):
    -#   host-podman  — this host runs podman exec into containers (no SSH).
    -#                  INSIDE_PODMAN=1 or INSIDE_MODE=local-podman, or auto on GOA host.
    -#   ssh          — from a laptop/remote: SSH to KOOPA_SSH / INSIDE_SSH, then podman there.
    -#
    -# Profiles:
    -#   koopa (default LOCAL_STACK=1) — taler-hacktivism* containers
    -#   stage-lfp (TESTPAYSAN) — SSH INSIDE_SSH (stagepaysan), low-priv podman
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -# shellcheck source=metrics.sh
    -source "$ROOT/metrics.sh"
    -
    -set_area inside
    -
    -PROFILE="${INSIDE_PROFILE:-}"
    -if [ -z "$PROFILE" ]; then
    -  if [ "${LOCAL_STACK:-0}" = "1" ]; then
    -    PROFILE=koopa
    -  elif [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] || [ -n "${INSIDE_SSH:-}" ]; then
    -    PROFILE=stage-lfp
    -  else
    -    PROFILE=koopa
    -  fi
    -fi
    -
    -# Resolve host-podman vs ssh before first check ID
    -_use_local_podman=0
    -if [ "$PROFILE" != "stage-lfp" ]; then
    -  if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then
    -    _use_local_podman=1
    -  elif command -v podman >/dev/null 2>&1 \
    -    && podman ps --format '{{.Names}}' 2>/dev/null | grep -qE 'taler-hacktivism'; then
    -    _use_local_podman=1
    -  fi
    -fi
    -if [ "$_use_local_podman" = "1" ]; then
    -  INSIDE_ACCESS=host-podman
    -  set_group host
    -else
    -  INSIDE_ACCESS=ssh
    -  set_group ssh
    -fi
    -export INSIDE_ACCESS
    -
    -section "inside · collect (${PROFILE} · access=${INSIDE_ACCESS})"
    -info "flags" "INSIDE_ACCESS=${INSIDE_ACCESS} INSIDE_PODMAN=${INSIDE_PODMAN:-0} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-0} SKIP_SSH=${SKIP_SSH:-0} KOOPA_SSH=${KOOPA_SSH:-} INSIDE_SSH=${INSIDE_SSH:-}"
    -
    -# ---------------------------------------------------------------------------
    -# stage-lfp: low-priv stagepaysan on francpaysan-host
    -# ---------------------------------------------------------------------------
    -if [ "$PROFILE" = "stage-lfp" ]; then
    -  SSH_HOST="${INSIDE_SSH:-francpaysan-stage-user}"
    -  BANK_CTR="${INSIDE_BANK_CTR:-stage-lfp-bank}"
    -  EX_CTR="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}"
    -  MER_CTR="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}"
    -  BANK_PORT="${INSIDE_BANK_PORT:-9032}"
    -  EX_PORT="${INSIDE_EXCHANGE_PORT:-9031}"
    -  MER_PORT="${INSIDE_MERCHANT_PORT:-9030}"
    -  DNS_BANK="${INSIDE_DNS_BANK:-stage.bank.lefrancpaysan.ch}"
    -  DNS_EX="${INSIDE_DNS_EXCHANGE:-stage.exchange.lefrancpaysan.ch}"
    -  DNS_MER="${INSIDE_DNS_MERCHANT:-stage.monnaie.lefrancpaysan.ch}"
    -  # Stage remote has more podman execs than koopa; allow a bit more wall time
    -  STAGE_SSH_T="${INSIDE_SSH_TIMEOUT:-${SSH_CMD_TIMEOUT:-24}}"
    -  if [ "${STAGE_SSH_T}" -lt 24 ] 2>/dev/null; then STAGE_SSH_T=24; fi
    -
    -  if ! mon_ssh_ok "$SSH_HOST"; then
    -    err "ssh" "cannot reach ${SSH_HOST} (stagepaysan low-priv) — set INSIDE_SSH= or SKIP"
    -    summary
    -    exit 1
    -  fi
    -  ok "ssh ${SSH_HOST}" "stagepaysan (podman, no sudo)"
    -
    -  # Inject names/ports into remote (ssh bash -s does not inherit local env).
    -  RAW=$(
    -    {
    -      printf 'BANK_CTR=%q; EX_CTR=%q; MER_CTR=%q\n' "$BANK_CTR" "$EX_CTR" "$MER_CTR"
    -      printf 'BANK_PORT=%q; EX_PORT=%q; MER_PORT=%q\n' "$BANK_PORT" "$EX_PORT" "$MER_PORT"
    -      printf 'DNS_BANK=%q; DNS_EX=%q; DNS_MER=%q\n' "$DNS_BANK" "$DNS_EX" "$DNS_MER"
    -      cat <<'REMOTE'
    -set +e
    -emit() { printf 'E|%s|%s|%s|%s\n' "$1" "$2" "$3" "$(printf '%s' "${4:-}" | tr '\n\r' '  ' | head -c 200)"; }
    -hc() { curl -skS -m 3 -o /tmp/mb -w '%{http_code}' "$1" 2>/dev/null || echo 000; }
    -hasp() { podman exec "$1" pgrep -f "$2" >/dev/null 2>&1; }
    -
    -BANK="${BANK_CTR}"
    -EX="${EX_CTR}"
    -MER="${MER_CTR}"
    -podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$BANK" || BANK=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*bank|lfp-bank' | head -1)
    -podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$EX" || EX=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*exchange|lfp-exchange' | head -1)
    -podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$MER" || MER=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*merchant|lfp-merchant' | head -1)
    -
    -check_dns() {
    -  local comp="$1" ctr="$2" host="$3"
    -  local line ip code
    -  [ -n "$host" ] || { emit "$comp" WARN "dns" "empty host"; return 1; }
    -  line=$(podman exec "$ctr" getent ahostsv4 "$host" 2>/dev/null | head -1)
    -  [ -z "$line" ] && line=$(podman exec "$ctr" getent hosts "$host" 2>/dev/null | head -1)
    -  ip=$(echo "$line" | awk '{print $1}')
    -  if [ -z "$ip" ]; then
    -    emit "$comp" WARN "dns $host" "no resolve inside container"
    -    return 1
    -  fi
    -  if [ "$ip" = "127.0.0.1" ] || [ "$ip" = "::1" ]; then
    -    emit "$comp" WARN "dns $host" "$ip (loopback)"
    -    return 1
    -  fi
    -  # 169.254.x = pasta host-gateway pin is OK for stage
    -  code=$(podman exec "$ctr" curl -skS -m 3 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000)
    -  if [ "$code" = "200" ]; then
    -    emit "$comp" OK "dns $host" "→ $ip /config=$code"
    -  else
    -    emit "$comp" WARN "dns $host" "→ $ip /config=$code"
    -  fi
    -}
    -
    -if [ -z "$BANK" ]; then emit bank ERROR container "not running"
    -else
    -  emit bank INFO container "$(podman ps --filter name=^${BANK}$ --format '{{.Names}} {{.Status}}' | head -1)"
    -  emit bank INFO ports "$(podman ps --filter name=^${BANK}$ --format '{{.Ports}}' | head -1)"
    -  hasp "$BANK" 'MainKt serve|libeufin-bank serve' && emit bank OK libeufin "running" || emit bank ERROR libeufin "not running"
    -  podman exec "$BANK" pg_isready -q 2>/dev/null && emit bank OK postgres "ready" || emit bank WARN postgres "pg_isready failed"
    -  c=$(hc "http://127.0.0.1:${BANK_PORT}/config")
    -  [ "$c" = "200" ] && emit bank OK "host :${BANK_PORT}/config" "HTTP $c" || emit bank ERROR "host :${BANK_PORT}/config" "HTTP $c"
    -  c=$(hc "http://127.0.0.1:${BANK_PORT}/taler-integration/config")
    -  [ "$c" = "200" ] && emit bank OK "host integration" "HTTP $c" || emit bank ERROR "host integration" "HTTP $c"
    -  check_dns bank "$BANK" "$DNS_BANK" || true
    -  check_dns bank "$BANK" "$DNS_EX" || true
    -fi
    -
    -if [ -z "$EX" ]; then emit exchange ERROR container "not running"
    -else
    -  emit exchange INFO container "$(podman ps --filter name=^${EX}$ --format '{{.Names}} {{.Status}}' | head -1)"
    -  c=$(hc "http://127.0.0.1:${EX_PORT}/config")
    -  [ "$c" = "200" ] && emit exchange OK "host :${EX_PORT}/config" "HTTP $c" || emit exchange ERROR "host :${EX_PORT}/config" "HTTP $c"
    -  c=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:${EX_PORT}/keys" 2>/dev/null || echo 000)
    -  [ "$c" = "200" ] && emit exchange OK "host :${EX_PORT}/keys" "HTTP $c" || emit exchange ERROR "host :${EX_PORT}/keys" "HTTP $c"
    -  hasp "$EX" 'taler-exchange-httpd' && emit exchange OK httpd "running" || emit exchange ERROR httpd "not running"
    -  hasp "$EX" 'taler-exchange-wirewatch' && emit exchange OK wirewatch "running" || emit exchange ERROR wirewatch "not running"
    -  hasp "$EX" 'taler-exchange-aggregator' && emit exchange OK aggregator "running" || emit exchange WARN aggregator "not running"
    -  hasp "$EX" 'taler-exchange-transfer' && emit exchange OK transfer "running" || emit exchange WARN transfer "not running"
    -  check_dns exchange "$EX" "$DNS_BANK" || true
    -  check_dns exchange "$EX" "$DNS_EX" || true
    -  check_dns exchange "$EX" "$DNS_MER" || true
    -fi
    -
    -if [ -z "$MER" ]; then emit merchant ERROR container "not running"
    -else
    -  emit merchant INFO container "$(podman ps --filter name=^${MER}$ --format '{{.Names}} {{.Status}}' | head -1)"
    -  c=$(hc "http://127.0.0.1:${MER_PORT}/config")
    -  [ "$c" = "200" ] && emit merchant OK "host :${MER_PORT}/config" "HTTP $c" || emit merchant ERROR "host :${MER_PORT}/config" "HTTP $c"
    -  hasp "$MER" 'taler-merchant-httpd' && emit merchant OK httpd "running" || emit merchant ERROR httpd "not running"
    -  # Stage merchant often has no wirewatch unit — INFO not WARN
    -  hasp "$MER" 'taler-merchant-wirewatch' && emit merchant OK wirewatch "running" || emit merchant INFO wirewatch "not running (optional on stage)"
    -  hasp "$MER" 'taler-merchant-depositcheck' && emit merchant OK depositcheck "running" || emit merchant INFO depositcheck "not running (optional on stage)"
    -  hasp "$MER" 'taler-merchant-exchangekeyupdate' && emit merchant OK exchangekeyupdate "running" || emit merchant INFO exchangekeyupdate "not running"
    -  check_dns merchant "$MER" "$DNS_BANK" || true
    -  check_dns merchant "$MER" "$DNS_EX" || true
    -  check_dns merchant "$MER" "$DNS_MER" || true
    -fi
    -
    -# Caddy is host-wide (often root); stagepaysan can only see the process
    -if pgrep -x caddy >/dev/null 2>&1; then
    -  emit caddy OK process "running (host)"
    -else
    -  emit caddy WARN process "not seen as stagepaysan (may still run as root)"
    -fi
    -
    -echo DONE
    -REMOTE
    -    } | mon_ssh_bash "$SSH_HOST" "${STAGE_SSH_T}" || true
    -  )
    -
    -  if [ -z "$RAW" ] || ! echo "$RAW" | grep -q '^E|'; then
    -    err "ssh" "stage remote timed out or empty (cap ${STAGE_SSH_T}s · host=${SSH_HOST})"
    -    summary
    -    exit 1
    -  fi
    -
    -  _last_inside_grp=""
    -  while IFS= read -r line; do
    -    case "$line" in
    -      E\|*)
    -        IFS='|' read -r _ comp level key detail <<<"$line"
    -        case "$comp" in
    -          bank|exchange|merchant|caddy) _g="$comp" ;;
    -          *) _g="ssh" ;;
    -        esac
    -        if [ "$_g" != "$_last_inside_grp" ]; then
    -          set_group "$_g"
    -          _last_inside_grp="$_g"
    -        fi
    -        case "$level" in
    -          OK)    ok "[$comp] $key${detail:+ ($detail)}" ;;
    -          ERROR) err "$comp" "$key" "$detail" ;;
    -          WARN)  warn "[$comp] $key" "$detail" ;;
    -          INFO)  info "[$comp] $key" "$detail" ;;
    -        esac
    -        ;;
    -    esac
    -  done <<<"$RAW"
    -
    -  # Stats from outside (laptop) — public HTTPS, no stagepaysan write needed
    -  set_group stats
    -  section "inside · public stats (outside-in)"
    -  _probe_public_stats() {
    -    local comp="$1" url="$2"
    -    local body code age unix now
    -    body=$(mktemp)
    -    code=$(curl -skS -m 8 -o "$body" -w '%{http_code}' "$url" 2>/dev/null || echo 000)
    -    if [ "$code" != "200" ]; then
    -      warn "[$comp] public stats.json" "HTTP $code · $url"
    -      rm -f "$body"
    -      return
    -    fi
    -    unix=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("generated_at_unix") or 0)' "$body" 2>/dev/null || echo 0)
    -    now=$(date +%s)
    -    if [ "${unix:-0}" -gt 0 ] 2>/dev/null; then
    -      age=$((now - unix))
    -      if [ "$age" -lt 0 ]; then age=0; fi
    -      if [ "$age" -le "${STATS_STALE_SECS:-900}" ]; then
    -        ok "[$comp] public stats.json" "HTTP 200 age=${age}s · $url"
    -      elif [ "$age" -le "${STATS_FAIL_SECS:-3600}" ]; then
    -        warn "[$comp] public stats.json" "stale age=${age}s · $url"
    -      else
    -        err "$comp" "public stats.json" "too old age=${age}s · $url"
    -      fi
    -    else
    -      ok "[$comp] public stats.json" "HTTP 200 · $url"
    -    fi
    -    rm -f "$body"
    -  }
    -  _probe_public_stats bank "https://${DNS_BANK}/intro/stats.json"
    -  _probe_public_stats exchange "https://${DNS_EX}/intro/stats.json"
    -  _probe_public_stats merchant "https://${DNS_MER}/intro/stats.json"
    -
    -  # Host load as stagepaysan (no container RSS from koopa metrics)
    -  set_group load
    -  section "inside · load (stagepaysan host)"
    -  LOAD_LINE=$(mon_ssh_bash "$SSH_HOST" 8 <<'EOF' || true
    -python3 - <<'PY'
    -import os
    -la=os.getloadavg()
    -print("loadavg=%.2f,%.2f,%.2f" % la)
    -try:
    -  with open("/proc/meminfo") as f:
    -    d={}
    -    for line in f:
    -      k,v=line.split(":")[0], line.split(":")[1].strip().split()[0]
    -      d[k]=int(v)
    -  total=d.get("MemTotal",0)/1024/1024
    -  avail=d.get("MemAvailable",0)/1024/1024
    -  used=total-avail
    -  print("mem_used=%.2fGiB avail=%.2fGiB total=%.2fGiB" % (used, avail, total))
    -except Exception:
    -  print("mem=?")
    -PY
    -EOF
    -)
    -  if [ -n "$LOAD_LINE" ]; then
    -    info "stage host" "$(echo "$LOAD_LINE" | tr '\n' ' ')"
    -  else
    -    info "stage host" "load probe empty"
    -  fi
    -
    -  set_group disk
    -  section "inside · disk free space (stage host + containers)"
    -  _disk_raw=$(mon_ssh_bash "$SSH_HOST" "${STAGE_SSH_T:-24}" <<'DISK' || true
    -set +e
    -echo "###HOST###"
    -df -Pk / /var /home /tmp /mnt/data 2>/dev/null || df -Pk
    -echo "###CTRS###"
    -for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do
    -  echo "###CTR $c###"
    -  podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null
    -done
    -DISK
    -)
    -  _host_df=$(printf '%s\n' "$_disk_raw" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d')
    -  mon_disk_check_remote_text "ssh:${SSH_HOST}" "$_host_df" || true
    -  _ctr=""; _buf=""
    -  while IFS= read -r _line || [ -n "$_line" ]; do
    -    case "$_line" in
    -      '###CTR '*)
    -        if [ -n "$_ctr" ]; then mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true; fi
    -        _ctr=${_line####CTR }; _ctr=${_ctr%###}; _buf=""
    -        ;;
    -      '###CTRS###'|'###HOST###') ;;
    -      *) [ -n "$_ctr" ] && _buf="${_buf}${_line}"$'\n' ;;
    -    esac
    -  done <<<"$_disk_raw"
    -  [ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true
    -
    -  summary
    -  exit 0
    -fi
    -
    -# ---------------------------------------------------------------------------
    -# koopa (default) — host-podman exec (on GOA host) or SSH to KOOPA_SSH (laptop)
    -# ---------------------------------------------------------------------------
    -section "inside · collect from koopa (access=${INSIDE_ACCESS})"
    -
    -if [ "$_use_local_podman" = "1" ]; then
    -  set_group host
    -  if ! command -v podman >/dev/null 2>&1; then
    -    err "host" "INSIDE_PODMAN/host-podman but podman missing"
    -    summary
    -    exit 1
    -  fi
    -  ok "host→container" "podman exec on this host (INSIDE_ACCESS=host-podman · no SSH)"
    -elif [ "${SKIP_SSH}" = "1" ] && [ "${LOCAL_STACK:-0}" != "1" ]; then
    -  set_group ssh
    -  warn "ssh" "SKIP_SSH=1 and not host-podman — skipped"
    -  summary
    -  exit 0
    -elif ! koopa_ssh_ok; then
    -  set_group ssh
    -  err "ssh" "cannot reach ${KOOPA_SSH} — from laptop use KOOPA_SSH=; on host set INSIDE_PODMAN=1"
    -  summary
    -  exit 1
    -else
    -  set_group ssh
    -  ok "ssh ${KOOPA_SSH}" "remote host then podman (INSIDE_ACCESS=ssh · laptop/remote)"
    -fi
    -
    -# Collect script (file → bash local or ssh bash -s)
    -_INSIDE_SCRIPT=$(mktemp)
    -trap 'rm -f "${_INSIDE_SCRIPT:-}"' RETURN
    -cat >"$_INSIDE_SCRIPT" <<'REMOTE'
    -set +e
    -emit() { printf 'E|%s|%s|%s|%s\n' "$1" "$2" "$3" "$(printf '%s' "${4:-}" | tr '\n\r' '  ' | head -c 200)"; }
    -# quick curl
    -hc() { curl -skS -m 3 -o /tmp/mb -w '%{http_code}' "$1" 2>/dev/null || echo 000; }
    -# quick process check inside container (pgrep only)
    -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 -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
    -check_dns() {
    -  local comp="$1" ctr="$2" host="$3"
    -  local line ip code
    -  # Prefer IPv4 (wirewatch/libcurl often happier; avoid dead AAAA)
    -  line=$(podman exec "$ctr" getent ahostsv4 "$host" 2>/dev/null | head -1)
    -  [ -z "$line" ] && line=$(podman exec "$ctr" getent hosts "$host" 2>/dev/null | head -1)
    -  ip=$(echo "$line" | awk '{print $1}')
    -  if [ -z "$ip" ]; then
    -    emit "$comp" ERROR "dns $host" "no resolve — run pin-container-hosts.sh"
    -    return 1
    -  fi
    -  # 127.0.0.1 is almost always wrong for public bank/exchange from inside pasta
    -  if [ "$ip" = "127.0.0.1" ] || [ "$ip" = "::1" ]; then
    -    emit "$comp" ERROR "dns $host" "$ip (loopback — wirewatch will fail)"
    -    return 1
    -  fi
    -  code=$(podman exec "$ctr" curl -skS -m 3 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000)
    -  if [ "$code" = "200" ]; then
    -    emit "$comp" OK "dns $host" "→ $ip /config=$code"
    -  else
    -    emit "$comp" WARN "dns $host" "→ $ip /config=$code"
    -  fi
    -}
    -
    -if [ -z "$BANK" ]; then emit bank ERROR container "not running"
    -else
    -  emit bank INFO container "$(podman ps --filter name=$BANK --format '{{.Names}} {{.Status}}' | head -1)"
    -  emit bank INFO ports "$(podman ps --filter name=$BANK --format '{{.Ports}}' | head -1)"
    -  hasp "$BANK" 'MainKt serve|libeufin-bank serve' && emit bank OK libeufin "running" || emit bank ERROR libeufin "not running — API/withdraw dead"
    -  podman exec "$BANK" pg_isready -q 2>/dev/null && emit bank OK postgres "ready" || emit bank ERROR postgres "not ready"
    -  c=$(hc http://127.0.0.1:9012/config)
    -  [ "$c" = "200" ] && emit bank OK "local /config" "HTTP $c" || emit bank ERROR "local /config" "HTTP $c"
    -  c=$(hc http://127.0.0.1:9012/taler-integration/config)
    -  [ "$c" = "200" ] && emit bank OK "local integration" "HTTP $c" || emit bank ERROR "local integration" "HTTP $c"
    -  hasp "$BANK" 'nginx' && emit bank OK nginx ":9013" || emit bank WARN nginx "not running"
    -  check_dns bank "$BANK" bank.hacktivism.ch || true
    -  check_dns bank "$BANK" exchange.hacktivism.ch || true
    -fi
    -
    -if [ -z "$EX" ]; then emit exchange ERROR container "not running"
    -else
    -  emit exchange INFO container "$(podman ps --filter name=$EX --format '{{.Names}} {{.Status}}' | head -1)"
    -  c=$(hc http://127.0.0.1:9011/config)
    -  [ "$c" = "200" ] && emit exchange OK "local /config" "HTTP $c" || emit exchange ERROR "local /config" "HTTP $c"
    -  c=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000)
    -  [ "$c" = "200" ] && emit exchange OK "local /keys" "HTTP $c" || emit exchange ERROR "local /keys" "HTTP $c"
    -  hasp "$EX" 'taler-exchange-httpd' && emit exchange OK httpd "running" || emit exchange ERROR httpd "not running"
    -  hasp "$EX" 'taler-exchange-wirewatch' && emit exchange OK wirewatch "running" || emit exchange ERROR wirewatch "not running — withdraw stuck after bank confirm"
    -  hasp "$EX" 'taler-exchange-aggregator' && emit exchange OK aggregator "running" || emit exchange WARN aggregator "not running"
    -  hasp "$EX" 'taler-exchange-transfer' && emit exchange OK transfer "running" || emit exchange WARN transfer "not running"
    -  # critical for wire gateway
    -  check_dns exchange "$EX" bank.hacktivism.ch || true
    -  check_dns exchange "$EX" exchange.hacktivism.ch || true
    -  check_dns exchange "$EX" taler.hacktivism.ch || true
    -fi
    -
    -if [ -z "$MER" ]; then emit merchant ERROR container "not running"
    -else
    -  emit merchant INFO container "$(podman ps --filter name=$MER --format '{{.Names}} {{.Status}}' | head -1)"
    -  c=$(hc https://127.0.0.1:9010/config)
    -  [ "$c" = "200" ] && emit merchant OK "local /config" "HTTP $c" || emit merchant ERROR "local /config" "HTTP $c"
    -  hasp "$MER" 'taler-merchant-httpd' && emit merchant OK httpd "running" || emit merchant ERROR httpd "not running"
    -  hasp "$MER" 'taler-merchant-wirewatch' && emit merchant OK wirewatch "running" || emit merchant WARN wirewatch "not running"
    -  # depositcheck: may abort on exchange track 404 (merchant assertion) — try ensure once
    -  if hasp "$MER" 'taler-merchant-depositcheck'; then
    -    emit merchant OK depositcheck "running"
    -  else
    -    podman exec "$MER" /usr/local/bin/ensure_merchant_helpers.sh >/dev/null 2>&1 || true
    -    sleep 1
    -    if hasp "$MER" 'taler-merchant-depositcheck'; then
    -      emit merchant OK depositcheck "running (after ensure_merchant_helpers)"
    -    elif hasp "$MER" 'taler-merchant-wirewatch'; then
    -      # Known: depositcheck can SIGABRT on exchange 404 for stale coins — not startable cleanly
    -      emit merchant INFO depositcheck "not running (often aborts on track 404; wirewatch OK · settlement partial)"
    -    else
    -      emit merchant WARN depositcheck "not running"
    -    fi
    -  fi
    -  check_dns merchant "$MER" bank.hacktivism.ch || true
    -  check_dns merchant "$MER" exchange.hacktivism.ch || true
    -  check_dns merchant "$MER" taler.hacktivism.ch || true
    -fi
    -
    -if systemctl is-active caddy >/dev/null 2>&1 || pgrep -x caddy >/dev/null 2>&1; then
    -  emit caddy OK process "active"
    -else
    -  emit caddy ERROR process "not active"
    -fi
    -echo DONE
    -REMOTE
    -
    -if [ "$_use_local_podman" = "1" ]; then
    -  RAW=$(bash "$_INSIDE_SCRIPT" || true)
    -else
    -  RAW=$(koopa_ssh_bash "${SSH_CMD_TIMEOUT}" <"$_INSIDE_SCRIPT" || true)
    -fi
    -rm -f "$_INSIDE_SCRIPT"
    -
    -if [ -z "$RAW" ] || ! echo "$RAW" | grep -q '^E|'; then
    -  if [ "$_use_local_podman" = "1" ]; then
    -    err "host" "collect empty (INSIDE_ACCESS=host-podman · podman exec failed)"
    -  else
    -    err "ssh" "collect empty (INSIDE_ACCESS=ssh · cap ${SSH_CMD_TIMEOUT}s · host=${KOOPA_SSH})"
    -  fi
    -  summary
    -  exit 1
    -fi
    -
    -# Fallback group for non-component rows: host (podman) vs ssh (remote)
    -_access_grp=ssh
    -[ "$_use_local_podman" = "1" ] && _access_grp=host
    -
    -_last_inside_grp=""
    -while IFS= read -r line; do
    -  case "$line" in
    -    E\|*)
    -      IFS='|' read -r _ comp level key detail <<<"$line"
    -      # Group IDs by component: inside.bank-02, inside.exchange-04; access = host|ssh
    -      case "$comp" in
    -        bank|exchange|merchant|caddy) _g="$comp" ;;
    -        *) _g="$_access_grp" ;;
    -      esac
    -      if [ "$_g" != "$_last_inside_grp" ]; then
    -        set_group "$_g"
    -        _last_inside_grp="$_g"
    -      fi
    -      case "$level" in
    -        OK)    ok "[$comp] $key${detail:+ ($detail)}" ;;
    -        ERROR) err "$comp" "$key" "$detail" ;;
    -        WARN)  warn "[$comp] $key" "$detail" ;;
    -        INFO)  info "[$comp] $key" "$detail" ;;
    -      esac
    -      ;;
    -  esac
    -done <<<"$RAW"
    -
    -# Host loadavg + RAM + per-container RSS/CPU (same probe as e2e/ladder)
    -set_group load
    -section "inside · load / memory"
    -METRICS_DIR="${METRICS_DIR:-$(mktemp -d)}"
    -export METRICS_DIR
    -metrics_report_load "${METRICS_DIR}/load-inside.json" "inside" || true
    -
    -# Disk free space: host + taler containers (WARN tight, ERROR full)
    -set_group disk
    -section "inside · disk free space"
    -_disk_ec=0
    -if [ "$_use_local_podman" = "1" ]; then
    -  mon_disk_check_host "host" || _disk_ec=1
    -  while read -r _cname; do
    -    [ -n "$_cname" ] || continue
    -    mon_disk_check_podman "$_cname" || _disk_ec=1
    -  done < <(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism|taler-|stage-lfp|lfp-' || true)
    -else
    -  # remote host via SSH: df on host + each container
    -  _disk_raw=$(koopa_ssh_bash "${SSH_CMD_TIMEOUT:-20}" <<'DISK' || true
    -set +e
    -echo "###HOST###"
    -df -Pk / /var /home /tmp 2>/dev/null || df -Pk
    -echo "###CTRS###"
    -for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do
    -  echo "###CTR $c###"
    -  podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null
    -done
    -DISK
    -)
    -  _host_df=$(printf '%s\n' "$_disk_raw" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d')
    -  mon_disk_check_remote_text "ssh:${KOOPA_SSH:-remote}" "$_host_df" || _disk_ec=1
    -  _ctr=""
    -  _buf=""
    -  while IFS= read -r _line || [ -n "$_line" ]; do
    -    case "$_line" in
    -      '###CTR '*)
    -        if [ -n "$_ctr" ]; then
    -          mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || _disk_ec=1
    -        fi
    -        _ctr=${_line####CTR }
    -        _ctr=${_ctr%###}
    -        _buf=""
    -        ;;
    -      '###CTRS###'|'###HOST###') ;;
    -      *)
    -        if [ -n "$_ctr" ]; then
    -          _buf="${_buf}${_line}"$'\n'
    -        fi
    -        ;;
    -    esac
    -  done <<<"$_disk_raw"
    -  if [ -n "$_ctr" ]; then
    -    mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || _disk_ec=1
    -  fi
    -fi
    -unset _disk_raw _host_df _ctr _buf _line _cname _disk_ec
    -
    -summary
    diff --git a/scripts/taler-monitoring/check_qr_payloads.py b/scripts/taler-monitoring/check_qr_payloads.py
    deleted file mode 100755
    index 74efde4..0000000
    --- a/scripts/taler-monitoring/check_qr_payloads.py
    +++ /dev/null
    @@ -1,294 +0,0 @@
    -#!/usr/bin/env python3
    -"""Collect + validate QR-related payloads for taler-monitoring (www.qr-*).
    -
    -Sources (CLI paths / URLs handled by caller writing local files):
    -  - landing HTML (href, data-qr-url, data-qr-taler, raw text)
    -  - demo-withdraw.json / auto-account.json (taler_withdraw_uri / qr_payload)
    -  - optional: already-decoded lines from zbarimg (kind=decoded)
    -
    -Stdout TSV: source\\tkind\\turi\\tstatus\\tdetail
    -  kind: withdraw | pay | pay-template | refund | payto | https | other | empty
    -  status: ok | bad
    -
    -Exit 0 always (caller tallies ok/bad lines). stderr for hard parse errors only.
    -"""
    -from __future__ import annotations
    -
    -import json
    -import re
    -import sys
    -from html.parser import HTMLParser
    -from pathlib import Path
    -from urllib.parse import unquote, urlparse
    -
    -# Schemes we care about for wallet / settlement QRs
    -TALER_RE = re.compile(r"taler://[^\s\"'<>]+", re.I)
    -PAYTO_RE = re.compile(r"payto://[^\s\"'<>]+", re.I)
    -HTTPS_RE = re.compile(r"https://[^\s\"'<>]+", re.I)
    -
    -WITHDRAW_RE = re.compile(
    -    r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)/?$"
    -)
    -# order pay / template — host may include path segments after
    -PAY_RE = re.compile(r"^taler://pay/([^/?#]+)/", re.I)
    -PAY_TEMPLATE_RE = re.compile(r"^taler://pay-template/([^/?#]+)/", re.I)
    -REFUND_RE = re.compile(r"^taler://refund/", re.I)
    -PAYTO_OK_RE = re.compile(r"^payto://[a-z0-9][a-z0-9+.-]*/", re.I)
    -
    -
    -def looks_like_real_uri(uri: str) -> bool:
    -    """Drop JS regex fragments, docs ellipses, incomplete placeholders."""
    -    if not uri or len(uri) < 12:
    -        return False
    -    if any(c in uri for c in "[]{}<>\n\r\t"):
    -        return False
    -    if "…" in uri or "..." in uri:
    -        return False
    -    # incomplete withdraw stubs in docs
    -    if re.match(r"^taler://withdraw/?$", uri, re.I):
    -        return False
    -    if re.match(r"^taler://pay/?$", uri, re.I):
    -        return False
    -    # trailing junk / JS regex fragments (allow ? & = in query strings)
    -    if uri.endswith((")", "(", "\\", "^", "*")):
    -        return False
    -    if re.search(r"[\\^$*]", uri):
    -        return False
    -    # lone incomplete `taler://…?` without path
    -    if re.match(r"^taler://[^/]+$", uri, re.I):
    -        return False
    -    return True
    -
    -
    -def strip_default_port_host(host: str) -> str | None:
    -    if not host or host.startswith(":"):
    -        return None
    -    if ":" in host:
    -        h, _, p = host.rpartition(":")
    -        if not h or not p.isdigit():
    -            return None
    -        if p in ("443", "80"):
    -            return None  # must be stripped for wallets
    -    return host
    -
    -
    -def classify_and_validate(uri: str) -> tuple[str, str, str]:
    -    """Return (kind, status, detail)."""
    -    uri = (uri or "").strip()
    -    if not uri:
    -        return "empty", "bad", "empty"
    -    # strip accidental trailing punctuation from HTML scrape
    -    uri = uri.rstrip(").,;]")
    -    low = uri.lower()
    -
    -    if low.startswith("taler://withdraw/"):
    -        m = WITHDRAW_RE.match(uri)
    -        if not m:
    -            return "withdraw", "bad", "shape need taler://withdraw/HOST/taler-integration/UUID"
    -        host = m.group(1)
    -        if strip_default_port_host(host) is None:
    -            return "withdraw", "bad", "host/port invalid or default :443/:80 not stripped"
    -        return "withdraw", "ok", f"host={host} id={m.group(2)[:12]}…"
    -
    -    if low.startswith("taler://pay-template/"):
    -        m = PAY_TEMPLATE_RE.match(uri)
    -        if not m:
    -            return "pay-template", "bad", "need taler://pay-template/HOST/…"
    -        host = m.group(1)
    -        if strip_default_port_host(host) is None:
    -            return "pay-template", "bad", "host/port invalid or default port not stripped"
    -        return "pay-template", "ok", f"host={host}"
    -
    -    if low.startswith("taler://pay/"):
    -        m = PAY_RE.match(uri)
    -        if not m:
    -            return "pay", "bad", "need taler://pay/HOST/…"
    -        host = m.group(1)
    -        if strip_default_port_host(host) is None:
    -            return "pay", "bad", "host/port invalid or default port not stripped"
    -        if "instances" not in uri and "/orders/" not in uri:
    -            # still often valid private order URIs
    -            return "pay", "ok", f"host={host}"
    -        return "pay", "ok", f"host={host}"
    -
    -    if low.startswith("taler://refund/"):
    -        if not REFUND_RE.match(uri):
    -            return "refund", "bad", "bad refund URI"
    -        return "refund", "ok", "refund"
    -
    -    if low.startswith("taler://"):
    -        return "other", "ok", "other taler:// (accepted)"
    -
    -    if low.startswith("payto://"):
    -        if not PAYTO_OK_RE.match(uri):
    -            return "payto", "bad", "need payto://method/…"
    -        # wallet pay QR must not be payto for GOA demo-withdraw (checked elsewhere)
    -        return "payto", "ok", uri.split("?", 1)[0][:72]
    -
    -    if low.startswith("https://") or low.startswith("http://"):
    -        p = urlparse(uri)
    -        if p.scheme not in ("http", "https") or not p.netloc:
    -            return "https", "bad", "bad URL"
    -        return "https", "ok", p.netloc + (p.path or "/")[:40]
    -
    -    return "other", "bad", "unsupported scheme"
    -
    -
    -class LinkHarvester(HTMLParser):
    -    def __init__(self) -> None:
    -        super().__init__()
    -        self.uris: list[tuple[str, str]] = []  # (source_hint, uri)
    -
    -    def handle_starttag(self, tag, attrs):
    -        ad = {k.lower(): (v or "") for k, v in attrs}
    -        for key in ("href", "src", "data-qr-url", "data-qr-taler", "data-uri", "data-payto"):
    -            if key in ad and ad[key]:
    -                self.uris.append((f"attr:{key}", ad[key].strip()))
    -        for k, v in ad.items():
    -            if k.startswith("data-") and v and (
    -                "taler://" in v.lower() or "payto://" in v.lower() or k.endswith("qr-url")
    -            ):
    -                self.uris.append((f"attr:{k}", v.strip()))
    -
    -    def handle_data(self, data):
    -        if not data or "://" not in data:
    -            return
    -        for m in TALER_RE.finditer(data):
    -            self.uris.append(("text", m.group(0)))
    -        for m in PAYTO_RE.finditer(data):
    -            self.uris.append(("text", m.group(0)))
    -
    -
    -def harvest_html(path: Path, source: str) -> list[tuple[str, str, str]]:
    -    raw = path.read_text(encoding="utf-8", errors="replace")
    -    h = LinkHarvester()
    -    try:
    -        h.feed(raw)
    -    except Exception:
    -        pass
    -    out: list[tuple[str, str, str]] = []
    -    for hint, u in h.uris:
    -        lu = u.lower()
    -        if lu.startswith(("taler://", "payto://", "https://", "http://")):
    -            if looks_like_real_uri(u):
    -                out.append((f"{source}:{hint}", u, "html"))
    -        # raster image paths that look like QR assets (not .js)
    -        elif re.search(r"\.(png|jpe?g|gif)(\?|$)", lu) and "qr" in lu:
    -            out.append((f"{source}:imgpath", u, "imgpath"))
    -    # Prefer attribute/text harvest; light regex only for complete-looking URIs in pre/code
    -    for m in TALER_RE.finditer(raw):
    -        u = m.group(0).rstrip(").,;]'\"")
    -        if looks_like_real_uri(u) and (
    -            WITHDRAW_RE.match(u)
    -            or PAY_RE.match(u)
    -            or PAY_TEMPLATE_RE.match(u)
    -            or u.lower().startswith("taler://refund/")
    -        ):
    -            out.append((f"{source}:re", u, "html"))
    -    for m in PAYTO_RE.finditer(raw):
    -        u = m.group(0).rstrip(").,;]'\"")
    -        if looks_like_real_uri(u) and PAYTO_OK_RE.match(u):
    -            out.append((f"{source}:re", u, "html"))
    -    return out
    -
    -
    -def harvest_json(path: Path, source: str) -> list[tuple[str, str, str]]:
    -    try:
    -        d = json.loads(path.read_text(encoding="utf-8", errors="replace"))
    -    except Exception as e:
    -        return [(source, "", f"json-error:{e}")]
    -    out: list[tuple[str, str, str]] = []
    -    if not isinstance(d, dict):
    -        return out
    -    for key in (
    -        "taler_withdraw_uri",
    -        "qr_payload",
    -        "taler_pay_uri",
    -        "payto_uri",
    -        "payto",
    -        "uri",
    -    ):
    -        v = d.get(key)
    -        if isinstance(v, str) and v.strip():
    -            out.append((f"{source}:{key}", v.strip(), "json"))
    -    # nested
    -    for k, v in d.items():
    -        if isinstance(v, str) and (
    -            v.startswith("taler://") or v.startswith("payto://")
    -        ):
    -            out.append((f"{source}:{k}", v.strip(), "json"))
    -    return out
    -
    -
    -def main(argv: list[str]) -> int:
    -    # args: pairs of  source_label  path
    -    if len(argv) < 3 or len(argv) % 2 == 0:
    -        print(
    -            "usage: check_qr_payloads.py LABEL path [LABEL path ...]",
    -            file=sys.stderr,
    -        )
    -        return 2
    -    seen: set[str] = set()
    -    rows: list[tuple[str, str, str, str, str]] = []
    -    i = 1
    -    while i < len(argv):
    -        label, path_s = argv[i], argv[i + 1]
    -        i += 2
    -        path = Path(path_s)
    -        if not path.is_file():
    -            rows.append((label, "empty", "", "bad", f"missing file {path_s}"))
    -            continue
    -        if path.suffix.lower() in (".html", ".htm") or "html" in path.name:
    -            items = harvest_html(path, label)
    -        elif path.suffix.lower() == ".json" or path.name.endswith(".json"):
    -            items = harvest_json(path, label)
    -        else:
    -            # plain text: one URI per line or raw
    -            text = path.read_text(encoding="utf-8", errors="replace").strip()
    -            items = []
    -            for line in text.splitlines():
    -                line = line.strip()
    -                if line:
    -                    items.append((label, line, "text"))
    -            if not items and text:
    -                items.append((label, text, "text"))
    -
    -        for src, uri, _origin in items:
    -            if not uri:
    -                continue
    -            # skip pure fragment / relative without scheme for validate
    -            if uri.startswith("#") or uri == "/":
    -                continue
    -            lu = uri.lower()
    -            if lu.startswith("/") or "imgpath" in src:
    -                continue
    -            if not looks_like_real_uri(uri):
    -                continue
    -            # https: app-store / wallet / explicit data-qr-* only
    -            if lu.startswith("http"):
    -                if "data-qr" not in src and not any(
    -                    x in lu
    -                    for x in (
    -                        "play.google",
    -                        "apps.apple",
    -                        "f-droid",
    -                        "wallet.taler",
    -                        "taler.net/wallet",
    -                    )
    -                ):
    -                    continue
    -            if uri in seen:
    -                continue
    -            seen.add(uri)
    -            kind, status, detail = classify_and_validate(uri)
    -            rows.append((src, kind, uri, status, detail))
    -
    -    for src, kind, uri, status, detail in rows:
    -        # TSV — uri may contain tabs rarely; replace
    -        safe = uri.replace("\t", " ").replace("\n", " ")
    -        print(f"{src}\t{kind}\t{safe}\t{status}\t{detail}")
    -    return 0
    -
    -
    -if __name__ == "__main__":
    -    raise SystemExit(main(sys.argv))
    diff --git a/scripts/taler-monitoring/check_sanity.sh b/scripts/taler-monitoring/check_sanity.sh
    deleted file mode 100755
    index 1e3b922..0000000
    --- a/scripts/taler-monitoring/check_sanity.sh
    +++ /dev/null
    @@ -1,285 +0,0 @@
    -#!/usr/bin/env bash
    -# Sanity checks for bank · exchange · merchant (public + server-side).
    -# Sections are independent; continues after failures.
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -
    -tmp=$(mktemp -d)
    -trap 'rm -rf "$tmp"' EXIT
    -
    -expect_code() {
    -  local label="$1" want="$2" url="$3"
    -  local code
    -  code=$(http_code "$url")
    -  case ",$want," in
    -    *",$code,"*) ok "$label" ;;
    -    *) fail "$label" "HTTP $code (want $want) $url" ;;
    -  esac
    -}
    -
    -json_currency() {
    -  python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("currency") or "")' "$1" 2>/dev/null || true
    -}
    -
    -# Area sanity.* — public + optional server-side per component
    -# Groups: sanity.bank / sanity.exchange / sanity.merchant
    -set_area sanity
    -
    -# ---------------------------------------------------------------------------
    -set_group bank
    -section "sanity · bank"
    -# ---------------------------------------------------------------------------
    -expect_code "bank public /config" 200 "$BANK_PUBLIC/config"
    -expect_code "bank public /taler-integration/config" 200 "$BANK_PUBLIC/taler-integration/config"
    -expect_code "bank public /webui/" 200 "$BANK_PUBLIC/webui/"
    -
    -code=$(http_body "$BANK_PUBLIC/config" "$tmp/bank-config.json")
    -if [ "$code" = "200" ]; then
    -  cur=$(json_currency "$tmp/bank-config.json")
    -  [ "$cur" = "GOA" ] && ok "bank currency GOA" || fail "bank currency" "got ${cur:-?}"
    -  # wire type / name if present
    -  python3 - "$tmp/bank-config.json" <<'PY' 2>/dev/null && ok "bank config JSON object" || fail "bank config JSON"
    -import json,sys
    -d=json.load(open(sys.argv[1]))
    -sys.exit(0 if isinstance(d, dict) and d.get("currency") else 1)
    -PY
    -  if json_has_alt_unit_names "$tmp/bank-config.json" >/tmp/alt-bank-s.$$ 2>&1; then
    -    ok "bank /config alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000)
    -echo "LOCAL_CONFIG=$code"
    -code2=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:9012/taler-integration/config 2>/dev/null || echo 000)
    -echo "LOCAL_INT=$code2"
    -if podman exec "$BANK" bash -c 'pgrep -f "MainKt serve|libeufin-bank serve" >/dev/null' 2>/dev/null; then
    -  echo "LIBEUFIN=1"
    -else
    -  echo "LIBEUFIN=0"
    -fi
    -if podman exec "$BANK" bash -c 'pg_isready -q' 2>/dev/null; then
    -  echo "PG=1"
    -else
    -  echo "PG=0"
    -fi
    -# in-container health if present
    -if podman exec "$BANK" test -x /usr/local/bin/check_bank-health.sh 2>/dev/null; then
    -  podman exec "$BANK" /usr/local/bin/check_bank-health.sh 2>&1 | sed 's/^/HEALTH /' | tail -20
    -  echo "HEALTH_EC=${PIPESTATUS[0]}"
    -fi
    -REMOTE
    -)
    -  echo "$BOUT" | grep -q '^CTR=.\+' && ok "bank container $(echo "$BOUT" | sed -n 's/^CTR=//p' | head -1)" || fail "bank container" "not found"
    -  echo "$BOUT" | grep -q 'LOCAL_CONFIG=200' && ok "bank local :9012/config" || fail "bank local :9012/config"
    -  echo "$BOUT" | grep -q 'LOCAL_INT=200' && ok "bank local :9012/taler-integration/config" || fail "bank local integration"
    -  echo "$BOUT" | grep -q 'LIBEUFIN=1' && ok "bank libeufin-bank process" || fail "bank libeufin-bank process"
    -  echo "$BOUT" | grep -q 'PG=1' && ok "bank postgres ready" || warn "bank postgres" "pg_isready failed"
    -  if echo "$BOUT" | grep -q 'HEALTH '; then
    -    if echo "$BOUT" | grep -qE 'HEALTH_EC=0|ALL CRITICAL CHECKS PASSED'; then
    -      ok "bank check_bank-health.sh"
    -    else
    -      # health script may false-fail process grep; warn not fail if local config ok
    -      warn "bank check_bank-health.sh" "non-zero or incomplete"
    -    fi
    -  fi
    -else
    -  warn "bank server-side" "ssh ${KOOPA_SSH} unavailable"
    -fi
    -
    -# ---------------------------------------------------------------------------
    -set_group exchange
    -section "sanity · exchange"
    -# ---------------------------------------------------------------------------
    -expect_code "exchange public /config" 200 "$EXCHANGE_PUBLIC/config"
    -expect_code "exchange public /keys" 200 "$EXCHANGE_PUBLIC/keys"
    -expect_code "exchange public /terms" 200 "$EXCHANGE_PUBLIC/terms"
    -
    -code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ex-config.json")
    -if [ "$code" = "200" ]; then
    -  cur=$(json_currency "$tmp/ex-config.json")
    -  [ "$cur" = "GOA" ] && ok "exchange currency GOA" || fail "exchange currency" "got ${cur:-?}"
    -  if json_has_alt_unit_names "$tmp/ex-config.json" "GOA" >/tmp/alt-ex-s.$$ 2>&1; then
    -    ok "exchange /config alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000)
    -echo "LOCAL_CONFIG=$code"
    -codek=$(curl -sS -m 8 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000)
    -echo "LOCAL_KEYS=$codek"
    -if [ -n "$EX" ]; then
    -  if podman exec "$EX" bash -c 'pgrep -f taler-exchange-httpd >/dev/null' 2>/dev/null; then
    -    echo "HTTPD=1"
    -  else
    -    echo "HTTPD=0"
    -  fi
    -  for p in taler-exchange-secmod-rsa taler-exchange-secmod-eddsa taler-exchange-wirewatch taler-exchange-aggregator; do
    -    if podman exec "$EX" bash -c "pgrep -f $p >/dev/null" 2>/dev/null; then
    -      echo "PROC_$p=1"
    -    else
    -      echo "PROC_$p=0"
    -    fi
    -  done
    -  if podman exec "$EX" test -x /usr/local/bin/check_exchange-health.sh 2>/dev/null; then
    -    SKIP_ENSURE=1 podman exec -e SKIP_ENSURE=1 "$EX" /usr/local/bin/check_exchange-health.sh 2>&1 | sed 's/^/HEALTH /' | tail -25
    -  fi
    -fi
    -REMOTE
    -)
    -  echo "$EOUT" | grep -q '^CTR=.\+' && ok "exchange container $(echo "$EOUT" | sed -n 's/^CTR=//p' | head -1)" || fail "exchange container"
    -  echo "$EOUT" | grep -q 'LOCAL_CONFIG=200' && ok "exchange local :9011/config" || fail "exchange local :9011/config"
    -  echo "$EOUT" | grep -q 'LOCAL_KEYS=200' && ok "exchange local :9011/keys" || fail "exchange local :9011/keys"
    -  echo "$EOUT" | grep -q 'HTTPD=1' && ok "exchange-httpd process" || warn "exchange-httpd process" "not detected"
    -  echo "$EOUT" | grep -q 'PROC_taler-exchange-wirewatch=1' && ok "exchange wirewatch" || warn "exchange wirewatch" "not running"
    -  echo "$EOUT" | grep -q 'PROC_taler-exchange-aggregator=1' && ok "exchange aggregator" || warn "exchange aggregator" "not running"
    -else
    -  warn "exchange server-side" "ssh unavailable"
    -fi
    -
    -# ---------------------------------------------------------------------------
    -set_group merchant
    -section "sanity · merchant"
    -# ---------------------------------------------------------------------------
    -expect_code "merchant public /config" 200 "$MERCHANT_PUBLIC/config"
    -expect_code "merchant public /webui/" 200 "$MERCHANT_PUBLIC/webui/"
    -
    -code=$(http_body "$MERCHANT_PUBLIC/config" "$tmp/mer-config.json")
    -if [ "$code" = "200" ]; then
    -  if python3 - "$tmp/mer-config.json" "$EXCHANGE_PUBLIC" <<'PY'
    -import json,sys
    -d=json.load(open(sys.argv[1]))
    -want=sys.argv[2].rstrip("/")
    -curs=list((d.get("currencies") or {}).keys())
    -ex=d.get("exchanges") or []
    -urls=[]
    -for e in ex:
    -  if isinstance(e, dict):
    -    u=e.get("base_url") or e.get("url") or e.get("exchange_base_url") or ""
    -    if u: urls.append(u.rstrip("/"))
    -  elif isinstance(e, str):
    -    urls.append(e.rstrip("/"))
    -ok_goa = "GOA" in curs or any((e.get("currency") if isinstance(e, dict) else None)=="GOA" for e in ex)
    -ok_ex = any(want in u or "exchange.hacktivism.ch" in u for u in urls)
    -print("currencies", curs)
    -print("exchanges", urls[:5])
    -sys.exit(0 if ok_goa and ok_ex else 1)
    -PY
    -  then
    -    ok "merchant config GOA + exchange.hacktivism.ch"
    -  else
    -    fail "merchant config GOA + exchange" "see currencies/exchanges"
    -  fi
    -  if json_has_alt_unit_names "$tmp/mer-config.json" >/tmp/alt-mer-s.$$ 2>&1; then
    -    ok "merchant currencies alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000)
    -echo "LOCAL_CONFIG=$code"
    -if [ -n "$MER" ]; then
    -  if podman exec "$MER" bash -c 'pgrep -f taler-merchant-httpd >/dev/null' 2>/dev/null; then
    -    echo "HTTPD=1"
    -  else
    -    echo "HTTPD=0"
    -  fi
    -  if podman exec "$MER" test -x /usr/local/bin/check_merchant-health.sh 2>/dev/null; then
    -    SKIP_ENSURE=1 podman exec -e SKIP_ENSURE=1 "$MER" /usr/local/bin/check_merchant-health.sh 2>&1 | sed 's/^/HEALTH /' | tail -30
    -  fi
    -fi
    -REMOTE
    -)
    -  echo "$MOUT" | grep -q '^CTR=.\+' && ok "merchant container $(echo "$MOUT" | sed -n 's/^CTR=//p' | head -1)" || fail "merchant container"
    -  echo "$MOUT" | grep -q 'LOCAL_CONFIG=200' && ok "merchant local :9010/config" || fail "merchant local :9010/config"
    -  echo "$MOUT" | grep -q 'HTTPD=1' && ok "merchant-httpd process" || warn "merchant-httpd process" "not detected"
    -  if echo "$MOUT" | grep -q 'HEALTH '; then
    -    if echo "$MOUT" | grep -qiE 'ALL CRITICAL|HEALTH_EC=0|\[OK\]'; then
    -      ok "merchant check_merchant-health.sh (sample OK)"
    -    else
    -      warn "merchant check_merchant-health.sh" "see remote output"
    -    fi
    -  fi
    -else
    -  warn "merchant server-side" "ssh unavailable"
    -fi
    -
    -summary
    diff --git a/scripts/taler-monitoring/check_server.sh b/scripts/taler-monitoring/check_server.sh
    deleted file mode 100755
    index 3a9618a..0000000
    --- a/scripts/taler-monitoring/check_server.sh
    +++ /dev/null
    @@ -1,158 +0,0 @@
    -#!/usr/bin/env bash
    -# Server-side component checks on koopa (via SSH).
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -
    -# Area server.* — host/container ports via SSH
    -set_area server
    -set_group ssh
    -section "server · ssh ${KOOPA_SSH}"
    -
    -if ! koopa_ssh_ok; then
    -  fail "ssh ${KOOPA_SSH}" "unreachable within ${SSH_CONNECT_TIMEOUT}s (SKIP_SSH=1 to skip)"
    -  summary
    -  exit 1
    -fi
    -ok "ssh ${KOOPA_SSH}"
    -
    -# Run a remote script; collect structured lines
    -REMOTE=$(koopa_ssh_bash "${SSH_CMD_TIMEOUT}" <<'REMOTE'
    -set +e
    -report() { printf 'R|%s|%s|%s\n' "$1" "$2" "$3"; }
    -
    -# containers
    -for name in taler-hacktivism-bank taler-hacktivism-exchange-ansible taler-hacktivism; do
    -  if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then
    -    st=$(podman ps --filter "name=^${name}$" --format '{{.Status}}' | head -1)
    -    report OK "container $name" "$st"
    -  else
    -    # soft match
    -    hit=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E "$name|bank|exchange|hacktivism" | head -3 | tr '\n' ' ')
    -    if podman ps --format '{{.Names}}' 2>/dev/null | grep -q bank && [ "$name" = taler-hacktivism-bank ]; then
    -      bn=$(podman ps --format '{{.Names}}' | grep -i bank | head -1)
    -      report OK "container bank ($bn)" "$(podman ps --filter name="$bn" --format '{{.Status}}' | head -1)"
    -    elif podman ps --format '{{.Names}}' 2>/dev/null | grep -qi exchange && echo "$name" | grep -qi exchange; then
    -      en=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1)
    -      report OK "container exchange ($en)" "$(podman ps --filter name="$en" --format '{{.Status}}' | head -1)"
    -    elif podman ps --format '{{.Names}}' 2>/dev/null | grep -qx taler-hacktivism && [ "$name" = taler-hacktivism ]; then
    -      report OK "container taler-hacktivism" "$(podman inspect -f '{{.State.Status}}' taler-hacktivism 2>/dev/null)"
    -    else
    -      report FAIL "container $name" "not running (seen: $hit)"
    -    fi
    -  fi
    -done
    -
    -# local HTTP on pasta ports
    -for spec in \
    -  "bank-api|http://127.0.0.1:9012/config|200" \
    -  "bank-integration|http://127.0.0.1:9012/taler-integration/config|200" \
    -  "landing|http://127.0.0.1:9013/intro/|200" \
    -  "exchange|http://127.0.0.1:9011/config|200" \
    -  "merchant|https://127.0.0.1:9010/config|200"
    -do
    -  IFS='|' read -r id url want <<<"$spec"
    -  code=$(curl -skS -m 5 -o /tmp/mon-s.out -w '%{http_code}' "$url" 2>/dev/null || echo 000)
    -  if [ "$code" = "$want" ]; then
    -    report OK "local $id : ${url#*//}" "HTTP $code"
    -  else
    -    report FAIL "local $id" "HTTP $code want $want"
    -  fi
    -done
    -
    -# processes inside bank
    -BANK=$(podman ps --format '{{.Names}}' | grep -iE 'bank|hacktivism-bank' | head -1)
    -if [ -n "$BANK" ]; then
    -  if podman exec "$BANK" bash -c 'pgrep -f "MainKt serve|libeufin-bank serve" >/dev/null' 2>/dev/null; then
    -    report OK "libeufin-bank process" "in $BANK"
    -  else
    -    report FAIL "libeufin-bank process" "not running in $BANK"
    -  fi
    -  if podman exec "$BANK" bash -c 'pg_isready -q' 2>/dev/null; then
    -    report OK "postgres (bank)" "ready"
    -  else
    -    report WARN "postgres (bank)" "pg_isready failed"
    -  fi
    -  if podman exec "$BANK" bash -c 'pgrep -x nginx >/dev/null' 2>/dev/null; then
    -    report OK "nginx landing" "in $BANK"
    -  else
    -    report WARN "nginx landing" "not seen in $BANK"
    -  fi
    -else
    -  report FAIL "bank container" "none"
    -fi
    -
    -# exchange process / systemd if any
    -EX=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1)
    -if [ -n "$EX" ]; then
    -  if podman exec "$EX" bash -c 'pgrep -f taler-exchange-httpd >/dev/null || systemctl is-active taler-exchange-httpd 2>/dev/null | grep -q active' 2>/dev/null; then
    -    report OK "exchange-httpd" "in $EX"
    -  else
    -    # config answering is enough
    -    report WARN "exchange-httpd process" "not detected; port check above"
    -  fi
    -fi
    -
    -MER=$(podman ps --format '{{.Names}}' | grep -E '^taler-hacktivism$' | head -1)
    -[ -z "$MER" ] && MER=$(podman ps --format '{{.Names}}' | grep -i merchant | head -1)
    -if [ -n "$MER" ]; then
    -  if podman exec "$MER" bash -c 'pgrep -f taler-merchant-httpd >/dev/null || true; curl -sk -m 3 -o /dev/null -w %{http_code} https://127.0.0.1:9010/config' 2>/dev/null | grep -q 200; then
    -    report OK "merchant-httpd" "responds in $MER"
    -  else
    -    report WARN "merchant-httpd" "check manually in $MER"
    -  fi
    -fi
    -
    -# caddy on host
    -if systemctl is-active caddy >/dev/null 2>&1 || pgrep -x caddy >/dev/null 2>&1; then
    -  report OK "caddy" "active"
    -else
    -  report WARN "caddy" "not detected as active"
    -fi
    -
    -# disk free (host + containers) — lines for mon_disk_check_remote_text on laptop side
    -echo "###DISK_HOST###"
    -df -Pk / /var /home /tmp 2>/dev/null || df -Pk
    -echo "###DISK_CTRS###"
    -for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do
    -  echo "###CTR $c###"
    -  podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null
    -done
    -REMOTE
    -)
    -
    -while IFS= read -r line; do
    -  case "$line" in
    -    R\|*)
    -      IFS='|' read -r _ st label detail <<<"$line"
    -      case "$st" in
    -        OK) ok "$label${detail:+ ($detail)}" ;;
    -        FAIL) fail "$label" "$detail" ;;
    -        WARN) warn "$label" "$detail" ;;
    -      esac
    -      ;;
    -  esac
    -done <<<"$REMOTE"
    -
    -set_group disk
    -section "server · disk free space"
    -_host_df=$(printf '%s\n' "$REMOTE" | sed -n '/^###DISK_HOST###$/,/^###DISK_CTRS###$/p' | sed '1d;$d')
    -mon_disk_check_remote_text "ssh:${KOOPA_SSH}" "$_host_df" || true
    -_ctr=""; _buf=""
    -while IFS= read -r _line || [ -n "$_line" ]; do
    -  case "$_line" in
    -    '###CTR '*)
    -      if [ -n "$_ctr" ]; then mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true; fi
    -      _ctr=${_line####CTR }; _ctr=${_ctr%###}; _buf=""
    -      ;;
    -    '###DISK_CTRS###'|'###DISK_HOST###') ;;
    -    *)
    -      # only buffer after we entered CTR section
    -      if [ -n "$_ctr" ]; then _buf="${_buf}${_line}"$'\n'; fi
    -      ;;
    -  esac
    -done <<<"$REMOTE"
    -[ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true
    -
    -summary
    diff --git a/scripts/taler-monitoring/check_surface.sh b/scripts/taler-monitoring/check_surface.sh
    deleted file mode 100755
    index 1452d3b..0000000
    --- a/scripts/taler-monitoring/check_surface.sh
    +++ /dev/null
    @@ -1,692 +0,0 @@
    -#!/usr/bin/env bash
    -# check_surface.sh — REMOTE-ONLY public surface / ecosystem scan.
    -#
    -# NOT in default phases. Explicit:
    -#   ./taler-monitoring.sh surface
    -#   ./taler-monitoring.sh -d hacktivism.ch surface
    -#
    -# Without -d / with generic ecosystem: catalog of taler.net, gnunet.org,
    -# taler-systems.com, taler-ops, mattermost, …
    -# With -d DOMAIN: expand domains.conf stack hosts + catalog entries matching
    -# that domain + common subdomain guesses; port/protocol probes only remote.
    -#
    -# Rules:
    -#   - no SSH into targets, no local podman on targets
    -#   - ICMP/port alone never proves "down" — confirm via expected protocol
    -#   - catalogued host unreachable via protocol → ERROR
    -#   - version unknown after probes → WARN
    -#   - CVE hit (OSV) for identified software+version → ERROR
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -
    -set_area surface
    -section "surface · remote ecosystem / domain inventory (outside-in only)"
    -
    -CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}"
    -# Common ports when scanning a host discovered without explicit list
    -DEFAULT_SCAN_PORTS="${SURFACE_SCAN_PORTS:-22,80,443,993,8443,9418}"
    -PORT_TIMEOUT="${SURFACE_PORT_TIMEOUT:-2}"
    -HTTP_TIMEOUT="${SURFACE_HTTP_TIMEOUT:-12}"
    -# CVE via OSV (public, no key). Disable: SURFACE_CVE=0
    -: "${SURFACE_CVE:=1}"
    -# Extra ports when -d domain mode
    -DOMAIN_EXTRA_PORTS="${SURFACE_DOMAIN_PORTS:-22,80,443,8443}"
    -
    -declare -A HOST_PORTS   # host -> comma ports
    -declare -A HOST_PROTO   # host -> expect proto
    -declare -A HOST_LABEL   # host -> short label
    -declare -A HOST_EXPECT  # host -> 1 if catalogued (must respond)
    -ORDERED_HOSTS=()
    -
    -add_host() {
    -  local h="$1" ports="${2:-}" proto="${3:-https}" label="${4:-}" expect="${5:-1}"
    -  h=$(printf '%s' "$h" | tr 'A-Z' 'a-z' | sed 's#^https\?://##;s#/.*##;s/\.$//')
    -  [ -n "$h" ] || return 0
    -  if [ -z "${HOST_PORTS[$h]+x}" ]; then
    -    ORDERED_HOSTS+=("$h")
    -    HOST_PORTS[$h]="${ports:-443}"
    -    HOST_PROTO[$h]="${proto:-https}"
    -    HOST_LABEL[$h]="${label:-$h}"
    -    HOST_EXPECT[$h]="$expect"
    -  else
    -    # merge ports
    -    local p
    -    for p in ${ports//,/ }; do
    -      case ",${HOST_PORTS[$h]}," in
    -        *",$p,"*) ;;
    -        *) HOST_PORTS[$h]="${HOST_PORTS[$h]},$p" ;;
    -      esac
    -    done
    -    [ "${HOST_EXPECT[$h]}" = "1" ] || HOST_EXPECT[$h]="$expect"
    -  fi
    -}
    -
    -load_catalog() {
    -  local line h ports proto label
    -  [ -f "$CATALOG" ] || {
    -    warn "catalog" "missing $CATALOG — using empty base list"
    -    return 0
    -  }
    -  while IFS= read -r line || [ -n "$line" ]; do
    -    line=${line%%#*}
    -    line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
    -    [ -z "$line" ] && continue
    -    # host ports proto label
    -    h=$(echo "$line" | awk '{print $1}')
    -    ports=$(echo "$line" | awk '{print $2}')
    -    proto=$(echo "$line" | awk '{print $3}')
    -    label=$(echo "$line" | awk '{print $4}')
    -    [ "$ports" = "-" ] && ports=""
    -    add_host "$h" "$ports" "${proto:-https}" "${label:-}" 1
    -  done <"$CATALOG"
    -  info "catalog" "loaded $CATALOG · ${#ORDERED_HOSTS[@]} hosts"
    -}
    -
    -# Clear inventory (used when switching to domain-only scope)
    -clear_hosts() {
    -  ORDERED_HOSTS=()
    -  unset HOST_PORTS HOST_PROTO HOST_LABEL HOST_EXPECT
    -  declare -gA HOST_PORTS HOST_PROTO HOST_LABEL HOST_EXPECT
    -}
    -
    -# Expand for -d domain: ONLY that domain (remote). No whole-ecosystem catalog.
    -expand_domain_scope() {
    -  local d="$1" h
    -  d=${d#https://}; d=${d%/}
    -  d=${d#http://}
    -  info "scope" "domain mode −d $d (remote only · not full ecosystem catalog)"
    -
    -  clear_hosts
    -
    -  # stack endpoints from applied profile (always expected)
    -  for h in \
    -    "${BANK_PUBLIC:-}" \
    -    "${EXCHANGE_PUBLIC:-}" \
    -    "${MERCHANT_PUBLIC:-}" \
    -    "${PAIVANA_PUBLIC:-}"
    -  do
    -    [ -n "$h" ] || continue
    -    add_host "$h" "80,443,$DOMAIN_EXTRA_PORTS" https "stack" 1
    -  done
    -
    -  # apex + common subdomains (expected if DNS exists — set expect after DNS in scan,
    -  # but catalogued stack already expected; guesses start optional)
    -  for sub in "" www bank exchange taler merchant backend shop shops stage \
    -             git docs www2 api static media landing mon401
    -  do
    -    if [ -z "$sub" ]; then
    -      h="$d"
    -    else
    -      h="${sub}.${d}"
    -    fi
    -    # stack hosts already added as expect=1; guesses optional until we promote
    -    if [ -z "${HOST_PORTS[$h]+x}" ]; then
    -      add_host "$h" "$DOMAIN_EXTRA_PORTS" https "guess" 0
    -    fi
    -  done
    -
    -  # also pick catalog lines that belong to this domain only
    -  if [ -f "$CATALOG" ]; then
    -    local line ports proto label
    -    while IFS= read -r line || [ -n "$line" ]; do
    -      line=${line%%#*}
    -      line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
    -      [ -z "$line" ] && continue
    -      h=$(echo "$line" | awk '{print $1}')
    -      h=$(printf '%s' "$h" | tr 'A-Z' 'a-z' | sed 's#^https\?://##;s#/.*##')
    -      if [ "$h" = "$d" ] || [[ "$h" == *".$d" ]]; then
    -        ports=$(echo "$line" | awk '{print $2}')
    -        proto=$(echo "$line" | awk '{print $3}')
    -        label=$(echo "$line" | awk '{print $4}')
    -        [ "$ports" = "-" ] && ports=""
    -        add_host "$h" "$ports" "${proto:-https}" "${label:-dom}" 1
    -      fi
    -    done <"$CATALOG"
    -  fi
    -}
    -
    -# Ecosystem mode: full catalog; all entries expected
    -expand_ecosystem_scope() {
    -  info "scope" "ecosystem mode (taler.net / gnunet.org / taler-systems.com / taler-ops / mattermost, … · remote only)"
    -  local h
    -  for h in "${ORDERED_HOSTS[@]}"; do
    -    HOST_EXPECT[$h]=1
    -  done
    -}
    -
    -# --- remote probes (no SSH) ---
    -dns_ok() {
    -  local h="$1"
    -  getent ahosts "$h" >/dev/null 2>&1 || getent hosts "$h" >/dev/null 2>&1
    -}
    -
    -# TCP connect only (not proof of service)
    -tcp_open() {
    -  local h="$1" port="$2"
    -  if command -v timeout >/dev/null 2>&1; then
    -    timeout "$PORT_TIMEOUT" bash -c "echo >/dev/tcp/${h}/${port}" 2>/dev/null
    -  else
    -    bash -c "echo >/dev/tcp/${h}/${port}" 2>/dev/null
    -  fi
    -}
    -
    -# ICMP optional — never sole grounds for ERROR
    -ping_host() {
    -  local h="$1"
    -  ping -c 1 -W 2 "$h" >/dev/null 2>&1 || ping -c 1 -w 2 "$h" >/dev/null 2>&1
    -}
    -
    -# HTTPS probe: code, server header, version hints, cert subject/dates
    -probe_https() {
    -  local h="$1" port="${2:-443}"
    -  local url hdr body code server via cert_end subj san
    -  url="https://${h}/"
    -  [ "$port" != "443" ] && url="https://${h}:${port}/"
    -  body=$(mktemp)
    -  hdr=$(mktemp)
    -  code=$(curl -skS -m "$HTTP_TIMEOUT" -D "$hdr" -o "$body" -w '%{http_code}' \
    -    --connect-timeout "$PORT_TIMEOUT" "$url" 2>/dev/null || echo 000)
    -  server=$(awk 'BEGIN{IGNORECASE=1} /^server:/{sub(/\r$/,""); sub(/^server:[[:space:]]*/,""); print; exit}' "$hdr" 2>/dev/null || true)
    -  via=$(awk 'BEGIN{IGNORECASE=1} /^x-powered-by:/{sub(/\r$/,""); sub(/^[^:]+:[[:space:]]*/,""); print; exit}' "$hdr" 2>/dev/null || true)
    -  # cert
    -  cert_end=$(echo | openssl s_client -servername "$h" -connect "${h}:${port}" 2>/dev/null \
    -    | openssl x509 -noout -enddate 2>/dev/null | sed 's/notAfter=//')
    -  subj=$(echo | openssl s_client -servername "$h" -connect "${h}:${port}" 2>/dev/null \
    -    | openssl x509 -noout -subject 2>/dev/null | head -1)
    -  # taler version hints in body
    -  local taler_hint
    -  taler_hint=$(grep -oE 'taler[^"[:space:]]{0,40}|GNU Taler|libeufin|gnunet' "$body" 2>/dev/null | head -3 | tr '\n' ' ' || true)
    -  # config JSON version if /config works
    -  local cfg_ver=""
    -  local ccode
    -  ccode=$(curl -skS -m "$HTTP_TIMEOUT" -o "$body" -w '%{http_code}' \
    -    "https://${h}/config" 2>/dev/null || echo 000)
    -  if [ "$ccode" = "200" ]; then
    -    cfg_ver=$(python3 -c 'import json,sys
    -try:
    - d=json.load(open(sys.argv[1]))
    - print(d.get("version") or d.get("name") or d.get("currency") or "config-json")
    -except Exception:
    - print("")' "$body" 2>/dev/null || true)
    -  fi
    -  rm -f "$hdr" "$body"
    -  printf 'code=%s server=%s powered=%s cert_end=%s cfg=%s hint=%s' \
    -    "$code" "${server:-}" "${via:-}" "${cert_end:-}" "${cfg_ver:-}" "${taler_hint:-}"
    -  # return 0 if HTTP answered (any 2xx/3xx/4xx — service is up)
    -  case "$code" in
    -    2??|3??|4??) return 0 ;;
    -    *) return 1 ;;
    -  esac
    -}
    -
    -probe_http() {
    -  local h="$1" port="${2:-80}"
    -  local url code
    -  url="http://${h}/"
    -  [ "$port" != "80" ] && url="http://${h}:${port}/"
    -  code=$(curl -sS -m "$HTTP_TIMEOUT" -o /dev/null -w '%{http_code}' \
    -    --connect-timeout "$PORT_TIMEOUT" "$url" 2>/dev/null || echo 000)
    -  printf 'code=%s' "$code"
    -  case "$code" in 2??|3??|4??) return 0 ;; *) return 1 ;; esac
    -}
    -
    -probe_ssh_banner() {
    -  local h="$1" port="${2:-22}"
    -  local ban
    -  ban=$(timeout "$PORT_TIMEOUT" bash -c "exec 3<>/dev/tcp/${h}/${port}; dd bs=256 count=1 <&3 2>/dev/null" 2>/dev/null \
    -    | tr -d '\r' | head -1 || true)
    -  printf 'banner=%s' "${ban:-}"
    -  [ -n "$ban" ]
    -}
    -
    -# Map Server header → package name guess for OSV
    -guess_package() {
    -  local server="$1"
    -  local s
    -  s=$(printf '%s' "$server" | tr 'A-Z' 'a-z')
    -  case "$s" in
    -    *nginx*) echo "nginx" ;;
    -    *apache*|*httpd*) echo "apache" ;;
    -    *caddy*) echo "caddy" ;;
    -    *openbsd\ httpd*) echo "openbsd-httpd" ;;
    -    *) echo "" ;;
    -  esac
    -}
    -
    -extract_version() {
    -  local server="$1"
    -  # nginx/1.22.1 → 1.22.1
    -  printf '%s' "$server" | sed -n 's/.*\/\([0-9][0-9.]*\).*/\1/p' | head -1
    -}
    -
    -# OSV / CVE check for software version taken from Server headers.
    -#
    -# Important: headers only expose upstream versions (nginx/1.26.3), never the
    -# Debian package revision (1.26.3-3+deb13u7). Querying OSV ecosystem=Debian
    -# with the bare version yields massive false positives (ancient DEBIAN-CVE-*
    -# with introduced:0 and no fixed event, plus every package revision that
    -# merely *starts with* 1.26.3).
    -#
    -# Policy:
    -#   - bare upstream version → upstream SEMVER signal only (WARN by default)
    -#   - full Debian package version (contains '-') → Debian OSV, filtered
    -#   - SURFACE_CVE=0 disables; SURFACE_CVE_LEVEL=error|warn (default warn for bare)
    -check_cves() {
    -  local pkg="$1" ver="$2" host="$3"
    -  [ "$SURFACE_CVE" = "1" ] || return 0
    -  [ -n "$pkg" ] && [ -n "$ver" ] || return 0
    -  local level out rc
    -  # bare header versions are approximate → WARN unless overridden
    -  if [[ "$ver" == *-* ]] || [[ "$ver" == *+* ]]; then
    -    level="${SURFACE_CVE_LEVEL:-error}"
    -  else
    -    level="${SURFACE_CVE_LEVEL:-warn}"
    -  fi
    -  out=$(
    -    SURFACE_CVE_PKG="$pkg" SURFACE_CVE_VER="$ver" SURFACE_CVE_HOST="$host" python3 - <<'PY' 2>/dev/null || true
    -import json, os, re, urllib.request
    -
    -pkg = os.environ.get("SURFACE_CVE_PKG", "")
    -ver = os.environ.get("SURFACE_CVE_VER", "")
    -host = os.environ.get("SURFACE_CVE_HOST", "")
    -
    -def ver_tuple(s: str):
    -    nums = [int(x) for x in re.findall(r"\d+", s or "")[:5]]
    -    return tuple(nums) if nums else ()
    -
    -def vt_cmp(a, b):
    -    n = max(len(a), len(b))
    -    a = a + (0,) * (n - len(a))
    -    b = b + (0,) * (n - len(b))
    -    return (a > b) - (a < b)
    -
    -def in_semver_events(ver, events):
    -    """True if ver is in [introduced, fixed) for SEMVER-like event list."""
    -    vt = ver_tuple(ver)
    -    if not vt:
    -        return False
    -    # process sequential introduced/fixed pairs
    -    intro = None
    -    for e in events or []:
    -        if "introduced" in e:
    -            intro = e.get("introduced")
    -        elif "fixed" in e or "last_affected" in e:
    -            fixed = e.get("fixed")
    -            last = e.get("last_affected")
    -            lo = ver_tuple("0" if intro in (None, "0") else str(intro))
    -            if vt_cmp(vt, lo) < 0:
    -                intro = None
    -                continue
    -            if fixed is not None:
    -                if vt_cmp(vt, ver_tuple(str(fixed))) < 0:
    -                    return True
    -            elif last is not None:
    -                if vt_cmp(vt, ver_tuple(str(last))) <= 0:
    -                    return True
    -            intro = None
    -    # open-ended introduced without fixed → ignore (never clears; Debian noise)
    -    return False
    -
    -def osv_post(body):
    -    req = urllib.request.Request(
    -        "https://api.osv.dev/v1/query",
    -        data=json.dumps(body).encode(),
    -        headers={"Content-Type": "application/json"},
    -        method="POST",
    -    )
    -    try:
    -        with urllib.request.urlopen(req, timeout=20) as r:
    -            return json.load(r)
    -    except Exception:
    -        return {}
    -
    -def osv_get(vid):
    -    try:
    -        with urllib.request.urlopen(f"https://api.osv.dev/v1/vulns/{vid}", timeout=15) as r:
    -            return json.load(r)
    -    except Exception:
    -        return {}
    -
    -bare = not (("-" in ver) or ("+" in ver))
    -hits = []
    -
    -if bare:
    -    # Upstream signal via CVE records that carry extracted_events for this package
    -    # (Server header has no Debian revision — do NOT use ecosystem=Debian).
    -    seed = {
    -        "nginx": [
    -            "CVE-2025-23419",
    -            "CVE-2025-53859",
    -            "CVE-2024-7347",
    -            "CVE-2024-34161",
    -            "CVE-2024-32760",
    -            "CVE-2024-31079",
    -            "CVE-2024-24989",
    -            "CVE-2024-24990",
    -        ],
    -        "apache": [
    -            "CVE-2024-38474",
    -            "CVE-2024-38476",
    -            "CVE-2024-38477",
    -            "CVE-2023-31122",
    -            "CVE-2023-43622",
    -        ],
    -        "caddy": [
    -            "CVE-2022-29718",
    -            "CVE-2023-50463",
    -        ],
    -    }.get(pkg, [])
    -    for vid in seed:
    -        doc = osv_get(vid)
    -        if not doc:
    -            continue
    -        ok_hit = False
    -        for a in doc.get("affected") or []:
    -            # prefer extracted_events (human SEMVER) on GIT/nginx ranges
    -            for rg in a.get("ranges") or []:
    -                db = rg.get("database_specific") or {}
    -                extracted = db.get("extracted_events") or []
    -                if extracted and in_semver_events(ver, extracted):
    -                    # package name filter when present
    -                    cpes = db.get("cpe") or []
    -                    if isinstance(cpes, str):
    -                        cpes = [cpes]
    -                    blob = json.dumps(a).lower() + json.dumps(cpes).lower()
    -                    if pkg == "nginx" and "nginx" not in blob and "f5" not in blob:
    -                        continue
    -                    ok_hit = True
    -                    break
    -                if rg.get("type") == "SEMVER" and in_semver_events(ver, rg.get("events") or []):
    -                    ok_hit = True
    -                    break
    -            if ok_hit:
    -                break
    -        if ok_hit:
    -            hits.append(vid)
    -else:
    -    # Full package version — Debian ecosystem is meaningful
    -    data = osv_post({"package": {"name": pkg, "ecosystem": "Debian"}, "version": ver})
    -    for v in data.get("vulns") or []:
    -        vid = v.get("id") or "?"
    -        actionable = False
    -        for a in v.get("affected") or []:
    -            for rg in a.get("ranges") or []:
    -                events = rg.get("events") or []
    -                has_end = any(("fixed" in e) or ("last_affected" in e) for e in events)
    -                if not has_end:
    -                    continue  # open-ended Debian noise
    -                if rg.get("type") in ("ECOSYSTEM", "SEMVER"):
    -                    # version string is full deb version; trust OSV query match
    -                    # but only if a fixed event exists (actionable)
    -                    actionable = True
    -                    break
    -            if actionable:
    -                break
    -            # exact version listed
    -            versions = a.get("versions") or []
    -            if ver in versions:
    -                actionable = True
    -                break
    -        if actionable:
    -            hits.append(vid)
    -
    -# de-dup preserve order
    -seen = set()
    -uniq = []
    -for h in hits:
    -    if h not in seen:
    -        seen.add(h)
    -        uniq.append(h)
    -
    -if uniq:
    -    print(f"HIT {len(uniq)} " + ",".join(uniq[:12]))
    -else:
    -    print("CLEAN")
    -PY
    -  )
    -  rc=0
    -  case "$out" in
    -    HIT\ *)
    -      local n ids
    -      n=$(printf '%s' "$out" | awk '{print $2}')
    -      ids=$(printf '%s' "$out" | cut -d' ' -f3-)
    -      if [ "$level" = "error" ]; then
    -        err "cve" "$host $pkg $ver — $n actionable vuln(s)" "$ids"
    -        rc=1
    -      else
    -        warn "cve" "$host $pkg $ver — $n actionable vuln(s) (header version · not Debian pkg)" "$ids"
    -        rc=0
    -      fi
    -      ;;
    -    CLEAN)
    -      if [[ "$ver" == *-* ]] || [[ "$ver" == *+* ]]; then
    -        info "cve" "$host $pkg $ver — OSV clean (Debian package version)"
    -      else
    -        info "cve" "$host $pkg $ver — no actionable upstream CVE for bare Server-header version"
    -      fi
    -      ;;
    -    *)
    -      info "cve" "$host $pkg $ver — CVE probe skipped/unavailable"
    -      ;;
    -  esac
    -  return "$rc"
    -}
    -
    -cert_expiry_check() {
    -  local h="$1" end="$2"
    -  [ -n "$end" ] || return 0
    -  local end_epoch now left days
    -  end_epoch=$(date -d "$end" +%s 2>/dev/null || date -j -f "%b %e %T %Y %Z" "$end" +%s 2>/dev/null || echo 0)
    -  now=$(date +%s)
    -  [ "$end_epoch" -gt 0 ] || return 0
    -  left=$((end_epoch - now))
    -  days=$((left / 86400))
    -  if [ "$left" -le 0 ]; then
    -    err "tls" "$h certificate EXPIRED" "notAfter=$end"
    -    return 1
    -  fi
    -  if [ "$days" -le 14 ]; then
    -    warn "tls" "$h certificate expires in ${days}d" "notAfter=$end"
    -  else
    -    info "tls" "$h cert ok · ${days}d left · notAfter=$end"
    -  fi
    -  return 0
    -}
    -
    -scan_host() {
    -  local h="$1"
    -  local ports proto label expect
    -  local p open_ports=() any_proto_ok=0 ping_ok=0 dns=0
    -  local probe_detail server_hdr pkg ver
    -
    -  ports=${HOST_PORTS[$h]:-443}
    -  proto=${HOST_PROTO[$h]:-https}
    -  label=${HOST_LABEL[$h]:-$h}
    -  expect=${HOST_EXPECT[$h]:-0}
    -
    -  set_group "$label"
    -
    -  if dns_ok "$h"; then
    -    dns=1
    -    ok "dns" "$h resolves"
    -  else
    -    if [ "$expect" = "1" ]; then
    -      err "dns" "$h does not resolve (catalogued)"
    -    else
    -      info "dns" "$h no resolve (optional guess) — skip"
    -    fi
    -    return 0
    -  fi
    -
    -  if ping_host "$h"; then
    -    ping_ok=1
    -    info "ping" "$h ICMP ok"
    -  else
    -    info "ping" "$h ICMP no reply (not decisive)"
    -  fi
    -
    -  # port scan (unique ports)
    -  local _seen_ports=" "
    -  for p in ${ports//,/ }; do
    -    [ -n "$p" ] || continue
    -    case "$_seen_ports" in *" $p "*) continue ;; esac
    -    _seen_ports="$_seen_ports$p "
    -    if tcp_open "$h" "$p"; then
    -      open_ports+=("$p")
    -      info "port" "$h:$p open (TCP)"
    -    else
    -      info "port" "$h:$p closed/filtered (TCP)"
    -    fi
    -  done
    -
    -  # Protocol verification on open ports (and always try 443/80 for https/http expect)
    -  local try_ports=("${open_ports[@]}")
    -  if [ ${#try_ports[@]} -eq 0 ]; then
    -    # still try expected protocol ports even if scan said closed (scan false negatives)
    -    case "$proto" in
    -      https) try_ports=(443) ;;
    -      http) try_ports=(80) ;;
    -      ssh) try_ports=(22) ;;
    -      *) try_ports=(443 80) ;;
    -    esac
    -  fi
    -
    -  server_hdr=""
    -  ver=""
    -  for p in "${try_ports[@]}"; do
    -    case "$p" in
    -      443|8443)
    -        if probe_detail=$(probe_https "$h" "$p"); then
    -          any_proto_ok=1
    -          ok "https" "$h:$p up · $probe_detail"
    -          server_hdr=$(printf '%s' "$probe_detail" | sed -n 's/.*server=\([^ ]*\).*/\1/p')
    -          # cert
    -          local cend
    -          cend=$(printf '%s' "$probe_detail" | sed -n 's/.*cert_end=\([^ ]*\).*/\1/p')
    -          cert_expiry_check "$h" "$cend" || true
    -          local cfg
    -          cfg=$(printf '%s' "$probe_detail" | sed -n 's/.*cfg=\([^ ]*\).*/\1/p')
    -          if [ -n "$cfg" ]; then
    -            info "version" "$h config/api hint: $cfg"
    -            ver="$cfg"
    -          fi
    -        else
    -          info "https" "$h:$p no HTTP response · ${probe_detail:-}"
    -        fi
    -        ;;
    -      80|8080)
    -        if probe_detail=$(probe_http "$h" "$p"); then
    -          any_proto_ok=1
    -          ok "http" "$h:$p up · $probe_detail"
    -        else
    -          info "http" "$h:$p no HTTP response"
    -        fi
    -        ;;
    -      22)
    -        if probe_detail=$(probe_ssh_banner "$h" "$p"); then
    -          any_proto_ok=1
    -          ok "ssh" "$h:$p banner · $probe_detail"
    -          ver=$(printf '%s' "$probe_detail" | sed 's/banner=//')
    -        else
    -          info "ssh" "$h:$p no SSH banner"
    -        fi
    -        ;;
    -      *)
    -        if tcp_open "$h" "$p"; then
    -          info "tcp" "$h:$p open · protocol unknown"
    -        fi
    -        ;;
    -    esac
    -  done
    -
    -  # Version summary
    -  if [ -n "$server_hdr" ]; then
    -    info "server-header" "$h · $server_hdr"
    -    pkg=$(guess_package "$server_hdr")
    -    ver_soft=$(extract_version "$server_hdr")
    -    if [ -n "$pkg" ] && [ -n "$ver_soft" ]; then
    -      info "version" "$h software $pkg $ver_soft (from Server header)"
    -      check_cves "$pkg" "$ver_soft" "$h" || true
    -    elif [ -n "$server_hdr" ]; then
    -      warn "version" "$h Server header present but version unknown · $server_hdr"
    -    fi
    -  elif [ -n "$ver" ]; then
    -    info "version" "$h · $ver"
    -  else
    -    if [ "$any_proto_ok" = "1" ]; then
    -      warn "version" "$h reachable but software version unknown"
    -    fi
    -  fi
    -
    -  # Expected service must answer protocol (not just ping/port)
    -  # Note: ${array[*]:-x} is NOT valid default syntax (bash treats : as slice).
    -  local ports_txt="${open_ports[*]}"
    -  ports_txt=${ports_txt:-none}
    -  if [ "$expect" = "1" ]; then
    -    if [ "$any_proto_ok" = "1" ]; then
    -      ok "reachability" "$h catalogued service OK (protocol confirmed)"
    -    else
    -      err "reachability" "$h catalogued but not reachable via ${proto}/protocol" \
    -        "dns=$dns ping=$ping_ok open_ports=${ports_txt} (ICMP/TCP alone not enough; protocol failed)"
    -    fi
    -  else
    -    if [ "$any_proto_ok" = "1" ]; then
    -      info "reachability" "$h optional host responds"
    -    else
    -      info "reachability" "$h optional · no protocol response (ok)"
    -    fi
    -  fi
    -}
    -
    -# --- main ---
    -# Scope (remote only):
    -#   ./taler-monitoring.sh surface              → full ecosystem catalog
    -#   ./taler-monitoring.sh -d hacktivism.ch surface → only that domain
    -if [ "${SURFACE_SCOPE:-}" = "ecosystem" ]; then
    -  load_catalog
    -  expand_ecosystem_scope
    -elif [ "${SURFACE_SCOPE:-}" = "domain" ] || [ "${TALER_DOMAIN_FROM_CLI:-0}" = "1" ]; then
    -  # domain mode: do not load whole ecosystem first
    -  CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}"
    -  expand_domain_scope "${TALER_DOMAIN:-hacktivism.ch}"
    -elif [ "${SURFACE_SCOPE:-auto}" = "auto" ]; then
    -  # no -d → ecosystem; with -d → domain (TALER_DOMAIN_FROM_CLI)
    -  if [ "${TALER_DOMAIN_FROM_CLI:-0}" = "1" ]; then
    -    CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}"
    -    expand_domain_scope "$TALER_DOMAIN"
    -  else
    -    load_catalog
    -    expand_ecosystem_scope
    -  fi
    -else
    -  load_catalog
    -  expand_ecosystem_scope
    -fi
    -
    -info "inventory" "${#ORDERED_HOSTS[@]} hosts to probe (remote only · no SSH · no local podman on targets)"
    -info "flags" "SURFACE_CVE=${SURFACE_CVE:-1} PORT_TIMEOUT=${PORT_TIMEOUT:-2} HTTP_TIMEOUT=${HTTP_TIMEOUT:-12}"
    -
    -for h in "${ORDERED_HOSTS[@]}"; do
    -  [ -n "$h" ] || continue
    -  scan_host "$h" || true
    -done
    -
    -# mon_disk on the *runner* only (optional) — not remote servers' disks
    -if [ "${SURFACE_CHECK_RUNNER_DISK:-0}" = "1" ]; then
    -  set_group disk
    -  if declare -F mon_disk_check_host >/dev/null 2>&1; then
    -    mon_disk_check_host "runner" || true
    -  else
    -    warn "disk" "mon_disk_check_host not available"
    -  fi
    -fi
    -
    -# summary returns non-zero when FAIL_N>0; do not trip set -e mid-script
    -summary || true
    -if [ "${FAIL_N:-0}" -eq 0 ]; then
    -  exit 0
    -fi
    -exit 1
    diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh
    deleted file mode 100755
    index 36520d4..0000000
    --- a/scripts/taler-monitoring/check_urls.sh
    +++ /dev/null
    @@ -1,1595 +0,0 @@
    -#!/usr/bin/env bash
    -# Outside-in public HTTPS checks (no SSH).
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -
    -# Area www.* — public HTTPS (outside-in)
    -# Groups: www.exchange / www.perf / www.stats / www.bank / www.merchant /
    -#         www.paivana / www.landing
    -set_area www
    -section "www · public URLs · ${TALER_DOMAIN:-?} (outside-in, no SSH)"
    -
    -tmp=$(mktemp -d)
    -trap 'rm -rf "$tmp"' EXIT
    -
    -check_url() {
    -  local label="$1" expect="$2" url="$3"
    -  local code
    -  code=$(http_code "$url")
    -  case ",$expect," in
    -    *",$code,"*) ok "$label $url" ;;
    -    *) fail "$label $url" "got $code want $expect" ;;
    -  esac
    -}
    -
    -# Pre-launch remote stack: local=0 and no expected currency (e.g. prod LFP not live yet).
    -# Missing endpoints → INFO, not WARN (infra not supposed to serve yet).
    -is_prelaunch_stack() {
    -  [ "${LOCAL_STACK:-1}" = "0" ] && [ -z "${EXPECT_CURRENCY:-}" ]
    -}
    -
    -# Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack.
    -# Always treats 301/302/303/307/308→200 (follow) as success when 200 is expected.
    -# Pre-launch (no currency): INFO instead of WARN for missing endpoints.
    -check_url_soft() {
    -  local label="$1" expect="$2" url="$3"
    -  local code
    -  code=$(http_code "$url")
    -  case ",$expect," in
    -    *",$code,"*) ok "$label $url" ;;
    -    *)
    -      # Follow redirects (308 Permanent Redirect is common for trailing slash)
    -      case "$code" in
    -        301|302|303|307|308)
    -          code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000)
    -          case ",$expect," in
    -            *",$code,"*) ok "$label $url" "via redirect → HTTP $code"; return ;;
    -          esac
    -          ;;
    -      esac
    -      if [ "${LOCAL_STACK:-1}" = "0" ]; then
    -        if is_prelaunch_stack; then
    -          info "$label $url" "HTTP $code — pre-launch / not serving yet"
    -        else
    -          warn "$label $url" "got $code (optional on remote domain)"
    -        fi
    -      else
    -        fail "$label $url" "got $code want $expect"
    -      fi
    -      ;;
    -  esac
    -}
    -
    -expect_currency() {
    -  local label="$1" file="$2" want="${EXPECT_CURRENCY:-}"
    -  local cur
    -  cur=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("currency",""))' "$file" 2>/dev/null || true)
    -  if [ -z "$want" ]; then
    -    info "$label currency" "${cur:-?}"
    -    return
    -  fi
    -  if [ "$cur" = "$want" ]; then
    -    ok "$label currency=$want"
    -  else
    -    fail "$label currency" "got ${cur:-?} want $want"
    -  fi
    -}
    -
    -# Legal docs: /terms and /privacy must be HTTP 200 with a real document body
    -# (not empty, not "not configured", not JSON API error).
    -# $1=label $2=url $3=optional needle regex (case-insensitive) for local stack
    -check_legal_doc() {
    -  local label="$1" url="$2" needle="${3:-}"
    -  local f code soft
    -  soft=0
    -  [ "${LOCAL_STACK:-1}" = "0" ] && soft=1
    -  f=$(mktemp)
    -  code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" \
    -    -H "Accept: text/html,text/markdown,text/plain,*/*" \
    -    -o "$f" -w '%{http_code}' "$url" 2>/dev/null || echo 000)
    -  if [ "$code" != "200" ]; then
    -    rm -f "$f"
    -    if [ "$soft" = "1" ]; then
    -      if is_prelaunch_stack; then
    -        info "$label" "HTTP $code — pre-launch / not serving yet · $url"
    -      else
    -        warn "$label" "HTTP $code — $url"
    -      fi
    -    else
    -      fail "$label" "HTTP $code — $url"
    -    fi
    -    return
    -  fi
    -  if [ ! -s "$f" ]; then
    -    rm -f "$f"
    -    fail "$label" "empty body — $url"
    -    return
    -  fi
    -  # merchant returns plain "not configured" when PRIVACY_ETAG missing
    -  if grep -qiE '^(not configured)\s*$' "$f" 2>/dev/null \
    -    || grep -qiE '"code"\s*:\s*21' "$f" 2>/dev/null; then
    -    rm -f "$f"
    -    fail "$label" "not configured / API error — $url"
    -    return
    -  fi
    -  if [ -n "$needle" ] && [ "${LOCAL_STACK:-1}" = "1" ]; then
    -    if ! grep -qiE "$needle" "$f" 2>/dev/null; then
    -      warn "$label content" "missing /$needle/ — $url"
    -      rm -f "$f"
    -      return
    -    fi
    -  fi
    -  ok "$label" "HTTP 200 · $(wc -c <"$f" | tr -d ' ') bytes"
    -  rm -f "$f"
    -}
    -
    -# Shared shape for bank mint JSON (demo-withdraw + auto-account).
    -# Args: $1=json file  $2=auto|demo
    -# stdout (ok): one detail line; for demo, optional "\twithdrawal_id" suffix.
    -# stderr (fail): short reason. Exit 0/1.
    -validate_bank_withdraw_json() {
    -  python3 - "$1" "$2" <<'PY'
    -import json, re, sys
    -from urllib.parse import urlparse
    -
    -def check_withdraw_uri(wuri: str):
    -    """HOST or HOST:non-default-port; default :443/:80 must be stripped (wallet fix)."""
    -    m = re.match(
    -        r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)$",
    -        wuri or "",
    -    )
    -    if not m:
    -        print(
    -            "need taler://withdraw/HOST/taler-integration/ID:",
    -            (wuri or "")[:120],
    -            file=sys.stderr,
    -        )
    -        return None
    -    host = m.group(1)
    -    if not host or host.startswith(":"):
    -        print("bad withdraw host:", host, file=sys.stderr)
    -        return None
    -    if ":" in host:
    -        h, _, p = host.rpartition(":")
    -        if not h or not p.isdigit():
    -            print("bad withdraw host:port:", host, file=sys.stderr)
    -            return None
    -        if p in ("443", "80"):
    -            print("withdraw must strip default port :%s:" % p, host, file=sys.stderr)
    -            return None
    -    return host, m.group(2), wuri
    -
    -path, mode = sys.argv[1], sys.argv[2]
    -d = json.load(open(path))
    -wuri = d.get("taler_withdraw_uri") or (
    -    d.get("qr_payload") if mode == "auto" else ""
    -) or ""
    -
    -if mode == "auto":
    -    if not d.get("ok"):
    -        print("ok!=true", file=sys.stderr)
    -        sys.exit(1)
    -    if d.get("payto_uri"):
    -        print("payto_uri must not be present", file=sys.stderr)
    -        sys.exit(1)
    -    if not check_withdraw_uri(wuri):
    -        sys.exit(1)
    -    webui = d.get("login_url") or d.get("webui") or d.get("account_url") or ""
    -    u = urlparse(webui)
    -    if u.scheme not in ("http", "https") or "webui" not in (u.path or ""):
    -        print("login webui missing:", webui[:80], file=sys.stderr)
    -        sys.exit(1)
    -    print("%s · %s" % (d.get("username", ""), wuri[:72]))
    -elif mode == "demo":
    -    if not d.get("ok", True) and "taler_withdraw_uri" not in d:
    -        print("not ok", file=sys.stderr)
    -        sys.exit(1)
    -    if not check_withdraw_uri(wuri):
    -        sys.exit(1)
    -    print("%s\t%s" % (wuri[:80], d.get("withdrawal_id") or ""))
    -else:
    -    print("bad mode:", mode, file=sys.stderr)
    -    sys.exit(2)
    -sys.exit(0)
    -PY
    -}
    -
    -# --- exchange (core; hard on local stack, soft on remote) ---  www.exchange-NN
    -set_group exchange
    -if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -  check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config"
    -else
    -  check_url_soft "exchange /config" 200 "$EXCHANGE_PUBLIC/config"
    -fi
    -code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ec.json")
    -if [ "$code" = "200" ]; then
    -  expect_currency "exchange" "$tmp/ec.json"
    -  # currency_specification.alt_unit_names (wallet codec)
    -  if json_has_alt_unit_names "$tmp/ec.json" "${EXPECT_CURRENCY:-}" >/tmp/alt-ex.$$ 2>&1; then
    -    ok "exchange /config alt_unit_names" "$(tr '\n' '; ' "$PERF_TSV"
    -
    -# Measure one URL: require HTTP expect (default 200), report time_total in ms.
    -# $1=label $2=url $3=optional expected codes (default 200)
    -check_perf() {
    -  local label="$1" url="$2" expect="${3:-200}"
    -  local out code t_s ms
    -  out=$(curl -skS --max-redirs 3 -L -m "${PERF_CURL_TIMEOUT:-25}" \
    -    -o /dev/null -w '%{http_code} %{time_total}' "$url" 2>/dev/null || echo "000 0")
    -  code=$(printf '%s' "$out" | awk '{print $1}')
    -  t_s=$(printf '%s' "$out" | awk '{print $2}')
    -  ms=$(awk -v t="${t_s:-0}" 'BEGIN{
    -    ms=(t+0)*1000
    -    if (ms>0 && ms<1) ms=1
    -    printf "%d", int(ms+0.5)
    -  }')
    -  # Always record sample for rollup (label, ms, http, url)
    -  printf '%s\t%s\t%s\t%s\n' "$label" "$ms" "$code" "$url" >>"$PERF_TSV"
    -  case ",$expect," in
    -    *",$code,"*)
    -      if [ "$ms" -ge "${PERF_FAIL_MS}" ] 2>/dev/null; then
    -        fail "$label" "HTTP $code · ${ms} ms ≥ fail ${PERF_FAIL_MS} ms · $url"
    -      elif [ "$ms" -ge "${PERF_WARN_MS}" ] 2>/dev/null; then
    -        warn "$label" "HTTP $code · ${ms} ms ≥ warn ${PERF_WARN_MS} ms · $url"
    -      else
    -        ok "$label" "HTTP $code · ${ms} ms · $url"
    -      fi
    -      ;;
    -    *)
    -      if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -        fail "$label" "HTTP $code want $expect · ${ms} ms · $url"
    -      elif is_prelaunch_stack; then
    -        info "$label" "HTTP $code · ${ms} ms · pre-launch · $url"
    -      else
    -        warn "$label" "HTTP $code want $expect · ${ms} ms · $url"
    -      fi
    -      ;;
    -  esac
    -}
    -
    -# Bank first (wallet-critical paths before UI chrome)
    -check_perf "perf bank /taler-integration/config" "$BANK_PUBLIC/taler-integration/config"
    -check_perf "perf bank /config" "$BANK_PUBLIC/config"
    -if [ "${CHECK_LANDING:-1}" = "1" ]; then
    -  check_perf "perf bank /intro/" "$BANK_PUBLIC/intro/"
    -  check_perf "perf bank /intro/stats.json" "$BANK_PUBLIC/intro/stats.json" 200
    -fi
    -check_perf "perf bank /webui/" "$BANK_PUBLIC/webui/" 200,301,302
    -
    -# Exchange
    -check_perf "perf exchange /config" "$EXCHANGE_PUBLIC/config"
    -check_perf "perf exchange /keys" "$EXCHANGE_PUBLIC/keys"
    -if [ "${CHECK_LANDING:-1}" = "1" ]; then
    -  check_perf "perf exchange /intro/" "$EXCHANGE_PUBLIC/intro/"
    -fi
    -
    -# Merchant
    -check_perf "perf merchant /config" "$MERCHANT_PUBLIC/config"
    -check_perf "perf merchant /webui/" "$MERCHANT_PUBLIC/webui/" 200,301,302
    -if [ "${CHECK_LANDING:-1}" = "1" ]; then
    -  check_perf "perf merchant /intro/" "$MERCHANT_PUBLIC/intro/"
    -fi
    -
    -info "perf note" "RTT measured from this host (outside-in)"
    -
    -# ---------------------------------------------------------------------------
    -# Landing stats.json (public outside-in) — freshness + display fields.
    -# Timestamps (required for external measurement):
    -#   generated_at_unix  — epoch seconds (preferred)
    -#   generated_at       — ISO-8601
    -#   generated_at_human — footer / “updated” UI string
    -# Bank collector always sets all three; exchange/merchant scripts set unix too.
    -# Env:
    -#   STATS_STALE_SECS   warn if age > this (default 900 = 15m; timer often 5–15m)
    -#   STATS_FAIL_SECS    hard fail if age > this (default 3600); 0 = never hard-fail on age
    -# ---------------------------------------------------------------------------
    -: "${STATS_STALE_SECS:=900}"
    -: "${STATS_FAIL_SECS:=3600}"
    -
    -_fetch_landing_stats_json() {
    -  # $1=name bank|exchange|merchant  $2=base URL  → writes $tmp/stats-$1.json, exit 0 if ok
    -  local name="$1" base="$2"
    -  local f="$tmp/stats-${name}.json"
    -  local code ctr path
    -  rm -f "$f"
    -  code=$(http_body "${base}/intro/stats.json" "$f" 2>/dev/null || echo 000)
    -  if [ "$code" = "200" ] && [ -s "$f" ]; then
    -    return 0
    -  fi
    -  # Optional inside-container fallback (no public path or empty)
    -  if [ "${LOCAL_STACK:-0}" = "1" ] && [ "${SKIP_SSH:-0}" != "1" ] && koopa_ssh_ok 2>/dev/null; then
    -    case "$name" in
    -      bank)     ctr=taler-hacktivism-bank; path=/var/www/bank-landing/stats.json ;;
    -      exchange) ctr=taler-hacktivism-exchange-ansible; path=/var/www/exchange-landing/stats.json ;;
    -      merchant) ctr=taler-hacktivism; path=/var/www/merchant-landing/stats.json ;;
    -      *) return 1 ;;
    -    esac
    -    if koopa_ssh_run 12 "podman exec ${ctr} cat ${path} 2>/dev/null" >"$f" 2>/dev/null \
    -       && [ -s "$f" ]; then
    -      return 0
    -    fi
    -  fi
    -  return 1
    -}
    -
    -# Validate one stats.json for monitoring display + outside age measurement.
    -# Prints lines: OK|WARN|ERR|STALE|FAIL 
    -# Sets $tmp/stats-meta-$name.txt with gen_unix|source for shared-feed compare.
    -check_landing_stats_json() {
    -  local name="$1" base="$2"
    -  local f="$tmp/stats-${name}.json"
    -  local line hard_missing=0
    -
    -  if ! _fetch_landing_stats_json "$name" "$base"; then
    -    if [ "${CHECK_LANDING:-1}" = "1" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -      fail "stats ${name}" "HTTP/body missing · ${base}/intro/stats.json"
    -    else
    -      warn "stats ${name}" "stats.json not available"
    -    fi
    -    return 1
    -  fi
    -  ok "stats ${name} reachable" "${base}/intro/stats.json"
    -
    -  # Multi-line verdict from python (STATUS detail)
    -  while IFS= read -r line; do
    -    case "$line" in
    -      META\ *)
    -        printf '%s\n' "${line#META }" >"$tmp/stats-meta-${name}.txt"
    -        ;;
    -      OK\ *)
    -        ok "stats ${name}" "${line#OK }"
    -        ;;
    -      WARN\ *)
    -        warn "stats ${name}" "${line#WARN }"
    -        ;;
    -      STALE\ *)
    -        warn "stats ${name} freshness" "stale · ${line#STALE }"
    -        ;;
    -      FAIL\ *)
    -        fail "stats ${name}" "${line#FAIL }"
    -        hard_missing=1
    -        ;;
    -      ERR\ *)
    -        if [ "${LOCAL_STACK:-0}" = "1" ] || [ "${CHECK_LANDING:-1}" = "1" ]; then
    -          fail "stats ${name}" "${line#ERR }"
    -        else
    -          warn "stats ${name}" "${line#ERR }"
    -        fi
    -        hard_missing=1
    -        ;;
    -      *)
    -        [ -n "$line" ] && warn "stats ${name}" "unparsed · $line"
    -        ;;
    -    esac
    -  done < <(python3 - "$f" "$name" "${STATS_STALE_SECS}" "${STATS_FAIL_SECS}" <<'PY' 2>/dev/null || echo "ERR python check failed"
    -import json, sys, time, re
    -from datetime import datetime, timezone
    -
    -path, role, stale_s, fail_s = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
    -now = int(time.time())
    -
    -def out(kind, msg):
    -    print("%s %s" % (kind, msg))
    -
    -try:
    -    d = json.load(open(path))
    -except Exception as e:
    -    out("ERR", "JSON parse: %s" % str(e)[:100])
    -    sys.exit(0)
    -
    -if not isinstance(d, dict):
    -    out("ERR", "body is not a JSON object")
    -    sys.exit(0)
    -
    -if d.get("ok") is False:
    -    out("ERR", "ok=false · %s" % str(d.get("error") or d.get("hint") or "")[:80])
    -    sys.exit(0)
    -
    -# --- timestamps (outside-in age measurement) ---
    -gu = d.get("generated_at_unix")
    -giso = d.get("generated_at") or d.get("generated_at_iso")
    -ghum = d.get("generated_at_human")
    -age = None
    -src_u = None
    -
    -if gu is not None and str(gu).strip() != "":
    -    try:
    -        src_u = int(float(gu))
    -        # tolerate ms accidental timestamps
    -        if src_u > 10_000_000_000:
    -            src_u //= 1000
    -        age = now - src_u
    -    except Exception:
    -        out("ERR", "generated_at_unix not numeric: %r" % gu)
    -        src_u = None
    -elif giso:
    -    # parse ISO when unix missing (legacy exchange/merchant before unix field)
    -    try:
    -        s = str(giso).strip()
    -        if s.endswith("Z"):
    -            s = s[:-1] + "+00:00"
    -        # allow +0200 without colon
    -        s = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", s)
    -        dt = datetime.fromisoformat(s)
    -        if dt.tzinfo is None:
    -            dt = dt.replace(tzinfo=timezone.utc)
    -        src_u = int(dt.timestamp())
    -        age = now - src_u
    -        out("WARN", "no generated_at_unix — derived age from generated_at ISO (deploy collector with unix)")
    -    except Exception as e:
    -        out("ERR", "cannot parse generated_at=%r (%s)" % (giso, e))
    -else:
    -    out("ERR", "missing generated_at_unix and generated_at — cannot measure freshness outside")
    -
    -if not ghum and not giso:
    -    out("ERR", "missing generated_at_human/generated_at — UI cannot show “updated” time")
    -elif ghum:
    -    out("OK", "timestamp human=%s" % ghum)
    -else:
    -    out("OK", "timestamp iso=%s" % giso)
    -
    -if src_u is not None:
    -    out("META", "%s|%s|%s" % (src_u, d.get("source") or "", d.get("currency") or ""))
    -    if age is not None and age < -120:
    -        out("WARN", "clock skew · generated_at_unix in the future by %ss" % (-age))
    -    if age is not None:
    -        out("OK", "generated_at_unix=%s age=%ss (now=%s)" % (src_u, age, now))
    -        if fail_s > 0 and age > fail_s:
    -            out("FAIL", "age=%ss > STATS_FAIL_SECS=%s · collector not updating" % (age, fail_s))
    -        elif age > stale_s:
    -            out("STALE", "age=%ss > STATS_STALE_SECS=%s · timer lag or stuck publish" % (age, stale_s))
    -
    -# --- display fields for landings / monitoring ---
    -src = str(d.get("source") or "")
    -# Bank-shaped (incl. stage shared feed on all three landings)
    -bankish = (
    -    role == "bank"
    -    or "collect_bank_stats" in src
    -    or "shared" in src
    -    or isinstance(d.get("withdraws"), dict)
    -    or isinstance(d.get("bank_accounts"), dict)
    -)
    -if bankish:
    -    missing = []
    -    if not isinstance(d.get("withdraws"), dict):
    -        missing.append("withdraws")
    -    ba = d.get("bank_accounts")
    -    wl = d.get("wallets")
    -    if not isinstance(ba, dict) and not isinstance(wl, dict):
    -        missing.append("bank_accounts|wallets")
    -    if d.get("currency") in (None, ""):
    -        missing.append("currency")
    -    if missing:
    -        out("ERR", "bank-shaped display missing: %s" % ",".join(missing))
    -    else:
    -        w = d.get("withdraws") or {}
    -        ba = ba if isinstance(ba, dict) else {}
    -        n_acc = ba.get("total") if ba.get("total") is not None else ba.get("users")
    -        out(
    -            "OK",
    -            "display bank-shape currency=%s accounts=%s withdraws.count=%s source=%s"
    -            % (d.get("currency"), n_acc, w.get("count"), (src or "?")[:48]),
    -        )
    -elif src == "exchange-db" or (role == "exchange" and "reserves" in d):
    -    need = []
    -    for k in ("reserves", "wire_in_count", "known_coins"):
    -        if k not in d and k.replace("known_coins", "coins_live") not in d:
    -            if k == "known_coins" and ("coins_live" in d or "coins_remaining_amount" in d):
    -                continue
    -            need.append(k)
    -    if need:
    -        out("WARN", "exchange display keys missing: %s" % ",".join(need))
    -    else:
    -        out(
    -            "OK",
    -            "display exchange-db reserves=%s wire_in=%s coins=%s"
    -            % (d.get("reserves"), d.get("wire_in_count"), d.get("known_coins") or d.get("coins_live")),
    -        )
    -elif src == "merchant-db" or (role == "merchant" and "instances" in d):
    -    if "instances" not in d and "orders" not in d:
    -        out("ERR", "merchant display missing instances/orders")
    -    else:
    -        out(
    -            "OK",
    -            "display merchant-db instances=%s orders=%s paid=%s"
    -            % (d.get("instances"), d.get("orders"), d.get("paid")),
    -        )
    -else:
    -    out("WARN", "unknown stats schema role=%s source=%s keys=%s" % (role, src[:40], ",".join(list(d.keys())[:8])))
    -
    -# performance block optional (stage shared bank feed may omit heavy probes)
    -p = d.get("performance")
    -if isinstance(p, dict) and p:
    -    mem = p.get("memory") if isinstance(p.get("memory"), dict) else {}
    -    rss = mem.get("container_rss_human") or mem.get("proc_sum_rss_human") or "—"
    -    load = p.get("loadavg") or "—"
    -    bits = []
    -    for k in ("config_ms", "integration_ms", "webui_ms", "keys_ms", "terms_ms"):
    -        if k in p and p[k] is not None:
    -            bits.append("%s=%s" % (k.replace("_ms", ""), p[k]))
    -    lat = " ".join(bits) if bits else "latency=—"
    -    out("OK", "performance loadavg=%s RSS=%s %s" % (load, rss, lat))
    -else:
    -    out("OK", "performance block optional/absent")
    -PY
    -)
    -
    -  return 0
    -}
    -
    -if [ "${CHECK_LANDING:-1}" = "1" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -  set_group stats
    -  section "www · landing stats.json (freshness + display fields · outside-in)"
    -  info "stats policy" "STALE≥${STATS_STALE_SECS}s WARN · FAIL≥${STATS_FAIL_SECS}s ERROR (0=disable fail) · need generated_at_unix"
    -  check_landing_stats_json "bank" "$BANK_PUBLIC" || true
    -  check_landing_stats_json "exchange" "$EXCHANGE_PUBLIC" || true
    -  check_landing_stats_json "merchant" "$MERCHANT_PUBLIC" || true
    -
    -  # Shared feed: stage-style publish copies same bank stats to all three URLs
    -  if [ -f "$tmp/stats-meta-bank.txt" ] && [ -f "$tmp/stats-meta-exchange.txt" ] && [ -f "$tmp/stats-meta-merchant.txt" ]; then
    -    shared_line=$(python3 - "$tmp/stats-meta-bank.txt" "$tmp/stats-meta-exchange.txt" "$tmp/stats-meta-merchant.txt" <<'PY' 2>/dev/null || true
    -import sys
    -def parse(p):
    -    try:
    -        t = open(p).read().strip().split("|", 2)
    -        return t[0], (t[1] if len(t) > 1 else ""), (t[2] if len(t) > 2 else "")
    -    except Exception:
    -        return "", "", ""
    -b, bs, bc = parse(sys.argv[1])
    -e, es, ec = parse(sys.argv[2])
    -m, ms, mc = parse(sys.argv[3])
    -if not b or not e or not m:
    -    print("SKIP incomplete meta")
    -elif b == e == m:
    -    print("SHARED gen_unix=%s bank_src=%s" % (b, (bs or "?")[:50]))
    -else:
    -    # Shared feed only when all three claim the same bank-collector source
    -    # (e.g. stage TESTPAYSAN "… shared"). GOA uses independent exchange-db /
    -    # merchant-db collectors — different gen_unix is expected.
    -    def bankish(s):
    -        s = (s or "").lower()
    -        return "shared" in s or "collect_bank_stats" in s
    -    if bankish(bs) and bankish(es) and bankish(ms):
    -        print("DRIFT bank=%s exchange=%s merchant=%s (expected equal for shared feed)" % (b, e, m))
    -    else:
    -        print("INDEPENDENT bank=%s exchange=%s merchant=%s" % (b, e, m))
    -PY
    -)
    -    case "$shared_line" in
    -      SHARED\ *)
    -        ok "stats shared feed" "${shared_line#SHARED }"
    -        ;;
    -      DRIFT\ *)
    -        warn "stats shared feed" "timestamps differ · ${shared_line#DRIFT }"
    -        ;;
    -      INDEPENDENT\ *)
    -        info "stats feeds" "independent collectors · ${shared_line#INDEPENDENT }"
    -        ;;
    -      *)
    -        info "stats feeds" "${shared_line:-n/a}"
    -        ;;
    -    esac
    -  fi
    -  info "stats note" "public GET /intro/stats.json; measure age via generated_at_unix vs wall clock"
    -fi
    -# Rollup + optional JSON for metrics_print_overall
    -if [ -s "$PERF_TSV" ]; then
    -  PERF_JSON="$tmp/perf-summary.json"
    -  perf_line=$(python3 - "$PERF_TSV" "$PERF_JSON" "$PERF_WARN_MS" "$PERF_FAIL_MS" <<'PY'
    -import json, sys
    -rows = []
    -for line in open(sys.argv[1]):
    -    parts = line.rstrip("\n").split("\t")
    -    if len(parts) < 3:
    -        continue
    -    label, ms_s, code = parts[0], parts[1], parts[2]
    -    try:
    -        ms = int(float(ms_s))
    -    except Exception:
    -        continue
    -    rows.append({"label": label, "ms": ms, "http": code})
    -vals = sorted(r["ms"] for r in rows)
    -n = len(vals)
    -if n == 0:
    -    print("n=0")
    -    json.dump({"n": 0}, open(sys.argv[2], "w"))
    -    raise SystemExit(0)
    -def pct(p):
    -    if n == 1:
    -        return vals[0]
    -    i = min(n - 1, max(0, int(round((p / 100.0) * (n - 1)))))
    -    return vals[i]
    -avg = int(round(sum(vals) / n))
    -warn_ms = int(sys.argv[3])
    -fail_ms = int(sys.argv[4])
    -slow = [r for r in rows if r["ms"] >= warn_ms]
    -rep = {
    -    "n": n,
    -    "min_ms": vals[0],
    -    "p50_ms": pct(50),
    -    "avg_ms": avg,
    -    "max_ms": vals[-1],
    -    "warn_ms": warn_ms,
    -    "fail_ms": fail_ms,
    -    "slow": [{"label": r["label"], "ms": r["ms"]} for r in slow],
    -    "samples": rows,
    -}
    -# metrics_print_overall expects named buckets with n/min/p50/avg/max
    -out = {
    -    "www_public_https": {
    -        "n": n,
    -        "min_ms": vals[0],
    -        "p50_ms": pct(50),
    -        "avg_ms": avg,
    -        "max_ms": vals[-1],
    -    },
    -    **{r["label"].replace(" ", "_"): {"n": 1, "min_ms": r["ms"], "p50_ms": r["ms"], "avg_ms": r["ms"], "max_ms": r["ms"]} for r in rows},
    -}
    -json.dump(out, open(sys.argv[2], "w"), indent=2)
    -extra = ""
    -if slow:
    -    extra = "  slow: " + ", ".join("%s=%dms" % (r["label"].replace("perf ", ""), r["ms"]) for r in slow)
    -print(
    -    "n=%d  min=%dms  p50=%dms  avg=%dms  max=%dms  (warn≥%dms fail≥%dms)%s"
    -    % (n, vals[0], pct(50), avg, vals[-1], warn_ms, fail_ms, extra)
    -)
    -PY
    -  )
    -  info "perf summary" "$perf_line"
    -  # Keep a copy if METRICS_DIR is set (e2e/ladder overall stats)
    -  if [ -n "${METRICS_DIR:-}" ] && [ -d "${METRICS_DIR}" ]; then
    -    cp -f "$PERF_JSON" "${METRICS_DIR}/perf-summary.json" 2>/dev/null || true
    -  fi
    -fi
    -info "perf note" "measured from this host (outside-in); thresholds PERF_WARN_MS=${PERF_WARN_MS} PERF_FAIL_MS=${PERF_FAIL_MS}"
    -
    -
    -# Exchange terms + privacy (same www.exchange group — issues map to exchange)
    -set_group exchange
    -check_legal_doc "exchange /terms" "$EXCHANGE_PUBLIC/terms" "terms|GOA|exploration|FADP|revDSG|privacy"
    -check_legal_doc "exchange /privacy" "$EXCHANGE_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|exploration"
    -# trailing slash: 200 or redirect to bare path
    -code=$(http_code "$EXCHANGE_PUBLIC/terms/")
    -case "$code" in
    -  200|301|302|303|307|308) ok "exchange /terms/" "HTTP $code" ;;
    -  *)
    -    if is_prelaunch_stack; then
    -      info "exchange /terms/" "HTTP $code — pre-launch"
    -    else
    -      warn "exchange /terms/" "HTTP $code"
    -    fi
    -    ;;
    -esac
    -
    -# --- bank ---
    -set_group bank
    -if [ "${LOCAL_STACK:-1}" = "0" ]; then
    -  check_url_soft "bank /config" 200 "$BANK_PUBLIC/config"
    -else
    -  check_url "bank /config" 200 "$BANK_PUBLIC/config"
    -fi
    -code=$(http_body "$BANK_PUBLIC/config" "$tmp/bc.json")
    -if [ "$code" = "200" ]; then
    -  expect_currency "bank" "$tmp/bc.json"
    -  if json_has_alt_unit_names "$tmp/bc.json" >/tmp/alt-bank.$$ 2>&1; then
    -    ok "bank /config alt_unit_names" "$(tr '\n' '; ' "$tmp/aa-val"); then
    -        ok "bank /intro/auto-account.json" "$aa_detail"
    -      else
    -        fail "bank /intro/auto-account.json" "invalid withdraw/login ($(tr '\n' ' ' <"$tmp/aa-val" | sed 's/[[:space:]]*$//'))"
    -      fi
    -      ;;
    -    404|405|501)
    -      if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -        fail "bank /intro/auto-account.json" "HTTP $aa_code (want 200; 405/501 = broken)"
    -      else
    -        info "bank /intro/auto-account.json" "HTTP $aa_code — skip (not a GOA auto-account stack)"
    -      fi
    -      ;;
    -    502|503|000)
    -      fail "bank /intro/auto-account.json" "HTTP $aa_code want 200"
    -      ;;
    -    *)
    -      if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -        fail "bank /intro/auto-account.json" "HTTP $aa_code want 200"
    -      else
    -        warn "bank /intro/auto-account.json" "HTTP $aa_code (optional off-GOA)"
    -      fi
    -      ;;
    -  esac
    -fi
    -
    -# Bank legal docs (landing nginx via Caddy /terms* /privacy* or /intro/*)
    -check_legal_doc "bank /terms" "$BANK_PUBLIC/terms" "terms|GOA|exploration|bank|FADP|revDSG"
    -# Prefer /privacy; fall back to /intro/privacy.html for older deploys
    -code=$(http_code "$BANK_PUBLIC/privacy")
    -if [ "$code" = "200" ]; then
    -  check_legal_doc "bank /privacy" "$BANK_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|bank"
    -else
    -  if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -    check_legal_doc "bank /privacy (or /intro/privacy.html)" \
    -      "$BANK_PUBLIC/intro/privacy.html" "privacy|FADP|revDSG|data|GOA|bank"
    -    # still report bare /privacy failure for local
    -    warn "bank /privacy" "HTTP $code — prefer Caddy handle /privacy* → landing"
    -  else
    -    check_url_soft "bank /privacy" 200 "$BANK_PUBLIC/privacy"
    -  fi
    -fi
    -
    -# --- merchant ---
    -set_group merchant
    -if [ "${LOCAL_STACK:-1}" = "0" ]; then
    -  check_url_soft "merchant /config" 200 "$MERCHANT_PUBLIC/config"
    -else
    -  check_url "merchant /config" 200 "$MERCHANT_PUBLIC/config"
    -fi
    -code=$(http_body "$MERCHANT_PUBLIC/config" "$tmp/mc.json")
    -if [ "$code" = "200" ]; then
    -  want="${EXPECT_CURRENCY:-}"
    -  if python3 - "$tmp/mc.json" "$want" <<'PY'
    -import json,sys
    -try:
    -    d=json.load(open(sys.argv[1]))
    -except Exception:
    -    sys.exit(2)
    -want=sys.argv[2]
    -if not want:
    -    sys.exit(0)
    -curs=list((d.get("currencies") or {}).keys())
    -ex=d.get("exchanges") or []
    -ok = want in curs or any((e.get("currency") if isinstance(e,dict) else None)==want for e in ex)
    -if not ok and isinstance(d.get("currency"), str):
    -    ok = d["currency"]==want
    -sys.exit(0 if ok else 1)
    -PY
    -  then
    -    ok "merchant /config (${want:-currency} ok)"
    -  else
    -    ec=$?
    -    if [ "$ec" = "2" ]; then
    -      warn "merchant /config" "non-JSON body"
    -    elif [ -n "$want" ]; then
    -      fail "merchant /config currency" "want $want"
    -    else
    -      info "merchant /config" "ok"
    -    fi
    -  fi
    -  # merchant-local currency maps (GOA + CHF, …)
    -  if json_has_alt_unit_names "$tmp/mc.json" >/tmp/alt-mer.$$ 2>&1; then
    -    ok "merchant /config currencies alt_unit_names" "$(tr '\n' '; ' /dev/null || true)
    -      # take first 3 digits only (avoid 200000 glue when curl exits non-zero after writing code)
    -      printf '%s' "$raw" | tr -cd '0-9' | head -c 3
    -      [ -n "$(printf '%s' "$raw" | tr -cd '0-9')" ] || printf '000'
    -    }
    -    for asset in index.html index.js; do
    -      tmo="${TIMEOUT}"
    -      [ "$asset" = "index.js" ] && tmo="${WEBUI_INDEX_JS_TIMEOUT:-60}"
    -      acode=$(_webui_asset_code "$MERCHANT_PUBLIC/webui/${asset}" "$tmo")
    -      case "$acode" in
    -        200) ok "merchant /webui/${asset}" "HTTP 200" ;;
    -        *)
    -          if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -            fail "merchant /webui/${asset}" "HTTP ${acode:-000}"
    -          else
    -            warn "merchant /webui/${asset}" "HTTP ${acode:-000}"
    -          fi
    -          ;;
    -      esac
    -    done
    -    # return group for any later merchant checks in this block
    -    set_group merchant
    -  fi
    -fi
    -
    -# Merchant legal docs
    -check_legal_doc "merchant /terms" "$MERCHANT_PUBLIC/terms" "terms|dual|GOA|CHF|explorational|merchant"
    -check_legal_doc "merchant /privacy" "$MERCHANT_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|CHF|merchant"
    -code=$(http_code "$MERCHANT_PUBLIC/terms/")
    -case "$code" in
    -  200|301|302|303|307|308) ok "merchant /terms/" "HTTP $code" ;;
    -  *)
    -    if is_prelaunch_stack; then
    -      info "merchant /terms/" "HTTP $code — pre-launch"
    -    else
    -      warn "merchant /terms/" "HTTP $code (expect 302 → /terms)"
    -    fi
    -    ;;
    -esac
    -
    -# ---------------------------------------------------------------------------
    -# Paivana paywall (local GOA stack) — public front only; pay path is e2e
    -# ---------------------------------------------------------------------------
    -if [ "${LOCAL_STACK:-1}" = "1" ] && [ "${E2E_PAIVANA:-1}" != "0" ]; then
    -  set_group paivana
    -  section "www · paivana paywall"
    -  : "${PAIVANA_PUBLIC:=https://paivana.hacktivism.ch}"
    -  PAIVANA_PUBLIC="${PAIVANA_PUBLIC%/}"
    -  hdr=$(curl -skS -m "${TIMEOUT}" -D - -o /dev/null "${PAIVANA_PUBLIC}/" 2>/dev/null || true)
    -  pcode=$(printf '%s' "$hdr" | awk 'BEGIN{c="000"} /^HTTP/{c=$2} END{print c}')
    -  loc=$(printf '%s' "$hdr" | awk 'BEGIN{IGNORECASE=1} /^location:/{sub(/\r$/,""); sub(/^location:[[:space:]]*/,""); print; exit}')
    -  case "$pcode" in
    -    301|302|303|307|308)
    -      if printf '%s' "$loc" | grep -qiE 'paivana|templates|well-known'; then
    -        ok "paivana /" "HTTP $pcode → template flow"
    -      else
    -        ok "paivana /" "HTTP $pcode redirect"
    -      fi
    -      info "paivana Location" "${loc:0:140}"
    -      ;;
    -    402)
    -      # Paywall may answer Payment Required with body (healthy, not 502)
    -      ok "paivana /" "HTTP 402 Payment Required (paywall up)"
    -      ;;
    -    200)
    -      warn "paivana /" "HTTP 200 (expected paywall redirect/402 to template)"
    -      ;;
    -    *)
    -      warn "paivana /" "HTTP ${pcode:-000} — ${PAIVANA_PUBLIC}/ (e2e pay may still work via template)"
    -      ;;
    -  esac
    -fi
    -
    -# ---------------------------------------------------------------------------
    -# Landing pages: every HTTPS link exposed on bank / merchant / exchange intros
    -# + required static assets + bank withdraw mint (taler://withdraw only)
    -# Skipped when CHECK_LANDING=0 (e.g. taler-ops.ch — no GOA-style landings).
    -# ---------------------------------------------------------------------------
    -if [ "${CHECK_LANDING:-1}" != "1" ]; then
    -  section "www · landing pages"
    -  info "landing checks" "skipped (CHECK_LANDING=0 · stack has no public /intro landings)"
    -  summary
    -  exit 0
    -fi
    -
    -set_group landing
    -section "www · landing exposed links · bank / merchant / exchange"
    -
    -# Probe one URL: print code to stdout (200 after following redirects counts as 200).
    -# Sets _landing_code. Exit 0 if OK (200 or redirect→200), 1 otherwise.
    -_landing_probe() {
    -  local url="$1"
    -  local code
    -  code=$(http_code "$url")
    -  case "$code" in
    -    200) _landing_code=200; return 0 ;;
    -    301|302|303|307|308)
    -      code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000)
    -      _landing_code="$code"
    -      [ "$code" = "200" ] && return 0
    -      return 1
    -      ;;
    -    *)
    -      _landing_code="$code"
    -      return 1
    -      ;;
    -  esac
    -}
    -
    -# Known-good landing static paths — one report line via caller aggregate, or
    -# soft=1 single warn. Returns 0 if ok.
    -check_landing_asset() {
    -  local label="$1" url="$2" soft="${3:-0}"
    -  if _landing_probe "$url"; then
    -    return 0
    -  fi
    -  if [ "$soft" = "1" ]; then
    -    warn "$label" "HTTP ${_landing_code:-?} — $url"
    -  else
    -    fail "$label" "HTTP ${_landing_code:-?} — $url"
    -  fi
    -  return 1
    -}
    -
    -# Soft external: never ERROR; used only for failures in aggregated external probe.
    -check_external_soft() {
    -  local label="$1" url="$2"
    -  local code
    -  code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000)
    -  case "$code" in
    -    200|204|301|302|303|307|308) return 0 ;;
    -    *) warn "$label" "HTTP $code · $url"; return 1 ;;
    -  esac
    -}
    -
    -# Parse one landing HTML: collect absolute https + root-relative href/src;
    -# resolve against base; classify own-stack vs external.
    -# Writes lists: $1.own  $1.ext  (one URL per line)
    -extract_landing_urls() {
    -  local base="$1" html="$2" out_prefix="$3"
    -  python3 - "$base" "$html" "$out_prefix" <<'PY'
    -import re, sys
    -from urllib.parse import urljoin, urlparse
    -
    -base, html_path, out = sys.argv[1], sys.argv[2], sys.argv[3]
    -html = open(html_path, encoding="utf-8", errors="replace").read()
    -base = base.rstrip("/") + "/"
    -parsed_base = urlparse(base)
    -# Hard-check only the three public Taler hosts for this stack (not git.* etc.)
    -own_hosts = {
    -    (parsed_base.hostname or "").lower(),
    -    "bank.hacktivism.ch",
    -    "exchange.hacktivism.ch",
    -    "taler.hacktivism.ch",
    -}
    -# include configured public hosts when domain differs (demo / ops)
    -for envu in (
    -    __import__("os").environ.get("BANK_PUBLIC", ""),
    -    __import__("os").environ.get("EXCHANGE_PUBLIC", ""),
    -    __import__("os").environ.get("MERCHANT_PUBLIC", ""),
    -):
    -    h = urlparse(envu).hostname if envu else None
    -    if h:
    -        own_hosts.add(h.lower())
    -
    -raw = set()
    -for m in re.finditer(
    -    r'''(?:href|src|content)=["']([^"'#]+)["']''', html, re.I
    -):
    -    raw.add(m.group(1).strip())
    -# bare absolute URLs in scripts (fetch, template strings)
    -for m in re.finditer(r'''https://[^\s"'<>\\]+''', html):
    -    u = m.group(0).rstrip("\\).,;'\"")
    -    # strip trailing punctuation leftovers
    -    while u and u[-1] in ".,);]}\"'":
    -        u = u[:-1]
    -    if u.startswith("https://"):
    -        raw.add(u)
    -
    -own, ext = set(), set()
    -skip_prefix = ("data:", "javascript:", "mailto:", "taler://", "blob:")
    -skip_exact = {"website", "summary_large_image", "image/png", "en_US"}
    -for r in raw:
    -    if not r or r in skip_exact:
    -        continue
    -    if r.startswith(skip_prefix):
    -        continue
    -    # meta content noise
    -    if re.fullmatch(r"\d+", r) or r.startswith("width="):
    -        continue
    -    if " " in r and not r.startswith("http"):
    -        continue
    -    if r.startswith("//"):
    -        absu = "https:" + r
    -    elif r.startswith("http://") or r.startswith("https://"):
    -        absu = r
    -    elif r.startswith("/"):
    -        absu = urljoin(base, r)
    -    else:
    -        # relative asset
    -        if "/" in r or r.endswith((".js", ".css", ".png", ".svg", ".html", ".json", ".uri")):
    -            absu = urljoin(base + "intro/", r)
    -        else:
    -            continue
    -    # drop query-only noise / anchors already stripped
    -    p = urlparse(absu)
    -    if p.scheme not in ("http", "https"):
    -        continue
    -    # normalize: drop fragment
    -    absu = absu.split("#", 1)[0]
    -    host = (p.hostname or "").lower()
    -    # og image query ok
    -    if host in own_hosts:
    -        own.add(absu)
    -    else:
    -        ext.add(absu)
    -
    -open(out + ".own", "w").write("\n".join(sorted(own)) + ("\n" if own else ""))
    -open(out + ".ext", "w").write("\n".join(sorted(ext)) + ("\n" if ext else ""))
    -print(f"own={len(own)} ext={len(ext)}")
    -PY
    -}
    -
    -check_one_landing() {
    -  local name="$1" base="$2"
    -  local html="$tmp/landing-${name}.html"
    -  local pref="$tmp/urls-${name}"
    -  local code n own_n ext_n
    -  local a_ok=0 a_fail=0 a_soft=0
    -  local own_ok=0 own_fail=0
    -  local ext_ok=0 ext_fail=0
    -  local fail_sample="" soft_sample=""
    -
    -  code=$(http_body "${base}/intro/" "$html")
    -  if [ "$code" != "200" ]; then
    -    if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -      fail "landing ${name}" "/intro/ HTTP $code — skip assets/links"
    -    else
    -      warn "landing ${name}" "/intro/ HTTP $code — skip assets/links"
    -    fi
    -    return
    -  fi
    -
    -  # Required static: qrcode.min.js. og-goa-shop.png only on GOA/local.
    -  if _landing_probe "${base}/intro/qrcode.min.js"; then
    -    a_ok=$((a_ok + 1))
    -  else
    -    a_fail=$((a_fail + 1))
    -    fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} ${base}/intro/qrcode.min.js"
    -  fi
    -  if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -    if _landing_probe "${base}/intro/og-goa-shop.png"; then
    -      a_ok=$((a_ok + 1))
    -    else
    -      a_fail=$((a_fail + 1))
    -      fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} ${base}/intro/og-goa-shop.png"
    -    fi
    -  fi
    -  # qr-logo: hard on GOA (wallet QR frame); soft elsewhere if missing
    -  if _landing_probe "${base}/intro/qr-logo.png"; then
    -    a_ok=$((a_ok + 1))
    -  else
    -    if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -      a_soft=$((a_soft + 1))
    -      soft_sample="${soft_sample}${soft_sample:+; }HTTP ${_landing_code} qr-logo.png"
    -    fi
    -  fi
    -
    -  n=$(extract_landing_urls "$base" "$html" "$pref" 2>/dev/null || echo "own=0 ext=0")
    -  own_n=0
    -  ext_n=0
    -  [ -f "${pref}.own" ] && own_n=$(grep -c . "${pref}.own" 2>/dev/null || echo 0)
    -  [ -f "${pref}.ext" ] && ext_n=$(grep -c . "${pref}.ext" 2>/dev/null || echo 0)
    -  # strip newlines from grep -c edge cases
    -  own_n=${own_n//[^0-9]/}
    -  ext_n=${ext_n//[^0-9]/}
    -  own_n=${own_n:-0}
    -  ext_n=${ext_n:-0}
    -
    -  # Probe own-stack URLs — count only; list failures
    -  if [ -f "${pref}.own" ]; then
    -    while IFS= read -r u; do
    -      [ -n "$u" ] || continue
    -      if _landing_probe "$u"; then
    -        own_ok=$((own_ok + 1))
    -      else
    -        own_fail=$((own_fail + 1))
    -        # keep a few samples (max ~3)
    -        if [ "$own_fail" -le 3 ]; then
    -          fail_sample="${fail_sample}${fail_sample:+; }own HTTP ${_landing_code} $u"
    -        fi
    -      fi
    -    done < "${pref}.own"
    -  fi
    -
    -  # External: soft counts (stores can be slow — longer timeout + browser UA)
    -  if [ -f "${pref}.ext" ]; then
    -    local _ext_to="${EXT_TIMEOUT:-25}"
    -    while IFS= read -r u; do
    -      [ -n "$u" ] || continue
    -      code=$(curl -skS --max-redirs 5 -L -m "${_ext_to}" \
    -        -A "Mozilla/5.0 (compatible; taler-monitoring/1.0)" \
    -        -o /dev/null -w '%{http_code}' "$u" 2>/dev/null || echo 000)
    -      case "$code" in
    -        200|204|301|302|303|307|308) ext_ok=$((ext_ok + 1)) ;;
    -        *)
    -          ext_fail=$((ext_fail + 1))
    -          if [ "$ext_fail" -le 3 ]; then
    -            soft_sample="${soft_sample}${soft_sample:+; }ext HTTP $code $u"
    -          fi
    -          ;;
    -      esac
    -    done < "${pref}.ext"
    -  fi
    -
    -  # One primary line per landing
    -  local detail
    -  detail="/intro $(wc -c <"$html" | tr -d ' ')B · assets ${a_ok}/$((a_ok + a_fail + a_soft)) · own-links ${own_ok}/${own_n} · external ${ext_ok}/${ext_n}"
    -  if [ "$a_fail" -gt 0 ] || [ "$own_fail" -gt 0 ] || [ "${own_n:-0}" -lt 1 ]; then
    -    if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -      fail "landing ${name}" "$detail${fail_sample:+ · $fail_sample}"
    -    else
    -      warn "landing ${name}" "$detail${fail_sample:+ · $fail_sample}"
    -    fi
    -  else
    -    ok "landing ${name}" "$detail"
    -  fi
    -  if [ "$a_soft" -gt 0 ] || [ "$ext_fail" -gt 0 ]; then
    -    warn "landing ${name} soft" "${soft_sample:-soft issues}"
    -  fi
    -}
    -
    -check_one_landing "bank" "$BANK_PUBLIC"
    -check_one_landing "merchant" "$MERCHANT_PUBLIC"
    -check_one_landing "exchange" "$EXCHANGE_PUBLIC"
    -
    -# Cross-links: one line
    -if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -  _cx_ok=0
    -  _cx_fail=0
    -  _cx_detail=""
    -  for pair in \
    -    "bank→merchant|$MERCHANT_PUBLIC/intro/" \
    -    "bank→exchange|$EXCHANGE_PUBLIC/intro/" \
    -    "merchant→bank|$BANK_PUBLIC/intro/" \
    -    "exchange→bank|$BANK_PUBLIC/intro/"
    -  do
    -    _cx_name="${pair%%|*}"
    -    _cx_url="${pair#*|}"
    -    if _landing_probe "$_cx_url"; then
    -      _cx_ok=$((_cx_ok + 1))
    -    else
    -      _cx_fail=$((_cx_fail + 1))
    -      _cx_detail="${_cx_detail}${_cx_detail:+; }${_cx_name} HTTP ${_landing_code}"
    -    fi
    -  done
    -  if [ "$_cx_fail" -eq 0 ]; then
    -    ok "landing cross-links" "${_cx_ok}/4 intros reachable"
    -  else
    -    fail "landing cross-links" "${_cx_ok}/4 ok · ${_cx_detail}"
    -  fi
    -fi
    -
    -# Bank withdraw mint + shop assets — compact
    -if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then
    -  _ba_ok=0
    -  _ba_soft=0
    -  _ba_msg=""
    -  # GOA landings ship shop-pay.*; stage TESTPAYSAN uses monnaies /assets/shop-ui.js
    -  if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -    if _landing_probe "$BANK_PUBLIC/intro/shop-pay.js"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.js; "; fi
    -    if _landing_probe "$BANK_PUBLIC/intro/shop-pay.css"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.css; "; fi
    -  fi
    -  dw_code=$(http_body "$BANK_PUBLIC/intro/demo-withdraw.json" "$tmp/dw.json")
    -  case "$dw_code" in
    -    200)
    -      if dw_out=$(validate_bank_withdraw_json "$tmp/dw.json" demo 2>"$tmp/dw-val"); then
    -        dw_detail=${dw_out%%$'\t'*}
    -        wid=${dw_out#*$'\t'}
    -        [ "$wid" = "$dw_out" ] && wid=
    -        ok "bank /intro/demo-withdraw.json" "$dw_detail"
    -        if [ -n "$wid" ]; then
    -          if _landing_probe "$BANK_PUBLIC/taler-integration/withdrawal-operation/${wid}"; then
    -            _ba_ok=$((_ba_ok + 1))
    -          else
    -            fail "landing bank withdraw-op" "HTTP ${_landing_code} · id=$wid"
    -          fi
    -        fi
    -        ok "landing bank withdraw/shop" "demo-withdraw + shop assets ok (${_ba_ok} checks)"
    -      else
    -        fail "landing bank demo-withdraw" "invalid taler://withdraw shape"
    -        fail "bank /intro/demo-withdraw.json" "invalid taler://withdraw ($(tr '\n' ' ' <"$tmp/dw-val" | sed 's/[[:space:]]*$//'))"
    -      fi
    -      ;;
    -    404|405|501)
    -      # GOA bank landing mints demo-withdraw.json; stage TESTPAYSAN uses ATM/wallet guide only
    -      if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -        fail "landing bank demo-withdraw" "HTTP $dw_code (want 200)"
    -      else
    -        info "landing bank demo-withdraw" "HTTP $dw_code — skip (not a GOA demo-withdraw stack)"
    -      fi
    -      ;;
    -    502|503|000)
    -      fail "landing bank demo-withdraw" "HTTP $dw_code (want 200)"
    -      ;;
    -    *)
    -      if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -        fail "landing bank demo-withdraw" "HTTP $dw_code want 200"
    -      else
    -        warn "landing bank demo-withdraw" "HTTP $dw_code"
    -      fi
    -      ;;
    -  esac
    -  if [ "$_ba_soft" -gt 0 ]; then
    -    warn "landing bank shop assets" "${_ba_msg}soft-missing"
    -  fi
    -fi
    -
    -# Merchant shop assets — GOA: shop-pay.*; stage TESTPAYSAN: /assets/shop-ui.js +
    -# shops.css + QR encoder (shop-ui modal needs /intro/qrcode.min.js or /qrcode.min.js).
    -_ma=0
    -_ma_need=2
    -_ma_label="shop-pay.js + .css"
    -if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -  _landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.js" && _ma=$((_ma + 1))
    -  _landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.css" && _ma=$((_ma + 1))
    -else
    -  # stage farmer shops: UI + stylesheet + qrcode (pay modal QR_Taler)
    -  _ma_need=3
    -  _ma_label="shop-ui.js + shops.css + qrcode.min.js"
    -  _landing_probe "$MERCHANT_PUBLIC/assets/shop-ui.js" && _ma=$((_ma + 1))
    -  _landing_probe "$MERCHANT_PUBLIC/assets/shops.css" && _ma=$((_ma + 1))
    -  # Prefer /intro/ (always on landings tree); root alias is Caddy-only convenience
    -  if _landing_probe "$MERCHANT_PUBLIC/intro/qrcode.min.js" \
    -    || _landing_probe "$MERCHANT_PUBLIC/qrcode.min.js"; then
    -    _ma=$((_ma + 1))
    -  fi
    -fi
    -if [ "$_ma" -eq "$_ma_need" ]; then
    -  ok "landing merchant shop assets" "${_ma_label}"
    -else
    -  if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -    warn "landing merchant shop assets" "${_ma}/${_ma_need} present (soft) · want ${_ma_label}"
    -  else
    -    # stage: hard-ish — missing qrcode broke pay QR («bibliothèque manquante»)
    -    if [ "$_ma" -lt 2 ]; then
    -      fail "landing merchant shop assets" "${_ma}/${_ma_need} · want ${_ma_label}"
    -    elif [ "$_ma" -lt "$_ma_need" ]; then
    -      warn "landing merchant shop assets" "${_ma}/${_ma_need} · want ${_ma_label}"
    -    else
    -      info "landing merchant shop assets" "${_ma}/${_ma_need} · ${_ma_label}"
    -    fi
    -  fi
    -fi
    -
    -# ---------------------------------------------------------------------------
    -# QR payloads — form + encode/decode roundtrip (qrencode + zbarimg)
    -# Collect taler:// + payto:// (+ app-store https from data-qr-url) from landings
    -# and bank mint JSON; re-encode PNG; decode; require exact payload match.
    -# Env: QR_CHECK=0 to skip; QR_ECC=M (default) for qrencode -l
    -# ---------------------------------------------------------------------------
    -if [ "${QR_CHECK:-1}" = "1" ] && [ "${CHECK_LANDING:-1}" = "1" ]; then
    -  set_group qr
    -  section "www · QR payloads (form + encode/decode · taler:// payto://)"
    -
    -  QR_ECC="${QR_ECC:-M}"
    -  qr_dir="$tmp/qr-check"
    -  mkdir -p "$qr_dir"
    -  _qr_tools=1
    -  if ! command -v qrencode >/dev/null 2>&1; then
    -    warn "qr tools" "qrencode missing — form checks only (apt install qrencode)"
    -    _qr_tools=0
    -  fi
    -  if ! command -v zbarimg >/dev/null 2>&1; then
    -    warn "qr tools" "zbarimg missing — form checks only (apt install zbar-tools)"
    -    _qr_tools=0
    -  fi
    -  if [ "$_qr_tools" = "1" ]; then
    -    ok "qr tools" "qrencode + zbarimg"
    -  fi
    -
    -  # Fetch sources (HTML landings + mint JSON when present)
    -  _qr_args=()
    -  for pair in \
    -    "bank-intro|$BANK_PUBLIC/intro/" \
    -    "merchant-intro|$MERCHANT_PUBLIC/intro/" \
    -    "exchange-intro|$EXCHANGE_PUBLIC/intro/"
    -  do
    -    _qn="${pair%%|*}"
    -    _qu="${pair#*|}"
    -    _qf="$qr_dir/${_qn}.html"
    -    _qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000)
    -    if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then
    -      _qr_args+=("$_qn" "$_qf")
    -    fi
    -  done
    -  for pair in \
    -    "demo-withdraw|$BANK_PUBLIC/intro/demo-withdraw.json" \
    -    "auto-account|$BANK_PUBLIC/intro/auto-account.json"
    -  do
    -    _qn="${pair%%|*}"
    -    _qu="${pair#*|}"
    -    _qf="$qr_dir/${_qn}.json"
    -    _qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000)
    -    if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then
    -      _qr_args+=("$_qn" "$_qf")
    -    fi
    -  done
    -
    -  if [ "${#_qr_args[@]}" -eq 0 ]; then
    -    warn "qr sources" "no landing HTML / mint JSON fetched — skip payload checks"
    -  else
    -    _qr_tsv="$qr_dir/payloads.tsv"
    -    if ! python3 "$ROOT/check_qr_payloads.py" "${_qr_args[@]}" >"$_qr_tsv" 2>"$qr_dir/harvest.err"; then
    -      warn "qr harvest" "collector failed · $(tr '\n' ' ' <"$qr_dir/harvest.err" | head -c 160)"
    -    fi
    -
    -    _qr_n=0
    -    _qr_form_ok=0
    -    _qr_form_bad=0
    -    _qr_rt_ok=0
    -    _qr_rt_bad=0
    -    _qr_taler=0
    -    _qr_payto=0
    -    _qr_bad_sample=""
    -    _qr_i=0
    -
    -    while IFS=$'\t' read -r src kind uri status detail || [ -n "${src:-}" ]; do
    -      [ -n "${src:-}" ] || continue
    -      _qr_n=$((_qr_n + 1))
    -      case "$kind" in
    -        withdraw|pay|pay-template|refund|other) _qr_taler=$((_qr_taler + 1)) ;;
    -        payto) _qr_payto=$((_qr_payto + 1)) ;;
    -      esac
    -
    -      if [ "$status" = "ok" ]; then
    -        _qr_form_ok=$((_qr_form_ok + 1))
    -      else
    -        _qr_form_bad=$((_qr_form_bad + 1))
    -        _qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }form/${kind} ${uri:0:56}"
    -        # taler:// and payto:// form errors are hard on local/GOA
    -        case "$kind" in
    -          withdraw|pay|pay-template|payto)
    -            if [ "${LOCAL_STACK:-1}" = "1" ] || [ "${EXPECT_CURRENCY:-}" = "GOA" ]; then
    -              fail "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}"
    -            else
    -              warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}"
    -            fi
    -            ;;
    -          *)
    -            warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}"
    -            ;;
    -        esac
    -        continue
    -      fi
    -
    -      # Encode → PNG → zbarimg (exact match)
    -      if [ "$_qr_tools" = "1" ] && [ -n "$uri" ]; then
    -        _qr_i=$((_qr_i + 1))
    -        _png="$qr_dir/p-${_qr_i}.png"
    -        # ECC: H for wallet-style long URIs, L for long https store links, else QR_ECC
    -        _ecc="$QR_ECC"
    -        case "$kind" in
    -          https) _ecc=L ;;
    -          withdraw|pay|pay-template) _ecc=M ;;
    -          payto) _ecc=M ;;
    -        esac
    -        if ! qrencode -l "$_ecc" -s 4 -m 2 -o "$_png" "$uri" 2>/dev/null; then
    -          _qr_rt_bad=$((_qr_rt_bad + 1))
    -          fail "qr encode ${kind}" "qrencode failed · ${uri:0:72}"
    -          continue
    -        fi
    -        _got=$(zbarimg --raw -q "$_png" 2>/dev/null | head -n1 | tr -d '\r' || true)
    -        if [ "$_got" = "$uri" ]; then
    -          _qr_rt_ok=$((_qr_rt_ok + 1))
    -        else
    -          _qr_rt_bad=$((_qr_rt_bad + 1))
    -          _qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }decode/${kind}"
    -          fail "qr decode ${kind}" "zbar mismatch · want ${uri:0:48}… · got ${_got:0:48}"
    -        fi
    -      fi
    -    done <"$_qr_tsv"
    -
    -    # Download any obvious static QR images from bank intro and decode (soft)
    -    if [ "$_qr_tools" = "1" ] && [ -f "$qr_dir/bank-intro.html" ]; then
    -      python3 - "$qr_dir/bank-intro.html" "$BANK_PUBLIC" "$qr_dir" <<'PY' >"$qr_dir/img-urls.txt" 2>/dev/null || true
    -import re, sys
    -from urllib.parse import urljoin
    -html = open(sys.argv[1], encoding="utf-8", errors="replace").read()
    -base = sys.argv[2].rstrip("/") + "/"
    -out = sys.argv[3]
    -seen = set()
    -for m in re.finditer(r'''(?:src|href)=["']([^"']+\.(?:png|jpe?g|gif|svg))["']''', html, re.I):
    -    u = m.group(1)
    -    if "qr" not in u.lower() and "withdraw" not in u.lower():
    -        continue
    -    if u.startswith("data:"):
    -        continue
    -    absu = urljoin(base + "intro/", u)
    -    if absu in seen:
    -        continue
    -    seen.add(absu)
    -    print(absu)
    -PY
    -      _img_n=0
    -      _img_ok=0
    -      while read -r _img_url; do
    -        [ -n "$_img_url" ] || continue
    -        _img_n=$((_img_n + 1))
    -        _imgf="$qr_dir/img-${_img_n}.bin"
    -        _ic=$(http_body "$_img_url" "$_imgf" 2>/dev/null || echo 000)
    -        if [ "$_ic" != "200" ] || [ ! -s "$_imgf" ]; then
    -          warn "qr image fetch" "HTTP ${_ic} · ${_img_url}"
    -          continue
    -        fi
    -        # zbarimg needs image format; skip non-raster
    -        if ! file "$_imgf" 2>/dev/null | grep -qiE 'PNG|JPEG|PBM|PGM|PPM|GIF'; then
    -          info "qr image skip" "not raster · ${_img_url}"
    -          continue
    -        fi
    -        _dec=$(zbarimg --raw -q "$_imgf" 2>/dev/null | head -n1 | tr -d '\r' || true)
    -        if [ -z "$_dec" ]; then
    -          warn "qr image decode" "no barcode · ${_img_url}"
    -          continue
    -        fi
    -        _img_ok=$((_img_ok + 1))
    -        # validate decoded payload form
    -        printf '%s\n' "$_dec" >"$qr_dir/img-decoded.txt"
    -        _line=$(python3 "$ROOT/check_qr_payloads.py" "img" "$qr_dir/img-decoded.txt" 2>/dev/null | head -1 || true)
    -        if [ -n "$_line" ]; then
    -          _st=$(printf '%s' "$_line" | cut -f4)
    -          _kd=$(printf '%s' "$_line" | cut -f2)
    -          if [ "$_st" = "ok" ]; then
    -            ok "qr image ${_kd}" "${_dec:0:64}"
    -          else
    -            case "$_kd" in
    -              withdraw|pay|pay-template|payto)
    -                fail "qr image ${_kd}" "bad form · ${_dec:0:72}"
    -                ;;
    -              *)
    -                warn "qr image ${_kd}" "bad form · ${_dec:0:72}"
    -                ;;
    -            esac
    -          fi
    -        else
    -          ok "qr image decode" "${_dec:0:64}"
    -        fi
    -      done <"$qr_dir/img-urls.txt"
    -      if [ "$_img_n" -gt 0 ]; then
    -        info "qr images" "fetched ${_img_n} · decoded ${_img_ok}"
    -      fi
    -    fi
    -
    -    # Summaries
    -    if [ "$_qr_n" -eq 0 ]; then
    -      warn "qr payloads" "none found in landings/mint JSON"
    -    else
    -      if [ "$_qr_form_bad" -eq 0 ]; then
    -        ok "qr form" "${_qr_form_ok}/${_qr_n} ok · taler=${_qr_taler} payto=${_qr_payto}"
    -      else
    -        if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -          fail "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad${_qr_bad_sample:+ · $_qr_bad_sample}"
    -        else
    -          warn "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad"
    -        fi
    -      fi
    -      if [ "$_qr_tools" = "1" ]; then
    -        if [ "$_qr_rt_bad" -eq 0 ] && [ "$_qr_rt_ok" -gt 0 ]; then
    -          ok "qr encode/decode" "${_qr_rt_ok} roundtrips (ecc default ${QR_ECC})"
    -        elif [ "$_qr_rt_ok" -eq 0 ] && [ "$_qr_rt_bad" -eq 0 ]; then
    -          info "qr encode/decode" "no payloads to encode"
    -        else
    -          fail "qr encode/decode" "${_qr_rt_ok} ok · ${_qr_rt_bad} fail"
    -        fi
    -      fi
    -    fi
    -  fi
    -else
    -  if [ "${QR_CHECK:-1}" != "1" ]; then
    -    info "qr checks" "skipped (QR_CHECK=0)"
    -  fi
    -fi
    -
    -summary
    diff --git a/scripts/taler-monitoring/check_versions.sh b/scripts/taler-monitoring/check_versions.sh
    deleted file mode 100755
    index 8fc7c66..0000000
    --- a/scripts/taler-monitoring/check_versions.sh
    +++ /dev/null
    @@ -1,563 +0,0 @@
    -#!/usr/bin/env bash
    -# Area versions-### — Taler packages vs deb.taler.net (trixie) + repo availability.
    -#
    -# Checks:
    -#   1) deb.taler.net apt endpoints reachable (InRelease / Packages / sample .deb)
    -#   2) containers can reach deb.taler.net (install path from inside)
    -#   3) installed taler*/libeufin*/libtaler*/libdonau* versions vs suite index
    -#
    -# Env:
    -#   TALER_APT_SUITE=trixie
    -#   TALER_APT_BASE=https://deb.taler.net/apt/debian
    -#   TALER_APT_INDEX=…/dists/trixie/main/binary-amd64/Packages
    -#   TALER_APT_TESTING_INDEX=…/dists/trixie-testing/…
    -#   TALER_PKG_BEHIND=warn|error   (default: core packages ERROR if behind, else warn)
    -#   SKIP_SSH=1                    skip container install checks
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -
    -set_area versions
    -set_group outside
    -
    -SUITE="${TALER_APT_SUITE:-trixie}"
    -APT_BASE="${TALER_APT_BASE:-https://deb.taler.net/apt/debian}"
    -APT_BASE="${APT_BASE%/}"
    -INDEX="${TALER_APT_INDEX:-${APT_BASE}/dists/${SUITE}/main/binary-amd64/Packages}"
    -INRELEASE="${TALER_APT_INRELEASE:-${APT_BASE}/dists/${SUITE}/InRelease}"
    -TESTING_INDEX="${TALER_APT_TESTING_INDEX:-${APT_BASE}/dists/${SUITE}-testing/main/binary-amd64/Packages}"
    -BEHIND_MODE="${TALER_PKG_BEHIND:-warn}"
    -
    -tmp=$(mktemp -d)
    -trap 'rm -rf "$tmp"' EXIT
    -
    -http_get() {
    -  # usage: http_get URL [outfile]  → prints http_code; body to outfile or /dev/null
    -  local url="$1" out="${2:-/dev/null}"
    -  curl -sS --max-time 30 -L -o "$out" -w '%{http_code}' "$url" 2>/dev/null || echo 000
    -}
    -
    -# ---------------------------------------------------------------------------
    -# 1) OUTSIDE — deb.taler.net install source (runner / laptop, no SSH)
    -# ---------------------------------------------------------------------------
    -section "versions · outside · deb.taler.net (${SUITE})"
    -
    -# DNS
    -HOST_APT="${APT_BASE#https://}"; HOST_APT="${HOST_APT#http://}"; HOST_APT="${HOST_APT%%/*}"
    -if getent hosts "$HOST_APT" >/dev/null 2>&1 \
    -  || python3 -c "import socket; socket.getaddrinfo('${HOST_APT}', 443)" >/dev/null 2>&1; then
    -  ip=$(python3 -c "import socket; print(socket.getaddrinfo('${HOST_APT}',443)[0][4][0])" 2>/dev/null || true)
    -  ok "outside DNS ${HOST_APT}" "${ip:-resolved}"
    -else
    -  fail "outside DNS ${HOST_APT}" "unresolvable — cannot use as apt source"
    -fi
    -
    -# HTTPS portal / apt root
    -code=$(http_get "https://${HOST_APT}/")
    -[ "$code" = "200" ] && ok "outside https://${HOST_APT}/" "HTTP $code" \
    -  || warn "outside https://${HOST_APT}/" "HTTP $code"
    -
    -code=$(http_get "${APT_BASE}/")
    -case "$code" in
    -  200|301|302) ok "outside apt base" "${APT_BASE}/ HTTP $code" ;;
    -  *) fail "outside apt base" "${APT_BASE}/ HTTP $code" ;;
    -esac
    -
    -# Suite metadata (what apt update needs)
    -code=$(http_get "$INRELEASE" "$tmp/InRelease")
    -if [ "$code" = "200" ] && [ -s "$tmp/InRelease" ]; then
    -  ok "outside InRelease" "${SUITE} HTTP 200 · $(wc -c <"$tmp/InRelease" | tr -d ' ') bytes"
    -else
    -  fail "outside InRelease" "HTTP $code — $INRELEASE (apt update will fail)"
    -fi
    -
    -RELEASE_URL="${APT_BASE}/dists/${SUITE}/Release"
    -code=$(http_get "$RELEASE_URL" "$tmp/Release")
    -[ "$code" = "200" ] && ok "outside Release" "${SUITE} HTTP 200" \
    -  || warn "outside Release" "HTTP $code — $RELEASE_URL"
    -
    -# Packages (plain) — required for version compare
    -code=$(http_get "$INDEX" "$tmp/Packages")
    -if [ "$code" = "200" ] && [ -s "$tmp/Packages" ] && grep -q '^Package: ' "$tmp/Packages"; then
    -  n_pkg=$(grep -c '^Package: ' "$tmp/Packages" || true)
    -  ok "outside Packages" "${SUITE} HTTP 200 · ${n_pkg} packages"
    -else
    -  fail "outside Packages" "HTTP $code — $INDEX"
    -  # still try more probes, but cannot compare versions without index
    -fi
    -
    -# Packages.gz (apt often prefers this)
    -PKGZ_URL="${APT_BASE}/dists/${SUITE}/main/binary-amd64/Packages.gz"
    -code=$(http_get "$PKGZ_URL" "$tmp/Packages.gz")
    -if [ "$code" = "200" ] && [ -s "$tmp/Packages.gz" ]; then
    -  ok "outside Packages.gz" "${SUITE} HTTP 200 · $(wc -c <"$tmp/Packages.gz" | tr -d ' ') bytes"
    -else
    -  warn "outside Packages.gz" "HTTP $code — apt may still use plain Packages"
    -fi
    -
    -# Sample pool .deb downloadable (proves packages are installable, not just listed)
    -if [ -s "$tmp/Packages" ]; then
    -  DEB_PATH=$(awk '
    -    /^Package: taler-exchange$/ { p=1 }
    -    p && /^Filename: / { sub(/^Filename: /,""); print; exit }
    -    p && /^$/ { p=0 }
    -  ' "$tmp/Packages")
    -  [ -z "$DEB_PATH" ] && DEB_PATH=$(awk '
    -    /^Package: taler-merchant$/ { p=1 }
    -    p && /^Filename: / { sub(/^Filename: /,""); print; exit }
    -    p && /^$/ { p=0 }
    -  ' "$tmp/Packages")
    -  if [ -n "$DEB_PATH" ]; then
    -    DEB_URL="${APT_BASE}/${DEB_PATH}"
    -    code=$(curl -sS --max-time 30 -o /dev/null -w '%{http_code}' -r 0-128 "$DEB_URL" 2>/dev/null || echo 000)
    -    case "$code" in
    -      200|206)
    -        ok "outside pool .deb" "$(basename "$DEB_PATH") HTTP $code"
    -        ;;
    -      *)
    -        fail "outside pool .deb" "HTTP $code — $DEB_URL (index ok but debs not fetchable)"
    -        ;;
    -    esac
    -  else
    -    warn "outside pool .deb" "no Filename for taler-exchange/merchant in index"
    -  fi
    -
    -  for pkg in taler-exchange taler-merchant libeufin-bank; do
    -    ver=$(awk -v p="$pkg" '
    -      $0=="Package: "p { hit=1; next }
    -      hit && /^Version: / { sub(/^Version: /,""); print; exit }
    -      hit && /^$/ { hit=0 }
    -    ' "$tmp/Packages")
    -    if [ -n "$ver" ]; then
    -      ok "outside suite offers ${pkg}" "$ver"
    -    else
    -      fail "outside suite offers ${pkg}" "missing from ${SUITE} Packages"
    -    fi
    -  done
    -fi
    -
    -HAVE_TESTING=0
    -code=$(http_get "$TESTING_INDEX" "$tmp/Packages-testing")
    -if [ "$code" = "200" ] && [ -s "$tmp/Packages-testing" ] && grep -q '^Package: ' "$tmp/Packages-testing"; then
    -  HAVE_TESTING=1
    -  ok "outside ${SUITE}-testing Packages" "HTTP 200 (optional compare)"
    -else
    -  info "outside ${SUITE}-testing Packages" "HTTP ${code:-000} (optional)"
    -fi
    -
    -# TLS: certificate verify (curl default) already used above; explicit openssl probe if available
    -if command -v openssl >/dev/null 2>&1; then
    -  if echo | openssl s_client -servername "$HOST_APT" -connect "${HOST_APT}:443" 2>/dev/null \
    -    | grep -q 'Verify return code: 0'; then
    -    ok "outside TLS ${HOST_APT}" "verify ok"
    -  else
    -    # curl succeeded with default CA — soft warn only
    -    warn "outside TLS ${HOST_APT}" "openssl verify not clean (curl may still work)"
    -  fi
    -fi
    -
    -# Need Packages index for later compare
    -if [ ! -s "$tmp/Packages" ] || ! grep -q '^Package: ' "$tmp/Packages"; then
    -  fail "outside Packages usable" "cannot continue version compare without suite index"
    -  summary
    -  exit 1
    -fi
    -
    -# ---------------------------------------------------------------------------
    -# 2) INSIDE — containers can reach deb.taler.net (pasta / install path)
    -#    host-podman: INSIDE_PODMAN=1 on GOA host · ssh: KOOPA_SSH / INSIDE_SSH from laptop
    -# ---------------------------------------------------------------------------
    -_VERS_LOCAL=0
    -_VERS_SSH_HOST=""
    -_VERS_BANK="taler-hacktivism-bank"
    -_VERS_EX="taler-hacktivism-exchange-ansible"
    -_VERS_MER="taler-hacktivism"
    -if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then
    -  _VERS_LOCAL=1
    -elif [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \
    -  || [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \
    -  || [ -n "${INSIDE_SSH:-}" ]; then
    -  _VERS_SSH_HOST="${INSIDE_SSH:-francpaysan-stage-user}"
    -  _VERS_BANK="${INSIDE_BANK_CTR:-stage-lfp-bank}"
    -  _VERS_EX="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}"
    -  _VERS_MER="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}"
    -elif [ "${LOCAL_STACK:-0}" = "1" ] && [ "${SKIP_SSH:-0}" != "1" ]; then
    -  # laptop → koopa via SSH (not host-podman)
    -  _VERS_SSH_HOST="${KOOPA_SSH}"
    -fi
    -
    -if [ "$_VERS_LOCAL" = "1" ]; then
    -  VERS_ACCESS=host-podman
    -  set_group host
    -else
    -  VERS_ACCESS=ssh
    -  set_group ssh
    -fi
    -
    -section "versions · inside · containers → deb.taler.net (access=${VERS_ACCESS})"
    -info "flags" "VERS_ACCESS=${VERS_ACCESS} INSIDE_PODMAN=${INSIDE_PODMAN:-0} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-0} SKIP_SSH=${SKIP_SSH:-0} KOOPA_SSH=${KOOPA_SSH:-} INSIDE_SSH=${INSIDE_SSH:-}"
    -
    -if [ "$_VERS_LOCAL" != "1" ] && [ -z "$_VERS_SSH_HOST" ]; then
    -  info "access" "no host-podman and no SSH target — outside-only package index"
    -  info "outside-only" "deb.taler.net public checks completed above"
    -  summary
    -  exit 0
    -fi
    -
    -if [ "$_VERS_LOCAL" = "1" ]; then
    -  if ! command -v podman >/dev/null 2>&1; then
    -    err "host" "INSIDE_PODMAN/host-podman but podman missing"
    -    info "outside-only" "deb.taler.net public checks completed above"
    -    summary
    -    exit 1
    -  fi
    -  ok "host→container" "podman exec on this host (VERS_ACCESS=host-podman · no SSH)"
    -elif [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then
    -  if ! koopa_ssh_ok; then
    -    err "ssh" "cannot reach ${KOOPA_SSH} — from laptop set KOOPA_SSH=; on host set INSIDE_PODMAN=1"
    -    info "outside-only" "deb.taler.net public checks completed above"
    -    summary
    -    exit 1
    -  fi
    -  ok "ssh ${KOOPA_SSH}" "remote then podman (VERS_ACCESS=ssh)"
    -else
    -  if ! mon_ssh_ok "$_VERS_SSH_HOST"; then
    -    err "ssh" "cannot reach ${_VERS_SSH_HOST} (stagepaysan) — skip container package compare"
    -    info "outside-only" "deb.taler.net public checks completed above"
    -    summary
    -    exit 1
    -  fi
    -  ok "ssh ${_VERS_SSH_HOST}" "stagepaysan (VERS_ACCESS=ssh)"
    -fi
    -
    -INRELEASE_URL="${APT_BASE}/dists/${SUITE}/InRelease"
    -# Write remote script to a file (avoids bash 3.2 parse bugs with case/;; inside $(…)</dev/null | grep -Fx "$want" | head -1)
    -  if [ -n "$c" ]; then echo "$c"; return; fi
    -  case "$want" in
    -    *exchange*) podman ps --format '{{.Names}}' 2>/dev/null | grep -i exchange | head -1 ;;
    -    *bank*)     podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*bank|hacktivism-bank|taler-bank|lfp-bank' | head -1 ;;
    -    *)
    -      c=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E '^taler-hacktivism$|^stage-lfp-merchant$' | head -1)
    -      if [ -n "$c" ]; then echo "$c"; return; fi
    -      podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'merchant|hacktivism' | grep -viE 'bank|exchange' | head -1
    -      ;;
    -  esac
    -}
    -for want in "$WANT_EX" "$WANT_BANK" "$WANT_MER"; do
    -  c=$(resolve_ctr "$want")
    -  [ -n "$c" ] || continue
    -  role=other
    -  case "$c" in
    -    *exchange*) role=exchange ;;
    -    *bank*)     role=bank ;;
    -    *)          role=merchant ;;
    -  esac
    -  code=$(podman exec "$c" curl -sS -m 12 -o /dev/null -w '%{http_code}' "$INRELEASE_URL" 2>/dev/null || echo 000)
    -  echo "R|${role}|${c}|${code}"
    -  if podman exec "$c" bash -lc 'grep -Rqs deb.taler.net /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null'; then
    -    echo "S|${role}|${c}|yes"
    -  else
    -    echo "S|${role}|${c}|no"
    -  fi
    -  podman exec "$c" dpkg-query -W -f '${Package}\t${Version}\n' 2>/dev/null \
    -    | awk -v role="$role" -v ctr="$c" '
    -        $1 ~ /^(taler-|libeufin-|libtaler|libdonau)/ {
    -          printf "P|%s|%s|%s|%s\n", role, ctr, $1, $2
    -        }'
    -done
    -REMOTE
    -} >"$tmp/remote-versions.sh"
    -
    -if [ "$_VERS_LOCAL" = "1" ]; then
    -  REMOTE_OUT=$(bash "$tmp/remote-versions.sh" 2>/dev/null || true)
    -elif [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then
    -  REMOTE_OUT=$(koopa_ssh_bash 60 <"$tmp/remote-versions.sh" || true)
    -else
    -  REMOTE_OUT=$(mon_ssh_bash "$_VERS_SSH_HOST" 60 <"$tmp/remote-versions.sh" || true)
    -fi
    -
    -printf '%s\n' "$REMOTE_OUT" | grep -E '^R\|' >"$tmp/reach.tsv" || true
    -printf '%s\n' "$REMOTE_OUT" | grep -E '^S\|' >"$tmp/sources.tsv" || true
    -printf '%s\n' "$REMOTE_OUT" | grep -E '^P\|' >"$tmp/installed.tsv" || true
    -
    -if [ -s "$tmp/reach.tsv" ]; then
    -  while IFS='|' read -r _ role ctr code; do
    -    if [ "$code" = "200" ]; then
    -      ok "container ${role} → deb.taler.net" "${ctr} InRelease HTTP $code"
    -    else
    -      fail "container ${role} → deb.taler.net" "${ctr} InRelease HTTP $code — apt install/update will fail"
    -    fi
    -  done <"$tmp/reach.tsv"
    -else
    -  warn "container → deb.taler.net" "no reachability rows"
    -fi
    -
    -if [ -s "$tmp/sources.tsv" ]; then
    -  while IFS='|' read -r _ role ctr has; do
    -    if [ "$has" = "yes" ]; then
    -      ok "container ${role} apt source" "${ctr} lists deb.taler.net"
    -    else
    -      warn "container ${role} apt source" "${ctr} no deb.taler.net in sources.list*"
    -    fi
    -  done <"$tmp/sources.tsv"
    -fi
    -
    -if [ ! -s "$tmp/installed.tsv" ]; then
    -  fail "installed packages" "none found in taler containers"
    -  summary
    -  exit 1
    -fi
    -n_inst=$(wc -l <"$tmp/installed.tsv" | tr -d ' ')
    -ok "collected installed packages" "${n_inst} rows"
    -
    -# ---------------------------------------------------------------------------
    -# 3) compare installed vs trixie (and note testing)
    -# ---------------------------------------------------------------------------
    -set_group compare
    -section "versions · compare installed vs ${SUITE}"
    -
    -export BEHIND_MODE HAVE_TESTING
    -export TALER_APT_SUITE="$SUITE"
    -CORE_PKGS="taler-exchange taler-exchange-database taler-merchant taler-merchant-webui libeufin-bank libeufin-common libtalerexchange libtalermerchant taler-terms-generator"
    -export CORE_PKGS
    -
    -python3 - "$tmp/Packages" "$tmp/Packages-testing" "$tmp/installed.tsv" <<'PY' >"$tmp/py.out"
    -import sys, os
    -from collections import defaultdict
    -
    -main_idx, test_idx, inst_path = sys.argv[1:4]
    -have_testing = os.environ.get("HAVE_TESTING", "0") == "1"
    -core = set(os.environ.get("CORE_PKGS", "").split())
    -suite = os.environ.get("TALER_APT_SUITE", "trixie")
    -
    -def _ord_ch(c):
    -    if c == "~":
    -        return -1
    -    if c.isdigit():
    -        return 0
    -    if c.isalpha():
    -        return ord(c)
    -    return ord(c) + 256
    -
    -def _split_ver(v):
    -    epoch = 0
    -    if ":" in v:
    -        e, v = v.split(":", 1)
    -        try:
    -            epoch = int(e)
    -        except ValueError:
    -            epoch = 0
    -    if "-" in v:
    -        upstream, deb = v.rsplit("-", 1)
    -    else:
    -        upstream, deb = v, ""
    -    return epoch, upstream, deb
    -
    -def _cmp_part(a, b):
    -    ia = ib = 0
    -    while ia < len(a) or ib < len(b):
    -        while True:
    -            ca = a[ia] if ia < len(a) and not a[ia].isdigit() else ""
    -            cb = b[ib] if ib < len(b) and not b[ib].isdigit() else ""
    -            if ca == "" and cb == "":
    -                break
    -            if ca == "" and cb:
    -                return -1 if _ord_ch(cb) > 0 else (1 if cb == "~" else -1)
    -            if cb == "" and ca:
    -                return 1 if _ord_ch(ca) > 0 else (-1 if ca == "~" else 1)
    -            oa, ob = _ord_ch(ca), _ord_ch(cb)
    -            ia += 1
    -            ib += 1
    -            if oa != ob:
    -                return (oa > ob) - (oa < ob)
    -        sa = sb = ""
    -        while ia < len(a) and a[ia].isdigit():
    -            sa += a[ia]; ia += 1
    -        while ib < len(b) and b[ib].isdigit():
    -            sb += b[ib]; ib += 1
    -        na = int(sa) if sa else 0
    -        nb = int(sb) if sb else 0
    -        if na != nb:
    -            return (na > nb) - (na < nb)
    -    return 0
    -
    -def deb_cmp(a, b):
    -    if a == b:
    -        return 0
    -    ea, ua, da = _split_ver(a)
    -    eb, ub, db = _split_ver(b)
    -    if ea != eb:
    -        return (ea > eb) - (ea < eb)
    -    c = _cmp_part(ua, ub)
    -    if c:
    -        return c
    -    return _cmp_part(da, db)
    -
    -def parse_packages(path):
    -    pkgs = {}
    -    if not path or not os.path.isfile(path) or os.path.getsize(path) == 0:
    -        return pkgs
    -    cur = ver = None
    -    with open(path, encoding="utf-8", errors="replace") as f:
    -        for line in f:
    -            line = line.rstrip("\n")
    -            if line.startswith("Package: "):
    -                if cur and ver and (cur not in pkgs or deb_cmp(ver, pkgs[cur]) > 0):
    -                    pkgs[cur] = ver
    -                cur = line[9:].strip()
    -                ver = None
    -            elif line.startswith("Version: ") and cur:
    -                ver = line[9:].strip()
    -            elif line == "" and cur:
    -                if ver and (cur not in pkgs or deb_cmp(ver, pkgs[cur]) > 0):
    -                    pkgs[cur] = ver
    -                cur = ver = None
    -        if cur and ver and (cur not in pkgs or deb_cmp(ver, pkgs[cur]) > 0):
    -            pkgs[cur] = ver
    -    return pkgs
    -
    -main = parse_packages(main_idx)
    -testing = parse_packages(test_idx) if have_testing else {}
    -
    -by_pkg = defaultdict(list)
    -with open(inst_path, encoding="utf-8") as f:
    -    for line in f:
    -        line = line.strip()
    -        if not line.startswith("P|"):
    -            continue
    -        parts = line.split("|")
    -        if len(parts) < 5:
    -            continue
    -        _, role, ctr, pkg, ver = parts[:5]
    -        by_pkg[pkg].append((role, ctr, ver))
    -
    -for pkg in sorted(by_pkg.keys()):
    -    versions = sorted({v for _, _, v in by_pkg[pkg]}, key=lambda v: v)
    -    # pick "highest" via deb_cmp
    -    inst = versions[0]
    -    for v in versions[1:]:
    -        if deb_cmp(v, inst) > 0:
    -            inst = v
    -    roles = ",".join(sorted({r for r, _, _ in by_pkg[pkg]}))
    -    multi = len(set(versions)) > 1
    -    trixie = main.get(pkg)
    -    testv = testing.get(pkg)
    -    status = "ok"
    -    detail = ""
    -    if multi:
    -        status = "warn"
    -        detail = "multiple installed: " + ", ".join(sorted(set(versions)))
    -    if trixie is None:
    -        if status == "ok":
    -            status = "info"
    -        detail = (detail + "; " if detail else "") + f"not in {suite} main index"
    -    else:
    -        c = deb_cmp(inst, trixie)
    -        if c == 0:
    -            detail = (detail + "; " if detail else "") + f"= {suite} {trixie}"
    -        elif c < 0:
    -            status = "behind"
    -            detail = f"installed {inst} < {suite} {trixie}"
    -        else:
    -            status = "ahead"
    -            detail = f"installed {inst} > {suite} {trixie}"
    -            if testv and deb_cmp(inst, testv) == 0:
    -                detail += f" (={suite}-testing)"
    -            elif testv:
    -                detail += f" (testing has {testv})"
    -    core_flag = "1" if pkg in core else "0"
    -    print(f"{status}|{pkg}|{inst}|{trixie or '-'}|{roles}|{core_flag}|{detail}")
    -PY
    -
    -ok_n=0; ahead_n=0; behind_n=0
    -while IFS='|' read -r status pkg inst trixie roles core detail; do
    -  [ -n "${status:-}" ] || continue
    -  label="pkg ${pkg} (${roles})"
    -  case "$status" in
    -    ok)
    -      ok "$label" "${inst}"
    -      ok_n=$((ok_n + 1))
    -      ;;
    -    ahead)
    -      info "$label" "$detail"
    -      ahead_n=$((ahead_n + 1))
    -      ;;
    -    behind)
    -      behind_n=$((behind_n + 1))
    -      if [ "$core" = "1" ] || [ "$BEHIND_MODE" = "error" ]; then
    -        fail "$label" "$detail"
    -      else
    -        warn "$label" "$detail"
    -      fi
    -      ;;
    -    warn)
    -      warn "$label" "$detail"
    -      ;;
    -    info)
    -      info "$label" "installed ${inst}${detail:+ — $detail}"
    -      ;;
    -    *)
    -      info "$label" "$status $detail"
    -      ;;
    -  esac
    -done <"$tmp/py.out"
    -
    -# Core packages must exist somewhere
    -for need in taler-exchange libeufin-bank taler-merchant; do
    -  if grep -qE "^P\|[^|]+\|[^|]+\|${need}\|" "$tmp/installed.tsv"; then
    -    ok "core installed ${need}"
    -  else
    -    fail "core installed ${need}" "not present in any taler container"
    -  fi
    -done
    -
    -info "suite" "deb.taler.net ${SUITE} (testing_index=${HAVE_TESTING})"
    -info "tally" "match=${ok_n} ahead=${ahead_n} behind=${behind_n}"
    -
    -# Disk free on the machine(s) we just probed (host-podman or SSH target)
    -set_group disk
    -section "versions · disk free space"
    -if [ "${_VERS_LOCAL:-0}" = "1" ]; then
    -  mon_disk_check_host "host" || true
    -  while read -r _vc; do
    -    [ -n "$_vc" ] || continue
    -    mon_disk_check_podman "$_vc" || true
    -  done < <(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism|taler-|stage-lfp|lfp-' || true)
    -elif [ -n "${_VERS_SSH_HOST:-}" ]; then
    -  _vdisk=$(mon_ssh_bash "$_VERS_SSH_HOST" 20 <<'DISK' || true
    -set +e
    -echo "###HOST###"
    -df -Pk / /var /home /tmp 2>/dev/null || df -Pk
    -echo "###CTRS###"
    -for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do
    -  echo "###CTR $c###"
    -  podman exec "$c" df -Pk / /var /tmp 2>/dev/null || true
    -done
    -DISK
    -)
    -  mon_disk_check_remote_text "ssh:${_VERS_SSH_HOST}" \
    -    "$(printf '%s\n' "$_vdisk" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d')" || true
    -  _ctr=""; _buf=""
    -  while IFS= read -r _line || [ -n "$_line" ]; do
    -    case "$_line" in
    -      '###CTR '*)
    -        [ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true
    -        _ctr=${_line####CTR }; _ctr=${_ctr%###}; _buf=""
    -        ;;
    -      '###CTRS###'|'###HOST###') ;;
    -      *) [ -n "$_ctr" ] && _buf="${_buf}${_line}"$'\n' ;;
    -    esac
    -  done <<<"$_vdisk"
    -  [ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true
    -fi
    -
    -summary
    diff --git a/scripts/taler-monitoring/domains.conf b/scripts/taler-monitoring/domains.conf
    deleted file mode 100644
    index 1cc12af..0000000
    --- a/scripts/taler-monitoring/domains.conf
    +++ /dev/null
    @@ -1,46 +0,0 @@
    -# taler-monitoring domain profiles
    -#
    -# When you add a stack, declare the three public endpoints here:
    -#   name   bank   exchange   merchant-backend   currency   local   landing   [canonical]
    -#
    -# - name: what -d / TALER_DOMAIN matches (aliases allowed as extra lines)
    -# - bank / exchange / merchant: hostname or full https:// URL
    -# - currency: GOA | KUDOS | CHF | … (empty = report only)
    -# - local: 1 = koopa SSH/inside/e2e stack; 0 = public-only
    -# - landing: 1 = check /intro landings + assets; 0 = skip (no GOA-style landings)
    -# - canonical: optional TALER_DOMAIN label after alias match
    -#
    -# CLI overrides after load: --bank --exchange --merchant --currency
    -# Alternate file: TALER_DOMAINS_CONF=/path/to/domains.conf
    -
    -# Local GOA (koopa) — landings matter
    -koopa              bank.hacktivism.ch   exchange.hacktivism.ch   taler.hacktivism.ch      GOA    1  1  hacktivism.ch
    -hacktivism         bank.hacktivism.ch   exchange.hacktivism.ch   taler.hacktivism.ch      GOA    1  1  hacktivism.ch
    -hacktivism.ch      bank.hacktivism.ch   exchange.hacktivism.ch   taler.hacktivism.ch      GOA    1  1
    -
    -# Public demo (KUDOS) — has intro-style pages; keep soft landing checks
    -taler.net          bank.demo.taler.net  exchange.demo.taler.net  backend.demo.taler.net    KUDOS  0  1  demo.taler.net
    -demo.taler.net     bank.demo.taler.net  exchange.demo.taler.net  backend.demo.taler.net    KUDOS  0  1
    -
    -# Public test stack (TESTKUDOS) — no GOA-style landings
    -test.taler.net     bank.test.taler.net  exchange.test.taler.net  backend.test.taler.net    TESTKUDOS  0  0
    -
    -# TOPS CHF — multi-tenant merchant my.taler-ops.ch; no public GOA landings
    -taler-ops.ch       bank.taler-ops.ch    exchange.taler-ops.ch    my.taler-ops.ch           CHF    0  0
    -my.taler-ops.ch    bank.taler-ops.ch    exchange.taler-ops.ch    my.taler-ops.ch           CHF    0  0  taler-ops.ch
    -stage.taler-ops.ch bank.stage.taler-ops.ch exchange.stage.taler-ops.ch my.stage.taler-ops.ch CHF 0  0
    -my.stage.taler-ops.ch bank.stage.taler-ops.ch exchange.stage.taler-ops.ch my.stage.taler-ops.ch CHF 0 0 stage.taler-ops.ch
    -
    -# Franc Paysan (Infomaniak) — merchant host is monnaie.* (not taler./backend.)
    -# Prod: no monetary launch yet; stage uses TESTPAYSAN (public HTTPS landings + shops)
    -lefrancpaysan.ch   bank.lefrancpaysan.ch  exchange.lefrancpaysan.ch  monnaie.lefrancpaysan.ch  -  0  0
    -monnaie.lefrancpaysan.ch bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch - 0 0 lefrancpaysan.ch
    -bank.lefrancpaysan.ch bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch - 0 0 lefrancpaysan.ch
    -exchange.lefrancpaysan.ch bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch - 0 0 lefrancpaysan.ch
    -
    -# Stage TESTPAYSAN — landings on / + farmer shops under stage.monnaie…/shops/
    -# name bank exchange merchant currency local landing [canonical]
    -stage.lefrancpaysan.ch          stage.bank.lefrancpaysan.ch  stage.exchange.lefrancpaysan.ch  stage.monnaie.lefrancpaysan.ch  TESTPAYSAN  0  1
    -stage.bank.lefrancpaysan.ch     stage.bank.lefrancpaysan.ch  stage.exchange.lefrancpaysan.ch  stage.monnaie.lefrancpaysan.ch  TESTPAYSAN  0  1  stage.lefrancpaysan.ch
    -stage.exchange.lefrancpaysan.ch stage.bank.lefrancpaysan.ch  stage.exchange.lefrancpaysan.ch  stage.monnaie.lefrancpaysan.ch  TESTPAYSAN  0  1  stage.lefrancpaysan.ch
    -stage.monnaie.lefrancpaysan.ch  stage.bank.lefrancpaysan.ch  stage.exchange.lefrancpaysan.ch  stage.monnaie.lefrancpaysan.ch  TESTPAYSAN  0  1  stage.lefrancpaysan.ch
    diff --git a/scripts/taler-monitoring/harness-live.txt b/scripts/taler-monitoring/harness-live.txt
    deleted file mode 100644
    index b7d5500..0000000
    --- a/scripts/taler-monitoring/harness-live.txt
    +++ /dev/null
    @@ -1,7 +0,0 @@
    -# Optional: taler-harness against *our* exchange (needs a harness version
    -# that accepts current /config JSON). Older installs often FAIL codec checks
    -# even when the exchange is fine — trust `./taler-monitoring.sh urls` first.
    -#
    -# idcommand
    -
    -lint-exchange-goa	taler-harness deployment lint-exchange-url https://exchange.hacktivism.ch/
    diff --git a/scripts/taler-monitoring/harness-tests.txt b/scripts/taler-monitoring/harness-tests.txt
    deleted file mode 100644
    index 1c63e3b..0000000
    --- a/scripts/taler-monitoring/harness-tests.txt
    +++ /dev/null
    @@ -1,41 +0,0 @@
    -# Real-life–relevant taler-harness integration tests
    -# (from: taler-harness list-integrationtests)
    -#
    -# Criteria: everyday user/merchant/bank paths — withdraw, pay, deposit, refund,
    -# libeufin bank, templates/paywall. Excludes KYC edge cases, experimental,
    -# timetravel, perf, backup, mailbox, most fault-injection.
    -#
    -# These spin up a local TESTKUDOS stack (same client code paths as production).
    -# For tests against *public* demos, see harness-live.txt / `./taler-monitoring.sh live`.
    -
    -# --- Core path: get money + spend it (the GOA story) ---
    -simple-payment
    -withdrawal-bank-integrated
    -payment
    -
    -# --- Withdraw variants users actually hit ---
    -withdrawal-manual
    -withdrawal-external
    -withdrawal-idempotent
    -
    -# --- Pay variants shops use ---
    -payment-template
    -paywall-flow
    -payment-idempotency
    -payment-zero
    -payment-abort
    -otp
    -
    -# --- After pay: coins / merchant ops ---
    -deposit
    -refund
    -refund-auto
    -wallet-refresh
    -
    -# --- Bank stack we run (libeufin / regional) ---
    -libeufin-bank
    -bank-api
    -
    -# --- Client hygiene ---
    -wallet-config
    -term-of-service-format
    diff --git a/scripts/taler-monitoring/host-agent/README.md b/scripts/taler-monitoring/host-agent/README.md
    deleted file mode 100644
    index 3acfe59..0000000
    --- a/scripts/taler-monitoring/host-agent/README.md
    +++ /dev/null
    @@ -1,183 +0,0 @@
    -# host-agent — reporting generation (GOA + FP + any stack)
    -
    -**Global pipeline** lives in **`run-host-report.sh`** (all stacks share it):
    -
    -| Behaviour | Default |
    -|-----------|---------|
    -| Suite refresh | `update-suite.sh` → `origin/main` |
    -| Wall clock | `RUN_TIMEOUT=600` |
    -| Log | line-buffered `run-*.log` (continuous write) |
    -| HTML | always (first run / empty tree too) + commit URL |
    -| Timeout notice | end of log + top jump on `/monitoring_err/` |
    -
    -Thin wrappers only set stack defaults:
    -
    -| Wrapper | Stack | Typical user |
    -|---------|-------|----------------|
    -| `run-hacktivism-monitoring.sh` | GOA `hacktivism.ch` | `hernani` @ koopa |
    -| `run-fp-stage-monitoring.sh` | FP stage | `stagepaysan` |
    -| `run-fp-prod-monitoring.sh` | FP prod | `francpaysan` |
    -| `run-aptdeploy.sh` | CLI: ensure + **aptdeploy** (no HTML) | local if hostname=koopa, else **ssh koopa-external** |
    -| `run-aptdeploy-monitoring.sh` | **aptdeploy** → `/taler-monitoring-aptdeploy*` | `hernani` @ koopa (4h timer, taler only) |
    -| `run-surface-monitoring.sh` | **surface** → `/taler-monitoring-surface*` | `hernani` @ koopa (hourly, taler only) |
    -
    -### apt-src deploy tests (hacktivism `/monitoring`)
    -
    -| Container | Mode |
    -|-----------|------|
    -| `koopa-taler-deploy-test-apt-src-trixie` | **fresh**: recreate when repo package fingerprint changes |
    -| `koopa-taler-deploy-test-apt-src-trixie-testing` | **fresh** for `trixie-testing` |
    -| `koopa-taler-deploy-test-apt-src-trixie-upgrade` | **upgrade**: initial install, then mytops-style `apt install` + `upgrade` |
    -| `koopa-taler-deploy-test-apt-src-trixie-testing-upgrade` | **upgrade** for testing suite |
    -
    -```bash
    -# from laptop / clementine → triggers on koopa-external
    -./scripts/taler-monitoring/host-agent/run-aptdeploy.sh
    -./scripts/taler-monitoring/host-agent/run-aptdeploy.sh upgrade
    -./scripts/taler-monitoring/host-agent/run-aptdeploy.sh fresh
    -./scripts/taler-monitoring/host-agent/run-aptdeploy.sh check-only
    -
    -# on koopa
    -./taler-monitoring.sh -d hacktivism.ch aptdeploy
    -```
    -
    -Checks: package table, `httpd --version`, libtalerutil/`ldd` on failure, `systemctl` for
    -`taler-merchant.target`, basic local `/config` probe. No nginx/HTTPS in containers.
    -
    -### surface + aptdeploy HTML (taler.hacktivism.ch only)
    -
    -| URL | Role | Timer |
    -|-----|------|-------|
    -| `…/taler-monitoring-surface/` | ecosystem surface scan | 1h |
    -| `…/taler-monitoring-surface_err/` | errors + jump list | ″ |
    -| `…/taler-monitoring-aptdeploy/` | apt-src container deploy tests | 4h |
    -| `…/taler-monitoring-aptdeploy_err/` | errors + jump list | ″ |
    -
    -Not on bank/exchange. Staging:
    -`~/monitoring-sites-staging/taler.hacktivism.ch/taler-monitoring-{surface,aptdeploy}*`.
    -
    -```bash
    -systemctl --user enable --now taler-monitoring-surface.timer
    -systemctl --user enable --now taler-monitoring-aptdeploy.timer
    -systemctl --user start taler-monitoring-surface.service
    -systemctl --user start taler-monitoring-aptdeploy.service
    -# Caddy: handles only in taler.hacktivism.ch block (root)
    -```
    -
    -Env examples: **`env/*.env.example`**. FP docs: `francpaysan-admin-log/docs/taler-monitoring-host-agents.md`.
    -
    -## GOA (koopa) — host-podman
    -
    -Runs **as user `hernani`** (systemd user, linger). **Primary** mode:
    -
    -- **`INSIDE_PODMAN=1`** / **`INSIDE_MODE=local-podman`**: access mode **`host-podman`** —  
    -  `podman exec` on this host into  
    -  `taler-hacktivism-bank` / `taler-hacktivism-exchange-ansible` / `taler-hacktivism`  
    -  (IDs `inside.host-*`, not `inside.ssh-*`).  
    -  From a **laptop**, leave `INSIDE_PODMAN` off → access mode **`ssh`** via `KOOPA_SSH`  
    -  (`inside.ssh-*`).
    -- Public **`urls` + `versions`** for the stack (`-d hacktivism.ch`).
    -- Writes console HTML for all three sites under  
    -  `~/monitoring-sites-staging/{bank,exchange,taler}.hacktivism.ch/`.
    -
    -Caddy (host root) can serve that tree at `/monitoring` — you configure Caddy.
    -
    -## Latest software + config isolation
    -
    -**Source of truth is always Forgejo** (`git.hacktivism.ch`), not a laptop rsync copy.
    -
    -Every host-agent / aptdeploy run:
    -
    -1. **`update-suite.sh`**: `git fetch` + **`SUITE_UPDATE_MODE=reset`** (default) → `origin/main`  
    -   into `SUITE_DIR` (prefer `~/src/koopa-admin-log`).
    -2. **Re-exec** host-agent scripts from that tree so the running code matches `COMMIT_URL`.
    -3. Local overrides only in **`~/.config/taler-monitoring/env`** — never overwritten by git.
    -4. HTML footer = exact commit:  
    -   `https://git.hacktivism.ch/hernani/koopa-admin-log/src/commit/`
    -5. **`RUN_TIMEOUT=600`**, line-buffered logs, HTML always written.
    -
    -If fetch fails → previous tree + WARN.  
    -**To ship features (aptdeploy, surface, …): push to `origin/main`.** Local-only edits vanish on the next reset.
    -
    -If pull fails (no network / auth), the previous tree is used and a WARN is logged.
    -
    -## Install (on koopa as hernani)
    -
    -```bash
    -# once: local config (optional)
    -mkdir -p ~/.config/taler-monitoring
    -cp ~/koopa-admin-log/scripts/taler-monitoring/host-agent/env.example \
    -   ~/.config/taler-monitoring/env
    -
    -# laptop:
    -./scripts/taler-monitoring/host-agent/install-host-agent.sh --remote koopa-external
    -
    -# or on koopa:
    -~/koopa-admin-log/scripts/taler-monitoring/host-agent/install-host-agent.sh
    -```
    -
    -Units:
    -
    -| Unit | Role |
    -|------|------|
    -| `taler-monitoring-hacktivism.path` | fires on software stamp + landing/caddy config changes |
    -| `taler-monitoring-hacktivism.timer` | every **4h** fallback |
    -| `taler-monitoring-hacktivism.service` | oneshot → `/monitoring*` (urls inside versions) |
    -| `taler-monitoring-surface.timer` | every **1h** |
    -| `taler-monitoring-surface.service` | → `/taler-monitoring-surface*` (taler only) |
    -| `taler-monitoring-aptdeploy.timer` | every **4h** |
    -| `taler-monitoring-aptdeploy.service` | → `/taler-monitoring-aptdeploy*` (taler only) |
    -
    -## After package / image / landing upgrades
    -
    -```bash
    -~/koopa-admin-log/scripts/taler-monitoring/host-agent/touch-software-stamp.sh
    -# → path unit starts the service
    -```
    -
    -## Manual
    -
    -```bash
    -systemctl --user start taler-monitoring-hacktivism.service
    -systemctl --user status taler-monitoring-hacktivism.service --no-pager
    -journalctl --user -u taler-monitoring-hacktivism.service -n 50 --no-pager
    -ls ~/monitoring-sites-staging/bank.hacktivism.ch/monitoring_err/
    -```
    -
    -## Relation to firecuda outside-only timer
    -
    -| | host-agent (koopa) | firecuda site-gen |
    -|--|--------------------|-------------------|
    -| Access | **podman on host** | public HTTPS only |
    -| Phases | urls **inside** versions | urls versions |
    -| Trigger | software path + 4h | *could* launchd 4h |
    -| Primary for GOA | **yes** | **off** (optional only) |
    -
    -**firecuda-external is not scheduled** right now (launchd disabled). Scripts under
    -`site-gen/` can still one-shot a public run if needed; see `site-gen/README.md`.
    -
    -No secrets required for default phases. `secrets.env` optional for e2e later.
    -
    -## Git commit on HTML pages
    -
    -Header of each generated page includes a link like:
    -
    -`https://git.hacktivism.ch/hernani/koopa-admin-log/src/commit/`
    -
    -That SHA is the tree **after** `update-suite.sh` for that run.
    -
    -## Packages on koopa
    -
    -See **[../DEPENDENCIES.md](../DEPENDENCIES.md)**.
    -
    -**Default host-agent needs no new packages** if curl/python3/podman/git are present (they are).
    -
    -**Root once** only for full QR checks in `urls`:
    -
    -```bash
    -zypper in qrencode zbar
    -```
    -
    -Without them, monitoring still runs; QR group WARNs and skips encode/decode.
    -
    -Caddy `/var/www/monitoring-sites` is separate (you configured hernani write access).
    diff --git a/scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md b/scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md
    deleted file mode 100644
    index 17dd386..0000000
    --- a/scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md
    +++ /dev/null
    @@ -1,57 +0,0 @@
    -# Root: make monitoring pages live on koopa (GOA)
    -
    -## Symptom if not applied
    -
    -Public URLs return **merchant/bank JSON** like:
    -
    -```json
    -{ "code": 21, "hint": "There is no endpoint defined for the URL provided..." }
    -```
    -
    -or a **404 HTML** page from the app reverse_proxy.
    -
    -Cause: live `/etc/caddy/Caddyfile` has **no** `handle /monitoring*` (falls through to
    -API). Prepared config is only under **`/home/hernani/koopa-caddy/Caddyfile`**.
    -
    -## One-shot as root (recommended)
    -
    -```bash
    -sudo /home/hernani/koopa-caddy/apply-monitoring-live.sh
    -```
    -
    -That script:
    -
    -1. `rsync` staging HTML → `/var/www/monitoring-sites/` (incl. surface + aptdeploy)
    -2. Makes tree writable for `hernani` (group `caddy` + ACL) so later runs self-sync
    -3. Installs prepared Caddyfile → `/etc/caddy/Caddyfile`, validates, reloads Caddy
    -4. Smoke-curls all monitoring URLs (must **not** be JSON `code:21`)
    -
    -## Paths
    -
    -| Path | Host | Source timer |
    -|------|------|----------------|
    -| `/monitoring/` + `/monitoring_err/` | bank + exchange + taler | `taler-monitoring-hacktivism.timer` (4h) |
    -| `/taler-monitoring-surface/` + `/_err/` | **taler.hacktivism.ch only** | `taler-monitoring-surface.timer` (1h) |
    -| `/taler-monitoring-aptdeploy/` + `/_err/` | **taler.hacktivism.ch only** | `taler-monitoring-aptdeploy.timer` (4h) |
    -
    -HTML is generated as **hernani** into  
    -`/home/hernani/monitoring-sites-staging/`.
    -
    -## Manual equivalent
    -
    -```bash
    -rsync -a --delete /home/hernani/monitoring-sites-staging/ /var/www/monitoring-sites/
    -chown -R hernani:caddy /var/www/monitoring-sites && chmod -R g+rwX /var/www/monitoring-sites
    -sudo /home/hernani/koopa-caddy/apply.sh   # or apply-monitoring-live.sh
    -```
    -
    -Expect **200** (HTML with sticky bar). Not merchant JSON `code:21`.
    -
    -## After each monitoring run (if www is not hernani-writable)
    -
    -```bash
    -rsync -a --delete /home/hernani/monitoring-sites-staging/ /var/www/monitoring-sites/
    -chown -R caddy:caddy /var/www/monitoring-sites
    -```
    -
    -Or grant `hernani` write on `/var/www/monitoring-sites` once so the host-agent can rsync itself.
    diff --git a/scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh b/scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh
    deleted file mode 100755
    index 75fa3e4..0000000
    --- a/scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh
    +++ /dev/null
    @@ -1,373 +0,0 @@
    -#!/usr/bin/env bash
    -# ensure-apt-deploy-test-containers.sh
    -#
    -# Four rootless podman containers on koopa (no nginx / no HTTPS):
    -#
    -#   Fresh rebuild when repo fingerprint changes:
    -#     koopa-taler-deploy-test-apt-src-trixie
    -#     koopa-taler-deploy-test-apt-src-trixie-testing
    -#
    -#   Persistent upgrade path (mytops upgrade.sh style):
    -#     koopa-taler-deploy-test-apt-src-trixie-upgrade
    -#     koopa-taler-deploy-test-apt-src-trixie-testing-upgrade
    -#
    -# Run as hernani on koopa. From elsewhere use run-aptdeploy.sh (ssh koopa-external).
    -#
    -set -euo pipefail
    -
    -PODMAN_BIN="${APT_DEPLOY_PODMAN:-podman}"
    -IMG="${APT_DEPLOY_BASE_IMAGE:-docker.io/library/debian:trixie}"
    -STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/taler-apt-deploy"
    -URI_BASE="${APT_DEPLOY_URI:-https://deb.taler.net/apt/debian}"
    -mkdir -p "$STATE_DIR"
    -
    -# Packages we track for "new version → rebuild fresh containers"
    -TRACK_PKGS="${APT_DEPLOY_TRACK_PKGS:-taler-merchant libtalermerchant libtalerexchange libdonau}"
    -
    -# Canonical four containers (keep). Everything else matching legacy names is removed.
    -CANONICAL_CONTAINERS=(
    -  koopa-taler-deploy-test-apt-src-trixie
    -  koopa-taler-deploy-test-apt-src-trixie-testing
    -  koopa-taler-deploy-test-apt-src-trixie-upgrade
    -  koopa-taler-deploy-test-apt-src-trixie-testing-upgrade
    -)
    -
    -# Legacy / typo names from earlier iterations — always delete if present
    -LEGACY_CONTAINERS=(
    -  koopa-taler-build-test-apt-trixie
    -  koopa-taler-build-test-apt-trixie-testing
    -  koopa-taler-deploy-test-apt-trixie
    -  koopa-taler-deploy-test-apt-trixie-testing
    -  koopa-taler-buidl-test-apt-trixie
    -  koopa-taler-buidl-test-apt-trixie-testing
    -)
    -
    -cleanup_legacy() {
    -  local n img
    -  echo "======== cleanup legacy containers / images ========"
    -  for n in "${LEGACY_CONTAINERS[@]}"; do
    -    if "$PODMAN_BIN" container exists "$n" 2>/dev/null; then
    -      echo "remove legacy container $n"
    -      "$PODMAN_BIN" rm -f "$n" 2>/dev/null || true
    -    fi
    -    # old setup images for that name
    -    if "$PODMAN_BIN" image exists "localhost/${n}:setup" 2>/dev/null; then
    -      echo "remove legacy image localhost/${n}:setup"
    -      "$PODMAN_BIN" rmi -f "localhost/${n}:setup" 2>/dev/null || true
    -    fi
    -  done
    -  # dangling setup images not in canonical set
    -  while read -r img; do
    -    [ -n "$img" ] || continue
    -    local base keep=0
    -    base=${img#localhost/}
    -    base=${base%:setup}
    -    for n in "${CANONICAL_CONTAINERS[@]}"; do
    -      [ "$base" = "$n" ] && keep=1 && break
    -    done
    -    if [ "$keep" -eq 0 ] && [[ "$img" == localhost/koopa-taler-*-test-apt* ]]; then
    -      echo "remove non-canonical image $img"
    -      "$PODMAN_BIN" rmi -f "$img" 2>/dev/null || true
    -    fi
    -  done < <("$PODMAN_BIN" images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -E 'localhost/koopa-taler-.*-test-apt' || true)
    -}
    -
    -# Prefer single suite tree: if both ~/src/koopa-admin-log and ~/koopa-admin-log are
    -# full git clones, keep the newer (by HEAD commit time) and replace the older with a symlink.
    -dedupe_suite_repos() {
    -  local a="$HOME/src/koopa-admin-log" b="$HOME/koopa-admin-log"
    -  local ta tb
    -  [ "${APT_DEPLOY_DEDUPE_REPOS:-1}" = "1" ] || return 0
    -  if [ -d "$a/.git" ] && [ -d "$b/.git" ] && [ ! -L "$a" ] && [ ! -L "$b" ]; then
    -    ta=$(git -C "$a" log -1 --format=%ct 2>/dev/null || echo 0)
    -    tb=$(git -C "$b" log -1 --format=%ct 2>/dev/null || echo 0)
    -    echo "======== dedupe suite repos (both are git clones) ========"
    -    echo "  $a HEAD_ts=$ta"
    -    echo "  $b HEAD_ts=$tb"
    -    if [ "$ta" -ge "$tb" ]; then
    -      echo "keep $a · remove $b · symlink $b → $a"
    -      rm -rf "$b"
    -      ln -sfn "$a" "$b"
    -    else
    -      echo "keep $b · remove $a · symlink $a → $b"
    -      rm -rf "$a"
    -      mkdir -p "$(dirname "$a")"
    -      ln -sfn "$b" "$a"
    -    fi
    -  elif [ -d "$a/scripts/taler-monitoring" ] && [ -d "$b/scripts/taler-monitoring" ] \
    -    && [ ! -d "$a/.git" ] && [ -d "$b/.git" ] && [ ! -L "$a" ]; then
    -    echo "remove non-git tree $a · symlink → $b"
    -    rm -rf "$a"
    -    mkdir -p "$(dirname "$a")"
    -    ln -sfn "$b" "$a"
    -  elif [ -d "$b/scripts/taler-monitoring" ] && [ -d "$a/scripts/taler-monitoring" ] \
    -    && [ ! -d "$b/.git" ] && [ -d "$a/.git" ] && [ ! -L "$b" ]; then
    -    echo "remove non-git tree $b · symlink → $a"
    -    rm -rf "$b"
    -    ln -sfn "$a" "$b"
    -  fi
    -}
    -
    -cleanup_legacy
    -dedupe_suite_repos
    -# ---------------------------------------------------------------------------
    -# Repo fingerprint: Version: lines from suite Packages index
    -# ---------------------------------------------------------------------------
    -repo_pkg_version() {
    -  local suite=$1 pkg=$2
    -  curl -fsS --max-time 45 \
    -    "${URI_BASE}/dists/${suite}/main/binary-amd64/Packages" 2>/dev/null \
    -    | awk -v p="$pkg" '
    -        $0 == "Package: " p { want=1; next }
    -        /^Package: / { want=0 }
    -        want && /^Version: / { print $2; exit }
    -      '
    -}
    -
    -repo_fingerprint() {
    -  local suite=$1 pkg v line=""
    -  for pkg in $TRACK_PKGS; do
    -    v=$(repo_pkg_version "$suite" "$pkg" || true)
    -    [ -n "$v" ] || v="?"
    -    line="${line}${pkg}=${v};"
    -  done
    -  printf '%s' "$line"
    -}
    -
    -container_fingerprint() {
    -  local name=$1
    -  "$PODMAN_BIN" exec "$name" bash -lc '
    -    for p in '"$TRACK_PKGS"'; do
    -      v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo missing)
    -      printf "%s=%s;" "$p" "$v"
    -    done
    -  ' 2>/dev/null || echo "missing"
    -}
    -
    -# ---------------------------------------------------------------------------
    -# Bootstrap: install base + taler source + packages (shared)
    -# ---------------------------------------------------------------------------
    -install_base_and_merchant() {
    -  local name=$1 suite=$2 srcfile=$3
    -  "$PODMAN_BIN" exec -u root "$name" bash -lc '
    -    set -euo pipefail
    -    export DEBIAN_FRONTEND=noninteractive
    -    apt-get update -qq
    -    apt-get install -y -qq ca-certificates curl wget gnupg systemd systemd-sysv \
    -      dbus procps iproute2
    -  '
    -  "$PODMAN_BIN" exec -u root "$name" bash -lc "
    -    set -euo pipefail
    -    export DEBIAN_FRONTEND=noninteractive
    -    mkdir -p /etc/apt/keyrings
    -    wget -q -O /etc/apt/keyrings/taler-systems.gpg https://taler.net/taler-systems.gpg
    -    cat >/etc/apt/sources.list.d/${srcfile} </dev/null
    -    systemctl enable taler-merchant.target 2>/dev/null
    -    systemctl start taler-merchant.target 2>/dev/null
    -    sleep 2
    -    systemctl is-active taler-merchant-httpd.service 2>/dev/null || true
    -  ' || true
    -}
    -
    -create_from_scratch() {
    -  local name=$1 suite=$2 srcfile=$3
    -  echo "======== CREATE (fresh) $name  Suites: $suite ========"
    -  "$PODMAN_BIN" pull "$IMG" >/dev/null
    -  "$PODMAN_BIN" rm -f "$name" 2>/dev/null || true
    -  # drop previous setup image so we do not accumulate duplicates
    -  "$PODMAN_BIN" rmi -f "localhost/${name}:setup" 2>/dev/null || true
    -  "$PODMAN_BIN" run -d --name "$name" --hostname "$name" "$IMG" sleep infinity
    -  install_base_and_merchant "$name" "$suite" "$srcfile"
    -  commit_and_systemd "$name"
    -  printf '%s\n' "$(repo_fingerprint "$suite")" >"$STATE_DIR/${name}.fp"
    -  printf '%s\n' "$(container_fingerprint "$name")" >"$STATE_DIR/${name}.installed"
    -  echo "created $name fp=$(cat "$STATE_DIR/${name}.fp")"
    -}
    -
    -# ---------------------------------------------------------------------------
    -# Upgrade path (mytops-admin-log upgrade.sh merchant packages, no restic/nginx)
    -# Prints a markdown-ish table of before → after versions.
    -# ---------------------------------------------------------------------------
    -upgrade_inside() {
    -  local name=$1
    -  local before after pkg v0 v1
    -  echo "======== UPGRADE $name (mytops-style apt install + upgrade) ========"
    -
    -  before=$("$PODMAN_BIN" exec "$name" bash -lc '
    -    for p in taler-merchant libtalermerchant libtalerexchange libdonau libgnunet \
    -             taler-merchant-webui taler-merchant-typst taler-terms-generator; do
    -      v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo "-")
    -      printf "%s %s\n" "$p" "$v"
    -    done
    -  ' 2>/dev/null || true)
    -
    -  # capture upgrade output
    -  set +e
    -  "$PODMAN_BIN" exec -u root "$name" bash -lc '
    -    set -euo pipefail
    -    export DEBIAN_FRONTEND=noninteractive
    -    systemctl stop taler-merchant.target 2>/dev/null || true
    -    systemctl stop taler-merchant-donaukeyupdate.service 2>/dev/null || true
    -    apt-get update -qq
    -    apt-get install -y \
    -      taler-merchant \
    -      taler-merchant-typst \
    -      taler-merchant-webui \
    -      taler-terms-generator \
    -      || true
    -    if dpkg -l challenger-httpd 2>/dev/null | grep -q "^ii"; then
    -      apt-get remove -y challenger-httpd || true
    -    fi
    -    apt-get upgrade -y
    -    systemctl daemon-reload 2>/dev/null || true
    -    systemctl enable taler-merchant.target 2>/dev/null || true
    -    # try dbconfig if present (may no-op without full config)
    -    command -v taler-merchant-dbconfig >/dev/null && taler-merchant-dbconfig 2>/dev/null || true
    -    systemctl reset-failed "taler-merchant*" 2>/dev/null || true
    -    systemctl start taler-merchant.target 2>/dev/null || true
    -    sleep 2
    -  ' 2>&1 | tee "$STATE_DIR/${name}.upgrade.log" | tail -30
    -  set -e
    -
    -  after=$("$PODMAN_BIN" exec "$name" bash -lc '
    -    for p in taler-merchant libtalermerchant libtalerexchange libdonau libgnunet \
    -             taler-merchant-webui taler-merchant-typst taler-terms-generator; do
    -      v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo "-")
    -      printf "%s %s\n" "$p" "$v"
    -    done
    -  ' 2>/dev/null || true)
    -
    -  echo
    -  echo "| package | before | after | change |"
    -  echo "|---------|--------|-------|--------|"
    -  while read -r pkg v0; do
    -    [ -n "${pkg:-}" ] || continue
    -    v1=$(printf '%s\n' "$after" | awk -v p="$pkg" '$1==p {print $2; exit}')
    -    v1=${v1:--}
    -    if [ "$v0" = "$v1" ]; then
    -      ch="="
    -    else
    -      ch="UPGRADED"
    -    fi
    -    echo "| $pkg | $v0 | $v1 | $ch |"
    -  done <<<"$before"
    -
    -  printf '%s\n' "$(container_fingerprint "$name")" >"$STATE_DIR/${name}.installed"
    -}
    -
    -# ---------------------------------------------------------------------------
    -# Fresh mode: recreate if missing / not running / repo fp changed
    -# ---------------------------------------------------------------------------
    -ensure_fresh() {
    -  local name=$1 suite=$2 srcfile=$3
    -  local want have running
    -
    -  want=$(repo_fingerprint "$suite")
    -  echo "repo fingerprint [$suite]: $want"
    -
    -  if ! "$PODMAN_BIN" container exists "$name" 2>/dev/null; then
    -    create_from_scratch "$name" "$suite" "$srcfile"
    -    return
    -  fi
    -
    -  running=$("$PODMAN_BIN" inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false)
    -  have=$(container_fingerprint "$name")
    -  echo "installed in $name: $have"
    -
    -  if [ "$running" != "true" ] \
    -    || ! "$PODMAN_BIN" exec "$name" bash -lc 'command -v taler-merchant-httpd' >/dev/null 2>&1 \
    -    || [ "$want" != "$have" ]; then
    -    echo "rebuild $name (running=$running fp_match=$([ "$want" = "$have" ] && echo yes || echo NO))"
    -    create_from_scratch "$name" "$suite" "$srcfile"
    -  else
    -    echo "OK $name up-to-date (fresh container matches repo)"
    -  fi
    -}
    -
    -# ---------------------------------------------------------------------------
    -# Upgrade mode: create once, then always upgrade_inside
    -# ---------------------------------------------------------------------------
    -ensure_upgrade() {
    -  local name=$1 suite=$2 srcfile=$3
    -  local running
    -
    -  if ! "$PODMAN_BIN" container exists "$name" 2>/dev/null; then
    -    create_from_scratch "$name" "$suite" "$srcfile"
    -    echo "(initial install for upgrade-track container $name)"
    -    return
    -  fi
    -
    -  running=$("$PODMAN_BIN" inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false)
    -  if [ "$running" != "true" ]; then
    -    echo "start $name"
    -    "$PODMAN_BIN" start "$name" || {
    -      echo "recreate broken $name"
    -      create_from_scratch "$name" "$suite" "$srcfile"
    -      return
    -    }
    -    sleep 3
    -  fi
    -
    -  if ! "$PODMAN_BIN" exec "$name" bash -lc 'command -v taler-merchant-httpd' >/dev/null 2>&1; then
    -    echo "recreate $name (no httpd after start)"
    -    create_from_scratch "$name" "$suite" "$srcfile"
    -    return
    -  fi
    -
    -  upgrade_inside "$name"
    -}
    -
    -MODE="${1:-all}"  # all | fresh | upgrade
    -
    -case "$MODE" in
    -  all|fresh)
    -    ensure_fresh   koopa-taler-deploy-test-apt-src-trixie                 trixie         taler.sources
    -    ensure_fresh   koopa-taler-deploy-test-apt-src-trixie-testing         trixie-testing taler-testing.sources
    -    ;;&
    -  all|upgrade)
    -    ensure_upgrade koopa-taler-deploy-test-apt-src-trixie-upgrade         trixie         taler.sources
    -    ensure_upgrade koopa-taler-deploy-test-apt-src-trixie-testing-upgrade trixie-testing taler-testing.sources
    -    ;;
    -  *)
    -    echo "usage: $0 [all|fresh|upgrade]" >&2
    -    exit 2
    -    ;;
    -esac
    -
    -echo "done ensure-apt-deploy-test-containers mode=$MODE"
    diff --git a/scripts/taler-monitoring/host-agent/env.example b/scripts/taler-monitoring/host-agent/env.example
    deleted file mode 100644
    index 15f6bae..0000000
    --- a/scripts/taler-monitoring/host-agent/env.example
    +++ /dev/null
    @@ -1,26 +0,0 @@
    -# Copy to:  ~/.config/taler-monitoring/env
    -# This file is NOT inside the git tree and is never overwritten by update-suite.sh.
    -#
    -# Full stack examples (same reporting generation for all):
    -#   env/hacktivism.env.example
    -#   env/stagepaysan.env.example
    -#   env/francpaysan.env.example
    -#
    -# SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git
    -# SUITE_GIT_REF=main
    -# SUITE_DIR=$HOME/src/koopa-admin-log
    -# SUITE_UPDATE_MODE=reset
    -#
    -# TALER_DOMAIN=hacktivism.ch
    -# INSIDE_PODMAN=1
    -# INSIDE_MODE=local-podman
    -# CONTINUE_ON_ERROR=1
    -# PHASES for the *default* hacktivism timer only (urls inside versions).
    -# Dedicated wrappers always override PHASES + HTML paths:
    -#   run-surface-monitoring.sh  → /taler-monitoring-surface*
    -#   run-aptdeploy-monitoring.sh → /taler-monitoring-aptdeploy*
    -# PHASES="urls inside versions"
    -# RUN_TIMEOUT=600
    -# HTML_OUT=$HOME/monitoring-sites-staging
    -# MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch"
    -# SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    diff --git a/scripts/taler-monitoring/host-agent/env/francpaysan.env.example b/scripts/taler-monitoring/host-agent/env/francpaysan.env.example
    deleted file mode 100644
    index ab1c534..0000000
    --- a/scripts/taler-monitoring/host-agent/env/francpaysan.env.example
    +++ /dev/null
    @@ -1,24 +0,0 @@
    -# Copy to:  ~/.config/taler-monitoring/env  (user francpaysan)
    -# Never inside the git tree — update-suite never overwrites this file.
    -#
    -# Shared reporting generation (run-host-report.sh / run-fp-prod-monitoring.sh):
    -#   RUN_TIMEOUT, line-buffered log, always HTML, commit link on git.hacktivism.ch
    -
    -TALER_DOMAIN=lefrancpaysan.ch
    -INSIDE_PODMAN=1
    -INSIDE_MODE=local-podman
    -LOCAL_STACK=0
    -CONTINUE_ON_ERROR=1
    -PHASES="urls inside versions"
    -RUN_TIMEOUT=600
    -
    -HTML_OUT=$HOME/monitoring-sites-staging
    -MON_HOSTS="bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch"
    -
    -SUITE_DIR=$HOME/src/koopa-admin-log
    -SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git
    -SUITE_GIT_REF=main
    -SUITE_UPDATE_MODE=reset
    -SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    -
    -# DEPLOY_WWW_ROOT=/var/www/monitoring-sites
    diff --git a/scripts/taler-monitoring/host-agent/env/hacktivism.env.example b/scripts/taler-monitoring/host-agent/env/hacktivism.env.example
    deleted file mode 100644
    index 6b57280..0000000
    --- a/scripts/taler-monitoring/host-agent/env/hacktivism.env.example
    +++ /dev/null
    @@ -1,24 +0,0 @@
    -# Copy to:  ~/.config/taler-monitoring/env  (user hernani on koopa)
    -# Shared reporting generation: RUN_TIMEOUT, line-buffered log, always HTML.
    -
    -TALER_DOMAIN=hacktivism.ch
    -INSIDE_PODMAN=1
    -INSIDE_MODE=local-podman
    -LOCAL_STACK=1
    -CONTINUE_ON_ERROR=1
    -PHASES="urls inside versions"
    -# aptdeploy HTML: run-aptdeploy-monitoring.sh → /taler-monitoring-aptdeploy*
    -RUN_TIMEOUT=600
    -# APT_DEPLOY_ENSURE=1
    -# APT_DEPLOY_SKIP=0
    -
    -HTML_OUT=$HOME/monitoring-sites-staging
    -MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch"
    -
    -SUITE_DIR=$HOME/src/koopa-admin-log
    -SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git
    -SUITE_GIT_REF=main
    -SUITE_UPDATE_MODE=reset
    -SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    -
    -DEPLOY_WWW_ROOT=/var/www/monitoring-sites
    diff --git a/scripts/taler-monitoring/host-agent/env/stagepaysan.env.example b/scripts/taler-monitoring/host-agent/env/stagepaysan.env.example
    deleted file mode 100644
    index 0959ebc..0000000
    --- a/scripts/taler-monitoring/host-agent/env/stagepaysan.env.example
    +++ /dev/null
    @@ -1,27 +0,0 @@
    -# Copy to:  ~/.config/taler-monitoring/env  (user stagepaysan)
    -# Never inside the git tree — update-suite never overwrites this file.
    -#
    -# Shared reporting generation (run-host-report.sh / run-fp-stage-monitoring.sh):
    -#   RUN_TIMEOUT, line-buffered log, always HTML, commit link on git.hacktivism.ch
    -
    -TALER_DOMAIN=stage.lefrancpaysan.ch
    -INSIDE_PODMAN=1
    -INSIDE_MODE=local-podman
    -INSIDE_PROFILE=stage-lfp
    -LOCAL_STACK=0
    -CONTINUE_ON_ERROR=1
    -PHASES="urls inside versions"
    -RUN_TIMEOUT=600
    -
    -HTML_OUT=$HOME/monitoring-sites-staging
    -MON_HOSTS="stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch"
    -
    -# Git clone of koopa-admin-log (repo root, not …/scripts/taler-monitoring)
    -SUITE_DIR=$HOME/src/koopa-admin-log
    -SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git
    -SUITE_GIT_REF=main
    -SUITE_UPDATE_MODE=reset
    -SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    -
    -# Optional public docroot if this user can write it
    -# DEPLOY_WWW_ROOT=/var/www/monitoring-sites
    diff --git a/scripts/taler-monitoring/host-agent/install-host-agent.sh b/scripts/taler-monitoring/host-agent/install-host-agent.sh
    deleted file mode 100755
    index adcce66..0000000
    --- a/scripts/taler-monitoring/host-agent/install-host-agent.sh
    +++ /dev/null
    @@ -1,107 +0,0 @@
    -#!/usr/bin/env bash
    -# install-host-agent.sh — install hernani user systemd units on koopa
    -#
    -#   # on koopa as hernani:
    -#   ~/koopa-admin-log/scripts/taler-monitoring/host-agent/install-host-agent.sh
    -#   # or from laptop:
    -#   ./install-host-agent.sh --remote koopa-external
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -REMOTE="${1:-}"
    -[ "${1:-}" = "--remote" ] && REMOTE="${2:-koopa-external}"
    -
    -install_local() {
    -  local mon home_unit
    -  mon=$(cd "$ROOT/.." && pwd)
    -  # Prefer ~/koopa-admin-log on live host
    -  if [ -d "$HOME/koopa-admin-log/scripts/taler-monitoring" ]; then
    -    mon="$HOME/koopa-admin-log/scripts/taler-monitoring"
    -  fi
    -  home_unit="$HOME/.config/systemd/user"
    -  mkdir -p "$home_unit" \
    -    "$HOME/.local/state/taler-hacktivism-monitoring" \
    -    "$HOME/monitoring-sites-staging" \
    -    "$HOME/.config/taler-monitoring" \
    -    "$HOME/src"
    -
    -  # Local env (never in git) — create from example if missing
    -  if [ ! -f "$HOME/.config/taler-monitoring/env" ] && [ -f "$ROOT/env.example" ]; then
    -    cp "$ROOT/env.example" "$HOME/.config/taler-monitoring/env"
    -    echo "created ~/.config/taler-monitoring/env"
    -  fi
    -
    -  # Prefer a real git clone for update-suite.sh
    -  if [ ! -d "$HOME/src/koopa-admin-log/.git" ] && [ ! -d "$HOME/koopa-admin-log/.git" ]; then
    -    if [ -d "$HOME/koopa-admin-log/scripts/taler-monitoring" ] && [ ! -d "$HOME/koopa-admin-log/.git" ]; then
    -      echo "note: $HOME/koopa-admin-log has no .git — update-suite will clone to ~/src/koopa-admin-log"
    -    fi
    -    git clone https://git.hacktivism.ch/hernani/koopa-admin-log.git "$HOME/src/koopa-admin-log" 2>/dev/null \
    -      || echo "WARN: clone failed (auth/network) — will use existing tree if any"
    -  fi
    -
    -  # Ensure suite is executable
    -  chmod +x "$mon"/taler-monitoring.sh "$mon"/check_*.sh 2>/dev/null || true
    -  chmod +x "$mon/host-agent"/*.sh 2>/dev/null || true
    -  chmod +x "$mon/site-gen"/*.sh "$mon/site-gen/console_to_html.py" 2>/dev/null || true
    -
    -  # Units reference %h/koopa-admin-log — prefer git clone or symlink
    -  if [ -d "$HOME/src/koopa-admin-log/.git" ] && [ ! -e "$HOME/koopa-admin-log" ]; then
    -    ln -sfn "$HOME/src/koopa-admin-log" "$HOME/koopa-admin-log"
    -    echo "symlink $HOME/koopa-admin-log → $HOME/src/koopa-admin-log"
    -  elif [ ! -e "$HOME/koopa-admin-log" ]; then
    -    admin_root=$(cd "$mon/../.." && pwd)
    -    ln -sfn "$admin_root" "$HOME/koopa-admin-log"
    -    echo "symlink $HOME/koopa-admin-log → $admin_root"
    -  fi
    -
    -  install -m 644 "$ROOT/taler-monitoring-hacktivism.service" "$home_unit/"
    -  install -m 644 "$ROOT/taler-monitoring-hacktivism.path" "$home_unit/"
    -  install -m 644 "$ROOT/taler-monitoring-hacktivism.timer" "$home_unit/"
    -  install -m 644 "$ROOT/taler-monitoring-surface.service" "$home_unit/"
    -  install -m 644 "$ROOT/taler-monitoring-surface.timer" "$home_unit/"
    -  install -m 644 "$ROOT/taler-monitoring-aptdeploy.service" "$home_unit/"
    -  install -m 644 "$ROOT/taler-monitoring-aptdeploy.timer" "$home_unit/"
    -
    -  # Fix ExecStart if install path differs (units use %h/koopa-admin-log)
    -  systemctl --user daemon-reload
    -  systemctl --user enable --now taler-monitoring-hacktivism.path
    -  systemctl --user enable --now taler-monitoring-hacktivism.timer
    -  systemctl --user enable --now taler-monitoring-surface.timer
    -  systemctl --user enable --now taler-monitoring-aptdeploy.timer
    -  # initial stamp so path exists
    -  "$ROOT/touch-software-stamp.sh"
    -  systemctl --user start taler-monitoring-hacktivism.service || true
    -  systemctl --user start taler-monitoring-surface.service || true
    -  systemctl --user start taler-monitoring-aptdeploy.service || true
    -
    -  echo "--- status ---"
    -  systemctl --user status taler-monitoring-hacktivism.path --no-pager -l | head -15
    -  systemctl --user status taler-monitoring-hacktivism.timer --no-pager -l | head -12
    -  systemctl --user status taler-monitoring-surface.timer --no-pager -l | head -12
    -  systemctl --user status taler-monitoring-aptdeploy.timer --no-pager -l | head -12
    -  systemctl --user list-timers --all | grep taler-monitoring || true
    -  echo "OK host-agent (hacktivism 4h + surface 1h + aptdeploy 4h)"
    -  echo "manual: systemctl --user start taler-monitoring-hacktivism.service"
    -  echo "manual surface: systemctl --user start taler-monitoring-surface.service"
    -  echo "manual aptdeploy: systemctl --user start taler-monitoring-aptdeploy.service"
    -  echo "after upgrades: $ROOT/touch-software-stamp.sh"
    -}
    -
    -if [ -n "$REMOTE" ] && [ "$REMOTE" != "--remote" ]; then
    -  echo "install on $REMOTE …"
    -  rsync -az --delete \
    -    --exclude secrets.env \
    -    --exclude 'android-test/artifacts' \
    -    "$ROOT/../../" \
    -    "${REMOTE}:koopa-admin-log/scripts/taler-monitoring/"
    -  # ensure full tree for %h/koopa-admin-log if missing configs etc.
    -  ssh -o BatchMode=yes -o ConnectTimeout=20 "$REMOTE" \
    -    'test -d $HOME/koopa-admin-log || git clone https://git.hacktivism.ch/hernani/koopa-admin-log.git $HOME/koopa-admin-log 2>/dev/null || true'
    -  rsync -az "$ROOT/" "${REMOTE}:koopa-admin-log/scripts/taler-monitoring/host-agent/"
    -  rsync -az "$ROOT/../site-gen/" "${REMOTE}:koopa-admin-log/scripts/taler-monitoring/site-gen/" 2>/dev/null || true
    -  ssh -o BatchMode=yes "$REMOTE" \
    -    'bash $HOME/koopa-admin-log/scripts/taler-monitoring/host-agent/install-host-agent.sh'
    -else
    -  install_local
    -fi
    diff --git a/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh b/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh
    deleted file mode 100755
    index fe81257..0000000
    --- a/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh
    +++ /dev/null
    @@ -1,43 +0,0 @@
    -#!/usr/bin/env bash
    -# run-aptdeploy-monitoring.sh — apt-src deploy tests → public HTML
    -# Public only on taler.hacktivism.ch:
    -#   https://taler.hacktivism.ch/taler-monitoring-aptdeploy/
    -#   https://taler.hacktivism.ch/taler-monitoring-aptdeploy_err/
    -#
    -# CLI one-shot without HTML remains: run-aptdeploy.sh [all|fresh|upgrade|check-only]
    -#
    -set -uo pipefail
    -AGENT_DIR=$(cd "$(dirname "$0")" && pwd)
    -
    -export AGENT_LABEL="${AGENT_LABEL:-aptdeploy-host-agent}"
    -export STATE_NAME="${STATE_NAME:-taler-aptdeploy-monitoring}"
    -export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}"
    -# containers are local on koopa
    -export INSIDE_PODMAN=1
    -export INSIDE_MODE=local-podman
    -export LOCAL_STACK=1
    -export SKIP_SSH=0
    -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}"
    -# four containers + checks; allow long wall
    -export RUN_TIMEOUT="${RUN_TIMEOUT:-1800}"
    -# dedicated job: always aptdeploy only
    -export PHASES=aptdeploy
    -# Only merchant public front — not bank/exchange
    -export MON_HOSTS="${MON_HOSTS:-taler.hacktivism.ch}"
    -export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}"
    -export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}"
    -
    -export HTML_OK_DIR="taler-monitoring-aptdeploy"
    -export HTML_ERR_DIR="taler-monitoring-aptdeploy_err"
    -export HTML_URL_OK="/taler-monitoring-aptdeploy/"
    -export HTML_URL_ERR="/taler-monitoring-aptdeploy_err/"
    -export PAGE_LABEL="taler-monitoring-aptdeploy"
    -
    -# Ensure all four apt-src containers (fresh rebuild + upgrade track)
    -if [ "${APT_DEPLOY_ENSURE:-1}" = "1" ] \
    -  && [ -x "$AGENT_DIR/ensure-apt-deploy-test-containers.sh" ]; then
    -  bash "$AGENT_DIR/ensure-apt-deploy-test-containers.sh" all \
    -    || echo "WARN: ensure-apt-deploy-test-containers failed (aptdeploy may ERROR)" >&2
    -fi
    -
    -exec bash "$AGENT_DIR/run-host-report.sh" "$@"
    diff --git a/scripts/taler-monitoring/host-agent/run-aptdeploy.sh b/scripts/taler-monitoring/host-agent/run-aptdeploy.sh
    deleted file mode 100755
    index fbf5afd..0000000
    --- a/scripts/taler-monitoring/host-agent/run-aptdeploy.sh
    +++ /dev/null
    @@ -1,150 +0,0 @@
    -#!/usr/bin/env bash
    -# run-aptdeploy.sh — ensure containers + aptdeploy phase only.
    -#
    -# Keep monitoring current:
    -#   1) Prefer Forgejo origin/main (update-suite reset)
    -#   2) If Forgejo is behind (no aptdeploy yet), re-apply suite-overlay saved
    -#      from this agent tree before reset (rsync from laptop fills that tree)
    -#   3) Run ensure + taler-monitoring.sh aptdeploy
    -#
    -# Host: on koopa → local; else ssh APT_DEPLOY_SSH + rsync mon tree first.
    -#
    -set -euo pipefail
    -
    -AGENT_DIR=$(cd "$(dirname "$0")" && pwd)
    -BOOT_MON=$(cd "$AGENT_DIR/.." && pwd)
    -MODE="${1:-all}"
    -APT_DEPLOY_SSH="${APT_DEPLOY_SSH:-koopa-external}"
    -OVERLAY_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/suite-overlay"
    -
    -on_koopa() {
    -  local h
    -  h=$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo "")
    -  case "$h" in koopa|koopa.*) return 0 ;; esac
    -  [ "${KOOPA_FORCE_LOCAL:-0}" = "1" ] && return 0
    -  return 1
    -}
    -
    -mon_has_aptdeploy() {
    -  local mon=$1
    -  [ -f "$mon/taler-monitoring.sh" ] && grep -qE 'aptdeploy\|apt-deploy' "$mon/taler-monitoring.sh"
    -}
    -
    -save_overlay_from() {
    -  local src=$1
    -  mon_has_aptdeploy "$src" || return 0
    -  mkdir -p "$OVERLAY_DIR"
    -  rsync -a --delete \
    -    --exclude secrets.env \
    -    --exclude 'android-test/artifacts' \
    -    --exclude 'android-test/apks' \
    -    --exclude 'android-test/out*' \
    -    --exclude '__pycache__' \
    -    "$src/" "$OVERLAY_DIR/"
    -  echo "saved suite-overlay from $src → $OVERLAY_DIR"
    -}
    -
    -apply_overlay_to() {
    -  local dest=$1
    -  [ -f "$OVERLAY_DIR/taler-monitoring.sh" ] || return 1
    -  mon_has_aptdeploy "$OVERLAY_DIR" || return 1
    -  mkdir -p "$dest"
    -  rsync -a \
    -    --exclude secrets.env \
    -    --exclude 'android-test/artifacts' \
    -    --exclude 'android-test/apks' \
    -    --exclude 'android-test/out*' \
    -    --exclude '__pycache__' \
    -    "$OVERLAY_DIR/" "$dest/"
    -  echo "applied suite-overlay → $dest (Forgejo lag; push origin/main when possible)"
    -  return 0
    -}
    -
    -pick_mon_tree() {
    -  # Preserve working mon before reset wipes tracked files
    -  save_overlay_from "$BOOT_MON"
    -
    -  # shellcheck source=update-suite.sh
    -  source "$AGENT_DIR/update-suite.sh"
    -
    -  local suite_mon="$SUITE_DIR/scripts/taler-monitoring"
    -  mkdir -p "$suite_mon"
    -
    -  if mon_has_aptdeploy "$suite_mon"; then
    -    MON_DIR="$suite_mon"
    -    echo "using Forgejo suite mon @ ${COMMIT_SHORT:-?} · $MON_DIR"
    -  elif apply_overlay_to "$suite_mon" && mon_has_aptdeploy "$suite_mon"; then
    -    MON_DIR="$suite_mon"
    -    echo "using suite mon + overlay @ ${COMMIT_SHORT:-?} · $MON_DIR"
    -  elif mon_has_aptdeploy "$BOOT_MON"; then
    -    MON_DIR="$BOOT_MON"
    -    echo "WARN: using boot mon $MON_DIR (overlay apply failed)" >&2
    -  else
    -    echo "ERROR: no mon tree with aptdeploy (Forgejo + overlay empty)" >&2
    -    echo "  rsync suite from laptop or push to git.hacktivism.ch" >&2
    -    exit 2
    -  fi
    -
    -  AGENT_DIR="$MON_DIR/host-agent"
    -  export MON_DIR AGENT_DIR SUITE_DIR COMMIT COMMIT_SHORT COMMIT_URL
    -  chmod +x "$AGENT_DIR"/*.sh "$MON_DIR"/taler-monitoring.sh "$MON_DIR"/check_*.sh 2>/dev/null || true
    -}
    -
    -run_local() {
    -  export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"
    -  export PYTHONUNBUFFERED=1
    -  export PROGRESS_OFF="${PROGRESS_OFF:-0}"
    -
    -  echo "======== aptdeploy local on $(hostname) mode=$MODE ========"
    -  pick_mon_tree
    -
    -  if [ "$MODE" != "check-only" ]; then
    -    bash "$AGENT_DIR/ensure-apt-deploy-test-containers.sh" "$MODE"
    -  fi
    -
    -  cd "$MON_DIR"
    -  export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}"
    -  export INSIDE_PODMAN="${INSIDE_PODMAN:-1}"
    -  export LOCAL_STACK="${LOCAL_STACK:-1}"
    -  export RUN_TIMEOUT="${RUN_TIMEOUT:-600}"
    -  echo "run: $MON_DIR/taler-monitoring.sh -d hacktivism.ch aptdeploy · suite=${COMMIT_SHORT:-?}"
    -  ./taler-monitoring.sh -d hacktivism.ch aptdeploy
    -}
    -
    -run_remote() {
    -  echo "======== aptdeploy remote via ssh ${APT_DEPLOY_SSH} mode=$MODE ========"
    -  if [ "${APT_DEPLOY_RSYNC:-1}" = "1" ]; then
    -    echo "rsync mon suite → ${APT_DEPLOY_SSH}:src/koopa-admin-log/scripts/taler-monitoring/"
    -    rsync -az \
    -      --exclude secrets.env \
    -      --exclude 'android-test/artifacts' \
    -      --exclude 'android-test/apks' \
    -      --exclude 'android-test/out*' \
    -      --exclude '__pycache__' \
    -      "$BOOT_MON/" \
    -      "${APT_DEPLOY_SSH}:src/koopa-admin-log/scripts/taler-monitoring/" \
    -      || echo "WARN: rsync failed" >&2
    -  fi
    -  ssh -o BatchMode=yes -o ConnectTimeout=25 "$APT_DEPLOY_SSH" bash -s -- "$MODE" <<'EOS'
    -set -euo pipefail
    -MODE=${1:-all}
    -export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"
    -export KOOPA_FORCE_LOCAL=1
    -AGENT="$HOME/src/koopa-admin-log/scripts/taler-monitoring/host-agent"
    -if [ ! -x "$AGENT/run-aptdeploy.sh" ]; then
    -  AGENT="$HOME/koopa-admin-log/scripts/taler-monitoring/host-agent"
    -fi
    -if [ ! -x "$AGENT/run-aptdeploy.sh" ]; then
    -  echo "ERROR: run-aptdeploy.sh missing on remote" >&2
    -  exit 1
    -fi
    -echo "remote AGENT=$AGENT"
    -exec bash "$AGENT/run-aptdeploy.sh" "$MODE"
    -EOS
    -}
    -
    -if on_koopa; then
    -  run_local
    -else
    -  run_remote
    -fi
    diff --git a/scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh b/scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh
    deleted file mode 100755
    index be876a8..0000000
    --- a/scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh
    +++ /dev/null
    @@ -1,20 +0,0 @@
    -#!/usr/bin/env bash
    -# FrancPaysan PROD host-agent (user francpaysan on francpaysan-host).
    -# Shared reporting: run-host-report.sh (timeout, line-buffered log, first-run HTML).
    -set -uo pipefail
    -AGENT_DIR=$(cd "$(dirname "$0")" && pwd)
    -
    -export AGENT_LABEL="${AGENT_LABEL:-fp-prod-host-agent}"
    -export STATE_NAME="${STATE_NAME:-taler-monitoring-lfp-prod}"
    -export TALER_DOMAIN="${TALER_DOMAIN:-lefrancpaysan.ch}"
    -export INSIDE_PODMAN="${INSIDE_PODMAN:-1}"
    -export INSIDE_MODE="${INSIDE_MODE:-local-podman}"
    -export LOCAL_STACK="${LOCAL_STACK:-0}"
    -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}"
    -export RUN_TIMEOUT="${RUN_TIMEOUT:-600}"
    -export PHASES="${PHASES:-urls inside versions}"
    -export MON_HOSTS="${MON_HOSTS:-bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch}"
    -export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}"
    -export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}"
    -
    -exec bash "$AGENT_DIR/run-host-report.sh" "$@"
    diff --git a/scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh b/scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh
    deleted file mode 100755
    index 70518f0..0000000
    --- a/scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh
    +++ /dev/null
    @@ -1,21 +0,0 @@
    -#!/usr/bin/env bash
    -# FrancPaysan STAGE host-agent (user stagepaysan on francpaysan-host).
    -# Shared reporting: run-host-report.sh (timeout, line-buffered log, first-run HTML).
    -set -uo pipefail
    -AGENT_DIR=$(cd "$(dirname "$0")" && pwd)
    -
    -export AGENT_LABEL="${AGENT_LABEL:-fp-stage-host-agent}"
    -export STATE_NAME="${STATE_NAME:-taler-monitoring-stage-lfp}"
    -export TALER_DOMAIN="${TALER_DOMAIN:-stage.lefrancpaysan.ch}"
    -export INSIDE_PODMAN="${INSIDE_PODMAN:-1}"
    -export INSIDE_MODE="${INSIDE_MODE:-local-podman}"
    -export INSIDE_PROFILE="${INSIDE_PROFILE:-stage-lfp}"
    -export LOCAL_STACK="${LOCAL_STACK:-0}"
    -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}"
    -export RUN_TIMEOUT="${RUN_TIMEOUT:-600}"
    -export PHASES="${PHASES:-urls inside versions}"
    -export MON_HOSTS="${MON_HOSTS:-stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch}"
    -export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}"
    -export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}"
    -
    -exec bash "$AGENT_DIR/run-host-report.sh" "$@"
    diff --git a/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh b/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh
    deleted file mode 100755
    index 8fd98fd..0000000
    --- a/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh
    +++ /dev/null
    @@ -1,23 +0,0 @@
    -#!/usr/bin/env bash
    -# GOA / hacktivism host-agent on koopa (hernani).
    -# Stack defaults only — reporting pipeline is shared: run-host-report.sh
    -set -uo pipefail
    -AGENT_DIR=$(cd "$(dirname "$0")" && pwd)
    -
    -export AGENT_LABEL="${AGENT_LABEL:-hacktivism-host-agent}"
    -export STATE_NAME="${STATE_NAME:-taler-hacktivism-monitoring}"
    -export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}"
    -export INSIDE_PODMAN="${INSIDE_PODMAN:-1}"
    -export INSIDE_MODE="${INSIDE_MODE:-local-podman}"
    -export LOCAL_STACK="${LOCAL_STACK:-1}"
    -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}"
    -export RUN_TIMEOUT="${RUN_TIMEOUT:-600}"
    -# stack checks only — aptdeploy has its own HTML path + timer:
    -#   run-aptdeploy-monitoring.sh → /taler-monitoring-aptdeploy* (taler.hacktivism.ch)
    -export PHASES="${PHASES:-urls inside versions}"
    -export MON_HOSTS="${MON_HOSTS:-bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch}"
    -export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}"
    -export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}"
    -export APT_DEPLOY_ENSURE=0
    -
    -exec bash "$AGENT_DIR/run-host-report.sh" "$@"
    diff --git a/scripts/taler-monitoring/host-agent/run-host-report.sh b/scripts/taler-monitoring/host-agent/run-host-report.sh
    deleted file mode 100755
    index 1be1def..0000000
    --- a/scripts/taler-monitoring/host-agent/run-host-report.sh
    +++ /dev/null
    @@ -1,358 +0,0 @@
    -#!/usr/bin/env bash
    -# run-host-report.sh — global reporting generation for all stacks
    -# (GOA/hacktivism, FP stage, FP prod, …).
    -#
    -# Shared behaviour (always):
    -#   1) update-suite → latest git.hacktivism.ch (local ~/.config never overwritten)
    -#   2) line-buffered run log (written continuously)
    -#   3) RUN_TIMEOUT wall clock for taler-monitoring.sh (default 600s)
    -#   4) always write HTML (first run / empty staging too) + Forgejo commit link
    -#   5) optional rsync to DEPLOY_WWW_ROOT if writable
    -#
    -# Configure via env file (not in git):
    -#   ${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/env
    -# or TALER_MONITORING_ENV=/path/to/env
    -#
    -# Thin wrappers only set stack defaults then exec this script:
    -#   run-hacktivism-monitoring.sh
    -#   run-fp-stage-monitoring.sh
    -#   run-fp-prod-monitoring.sh
    -#
    -set -uo pipefail
    -
    -export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"
    -export PYTHONUNBUFFERED=1
    -
    -AGENT_DIR=$(cd "$(dirname "$0")" && pwd)
    -AGENT_MON=$(cd "$AGENT_DIR/.." && pwd)
    -CFG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring"
    -ENV_FILE="${TALER_MONITORING_ENV:-$CFG_DIR/env}"
    -mkdir -p "$CFG_DIR"
    -
    -# Thin wrappers (surface, aptdeploy-only, …) export stack knobs before exec.
    -# Snapshot them so ~/.config/taler-monitoring/env cannot clobber dedicated jobs
    -# (env often has PHASES="urls inside versions aptdeploy" for the main timer).
    -_wrap_keys=(
    -  PHASES HTML_OUT HTML_OK_DIR HTML_ERR_DIR HTML_URL_OK HTML_URL_ERR PAGE_LABEL
    -  RUN_TIMEOUT STATE_NAME AGENT_LABEL MON_HOSTS TALER_DOMAIN DEPLOY_WWW_ROOT
    -  INSIDE_PODMAN INSIDE_MODE LOCAL_STACK SKIP_SSH CONTINUE_ON_ERROR AUTH401_CONTINUE
    -  APT_DEPLOY_ENSURE SURFACE_SCOPE TALER_DOMAIN_FROM_CLI STRICT_EXIT SOURCE_SUITE_PATH
    -)
    -for _k in "${_wrap_keys[@]}"; do
    -  if [ -n "${!_k+x}" ]; then
    -    printf -v "_WRAP_SET_${_k}" '%s' 1
    -    printf -v "_WRAP_VAL_${_k}" '%s' "${!_k}"
    -  fi
    -done
    -unset _k
    -
    -if [ -f "$ENV_FILE" ]; then
    -  # shellcheck disable=SC1090
    -  set -a
    -  # shellcheck source=/dev/null
    -  source "$ENV_FILE"
    -  set +a
    -fi
    -# Tell update-suite.sh not to re-source env (would clobber wrapper PHASES etc.)
    -export _TALER_MON_ENV_LOADED=1
    -
    -# Restore wrapper overrides (dedicated jobs win over shared env file)
    -for _k in "${_wrap_keys[@]}"; do
    -  _set_var="_WRAP_SET_${_k}"
    -  _val_var="_WRAP_VAL_${_k}"
    -  if [ "${!_set_var:-}" = "1" ]; then
    -    printf -v "$_k" '%s' "${!_val_var}"
    -    export "$_k"
    -  fi
    -done
    -unset _k _set_var _val_var
    -
    -# --- global report defaults (env file / wrapper wins if already set) ---
    -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}"
    -export AUTH401_CONTINUE="${AUTH401_CONTINUE:-${CONTINUE_ON_ERROR}}"
    -export RUN_TIMEOUT="${RUN_TIMEOUT:-600}"
    -export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}"
    -PHASES="${PHASES:-urls inside versions}"
    -HTML_BASE="${HTML_OUT:-$HOME/monitoring-sites-staging}"
    -MON_HOSTS="${MON_HOSTS:-}"
    -SUITE_PATH="${SOURCE_SUITE_PATH:-scripts/taler-monitoring}"
    -AGENT_LABEL="${AGENT_LABEL:-host-report}"
    -STATE_NAME="${STATE_NAME:-taler-monitoring-${TALER_DOMAIN}}"
    -STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/${STATE_NAME}"
    -LOG_DIR="$STATE_DIR/logs"
    -DEPLOY_WWW="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}"
    -# HTML path layout under each host (monitoring / surface / …)
    -HTML_OK_DIR="${HTML_OK_DIR:-monitoring}"
    -HTML_ERR_DIR="${HTML_ERR_DIR:-monitoring_err}"
    -HTML_URL_OK="${HTML_URL_OK:-/monitoring/}"
    -HTML_URL_ERR="${HTML_URL_ERR:-/monitoring_err/}"
    -PAGE_LABEL="${PAGE_LABEL:-monitoring}"
    -
    -# Sensible MON_HOSTS from domain if not set
    -if [ -z "$MON_HOSTS" ]; then
    -  case "$TALER_DOMAIN" in
    -    hacktivism.ch|koopa)
    -      MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch"
    -      ;;
    -    stage.lefrancpaysan.ch|stage.*lefrancpaysan*)
    -      MON_HOSTS="stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch"
    -      ;;
    -    lefrancpaysan.ch)
    -      MON_HOSTS="bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch"
    -      ;;
    -    *)
    -      MON_HOSTS="${TALER_DOMAIN}"
    -      ;;
    -  esac
    -fi
    -
    -mkdir -p "$LOG_DIR" "$HTML_BASE"
    -
    -STAMP=$(date +%Y%m%d-%H%M%S)
    -LOG="$LOG_DIR/run-${STAMP}.log"
    -: >"$LOG"
    -if command -v stdbuf >/dev/null 2>&1; then
    -  exec > >(stdbuf -oL -eL tee -a "$LOG") 2>&1
    -else
    -  exec > >(tee -a "$LOG") 2>&1
    -fi
    -
    -echo "======== $(date -Iseconds 2>/dev/null || date) ${AGENT_LABEL} ========"
    -echo "env_file=$ENV_FILE"
    -echo "domain=$TALER_DOMAIN · phases=$PHASES · RUN_TIMEOUT=${RUN_TIMEOUT}s"
    -echo "hosts=$MON_HOSTS"
    -echo "flags INSIDE_PODMAN=${INSIDE_PODMAN:-} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-} SKIP_SSH=${SKIP_SSH:-} INSIDE_PROFILE=${INSIDE_PROFILE:-}"
    -
    -# --- refresh from Forgejo; re-apply suite-overlay if Forgejo lacks needed phases ---
    -OVERLAY_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/suite-overlay"
    -# preserve capable mon before reset wipes tracked files
    -if [ -f "$AGENT_MON/taler-monitoring.sh" ]; then
    -  mkdir -p "$OVERLAY_DIR"
    -  rsync -a \
    -    --exclude secrets.env --exclude 'android-test/artifacts' --exclude 'android-test/apks' \
    -    --exclude 'android-test/out*' --exclude '__pycache__' \
    -    "$AGENT_MON/" "$OVERLAY_DIR/" 2>/dev/null || true
    -fi
    -
    -# shellcheck source=update-suite.sh
    -source "$AGENT_DIR/update-suite.sh"
    -
    -SUITE_MON="${SUITE_DIR}/scripts/taler-monitoring"
    -ROOT="$SUITE_MON"
    -_need_overlay=0
    -# Detect missing features in Forgejo tree (untracked helper files alone are not enough —
    -# taler-monitoring.sh must accept the phase name).
    -case " $PHASES " in
    -  *" aptdeploy "*|*" apt-deploy "*|*" apt_src "*)
    -    if ! grep -qE 'aptdeploy\|apt-deploy' "$SUITE_MON/taler-monitoring.sh" 2>/dev/null; then
    -      _need_overlay=1
    -    fi
    -    ;;
    -esac
    -case " $PHASES " in
    -  *" surface "*|*" ecosystem "*)
    -    if ! grep -qE 'surface\|ecosystem' "$SUITE_MON/taler-monitoring.sh" 2>/dev/null \
    -      || [ ! -f "$SUITE_MON/check_surface.sh" ]; then
    -      _need_overlay=1
    -    fi
    -    ;;
    -esac
    -# Wrapper-preserve + HTML path knobs may live only in local overlay until pushed
    -if [ -f "$OVERLAY_DIR/host-agent/run-host-report.sh" ] \
    -  && ! grep -q '_wrap_keys\|_WRAP_SET_' "$SUITE_MON/host-agent/run-host-report.sh" 2>/dev/null \
    -  && grep -q '_wrap_keys\|_WRAP_SET_' "$OVERLAY_DIR/host-agent/run-host-report.sh" 2>/dev/null; then
    -  _need_overlay=1
    -fi
    -if [ "$_need_overlay" = "1" ] && [ -f "$OVERLAY_DIR/taler-monitoring.sh" ]; then
    -  echo "WARN: Forgejo @ ${COMMIT_SHORT:-?} lagging local suite-overlay (phases=[$PHASES])" >&2
    -  echo "      re-applying suite-overlay → $SUITE_MON" >&2
    -  mkdir -p "$SUITE_MON"
    -  rsync -a \
    -    --exclude secrets.env --exclude 'android-test/artifacts' --exclude 'android-test/apks' \
    -    --exclude 'android-test/out*' --exclude '__pycache__' \
    -    "$OVERLAY_DIR/" "$SUITE_MON/"
    -  ROOT="$SUITE_MON"
    -elif [ "$_need_overlay" = "1" ]; then
    -  echo "WARN: Forgejo missing features and no suite-overlay — using agent mon $AGENT_MON" >&2
    -  ROOT="$AGENT_MON"
    -else
    -  echo "using suite mon @ ${COMMIT_SHORT:-?} · $ROOT"
    -fi
    -# Always restore host-agent + site-gen from pre-reset snapshot when present
    -# (systemd ExecStart points at koopa-admin-log which may be the reset suite tree).
    -if [ -d "$OVERLAY_DIR/host-agent" ]; then
    -  mkdir -p "$SUITE_MON/host-agent" 2>/dev/null || true
    -  rsync -a "$OVERLAY_DIR/host-agent/" "$SUITE_MON/host-agent/" 2>/dev/null || true
    -fi
    -if [ -d "$OVERLAY_DIR/site-gen" ]; then
    -  mkdir -p "$SUITE_MON/site-gen"
    -  rsync -a "$OVERLAY_DIR/site-gen/" "$SUITE_MON/site-gen/" 2>/dev/null || true
    -fi
    -unset _need_overlay
    -
    -if [ -f "$ROOT/site-gen/console_to_html.py" ]; then
    -  SITE_GEN="$ROOT/site-gen"
    -elif [ -f "$AGENT_MON/site-gen/console_to_html.py" ]; then
    -  SITE_GEN="$AGENT_MON/site-gen"
    -  echo "note: using agent site-gen at $SITE_GEN"
    -elif [ -f "$SUITE_MON/site-gen/console_to_html.py" ]; then
    -  SITE_GEN="$SUITE_MON/site-gen"
    -else
    -  SITE_GEN=""
    -fi
    -
    -if [ ! -x "$ROOT/taler-monitoring.sh" ]; then
    -  if [ -x "$AGENT_MON/taler-monitoring.sh" ]; then
    -    ROOT="$AGENT_MON"
    -    echo "note: using agent mon tree $ROOT"
    -  else
    -    echo "ERROR: suite missing at $ROOT" >&2
    -    COMMIT="${COMMIT:-unknown}"
    -    COMMIT_SHORT="${COMMIT_SHORT:-unknown}"
    -    COMMIT_URL="${COMMIT_URL:-}"
    -  fi
    -fi
    -
    -echo "commit=${COMMIT_SHORT:-?} · ${COMMIT_URL:-}"
    -
    -ec=1
    -if [ -x "${ROOT:-}/taler-monitoring.sh" ]; then
    -  cd "$ROOT" || exit 1
    -  chmod +x taler-monitoring.sh check_*.sh host-agent/*.sh site-gen/*.sh site-gen/*.py 2>/dev/null || true
    -
    -  # Overlay newer inside/versions host-podman bits if suite clone is stale
    -  for f in check_inside.sh check_versions.sh taler-monitoring.sh lib.sh; do
    -    if [ -f "$AGENT_MON/$f" ] && [ -f "$ROOT/$f" ]; then
    -      if ! grep -q 'host-podman\|INSIDE_ACCESS\|RUN_TIMEOUT' "$ROOT/$f" 2>/dev/null \
    -        && grep -q 'host-podman\|INSIDE_ACCESS\|RUN_TIMEOUT' "$AGENT_MON/$f" 2>/dev/null; then
    -        echo "note: overlay $f from agent install"
    -        cp -f "$AGENT_MON/$f" "$ROOT/$f"
    -        chmod +x "$ROOT/$f" 2>/dev/null || true
    -      fi
    -    fi
    -  done
    -
    -  # shellcheck disable=SC2086
    -  ./taler-monitoring.sh -d "$TALER_DOMAIN" $PHASES
    -  ec=$?
    -fi
    -
    -# --- always generate HTML (first run / missing pages / every run) ---
    -write_bootstrap_html() {
    -  local out="$1" host="$2" mode="$3"
    -  mkdir -p "$(dirname "$out")"
    -  local cshort="${COMMIT_SHORT:-unknown}"
    -  local curl="${COMMIT_URL:-}"
    -  local clink="$cshort"
    -  local gen iso
    -  gen=$(date -u +"%Y-%m-%d %H:%M:%SZ" 2>/dev/null || date)
    -  iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -Iseconds)
    -  if [ -n "$curl" ]; then
    -    clink="$cshort"
    -  fi
    -  cat >"$out" <
    -
    -
    -monitoring ${mode} · ${host}
    -
    -
    -
    -

    Bootstrap page (converter missing or first install). See host-agent log.

    -
    $(tail -n 80 "$LOG" 2>/dev/null | sed 's/&/\&/g;s/
    -
    - -EOF - echo "bootstrap html → $out" -} - -htmlify_host() { - local host="$1" - local mon="$HTML_BASE/$host/${HTML_OK_DIR}/index.html" - local mon_err="$HTML_BASE/$host/${HTML_ERR_DIR}/index.html" - mkdir -p "$HTML_BASE/$host/${HTML_OK_DIR}" "$HTML_BASE/$host/${HTML_ERR_DIR}" - - if [ -n "$SITE_GEN" ] && [ -f "$SITE_GEN/console_to_html.py" ]; then - python3 "$SITE_GEN/console_to_html.py" \ - --log "$LOG" \ - --out "$mon_err" \ - --hostname "$host" \ - --mode err \ - --commit "${COMMIT:-}" \ - --commit-url "${COMMIT_URL:-}" \ - --suite-path "$SUITE_PATH" \ - --page-label "$PAGE_LABEL" \ - --path-err "$HTML_URL_ERR" \ - --link-other "$HTML_URL_OK" - if [ "$ec" -eq 0 ]; then - python3 "$SITE_GEN/console_to_html.py" \ - --log "$LOG" \ - --out "$mon" \ - --hostname "$host" \ - --mode ok \ - --commit "${COMMIT:-}" \ - --commit-url "${COMMIT_URL:-}" \ - --suite-path "$SUITE_PATH" \ - --page-label "$PAGE_LABEL" \ - --path-err "$HTML_URL_ERR" \ - --link-other "" - rm -rf "$HTML_BASE/$host/${HTML_ERR_DIR}" - echo "html $host → ${HTML_URL_OK} only (clean · ${COMMIT_SHORT:-?})" - else - python3 "$SITE_GEN/console_to_html.py" \ - --log "$LOG" \ - --out "$mon" \ - --hostname "$host" \ - --mode redirect \ - --commit "${COMMIT:-}" \ - --commit-url "${COMMIT_URL:-}" \ - --suite-path "$SUITE_PATH" \ - --page-label "$PAGE_LABEL" \ - --path-err "$HTML_URL_ERR" - echo "html $host → ${HTML_URL_OK} stub + ${HTML_URL_ERR} (ec=$ec · ${COMMIT_SHORT:-?})" - fi - else - echo "WARN: console_to_html.py missing — bootstrap pages" - write_bootstrap_html "$mon_err" "$host" "err" - if [ "$ec" -eq 0 ]; then - write_bootstrap_html "$mon" "$host" "ok" - rm -rf "$HTML_BASE/$host/${HTML_ERR_DIR}" - else - write_bootstrap_html "$mon" "$host" "redirect" - fi - fi - - if [ ! -f "$mon" ]; then - write_bootstrap_html "$mon" "$host" "err" - fi - - printf '%s\n' "${COMMIT:-unknown}" >"$HTML_BASE/$host/COMMIT" - printf '%s\n' "${COMMIT_URL:-}" >"$HTML_BASE/$host/COMMIT_URL" -} -for host in $MON_HOSTS; do - htmlify_host "$host" -done - -if [ -n "$DEPLOY_WWW" ] && [ -w "$DEPLOY_WWW" ] 2>/dev/null; then - rsync -a --delete "$HTML_BASE/" "$DEPLOY_WWW/" && \ - echo "synced → $DEPLOY_WWW/" -fi - -ls -1t "$LOG_DIR"/run-*.log 2>/dev/null | tail -n +30 | xargs rm -f 2>/dev/null || true - -echo "======== done ec=$ec · log=$LOG · ${COMMIT_URL:-} ========" -if [ "${STRICT_EXIT:-0}" = "1" ]; then - exit "$ec" -fi -exit 0 diff --git a/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh b/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh deleted file mode 100755 index 1b7a8b8..0000000 --- a/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -# run-surface-monitoring.sh — hourly remote-only ecosystem surface scan -# Public only on taler.hacktivism.ch: -# https://taler.hacktivism.ch/taler-monitoring-surface/ -# https://taler.hacktivism.ch/taler-monitoring-surface_err/ -# -set -uo pipefail -AGENT_DIR=$(cd "$(dirname "$0")" && pwd) - -export AGENT_LABEL="${AGENT_LABEL:-surface-host-agent}" -export STATE_NAME="${STATE_NAME:-taler-surface-monitoring}" -export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}" -# surface is remote-only — force flags (ignore shared env) -export INSIDE_PODMAN=0 -export LOCAL_STACK=0 -export SKIP_SSH=1 -export INSIDE_MODE="${INSIDE_MODE:-none}" -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" -# many hosts; allow up to 45 min wall (phase also has RUN_TIMEOUT) -export RUN_TIMEOUT="${RUN_TIMEOUT:-2400}" -# dedicated job: always surface only (never inherit PHASES from shared env) -export PHASES=surface -# Only merchant public front — not bank/exchange -export MON_HOSTS="${MON_HOSTS:-taler.hacktivism.ch}" -export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}" -export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" - -export HTML_OK_DIR="taler-monitoring-surface" -export HTML_ERR_DIR="taler-monitoring-surface_err" -export HTML_URL_OK="/taler-monitoring-surface/" -export HTML_URL_ERR="/taler-monitoring-surface_err/" -export PAGE_LABEL="taler-monitoring-surface" - -# ecosystem catalog by default (not domain-only) -export SURFACE_SCOPE="${SURFACE_SCOPE:-ecosystem}" -export TALER_DOMAIN_FROM_CLI=0 - -# Do not run apt-deploy ensure for surface-only jobs -export APT_DEPLOY_ENSURE=0 - -exec bash "$AGENT_DIR/run-host-report.sh" "$@" diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service b/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service deleted file mode 100644 index 3d71eb1..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service +++ /dev/null @@ -1,18 +0,0 @@ -[Unit] -Description=Taler monitoring aptdeploy (apt-src container deploy tests) -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin -Environment=CONTINUE_ON_ERROR=1 -Environment=RUN_TIMEOUT=1800 -WorkingDirectory=%h/koopa-admin-log/scripts/taler-monitoring -ExecStart=%h/koopa-admin-log/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh -Nice=10 -TimeoutStartSec=45min - -[Install] -WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer b/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer deleted file mode 100644 index 602ce8b..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Periodic taler-monitoring aptdeploy (apt-src deploy tests) -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md - -[Timer] -OnBootSec=35min -OnUnitActiveSec=4h -AccuracySec=5min -Persistent=true -RandomizedDelaySec=10min -Unit=taler-monitoring-aptdeploy.service - -[Install] -WantedBy=timers.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path deleted file mode 100644 index 1cbf003..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path +++ /dev/null @@ -1,19 +0,0 @@ -[Unit] -Description=Watch hacktivism software/config changes → re-run monitoring -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md - -[Path] -# Explicit stamp after package/image upgrades (touch-software-stamp.sh) -PathChanged=%h/.local/state/taler-hacktivism-monitoring/software.stamp -PathModified=%h/.local/state/taler-hacktivism-monitoring/software.stamp -# Landing + caddy config in admin-log (when checked out under ~) -PathChanged=%h/koopa-admin-log/configs/bank-landing -PathChanged=%h/koopa-admin-log/configs/exchange-landing -PathChanged=%h/koopa-admin-log/configs/merchant-landing -PathChanged=%h/koopa-admin-log/configs/caddy -# Debounce: wait for quiet period before firing -TriggerLimitIntervalSec=120 -TriggerLimitBurst=3 - -[Install] -WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service deleted file mode 100644 index 712920c..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service +++ /dev/null @@ -1,21 +0,0 @@ -[Unit] -Description=Taler monitoring GOA/hacktivism (host-podman inside + public urls) -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -# Primary: host can podman-exec into taler-hacktivism* containers -Environment=INSIDE_PODMAN=1 -Environment=CONTINUE_ON_ERROR=1 -Environment=RUN_TIMEOUT=600 -Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin -WorkingDirectory=%h/koopa-admin-log/scripts/taler-monitoring -ExecStart=%h/koopa-admin-log/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh -Nice=10 -# Suite budget is RUN_TIMEOUT (10m) + git/HTML overhead -TimeoutStartSec=15min - -[Install] -WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer deleted file mode 100644 index b65c139..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Periodic hacktivism monitoring (fallback if no path events) -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md - -[Timer] -# Every 4h + boot -OnBootSec=10min -OnUnitActiveSec=4h -AccuracySec=5min -Persistent=true -Unit=taler-monitoring-hacktivism.service - -[Install] -WantedBy=timers.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-surface.service b/scripts/taler-monitoring/host-agent/taler-monitoring-surface.service deleted file mode 100644 index 6018dee..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-surface.service +++ /dev/null @@ -1,18 +0,0 @@ -[Unit] -Description=Taler monitoring surface (remote ecosystem inventory) -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin -Environment=CONTINUE_ON_ERROR=1 -Environment=RUN_TIMEOUT=2400 -WorkingDirectory=%h/koopa-admin-log/scripts/taler-monitoring -ExecStart=%h/koopa-admin-log/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh -Nice=10 -TimeoutStartSec=50min - -[Install] -WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer b/scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer deleted file mode 100644 index a9ed8c9..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Hourly taler-monitoring surface scan (ecosystem) -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md - -[Timer] -OnBootSec=20min -OnUnitActiveSec=1h -AccuracySec=2min -Persistent=true -RandomizedDelaySec=5min -Unit=taler-monitoring-surface.service - -[Install] -WantedBy=timers.target diff --git a/scripts/taler-monitoring/host-agent/touch-software-stamp.sh b/scripts/taler-monitoring/host-agent/touch-software-stamp.sh deleted file mode 100755 index 0aff400..0000000 --- a/scripts/taler-monitoring/host-agent/touch-software-stamp.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Call after upgrading hacktivism packages / redeploying landings / image rebuilds. -# systemd path unit watches this stamp → re-runs monitoring. -set -euo pipefail -STATE="${XDG_STATE_HOME:-$HOME/.local/state}/taler-hacktivism-monitoring" -mkdir -p "$STATE" -STAMP="$STATE/software.stamp" -date -Iseconds >"$STAMP" -# also record container image ids for debugging -if command -v podman >/dev/null 2>&1; then - { - echo "# images $(date -Iseconds)" - podman images --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.Digest}}' 2>/dev/null | grep -i hacktivism || true - podman ps --format '{{.Names}} {{.ImageID}} {{.Status}}' 2>/dev/null | grep -i hacktivism || true - } >"$STATE/software-images.txt" || true -fi -echo "touched $STAMP" diff --git a/scripts/taler-monitoring/host-agent/update-suite.sh b/scripts/taler-monitoring/host-agent/update-suite.sh deleted file mode 100755 index 70d348d..0000000 --- a/scripts/taler-monitoring/host-agent/update-suite.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# update-suite.sh — ensure taler-monitoring code is latest from Forgejo. -# -# Local config (NEVER overwritten by git): -# ${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/env -# -# Env overrides (also settable in that file): -# SUITE_GIT_URL default https://git.hacktivism.ch/hernani/koopa-admin-log.git -# SUITE_GIT_REF default main -# SUITE_DIR default $HOME/src/koopa-admin-log (or $HOME/koopa-admin-log) -# SUITE_UPDATE_MODE ff (default) | reset (reset = hard to origin/REF) -# -set -uo pipefail - -CFG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring" -ENV_FILE="${TALER_MONITORING_ENV:-$CFG_DIR/env}" -# When sourced from run-host-report.sh the env file is already loaded and -# wrapper PHASES/HTML_* must not be clobbered. Only load here if needed. -if [ -f "$ENV_FILE" ] && [ "${_TALER_MON_ENV_LOADED:-0}" != "1" ]; then - # shellcheck disable=SC1090 - set -a - # shellcheck source=/dev/null - source "$ENV_FILE" - set +a -fi - -SUITE_GIT_URL="${SUITE_GIT_URL:-https://git.hacktivism.ch/hernani/koopa-admin-log.git}" -SUITE_GIT_REF="${SUITE_GIT_REF:-main}" -# Host agents default to reset so "always latest from Forgejo" wins over local -# diverged trees. Override with SUITE_UPDATE_MODE=ff for careful pulls. -SUITE_UPDATE_MODE="${SUITE_UPDATE_MODE:-reset}" - -if [ -z "${SUITE_DIR:-}" ]; then - if [ -d "$HOME/src/koopa-admin-log/.git" ]; then - SUITE_DIR="$HOME/src/koopa-admin-log" - elif [ -d "$HOME/koopa-admin-log/.git" ]; then - SUITE_DIR="$HOME/koopa-admin-log" - else - SUITE_DIR="$HOME/src/koopa-admin-log" - fi -fi - -export SUITE_DIR - -mkdir -p "$(dirname "$SUITE_DIR")" "$CFG_DIR" - -if [ ! -d "$SUITE_DIR/.git" ]; then - echo "clone $SUITE_GIT_URL → $SUITE_DIR" - git clone --branch "$SUITE_GIT_REF" "$SUITE_GIT_URL" "$SUITE_DIR" \ - || git clone "$SUITE_GIT_URL" "$SUITE_DIR" -fi - -cd "$SUITE_DIR" || exit 1 -git remote set-url origin "$SUITE_GIT_URL" 2>/dev/null \ - || git remote add origin "$SUITE_GIT_URL" 2>/dev/null || true - -echo "fetch origin ($SUITE_GIT_REF) …" -if ! git fetch --tags origin 2>&1; then - echo "WARN: git fetch failed — using existing tree at $SUITE_DIR" >&2 - git rev-parse HEAD 2>/dev/null || true - return 0 2>/dev/null || exit 0 -fi - -case "$SUITE_UPDATE_MODE" in - reset) - git checkout -B "$SUITE_GIT_REF" "origin/$SUITE_GIT_REF" 2>/dev/null \ - || git checkout -B "$SUITE_GIT_REF" "FETCH_HEAD" - git reset --hard "origin/$SUITE_GIT_REF" 2>/dev/null \ - || git reset --hard FETCH_HEAD - ;; - *) - git checkout "$SUITE_GIT_REF" 2>/dev/null || git checkout -B "$SUITE_GIT_REF" "origin/$SUITE_GIT_REF" 2>/dev/null || true - if ! git pull --ff-only origin "$SUITE_GIT_REF" 2>&1; then - echo "WARN: ff-only pull failed — try SUITE_UPDATE_MODE=reset or fix local commits" >&2 - fi - ;; -esac - -COMMIT=$(git rev-parse HEAD) -COMMIT_SHORT=$(git rev-parse --short=12 HEAD) -# Forgejo web UI -SOURCE_REPO_WEB="${SOURCE_REPO_WEB:-https://git.hacktivism.ch/hernani/koopa-admin-log}" -COMMIT_URL="${SOURCE_REPO_WEB}/src/commit/${COMMIT}" -export COMMIT COMMIT_SHORT COMMIT_URL SOURCE_REPO_WEB - -echo "suite @ $COMMIT_SHORT $COMMIT_URL" -echo " dir=$SUITE_DIR" -echo " config=$ENV_FILE (not touched by git)" -echo " note: always latest = origin/${SUITE_GIT_REF} on git.hacktivism.ch (push local work there)" - -# Symlink convenience path if missing or not a git clone -if [ -d "$SUITE_DIR/.git" ]; then - if [ ! -e "$HOME/koopa-admin-log" ]; then - ln -sfn "$SUITE_DIR" "$HOME/koopa-admin-log" 2>/dev/null || true - elif [ -L "$HOME/koopa-admin-log" ]; then - ln -sfn "$SUITE_DIR" "$HOME/koopa-admin-log" 2>/dev/null || true - fi -fi - -# Make suite scripts executable after reset -if [ -d "$SUITE_DIR/scripts/taler-monitoring" ]; then - chmod +x \ - "$SUITE_DIR"/scripts/taler-monitoring/taler-monitoring.sh \ - "$SUITE_DIR"/scripts/taler-monitoring/check_*.sh \ - "$SUITE_DIR"/scripts/taler-monitoring/host-agent/*.sh \ - "$SUITE_DIR"/scripts/taler-monitoring/site-gen/*.sh \ - "$SUITE_DIR"/scripts/taler-monitoring/site-gen/*.py \ - 2>/dev/null || true -fi diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh deleted file mode 100755 index 8455c6b..0000000 --- a/scripts/taler-monitoring/lib.sh +++ /dev/null @@ -1,1257 +0,0 @@ -# shellcheck shell=bash -# Shared helpers for taler-monitoring (laptop or koopa). - -# Default stack = GOA / hacktivism (overridden by TALER_DOMAIN / --domain) -: "${TALER_DOMAIN:=hacktivism.ch}" -: "${BANK_PUBLIC:=https://bank.hacktivism.ch}" -: "${EXCHANGE_PUBLIC:=https://exchange.hacktivism.ch}" -: "${MERCHANT_PUBLIC:=https://taler.hacktivism.ch}" -: "${BANK_LOCAL:=http://127.0.0.1:9012}" -: "${EXCHANGE_LOCAL:=http://127.0.0.1:9011}" -: "${MERCHANT_LOCAL:=https://127.0.0.1:9010}" -: "${LANDING_LOCAL:=http://127.0.0.1:9013}" -: "${KOOPA_SSH:=koopa}" -# 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}" -# Merchant portal /private API: instance ids must be lowercase or you get odd 401s. -# (Display names may use capitals; path segment /instances/{id}/ must not.) -MERCHANT_INSTANCE=$(printf '%s' "${MERCHANT_INSTANCE}" | tr '[:upper:]' '[:lower:]') -: "${WITHDRAW_AMT:=GOA:20}" # single-shot fallback; e2e ladder uses ATM notes -: "${PAY_AMT:=GOA:0.01}" -: "${CREDIT_AMT:=GOA:4700}" # covers ATM ladder 20+50+100+200+4200 (paivana) -: "${TIMEOUT:=12}" -: "${E2E_TIMEOUT:=55}" # whole e2e budget; skip rest when exceeded (e2e raises as needed) -: "${E2E_PAY_SECS:=22}" # dedicated seconds for pay handle-uri (avoid Alarm clock) -# Whole-run wall clock for taler-monitoring.sh (seconds). 0 = unlimited. -# Host-agent default phases (urls/inside/versions) should finish under this. -: "${RUN_TIMEOUT:=600}" -# Local GOA: public paivana paywall (https://paivana.hacktivism.ch · template GOA:4200) -: "${PAIVANA_PUBLIC:=https://paivana.hacktivism.ch}" -: "${E2E_PAIVANA:=1}" # 0 = skip paivana section in e2e -# Devtest: inject reserve credit via wire-gateway admin/add-incoming (optional). -# Default off once wirewatch DNS works; set E2E_FAKE_INCOMING=1 to force. -: "${E2E_FAKE_INCOMING:=0}" -# SSH must never hang the monitoring run -: "${SSH_CONNECT_TIMEOUT:=3}" -: "${SSH_CMD_TIMEOUT:=12}" # hard cap for whole remote script (seconds) -: "${SKIP_SSH:=0}" -# Expected currency for public /config checks (empty = report only, don't fail) -# Only if unset (not if intentionally empty = pre-launch / report-only) -: "${EXPECT_CURRENCY=GOA}" -# 1 = this is the local koopa/hacktivism stack (inside/e2e/SSH make sense) -: "${LOCAL_STACK:=1}" -# Probe merchant host candidates when applying a generic domain (0=off) -: "${TALER_DOMAIN_PROBE:=1}" - -BANK_PUBLIC=${BANK_PUBLIC%/} -EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} -MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} - -# --------------------------------------------------------------------------- -# Domain profiles → bank / exchange / merchant base URLs -# -# Single place to declare a stack: scripts/taler-monitoring/domains.conf -# (or TALER_DOMAINS_CONF). Each profile names the three public endpoints. -# -# CLI still wins after profile load: -# --bank URL --exchange URL --merchant URL --currency CODE -# Env: BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC EXPECT_CURRENCY -# -# Unknown domains fall back to heuristics (see apply_taler_domain). -# --------------------------------------------------------------------------- - -_MONITOR_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -: "${TALER_DOMAINS_CONF:=${_MONITOR_LIB_DIR}/domains.conf}" - -# host_or_url → https://… (no trailing slash) -_to_https_base() { - local x="${1%/}" - case "$x" in - https://*|http://*) printf '%s' "$x" ;; - "") printf '' ;; - *) printf 'https://%s' "$x" ;; - esac -} - -# 1 = check GOA-style /intro landing pages (assets, link crawl, demo-withdraw). -# 0 = skip (e.g. taler-ops.ch / mytops — no public landings). -: "${CHECK_LANDING:=1}" - -# Declare bank + exchange + merchant (+ currency, local stack, landing, domain id). -# Usage: -# set_taler_stack BANK EXCHANGE MERCHANT [CURRENCY] [LOCAL 0|1] [LANDING 0|1] [TALER_DOMAIN] -# BANK/EXCHANGE/MERCHANT: hostname or full URL -set_taler_stack() { - local bank="${1:-}" exchange="${2:-}" merchant="${3:-}" - local currency="${4:-}" local_stack="${5:-0}" landing="${6:-}" domain_id="${7:-}" - - BANK_PUBLIC=$(_to_https_base "$bank") - EXCHANGE_PUBLIC=$(_to_https_base "$exchange") - MERCHANT_PUBLIC=$(_to_https_base "$merchant") - # currency "-" or empty → report-only (do not force GOA) - if [ -n "$currency" ] && [ "$currency" != "-" ]; then - EXPECT_CURRENCY="$currency" - elif [ "$currency" = "-" ]; then - EXPECT_CURRENCY="" - fi - LOCAL_STACK="$local_stack" - if [ "$LOCAL_STACK" = "1" ]; then - SKIP_SSH=0 - else - SKIP_SSH=1 - fi - # default: landings only matter on local GOA stack - if [ -n "$landing" ]; then - CHECK_LANDING="$landing" - elif [ "$LOCAL_STACK" = "1" ]; then - CHECK_LANDING=1 - else - CHECK_LANDING=0 - fi - [ -n "$domain_id" ] && TALER_DOMAIN="$domain_id" - - BANK_PUBLIC=${BANK_PUBLIC%/} - EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} - MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} -} - -# Load first matching profile from domains.conf. -# Fields (whitespace-separated; # comments; blank lines ignored): -# name bank exchange merchant currency local[0|1] landing[0|1] [canonical_domain] -# Returns 0 if found, 1 if not. -load_domain_profile() { - local want="$1" conf="${TALER_DOMAINS_CONF:-}" - local line name bank exchange merchant currency local_stack landing canon - [ -n "$want" ] || return 1 - [ -n "$conf" ] && [ -f "$conf" ] || return 1 - - while IFS= read -r line || [ -n "$line" ]; do - line="${line%%#*}" - # shellcheck disable=SC2086 - set -- $line - [ $# -ge 5 ] || continue - name="$1" - [ "$name" = "$want" ] || continue - bank="$2" - exchange="$3" - merchant="$4" - currency="$5" - local_stack="${6:-0}" - landing="${7:-}" - canon="${8:-$name}" - # Backward compat: old 7th field was canonical domain (contains a dot) - if [ -n "$landing" ] && [[ "$landing" == *.* && "$landing" != "0" && "$landing" != "1" ]]; then - canon="$landing" - landing="" - fi - set_taler_stack "$bank" "$exchange" "$merchant" "$currency" "$local_stack" "$landing" "$canon" - return 0 - done <"$conf" - return 1 -} - -_normalize_domain() { - local d="$1" - d="${d#https://}" - d="${d#http://}" - d="${d%%/*}" - d="${d%%:*}" - # Strip service host prefix only if a real domain remains (has a dot). - # e.g. bank.demo.taler.net → demo.taler.net, taler.hacktivism.ch → hacktivism.ch - # but NOT taler.net → net - case "$d" in - bank.*|exchange.*|taler.*|backend.*|merchant.*|shop.*|libeufin.*|my.*|map.*|monnaie.*) - rest="${d#*.}" - if [[ "$rest" == *.* ]]; then - d="$rest" - fi - ;; - esac - printf '%s' "$d" -} - -_probe_https_config() { - # 0 if https://$1/config returns 200 - local host="$1" code - code=$(curl -skS --max-redirs 0 -m 4 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000) - [ "$code" = "200" ] -} - -apply_taler_domain() { - local raw="${1:-}" - local d - [ -n "$raw" ] || return 0 - d=$(_normalize_domain "$raw") - TALER_DOMAIN="$d" - - # 1) Explicit profile (domains.conf) — preferred way to add stacks - if load_domain_profile "$d" || load_domain_profile "$raw"; then - # Optional tiny defaults for e2e ladders by currency - case "${EXPECT_CURRENCY:-}" in - GOA) - : "${WITHDRAW_AMT:=GOA:20}" - : "${PAY_AMT:=GOA:0.01}" - : "${CREDIT_AMT:=GOA:400}" - ;; - KUDOS|TESTKUDOS) - MERCHANT_INSTANCE="${MERCHANT_INSTANCE:-sandbox}" - WITHDRAW_AMT="${WITHDRAW_AMT:-${EXPECT_CURRENCY}:20}" - PAY_AMT="${PAY_AMT:-${EXPECT_CURRENCY}:0.01}" - CREDIT_AMT="${CREDIT_AMT:-${EXPECT_CURRENCY}:100}" - ;; - TESTPAYSAN) - # Stage FrancPaysan: farmer shops (override GOA default goa-demo-cp4zqk) - case "${MERCHANT_INSTANCE:-}" in - ""|goa-demo*|goa-shop) - MERCHANT_INSTANCE=fermes-des-collines - ;; - esac - MERCHANT_INSTANCE=$(printf '%s' "${MERCHANT_INSTANCE}" | tr '[:upper:]' '[:lower:]') - WITHDRAW_AMT="${WITHDRAW_AMT:-TESTPAYSAN:50}" - PAY_AMT="${PAY_AMT:-TESTPAYSAN:5}" - # Cover oeufs(5)+fromage(8.5)+jus(6)+shop pick(~5–12) + fees - CREDIT_AMT="${CREDIT_AMT:-TESTPAYSAN:200}" - # Public template pays on by default (shops landings) - : "${E2E_USE_TEMPLATES:=1}" - # Map pay-ladder amounts → public shop templates (instance set in e2e) - : "${E2E_TEMPLATE_MAP:=5=oeufs-6 4.5=pain-seigle 6=jus-pomme 8.5=fromage-chevre 12=miel-printemps 25=panier-legumes}" - : "${E2E_PAY_VALUES:=5 8.5 6}" - : "${E2E_WITHDRAW_VALUES:=20 50}" - # Inside checks as low-priv stagepaysan (podman, no sudo) — not koopa - INSIDE_PROFILE="${INSIDE_PROFILE:-stage-lfp}" - INSIDE_SSH="${INSIDE_SSH:-francpaysan-stage-user}" - INSIDE_BANK_CTR="${INSIDE_BANK_CTR:-stage-lfp-bank}" - INSIDE_EXCHANGE_CTR="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" - INSIDE_MERCHANT_CTR="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" - # Host pasta ports (127.0.0.1 on francpaysan-host) - INSIDE_BANK_PORT="${INSIDE_BANK_PORT:-9032}" - INSIDE_EXCHANGE_PORT="${INSIDE_EXCHANGE_PORT:-9031}" - INSIDE_MERCHANT_PORT="${INSIDE_MERCHANT_PORT:-9030}" - INSIDE_DNS_BANK="${INSIDE_DNS_BANK:-stage.bank.lefrancpaysan.ch}" - INSIDE_DNS_EXCHANGE="${INSIDE_DNS_EXCHANGE:-stage.exchange.lefrancpaysan.ch}" - INSIDE_DNS_MERCHANT="${INSIDE_DNS_MERCHANT:-stage.monnaie.lefrancpaysan.ch}" - ;; - CHF) - WITHDRAW_AMT="${WITHDRAW_AMT:-CHF:20}" - PAY_AMT="${PAY_AMT:-CHF:0.01}" - CREDIT_AMT="${CREDIT_AMT:-CHF:100}" - ;; - esac - else - # 2) Unknown domain — heuristics only (prefer profile in domains.conf) - # Override: --bank / --exchange / --merchant / --currency - # Do not keep default GOA: empty currency = report only, no hard fail. - BANK_PUBLIC="https://bank.${d}" - EXCHANGE_PUBLIC="https://exchange.${d}" - # TOPS-style multi-tenant merchant first, then legacy names - MERCHANT_PUBLIC="https://my.${d}" - EXPECT_CURRENCY="" - LOCAL_STACK=0 - SKIP_SSH=1 - CHECK_LANDING=0 - if [ "${TALER_DOMAIN_PROBE}" = "1" ]; then - local h - for h in "monnaie.${d}" "backend.${d}" "my.${d}" "taler.${d}" "merchant.${d}" "shop.${d}"; do - _probe_https_config "$h" && { MERCHANT_PUBLIC="https://$h"; break; } - done - for h in "bank.${d}" "libeufin.${d}"; do - _probe_https_config "$h" && { BANK_PUBLIC="https://$h"; break; } - done - _probe_https_config "exchange.${d}" || true - fi - fi - - BANK_PUBLIC=${BANK_PUBLIC%/} - EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} - MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} - - # Koopa SSH only for LOCAL_STACK=1. Stage LFP uses INSIDE_SSH (stagepaysan) separately. - if [ "${LOCAL_STACK}" != "1" ]; then - SKIP_SSH=1 - fi - # Default inside profile for local GOA - if [ "${LOCAL_STACK}" = "1" ]; then - INSIDE_PROFILE="${INSIDE_PROFILE:-koopa}" - fi -} - -# Apply TALER_DOMAIN from env once (CLI exports TALER_DOMAIN_APPLIED=1 after overrides). -if [ "${TALER_DOMAIN_APPLIED:-0}" != "1" ] \ - && [ -n "${TALER_DOMAIN:-}" ] && [ "${TALER_DOMAIN}" != "hacktivism.ch" ]; then - apply_taler_domain "$TALER_DOMAIN" - TALER_DOMAIN_APPLIED=1 -fi - -# Safe SSH: publickey only, short connect, overall alarm so we never block forever. -SSH_BASE_OPTS=( - -o BatchMode=yes - -o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" - -o ConnectionAttempts=1 - -o ServerAliveInterval=3 - -o ServerAliveCountMax=2 - -o StrictHostKeyChecking=accept-new - -o PreferredAuthentications=publickey - -o PasswordAuthentication=no - -o KbdInteractiveAuthentication=no - -o GSSAPIAuthentication=no - -o NumberOfPasswordPrompts=0 -) - -# Hard wall-clock timeout so ssh/curl never block the monitoring run forever. -with_timeout() { - local secs="$1"; shift - if command -v gtimeout >/dev/null 2>&1; then - gtimeout --kill-after=2 "$secs" "$@" - return $? - fi - if command -v timeout >/dev/null 2>&1; then - timeout -k 2 "$secs" "$@" 2>/dev/null || timeout --kill-after=2 "$secs" "$@" - return $? - fi - # Portable: perl alarm + process group kill - perl -e ' - use strict; use warnings; - my $secs = shift @ARGV; - my $pid = fork(); - die "fork: $!" unless defined $pid; - if ($pid == 0) { - setpgrp(0, 0); - exec @ARGV; - exit 127; - } - $SIG{ALRM} = sub { - kill "TERM", -$pid; - select(undef, undef, undef, 1.0); - kill "KILL", -$pid; - exit 124; - }; - alarm $secs; - waitpid($pid, 0); - my $code = $? >> 8; - alarm 0; - exit $code; - ' "$secs" "$@" -} - -# Generic short SSH to a named host (low-priv stagepaysan or koopa). -# usage: mon_ssh_bash HOST [timeout] <<'EOF' ... EOF -mon_ssh_bash() { - local host="$1" t="${2:-$SSH_CMD_TIMEOUT}" - [ -n "$host" ] || return 1 - with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "$host" bash -s -} -mon_ssh_ok() { - local host="$1" - [ -n "$host" ] || return 1 - with_timeout $((SSH_CONNECT_TIMEOUT + 5)) \ - ssh "${SSH_BASE_OPTS[@]}" "$host" true >/dev/null 2>&1 -} - -# 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 - resolve_koopa_ssh -} - -# Run remote bash -s with optional stdin script; hard-capped -# usage: koopa_ssh_bash [timeout_secs] <<'EOF' ... EOF -# or: koopa_ssh_run timeout_secs 'remote command' -koopa_ssh_run() { - local t="${1:-$SSH_CMD_TIMEOUT}" - shift - 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 - -} - -# ANSI colours — left badge (clear severity) + body label colour + dim detail. -# Off: NO_COLOR=1 or CLICOLOR=0. -# OK green badge · green label -# INFO blue badge · cyan label -# WARN yellow badge · yellow label (never red) -# ERROR red badge · red label -# BLOCKER magenta badge· magenta label -# progress cyan badge = " 42%" + bar fills left→right -if [ "${NO_COLOR:-0}" = "1" ] || [ "${CLICOLOR:-1}" = "0" ]; then - G= R= Y= C= M= D= W= B= N= - BG_OK= BG_INFO= BG_WARN= BG_ERR= BG_BLK= BG_SEC= BG_PROG= - BOX=0 -else - G=$'\e[1;32m' # green - R=$'\e[1;31m' # red - Y=$'\e[1;33m' # yellow - C=$'\e[1;36m' # cyan - M=$'\e[1;35m' # magenta - D=$'\e[2m' # dim tid / detail - W=$'\e[1;37m' # bold white - B=$'\e[1m' # bold - N=$'\e[0m' # reset - # High-contrast left badges (bright white / black on strong bg) - BG_OK=$'\e[1;97;42m' # white on green - BG_INFO=$'\e[1;97;44m' # white on blue - BG_WARN=$'\e[1;30;103m' # black on bright yellow — not red - BG_ERR=$'\e[1;97;41m' # white on red — errors only - BG_BLK=$'\e[1;97;45m' # white on magenta — blockers - BG_SEC=$'\e[1;97;44m' # white on blue (section) - BG_PROG=$'\e[1;30;106m' # black on bright cyan (progress) - BOX=1 -fi - -PASS_N=0 -FAIL_N=0 -WARN_N=0 -INFO_N=0 -BLOCKERS=() # human-readable payment/withdraw blockers -ERRORS=() # all ERROR lines (component scope) - -# Grouped test IDs: area.group-NN (e.g. www.exchange-01, e2e.pay-03) -# plus global run number: #042 (monotonic for the whole ./taler-monitoring.sh run) -# set_area www # phase: www | e2e | inside | versions | … -# set_group exchange # → www.exchange-01 -# set_group bank # → www.bank-01 -# Without set_group: area-01, area-02, … (flat within area) -# Line shape: ┌ OK ┐ #003 www.exchange-02 label · detail -TEST_AREA="" -TEST_GROUP="" -TEST_N=0 -LAST_TID="" -# Global / progress: may continue across check_*.sh processes via TALER_MON_STATE -GLOBAL_N="${GLOBAL_N:-0}" -LAST_GLOBAL="" -PROGRESS_DONE="${PROGRESS_DONE:-0}" -PROGRESS_TOTAL="${PROGRESS_TOTAL:-0}" -PROGRESS_SHOW_EVERY="${PROGRESS_SHOW_EVERY:-8}" -PROGRESS_LAST_SHOWN="${PROGRESS_LAST_SHOWN:-0}" -_mon_state_load() { - [ -n "${TALER_MON_STATE:-}" ] && [ -f "${TALER_MON_STATE}" ] || return 0 - # shellcheck disable=SC1090 - . "${TALER_MON_STATE}" -} -_mon_state_save() { - [ -n "${TALER_MON_STATE:-}" ] || return 0 - { - printf 'GLOBAL_N=%s\n' "$GLOBAL_N" - printf 'PROGRESS_DONE=%s\n' "$PROGRESS_DONE" - printf 'PROGRESS_TOTAL=%s\n' "$PROGRESS_TOTAL" - printf 'PROGRESS_LAST_SHOWN=%s\n' "$PROGRESS_LAST_SHOWN" - } >"${TALER_MON_STATE}" -} -_mon_state_load -# Per-group counters so re-entering www.exchange after perf continues NN -# (shell vars TEST_CNT__). -_tid_key() { - # $1=area $2=group → safe identifier - printf 'TEST_CNT_%s_%s' "$1" "$2" | tr -c 'A-Za-z0-9_' '_' -} -_tid_save() { - [ -z "${TEST_AREA:-}" ] || [ -z "${TEST_GROUP:-}" ] && return 0 - local k - k=$(_tid_key "$TEST_AREA" "$TEST_GROUP") - eval "$k=\$TEST_N" -} -_tid_load() { - local k - k=$(_tid_key "$TEST_AREA" "$1") - eval "TEST_N=\${$k:-0}" -} -set_area() { - # leave previous group counter saved - _tid_save - TEST_AREA="$1" - TEST_GROUP="" - TEST_N=0 - LAST_TID="" -} -# Start a logical sub-group. Short stable names: -# exchange bank merchant perf stats landing paivana -# prereq wallet atm settle pay shop dig load report -# ssh caddy outside inside compare withdraw -# Counter continues if the same group is re-entered later in the area. -# Prints a compact group chip so log + issue text map cleanly (www.bank-03). -set_group() { - local g="$1" - # no-op if same group already active (e.g. inside emit loop) - if [ "$g" = "${TEST_GROUP:-}" ] && [ -n "$g" ]; then - return 0 - fi - _tid_save - TEST_GROUP="$g" - LAST_TID="" - if [ -z "${TEST_AREA:-}" ] || [ -z "$g" ]; then - TEST_N=0 - return 0 - fi - _tid_load "$g" - local tag="${TEST_AREA}.${g}" - if [ "${BOX:-0}" = "1" ]; then - # cyan-outline group chip: ┌ www.exchange ┐ - printf '%s%s┌ %s ┐%s\n' "$D" "$C" "$tag" "$N" - else - printf -- '-- %s --\n' "$tag" - fi - # show progress when entering a new group (if totals known) - _progress_maybe_show 1 -} -set_progress_total() { - # Optional: expected number of numbered checks in this run. - # PROGRESS_TOTAL=0 → no percent, only "done=N". - # Does not reset GLOBAL_N / PROGRESS_DONE if already mid-run (state file). - local n="${1:-0}" - # Never shrink below already completed work (re-estimate mid-run safe) - if [ "${PROGRESS_DONE:-0}" -gt 0 ] && [ "$n" -gt 0 ] && [ "$n" -lt "$PROGRESS_DONE" ]; then - n=$PROGRESS_DONE - fi - PROGRESS_TOTAL="$n" - if [ "${PROGRESS_DONE:-0}" -eq 0 ]; then - PROGRESS_LAST_SHOWN=0 - fi - _mon_state_save -} -add_progress_total() { - local n="${1:-0}" - PROGRESS_TOTAL=$((PROGRESS_TOTAL + n)) - _mon_state_save -} -# When estimate was short, grow total so we never print done>total (e.g. 224/205). -# Keeps ~10% headroom so the bar is not stuck at 100% while checks continue. -_progress_rebalance() { - [ "${PROGRESS_TOTAL:-0}" -gt 0 ] || return 0 - [ "${PROGRESS_DONE:-0}" -gt 0 ] || return 0 - if [ "$PROGRESS_DONE" -gt "$PROGRESS_TOTAL" ]; then - local head=$((PROGRESS_DONE / 10)) - [ "$head" -lt 12 ] && head=12 - PROGRESS_TOTAL=$((PROGRESS_DONE + head)) - fi -} -_progress_bar_line() { - # Format (grows left → right; badge = percent, not "PROG"): - # ┌ 42% ┐ ████████░░░░░░░░░░░░░░░░ 31/74 - local done="$1" total="$2" width=28 pct=0 filled empty i bar pct_lab - # Defensive: never display done > total - if [ "$total" -gt 0 ] && [ "$done" -gt "$total" ]; then - total=$done - fi - if [ "$total" -gt 0 ]; then - # integer percent; while mid-run past a short estimate, cap display < 100% - pct=$((done * 100 / total)) - [ "$pct" -gt 100 ] && pct=100 - if [ "$done" -lt "$total" ] && [ "$pct" -ge 100 ]; then - pct=99 - fi - # fill cells from the left (progress moves rightward as filled grows) - filled=$((done * width / total)) - [ "$filled" -gt "$width" ] && filled=$width - # show at least 1 block once any work done (unless 0%) - if [ "$done" -gt 0 ] && [ "$filled" -eq 0 ]; then - filled=1 - fi - # at 100% fill complete bar - if [ "$done" -ge "$total" ]; then - filled=$width - pct=100 - fi - empty=$((width - filled)) - bar="" - i=0 - while [ "$i" -lt "$filled" ]; do bar="${bar}█"; i=$((i + 1)); done - i=0 - while [ "$i" -lt "$empty" ]; do bar="${bar}░"; i=$((i + 1)); done - pct_lab=$(printf '%3d%%' "$pct") - if [ "${BOX:-0}" = "1" ]; then - # badge = percent (clearer than "PROG"); bar grows ░→█ left to right - printf '%s┌%s┐%s %s%s%s %s%d/%d%s\n' \ - "$BG_PROG" "$pct_lab" "$N" \ - "$C" "$bar" "$N" \ - "$D" "$done" "$total" "$N" - else - printf -- '%s%4s%s %s %d/%d\n' \ - "$C" "$pct_lab" "$N" "$bar" "$done" "$total" - fi - else - if [ "${BOX:-0}" = "1" ]; then - printf '%s┌ … ┐%s %sdone=%d%s (set PROGRESS_TOTAL= for %%)\n' \ - "$BG_PROG" "$N" "$D" "$done" "$N" - else - printf -- '%sdone=%d%s (set PROGRESS_TOTAL= for %%)\n' "$D" "$done" "$N" - fi - fi -} -_progress_maybe_show() { - local force="${1:-0}" - [ "${PROGRESS_OFF:-0}" = "1" ] && return 0 - [ "$PROGRESS_DONE" -le 0 ] && [ "$force" != "1" ] && return 0 - _progress_rebalance - if [ "$force" = "1" ] || \ - [ $((PROGRESS_DONE - PROGRESS_LAST_SHOWN)) -ge "$PROGRESS_SHOW_EVERY" ] || \ - { [ "$PROGRESS_TOTAL" -gt 0 ] && [ "$PROGRESS_DONE" -ge "$PROGRESS_TOTAL" ]; }; then - _progress_bar_line "$PROGRESS_DONE" "$PROGRESS_TOTAL" - PROGRESS_LAST_SHOWN=$PROGRESS_DONE - fi -} -# Assign next id into LAST_TID (must not run in a subshell). -_take_tid() { - LAST_TID="" - LAST_GLOBAL="" - if [ -z "${TEST_AREA:-}" ]; then - return - fi - # Global monotonic number for the whole monitoring run (#001 …) - GLOBAL_N=$((GLOBAL_N + 1)) - LAST_GLOBAL=$(printf '#%03d' "$GLOBAL_N") - TEST_N=$((TEST_N + 1)) - if [ -n "${TEST_GROUP:-}" ]; then - LAST_TID=$(printf '%s.%s-%02d' "$TEST_AREA" "$TEST_GROUP" "$TEST_N") - _tid_save - else - LAST_TID=$(printf '%s-%02d' "$TEST_AREA" "$TEST_N") - fi - PROGRESS_DONE=$((PROGRESS_DONE + 1)) - _progress_rebalance - _mon_state_save - _progress_maybe_show 0 -} -# Dim ids: "#003 www.exchange-01 " or empty -_fmt_tid() { - if [ -n "${LAST_GLOBAL:-}" ]; then - printf '%s%s%s ' "$D" "$LAST_GLOBAL" "$N" - fi - if [ -n "${LAST_TID:-}" ]; then - printf '%s%s%s ' "$D" "$LAST_TID" "$N" - fi -} -# Boxed badge cell: ┌ OK ┐ with filled bg (tag left-padded, width 7 for BLOCKER) -# $1=badge style $2=tag text -_fmt_badge() { - local badge="$1" tag="$2" inner - inner=$(printf -- '%-7s' "$tag") - if [ "${BOX:-0}" = "1" ]; then - printf '%s┌ %s┐%s' "$badge" "$inner" "$N" - else - printf '%s[ %s]%s' "$badge" "$inner" "$N" - fi -} -# One concrete line: ┌ OK ┐ tid label · detail -# $1=badge style $2=tag text $3=label colour $4=label $5=detail -_msg_line() { - local badge="$1" tag="$2" lcol="$3" label="$4" detail="${5:-}" - if [ -n "$detail" ]; then - printf -- '%s %s%s%s%s %s·%s %s%s%s\n' \ - "$(_fmt_badge "$badge" "$tag")" "$(_fmt_tid)" "$lcol" "$label" "$N" "$D" "$N" "$D" "$detail" "$N" - else - printf -- '%s %s%s%s%s\n' \ - "$(_fmt_badge "$badge" "$tag")" "$(_fmt_tid)" "$lcol" "$label" "$N" - fi -} - -ok() { - # ok "what is good" ["concrete evidence: HTTP 200 · 12ms · …"] - local label="$1" detail="${2:-}" - _take_tid - _msg_line "$BG_OK" "OK" "$G" "$label" "$detail" - PASS_N=$((PASS_N + 1)) -} -# component-scoped error: err bank "what failed" "why / HTTP / path" -err() { - local comp="$1" msg="$2" detail="${3:-}" - _take_tid - _msg_line "$BG_ERR" "ERROR" "$R" "${comp}: ${msg}" "$detail" - FAIL_N=$((FAIL_N + 1)) - ERRORS+=("${LAST_TID:+$LAST_TID }[$comp] $msg${detail:+ · $detail}") -} -# fail "what failed" ["why / HTTP code / path"] -fail() { - local label="$1" detail="${2:-}" - _take_tid - _msg_line "$BG_ERR" "ERROR" "$R" "$label" "$detail" - FAIL_N=$((FAIL_N + 1)) - ERRORS+=("${LAST_TID:+$LAST_TID }$label${detail:+ · $detail}") -} -warn() { - # warn "what is soft-bad" ["why still ok to continue"] - # warn component "what" "why" - # Yellow only — never red (red = ERROR / BLOCKER path) - local a1="${1:-}" a2="${2:-}" a3="${3:-}" - local head detail - _take_tid - if [ -n "$a3" ]; then - head="${a1}: ${a2}" - detail="$a3" - elif [ -n "$a2" ]; then - head="$a1" - detail="$a2" - else - head="$a1" - detail="" - fi - _msg_line "$BG_WARN" "WARN" "$Y" "$head" "$detail" - WARN_N=$((WARN_N + 1)) -} -info() { - # info "topic" ["concrete fact / value / next step"] - local label="$1" detail="${2:-}" - _take_tid - _msg_line "$BG_INFO" "INFO" "$C" "$label" "$detail" - INFO_N=$((INFO_N + 1)) -} -blocker() { - # Hard stop on pay/withdraw path: blocker "step" "why it cannot continue" - local step="$1" msg="$2" - _take_tid - _msg_line "$BG_BLK" "BLOCKER" "$M" "${step}" "$msg" - BLOCKERS+=("${LAST_TID:+$LAST_TID }[$step] $msg") - FAIL_N=$((FAIL_N + 1)) - ERRORS+=("BLOCKER ${LAST_TID:+$LAST_TID }[$step] $msg") -} -section() { - # Boxed section header (3 lines when colour on) - local title="$*" - local w=${#title} - [ "$w" -lt 24 ] && w=24 - [ "$w" -gt 56 ] && w=56 - local pad line i - pad=$(printf -- "%-${w}s" "$title") - if [ "${BOX:-0}" = "1" ]; then - line="" - i=0 - while [ "$i" -lt $((w + 2)) ]; do - line="${line}═" - i=$((i + 1)) - done - printf -- '\n%s╔%s╗%s\n' "$BG_SEC" "$line" "$N" - printf -- '%s║%s %s%s%s %s║%s\n' "$BG_SEC" "$N" "$B" "$pad" "$N" "$BG_SEC" "$N" - printf -- '%s╚%s╝%s\n' "$BG_SEC" "$line" "$N" - else - printf -- '\n== %s ==\n' "$title" - fi -} - -summary() { - local blk_n=${#BLOCKERS[@]} - echo "" - # final progress line (no _take_tid — plain printf) - if [ "${PROGRESS_OFF:-0}" != "1" ] && [ "$PROGRESS_DONE" -gt 0 ]; then - # Snap total to actual so last line is exact N/N 100% - if [ "$PROGRESS_TOTAL" -gt 0 ] && [ "$PROGRESS_DONE" -ne "$PROGRESS_TOTAL" ]; then - PROGRESS_TOTAL=$PROGRESS_DONE - _mon_state_save - fi - _progress_bar_line "$PROGRESS_DONE" "$PROGRESS_TOTAL" - fi - if [ "$GLOBAL_N" -gt 0 ]; then - printf -- '%s numbered checks this run: #001…#%03d%s\n' "$D" "$GLOBAL_N" "$N" - fi - if [ "$blk_n" -gt 0 ]; then - if [ "${BOX:-0}" = "1" ]; then - printf -- '%s┌ BLOCKERS · pay/withdraw cannot finish ┐%s\n' "$BG_BLK" "$N" - else - printf -- '%s--- BLOCKERS (pay/withdraw cannot finish) ---%s\n' "$M" "$N" - fi - local b - for b in "${BLOCKERS[@]}"; do - printf -- ' %s•%s %s%s%s\n' "$M" "$N" "$W" "$b" "$N" - done - fi - if [ "${#ERRORS[@]}" -gt 0 ] && [ "$blk_n" -lt "${#ERRORS[@]}" ]; then - if [ "${BOX:-0}" = "1" ]; then - printf -- '%s┌ ERRORS · failed checks ┐%s\n' "$BG_ERR" "$N" - else - printf -- '%s--- ERRORS (failed checks) ---%s\n' "$R" "$N" - fi - local e - for e in "${ERRORS[@]}"; do - case "$e" in BLOCKER*) continue ;; esac - printf -- ' %s•%s %s%s%s\n' "$R" "$N" "$W" "$e" "$N" - done - fi - - # Coloured totals — same severity badges as check lines (always; NO_COLOR blanks ANSI) - if [ "${BOX:-0}" = "1" ]; then - printf -- '\n%s┌ SUMMARY ┐%s\n' "$BG_SEC" "$N" - _sum_badge() { # $1=bg $2=tag $3=fg $4=n - printf -- ' %s┌ %-5s┐%s %s%4d%s\n' "$1" "$2" "$N" "$3" "$4" "$N" - } - _sum_badge "$BG_OK" "OK" "$G" "$PASS_N" - [ "$FAIL_N" -gt 0 ] && _sum_badge "$BG_ERR" "ERROR" "$R" "$FAIL_N" - [ "$WARN_N" -gt 0 ] && _sum_badge "$BG_WARN" "WARN" "$Y" "$WARN_N" - [ "$INFO_N" -gt 0 ] && _sum_badge "$BG_INFO" "INFO" "$C" "$INFO_N" - [ "$blk_n" -gt 0 ] && _sum_badge "$BG_BLK" "BLOCK" "$M" "$blk_n" - else - printf -- '\n[ SUMMARY ]\n' - printf -- ' [ OK ] %s%4d%s\n' "$G" "$PASS_N" "$N" - [ "$FAIL_N" -gt 0 ] && printf -- ' [ ERROR ] %s%4d%s\n' "$R" "$FAIL_N" "$N" - [ "$WARN_N" -gt 0 ] && printf -- ' [ WARN ] %s%4d%s\n' "$Y" "$WARN_N" "$N" - [ "$INFO_N" -gt 0 ] && printf -- ' [ INFO ] %s%4d%s\n' "$C" "$INFO_N" "$N" - [ "$blk_n" -gt 0 ] && printf -- ' [ BLOCK ] %s%4d%s\n' "$M" "$blk_n" "$N" - fi - # one-line rollup (bold label, severity-coloured counts) - printf -- ' %stotals:%s %s%d OK%s' "$B" "$N" "$G" "$PASS_N" "$N" - [ "$FAIL_N" -gt 0 ] && printf -- ' · %s%d ERROR%s' "$R" "$FAIL_N" "$N" - [ "$WARN_N" -gt 0 ] && printf -- ' · %s%d WARN%s' "$Y" "$WARN_N" "$N" - [ "$INFO_N" -gt 0 ] && printf -- ' · %s%d INFO%s' "$C" "$INFO_N" "$N" - [ "$blk_n" -gt 0 ] && printf -- ' · %s%d BLOCKER%s' "$M" "$blk_n" "$N" - printf '\n' - # overall verdict - if [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ] && [ "$WARN_N" -eq 0 ]; then - if [ "${BOX:-0}" = "1" ]; then - printf -- ' %s┌ OK ┐%s %sall clear%s\n' "$BG_OK" "$N" "$G" "$N" - else - printf -- ' %s[ OK ] all clear%s\n' "$G" "$N" - fi - elif [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ]; then - if [ "${BOX:-0}" = "1" ]; then - printf -- ' %s┌ WARN ┐%s %swarnings only (no hard fail)%s\n' "$BG_WARN" "$N" "$Y" "$N" - else - printf -- ' %s[ WARN ] warnings only (no hard fail)%s\n' "$Y" "$N" - fi - else - if [ "${BOX:-0}" = "1" ]; then - printf -- ' %s┌ ERROR ┐%s %sfailed — see list above%s\n' "$BG_ERR" "$N" "$R" "$N" - else - printf -- ' %s[ ERROR ] failed — see list above%s\n' "$R" "$N" - fi - fi - [ "$FAIL_N" -eq 0 ] -} - -# --------------------------------------------------------------------------- -# Disk space on hosts / containers under test -# WARN when tight, ERROR when full / critically low -# DISK_WARN_USED_PCT=85 # free below 15% → WARN -# DISK_ERR_USED_PCT=95 # free below 5% → ERROR -# DISK_ERR_FREE_BYTES=0 # free == 0 always ERROR -# --------------------------------------------------------------------------- -: "${DISK_WARN_USED_PCT:=85}" -: "${DISK_ERR_USED_PCT:=95}" - -# Parse one POSIX `df -P` body line (no header): fs size used avail capacity mount -# size/used/avail in 1K-blocks when using df -Pk -_mon_disk_eval_line() { - local where="$1" fs="$2" size="$3" used="$4" avail="$5" cap="$6" mnt="$7" - local pct label detail - pct=${cap%%%} - # non-numeric capacity → skip - case "$pct" in - ''|*[!0-9]*) return 0 ;; - esac - label="disk ${where} ${mnt}" - detail="fs=$fs size_1k=$size used_1k=$used avail_1k=$avail use=${pct}%" - if [ "$avail" = "0" ] || [ "$pct" -ge 100 ]; then - err "disk" "${where} ${mnt} FULL / overflow" "$detail" - return 1 - fi - if [ "$pct" -ge "${DISK_ERR_USED_PCT}" ]; then - err "disk" "${where} ${mnt} critically low free space (≥${DISK_ERR_USED_PCT}% used)" "$detail" - return 1 - fi - if [ "$pct" -ge "${DISK_WARN_USED_PCT}" ]; then - warn "disk" "${where} ${mnt} free space tight (≥${DISK_WARN_USED_PCT}% used)" "$detail" - return 0 - fi - ok "$label" "$detail" - return 0 -} - -# Report all filesystems from `df -P` / `df -Pk` output (with header). -# $1 = where label (host, container:name, ssh:host) -# $2 = full df text -mon_disk_report_df() { - local where="$1" text="$2" line fs size used avail cap mnt rest ec=0 - [ -n "$text" ] || { - warn "disk" "${where}: no df output" - return 0 - } - while IFS= read -r line || [ -n "$line" ]; do - [ -n "$line" ] || continue - case "$line" in - Filesystem*|Filesystem*) continue ;; - esac - # df -P: Filesystem 1024-blocks Used Available Capacity Mounted on - # shellcheck disable=SC2086 - set -- $line - [ "$#" -ge 6 ] || continue - fs=$1 - size=$2 - used=$3 - avail=$4 - cap=$5 - shift 5 - mnt=$* - # skip special/pseudo if tiny and not root-ish - case "$fs" in - tmpfs|devtmpfs|overlay|shm|nsfs|proc|sysfs|cgroup*) - # still check root-like mounts that fill (overlay / in containers) - case "$mnt" in - /|/var|/var/*|/home|/home/*|/data|/mnt/*) ;; - *) continue ;; - esac - ;; - esac - if ! _mon_disk_eval_line "$where" "$fs" "$size" "$used" "$avail" "$cap" "$mnt"; then - ec=1 - fi - done <<<"$text" - return "$ec" -} - -# Local host: important mounts -mon_disk_check_host() { - local where text - where="${1:-host}" - text="" - text=$(df -Pk / /var /home /tmp 2>/dev/null | awk 'NR==1 || !seen[$1,$6]++' || true) - # fallback whole table - if [ -z "$text" ]; then - text=$(df -Pk 2>/dev/null || true) - fi - mon_disk_report_df "$where" "$text" -} - -# Run df inside podman container -mon_disk_check_podman() { - local cname="$1" text - local bin="${2:-podman}" - text=$("$bin" exec "$cname" df -Pk / /var /tmp 2>/dev/null || "$bin" exec "$cname" df -Pk 2>/dev/null || true) - mon_disk_report_df "ctr:${cname}" "$text" -} - -# df text already collected remotely -mon_disk_check_remote_text() { - local where="$1" text="$2" - mon_disk_report_df "$where" "$text" -} - -http_code() { - local url="$1"; shift - curl -skS --max-redirs 0 -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000 -} - -http_body() { - local url="$1" out="$2"; shift 2 - curl -skS --max-redirs 0 -m "${TIMEOUT}" -o "$out" -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000 -} - -# --------------------------------------------------------------------------- -# Currency unit map checks (wallet codec: alt_unit_names must include "0") -# --------------------------------------------------------------------------- -# Returns 0 if JSON body at $1 has usable alt_unit_names. -# Supports: -# - exchange/bank: currency_specification.alt_unit_names -# - merchant: currencies..alt_unit_names for each code -# Optional $2 = required currency code for currency_specification.currency -json_has_alt_unit_names() { - local file="$1" want_cur="${2:-}" - python3 - "$file" "$want_cur" <<'PY' -import json, sys -path, want = sys.argv[1], sys.argv[2] -try: - d = json.load(open(path)) -except Exception as e: - print(f"json-error: {e}") - sys.exit(2) - -def check_au(au, label): - if not isinstance(au, dict) or not au: - print(f"{label}: missing/empty alt_unit_names") - return False - if "0" not in au or not str(au.get("0") or "").strip(): - print(f"{label}: alt_unit_names missing non-empty key \"0\" (have {sorted(au.keys())})") - return False - print(f"{label}: alt_unit_names ok (0={au.get('0')!r}, n={len(au)})") - return True - -ok = True -cs = d.get("currency_specification") -if isinstance(cs, dict): - if want and cs.get("currency") and cs.get("currency") != want: - print(f"currency_specification.currency={cs.get('currency')!r} want {want!r}") - ok = False - if not check_au(cs.get("alt_unit_names"), "currency_specification"): - ok = False -elif "currency_specification" in d: - print("currency_specification: not an object") - ok = False - -curs = d.get("currencies") -if isinstance(curs, dict) and curs: - for code, spec in curs.items(): - if not isinstance(spec, dict): - print(f"currencies.{code}: not an object") - ok = False - continue - if not check_au(spec.get("alt_unit_names"), f"currencies.{code}"): - ok = False - -if not isinstance(cs, dict) and not (isinstance(curs, dict) and curs): - # neither shape — fail - print("no currency_specification or currencies map") - ok = False - -sys.exit(0 if ok else 1) -PY -} - -# Check one exchange base URL's /config for alt_unit_names. -# $1=label $2=base_url $3=expected currency (optional) $4=strict(1) or soft(0) -check_exchange_alt_units() { - local label="$1" base="$2" want_cur="${3:-}" strict="${4:-1}" - local f code - base="${base%/}" - f=$(mktemp) - code=$(http_body "${base}/config" "$f") - if [ "$code" != "200" ]; then - rm -f "$f" - if [ "$strict" = "1" ]; then - fail "$label /config" "HTTP $code ($base)" - else - warn "$label /config" "HTTP $code ($base)" - fi - return - fi - local out ec - set +e - out=$(json_has_alt_unit_names "$f" "$want_cur" 2>&1) - ec=$? - set -e - rm -f "$f" - if [ "$ec" -eq 0 ]; then - ok "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" - else - if [ "$strict" = "1" ]; then - fail "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" - else - warn "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" - fi - fi -} - -# From merchant /config JSON file: walk exchanges[] and check each base_url/config. -# Local stack hosts (hacktivism.ch or $EXCHANGE_PUBLIC host) are strict; others soft. -check_merchant_listed_exchanges_alt_units() { - local mer_json="$1" - local list - list=$(python3 - "$mer_json" <<'PY' -import json, sys -d = json.load(open(sys.argv[1])) -for e in d.get("exchanges") or []: - if not isinstance(e, dict): - continue - u = (e.get("base_url") or e.get("url") or "").rstrip("/") - c = e.get("currency") or "" - if u: - print(f"{c}\t{u}") -PY -) - if [ -z "$list" ]; then - fail "merchant exchanges[]" "empty — no exchanges to check for alt_unit_names" - return - fi - local line cur url strict host - while IFS=$'\t' read -r cur url; do - [ -n "$url" ] || continue - host="${url#https://}"; host="${host#http://}"; host="${host%%/*}" - strict=1 - case "$host" in - *hacktivism.ch) strict=1 ;; - *) - # foreign exchange (e.g. taler-ops) — soft unless it is our configured EXCHANGE_PUBLIC - if [ "$url" = "${EXCHANGE_PUBLIC}" ] || [ "$url" = "${EXCHANGE_PUBLIC}/" ]; then - strict=1 - else - strict=0 - fi - ;; - esac - check_exchange_alt_units "exchange ${cur:-?} ${host}" "$url" "$cur" "$strict" - done <<<"$list" -} - -# Live bank/merchant passwords: sibling **koopa-admin-secrets** (never in admin-log). -# Override: SECRETS_ROOT=/path/to/koopa-admin-secrets/koopa/host-root -# or KOOPA_ADMIN_SECRETS=/path/to/koopa-admin-secrets -SECRETS_ROOT="${SECRETS_ROOT:-}" -_secrets_search_log() { :; } # placeholder if we later want debug -if [ -z "$SECRETS_ROOT" ]; then - _mon_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - _admin_log="$(cd "${_mon_dir}/../.." && pwd)" - # Prefer explicit env pointing at the secrets *repo* root - if [ -n "${KOOPA_ADMIN_SECRETS:-}" ] && [ -d "${KOOPA_ADMIN_SECRETS}/koopa/host-root/taler-bank" ]; then - SECRETS_ROOT="${KOOPA_ADMIN_SECRETS}/koopa/host-root" - fi -fi -if [ -z "$SECRETS_ROOT" ]; then - _mon_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - _admin_log="$(cd "${_mon_dir}/../.." && pwd)" - for d in \ - "${_admin_log}/../koopa-admin-secrets/koopa/host-root" \ - "${_admin_log}/../../koopa-admin-secrets/koopa/host-root" \ - "${HOME}/taler/src/koopa-admin-secrets/koopa/host-root" \ - "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root" \ - "${HOME}/koopa-admin-secrets/koopa/host-root" \ - "/Users/newkamek/src/koopa/koopa-admin-secrets/koopa/host-root" \ - "$(cd "${_admin_log}/../.." 2>/dev/null && pwd)/koopa-admin-secrets/koopa/host-root" - do - # resolve .. without requiring the dir to exist first for cd - if [ -d "$d/taler-bank" ]; then - SECRETS_ROOT=$(cd "$d" && pwd) - break - fi - done - unset _mon_dir _admin_log d -fi - -read_secret() { - local rel="$1" base val="" - base=$(basename "$rel") - if [ -n "${SECRETS_ROOT:-}" ]; then - if [ -f "${SECRETS_ROOT}/${rel}" ]; then - tr -d '\n\r' <"${SECRETS_ROOT}/${rel}" - return 0 - fi - # also accept flat layout under host-root/ - if [ -f "${SECRETS_ROOT}/${base}" ]; then - tr -d '\n\r' <"${SECRETS_ROOT}/${base}" - return 0 - fi - fi - # Optional: copy under ~/.config/taler-landing/ (landing-stats style) - if [ -f "${HOME}/.config/taler-landing/${base}" ]; then - tr -d '\n\r' <"${HOME}/.config/taler-landing/${base}" - return 0 - fi - # Live on koopa host /root (same basenames as deploy) - if koopa_ssh_ok; then - val=$(koopa_ssh_run 12 "tr -d '\\n\\r' /dev/null || true" 2>/dev/null || true) - if [ -n "$val" ]; then - printf '%s' "$val" - return 0 - fi - # container path used by some installs - val=$(koopa_ssh_run 12 \ - "podman exec taler-hacktivism-bank tr -d '\\n\\r' /dev/null || true" 2>/dev/null || true) - if [ -n "$val" ]; then - printf '%s' "$val" - return 0 - fi - fi - return 1 -} - -# Human hint when secrets missing (e2e prereq) -secrets_hint() { - cat </dev/null || true) - if [ -n "$cand" ] && [ -f "$cand" ]; then - if head -1 "$cand" 2>/dev/null | grep -q 'node\|mjs'; then - # shebang node script or .mjs - echo "$cand"; return 0 - fi - # follow symlink into package tree - if [ -L "$cand" ]; then - c=$(readlink -f "$cand" 2>/dev/null || true) - [ -n "$c" ] && [ -f "$c" ] && { echo "$c"; return 0; } - fi - fi - return 1 -} diff --git a/scripts/taler-monitoring/metrics.sh b/scripts/taler-monitoring/metrics.sh deleted file mode 100644 index 4c440ea..0000000 --- a/scripts/taler-monitoring/metrics.sh +++ /dev/null @@ -1,1431 +0,0 @@ -# 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 - -# --------------------------------------------------------------------------- -# 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_SPENT, COINS_AMOUNT_CIRC, COINS_SUMMARY. -metrics_wallet_coins() { - local out="${1:-$METRICS_DIR/coins.json}" - 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 (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:-}" ] && [ -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}" "${ALT_UNITS_FILE:-}" <<'PY' -import json, re, sys -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): - 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 [] - -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() -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 c.get("denom_value") or "?" - st = str(c.get("coinStatus") or c.get("status") or "?") - by_denom_all[dv] += 1 - by_status[st] += 1 - 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) - -def denom_key(k): - try: - return float(str(k).split(":", 1)[-1]) - except Exception: - return 0.0 - -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 = [] -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": 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": 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) -open(out_path + ".total", "w").write(str(total)) -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}" - 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 (host + bank/exchange/merchant) --- -# Writes JSON to $1. RAM, process counts, DB sizes, disk I/O counters. -# koopa: KOOPA_SSH (+ fallbacks). stage-lfp: INSIDE_SSH (stagepaysan) + stage-lfp-* names. -metrics_taler_load() { - local out="${1:-$METRICS_DIR/load.json}" - local label="${2:-snap}" - 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 - local _m_bank _m_mer _m_ex _m_ssh - _m_bank="taler-hacktivism-bank" - _m_mer="taler-hacktivism" - _m_ex="taler-hacktivism-exchange-ansible" - _m_ssh="" - if [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \ - || [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \ - || { [ -n "${INSIDE_SSH:-}" ] && [ "${LOCAL_STACK:-0}" != "1" ]; }; then - _m_bank="${INSIDE_BANK_CTR:-stage-lfp-bank}" - _m_mer="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" - _m_ex="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" - _m_ssh="${INSIDE_SSH:-francpaysan-stage-user}" - fi - remote_py=$(cat </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( - 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 -) - # Prefer stagepaysan (stage-lfp), else koopa SSH, else local podman only on LOCAL_STACK. - if [ -n "${_m_ssh}" ] && type mon_ssh_ok >/dev/null 2>&1 && mon_ssh_ok "${_m_ssh}"; then - raw=$(printf '%s' "$remote_py" | with_timeout "${METRICS_LOAD_SSH_TIMEOUT:-90}" \ - ssh "${SSH_BASE_OPTS[@]}" "${_m_ssh}" python3 - 2>/dev/null || true) - elif [ "${SKIP_SSH:-0}" != "1" ] && type koopa_ssh_ok >/dev/null 2>&1 && koopa_ssh_ok; then - 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 [ "${LOCAL_STACK:-0}" = "1" ] && command -v podman >/dev/null 2>&1; then - raw=$(printf '%s' "$remote_py" | python3 - 2>/dev/null || true) - else - printf '%s\n' "{\"ok\":false,\"reason\":\"no-ssh-for-load\",\"label\":\"$label\"}" >"$out" - return 0 - fi - if [ -z "$raw" ]; then - printf '%s\n' "{\"ok\":false,\"reason\":\"probe-failed\",\"label\":\"$label\",\"ssh\":\"${_m_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():]) - 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}" - 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 -} - -# Record a successful withdraw/pay amount for end statistics (one amount per line). -# $1=withdrawn|spent $2=amount (CUR:n) -metrics_record_flow() { - local kind="$1" amt="$2" - [ -n "$amt" ] || return 0 - mkdir -p "${METRICS_DIR}" 2>/dev/null || true - case "$kind" in - withdrawn|withdraw) printf '%s\n' "$amt" >>"${METRICS_DIR}/flow-withdrawn.txt" ;; - spent|pay) printf '%s\n' "$amt" >>"${METRICS_DIR}/flow-spent.txt" ;; - *) return 1 ;; - esac -} - -# Overall end-of-run statistics block -# Files under METRICS_DIR: -# coins-final.json, coins-history.tsv, perf-summary.json, -# load-before/after.json, flow-withdrawn.txt, flow-spent.txt, -# optional ladder TSV via METRICS_WITHDRAW_TSV / METRICS_PAY_TSV -metrics_print_overall() { - local title="${1:-overall statistics}" - section "statistics · $title" - metrics_print_final_stats || true - [ -n "${METRICS_EXTRA_LINES:-}" ] && printf '%s\n' "$METRICS_EXTRA_LINES" -} - -# Rich final dashboard: coins, withdrawn/spent, performance, tendencies -metrics_print_final_stats() { - python3 - \ - "${METRICS_DIR:-/tmp}" \ - "${ALT_UNITS_FILE:-}" \ - "${METRICS_WITHDRAW_TSV:-}" \ - "${METRICS_PAY_TSV:-}" \ - "${CUR:-GOA}" \ - <<'PY' -import csv, json, os, sys -from decimal import Decimal, InvalidOperation, ROUND_HALF_UP -from pathlib import Path - -mdir = Path(sys.argv[1]) -alt_path = sys.argv[2] or "" -wd_tsv = sys.argv[3] or "" -pay_tsv = sys.argv[4] or "" -cur = sys.argv[5] or "GOA" - -alt = {} -if alt_path and Path(alt_path).is_file(): - try: - alt = json.load(open(alt_path)) - except Exception: - alt = {} -if not alt: - alt = {"0": cur} - -def D(x, default=Decimal(0)): - try: - return Decimal(str(x)) - except Exception: - return default - -def parse_amt(s): - s = str(s or "").strip() - if not 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_alt(c, v: Decimal) -> str: - 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) - for sc, name in scales: - unit = Decimal(10) ** sc - coeff = absval / unit - if coeff >= 1: - if sc == 0: - return "%s %s" % (fmt_num(v), name) - return "%s %s (%s)" % (fmt_num(coeff if v >= 0 else -coeff), name, base_s) - return "%s %s (%s)" % (fmt_num(v), base_name, base_s) - -def sum_amount_file(path: Path): - total = Decimal(0) - ccy = cur - n = 0 - if not path.is_file(): - return ccy, total, n - for line in path.read_text().splitlines(): - line = line.strip() - if not line: - continue - c, v = parse_amt(line) - ccy = c - total += v - n += 1 - return ccy, total, n - -def sum_tsv_amounts(path, amount_col="amount", status_ok=None): - total = Decimal(0) - ccy = cur - n = 0 - if not path or not Path(path).is_file(): - return ccy, total, n - with open(path, newline="") as f: - for row in csv.DictReader(f, delimiter="\t"): - st = (row.get("status") or "").upper() - if status_ok is not None and st not in status_ok: - continue - c, v = parse_amt(row.get(amount_col) or "") - if v <= 0: - continue - ccy = c - total += v - n += 1 - return ccy, total, n - -def trend_label(delta, eps=Decimal("0.01")): - if delta > eps: - return "↑ rising" - if delta < -eps: - return "↓ falling" - return "→ stable" - -def half_trend(values): - """Compare avg of second half vs first half of a list of numbers.""" - xs = [float(x) for x in values if x is not None] - if len(xs) < 4: - if len(xs) < 2: - return "n/a (few samples)", None, None - a, b = xs[0], xs[-1] - return trend_label(Decimal(str(b - a))), a, b - mid = len(xs) // 2 - first = sum(xs[:mid]) / mid - second = sum(xs[mid:]) / (len(xs) - mid) - d = second - first - lab = "↑ slowing (worse)" if d > 0.05 * max(abs(first), 1) else ( - "↓ faster (better)" if d < -0.05 * max(abs(first), 1) else "→ steady" - ) - return lab, first, second - -print("") -print("╔══════════════════════════════════════════════════════════════╗") -print("║ FINAL STATISTICS ║") -print("╚══════════════════════════════════════════════════════════════╝") - -# --- coins snapshot --- -coins = {} -cf = mdir / "coins-final.json" -if cf.is_file(): - try: - coins = json.load(open(cf)) - except Exception: - coins = {} - -print("") -print("── Coins (wallet) ─────────────────────────────────────────────") -if coins.get("ok"): - circ_n = coins.get("in_circulation") or 0 - spent_n = coins.get("spent") or 0 - total_n = coins.get("total_coins") or 0 - amt_alt = coins.get("amount_in_circulation_alt") or coins.get("amount_in_circulation_s") or "?" - spent_alt = coins.get("amount_spent_alt") or coins.get("amount_spent_s") or "0" - print(" total coins %s" % total_n) - print(" in circulation %s coins · %s" % (circ_n, amt_alt)) - print(" spent (in dump) %s coins · %s" % (spent_n, spent_alt)) - circ = coins.get("by_denom_circulation") or [] - if circ: - print(" denoms in circ:") - for x in circ: - print( - " · %s × %s = %s" - % ( - x.get("denom_alt") or x.get("denom"), - x.get("count"), - x.get("amount_alt") or x.get("amount"), - ) - ) - spent = coins.get("by_denom_spent") or [] - if spent: - print(" denoms spent:") - for x in spent: - print( - " · %s × %s = %s" - % ( - x.get("denom_alt") or x.get("denom"), - x.get("count"), - x.get("amount_alt") or x.get("amount"), - ) - ) -else: - print(" (no coins-final.json — run with wallet snaps)") - -# --- withdrawn / spent flows --- -print("") -print("── Money flow (this run) ──────────────────────────────────────") -# Prefer ladder TSV if provided; else flow-*.txt from e2e -wd_ok = {"OK", "OK_BANK", "ZERO_REJECT", "ZERO_SKIP", "SKIP_DENOM", "CEILING_REJECT"} -pay_ok = {"OK", "ZERO_SKIP", "CEILING_SKIP", "CEILING_REJECT"} -c1, wd_sum, wd_n = sum_tsv_amounts(wd_tsv, "amount", None) -# count successful withdraws by status prefix OK or soft -if wd_tsv and Path(wd_tsv).is_file(): - c1, wd_sum, wd_n = Decimal(0), Decimal(0), 0 - ccy = cur - with open(wd_tsv, newline="") as f: - for row in csv.DictReader(f, delimiter="\t"): - st = (row.get("status") or "").upper() - # count value when mint/settle produced money in wallet - if st in ("OK", "OK_BANK"): - c, v = parse_amt(row.get("amount")) - ccy, wd_sum, wd_n = c, wd_sum + v, wd_n + 1 - c1 = ccy -else: - c1, wd_sum, wd_n = sum_amount_file(mdir / "flow-withdrawn.txt") - -if pay_tsv and Path(pay_tsv).is_file(): - c2, pay_sum, pay_n = Decimal(0), Decimal(0), 0 - ccy = cur - with open(pay_tsv, newline="") as f: - for row in csv.DictReader(f, delimiter="\t"): - st = (row.get("status") or "").upper() - if st == "OK": - c, v = parse_amt(row.get("amount")) - ccy, pay_sum, pay_n = c, pay_sum + v, pay_n + 1 - c2 = ccy -else: - c2, pay_sum, pay_n = sum_amount_file(mdir / "flow-spent.txt") - -print(" withdrawn %s events · %s" % (wd_n, fmt_alt(c1, wd_sum))) -print(" spent (paid) %s events · %s" % (pay_n, fmt_alt(c2, pay_sum))) -net = wd_sum - pay_sum -print(" net (wd − spent) %s %s" % (fmt_alt(c1, net), trend_label(net))) -if coins.get("ok"): - # circulation amount vs net flow - circ_map = coins.get("amount_in_circulation") or {} - circ_v = Decimal(0) - for _c, vs in circ_map.items(): - circ_v += D(vs) - print(" wallet circ now %s" % fmt_alt(cur, circ_v)) - print(" residual check wallet_circ vs net: Δ %s" - % fmt_alt(cur, circ_v - net)) - -# --- coin history tendency --- -print("") -print("── Tendency · coins over run ─────────────────────────────────") -hist = mdir / "coins-history.tsv" -if hist.is_file() and hist.stat().st_size > 0: - rows = list(csv.DictReader(open(hist), delimiter="\t")) - if rows: - def circ_amt(row): - # amount_circ column may be "GOA:20" or alt form — try numeric from total/in_circ - a = row.get("amount_circ") or "0" - if ":" in a and " " not in a: - return parse_amt(a)[1] - # try extract GOA: from parentheses - import re - m = re.search(r"\(([^)]+:[0-9.]+)\)", a) - if m: - return parse_amt(m.group(1))[1] - return D(row.get("in_circ") or 0) - - first, last = rows[0], rows[-1] - c0, c1_ = D(first.get("in_circ") or 0), D(last.get("in_circ") or 0) - a0, a1 = circ_amt(first), circ_amt(last) - print(" samples %d snaps (%s → %s)" - % (len(rows), first.get("label"), last.get("label"))) - print(" coins in circ %s → %s (Δ %+d) %s" - % (fmt_num(c0), fmt_num(c1_), int(c1_ - c0), trend_label(c1_ - c0))) - print(" amount in circ %s → %s %s" - % (fmt_alt(cur, a0), fmt_alt(cur, a1), trend_label(a1 - a0))) - # simple linear slope on coin count - if len(rows) >= 3: - ys = [float(D(r.get("in_circ") or 0)) for r in rows] - n = len(ys) - xs = list(range(n)) - xm, ym = sum(xs) / n, sum(ys) / n - num = sum((x - xm) * (y - ym) for x, y in zip(xs, ys)) - den = sum((x - xm) ** 2 for x in xs) or 1 - slope = num / den - print(" trend slope %+.3f coins/snap %s" - % (slope, "↑ accumulating" if slope > 0.05 else ("↓ draining" if slope < -0.05 else "→ flat"))) - print(" history (label · in_circ · amount):") - for r in rows[-12:]: # last 12 - print(" · %-28s circ=%-4s %s" - % (r.get("label", "?")[:28], r.get("in_circ"), r.get("amount_circ"))) - if len(rows) > 12: - print(" · … (%d earlier snaps)" % (len(rows) - 12)) - else: - print(" (empty history)") -else: - print(" (no coins-history.tsv)") - -# --- performance --- -print("") -print("── Performance indicators ───────────────────────────────────") -perf = {} -pf = mdir / "perf-summary.json" -if pf.is_file(): - try: - perf = json.load(open(pf)) - except Exception: - perf = {} - -def print_bucket(name, v): - if not isinstance(v, dict) or not v.get("n"): - return - print( - " %-14s n=%s min=%sms p50=%sms avg=%sms max=%sms" - % (name, v.get("n"), v.get("min_ms"), v.get("p50_ms"), v.get("avg_ms"), v.get("max_ms")) - ) - -if perf: - for k, v in perf.items(): - print_bucket(k, v) -else: - print(" (no perf-summary.json)") - -# per-rung timing tendency from ladder TSV -for label, path, cols in ( - ("withdraw rungs", wd_tsv, ("ms_total", "ms_mint", "ms_settle")), - ("pay rungs", pay_tsv, ("ms_total", "ms_order", "ms_handle", "ms_settle")), -): - if not path or not Path(path).is_file(): - continue - with open(path, newline="") as f: - rrows = list(csv.DictReader(f, delimiter="\t")) - if len(rrows) < 2: - continue - print(" tendency · %s:" % label) - for col in cols: - vals = [] - for r in rrows: - try: - vals.append(int(r.get(col) or 0)) - except Exception: - pass - vals = [v for v in vals if v > 0] - if len(vals) < 2: - continue - lab, a, b = half_trend(vals) - if a is None: - print(" %-12s %s" % (col, lab)) - else: - print(" %-12s first-half avg=%.0fms → second-half avg=%.0fms %s" - % (col, a, b, lab)) - -# --- load --- -print("") -print("── Host / container load ────────────────────────────────────") -lb, la = mdir / "load-before.json", mdir / "load-after.json" - -def load_brief(path, tag): - if not path.is_file(): - print(" %s: (missing)" % tag) - return None - try: - d = json.load(open(path)) - except Exception: - print(" %s: (unreadable)" % tag) - return None - if not d.get("ok"): - print(" %s: %s" % (tag, d.get("reason", "?"))) - return d - h = d.get("host") or {} - mem = h.get("memory") or {} - la_ = h.get("loadavg") or [] - avail = mem.get("mem_available_b") - tot = mem.get("mem_total_b") - def gi(b): - return "?" if b is None else "%.2f GiB" % (b / 1024 / 1024 / 1024) - used = (tot - avail) if (tot is not None and avail is not None) else None - print(" %s loadavg=%s mem_used=%s / %s" - % (tag, la_, gi(used), gi(tot))) - for role in ("bank", "exchange", "merchant"): - c = (d.get("taler") or {}).get(role) or {} - if not c.get("running"): - continue - pr = c.get("processes") or {} - st = c.get("podman_stats") or {} - rss = pr.get("rss_total_b") - rss_s = "?" if rss is None else "%.2f GiB" % (rss / 1024 / 1024 / 1024) - print(" %-8s rss=%s cpu=%s procs=%s" - % (role, rss_s, st.get("cpu_pct") or "?", pr.get("proc_total"))) - return d - -db = load_brief(lb, "before") -da = load_brief(la, "after") -if db and da and db.get("ok") and da.get("ok"): - bla = (db.get("host") or {}).get("loadavg") or [0] - ala = (da.get("host") or {}).get("loadavg") or [0] - if bla and ala: - dload = float(ala[0]) - float(bla[0]) - print(" loadavg1 tendency %.2f → %.2f (Δ %+.2f) %s" - % (float(bla[0]), float(ala[0]), dload, - "↑ higher load" if dload > 0.1 else ("↓ lower load" if dload < -0.1 else "→ steady"))) - bm = (db.get("host") or {}).get("memory") or {} - am = (da.get("host") or {}).get("memory") or {} - if bm.get("mem_available_b") is not None and am.get("mem_available_b") is not None: - dmem = (am["mem_available_b"] - bm["mem_available_b"]) / 1024 / 1024 / 1024 - print(" mem_avail tendency %.2f → %.2f GiB (Δ %+.3f) %s" - % (bm["mem_available_b"] / 1024**3, am["mem_available_b"] / 1024**3, dmem, - "↑ more free" if dmem > 0.05 else ("↓ less free" if dmem < -0.05 else "→ steady"))) - -print("") -print("──────────────────────────────────────────────────────────────") -PY -} diff --git a/scripts/taler-monitoring/secrets.env.example b/scripts/taler-monitoring/secrets.env.example deleted file mode 100644 index 87ec88c..0000000 --- a/scripts/taler-monitoring/secrets.env.example +++ /dev/null @@ -1,87 +0,0 @@ -# taler-monitoring — local secrets template -# -# Copy to secrets.env (gitignored) and fill values, OR export the same vars: -# cp secrets.env.example secrets.env -# $EDITOR secrets.env -# -# Never commit secrets.env. Prefer file paths under koopa-admin-secrets -# for long-term storage; this file is for laptop one-shots / overrides. -# -# Load order: env already set > secrets.env > SECRETS_ROOT files > SSH koopa - -# --- Preferred: point at secrets tree (no passwords in this file) --- -# Sibling of admin-log on this machine: -# KOOPA_ADMIN_SECRETS=$HOME/src/koopa/koopa-admin-secrets -# Or host-root only: -# SECRETS_ROOT=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root - -# --- Or paste passwords / tokens (local GOA e2e) --- -# Bank admin (libeufin-bank admin) — funds / creates accounts in e2e -# E2E_BANK_ADMIN_PASS= -# BANK_ADMIN_PASS= # alias, same meaning - -# Merchant instance token for private orders (goa-demo-cp4zqk on hacktivism) -# Full "secret-token:…" or raw secret (Bearer prefix added by e2e) -# E2E_MERCHANT_TOKEN= -# MERCHANT_TOKEN= -# MERCHANT_INSTANCE_PASSWORD= - -# --- FrancPaysan STAGE (TESTPAYSAN) e2e --- -# Default phases for -d stage.lefrancpaysan.ch: urls + inside + versions + e2e -# inside uses francpaysan-stage-user (stagepaysan); stats from public HTTPS. -# Admin credit (required for ATM withdraw ladder on stage): -# E2E_BANK_ADMIN_PASS=… # or auto-load via SSH francpaysan-host -# FRANCPAYSAN_SSH=francpaysan-host -# FRANCPAYSAN_SECRETS=$HOME/francpaysan-secrets -# Public shop pays use E2E_USE_TEMPLATES=1 (default on TESTPAYSAN) — no merchant token. - -# Optional overrides -# MERCHANT_INSTANCE=goa-demo-cp4zqk -# EXPECT_CURRENCY=GOA -# E2E_TIMEOUT=340 -# PROGRESS_TOTAL=120 -# PROGRESS_SHOW_EVERY=8 -# PROGRESS_OFF=1 - -# --- Merchant SPA /webui/ fingerprints (urls phase, group www.webui) --- -# EXPECT_WEBUI_VERSION=1.6.11 -# EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 -# WEBUI_OVERLAY_DENY=c778af04|master-11340 -# CHECK_WEBUI_SPA=0 - -# --- auth401 phase (WebUI signup/login/pwchange/admin + case matrix) --- -# Default FULL=1 creates throwaway MIX instance + runs all groups. -# Stage also loads durable mon401 for extra durable.* checks: -# AUTH401_FULL=1 -# AUTH401_CONTINUE=1 # optional: hard prereqs soft; full ERROR list always via EXIT trap -# CONTINUE_ON_ERROR=1 # alias for AUTH401_CONTINUE -# (auth401 no longer uses set -e — silent mid-run abort fixed) -# AUTH401_CREATE=1 -# AUTH401_PREFER_DURABLE=0 # 1 = only mon401, skip throwaway signup -# AUTH401_INSTANCE=mon401 -# AUTH401_PASSWORD= -# AUTH401_DURABLE_ID=mon401 -# AUTH401_CREATE_PASSWORD=Mon401-Test-Pass! -# AUTH401_SKIP_IF_MFA=1 -# AUTH401_BEARER= # default-instance-token (not admin) -# AUTH401_ADMIN_TOKEN= # required for admin-create group -# AUTH401_ADMIN_PASSWORD= # or login as AUTH401_ADMIN_USER=admin -# AUTH401_ADMIN_USER=admin -# FRANCPAYSAN_SECRETS=$HOME/francpaysan-secrets - -# --- Ladder (GOA withdraw ladder · explorer pool) --- -# Prefer SECRETS_ROOT / KOOPA_ADMIN_SECRETS (bank-explorer-password.txt). -# Overrides only if needed: -# EXP_USER=explorer -# EXP_PW= -# EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt -# CLI_JS=$HOME/taler/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs - -# Values that typically appear on a full stack (for orientation only): -# bank-admin-password.txt -# bank-explorer-password.txt -# bank-exchange-password.txt -# bank-goa-demo-cp4zqk-password.txt -# merchant-goa-demo-cp4zqk-password.txt -# exchange-attribute-encryption.secret.conf -# (see ../SECRETS.md and koopa-admin-secrets/MAP.md) diff --git a/scripts/taler-monitoring/site-gen/README.md b/scripts/taler-monitoring/site-gen/README.md deleted file mode 100644 index 82e52d2..0000000 --- a/scripts/taler-monitoring/site-gen/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# monitoring site-gen - -Build **console-style HTML** from `taler-monitoring` runs and stage them for -**host Caddy** (`/monitoring`, `/monitoring_err`). - -Same **global reporting defaults** as host-agents (`run-host-report.sh`): - -- **`RUN_TIMEOUT=600`** on each suite run -- **line-buffered** logs (continuous write) -- **always** write HTML (first run / empty logs too) -- Forgejo **commit link** in the page header - -Applies to all nine fronts (GOA + FP stage + FP prod) in the default `SITES` list. - -| Host | Role | -|------|------| -| **firecuda-external** | optional runner (public DNS) | -| **koopa-external** | deploy staging + Caddy (root service) | -| **host-agents** | primary: GOA + FP on-box (`../host-agent/`) | - -No secrets in `settings.conf` — only hosts, paths, site list. - -## firecuda-external (outside-only) — **disabled for now** - -**firecuda-external could** run public-only monitoring (`SKIP_SSH=1`, phases -`urls versions`) on a schedule (launchd every 4h) and write HTML under -`~/var/taler-monitoring-sites-work/html/`. Scripts for that still live here -(`install-firecuda-timer.sh`, `run-on-firecuda.sh`, plist template). - -**Current policy: do not run a real launchd job on firecuda.** -Primary GOA monitoring is **koopa host-agent** (`host-agent/`, user `hernani`, -local `podman exec`). Re-enable firecuda only if you explicitly want a second, -outside-only view. - -```bash -# optional one-shot (no timer): -# ssh firecuda-external '~/taler-monitoring-site-gen/site-gen/run-on-firecuda.sh' - -# if a timer was installed earlier, ensure it is off: -# ssh firecuda-external 'launchctl list | grep taler-monitoring || echo off' -``` - -Disabled plists may remain as `*.plist.disabled-*` under `~/Library/LaunchAgents/`. - -## Quick start (laptop) - -```bash -cd scripts/taler-monitoring/site-gen -cp settings.conf.example settings.conf # optional -chmod +x generate-monitoring-sites.sh deploy-monitoring-sites.sh console_to_html.py - -ONLY_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" \ - SKIP_SSH=1 RUNNER_SSH= \ - ./generate-monitoring-sites.sh - -./deploy-monitoring-sites.sh -``` - -Then on koopa **as root**: see **[ROOT-ON-KOOPA.md](./ROOT-ON-KOOPA.md)**. - -## URL rules - -| Path | When | -|------|------| -| `/monitoring_err/` | always: full console log + error list on top (CONTINUE_ON_ERROR run) | -| `/monitoring/` | **clean** full log if last strict run exit 0; else **stub** linking to `/monitoring_err/` | - -Footer links **Forgejo commit** of the admin-log tree used for that run. - -## Layout - -```text -site-gen/ - settings.conf.example - generate-monitoring-sites.sh - deploy-monitoring-sites.sh - console_to_html.py - caddy-monitoring-handles.snippet - ROOT-ON-KOOPA.md - README.md -``` diff --git a/scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md b/scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md deleted file mode 100644 index 604b997..0000000 --- a/scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md +++ /dev/null @@ -1,92 +0,0 @@ -# Root on koopa (hacktivism monitoring sites) - -Host Caddy runs as **root/systemd** (`caddy` user). Static monitoring HTML is -**not** put into Taler containers — only host paths + Caddy `handle`. - -After `generate-monitoring-sites.sh` + `deploy-monitoring-sites.sh` (as hernani), -files live in: - -```text -/home/hernani/monitoring-sites-staging//monitoring/index.html -/home/hernani/monitoring-sites-staging//monitoring_err/index.html -``` - -## One-shot as root (copy + Caddy) - -```bash -# on koopa (koopa-external), as root: - -# 1) web root -install -d -o caddy -g caddy -m 755 /var/www/monitoring-sites -rsync -a --delete /home/hernani/monitoring-sites-staging/ /var/www/monitoring-sites/ -chown -R caddy:caddy /var/www/monitoring-sites - -# 2) Caddyfile — merge handles from admin-log mirror, then: -# (either edit /etc/caddy/Caddyfile by hand using snippet below, -# or copy full mirror after review) -# -# install -m 644 /home/hernani/koopa-admin-log/configs/caddy/Caddyfile /etc/caddy/Caddyfile -# # or: ~/koopa-caddy/apply.sh after syncing Caddyfile into ~/koopa-caddy/ - -caddy validate --config /etc/caddy/Caddyfile -systemctl reload caddy -systemctl is-active caddy -``` - -## Snippet per hacktivism site (inside each `*.hacktivism.ch { }` block) - -Place **before** the catch-all `reverse_proxy` (same idea as `/intro*`): - -```caddy - # Public taler-monitoring console HTML (static; host only) - handle /monitoring_err* { - root * /var/www/monitoring-sites/{host} - rewrite * /monitoring_err{uri} - # uri is /monitoring_err or /monitoring_err/ → serve directory index - file_server - } - handle /monitoring* { - root * /var/www/monitoring-sites/{host} - file_server - } -``` - -Simpler layout (recommended): path on disk matches URL under host root: - -```text -/var/www/monitoring-sites/bank.hacktivism.ch/monitoring/index.html -/var/www/monitoring-sites/bank.hacktivism.ch/monitoring_err/index.html -``` - -```caddy - handle_path /monitoring_err/* { - root * /var/www/monitoring-sites/{host}/monitoring_err - file_server - } - handle /monitoring_err { - redir /monitoring_err/ 302 - } - handle_path /monitoring/* { - root * /var/www/monitoring-sites/{host}/monitoring - file_server - } - handle /monitoring { - redir /monitoring/ 302 - } -``` - -Canonical snippet is also in `caddy-monitoring-handles.snippet` and applied -in `configs/caddy/Caddyfile` for the three GOA hosts. - -## Check - -```bash -curl -sS -o /dev/null -w '%{http_code}\n' https://bank.hacktivism.ch/monitoring/ -curl -sS -o /dev/null -w '%{http_code}\n' https://bank.hacktivism.ch/monitoring_err/ -curl -sS https://bank.hacktivism.ch/monitoring_err/ | head -``` - -## Not needed as root - -- Running `taler-monitoring` (use **firecuda-external** / laptop) -- Writing into podman Taler containers diff --git a/scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet b/scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet deleted file mode 100644 index 1c09531..0000000 --- a/scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet +++ /dev/null @@ -1,57 +0,0 @@ -# Host Caddy on koopa (root service). Paste BEFORE catch-all reverse_proxy. -# -# Disk layout: -# /var/www/monitoring-sites/{host}/monitoring/index.html -# /var/www/monitoring-sites/{host}/monitoring_err/index.html -# /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface*/index.html -# /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy*/index.html -# -# /monitoring* → bank + exchange + taler.hacktivism.ch -# /taler-monitoring-surface* → taler.hacktivism.ch only -# /taler-monitoring-aptdeploy* → taler.hacktivism.ch only - -# --- only inside taler.hacktivism.ch { ... } --- - handle /taler-monitoring-surface_err { - redir /taler-monitoring-surface_err/ 302 - } - handle /taler-monitoring-surface_err/ { - root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface_err - file_server - } - handle /taler-monitoring-surface { - redir /taler-monitoring-surface/ 302 - } - handle /taler-monitoring-surface/ { - root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface - file_server - } - handle /taler-monitoring-aptdeploy_err { - redir /taler-monitoring-aptdeploy_err/ 302 - } - handle /taler-monitoring-aptdeploy_err/ { - root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy_err - file_server - } - handle /taler-monitoring-aptdeploy { - redir /taler-monitoring-aptdeploy/ 302 - } - handle /taler-monitoring-aptdeploy/ { - root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy - file_server - } - -# --- bank + exchange + taler (each site block) --- - handle /monitoring_err { - redir /monitoring_err/ 302 - } - handle /monitoring_err/ { - root * /var/www/monitoring-sites/{host}/monitoring_err - file_server - } - handle /monitoring { - redir /monitoring/ 302 - } - handle /monitoring/ { - root * /var/www/monitoring-sites/{host}/monitoring - file_server - } diff --git a/scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist b/scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist deleted file mode 100644 index 0b6f68b..0000000 --- a/scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - Label - com.hacktivism.taler-monitoring-sites - ProgramArguments - - /bin/bash - __SITE_GEN__/run-on-firecuda.sh - - StartInterval - 14400 - RunAtLoad - - StandardOutPath - __HOME__/Library/Logs/taler-monitoring-sites/launchd.out.log - StandardErrorPath - __HOME__/Library/Logs/taler-monitoring-sites/launchd.err.log - EnvironmentVariables - - PATH - /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin - SKIP_SSH - 1 - CONTINUE_ON_ERROR - 1 - - - diff --git a/scripts/taler-monitoring/site-gen/console_to_html.py b/scripts/taler-monitoring/site-gen/console_to_html.py deleted file mode 100755 index 14aef2a..0000000 --- a/scripts/taler-monitoring/site-gen/console_to_html.py +++ /dev/null @@ -1,935 +0,0 @@ -#!/usr/bin/env python3 -"""Convert taler-monitoring console log to a console-feel HTML page.""" -from __future__ import annotations - -import argparse -import html -import re -from datetime import datetime, timezone -from pathlib import Path - -# ANSI strip -ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") -ERR_BULLET_RE = re.compile(r"^\s*[•·*-]\s+(?P.+)$") -TID_RE = re.compile(r"\b(?P[a-z0-9_.-]+-\d+)\b", re.I) - - -def strip_ansi(s: str) -> str: - return ANSI_RE.sub("", s) - - -def classify(line: str) -> str: - u = line.upper() - # section / summary headers before badge matching (they contain the word ERROR) - if "SUMMARY" in u or "--- ERRORS" in u or "ERRORS ·" in u or "ERRORS (" in u: - return "meta" - if "numbered checks" in line.lower() or "totals:" in line.lower(): - return "meta" - if "BLOCKER" in u or "┌ BLOCKER" in u: - return "blocker" - if "ERROR" in u or "┌ ERROR" in u: - return "error" - if "WARN" in u or "┌ WARN" in u: - return "warn" - if "INFO" in u or "┌ INFO" in u: - return "info" - if " OK" in u or "[OK" in u or "┌ OK" in u or u.strip().startswith("OK"): - return "ok" - if u.strip().startswith("-- ") or u.strip().startswith("=="): - return "section" - return "plain" - - -def is_run_timeout_text(text: str) -> bool: - u = text.upper() - return "RUN.TIMEOUT" in u or "RUN_TIMEOUT" in u or "RUN TIMEOUT" in u - - -def is_summary_error_line(ln: str) -> bool: - """Skip summary/header lines that contain ERROR but are not checks.""" - low = ln.lower() - if "failed — see" in low or "failed - see" in low: - return True - if "errors · failed" in low or "errors (failed" in low: - return True - if "--- errors" in low: - return True - if "┌ summary" in low or "[ summary" in low: - return True - if "totals:" in low: - return True - # header-ish RUN_TIMEOUT= without error badge - if is_run_timeout_text(ln) and "ERROR" not in ln.upper() and "run.timeout" not in low: - return True - return False - - -def extract_errors(lines: list[str]) -> list[str]: - errs: list[str] = [] - timeout_detail = "" - in_block = False - for ln in lines: - if "--- ERRORS" in ln or "ERRORS (failed" in ln or "RUN TIMEOUT · extraordinary" in ln: - in_block = True - continue - if in_block: - if ln.strip().startswith("---") or "[ SUMMARY" in ln or "totals:" in ln: - in_block = False - continue - m = ERR_BULLET_RE.match(ln) - if m: - body = m.group("body").strip() - if is_run_timeout_text(body): - timeout_detail = body - else: - errs.append(body) - elif "ERROR" in ln.upper() and ln.strip() and not is_run_timeout_text(ln): - errs.append(ln.strip()) - if re.search(r"\bERROR\b", ln, re.I) and "--- ERRORS" not in ln: - if is_run_timeout_text(ln): - if "run.timeout-01" in ln.lower() or "RUN_TIMEOUT exceeded" in ln: - timeout_detail = re.sub(r"\s+", " ", strip_ansi(ln)).strip() - continue - if is_summary_error_line(ln): - continue - body = re.sub(r"^.*\bERROR\b\s*\]?\s*", "", ln, flags=re.I).strip(" ·") - if body and body not in errs and "failed — see" not in body.lower(): - if re.search( - r"#\d+|www\.|e2e\.|auth401\.|versions\.|inside\.|surface\.|aptdeploy\.", - body, - ): - errs.append(body) - seen: set[str] = set() - out: list[str] = [] - for e in errs: - if e in seen or is_run_timeout_text(e): - continue - seen.add(e) - out.append(e) - if timeout_detail or any( - is_run_timeout_text(l) and ("ERROR" in l.upper() or "run.timeout" in l.lower()) - for l in lines - ): - # only if real timeout ERROR badge, not mere RUN_TIMEOUT= header - if timeout_detail or any( - "run.timeout-01" in l.lower() or "RUN_TIMEOUT exceeded" in l for l in lines - ): - canon = timeout_detail or "run.timeout-01 [run] RUN_TIMEOUT exceeded" - out = [canon] + out - return out - - -def count_status(lines: list[str]) -> tuple[int, int, int | None, int | None]: - """Return (error_count, warn_count, first_error_idx, first_warn_idx).""" - n_err = 0 - n_warn = 0 - first_err: int | None = None - first_warn: int | None = None - for i, ln in enumerate(lines): - kind = classify(ln) - if kind in ("error", "blocker"): - if is_summary_error_line(ln): - continue - n_err += 1 - if first_err is None: - first_err = i - elif kind == "warn": - n_warn += 1 - if first_warn is None: - first_warn = i - return n_err, n_warn, first_err, first_warn - - -def slug_error(i: int, text: str) -> str: - if is_run_timeout_text(text): - return "err-run-timeout" - m = TID_RE.search(text) - if m: - return "err-" + re.sub(r"[^a-z0-9_-]+", "-", m.group("tid").lower()) - return f"err-{i:03d}" - - -def render_line(ln: str, err_slugs: dict[str, str], extra_ids: list[str] | None = None) -> str: - kind = classify(ln) - esc = html.escape(ln) - for tid, slug in err_slugs.items(): - if tid in ln: - esc = esc.replace( - html.escape(tid), - f'{html.escape(tid)}', - ) - id_attr = "" - if extra_ids: - id_attr = f' id="{" ".join(extra_ids)}"' # invalid multi-id; use first only - # HTML allows one id — join carefully - id_attr = f' id="{html.escape(extra_ids[0])}"' - if len(extra_ids) > 1: - # secondary anchors as empty spans prepended - spans = "".join(f'' for x in extra_ids[1:]) - return f'{spans}{esc}\n' - return f'{esc}\n' - - -def status_level(n_err: int, n_warn: int) -> str: - if n_err > 0: - return "red" - if n_warn > 0: - return "yellow" - return "green" - - -def extract_phases(log_text: str) -> str: - """Best-effort phases string from host-agent / taler-monitoring log header.""" - for pat in ( - r"phases=([a-z0-9 _.-]+)", - r"· phases=([a-z0-9 _.-]+)", - r"phases\s{2,}([a-z0-9 _.-]+)", - r"^\s*phases\s+([a-z0-9 _.-]+)\s*$", - ): - m = re.search(pat, log_text, re.I | re.M) - if m: - return re.sub(r"\s+", " ", m.group(1)).strip(" ·") - return "" - - -def monitoring_scope( - page_label: str, - hostname: str, - log_text: str = "", -) -> dict[str, object]: - """ - Human-readable description of what this monitoring page covers. - Returned keys: title, summary, items (list[str]), kind - """ - label = (page_label or "monitoring").lower().replace("_", "-") - phases = extract_phases(log_text) - host = hostname or "?" - - if "surface" in label: - return { - "kind": "surface", - "title": "Remote ecosystem surface inventory", - "summary": "Public catalog hosts · DNS / TCP / HTTPS / TLS · Server version · CVE", - "items": [ - "Outside-in only: no SSH and no podman exec on remote targets", - "Catalog (surface-catalog.conf): taler.net, demo/test, gnunet.org, " - "taler-ops.ch, taler-systems.com, deb.taler.net, ftp.gnu.org, …", - "Per host: DNS resolve, ICMP (info), open ports, protocol probes, " - "TLS cert expiry, Server-header software version, optional CVE check", - f"HTML published only on taler.hacktivism.ch " - f"(this page host: {host})", - "Timer: taler-monitoring-surface.timer (hourly)", - ], - } - - if "aptdeploy" in label or "apt-deploy" in label or "apt_src" in label: - return { - "kind": "aptdeploy", - "title": "apt-src merchant deploy tests (podman on koopa)", - "summary": "4 containers · fresh + upgrade tracks · trixie & trixie-testing", - "items": [ - "koopa-taler-deploy-test-apt-src-trixie (fresh, suite trixie)", - "koopa-taler-deploy-test-apt-src-trixie-testing (fresh, trixie-testing)", - "koopa-taler-deploy-test-apt-src-trixie-upgrade (upgrade track)", - "koopa-taler-deploy-test-apt-src-trixie-testing-upgrade (upgrade track)", - "Checks: packages, taler-merchant-httpd --version / ldd, systemd unit, " - "optional local /config probe — not public nginx/HTTPS", - f"HTML published only on taler.hacktivism.ch (this page host: {host})", - "Timer: taler-monitoring-aptdeploy.timer (4h)", - ], - } - - # Default: stack /monitoring pages (bank, exchange, merchant) - phase_txt = phases or "urls · inside · versions" - role = "stack" - if host.startswith("bank."): - role = "bank" - focus = "Libeufin bank public HTTPS + container inside checks" - elif host.startswith("exchange.") or host.startswith("stage.exchange."): - role = "exchange" - focus = "Exchange public HTTPS + container inside checks" - elif host.startswith("taler.") or "merchant" in host or host.startswith("monnaie."): - role = "merchant" - focus = "Merchant backend / SPA public HTTPS + container inside checks" - else: - focus = "Taler stack public + inside checks" - - items = [ - f"Focus: {focus}", - f"Phases this run: {phase_txt}", - "Typical: public URLs (HTTPS, QR, perf), inside (podman/ssh), package versions", - f"HTML host: {host}", - "Timer: taler-monitoring-hacktivism (path + 4h) · /monitoring on bank, exchange, taler", - ] - if phases: - items.insert(2, f"Log header phases= {phases}") - - return { - "kind": role, - "title": f"Stack monitoring · {host}", - "summary": f"{phase_txt} · public + inside", - "items": items, - } - - -def sticky_bar_html( - *, - generated_at: str, - generated_iso: str, - n_err: int, - n_warn: int, - hostname: str, - page_label: str, - mode: str, - commit_html: str, - suite_path: str, - link_other: str | None, - first_err_href: str | None, - first_warn_href: str | None, - scope: dict[str, object] | None = None, -) -> str: - level = status_level(n_err, n_warn) - if level == "red": - status_txt = "ERRORS" - elif level == "yellow": - status_txt = "WARNINGS" - else: - status_txt = "OK" - - if first_err_href and n_err: - err_stat = ( - f'{n_err} error{"s" if n_err != 1 else ""}' - ) - else: - err_stat = f'{n_err} errors' - - if first_warn_href and n_warn: - warn_stat = ( - f'{n_warn} warning{"s" if n_warn != 1 else ""}' - ) - else: - warn_stat = f'{n_warn} warnings' - - other = "" - if link_other: - other = ( - f'' - f"{html.escape(link_other)}" - ) - - if scope is None: - scope = monitoring_scope(page_label, hostname, "") - scope_title = html.escape(str(scope.get("title") or "Monitoring")) - scope_summary = html.escape(str(scope.get("summary") or "")) - scope_kind = html.escape(str(scope.get("kind") or "monitoring")) - lis = "\n".join( - f"
  • {html.escape(str(it))}
  • " for it in (scope.get("items") or []) - ) - suite_bit = ( - f" · {html.escape(suite_path)}" if suite_path else "" - ) - - # Compact sticky row always visible; "Was geprüft wird" expands inside sticky-bar - return f""" - -""" - - -STICKY_CSS = """ -.sticky-bar { - position: sticky; top: 0; z-index: 100; - border-bottom: 2px solid var(--border); - padding: 10px 14px 8px; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - box-shadow: 0 2px 12px #0008; -} -.sticky-bar.sticky-green { - background: linear-gradient(180deg, #0d1f14f2, #0a1410ee); - border-bottom-color: #1f6b3a; -} -.sticky-bar.sticky-yellow { - background: linear-gradient(180deg, #2a2210f2, #1a160cee); - border-bottom-color: #a68b2d; -} -.sticky-bar.sticky-red { - background: linear-gradient(180deg, #2a1010f2, #1a0808ee); - border-bottom-color: #a33; -} -.sticky-bar-row { - display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; - max-width: 1200px; -} -.sticky-bar-row.secondary { - margin-top: 8px; font-size: 12px; color: var(--dim); -} -.sticky-bar-row.secondary a { color: var(--info); text-decoration: none; } -.sticky-bar-row.secondary a:hover { text-decoration: underline; } -.status-pill { - display: inline-block; font-size: 11px; font-weight: 800; - letter-spacing: 0.06em; padding: 2px 10px; border-radius: 3px; - border: 1px solid; -} -.status-pill.sticky-green { color: var(--ok); border-color: #1f6b3a; background: #0a1a10; } -.status-pill.sticky-yellow { color: var(--warn); border-color: #a68b2d; background: #1a1608; } -.status-pill.sticky-red { color: var(--err); border-color: #a33; background: #1a0a0a; } -.host { font-weight: 600; color: #eee; font-size: 13px; } -.sep { color: var(--dim); } -.stat { - font-weight: 700; font-size: 12px; text-decoration: none; - padding: 1px 8px; border-radius: 3px; border: 1px solid transparent; -} -.stat.err { color: var(--err); border-color: #522; background: #1a0a0a; } -.stat.warn { color: var(--warn); border-color: #664; background: #1a1608; } -.stat.muted { opacity: 0.55; font-weight: 600; } -a.stat:hover { text-decoration: underline; filter: brightness(1.15); } -.generated { color: var(--dim); font-size: 12px; } -.generated time { color: #bbb; } -.generated .age { color: #888; margin-left: 4px; } -.generated .age:not(:empty)::before { content: "("; } -.generated .age:not(:empty)::after { content: ")"; } -.badge { - display: inline-block; padding: 1px 8px; border-radius: 3px; - font-size: 11px; font-weight: 700; margin-right: 4px; - border: 1px solid var(--border); -} -.badge.mode-err { color: var(--err); border-color: #522; background: #1a0a0a; } -.badge.mode-ok { color: var(--ok); border-color: #143; background: #0a1a10; } -.badge.mode-redirect { color: var(--warn); border-color: #664; background: #1a1608; } -/* collapsible block inside sticky-bar */ -.scope-toggle { - margin-left: auto; - display: inline-flex; - align-items: center; - gap: 8px; - max-width: min(42rem, 100%); - cursor: pointer; - font: inherit; - font-size: 12px; - color: #ddd; - background: #0006; - border: 1px solid #444; - border-radius: 4px; - padding: 4px 10px; - text-align: left; -} -.scope-toggle:hover { border-color: #888; background: #111a; color: #fff; } -.scope-toggle:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; } -.scope-toggle[aria-expanded="true"] { - border-color: #777; - background: #141414; -} -.scope-chevron { - display: inline-block; - width: 0; height: 0; - border-left: 5px solid #aaa; - border-top: 4px solid transparent; - border-bottom: 4px solid transparent; - transition: transform 0.12s ease; - flex-shrink: 0; -} -.scope-toggle[aria-expanded="true"] .scope-chevron { - transform: rotate(90deg); - border-left-color: #eee; -} -.scope-toggle-label { - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; - font-size: 11px; - white-space: nowrap; -} -.scope-toggle-hint { - color: #888; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} -.sticky-scope-panel { - margin-top: 8px; - max-width: 1200px; - border: 1px solid #333; - border-radius: 4px; - background: #080808e6; - padding: 8px 10px 10px; -} -.sticky-scope-panel[hidden] { display: none !important; } -.scope-body { padding: 4px 4px 2px 6px; } -.scope-title { - margin: 4px 0 4px; - font-size: 13px; - font-weight: 600; - color: #eee; -} -.scope-lead { - margin: 0 0 6px; - font-size: 12px; - color: #999; -} -.scope-list { - margin: 0; - padding-left: 1.15em; - color: #b0b0b0; - font-size: 12px; - line-height: 1.5; -} -.scope-list li { margin: 3px 0; } -#first-error, #first-warn, -[id^="err-"][id$="-line"] { - scroll-margin-top: 120px; -} -@media (max-width: 720px) { - .scope-toggle { margin-left: 0; width: 100%; } - .scope-toggle-hint { display: none; } -} -""" - -STICKY_JS = """ - -""" - - -def build_html( - *, - title: str, - hostname: str, - mode: str, - log_text: str, - commit: str, - commit_url: str, - suite_path: str, - generated_at: str, - generated_iso: str, - link_other: str | None, - page_label: str = "monitoring", -) -> str: - raw_lines = [strip_ansi(l).rstrip("\n") for l in log_text.splitlines()] - while raw_lines and not raw_lines[-1].strip(): - raw_lines.pop() - - n_err, n_warn, first_err_i, first_warn_i = count_status(raw_lines) - errors = extract_errors(raw_lines) - - err_slugs: dict[str, str] = {} - for i, e in enumerate(errors, 1): - slug = slug_error(i, e) - err_slugs[e] = slug - m = TID_RE.search(e) - if m: - err_slugs[m.group("tid")] = slug - - tid_to_slug = { - k: v for k, v in err_slugs.items() if re.match(r"^[a-z0-9_.-]+-\d+$", k, re.I) - } - - # Build body with anchors for first error/warn + per-error slugs - body_lines: list[str] = [] - claimed_err: set[str] = set() - for idx, ln in enumerate(raw_lines): - extra: list[str] = [] - if first_err_i is not None and idx == first_err_i: - extra.append("first-error") - if first_warn_i is not None and idx == first_warn_i: - extra.append("first-warn") - # per-error jump targets (all modes) - if classify(ln) in ("error", "blocker") and not is_summary_error_line(ln): - for i, e in enumerate(errors, 1): - slug = slug_error(i, e) - if slug in claimed_err: - continue - tid_m = TID_RE.search(e) - if is_run_timeout_text(e): - if "run.timeout-01" in ln and "ERROR" in ln.upper(): - extra.append(f"{slug}-line") - claimed_err.add(slug) - break - continue - key = tid_m.group(1) if tid_m else e[:40] - if key and key in ln: - extra.append(f"{slug}-line") - claimed_err.add(slug) - break - body_lines.append(render_line(ln, tid_to_slug, extra or None)) - - err_nav = "" - if errors and mode == "err": - items = [] - timeout_items = [] - for i, e in enumerate(errors, 1): - slug = slug_error(i, e) - li = ( - f'
  • ' - f'{html.escape(e)}
  • ' - ) - if is_run_timeout_text(e): - timeout_items.append(li) - else: - items.append(li) - if timeout_items: - err_nav += ( - '
    \n' - "

    EXTRAORDINARY · RUN TIMEOUT

    \n" - "

    Whole-run wall clock exceeded. " - 'Jump to detail →

    \n' - '
      \n' - + "\n".join(timeout_items[:1]) - + "\n
    \n
    \n" - ) - rest = items if timeout_items else (timeout_items + items) - if rest or not timeout_items: - err_nav += ( - '
    \n' - f"

    ERRORS ({len(errors)}) — jump to detail

    \n" - '
      \n' - + "\n".join(rest if rest else items) - + "\n
    \n
    \n" - ) - - commit_short = commit[:12] if commit else "unknown" - commit_html = ( - f'{html.escape(commit_short)}' - if commit_url - else html.escape(commit_short) - ) - - first_err_href = "#first-error" if first_err_i is not None else None - first_warn_href = "#first-warn" if first_warn_i is not None else None - scope = monitoring_scope(page_label, hostname, log_text) - - bar = sticky_bar_html( - generated_at=generated_at, - generated_iso=generated_iso, - n_err=n_err, - n_warn=n_warn, - hostname=hostname, - page_label=page_label, - mode=mode, - commit_html=commit_html, - suite_path=suite_path, - link_other=link_other, - first_err_href=first_err_href, - first_warn_href=first_warn_href, - scope=scope, - ) - - return f""" - - - - - -{html.escape(title)} - - - -{bar} -
    -{err_nav} -
    -{"".join(body_lines)} -
    -
    - Console-style render of taler-monitoring output. - Sticky bar: green = clean · yellow = warnings · red = errors. - Commit pins the exact tree used for this run. -
    -
    -{STICKY_JS} - - -""" - - -def build_redirect_html( - *, - hostname: str, - commit: str, - commit_url: str, - path_err: str = "/monitoring_err/", - page_label: str = "monitoring", - generated_at: str = "", - generated_iso: str = "", - n_err: int = 0, - n_warn: int = 0, - log_text: str = "", -) -> str: - commit_short = commit[:12] if commit else "unknown" - link = path_err if path_err.endswith("/") else path_err + "/" - c = ( - f'{html.escape(commit_short)}' - if commit_url - else html.escape(commit_short) - ) - if not generated_at: - now = datetime.now(timezone.utc) - generated_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ") - generated_at = now.strftime("%Y-%m-%d %H:%M:%SZ") - # Redirect pages exist only when the run failed → treat as red if n_err unknown - if n_err <= 0: - n_err = max(n_err, 1) - scope = monitoring_scope(page_label, hostname, log_text) - bar = sticky_bar_html( - generated_at=generated_at, - generated_iso=generated_iso, - n_err=n_err, - n_warn=n_warn, - hostname=hostname, - page_label=page_label, - mode="redirect", - commit_html=c, - suite_path="", - link_other=link, - first_err_href=link, - first_warn_href=None, - scope=scope, - ) - return f""" - - - - - - -{html.escape(page_label)} · errors · {html.escape(hostname)} - - - -{bar} -
    -

    {html.escape(page_label)} has failures.

    -

    See {html.escape(link)} for the full console log, - error index, and sticky status bar.

    -

    generated {html.escape(generated_at)} · source {c}

    -
    -{STICKY_JS} - - -""" - - -def main() -> None: - ap = argparse.ArgumentParser() - ap.add_argument("--log", required=True, type=Path) - ap.add_argument("--out", required=True, type=Path) - ap.add_argument("--hostname", required=True) - ap.add_argument("--mode", choices=("ok", "err", "redirect"), required=True) - ap.add_argument("--commit", default="") - ap.add_argument("--commit-url", default="") - ap.add_argument("--suite-path", default="scripts/taler-monitoring") - ap.add_argument("--title", default="") - ap.add_argument("--link-other", default="") - ap.add_argument( - "--path-err", - default="/monitoring_err/", - help="URL path for error page (redirect target)", - ) - ap.add_argument( - "--page-label", - default="monitoring", - help="Short name shown in titles/redirect box", - ) - args = ap.parse_args() - - log_text = args.log.read_text(errors="replace") if args.log.is_file() else "" - now = datetime.now(timezone.utc) - generated_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ") - generated_at = now.strftime("%Y-%m-%d %H:%M:%SZ") - title = args.title or f"{args.page_label} {args.mode} · {args.hostname}" - - raw_for_counts = [strip_ansi(l).rstrip("\n") for l in log_text.splitlines()] - n_err, n_warn, _, _ = count_status(raw_for_counts) - - if args.mode == "redirect": - html_out = build_redirect_html( - hostname=args.hostname, - commit=args.commit, - commit_url=args.commit_url, - path_err=args.path_err, - page_label=args.page_label, - generated_at=generated_at, - generated_iso=generated_iso, - n_err=n_err, - n_warn=n_warn, - log_text=log_text, - ) - else: - html_out = build_html( - title=title, - hostname=args.hostname, - mode=args.mode, - log_text=log_text, - commit=args.commit, - commit_url=args.commit_url, - suite_path=args.suite_path, - generated_at=generated_at, - generated_iso=generated_iso, - link_other=args.link_other or None, - page_label=args.page_label, - ) - - args.out.parent.mkdir(parents=True, exist_ok=True) - args.out.write_text(html_out, encoding="utf-8") - print(f"wrote {args.out} (errors={n_err} warnings={n_warn} level={status_level(n_err, n_warn)})") - - -if __name__ == "__main__": - main() diff --git a/scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh b/scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh deleted file mode 100755 index c5718e1..0000000 --- a/scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# deploy-monitoring-sites.sh — rsync generated HTML to DEPLOY_WWW_ROOT on koopa. -# Does NOT reload Caddy (needs root — see ROOT-ON-KOOPA.md). -set -uo pipefail - -ROOT=$(cd "$(dirname "$0")" && pwd) -SETTINGS="${SITE_GEN_SETTINGS:-$ROOT/settings.conf}" -[ -f "$SETTINGS" ] || SETTINGS="$ROOT/settings.conf.example" -# shellcheck disable=SC1090 -set -a -source <(grep -v '^\s*#' "$SETTINGS" | grep -v '^\s*$' | sed 's/\r$//') -set +a - -WORK_ROOT="${WORK_ROOT:-/tmp/taler-monitoring-sites-work}" -HTML_DIR="$WORK_ROOT/html" -DEPLOY_SSH="${DEPLOY_SSH:-koopa-external}" -DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" - -if [ ! -d "$HTML_DIR" ]; then - echo "no HTML_DIR $HTML_DIR — run generate-monitoring-sites.sh first" >&2 - exit 1 -fi - -echo "deploy $HTML_DIR → ${DEPLOY_SSH}:${DEPLOY_WWW_ROOT}/" -# Prefer writing to a hernani-owned staging dir if /var/www not writable -STAGE_REMOTE="${DEPLOY_STAGING:-/home/hernani/monitoring-sites-staging}" - -ssh -o BatchMode=yes -o ConnectTimeout=20 "$DEPLOY_SSH" \ - "mkdir -p '$STAGE_REMOTE' '$DEPLOY_WWW_ROOT' 2>/dev/null || mkdir -p '$STAGE_REMOTE'" - -rsync -az --delete "$HTML_DIR/" "${DEPLOY_SSH}:${STAGE_REMOTE}/" -echo "staged on $DEPLOY_SSH:$STAGE_REMOTE" -echo -echo "If $DEPLOY_WWW_ROOT is root-owned, as root on koopa:" -echo " mkdir -p $DEPLOY_WWW_ROOT" -echo " rsync -a --delete $STAGE_REMOTE/ $DEPLOY_WWW_ROOT/" -echo " chown -R caddy:caddy $DEPLOY_WWW_ROOT # or root:caddy — match other www" -echo " # then Caddyfile handles + reload — see ROOT-ON-KOOPA.md" -echo -# try direct rsync to DEPLOY_WWW_ROOT if writable -if ssh -o BatchMode=yes "$DEPLOY_SSH" "test -w '$DEPLOY_WWW_ROOT' 2>/dev/null || test -w \"\$(dirname '$DEPLOY_WWW_ROOT')\""; then - rsync -az --delete "$HTML_DIR/" "${DEPLOY_SSH}:${DEPLOY_WWW_ROOT}/" && \ - echo "also synced directly to $DEPLOY_WWW_ROOT" -fi diff --git a/scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh b/scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh deleted file mode 100755 index f45e5bd..0000000 --- a/scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env bash -# generate-monitoring-sites.sh — run taler-monitoring, build console HTML for -# /monitoring and /monitoring_err (static files for host Caddy later). -# -# Usage: -# ./generate-monitoring-sites.sh # all SITES (or ONLY_HOSTS) -# ONLY_HOSTS="bank.hacktivism.ch taler.hacktivism.ch" ./generate-monitoring-sites.sh -# ./generate-monitoring-sites.sh --dry-run # HTML from empty/missing logs only -# ./generate-monitoring-sites.sh --skip-run # only convert existing logs -# -# Settings: settings.conf (from settings.conf.example). No secrets in settings. -# Secrets for e2e: ../secrets.env (optional; default phases are urls versions). -# -set -uo pipefail - -export PYTHONUNBUFFERED=1 -# Global reporting defaults (same as host-agent run-host-report.sh) -: "${RUN_TIMEOUT:=600}" -export RUN_TIMEOUT - -ROOT=$(cd "$(dirname "$0")" && pwd) -SETTINGS="${SITE_GEN_SETTINGS:-$ROOT/settings.conf}" -EXAMPLE="$ROOT/settings.conf.example" - -if [ ! -f "$SETTINGS" ]; then - if [ -f "$EXAMPLE" ]; then - echo "note: no $SETTINGS — using example defaults (copy to settings.conf to customize)" - SETTINGS="$EXAMPLE" - else - echo "missing settings: $EXAMPLE" >&2 - exit 2 - fi -fi - -# Load KEY=value lines; allow SITES already set in environment to win. -# Multiline SITES in the conf file: use SITES_FILE= or env SITES= instead. -while IFS= read -r _line || [ -n "$_line" ]; do - _line=${_line%%#*} - _line=$(echo "$_line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - [ -z "$_line" ] && continue - case "$_line" in - SITES=*|SITES_FILE=*) continue ;; # handled below / env - *=*) - _k=${_line%%=*} - _v=${_line#*=} - # do not override pre-set env - if [ -z "${!_k+x}" ] 2>/dev/null || [ -z "${!_k}" ]; then - export "$_k=$_v" - fi - ;; - esac -done < "$SETTINGS" - -# SITES from dedicated file or keep env; else parse heredoc-style from example list -if [ -n "${SITES_FILE:-}" ] && [ -f "$SITES_FILE" ]; then - SITES=$(grep -v '^\s*#' "$SITES_FILE" | grep -v '^\s*$') - export SITES -elif [ -z "${SITES:-}" ]; then - # default 9 fronts - SITES="bank.hacktivism.ch|hacktivism.ch -exchange.hacktivism.ch|hacktivism.ch -taler.hacktivism.ch|hacktivism.ch -stage.bank.lefrancpaysan.ch|stage.lefrancpaysan.ch -stage.exchange.lefrancpaysan.ch|stage.lefrancpaysan.ch -stage.monnaie.lefrancpaysan.ch|stage.lefrancpaysan.ch -bank.lefrancpaysan.ch|lefrancpaysan.ch -exchange.lefrancpaysan.ch|lefrancpaysan.ch -monnaie.lefrancpaysan.ch|lefrancpaysan.ch" - export SITES -fi - -SKIP_RUN=0 -DRY=0 -while [ $# -gt 0 ]; do - case "$1" in - --skip-run) SKIP_RUN=1; shift ;; - --dry-run) DRY=1; SKIP_RUN=1; shift ;; - -h|--help) sed -n '1,20p' "$0"; exit 0 ;; - *) echo "unknown arg: $1" >&2; exit 2 ;; - esac -done - -MONITORING_ROOT=$(cd "$ROOT/${MONITORING_ROOT:-..}" && pwd) -WORK_ROOT="${WORK_ROOT:-/tmp/taler-monitoring-sites-work}" -LOG_DIR="$WORK_ROOT/logs" -HTML_DIR="$WORK_ROOT/html" -mkdir -p "$LOG_DIR" "$HTML_DIR" - -# commit of monitoring suite used for this run -if git -C "$MONITORING_ROOT/../.." rev-parse HEAD >/dev/null 2>&1; then - REPO_ROOT=$(cd "$MONITORING_ROOT/../.." && pwd) -elif git -C "$MONITORING_ROOT" rev-parse HEAD >/dev/null 2>&1; then - REPO_ROOT=$(cd "$MONITORING_ROOT" && pwd) -else - REPO_ROOT=$(cd "$ROOT/../../.." && pwd) -fi -COMMIT=$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown) -COMMIT_SHORT=$(git -C "$REPO_ROOT" rev-parse --short=12 HEAD 2>/dev/null || echo unknown) -SOURCE_REPO_WEB="${SOURCE_REPO_WEB:-https://git.hacktivism.ch/hernani/koopa-admin-log}" -TMPL="${SOURCE_COMMIT_URL_TMPL:-\{repo\}/src/commit/\{commit\}}" -COMMIT_URL=$(printf '%s' "$TMPL" | sed "s|{repo}|$SOURCE_REPO_WEB|g; s|{commit}|$COMMIT|g") -SUITE_PATH="${SOURCE_SUITE_PATH:-scripts/taler-monitoring}" - -# Outside-only public checks (no SSH into koopa/stage containers) -PHASES_ERR="${PHASES_ERR:-urls versions}" -PHASES_OK="${PHASES_OK:-urls versions}" -CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" -SKIP_SSH="${SKIP_SSH:-1}" -# Drop SSH phases if someone passes "all" / inside / server / e2e by mistake -filter_phases_outside() { - local out=() p - for p in "$@"; do - case "$p" in - inside|server|e2e|ladder|goa-ladder|auth401) continue ;; - all) out+=(urls versions);; - *) out+=("$p") ;; - esac - done - printf '%s\n' "${out[@]}" -} - -run_mon() { - local domain="$1" phases="$2" logfile="$3" cont="$4" - # shellcheck disable=SC2206 - local ph=( $phases ) - mapfile -t ph < <(filter_phases_outside "${ph[@]}") - [ "${#ph[@]}" -gt 0 ] || ph=(urls versions) - - : >"$logfile" - echo "+ -d $domain · phases=${ph[*]} · CONTINUE=$cont · SKIP_SSH=$SKIP_SSH · RUN_TIMEOUT=$RUN_TIMEOUT" - - if [ -n "${RUNNER_SSH:-}" ]; then - echo "+ via ssh $RUNNER_SSH" - rsync -az --delete \ - --exclude secrets.env \ - --exclude 'android-test/artifacts' \ - --exclude '.git' \ - "$MONITORING_ROOT/" \ - "${RUNNER_SSH}:${RUNNER_REMOTE_WORKDIR}/" \ - || return 1 - # shellcheck disable=SC2029 - if command -v stdbuf >/dev/null 2>&1; then - ssh -o BatchMode=yes -o ConnectTimeout=30 "$RUNNER_SSH" \ - "cd '${RUNNER_REMOTE_WORKDIR}' && chmod +x taler-monitoring.sh check_*.sh 2>/dev/null; \ - export CONTINUE_ON_ERROR='$cont' AUTH401_CONTINUE='$cont' SKIP_SSH='$SKIP_SSH' RUN_TIMEOUT='$RUN_TIMEOUT'; \ - stdbuf -oL -eL ./taler-monitoring.sh -d '$domain' ${ph[*]}" \ - 2>&1 | stdbuf -oL -eL tee -a "$logfile" - else - ssh -o BatchMode=yes -o ConnectTimeout=30 "$RUNNER_SSH" \ - "cd '${RUNNER_REMOTE_WORKDIR}' && chmod +x taler-monitoring.sh check_*.sh 2>/dev/null; \ - export CONTINUE_ON_ERROR='$cont' AUTH401_CONTINUE='$cont' SKIP_SSH='$SKIP_SSH' RUN_TIMEOUT='$RUN_TIMEOUT'; \ - ./taler-monitoring.sh -d '$domain' ${ph[*]}" \ - 2>&1 | tee -a "$logfile" - fi - return "${PIPESTATUS[0]}" - fi - - if command -v stdbuf >/dev/null 2>&1; then - ( cd "$MONITORING_ROOT" && \ - CONTINUE_ON_ERROR="$cont" AUTH401_CONTINUE="$cont" SKIP_SSH="$SKIP_SSH" \ - RUN_TIMEOUT="$RUN_TIMEOUT" \ - stdbuf -oL -eL ./taler-monitoring.sh -d "$domain" "${ph[@]}" ) \ - 2>&1 | stdbuf -oL -eL tee -a "$logfile" - else - ( cd "$MONITORING_ROOT" && \ - CONTINUE_ON_ERROR="$cont" AUTH401_CONTINUE="$cont" SKIP_SSH="$SKIP_SSH" \ - RUN_TIMEOUT="$RUN_TIMEOUT" \ - ./taler-monitoring.sh -d "$domain" "${ph[@]}" ) \ - 2>&1 | tee -a "$logfile" - fi - return "${PIPESTATUS[0]}" -} - -htmlify() { - local host="$1" mode="$2" log="$3" out="$4" other="$5" - mkdir -p "$(dirname "$out")" - if [ ! -f "$ROOT/console_to_html.py" ]; then - echo "WARN: console_to_html.py missing — bootstrap $out" - { - echo "$host $mode" - echo "

    $host · $mode

    commit $COMMIT_SHORT

    " - echo "
    "
    -      tail -n 100 "$log" 2>/dev/null | sed 's/&/\&/g;s/"
    -    } >"$out"
    -    return 0
    -  fi
    -  python3 "$ROOT/console_to_html.py" \
    -    --log "$log" \
    -    --out "$out" \
    -    --hostname "$host" \
    -    --mode "$mode" \
    -    --commit "$COMMIT" \
    -    --commit-url "$COMMIT_URL" \
    -    --suite-path "$SUITE_PATH" \
    -    --link-other "$other"
    -}
    -
    -# Parse SITES
    -mapfile -t SITE_LINES < <(printf '%s\n' "$SITES" | sed '/^\s*$/d' | sed 's/#.*//')
    -ONLY="${ONLY_HOSTS:-}"
    -
    -for line in "${SITE_LINES[@]}"; do
    -  line=$(echo "$line" | tr -d ' \t')
    -  [ -z "$line" ] && continue
    -  host=${line%%|*}
    -  domain=${line#*|}
    -  if [ -n "$ONLY" ]; then
    -    echo " $ONLY " | grep -q " $host " || continue
    -  fi
    -
    -  echo "======== $host  (domain=$domain)  commit=$COMMIT_SHORT ========"
    -  base_html="$HTML_DIR/$host"
    -  mkdir -p "$base_html/monitoring" "$base_html/monitoring_err"
    -  log_err="$LOG_DIR/${host}.err.log"
    -  log_ok="$LOG_DIR/${host}.ok.log"
    -  ec_err=1
    -  ec_ok=1
    -
    -  if [ "$SKIP_RUN" != "1" ]; then
    -    run_mon "$domain" "$PHASES_ERR" "$log_err" "$CONTINUE_ON_ERROR"
    -    ec_err=$?
    -    # ok-run without continue (strict)
    -    run_mon "$domain" "$PHASES_OK" "$log_ok" "0"
    -    ec_ok=$?
    -  else
    -    [ -f "$log_err" ] || : >"$log_err"
    -    [ -f "$log_ok" ] || : >"$log_ok"
    -    # infer exit from log if present
    -    if grep -q '\[ ERROR \]' "$log_err" 2>/dev/null || grep -q 'ERROR' "$log_err" 2>/dev/null; then
    -      ec_err=1
    -    else
    -      ec_err=0
    -    fi
    -    ec_ok=$ec_err
    -  fi
    -
    -  # Always write pages (first run / empty logs too)
    -  [ -f "$log_err" ] || : >"$log_err"
    -  [ -f "$log_ok" ] || : >"$log_ok"
    -
    -  htmlify "$host" "err" "$log_err" \
    -    "$base_html/monitoring_err/index.html" "/monitoring/"
    -
    -  if [ "$ec_ok" -eq 0 ] && [ "$ec_err" -eq 0 ]; then
    -    htmlify "$host" "ok" "$log_ok" \
    -      "$base_html/monitoring/index.html" "/monitoring_err/"
    -    echo "  → /monitoring (clean) + /monitoring_err"
    -  else
    -    htmlify "$host" "redirect" "$log_err" \
    -      "$base_html/monitoring/index.html" "/monitoring_err/"
    -    echo "  → /monitoring (redirect stub) + /monitoring_err (errors)  ec_err=$ec_err ec_ok=$ec_ok"
    -  fi
    -
    -  # first-run safety
    -  if [ ! -f "$base_html/monitoring/index.html" ]; then
    -    htmlify "$host" "err" "$log_err" \
    -      "$base_html/monitoring/index.html" "/monitoring_err/"
    -  fi
    -
    -  printf '%s\n' "$COMMIT" >"$base_html/COMMIT"
    -  printf '%s\n' "$COMMIT_URL" >"$base_html/COMMIT_URL"
    -  printf 'host=%s domain=%s ec_err=%s ec_ok=%s commit=%s run_timeout=%s\n' \
    -    "$host" "$domain" "$ec_err" "$ec_ok" "$COMMIT" "$RUN_TIMEOUT" | tee "$base_html/STATUS.txt"
    -done
    -
    -echo
    -echo "HTML under $HTML_DIR"
    -echo "commit $COMMIT_SHORT  $COMMIT_URL"
    -echo "RUN_TIMEOUT=${RUN_TIMEOUT}s (global; 0=unlimited)"
    -echo "Deploy (as hernani, may need sudo for /var/www):"
    -echo "  ./deploy-monitoring-sites.sh"
    -echo "Root on koopa: see ROOT-ON-KOOPA.md"
    diff --git a/scripts/taler-monitoring/site-gen/install-firecuda-timer.sh b/scripts/taler-monitoring/site-gen/install-firecuda-timer.sh
    deleted file mode 100755
    index 9615b96..0000000
    --- a/scripts/taler-monitoring/site-gen/install-firecuda-timer.sh
    +++ /dev/null
    @@ -1,79 +0,0 @@
    -#!/usr/bin/env bash
    -# install-firecuda-timer.sh — OPTIONAL: rsync suite to firecuda + launchd every 4h
    -#
    -# POLICY (2026-07): firecuda launchd is **disabled**. Prefer koopa host-agent.
    -# This script remains for documentation / future re-enable only.
    -# Do not run unless you intentionally want a second outside-only scheduler.
    -#
    -#   ./install-firecuda-timer.sh
    -#   ./install-firecuda-timer.sh --run-now
    -#
    -set -euo pipefail
    -
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -MON_ROOT=$(cd "$ROOT/.." && pwd)
    -FIRECUDA="${FIRECUDA_SSH:-firecuda-external}"
    -REMOTE_BASE="${FIRECUDA_INSTALL_DIR:-taler-monitoring-site-gen}"
    -RUN_NOW=0
    -[ "${1:-}" = "--run-now" ] && RUN_NOW=1
    -
    -echo "install → $FIRECUDA:~/$REMOTE_BASE (outside-only, every 4h)"
    -
    -ssh -o BatchMode=yes -o ConnectTimeout=20 "$FIRECUDA" \
    -  "mkdir -p \"\$HOME/$REMOTE_BASE\" \"\$HOME/Library/Logs/taler-monitoring-sites\" \"\$HOME/var/taler-monitoring-sites-work\" \"\$HOME/Library/LaunchAgents\""
    -
    -# Sync full taler-monitoring suite (no secrets.env)
    -rsync -az --delete \
    -  --exclude secrets.env \
    -  --exclude 'android-test/artifacts' \
    -  --exclude '.git' \
    -  --exclude 'site-gen/settings.conf' \
    -  "$MON_ROOT/" \
    -  "${FIRECUDA}:$REMOTE_BASE/"
    -
    -# settings on firecuda: outside only, run locally on firecuda, deploy to koopa
    -ssh -o BatchMode=yes "$FIRECUDA" "cat > \"\$HOME/$REMOTE_BASE/site-gen/settings.conf\" <<'EOF'
    -# auto-installed by install-firecuda-timer.sh — no secrets
    -RUNNER_SSH=
    -SKIP_SSH=1
    -CONTINUE_ON_ERROR=1
    -PHASES_ERR=urls versions
    -PHASES_OK=urls versions
    -DEPLOY_SSH=koopa-external
    -DEPLOY_STAGING=/home/hernani/monitoring-sites-staging
    -WORK_ROOT=
    -MONITORING_ROOT=..
    -SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    -SOURCE_COMMIT_URL_TMPL={repo}/src/commit/{commit}
    -SOURCE_SUITE_PATH=scripts/taler-monitoring
    -EOF
    -# WORK_ROOT expanded on remote
    -sed -i.bak \"s|^WORK_ROOT=.*|WORK_ROOT=\$HOME/var/taler-monitoring-sites-work|\" \"\$HOME/$REMOTE_BASE/site-gen/settings.conf\" 2>/dev/null || \
    -  sed -i '' \"s|^WORK_ROOT=.*|WORK_ROOT=\$HOME/var/taler-monitoring-sites-work|\" \"\$HOME/$REMOTE_BASE/site-gen/settings.conf\"
    -"
    -
    -# launchd plist with absolute paths
    -ssh -o BatchMode=yes "$FIRECUDA" bash -s < "\$PLIST_DST"
    -chmod +x "\$SITE_GEN"/*.sh "\$SITE_GEN"/console_to_html.py 2>/dev/null || true
    -chmod +x "\$SITE_GEN/run-on-firecuda.sh"
    -launchctl bootout "gui/\$(id -u)/com.hacktivism.taler-monitoring-sites" 2>/dev/null || true
    -launchctl unload "\$PLIST_DST" 2>/dev/null || true
    -launchctl bootstrap "gui/\$(id -u)" "\$PLIST_DST" 2>/dev/null || launchctl load "\$PLIST_DST"
    -launchctl enable "gui/\$(id -u)/com.hacktivism.taler-monitoring-sites" 2>/dev/null || true
    -launchctl print "gui/\$(id -u)/com.hacktivism.taler-monitoring-sites" 2>/dev/null | head -20 || launchctl list | grep taler-monitoring || true
    -echo "OK: launchd com.hacktivism.taler-monitoring-sites (StartInterval=14400)"
    -REMOTE
    -
    -if [ "$RUN_NOW" = "1" ]; then
    -  echo "run once now…"
    -  ssh -o BatchMode=yes "$FIRECUDA" "\"\$HOME/$REMOTE_BASE/site-gen/run-on-firecuda.sh\"" || true
    -fi
    -
    -echo "done. logs: $FIRECUDA:~/Library/Logs/taler-monitoring-sites/"
    -echo "manual: ssh $FIRECUDA '~/taler-monitoring-site-gen/site-gen/run-on-firecuda.sh'"
    diff --git a/scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh b/scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh
    deleted file mode 100755
    index b1b10ec..0000000
    --- a/scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh
    +++ /dev/null
    @@ -1,15 +0,0 @@
    -#!/usr/bin/env bash
    -# pull-firecuda-to-koopa.sh — copy HTML from firecuda work dir → koopa staging
    -# Run on laptop (hernani) where both SSH aliases work.
    -set -euo pipefail
    -FIRECUDA="${FIRECUDA_SSH:-firecuda-external}"
    -KOOPA="${DEPLOY_SSH:-koopa-external}"
    -REMOTE_HTML="${FIRECUDA_HTML:-var/taler-monitoring-sites-work/html/}"
    -STAGE="${DEPLOY_STAGING:-/home/hernani/monitoring-sites-staging}"
    -
    -echo "firecuda:$REMOTE_HTML → $KOOPA:$STAGE"
    -ssh -o BatchMode=yes -o ConnectTimeout=20 "$KOOPA" "mkdir -p '$STAGE'"
    -rsync -az --delete \
    -  "${FIRECUDA}:${REMOTE_HTML}" \
    -  "${KOOPA}:${STAGE}/"
    -echo "OK staged. Root: rsync to /var/www/monitoring-sites (see ROOT-ON-KOOPA.md)"
    diff --git a/scripts/taler-monitoring/site-gen/run-on-firecuda.sh b/scripts/taler-monitoring/site-gen/run-on-firecuda.sh
    deleted file mode 100755
    index 6f77de6..0000000
    --- a/scripts/taler-monitoring/site-gen/run-on-firecuda.sh
    +++ /dev/null
    @@ -1,59 +0,0 @@
    -#!/usr/bin/env bash
    -# run-on-firecuda.sh — entrypoint for launchd/cron on firecuda-external.
    -# Outside-only monitoring (SKIP_SSH=1), all 9 sites, then stage HTML to koopa.
    -#
    -# Installed path on firecuda (default):
    -#   ~/taler-monitoring-site-gen/site-gen/run-on-firecuda.sh
    -#
    -set -uo pipefail
    -
    -export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"
    -
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -LOG_DIR="${FIRECUDA_LOG_DIR:-$HOME/Library/Logs/taler-monitoring-sites}"
    -mkdir -p "$LOG_DIR"
    -STAMP=$(date +%Y%m%d-%H%M%S)
    -LOG="$LOG_DIR/run-${STAMP}.log"
    -exec >>"$LOG" 2>&1
    -
    -echo "======== $(date -Iseconds 2>/dev/null || date) firecuda monitoring sites ========"
    -
    -# Always: public DNS only, no SSH checks into stacks
    -export SKIP_SSH=1
    -export CONTINUE_ON_ERROR=1
    -export AUTH401_CONTINUE=1
    -export RUNNER_SSH=
    -export PHASES_ERR="${PHASES_ERR:-urls versions}"
    -export PHASES_OK="${PHASES_OK:-urls versions}"
    -export WORK_ROOT="${WORK_ROOT:-$HOME/var/taler-monitoring-sites-work}"
    -export MONITORING_ROOT="${MONITORING_ROOT:-$ROOT/..}"
    -export DEPLOY_SSH="${DEPLOY_SSH:-koopa-external}"
    -export DEPLOY_STAGING="${DEPLOY_STAGING:-/home/hernani/monitoring-sites-staging}"
    -
    -# Prefer full 9-site list from settings or defaults inside generate script
    -unset ONLY_HOSTS
    -
    -cd "$ROOT" || exit 1
    -chmod +x generate-monitoring-sites.sh deploy-monitoring-sites.sh console_to_html.py 2>/dev/null || true
    -
    -./generate-monitoring-sites.sh
    -ec=$?
    -
    -# HTML stays on firecuda under WORK_ROOT/html.
    -# Optional push to koopa if this host can SSH there (often only from laptop):
    -if [ -n "${DEPLOY_SSH:-}" ] && [ "${DEPLOY_FROM_FIRECUDA:-0}" = "1" ]; then
    -  if [ -x ./deploy-monitoring-sites.sh ]; then
    -    ./deploy-monitoring-sites.sh || echo "WARN: deploy staging failed"
    -  fi
    -else
    -  echo "note: HTML at ${WORK_ROOT}/html — pull from laptop:"
    -  echo "  rsync -az firecuda-external:var/taler-monitoring-sites-work/html/ \\"
    -  echo "    koopa-external:monitoring-sites-staging/"
    -  echo "  # or: ./pull-firecuda-to-koopa.sh"
    -fi
    -
    -# keep last 20 logs
    -ls -1t "$LOG_DIR"/run-*.log 2>/dev/null | tail -n +21 | xargs rm -f 2>/dev/null || true
    -
    -echo "======== done ec=$ec ========"
    -exit "$ec"
    diff --git a/scripts/taler-monitoring/site-gen/settings.conf.example b/scripts/taler-monitoring/site-gen/settings.conf.example
    deleted file mode 100644
    index 3d14fc2..0000000
    --- a/scripts/taler-monitoring/site-gen/settings.conf.example
    +++ /dev/null
    @@ -1,55 +0,0 @@
    -# monitoring site-gen — non-secret settings (safe as example)
    -# Copy:  cp settings.conf.example settings.conf
    -# Never put passwords/tokens here.
    -
    -# On firecuda the suite runs *locally* (RUNNER_SSH empty). From a laptop you
    -# can set RUNNER_SSH=firecuda-external to drive a one-off remote run instead.
    -RUNNER_SSH=
    -RUNNER_SSH_FALLBACKS=
    -RUNNER_REMOTE_WORKDIR=/tmp/taler-monitoring-site-gen
    -MONITORING_ROOT=..
    -
    -# Outside-only: never SSH into bank/exchange/merchant containers
    -SKIP_SSH=1
    -
    -# Deploy staging on koopa (host Caddy later — root copies to /var/www)
    -DEPLOY_SSH=koopa-external
    -DEPLOY_SSH_FALLBACKS=koopa
    -# Static files for Caddy file_server (created per hostname)
    -DEPLOY_WWW_ROOT=/var/www/monitoring-sites
    -DEPLOY_SUDO=sudo
    -CADDY_CONFIG=/etc/caddy/Caddyfile
    -CADDY_MIRROR_REL=configs/caddy/Caddyfile
    -# Working tree on deploy host for apply (optional)
    -KOOPA_CADDY_DIR=/home/hernani/koopa-caddy
    -
    -# Source link for HTML footer (Forgejo)
    -SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    -SOURCE_COMMIT_URL_TMPL={repo}/src/commit/{commit}
    -SOURCE_SUITE_PATH=scripts/taler-monitoring
    -
    -CONTINUE_ON_ERROR=1
    -PHASES_ERR=urls versions
    -PHASES_OK=urls versions
    -SKIP_E2E=1
    -
    -# Local work dir for logs + HTML
    -WORK_ROOT=/tmp/taler-monitoring-sites-work
    -
    -# Sites: hostname|domain_profile  (9 fronts)
    -# Start with hacktivism; stage + prod LFP follow
    -SITES="
    -bank.hacktivism.ch|hacktivism.ch
    -exchange.hacktivism.ch|hacktivism.ch
    -taler.hacktivism.ch|hacktivism.ch
    -stage.bank.lefrancpaysan.ch|stage.lefrancpaysan.ch
    -stage.exchange.lefrancpaysan.ch|stage.lefrancpaysan.ch
    -stage.monnaie.lefrancpaysan.ch|stage.lefrancpaysan.ch
    -bank.lefrancpaysan.ch|lefrancpaysan.ch
    -exchange.lefrancpaysan.ch|lefrancpaysan.ch
    -monnaie.lefrancpaysan.ch|lefrancpaysan.ch
    -"
    -
    -# Only process this subset (space-separated hostnames). Empty = all SITES.
    -# Example for first deploy: ONLY_HOSTS=bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch
    -ONLY_HOSTS=
    diff --git a/scripts/taler-monitoring/surface-catalog.conf b/scripts/taler-monitoring/surface-catalog.conf
    deleted file mode 100644
    index 16d5755..0000000
    --- a/scripts/taler-monitoring/surface-catalog.conf
    +++ /dev/null
    @@ -1,52 +0,0 @@
    -# surface-catalog.conf — known public Taler / GNUnet / Taler Systems hosts
    -# Format (whitespace-separated; # comments):
    -#   host   ports   expect_proto   label
    -# expect_proto: https | http | ssh | tcp | any
    -# ports: comma-separated (empty = defaults for proto)
    -#
    -# This list is expanded at runtime (DNS discovery, cert SANs, -d domain profile).
    -# Phase `surface` is NOT in default phases — run explicitly:
    -#   ./taler-monitoring.sh surface
    -#   ./taler-monitoring.sh -d hacktivism.ch surface
    -
    -# --- taler.net ecosystem ---
    -www.taler.net                 443     https   taler-www
    -docs.taler.net                443     https   taler-docs
    -git.taler.net                 443,22  https   taler-git
    -bugs.taler.net                443     https   taler-bugs
    -lists.taler.net               443     https   taler-lists
    -tutorials.taler.net           443     https   taler-tutorials
    -bank.demo.taler.net           443     https   demo-bank
    -exchange.demo.taler.net       443     https   demo-exchange
    -backend.demo.taler.net        443     https   demo-merchant
    -shop.demo.taler.net           443     https   demo-shop
    -bank.test.taler.net           443     https   test-bank
    -exchange.test.taler.net       443     https   test-exchange
    -backend.test.taler.net        443     https   test-merchant
    -
    -# --- taler-ops / TOPS ---
    -www.taler-ops.ch              443     https   tops-www
    -taler-ops.ch                  443     https   tops-apex
    -bank.taler-ops.ch             443     https   tops-bank
    -exchange.taler-ops.ch         443     https   tops-exchange
    -my.taler-ops.ch               443     https   tops-merchant
    -stage.taler-ops.ch            443     https   tops-stage-www
    -stage.my.taler-ops.ch         443     https   tops-stage-merchant
    -exchange.stage.taler-ops.ch   443     https   tops-stage-exchange
    -nexus.stage.taler-ops.ch      443     https   tops-stage-nexus
    -shops.taler-ops.ch            443     https   tops-shops
    -mattermost.taler.net          443     https   mattermost
    -
    -# --- gnunet.org ---
    -www.gnunet.org                443,80  https   gnunet-www
    -gnunet.org                    443,80  https   gnunet-apex
    -git.gnunet.org                443,22  https   gnunet-git
    -bugs.gnunet.org               443     https   gnunet-bugs
    -
    -# --- taler-systems.com ---
    -www.taler-systems.com         443     https   tsa-www
    -taler-systems.com             443     https   tsa-apex
    -
    -# --- common infra hostnames (remote only; may 404/redirect) ---
    -deb.taler.net                 443     https   deb-taler
    -ftp.gnu.org                   443     https   gnu-ftp
    diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh
    deleted file mode 100755
    index 557c7a0..0000000
    --- a/scripts/taler-monitoring/taler-monitoring.sh
    +++ /dev/null
    @@ -1,477 +0,0 @@
    -#!/usr/bin/env bash
    -# taler-monitoring — public URL / stack checks for a Taler domain
    -#
    -#   ./taler-monitoring.sh                          # local GOA (urls + inside + e2e)
    -#   ./taler-monitoring.sh -d taler.net urls         # public demo, no SSH
    -#   ./taler-monitoring.sh --domain taler-ops.ch     # public ops, no SSH
    -#   TALER_DOMAIN=demo.taler.net ./taler-monitoring.sh urls
    -#
    -# Tags: [OK] [INFO] [WARN] [ERROR] [BLOCKER]
    -# Exit 0 only if every selected phase exits 0.
    -
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -
    -# Line-buffered stdout/stderr so redirected logs (tee/host-agent) flush each line.
    -if [ "${_TALER_MON_STDBUF:-0}" != "1" ] && command -v stdbuf >/dev/null 2>&1; then
    -  export _TALER_MON_STDBUF=1
    -  exec stdbuf -oL -eL bash "$0" "$@"
    -fi
    -
    -usage() {
    -  cat <<'EOF'
    -taler-monitoring — bank / exchange / merchant checks
    -
    -Usage:
    -  ./taler-monitoring.sh [options] [phases...]
    -
    -Phases:
    -  urls     public HTTPS (no SSH)          ← default with --domain
    -           (includes merchant /webui/ SPA fingerprints: version.txt, overlay, assets)
    -  inside   container status via SSH       (local stack only)
    -  versions taler packages vs deb.taler.net trixie + repo availability
    -  sanity   public + optional server
    -  server   server-side only (SSH)
    -  e2e      withdraw + pay (small amounts; remote aborts on login/KYC)
    -  ladder   withdraw/pay amount ladder (GOA ceiling or stage TESTPAYSAN max_wire)
    -  auth401  merchant Basic-auth / case matrix (HTTP 401 paths; may create throwaway instance)
    -  aptdeploy  koopa podman apt-src smoke: taler-merchant in
    -             koopa-taler-deploy-test-apt-src-trixie{,-testing}
    -  surface  REMOTE-ONLY public inventory (NOT in default/all/full):
    -             ecosystem hosts (taler.net, gnunet.org, taler-systems.com, mattermost, …)
    -             or -d DOMAIN → that domain’s surface; port/protocol/TLS/CVE (OSV)
    -  all      urls + inside + versions + sanity + e2e  (SSH phases only on koopa)
    -  full     all + server + ladder + auth401  (maximum; long-running, needs secrets)
    -           NOTE: surface is never included in all/full — pass it explicitly
    -
    -Options:
    -  -d, --domain DOMAIN   load profile from domains.conf (bank/exchange/merchant)
    -                        presets: koopa | hacktivism.ch | taler.net | taler-ops.ch
    -                                 | my.taler-ops.ch | stage.taler-ops.ch
    -                        unknown domain → heuristic hosts (see README)
    -                        local (koopa) allows SSH; others public-only
    -  --bank URL            bank base (hostname or https://…) — overrides profile
    -  --exchange URL        exchange base — overrides profile
    -  --merchant URL        merchant-backend base — overrides profile
    -  --currency CODE       expected currency (GOA, KUDOS, CHF, …); empty = report only
    -  --no-probe            do not probe alternate hosts (unknown domains only)
    -  -h, --help
    -
    -Add a stack: edit domains.conf (name + bank + exchange + merchant + currency).
    -
    -Examples:
    -  ./taler-monitoring.sh -d taler.net
    -  ./taler-monitoring.sh -d taler-ops.ch urls
    -  ./taler-monitoring.sh -d my.taler-ops.ch urls
    -  ./taler-monitoring.sh -d taler-ops.ch --merchant https://my.taler-ops.ch urls
    -  ./taler-monitoring.sh -d demo.taler.net --currency KUDOS
    -  ./taler-monitoring.sh --exchange https://exchange.taler-ops.ch \
    -      --merchant https://my.taler-ops.ch --bank https://bank.taler-ops.ch urls
    -
    -Env (same meaning):
    -  TALER_DOMAIN  BANK_PUBLIC  EXCHANGE_PUBLIC  MERCHANT_PUBLIC  EXPECT_CURRENCY
    -  TALER_DOMAINS_CONF  SKIP_SSH=1
    -  NO_COLOR=1 / CLICOLOR=0   disable green/yellow/red tags (default: coloured)
    -  SKIP_SSH=1    NO_COLOR=1
    -  RUN_TIMEOUT=600   whole-run wall clock seconds (default 600; 0=unlimited)
    -                    long phases (ladder/full/e2e) need a higher value or 0
    -  DISK_WARN_USED_PCT=85  disk free: WARN when used ≥ this %
    -  DISK_ERR_USED_PCT=95   disk free: ERROR when used ≥ this % (or avail=0 / 100%)
    -  PERF_WARN_MS  PERF_FAIL_MS   (urls latency; default 8000 / 20000)
    -  QR_CHECK=0    skip QR form + qrencode/zbarimg (urls phase)
    -  QR_ECC=M      qrencode ECC level (default M)
    -  KOOPA_SSH  KOOPA_SSH_FALLBACKS   (default koopa → koopa-external)
    -  METRICS_LOAD=0   skip host/container RAM/CPU probes (e2e/ladder/inside)
    -  EXPECT_WEBUI_VERSION=1.6.11   pin merchant /webui/version.txt (urls)
    -  EXPECT_WEBUI_OVERLAY=selfbuild  require substr in version-overlay.txt
    -  WEBUI_OVERLAY_DENY='master-11340'  fail if overlay matches regex
    -  CHECK_WEBUI_SPA=0             skip SPA fingerprint block in urls
    -  AUTH401_*                     see check_auth401.sh / secrets.env.example
    -  AUTH401_CONTINUE=1 / CONTINUE_ON_ERROR=1
    -                        auth401: run all groups, collect every ERROR (no mid-run halt)
    -  APT_DEPLOY_SKIP=1     skip aptdeploy phase
    -  APT_DEPLOY_CONTAINERS="name:suite …"  default trixie + trixie-testing deploy-test pods
    -  SURFACE_CVE=0         disable CVE queries in surface phase
    -  SURFACE_CVE_LEVEL=warn|error  bare Server-header versions default warn
    -                        (Debian package versions default error)
    -  SURFACE_CATALOG=path  override surface-catalog.conf
    -
    -Full load (GOA / hacktivism):
    -  ./taler-monitoring.sh -d hacktivism.ch full
    -  # or explicit:
    -  ./taler-monitoring.sh -d hacktivism.ch urls inside versions sanity server e2e ladder auth401
    -  # hacktivism host-agent also runs aptdeploy (apt-src merchant containers on koopa)
    -
    -Remote surface / ecosystem (explicit only — never default):
    -  ./taler-monitoring.sh surface
    -  ./taler-monitoring.sh -d hacktivism.ch surface
    -  ./taler-monitoring.sh -d lefrancpaysan.ch surface
    -
    -SPA pin after selfbuild:
    -  EXPECT_WEBUI_VERSION=1.6.11 EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \\
    -    WEBUI_OVERLAY_DENY='c778af04|master-11340' \\
    -    ./taler-monitoring.sh -d hacktivism.ch urls
    -EOF
    -}
    -
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -# Optional gitignored secrets.env next to this suite (or MONITORING_SECRETS_ENV=)
    -load_monitoring_secrets_env 2>/dev/null || true
    -
    -# Persist #NNN + progress across check_*.sh child processes
    -TALER_MON_STATE=$(mktemp "${TMPDIR:-/tmp}/taler-mon-state.XXXXXX")
    -export TALER_MON_STATE
    -trap 'rm -f "${TALER_MON_STATE:-}"' EXIT
    -
    -PHASES=()
    -DOMAIN_SET=0
    -BANK_OVERRIDE=""
    -EXCHANGE_OVERRIDE=""
    -MERCHANT_OVERRIDE=""
    -CURRENCY_OVERRIDE=""
    -NO_PROBE=0
    -
    -while [ $# -gt 0 ]; do
    -  case "$1" in
    -    -h|--help) usage; exit 0 ;;
    -    -d|--domain)
    -      [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; }
    -      apply_taler_domain "$2"
    -      DOMAIN_SET=1
    -      TALER_DOMAIN_FROM_CLI=1
    -      shift 2
    -      ;;
    -    --bank)
    -      [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; }
    -      BANK_OVERRIDE="${2%/}"; shift 2
    -      ;;
    -    --exchange)
    -      [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; }
    -      EXCHANGE_OVERRIDE="${2%/}"; shift 2
    -      ;;
    -    --merchant)
    -      [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; }
    -      MERCHANT_OVERRIDE="${2%/}"; shift 2
    -      ;;
    -    --currency)
    -      [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; }
    -      CURRENCY_OVERRIDE="$2"; shift 2
    -      ;;
    -    --no-probe) NO_PROBE=1; shift ;;
    -    urls|inside|versions|sanity|server|e2e|ladder|goa-ladder|auth401|aptdeploy|apt-deploy|apt_src|surface|ecosystem|all|full) PHASES+=("$1"); shift ;;
    -    *)
    -      # bare domain shorthand: ./taler-monitoring.sh taler.net
    -      if [[ "$1" == *.* && "$1" != *://* && "$1" != -* ]]; then
    -        apply_taler_domain "$1"
    -        DOMAIN_SET=1
    -        TALER_DOMAIN_FROM_CLI=1
    -        shift
    -      else
    -        echo "Unknown: $1" >&2; usage >&2; exit 2
    -      fi
    -      ;;
    -  esac
    -done
    -
    -if [ "$NO_PROBE" = "1" ]; then
    -  TALER_DOMAIN_PROBE=0
    -fi
    -if [ -n "$CURRENCY_OVERRIDE" ]; then
    -  EXPECT_CURRENCY="$CURRENCY_OVERRIDE"
    -fi
    -if [ -n "$BANK_OVERRIDE" ]; then BANK_PUBLIC="$BANK_OVERRIDE"; fi
    -if [ -n "$EXCHANGE_OVERRIDE" ]; then EXCHANGE_PUBLIC="$EXCHANGE_OVERRIDE"; fi
    -if [ -n "$MERCHANT_OVERRIDE" ]; then MERCHANT_PUBLIC="$MERCHANT_OVERRIDE"; fi
    -
    -# Only koopa may use SSH. Remote domains: public + optional e2e (no SSH).
    -if [ "${LOCAL_STACK:-1}" != "1" ]; then
    -  SKIP_SSH=1
    -fi
    -
    -# Remote: no SSH; ATM withdraw ladder set in check_e2e (smaller notes)
    -if [ "${LOCAL_STACK}" != "1" ]; then
    -  E2E_FAKE_INCOMING=0
    -  E2E_REMOTE=1
    -  : "${E2E_WITHDRAW_VALUES:=10 20 50}"
    -  : "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}"
    -fi
    -
    -# Export so check_*.sh (re-source lib) see the same targets via env
    -export TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC
    -export EXPECT_CURRENCY SKIP_SSH LOCAL_STACK TALER_DOMAIN_PROBE CHECK_LANDING
    -export TALER_DOMAIN_FROM_CLI="${TALER_DOMAIN_FROM_CLI:-0}"
    -export DOMAIN_SET="${DOMAIN_SET:-0}"
    -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 E2E_USE_TEMPLATES E2E_TEMPLATE_MAP
    -export PAIVANA_PUBLIC E2E_PAIVANA E2E_PAIVANA_TEMPLATE E2E_PAIVANA_AMOUNT E2E_PAIVANA_INSTANCE
    -export INSIDE_PROFILE INSIDE_SSH
    -export INSIDE_BANK_CTR INSIDE_EXCHANGE_CTR INSIDE_MERCHANT_CTR
    -export INSIDE_BANK_PORT INSIDE_EXCHANGE_PORT INSIDE_MERCHANT_PORT
    -export INSIDE_DNS_BANK INSIDE_DNS_EXCHANGE INSIDE_DNS_MERCHANT
    -# 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}"
    -: "${LADDER_CONFIRM_POLLS:=40}"
    -: "${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}"
    -: "${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
    -# Optional: auth401 and other phases may honor CONTINUE_ON_ERROR
    -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-}"
    -export AUTH401_CONTINUE="${AUTH401_CONTINUE:-${CONTINUE_ON_ERROR:-}}"
    -
    -# Default phases
    -# - local GOA (koopa): urls + inside + versions + e2e
    -# - FrancPaysan STAGE: urls + inside (stagepaysan) + versions + e2e
    -# - other remote: urls only (pass e2e/inside explicitly)
    -if [ "${#PHASES[@]}" -eq 0 ]; then
    -  if [ "${LOCAL_STACK}" = "1" ]; then
    -    PHASES=(urls inside versions e2e)
    -  elif [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \
    -    || [[ "${TALER_DOMAIN:-}" == stage.*lefrancpaysan* ]] \
    -    || [[ "${TALER_DOMAIN:-}" == *stage.lefrancpaysan* ]]; then
    -    # Like hacktivism breadth: public urls + inside (stagepaysan) + versions + e2e
    -    PHASES=(urls inside versions e2e)
    -  else
    -    PHASES=(urls)
    -  fi
    -fi
    -
    -OUT_PHASES=()
    -for p in "${PHASES[@]}"; do
    -  if [ "$p" = "all" ]; then
    -    if [ "${LOCAL_STACK}" = "1" ]; then
    -      OUT_PHASES+=(urls inside versions sanity e2e)
    -    elif [ -n "${INSIDE_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "stage-lfp" ]; then
    -      OUT_PHASES+=(urls inside versions e2e)
    -    else
    -      # remote without inside SSH: public + optional e2e
    -      OUT_PHASES+=(urls versions e2e)
    -    fi
    -  elif [ "$p" = "full" ]; then
    -    # Maximum coverage. Long. Needs secrets for e2e/ladder/auth401.
    -    if [ "${LOCAL_STACK}" = "1" ]; then
    -      OUT_PHASES+=(urls inside versions sanity server e2e ladder auth401)
    -    elif [ -n "${INSIDE_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "stage-lfp" ]; then
    -      OUT_PHASES+=(urls inside versions e2e ladder auth401)
    -    else
    -      OUT_PHASES+=(urls versions e2e auth401)
    -    fi
    -  else
    -    OUT_PHASES+=("$p")
    -  fi
    -done
    -PHASES=()
    -seen=" "
    -for p in "${OUT_PHASES[@]}"; do
    -  # server = koopa-only. inside allowed on koopa OR stagepaysan (INSIDE_SSH).
    -  case "$p" in
    -    server)
    -      if [ "${LOCAL_STACK}" != "1" ]; then
    -        echo "[INFO]  skip phase 'server' (koopa only)" >&2
    -        continue
    -      fi
    -      ;;
    -    inside)
    -      if [ "${LOCAL_STACK}" != "1" ] && [ -z "${INSIDE_SSH:-}" ] && [ "${INSIDE_PROFILE:-}" != "stage-lfp" ]; then
    -        echo "[INFO]  skip phase 'inside' (no INSIDE_SSH / not local)" >&2
    -        continue
    -      fi
    -      ;;
    -  esac
    -  case "$seen" in *" $p "*) ;; *) PHASES+=("$p"); seen="$seen$p " ;; esac
    -done
    -
    -if [ "${#PHASES[@]}" -eq 0 ]; then
    -  PHASES=(urls)
    -fi
    -
    -# Rough expected check counts for progress % (override: PROGRESS_TOTAL=N).
    -# Prefer slightly high — lib.sh rebalances if still short (never shows done>total).
    -if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then
    -  _pt=0
    -  for p in "${PHASES[@]}"; do
    -    case "$p" in
    -      urls)     _pt=$((_pt + 95)) ;;   # +qr + webui SPA fingerprints
    -      inside)   _pt=$((_pt + 30)) ;;
    -      versions) _pt=$((_pt + 25)) ;;
    -      sanity)   _pt=$((_pt + 35)) ;;
    -      server)   _pt=$((_pt + 20)) ;;
    -      # e2e: ATM ladder emits many INFO lines (coins before/after each note)
    -      e2e)      _pt=$((_pt + 240)) ;;
    -      ladder|goa-ladder) _pt=$((_pt + 120)) ;;
    -      auth401)  _pt=$((_pt + 70)) ;;
    -      aptdeploy) _pt=$((_pt + 20)) ;;
    -      surface|ecosystem) _pt=$((_pt + 80)) ;;
    -    esac
    -  done
    -  set_progress_total "$_pt"
    -  unset _pt
    -fi
    -
    -# Whole-run wall clock (default 10 min). Override: RUN_TIMEOUT=0 (unlimited).
    -: "${RUN_TIMEOUT:=600}"
    -export RUN_TIMEOUT
    -MON_T0=$(date +%s)
    -export MON_T0
    -RUN_TIMED_OUT=0
    -RUN_TIMEOUT_AT_PHASE=""
    -RUN_SKIPPED_PHASES=()
    -
    -mon_seconds_left() {
    -  if [ "${RUN_TIMEOUT:-0}" -eq 0 ]; then
    -    printf '%s' "999999"
    -    return 0
    -  fi
    -  local now left
    -  now=$(date +%s)
    -  left=$((RUN_TIMEOUT - (now - MON_T0)))
    -  [ "$left" -lt 0 ] && left=0
    -  printf '%s' "$left"
    -}
    -
    -# Run one phase script under remaining RUN_TIMEOUT budget.
    -# Exit 124 (timeout utility) → mark RUN_TIMED_OUT.
    -run_phase() {
    -  local phase="$1" script="$2" left rc
    -  left=$(mon_seconds_left)
    -  if [ "${RUN_TIMEOUT:-0}" -gt 0 ] && [ "$left" -le 0 ]; then
    -    RUN_TIMED_OUT=1
    -    RUN_TIMEOUT_AT_PHASE="${RUN_TIMEOUT_AT_PHASE:-$phase}"
    -    return 1
    -  fi
    -  if [ "${RUN_TIMEOUT:-0}" -eq 0 ]; then
    -    "$script"
    -    return $?
    -  fi
    -  # Keep at least 2s so timeout(1) can start the child.
    -  [ "$left" -lt 2 ] && left=2
    -  set +e
    -  with_timeout "$left" "$script"
    -  rc=$?
    -  set -e
    -  if [ "$rc" -eq 124 ]; then
    -    RUN_TIMED_OUT=1
    -    RUN_TIMEOUT_AT_PHASE="$phase"
    -    return 1
    -  fi
    -  return "$rc"
    -}
    -
    -# How inside/versions will reach containers (host-podman vs ssh)
    -_INSIDE_ACCESS_HINT=ssh
    -if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then
    -  _INSIDE_ACCESS_HINT=host-podman
    -elif [ "${SKIP_SSH:-0}" = "1" ]; then
    -  _INSIDE_ACCESS_HINT=skipped
    -elif [ "${LOCAL_STACK:-0}" = "1" ] && command -v podman >/dev/null 2>&1 \
    -  && podman ps --format '{{.Names}}' 2>/dev/null | grep -qE 'taler-hacktivism'; then
    -  _INSIDE_ACCESS_HINT=host-podman
    -fi
    -
    -printf 'target domain=%s\n' "${TALER_DOMAIN}"
    -printf '  bank     %s\n' "$BANK_PUBLIC"
    -printf '  exchange %s\n' "$EXCHANGE_PUBLIC"
    -printf '  merchant %s\n' "$MERCHANT_PUBLIC"
    -printf '  currency expect=%s\n' "${EXPECT_CURRENCY:-any}"
    -printf '  phases   %s\n' "${PHASES[*]}"
    -printf '  flags    LOCAL_STACK=%s SKIP_SSH=%s INSIDE_PODMAN=%s INSIDE_MODE=%s\n' \
    -  "${LOCAL_STACK:-}" "${SKIP_SSH:-0}" "${INSIDE_PODMAN:-0}" "${INSIDE_MODE:-}"
    -printf '  flags    KOOPA_SSH=%s INSIDE_SSH=%s INSIDE_PROFILE=%s\n' \
    -  "${KOOPA_SSH:-}" "${INSIDE_SSH:-}" "${INSIDE_PROFILE:-}"
    -printf '  access   inside/versions → %s' "$_INSIDE_ACCESS_HINT"
    -case "$_INSIDE_ACCESS_HINT" in
    -  host-podman) printf ' (podman exec on this host; IDs inside.host-*)\n' ;;
    -  ssh)         printf ' (SSH then podman; IDs inside.ssh-*)\n' ;;
    -  skipped)     printf ' (SKIP_SSH=1)\n' ;;
    -  *)           printf '\n' ;;
    -esac
    -if [ "${RUN_TIMEOUT:-0}" -eq 0 ]; then
    -  printf '  run_timeout unlimited (RUN_TIMEOUT=0)\n'
    -else
    -  printf '  run_timeout %ss wall clock (RUN_TIMEOUT=; 0=unlimited)\n' "$RUN_TIMEOUT"
    -fi
    -printf '  progress total≈%s (set PROGRESS_TOTAL= to override; PROGRESS_OFF=1 to hide)\n' "${PROGRESS_TOTAL:-0}"
    -unset _INSIDE_ACCESS_HINT
    -
    -chmod +x "$ROOT"/check_*.sh 2>/dev/null || true
    -
    -ec=0
    -for p in "${PHASES[@]}"; do
    -  if [ "$RUN_TIMED_OUT" = "1" ]; then
    -    RUN_SKIPPED_PHASES+=("$p")
    -    continue
    -  fi
    -  left=$(mon_seconds_left)
    -  if [ "${RUN_TIMEOUT:-0}" -gt 0 ] && [ "$left" -le 0 ]; then
    -    RUN_TIMED_OUT=1
    -    RUN_TIMEOUT_AT_PHASE="${RUN_TIMEOUT_AT_PHASE:-$p}"
    -    RUN_SKIPPED_PHASES+=("$p")
    -    continue
    -  fi
    -  case "$p" in
    -    urls)     run_phase urls     "$ROOT/check_urls.sh"     || ec=1 ;;
    -    inside)   run_phase inside   "$ROOT/check_inside.sh"   || ec=1 ;;
    -    versions) run_phase versions "$ROOT/check_versions.sh" || ec=1 ;;
    -    sanity)   run_phase sanity   "$ROOT/check_sanity.sh"   || ec=1 ;;
    -    server)   run_phase server   "$ROOT/check_server.sh"   || ec=1 ;;
    -    e2e)      run_phase e2e      "$ROOT/check_e2e.sh"      || ec=1 ;;
    -    ladder|goa-ladder) run_phase ladder "$ROOT/check_goa_ladder.sh" || ec=1 ;;
    -    auth401)  run_phase auth401  "$ROOT/check_auth401.sh"  || ec=1 ;;
    -    aptdeploy|apt-deploy|apt_src) run_phase aptdeploy "$ROOT/check_apt_deploy.sh" || ec=1 ;;
    -    surface|ecosystem) run_phase surface "$ROOT/check_surface.sh" || ec=1 ;;
    -  esac
    -done
    -
    -# Extraordinary run-budget failure: always report at end; HTML links top → here.
    -if [ "$RUN_TIMED_OUT" = "1" ]; then
    -  ec=1
    -  elapsed=$(( $(date +%s) - MON_T0 ))
    -  skipped="${RUN_SKIPPED_PHASES[*]:-}"
    -  # Stable id for jump links (console HTML + err-top banner)
    -  printf '\n'
    -  printf '╔══════════════════════════════════════════════════════════╗\n'
    -  printf '║ RUN TIMEOUT · extraordinary (see jump target below)     ║\n'
    -  printf '╚══════════════════════════════════════════════════════════╝\n'
    -  printf '┌ ERROR  ┐ #run.timeout-01  RUN_TIMEOUT exceeded · budget %ss · elapsed %ss\n' \
    -    "$RUN_TIMEOUT" "$elapsed"
    -  printf '  detail: wall-clock limit hit'
    -  if [ -n "${RUN_TIMEOUT_AT_PHASE:-}" ]; then
    -    printf ' during/after phase "%s"' "$RUN_TIMEOUT_AT_PHASE"
    -  fi
    -  if [ -n "$skipped" ]; then
    -    printf ' · skipped: %s' "$skipped"
    -  fi
    -  printf '\n'
    -  printf '  id=run.timeout-01\n'
    -  printf '  hint: raise RUN_TIMEOUT= (seconds) or set RUN_TIMEOUT=0 for unlimited\n'
    -  printf -- '┌ RUN TIMEOUT · extraordinary ┐\n'
    -  printf -- '  • run.timeout-01 [run] RUN_TIMEOUT=%ss exceeded (elapsed %ss)%s%s\n' \
    -    "$RUN_TIMEOUT" "$elapsed" \
    -    "${RUN_TIMEOUT_AT_PHASE:+ · phase $RUN_TIMEOUT_AT_PHASE}" \
    -    "${skipped:+ · skipped $skipped}"
    -  printf -- '--- ERRORS (failed checks) ---\n'
    -  printf -- '  • run.timeout-01 [run] RUN_TIMEOUT=%ss exceeded (elapsed %ss)\n' \
    -    "$RUN_TIMEOUT" "$elapsed"
    -fi
    -
    -exit "$ec"
    
    From 010cf58c77fff7185b1363169af53d707bc5a3bf Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= 
    Date: Sat, 18 Jul 2026 19:58:22 +0200
    Subject: [PATCH 316/326] monitoring: move suite to standalone taler-monitoring
     repo
    
    Remove scripts/taler-monitoring implementation from koopa-admin-log.
    Leave scripts/taler-monitoring/README.md pointing at
    https://git.hacktivism.ch/hernani/taler-monitoring (v1.0).
    ---
     scripts/README.md                             |    2 +-
     scripts/taler-monitoring/.gitignore           |    6 -
     .../taler-monitoring/CLI-AUTOMATION-NOTES.md  |  228 ---
     scripts/taler-monitoring/DEPENDENCIES.md      |   70 -
     .../taler-monitoring/GIT-AUTOMATION-NOTES.md  |  198 --
     scripts/taler-monitoring/README.md            |  366 +---
     scripts/taler-monitoring/TESTS.md             |  193 --
     .../taler-monitoring/android-test/.gitignore  |   11 -
     .../android-test/GUI-AUTOMATION-NOTES.md      |  362 ----
     .../taler-monitoring/android-test/README.md   |  111 --
     .../android-test/lib_android_env.sh           |  158 --
     .../android-test/lib_release_age.sh           |  133 --
     .../taler-monitoring/android-test/lib_ui.py   |  192 --
     .../run-android-build-and-smoke.sh            |  167 --
     .../android-test/run-android-gui-smoke.sh     |  201 --
     .../android-test/run-android-pay-smoke.sh     |  308 ---
     .../run-android-variant-matrix.sh             |  322 ----
     .../android-test/run-goa-gui-chain.sh         |  366 ----
     .../android-test/start-android-emulator.sh    |   90 -
     scripts/taler-monitoring/check_apt_deploy.sh  |  279 ---
     scripts/taler-monitoring/check_auth401.sh     |  704 -------
     scripts/taler-monitoring/check_e2e.sh         | 1711 -----------------
     scripts/taler-monitoring/check_goa_ladder.sh  | 1364 -------------
     scripts/taler-monitoring/check_inside.sh      |  546 ------
     scripts/taler-monitoring/check_qr_payloads.py |  294 ---
     scripts/taler-monitoring/check_sanity.sh      |  285 ---
     scripts/taler-monitoring/check_server.sh      |  158 --
     scripts/taler-monitoring/check_surface.sh     |  692 -------
     scripts/taler-monitoring/check_urls.sh        | 1595 ---------------
     scripts/taler-monitoring/check_versions.sh    |  563 ------
     scripts/taler-monitoring/domains.conf         |   46 -
     scripts/taler-monitoring/harness-live.txt     |    7 -
     scripts/taler-monitoring/harness-tests.txt    |   41 -
     scripts/taler-monitoring/host-agent/README.md |  183 --
     .../host-agent/ROOT-APPLY-MONITORING.md       |   57 -
     .../ensure-apt-deploy-test-containers.sh      |  373 ----
     .../taler-monitoring/host-agent/env.example   |   26 -
     .../host-agent/env/francpaysan.env.example    |   24 -
     .../host-agent/env/hacktivism.env.example     |   24 -
     .../host-agent/env/stagepaysan.env.example    |   27 -
     .../host-agent/install-host-agent.sh          |  107 --
     .../host-agent/run-aptdeploy-monitoring.sh    |   43 -
     .../host-agent/run-aptdeploy.sh               |  150 --
     .../host-agent/run-fp-prod-monitoring.sh      |   20 -
     .../host-agent/run-fp-stage-monitoring.sh     |   21 -
     .../host-agent/run-hacktivism-monitoring.sh   |   23 -
     .../host-agent/run-host-report.sh             |  358 ----
     .../host-agent/run-surface-monitoring.sh      |   41 -
     .../taler-monitoring-aptdeploy.service        |   18 -
     .../taler-monitoring-aptdeploy.timer          |   14 -
     .../taler-monitoring-hacktivism.path          |   19 -
     .../taler-monitoring-hacktivism.service       |   21 -
     .../taler-monitoring-hacktivism.timer         |   14 -
     .../taler-monitoring-surface.service          |   18 -
     .../host-agent/taler-monitoring-surface.timer |   14 -
     .../host-agent/touch-software-stamp.sh        |   17 -
     .../host-agent/update-suite.sh                |  109 --
     scripts/taler-monitoring/lib.sh               | 1257 ------------
     scripts/taler-monitoring/metrics.sh           | 1431 --------------
     scripts/taler-monitoring/secrets.env.example  |   87 -
     scripts/taler-monitoring/site-gen/README.md   |   81 -
     .../site-gen/ROOT-ON-KOOPA.md                 |   92 -
     .../site-gen/caddy-monitoring-handles.snippet |   57 -
     ...om.hacktivism.taler-monitoring-sites.plist |   34 -
     .../site-gen/console_to_html.py               |  935 ---------
     .../site-gen/deploy-monitoring-sites.sh       |   44 -
     .../site-gen/generate-monitoring-sites.sh     |  273 ---
     .../site-gen/install-firecuda-timer.sh        |   79 -
     .../site-gen/pull-firecuda-to-koopa.sh        |   15 -
     .../site-gen/run-on-firecuda.sh               |   59 -
     .../site-gen/settings.conf.example            |   55 -
     scripts/taler-monitoring/surface-catalog.conf |   52 -
     scripts/taler-monitoring/taler-monitoring.sh  |  477 -----
     73 files changed, 19 insertions(+), 18499 deletions(-)
     delete mode 100644 scripts/taler-monitoring/.gitignore
     delete mode 100644 scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md
     delete mode 100644 scripts/taler-monitoring/DEPENDENCIES.md
     delete mode 100644 scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md
     delete mode 100644 scripts/taler-monitoring/TESTS.md
     delete mode 100644 scripts/taler-monitoring/android-test/.gitignore
     delete mode 100644 scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md
     delete mode 100644 scripts/taler-monitoring/android-test/README.md
     delete mode 100644 scripts/taler-monitoring/android-test/lib_android_env.sh
     delete mode 100644 scripts/taler-monitoring/android-test/lib_release_age.sh
     delete mode 100755 scripts/taler-monitoring/android-test/lib_ui.py
     delete mode 100755 scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh
     delete mode 100755 scripts/taler-monitoring/android-test/run-android-gui-smoke.sh
     delete mode 100755 scripts/taler-monitoring/android-test/run-android-pay-smoke.sh
     delete mode 100755 scripts/taler-monitoring/android-test/run-android-variant-matrix.sh
     delete mode 100755 scripts/taler-monitoring/android-test/run-goa-gui-chain.sh
     delete mode 100755 scripts/taler-monitoring/android-test/start-android-emulator.sh
     delete mode 100755 scripts/taler-monitoring/check_apt_deploy.sh
     delete mode 100755 scripts/taler-monitoring/check_auth401.sh
     delete mode 100755 scripts/taler-monitoring/check_e2e.sh
     delete mode 100755 scripts/taler-monitoring/check_goa_ladder.sh
     delete mode 100755 scripts/taler-monitoring/check_inside.sh
     delete mode 100755 scripts/taler-monitoring/check_qr_payloads.py
     delete mode 100755 scripts/taler-monitoring/check_sanity.sh
     delete mode 100755 scripts/taler-monitoring/check_server.sh
     delete mode 100755 scripts/taler-monitoring/check_surface.sh
     delete mode 100755 scripts/taler-monitoring/check_urls.sh
     delete mode 100755 scripts/taler-monitoring/check_versions.sh
     delete mode 100644 scripts/taler-monitoring/domains.conf
     delete mode 100644 scripts/taler-monitoring/harness-live.txt
     delete mode 100644 scripts/taler-monitoring/harness-tests.txt
     delete mode 100644 scripts/taler-monitoring/host-agent/README.md
     delete mode 100644 scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md
     delete mode 100755 scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh
     delete mode 100644 scripts/taler-monitoring/host-agent/env.example
     delete mode 100644 scripts/taler-monitoring/host-agent/env/francpaysan.env.example
     delete mode 100644 scripts/taler-monitoring/host-agent/env/hacktivism.env.example
     delete mode 100644 scripts/taler-monitoring/host-agent/env/stagepaysan.env.example
     delete mode 100755 scripts/taler-monitoring/host-agent/install-host-agent.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-aptdeploy.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-host-report.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/run-surface-monitoring.sh
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-surface.service
     delete mode 100644 scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer
     delete mode 100755 scripts/taler-monitoring/host-agent/touch-software-stamp.sh
     delete mode 100755 scripts/taler-monitoring/host-agent/update-suite.sh
     delete mode 100755 scripts/taler-monitoring/lib.sh
     delete mode 100644 scripts/taler-monitoring/metrics.sh
     delete mode 100644 scripts/taler-monitoring/secrets.env.example
     delete mode 100644 scripts/taler-monitoring/site-gen/README.md
     delete mode 100644 scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md
     delete mode 100644 scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet
     delete mode 100644 scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist
     delete mode 100755 scripts/taler-monitoring/site-gen/console_to_html.py
     delete mode 100755 scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh
     delete mode 100755 scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh
     delete mode 100755 scripts/taler-monitoring/site-gen/install-firecuda-timer.sh
     delete mode 100755 scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh
     delete mode 100755 scripts/taler-monitoring/site-gen/run-on-firecuda.sh
     delete mode 100644 scripts/taler-monitoring/site-gen/settings.conf.example
     delete mode 100644 scripts/taler-monitoring/surface-catalog.conf
     delete mode 100755 scripts/taler-monitoring/taler-monitoring.sh
    
    diff --git a/scripts/README.md b/scripts/README.md
    index d062c73..5d9a36d 100644
    --- a/scripts/README.md
    +++ b/scripts/README.md
    @@ -8,7 +8,7 @@
     | `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) |
    +| `taler-monitoring/` | **moved** → [hernani/taler-monitoring](https://git.hacktivism.ch/hernani/taler-monitoring) (see `scripts/taler-monitoring/README.md`) |
     | `taler-shared/` | shared helpers (`upgrade-goa-debs.sh` = apt upgrade **inside** bank/exchange/merchant containers) |
     | `taler-landing/` | **hernani user systemd**: central landing `stats.json` (full bank scan + alt units) |
     | `monitoring/` | host `/home/hernani/scripts` (tor relay stats) |
    diff --git a/scripts/taler-monitoring/.gitignore b/scripts/taler-monitoring/.gitignore
    deleted file mode 100644
    index 754bb8e..0000000
    --- a/scripts/taler-monitoring/.gitignore
    +++ /dev/null
    @@ -1,6 +0,0 @@
    -android-test/apks/
    -android-test/out*/
    -android-test/artifacts/
    -__pycache__/
    -secrets.env
    -*.pyc
    diff --git a/scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md
    deleted file mode 100644
    index 3cd311a..0000000
    --- a/scripts/taler-monitoring/CLI-AUTOMATION-NOTES.md
    +++ /dev/null
    @@ -1,228 +0,0 @@
    -# CLI automation notes (`taler-wallet-cli` + monitoring)
    -
    -Living list of **recurring issues** seen while driving GOA (hacktivism) and
    -stage TESTPAYSAN via **CLI** (`taler-wallet-cli`, bank/merchant HTTP, monitoring
    -e2e/ladder). Ordered roughly by **how often they hurt automation** and how
    -useful a fix in **wallet-cli / wallet-core** (or clearer APIs) would be.
    -
    -Companion: Android GUI → [`android-test/GUI-AUTOMATION-NOTES.md`](./android-test/GUI-AUTOMATION-NOTES.md).
    -
    -Scripts: `check_e2e.sh`, `check_goa_ladder.sh`, `taler-monitoring.sh`.
    -
    -Legend:
    -
    -| Tag | Meaning |
    -|-----|---------|
    -| **cli** | wallet-cli UX / flags / hang behaviour |
    -| **core** | wallet-core protocol / state machine |
    -| **bank** | libeufin-bank / integration API |
    -| **ops** | secrets, paths, multi-host |
    -| **doc** | missing or wrong documentation |
    -
    ----
    -
    -## 1. `run-until-done` is unusable for automation
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e, ladder, Android notes, macOS comments: *hangs / banned in monitoring* |
    -| **Area** | **cli** / **core** |
    -| **Problem** | `taler-wallet-cli run-until-done` (or long shepherd runs) often **never returns** or blocks CI-length timeouts. Monitoring **forbids** it and polls balance + bank `transfer_done` instead. |
    -| **Workaround** | `advanced serve` + socket (`WSOCK`) or pure poll loops (`LADDER_SETTLE_*`, e2e settle wait). |
    -| **Wanted for wallet-cli** | Bounded wait: e.g. `run-until-done --timeout=Ns --exit-on=withdrawn|paid|idle`; stable non-zero exit on timeout; progress on stdout/JSON. |
    -
    ----
    -
    -## 2. Withdraw completion is not observable from CLI alone
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e “coins missing after withdraw”; ladder `OK_BANK` vs wallet avail |
    -| **Area** | **cli** / **core** / **bank** |
    -| **Problem** | After `accept-uri` + bank confirm, coins may lag (wirewatch). CLI has no single “withdraw settled” command with clear success/failure; automation reimplements status from bank integration JSON + balance polls. |
    -| **Workaround** | Poll `balance` + `…/withdrawal-operation/{id}` (`status`, `transfer_done`, `selected_reserve_pub`). |
    -| **Wanted** | `wallet-cli withdrawals wait --id=… --timeout=` or JSON event stream: `selected → confirmed → coins-available`. |
    -
    ----
    -
    -## 3. `reserve_pub` / force-select is fragile (5114)
    -
    -| | |
    -|--|--|
    -| **Seen** | ladder force-select, e2e confirm path, Android spinner issues |
    -| **Area** | **cli** / **core** / **bank** |
    -| **Problem** | Cumulative wallets re-emit old reserves; binding the wrong `reserve_pub` yields **HTTP 409 / code 5114** (“already bound”). Automation scrapes accept/tx dumps for candidates. |
    -| **Workaround** | Score pubs by WID/amount; try several; treat 5114 as “stale reserve” not “out of money”. |
    -| **Wanted** | CLI returns **the** `reserve_pub` for the just-accepted withdraw in structured JSON; `force-select` / bank-side select API documented with idempotency. |
    -
    ----
    -
    -## 4. Exchange not auto-known for bank deep links / withdraw URIs
    -
    -| | |
    -|--|--|
    -| **Seen** | Android endless spinner; GOA `bank.hacktivism.ch` withdraw; fixed in app branch `fix/bank-withdraw-auto-exchange` |
    -| **Area** | **core** / **cli** |
    -| **Problem** | Withdraw URI names an exchange the wallet has never added → stuck loading / empty exchange list. CLI requires explicit `exchanges add` + `update` + `accept-tos` every fresh DB. |
    -| **Workaround** | e2e always adds exchange before ATM ladder; Android needs ensureExchange-style fix. |
    -| **Wanted** | CLI/core: on `accept-uri` withdraw, **auto-add** exchange from URI/bank details (`allowCompletion`), then surface ToS if needed in one command. |
    -
    ----
    -
    -## 5. Default ports in URIs (`:443` / `:80`) break round-trips
    -
    -| | |
    -|--|--|
    -| **Seen** | ladder/e2e strip ports; QR checks; mint URIs with `:443` |
    -| **Area** | **cli** / **bank** / **doc** |
    -| **Problem** | Some bank/wallet outputs include `taler://…host:443/…`. QR validators and some clients reject or double-normalize inconsistently. |
    -| **Workaround** | `sed 's/:443\//\//g'` everywhere in automation. |
    -| **Wanted** | Canonical form without default ports in **all** wallet-cli and bank integration outputs; document as invariant. |
    -
    ----
    -
    -## 6. No first-class “demo withdraw” / explorer mint in wallet-cli
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e uses bank admin + ATM create; ladder/gui-chain reimplement explorer mint in Python |
    -| **Area** | **cli** / **ops** |
    -| **Problem** | Automation always hand-rolls Basic auth → token → `POST …/withdrawals` → `taler_withdraw_uri`. Easy to get wrong (auth header, amount currency). |
    -| **Workaround** | Shared Python in `goa-chain` / ladder / e2e; secrets files. |
    -| **Wanted** | Optional helper: `wallet-cli testing mint-withdraw --bank=URL --user=explorer --password-file=… --amount=GOA:10` (or bank-side only tool in libeufin) emitting a clean URI. |
    -
    ----
    -
    -## 7. Long-lived wallet process required for pay/withdraw progress
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e `advanced serve` / shepherd socket |
    -| **Area** | **cli** |
    -| **Problem** | One-shot CLI invocations do not keep background work alive; without serve/shepherd, withdraw/pay may stall mid-flight. |
    -| **Workaround** | Start serve once per e2e run; route `wcli` through the socket. |
    -| **Wanted** | Documented, supported “session mode”: start/stop serve; or each mutating command optionally `--background-until=…` with timeout. |
    -
    ----
    -
    -## 8. ToS accept is a separate step that fails silently or blocks
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e `exchanges accept-tos`; ladder after add; Android GUI ToS screens |
    -| **Area** | **cli** / **core** |
    -| **Problem** | Fresh exchange → operations need ToS; CLI must call `accept-tos` explicitly. Failures are easy to miss in multi-step scripts. |
    -| **Workaround** | Always `accept-tos` after `update` in e2e/ladder. |
    -| **Wanted** | `accept-uri --accept-tos` or auto-prompt with non-interactive `--yes` that covers exchange ToS for that URI’s exchange. |
    -
    ----
    -
    -## 9. Pay template / public order path is merchant-shaped, not CLI-shaped
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e shop templates, stage farmer shops, ladder private orders |
    -| **Area** | **cli** / **doc** |
    -| **Problem** | Wallet-cli pays via `handle-uri` on `taler://pay/…`; creating the order is always custom curl (template POST or private order + token). No unified “pay this amount to instance” for public templates. |
    -| **Workaround** | Monitoring builds URI externally then `handle-uri --yes`. |
    -| **Wanted** | Documented recipe only, or `wallet-cli testing pay-template --base=… --instance=… --id=…` for demos. |
    -
    ----
    -
    -## 10. Amount / currency parsing edge cases
    -
    -| | |
    -|--|--|
    -| **Seen** | ladder min denom 0.01 vs GOA 1e-6; zero withdraw rejected (HTTP 409 / amount too low); max wire ceilings |
    -| **Area** | **cli** / **bank** |
    -| **Problem** | `CURRENCY:0` and sub-min amounts fail differently per stack; CLI error strings are not machine-stable. |
    -| **Workaround** | Soft-skip zero rung; clamp ladder min to exchange denoms / bank max_wire. |
    -| **Wanted** | Structured errors (`AMOUNT_too_small`, `currency_unknown`) in JSON mode; `wallet-cli amount validate --exchange=`. |
    -
    ----
    -
    -## 11. Finding the right `taler-wallet-cli` binary
    -
    -| | |
    -|--|--|
    -| **Seen** | `find_wallet_cli` in `lib.sh`; hardcoded laptop paths; wrapper vs `.mjs` |
    -| **Area** | **ops** / **cli** |
    -| **Problem** | Debian package is a shell wrapper; some tools need `node …/taler-wallet-cli.mjs`. Hardcoded `/Users/…` paths break other hosts. |
    -| **Workaround** | `find_wallet_cli` search list; `WALLET_CLI=` override. |
    -| **Wanted** | Single install story: `wallet-cli --version` JSON with path + libversion; no need to pass `.mjs` to node by hand. |
    -
    ----
    -
    -## 12. Secrets and multi-stack confusion
    -
    -| | |
    -|--|--|
    -| **Seen** | explorer vs admin password; GOA secrets used on stage; ladder EXP_PW |
    -| **Area** | **ops** |
    -| **Problem** | CLI does not know “which stack”; wrong password → 401 mid-ladder. Not a wallet-cli bug, but every CLI automation hits it. |
    -| **Workaround** | `SECRETS_ROOT`, stage SSH, `STACK=` profiles. |
    -| **Wanted** | Optional `~/.config/taler/stacks.d/goa.env` convention documented next to wallet-cli; still no secrets in repo. |
    -
    ----
    -
    -## 13. No stable machine-readable “step result” for scripts
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e greps accept output; ladder scrapes JSON from mixed stdout |
    -| **Area** | **cli** |
    -| **Problem** | Human logs + occasional JSON blobs; hard to parse reliably. |
    -| **Workaround** | Python scrapers, temp files, `tee`. |
    -| **Wanted** | Global `--json` / `--ndjson` for all commands; one object per completed operation with `ok`, `op`, `ids`, `amounts`. |
    -
    ----
    -
    -## 14. Pay settlement wait is symmetric to withdraw pain
    -
    -| | |
    -|--|--|
    -| **Seen** | e2e pay settle loops; ladder pay settle rounds |
    -| **Area** | **cli** / **core** |
    -| **Problem** | After `handle-uri` pay, success is “order paid” / balance drop / merchant order status — not one CLI wait. |
    -| **Workaround** | Short poll loops; never run-until-done. |
    -| **Wanted** | Same as §1–2: bounded wait on transaction id / order id. |
    -
    ----
    -
    -## Priority shortlist for `taler-wallet-cli` / core
    -
    -If only a few changes land, these unlock the most automation:
    -
    -1. **Bounded `run-until-done` / wait-for-state** (§1, §2, §14)  
    -2. **Structured JSON on every command** (§13)  
    -3. **Auto-add exchange + ToS on accept-uri** (§4, §8)  
    -4. **Canonical URIs without :443** (§5)  
    -5. **Emit reserve_pub for last withdraw** (§3)  
    -
    ----
    -
    -## How we work around today (monitoring)
    -
    -| Issue | Monitoring behaviour |
    -|-------|----------------------|
    -| run-until-done | Disabled; balance + bank poll |
    -| serve | Optional long-lived socket in e2e |
    -| exchange | Explicit add/update/accept-tos |
    -| ladder explorer | `read_secret` / EXP_PW_FILE |
    -| stage maxima | bank `max_wire` + keys min denom |
    -| force-select | multi-rpub try; soft-skip confirm |
    -
    ----
    -
    -## How to add an issue
    -
    -```markdown
    -### N. short title
    -| | |
    -|--|--|
    -| **Seen** | where / which stack |
    -| **Area** | cli / core / bank / ops / doc |
    -| **Problem** | … |
    -| **Workaround** | … |
    -| **Wanted** | concrete CLI/core behaviour |
    -```
    diff --git a/scripts/taler-monitoring/DEPENDENCIES.md b/scripts/taler-monitoring/DEPENDENCIES.md
    deleted file mode 100644
    index 3e7db4c..0000000
    --- a/scripts/taler-monitoring/DEPENDENCIES.md
    +++ /dev/null
    @@ -1,70 +0,0 @@
    -# taler-monitoring — host package dependencies
    -
    -## koopa (openSUSE Tumbleweed, user `hernani`)
    -
    -### Required (host-agent / GOA primary: `urls inside versions`)
    -
    -| Binary | Package (zypper) | Role |
    -|--------|------------------|------|
    -| `bash` | (base) | runner |
    -| `curl` | `curl` | HTTPS/API probes |
    -| `python3` | `python313-base` (provides `/usr/bin/python3`) | JSON, HTML site-gen, metrics |
    -| `podman` | `podman` | **inside**: `podman exec` into taler-hacktivism* |
    -| `git` | `git` | pin commit SHA in monitoring HTML footer |
    -| `rsync` | `rsync` | optional: deploy/sync suite |
    -| `timeout` / `mktemp` / `getent` | coreutils / glibc | helpers |
    -
    -**Status on koopa (2026-07-18):** all of the above present; rootless podman owns GOA containers.
    -
    -### Recommended (full `urls` QR group)
    -
    -| Binary | Package | Role |
    -|--------|---------|------|
    -| `qrencode` | **`qrencode`** | encode taler:// / payto:// → PNG |
    -| `zbarimg` | **`zbar`** | decode PNG; exact payload match |
    -
    -Without these, QR checks **WARN** and skip encode/decode (`apt install` messages in code are Debian-oriented; on Tumbleweed use zypper).
    -
    -```bash
    -# as root on koopa
    -zypper refresh
    -zypper in qrencode zbar
    -# verify as hernani:
    -command -v qrencode zbarimg
    -```
    -
    -### Optional (not needed for host-agent default)
    -
    -| Binary / tool | When |
    -|---------------|------|
    -| `jq` | nice-to-have; suite uses python3 for JSON |
    -| `dig` / `bind-utils` | debug DNS; not required for default phases |
    -| `ssh` | only for access mode **ssh** (laptop → `KOOPA_SSH` / stage `INSIDE_SSH`); not needed for **host-podman** |
    -| `node` / `pnpm` + wallet-cli | **e2e** / ladder only (+ secrets) |
    -| `secrets.env` / admin passwords | e2e only — never system packages |
    -
    -### systemd user (no extra packages)
    -
    -- `systemd` user session + **`loginctl enable-linger hernani`** (already **Linger=yes** on koopa)
    -- Units: `scripts/taler-monitoring/host-agent/*.service|path|timer`
    -
    -### Caddy HTML publish (separate from monitoring run)
    -
    -- Host Caddy already installed (root service).
    -- Writing `/var/www/monitoring-sites`: either root `rsync` or directory owned/writable by `hernani` (you set this up).
    -
    ----
    -
    -## firecuda-external (macOS, outside-only timer)
    -
    -| Need | Notes |
    -|------|--------|
    -| `bash`, `curl`, `python3`, `rsync`, `git` (optional) | usually Homebrew / Xcode CLT |
    -| **No** `podman` | phases are `urls versions` only (`SKIP_SSH=1`) |
    -| `qrencode` / `zbarimg` | optional for QR; `brew install qrencode zbar` if desired |
    -
    ----
    -
    -## Franc Paysan hosts (francpaysan / stagepaysan)
    -
    -Document package lists under **`francpaysan-admin-log`** when those host-agents are installed (same idea: curl, python3, podman if inside, qrencode/zbar for full urls).
    diff --git a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md b/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md
    deleted file mode 100644
    index 4ad80b3..0000000
    --- a/scripts/taler-monitoring/GIT-AUTOMATION-NOTES.md
    +++ /dev/null
    @@ -1,198 +0,0 @@
    -# Git / automation notes (taler-monitoring + mobile)
    -
    -Notes for **automated checks** around the Taler stacks (GOA, stage
    -TESTPAYSAN, …). CLI monitoring lives in this directory; **Android wallet
    -UI automation is separate tooling** and is **not** installed on the
    -default ops laptop yet.
    -
    ----
    -
    -## Git commit / push cadence (existing model)
    -
    -Observed in `koopa-admin-log` / `francpaysan-admin-log` histories, plus ops preference:
    -
    -| Rule | Practice |
    -|------|----------|
    -| **Commits** | **Prefer frequent** small commits over rare large ones |
    -| **Interval** | Soft range **7–13 minutes** while a topic is open — **not** a fixed 9‑minute metronome; denser 0–2 min bursts for tiny follow-ups are fine |
    -| **Message** | `area: short description` — e.g. `monitoring: …`, `landings: …`, `docs: …` |
    -| **Push** | **Only at the end of a work session** (`CADENCE_PUSH=1` or manual `git push`) — not after every commit |
    -
    -### Agent / human workflow
    -
    -1. Finish a small slice → commit (`area: …`). Repeat often.  
    -2. Do **not** push after each commit.  
    -3. When the topic/session is done → one `git push` (or `CADENCE_PUSH=1`).  
    -4. Soft next-commit window: about **7–13 min** (jitter), sooner if the next slice is ready.
    -
    -### Helper script
    -
    -```bash
    -# dry-run
    -CADENCE_DRY=1 ./scripts/git-cadence-commit-push.sh
    -
    -# commit only if dirty (default: no push; skips secrets)
    -./scripts/git-cadence-commit-push.sh
    -./scripts/git-cadence-commit-push.sh ~/taler/src/francpaysan-admin-log
    -
    -# end of session — push commits that are already local
    -CADENCE_PUSH=1 ./scripts/git-cadence-commit-push.sh
    -
    -# soft timer: only commit if last commit age ≥ random target in 7–13 min
    -CADENCE_JITTER=1 ./scripts/git-cadence-commit-push.sh
    -```
    -
    -Optional user cron (check every 7 min; **jitter** keeps effective gaps in **7–13 min**):
    -
    -```bash
    -*/7 * * * * CADENCE_JITTER=1 $HOME/src/koopa/koopa-admin-log/scripts/git-cadence-commit-push.sh >>$HOME/.cache/git-cadence-koopa.log 2>&1
    -```
    -
    -**Auth note:** `francpaysan-admin-log` (gnunet SSH) pushes OK.  
    -`koopa-admin-log` (hacktivism HTTPS) needs credentials/SSH key before end-of-session push works from this host.
    -
    -## What runs without Android
    -
    -| Phase | Needs |
    -|-------|--------|
    -| `urls` / `sanity` | public HTTPS only |
    -| `inside` / `versions` | SSH (`koopa` or `francpaysan-stage-user`) |
    -| `e2e` / `ladder` | `taler-wallet-cli` + bank/merchant secrets |
    -
    -```bash
    -./taler-monitoring.sh -d stage.lefrancpaysan.ch urls
    -./taler-monitoring.sh -d stage.lefrancpaysan.ch e2e   # after secrets.env
    -```
    -
    -Mobile app checks (manual withdraw/pay, then automation) are **out of
    -band** until the software below is present.
    -
    ----
    -
    -## Android automation software — **required** (not installed yet)
    -
    -To automate **GNU Taler Wallet** on Android (stable + master builds;
    -manual then auto withdraw; fixed + variable pay) against stage
    -TESTPAYSAN, the following must be available on the runner host.
    -
    -### Status on this host (2026-07-17)
    -
    -| Component | Status |
    -|-----------|--------|
    -| Java (OpenJDK 21) | present (`/usr/bin/java`) |
    -| `aapt` / `aapt2` | present |
    -| **`adb`** (platform-tools) | **missing** (package was removed: `android-tools-adb` rc) |
    -| **Android SDK** (`ANDROID_HOME` / `ANDROID_SDK_ROOT`) | **missing** / empty |
    -| **Emulator + AVD** | **missing** |
    -| Appium / Maestro / `uiautomator2` | **missing** |
    -| scrcpy | missing (optional) |
    -
    -### Must install before mobile automation
    -
    -1. **Android platform-tools** — at least `adb`  
    -   - Debian: `android-tools-adb` (and preferably full SDK platform-tools)
    -2. **Android SDK command-line tools** — `sdkmanager`, platforms, build-tools  
    -   - set `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) permanently
    -3. **System image + AVD** — for emulator runs (x86_64 or host-matching ABI)  
    -   - `emulator`, `avdmanager`, KVM/acceleration if available
    -4. **UI automation stack** (pick one, prefer the leaner path for wallet flows):  
    -   - **Maestro**, or  
    -   - **Appium** + **UiAutomator2** (+ Node/Python bindings as needed)
    -5. **Optional:** `scrcpy` for live view during manual debugging
    -
    -### Explicitly out of scope until freigabe
    -
    -- Installing production wallet APKs and driving real stage withdraw/pay  
    -  without a human go-ahead  
    -- Committing secrets, device serials, or APK binaries into git
    -
    -### Operator still needs to supply
    -
    -- Stable / master **APK** (or store/build URLs) for the wallet  
    -- USB debugging consent on a physical device, **or** agreement to use AVD only  
    -- Which flows are in-scope first (withdraw communal QR → shop pay, …)
    -
    -### Suggested freigabe prompt (for an agent)
    -
    -See session notes: full copy-paste block under “Android-Automatisierung
    -vollständig einrichten” — install tooling, verify `adb devices` + emulator,
    -document paths; **no** real wallet payments until approved.
    -
    ----
    -
    -## Related docs
    -
    -| Doc | Role |
    -|-----|------|
    -| [README.md](./README.md) | how to run `taler-monitoring.sh` |
    -| [TESTS.md](./TESTS.md) | grouped check IDs |
    -| [secrets.env.example](./secrets.env.example) | e2e secrets layout |
    -| `francpaysan-admin-log/TODO.md` | stage wallet mobile test backlog |
    -
    -When Android tooling is installed, append a short “installed on …”
    -section here (date, `adb version`, AVD name, `ANDROID_HOME`).
    -
    ----
    -
    -## Android pay smoke (published APK, no root preferred)
    -
    -Script: [`android-test/run-android-pay-smoke.sh`](./android-test/run-android-pay-smoke.sh)
    -
    -| Step | Detail |
    -|------|--------|
    -| APK | F-Droid **Taler Wallet 1.6.1** (`net.taler.wallet.fdroid_854.apk`) |
    -| Install | `adb install -r` (no root) |
    -| Withdraw | `taler://withdraw/…` from `…/intro/demo-withdraw.json` (GOA or stage) |
    -| Pay | stage: shop template order → `taler://pay/…`; GOA: `taler://pay-template/…/goa-shop/paivana` |
    -| Evidence | screenshots + logcat under `android-test/out/` |
    -
    -```bash
    -# device or emulator already booted
    -./android-test/run-android-pay-smoke.sh
    -STACK=goa ./android-test/run-android-pay-smoke.sh
    -STACK=stage ./android-test/run-android-pay-smoke.sh
    -```
    -
    -**User-local emulator (no apt root if SDK already under `$HOME/Android/Sdk`):**
    -
    -```bash
    -# Default = headless (no host window); guest GLES via SwiftShader.
    -# Prefer the helper — same flags smokes use:
    -./android-test/start-android-emulator.sh --wait
    -./android-test/run-android-pay-smoke.sh
    -# Or let smoke auto-start the AVD (AUTO_START_EMULATOR=1).
    -# Windowed laptop: WINDOWED=1 ./android-test/start-android-emulator.sh --wait
    -```
    -
    -**Limits (clementine 2026-07-17):** host RAM ~3.7 GiB → emulator often ANRs System UI;
    -wallet-core still receives withdraw intents (`prepareBankIntegratedWithdrawal` in logcat).
    -For reliable full UI confirm/pay use a physical phone or a host with **≥6–8 GiB** free RAM.
    -
    -### Status snapshot (2026-07-17)
    -
    -| Item | Status |
    -|------|--------|
    -| `adb` | present (`android-sdk-platform-tools`) |
    -| F-Droid APK | downloaded under `android-test/apks/` |
    -| User SDK + emulator + AVD `TalerWallet34` | installed under `$HOME/Android/Sdk` (~5 GiB) |
    -| `adb install` wallet | **Success** on emulator-5554 |
    -| Deep-link withdraw | **delivered**; wallet-core started `prepareBankIntegratedWithdrawal` for GOA |
    -| Unattended UI confirm | flaky (ANR) on this host’s RAM |
    -| Source build `assembleFdroidDebug` | **OK** (2026-07-17, `taler-android@f65a976c0d4a`, ~12 min, APK ~59 MiB) |
    -
    -### From-source smoke (same tests as F-Droid)
    -
    -```bash
    -# single ref (default: inference fix branch)
    -./android-test/run-android-build-and-smoke.sh
    -BRANCH=master ./android-test/run-android-build-and-smoke.sh
    -# self-build published stable tag (parity with F-Droid 1.6.1 source)
    -BRANCH=wallet-1.6.1 LABEL=stable-self ./android-test/run-android-build-and-smoke.sh
    -# matrix: published APK + stable self-build + master (anti–weird-build)
    -./android-test/run-android-variant-matrix.sh
    -STACK=goa ./android-test/run-android-build-and-smoke.sh
    -# already built:
    -APK_PATH=$HOME/taler/taler-android/wallet/build/outputs/apk/fdroid/debug/wallet-fdroid-debug.apk \
    -  PKG=net.taler.wallet.fdroid.debug \
    -  ./android-test/run-android-pay-smoke.sh
    -```
    diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md
    index 7d3ae2f..a0acf02 100644
    --- a/scripts/taler-monitoring/README.md
    +++ b/scripts/taler-monitoring/README.md
    @@ -1,356 +1,26 @@
    -# taler-monitoring
    +# taler-monitoring has moved
     
    -Host packages (koopa / runners): **[DEPENDENCIES.md](./DEPENDENCIES.md)**.  
    -Host-agents (GOA + FP; shared report pipeline): **[host-agent/README.md](./host-agent/README.md)**.  
    -Public HTML site-gen (same timeout/log/HTML defaults): **[site-gen/README.md](./site-gen/README.md)**.  
    -FP install notes: `francpaysan-admin-log/docs/taler-monitoring-host-agents.md`.
    +The monitoring suite is no longer part of **koopa-admin-log**.
     
    -Report for the **GOA** stack with boxed severity badges and **grouped test IDs**
    -`area.group-NN` (e.g. `www.exchange-01`, `e2e.pay-03`):
    +| | |
    +|--|--|
    +| **Repository** | https://git.hacktivism.ch/hernani/taler-monitoring |
    +| **Clone** | `git clone https://git.hacktivism.ch/hernani/taler-monitoring.git ~/src/taler-monitoring` |
    +| **Tag** | `v1.0` (initial standalone release) |
    +| **History** | `VERSIONS.md` in that repo |
     
    -| Tag | Meaning |
    -|-----|---------|
    -| `┌ OK ┐` | check passed |
    -| `┌ INFO ┐` | status / note |
    -| `┌ WARN ┐` | degraded but maybe not fatal |
    -| `┌ ERROR ┐` | component problem |
    -| `┌ BLOCKER ┐` | **withdraw/pay path cannot complete** because of this |
    -
    -IDs reset per **group** inside an area — so “too many www tests” become
    -`www.exchange-*`, `www.bank-*`, `www.landing-*`, etc.  
    -Catalog: **[TESTS.md](./TESTS.md)**.  
    -Android / tooling: **[GIT-AUTOMATION-NOTES.md](./GIT-AUTOMATION-NOTES.md)**.  
    -GUI Android notes: **[android-test/GUI-AUTOMATION-NOTES.md](./android-test/GUI-AUTOMATION-NOTES.md)**.  
    -CLI / wallet-cli recurring issues (for upstream improvements): **[CLI-AUTOMATION-NOTES.md](./CLI-AUTOMATION-NOTES.md)**.
    -
    -```text
    -┌ OK    ┐ www.exchange-01  exchange /config · HTTP 200
    -┌ BLOCKER┐ e2e.prereq-02   merchant HTTP 502
    -```
    -
    -End of each phase: totals + list of **BLOCKERS** and **ERRORS** (ids included).
    -
    -## Secrets (local GOA e2e)
    -
    -**Do not commit passwords.** Canonical trees: `~/src/koopa/koopa-admin-log` +
    -sibling `~/src/koopa/koopa-admin-secrets`.
    -
    -### Local file (recommended on laptop)
    +## Quick start
     
     ```bash
    -cd ~/src/koopa/koopa-admin-log/scripts/taler-monitoring
    -cp secrets.env.example secrets.env   # gitignored
    -$EDITOR secrets.env
    -./taler-monitoring.sh e2e
    +git clone https://git.hacktivism.ch/hernani/taler-monitoring.git ~/src/taler-monitoring
    +cd ~/src/taler-monitoring
    +./taler-monitoring.sh -d hacktivism.ch urls
    +# host-agent (koopa):
    +./host-agent/install-host-agent.sh
     ```
     
    -See **[secrets.env.example](./secrets.env.example)** for every variable that
    -may appear (admin pass, merchant token, SECRETS_ROOT, progress knobs).
    +Landing configs and Caddy site configs for GOA remain in **koopa-admin-log**
    +(`configs/`). The host-agent path unit still watches those paths when present.
     
    -### Search order
    -
    -1. Already-exported env (`E2E_BANK_ADMIN_PASS`, …)
    -2. `secrets.env` next to this suite (`MONITORING_SECRETS_ENV=` override)
    -3. `SECRETS_ROOT` / `KOOPA_ADMIN_SECRETS` file tree
    -4. Sibling `../koopa-admin-secrets/koopa/host-root`
    -5. `$HOME/src/koopa/koopa-admin-secrets/koopa/host-root`
    -6. `$HOME/.config/taler-landing/bank-admin-password.txt`
    -7. SSH koopa `/root/…` or bank container
    -
    -### Line IDs + progress
    -
    -Each check prints **global** and **grouped** ids:
    -
    -```text
    -┌ OK    ┐ #014  www.exchange-03  exchange /config · HTTP 200
    -┌ WARN  ┐ #015  www.stats-02     stats age soft  · yellow badge (not red)
    -┌  42% ┐ ████████░░░░░░░░░░░░░░░░░░░░  14/55
    -```
    -
    -- `#014` — run-wide counter (whole `./taler-monitoring.sh` invocation)  
    -- `www.exchange-03` — area.group-NN  
    -- **Colours (left badge + label):**
    -  - **OK** green · **INFO** blue/cyan · **WARN** yellow (never red)  
    -  - **ERROR** red · **BLOCKER** magenta  
    -- Progress: badge shows **percent** (not “PROG”); bar fills **left → right**;
    -  `done/total` on the right. Estimate auto-grows if short. Override:
    -  `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1`
    -- **QR (urls):** harvest `taler://` / `payto://` (+ app store `data-qr-url`) from landings and
    -  `demo-withdraw.json` / `auto-account.json` → validate form → `qrencode` PNG → `zbarimg`
    -  decode must match. Requires packages **`qrencode`** + **`zbar-tools`**.
    -
    -## Commands
    -
    -```bash
    -# Local GOA stack (may use SSH for inside/e2e)
    -./taler-monitoring.sh              # urls + inside + versions + e2e
    -./taler-monitoring.sh inside       # containers on koopa
    -./taler-monitoring.sh versions     # deb.taler.net + package versions vs trixie
    -./taler-monitoring.sh sanity
    -./taler-monitoring.sh e2e
    -
    -# FrancPaysan STAGE (TESTPAYSAN) — like hacktivism breadth:
    -#   default phases: urls + inside + versions + e2e
    -./taler-monitoring.sh -d stage.lefrancpaysan.ch
    -./taler-monitoring.sh -d stage.lefrancpaysan.ch inside   # stagepaysan only
    -# inside: low-priv SSH francpaysan-stage-user (User stagepaysan) → podman
    -#   containers stage-lfp-{bank,exchange-ansible,merchant} · ports 9032/9031/9030
    -# stats: outside-in public …/intro/stats.json (age via generated_at_unix)
    -# Admin credit for e2e: E2E_BANK_ADMIN_PASS=… or francpaysan-host secrets
    -#   (bank-admin-password.txt under /mnt/data/stagepaysan/bank/secrets/)
    -# Override: INSIDE_SSH=… INSIDE_PROFILE=stage-lfp
    -
    -# Other domains — public HTTPS only (pass e2e/inside explicitly)
    -./taler-monitoring.sh -d taler.net
    -./taler-monitoring.sh --domain taler-ops.ch
    -./taler-monitoring.sh -d my.taler-ops.ch urls
    -./taler-monitoring.sh -d demo.taler.net urls
    -./taler-monitoring.sh taler.net    # bare domain = same as -d
    -
    -# Explicit endpoints (any mix; override profile or skip -d)
    -./taler-monitoring.sh --bank https://bank.demo.taler.net \
    -  --exchange https://exchange.demo.taler.net \
    -  --merchant https://backend.demo.taler.net --currency KUDOS urls
    -
    -# Landing stats.json freshness (outside-in; age via generated_at_unix)
    -STATS_STALE_SECS=900 STATS_FAIL_SECS=3600 ./taler-monitoring.sh -d stage.lefrancpaysan.ch urls
    -
    -# --- Full load (GOA / hacktivism) ---
    -# all  = urls + inside + versions + sanity + e2e
    -# full = all + server + ladder + auth401  (long; needs secrets.env)
    -./taler-monitoring.sh -d hacktivism.ch full
    -./taler-monitoring.sh -d hacktivism.ch urls inside versions sanity server e2e ladder auth401
    -
    -# SPA pin after selfbuild-webui.sh (container):
    -EXPECT_WEBUI_VERSION=1.6.11 EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \
    -  WEBUI_OVERLAY_DENY='c778af04|master-11340' \
    -  ./taler-monitoring.sh -d hacktivism.ch urls
    -
    -# Only 401 matrix (may create throwaway instance):
    -./taler-monitoring.sh -d hacktivism.ch auth401
    -
    -# Stage FrancPaysan — durable mon401 account (password in francpaysan-secrets):
    -./taler-monitoring.sh -d stage.monnaie.lefrancpaysan.ch auth401
    -# secrets: ~/francpaysan-secrets/stage/instances/mon401.password
    -#           ~/francpaysan-secrets/stage/default-instance-token.txt
    -# force throwaway create instead: AUTH401_PREFER_DURABLE=0 AUTH401_CREATE=1 …
    -```
    -
    -### Landing `stats.json` (www.stats)
    -
    -Public `GET …/intro/stats.json` must expose timestamps so monitoring can tell
    -whether the collector is still updating:
    -
    -| Field | Role |
    -|-------|------|
    -| `generated_at_unix` | epoch seconds — **preferred** for age = now − unix |
    -| `generated_at` | ISO-8601 (fallback if unix missing) |
    -| `generated_at_human` | UI “updated” / footer string |
    -
    -Also checks display payloads (bank-shape `withdraws` / accounts, or exchange-db /
    -merchant-db counters). Soft WARN if age > `STATS_STALE_SECS` (default 900);
    -ERROR if age > `STATS_FAIL_SECS` (default 3600; set `0` to disable).
    -Stage shared feed: same `gen_unix` on bank + exchange + monnaies.
    -
    -## Domain profiles (`domains.conf`)
    -
    -Each monitored stack must say **what is bank, exchange, and merchant-backend**.
    -
    -Edit **[domains.conf](./domains.conf)** (or set `TALER_DOMAINS_CONF=`):
    -
    -```text
    -# name   bank   exchange   merchant-backend   currency   local   [canonical]
    -taler-ops.ch  bank.taler-ops.ch  exchange.taler-ops.ch  my.taler-ops.ch  CHF  0
    -```
    -
    -| Field | Meaning |
    -|-------|---------|
    -| **name** | `-d NAME` / `TALER_DOMAIN` match (add alias lines as needed) |
    -| **bank** | libeufin/bank public host or `https://…` |
    -| **exchange** | exchange public host or URL |
    -| **merchant** | merchant **backend** (SPA/API), e.g. `my.taler-ops.ch` |
    -| **currency** | `GOA` / `KUDOS` / `CHF` / … |
    -| **local** | `1` = koopa SSH stack; `0` = public only |
    -| **landing** | `1` = check `/intro` landings + assets; `0` = skip (TOPS / mytops) |
    -| **canonical** | optional label after alias (e.g. `my.taler-ops.ch` → `taler-ops.ch`) |
    -
    -Then:
    -
    -```bash
    -./taler-monitoring.sh -d taler-ops.ch urls
    -```
    -
    -Overrides (always win after profile load):
    -
    -```bash
    -./taler-monitoring.sh -d taler-ops.ch \
    -  --merchant https://my.taler-ops.ch \
    -  --exchange https://exchange.taler-ops.ch \
    -  --bank https://bank.taler-ops.ch urls
    -```
    -
    -Helpers in `lib.sh`: `set_taler_stack`, `load_domain_profile`, `apply_taler_domain`.
    -
    -### Built-in profiles (see `domains.conf`)
    -
    -| Domain / alias | Bank | Exchange | Merchant | Currency |
    -|----------------|------|----------|----------|----------|
    -| `hacktivism.ch` / `koopa` | bank.hacktivism.ch | exchange.hacktivism.ch | taler.hacktivism.ch | GOA |
    -| `taler.net` / `demo.taler.net` | bank.demo.taler.net | exchange.demo.taler.net | backend.demo.taler.net | KUDOS |
    -| `test.taler.net` | bank.test… | exchange.test… | backend.test… | **TESTKUDOS** · no landings |
    -| `lefrancpaysan.ch` | bank.… | exchange.… | **monnaie.…** (merchant) | report-only · no landings |
    -| **`stage.lefrancpaysan.ch`** | stage.bank.… | stage.exchange.… | **stage.monnaie.…** | **TESTPAYSAN** · landings · **inside via stagepaysan** |
    -| `taler-ops.ch` / `my.taler-ops.ch` | bank.taler-ops.ch | exchange.taler-ops.ch | **my.taler-ops.ch** | CHF · **no landings** |
    -| `stage.taler-ops.ch` | bank.stage… | exchange.stage… | **my.stage…** | CHF · **no landings** |
    -| unknown | bank.DOMAIN | exchange.DOMAIN | my.DOMAIN then probe | any · no landings |
    -
    -**Landings:** GOA, stage LFP (`TESTPAYSAN`), and optionally demo use public `/intro` pages. **taler-ops.ch does not** — `CHECK_LANDING=0` skips intro crawl.
    -
    -**SSH:**
    -| Stack | Host alias | User | Scope |
    -|-------|------------|------|--------|
    -| koopa / hacktivism | `koopa` | hernani | full host + containers |
    -| stage LFP | `francpaysan-stage-user` | **stagepaysan** | own podman only (no sudo / no prod data) |
    -
    -Other domains: never SSH. Optional **e2e** aborts cleanly on login/KYC.
    -
    -### E2E amounts (variable)
    -
    -| | Local (koopa) | Remote |
    -|--|---------------|--------|
    -| **ATM withdraw** | 20 · 50 · 100 · 200 · **4200** (paivana) | 10 · 20 · 50 |
    -| **Pay ladder** | 0.01 … 10 | 0.01 … 1 |
    -| **Paivana** | HTTP paywall + template pay **GOA:4200** (`goa-shop` / `paivana`) | skipped |
    -
    -```bash
    -./taler-monitoring.sh e2e
    -./taler-monitoring.sh -d taler.net urls e2e
    -# Amount ladder (withdraw 0 → … → max, then pay): same script for GOA + stage
    -./taler-monitoring.sh ladder                              # GOA / current domain
    -./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder    # TESTPAYSAN maxima
    -# Stage auto-reads bank max_wire_transfer_amount (e.g. 2000) and min denom (0.01).
    -# Explorer password: francpaysan-stage-user …/bank-explorer-password.txt (or EXP_PW=).
    -# Override: LADDER_MAX_AMOUNT=100 LADDER_STEPS=7 LADDER_PAY=0 …
    -# customize e2e:
    -E2E_WITHDRAW_VALUES="20 50 100" E2E_PAY_VALUES="0.05 1 5" ./taler-monitoring.sh e2e
    -E2E_VARIABLE=0 WITHDRAW_AMT=GOA:50 PAY_AMT=GOA:1 ./taler-monitoring.sh e2e   # single fixed
    -# GOA shop catalog (local hacktivism): full list in E2E_SHOP_PRODUCTS; each run
    -#   shuffles and pays E2E_SHOP_PICK_N products (default 2).
    -#   E2E_SHOP_PRODUCTS lines: id|Product name|GOA:amount
    -#   E2E_SHOP_PICK_N=2
    -#   (landing QR = taler://pay-template/…/{id}; popup = live taler://pay after POST templates/{id})
    -# Paivana paywall (local GOA only):
    -#   E2E_PAIVANA=1 (default)  PAIVANA_PUBLIC=https://paivana.hacktivism.ch
    -#   E2E_PAIVANA_TEMPLATE=paivana  E2E_PAIVANA_AMOUNT=GOA:4200  E2E_PAIVANA_INSTANCE=goa-shop
    -#   E2E_PAIVANA=0            # skip
    -# remote secrets:
    -#   E2E_BANK_ADMIN_PASS=…  E2E_MERCHANT_TOKEN=…
    -```
    -
    -## Phases
    -
    -| Phase | What |
    -|-------|------|
    -| **inside** | SSH koopa: processes, postgres, local /config,/keys, wirewatch, recent log ERRORs |
    -| **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)
    -TALER_APT_SUITE=trixie ./taler-monitoring.sh versions
    -TALER_PKG_BEHIND=error ./taler-monitoring.sh versions   # any behind = ERROR
    -```
    -
    -E2E maps failures to blockers, e.g.:
    -
    -- `bank-confirm HTTP 409` → wallet did not select exchange  
    -- `no GOA balance` → wirewatch / transfer  
    -- `create order failed` → merchant auth/instance  
    -- `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
    -```
    -
    -## 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`.
    -
    -### Final statistics dashboard (e2e · ladder)
    -
    -At the end of **e2e** and **ladder**, a boxed **FINAL STATISTICS** block prints:
    -
    -| Section | Content |
    -|---------|---------|
    -| **Coins** | in circulation / spent counts + amounts + denoms (alt names) |
    -| **Money flow** | withdrawn events/amount, spent (paid), net (wd − spent) |
    -| **Tendency** | coin/amount trend over snaps (↑↓→), slope, last history rows |
    -| **Performance** | min / p50 / avg / max per phase; first-half vs second-half latency |
    -| **Load** | host loadavg + mem before/after; per-container RSS/CPU; tendencies |
    -
    -Successful withdraw/pay steps append to `flow-withdrawn.txt` / `flow-spent.txt` (ladder also uses its TSVs).
    -
    -## 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` or `koopa-external` (inside / load / sanity server bits)
    -- secrets under `koopa-admin-secrets/...` for e2e
    -- `taler-wallet-cli` for e2e
    +Do **not** add monitoring scripts back into this directory — develop and push
    +against **taler-monitoring** only.
    diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md
    deleted file mode 100644
    index 7b667f4..0000000
    --- a/scripts/taler-monitoring/TESTS.md
    +++ /dev/null
    @@ -1,193 +0,0 @@
    -# taler-monitoring — grouped test IDs
    -
    -Every check line has a **global** run number and a **grouped** id:
    -
    -```text
    -┌ OK    ┐ #001  www.exchange-01  exchange /config · HTTP 200
    -┌ WARN  ┐ #002  www.stats-02     soft age  · yellow (never red)
    -┌ ERROR ┐ #003  e2e.pay-03       order create failed · HTTP 502
    -┌ BLOCKER┐ #004  e2e.prereq-02   merchant secret missing
    -┌ PROG  ┐ ████░░░░  25%  4/16
    -```
    -
    -- `#NNN` — monotonic for the whole monitoring run  
    -- `area.group-NN` — logical block (resets per group, continues if re-entered)
    -
    -| Area | Phase script | Groups (examples) |
    -|------|--------------|-------------------|
    -| **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` `qr` |
    -| **inside** | `check_inside.sh` | `ssh` `bank` `exchange` `merchant` `caddy` `load` |
    -| **versions** | `check_versions.sh` | `outside` `inside` `compare` |
    -| **aptdeploy** | `check_apt_deploy.sh` | `trixie` `trixie-testing` (koopa podman apt-src merchant smoke) |
    -| **surface** | `check_surface.sh` | **remote-only** ecosystem / `-d` domain inventory (not in default/all/full) |
    -| **sanity** | `check_sanity.sh` | `bank` `exchange` `merchant` |
    -| **server** | `check_server.sh` | (flat `server-NN` or host groups) |
    -| **e2e** | `check_e2e.sh` | `prereq` `load` `bank` `wallet` `atm` `settle` `pay` `shop` `paivana` `dig` `report` |
    -| **ladder** | `check_goa_ladder.sh` | `plan` `load` `withdraw` `pay` `report` |
    -
    -**Why groups:** flat `www-001`…`www-080` was hard to talk about.  
    -`www.bank-04` / `e2e.atm-02` pin the failure to a logical block.
    -
    -Usage in scripts:
    -
    -```bash
    -set_area www
    -set_group exchange   # → www.exchange-01, www.exchange-02, …
    -set_group bank       # counter resets → www.bank-01
    -```
    -
    -`set_group` also prints a small group chip (`┌ www.exchange ┐`) so logs show section boundaries.  
    -Re-entering the same group later (e.g. exchange legal after perf) **continues** NN — `www.exchange-03`, not a second `-01`.  
    -Without `set_group`, IDs stay flat: `area-01`, `area-02`, …
    -
    -Optional soft checks still consume a number when they WARN.  
    -Numbering follows **executed** checks (early skip may shift later NN inside the same group).
    -
    ----
    -
    -## www — public URLs (`./taler-monitoring.sh urls`)
    -
    -| Group | Checks (typical) |
    -|-------|------------------|
    -| **www.exchange-** | `/config`, currency, **alt_unit_names**; `/keys` (+ alt soft); `/intro/`, `/`; **`/terms`**, **`/privacy`**, `/terms/` |
    -| **www.perf-** | outside-in RTT: bank/exchange/merchant `/config`, keys, webui, intro — ms; WARN ≥ `PERF_WARN_MS` (8000); ERROR ≥ `PERF_FAIL_MS` (20000) |
    -| **www.stats-** | `/intro/stats.json` **reachable**; **timestamps** (`generated_at_unix` + human/ISO); **freshness** vs wall clock (`STATS_STALE_SECS` WARN, `STATS_FAIL_SECS` ERROR); **display fields** (bank-shape withdraws/accounts, or exchange-db / merchant-db keys); optional performance block; shared-feed equal `gen_unix` when bank collector is published to all three |
    -| **www.bank-** | `/config`, currency, alt_unit_names; integration/webui/intro; **auto-account.json** (required GOA/local; skip/soft off-GOA e.g. TESTPAYSAN); `/terms`, `/privacy` |
    -| **www.merchant-** | `/config` currency + currencies alt_unit_names; listed exchanges alt; webui/intro; **`/terms`**, **`/privacy`** |
    -| **www.webui-** | SPA fingerprints: **`/webui/version.txt`**, **`/webui/version-overlay.txt`**, `index.html` / `index.js`; optional pin via `EXPECT_WEBUI_VERSION` / `EXPECT_WEBUI_OVERLAY` / `WEBUI_OVERLAY_DENY` |
    -| **www.paivana-** | local GOA paywall front (redirect to template) |
    -| **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; stage merchant shop: `shop-ui.js` + `shops.css` + `qrcode.min.js`; cross-links local |
    -| **www.qr-** | QR payloads: harvest `taler://` / `payto://` / app `data-qr-url` from landings + mint JSON; **form** check; **qrencode → zbarimg** exact roundtrip; optional static QR images. Needs `qrencode` + `zbar-tools`. Skip: `QR_CHECK=0` |
    -
    -**QR rule:** `taler://withdraw/HOST/taler-integration/UUID` (no default `:443`/`:80`); `taler://pay/` / `pay-template/`; `payto://…` shape OK; decoded PNG must equal payload. Form errors on withdraw/pay/payto are **ERROR** on local/GOA.
    -
    -**Legal docs rule:** HTTP 200, non-empty body, not plain `not configured`, not merchant API JSON `code:21`. Local stack may require content needle (terms/privacy/FADP/GOA…).
    -
    -**Performance rule:** Measured from the **monitoring runner** (public via Caddy), not container loopback. Latency (ms) on every perf line + **perf summary** (n / min / p50 / avg / max).
    -
    -**Landing links rule:** Own-stack must be HTTP 200 (or redirect→200). External stores/docs soft WARN. Auto-account wallet link must be `taler://withdraw/HOST/taler-integration/…` (default ports stripped), never payto.
    -
    -**alt_unit_names rule:** non-empty map including scale key `"0"`. Multi-currency merchant: follow each `exchanges[]` public `/config`.
    -
    ----
    -
    -## inside — koopa SSH (`./taler-monitoring.sh inside`)
    -
    -| Group | Checks |
    -|-------|--------|
    -| **inside.host-** | **host-podman**: reachability via `podman exec` on this machine (GOA host / `INSIDE_PODMAN=1`) — not SSH |
    -| **inside.ssh-** | **ssh**: from laptop/remote — SSH to `KOOPA_SSH` / `INSIDE_SSH`, then podman there |
    -| **inside.bank-** | container, libeufin, postgres, local `/config`, nginx, DNS pins |
    -| **inside.exchange-** | container, httpd, wirewatch, aggregator, transfer, local keys, DNS |
    -| **inside.merchant-** | container, httpd, wirewatch, depositcheck, DNS |
    -| **inside.caddy-** | host reverse-proxy process |
    -| **inside.load-** | host loadavg + RAM; per-container RSS/CPU |
    -| **inside.disk-** / **aptdeploy.disk-** / **versions.disk-** / **server.disk-** | free space via `df`: **WARN** if use ≥ `DISK_WARN_USED_PCT` (85), **ERROR** if full or use ≥ `DISK_ERR_USED_PCT` (95); host + containers |
    -
    -Remote lines `E|comp|LEVEL|key|detail` each become one numbered result under that component group.
    -
    -SSH: `KOOPA_SSH` (default `koopa`), then `KOOPA_SSH_FALLBACKS` when LAN is unreachable.
    -
    ----
    -
    -## e2e — withdraw + pay (`./taler-monitoring.sh e2e`)
    -
    -| Group | Checks |
    -|-------|--------|
    -| **e2e.prereq-** | wallet-cli, currency, budgets, secrets, public reachability |
    -| **e2e.load-** | host/container load snapshots (before / after ATM / after pay) |
    -| **e2e.bank-** | account, credit, withdraw ops |
    -| **e2e.wallet-** | exchange + ToS, wallet setup |
    -| **e2e.atm-** | ATM withdraw ladder rungs |
    -| **e2e.settle-** | wallet settlement wait / spendable balance |
    -| **e2e.pay-** | variable payments |
    -| **e2e.shop-** | GOA shop product catalog pays |
    -| **e2e.paivana-** | template paywall pay |
    -| **e2e.dig-** | coins-missing dig (inside + withdrawal-operation) |
    -| **e2e.report-** | final tallies |
    -
    -When filing an issue, quote the full id + label, e.g.  
    -`e2e.atm-03  ATM withdraw 5 GOA · bank confirmed, wallet empty`.
    -
    ----
    -
    -## versions / sanity / ladder / server / auth401
    -
    -| Area.group | Meaning |
    -|------------|---------|
    -| **versions.outside-** | deb.taler.net suite index |
    -| **versions.host-** | host-podman: packages via local `podman exec` (`INSIDE_PODMAN=1`) |
    -| **versions.ssh-** | ssh: packages via `KOOPA_SSH` / `INSIDE_SSH` from laptop/remote |
    -| **versions.compare-** | installed vs suite |
    -| **aptdeploy.trixie-fresh-** | fresh rebuild container `…-apt-src-trixie` |
    -| **aptdeploy.trixie-testing-fresh-** | fresh rebuild `…-trixie-testing` |
    -| **aptdeploy.trixie-upgrade-** | upgrade-track `…-trixie-upgrade` (install once, then mytops-style upgrade) |
    -| **aptdeploy.trixie-testing-upgrade-** | upgrade-track `…-trixie-testing-upgrade` |
    -
    -Standalone: `host-agent/run-aptdeploy.sh` (on koopa local; else `ssh koopa-external`).
    -
    -### surface (remote-only public inventory)
    -
    -```bash
    -./taler-monitoring.sh surface                      # taler.net / gnunet / taler-systems / mattermost / …
    -./taler-monitoring.sh -d hacktivism.ch surface     # all things under that domain
    -```
    -
    -- Catalog: `surface-catalog.conf` (extend as hosts are found)
    -- DNS, optional ICMP, TCP ports, then **protocol** checks (HTTPS/HTTP/SSH) before ERROR
    -- TLS cert expiry; Server-header / `/config` version hints; else **WARN: version unknown**
    -- OSV CVE query when package+version known → **ERROR** on hits
    -- Never SSH / never podman on the *targets*
    -| **sanity.bank-** / **.exchange-** / **.merchant-** | public + optional server-side per component |
    -| **ladder.plan-** / **.load-** / **.withdraw-** / **.pay-** / **.report-** | amount ladder (GOA ceiling or stage TESTPAYSAN `max_wire`) |
    -| **server-** | SSH host ports / processes (flat unless grouped later) |
    -| **auth401.*** | merchant 401 / case matrix (WebUI APIs): **signup** `POST /instances` MIX id, **login** Basic user low vs MIX/UPPER, full **case** matrix, **password** bad/empty, **pwchange** `POST private/auth` + re-login, **admin-create** `POST /management/instances` (if admin token), **bearer**, **durable mon401**, **webui** SPA `toLowerCase` probe (`check_auth401.sh`) |
    -
    -### SPA pin (after selfbuild-webui)
    -
    -```bash
    -EXPECT_WEBUI_VERSION=1.6.11 \
    -EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \
    -WEBUI_OVERLAY_DENY='c778af04|master-11340' \
    -  ./taler-monitoring.sh -d hacktivism.ch urls
    -```
    -
    -Skip SPA block: `CHECK_WEBUI_SPA=0`.
    -
    -### Full load (maximum phases)
    -
    -| Alias | Expands to (local GOA) |
    -|-------|-------------------------|
    -| **`all`** | `urls inside versions sanity e2e` |
    -| **`full`** | `urls inside versions sanity server e2e ladder auth401` |
    -
    -```bash
    -cd ~/src/koopa/koopa-admin-log/scripts/taler-monitoring
    -# secrets.env with bank admin + merchant tokens (see secrets.env.example)
    -./taler-monitoring.sh -d hacktivism.ch full
    -# same, explicit:
    -./taler-monitoring.sh -d hacktivism.ch \
    -  urls inside versions sanity server e2e ladder auth401
    -```
    -
    ----
    -
    -## Visual format (colour on)
    -
    -```text
    -╔══════════════════════════════╗
    -║ www · public URLs · …        ║
    -╚══════════════════════════════╝
    -┌ www.exchange ┐
    -┌ OK    ┐ www.exchange-01  exchange /config · HTTP 200
    -┌ www.bank ┐
    -┌ OK    ┐ www.bank-01  bank /config · HTTP 200
    -┌ ERROR ┐ www.bank-04  bank /intro/auto-account.json · invalid withdraw
    -
    -┌ ERRORS · failed checks ┐
    -  • www.bank-04 bank /intro/auto-account.json · invalid withdraw
    -totals: 40 OK, 1 ERROR, 2 WARN, 5 INFO
    -```
    -
    -`NO_COLOR=1` or `CLICOLOR=0` disables boxes/colours (ASCII `[ OK ]` + `-- group --` headers).  
    -Always use `printf --` friendly plain headers when colour is off (leading `---` is not a printf option).
    diff --git a/scripts/taler-monitoring/android-test/.gitignore b/scripts/taler-monitoring/android-test/.gitignore
    deleted file mode 100644
    index 49b08e5..0000000
    --- a/scripts/taler-monitoring/android-test/.gitignore
    +++ /dev/null
    @@ -1,11 +0,0 @@
    -apks/*.apk
    -out/
    -*.png
    -out-source/
    -out-source-*/
    -out-gui/
    -out-source-gui/
    -out-gui-chain/
    -out-matrix-*/
    -out-stack-run-*/
    -out-survey-*/
    diff --git a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md b/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md
    deleted file mode 100644
    index 18fe398..0000000
    --- a/scripts/taler-monitoring/android-test/GUI-AUTOMATION-NOTES.md
    +++ /dev/null
    @@ -1,362 +0,0 @@
    -# GUI automation notes (Android wallet)
    -
    -Canonical notes for **graphical** Android wallet tests against GOA / stage.
    -
    ----
    -
    -## Minimal variant (implemented first) — status 2026-07
    -
    -This is the **minimal vanilla** path we actually shipped; full dual-platform
    -GUI is planned via flags (below), not required to use these scripts today.
    -
    -| Piece | Status | Notes |
    -|-------|--------|--------|
    -| F-Droid published APK install | **done** | `net.taler.wallet.fdroid` 1.6.1 / 854 |
    -| Source build + same smoke | **done** | `:wallet:assembleFdroidDebug` → `net.taler.wallet.fdroid.debug` |
    -| Default source branch | **done** | `dev/hernani-inference/fix-bank-withdraw-auto-exchange` (minimal GOA exchange auto-add) |
    -| Deep-link entry (≡ QR) | **done** | `adb` `VIEW` **withdraw then pay** (`taler://withdraw/…` → `taler://pay…`) |
    -| Explorer API mint (chain) | **done** | `run-goa-gui-chain.sh` (GOA + stage) |
    -| UI taps via uiautomator | **done** | best-effort Confirm/ToS/Pay; ANR → prefer **Wait** |
    -| Screenshots / logcat artifacts | **done** | under `out*`, `out-gui-chain/` (gitignored) |
    -| Host CLI e2e settlement | **separate** | `taler-monitoring.sh e2e` / `ladder` (not Android UI) |
    -| Reliable unattended Confirm on 4 GiB Linux host | **limited** | System UI ANR; use phone or more RAM |
    -| iOS GUI | **not in this tree** | see taler-ios `dev/hernani-inference/gui-workflows` |
    -| macOS full dual AVD helpers | **upstream** | taler-android `gui-workflows` (Homebrew); ported ideas here for Linux |
    -
    -**Ground rule:** bring it to run; app diffs only if needed and **minimal**; prefer
    -extending existing `dev/hernani-inference/*` branches over new trees.
    -
    -### Minimalvariante: was geht / was nicht
    -
    -Einschränkungen der **aktuell implementierten** Minimalvariante (deep-link +
    -uiautomator best-effort). Kein voller Dual-Platform-GUI-Stack, kein Ersatz für
    -Host-CLI-Settlement.
    -
    -#### Geht (grün)
    -
    -| Fähigkeit | Wie / Hinweis |
    -|-----------|----------------|
    -| APK installieren | F-Droid `net.taler.wallet.fdroid` **oder** Source `assembleFdroidDebug` (`.debug`) |
    -| GOA-Exchange ohne manuelles Add | Source-Build von `fix-bank-withdraw-auto-exchange` (Default in `run-android-build-and-smoke.sh`) |
    -| **Abheben + Bezahlen** | Beide Beine: erst Withdraw-URI + Taps, dann Pay-URI + Taps (`DO_WITHDRAW`/`DO_PAY`) |
    -| Light UI-Drive | Confirm / Accept / Pay-Taps über Label-Suche (`lib_ui.py`, gui-smoke, chain) |
    -| Multi-Runden-Taps | `run-android-gui-smoke.sh` / `run-goa-gui-chain.sh` (best-effort) |
    -| Explorer-Mint (Kette) | `run-goa-gui-chain.sh` + `EXP_PW_FILE` (GOA + stage) |
    -| Stack-Wahl | `STACK=goa` / `stage` (Bank/Merchant-URLs) |
    -| Artefakte | Screenshots, UI-XML, logcat unter `out*` / `out-gui-chain/` (gitignored) |
    -| Hybrid-Smoke | Install + Intent + leichte Taps: `run-android-pay-smoke.sh` |
    -| Settlement **nachweisen** | **Host-seitig**: `taler-monitoring.sh e2e` / `ladder` (nicht Android-UI) |
    -| Flags abschalten | `AUTO_ANDROID=0` skip; `AUTO_GUI=0` → nur deep-link/pay-smoke |
    -
    -#### Geht **nicht** oder nur eingeschränkt (rot / gelb)
    -
    -| Beschränkung | Folge / Workaround |
    -|--------------|--------------------|
    -| **Unattended Confirm → settled im Emulator (~4 GiB Host)** | Häufig **System UI ANR**; Taps bleiben hängen. Prefer **Wait** auf ANR-Dialog, echtes Gerät, oder Host mit mehr RAM. Kein zuverlässiges grünes E2E nur über GUI auf dem schwachen Emulator. |
    -| **Vollständige Wallet-Settlement-Assertion in der GUI** | Minimalvariante prüft **nicht** „Balance final / transfer_done“ in der App-Oberfläche. Proof bleibt CLI/e2e. |
    -| **Kamera / physischer QR-Scan** | Absichtlich nicht automatisiert; deep-link ist der Shortcut. Landing-QR-Pfade sind **HTTP**-Thema, nicht Android-GUI. |
    -| **Browser-Shop-Checkout-UI** | Kein Chromium/WebView-Drive; Pay-URI kommt per Template-`POST` + Intent. |
    -| **Volles ToS-Scroll / alle Dialoge** | Best-effort Accept-Tap; lange ToS oder unerwartete Sheets können hängen bleiben. |
    -| **Published F-Droid allein auf GOA** | Kann Exchange-Add / Spinner-Probleme zeigen → **fix-branch-APK** bauen; bei Spinner-Hang optional `fix-withdraw-spinner-fallback`. |
    -| **iOS-GUI** | Nicht in diesem Tree; siehe taler-ios `gui-workflows`. |
    -| **macOS dual AVD / Homebrew-Helpers** | Upstream `gui-workflows`; hier nur Linux-taugliche Port-Ideen. |
    -| **`AUTO_*` in jedem Script erzwungen** | Design + teilweise verdrahtet; Wrapper-Checkliste noch offen. |
    -| **`run-until-done` / CLI-Wallet in GUI-Skripten** | Gehört zu CLI-Automation, nicht GUI; siehe `CLI-AUTOMATION-NOTES.md`. |
    -| **CI grün = „User hat bezahlt“** | Smoke = Intent + best-effort UI. Wirtschaftlicher Erfolg = Host-e2e / ladder / Explorer. |
    -
    -#### Erwartetes Ergebnis pro Schicht
    -
    -| Schicht | Erfolgskriterium Minimalvariante |
    -|---------|----------------------------------|
    -| A Install | APK installed, App startet |
    -| B Deep-link | Intent delivered, Wallet öffnet Withdraw/Pay-Flow |
    -| C GUI taps | Dump+Tap ohne Crash; ANR ggf. mit Wait; **kein** Garant für „Paid“ |
    -| D Evidence | Artefakte geschrieben |
    -| Settlement | **Außerhalb** GUI: Host e2e/ladder |
    -
    -#### Wann Minimalvariante reicht
    -
    -- Smoke nach APK-/Branch-Build (Intent kommt an, App crasht nicht).
    -- Manuelle Nacharbeit am Gerät nach vorbereiteten URIs.
    -- GOA/stage **Erreichbarkeit** von Landing/Template + Wallet-Einstieg.
    -
    -#### Wann nicht reicht → nächster Schritt
    -
    -- Unattended grünes Pay-E2E auf dem Emulator → mehr RAM / physisches Gerät, ggf. `fix-withdraw-spinner-fallback`.
    -- Beweis „Münzen da / Merchant paid“ → CLI e2e/ladder, nicht GUI-smoke.
    -- iOS / Dual-Platform → andere Repos/Flags, nicht diese Skripte.
    -
    -```bash
    -cd scripts/taler-monitoring/android-test
    -
    -# Minimal hybrid (install + deep-link + light taps)
    -STACK=goa ./run-android-pay-smoke.sh
    -
    -# GUI multi-round taps
    -STACK=goa ./run-android-gui-smoke.sh
    -STACK=stage ./run-android-gui-smoke.sh
    -
    -# Explorer mint + multi withdraw/pay chain (from gui-workflows)
    -EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \
    -  STACK=goa PKG=net.taler.wallet.fdroid.debug \
    -  ./run-goa-gui-chain.sh
    -
    -# Build fix-branch APK then smoke (GUI=1 for gui smoke)
    -./run-android-build-and-smoke.sh
    -GUI=1 ./run-android-build-and-smoke.sh
    -```
    -
    ----
    -
    -## Platform capability flags (planned + defaults)
    -
    -Automation can run on hosts that support **Android only**, **GUI tooling only**
    -(conceptual), or **both**. Flags keep that explicit for CI and laptops.
    -
    -### Proposed env flags
    -
    -| Variable | Values | Meaning |
    -|----------|--------|---------|
    -| `AUTO_ANDROID` | `0` / `1` | Run Android wallet automation (adb, APK, emulator/device) |
    -| `AUTO_GUI` | `0` / `1` | Drive **graphical** UI (uiautomator taps, multi-round). If `0`, deep-link/CLI-only install smoke. |
    -| `AUTO_PLATFORM` | `auto` / `linux` / `macos` / `ios` | Host family (optional override) |
    -| `EMULATOR_HEADLESS` | **`1` (default)** / `0` | No host window (`-no-window`). Server/CI-safe. |
    -| `WINDOWED` | `0` / `1` | Shortcut: `WINDOWED=1` ⇒ show emulator window (`EMULATOR_HEADLESS=0`) |
    -| `EMULATOR_GPU` | default **`swiftshader_indirect`** | Guest GLES still runs headless (layout/taps/screenshots). Override `host` only if windowed + real GPU. |
    -| `AUTO_START_EMULATOR` | **`1` (default)** / `0` | If no adb device, start AVD via `start-android-emulator.sh --wait` |
    -
    ----
    -
    -## Headless mode (default)
    -
    -**Default for all GUI / pay smokes:** no window on the host. Opening an emulator
    -GUI on servers (or headless CI) is wrong; these scripts therefore start the AVD
    -with **`-no-window`** and still keep **graphics logic** in the guest:
    -
    -| Piece | Headless default |
    -|-------|------------------|
    -| Host window | **off** (`-no-window`, `QT_QPA_PLATFORM=offscreen`, no `DISPLAY`) |
    -| Guest GLES | **on** via **SwiftShader** (`-gpu swiftshader_indirect`) |
    -| UI automation | **on** — `uiautomator` dump/tap + `screencap` (no X11 needed) |
    -| Audio / boot anim | off (`-no-audio -no-boot-anim`) |
    -
    -```bash
    -# explicit (same as default)
    -./start-android-emulator.sh --wait
    -
    -# or let smoke auto-start headless AVD when no phone is plugged in
    -STACK=goa ./run-android-gui-smoke.sh
    -
    -# laptop: show the emulator window
    -WINDOWED=1 ./start-android-emulator.sh --wait
    -# or: EMULATOR_HEADLESS=0 EMULATOR_GPU=host ./start-android-emulator.sh --wait
    -```
    -
    -Shared code: `lib_android_env.sh` + `start-android-emulator.sh`.
    -
    -**Semantics:**
    -
    -| `AUTO_ANDROID` | `AUTO_GUI` | Behaviour |
    -|----------------|------------|-----------|
    -| `1` | `0` | Android install + deep-link smoke only (no multi-round taps) |
    -| `1` | `1` | Android + graphical drive (vanilla GUI / chain) |
    -| `0` | `1` | Reserved (e.g. future desktop/web GUI); currently **no-op** with a clear message |
    -| `0` | `0` | Skip mobile automation |
    -
    -### Defaults by host (when flags unset)
    -
    -| Host (`uname -s`) | Default `AUTO_ANDROID` | Default `AUTO_GUI` | Rationale |
    -|-------------------|------------------------|--------------------|-----------|
    -| **Linux** | **`1`** | **`1`** if device/emulator present, else scripts exit 3 | This repo’s day-to-day path; **Android only** (no iOS here) |
    -| **Darwin (macOS)** | `1` | `1` | Can run Android emulators **and** (separately) taler-ios GUI helpers; both flags on for Android scripts; iOS is out of tree |
    -| **Other** | `0` | `0` | Fail closed |
    -
    -**Linux default = Android** (no second mobile platform in this suite).  
    -**macOS** may enable both ecosystems in the wider monorepo sense; for *these*
    -scripts only Android is implemented — set `AUTO_ANDROID=1` (default) and keep
    -iOS under `taler-ios`.
    -
    -### Resolution helper (convention for future wrappers)
    -
    -```bash
    -# Example for a future run-all-mobile.sh
    -os=$(uname -s)
    -: "${AUTO_PLATFORM:=auto}"
    -case "$AUTO_PLATFORM" in
    -  auto) case "$os" in Linux) AUTO_PLATFORM=linux ;; Darwin) AUTO_PLATFORM=macos ;; *) AUTO_PLATFORM=other ;; esac ;;
    -esac
    -case "$AUTO_PLATFORM" in
    -  linux)
    -    : "${AUTO_ANDROID:=1}"
    -    : "${AUTO_GUI:=1}"
    -    # no iOS
    -    ;;
    -  macos)
    -    : "${AUTO_ANDROID:=1}"
    -    : "${AUTO_GUI:=1}"
    -    # optional later: AUTO_IOS=1 for taler-ios scripts
    -    ;;
    -  *)
    -    : "${AUTO_ANDROID:=0}"
    -    : "${AUTO_GUI:=0}"
    -    ;;
    -esac
    -```
    -
    -Scripts **today** implement Android only; they should honour:
    -
    -- `AUTO_ANDROID=0` → exit 0 with “skipped (AUTO_ANDROID=0)”
    -- `AUTO_GUI=0` → call deep-link smoke without multi-round GUI (or set `GUI_ROUNDS=0`)
    -
    ----
    -
    -## Vanilla layers (detail)
    -
    -| Layer | What | Tooling |
    -|-------|------|---------|
    -| **A — Install** | F-Droid or from-source APK | `adb install` |
    -| **B — Entry (shortcut)** | `taler://withdraw/…` / `taler://pay…` | `adb am start -a VIEW` |
    -| **C — Graphical UI** | Confirm / ToS / Pay taps; ANR Wait | `uiautomator` + `lib_ui.py` / chain |
    -| **D — Evidence** | Screenshots, XML, logcat, URIs | `out*/` |
    -
    -### Legitimate shortcuts
    -
    -| Shortcut | Replaces |
    -|----------|----------|
    -| Deep-link withdraw/pay | Camera QR / opening paywall |
    -| Host `POST` template → pay URI | Browser shop checkout UI |
    -| Label-list taps | Human reading button text |
    -| Skip full ToS scroll | Tap Accept if shown |
    -
    -### Scripts
    -
    -| Script | Role |
    -|--------|------|
    -| `run-android-pay-smoke.sh` | Hybrid: install + deep-link + light taps |
    -| `run-android-gui-smoke.sh` | Multi-round GUI drive |
    -| `run-goa-gui-chain.sh` | Multi mint/withdraw/pay (gui-workflows port) |
    -| `run-android-build-and-smoke.sh` | Build git ref (branch/tag) + smoke (`GUI=1` optional) |
    -| `run-android-variant-matrix.sh` | **published + stable-self + master** (compare builds) |
    -| `start-android-emulator.sh` | Start AVD (**headless default**, SwiftShader) |
    -| `lib_android_env.sh` | Flags, PATH, `android_ensure_device` |
    -| `lib_ui.py` | Dump/tap/ANR helpers |
    -
    -### Build variants (anti–“weird local build”)
    -
    -| Variant | Source | Severity | Purpose |
    -|---------|--------|----------|---------|
    -| **stable-self** | **only** rebuild of current stable tag | WARN | Self-build vs published for **one** release |
    -| **published** | F-Droid current APK | **≤14d → BLOCKER**, else WARN | What users install |
    -| **older / milestones** | F-Droid APK ~**3 / 6 / 9 / 12 mo** | **always WARN** | No self-build of old tags |
    -| **master** | `origin/master` rebuild | WARN | Tip — never hard-blocks |
    -| **fix** (optional) | inference branch | soft | GOA automation fixes |
    -
    -**Self-build:** current stable only (plus master/fix if requested) — not older releases.  
    -**Blocker window:** releases ≤ 14 days.  
    -**Milestones:** nearest tags to 90/180/270/365d with a still-hosted F-Droid APK.
    -
    ----
    -
    -## Stacks under test
    -
    -| Stack | Withdraw | Pay |
    -|-------|----------|-----|
    -| **GOA** | explorer mint / `demo-withdraw.json` @ bank.hacktivism.ch | goa-shop templates / Paivana pay-template |
    -| **stage** | explorer / demo-withdraw @ stage.bank… | fermes / jardin public templates |
    -
    -### Lageübersicht GOA + stage (as-is survey · 2026-07-17 ~22:05–22:18)
    -
    -**Device:** headless `emulator-5554` · host ~3.7 GiB RAM (often <300 MiB free) · timeout per leg ≤420 s.  
    -**Legs:** Abheben + Bezahlen (`DO_WITHDRAW=1 DO_PAY=1`). Artefakte: `out-survey-20260717-220527/` (gitignored).
    -
    -#### Varianten
    -
    -| Variante | Quelle | Bemerkung |
    -|----------|--------|-----------|
    -| **stable (published)** | F-Droid `net.taler.wallet.fdroid` **1.6.1 / 854** | as-is |
    -| **master as-is** | rebuild `origin/master` @ `f65a976c` → `.fdroid.debug` | as-is, no patches |
    -| **ältere Releases** (3/6/9/12 mon) | F-Droid APKs für `1.5.0`/`1.3.0`/`1.1.0`/`1.0.8` | **nicht auffindbar** (HTTP miss) → **SKIP** (kein Selbstbau älterer Tags) |
    -| **master + inference** (extra) | local branch `local/master-with-inference` @ `3ebe8b09` | merges `fix-bank-withdraw-auto-exchange` + `fix-withdraw-spinner-fallback` · **not pushed** |
    -
    -#### Ergebnis-Matrix
    -
    -| Variant | Stack | Install / deep-link | Withdraw URI | Pay URI | wallet-core Abheben | wallet-core Pay | ANR | Exit |
    -|---------|-------|---------------------|--------------|---------|---------------------|-----------------|-----|------|
    -| stable published | **goa** (hacktivism) | OK | OK bank.hacktivism.ch | OK paivana template | **NO** | soft/YES once* | **YES** | **10** |
    -| stable published | **stage** (*.lefrancpaysan) | OK | OK stage.bank… | OK fermes order | **NO** | **NO** | **YES** | **10** |
    -| master as-is | **goa** | OK | OK | OK paivana | **NO** | **NO** | **YES** | **10** |
    -| master as-is | **stage** | OK | OK | OK fermes | **NO** | **NO** | **YES** | **10** |
    -| master+inference | **goa** | OK | OK | OK | **NO** | **NO** | **YES** | **10** |
    -| master+inference | **stage** | OK | OK | OK | **NO** | **NO** | **YES** | **10** |
    -
    -\*stable-goa console once reported pay wallet-core YES mid-run; final score still ANR-dominated / not reliable settlement.
    -
    -#### Was pro Stack **steht** (Infra)
    -
    -| Check | **goa / hacktivism** | **stage / \*.lefrancpaysan** |
    -|-------|----------------------|------------------------------|
    -| Bank `…/intro/demo-withdraw.json` → `taler://withdraw/…` | **OK** | **OK** |
    -| Pay entry | paivana `pay-template` **OK** | fermes `panier-legumes` POST → `taler://pay/…` **OK** |
    -| adb `VIEW` Intent delivery | **OK** | **OK** |
    -| Unattended Confirm/Abheben/Pay GUI | **FAIL (ANR)** | **FAIL (ANR)** |
    -| wallet-core prepare/accept withdraw in logcat | **not seen** | **not seen** |
    -
    -**Fazit as-is:** Beide Stacks liefern die **Eingänge** (Withdraw- + Pay-URIs). Die **GUI/wallet-core-Strecke** ist auf diesem Emulator für **alle** getesteten App-Varianten (stable, master, inference) **gleich tot** — System UI / Android System ANR, keine brauchbaren Taps. Das ist **kein Stack-Unterschied** GOA vs stage und **kein** belastbarer App-Versions-Vergleich.
    -
    -#### Inference-Patch-Versuch (lokal, **kein Push**)
    -
    -Weil **nichts** der as-is-Varianten wallet-core-grün war, wurde zusätzlich gebaut:
    -
    -```text
    -taler-android branch: local/master-with-inference   # NOT pushed
    -base:     origin/master
    -merged:   origin/dev/hernani-inference/fix-bank-withdraw-auto-exchange
    -          origin/dev/hernani-inference/fix-withdraw-spinner-fallback
    -commit:   3ebe8b091379
    -APK:      apks/wallet-master-inference-3ebe8b091379.apk (local)
    -```
    -
    -| Stack | Inference smoke | Besser als master/stable? |
    -|-------|-----------------|---------------------------|
    -| goa | exit 10, ANR, no withdraw core | **Nein** |
    -| stage | exit 10, ANR, no withdraw core | **Nein** |
    -
    -**Kein grünes Inference-Signal** auf diesem Host → wir können **nicht** belegen, dass die Patches Withdraw/Pay „retten“.  
    -Ebenso können wir **nicht** belegen, dass master ohne Patches „schuld“ ist: die Umgebung erreicht wallet-core gar nicht.
    -
    -> **Wenn später auf Gerät/mehr RAM die Inference-APK plötzlich grün und master/stable rot ist:**  
    -> das ist der **klare Fix-Hinweis** — Patches aus `dev/hernani-inference/*` upstreamen / in master holen.  
    -> **Heute:** noch **nicht** der Fall; primärer Blocker = **Emulator/RAM/ANR**.
    -
    -#### Nächste sinnvolle Schritte (später)
    -
    -1. Physisches Gerät oder Host ≥6–8 GiB free RAM  
    -2. Nur **eine** Wallet-Package-ID installiert (kein Open-with)  
    -3. Survey wiederholen: stable · master · `local/master-with-inference` auf **goa + stage**  
    -4. Ältere F-Droid-APKs nur wenn wieder gehostet; kein Selbstbau alter Tags  
    -
    ----
    -
    -## Upstream branches (`taler-android` `dev/hernani-inference/*`)
    -
    -| Branch | Role |
    -|--------|------|
    -| `gui-workflows` | macOS emulator helpers + original `goa-chain-emu.sh` |
    -| `fix/bank-withdraw-auto-exchange` | **Minimal** GOA exchange auto-add (default build branch) |
    -| `fix/withdraw-spinner-fallback` | Spinner + OIM; use if spinner still hangs |
    -| `experimental-oim*` | Optional cash UI |
    -
    ----
    -
    -## Checklist
    -
    -- [x] Minimal variant documented  
    -- [x] Minimalvariante: was geht / was nicht (Beschränkungen)  
    -- [x] Two stacks (goa / stage)  
    -- [x] Dual-stack run documented (2026-07-17: both FAIL ANR, landings OK)  
    -- [x] Flags design (`AUTO_ANDROID` / `AUTO_GUI` / platform defaults)  
    -- [x] **Headless emulator default** (`EMULATOR_HEADLESS=1`, SwiftShader)  
    -- [x] Linux default = Android suite only  
    -- [x] Inference-branch policy  
    -- [ ] Wrapper enforces flags in every entry script (incremental)  
    -- [ ] Reliable unattended Confirm on low-RAM Linux emulator  
    diff --git a/scripts/taler-monitoring/android-test/README.md b/scripts/taler-monitoring/android-test/README.md
    deleted file mode 100644
    index d91d82c..0000000
    --- a/scripts/taler-monitoring/android-test/README.md
    +++ /dev/null
    @@ -1,111 +0,0 @@
    -# Android wallet smoke (Abheben + Bezahlen)
    -
    -Smokes always cover **withdraw first**, then **pay** (not pay-only).
    -
    -| Leg | Default | What |
    -|-----|---------|------|
    -| **1 WITHDRAW** | on | `taler://withdraw/…` + Confirm/Abheben taps |
    -| **2 PAY** | on | `taler://pay…` / pay-template + Pay taps |
    -
    -```bash
    -# needs adb device/emulator
    -./run-android-pay-smoke.sh
    -STACK=goa ./run-android-pay-smoke.sh
    -DO_PAY=0 ./run-android-pay-smoke.sh          # withdraw only
    -DO_WITHDRAW=0 DO_PAY=1 …                     # pay only (unusual)
    -```
    -
    -APK: F-Droid **1.6.1** (`net.taler.wallet.fdroid`).
    -
    -## 2) Source build + same tests
    -
    -Builds `taler-android` at a git **ref** (`:wallet:assembleFdroidDebug`), then
    -runs the **same** smoke (install + withdraw/pay deep-links).
    -
    -```bash
    -# default: inference fix branch (GOA withdraw reliability)
    -./run-android-build-and-smoke.sh
    -# tip of master
    -BRANCH=master ./run-android-build-and-smoke.sh
    -# self-build of published stable **tag** (same release as F-Droid 1.6.1)
    -BRANCH=wallet-1.6.1 LABEL=stable-self ./run-android-build-and-smoke.sh
    -SKIP_PULL=1 BRANCH=master ./run-android-build-and-smoke.sh
    -TALER_ANDROID_SRC=/path/to/taler-android ./run-android-build-and-smoke.sh
    -```
    -
    -Built package id: `net.taler.wallet.fdroid.debug`.  
    -Needs `ANDROID_HOME` (e.g. `$HOME/Android/Sdk`), Java 17+, network for Maven.
    -
    -## 2b) Variant matrix (published + stable self-build + master)
    -
    -Runs several smokes so a single odd developer build cannot be the only sample:
    -
    -| Variant | What |
    -|---------|------|
    -| Order | Variant | Severity | What |
    -|------|---------|----------|------|
    -| **1** | **stable-self** | WARN | **Only** self-build: current stable tag |
    -| **2** | **published** | age ≤14d → **BLOCKER**, else WARN | Current F-Droid APK |
    -| **3** | **older / milestones** | **always WARN** | F-Droid APKs nearest **3 / 6 / 9 / 12 months** (if still hosted) — **no self-build** |
    -| **4** | **master** | WARN | Rebuild `origin/master` |
    -
    -**Self-build policy:** only **current published stable** (`stable-self`) + optional **master**.  
    -Historical versions: published F-Droid APK only; skip if APK gone.  
    -**Milestones:** `MILESTONE_DAYS="90 180 270 365"` (±50d), warn only.  
    -**Young releases** (≤ 14 days): hard blocker on fail.
    -
    -```bash
    -./run-android-variant-matrix.sh
    -STACK=goa ./run-android-variant-matrix.sh
    -INCLUDE_OLDER=0 VARIANTS=stable-self,published,master ./run-android-variant-matrix.sh
    -# custom milestones (days):
    -MILESTONE_DAYS="90 180 270 365" ./run-android-variant-matrix.sh
    -```
    -
    -Artifacts + `SUMMARY.txt` under `out-matrix-/`.
    -
    -## 3) Graphical UI (vanilla)
    -
    -Deep-link entry (≡ QR) **plus** multi-round uiautomator taps:
    -
    -```bash
    -STACK=goa ./run-android-gui-smoke.sh
    -STACK=stage ./run-android-gui-smoke.sh
    -# after source build:
    -GUI=1 ./run-android-build-and-smoke.sh
    -```
    -
    -## 4) Full GOA/stage chain (from `dev/hernani-inference/gui-workflows`)
    -
    -Bank API mint → withdraw deep-link → taps → template pay → taps:
    -
    -```bash
    -# needs explorer password + device
    -EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt \
    -  STACK=goa PKG=net.taler.wallet.fdroid.debug \
    -  ./run-goa-gui-chain.sh
    -
    -STACK=stage ./run-goa-gui-chain.sh
    -```
    -
    -Canonical notes (minimal variant, **flags**, Linux defaults):
    -**[GUI-AUTOMATION-NOTES.md](./GUI-AUTOMATION-NOTES.md)**.
    -
    -| Flag | Linux default | Meaning |
    -|------|---------------|---------|
    -| `AUTO_ANDROID` | `1` | Run Android adb/APK automation |
    -| `AUTO_GUI` | `1` | Graphical uiautomator drive (else deep-link only) |
    -| `AUTO_PLATFORM` | `auto` → `linux` | Host family; macOS can later pair with iOS out of tree |
    -| **`EMULATOR_HEADLESS`** | **`1`** | **No host window** (`-no-window`). Server-safe. |
    -| `EMULATOR_GPU` | `swiftshader_indirect` | Guest GLES still active for UI/taps/screenshots |
    -| `AUTO_START_EMULATOR` | `1` | Auto-start headless AVD if no adb device |
    -| `WINDOWED` | `0` | `WINDOWED=1` → show emulator window |
    -
    -```bash
    -# headless AVD only (default path for servers)
    -./start-android-emulator.sh --wait
    -# windowed (laptops)
    -WINDOWED=1 ./start-android-emulator.sh --wait
    -```
    -
    -See also parent [GIT-AUTOMATION-NOTES.md](../GIT-AUTOMATION-NOTES.md).
    diff --git a/scripts/taler-monitoring/android-test/lib_android_env.sh b/scripts/taler-monitoring/android-test/lib_android_env.sh
    deleted file mode 100644
    index 670d796..0000000
    --- a/scripts/taler-monitoring/android-test/lib_android_env.sh
    +++ /dev/null
    @@ -1,158 +0,0 @@
    -# shellcheck shell=bash
    -# Shared Android env for GUI / adb smokes.
    -# Source:  . "$ROOT/lib_android_env.sh"
    -#
    -# Defaults favour **servers / CI**: no host window (headless emulator), while the
    -# guest still runs GLES via SwiftShader so UI layout / uiautomator work.
    -#
    -# See GUI-AUTOMATION-NOTES.md → Headless mode
    -
    -# --- SDK / PATH ---------------------------------------------------------------
    -export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
    -export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
    -for d in \
    -  "$ANDROID_HOME/platform-tools" \
    -  "$ANDROID_HOME/emulator" \
    -  "$ANDROID_HOME/cmdline-tools/latest/bin"
    -do
    -  [ -d "$d" ] && case ":$PATH:" in *":$d:"*) ;; *) PATH="$d:$PATH" ;; esac
    -done
    -export PATH
    -
    -# AVD path (some hosts keep AVDs under ~/.config/.android)
    -if [ -z "${ANDROID_AVD_HOME:-}" ]; then
    -  if [ -d "$HOME/.android/avd" ]; then
    -    export ANDROID_AVD_HOME="$HOME/.android/avd"
    -  elif [ -d "$HOME/.config/.android/avd" ]; then
    -    export ANDROID_AVD_HOME="$HOME/.config/.android/avd"
    -    # emulator also looks at ~/.android/avd — soft-link if missing
    -    if [ ! -e "$HOME/.android/avd" ]; then
    -      mkdir -p "$HOME/.android"
    -      ln -sfn "$HOME/.config/.android/avd" "$HOME/.android/avd" 2>/dev/null || true
    -    fi
    -  fi
    -fi
    -
    -# --- Platform / feature flags -------------------------------------------------
    -os=$(uname -s)
    -case "${AUTO_PLATFORM:-auto}" in
    -  auto)
    -    case "$os" in
    -      Linux)  : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;;
    -      Darwin) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;;
    -      *)      : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;;
    -    esac
    -    ;;
    -  linux|macos) : "${AUTO_ANDROID:=1}"; : "${AUTO_GUI:=1}" ;;
    -  *)           : "${AUTO_ANDROID:=0}"; : "${AUTO_GUI:=0}" ;;
    -esac
    -
    -# Headless = default (no X11/Wayland window on host). GUI *logic* still runs in
    -# the guest via software GPU. Set EMULATOR_HEADLESS=0 or WINDOWED=1 for a window.
    -: "${EMULATOR_HEADLESS:=1}"
    -if [ "${WINDOWED:-0}" = "1" ]; then
    -  EMULATOR_HEADLESS=0
    -fi
    -export EMULATOR_HEADLESS
    -
    -# Soft GLES for headless; windowed may override to host/auto if set by user.
    -if [ "${EMULATOR_HEADLESS}" = "1" ]; then
    -  : "${EMULATOR_GPU:=swiftshader_indirect}"
    -else
    -  : "${EMULATOR_GPU:=swiftshader_indirect}"  # still safe default; host GPU: EMULATOR_GPU=host
    -fi
    -export EMULATOR_GPU
    -
    -: "${EMULATOR_AVD:=TalerWallet34}"
    -: "${EMULATOR_MEMORY:=1536}"
    -: "${EMULATOR_CORES:=2}"
    -# If no adb device: start headless AVD (0 = require phone/manual emulator)
    -: "${AUTO_START_EMULATOR:=1}"
    -: "${EMULATOR_BOOT_TIMEOUT:=360}"
    -: "${EMULATOR_LOG:=/tmp/taler-android-emulator.log}"
    -: "${EMULATOR_NO_SNAPSHOT:=1}"
    -
    -android_adb_serial() {
    -  adb devices 2>/dev/null | awk '/\tdevice$/{print $1; exit}'
    -}
    -
    -android_has_device() {
    -  [ -n "$(android_adb_serial)" ]
    -}
    -
    -# Build emulator argv for current headless/windowed mode.
    -# Prints args; caller exec/nohup.
    -android_emulator_args() {
    -  local avd="${1:-$EMULATOR_AVD}"
    -  local args=( -avd "$avd" -gpu "$EMULATOR_GPU" -no-audio -no-boot-anim )
    -  if [ "${EMULATOR_HEADLESS}" = "1" ]; then
    -    args+=( -no-window )
    -    # Avoid Qt trying to open a display on servers
    -    export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}"
    -  fi
    -  # Broken default_boot snapshots leave adb offline forever — cold boot by default.
    -  if [ "${EMULATOR_NO_SNAPSHOT:-1}" = "1" ]; then
    -    args+=( -no-snapshot -no-snapshot-load -no-snapshot-save )
    -  fi
    -  if [ -n "${EMULATOR_MEMORY:-}" ]; then
    -    args+=( -memory "$EMULATOR_MEMORY" )
    -  fi
    -  if [ -n "${EMULATOR_CORES:-}" ]; then
    -    args+=( -cores "$EMULATOR_CORES" )
    -  fi
    -  # extra user flags
    -  # shellcheck disable=SC2206
    -  if [ -n "${EMULATOR_EXTRA_ARGS:-}" ]; then
    -    # intentional word-split for extra flags
    -    args+=( ${EMULATOR_EXTRA_ARGS} )
    -  fi
    -  printf '%s\n' "${args[@]}"
    -}
    -
    -# Ensure an adb "device" is online. Optionally start headless emulator.
    -# Sets SERIAL / ANDROID_SERIAL on success. Returns 0 ok, 3 no device.
    -android_ensure_device() {
    -  local serial
    -  serial="${SERIAL:-${ANDROID_SERIAL:-}}"
    -  if [ -z "$serial" ]; then
    -    serial=$(android_adb_serial || true)
    -  fi
    -  if [ -n "$serial" ]; then
    -    SERIAL="$serial"
    -    export ANDROID_SERIAL="$serial"
    -    return 0
    -  fi
    -
    -  if [ "${AUTO_START_EMULATOR}" != "1" ]; then
    -    echo "no adb device (AUTO_START_EMULATOR=0 — plug phone or start AVD manually)" >&2
    -    return 3
    -  fi
    -
    -  if ! command -v emulator >/dev/null 2>&1; then
    -    echo "no adb device and emulator binary missing under $ANDROID_HOME" >&2
    -    return 3
    -  fi
    -
    -  echo "no adb device → starting AVD ${EMULATOR_AVD} (headless=${EMULATOR_HEADLESS} gpu=${EMULATOR_GPU})"
    -  local starter
    -  starter="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/start-android-emulator.sh"
    -  if [ ! -x "$starter" ]; then
    -    echo "missing $starter" >&2
    -    return 3
    -  fi
    -  EMULATOR_HEADLESS="$EMULATOR_HEADLESS" EMULATOR_GPU="$EMULATOR_GPU" \
    -    EMULATOR_AVD="$EMULATOR_AVD" EMULATOR_MEMORY="$EMULATOR_MEMORY" \
    -    EMULATOR_CORES="$EMULATOR_CORES" EMULATOR_LOG="$EMULATOR_LOG" \
    -    EMULATOR_BOOT_TIMEOUT="$EMULATOR_BOOT_TIMEOUT" \
    -    "$starter" --wait || return 3
    -
    -  serial=$(android_adb_serial || true)
    -  if [ -z "$serial" ]; then
    -    echo "emulator started but no adb device yet — see $EMULATOR_LOG" >&2
    -    return 3
    -  fi
    -  SERIAL="$serial"
    -  export ANDROID_SERIAL="$serial"
    -  echo "device: $SERIAL (headless=${EMULATOR_HEADLESS})"
    -  return 0
    -}
    diff --git a/scripts/taler-monitoring/android-test/lib_release_age.sh b/scripts/taler-monitoring/android-test/lib_release_age.sh
    deleted file mode 100644
    index 7be935f..0000000
    --- a/scripts/taler-monitoring/android-test/lib_release_age.sh
    +++ /dev/null
    @@ -1,133 +0,0 @@
    -# shellcheck shell=bash
    -# Release age helpers for Android wallet matrix.
    -# Source after ROOT/lib_android_env.sh (optional).
    -#
    -# BLOCKER only when a *released* app is within BLOCKER_MAX_AGE_DAYS (default 14).
    -# Older releases: try them, but failures are WARN only.
    -
    -: "${BLOCKER_MAX_AGE_DAYS:=14}"
    -: "${TALER_ANDROID_SRC:=$HOME/taler/taler-android}"
    -
    -# Days since git tag (tagger/committer date). Empty if unknown.
    -# Usage: release_age_days wallet-1.6.1  → e.g. 28
    -release_age_days() {
    -  local tag="$1"
    -  local src="${2:-$TALER_ANDROID_SRC}"
    -  [ -d "$src/.git" ] || return 1
    -  local epoch
    -  epoch=$(git -C "$src" log -1 --format=%ct "refs/tags/${tag}" 2>/dev/null || true)
    -  [ -n "$epoch" ] || epoch=$(git -C "$src" log -1 --format=%ct "$tag" 2>/dev/null || true)
    -  [ -n "$epoch" ] || return 1
    -  local now
    -  now=$(date +%s)
    -  echo $(( (now - epoch) / 86400 ))
    -}
    -
    -# severity from age: blocker if age_days <= BLOCKER_MAX_AGE_DAYS, else warn
    -# Unknown age → warn (never accidental hard fail on mystery builds)
    -severity_from_age_days() {
    -  local days="${1:-}"
    -  if [ -z "$days" ] || ! [[ "$days" =~ ^[0-9]+$ ]]; then
    -    echo warn
    -    return
    -  fi
    -  if [ "$days" -le "${BLOCKER_MAX_AGE_DAYS}" ]; then
    -    echo blocker
    -  else
    -    echo warn
    -  fi
    -}
    -
    -severity_for_tag() {
    -  local tag="$1"
    -  local days
    -  days=$(release_age_days "$tag" 2>/dev/null || true)
    -  severity_from_age_days "$days"
    -}
    -
    -# List wallet-* release tags newest first (exclude rc/dev).
    -# Args: max count (default 5). Pass 0 or "all" for no limit.
    -list_wallet_release_tags() {
    -  local n="${1:-5}"
    -  local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}"
    -  [ -d "$src/.git" ] || return 1
    -  git -C "$src" fetch --tags origin 2>/dev/null || true
    -  local cmd=(git -C "$src" for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags)
    -  if [ "$n" = "all" ] || [ "$n" = "0" ]; then
    -    "${cmd[@]}" | grep -E '^wallet-[0-9]+\.[0-9]+(\.[0-9]+)?$'
    -  else
    -    "${cmd[@]}" | grep -E '^wallet-[0-9]+\.[0-9]+(\.[0-9]+)?$' | head -n "$n"
    -  fi
    -}
    -
    -# Pick one tag closest to each age milestone (days). Always intended as WARN samples.
    -# Default milestones: 3,6,9,12 months ≈ 90,180,270,365 days.
    -# Env: MILESTONE_DAYS="90 180 270 365"  MILESTONE_TOLERANCE_DAYS=50
    -# Prints lines: TAG AGE_DAYS TARGET_DAYS  (only when a tag within tolerance exists)
    -select_milestone_tags() {
    -  local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}"
    -  local milestones="${MILESTONE_DAYS:-90 180 270 365}"
    -  local tol="${MILESTONE_TOLERANCE_DAYS:-50}"
    -  local skip="${1:-}"   # optional tag to exclude (e.g. current STABLE_TAG)
    -  local -a tags=()
    -  mapfile -t tags < <(list_wallet_release_tags all 2>/dev/null || true)
    -  [ "${#tags[@]}" -gt 0 ] || return 0
    -
    -  local target best_tag best_age best_delta age t delta
    -  local -A used=()
    -  for target in $milestones; do
    -    best_tag=""
    -    best_age=""
    -    best_delta=99999
    -    for t in "${tags[@]}"; do
    -      [ -n "$t" ] || continue
    -      [ "$t" = "$skip" ] && continue
    -      [ -n "${used[$t]:-}" ] && continue
    -      age=$(release_age_days "$t" "$src" 2>/dev/null || true)
    -      [[ "$age" =~ ^[0-9]+$ ]] || continue
    -      delta=$(( age > target ? age - target : target - age ))
    -      if [ "$delta" -le "$tol" ] && [ "$delta" -lt "$best_delta" ]; then
    -        best_delta=$delta
    -        best_tag=$t
    -        best_age=$age
    -      fi
    -    done
    -    if [ -n "$best_tag" ]; then
    -      used[$best_tag]=1
    -      echo "$best_tag $best_age $target"
    -    fi
    -  done
    -}
    -
    -# Guess F-Droid APK URL for a wallet version name (e.g. 1.6.1).
    -# Uses versionCode from tag's build.gradle if possible, else STABLE mapping.
    -# Prints: versionCode apk_url  or returns 1
    -fdroid_apk_for_version_name() {
    -  local vn="$1"   # 1.6.1
    -  local src="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}"
    -  local tag="wallet-${vn}"
    -  local vc=""
    -  if git -C "$src" cat-file -e "refs/tags/${tag}:wallet/build.gradle" 2>/dev/null; then
    -    vc=$(git -C "$src" show "refs/tags/${tag}:wallet/build.gradle" 2>/dev/null \
    -      | sed -n 's/^[[:space:]]*versionCode[[:space:]]\+\([0-9]\+\).*/\1/p' | head -1)
    -  fi
    -  # ABI x86_64 → +4 in override scheme 10*vc+abi (common F-Droid multi-apk)
    -  # Prefer universal-style code used by F-Droid: for 1.6.1 → 854 = 10*85+4
    -  local apk_vc=""
    -  if [ -n "$vc" ]; then
    -    apk_vc=$((10 * vc + 4))
    -  fi
    -  # Known overrides / suggested
    -  case "$vn" in
    -    1.6.1) apk_vc=854 ;;
    -  esac
    -  [ -n "$apk_vc" ] || return 1
    -  echo "${apk_vc}|https://f-droid.org/repo/net.taler.wallet.fdroid_${apk_vc}.apk"
    -}
    -
    -# Check HTTP HEAD/GET that APK exists (0 = ok)
    -fdroid_apk_exists() {
    -  local url="$1"
    -  curl -sfS -m 15 -o /dev/null -r 0-0 "$url" 2>/dev/null \
    -    || curl -sfS -m 15 -o /dev/null -I "$url" 2>/dev/null
    -}
    diff --git a/scripts/taler-monitoring/android-test/lib_ui.py b/scripts/taler-monitoring/android-test/lib_ui.py
    deleted file mode 100755
    index a7cdad5..0000000
    --- a/scripts/taler-monitoring/android-test/lib_ui.py
    +++ /dev/null
    @@ -1,192 +0,0 @@
    -#!/usr/bin/env python3
    -"""Minimal adb + uiautomator helpers for Taler wallet GUI smoke (no root).
    -
    -Does not replace Maestro/Appium; uses only adb shell + uiautomator dump.
    -"""
    -from __future__ import annotations
    -
    -import re
    -import subprocess
    -import time
    -from pathlib import Path
    -from typing import Iterable, List, Optional, Sequence, Tuple
    -
    -Bounds = Tuple[int, int, int, int]
    -
    -
    -def adb(serial: str, *args: str, check: bool = False, timeout: Optional[float] = 60) -> subprocess.CompletedProcess:
    -    cmd = ["adb", "-s", serial, *args]
    -    return subprocess.run(cmd, check=check, capture_output=True, text=True, timeout=timeout)
    -
    -
    -def dump_ui(serial: str, dest: Path) -> str:
    -    adb(serial, "shell", "uiautomator", "dump", "/sdcard/ui.xml", timeout=90)
    -    adb(serial, "pull", "/sdcard/ui.xml", str(dest), timeout=30)
    -    return dest.read_text(errors="replace") if dest.is_file() else ""
    -
    -
    -def screencap(serial: str, dest: Path) -> None:
    -    p = subprocess.run(
    -        ["adb", "-s", serial, "exec-out", "screencap", "-p"],
    -        capture_output=True,
    -        timeout=60,
    -    )
    -    if p.returncode == 0 and p.stdout:
    -        dest.write_bytes(p.stdout)
    -
    -
    -def texts(xml: str) -> List[str]:
    -    return [t for t in re.findall(r'text="([^"]*)"', xml) if t.strip()]
    -
    -
    -def find_bounds(xml: str, label: str) -> List[Bounds]:
    -    """Return list of (x1,y1,x2,y2) for nodes whose text or content-desc matches label (exact)."""
    -    out: List[Bounds] = []
    -    esc = re.escape(label)
    -    patterns = [
    -        rf'(?:text|content-desc)="{esc}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
    -        rf'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*(?:text|content-desc)="{esc}"',
    -    ]
    -    for pat in patterns:
    -        for m in re.finditer(pat, xml, flags=re.I):
    -            out.append(tuple(map(int, m.groups())))  # type: ignore[arg-type]
    -    return out
    -
    -
    -def find_bounds_re(xml: str, pattern: str) -> List[Tuple[str, Bounds]]:
    -    out: List[Tuple[str, Bounds]] = []
    -    for m in re.finditer(
    -        rf'(?:text|content-desc)="([^"]*{pattern}[^"]*)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
    -        xml,
    -        flags=re.I,
    -    ):
    -        label = m.group(1)
    -        b = tuple(map(int, m.group(2, 3, 4, 5)))
    -        out.append((label, b))  # type: ignore[arg-type]
    -    return out
    -
    -
    -def center(b: Bounds) -> Tuple[int, int]:
    -    x1, y1, x2, y2 = b
    -    return (x1 + x2) // 2, (y1 + y2) // 2
    -
    -
    -def tap(serial: str, x: int, y: int) -> None:
    -    adb(serial, "shell", "input", "tap", str(x), str(y))
    -
    -
    -def tap_bounds(serial: str, b: Bounds) -> None:
    -    x, y = center(b)
    -    tap(serial, x, y)
    -
    -
    -def dismiss_anr(serial: str, xml: str) -> bool:
    -    """If System UI isn't responding, prefer Wait over Close."""
    -    if "isn't responding" not in xml and "reagiert nicht" not in xml.lower():
    -        return False
    -    for label in ("Wait", "Warten", "OK"):
    -        bs = find_bounds(xml, label)
    -        if bs:
    -            tap_bounds(serial, bs[0])
    -            return True
    -    # fallback: right-ish button often Wait
    -    adb(serial, "shell", "input", "keyevent", "22")
    -    adb(serial, "shell", "input", "keyevent", "66")
    -    return True
    -
    -
    -# Labels seen on wallet UI (EN/DE/FR) — expand as needed
    -CONFIRM_LABELS = [
    -    "Confirm",
    -    "Confirm withdrawal",
    -    "Withdraw",
    -    "Accept",
    -    "I accept",
    -    "Agree",
    -    "Continue",
    -    "Next",
    -    "OK",
    -    "Pay",
    -    "Pay now",
    -    "Bestätigen",
    -    "Abheben",
    -    "Akzeptieren",
    -    "Zustimmen",
    -    "Weiter",
    -    "Bezahlen",
    -    "Confirmer",
    -    "Retirer",
    -    "Accepter",
    -    "Payer",
    -]
    -
    -TOS_LABELS = [
    -    "I accept",
    -    "Accept",
    -    "Agree",
    -    "Akzeptieren",
    -    "Zustimmen",
    -    "Accepter",
    -]
    -
    -
    -def try_tap_any(serial: str, xml: str, labels: Sequence[str]) -> Optional[str]:
    -    for label in labels:
    -        bs = find_bounds(xml, label)
    -        if bs:
    -            tap_bounds(serial, bs[0])
    -            return label
    -    return None
    -
    -
    -def gui_drive(
    -    serial: str,
    -    out_dir: Path,
    -    *,
    -    rounds: int = 8,
    -    sleep_s: float = 3.0,
    -    phase: str = "gui",
    -) -> dict:
    -    """Poll UI, dismiss ANR, tap confirm/ToS-like buttons. Returns status dict."""
    -    out_dir.mkdir(parents=True, exist_ok=True)
    -    seen_texts: List[str] = []
    -    taps: List[str] = []
    -    anr = 0
    -    for i in range(1, rounds + 1):
    -        xml_path = out_dir / f"{phase}-{i:02d}-ui.xml"
    -        png_path = out_dir / f"{phase}-{i:02d}.png"
    -        try:
    -            xml = dump_ui(serial, xml_path)
    -        except Exception as e:
    -            taps.append(f"dump-fail:{e}")
    -            time.sleep(sleep_s)
    -            continue
    -        try:
    -            screencap(serial, png_path)
    -        except Exception:
    -            pass
    -        ts = texts(xml)
    -        seen_texts.extend(ts[:20])
    -        if dismiss_anr(serial, xml):
    -            anr += 1
    -            time.sleep(sleep_s)
    -            continue
    -        hit = try_tap_any(serial, xml, CONFIRM_LABELS)
    -        if hit:
    -            taps.append(hit)
    -            time.sleep(sleep_s)
    -            continue
    -        # partial regex for amount confirm rows
    -        for label, b in find_bounds_re(xml, r"Confirm|Withdraw|Pay|Accept|Bestätig|Abheben|Payer"):
    -            tap_bounds(serial, b)
    -            taps.append(f"re:{label}")
    -            time.sleep(sleep_s)
    -            break
    -        else:
    -            time.sleep(sleep_s)
    -    return {
    -        "taps": taps,
    -        "anr_dismissals": anr,
    -        "sample_texts": seen_texts[-40:],
    -        "rounds": rounds,
    -    }
    diff --git a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh b/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh
    deleted file mode 100755
    index 659b446..0000000
    --- a/scripts/taler-monitoring/android-test/run-android-build-and-smoke.sh
    +++ /dev/null
    @@ -1,167 +0,0 @@
    -#!/usr/bin/env bash
    -# Build GNU Taler Android wallet from source at a git **ref**, then run the same
    -# adb deep-link (or GUI) smoke as run-android-pay-smoke.sh.
    -#
    -# Source (default): $HOME/taler/taler-android
    -# Build:            ./gradlew :wallet:assembleFdroidDebug
    -# Package id:       net.taler.wallet.fdroid.debug
    -#
    -# Typical refs:
    -#   BRANCH=master                         # tip of master
    -#   BRANCH=wallet-1.6.1                   # published stable **tag** (self-build)
    -#   BRANCH=dev/hernani-inference/fix-…   # GOA automation fix branch
    -#
    -# For a multi-variant matrix (master + stable self-build + published APK):
    -#   ./run-android-variant-matrix.sh
    -#
    -# Usage:
    -#   ./run-android-build-and-smoke.sh
    -#   BRANCH=master STACK=goa ./run-android-build-and-smoke.sh
    -#   BRANCH=wallet-1.6.1 LABEL=stable-self ./run-android-build-and-smoke.sh
    -#   SKIP_PULL=1 BRANCH=master ./run-android-build-and-smoke.sh
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -SMOKE="$ROOT/run-android-pay-smoke.sh"
    -SRC="${TALER_ANDROID_SRC:-$HOME/taler/taler-android}"
    -SKIP_PULL="${SKIP_PULL:-0}"
    -# Prefer existing inference fix branch for GOA withdraw (spinner/exchange resolve).
    -# Matrix uses BRANCH=master and BRANCH=$STABLE_TAG explicitly.
    -BRANCH="${BRANCH:-dev/hernani-inference/fix-bank-withdraw-auto-exchange}"
    -# Optional human label for logs / artifact names (default = sanitized BRANCH)
    -LABEL="${LABEL:-}"
    -VARIANT="${VARIANT:-fdroidDebug}"
    -export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
    -export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
    -
    -if [ ! -x "$SRC/gradlew" ]; then
    -  echo "taler-android not found at $SRC (set TALER_ANDROID_SRC=…)" >&2
    -  exit 2
    -fi
    -if [ ! -d "$ANDROID_HOME" ]; then
    -  echo "ANDROID_HOME missing: $ANDROID_HOME" >&2
    -  exit 2
    -fi
    -
    -printf 'sdk.dir=%s\n' "$ANDROID_HOME" >"$SRC/local.properties"
    -
    -# --- checkout REF (branch, origin/branch, or tag) --------------------------------
    -cd "$SRC"
    -if [ -d .git ]; then
    -  git fetch origin --tags 2>/dev/null || git fetch origin 2>/dev/null || true
    -  REF="$BRANCH"
    -  checked=0
    -  if [ -n "$REF" ]; then
    -    if git rev-parse --verify "refs/tags/${REF}" >/dev/null 2>&1; then
    -      echo "checkout tag ${REF} (stable / release self-build)"
    -      git checkout -f "refs/tags/${REF}"
    -      checked=1
    -      SKIP_PULL=1
    -    elif git rev-parse --verify "origin/${REF}" >/dev/null 2>&1; then
    -      echo "checkout origin/${REF}"
    -      git checkout -B "$REF" "origin/${REF}" 2>/dev/null || git checkout "$REF" 2>/dev/null || true
    -      checked=1
    -    elif git rev-parse --verify "${REF}" >/dev/null 2>&1; then
    -      echo "checkout ${REF}"
    -      git checkout -f "$REF"
    -      checked=1
    -    else
    -      echo "WARN: ref '${REF}' not found locally after fetch — staying on current HEAD" >&2
    -    fi
    -  fi
    -  if [ "$SKIP_PULL" != "1" ] && [ "$checked" = "1" ]; then
    -    # only pull real branches, never tags
    -    if git symbolic-ref -q HEAD >/dev/null 2>&1; then
    -      br=$(git rev-parse --abbrev-ref HEAD)
    -      git pull --ff-only origin "$br" 2>/dev/null || true
    -    fi
    -  fi
    -fi
    -
    -COMMIT=$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)
    -HEAD_DESC=$(git describe --tags --always 2>/dev/null || echo "$COMMIT")
    -BR_NOW=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)
    -if [ -z "$LABEL" ]; then
    -  LABEL=$(printf '%s' "$BRANCH" | tr '/:' '--' | tr -c 'A-Za-z0-9._-' '-' | cut -c1-48)
    -fi
    -echo "source: $SRC @ $COMMIT ($HEAD_DESC)  ref=$BRANCH  label=$LABEL  head=$BR_NOW"
    -echo "variant: $VARIANT"
    -
    -# Map VARIANT → gradle task
    -case "$VARIANT" in
    -  fdroidDebug|FdroidDebug) task=assembleFdroidDebug ;;
    -  fdroidRelease|FdroidRelease) task=assembleFdroidRelease ;;
    -  nightlyDebug|NightlyDebug) task=assembleNightlyDebug ;;
    -  googleDebug|GoogleDebug) task=assembleGoogleDebug ;;
    -  *)
    -    task="assemble$(printf '%s' "$VARIANT" | python3 -c 'import sys; s=sys.stdin.read().strip(); print(s[:1].upper()+s[1:] if s else "FdroidDebug")')"
    -    ;;
    -esac
    -
    -# Low-RAM hosts: cap JVM
    -export GRADLE_OPTS="${GRADLE_OPTS:--Xmx1280m -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1}"
    -echo "gradle :wallet:$task …"
    -./gradlew ":wallet:$task" --no-daemon --max-workers="${GRADLE_MAX_WORKERS:-1}" \
    -  -Dorg.gradle.jvmargs="${GRADLE_JVMARGS:--Xmx1280m}"
    -
    -# Prefer fdroid debug APK if multiple exist
    -APK=$(find "$SRC/wallet/build/outputs/apk" -path '*fdroid*debug*.apk' -type f 2>/dev/null | sort | tail -1)
    -if [ -z "$APK" ] || [ ! -f "$APK" ]; then
    -  APK=$(find "$SRC/wallet/build/outputs/apk" -name '*.apk' -type f 2>/dev/null | sort | tail -1)
    -fi
    -if [ -z "$APK" ] || [ ! -f "$APK" ]; then
    -  echo "no APK under wallet/build/outputs/apk" >&2
    -  exit 4
    -fi
    -echo "built apk: $APK ($(wc -c <"$APK") bytes)"
    -
    -PKG="${PKG:-}"
    -if [ -z "$PKG" ] && command -v aapt >/dev/null 2>&1; then
    -  PKG=$(aapt dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\([^']*\)'.*/\1/p" | head -1)
    -fi
    -if [ -z "$PKG" ] && command -v aapt2 >/dev/null 2>&1; then
    -  PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\([^']*\)'.*/\1/p" | head -1)
    -fi
    -: "${PKG:=net.taler.wallet.fdroid.debug}"
    -
    -mkdir -p "$ROOT/apks" "$ROOT/out"
    -STAMP_APK="$ROOT/apks/wallet-${LABEL}-${COMMIT}.apk"
    -cp -f "$APK" "$STAMP_APK"
    -echo "copied → $STAMP_APK"
    -{
    -  echo "label=$LABEL"
    -  echo "ref=$BRANCH"
    -  echo "commit=$COMMIT"
    -  echo "describe=$HEAD_DESC"
    -  echo "apk=$STAMP_APK"
    -  echo "pkg=$PKG"
    -  echo "task=$task"
    -} | tee "$ROOT/out/build-${LABEL}.txt"
    -echo "$COMMIT" >"$ROOT/out/build-commit.txt"
    -echo "$APK" >"$ROOT/out/build-apk-path.txt"
    -
    -export APK_PATH="$STAMP_APK"
    -export APK_NAME
    -APK_NAME="$(basename "$STAMP_APK")"
    -export PKG
    -export OUT_DIR="${OUT_DIR:-$ROOT/out-source-$LABEL}"
    -mkdir -p "$OUT_DIR"
    -cp -f "$ROOT/out/build-${LABEL}.txt" "$OUT_DIR/build-meta.txt" 2>/dev/null || true
    -
    -SMOKE_CMD="$SMOKE"
    -if [ "${GUI:-0}" = "1" ]; then
    -  SMOKE_CMD="$ROOT/run-android-gui-smoke.sh"
    -  case "$OUT_DIR" in
    -    *-gui) ;;
    -    *) OUT_DIR="${OUT_DIR}-gui"; export OUT_DIR; mkdir -p "$OUT_DIR" ;;
    -  esac
    -fi
    -echo "=== smoke label=$LABEL PKG=$PKG GUI=${GUI:-0} OUT_DIR=$OUT_DIR ==="
    -# Do not exec: allow matrix wrapper to run multiple variants
    -set +e
    -env APK_DIR="$ROOT/apks" APK_NAME="$APK_NAME" APK_PATH="$APK_PATH" PKG="$PKG" OUT_DIR="$OUT_DIR" \
    -  "$SMOKE_CMD"
    -rc=$?
    -set -e
    -echo "=== smoke label=$LABEL exit=$rc ==="
    -exit "$rc"
    diff --git a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh b/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh
    deleted file mode 100755
    index a22f1b8..0000000
    --- a/scripts/taler-monitoring/android-test/run-android-gui-smoke.sh
    +++ /dev/null
    @@ -1,201 +0,0 @@
    -#!/usr/bin/env bash
    -# GUI-oriented Android smoke: **withdraw (Abheben)** then **pay**, each with
    -# deep-link entry + multi-round uiautomator taps. Not pay-only.
    -#
    -#   1) taler://withdraw/…  + Confirm/Abheben/ToS taps
    -#   2) taler://pay…        + Pay/Confirm taps
    -#
    -# See GUI-AUTOMATION-NOTES.md
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib_android_env.sh
    -. "$ROOT/lib_android_env.sh"
    -
    -[ "${AUTO_ANDROID}" = "1" ] || { echo "skipped: AUTO_ANDROID=${AUTO_ANDROID}"; exit 0; }
    -if [ "${AUTO_GUI}" != "1" ]; then
    -  echo "AUTO_GUI=0 → deep-link smoke only"
    -  exec env AUTO_ANDROID=1 "$ROOT/run-android-pay-smoke.sh"
    -fi
    -
    -export OUT_DIR="${OUT_DIR:-$ROOT/out-gui}"
    -export GUI_ROUNDS="${GUI_ROUNDS:-10}"
    -export GUI_SLEEP="${GUI_SLEEP:-3}"
    -
    -# Install + deliver intents via existing smoke, but skip its weak single-tap
    -# by setting GUI_MODE=1 which we implement here more fully.
    -# Simpler: do install/URI here by sourcing patterns from pay-smoke via env + python.
    -
    -# Delegate install + URI start to pay-smoke with GUI_AFTER=1 if we patch it;
    -# instead run a self-contained flow calling pay-smoke pieces.
    -
    -APK_DIR="${APK_DIR:-$ROOT/apks}"
    -APK_URL="${APK_URL:-https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}"
    -APK_NAME="${APK_NAME:-net.taler.wallet.fdroid_854.apk}"
    -APK_PATH="${APK_PATH:-}"
    -PKG="${PKG:-net.taler.wallet.fdroid}"
    -STACK="${STACK:-auto}"
    -SERIAL="${SERIAL:-}"
    -mkdir -p "$OUT_DIR"
    -
    -command -v adb >/dev/null || { echo "adb missing" >&2; exit 2; }
    -
    -# Headless default: no host window; guest GLES via SwiftShader (uiautomator).
    -android_ensure_device || { echo "no adb device (try ./start-android-emulator.sh --wait)" >&2; exit 3; }
    -ADB=(adb -s "$SERIAL")
    -echo "device: $SERIAL  mode: GUI (uiautomator) headless=${EMULATOR_HEADLESS} gpu=${EMULATOR_GPU}"
    -
    -case "$STACK" in
    -  auto)
    -    if curl -sfS -m 5 -o /dev/null https://bank.hacktivism.ch/config; then STACK=goa; else STACK=stage; fi
    -    ;;
    -esac
    -case "$STACK" in
    -  goa)
    -    BANK=https://bank.hacktivism.ch
    -    MERCHANT=https://taler.hacktivism.ch
    -    WITHDRAW_JSON="$BANK/intro/demo-withdraw.json"
    -    ;;
    -  stage)
    -    BANK=https://stage.bank.lefrancpaysan.ch
    -    MERCHANT=https://stage.monnaie.lefrancpaysan.ch
    -    WITHDRAW_JSON="$BANK/intro/demo-withdraw.json"
    -    ;;
    -  *) echo "unknown STACK" >&2; exit 2 ;;
    -esac
    -echo "stack: $STACK"
    -
    -if [ -n "$APK_PATH" ] && [ -f "$APK_PATH" ]; then
    -  :
    -elif [ -f "$APK_DIR/$APK_NAME" ]; then
    -  APK_PATH="$APK_DIR/$APK_NAME"
    -else
    -  APK_PATH="$APK_DIR/$APK_NAME"
    -  mkdir -p "$APK_DIR"
    -  curl -fL --retry 3 -o "$APK_PATH" "$APK_URL"
    -fi
    -echo "apk: $APK_PATH  pkg: $PKG"
    -
    -"${ADB[@]}" wait-for-device
    -"${ADB[@]}" install -r "$APK_PATH"
    -"${ADB[@]}" shell settings put global window_animation_scale 0 || true
    -"${ADB[@]}" shell settings put global transition_animation_scale 0 || true
    -"${ADB[@]}" shell settings put global animator_duration_scale 0 || true
    -
    -: "${DO_WITHDRAW:=1}"
    -: "${DO_PAY:=1}"
    -: "${SMOKE_STRICT:=0}"
    -
    -WURI=""
    -PAY_URI=""
    -WITHDRAW_OK=0
    -PAY_OK=0
    -
    -"${ADB[@]}" shell am force-stop "$PKG" 2>/dev/null || true
    -"${ADB[@]}" logcat -c 2>/dev/null || true
    -
    -if [ "$DO_WITHDRAW" = "1" ]; then
    -  echo
    -  echo "======== 1 WITHDRAW (Abheben) ========"
    -  WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")')
    -  [ -n "$WURI" ] || { echo "no withdraw uri" >&2; exit 4; }
    -  echo "withdraw: $WURI"
    -  echo "$WURI" >"$OUT_DIR/last-withdraw.uri"
    -  "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" | tee "$OUT_DIR/start-withdraw.txt"
    -  sleep 5
    -  python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY'
    -import json, sys, importlib.util
    -from pathlib import Path
    -lib_path, serial, out, rounds, sleep_s = (
    -    Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5])
    -)
    -spec = importlib.util.spec_from_file_location("lib_ui", lib_path)
    -lib = importlib.util.module_from_spec(spec)
    -spec.loader.exec_module(lib)
    -st = lib.gui_drive(serial, out / "gui-withdraw", rounds=rounds, sleep_s=sleep_s, phase="wd")
    -(out / "gui-withdraw-status.json").write_text(json.dumps(st, indent=2))
    -print("gui-withdraw taps:", st.get("taps"))
    -print("gui-withdraw anr:", st.get("anr_dismissals"))
    -PY
    -else
    -  echo "======== 1 WITHDRAW skipped (DO_WITHDRAW=0) ========"
    -fi
    -
    -if [ "$DO_PAY" = "1" ]; then
    -  echo
    -  echo "======== 2 PAY (Bezahlen) ========"
    -  if [ "$STACK" = "stage" ]; then
    -    if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \
    -      "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then
    -      PAY_URI=$(printf '%s' "$RESP" | python3 -c 'import json,sys;d=json.load(sys.stdin);oid=d.get("order_id")or"";tok=d.get("token")or"";
    -print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok) if oid and tok else "")')
    -    fi
    -  else
    -    PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana"
    -  fi
    -  if [ -n "$PAY_URI" ]; then
    -    echo "pay: $PAY_URI"
    -    echo "$PAY_URI" >"$OUT_DIR/last-pay.uri"
    -    "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" | tee "$OUT_DIR/start-pay.txt"
    -    sleep 5
    -    python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$GUI_ROUNDS" "$GUI_SLEEP" <<'PY'
    -import json, sys, importlib.util
    -from pathlib import Path
    -lib_path, serial, out, rounds, sleep_s = (
    -    Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5])
    -)
    -spec = importlib.util.spec_from_file_location("lib_ui", lib_path)
    -lib = importlib.util.module_from_spec(spec)
    -spec.loader.exec_module(lib)
    -st = lib.gui_drive(serial, out / "gui-pay", rounds=rounds, sleep_s=sleep_s, phase="pay")
    -(out / "gui-pay-status.json").write_text(json.dumps(st, indent=2))
    -print("gui-pay taps:", st.get("taps"))
    -print("gui-pay anr:", st.get("anr_dismissals"))
    -PY
    -  else
    -    echo "pay: (no URI)"
    -  fi
    -else
    -  echo "======== 2 PAY skipped (DO_PAY=0) ========"
    -fi
    -
    -"${ADB[@]}" logcat -d -t 500 >"$OUT_DIR/logcat.txt" || true
    -grep -iE 'taler-wallet|prepareBank|acceptWithdrawal|confirmWithdrawal|preparePay|confirmPay|Error|success|withdraw|pay' \
    -  "$OUT_DIR/logcat.txt" | tail -100 >"$OUT_DIR/logcat-taler.txt" || true
    -
    -if [ "$DO_WITHDRAW" = "1" ] && grep -qE 'prepareBankIntegratedWithdrawal|acceptWithdrawal|confirmWithdrawal' \
    -    "$OUT_DIR/logcat.txt" 2>/dev/null; then
    -  WITHDRAW_OK=1
    -fi
    -if [ "$DO_PAY" = "1" ] && [ -n "$PAY_URI" ] && \
    -    grep -qiE 'preparePay|confirmPay|preparePurchase|checkPay|pay-template|PayForTemplate' \
    -    "$OUT_DIR/logcat.txt" 2>/dev/null; then
    -  PAY_OK=1
    -fi
    -
    -rc=0
    -echo
    -echo "=== GUI smoke summary ($STACK) — withdraw + pay ==="
    -echo "package: $PKG  DO_WITHDRAW=$DO_WITHDRAW DO_PAY=$DO_PAY STRICT=$SMOKE_STRICT"
    -if [ "$DO_WITHDRAW" = "1" ]; then
    -  echo "withdraw URI: delivered → $OUT_DIR/gui-withdraw-status.json"
    -  echo "withdraw wallet-core: $([ "$WITHDRAW_OK" = 1 ] && echo YES || echo NO)"
    -  if [ "$SMOKE_STRICT" = "1" ] && [ "$WITHDRAW_OK" != "1" ]; then
    -    echo "FAIL: strict withdraw missing"; rc=11
    -  fi
    -fi
    -if [ "$DO_PAY" = "1" ]; then
    -  echo "pay URI: ${PAY_URI:-none} → $OUT_DIR/gui-pay-status.json"
    -  echo "pay wallet-core: $([ "$PAY_OK" = 1 ] && echo YES || echo NO)"
    -  if [ "$SMOKE_STRICT" = "1" ] && [ "$PAY_OK" != "1" ]; then
    -    echo "FAIL: strict pay missing"; [ "$rc" -eq 0 ] && rc=13
    -  fi
    -fi
    -if grep -qiE "isn't responding|not responding" "$OUT_DIR/logcat.txt" 2>/dev/null \
    -  || grep -rqiE "isn't responding" "$OUT_DIR/gui-withdraw" "$OUT_DIR/gui-pay" 2>/dev/null; then
    -  echo "FAIL: ANR"; rc=10
    -fi
    -echo "artifacts: $OUT_DIR/"
    -ls -la "$OUT_DIR" | sed 's/^/  /'
    -echo "Documented shortcuts → GUI-AUTOMATION-NOTES.md"
    -exit "$rc"
    diff --git a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh b/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh
    deleted file mode 100755
    index 48ec49f..0000000
    --- a/scripts/taler-monitoring/android-test/run-android-pay-smoke.sh
    +++ /dev/null
    @@ -1,308 +0,0 @@
    -#!/usr/bin/env bash
    -# Android wallet smoke: install APK, then **withdraw (Abheben)** and **pay** via
    -# deep-link + best-effort UI taps. Both legs are first-class (not pay-only).
    -#
    -# Flow (default both on):
    -#   1) WITHDRAW  taler://withdraw/…  from bank demo-withdraw.json  + confirm taps
    -#   2) PAY       taler://pay… / pay-template  + optional taps
    -#
    -# Env:
    -#   DO_WITHDRAW=1 (default)  DO_PAY=1 (default)
    -#   SMOKE_STRICT=1 → require wallet-core withdraw (+ pay if DO_PAY) + no ANR
    -#   WITHDRAW_GUI_ROUNDS  multi-round Abheben taps (default 6)
    -#
    -# Usage:
    -#   ./android-test/run-android-pay-smoke.sh
    -#   STACK=goa ./android-test/run-android-pay-smoke.sh
    -#   DO_PAY=0 ./android-test/run-android-pay-smoke.sh   # withdraw-only
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib_android_env.sh
    -. "$ROOT/lib_android_env.sh"
    -
    -if [ "${AUTO_ANDROID}" != "1" ]; then
    -  echo "skipped: AUTO_ANDROID=${AUTO_ANDROID} (platform flags — see GUI-AUTOMATION-NOTES.md)"
    -  exit 0
    -fi
    -
    -APK_DIR="${APK_DIR:-$ROOT/apks}"
    -APK_URL="${APK_URL:-https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}"
    -APK_NAME="${APK_NAME:-net.taler.wallet.fdroid_854.apk}"
    -# Optional override: full path to an already-built APK (from-source)
    -APK_PATH="${APK_PATH:-}"
    -PKG="${PKG:-net.taler.wallet.fdroid}"
    -STACK="${STACK:-auto}"   # auto | goa | stage
    -SERIAL="${SERIAL:-}"
    -OUT_DIR="${OUT_DIR:-$ROOT/out}"
    -mkdir -p "$APK_DIR" "$OUT_DIR"
    -
    -if ! command -v adb >/dev/null 2>&1; then
    -  echo "adb missing — install android-tools-adb or user SDK platform-tools" >&2
    -  exit 2
    -fi
    -
    -# Default: headless AVD (no host window). WINDOWED=1 / EMULATOR_HEADLESS=0 for UI.
    -if ! android_ensure_device; then
    -  cat >&2 </dev/null; then
    -      STACK=goa
    -    else
    -      STACK=stage
    -    fi
    -    ;;
    -esac
    -case "$STACK" in
    -  goa)
    -    BANK=https://bank.hacktivism.ch
    -    MERCHANT=https://taler.hacktivism.ch
    -    WITHDRAW_JSON="$BANK/intro/demo-withdraw.json"
    -    PAY_HINT="paivana template / goa-shop"
    -    ;;
    -  stage)
    -    BANK=https://stage.bank.lefrancpaysan.ch
    -    MERCHANT=https://stage.monnaie.lefrancpaysan.ch
    -    WITHDRAW_JSON="$BANK/intro/demo-withdraw.json"
    -    PAY_HINT="farmer shop templates"
    -    ;;
    -  *) echo "unknown STACK=$STACK" >&2; exit 2 ;;
    -esac
    -echo "stack: $STACK  bank: $BANK"
    -
    -# APK: explicit path, or download published F-Droid into APK_DIR
    -if [ -n "$APK_PATH" ] && [ -f "$APK_PATH" ]; then
    -  :
    -elif [ -f "$APK_DIR/$APK_NAME" ]; then
    -  APK_PATH="$APK_DIR/$APK_NAME"
    -else
    -  APK_PATH="$APK_DIR/$APK_NAME"
    -  echo "downloading published wallet APK…"
    -  curl -fL --retry 3 -o "$APK_PATH" "$APK_URL"
    -fi
    -echo "apk: $APK_PATH ($(wc -c <"$APK_PATH") bytes)"
    -echo "pkg: $PKG"
    -
    -: "${DO_WITHDRAW:=1}"
    -: "${DO_PAY:=1}"
    -: "${WITHDRAW_GUI_ROUNDS:=6}"
    -: "${PAY_GUI_ROUNDS:=4}"
    -: "${SMOKE_STRICT:=0}"
    -
    -"${ADB[@]}" wait-for-device
    -"${ADB[@]}" install -r "$APK_PATH"
    -"${ADB[@]}" shell settings put global window_animation_scale 0 || true
    -"${ADB[@]}" shell settings put global transition_animation_scale 0 || true
    -"${ADB[@]}" shell settings put global animator_duration_scale 0 || true
    -
    -"${ADB[@]}" shell am force-stop "$PKG" 2>/dev/null || true
    -"${ADB[@]}" logcat -c 2>/dev/null || true
    -
    -WURI=""
    -PAY_URI=""
    -WITHDRAW_OK=0
    -PAY_OK=0
    -
    -# ═══════════════════════════════════════════════════════════════
    -# 1) WITHDRAW / Abheben  (required by default — not pay-only)
    -# ═══════════════════════════════════════════════════════════════
    -if [ "$DO_WITHDRAW" = "1" ]; then
    -  echo
    -  echo "======== 1 WITHDRAW (Abheben) ========"
    -  WURI=$(curl -sfS -m 25 "$WITHDRAW_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("taler_withdraw_uri") or d.get("qr_payload") or "")')
    -  if [ -z "$WURI" ]; then
    -    echo "FAIL: no taler_withdraw_uri from $WITHDRAW_JSON" >&2
    -    exit 4
    -  fi
    -  echo "withdraw URI: $WURI"
    -  echo "$WURI" >"$OUT_DIR/last-withdraw.uri"
    -
    -  "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$WURI" 2>&1 | tee "$OUT_DIR/start-withdraw.txt"
    -  sleep 6
    -  "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/01-after-withdraw-uri.png" || true
    -  "${ADB[@]}" shell uiautomator dump /sdcard/ui.xml 2>/dev/null || true
    -  "${ADB[@]}" pull /sdcard/ui.xml "$OUT_DIR/01-ui.xml" 2>/dev/null || true
    -
    -  # Multi-round confirm / Abheben / ToS (same idea as GUI smoke)
    -  if [ -f "$ROOT/lib_ui.py" ]; then
    -    python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$WITHDRAW_GUI_ROUNDS" 2 <<'PY' || true
    -import json, sys, importlib.util
    -from pathlib import Path
    -lib_path, serial, out, rounds, sleep_s = (
    -    Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5])
    -)
    -spec = importlib.util.spec_from_file_location("lib_ui", lib_path)
    -lib = importlib.util.module_from_spec(spec)
    -spec.loader.exec_module(lib)
    -st = lib.gui_drive(serial, out / "gui-withdraw", rounds=rounds, sleep_s=sleep_s, phase="wd")
    -(out / "gui-withdraw-status.json").write_text(json.dumps(st, indent=2))
    -print("withdraw GUI taps:", st.get("taps"), "anr:", st.get("anr_dismissals"))
    -PY
    -  else
    -    python3 - "$SERIAL" "$OUT_DIR/01-ui.xml" <<'PY' || true
    -import re, subprocess, sys
    -serial, path = sys.argv[1], sys.argv[2]
    -try:
    -    xml = open(path, errors="replace").read()
    -except FileNotFoundError:
    -    raise SystemExit(0)
    -labels = [
    -    "Confirm", "Confirm withdrawal", "Withdraw", "Accept", "Continue", "OK",
    -    "I accept", "Agree", "Bestätigen", "Abheben", "Akzeptieren", "Retirer",
    -]
    -for label in labels:
    -    for pat in (
    -        r'text="%s"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' % re.escape(label),
    -        r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*text="%s"' % re.escape(label),
    -    ):
    -        for m in re.finditer(pat, xml, flags=re.I):
    -            x1, y1, x2, y2 = map(int, m.groups())
    -            x, y = (x1 + x2) // 2, (y1 + y2) // 2
    -            print(f"tap {label} @ {x},{y}")
    -            subprocess.run(["adb", "-s", serial, "shell", "input", "tap", str(x), str(y)], check=False)
    -PY
    -    sleep 8
    -  fi
    -  "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/02-after-withdraw-taps.png" || true
    -  # Mid-flow logcat: withdraw must register before pay
    -  "${ADB[@]}" logcat -d -t 400 >"$OUT_DIR/logcat-after-withdraw.txt" || true
    -  if grep -qE 'prepareBankIntegratedWithdrawal|acceptWithdrawal|confirmWithdrawal' \
    -      "$OUT_DIR/logcat-after-withdraw.txt" 2>/dev/null; then
    -    WITHDRAW_OK=1
    -    echo "withdraw: wallet-core activity YES"
    -  else
    -    echo "withdraw: wallet-core activity NOT seen yet (onboarding/ANR/slow?)"
    -  fi
    -else
    -  echo "======== 1 WITHDRAW skipped (DO_WITHDRAW=0) ========"
    -fi
    -
    -# ═══════════════════════════════════════════════════════════════
    -# 2) PAY  (after withdraw; still first-class, but second leg)
    -# ═══════════════════════════════════════════════════════════════
    -if [ "$DO_PAY" = "1" ]; then
    -  echo
    -  echo "======== 2 PAY (Bezahlen) ========"
    -  if [ "$STACK" = "stage" ]; then
    -    if RESP=$(curl -sfS -m 20 -X POST -H 'Content-Type: application/json' -d '{}' \
    -      "$MERCHANT/instances/fermes-des-collines/templates/panier-legumes" 2>/dev/null); then
    -      PAY_URI=$(printf '%s' "$RESP" | python3 -c '
    -import json,sys
    -d=json.load(sys.stdin)
    -oid=d.get("order_id") or ""
    -tok=d.get("token") or ""
    -if oid and tok:
    -  print("taler://pay/stage.monnaie.lefrancpaysan.ch/instances/fermes-des-collines/%s/?c=%s"%(oid,tok))
    -' 2>/dev/null || true)
    -    fi
    -  elif [ "$STACK" = "goa" ]; then
    -    PAY_URI="taler://pay-template/taler.hacktivism.ch/instances/goa-shop/paivana"
    -  fi
    -
    -  if [ -n "$PAY_URI" ]; then
    -    echo "pay URI: $PAY_URI  ($PAY_HINT)"
    -    echo "$PAY_URI" >"$OUT_DIR/last-pay.uri"
    -    "${ADB[@]}" shell am start -a android.intent.action.VIEW -d "$PAY_URI" 2>&1 | tee "$OUT_DIR/start-pay.txt"
    -    sleep 6
    -    if [ -f "$ROOT/lib_ui.py" ]; then
    -      python3 - "$ROOT/lib_ui.py" "$SERIAL" "$OUT_DIR" "$PAY_GUI_ROUNDS" 2 <<'PY' || true
    -import json, sys, importlib.util
    -from pathlib import Path
    -lib_path, serial, out, rounds, sleep_s = (
    -    Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5])
    -)
    -spec = importlib.util.spec_from_file_location("lib_ui", lib_path)
    -lib = importlib.util.module_from_spec(spec)
    -spec.loader.exec_module(lib)
    -st = lib.gui_drive(serial, out / "gui-pay", rounds=rounds, sleep_s=sleep_s, phase="pay")
    -(out / "gui-pay-status.json").write_text(json.dumps(st, indent=2))
    -print("pay GUI taps:", st.get("taps"), "anr:", st.get("anr_dismissals"))
    -PY
    -    fi
    -    "${ADB[@]}" exec-out screencap -p >"$OUT_DIR/03-after-pay-uri.png" || true
    -  else
    -    echo "pay: (no public template URI for stack=$STACK)"
    -  fi
    -else
    -  echo "======== 2 PAY skipped (DO_PAY=0) ========"
    -fi
    -
    -# Final evidence
    -"${ADB[@]}" logcat -d -t 500 >"$OUT_DIR/logcat.txt" || true
    -grep -iE 'taler-wallet|prepareBank|acceptWithdrawal|confirmWithdrawal|preparePay|confirmPay|error|Error|success|withdraw|pay' \
    -  "$OUT_DIR/logcat.txt" | tail -100 >"$OUT_DIR/logcat-taler.txt" || true
    -
    -# Re-evaluate withdraw/pay from full logcat
    -if [ "$DO_WITHDRAW" = "1" ]; then
    -  if grep -qE 'prepareBankIntegratedWithdrawal|acceptWithdrawal|confirmWithdrawal' \
    -      "$OUT_DIR/logcat.txt" 2>/dev/null; then
    -    WITHDRAW_OK=1
    -  fi
    -fi
    -if [ "$DO_PAY" = "1" ] && [ -n "$PAY_URI" ]; then
    -  if grep -qiE 'preparePay|confirmPay|preparePurchase|checkPay|pay-template|PayForTemplate' \
    -      "$OUT_DIR/logcat.txt" 2>/dev/null; then
    -    PAY_OK=1
    -  fi
    -fi
    -
    -rc=0
    -echo
    -echo "=== summary (withdraw + pay) ==="
    -echo "package: $PKG  stack: $STACK"
    -echo "DO_WITHDRAW=$DO_WITHDRAW  DO_PAY=$DO_PAY  SMOKE_STRICT=$SMOKE_STRICT"
    -if [ "$DO_WITHDRAW" = "1" ]; then
    -  echo "withdraw URI: ${WURI:-(none)}"
    -  if [ "$WITHDRAW_OK" = "1" ]; then
    -    echo "withdraw wallet-core: YES"
    -  else
    -    echo "withdraw wallet-core: NO"
    -    if [ "$SMOKE_STRICT" = "1" ]; then
    -      echo "FAIL: SMOKE_STRICT requires withdraw (prepareBank/accept/confirm) in logcat"
    -      rc=11
    -    fi
    -  fi
    -fi
    -if [ "$DO_PAY" = "1" ]; then
    -  echo "pay URI: ${PAY_URI:-(none)}"
    -  if [ -z "$PAY_URI" ]; then
    -    echo "pay: no URI (template/API)"
    -    if [ "$SMOKE_STRICT" = "1" ]; then
    -      echo "FAIL: SMOKE_STRICT requires a pay URI when DO_PAY=1"
    -      rc=12
    -    fi
    -  elif [ "$PAY_OK" = "1" ]; then
    -    echo "pay wallet-core: YES"
    -  else
    -    echo "pay wallet-core: NO (intent may still have been delivered)"
    -    if [ "$SMOKE_STRICT" = "1" ]; then
    -      echo "FAIL: SMOKE_STRICT requires pay path activity in logcat"
    -      [ "$rc" -eq 0 ] && rc=13
    -    fi
    -  fi
    -fi
    -if grep -qiE "isn't responding|isn.t responding|System UI isn" "$OUT_DIR/01-ui.xml" 2>/dev/null \
    -  || grep -qiE "isn't responding|not responding" "$OUT_DIR/logcat.txt" 2>/dev/null \
    -  || grep -rqiE "isn't responding" "$OUT_DIR/gui-withdraw" 2>/dev/null; then
    -  echo "FAIL: System UI / app ANR"
    -  rc=10
    -fi
    -echo "artifacts: $OUT_DIR/"
    -ls -la "$OUT_DIR" | sed 's/^/  /'
    -echo
    -echo "NOTE: Full unattended Abheben+Bezahlen needs responsive device (≥6–8 GiB host RAM)."
    -echo "Smoke legs: withdraw=$( [ "$DO_WITHDRAW" = 1 ] && echo on || echo off) pay=$( [ "$DO_PAY" = 1 ] && echo on || echo off) rc=$rc"
    -exit "$rc"
    diff --git a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh b/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh
    deleted file mode 100755
    index 54fe5dc..0000000
    --- a/scripts/taler-monitoring/android-test/run-android-variant-matrix.sh
    +++ /dev/null
    @@ -1,322 +0,0 @@
    -#!/usr/bin/env bash
    -# Run Android wallet smoke against several build / published variants.
    -#
    -# Default order:
    -#   1) stable-self — **only** self-build of current published stable (WARN)
    -#   2) published   — current F-Droid APK
    -#   3) older       — F-Droid APKs ~3/6/9/12 mo if still hosted (WARN, no self-build)
    -#   4) master      — tip self-build (WARN)
    -#
    -# Self-build policy: current stable + master/fix only — never rebuild old releases.
    -#
    -# Severity: age ≤14d → BLOCKER; milestones/older/self-builds → WARN
    -#
    -# Env:
    -#   STABLE_TAG, STABLE_APK_URL, STABLE_APK_NAME
    -#   MASTER_BRANCH, FIX_BRANCH
    -#   VARIANTS          default: stable-self,published,older,master
    -#   INCLUDE_OLDER=1   expand token "older" / "milestones" (default 1)
    -#   MILESTONE_DAYS    default "90 180 270 365" (≈ 3,6,9,12 months)
    -#   MILESTONE_TOLERANCE_DAYS  default 50 — max distance to accept a tag
    -#   BLOCKER_MAX_AGE_DAYS  default 14
    -#   PUBLISHED_FORCE_BLOCKER=1  always treat current published as blocker
    -#   FAIL_FAST=1, GUI=0, STACK=…
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib_android_env.sh
    -. "$ROOT/lib_android_env.sh"
    -# shellcheck source=lib_release_age.sh
    -. "$ROOT/lib_release_age.sh"
    -
    -: "${STABLE_TAG:=wallet-1.6.1}"
    -: "${STABLE_APK_URL:=https://f-droid.org/repo/net.taler.wallet.fdroid_854.apk}"
    -: "${STABLE_APK_NAME:=net.taler.wallet.fdroid_854.apk}"
    -: "${MASTER_BRANCH:=master}"
    -: "${FIX_BRANCH:=dev/hernani-inference/fix-bank-withdraw-auto-exchange}"
    -: "${VARIANTS:=stable-self,published,older,master}"
    -: "${INCLUDE_OLDER:=1}"
    -: "${MILESTONE_DAYS:=90 180 270 365}"
    -: "${MILESTONE_TOLERANCE_DAYS:=50}"
    -: "${BLOCKER_MAX_AGE_DAYS:=14}"
    -: "${PUBLISHED_FORCE_BLOCKER:=0}"
    -: "${FAIL_FAST:=1}"
    -: "${GUI:=0}"
    -: "${STACK:=auto}"
    -: "${TALER_ANDROID_SRC:=$HOME/taler/taler-android}"
    -export TALER_ANDROID_SRC BLOCKER_MAX_AGE_DAYS MILESTONE_DAYS MILESTONE_TOLERANCE_DAYS
    -
    -TS=$(date +%Y%m%d-%H%M%S)
    -MATRIX_OUT="${MATRIX_OUT:-$ROOT/out-matrix-$TS}"
    -mkdir -p "$MATRIX_OUT"
    -SUMMARY="$MATRIX_OUT/SUMMARY.txt"
    -: >"$SUMMARY"
    -
    -echo "=== Android variant matrix ===" | tee -a "$SUMMARY"
    -echo "STACK=$STACK  GUI=$GUI  headless=${EMULATOR_HEADLESS}  variants=$VARIANTS" | tee -a "$SUMMARY"
    -echo "STABLE_TAG=$STABLE_TAG  BLOCKER_MAX_AGE_DAYS=$BLOCKER_MAX_AGE_DAYS" | tee -a "$SUMMARY"
    -echo "milestones(days)=$MILESTONE_DAYS  tolerance=$MILESTONE_TOLERANCE_DAYS (always WARN)" | tee -a "$SUMMARY"
    -echo "out: $MATRIX_OUT" | tee -a "$SUMMARY"
    -
    -android_ensure_device || {
    -  echo "no adb device — start ./start-android-emulator.sh --wait" >&2
    -  exit 3
    -}
    -export SERIAL ANDROID_SERIAL
    -echo "device: $SERIAL" | tee -a "$SUMMARY"
    -
    -# --- expand VARIANTS (token "older" → concrete older-pub / older-self entries) ---
    -# Append one older release as WARN-only — **F-Droid APK only** (no self-build).
    -# Returns 0 if queued, 1 if skipped (no published APK).
    -append_older_warn() {
    -  local t="$1" age="$2" target="${3:-}"
    -  local vn meta apk_vc apk_url
    -  vn="${t#wallet-}"
    -  if [ -n "$target" ]; then
    -    echo "  milestone ~${target}d → $t (age=${age}d) severity=WARN" | tee -a "$SUMMARY"
    -  else
    -    echo "  older $t (age=${age}d) severity=WARN" | tee -a "$SUMMARY"
    -  fi
    -  meta=$(fdroid_apk_for_version_name "$vn" 2>/dev/null || true)
    -  if [ -n "$meta" ]; then
    -    apk_vc="${meta%%|*}"
    -    apk_url="${meta#*|}"
    -    if fdroid_apk_exists "$apk_url"; then
    -      out+=("older-pub:${vn}:${apk_vc}:warn")
    -      return 0
    -    fi
    -    echo "  SKIP $t — no F-Droid APK at ${apk_url:-?} (self-build only for current stable)" | tee -a "$SUMMARY"
    -  else
    -    echo "  SKIP $t — cannot map F-Droid versionCode (self-build only for current stable)" | tee -a "$SUMMARY"
    -  fi
    -  return 1
    -}
    -
    -expand_variants() {
    -  local raw="$1"
    -  local -a out=()
    -  local v tag vn age sev line t target
    -
    -  IFS=',' read -r -a toks <<<"$raw"
    -  for v in "${toks[@]}"; do
    -    v=$(echo "$v" | tr -d '[:space:]')
    -    [ -n "$v" ] || continue
    -    if [ "$v" = "older" ] || [ "$v" = "older-releases" ] || [ "$v" = "milestones" ]; then
    -      [ "$INCLUDE_OLDER" = "1" ] || continue
    -      # 3/6/9/12-month samples: published APK only, always WARN.
    -      local found=0 queued=0
    -      while read -r t age target; do
    -        [ -n "$t" ] || continue
    -        [ "$t" = "$STABLE_TAG" ] && continue
    -        found=1
    -        if append_older_warn "$t" "$age" "$target"; then
    -          queued=$((queued + 1))
    -        fi
    -      done < <(select_milestone_tags "$STABLE_TAG" 2>/dev/null || true)
    -      if [ "$found" = "0" ]; then
    -        echo "  (no milestone tags within ±${MILESTONE_TOLERANCE_DAYS}d of $MILESTONE_DAYS)" | tee -a "$SUMMARY"
    -      elif [ "$queued" = "0" ]; then
    -        echo "  (milestone tags found but none still on F-Droid — no older APK smokes)" | tee -a "$SUMMARY"
    -      fi
    -    else
    -      out+=("$v")
    -    fi
    -  done
    -  printf '%s\n' "${out[@]}"
    -}
    -
    -mapfile -t VLIST < <(expand_variants "$VARIANTS")
    -echo "expanded variants: ${VLIST[*]}" | tee -a "$SUMMARY"
    -
    -declare -a RESULTS=()
    -declare -a WARNINGS=()
    -overall=0
    -
    -severity_for_published_current() {
    -  if [ "${PUBLISHED_FORCE_BLOCKER}" = "1" ]; then
    -    echo blocker
    -    return
    -  fi
    -  local days
    -  days=$(release_age_days "$STABLE_TAG" 2>/dev/null || true)
    -  severity_from_age_days "$days"
    -}
    -
    -run_published_apk() {
    -  local label="$1"
    -  local apk_url="$2"
    -  local apk_name="$3"
    -  local sev="$4"
    -  local out="$MATRIX_OUT/$label"
    -  mkdir -p "$out"
    -  echo "" | tee -a "$SUMMARY"
    -  echo "-------- variant: $label (F-Droid APK) [$(echo "$sev" | tr 'a-z' 'A-Z')] --------" | tee -a "$SUMMARY"
    -  echo "  url=$apk_url" | tee -a "$SUMMARY"
    -  local smoke="$ROOT/run-android-pay-smoke.sh"
    -  [ "$GUI" = "1" ] && smoke="$ROOT/run-android-gui-smoke.sh"
    -  local strict=1
    -  [ "$sev" = "warn" ] && strict="${SMOKE_STRICT_WARN:-0}"
    -  [ "$sev" = "blocker" ] && strict="${SMOKE_STRICT:-1}"
    -  set +e
    -  env STACK="$STACK" \
    -    APK_URL="$apk_url" \
    -    APK_NAME="$apk_name" \
    -    APK_PATH="" \
    -    PKG=net.taler.wallet.fdroid \
    -    OUT_DIR="$out" \
    -    SERIAL="$SERIAL" \
    -    AUTO_START_EMULATOR=0 \
    -    SMOKE_STRICT="$strict" \
    -    "$smoke"
    -  local rc=$?
    -  set -e
    -  echo "$label exit=$rc severity=$sev" | tee -a "$SUMMARY"
    -  echo "$label $rc $sev $apk_url" >>"$MATRIX_OUT/results.tsv"
    -  return "$rc"
    -}
    -
    -run_selfbuild() {
    -  local label="$1"
    -  local ref="$2"
    -  local sev="${3:-warn}"
    -  local out="$MATRIX_OUT/$label"
    -  mkdir -p "$out"
    -  echo "" | tee -a "$SUMMARY"
    -  echo "-------- variant: $label (self-build ref=$ref) [$(echo "$sev" | tr 'a-z' 'A-Z')] --------" | tee -a "$SUMMARY"
    -  local strict="${SMOKE_STRICT:-1}"
    -  [ "$sev" = "warn" ] && strict="${SMOKE_STRICT_SELF:-0}"
    -  [ "$sev" = "blocker" ] && strict="${SMOKE_STRICT:-1}"
    -  set +e
    -  env STACK="$STACK" \
    -    BRANCH="$ref" \
    -    LABEL="$label" \
    -    GUI="$GUI" \
    -    OUT_DIR="$out" \
    -    SERIAL="$SERIAL" \
    -    ANDROID_SERIAL="$SERIAL" \
    -    AUTO_START_EMULATOR=0 \
    -    PKG=net.taler.wallet.fdroid.debug \
    -    SMOKE_STRICT="$strict" \
    -    TALER_ANDROID_SRC="$TALER_ANDROID_SRC" \
    -    "$ROOT/run-android-build-and-smoke.sh"
    -  local rc=$?
    -  set -e
    -  echo "$label (ref=$ref) exit=$rc severity=$sev" | tee -a "$SUMMARY"
    -  echo "$label $rc $ref $sev" >>"$MATRIX_OUT/results.tsv"
    -  if [ -f "$out/build-meta.txt" ]; then
    -    echo "build-meta:" | tee -a "$SUMMARY"
    -    cat "$out/build-meta.txt" | tee -a "$SUMMARY"
    -  fi
    -  return "$rc"
    -}
    -
    -record_result() {
    -  local v="$1" rc="$2" sev="$3"
    -  if [ "$rc" -eq 0 ]; then
    -    RESULTS+=("$v:OK($sev)")
    -    return
    -  fi
    -  case "$sev" in
    -    warn)
    -      echo "WARN: $v failed rc=$rc — not a blocker; continuing" | tee -a "$SUMMARY"
    -      WARNINGS+=("$v:$rc")
    -      RESULTS+=("$v:WARN($rc)")
    -      ;;
    -    blocker)
    -      echo "BLOCKER: $v failed rc=$rc (release within ${BLOCKER_MAX_AGE_DAYS}d window)" | tee -a "$SUMMARY"
    -      RESULTS+=("$v:BLOCKER($rc)")
    -      overall=1
    -      if [ "$FAIL_FAST" = "1" ]; then
    -        echo "FAIL_FAST: stopping after blocker $v" | tee -a "$SUMMARY"
    -        return 2
    -      fi
    -      ;;
    -    soft)
    -      echo "FAIL: $v rc=$rc" | tee -a "$SUMMARY"
    -      RESULTS+=("$v:FAIL($rc)")
    -      overall=1
    -      if [ "$FAIL_FAST" = "1" ]; then
    -        echo "FAIL_FAST: stopping after $v" | tee -a "$SUMMARY"
    -        return 2
    -      fi
    -      ;;
    -  esac
    -  return 0
    -}
    -
    -for raw in "${VLIST[@]}"; do
    -  v=$(echo "$raw" | tr -d '[:space:]')
    -  [ -n "$v" ] || continue
    -  rc=0
    -  sev=warn
    -  stop=0
    -
    -  case "$v" in
    -    published|fdroid|stable-published)
    -      sev=$(severity_for_published_current)
    -      age=$(release_age_days "$STABLE_TAG" 2>/dev/null || echo "?")
    -      echo "published current: tag=$STABLE_TAG age_days=$age → severity=$sev" | tee -a "$SUMMARY"
    -      run_published_apk "published" "$STABLE_APK_URL" "$STABLE_APK_NAME" "$sev" || rc=$?
    -      ;;
    -    stable-self|stable|self-stable|release-self)
    -      # rebuild of stable: always warn (toolchain), age does not make self-build a blocker
    -      sev=warn
    -      run_selfbuild "stable-self" "$STABLE_TAG" warn || rc=$?
    -      ;;
    -    master|main)
    -      sev=warn
    -      run_selfbuild "master" "$MASTER_BRANCH" warn || rc=$?
    -      ;;
    -    fix|fix-branch|inference)
    -      sev=soft
    -      run_selfbuild "fix" "$FIX_BRANCH" soft || rc=$?
    -      ;;
    -    older-pub:*)
    -      # older-pub:VERSION:VC:SEV
    -      IFS=':' read -r _ vn apk_vc sev <<<"$v"
    -      : "${sev:=warn}"
    -      label="older-pub-${vn}"
    -      url="https://f-droid.org/repo/net.taler.wallet.fdroid_${apk_vc}.apk"
    -      name="net.taler.wallet.fdroid_${apk_vc}.apk"
    -      age=$(release_age_days "wallet-${vn}" 2>/dev/null || echo "?")
    -      echo "older published $vn age_days=$age severity=$sev" | tee -a "$SUMMARY"
    -      run_published_apk "$label" "$url" "$name" "$sev" || rc=$?
    -      v="$label"
    -      ;;
    -    older-self:*)
    -      # Explicit older-self is disabled by policy (self-build = current stable only).
    -      echo "SKIP $v — self-build only for current stable ($STABLE_TAG); use older-pub or published" | tee -a "$SUMMARY"
    -      RESULTS+=("$v:SKIP(no-selfbuild-older)")
    -      continue
    -      ;;
    -    *)
    -      # raw git ref: only master-like / fix via named tokens; bare wallet-* tags → no self-build
    -      if [[ "$v" =~ ^wallet-[0-9] ]]; then
    -        echo "SKIP self-build $v — only current stable self-build ($STABLE_TAG) is enabled" | tee -a "$SUMMARY"
    -        RESULTS+=("$v:SKIP(no-selfbuild-older)")
    -        continue
    -      fi
    -      sev=warn
    -      run_selfbuild "ref-${v//\//-}" "$v" warn || rc=$?
    -      ;;
    -  esac
    -
    -  record_result "$v" "$rc" "$sev" || stop=$?
    -  [ "$stop" = "2" ] && break
    -done
    -
    -echo "" | tee -a "$SUMMARY"
    -echo "=== matrix done (overall=$overall) ===" | tee -a "$SUMMARY"
    -echo "rule: age ≤ ${BLOCKER_MAX_AGE_DAYS}d → BLOCKER; 3/6/9/12mo milestones + older → WARN only" | tee -a "$SUMMARY"
    -for r in "${RESULTS[@]}"; do
    -  echo "  $r" | tee -a "$SUMMARY"
    -done
    -if [ "${#WARNINGS[@]}" -gt 0 ]; then
    -  echo "warnings (non-blocking):" | tee -a "$SUMMARY"
    -  for w in "${WARNINGS[@]}"; do
    -    echo "  WARN $w" | tee -a "$SUMMARY"
    -  done
    -fi
    -echo ""
    -echo "Artifacts: $MATRIX_OUT"
    -exit "$overall"
    diff --git a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh b/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh
    deleted file mode 100755
    index 1c20a63..0000000
    --- a/scripts/taler-monitoring/android-test/run-goa-gui-chain.sh
    +++ /dev/null
    @@ -1,366 +0,0 @@
    -#!/usr/bin/env bash
    -# GOA (and optionally stage) GUI chain on Android — adapted from
    -# taler-android branch **dev/hernani-inference/gui-workflows**
    -# (`scripts/goa-chain-emu.sh`, docs/gui-workflows.md).
    -#
    -# Linux + macOS: uses adb + uiautomator only (no Homebrew hard dependency).
    -# Upstream scripts are documented as macOS-only for SDK install paths.
    -#
    -# Flow:
    -#   1) API mint withdraw (explorer) → VIEW taler://withdraw → GUI taps
    -#   2) Merchant template POST → VIEW taler://pay → GUI taps
    -#
    -# Usage:
    -#   ./run-goa-gui-chain.sh
    -#   STACK=stage ./run-goa-gui-chain.sh
    -#   WITHDRAW_AMOUNTS='GOA:10' PAY_LIMIT=2 ./run-goa-gui-chain.sh
    -#   PKG=net.taler.wallet.fdroid.debug ./run-goa-gui-chain.sh --no-clear
    -#
    -# Env: ANDROID_SERIAL, PKG, EXP_PW_FILE, BANK, MERCHANT, INSTANCE,
    -#      WITHDRAW_AMOUNTS, PRODUCTS, PAY_LIMIT, SHOTDIR, PAUSE
    -#
    -# See GUI-AUTOMATION-NOTES.md
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -
    -CLEAR=1
    -for a in "$@"; do
    -  case "$a" in
    -    --no-clear) CLEAR=0 ;;
    -    -h|--help) sed -n '2,28p' "$0"; exit 0 ;;
    -  esac
    -done
    -
    -die() { echo "ERROR: $*" >&2; exit 1; }
    -step() { echo; echo "======== $* ========"; }
    -info() { echo "  $*"; }
    -pause() { sleep "${1:-${PAUSE:-0.8}}"; }
    -
    -# Platform flags + headless emulator defaults (see GUI-AUTOMATION-NOTES.md)
    -# shellcheck source=lib_android_env.sh
    -. "$ROOT/lib_android_env.sh"
    -[ "${AUTO_ANDROID}" = "1" ] || { echo "skipped: AUTO_ANDROID=${AUTO_ANDROID}"; exit 0; }
    -[ "${AUTO_GUI}" = "1" ] || { echo "skipped: AUTO_GUI=${AUTO_GUI} (chain is GUI)"; exit 0; }
    -
    -command -v adb >/dev/null || die "adb not found"
    -command -v python3 >/dev/null || die "python3 required"
    -
    -STACK="${STACK:-goa}"
    -case "$STACK" in
    -  goa|hacktivism)
    -    PKG="${PKG:-net.taler.wallet.fdroid.debug}"
    -    BANK="${BANK:-https://bank.hacktivism.ch}"
    -    MERCHANT="${MERCHANT:-https://taler.hacktivism.ch}"
    -    INSTANCE=$(printf '%s' "${INSTANCE:-goa-shop}" | tr '[:upper:]' '[:lower:]')
    -    DONATE_INSTANCE=$(printf '%s' "${DONATE_INSTANCE:-goa-demo-cp4zqk}" | tr '[:upper:]' '[:lower:]')
    -    DONATE_TEMPLATE="${DONATE_TEMPLATE:-goa-free}"
    -    DEFAULT_AMOUNTS=("GOA:10" "GOA:20")
    -    DEFAULT_PAYS=("product:orbit-sticker" "product:nebula-coffee" "product:voidwave-playlist" "donate:GOA:12")
    -    CUR_HINT=GOA
    -    ;;
    -  stage|testpaysan)
    -    PKG="${PKG:-net.taler.wallet.fdroid.debug}"
    -    BANK="${BANK:-https://stage.bank.lefrancpaysan.ch}"
    -    MERCHANT="${MERCHANT:-https://stage.monnaie.lefrancpaysan.ch}"
    -    INSTANCE=$(printf '%s' "${INSTANCE:-fermes-des-collines}" | tr '[:upper:]' '[:lower:]')
    -    DONATE_INSTANCE=$(printf '%s' "${DONATE_INSTANCE:-fermes-des-collines}" | tr '[:upper:]' '[:lower:]')
    -    DONATE_TEMPLATE="${DONATE_TEMPLATE:-don-panier-libre}"
    -    DEFAULT_AMOUNTS=("TESTPAYSAN:10" "TESTPAYSAN:20")
    -    DEFAULT_PAYS=("product:panier-legumes" "product:fromage-chevre" "product:oeufs-6")
    -    CUR_HINT=TESTPAYSAN
    -    ;;
    -  *) die "STACK must be goa|stage (got $STACK)" ;;
    -esac
    -
    -ACT="${ACT:-$PKG/net.taler.wallet.main.MainActivity}"
    -EXP_USER="${EXP_USER:-explorer}"
    -PAY_LIMIT="${PAY_LIMIT:-4}"
    -PAUSE="${PAUSE:-0.8}"
    -SHOTDIR="${SHOTDIR:-$ROOT/out-gui-chain/$(date +%Y%m%d-%H%M%S)-$STACK}"
    -mkdir -p "$SHOTDIR"
    -
    -# Explorer password (same search as taler-monitoring ladder)
    -if [[ -z "${EXP_PW_FILE:-}" ]]; then
    -  for cand in \
    -    "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt" \
    -    "${HOME}/.config/taler-landing/bank-explorer-password.txt"
    -  do
    -    [[ -f "$cand" ]] && EXP_PW_FILE="$cand" && break
    -  done
    -fi
    -# stage: try stagepaysan secret via ssh if missing
    -if [[ ! -f "${EXP_PW_FILE:-}" && "$STACK" = "stage" ]]; then
    -  for host in francpaysan-stage-user francpaysan-host; do
    -    tmp=$(mktemp)
    -    if ssh -o BatchMode=yes -o ConnectTimeout=10 "$host" \
    -      'tr -d "\n\r" "$tmp" 2>/dev/null && [[ -s "$tmp" ]]; then
    -      EXP_PW_FILE="$tmp"
    -      info "explorer password via $host"
    -      break
    -    fi
    -    rm -f "$tmp"
    -  done
    -fi
    -[[ -n "${EXP_PW_FILE:-}" && -f "$EXP_PW_FILE" ]] || die "set EXP_PW_FILE (explorer password)"
    -
    -# Headless AVD by default (no host window). WINDOWED=1 for a visible emulator.
    -SERIAL="${SERIAL:-${ANDROID_SERIAL:-}}"
    -android_ensure_device || die "no adb device (./start-android-emulator.sh --wait)"
    -export ANDROID_SERIAL="$SERIAL"
    -info "ANDROID_SERIAL=$ANDROID_SERIAL  STACK=$STACK  PKG=$PKG  SHOTDIR=$SHOTDIR  headless=$EMULATOR_HEADLESS gpu=$EMULATOR_GPU"
    -ADB=(adb -s "$ANDROID_SERIAL")
    -
    -adb_sh() { "${ADB[@]}" shell "$@"; }
    -
    -shot() {
    -  local n="$1"
    -  adb_sh screencap -p "/sdcard/demo-$n.png" 2>/dev/null || true
    -  "${ADB[@]}" pull "/sdcard/demo-$n.png" "$SHOTDIR/$n.png" >/dev/null 2>&1 || true
    -  info "[shot] $SHOTDIR/$n.png"
    -}
    -
    -tap_text() {
    -  local label="$1"
    -  adb_sh uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || return 1
    -  "${ADB[@]}" pull /sdcard/ui.xml "$SHOTDIR/ui.xml" >/dev/null 2>&1 || return 1
    -  SERIAL="$ANDROID_SERIAL" python3 - "$label" "$SHOTDIR/ui.xml" <<'PY'
    -import os, re, subprocess, sys
    -from pathlib import Path
    -label, path = sys.argv[1], sys.argv[2]
    -serial = os.environ.get("SERIAL") or os.environ.get("ANDROID_SERIAL") or ""
    -xml = Path(path).read_text(errors="replace")
    -# ANR dismiss
    -if "isn't responding" in xml or "reagiert nicht" in xml.lower():
    -    for lab in ("Wait", "Warten"):
    -        for m in re.finditer(rf'text="{lab}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml):
    -            x = (int(m.group(1))+int(m.group(3)))//2
    -            y = (int(m.group(2))+int(m.group(4)))//2
    -            subprocess.run(["adb","-s",serial,"shell","input","tap",str(x),str(y)], check=False)
    -            print(f"anr-wait @{x},{y}")
    -            sys.exit(0)
    -for pat in [
    -    rf'text="{re.escape(label)}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
    -    rf'text="[^"]*{re.escape(label)}[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
    -    rf'content-desc="[^"]*{re.escape(label)}[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
    -]:
    -    for m in re.finditer(pat, xml, re.I):
    -        chunk = xml[max(0, m.start()-120):m.end()]
    -        if 'enabled="false"' in chunk:
    -            continue
    -        x = (int(m.group(1))+int(m.group(3)))//2
    -        y = (int(m.group(2))+int(m.group(4)))//2
    -        print(f"tap {label!r} @{x},{y}")
    -        cmd = ["adb","shell","input","tap",str(x),str(y)]
    -        if serial:
    -            cmd = ["adb","-s",serial,"shell","input","tap",str(x),str(y)]
    -        subprocess.run(cmd, check=False)
    -        sys.exit(0)
    -print("miss", label)
    -sys.exit(1)
    -PY
    -}
    -
    -mint_withdraw() {
    -  local amount="$1"
    -  EXP_USER="$EXP_USER" EXP_PW_FILE="$EXP_PW_FILE" BANK="$BANK" \
    -  python3 - "$amount" <<'PY'
    -import base64, json, os, ssl, sys, urllib.request
    -from pathlib import Path
    -amount = sys.argv[1]
    -bank = os.environ["BANK"].rstrip("/")
    -user = os.environ["EXP_USER"]
    -pw = Path(os.environ["EXP_PW_FILE"]).read_text().strip()
    -auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
    -ctx = ssl.create_default_context()
    -
    -def req(method, url, data=None, headers=None):
    -    h = dict(headers or {})
    -    body = None
    -    if data is not None:
    -        body = json.dumps(data).encode()
    -        h["Content-Type"] = "application/json"
    -    r = urllib.request.Request(url, data=body, headers=h, method=method)
    -    with urllib.request.urlopen(r, context=ctx, timeout=25) as resp:
    -        return json.load(resp)
    -
    -tok = req(
    -    "POST",
    -    f"{bank}/accounts/{user}/token",
    -    {"scope": "readwrite", "duration": {"d_us": 3600_000_000}},
    -    {"Authorization": auth},
    -)["access_token"]
    -wd = req(
    -    "POST",
    -    f"{bank}/accounts/{user}/withdrawals",
    -    {"amount": amount},
    -    {"Authorization": f"Bearer {tok}"},
    -)
    -print(wd.get("taler_withdraw_uri") or wd.get("talerWithdrawUri") or "")
    -PY
    -}
    -
    -mint_pay() {
    -  local mode="$1"
    -  MERCHANT="$MERCHANT" INSTANCE="$INSTANCE" \
    -  DONATE_INSTANCE="$DONATE_INSTANCE" DONATE_TEMPLATE="$DONATE_TEMPLATE" \
    -  python3 - "$mode" <<'PY'
    -import json, os, ssl, sys, urllib.error, urllib.request
    -mode = sys.argv[1]
    -merchant = os.environ["MERCHANT"].rstrip("/")
    -inst = os.environ["INSTANCE"]
    -d_inst = os.environ["DONATE_INSTANCE"]
    -d_tmpl = os.environ["DONATE_TEMPLATE"]
    -ctx = ssl.create_default_context()
    -
    -def post(url, data):
    -    body = json.dumps(data).encode()
    -    r = urllib.request.Request(url, data=body, method="POST",
    -        headers={"Content-Type": "application/json"})
    -    try:
    -        with urllib.request.urlopen(r, context=ctx, timeout=25) as resp:
    -            return resp.status, json.load(resp)
    -    except urllib.error.HTTPError as e:
    -        raw = e.read() if hasattr(e, "read") else b""
    -        try:
    -            return getattr(e, "code", None), json.loads(raw)
    -        except Exception as err:
    -            raise SystemExit(f"POST {url} failed: {e} body={raw[:200]!r}") from err
    -
    -if mode.startswith("product:"):
    -    pid = mode.split(":", 1)[1]
    -    st, d = post(f"{merchant}/instances/{inst}/templates/{pid}", {})
    -    use_inst = inst
    -elif mode.startswith("donate:"):
    -    amt = mode.split(":", 1)[1]
    -    st, d = post(f"{merchant}/instances/{d_inst}/templates/{d_tmpl}", {"amount": amt})
    -    use_inst = d_inst
    -else:
    -    raise SystemExit(f"bad mode {mode!r}")
    -
    -if "taler_pay_uri" in d:
    -    print(d["taler_pay_uri"]); raise SystemExit(0)
    -oid, tok = d.get("order_id"), d.get("token")
    -if not oid or not tok:
    -    raise SystemExit(f"no order: {d!r}"[:200])
    -host = merchant.replace("https://","").replace("http://","").split("/")[0]
    -print(f"taler://pay/{host}/instances/{use_inst}/{oid}/?c={tok}")
    -PY
    -}
    -
    -click_through() {
    -  local labels=("$@")
    -  local i lab misses=0
    -  # Prefer dismissing ANR before hunting Confirm (low-RAM hosts thrash System UI)
    -  for i in 1 2 3 4 5 6; do
    -    if tap_text "Wait" 2>/dev/null || tap_text "Warten" 2>/dev/null; then
    -      info "dismissed ANR (Wait)"
    -      pause 2.0
    -      misses=0
    -      continue
    -    fi
    -    local hit=0
    -    for lab in "${labels[@]}"; do
    -      if tap_text "$lab" 2>/dev/null; then
    -        hit=1
    -        misses=0
    -        pause 0.9
    -        break
    -      fi
    -    done
    -    if [[ "$hit" -eq 0 ]]; then
    -      misses=$((misses + 1))
    -      # stop early if hierarchy has nothing useful (avoid burning order deadlines)
    -      [[ "$misses" -ge 3 ]] && break
    -    fi
    -    pause 0.5
    -  done
    -}
    -
    -if [[ -n "${WITHDRAW_AMOUNTS:-}" ]]; then
    -  # shellcheck disable=SC2206
    -  AMOUNTS=($WITHDRAW_AMOUNTS)
    -else
    -  AMOUNTS=("${DEFAULT_AMOUNTS[@]}")
    -fi
    -if [[ -n "${PRODUCTS:-}" ]]; then
    -  PAYS=()
    -  for p in $PRODUCTS; do PAYS+=("product:$p"); done
    -else
    -  PAYS=("${DEFAULT_PAYS[@]}")
    -fi
    -
    -############################
    -step "0 device + wallet ($STACK / $CUR_HINT)"
    -adb_sh input keyevent KEYCODE_WAKEUP 2>/dev/null || true
    -adb_sh wm dismiss-keyguard 2>/dev/null || true
    -if [[ "$CLEAR" -eq 1 ]]; then
    -  info "pm clear $PKG"
    -  adb_sh pm clear "$PKG" >/dev/null 2>&1 || true
    -fi
    -adb_sh am force-stop "$PKG" 2>/dev/null || true
    -pause 0.4
    -# Prefer explicit MainActivity (from gui-workflows); fall back to VIEW launcher
    -if ! adb_sh am start -n "$ACT" >/dev/null 2>&1; then
    -  info "MainActivity path failed — monkey launch"
    -  adb_sh monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
    -fi
    -pause 2.0
    -shot "00-start"
    -
    -############################
    -step "2 withdraw chain"
    -idx=0
    -for AMT in "${AMOUNTS[@]}"; do
    -  idx=$((idx + 1))
    -  step "2.$idx withdraw $AMT"
    -  URI="$(mint_withdraw "$AMT" | head -1)"
    -  [[ -n "$URI" ]] || die "mint failed for $AMT"
    -  # wallet / monitoring QR rules: strip default ports
    -  URI=$(printf '%s' "$URI" | sed 's/:443\//\//g; s/:443?/?/g; s/:80\//\//g')
    -  info "URI=$URI"
    -  echo "$URI" >>"$SHOTDIR/withdraw-uris.txt"
    -  adb_sh am start -a android.intent.action.VIEW -d "$URI" "$PKG" >/dev/null
    -  pause 1.5
    -  shot "w${idx}-open"
    -  click_through "Accept" "I accept" "Accept terms" "Confirm" "Continue" "Withdraw" "Next" "OK" "Agree" "Bestätigen" "Abheben" "Akzeptieren"
    -  pause 3.0
    -  shot "w${idx}-after"
    -  adb_sh am start -n "$ACT" >/dev/null 2>&1 || true
    -  pause 0.6
    -  tap_text "Assets" 2>/dev/null || true
    -  pause 0.4
    -  shot "w${idx}-assets"
    -done
    -
    -############################
    -step "3 pay chain (limit=$PAY_LIMIT)"
    -pidx=0
    -for PAY in "${PAYS[@]}"; do
    -  pidx=$((pidx + 1))
    -  [[ "$pidx" -gt "$PAY_LIMIT" ]] && break
    -  step "3.$pidx pay $PAY"
    -  if ! PAYURI="$(mint_pay "$PAY" | head -1)"; then
    -    info "mint_pay failed for $PAY — skip"
    -    continue
    -  fi
    -  [[ -n "$PAYURI" ]] || { info "empty pay uri — skip"; continue; }
    -  PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g; s/:80\//\//g')
    -  info "PAYURI=$PAYURI"
    -  echo "$PAYURI" >>"$SHOTDIR/pay-uris.txt"
    -  adb_sh am start -a android.intent.action.VIEW -d "$PAYURI" "$PKG" >/dev/null
    -  pause 1.5
    -  shot "p${pidx}-open"
    -  click_through "Pay" "Confirm" "Accept" "Continue" "OK" "Next" "Bezahlen" "Bestätigen" "Payer"
    -  pause 1.5
    -  shot "p${pidx}-after"
    -  adb_sh am start -n "$ACT" >/dev/null 2>&1 || true
    -  pause 0.5
    -done
    -
    -step "4 done → $SHOTDIR"
    -ls -la "$SHOTDIR" | tail -40
    -adb_sh dumpsys window 2>/dev/null | grep mCurrentFocus | head -1 || true
    -info "PKG=$PKG amounts=${AMOUNTS[*]} pays(limit)=$PAY_LIMIT"
    -echo "See GUI-AUTOMATION-NOTES.md (ported from taler-android dev/hernani-inference/gui-workflows)"
    diff --git a/scripts/taler-monitoring/android-test/start-android-emulator.sh b/scripts/taler-monitoring/android-test/start-android-emulator.sh
    deleted file mode 100755
    index e62b5f4..0000000
    --- a/scripts/taler-monitoring/android-test/start-android-emulator.sh
    +++ /dev/null
    @@ -1,90 +0,0 @@
    -#!/usr/bin/env bash
    -# Start the Taler Android AVD. **Default = headless** (no host window).
    -# Guest GLES still runs via SwiftShader so uiautomator / screenshots work.
    -#
    -# Usage:
    -#   ./start-android-emulator.sh              # headless, background
    -#   ./start-android-emulator.sh --wait       # headless + wait for boot
    -#   ./start-android-emulator.sh --windowed   # show emulator window
    -#   EMULATOR_HEADLESS=0 ./start-android-emulator.sh
    -#   EMULATOR_GPU=host WINDOWED=1 ./start-android-emulator.sh
    -#
    -# Env: EMULATOR_AVD EMULATOR_MEMORY EMULATOR_CORES EMULATOR_GPU
    -#      EMULATOR_HEADLESS EMULATOR_LOG EMULATOR_BOOT_TIMEOUT EMULATOR_EXTRA_ARGS
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib_android_env.sh
    -. "$ROOT/lib_android_env.sh"
    -
    -WAIT=0
    -for a in "$@"; do
    -  case "$a" in
    -    --wait) WAIT=1 ;;
    -    --windowed| --gui) EMULATOR_HEADLESS=0; export EMULATOR_HEADLESS ;;
    -    --headless) EMULATOR_HEADLESS=1; export EMULATOR_HEADLESS ;;
    -    -h|--help)
    -      sed -n '2,18p' "$0"
    -      exit 0
    -      ;;
    -  esac
    -done
    -
    -command -v emulator >/dev/null || {
    -  echo "emulator not found (ANDROID_HOME=$ANDROID_HOME)" >&2
    -  exit 2
    -}
    -command -v adb >/dev/null || {
    -  echo "adb not found" >&2
    -  exit 2
    -}
    -
    -# Already booted?
    -if serial=$(android_adb_serial); [ -n "$serial" ]; then
    -  boot=$(adb -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)
    -  if [ "$boot" = "1" ]; then
    -    echo "already running: $serial (boot_completed=1) — headless default still applies to *new* starts"
    -    exit 0
    -  fi
    -fi
    -
    -# Avoid pkill patterns that match this wrapper's argv (self-kill).
    -mapfile -t EMU_ARGS < <(android_emulator_args "$EMULATOR_AVD")
    -echo "starting: emulator ${EMU_ARGS[*]}"
    -echo "  headless=$EMULATOR_HEADLESS  gpu=$EMULATOR_GPU  log=$EMULATOR_LOG"
    -
    -if [ "${EMULATOR_HEADLESS}" = "1" ]; then
    -  export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}"
    -  # Do not require DISPLAY
    -  unset DISPLAY || true
    -fi
    -
    -mkdir -p "$(dirname "$EMULATOR_LOG")"
    -# Background; do not use pkill -f emulator from callers matching full cmdline
    -nohup emulator "${EMU_ARGS[@]}" >"$EMULATOR_LOG" 2>&1 &
    -echo "emulator pid=$!  log=$EMULATOR_LOG"
    -
    -if [ "$WAIT" != "1" ]; then
    -  echo "not waiting (pass --wait for boot). adb devices when ready."
    -  exit 0
    -fi
    -
    -echo "waiting for boot (timeout ${EMULATOR_BOOT_TIMEOUT}s)…"
    -deadline=$((SECONDS + EMULATOR_BOOT_TIMEOUT))
    -serial=""
    -while (( SECONDS < deadline )); do
    -  adb wait-for-device 2>/dev/null || true
    -  serial=$(android_adb_serial || true)
    -  if [ -n "$serial" ]; then
    -    boot=$(adb -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)
    -    if [ "$boot" = "1" ]; then
    -      echo "BOOT_OK serial=$serial headless=$EMULATOR_HEADLESS gpu=$EMULATOR_GPU"
    -      exit 0
    -    fi
    -  fi
    -  sleep 2
    -done
    -
    -echo "boot timeout — last log:" >&2
    -tail -30 "$EMULATOR_LOG" >&2 || true
    -exit 3
    diff --git a/scripts/taler-monitoring/check_apt_deploy.sh b/scripts/taler-monitoring/check_apt_deploy.sh
    deleted file mode 100755
    index 5240e82..0000000
    --- a/scripts/taler-monitoring/check_apt_deploy.sh
    +++ /dev/null
    @@ -1,279 +0,0 @@
    -#!/usr/bin/env bash
    -# check_apt_deploy.sh — podman apt-src merchant deploy tests on koopa.
    -#
    -# Containers (default):
    -#   Fresh (rebuild when repo versions change):
    -#     koopa-taler-deploy-test-apt-src-trixie
    -#     koopa-taler-deploy-test-apt-src-trixie-testing
    -#   Upgrade track (initial install, then mytops-style apt upgrade):
    -#     koopa-taler-deploy-test-apt-src-trixie-upgrade
    -#     koopa-taler-deploy-test-apt-src-trixie-testing-upgrade
    -#
    -# Env:
    -#   APT_DEPLOY_CONTAINERS  "name:suite:mode …"  mode=fresh|upgrade (optional, default fresh)
    -#   APT_DEPLOY_SKIP=1
    -#   APT_DEPLOY_PODMAN=podman
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -
    -if [ "${APT_DEPLOY_SKIP:-0}" = "1" ]; then
    -  echo "[INFO]  skip phase aptdeploy (APT_DEPLOY_SKIP=1)"
    -  exit 0
    -fi
    -
    -set_area aptdeploy
    -section "aptdeploy · podman apt-src merchant (fresh + upgrade tracks)"
    -
    -PODMAN_BIN="${APT_DEPLOY_PODMAN:-podman}"
    -DEFAULT_LIST="\
    -koopa-taler-deploy-test-apt-src-trixie:trixie:fresh \
    -koopa-taler-deploy-test-apt-src-trixie-testing:trixie-testing:fresh \
    -koopa-taler-deploy-test-apt-src-trixie-upgrade:trixie:upgrade \
    -koopa-taler-deploy-test-apt-src-trixie-testing-upgrade:trixie-testing:upgrade"
    -LIST="${APT_DEPLOY_CONTAINERS:-$DEFAULT_LIST}"
    -
    -if ! command -v "$PODMAN_BIN" >/dev/null 2>&1; then
    -  err "podman" "podman binary missing ($PODMAN_BIN)"
    -  summary
    -  exit 1
    -fi
    -ok "podman" "$("$PODMAN_BIN" --version 2>/dev/null | head -1)"
    -info "host" "$(hostname 2>/dev/null || echo unknown) · whoami=$(whoami)"
    -
    -fail_any=0
    -
    -print_pkg_table() {
    -  local name=$1
    -  local rows
    -  rows=$("$PODMAN_BIN" exec "$name" bash -lc '
    -    echo "| package | version |"
    -    echo "|---------|---------|"
    -    for p in taler-merchant libtalermerchant libtalerexchange libdonau libgnunet \
    -             taler-merchant-webui taler-merchant-typst taler-terms-generator; do
    -      v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo missing)
    -      echo "| $p | $v |"
    -    done
    -  ' 2>/dev/null || echo "| (query failed) | |")
    -  info "pkg-table" "$name"
    -  # emit as plain lines so HTML log keeps the table
    -  while IFS= read -r line; do
    -    [ -n "$line" ] || continue
    -    info "pkg" "$line"
    -  done <<<"$rows"
    -}
    -
    -check_ldd_deep() {
    -  local name=$1
    -  local out
    -  out=$("$PODMAN_BIN" exec "$name" bash -lc '
    -    set +e
    -    bin=$(command -v taler-merchant-httpd)
    -    echo "=== ldd taler-merchant-httpd ==="
    -    ldd "$bin" 2>&1 | grep -E "libtalerutil|libdonau|libgnunet|not found" || true
    -    echo "=== ldd libdonau (if present) ==="
    -    for f in /usr/lib/*/libdonau.so* /usr/lib/*/libdonauutil.so*; do
    -      [ -e "$f" ] || continue
    -      echo "-- $f --"
    -      ldd "$f" 2>&1 | grep -E "libtalerutil|not found" || true
    -    done
    -    echo "=== libtalerutil files ==="
    -    ls -la /usr/lib/*/libtalerutil* 2>/dev/null || echo "(none)"
    -  ' 2>/dev/null || echo "ldd probe failed")
    -  while IFS= read -r line; do
    -    [ -n "$line" ] || continue
    -    info "ldd" "$line"
    -  done <<<"$out"
    -  if echo "$out" | grep -q 'not found'; then
    -    err "ldd" "shared library missing in $name" "see ldd lines above"
    -    return 1
    -  fi
    -  return 0
    -}
    -
    -check_systemd() {
    -  local name=$1
    -  local out active_target active_httpd enabled
    -  out=$("$PODMAN_BIN" exec "$name" bash -lc '
    -    set +e
    -    echo "system: $(systemctl is-system-running 2>&1)"
    -    echo "target_enabled: $(systemctl is-enabled taler-merchant.target 2>&1)"
    -    echo "target_active: $(systemctl is-active taler-merchant.target 2>&1)"
    -    echo "httpd_active: $(systemctl is-active taler-merchant-httpd.service 2>&1)"
    -    echo "httpd_enabled: $(systemctl is-enabled taler-merchant-httpd.service 2>&1)"
    -    systemctl start taler-merchant.target 2>&1 | tail -5
    -    sleep 2
    -    echo "after_start_target: $(systemctl is-active taler-merchant.target 2>&1)"
    -    echo "after_start_httpd: $(systemctl is-active taler-merchant-httpd.service 2>&1)"
    -    systemctl --failed --no-legend 2>/dev/null | grep -i taler || echo "failed_taler: none"
    -  ' 2>/dev/null || echo "systemd probe failed")
    -  while IFS= read -r line; do
    -    [ -n "$line" ] || continue
    -    info "systemd" "$line"
    -  done <<<"$out"
    -
    -  active_httpd=$(echo "$out" | sed -n 's/^after_start_httpd: //p' | tail -1)
    -  if [ "$active_httpd" = "active" ]; then
    -    ok "systemd httpd" "taler-merchant-httpd active after start taler-merchant.target"
    -    return 0
    -  fi
    -  # soft if target not shipped as enabled in container — still ERROR for deploy smoke
    -  err "systemd httpd" "taler-merchant-httpd not active" "after_start_httpd=${active_httpd:-?} · see systemd lines"
    -  return 1
    -}
    -
    -check_merchant_basics() {
    -  local name=$1
    -  local out code
    -  # unix socket or curl localhost if httpd listens
    -  out=$("$PODMAN_BIN" exec "$name" bash -lc '
    -    set +e
    -    echo "=== binaries ==="
    -    command -v taler-merchant-httpd taler-merchant-dbinit taler-config 2>/dev/null
    -    echo "=== --version ==="
    -    taler-merchant-httpd --version 2>&1
    -    echo "=== config probe ==="
    -    # try common paths without TLS
    -    for u in \
    -      http://127.0.0.1:9966/config \
    -      http://127.0.0.1:8081/config \
    -      http://127.0.0.1/config
    -    do
    -      code=$(curl -sS -m 2 -o /tmp/mcfg -w "%{http_code}" "$u" 2>/dev/null || echo 000)
    -      echo "curl $u -> $code"
    -      [ "$code" = "200" ] && head -c 120 /tmp/mcfg && echo
    -    done
    -    sock=$(ls /run/taler-merchant/httpd/*.sock 2>/dev/null | head -1)
    -    if [ -n "$sock" ]; then
    -      echo "socket: $sock"
    -      code=$(curl -sS -m 2 --unix-socket "$sock" -o /tmp/mcfg -w "%{http_code}" http://localhost/config 2>/dev/null || echo 000)
    -      echo "curl --unix-socket -> $code"
    -      [ "$code" = "200" ] && head -c 160 /tmp/mcfg && echo
    -    else
    -      echo "socket: (none under /run/taler-merchant/httpd/)"
    -    fi
    -  ' 2>/dev/null || echo "basics probe failed")
    -
    -  while IFS= read -r line; do
    -    [ -n "$line" ] || continue
    -    info "basics" "$line"
    -  done <<<"$out"
    -
    -  if echo "$out" | grep -qi 'error while loading shared libraries'; then
    -    err "httpd" "shared library error on --version" "see basics/ldd"
    -    return 1
    -  fi
    -  if ! echo "$out" | grep -q 'taler-merchant-httpd --version\|v\.\|taler-merchant-httpd'; then
    -    # version line varies; success if exit was ok — check via separate exec
    -    :
    -  fi
    -
    -  set +e
    -  "$PODMAN_BIN" exec "$name" taler-merchant-httpd --version >/dev/null 2>&1
    -  local ec=$?
    -  set -e
    -  if [ "$ec" -eq 0 ]; then
    -    ok "httpd --version" "exit 0"
    -  else
    -    err "httpd --version" "exit $ec"
    -    return 1
    -  fi
    -
    -  # /config is nice-to-have if stack is fully configured
    -  if echo "$out" | grep -qE 'curl .* -> 200'; then
    -    ok "merchant /config" "HTTP 200 (local)"
    -  else
    -    warn "merchant /config" "no local HTTP 200 (unit may need BASE_URL/db — still report version/ldd)"
    -  fi
    -  return 0
    -}
    -
    -check_one() {
    -  local name="$1" expect_suite="$2" mode="${3:-fresh}"
    -  local st suite_line pkgs
    -
    -  # group ids: aptdeploy.trixie-01 / aptdeploy.trixie-testing-upgrade-01
    -  set_group "${expect_suite}${mode:+-$mode}"
    -  section "aptdeploy · $name (suite=$expect_suite mode=$mode)"
    -
    -  if ! "$PODMAN_BIN" container exists "$name" 2>/dev/null; then
    -    err "container" "$name missing" "run ensure-apt-deploy-test-containers.sh"
    -    fail_any=1
    -    return
    -  fi
    -  st=$("$PODMAN_BIN" inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo unknown)
    -  if [ "$st" != "running" ]; then
    -    err "container" "$name not running" "status=$st"
    -    fail_any=1
    -    return
    -  fi
    -  ok "container" "$name running (mode=$mode)"
    -
    -  suite_line=$("$PODMAN_BIN" exec "$name" bash -lc \
    -    'grep -h "^Suites:" /etc/apt/sources.list.d/*taler* 2>/dev/null | head -1' 2>/dev/null || true)
    -  if echo "$suite_line" | grep -qE "Suites:[[:space:]]*${expect_suite}([[:space:]]|$)"; then
    -    ok "apt suite" "$expect_suite"
    -  else
    -    err "apt suite" "expected $expect_suite" "got: ${suite_line:-empty}"
    -    fail_any=1
    -  fi
    -
    -  print_pkg_table "$name"
    -
    -  if ! check_merchant_basics "$name"; then
    -    fail_any=1
    -    check_ldd_deep "$name" || fail_any=1
    -  else
    -    # still show ldd summary as info when ok
    -    ldd_out=$("$PODMAN_BIN" exec "$name" bash -lc \
    -      'ldd "$(command -v taler-merchant-httpd)" 2>&1 | grep -E "libtalerutil|not found" || true' 2>/dev/null || true)
    -    if echo "$ldd_out" | grep -q 'not found'; then
    -      err "libtalerutil" "not found" "$ldd_out"
    -      check_ldd_deep "$name" || true
    -      fail_any=1
    -    else
    -      info "libtalerutil" "$(echo "$ldd_out" | tr '\n' ' ')"
    -    fi
    -  fi
    -
    -  if ! check_systemd "$name"; then
    -    fail_any=1
    -    check_ldd_deep "$name" || true
    -  fi
    -
    -  # Disk free inside this deploy-test container (+ host once, first container only)
    -  if [ "${_APT_DEPLOY_HOST_DISK_DONE:-0}" != "1" ]; then
    -    set_group disk
    -    mon_disk_check_host "host" || fail_any=1
    -    _APT_DEPLOY_HOST_DISK_DONE=1
    -  fi
    -  set_group disk
    -  if ! mon_disk_check_podman "$name" "$PODMAN_BIN"; then
    -    fail_any=1
    -  fi
    -}
    -
    -for entry in $LIST; do
    -  # name:suite or name:suite:mode
    -  cname=${entry%%:*}
    -  rest=${entry#*:}
    -  case "$rest" in
    -    *:*)
    -      suite=${rest%%:*}
    -      mode=${rest#*:}
    -      ;;
    -    *)
    -      suite=$rest
    -      mode=fresh
    -      ;;
    -  esac
    -  check_one "$cname" "$suite" "$mode"
    -done
    -
    -summary
    -if [ "$fail_any" -ne 0 ]; then
    -  exit 1
    -fi
    -exit 0
    diff --git a/scripts/taler-monitoring/check_auth401.sh b/scripts/taler-monitoring/check_auth401.sh
    deleted file mode 100755
    index dfca606..0000000
    --- a/scripts/taler-monitoring/check_auth401.sh
    +++ /dev/null
    @@ -1,704 +0,0 @@
    -#!/usr/bin/env bash
    -# auth401 — merchant HTTP 401 / case-sensitivity matrix (WebUI-shaped flows)
    -#
    -# Exercises the same APIs the merchant SPA uses for:
    -#   • self-provision signup (POST /instances) — mixed-case id
    -#   • login / createAccessToken (POST …/private/token) — Basic user case
    -#   • password change (POST …/private/auth)
    -#   • admin-create (POST /management/instances) when admin bearer available
    -#   • access-token + private/ with Bearer
    -#
    -# Backend fact (#11340): Basic-auth *username* is case-sensitive; URL path is
    -# usually not. SPA must always lowercase the username (createAccessToken, signup
    -# id, admin-create, pw change re-login). These tests assert the *raw API*
    -# behaviour so a missing SPA lowercasing shows up as 401 where 200 is wanted
    -# only after lowercasing.
    -#
    -#   ./taler-monitoring.sh -d stage.monnaie.lefrancpaysan.ch auth401
    -#   ./taler-monitoring.sh -d hacktivism.ch auth401
    -#
    -# Continue through all groups / collect every ERROR (optional):
    -#   AUTH401_CONTINUE=1 ./taler-monitoring.sh -d stage… auth401
    -#   CONTINUE_ON_ERROR=1 ./taler-monitoring.sh -d stage… auth401   # same
    -#
    -# Env: see secrets.env.example (AUTH401_*).
    -#
    -# NOTE: we intentionally do NOT use `set -e`. A single failed curl/grep under
    -# set -e would abort with no ERROR summary — the opposite of what monitoring
    -# needs. Optional CONTINUE_ON_ERROR still controls hard prereq early-exit.
    -set -uo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -load_monitoring_secrets_env 2>/dev/null || true
    -
    -tmp=$(mktemp -d)
    -AUTH401_FINISHED=0
    -_auth401_finish() {
    -  [ "${AUTH401_FINISHED}" = "1" ] && return 0
    -  AUTH401_FINISHED=1
    -  # summary() returns non-zero when FAIL_N>0 — never let that skip the print
    -  summary || true
    -  rm -rf "${tmp:-}"
    -  if [ "${FAIL_N:-0}" -gt 0 ]; then
    -    exit 1
    -  fi
    -  exit 0
    -}
    -trap '_auth401_finish' EXIT
    -
    -# ---------------------------------------------------------------------------
    -# Parameters
    -# ---------------------------------------------------------------------------
    -: "${AUTH401_TIMEOUT:=${TIMEOUT:-20}}"
    -: "${AUTH401_CREATE:=1}"
    -: "${AUTH401_INSTANCE:=}"
    -: "${AUTH401_PASSWORD:=${MERCHANT_INSTANCE_PASSWORD:-${AUTH401_PASS:-}}}"
    -: "${AUTH401_ID_PREFIX:=mon401}"
    -: "${AUTH401_SKIP_IF_MFA:=1}"
    -: "${AUTH401_BEARER:=${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}}"
    -: "${AUTH401_CREATE_PASSWORD:=${AUTH401_PASSWORD:-Mon401-Test-Pass!}}"
    -: "${AUTH401_DURABLE_ID:=mon401}"
    -# Prefer durable alone (skip throwaway) — default 0 so full matrix always runs
    -: "${AUTH401_PREFER_DURABLE:=0}"
    -# Run expanded groups (signup/login/pwchange/admin/webui). Default 1.
    -: "${AUTH401_FULL:=1}"
    -# Admin bearer for POST /management/instances (optional)
    -: "${AUTH401_ADMIN_TOKEN:=${MERCHANT_ADMIN_TOKEN:-}}"
    -: "${AUTH401_ADMIN_PASSWORD:=}"
    -: "${AUTH401_ADMIN_USER:=admin}"
    -# 1 = do not abort on hard prereq fail; run remaining groups (default 0)
    -# Alias: CONTINUE_ON_ERROR=1
    -: "${AUTH401_CONTINUE:=${CONTINUE_ON_ERROR:-0}}"
    -# Never rotate password on durable account (default 1 — keep mon401.password valid)
    -: "${AUTH401_PWCHANGE_THROWAY_ONLY:=1}"
    -
    -TIMEOUT="$AUTH401_TIMEOUT"
    -export TIMEOUT
    -
    -_auth401_abort_or_continue() {
    -  local why="${1:-prereq failed}"
    -  if [ "${AUTH401_CONTINUE}" = "1" ]; then
    -    warn "continue" "${why} (AUTH401_CONTINUE=1 / CONTINUE_ON_ERROR=1)"
    -    return 0
    -  fi
    -  # hard stop — EXIT trap prints summary + all ERRORS collected so far
    -  exit 1
    -}
    -
    -BASE="${MERCHANT_PUBLIC%/}"
    -if [ -z "$BASE" ]; then
    -  echo "MERCHANT_PUBLIC empty — set -d domain or --merchant" >&2
    -  exit 2
    -fi
    -
    -# ---------------------------------------------------------------------------
    -# Helpers
    -# ---------------------------------------------------------------------------
    -expect_http() {
    -  local label="$1" want="$2" got="$3" detail="${4:-}"
    -  case ",$want," in
    -    *",$got,"*) ok "$label" "HTTP $got${detail:+ · $detail}" ;;
    -    *) fail "$label" "HTTP $got (want $want)${detail:+ · $detail}" ;;
    -  esac
    -}
    -
    -to_lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
    -to_upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]'; }
    -
    -# First char upper, rest lower → synthetic MIX for Basic user (always ≠ low if len>0)
    -synth_mix() {
    -  python3 -c "s='''$1'''; print((s[:1].upper()+s[1:].lower()) if s else s)"
    -}
    -
    -# First 3 upper (partial mix)
    -synth_partmix() {
    -  python3 -c "s='''$1'''; print(s[:3].upper()+s[3:] if len(s)>=3 else s.upper())"
    -}
    -
    -# Normalize curl status: "200", "000", never "200000" / "000000"
    -_http_norm() {
    -  local raw="$1" dig
    -  dig=$(printf '%s' "$raw" | tr -cd '0-9')
    -  if [ -z "$dig" ]; then
    -    printf '000'
    -  else
    -    # first 3 digits = real code (curl 200 then || echo 000 → 200000)
    -    printf '%s' "$dig" | head -c 3
    -  fi
    -}
    -
    -token_http() {
    -  local path_id="$1" user="$2" password="$3"
    -  local b64 raw
    -  b64=$(printf '%s' "${user}:${password}" | base64 -w0 2>/dev/null \
    -    || printf '%s' "${user}:${password}" | base64)
    -  raw=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -    -o "$tmp/last_token.json" -w '%{http_code}' \
    -    -X POST "${BASE}/instances/${path_id}/private/token" \
    -    -H "Authorization: Basic ${b64}" \
    -    -H 'Content-Type: application/json' \
    -    -d '{"scope":"write","duration":{"d_us":3600000000},"refreshable":true}' \
    -    2>/dev/null || true)
    -  _http_norm "${raw:-}"
    -}
    -
    -json_code_hint() {
    -  python3 - "$tmp/last_token.json" <<'PY' 2>/dev/null || true
    -import json, sys
    -try:
    -    d = json.load(open(sys.argv[1]))
    -except Exception:
    -    print("")
    -    raise SystemExit
    -c = d.get("code", "")
    -h = (d.get("hint") or d.get("detail") or "")[:70]
    -print(f"code={c} {h}".strip())
    -PY
    -}
    -
    -extract_token() {
    -  python3 - "$tmp/last_token.json" <<'PY' 2>/dev/null || true
    -import json, sys
    -try:
    -    d = json.load(open(sys.argv[1]))
    -except Exception:
    -    print("")
    -    raise SystemExit(0)
    -print(d.get("access_token") or d.get("token") or "")
    -PY
    -}
    -
    -instance_body() {
    -  local id="$1" pass="$2" name="${3:-auth401 probe}"
    -  python3 - "$id" "$pass" "$name" <<'PY'
    -import json, sys
    -print(json.dumps({
    -  "address": {},
    -  "auth": {"method": "token", "password": sys.argv[2]},
    -  "default_pay_delay": {"d_us": 60000000},
    -  "default_wire_transfer_delay": {"d_us": 30000000},
    -  "default_refund_delay": {"d_us": 30000000},
    -  "id": sys.argv[1],
    -  "jurisdiction": {},
    -  "name": sys.argv[3],
    -  "use_stefan": True,
    -}))
    -PY
    -}
    -
    -auth_change_http() {
    -  local path_id="$1" bearer="$2" new_pass="$3"
    -  local tok="$bearer" body
    -  case "$tok" in secret-token:*) ;; *) tok="secret-token:${tok}" ;; esac
    -  body=$(NEW_PASS="$new_pass" python3 -c 'import json,os; print(json.dumps({"method":"token","password":os.environ["NEW_PASS"]}))')
    -  curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -    -o "$tmp/auth_change.json" -w '%{http_code}' \
    -    -X POST "${BASE}/instances/${path_id}/private/auth" \
    -    -H "Authorization: Bearer ${tok}" \
    -    -H 'Content-Type: application/json' \
    -    -d "$body" \
    -    2>/dev/null || echo 000
    -}
    -
    -# ---------------------------------------------------------------------------
    -# Stage secrets (quiet)
    -# ---------------------------------------------------------------------------
    -_auth401_stage_secret_roots() {
    -  printf '%s\n' \
    -    "${FRANCPAYSAN_SECRETS:-}" \
    -    "${HOME}/francpaysan-secrets" \
    -    "${HOME}/src/francpaysan-secrets" \
    -    "${HOME}/taler/src/francpaysan-secrets"
    -}
    -
    -_auth401_resolve_secrets_quiet() {
    -  local root inst_id pass
    -  for root in $(_auth401_stage_secret_roots); do
    -    [ -n "$root" ] && [ -d "$root" ] || continue
    -    # durable ids: prefer mon401fix when mon401 was rotated by old tests
    -    if [ -z "${DURABLE_PASS:-}" ]; then
    -      for inst_id in "${AUTH401_DURABLE_ID}" mon401fix mon401 "${MERCHANT_INSTANCE:-}"; do
    -        [ -n "$inst_id" ] || continue
    -        pass=""
    -        if [ -f "$root/stage/instances/${inst_id}.password" ]; then
    -          pass=$(tr -d '\n\r' <"$root/stage/instances/${inst_id}.password")
    -        fi
    -        # skip empty password files
    -        if [ -n "$pass" ]; then
    -          # verify file is for this id (mon401.password may be stale copy of mon401fix)
    -          DURABLE_PASS="$pass"
    -          DURABLE_ID=$(to_lower "$inst_id")
    -          AUTH401_SECRET_NOTE="durable ${inst_id} from ${root}/stage/instances/"
    -          # only set AUTH401_PASSWORD if caller did not pin an instance
    -          if [ -z "${AUTH401_PASSWORD}" ] && [ -z "${AUTH401_INSTANCE}" ]; then
    -            AUTH401_PASSWORD="$pass"
    -          fi
    -          break
    -        fi
    -      done
    -    fi
    -    if [ -z "${AUTH401_BEARER}" ] && [ -f "$root/stage/default-instance-token.txt" ]; then
    -      AUTH401_BEARER=$(tr -d '\n\r' <"$root/stage/default-instance-token.txt")
    -      AUTH401_BEARER_NOTE="default-instance-token.txt"
    -    fi
    -    if [ -z "${AUTH401_ADMIN_TOKEN}" ] && [ -f "$root/stage/admin-instance-token.txt" ]; then
    -      AUTH401_ADMIN_TOKEN=$(tr -d '\n\r' <"$root/stage/admin-instance-token.txt")
    -      AUTH401_ADMIN_NOTE="admin-instance-token.txt"
    -    fi
    -    [ -n "${DURABLE_PASS:-}" ] && break
    -  done
    -  # also keep AUTH401_INSTANCE for durable-only mode
    -  if [ "${AUTH401_PREFER_DURABLE}" = "1" ] && [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then
    -    AUTH401_INSTANCE="${AUTH401_INSTANCE:-$DURABLE_ID}"
    -    AUTH401_PASSWORD="${AUTH401_PASSWORD:-$DURABLE_PASS}"
    -    AUTH401_CREATE=0
    -    AUTH401_MODE_NOTE="PREFER_DURABLE=1 → only durable ${AUTH401_INSTANCE}"
    -  fi
    -}
    -
    -_auth401_resolve_secrets_quiet
    -: "${AUTH401_CREATE_PASSWORD:=${AUTH401_PASSWORD:-Mon401-Test-Pass!}}"
    -export AUTH401_INSTANCE AUTH401_PASSWORD AUTH401_BEARER AUTH401_CREATE AUTH401_ADMIN_TOKEN
    -
    -# ---------------------------------------------------------------------------
    -set_area auth401
    -section "auth401 · merchant 401 / case matrix (WebUI APIs) · ${BASE}"
    -
    -if [ -n "${AUTH401_SECRET_NOTE:-}" ]; then info "stage secret" "$AUTH401_SECRET_NOTE"; fi
    -if [ -n "${AUTH401_BEARER_NOTE:-}" ]; then info "stage secret" "AUTH401_BEARER from ${AUTH401_BEARER_NOTE}"; fi
    -if [ -n "${AUTH401_ADMIN_NOTE:-}" ]; then info "stage secret" "AUTH401_ADMIN_TOKEN from ${AUTH401_ADMIN_NOTE}"; fi
    -if [ -n "${AUTH401_MODE_NOTE:-}" ]; then info "auth401 mode" "$AUTH401_MODE_NOTE"; fi
    -info "auth401 scope" "FULL=${AUTH401_FULL} CREATE=${AUTH401_CREATE} PREFER_DURABLE=${AUTH401_PREFER_DURABLE} CONTINUE=${AUTH401_CONTINUE}"
    -if [ "${AUTH401_CONTINUE}" = "1" ]; then
    -  info "continue mode" "CONTINUE_ON_ERROR/AUTH401_CONTINUE=1 — hard prereqs soft; always full ERROR list via EXIT trap"
    -fi
    -
    -# ===========================================================================
    -set_group config
    -# ===========================================================================
    -cfg_code=$(http_body "$BASE/config" "$tmp/mer-config.json")
    -expect_http "merchant /config" 200 "$cfg_code" "$BASE/config"
    -
    -HAVE_SP=0
    -MANDATORY_TAN=0
    -if [ "$cfg_code" = "200" ]; then
    -  eval "$(python3 - "$tmp/mer-config.json" <<'PY'
    -import json, sys
    -d = json.load(open(sys.argv[1]))
    -sp = 1 if d.get("have_self_provisioning") else 0
    -tans = d.get("mandatory_tan_channels") or []
    -print(f"HAVE_SP={sp}")
    -print(f"MANDATORY_TAN={1 if tans else 0}")
    -print(f"TAN_CH={','.join(tans) if tans else ''}")
    -print(f"API_VER={d.get('version','')}")
    -PY
    -)"
    -  info "self-provision" "have_self_provisioning=${HAVE_SP} mandatory_tan=${MANDATORY_TAN}${TAN_CH:+ ($TAN_CH)} api=${API_VER:-?}"
    -else
    -  fail "merchant config required for auth401" "HTTP $cfg_code"
    -  _auth401_abort_or_continue "no /config"
    -fi
    -
    -# ===========================================================================
    -set_group webui
    -# SPA surface: /webui/ loads + version stamp; optional toLowerCase in bundle
    -# ===========================================================================
    -if [ "${AUTH401_FULL}" = "1" ]; then
    -  wcode=$(http_code "$BASE/webui/")
    -  case "$wcode" in
    -    200|301|302) ok "webui /" "HTTP $wcode" ;;
    -    *) warn "webui /" "HTTP $wcode" ;;
    -  esac
    -  vcode=$(http_body "$BASE/webui/version.txt" "$tmp/spa-ver.txt" 2>/dev/null || echo 000)
    -  if [ "$vcode" = "200" ] && [ -s "$tmp/spa-ver.txt" ]; then
    -    ok "webui version.txt" "$(tr -d '\n\r' <"$tmp/spa-ver.txt")"
    -  else
    -    warn "webui version.txt" "HTTP ${vcode:-000}"
    -  fi
    -  # Download a slice of index.js — look for lowercasing mitigation (#11340)
    -  jcode=$(curl -skS -m 60 -o "$tmp/spa-index.js" -w '%{http_code}' "$BASE/webui/index.js" 2>/dev/null || echo 000)
    -  jcode=$(printf '%s' "$jcode" | tr -cd '0-9' | head -c 3)
    -  if [ "$jcode" = "200" ] && [ -s "$tmp/spa-index.js" ]; then
    -    ok "webui index.js" "HTTP 200 · $(wc -c <"$tmp/spa-index.js" | tr -d ' ') bytes"
    -    if grep -q 'toLowerCase' "$tmp/spa-index.js" 2>/dev/null; then
    -      ok "webui bundle toLowerCase" "present (SPA may mitigate #11340)"
    -    else
    -      warn "webui bundle toLowerCase" "not found in index.js — login MIX may 401 without lowercasing"
    -    fi
    -  else
    -    warn "webui index.js" "HTTP ${jcode:-000}"
    -  fi
    -fi
    -
    -# ===========================================================================
    -set_group signup
    -# WebUI self-provision: POST /instances with mixed-case id (as typed)
    -# ===========================================================================
    -CREATED=0
    -USE_ID=""
    -USE_PASS=""
    -ID_MIX=""
    -ID_LOW=""
    -CREATE_PASS="$AUTH401_CREATE_PASSWORD"
    -stamp=$(date +%H%M%S)
    -# Mixed-case id like the SPA would send before lowercasing (or if it forgot)
    -ID_MIX="$(printf '%s' "${AUTH401_ID_PREFIX}" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')X${stamp}"
    -ID_LOW=$(to_lower "$ID_MIX")
    -
    -if [ "${AUTH401_FULL}" = "1" ] && [ "$AUTH401_CREATE" = "1" ] && [ "$HAVE_SP" = "1" ]; then
    -  if [ "$MANDATORY_TAN" = "1" ] && [ "$AUTH401_SKIP_IF_MFA" = "1" ]; then
    -    warn "signup skipped" "mandatory_tan_channels set — set AUTH401_INSTANCE+PASSWORD"
    -  else
    -    body=$(instance_body "$ID_MIX" "$CREATE_PASS" "auth401 signup MIX")
    -    ccode=$(_http_norm "$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -      -o "$tmp/create.json" -w '%{http_code}' \
    -      -X POST "${BASE}/instances" \
    -      -H 'Content-Type: application/json' \
    -      -d "$body" 2>/dev/null || true)")
    -    case "$ccode" in
    -      200|204)
    -        ok "signup POST /instances id=MIX" "HTTP $ccode id=$ID_MIX (SPA self-provision)"
    -        CREATED=1
    -        USE_ID="$ID_MIX"
    -        USE_PASS="$CREATE_PASS"
    -        # Immediate login as SPA after signup: must use *lowercase* Basic user
    -        code=$(token_http "$ID_LOW" "$ID_LOW" "$CREATE_PASS")
    -        expect_http "signup→login SPA-sim path=low user=low → 200" 200 "$code" "$(json_code_hint)"
    -        # Bug path: SPA forgot toLowerCase on login after signup with MIX id typed
    -        code=$(token_http "$ID_LOW" "$ID_MIX" "$CREATE_PASS")
    -        expect_http "signup→login RAW user=MIX (no lower) → 401" 401 "$code" "$(json_code_hint)"
    -        ;;
    -      202)
    -        warn "signup needs MFA" "HTTP 202 — use AUTH401_INSTANCE"
    -        ;;
    -      *)
    -        fail "signup POST /instances" "HTTP $ccode $(head -c 120 "$tmp/create.json" 2>/dev/null | tr '\n' ' ')"
    -        ;;
    -    esac
    -  fi
    -elif [ -n "${AUTH401_INSTANCE:-}" ] && [ -n "${AUTH401_PASSWORD:-}" ]; then
    -  USE_ID=$(to_lower "$AUTH401_INSTANCE")
    -  USE_PASS="$AUTH401_PASSWORD"
    -  ID_LOW=$(to_lower "$USE_ID")
    -  ID_MIX=$(synth_mix "$ID_LOW")
    -  info "signup" "using existing AUTH401_INSTANCE=$USE_ID (no POST /instances)"
    -  ok "using existing instance" "$USE_ID"
    -else
    -  warn "signup skipped" "CREATE=${AUTH401_CREATE} HAVE_SP=${HAVE_SP} (need instance for matrix)"
    -fi
    -
    -# Fallback durable
    -if [ -z "$USE_ID" ] && [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then
    -  USE_ID="$DURABLE_ID"
    -  USE_PASS="$DURABLE_PASS"
    -  ID_LOW=$(to_lower "$USE_ID")
    -  ID_MIX=$(synth_mix "$ID_LOW")
    -  info "fallback durable" "$USE_ID"
    -  ok "using durable instance" "$USE_ID"
    -fi
    -
    -if [ -z "$USE_ID" ]; then
    -  fail "no instance under test" "need signup OK or AUTH401_INSTANCE+PASSWORD or mon401.password"
    -  _auth401_abort_or_continue "no instance under test"
    -  # CONTINUE: synthesize dummy ids so later groups still run (expect many 404/401)
    -  ID_LOW_USE="auth401-missing-instance"
    -  ID_USER_MIX=$(synth_mix "$ID_LOW_USE")
    -  ID_PATH_MIX="$ID_USER_MIX"
    -  ID_UPPER=$(to_upper "$ID_LOW_USE")
    -  ID_PARTMIX=$(synth_partmix "$ID_LOW_USE")
    -  PASS_OK="invalid"
    -  PASS_BAD='auth401-wrong-password-NOT-VALID'
    -  USE_ID="$ID_LOW_USE"
    -  USE_PASS="$PASS_OK"
    -fi
    -
    -ID_LOW_USE=$(to_lower "$USE_ID")
    -# Always synthetic case variants (even for mon401) so MIX ≠ low
    -ID_USER_MIX=$(synth_mix "$ID_LOW_USE")
    -ID_PATH_MIX="$ID_USER_MIX"
    -ID_UPPER=$(to_upper "$ID_LOW_USE")
    -ID_PARTMIX=$(synth_partmix "$ID_LOW_USE")
    -PASS_OK="$USE_PASS"
    -PASS_BAD='auth401-wrong-password-NOT-VALID'
    -
    -info "probe ids" "low=${ID_LOW_USE} MIX_user=${ID_USER_MIX} UPPER=${ID_UPPER} created=${CREATED} pass_len=${#PASS_OK}"
    -
    -# ===========================================================================
    -set_group login
    -# WebUI login = POST …/private/token with Basic (username, password)
    -# ===========================================================================
    -if [ "${AUTH401_FULL}" = "1" ]; then
    -  code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
    -  expect_http "login SPA-sim user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
    -  code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$PASS_OK")
    -  expect_http "login RAW user=MIX pwd=OK → 401 (#11340)" 401 "$code" "$(json_code_hint)"
    -  code=$(token_http "$ID_LOW_USE" "$ID_UPPER" "$PASS_OK")
    -  expect_http "login RAW user=UPPER pwd=OK → 401" 401 "$code" "$(json_code_hint)"
    -  code=$(token_http "$ID_PATH_MIX" "$ID_LOW_USE" "$PASS_OK")
    -  expect_http "login path=MIX user=low pwd=OK → 200 (path tolerant)" 200 "$code" "$(json_code_hint)"
    -  code=$(token_http "$ID_UPPER" "$ID_LOW_USE" "$PASS_OK")
    -  expect_http "login path=UPPER user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
    -fi
    -
    -# ===========================================================================
    -set_group case
    -# Full Basic case matrix (always hard expects — synthetic MIX)
    -# ===========================================================================
    -code=$(token_http "$ID_PATH_MIX" "$ID_USER_MIX" "$PASS_OK")
    -expect_http "path=MIX user=MIX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_PATH_MIX" "$ID_LOW_USE" "$PASS_OK")
    -expect_http "path=MIX user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$PASS_OK")
    -expect_http "path=low user=MIX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
    -expect_http "path=low user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_UPPER" "$ID_LOW_USE" "$PASS_OK")
    -expect_http "path=UPPER user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_LOW_USE" "$ID_UPPER" "$PASS_OK")
    -expect_http "path=low user=UPPER pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_LOW_USE" "$ID_PARTMIX" "$PASS_OK")
    -expect_http "path=low user=MiX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)"
    -
    -# ===========================================================================
    -set_group password
    -# ===========================================================================
    -code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_BAD")
    -expect_http "pwd=BAD → 401" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "")
    -expect_http "pwd=EMPTY → 401" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_PATH_MIX" "$ID_USER_MIX" "$PASS_BAD")
    -expect_http "path=MIX user=MIX pwd=BAD → 401" 401 "$code" "$(json_code_hint)"
    -
    -# ===========================================================================
    -set_group pwchange
    -# WebUI password change: POST private/auth then re-login
    -# Only on throwaway signup instances — never rotate durable mon401.password
    -# ===========================================================================
    -if [ "${AUTH401_FULL}" = "1" ]; then
    -  if [ "${AUTH401_PWCHANGE_THROWAY_ONLY}" = "1" ] && [ "${CREATED:-0}" != "1" ]; then
    -    info "pwchange skipped" "not a throwaway instance (CREATED=0) — refuse to rotate durable password"
    -  else
    -    code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
    -    if [ "$code" != "200" ]; then
    -      fail "pwchange: login for token" "HTTP $code — skip pwchange"
    -    else
    -      TOK=$(extract_token)
    -      if [ -z "$TOK" ]; then
    -        fail "pwchange: parse token" "empty access_token"
    -      else
    -        ok "pwchange: got bearer" "len=${#TOK}"
    -        NEW_PASS="Auth401-New-${stamp}!"
    -        ac=$(_http_norm "$(auth_change_http "$ID_LOW_USE" "$TOK" "$NEW_PASS")")
    -        case "$ac" in
    -          200|204)
    -            ok "pwchange POST private/auth" "HTTP $ac (SPA password form)"
    -            code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
    -            expect_http "pwchange: old pwd → 401" 401 "$code" "$(json_code_hint)"
    -            code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$NEW_PASS")
    -            expect_http "pwchange: new pwd user=low → 200" 200 "$code" "$(json_code_hint)"
    -            code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$NEW_PASS")
    -            expect_http "pwchange: new pwd user=MIX → 401" 401 "$code" "$(json_code_hint)"
    -            PASS_OK="$NEW_PASS"
    -            USE_PASS="$NEW_PASS"
    -            ;;
    -          202)
    -            warn "pwchange needs MFA" "HTTP 202"
    -            ;;
    -          *)
    -            fail "pwchange POST private/auth" "HTTP $ac $(head -c 100 "$tmp/auth_change.json" 2>/dev/null | tr '\n' ' ')"
    -            ;;
    -        esac
    -      fi
    -    fi
    -  fi
    -fi
    -
    -# ===========================================================================
    -set_group admin
    -# Admin UI: POST /management/instances (needs admin bearer)
    -# ===========================================================================
    -if [ "${AUTH401_FULL}" = "1" ]; then
    -  ADMIN_TOK="${AUTH401_ADMIN_TOKEN:-}"
    -  # Optional: login as admin user with password
    -  if [ -z "$ADMIN_TOK" ] && [ -n "${AUTH401_ADMIN_PASSWORD}" ]; then
    -    code=$(token_http "$AUTH401_ADMIN_USER" "$AUTH401_ADMIN_USER" "$AUTH401_ADMIN_PASSWORD")
    -    if [ "$code" = "200" ]; then
    -      ADMIN_TOK=$(extract_token)
    -      ok "admin login user=${AUTH401_ADMIN_USER}" "got token"
    -    else
    -      # try lower only
    -      au=$(to_lower "$AUTH401_ADMIN_USER")
    -      code=$(token_http "$au" "$au" "$AUTH401_ADMIN_PASSWORD")
    -      if [ "$code" = "200" ]; then
    -        ADMIN_TOK=$(extract_token)
    -        ok "admin login user=${au}" "got token"
    -      else
    -        warn "admin login" "HTTP $code — set AUTH401_ADMIN_TOKEN"
    -      fi
    -    fi
    -  fi
    -
    -  if [ -z "$ADMIN_TOK" ]; then
    -    info "admin-create skipped" "no AUTH401_ADMIN_TOKEN / AUTH401_ADMIN_PASSWORD (stage default is not admin)"
    -  else
    -    case "$ADMIN_TOK" in secret-token:*) ;; *) ADMIN_TOK="secret-token:${ADMIN_TOK}" ;; esac
    -    AID_MIX="Adm401X${stamp}"
    -    AID_LOW=$(to_lower "$AID_MIX")
    -    APASS="Adm401-Pass-${stamp}!"
    -    body=$(instance_body "$AID_MIX" "$APASS" "auth401 admin-create MIX")
    -    acode=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -      -o "$tmp/admin_create.json" -w '%{http_code}' \
    -      -X POST "${BASE}/management/instances" \
    -      -H "Authorization: Bearer ${ADMIN_TOK}" \
    -      -H 'Content-Type: application/json' \
    -      -d "$body" 2>/dev/null || echo 000)
    -    case "$acode" in
    -      200|204)
    -        ok "admin-create POST /management/instances id=MIX" "HTTP $acode id=$AID_MIX"
    -        code=$(token_http "$AID_LOW" "$AID_LOW" "$APASS")
    -        expect_http "admin-create→login user=low → 200" 200 "$code" "$(json_code_hint)"
    -        code=$(token_http "$AID_LOW" "$AID_MIX" "$APASS")
    -        expect_http "admin-create→login user=MIX → 401" 401 "$code" "$(json_code_hint)"
    -        # admin-set auth on new instance
    -        code=$(token_http "$AID_LOW" "$AID_LOW" "$APASS")
    -        if [ "$code" = "200" ]; then
    -          AT=$(extract_token)
    -          NEW_A="Adm401-New-${stamp}!"
    -          ch=$(auth_change_http "$AID_LOW" "$AT" "$NEW_A")
    -          case "$ch" in
    -            200|204)
    -              ok "admin-created instance pwchange" "HTTP $ch"
    -              code=$(token_http "$AID_LOW" "$AID_LOW" "$NEW_A")
    -              expect_http "admin-created re-login new pwd → 200" 200 "$code" "$(json_code_hint)"
    -              ;;
    -            *) warn "admin-created pwchange" "HTTP $ch" ;;
    -          esac
    -        fi
    -        ;;
    -      401|403)
    -        warn "admin-create unauthorized" "HTTP $acode — token not admin (expected on stage default)"
    -        ;;
    -      202)
    -        warn "admin-create MFA" "HTTP 202"
    -        ;;
    -      *)
    -        fail "admin-create" "HTTP $acode $(head -c 120 "$tmp/admin_create.json" 2>/dev/null | tr '\n' ' ')"
    -        ;;
    -    esac
    -  fi
    -fi
    -
    -# ===========================================================================
    -set_group missing
    -# ===========================================================================
    -code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -  -o "$tmp/last_token.json" -w '%{http_code}' \
    -  -X POST "${BASE}/instances/${ID_LOW_USE}/private/token" \
    -  -H 'Content-Type: application/json' \
    -  -d '{"scope":"write","duration":{"d_us":3600000000},"refreshable":true}' \
    -  2>/dev/null || echo 000)
    -expect_http "private/token NO_AUTH → 401" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "$ID_LOW_USE" "auth401-not-a-real-user" "$PASS_OK")
    -expect_http "user=OTHER pwd=OK → 401" 401 "$code" "$(json_code_hint)"
    -
    -code=$(token_http "auth401-does-not-exist-${stamp}" "$ID_LOW_USE" "$PASS_OK")
    -# Must not look like auth failure (401). Prefer 404; 502 from proxy is soft.
    -case "$code" in
    -  404) ok "path=missing → 404 (not 401)" "HTTP 404 · $(json_code_hint)" ;;
    -  401) fail "path=missing must not be 401" "HTTP 401 · $(json_code_hint)" ;;
    -  502|503) warn "path=missing" "HTTP $code (want 404; proxy flake, not auth)" ;;
    -  *) fail "path=missing → 404 (not 401)" "HTTP $code (want 404) · $(json_code_hint)" ;;
    -esac
    -
    -# ===========================================================================
    -set_group bearer
    -# ===========================================================================
    -code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
    -if [ "$code" = "200" ]; then
    -  TOKEN=$(extract_token)
    -  if [ -n "$TOKEN" ]; then
    -    ok "got access_token" "len=${#TOKEN}"
    -    code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -      -o "$tmp/priv.json" -w '%{http_code}' \
    -      "${BASE}/instances/${ID_LOW_USE}/private/" \
    -      -H "Authorization: Bearer ${TOKEN}" 2>/dev/null || echo 000)
    -    expect_http "GET private/ Bearer OK → 200" 200 "$code"
    -
    -    code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -      -o "$tmp/priv.json" -w '%{http_code}' \
    -      "${BASE}/instances/${ID_LOW_USE}/private/" \
    -      -H "Authorization: Bearer secret-token:AUTH401INVALID000" 2>/dev/null || echo 000)
    -    expect_http "GET private/ Bearer BAD → 401" 401 "$code"
    -
    -    code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -      -o "$tmp/priv.json" -w '%{http_code}' \
    -      "${BASE}/instances/${ID_LOW_USE}/private/" 2>/dev/null || echo 000)
    -    expect_http "GET private/ NO_AUTH → 401" 401 "$code"
    -  else
    -    fail "parse access_token" "HTTP $code but no token field"
    -  fi
    -else
    -  fail "token for bearer tests" "HTTP $code"
    -fi
    -
    -code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -  -o "$tmp/mgmt.json" -w '%{http_code}' \
    -  "${BASE}/management/instances" 2>/dev/null || echo 000)
    -expect_http "GET /management/instances NO_AUTH → 401" 401 "$code"
    -
    -if [ -n "${AUTH401_BEARER:-}" ]; then
    -  if [ -z "${AUTH401_BEARER_INSTANCE:-}" ]; then
    -    case "${AUTH401_BEARER_NOTE:-}" in
    -      *default-instance-token*) AUTH401_BEARER_INSTANCE=default ;;
    -      *) AUTH401_BEARER_INSTANCE="${DURABLE_ID:-default}" ;;
    -    esac
    -  fi
    -  inst=$(to_lower "$AUTH401_BEARER_INSTANCE")
    -  tok="$AUTH401_BEARER"
    -  case "$tok" in secret-token:*) ;; *) tok="secret-token:${tok}" ;; esac
    -  code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
    -    -o "$tmp/priv2.json" -w '%{http_code}' \
    -    "${BASE}/instances/${inst}/private/" \
    -    -H "Authorization: Bearer ${tok}" 2>/dev/null || echo 000)
    -  case "$code" in
    -    200) ok "GET private/ secrets Bearer" "HTTP 200 instance=$inst" ;;
    -    401) warn "secrets Bearer rejected" "HTTP 401 on $inst" ;;
    -    *) warn "secrets Bearer" "HTTP $code on $inst" ;;
    -  esac
    -fi
    -
    -# ===========================================================================
    -set_group durable
    -# Extra: durable mon401 still works after throwaway tests (if present)
    -# ===========================================================================
    -if [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then
    -  dlow=$(to_lower "$DURABLE_ID")
    -  dmix=$(synth_mix "$dlow")
    -  code=$(token_http "$dlow" "$dlow" "$DURABLE_PASS")
    -  expect_http "durable ${dlow} login user=low → 200" 200 "$code" "$(json_code_hint)"
    -  code=$(token_http "$dlow" "$dmix" "$DURABLE_PASS")
    -  expect_http "durable ${dlow} login user=MIX → 401" 401 "$code" "$(json_code_hint)"
    -else
    -  info "durable" "no mon401.password — skipped"
    -fi
    -
    -# ===========================================================================
    -set_group report
    -# ===========================================================================
    -info "summary matrix" "401: Basic user case≠low | bad/empty pwd | no auth | bad bearer | wrong user · 200: user=low + path any case · 404: missing instance · SPA must toLowerCase username on login/signup/admin/pw-relogin"
    -if [ "$CREATED" = "1" ]; then
    -  info "throwaway left" "id=${ID_MIX:-?} low=${ID_LOW_USE} (password rotated if pwchange ran)"
    -fi
    -
    -# fall through → EXIT trap runs _auth401_finish (summary + full ERROR list)
    -:
    diff --git a/scripts/taler-monitoring/check_e2e.sh b/scripts/taler-monitoring/check_e2e.sh
    deleted file mode 100755
    index 05cb5f7..0000000
    --- a/scripts/taler-monitoring/check_e2e.sh
    +++ /dev/null
    @@ -1,1711 +0,0 @@
    -#!/usr/bin/env bash
    -# Full withdraw + pay cycle; clear BLOCKERS when the path cannot finish.
    -# Skips remaining steps if overall budget exceeded (E2E_TIMEOUT, default 90s).
    -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)
    -: "${E2E_SETTLE_ROUNDS:=18}"
    -: "${E2E_SETTLE_SLEEP:=3}"
    -E2E_START=$(date +%s)
    -BAL_BEFORE="(not captured)"
    -BAL_AFTER="(not captured)"
    -E2E_REPORTED=0
    -e2e_left() { echo $(( E2E_TIMEOUT - ($(date +%s) - E2E_START) )); }
    -e2e_over() { [ "$(e2e_left)" -le 0 ]; }
    -
    -# Fixed short timeout — used even when e2e budget is exhausted / on EXIT
    -# stdout only for JSON; logs go to stderr file so balance parse stays clean
    -wcli_bal_snap() {
    -  local out="$1"
    -  if [ -z "${WALLET_CLI:-}" ]; then
    -    echo "(no wallet)" >"$out"
    -    return 1
    -  fi
    -  # one-shot needs DB file; serve mode needs socket
    -  if [ -z "${WSERVE_PID:-}" ] || [ ! -S "${WSOCK:-}" ]; then
    -    if [ ! -f "${WDB:-}" ]; then
    -      echo "(no wallet)" >"$out"
    -      return 1
    -    fi
    -  fi
    -  local err="${out}.err"
    -  if command -v perl >/dev/null 2>&1; then
    -    if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then
    -      perl -e 'alarm shift; exec @ARGV' 10 \
    -        node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle balance \
    -        >"$out" 2>"$err" || true
    -    else
    -      perl -e 'alarm shift; exec @ARGV' 10 \
    -        node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \
    -        >"$out" 2>"$err" || true
    -    fi
    -  else
    -    if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then
    -      node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle balance \
    -        >"$out" 2>"$err" || true
    -    else
    -      node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \
    -        >"$out" 2>"$err" || true
    -    fi
    -  fi
    -  [ -s "$out" ]
    -}
    -
    -fmt_bal() {
    -  python3 -c '
    -import json,re,sys
    -raw=open(sys.argv[1]).read() if sys.argv[1] else ""
    -if not raw.strip() or raw.strip().startswith("(no"):
    -  print(raw.strip() or "(empty)"); sys.exit(0)
    -# extract first JSON object with "balances"
    -d=None
    -for m in re.finditer(r"\{", raw):
    -  try:
    -    d=json.loads(raw[m.start():])
    -    if isinstance(d, dict) and "balances" in d:
    -      break
    -  except Exception:
    -    d=None
    -if not isinstance(d, dict):
    -  print(re.sub(r"\s+"," ", raw)[:200]); sys.exit(0)
    -parts=[]
    -for b in d.get("balances") or []:
    -  cur=(b.get("scopeInfo") or {}).get("currency") or "?"
    -  parts.append("%s avail=%s pendingIn=%s" % (cur, b.get("available"), b.get("pendingIncoming")))
    -print("; ".join(parts) if parts else "(no balances)")
    -' "${1:-/dev/null}" 2>/dev/null || echo "(unreadable)"
    -}
    -
    -# Numeric available for currency CUR (from balance file or fresh wallet snap)
    -wallet_avail_num() {
    -  local f="${1:-}"
    -  if [ -z "$f" ] || [ ! -s "$f" ]; then
    -    f="$SCRATCH/bal-live.out"
    -    wcli_bal_snap "$f" || wcli balance >"$f" 2>&1 || true
    -  fi
    -  python3 -c '
    -import json,re,sys
    -cur=sys.argv[2]
    -raw=open(sys.argv[1]).read() if sys.argv[1] else ""
    -d=None
    -for m in re.finditer(r"\{", raw):
    -  try:
    -    d=json.loads(raw[m.start():])
    -    if isinstance(d, dict) and "balances" in d: break
    -  except Exception:
    -    d=None
    -if isinstance(d, dict):
    -  for b in d.get("balances") or []:
    -    c=(b.get("scopeInfo") or {}).get("currency") or ""
    -    av=b.get("available") or ""
    -    if c==cur or av.startswith(cur+":"):
    -      try:
    -        print(float(av.split(":",1)[1])); sys.exit(0)
    -      except Exception:
    -        pass
    -# text fallback
    -m=re.search(r"%s:([0-9]+(?:\.[0-9]+)?)" % re.escape(cur), raw)
    -print(float(m.group(1)) if m else 0.0)
    -' "${f:-/dev/null}" "${CUR:-GOA}" 2>/dev/null || echo 0
    -}
    -
    -# Wait for spendable balance (settlement / wirewatch lag). Returns 0 if avail > min.
    -# Emphasizes timing, not hard failure, while waiting.
    -wait_wallet_balance() {
    -  local min_n="${1:-0}"
    -  local rounds="${2:-15}"
    -  local sleep_s="${3:-3}"
    -  local r av
    -  # Poll balance only — never run-until-done (hangs; banned in monitoring).
    -  info "wallet settle wait" "up to ${rounds}×${sleep_s}s for avail>${min_n} ${CUR} (poll balance only; no run-until-done)"
    -  for r in $(seq 1 "$rounds"); do
    -    wcli_bal_snap "$SCRATCH/bal-live.out" || wcli balance >"$SCRATCH/bal-live.out" 2>&1 || true
    -    av=$(wallet_avail_num "$SCRATCH/bal-live.out")
    -    if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > float(sys.argv[2]) else 1)" "$av" "$min_n" 2>/dev/null; then
    -      ok "wallet balance ready" "avail=${CUR}:${av} after ~$((r * sleep_s))s settle wait"
    -      info "balance" "$(fmt_bal "$SCRATCH/bal-live.out")"
    -      return 0
    -    fi
    -    if [ "$r" = "1" ] || [ "$((r % 3))" = "0" ]; then
    -      info "settlement lag" "round $r/${rounds}: avail=${CUR}:${av} (still waiting — not an error yet)"
    -    fi
    -    # gentle wirewatch nudge mid-wait (local only)
    -    if [ "$r" = "4" ] || [ "$r" = "8" ]; then
    -      if [ "${E2E_REMOTE:-0}" != "1" ] && koopa_ssh_ok; then
    -        koopa_ssh_run 8 \
    -          'podman exec taler-hacktivism-exchange-ansible systemctl restart taler-exchange-wirewatch 2>/dev/null || true' \
    -          >/dev/null 2>&1 || true
    -      fi
    -    fi
    -    sleep "$sleep_s"
    -  done
    -  av=$(wallet_avail_num "$SCRATCH/bal-live.out")
    -  warn "settlement lag" "after ${rounds}×${sleep_s}s still avail=${CUR}:${av} — coins may still be in flight"
    -  return 1
    -}
    -
    -print_balances() {
    -  section "e2e · wallet balances"
    -  info "BALANCE before" "$BAL_BEFORE"
    -  # refresh after if we can
    -  if [ -n "${SCRATCH:-}" ]; then
    -    wcli_bal_snap "$SCRATCH/bal-end.out" || true
    -    if [ -s "$SCRATCH/bal-end.out" ]; then
    -      BAL_AFTER=$(fmt_bal "$SCRATCH/bal-end.out")
    -    fi
    -  fi
    -  info "BALANCE after " "$BAL_AFTER"
    -}
    -
    -e2e_finish() {
    -  local code="${1:-1}"
    -  if [ "$E2E_REPORTED" = "1" ]; then
    -    return "$code"
    -  fi
    -  E2E_REPORTED=1
    -  print_balances
    -  # Final coin inventory + host/container load + statistics dashboard
    -  if [ -n "${METRICS_DIR:-}" ]; then
    -    section "metrics · e2e coins final"
    -    metrics_report_coins "e2e-end" || true
    -    if [ "${METRICS_LOAD:-1}" != "0" ]; then
    -      metrics_report_load "${METRICS_DIR}/load-after.json" "e2e-end" || true
    -    fi
    -    metrics_print_overall "e2e final" || true
    -  fi
    -  summary || true
    -  return "$code"
    -}
    -
    -e2e_skip_rest() {
    -  local why="$1"
    -  warn "e2e timeout" "budget ${E2E_TIMEOUT}s exceeded — $why"
    -  blocker "e2e-timeout" "skipped remaining steps after ${E2E_TIMEOUT}s ($why)"
    -  section "e2e · report"
    -  info "WITHDRAW" "incomplete (timeout)"
    -  info "PAY" "SKIPPED (timeout)"
    -  # If we already started a withdrawal, dig inside for missing coins
    -  if [ -n "${WID:-}" ]; then
    -    dig_when_no_coins 2>/dev/null || true
    -  fi
    -  e2e_finish 1
    -  exit 1
    -}
    -
    -# Area e2e-### — withdraw + pay cycle
    -set_area e2e
    -set_group prereq
    -section "e2e · prerequisites"
    -info "e2e budget" "${E2E_TIMEOUT}s (set E2E_TIMEOUT= to change)"
    -
    -# Remote domains (not koopa): public path only, tiny amounts, abort on login/KYC
    -: "${E2E_REMOTE:=0}"
    -if [ "${LOCAL_STACK:-1}" != "1" ]; then
    -  E2E_REMOTE=1
    -  SKIP_SSH=1
    -  E2E_FAKE_INCOMING=0
    -fi
    -
    -# Currency for amounts / balance checks
    -CUR="${EXPECT_CURRENCY:-}"
    -if [ -z "$CUR" ]; then
    -  CUR=$(curl -skS -m 8 "$BANK_PUBLIC/config" 2>/dev/null | python3 -c 'import sys,json
    -try: print(json.load(sys.stdin).get("currency") or "")
    -except Exception: print("")' 2>/dev/null || true)
    -fi
    -[ -z "$CUR" ] && CUR=$(curl -skS -m 8 "$EXCHANGE_PUBLIC/config" 2>/dev/null | python3 -c 'import sys,json
    -try: print(json.load(sys.stdin).get("currency") or "")
    -except Exception: print("")' 2>/dev/null || true)
    -[ -z "$CUR" ] && CUR="GOA"
    -
    -# ---------------------------------------------------------------------------
    -# Variable amounts
    -#   Withdraw = typical ATM notes only (Bankomat): 20 / 50 / 100 / 200 …
    -#   Pay      = several smaller purchase amounts
    -# Env:
    -#   E2E_WITHDRAW_VALUES="20 50 100"   # bare numbers → prefixed with CUR
    -#   E2E_PAY_VALUES="0.01 0.05 0.5 1 2 5"
    -#   E2E_VARIABLE=0                    # single fixed WITHDRAW_AMT / PAY_AMT
    -#   E2E_ATM_MAX=N                     # try at most N ATM withdraws (budget)
    -# ---------------------------------------------------------------------------
    -: "${E2E_VARIABLE:=1}"
    -: "${E2E_ATM_MAX:=5}"
    -if [ "$E2E_REMOTE" = "1" ]; then
    -  # remote: still ATM-shaped; TESTPAYSAN needs larger notes for shop pays
    -  if [ "$CUR" = "TESTPAYSAN" ]; then
    -    : "${E2E_WITHDRAW_VALUES:=20 50}"
    -    # farmer shop template face values (oeufs-6=5, fromage=8.5, jus=6)
    -    : "${E2E_PAY_VALUES:=5 8.5 6}"
    -  else
    -    : "${E2E_WITHDRAW_VALUES:=10 20 50}"
    -    : "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}"
    -  fi
    -else
    -  # local GOA: classic ATM notes + 4200 for paivana paywall template
    -  : "${E2E_WITHDRAW_VALUES:=20 50 100 200 4200}"
    -  : "${E2E_PAY_VALUES:=0.01 0.05 0.1 0.5 1 2 5 10}"
    -fi
    -# Public template pays (landing/shop style) — default on for TESTPAYSAN stage
    -if [ -z "${E2E_USE_TEMPLATES:-}" ]; then
    -  if [ "$CUR" = "TESTPAYSAN" ]; then E2E_USE_TEMPLATES=1; else E2E_USE_TEMPLATES=0; fi
    -fi
    -: "${E2E_USE_TEMPLATES:=0}"
    -
    -# Build CUR:amount lists
    -build_amt_list() {
    -  local cur="$1"; shift
    -  local v out=""
    -  for v in "$@"; do
    -    case "$v" in
    -      *:*) out="${out}${out:+ }$v" ;;
    -      *)   out="${out}${out:+ }${cur}:${v}" ;;
    -    esac
    -  done
    -  printf '%s' "$out"
    -}
    -# shellcheck disable=SC2086
    -WITHDRAW_LIST=$(build_amt_list "$CUR" $E2E_WITHDRAW_VALUES)
    -# shellcheck disable=SC2086
    -PAY_LIST=$(build_amt_list "$CUR" $E2E_PAY_VALUES)
    -
    -if [ "$E2E_VARIABLE" != "1" ]; then
    -  if [ "$E2E_REMOTE" = "1" ]; then
    -    WITHDRAW_LIST="${WITHDRAW_AMT:-${CUR}:20}"
    -    PAY_LIST="${PAY_AMT:-${CUR}:0.01}"
    -  else
    -    WITHDRAW_LIST="${WITHDRAW_AMT:-${CUR}:20}"
    -    PAY_LIST="${PAY_AMT:-${CUR}:0.01}"
    -  fi
    -fi
    -
    -# Credit = sum of planned ATM withdraws + small buffer for fees
    -CREDIT_AMT=$(python3 -c '
    -import sys
    -cur=sys.argv[1]
    -vals=sys.argv[2].split()
    -s=0.0
    -for a in vals:
    -  try: s += float(a.split(":",1)[-1])
    -  except Exception: pass
    -buf = max(10.0, s * 0.05)
    -print("%s:%g" % (cur, s + buf))
    -' "$CUR" "$WITHDRAW_LIST" 2>/dev/null || echo "${CUR}:100")
    -
    -# First withdraw/pay for legacy single-step labels
    -WITHDRAW_AMT=$(printf '%s' "$WITHDRAW_LIST" | awk '{print $1}')
    -PAY_AMT=$(printf '%s' "$PAY_LIST" | awk '{print $1}')
    -
    -# More wall time when running ATM ladder
    -if [ "$E2E_VARIABLE" = "1" ]; then
    -  n_w=$(printf '%s' "$WITHDRAW_LIST" | wc -w | tr -d ' ')
    -  n_p=$(printf '%s' "$PAY_LIST" | wc -w | tr -d ' ')
    -  need=$(( 50 + n_w * 40 + n_p * 20 + 60 ))  # + room for paivana pay
    -  if [ "${E2E_TIMEOUT}" -lt "$need" ]; then
    -    E2E_TIMEOUT=$need
    -  fi
    -  # cap ATM attempts but always keep CUR:4200 if listed (paivana)
    -  WITHDRAW_LIST=$(
    -    printf '%s' "$WITHDRAW_LIST" | tr ' ' '\n' | awk -v max="$E2E_ATM_MAX" -v big="${CUR}:4200" '
    -      NF==0 { next }
    -      $0==big { has=1; next }
    -      { small[++n]=$0 }
    -      END {
    -        keep = max - (has ? 1 : 0)
    -        if (keep < 0) keep = 0
    -        for (i = 1; i <= n && i <= keep; i++) printf "%s ", small[i]
    -        if (has) printf "%s", big
    -        print ""
    -      }' | sed 's/ *$//'
    -  )
    -fi
    -
    -BANK_HOST=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$BANK_PUBLIC"'").hostname or "bank")' 2>/dev/null || echo bank)
    -
    -SCRATCH=$(mktemp -d)
    -WDB="$SCRATCH/wallet.sqlite3"
    -WSOCK="$SCRATCH/wallet.sock"
    -WSERVE_PID=""
    -# Client/server wallet (advanced serve): long-lived shepherd without run-until-done.
    -# Default on — wallet-cli only completes withdraw/pay while a serve process is up.
    -: "${E2E_WALLET_SERVE:=1}"
    -METRICS_DIR="${METRICS_DIR:-$SCRATCH/metrics}"
    -mkdir -p "$METRICS_DIR"
    -export METRICS_DIR CUR="${CUR:-${EXPECT_CURRENCY:-GOA}}" WDB
    -export PATH="${HOME}/.local/bin:${HOME}/taler/opt/taler-wallet-cli/usr/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
    -
    -stop_wallet_serve() {
    -  if [ -n "${WSERVE_PID:-}" ] && kill -0 "$WSERVE_PID" 2>/dev/null; then
    -    kill "$WSERVE_PID" 2>/dev/null || true
    -    wait "$WSERVE_PID" 2>/dev/null || true
    -  fi
    -  WSERVE_PID=""
    -  rm -f "${WSOCK:-}" 2>/dev/null || true
    -}
    -
    -# Always dump balances on any exit (timeout, blocker, signal, success)
    -trap 'ec=$?; e2e_finish "$ec"; stop_wallet_serve; rm -rf "$SCRATCH"; exit "$ec"' EXIT
    -
    -# Abort cleanly on login / KYC / registration barriers (esp. remote domains)
    -e2e_abort_auth() {
    -  local step="$1" msg="$2"
    -  warn "$step" "$msg"
    -  blocker "$step" "abort (login/KYC): $msg"
    -  section "e2e · report"
    -  info "ABORTED" "login or KYC blocked further e2e — not retrying"
    -  info "hint" "remote e2e needs open bank registration + credit path; set E2E_BANK_ADMIN_PASS / E2E_MERCHANT_TOKEN if you have them"
    -  e2e_finish 1
    -  exit 1
    -}
    -
    -is_auth_kyc_body() {
    -  # stdin or file arg: true if looks like login/KYC barrier
    -  local f="${1:-}"
    -  local t
    -  if [ -n "$f" ] && [ -f "$f" ]; then t=$(head -c 800 "$f" 2>/dev/null || true)
    -  else t=$(cat 2>/dev/null || true); fi
    -  printf '%s' "$t" | grep -qiE 'kyc|legitim|login required|unauthorized|forbidden|captcha|challenge|tan_|not.?allowed|registration.?disabled|admin.?only|permission.?denied|401|403'
    -}
    -
    -WALLET_CLI=$(find_wallet_cli) || {
    -  blocker "prereq" "taler-wallet-cli not found (set WALLET_CLI=)"
    -  exit 1
    -}
    -# Implementation version (--version) + wallet-core API ranges (version command)
    -WALLET_IMPL_VER=$(
    -  node "$WALLET_CLI" --version 2>/dev/null | head -1 | tr -d '\r' || true
    -)
    -WALLET_CORE_VER=$(
    -  _wver_db=$(mktemp "${TMPDIR:-/tmp}/wver.XXXXXX.sqlite3" 2>/dev/null || echo "${TMPDIR:-/tmp}/wver-$$.sqlite3")
    -  node "$WALLET_CLI" --wallet-db="$_wver_db" --no-throttle --skip-defaults version 2>/dev/null \
    -    | python3 -c '
    -import json,re,sys
    -raw=sys.stdin.read()
    -d=None
    -for m in re.finditer(r"\{", raw):
    -  try:
    -    d=json.loads(raw[m.start():])
    -    if isinstance(d, dict) and "version" in d:
    -      break
    -  except Exception:
    -    d=None
    -if not isinstance(d, dict):
    -  sys.exit(0)
    -parts=[]
    -# wallet-core libversion + protocol ranges used against exchange/merchant/bank
    -for k in ("version","implementationSemver","exchange","merchant","bank","bankIntegrationApiRange","corebankApiRange"):
    -  if k in d and d[k] is not None:
    -    parts.append("%s=%s" % (k, d[k]))
    -print(" ".join(parts))
    -' 2>/dev/null || true
    -  rm -f "$_wver_db" "$_wver_db"-* 2>/dev/null || true
    -)
    -ok "wallet-cli" "${WALLET_IMPL_VER:-?} · ${WALLET_CLI}"
    -if [ -n "${WALLET_CORE_VER:-}" ]; then
    -  info "wallet-core" "$WALLET_CORE_VER"
    -else
    -  info "wallet-core" "version details unavailable (impl ${WALLET_IMPL_VER:-?})"
    -fi
    -info "e2e mode" "$([ "$E2E_REMOTE" = "1" ] && echo "remote/public domain (no SSH)" || echo "local koopa stack")"
    -info "currency" "$CUR"
    -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)
    -set_group load
    -section "e2e · load snapshot (before withdraw/pay)"
    -metrics_report_load "${METRICS_DIR}/load-before.json" "e2e-start" || true
    -
    -# Local stack: koopa secrets. Remote: env first; TESTPAYSAN stage may load francpaysan host secrets.
    -set_group prereq
    -if [ "$E2E_REMOTE" = "1" ]; then
    -  ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-${BANK_ADMIN_PASS:-}}"
    -  MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}"
    -  # Stage FrancPaysan: allow admin credit without committing passwords.
    -  # Order: already-set env → local secrets tree → SSH francpaysan-host (ops laptop).
    -  if [ -z "$ADMIN_PASS" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then
    -    _fp_try=""
    -    for _fp_try in \
    -      "${FRANCPAYSAN_SECRETS:-}/stage/bank-admin-password.txt" \
    -      "${HOME}/francpaysan-secrets/stage/bank-admin-password.txt" \
    -      "${HOME}/src/francpaysan-secrets/stage/bank-admin-password.txt" \
    -      "${HOME}/taler/src/francpaysan-secrets/stage/bank-admin-password.txt"
    -    do
    -      if [ -n "$_fp_try" ] && [ -f "$_fp_try" ]; then
    -        ADMIN_PASS=$(tr -d '\n\r' <"$_fp_try")
    -        info "bank admin secret" "from $_fp_try"
    -        break
    -      fi
    -    done
    -    if [ -z "$ADMIN_PASS" ] && command -v ssh >/dev/null 2>&1; then
    -      for _fp_host in "${FRANCPAYSAN_SSH:-francpaysan-host}" francpaysan-host; do
    -        ADMIN_PASS=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "$_fp_host" \
    -          'sudo cat /mnt/data/stagepaysan/bank/secrets/bank-admin-password.txt 2>/dev/null' \
    -          2>/dev/null | tr -d '\n\r' || true)
    -        if [ -n "$ADMIN_PASS" ]; then
    -          info "bank admin secret" "from ${_fp_host}:/mnt/data/stagepaysan/bank/secrets/"
    -          break
    -        fi
    -      done
    -    fi
    -    unset _fp_try _fp_host
    -  fi
    -  # Stage merchant: public templates need no token; optional default-instance token
    -  if [ -z "$MPW" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then
    -    for _fp_try in \
    -      "${FRANCPAYSAN_SECRETS:-}/stage/default-instance-token.txt" \
    -      "${HOME}/francpaysan-secrets/stage/default-instance-token.txt"
    -    do
    -      if [ -n "$_fp_try" ] && [ -f "$_fp_try" ]; then
    -        MPW=$(tr -d '\n\r' <"$_fp_try")
    -        break
    -      fi
    -    done
    -    if [ -z "$MPW" ] && command -v ssh >/dev/null 2>&1; then
    -      MPW=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "${FRANCPAYSAN_SSH:-francpaysan-host}" \
    -        'sudo cat /mnt/data/stagepaysan/merchant/secrets/default-instance-token.txt 2>/dev/null' \
    -        2>/dev/null | tr -d '\n\r' || true)
    -    fi
    -    unset _fp_try
    -  fi
    -else
    -  ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-$(read_secret "taler-bank/bank-admin-password.txt" || true)}"
    -  MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-$(read_secret "taler-merchant/merchant-goa-demo-cp4zqk-password.txt" || true)}}"
    -fi
    -if [ -n "$ADMIN_PASS" ]; then
    -  ok "bank admin secret" "${SECRETS_ROOT:+SECRETS_ROOT=${SECRETS_ROOT}}"
    -elif [ "$E2E_REMOTE" = "1" ]; then
    -  warn "bank admin secret" "missing — will try public registration only"
    -else
    -  blocker "prereq" "bank admin password missing (SECRETS_ROOT or koopa /root)"
    -  info "secrets" "SECRETS_ROOT=${SECRETS_ROOT:-'(unset)'}"
    -  if type secrets_hint >/dev/null 2>&1; then
    -    while IFS= read -r _line; do info "secrets-hint" "$_line"; done < <(secrets_hint)
    -  fi
    -  exit 1
    -fi
    -if [ -n "$MPW" ]; then
    -  # Secrets often store full "secret-token:…"; strip before re-prefixing Bearer.
    -  MPW=$(printf '%s' "$MPW" | tr -d '\n\r')
    -  case "$MPW" in
    -    secret-token:*) MPW_TOKEN="$MPW" ;;
    -    *) MPW_TOKEN="secret-token:${MPW}" ;;
    -  esac
    -  ok "merchant secret (instance ${MERCHANT_INSTANCE})"
    -elif [ "$E2E_REMOTE" = "1" ]; then
    -  MPW_TOKEN=""
    -  warn "merchant secret" "missing — private orders fail unless E2E_USE_TEMPLATES=1 (set E2E_MERCHANT_TOKEN)"
    -else
    -  blocker "prereq" "merchant instance password missing"
    -  info "secrets" "SECRETS_ROOT=${SECRETS_ROOT:-'(unset)'} — need taler-merchant/merchant-*-password.txt"
    -  if type secrets_hint >/dev/null 2>&1; then
    -    while IFS= read -r _line; do info "secrets-hint" "$_line"; done < <(secrets_hint)
    -  fi
    -  exit 1
    -fi
    -# Authorization header for merchant private API (never double secret-token:)
    -merchant_auth_header() {
    -  local t="${1:-${MPW_TOKEN:-}}"
    -  [ -n "$t" ] || return 1
    -  case "$t" in
    -    secret-token:*) printf 'Authorization: Bearer %s' "$t" ;;
    -    *) printf 'Authorization: Bearer secret-token:%s' "$t" ;;
    -  esac
    -}
    -
    -# Public reachability gates (remote: soft-skip if bank/merchant absent)
    -for pair in \
    -  "bank|$BANK_PUBLIC/config" \
    -  "exchange|$EXCHANGE_PUBLIC/config" \
    -  "exchange-keys|$EXCHANGE_PUBLIC/keys" \
    -  "bank-integration|$BANK_PUBLIC/taler-integration/config" \
    -  "merchant|$MERCHANT_PUBLIC/config"
    -do
    -  IFS='|' read -r name url <<<"$pair"
    -  code=$(http_code "$url")
    -  if [ "$code" = "200" ]; then
    -    ok "reachable $name"
    -  else
    -    if [ "$E2E_REMOTE" = "1" ] && [[ "$name" == bank* || "$name" == merchant ]]; then
    -      warn "reachable $name" "HTTP $code — e2e may abort"
    -    else
    -      blocker "prereq" "$name HTTP $code ($url) — cannot start payment cycle"
    -    fi
    -  fi
    -done
    -# Remote: need at least bank+exchange+merchant for a full cycle
    -if [ "$E2E_REMOTE" = "1" ]; then
    -  bc=$(http_code "$BANK_PUBLIC/config")
    -  ec=$(http_code "$EXCHANGE_PUBLIC/config")
    -  mc=$(http_code "$MERCHANT_PUBLIC/config")
    -  if [ "$bc" != "200" ] || [ "$ec" != "200" ]; then
    -    e2e_abort_auth "prereq" "bank/exchange not publicly usable (bank=$bc exchange=$ec) — skip e2e"
    -  fi
    -  if [ "$mc" != "200" ]; then
    -    warn "prereq" "merchant HTTP $mc — will try withdraw only if possible"
    -  fi
    -fi
    -if [ "${#BLOCKERS[@]}" -gt 0 ]; then
    -  exit 1
    -fi
    -
    -USER="mon$(date +%s | tail -c 6)"
    -USER_PW=$(python3 -c 'import secrets;print(secrets.token_urlsafe(10))')
    -info "e2e user" "$USER  withdraw=$WITHDRAW_AMT pay=$PAY_AMT"
    -BANK="$BANK_PUBLIC"
    -INST="$MERCHANT_INSTANCE"
    -
    -start_wallet_serve() {
    -  [ "${E2E_WALLET_SERVE}" = "1" ] || return 0
    -  [ -n "${WALLET_CLI:-}" ] && [ -f "${WALLET_CLI}" ] || return 1
    -  stop_wallet_serve
    -  rm -f "$WSOCK"
    -  # serve holds the DB open; clients use --wallet-connection only
    -  node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults \
    -    advanced serve --unix-path "$WSOCK" \
    -    >"$SCRATCH/wallet-serve.log" 2>&1 &
    -  WSERVE_PID=$!
    -  local i
    -  for i in $(seq 1 40); do
    -    if [ -S "$WSOCK" ] && kill -0 "$WSERVE_PID" 2>/dev/null; then
    -      ok "wallet serve" "pid=$WSERVE_PID sock=$WSOCK (no run-until-done)"
    -      return 0
    -    fi
    -    sleep 0.15
    -  done
    -  warn "wallet serve" "socket not ready — falling back to one-shot CLI (coins may stay pending)"
    -  stop_wallet_serve
    -  return 1
    -}
    -
    -# $1 = max seconds for this call (optional); rest = wallet-cli args
    -# Prefer live serve socket so the shepherd stays up (alternative to run-until-done).
    -wcli() {
    -  local maxc=12
    -  if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
    -    maxc=$1
    -    shift
    -  fi
    -  e2e_over && return 124
    -  local cap
    -  cap=$(e2e_left)
    -  [ "$cap" -gt "$maxc" ] && cap=$maxc
    -  [ "$cap" -lt 3 ] && return 124
    -  if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then
    -    if command -v perl >/dev/null 2>&1; then
    -      perl -e 'alarm shift; exec @ARGV' "$cap" \
    -        node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@"
    -    else
    -      node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@"
    -    fi
    -  else
    -    if command -v perl >/dev/null 2>&1; then
    -      perl -e 'alarm shift; exec @ARGV' "$cap" \
    -        node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"
    -    else
    -      node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"
    -    fi
    -  fi
    -}
    -
    -# Pay must not be starved: use reserved seconds even if global budget is tight
    -wcli_pay() {
    -  local cap=$E2E_PAY_SECS
    -  local left
    -  left=$(e2e_left)
    -  if [ "$left" -gt "$cap" ]; then
    -    cap=$E2E_PAY_SECS
    -  elif [ "$left" -ge 8 ]; then
    -    cap=$left
    -  else
    -    # still try once with floor 12s so Alarm clock is not the blocker
    -    cap=12
    -  fi
    -  info "pay wallet cap" "${cap}s"
    -  if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then
    -    if command -v perl >/dev/null 2>&1; then
    -      perl -e 'alarm shift; exec @ARGV' "$cap" \
    -        node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@"
    -    else
    -      node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@"
    -    fi
    -  else
    -    if command -v perl >/dev/null 2>&1; then
    -      perl -e 'alarm shift; exec @ARGV' "$cap" \
    -        node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"
    -    else
    -      node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"
    -    fi
    -  fi
    -}
    -
    -# When coins never become available: run inside + targeted bank/exchange dig
    -dig_when_no_coins() {
    -  set_group dig
    -  section "e2e · coins missing — inside dig"
    -  info "reason" "wallet empty after withdraw path (often wirewatch timing) — probing bank + exchange"
    -  if [ -n "${WID:-}" ]; then
    -    curl -sS -m 6 -o "$SCRATCH/wd-dig.json" \
    -      "$BANK_PUBLIC/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || true
    -    info "backend withdrawal-operation" "$(python3 -c '
    -import json
    -try:
    - d=json.load(open("'"$SCRATCH"'/wd-dig.json"))
    - print("status=%s transfer_done=%s selection_done=%s amount=%s reserve=%s" % (
    -  d.get("status"), d.get("transfer_done"), d.get("selection_done"),
    -  d.get("amount"), (d.get("selected_reserve_pub") or "-")[:24]))
    -except Exception as e:
    - print("unreadable", e)
    -' 2>/dev/null)"
    -  fi
    -  # Full component inside report (capped SSH; ignore its exit)
    -  if [ "${SKIP_SSH}" != "1" ] && [ -x "$ROOT/check_inside.sh" ]; then
    -    info "inside" "running check_inside.sh …"
    -    # subshell so its PASS_N/FAIL_N/summary don't poison ours; still print to stdout
    -    (
    -      # shellcheck disable=SC2030
    -      "$ROOT/check_inside.sh" || true
    -    ) || true
    -  else
    -    warn "inside" "skipped (SKIP_SSH=1 or check_inside.sh missing)"
    -  fi
    -  # Extra targeted: wirewatch + recent journal + bank→exchange path
    -  if koopa_ssh_ok; then
    -    section "e2e · coins missing — exchange/bank focus"
    -    DIG=$(koopa_ssh_bash 15 <<'REMOTE' || true
    -set +e
    -emit() { printf 'D|%s|%s\n' "$1" "$(printf '%s' "$2" | tr '\n' ' ' | head -c 220)"; }
    -EX=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1)
    -BANK=$(podman ps --format '{{.Names}}' | grep -iE 'hacktivism-bank|taler-bank' | head -1)
    -[ -z "$BANK" ] && BANK=$(podman ps --format '{{.Names}}' | grep -i bank | head -1)
    -if [ -n "$EX" ]; then
    -  podman exec "$EX" pgrep -af taler-exchange-wirewatch 2>/dev/null | head -1 | \
    -    { read -r l; [ -n "$l" ] && emit OK "wirewatch: $l" || emit ERROR "wirewatch not running"; }
    -  podman exec "$EX" pgrep -af taler-exchange-transfer 2>/dev/null | head -1 | \
    -    { read -r l; [ -n "$l" ] && emit OK "transfer: $l" || emit WARN "transfer not running"; }
    -  podman exec "$EX" pgrep -af taler-exchange-aggregator 2>/dev/null | head -1 | \
    -    { read -r l; [ -n "$l" ] && emit OK "aggregator: $l" || emit WARN "aggregator not running"; }
    -  # last wirewatch log lines if journal available
    -  j=$(podman exec "$EX" bash -c 'journalctl -u "taler-exchange-wirewatch*" -n 8 --no-pager 2>/dev/null | tail -5' 2>/dev/null | tr '\n' ';' | head -c 280)
    -  [ -n "$j" ] && emit INFO "wirewatch journal: $j"
    -  # reserve lookup via exchange if tools exist (best-effort)
    -  c=$(curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000)
    -  emit INFO "exchange /keys HTTP $c"
    -else
    -  emit ERROR "no exchange container"
    -fi
    -if [ -n "$BANK" ]; then
    -  podman exec "$BANK" pgrep -af 'MainKt serve|libeufin-bank serve' 2>/dev/null | head -1 | \
    -    { read -r l; [ -n "$l" ] && emit OK "libeufin: $l" || emit ERROR "libeufin not running"; }
    -  c=$(curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:9012/taler-integration/config 2>/dev/null || echo 000)
    -  emit INFO "bank integration /config HTTP $c"
    -else
    -  emit ERROR "no bank container"
    -fi
    -REMOTE
    -)
    -    while IFS= read -r line; do
    -      case "$line" in
    -        D\|*)
    -          IFS="|" read -r _ lvl msg <<<"$line"
    -          case "$lvl" in
    -            OK) ok "dig $msg" ;;
    -            ERROR) err "dig" "$msg" ;;
    -            WARN) warn "dig" "$msg" ;;
    -            INFO) info "dig" "$msg" ;;
    -          esac
    -          ;;
    -      esac
    -    done <<<"$DIG"
    -  fi
    -}
    -
    -# Baseline wallet balance (empty DB) — always shown even if we abort later
    -wcli_bal_snap "$SCRATCH/bal-before.out" || true
    -BAL_BEFORE=$(fmt_bal "$SCRATCH/bal-before.out")
    -info "BALANCE before" "$BAL_BEFORE"
    -
    -# ---------------------------------------------------------------------------
    -set_group bank
    -section "e2e · bank (account · credit · withdraw)"
    -# ---------------------------------------------------------------------------
    -AT=""
    -if [ -n "${ADMIN_PASS:-}" ]; then
    -  AT=$(curl -sS -m 15 -u "admin:${ADMIN_PASS}" -H 'Content-Type: application/json' \
    -    -d '{"scope":"readwrite"}' \
    -    "$BANK/accounts/admin/token" 2>"$SCRATCH/at.err" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || true)
    -fi
    -if [ -n "$AT" ]; then
    -  ok "admin token"
    -elif [ "$E2E_REMOTE" = "1" ]; then
    -  warn "admin token" "unavailable — trying open registration"
    -else
    -  if is_auth_kyc_body "$SCRATCH/at.err"; then
    -    e2e_abort_auth "bank-auth" "admin login blocked (KYC/auth) — $(head -c 80 "$SCRATCH/at.err" 2>/dev/null)"
    -  fi
    -  blocker "bank-auth" "admin token failed — check admin password / bank up ($(head -c 80 "$SCRATCH/at.err" 2>/dev/null))"
    -  exit 1
    -fi
    -
    -# Create account: with admin bearer if we have it, else unauthenticated registration (demo banks)
    -if [ -n "$AT" ]; then
    -  code=$(curl -sS -m 15 -o "$SCRATCH/acc.json" -w '%{http_code}' -X POST \
    -    -H "Authorization: Bearer $AT" -H 'Content-Type: application/json' \
    -    -d "{\"username\":\"${USER}\",\"password\":\"${USER_PW}\",\"name\":\"Monitoring\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"${CUR}:1000\"}" \
    -    "$BANK/accounts")
    -else
    -  code=$(curl -sS -m 15 -o "$SCRATCH/acc.json" -w '%{http_code}' -X POST \
    -    -H 'Content-Type: application/json' \
    -    -d "{\"username\":\"${USER}\",\"password\":\"${USER_PW}\",\"name\":\"Monitoring\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"${CUR}:1000\"}" \
    -    "$BANK/accounts")
    -fi
    -if [ "$code" = "200" ] || [ "$code" = "201" ]; then
    -  ok "create account $USER"
    -elif [ "$code" = "401" ] || [ "$code" = "403" ] || is_auth_kyc_body "$SCRATCH/acc.json"; then
    -  e2e_abort_auth "bank-account" "registration/login blocked HTTP $code — $(head -c 100 "$SCRATCH/acc.json" | tr '\n' ' ')"
    -else
    -  if [ "$E2E_REMOTE" = "1" ]; then
    -    e2e_abort_auth "bank-account" "POST /accounts HTTP $code — cannot open account on this domain"
    -  fi
    -  blocker "bank-account" "POST /accounts HTTP $code — $(head -c 100 "$SCRATCH/acc.json" | tr '\n' ' ')"
    -  exit 1
    -fi
    -
    -if [ -n "$AT" ]; then
    -  export USER_NAME="$USER" CREDIT_AMT="$CREDIT_AMT" OUT="$SCRATCH/credit.json" BANK_HOST="$BANK_HOST"
    -  python3 - <<'PY'
    -import json, os
    -ALPH = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
    -def crock32(b):
    -  bits = val = 0; o = []
    -  for byte in b:
    -    val = (val << 8) | byte; bits += 8
    -    while bits >= 5:
    -      bits -= 5; o.append(ALPH[(val >> bits) & 31])
    -  if bits: o.append(ALPH[(val << (5 - bits)) & 31])
    -  return "".join(o)
    -uid = crock32(os.urandom(32))
    -user = os.environ["USER_NAME"]
    -host = os.environ.get("BANK_HOST") or "bank"
    -json.dump({
    -  "payto_uri": f"payto://x-taler-bank/{host}/{user}?receiver-name={user}&message=mon",
    -  "amount": os.environ["CREDIT_AMT"],
    -  "request_uid": uid,
    -}, open(os.environ["OUT"], "w"))
    -PY
    -  curl -sS -m 15 -o "$SCRATCH/credit.out" -H "Authorization: Bearer $AT" \
    -    -H 'Content-Type: application/json' -d @"$SCRATCH/credit.json" \
    -    "$BANK/accounts/admin/transactions" >/dev/null || true
    -  if grep -q row_id "$SCRATCH/credit.out" 2>/dev/null; then
    -    ok "credit $CREDIT_AMT → $USER"
    -  else
    -    if is_auth_kyc_body "$SCRATCH/credit.out"; then
    -      e2e_abort_auth "bank-credit" "admin credit blocked (KYC/auth)"
    -    fi
    -    if [ "$E2E_REMOTE" = "1" ]; then
    -      e2e_abort_auth "bank-credit" "admin transfer failed — no credit path on remote ($(head -c 80 "$SCRATCH/credit.out" | tr '\n' ' '))"
    -    fi
    -    blocker "bank-credit" "admin transfer failed — $(head -c 100 "$SCRATCH/credit.out" | tr '\n' ' ')"
    -    exit 1
    -  fi
    -else
    -  # No admin: remote demo may start funded or require cash-in — try withdraw later; warn
    -  warn "bank-credit" "skipped (no admin) — withdraw may fail without balance"
    -fi
    -
    -UT=$(curl -sS -m 15 -u "${USER}:${USER_PW}" -H 'Content-Type: application/json' \
    -  -d '{"scope":"readwrite"}' \
    -  "$BANK/accounts/${USER}/token" 2>"$SCRATCH/ut.err" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || true)
    -if [ -n "$UT" ]; then
    -  ok "user token"
    -elif is_auth_kyc_body "$SCRATCH/ut.err" || is_auth_kyc_body /dev/null; then
    -  e2e_abort_auth "bank-auth" "user token failed (login/KYC) for $USER"
    -else
    -  e2e_abort_auth "bank-auth" "user token failed for $USER — $(head -c 80 "$SCRATCH/ut.err" 2>/dev/null)"
    -fi
    -
    -# ---------------------------------------------------------------------------
    -set_group wallet
    -section "e2e · wallet setup (exchange + ToS once)"
    -# ---------------------------------------------------------------------------
    -# Long-lived serve = client/server wallet (shepherd keeps running; no run-until-done).
    -start_wallet_serve || true
    -if ! wcli exchanges add "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-add.out" 2>&1; then
    -  if ! grep -qiE 'already|ok' "$SCRATCH/ex-add.out"; then
    -    blocker "wallet-exchange" "exchanges add failed — $(tail -c 160 "$SCRATCH/ex-add.out" | tr '\n' ' ')"
    -  else
    -    ok "wallet exchange known"
    -  fi
    -else
    -  ok "wallet add exchange"
    -fi
    -wcli exchanges update "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-up.out" 2>&1 || true
    -if wcli exchanges accept-tos "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-tos.out" 2>&1; then
    -  ok "wallet accept ToS"
    -else
    -  if grep -qiE 'pending|tos|terms' "$SCRATCH/ex-tos.out"; then
    -    blocker "wallet-tos" "accept-tos failed — wallet may refuse withdraw ($(tail -c 120 "$SCRATCH/ex-tos.out" | tr '\n' ' '))"
    -  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 '' \
    -    "$BANK/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || true
    -  python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("status",""))' 2>/dev/null || true
    -}
    -wd_status_detail() {
    -  python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"))
    -print("status=%s reserve=%s exchange=%s" % (
    -  d.get("status"),
    -  (d.get("selected_reserve_pub") or "-")[:20],
    -  (d.get("selected_exchange") or d.get("selected_exchange_account") or "-")[:60],
    -))' 2>/dev/null || echo "(no status body)"
    -}
    -
    -fake_incoming_speedup() {
    -  [ "${E2E_FAKE_INCOMING}" = "1" ] || return 0
    -  st=$(wd_status)
    -  RPUB=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("selected_reserve_pub") or "")' 2>/dev/null || true)
    -  AMT=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("amount") or "'"$WITHDRAW_AMT"'")' 2>/dev/null || echo "$WITHDRAW_AMT")
    -  DEBIT=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("sender_wire") or "")' 2>/dev/null || true)
    -  [ -z "$DEBIT" ] && DEBIT="payto://x-taler-bank/${BANK_HOST}/${USER}?receiver-name=${USER}"
    -  [ -z "$RPUB" ] && return 0
    -  [ -z "${AT:-}" ] && return 0
    -  WG="${BANK}/accounts/exchange/taler-wire-gateway/admin/add-incoming"
    -  curl -sS -m 10 -o "$SCRATCH/fake-in.json" -w '%{http_code}' -X POST \
    -    -H "Authorization: Bearer $AT" -H 'Content-Type: application/json' \
    -    -d "{\"amount\":\"${AMT}\",\"reserve_pub\":\"${RPUB}\",\"debit_account\":\"${DEBIT}\"}" \
    -    "$WG" >/dev/null || true
    -  if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then
    -    koopa_ssh_run 22 \
    -      'podman exec taler-hacktivism-exchange-ansible bash -c "runuser -u taler-exchange-wire -- timeout 15 taler-exchange-wirewatch -c /etc/taler-exchange/taler-exchange.conf -t -L INFO 2>&1 | tail -5"' \
    -      >/dev/null 2>&1 || true
    -  fi
    -}
    -
    -# One ATM-style withdraw: create → accept → select → confirm → settle
    -# returns 0 on wallet balance increase, 1 on soft fail (ladder continues)
    -e2e_one_withdraw() {
    -  WITHDRAW_AMT="$1"
    -  local tag
    -  tag=$(printf '%s' "$WITHDRAW_AMT" | tr '.:' '__')
    -  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' \
    -    -d "{\"amount\":\"${WITHDRAW_AMT}\",\"exchange_url\":\"${EXCHANGE_PUBLIC}/\"}" \
    -    "$BANK/accounts/${USER}/withdrawals"
    -  WID=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("withdrawal_id") or d.get("id") or "")' 2>/dev/null || true)
    -  URI=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("taler_withdraw_uri") or "")' 2>/dev/null || true)
    -  URI_CLEAN=$(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)
    -  if [ -z "$WID" ] || [ -z "$URI_CLEAN" ]; then
    -    warn "ATM withdraw $WITHDRAW_AMT" "create failed — $(head -c 80 "$SCRATCH/wd-$tag.json" | tr '\n' ' ')"
    -    return 1
    -  fi
    -  ok "ATM withdrawal created $WITHDRAW_AMT ($WID)"
    -  cp "$SCRATCH/wd-$tag.json" "$SCRATCH/wd.json"
    -
    -  USE_URI="$URI"
    -  [ -z "$USE_URI" ] && USE_URI="$URI_CLEAN"
    -  if wcli withdraw accept-uri --exchange "${EXCHANGE_PUBLIC}/" "$USE_URI" >"$SCRATCH/accept-$tag.out" 2>&1; then
    -    ok "wallet accept $WITHDRAW_AMT"
    -  elif [ "$USE_URI" != "$URI_CLEAN" ] && [ -n "$URI_CLEAN" ] \
    -    && wcli withdraw accept-uri --exchange "${EXCHANGE_PUBLIC}/" "$URI_CLEAN" >"$SCRATCH/accept-$tag.out" 2>&1; then
    -    ok "wallet accept $WITHDRAW_AMT (no :443)"
    -  else
    -    warn "ATM withdraw $WITHDRAW_AMT" "accept-uri failed — $(tail -c 100 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')"
    -    return 1
    -  fi
    -  cp "$SCRATCH/accept-$tag.out" "$SCRATCH/accept.out"
    -
    -  # Poll bank withdrawal status only (no run-until-done). force-select if stuck pending.
    -  st=""
    -  for i in 1 2 3 4 5 6 8 10; do
    -    e2e_over && break
    -    st=$(wd_status)
    -    case "$st" in selected|confirmed|aborted) break ;; esac
    -    sleep 0.4
    -  done
    -  if [ "$st" != "selected" ] && [ "$st" != "confirmed" ]; then
    -    wcli transactions >"$SCRATCH/tx-pre.json" 2>&1 || true
    -    RPUB=$(python3 -c '
    -import re
    -t=open("'"$SCRATCH"'/accept.out").read()+open("'"$SCRATCH"'/tx-pre.json").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 "")
    -' 2>/dev/null || true)
    -    EPAYTO=$(curl -sS -m 12 "$EXCHANGE_PUBLIC/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/sel.json" -X POST \
    -        -H 'Content-Type: application/json' \
    -        -d "{\"reserve_pub\":\"$RPUB\",\"selected_exchange\":\"$EPAYTO\"}" \
    -        "$BANK/taler-integration/withdrawal-operation/${WID}" >/dev/null || true
    -      st=$(wd_status)
    -    fi
    -  fi
    -  if [ "$st" != "selected" ] && [ "$st" != "confirmed" ]; then
    -    warn "ATM withdraw $WITHDRAW_AMT" "not selected (status=${st:-?})"
    -    return 1
    -  fi
    -  if [ "$st" != "confirmed" ]; then
    -    code=$(curl -sS -m 15 -o "$SCRATCH/conf-$tag.json" -w '%{http_code}' -X POST \
    -      -H "Authorization: Bearer $UT" -H 'Content-Type: application/json' -d '{}' \
    -      "$BANK/accounts/${USER}/withdrawals/${WID}/confirm")
    -    case "$code" in
    -      200|204) ok "bank confirm $WITHDRAW_AMT" ;;
    -      *) warn "ATM withdraw $WITHDRAW_AMT" "confirm HTTP $code"; return 1 ;;
    -    esac
    -  fi
    -  fake_incoming_speedup
    -
    -  # Short per-ATM settle: poll balance + bank transfer_done only (never run-until-done)
    -  local ok_bal=0 r av xfer
    -  for r in 1 2 3 4 5 6 8 10; do
    -    wcli_bal_snap "$SCRATCH/bal-$tag.out" || wcli balance >"$SCRATCH/bal-$tag.out" 2>&1 || true
    -    av=$(wallet_avail_num "$SCRATCH/bal-$tag.out")
    -    if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > 0 else 1)" "$av" 2>/dev/null; then
    -      ok_bal=1
    -      ok "wallet funded after ATM $WITHDRAW_AMT" "avail=${CUR}:${av}"
    -      info "balance" "$(fmt_bal "$SCRATCH/bal-$tag.out")"
    -      break
    -    fi
    -    xfer=$(curl -sS -m 8 "$BANK/taler-integration/withdrawal-operation/${WID}" 2>/dev/null \
    -      | 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
    -      ok "ATM $WITHDRAW_AMT bank transfer_done" "wallet avail=${CUR}:${av} ($xfer) — no run-until-done"
    -      ok_bal=1
    -      break
    -    fi
    -    sleep 2
    -  done
    -  metrics_report_coins "after-ATM-${tag}" || true
    -  if [ "$ok_bal" = "1" ]; then
    -    metrics_record_flow withdrawn "$WITHDRAW_AMT" || true
    -    return 0
    -  fi
    -  # Bank side often already confirmed — treat as timing lag, not hard fail
    -  st=$(wd_status)
    -  if [ "$st" = "confirmed" ]; then
    -    warn "ATM withdraw $WITHDRAW_AMT" "bank confirmed; wallet not funded yet (settlement timing — will recheck later)"
    -    return 2
    -  fi
    -  warn "ATM withdraw $WITHDRAW_AMT" "no wallet balance yet (status=${st:-?})"
    -  return 1
    -}
    -
    -# Map CUR:amount → public template id.
    -# GOA default: e2e-* on goa-demo. TESTPAYSAN: farmer shop templates (set in apply_taler_domain).
    -# Override with E2E_TEMPLATE_MAP="5=oeufs-6 8.5=fromage-chevre"
    -e2e_template_id_for_amount() {
    -  local amt="$1"
    -  local val map_entry k v
    -  val=$(printf '%s' "$amt" | awk -F: '{print $NF}')
    -  if [ -z "${E2E_TEMPLATE_MAP:-}" ]; then
    -    if [ "${CUR:-}" = "TESTPAYSAN" ]; then
    -      E2E_TEMPLATE_MAP="5=oeufs-6 4.5=pain-seigle 6=jus-pomme 8.5=fromage-chevre 12=miel-printemps 25=panier-legumes"
    -    else
    -      E2E_TEMPLATE_MAP="0.01=e2e-001 0.05=e2e-005 1=e2e-1 1.0=e2e-1"
    -    fi
    -  fi
    -  for map_entry in $E2E_TEMPLATE_MAP; do
    -    k="${map_entry%%=*}"
    -    v="${map_entry#*=}"
    -    if [ "$k" = "$val" ]; then
    -      printf '%s' "$v"
    -      return 0
    -    fi
    -  done
    -  return 1
    -}
    -
    -# Template id → merchant instance (stage farmer shops / GOA goa-shop)
    -e2e_template_instance_for_id() {
    -  local pid="$1"
    -  case "$pid" in
    -    oeufs-6|pain-seigle|jus-pomme) printf '%s' "jardin-du-creux" ;;
    -    panier-legumes|fromage-chevre|miel-printemps) printf '%s' "fermes-des-collines" ;;
    -    e2e-001|e2e-005|e2e-1|orbit-sticker|nebula-coffee|voidwave-playlist|comet-cap|shuttle-pass|beacon-badge|relay-pin|eclipse-shades|star-chart|rainbow-pill|blue-or-red-pill)
    -      printf '%s' "${E2E_SHOP_INSTANCE:-goa-shop}"
    -      ;;
    -    *) printf '%s' "${MERCHANT_INSTANCE:-default}" ;;
    -  esac
    -}
    -
    -# One payment: e2e_one_pay AMOUNT [SUMMARY] [TAG]
    -# SUMMARY defaults to "monitoring pay $AMOUNT"; TAG defaults from amount.
    -# When E2E_USE_TEMPLATES=1 and amount maps to a public template, uses
    -# POST /instances/{inst}/templates/{id} (same as landings/shops).
    -e2e_one_pay() {
    -  PAY_AMT="$1"
    -  local PAY_SUM="${2:-monitoring pay ${PAY_AMT}}"
    -  local tag="${3:-}"
    -  if [ -z "$tag" ]; then
    -    tag=$(printf '%s' "$PAY_AMT" | tr '.:' '__')
    -  fi
    -  # shell-safe tag for files
    -  tag=$(printf '%s' "$tag" | tr -c 'A-Za-z0-9._-' '_')
    -  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
    -
    -  # Prefer public templates when enabled (stage TESTPAYSAN / shop-style orders)
    -  local tpl_id=""
    -  if [ "${E2E_USE_TEMPLATES:-0}" = "1" ]; then
    -    tpl_id=$(e2e_template_id_for_amount "$PAY_AMT" 2>/dev/null || true)
    -    if [ -n "$tpl_id" ]; then
    -      info "pay $PAY_AMT" "via public template $tpl_id (E2E_USE_TEMPLATES=1)"
    -      e2e_one_pay_public_template "$tpl_id" "$PAY_SUM" "$PAY_AMT"
    -      return $?
    -    fi
    -    warn "pay $PAY_AMT" "no template map for amount — fall back to private order"
    -  fi
    -
    -  if [ -z "${MPW:-}" ] && [ -z "${MPW_TOKEN:-}" ]; then
    -    warn "pay $PAY_AMT" "no merchant token"
    -    return 1
    -  fi
    -  local AUTH
    -  AUTH=$(merchant_auth_header) || {
    -    warn "pay $PAY_AMT" "no merchant token"
    -    return 1
    -  }
    -  # JSON-escape summary for curl -d
    -  local SUM_JSON
    -  SUM_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$PAY_SUM" 2>/dev/null || printf '"%s"' "$PAY_SUM")
    -  curl -skS -m 15 -o "$SCRATCH/ord-$tag.json" -X POST \
    -    -H "$AUTH" -H 'Content-Type: application/json' \
    -    -d "{\"order\":{\"summary\":${SUM_JSON},\"amount\":\"${PAY_AMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}" \
    -    "${MERCHANT_PUBLIC}/instances/${INST}/private/orders" 2>"$SCRATCH/ord-$tag.err" || true
    -  if ! grep -q order_id "$SCRATCH/ord-$tag.json" 2>/dev/null; then
    -    if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then
    -      local mpw_raw="${MPW_TOKEN:-secret-token:${MPW}}"
    -      koopa_ssh_run 20 \
    -        "curl -skS -m 12 -X POST -H 'Authorization: Bearer ${mpw_raw}' -H 'Content-Type: application/json' -d '{\"order\":{\"summary\":${SUM_JSON},\"amount\":\"${PAY_AMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}' 'https://127.0.0.1:9010/instances/${INST}/private/orders'" \
    -        >"$SCRATCH/ord-$tag.json" 2>"$SCRATCH/ord-$tag.err" || true
    -    fi
    -  fi
    -  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-$tag.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-$tag.json" 2>/dev/null || true)
    -  if [ -z "$OID" ]; then
    -    if is_auth_kyc_body "$SCRATCH/ord-$tag.json"; then
    -      e2e_abort_auth "merchant-order" "merchant auth/KYC on pay $PAY_AMT ($PAY_SUM)"
    -    fi
    -    warn "pay $PAY_AMT ($PAY_SUM)" "order create failed — $(head -c 80 "$SCRATCH/ord-$tag.json" | tr '\n' ' ')"
    -    return 1
    -  fi
    -  ok "merchant order $OID ($PAY_AMT · $PAY_SUM)"
    -  curl -skS -m 12 -o "$SCRATCH/ord-det-$tag.json" -H "$AUTH" \
    -    "${MERCHANT_PUBLIC}/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-$tag.json" 2>/dev/null || true)
    -  if [ -z "$PAYURI" ] && [ -n "$OTOK" ]; then
    -    MH=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$MERCHANT_PUBLIC"'").hostname or "taler.hacktivism.ch")' 2>/dev/null || echo "taler.hacktivism.ch")
    -    PAYURI="taler://pay/${MH}/instances/${INST}/${OID}/?c=${OTOK}"
    -  fi
    -  # normalize default HTTPS port (wallets accept both)
    -  PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g')
    -  if [ -z "$PAYURI" ]; then
    -    warn "pay $PAY_AMT ($PAY_SUM)" "no pay URI for $OID"
    -    return 1
    -  fi
    -  ok "taler_pay_uri ready ($tag)"
    -  if ! wcli_pay handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$tag.out" 2>&1; then
    -    warn "pay $PAY_AMT ($PAY_SUM)" "handle-uri failed — $(tail -c 100 "$SCRATCH/pay-$tag.out" | tr '\n' ' ')"
    -    return 1
    -  fi
    -  ok "wallet handle pay $PAY_AMT ($PAY_SUM)"
    -  # Settle: poll merchant order + wallet transactions only (never run-until-done)
    -  local r
    -  for r in 1 2 3 4 5 6; do
    -    wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true
    -    if [ -n "${AUTH:-}" ]; then
    -      curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \
    -        "${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true
    -    fi
    -    if [ -n "${OTOK:-}" ]; then
    -      curl -skS -m 8 -o "$SCRATCH/ord-pub-$tag.json" \
    -        "${MERCHANT_PUBLIC}/instances/${INST}/orders/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OID")?token=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OTOK")" \
    -        2>/dev/null || true
    -    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 \
    -      || python3 -c 'import json,sys
    -for p in sys.argv[1:]:
    -  try:
    -    d=json.load(open(p))
    -    if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid":
    -      sys.exit(0)
    -  except Exception:
    -    pass
    -sys.exit(1)
    -' "$SCRATCH/ord-paid-$tag.json" "$SCRATCH/ord-pub-$tag.json" 2>/dev/null; then
    -      ok "payment settled $PAY_AMT ($PAY_SUM · order $OID)"
    -      metrics_report_coins "after-pay-${tag}" || true
    -      metrics_record_flow spent "$PAY_AMT" || true
    -      return 0
    -    fi
    -    sleep 1
    -  done
    -  metrics_report_coins "after-pay-fail-${tag}" || true
    -  warn "pay $PAY_AMT ($PAY_SUM)" "not settled for order $OID"
    -  return 1
    -}
    -
    -# GOA shop catalog (full list) — keep in sync with configs/merchant-landing/index.html
    -# id|product_name|amount
    -# Landing QR = taler://pay-template/…/{id}; popup = public POST templates/{id}.
    -# E2E does not pay every product every run: shuffle + pick E2E_SHOP_PICK_N (default 2).
    -: "${E2E_SHOP_PRODUCTS:=orbit-sticker|Orbit sticker pack|GOA:2
    -nebula-coffee|Nebula coffee|GOA:5
    -voidwave-playlist|Voidwave playlist|GOA:8
    -comet-cap|Comet cap|GOA:15
    -shuttle-pass|Shuttle day pass|GOA:25
    -beacon-badge|Beacon badge|GOA:3
    -relay-pin|Relay pin|GOA:4
    -eclipse-shades|Eclipse shades|GOA:12
    -star-chart|Star chart print|GOA:7
    -rainbow-pill|Rainbow pill (sample/joke)|GOA:4.2
    -blue-or-red-pill|Blue or red pill (sample/joke)|GOA:1.5}"
    -: "${E2E_SHOP_PICK_N:=2}"
    -# Public templates live on goa-shop (landing shop-pay.js); not the e2e default demo instance.
    -: "${E2E_SHOP_INSTANCE:=goa-shop}"
    -
    -# Settlement payto shown on landing shop popup (static)
    -: "${E2E_SHOP_PAYTO:=payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop}"
    -
    -# Pay one shop product the way the public landing does:
    -#   POST /instances/{inst}/templates/{id} body {} → order_id+token → taler_pay_uri
    -# Also checks pay-template URI shape (what qrencode PNGs encode).
    -# Usage: e2e_one_pay_public_template PRODUCT_ID PRODUCT_NAME AMOUNT
    -e2e_one_pay_public_template() {
    -  local pid="$1"
    -  local pname="${2:-$1}"
    -  local pamt="$3"
    -  local inst="${4:-}"
    -  local tag
    -  tag=$(printf 'shop_%s' "$pid" | tr -c 'A-Za-z0-9._-' '_')
    -  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
    -
    -  # Resolve instance: explicit arg → template map → global INST
    -  if [ -z "$inst" ]; then
    -    inst=$(e2e_template_instance_for_id "$pid")
    -  fi
    -  [ -n "$inst" ] || inst="${INST:-${MERCHANT_INSTANCE:-default}}"
    -
    -  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")
    -  local TPL_URI="taler://pay-template/${MH}/instances/${inst}/${pid}"
    -  local TPL_HTTPS="${MERCHANT_PUBLIC}/instances/${inst}/templates/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$pid")"
    -  info "shop $pname" "instance $inst · pay-template URI $TPL_URI"
    -  info "shop $pname" "public template POST $TPL_HTTPS"
    -  # Stage settlement payto: instance bank account; GOA uses E2E_SHOP_PAYTO
    -  local payto_show="${E2E_SHOP_PAYTO:-}"
    -  if [ "${CUR:-}" = "TESTPAYSAN" ]; then
    -    payto_show="payto://x-taler-bank/stage.bank.lefrancpaysan.ch/${inst}"
    -  fi
    -  info "shop $pname" "settlement payto ${payto_show:-(none)}"
    -
    -  # Public create (no merchant secret) — same as shop-pay.js
    -  curl -skS -m 15 -o "$SCRATCH/tpl-$tag.json" -X POST \
    -    -H 'Content-Type: application/json' \
    -    -d '{}' \
    -    "$TPL_HTTPS" 2>"$SCRATCH/tpl-$tag.err" || true
    -  if ! grep -q order_id "$SCRATCH/tpl-$tag.json" 2>/dev/null; then
    -    if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then
    -      koopa_ssh_run 20 \
    -        "curl -skS -m 12 -X POST -H 'Content-Type: application/json' -d '{}' 'https://127.0.0.1:9010/instances/${INST}/templates/${pid}'" \
    -        >"$SCRATCH/tpl-$tag.json" 2>"$SCRATCH/tpl-$tag.err" || true
    -    fi
    -  fi
    -  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/tpl-$tag.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/tpl-$tag.json" 2>/dev/null || true)
    -  if [ -z "$OID" ] || [ -z "$OTOK" ]; then
    -    warn "shop $pname ($pid)" "public template POST failed — $(head -c 100 "$SCRATCH/tpl-$tag.json" 2>/dev/null | tr '\n' ' ')"
    -    return 1
    -  fi
    -  ok "shop $pname" "public order $OID (template $pid)"
    -
    -  local statusUrl="${MERCHANT_PUBLIC}/instances/${INST}/orders/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OID")?token=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OTOK")"
    -  curl -skS -m 12 -o "$SCRATCH/tpl-st-$tag.json" "$statusUrl" 2>/dev/null || true
    -  PAYURI=$(python3 -c 'import json,sys
    -try:
    - d=json.load(open(sys.argv[1]))
    - print(d.get("taler_pay_uri") or "")
    -except Exception: print("")
    -' "$SCRATCH/tpl-st-$tag.json" 2>/dev/null || true)
    -  if [ -z "$PAYURI" ]; then
    -    PAYURI="taler://pay/${MH}/instances/${INST}/${OID}/?c=${OTOK}"
    -  fi
    -  PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g')
    -  if ! printf '%s' "$PAYURI" | grep -q '^taler://pay/'; then
    -    warn "shop $pname ($pid)" "bad pay URI: $PAYURI"
    -    return 1
    -  fi
    -  ok "shop $pname" "taler_pay_uri $PAYURI"
    -
    -  if ! wcli_pay handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$tag.out" 2>&1; then
    -    warn "shop $pname ($pid)" "handle-uri failed — $(tail -c 120 "$SCRATCH/pay-$tag.out" | tr '\n' ' ')"
    -    return 1
    -  fi
    -  ok "shop $pname" "wallet accepted pay URI"
    -
    -  # Settle: public order status + transactions only (never run-until-done)
    -  local r AUTH=""
    -  AUTH=$(merchant_auth_header 2>/dev/null) || AUTH=""
    -  for r in 1 2 3 4 5 6; do
    -    wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true
    -    if [ -n "$AUTH" ]; then
    -      curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \
    -        "${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true
    -    fi
    -    curl -skS -m 8 -o "$SCRATCH/ord-pub-$tag.json" "$statusUrl" 2>/dev/null || true
    -    if python3 -c 'import json,sys
    -for p in sys.argv[1:]:
    -  try:
    -    d=json.load(open(p))
    -    if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid":
    -      sys.exit(0)
    -  except Exception:
    -    pass
    -sys.exit(1)
    -' "$SCRATCH/ord-paid-$tag.json" "$SCRATCH/ord-pub-$tag.json" 2>/dev/null \
    -      || 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 ($pamt · order $OID)"
    -      metrics_report_coins "after-shop-${tag}" || true
    -      metrics_record_flow spent "$pamt" || true
    -      return 0
    -    fi
    -    sleep 1
    -  done
    -  metrics_report_coins "after-shop-fail-${tag}" || true
    -  warn "shop $pname ($pid)" "not settled for order $OID"
    -  return 1
    -}
    -
    -# ---------------------------------------------------------------------------
    -set_group atm
    -section "e2e · ATM withdraw ladder"
    -# ---------------------------------------------------------------------------
    -WITHDRAW_OK=0
    -WITHDRAW_OK_N=0
    -WITHDRAW_LAG_N=0
    -WITHDRAW_FAIL_N=0
    -WITHDRAW_REPORT=""
    -for WITHDRAW_AMT in $WITHDRAW_LIST; do
    -  e2e_over && { warn "ATM ladder" "time budget low — stopping more ATM withdraws (not a protocol error)"; break; }
    -  set +e
    -  e2e_one_withdraw "$WITHDRAW_AMT"
    -  wc=$?
    -  set -e
    -  case "$wc" in
    -    0)
    -      WITHDRAW_OK=1
    -      WITHDRAW_OK_N=$((WITHDRAW_OK_N + 1))
    -      WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=OK"
    -      ;;
    -    2)
    -      WITHDRAW_LAG_N=$((WITHDRAW_LAG_N + 1))
    -      WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=LAG"
    -      ;;
    -    *)
    -      WITHDRAW_FAIL_N=$((WITHDRAW_FAIL_N + 1))
    -      WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=FAIL"
    -      ;;
    -  esac
    -done
    -info "ATM withdraw summary" "$WITHDRAW_REPORT (ok=$WITHDRAW_OK_N lag=$WITHDRAW_LAG_N fail=$WITHDRAW_FAIL_N)"
    -set_group load
    -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
    -set_group settle
    -section "e2e · wallet settlement (timing)"
    -if ! wait_wallet_balance 0 "${E2E_SETTLE_ROUNDS}" "${E2E_SETTLE_SLEEP}"; then
    -  if [ "$WITHDRAW_LAG_N" -gt 0 ] || [ "$WITHDRAW_OK_N" -gt 0 ]; then
    -    warn "settlement timing" "ATM path reached bank confirm (lag=$WITHDRAW_LAG_N ok=$WITHDRAW_OK_N) but wallet empty after wait — TIME lag, not protocol error"
    -  fi
    -fi
    -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
    -  WITHDRAW_OK=1
    -  if [ "$WITHDRAW_OK_N" = "0" ]; then
    -    warn "settlement timing" "coins arrived after ATM ladder (${CUR}:${av_now}) — earlier 'FAIL' was timing lag"
    -    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"
    -    # diagnostic dig only — do not treat as hard stop if we can still see bank state
    -    if [ "$E2E_REMOTE" != "1" ]; then
    -      dig_when_no_coins || true
    -    fi
    -    # one last balance after dig (dig takes wall time — often enough for wirewatch)
    -    wait_wallet_balance 0 8 3 || true
    -    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
    -      WITHDRAW_OK=1
    -      warn "settlement timing" "coins present after dig wait (${CUR}:${av_now}) — timing, not blocker"
    -    else
    -      blocker "withdraw-settle" "no spendable ${CUR} after ATM ladder + settle wait ($WITHDRAW_LIST)"
    -      section "e2e · report"
    -      info "WITHDRAW" "$WITHDRAW_REPORT — no coins after extended wait"
    -      info "PAY" "SKIPPED (no spendable balance after settle wait)"
    -      exit 1
    -    fi
    -  fi
    -fi
    -
    -# ---------------------------------------------------------------------------
    -set_group pay
    -section "e2e · variable payments (if balance allows)"
    -# ---------------------------------------------------------------------------
    -PAY_OK=0
    -PAY_OK_N=0
    -PAY_FAIL_N=0
    -PAY_SKIP_N=0
    -PAY_REPORT=""
    -if [ -z "${MPW:-}" ] && [ "${E2E_USE_TEMPLATES:-0}" != "1" ]; then
    -  if [ "$E2E_REMOTE" = "1" ]; then
    -    e2e_abort_auth "merchant-order" "no merchant token — set E2E_MERCHANT_TOKEN (or E2E_USE_TEMPLATES=1)"
    -  fi
    -  blocker "merchant-order" "no merchant token"
    -  exit 1
    -fi
    -if [ -z "${MPW:-}" ] && [ "${E2E_USE_TEMPLATES:-0}" = "1" ]; then
    -  info "pay" "no merchant token — public templates only (E2E_USE_TEMPLATES=1)"
    -fi
    -
    -for PAY_AMT in $PAY_LIST; do
    -  e2e_over && { warn "pay ladder" "time budget low — stopping more payments"; break; }
    -  av_now=$(wallet_avail_num)
    -  pay_n=$(python3 -c 'import sys; print(float(sys.argv[1].split(":",1)[-1]))' "$PAY_AMT" 2>/dev/null || echo 0)
    -  if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-12 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_n" 2>/dev/null; then
    -    warn "pay $PAY_AMT" "skip — insufficient avail ${CUR}:${av_now} (need ${pay_n})"
    -    PAY_SKIP_N=$((PAY_SKIP_N + 1))
    -    PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=SKIP(bal)"
    -    continue
    -  fi
    -  set +e
    -  e2e_one_pay "$PAY_AMT"
    -  pc=$?
    -  set -e
    -  if [ "$pc" = "0" ]; then
    -    PAY_OK=1
    -    PAY_OK_N=$((PAY_OK_N + 1))
    -    PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=OK"
    -  else
    -    PAY_FAIL_N=$((PAY_FAIL_N + 1))
    -    PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=FAIL"
    -  fi
    -done
    -info "pay summary" "$PAY_REPORT (ok=$PAY_OK_N fail=$PAY_FAIL_N skip=$PAY_SKIP_N)"
    -set_group load
    -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
    -    warn "pay" "balance ${CUR}:${av_now} but no pay amount completed — check merchant/timing (not necessarily empty wallet)"
    -  else
    -    blocker "pay-settle" "no payment succeeded and no spendable balance ($PAY_LIST)"
    -  fi
    -fi
    -
    -# ---------------------------------------------------------------------------
    -set_group shop
    -section "e2e · shop products (public templates)"
    -# ---------------------------------------------------------------------------
    -# Public template POST + wallet pay — catalog random pick of N products.
    -# GOA: goa-shop on hacktivism. TESTPAYSAN: farmer shops (jardin / fermes).
    -SHOP_OK=0
    -SHOP_OK_N=0
    -SHOP_FAIL_N=0
    -SHOP_SKIP_N=0
    -SHOP_REPORT=""
    -# Auto catalog for stage TESTPAYSAN when not overridden
    -if [ "${CUR:-}" = "TESTPAYSAN" ] && [ -z "${E2E_SHOP_PRODUCTS_SET:-}" ] && [ -z "${E2E_SHOP_FORCE_GOA:-}" ]; then
    -  # id|name|amount|instance — force stage catalog (GOA default is set earlier)
    -  E2E_SHOP_PRODUCTS="oeufs-6|Œufs plein air × 6|TESTPAYSAN:5|jardin-du-creux
    -pain-seigle|Pain de seigle 800 g|TESTPAYSAN:4.5|jardin-du-creux
    -jus-pomme|Jus de pomme 1 L|TESTPAYSAN:6|jardin-du-creux
    -panier-legumes|Panier légumes|TESTPAYSAN:25|fermes-des-collines
    -fromage-chevre|Fromage de chèvre 200 g|TESTPAYSAN:8.5|fermes-des-collines
    -miel-printemps|Miel de printemps 250 g|TESTPAYSAN:12|fermes-des-collines"
    -  # Force stage defaults (GOA goa-shop is already set earlier via :=)
    -  if [ -z "${E2E_SHOP_INSTANCE_SET:-}" ]; then
    -    E2E_SHOP_INSTANCE=jardin-du-creux
    -  fi
    -  if [ -z "${E2E_SHOP_PAYTO_SET:-}" ]; then
    -    E2E_SHOP_PAYTO="payto://x-taler-bank/stage.bank.lefrancpaysan.ch/jardin-du-creux"
    -  fi
    -  E2E_SHOP_PICK_N="${E2E_SHOP_PICK_N:-2}"
    -  E2E_SHOP_ENABLE=1
    -fi
    -# GOA local only by default; remote non-GOA shops need E2E_SHOP_ENABLE=1 or TESTPAYSAN auto above
    -if [ -z "${E2E_SHOP_ENABLE:-}" ]; then
    -  if [ "$E2E_REMOTE" = "1" ] || [ "${CUR:-}" != "GOA" ]; then
    -    E2E_SHOP_ENABLE=0
    -  else
    -    E2E_SHOP_ENABLE=1
    -  fi
    -fi
    -if [ "${E2E_SHOP_ENABLE}" != "1" ]; then
    -  info "shop" "SKIPPED (set E2E_SHOP_ENABLE=1 or use TESTPAYSAN stage catalog)"
    -else
    -  # Build catalog array from E2E_SHOP_PRODUCTS (id|name|amount[|instance] lines)
    -  SHOP_CATALOG=()
    -  while IFS= read -r line; do
    -    [ -z "$line" ] && continue
    -    case "$line" in \#*) continue ;; esac
    -    SHOP_CATALOG+=("$line")
    -  done </dev/null || echo 0)
    -      if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-12 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_n" 2>/dev/null; then
    -        warn "shop $pname ($pid)" "skip — insufficient avail ${CUR}:${av_now} (need ${pay_n})"
    -        SHOP_SKIP_N=$((SHOP_SKIP_N + 1))
    -        SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=SKIP(bal)"
    -        continue
    -      fi
    -      set +e
    -      e2e_one_pay_public_template "$pid" "$pname" "$pamt"
    -      pc=$?
    -      set -e
    -      if [ "$pc" = "0" ]; then
    -        SHOP_OK=1
    -        SHOP_OK_N=$((SHOP_OK_N + 1))
    -        PAY_OK=1
    -        PAY_OK_N=$((PAY_OK_N + 1))
    -        SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=OK"
    -      else
    -        SHOP_FAIL_N=$((SHOP_FAIL_N + 1))
    -        SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=FAIL"
    -      fi
    -    done </dev/null || true
    -  fi
    -fi
    -
    -# ---------------------------------------------------------------------------
    -set_group paivana
    -section "e2e · paivana paywall (template GOA:4200)"
    -# ---------------------------------------------------------------------------
    -# Public paywall: https://paivana.hacktivism.ch → 302 to merchant template "paivana".
    -# Requires spendable ≥ 4200 GOA (ATM ladder includes GOA:4200 by default).
    -: "${PAIVANA_PUBLIC:=https://paivana.hacktivism.ch}"
    -: "${E2E_PAIVANA:=1}"
    -: "${E2E_PAIVANA_TEMPLATE:=paivana}"
    -: "${E2E_PAIVANA_AMOUNT:=GOA:4200}"
    -: "${E2E_PAIVANA_INSTANCE:=goa-shop}"
    -PAIVANA_OK=0
    -PAIVANA_REPORT=""
    -if [ "$E2E_REMOTE" = "1" ] || [ "${CUR:-}" != "GOA" ] || [ "${E2E_PAIVANA}" = "0" ]; then
    -  info "paivana" "SKIPPED (remote, non-GOA, or E2E_PAIVANA=0)"
    -  PAIVANA_REPORT="SKIP"
    -else
    -  PAIVANA_PUBLIC="${PAIVANA_PUBLIC%/}"
    -  # HTTP: paywall front must redirect into template flow (or serve well-known)
    -  hdr=$(curl -skS -m 12 -D - -o /dev/null "$PAIVANA_PUBLIC/" 2>/dev/null || true)
    -  pcode=$(printf '%s' "$hdr" | awk 'BEGIN{c="000"} /^HTTP/{c=$2} END{print c}')
    -  loc=$(printf '%s' "$hdr" | awk 'BEGIN{IGNORECASE=1} /^location:/{sub(/\r$/,""); sub(/^location:[[:space:]]*/,""); print; exit}')
    -  case "$pcode" in
    -    301|302|303|307|308)
    -      if printf '%s' "$loc" | grep -qiE 'paivana|templates|well-known'; then
    -        ok "paivana HTTP" "HTTP $pcode → ${loc:0:120}"
    -      else
    -        ok "paivana HTTP" "HTTP $pcode redirect (Location: ${loc:0:80})"
    -      fi
    -      ;;
    -    200)
    -      warn "paivana HTTP" "HTTP 200 without paywall redirect — unexpected for locked site"
    -      ;;
    -    *)
    -      fail "paivana HTTP" "HTTP ${pcode:-000} on $PAIVANA_PUBLIC/ (want 302 to template)"
    -      PAIVANA_REPORT="HTTP_FAIL"
    -      ;;
    -  esac
    -
    -  # Ensure wallet can cover 4200 (extra ATM if ladder did not fund enough)
    -  pay_need=$(python3 -c 'import sys; print(float(sys.argv[1].split(":",1)[-1]))' "$E2E_PAIVANA_AMOUNT" 2>/dev/null || echo 4200)
    -  av_now=$(wallet_avail_num)
    -  if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-9 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_need" 2>/dev/null; then
    -    info "paivana" "avail ${CUR}:${av_now} < ${pay_need} — ATM withdraw ${E2E_PAIVANA_AMOUNT}"
    -    e2e_over && warn "paivana" "budget low before 4200 withdraw"
    -    set +e
    -    e2e_one_withdraw "$E2E_PAIVANA_AMOUNT"
    -    wc=$?
    -    set -e
    -    if [ "$wc" = "0" ]; then
    -      WITHDRAW_OK=1
    -      WITHDRAW_OK_N=$((WITHDRAW_OK_N + 1))
    -      WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${E2E_PAIVANA_AMOUNT}=OK(paivana)"
    -    elif [ "$wc" = "2" ]; then
    -      WITHDRAW_LAG_N=$((WITHDRAW_LAG_N + 1))
    -      WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${E2E_PAIVANA_AMOUNT}=LAG(paivana)"
    -    else
    -      WITHDRAW_FAIL_N=$((WITHDRAW_FAIL_N + 1))
    -      WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${E2E_PAIVANA_AMOUNT}=FAIL(paivana)"
    -    fi
    -    wait_wallet_balance "$(python3 -c "print(max(0, $pay_need - 1))" 2>/dev/null || echo 4199)" 12 3 || true
    -    av_now=$(wallet_avail_num)
    -  fi
    -
    -  if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-9 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_need" 2>/dev/null; then
    -    warn "paivana" "skip pay — insufficient avail ${CUR}:${av_now} (need ${pay_need})"
    -    PAIVANA_REPORT="${PAIVANA_REPORT:+$PAIVANA_REPORT+}SKIP(bal)"
    -  else
    -    _E2E_INST_SAVE="$INST"
    -    INST="${E2E_PAIVANA_INSTANCE}"
    -    set +e
    -    e2e_one_pay_public_template \
    -      "${E2E_PAIVANA_TEMPLATE}" \
    -      "Paivana paywall" \
    -      "${E2E_PAIVANA_AMOUNT}"
    -    pc=$?
    -    set -e
    -    INST="$_E2E_INST_SAVE"
    -    unset _E2E_INST_SAVE
    -    if [ "$pc" = "0" ]; then
    -      PAIVANA_OK=1
    -      PAY_OK=1
    -      PAY_OK_N=$((PAY_OK_N + 1))
    -      PAIVANA_REPORT="${PAIVANA_REPORT:+$PAIVANA_REPORT+}PAY_OK"
    -      ok "paivana" "template ${E2E_PAIVANA_TEMPLATE} paid ${E2E_PAIVANA_AMOUNT}"
    -    else
    -      PAIVANA_REPORT="${PAIVANA_REPORT:+$PAIVANA_REPORT+}PAY_FAIL"
    -      warn "paivana" "template pay failed (${E2E_PAIVANA_TEMPLATE} · ${E2E_PAIVANA_AMOUNT})"
    -    fi
    -  fi
    -  info "paivana summary" "${PAIVANA_REPORT:-?} · instance ${E2E_PAIVANA_INSTANCE}"
    -fi
    -
    -set_group report
    -section "e2e · report"
    -info "user" "$USER"
    -info "ATM withdraws" "$WITHDRAW_REPORT"
    -info "payments" "$PAY_REPORT"
    -info "shop" "${SHOP_REPORT:-(n/a)}"
    -info "paivana" "${PAIVANA_REPORT:-(n/a)}"
    -info "final balance" "$(fmt_bal "$SCRATCH/bal-live.out" 2>/dev/null || echo "(n/a)")"
    -info "scratch" "$SCRATCH"
    -# Success if we had coins and at least one pay, OR coins + only pay skips (nothing affordable)
    -if [ "${#BLOCKERS[@]}" -eq 0 ]; then
    -  if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK" = "1" ]; then
    -    exit 0
    -  fi
    -  if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK_N" = "0" ] && [ "$PAY_FAIL_N" = "0" ]; then
    -    warn "pay" "no payment tried/completed — withdraw OK"
    -    exit 0
    -  fi
    -  if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK" != "1" ]; then
    -    # money there, pays failed → soft exit 1 without inventing blockers if already warned
    -    exit 1
    -  fi
    -fi
    -exit 1
    diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh
    deleted file mode 100755
    index d7c60ae..0000000
    --- a/scripts/taler-monitoring/check_goa_ladder.sh
    +++ /dev/null
    @@ -1,1364 +0,0 @@
    -#!/usr/bin/env bash
    -# check_goa_ladder.sh — withdraw/pay amount ladder (GOA + stage TESTPAYSAN)
    -#
    -# Flow (bank landings):
    -#   1) GET /intro/auto-account.json  → personal *account-* (balance 0)
    -#   2) Mint pool withdrawals as explorer + confirm when selected
    -#   3) wallet-cli accept-uri only (no run-until-done)
    -#   4) bank confirm when selected; settle = balance + transfer_done
    -#
    -# Amounts: random strictly increasing; pins 0, max-1, max.
    -# Phase A: withdraw ladder (cumulative wallet). Phase B: pay ladder.
    -#
    -# Stacks:
    -#   GOA  — LADDER_MAX_AMOUNT = libeufin ceiling; explorer from koopa-admin-secrets
    -#   TESTPAYSAN stage — auto max_wire from bank /config, min denom from /keys;
    -#                      explorer from stagepaysan secrets / SSH
    -#
    -# Usage:
    -#   ./taler-monitoring.sh ladder
    -#   ./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder
    -#   LADDER_MAX_AMOUNT=100 LADDER_STEPS=7 ./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder
    -#
    -# Env (see body): LADDER_STEPS, LADDER_MAX_AMOUNT, LADDER_MIN_AMOUNT, LADDER_STACK_AUTO,
    -#   LADDER_PAY, EXP_PW / EXP_PW_FILE, CLI_JS, MERCHANT_INSTANCE, …
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -# When invoked standalone (not via taler-monitoring.sh), still load secrets.env
    -load_monitoring_secrets_env 2>/dev/null || true
    -
    -# Area ladder.* — withdraw/pay amount ladder (GOA + TESTPAYSAN)
    -# Groups: ladder.plan / ladder.load / ladder.withdraw / ladder.pay / ladder.report
    -set_area ladder
    -set_group plan
    -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 / EXP_PW optional overrides; otherwise read_secret + SECRETS_ROOT / stage SSH
    -: "${EXP_PW_FILE:=}"
    -: "${EXP_PW:=}"
    -: "${CLI_JS:=}"
    -# GOA libeufin amount ceiling (hacktivism). Stage auto-replaces via bank /config.
    -: "${LADDER_MAX_AMOUNT:=4503599627370496}"
    -: "${LADDER_STEPS:=23}"
    -: "${LADDER_LOAD:=1}"
    -: "${LADDER_PAY:=1}"
    -: "${LADDER_STACK_AUTO:=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}"
    -# Public variable template for stage pays (optional; fixed templates used when amount maps)
    -: "${LADDER_PAY_TEMPLATE:=}"
    -: "${LADDER_PAY_TEMPLATE_INSTANCE:=fermes-des-collines}"
    -
    -GOA_LADDER_CEILING="4503599627370496"
    -
    -# Resolve wallet-cli .mjs (no hardcoded laptop path)
    -if [ -z "${CLI_JS}" ] || [ ! -f "${CLI_JS}" ]; then
    -  CLI_JS=$(find_wallet_cli 2>/dev/null || true)
    -fi
    -if [ -z "${CLI_JS}" ] || [ ! -f "${CLI_JS}" ]; then
    -  # allow PATH wrapper as last resort (wcli falls back to taler-wallet-cli)
    -  CLI_JS=""
    -fi
    -
    -CUR="${EXPECT_CURRENCY:-GOA}"
    -BANK="${BANK_PUBLIC%/}"
    -EX="${EXCHANGE_PUBLIC%/}/"
    -MER="${MERCHANT_PUBLIC%/}"
    -INST="${MERCHANT_INSTANCE}"
    -
    -# --- Stack-specific ladder maxima (TESTPAYSAN stage) ---
    -# Uses bank max_wire_transfer_amount and exchange min denom when still on GOA defaults.
    -apply_ladder_stack_defaults() {
    -  [ "${LADDER_STACK_AUTO}" = "1" ] || return 0
    -  if [ "${CUR}" != "TESTPAYSAN" ]; then
    -    case "${TALER_DOMAIN:-}" in
    -      stage.lefrancpaysan.ch|stage.bank.lefrancpaysan.ch|stage.exchange.lefrancpaysan.ch|stage.monnaie.lefrancpaysan.ch)
    -        CUR="TESTPAYSAN"
    -        ;;
    -      *) return 0 ;;
    -    esac
    -  fi
    -
    -  local bank_cfg max_wire min_denom
    -  bank_cfg=$(curl -sS -m 12 "${BANK}/config" 2>/dev/null || true)
    -  max_wire=$(printf '%s' "$bank_cfg" | python3 -c '
    -import json,sys
    -try:
    -  d=json.load(sys.stdin)
    -except Exception:
    -  print("")
    -  raise SystemExit(0)
    -a=d.get("max_wire_transfer_amount") or ""
    -print(a.split(":",1)[-1] if a else "")
    -' 2>/dev/null || true)
    -
    -  min_denom=$(curl -sS -m 25 -H 'Accept: application/json' "${EX%/}/keys" 2>/dev/null | python3 -c '
    -import json,sys
    -try:
    -  d=json.load(sys.stdin)
    -except Exception:
    -  print("")
    -  raise SystemExit(0)
    -den=d.get("denoms") or d.get("denominations") or []
    -vals=[]
    -for x in den if isinstance(den,list) else []:
    -  v=x.get("value") or x.get("amount") or ""
    -  if not v: continue
    -  try: vals.append(float(str(v).split(":")[-1]))
    -  except Exception: pass
    -print(min(vals) if vals else "")
    -' 2>/dev/null || true)
    -
    -  # Fallback maxima if public config unreachable
    -  [ -n "$max_wire" ] || max_wire="2000"
    -  [ -n "$min_denom" ] || min_denom="0.01"
    -
    -  # Only rewrite when caller left GOA defaults (explicit LADDER_MAX_AMOUNT=… kept)
    -  if [ -n "$max_wire" ] && { [ "${LADDER_MAX_AMOUNT}" = "${GOA_LADDER_CEILING}" ] || [ "${LADDER_MAX_AMOUNT}" = "4503599627370496" ]; }; then
    -    LADDER_MAX_AMOUNT="$max_wire"
    -  fi
    -  if [ -n "$min_denom" ] && { [ "${LADDER_MIN_AMOUNT}" = "0.000001" ] || [ "${LADDER_MIN_AMOUNT}" = "0.00000001" ]; }; then
    -    LADDER_MIN_AMOUNT="$min_denom"
    -  fi
    -  # Slightly fewer rungs on stage (full GOA 23 still ok if user set LADDER_STEPS)
    -  if [ "${LADDER_STEPS}" = "23" ]; then
    -    LADDER_STEPS=15
    -  fi
    -  # Stage farmer shops: private goa-demo instance does not exist
    -  if [ "${MERCHANT_INSTANCE}" = "goa-demo-cp4zqk" ]; then
    -    MERCHANT_INSTANCE="${LADDER_PAY_TEMPLATE_INSTANCE:-fermes-des-collines}"
    -    INST="$MERCHANT_INSTANCE"
    -  fi
    -  # Prefer public templates for pays when no merchant token (set later)
    -  : "${E2E_USE_TEMPLATES:=1}"
    -  export E2E_USE_TEMPLATES
    -  export LADDER_MAX_AMOUNT LADDER_MIN_AMOUNT LADDER_STEPS MERCHANT_INSTANCE
    -  info "ladder stack" "TESTPAYSAN · max=${CUR}:${LADDER_MAX_AMOUNT} min=${CUR}:${LADDER_MIN_AMOUNT} steps=${LADDER_STEPS} (from bank max_wire / keys denoms)"
    -}
    -apply_ladder_stack_defaults
    -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
    -  now=$(date +%s)
    -  [ $((now - SECTION_T0)) -ge "$LADDER_TIMEOUT_S" ]
    -}
    -
    -wcli() {
    -  if [ -n "${CLI_JS:-}" ] && [ -f "$CLI_JS" ]; then
    -    node "$CLI_JS" --wallet-db="$WDB" --no-throttle "$@"
    -  else
    -    taler-wallet-cli --wallet-db="$WDB" --no-throttle "$@"
    -  fi
    -}
    -
    -# Explorer pool password: env → file override → stack secrets → SSH
    -resolve_explorer_pw() {
    -  local pw="" f="" host=""
    -  if [ -n "${EXP_PW:-}" ]; then
    -    printf '%s' "$EXP_PW"
    -    return 0
    -  fi
    -  if [ -n "${EXP_PW_FILE:-}" ] && [ -f "$EXP_PW_FILE" ]; then
    -    tr -d '\n\r' <"$EXP_PW_FILE"
    -    return 0
    -  fi
    -
    -  # Stage TESTPAYSAN — never use GOA koopa-admin-secrets explorer pw first
    -  if [ "${CUR}" = "TESTPAYSAN" ] \
    -    || [[ "${TALER_DOMAIN:-}" == stage.*lefrancpaysan* ]] \
    -    || [[ "${TALER_DOMAIN:-}" == *stage.lefrancpaysan* ]]; then
    -    for f in \
    -      "${FRANCPAYSAN_SECRETS:-}/stage/bank-explorer-password.txt" \
    -      "${HOME}/francpaysan-secrets/stage/bank-explorer-password.txt" \
    -      "${HOME}/src/francpaysan-secrets/stage/bank-explorer-password.txt" \
    -      "${HOME}/taler/src/francpaysan-secrets/stage/bank-explorer-password.txt" \
    -      "${HOME}/.config/taler-landing/stage-bank-explorer-password.txt"
    -    do
    -      [ -n "$f" ] && [ -f "$f" ] || continue
    -      tr -d '\n\r' <"$f"
    -      return 0
    -    done
    -    for host in \
    -      "${INSIDE_SSH:-}" \
    -      francpaysan-stage-user \
    -      francpaysan-host
    -    do
    -      [ -n "$host" ] || continue
    -      pw=$(ssh -o BatchMode=yes -o ConnectTimeout=12 "$host" \
    -        'tr -d "\n\r" /dev/null \
    -         || sudo tr -d "\n\r" /dev/null' \
    -        2>/dev/null || true)
    -      if [ -n "$pw" ]; then
    -        printf '%s' "$pw"
    -        return 0
    -      fi
    -    done
    -    return 1
    -  fi
    -
    -  # GOA / default: SECRETS_ROOT / ~/.config / koopa SSH
    -  if pw=$(read_secret "taler-bank/bank-explorer-password.txt" 2>/dev/null) && [ -n "$pw" ]; then
    -    printf '%s' "$pw"
    -    return 0
    -  fi
    -  for f in \
    -    "${SECRETS_ROOT:+${SECRETS_ROOT}/taler-bank/bank-explorer-password.txt}" \
    -    "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt" \
    -    "${HOME}/.config/taler-landing/bank-explorer-password.txt"
    -  do
    -    [ -n "$f" ] && [ -f "$f" ] || continue
    -    tr -d '\n\r' <"$f"
    -    return 0
    -  done
    -  return 1
    -}
    -
    -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 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(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)
    -    if q == q.to_integral():
    -        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)
    -    if v < min_amt:
    -        return min_amt
    -    return v.quantize(Decimal("0.00000001"), rounding=ROUND_UP)
    -
    -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(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 >= 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(v)
    -        prev = v
    -    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"
    -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 · ${CUR} 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"
    -_max_m1=$(python3 -c 'import sys; from decimal import Decimal; m=Decimal(sys.argv[1]); print(m-1 if m>1 else m)' "${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)"
    -info "currency/domain" "${CUR} · ${TALER_DOMAIN:-?} · bank=${BANK}"
    -
    -set_group load
    -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 ! EXP_PW=$(resolve_explorer_pw); then
    -  err bank "explorer password missing" \
    -    "set EXP_PW / EXP_PW_FILE or SECRETS_ROOT=…/koopa/host-root (taler-bank/bank-explorer-password.txt)${SECRETS_ROOT:+ · SECRETS_ROOT=${SECRETS_ROOT}}"
    -  secrets_hint 2>/dev/null || true
    -  exit 1
    -fi
    -if [ -n "${SECRETS_ROOT:-}" ]; then
    -  info "explorer secret" "resolved (SECRETS_ROOT=${SECRETS_ROOT} · stage uses stagepaysan secret first when CUR=TESTPAYSAN)"
    -else
    -  info "explorer secret" "resolved via EXP_PW / EXP_PW_FILE / stage SSH / koopa"
    -fi
    -if [ -n "${CLI_JS:-}" ] && [ -f "${CLI_JS}" ]; then
    -  info "wallet-cli" "$CLI_JS"
    -else
    -  info "wallet-cli" "PATH taler-wallet-cli ($(command -v taler-wallet-cli 2>/dev/null || echo missing))"
    -fi
    -
    -# --- 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 ${CUR}: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)"
    -
    -# 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
    -  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)
    -rm -f "$SCRATCH/wallet-main.sqlite3"
    -wallet_prepare "main"
    -ms_tos=$(elapsed_ms "$t0")
    -ok "wallet exchange + ToS (${ms_tos}ms) — cumulative DB for withdraw+pay (no run-until-done)"
    -
    -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
    -if [ "$#" -gt "$LADDER_MAX_RUNGS" ]; then
    -  # shellcheck disable=SC2046
    -  set -- $(printf '%s\n' "$@" | head -n "$LADDER_MAX_RUNGS")
    -fi
    -LADDER_N=$#
    -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=()
    -
    -set_group withdraw
    -section "ladder · phase A · withdraw (${LADDER_N} rungs)"
    -rung=0
    -for AMT in "$@"; do
    -  rung=$((rung + 1))
    -  if ladder_over; then
    -    STOP_REASON="timeout budget ${LADDER_TIMEOUT_S}s"
    -    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
    -
    -  section "ladder · rung $rung  $AMT · $(format_amount_alt "$AMT")"
    -  tag=$(printf '%s' "$AMT" | tr '.:' '__')
    -  # 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
    -  t_rung=$(now_ms)
    -  ms_mint=0 ms_accept=0 ms_confirm=0 ms_settle=0
    -  note=""
    -  status="FAIL"
    -  WID="-"
    -  FORCE_SEL_409_LOGGED=0
    -
    -  # keep cumulative main wallet
    -  wallet_prepare "main"
    -
    -  # 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 / 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
    -    # 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
    -      status="ZERO_REJECT"
    -      note="zero-withdraw rejected (expected possible): $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
    -    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"
    -    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 200 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')"
    -    ms_total=$(elapsed_ms "$t_rung")
    -    # 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"
    -        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
    -      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"
    -    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. 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 \
    -      | 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 \
    -      -H "Authorization: Bearer ${TOK}" -H 'Content-Type: application/json' -d '{}' \
    -      "${BANK}/accounts/${EXP_USER}/withdrawals/${WID}/confirm"
    -  }
    -  # Collect reserve_pub candidates for *this* withdrawal (WID + amount).
    -  # Cumulative wallets re-print old reserves in accept/tx dumps — never trust a single "last" blindly.
    -  # Prints unique pubs one per line, preferred order first.
    -  extract_rpubs_for_wid() {
    -    python3 - "$WID" "$AMT" "$SCRATCH/accept-$tag.out" "$SCRATCH/tx-$tag.json" "$SCRATCH/used-rpubs.txt" <<'PY'
    -import json, re, sys
    -from pathlib import Path
    -
    -wid = sys.argv[1]
    -amt = sys.argv[2]
    -paths = sys.argv[3:5]
    -used_path = sys.argv[5]
    -used = set()
    -if Path(used_path).is_file():
    -    used = {ln.strip() for ln in open(used_path) if ln.strip()}
    -
    -def walk_collect(o, bag, ctx=None):
    -    ctx = dict(ctx or {})
    -    if isinstance(o, dict):
    -        for k, v in o.items():
    -            kl = str(k).lower()
    -            if kl in ("withdrawal_id", "withdraw_id", "wopid", "id") and isinstance(v, str):
    -                ctx["id"] = v
    -            if kl in ("amount", "rawamount", "instructedamount") and isinstance(v, str):
    -                ctx["amount"] = v
    -            if kl in ("taler_withdraw_uri", "talerwithdrawuri", "uri") and isinstance(v, str):
    -                ctx["uri"] = v
    -            if kl in ("reserve_pub", "reservepub") and isinstance(v, str) and len(v) >= 40:
    -                bag.append((v, dict(ctx)))
    -            walk_collect(v, bag, ctx)
    -    elif isinstance(o, list):
    -        for i in o:
    -            walk_collect(i, bag, ctx)
    -
    -raw_pubs = []  # ordered as found
    -scored = []    # (score, pub) higher better
    -blob_all = ""
    -for p in paths:
    -    try:
    -        blob_all += open(p, errors="replace").read() + "\n"
    -    except Exception:
    -        pass
    -
    -# 1) full JSON objects in files
    -for p in paths:
    -    try:
    -        t = open(p, errors="replace").read()
    -    except Exception:
    -        continue
    -    # whole-file JSON
    -    for m in re.finditer(r"\{", t):
    -        try:
    -            o = json.loads(t[m.start():])
    -        except Exception:
    -            continue
    -        bag = []
    -        walk_collect(o, bag)
    -        for pub, ctx in bag:
    -            raw_pubs.append(pub)
    -            score = 0
    -            cid = str(ctx.get("id") or "")
    -            camt = str(ctx.get("amount") or "")
    -            curi = str(ctx.get("uri") or "")
    -            if wid and wid in cid:
    -                score += 100
    -            if wid and wid in curi:
    -                score += 80
    -            if amt and (camt == amt or camt.endswith(amt.split(":", 1)[-1])):
    -                score += 40
    -            if pub in used:
    -                score -= 200
    -            scored.append((score, pub))
    -
    -# 2) regex fallback on accept file only (more current)
    -try:
    -    acc = open(paths[0], errors="replace").read()
    -except Exception:
    -    acc = ""
    -for pat in (
    -    r"\"reserve_pub\"\s*:\s*\"([A-Z0-9]{40,})\"",
    -    r"\"reservePub\"\s*:\s*\"([A-Z0-9]{40,})\"",
    -    r"reserve_pub[\"\s:=]+([A-Z0-9]{40,})",
    -):
    -    for m in re.finditer(pat, acc, re.I):
    -        pub = m.group(1)
    -        raw_pubs.append(pub)
    -        score = 10
    -        # proximity to WID in accept output
    -        window = acc[max(0, m.start() - 400) : m.end() + 400]
    -        if wid and wid in window:
    -            score += 100
    -        if amt and amt in window:
    -            score += 30
    -        if pub in used:
    -            score -= 200
    -        scored.append((score, pub))
    -
    -# prefer high score, then later occurrence
    -order = []
    -seen = set()
    -for score, pub in sorted(scored, key=lambda x: (-x[0],), reverse=False):
    -    # sort by score desc: use reverse sorted
    -    pass
    -for score, pub in sorted(scored, key=lambda x: x[0], reverse=True):
    -    if pub in seen or pub in used:
    -        continue
    -    seen.add(pub)
    -    order.append(pub)
    -# append unused raw in reverse (newest-ish)
    -for pub in reversed(raw_pubs):
    -    if pub in seen or pub in used:
    -        continue
    -    seen.add(pub)
    -    order.append(pub)
    -for pub in order:
    -    print(pub)
    -PY
    -  }
    -
    -  mark_rpub_used() {
    -    local p="$1"
    -    [ -n "$p" ] || return 0
    -    mkdir -p "$SCRATCH" 2>/dev/null || true
    -    grep -qxF "$p" "$SCRATCH/used-rpubs.txt" 2>/dev/null || echo "$p" >>"$SCRATCH/used-rpubs.txt"
    -  }
    -
    -  force_select_if_needed() {
    -    local st_now="$1"
    -    [ "$st_now" = "pending" ] || [ -z "$st_now" ] || return 0
    -    local rpub epayto code_fs any=0
    -    # refresh tx dump each try (wallet may attach reserve late)
    -    wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true
    -    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 [ -z "$epayto" ]; then
    -      warn bank "force-select skipped" "problem: exchange payto empty from /keys"
    -      return 0
    -    fi
    -    # Try candidates until bank leaves pending (200/204) or we exhaust
    -    while IFS= read -r rpub; do
    -      [ -n "$rpub" ] || continue
    -      any=1
    -      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}" 2>/dev/null || echo "000")
    -      if [ "$code_fs" = "200" ] || [ "$code_fs" = "204" ]; then
    -        info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}… (ok for WID ${WID:0:8})"
    -        mark_rpub_used "$rpub"
    -        return 0
    -      fi
    -      if [ "$code_fs" = "409" ]; then
    -        # 5114 = this reserve already bound to another op — not "out of money"
    -        info "force-select" "HTTP 409 rpub=${rpub:0:12}… (stale/used reserve — not balance; trying next)"
    -        mark_rpub_used "$rpub"
    -        continue
    -      fi
    -      info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}… body=$(tr '\n' ' ' <"$SCRATCH/force-sel-$tag.json" 2>/dev/null | head -c 120)"
    -      # other errors: still try next candidate
    -    done < <(extract_rpubs_for_wid)
    -    if [ "$any" != "1" ]; then
    -      warn bank "force-select skipped" \
    -        "problem: no reserve_pub for this withdraw (WID=${WID:0:8}…); wallet may not have selected yet"
    -    fi
    -  }
    -
    -  # No run-until-done (hangs / banned). Read transactions only for reserve_pub candidates.
    -  wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true
    -
    -  t0=$(now_ms)
    -  conf_ok=0
    -  st=""
    -  # 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
    -      selected)
    -        ccode=$(do_confirm)
    -        if [ "$ccode" = "204" ] || [ "$ccode" = "200" ]; then
    -          conf_ok=1
    -          info "confirm" "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
    -    # force-select while pending — try alternate rpubs on 5114 (not out-of-money)
    -    if [ "$st" = "pending" ] || [ -z "$st" ]; then
    -      if [ "$i" -eq 1 ] || [ "$i" -eq 2 ] || [ $((i % 3)) -eq 0 ]; then
    -        force_select_if_needed "$st"
    -      fi
    -    fi
    -    sleep 0.35
    -  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: not confirmed — usually stale reserve_pub (5114), NOT empty pool balance
    -    status="SKIP_CONFIRM"
    -    warn bank "confirm $AMT skipped" \
    -      "problem: bank status='${st:-empty}' after ${LADDER_CONFIRM_POLLS} polls (want selected/confirmed). Usually reserve_pub mismatch (5114), not out-of-money — mint/accept already OK. 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
    -  ok "confirm $AMT ${ms_confirm}ms (client, on selected)"
    -
    -  # settle: poll wallet balance + bank transfer_done only — never run-until-done
    -  t0=$(now_ms)
    -  settled=0
    -  xfer="?"
    -  if [ "$IS_ZERO" = "1" ]; then
    -    settled=1
    -    note="zero-amount: no coin delta expected"
    -  else
    -    for r in $(seq 1 "$LADDER_SETTLE_ROUNDS"); do
    -      ladder_over && break
    -      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
    -      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"
    -    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"
    -    metrics_report_coins "ladder-r${rung}-after-${tag}" || true
    -    metrics_record_flow withdrawn "$AMT" || 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
    -    metrics_record_flow withdrawn "$AMT" || true
    -  else
    -    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"
    -    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
    -  set_group pay
    -  section "ladder · phase B · pay"
    -  metrics_report_coins "before-pay-ladder" || true
    -  # Merchant secret (same as e2e) — stage often has only public shop templates
    -  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" ] && [ "${CUR}" = "TESTPAYSAN" ]; then
    -    for f in \
    -      "${FRANCPAYSAN_SECRETS:-}/stage/default-instance-token.txt" \
    -      "${HOME}/francpaysan-secrets/stage/default-instance-token.txt" \
    -      "${HOME}/src/francpaysan-secrets/stage/default-instance-token.txt"
    -    do
    -      [ -f "$f" ] || continue
    -      MPW=$(tr -d '\n\r' <"$f")
    -      break
    -    done
    -    if [ -z "$MPW" ]; then
    -      for host in "${INSIDE_SSH:-}" francpaysan-stage-user francpaysan-host; do
    -        [ -n "$host" ] || continue
    -        MPW=$(ssh -o BatchMode=yes -o ConnectTimeout=12 "$host" \
    -          'tr -d "\n\r" /dev/null || true' \
    -          2>/dev/null || true)
    -        [ -n "$MPW" ] && break
    -      done
    -    fi
    -  fi
    -  if [ -z "$MPW" ]; then
    -    if [ "${CUR}" = "TESTPAYSAN" ]; then
    -      # Stage: withdraw ladder is the main probe; pays need instance token or
    -      # public templates for *exact* face values (not continuous ladder amounts).
    -      info pay "no merchant token — skip pay ladder on TESTPAYSAN (withdraw maxima still covered)"
    -      info pay "hint: set E2E_MERCHANT_TOKEN or use ./taler-monitoring.sh -d stage.lefrancpaysan.ch e2e for shop templates"
    -    else
    -      warn pay "no merchant token — skip pay ladder (set E2E_MERCHANT_TOKEN or secrets)"
    -    fi
    -  else
    -    ok "merchant token" "instance ${INST}"
    -    case "$MPW" in
    -      secret-token:*) AUTH="Authorization: Bearer ${MPW}" ;;
    -      *) AUTH="Authorization: Bearer secret-token:${MPW}" ;;
    -    esac
    -    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: merchant order + wallet tx only — never run-until-done
    -      t0=$(now_ms)
    -      settled=0
    -      r=0
    -      while [ "$r" -lt "${LADDER_PAY_SETTLE_ROUNDS}" ]; do
    -        r=$((r + 1))
    -        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
    -        sleep 0.5
    -      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
    -        metrics_record_flow spent "$PAMT" || 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 ---
    -set_group report
    -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 — 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 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 rs:
    -        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),
    -    "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(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("--- withdraw speed (ms) ---")
    -for k in ("mint", "accept", "confirm", "settle", "rung_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("--- 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("  %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("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
    -
    -set_group load
    -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
    -export METRICS_WITHDRAW_TSV="$TSV"
    -export METRICS_PAY_TSV="$PAY_TSV"
    -metrics_report_coins "ladder-end" || true
    -metrics_print_overall "ladder final" || 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" "$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
    -
    -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 withdraw rungs"
    -  exit 1
    -fi
    -ok "ladder finished withdraw_ok=$OK_N pay_ok=$PAY_OK_N phase=${ms_phase}ms"
    -exit 0
    diff --git a/scripts/taler-monitoring/check_inside.sh b/scripts/taler-monitoring/check_inside.sh
    deleted file mode 100755
    index 7f62880..0000000
    --- a/scripts/taler-monitoring/check_inside.sh
    +++ /dev/null
    @@ -1,546 +0,0 @@
    -#!/usr/bin/env bash
    -# Inside status for bank / exchange / merchant containers.
    -#
    -# Access modes (shown in the log as flags):
    -#   host-podman  — this host runs podman exec into containers (no SSH).
    -#                  INSIDE_PODMAN=1 or INSIDE_MODE=local-podman, or auto on GOA host.
    -#   ssh          — from a laptop/remote: SSH to KOOPA_SSH / INSIDE_SSH, then podman there.
    -#
    -# Profiles:
    -#   koopa (default LOCAL_STACK=1) — taler-hacktivism* containers
    -#   stage-lfp (TESTPAYSAN) — SSH INSIDE_SSH (stagepaysan), low-priv podman
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -# shellcheck source=metrics.sh
    -source "$ROOT/metrics.sh"
    -
    -set_area inside
    -
    -PROFILE="${INSIDE_PROFILE:-}"
    -if [ -z "$PROFILE" ]; then
    -  if [ "${LOCAL_STACK:-0}" = "1" ]; then
    -    PROFILE=koopa
    -  elif [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] || [ -n "${INSIDE_SSH:-}" ]; then
    -    PROFILE=stage-lfp
    -  else
    -    PROFILE=koopa
    -  fi
    -fi
    -
    -# Resolve host-podman vs ssh before first check ID
    -_use_local_podman=0
    -if [ "$PROFILE" != "stage-lfp" ]; then
    -  if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then
    -    _use_local_podman=1
    -  elif command -v podman >/dev/null 2>&1 \
    -    && podman ps --format '{{.Names}}' 2>/dev/null | grep -qE 'taler-hacktivism'; then
    -    _use_local_podman=1
    -  fi
    -fi
    -if [ "$_use_local_podman" = "1" ]; then
    -  INSIDE_ACCESS=host-podman
    -  set_group host
    -else
    -  INSIDE_ACCESS=ssh
    -  set_group ssh
    -fi
    -export INSIDE_ACCESS
    -
    -section "inside · collect (${PROFILE} · access=${INSIDE_ACCESS})"
    -info "flags" "INSIDE_ACCESS=${INSIDE_ACCESS} INSIDE_PODMAN=${INSIDE_PODMAN:-0} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-0} SKIP_SSH=${SKIP_SSH:-0} KOOPA_SSH=${KOOPA_SSH:-} INSIDE_SSH=${INSIDE_SSH:-}"
    -
    -# ---------------------------------------------------------------------------
    -# stage-lfp: low-priv stagepaysan on francpaysan-host
    -# ---------------------------------------------------------------------------
    -if [ "$PROFILE" = "stage-lfp" ]; then
    -  SSH_HOST="${INSIDE_SSH:-francpaysan-stage-user}"
    -  BANK_CTR="${INSIDE_BANK_CTR:-stage-lfp-bank}"
    -  EX_CTR="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}"
    -  MER_CTR="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}"
    -  BANK_PORT="${INSIDE_BANK_PORT:-9032}"
    -  EX_PORT="${INSIDE_EXCHANGE_PORT:-9031}"
    -  MER_PORT="${INSIDE_MERCHANT_PORT:-9030}"
    -  DNS_BANK="${INSIDE_DNS_BANK:-stage.bank.lefrancpaysan.ch}"
    -  DNS_EX="${INSIDE_DNS_EXCHANGE:-stage.exchange.lefrancpaysan.ch}"
    -  DNS_MER="${INSIDE_DNS_MERCHANT:-stage.monnaie.lefrancpaysan.ch}"
    -  # Stage remote has more podman execs than koopa; allow a bit more wall time
    -  STAGE_SSH_T="${INSIDE_SSH_TIMEOUT:-${SSH_CMD_TIMEOUT:-24}}"
    -  if [ "${STAGE_SSH_T}" -lt 24 ] 2>/dev/null; then STAGE_SSH_T=24; fi
    -
    -  if ! mon_ssh_ok "$SSH_HOST"; then
    -    err "ssh" "cannot reach ${SSH_HOST} (stagepaysan low-priv) — set INSIDE_SSH= or SKIP"
    -    summary
    -    exit 1
    -  fi
    -  ok "ssh ${SSH_HOST}" "stagepaysan (podman, no sudo)"
    -
    -  # Inject names/ports into remote (ssh bash -s does not inherit local env).
    -  RAW=$(
    -    {
    -      printf 'BANK_CTR=%q; EX_CTR=%q; MER_CTR=%q\n' "$BANK_CTR" "$EX_CTR" "$MER_CTR"
    -      printf 'BANK_PORT=%q; EX_PORT=%q; MER_PORT=%q\n' "$BANK_PORT" "$EX_PORT" "$MER_PORT"
    -      printf 'DNS_BANK=%q; DNS_EX=%q; DNS_MER=%q\n' "$DNS_BANK" "$DNS_EX" "$DNS_MER"
    -      cat <<'REMOTE'
    -set +e
    -emit() { printf 'E|%s|%s|%s|%s\n' "$1" "$2" "$3" "$(printf '%s' "${4:-}" | tr '\n\r' '  ' | head -c 200)"; }
    -hc() { curl -skS -m 3 -o /tmp/mb -w '%{http_code}' "$1" 2>/dev/null || echo 000; }
    -hasp() { podman exec "$1" pgrep -f "$2" >/dev/null 2>&1; }
    -
    -BANK="${BANK_CTR}"
    -EX="${EX_CTR}"
    -MER="${MER_CTR}"
    -podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$BANK" || BANK=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*bank|lfp-bank' | head -1)
    -podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$EX" || EX=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*exchange|lfp-exchange' | head -1)
    -podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$MER" || MER=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*merchant|lfp-merchant' | head -1)
    -
    -check_dns() {
    -  local comp="$1" ctr="$2" host="$3"
    -  local line ip code
    -  [ -n "$host" ] || { emit "$comp" WARN "dns" "empty host"; return 1; }
    -  line=$(podman exec "$ctr" getent ahostsv4 "$host" 2>/dev/null | head -1)
    -  [ -z "$line" ] && line=$(podman exec "$ctr" getent hosts "$host" 2>/dev/null | head -1)
    -  ip=$(echo "$line" | awk '{print $1}')
    -  if [ -z "$ip" ]; then
    -    emit "$comp" WARN "dns $host" "no resolve inside container"
    -    return 1
    -  fi
    -  if [ "$ip" = "127.0.0.1" ] || [ "$ip" = "::1" ]; then
    -    emit "$comp" WARN "dns $host" "$ip (loopback)"
    -    return 1
    -  fi
    -  # 169.254.x = pasta host-gateway pin is OK for stage
    -  code=$(podman exec "$ctr" curl -skS -m 3 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000)
    -  if [ "$code" = "200" ]; then
    -    emit "$comp" OK "dns $host" "→ $ip /config=$code"
    -  else
    -    emit "$comp" WARN "dns $host" "→ $ip /config=$code"
    -  fi
    -}
    -
    -if [ -z "$BANK" ]; then emit bank ERROR container "not running"
    -else
    -  emit bank INFO container "$(podman ps --filter name=^${BANK}$ --format '{{.Names}} {{.Status}}' | head -1)"
    -  emit bank INFO ports "$(podman ps --filter name=^${BANK}$ --format '{{.Ports}}' | head -1)"
    -  hasp "$BANK" 'MainKt serve|libeufin-bank serve' && emit bank OK libeufin "running" || emit bank ERROR libeufin "not running"
    -  podman exec "$BANK" pg_isready -q 2>/dev/null && emit bank OK postgres "ready" || emit bank WARN postgres "pg_isready failed"
    -  c=$(hc "http://127.0.0.1:${BANK_PORT}/config")
    -  [ "$c" = "200" ] && emit bank OK "host :${BANK_PORT}/config" "HTTP $c" || emit bank ERROR "host :${BANK_PORT}/config" "HTTP $c"
    -  c=$(hc "http://127.0.0.1:${BANK_PORT}/taler-integration/config")
    -  [ "$c" = "200" ] && emit bank OK "host integration" "HTTP $c" || emit bank ERROR "host integration" "HTTP $c"
    -  check_dns bank "$BANK" "$DNS_BANK" || true
    -  check_dns bank "$BANK" "$DNS_EX" || true
    -fi
    -
    -if [ -z "$EX" ]; then emit exchange ERROR container "not running"
    -else
    -  emit exchange INFO container "$(podman ps --filter name=^${EX}$ --format '{{.Names}} {{.Status}}' | head -1)"
    -  c=$(hc "http://127.0.0.1:${EX_PORT}/config")
    -  [ "$c" = "200" ] && emit exchange OK "host :${EX_PORT}/config" "HTTP $c" || emit exchange ERROR "host :${EX_PORT}/config" "HTTP $c"
    -  c=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:${EX_PORT}/keys" 2>/dev/null || echo 000)
    -  [ "$c" = "200" ] && emit exchange OK "host :${EX_PORT}/keys" "HTTP $c" || emit exchange ERROR "host :${EX_PORT}/keys" "HTTP $c"
    -  hasp "$EX" 'taler-exchange-httpd' && emit exchange OK httpd "running" || emit exchange ERROR httpd "not running"
    -  hasp "$EX" 'taler-exchange-wirewatch' && emit exchange OK wirewatch "running" || emit exchange ERROR wirewatch "not running"
    -  hasp "$EX" 'taler-exchange-aggregator' && emit exchange OK aggregator "running" || emit exchange WARN aggregator "not running"
    -  hasp "$EX" 'taler-exchange-transfer' && emit exchange OK transfer "running" || emit exchange WARN transfer "not running"
    -  check_dns exchange "$EX" "$DNS_BANK" || true
    -  check_dns exchange "$EX" "$DNS_EX" || true
    -  check_dns exchange "$EX" "$DNS_MER" || true
    -fi
    -
    -if [ -z "$MER" ]; then emit merchant ERROR container "not running"
    -else
    -  emit merchant INFO container "$(podman ps --filter name=^${MER}$ --format '{{.Names}} {{.Status}}' | head -1)"
    -  c=$(hc "http://127.0.0.1:${MER_PORT}/config")
    -  [ "$c" = "200" ] && emit merchant OK "host :${MER_PORT}/config" "HTTP $c" || emit merchant ERROR "host :${MER_PORT}/config" "HTTP $c"
    -  hasp "$MER" 'taler-merchant-httpd' && emit merchant OK httpd "running" || emit merchant ERROR httpd "not running"
    -  # Stage merchant often has no wirewatch unit — INFO not WARN
    -  hasp "$MER" 'taler-merchant-wirewatch' && emit merchant OK wirewatch "running" || emit merchant INFO wirewatch "not running (optional on stage)"
    -  hasp "$MER" 'taler-merchant-depositcheck' && emit merchant OK depositcheck "running" || emit merchant INFO depositcheck "not running (optional on stage)"
    -  hasp "$MER" 'taler-merchant-exchangekeyupdate' && emit merchant OK exchangekeyupdate "running" || emit merchant INFO exchangekeyupdate "not running"
    -  check_dns merchant "$MER" "$DNS_BANK" || true
    -  check_dns merchant "$MER" "$DNS_EX" || true
    -  check_dns merchant "$MER" "$DNS_MER" || true
    -fi
    -
    -# Caddy is host-wide (often root); stagepaysan can only see the process
    -if pgrep -x caddy >/dev/null 2>&1; then
    -  emit caddy OK process "running (host)"
    -else
    -  emit caddy WARN process "not seen as stagepaysan (may still run as root)"
    -fi
    -
    -echo DONE
    -REMOTE
    -    } | mon_ssh_bash "$SSH_HOST" "${STAGE_SSH_T}" || true
    -  )
    -
    -  if [ -z "$RAW" ] || ! echo "$RAW" | grep -q '^E|'; then
    -    err "ssh" "stage remote timed out or empty (cap ${STAGE_SSH_T}s · host=${SSH_HOST})"
    -    summary
    -    exit 1
    -  fi
    -
    -  _last_inside_grp=""
    -  while IFS= read -r line; do
    -    case "$line" in
    -      E\|*)
    -        IFS='|' read -r _ comp level key detail <<<"$line"
    -        case "$comp" in
    -          bank|exchange|merchant|caddy) _g="$comp" ;;
    -          *) _g="ssh" ;;
    -        esac
    -        if [ "$_g" != "$_last_inside_grp" ]; then
    -          set_group "$_g"
    -          _last_inside_grp="$_g"
    -        fi
    -        case "$level" in
    -          OK)    ok "[$comp] $key${detail:+ ($detail)}" ;;
    -          ERROR) err "$comp" "$key" "$detail" ;;
    -          WARN)  warn "[$comp] $key" "$detail" ;;
    -          INFO)  info "[$comp] $key" "$detail" ;;
    -        esac
    -        ;;
    -    esac
    -  done <<<"$RAW"
    -
    -  # Stats from outside (laptop) — public HTTPS, no stagepaysan write needed
    -  set_group stats
    -  section "inside · public stats (outside-in)"
    -  _probe_public_stats() {
    -    local comp="$1" url="$2"
    -    local body code age unix now
    -    body=$(mktemp)
    -    code=$(curl -skS -m 8 -o "$body" -w '%{http_code}' "$url" 2>/dev/null || echo 000)
    -    if [ "$code" != "200" ]; then
    -      warn "[$comp] public stats.json" "HTTP $code · $url"
    -      rm -f "$body"
    -      return
    -    fi
    -    unix=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("generated_at_unix") or 0)' "$body" 2>/dev/null || echo 0)
    -    now=$(date +%s)
    -    if [ "${unix:-0}" -gt 0 ] 2>/dev/null; then
    -      age=$((now - unix))
    -      if [ "$age" -lt 0 ]; then age=0; fi
    -      if [ "$age" -le "${STATS_STALE_SECS:-900}" ]; then
    -        ok "[$comp] public stats.json" "HTTP 200 age=${age}s · $url"
    -      elif [ "$age" -le "${STATS_FAIL_SECS:-3600}" ]; then
    -        warn "[$comp] public stats.json" "stale age=${age}s · $url"
    -      else
    -        err "$comp" "public stats.json" "too old age=${age}s · $url"
    -      fi
    -    else
    -      ok "[$comp] public stats.json" "HTTP 200 · $url"
    -    fi
    -    rm -f "$body"
    -  }
    -  _probe_public_stats bank "https://${DNS_BANK}/intro/stats.json"
    -  _probe_public_stats exchange "https://${DNS_EX}/intro/stats.json"
    -  _probe_public_stats merchant "https://${DNS_MER}/intro/stats.json"
    -
    -  # Host load as stagepaysan (no container RSS from koopa metrics)
    -  set_group load
    -  section "inside · load (stagepaysan host)"
    -  LOAD_LINE=$(mon_ssh_bash "$SSH_HOST" 8 <<'EOF' || true
    -python3 - <<'PY'
    -import os
    -la=os.getloadavg()
    -print("loadavg=%.2f,%.2f,%.2f" % la)
    -try:
    -  with open("/proc/meminfo") as f:
    -    d={}
    -    for line in f:
    -      k,v=line.split(":")[0], line.split(":")[1].strip().split()[0]
    -      d[k]=int(v)
    -  total=d.get("MemTotal",0)/1024/1024
    -  avail=d.get("MemAvailable",0)/1024/1024
    -  used=total-avail
    -  print("mem_used=%.2fGiB avail=%.2fGiB total=%.2fGiB" % (used, avail, total))
    -except Exception:
    -  print("mem=?")
    -PY
    -EOF
    -)
    -  if [ -n "$LOAD_LINE" ]; then
    -    info "stage host" "$(echo "$LOAD_LINE" | tr '\n' ' ')"
    -  else
    -    info "stage host" "load probe empty"
    -  fi
    -
    -  set_group disk
    -  section "inside · disk free space (stage host + containers)"
    -  _disk_raw=$(mon_ssh_bash "$SSH_HOST" "${STAGE_SSH_T:-24}" <<'DISK' || true
    -set +e
    -echo "###HOST###"
    -df -Pk / /var /home /tmp /mnt/data 2>/dev/null || df -Pk
    -echo "###CTRS###"
    -for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do
    -  echo "###CTR $c###"
    -  podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null
    -done
    -DISK
    -)
    -  _host_df=$(printf '%s\n' "$_disk_raw" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d')
    -  mon_disk_check_remote_text "ssh:${SSH_HOST}" "$_host_df" || true
    -  _ctr=""; _buf=""
    -  while IFS= read -r _line || [ -n "$_line" ]; do
    -    case "$_line" in
    -      '###CTR '*)
    -        if [ -n "$_ctr" ]; then mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true; fi
    -        _ctr=${_line####CTR }; _ctr=${_ctr%###}; _buf=""
    -        ;;
    -      '###CTRS###'|'###HOST###') ;;
    -      *) [ -n "$_ctr" ] && _buf="${_buf}${_line}"$'\n' ;;
    -    esac
    -  done <<<"$_disk_raw"
    -  [ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true
    -
    -  summary
    -  exit 0
    -fi
    -
    -# ---------------------------------------------------------------------------
    -# koopa (default) — host-podman exec (on GOA host) or SSH to KOOPA_SSH (laptop)
    -# ---------------------------------------------------------------------------
    -section "inside · collect from koopa (access=${INSIDE_ACCESS})"
    -
    -if [ "$_use_local_podman" = "1" ]; then
    -  set_group host
    -  if ! command -v podman >/dev/null 2>&1; then
    -    err "host" "INSIDE_PODMAN/host-podman but podman missing"
    -    summary
    -    exit 1
    -  fi
    -  ok "host→container" "podman exec on this host (INSIDE_ACCESS=host-podman · no SSH)"
    -elif [ "${SKIP_SSH}" = "1" ] && [ "${LOCAL_STACK:-0}" != "1" ]; then
    -  set_group ssh
    -  warn "ssh" "SKIP_SSH=1 and not host-podman — skipped"
    -  summary
    -  exit 0
    -elif ! koopa_ssh_ok; then
    -  set_group ssh
    -  err "ssh" "cannot reach ${KOOPA_SSH} — from laptop use KOOPA_SSH=; on host set INSIDE_PODMAN=1"
    -  summary
    -  exit 1
    -else
    -  set_group ssh
    -  ok "ssh ${KOOPA_SSH}" "remote host then podman (INSIDE_ACCESS=ssh · laptop/remote)"
    -fi
    -
    -# Collect script (file → bash local or ssh bash -s)
    -_INSIDE_SCRIPT=$(mktemp)
    -trap 'rm -f "${_INSIDE_SCRIPT:-}"' RETURN
    -cat >"$_INSIDE_SCRIPT" <<'REMOTE'
    -set +e
    -emit() { printf 'E|%s|%s|%s|%s\n' "$1" "$2" "$3" "$(printf '%s' "${4:-}" | tr '\n\r' '  ' | head -c 200)"; }
    -# quick curl
    -hc() { curl -skS -m 3 -o /tmp/mb -w '%{http_code}' "$1" 2>/dev/null || echo 000; }
    -# quick process check inside container (pgrep only)
    -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 -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
    -check_dns() {
    -  local comp="$1" ctr="$2" host="$3"
    -  local line ip code
    -  # Prefer IPv4 (wirewatch/libcurl often happier; avoid dead AAAA)
    -  line=$(podman exec "$ctr" getent ahostsv4 "$host" 2>/dev/null | head -1)
    -  [ -z "$line" ] && line=$(podman exec "$ctr" getent hosts "$host" 2>/dev/null | head -1)
    -  ip=$(echo "$line" | awk '{print $1}')
    -  if [ -z "$ip" ]; then
    -    emit "$comp" ERROR "dns $host" "no resolve — run pin-container-hosts.sh"
    -    return 1
    -  fi
    -  # 127.0.0.1 is almost always wrong for public bank/exchange from inside pasta
    -  if [ "$ip" = "127.0.0.1" ] || [ "$ip" = "::1" ]; then
    -    emit "$comp" ERROR "dns $host" "$ip (loopback — wirewatch will fail)"
    -    return 1
    -  fi
    -  code=$(podman exec "$ctr" curl -skS -m 3 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000)
    -  if [ "$code" = "200" ]; then
    -    emit "$comp" OK "dns $host" "→ $ip /config=$code"
    -  else
    -    emit "$comp" WARN "dns $host" "→ $ip /config=$code"
    -  fi
    -}
    -
    -if [ -z "$BANK" ]; then emit bank ERROR container "not running"
    -else
    -  emit bank INFO container "$(podman ps --filter name=$BANK --format '{{.Names}} {{.Status}}' | head -1)"
    -  emit bank INFO ports "$(podman ps --filter name=$BANK --format '{{.Ports}}' | head -1)"
    -  hasp "$BANK" 'MainKt serve|libeufin-bank serve' && emit bank OK libeufin "running" || emit bank ERROR libeufin "not running — API/withdraw dead"
    -  podman exec "$BANK" pg_isready -q 2>/dev/null && emit bank OK postgres "ready" || emit bank ERROR postgres "not ready"
    -  c=$(hc http://127.0.0.1:9012/config)
    -  [ "$c" = "200" ] && emit bank OK "local /config" "HTTP $c" || emit bank ERROR "local /config" "HTTP $c"
    -  c=$(hc http://127.0.0.1:9012/taler-integration/config)
    -  [ "$c" = "200" ] && emit bank OK "local integration" "HTTP $c" || emit bank ERROR "local integration" "HTTP $c"
    -  hasp "$BANK" 'nginx' && emit bank OK nginx ":9013" || emit bank WARN nginx "not running"
    -  check_dns bank "$BANK" bank.hacktivism.ch || true
    -  check_dns bank "$BANK" exchange.hacktivism.ch || true
    -fi
    -
    -if [ -z "$EX" ]; then emit exchange ERROR container "not running"
    -else
    -  emit exchange INFO container "$(podman ps --filter name=$EX --format '{{.Names}} {{.Status}}' | head -1)"
    -  c=$(hc http://127.0.0.1:9011/config)
    -  [ "$c" = "200" ] && emit exchange OK "local /config" "HTTP $c" || emit exchange ERROR "local /config" "HTTP $c"
    -  c=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000)
    -  [ "$c" = "200" ] && emit exchange OK "local /keys" "HTTP $c" || emit exchange ERROR "local /keys" "HTTP $c"
    -  hasp "$EX" 'taler-exchange-httpd' && emit exchange OK httpd "running" || emit exchange ERROR httpd "not running"
    -  hasp "$EX" 'taler-exchange-wirewatch' && emit exchange OK wirewatch "running" || emit exchange ERROR wirewatch "not running — withdraw stuck after bank confirm"
    -  hasp "$EX" 'taler-exchange-aggregator' && emit exchange OK aggregator "running" || emit exchange WARN aggregator "not running"
    -  hasp "$EX" 'taler-exchange-transfer' && emit exchange OK transfer "running" || emit exchange WARN transfer "not running"
    -  # critical for wire gateway
    -  check_dns exchange "$EX" bank.hacktivism.ch || true
    -  check_dns exchange "$EX" exchange.hacktivism.ch || true
    -  check_dns exchange "$EX" taler.hacktivism.ch || true
    -fi
    -
    -if [ -z "$MER" ]; then emit merchant ERROR container "not running"
    -else
    -  emit merchant INFO container "$(podman ps --filter name=$MER --format '{{.Names}} {{.Status}}' | head -1)"
    -  c=$(hc https://127.0.0.1:9010/config)
    -  [ "$c" = "200" ] && emit merchant OK "local /config" "HTTP $c" || emit merchant ERROR "local /config" "HTTP $c"
    -  hasp "$MER" 'taler-merchant-httpd' && emit merchant OK httpd "running" || emit merchant ERROR httpd "not running"
    -  hasp "$MER" 'taler-merchant-wirewatch' && emit merchant OK wirewatch "running" || emit merchant WARN wirewatch "not running"
    -  # depositcheck: may abort on exchange track 404 (merchant assertion) — try ensure once
    -  if hasp "$MER" 'taler-merchant-depositcheck'; then
    -    emit merchant OK depositcheck "running"
    -  else
    -    podman exec "$MER" /usr/local/bin/ensure_merchant_helpers.sh >/dev/null 2>&1 || true
    -    sleep 1
    -    if hasp "$MER" 'taler-merchant-depositcheck'; then
    -      emit merchant OK depositcheck "running (after ensure_merchant_helpers)"
    -    elif hasp "$MER" 'taler-merchant-wirewatch'; then
    -      # Known: depositcheck can SIGABRT on exchange 404 for stale coins — not startable cleanly
    -      emit merchant INFO depositcheck "not running (often aborts on track 404; wirewatch OK · settlement partial)"
    -    else
    -      emit merchant WARN depositcheck "not running"
    -    fi
    -  fi
    -  check_dns merchant "$MER" bank.hacktivism.ch || true
    -  check_dns merchant "$MER" exchange.hacktivism.ch || true
    -  check_dns merchant "$MER" taler.hacktivism.ch || true
    -fi
    -
    -if systemctl is-active caddy >/dev/null 2>&1 || pgrep -x caddy >/dev/null 2>&1; then
    -  emit caddy OK process "active"
    -else
    -  emit caddy ERROR process "not active"
    -fi
    -echo DONE
    -REMOTE
    -
    -if [ "$_use_local_podman" = "1" ]; then
    -  RAW=$(bash "$_INSIDE_SCRIPT" || true)
    -else
    -  RAW=$(koopa_ssh_bash "${SSH_CMD_TIMEOUT}" <"$_INSIDE_SCRIPT" || true)
    -fi
    -rm -f "$_INSIDE_SCRIPT"
    -
    -if [ -z "$RAW" ] || ! echo "$RAW" | grep -q '^E|'; then
    -  if [ "$_use_local_podman" = "1" ]; then
    -    err "host" "collect empty (INSIDE_ACCESS=host-podman · podman exec failed)"
    -  else
    -    err "ssh" "collect empty (INSIDE_ACCESS=ssh · cap ${SSH_CMD_TIMEOUT}s · host=${KOOPA_SSH})"
    -  fi
    -  summary
    -  exit 1
    -fi
    -
    -# Fallback group for non-component rows: host (podman) vs ssh (remote)
    -_access_grp=ssh
    -[ "$_use_local_podman" = "1" ] && _access_grp=host
    -
    -_last_inside_grp=""
    -while IFS= read -r line; do
    -  case "$line" in
    -    E\|*)
    -      IFS='|' read -r _ comp level key detail <<<"$line"
    -      # Group IDs by component: inside.bank-02, inside.exchange-04; access = host|ssh
    -      case "$comp" in
    -        bank|exchange|merchant|caddy) _g="$comp" ;;
    -        *) _g="$_access_grp" ;;
    -      esac
    -      if [ "$_g" != "$_last_inside_grp" ]; then
    -        set_group "$_g"
    -        _last_inside_grp="$_g"
    -      fi
    -      case "$level" in
    -        OK)    ok "[$comp] $key${detail:+ ($detail)}" ;;
    -        ERROR) err "$comp" "$key" "$detail" ;;
    -        WARN)  warn "[$comp] $key" "$detail" ;;
    -        INFO)  info "[$comp] $key" "$detail" ;;
    -      esac
    -      ;;
    -  esac
    -done <<<"$RAW"
    -
    -# Host loadavg + RAM + per-container RSS/CPU (same probe as e2e/ladder)
    -set_group load
    -section "inside · load / memory"
    -METRICS_DIR="${METRICS_DIR:-$(mktemp -d)}"
    -export METRICS_DIR
    -metrics_report_load "${METRICS_DIR}/load-inside.json" "inside" || true
    -
    -# Disk free space: host + taler containers (WARN tight, ERROR full)
    -set_group disk
    -section "inside · disk free space"
    -_disk_ec=0
    -if [ "$_use_local_podman" = "1" ]; then
    -  mon_disk_check_host "host" || _disk_ec=1
    -  while read -r _cname; do
    -    [ -n "$_cname" ] || continue
    -    mon_disk_check_podman "$_cname" || _disk_ec=1
    -  done < <(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism|taler-|stage-lfp|lfp-' || true)
    -else
    -  # remote host via SSH: df on host + each container
    -  _disk_raw=$(koopa_ssh_bash "${SSH_CMD_TIMEOUT:-20}" <<'DISK' || true
    -set +e
    -echo "###HOST###"
    -df -Pk / /var /home /tmp 2>/dev/null || df -Pk
    -echo "###CTRS###"
    -for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do
    -  echo "###CTR $c###"
    -  podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null
    -done
    -DISK
    -)
    -  _host_df=$(printf '%s\n' "$_disk_raw" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d')
    -  mon_disk_check_remote_text "ssh:${KOOPA_SSH:-remote}" "$_host_df" || _disk_ec=1
    -  _ctr=""
    -  _buf=""
    -  while IFS= read -r _line || [ -n "$_line" ]; do
    -    case "$_line" in
    -      '###CTR '*)
    -        if [ -n "$_ctr" ]; then
    -          mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || _disk_ec=1
    -        fi
    -        _ctr=${_line####CTR }
    -        _ctr=${_ctr%###}
    -        _buf=""
    -        ;;
    -      '###CTRS###'|'###HOST###') ;;
    -      *)
    -        if [ -n "$_ctr" ]; then
    -          _buf="${_buf}${_line}"$'\n'
    -        fi
    -        ;;
    -    esac
    -  done <<<"$_disk_raw"
    -  if [ -n "$_ctr" ]; then
    -    mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || _disk_ec=1
    -  fi
    -fi
    -unset _disk_raw _host_df _ctr _buf _line _cname _disk_ec
    -
    -summary
    diff --git a/scripts/taler-monitoring/check_qr_payloads.py b/scripts/taler-monitoring/check_qr_payloads.py
    deleted file mode 100755
    index 74efde4..0000000
    --- a/scripts/taler-monitoring/check_qr_payloads.py
    +++ /dev/null
    @@ -1,294 +0,0 @@
    -#!/usr/bin/env python3
    -"""Collect + validate QR-related payloads for taler-monitoring (www.qr-*).
    -
    -Sources (CLI paths / URLs handled by caller writing local files):
    -  - landing HTML (href, data-qr-url, data-qr-taler, raw text)
    -  - demo-withdraw.json / auto-account.json (taler_withdraw_uri / qr_payload)
    -  - optional: already-decoded lines from zbarimg (kind=decoded)
    -
    -Stdout TSV: source\\tkind\\turi\\tstatus\\tdetail
    -  kind: withdraw | pay | pay-template | refund | payto | https | other | empty
    -  status: ok | bad
    -
    -Exit 0 always (caller tallies ok/bad lines). stderr for hard parse errors only.
    -"""
    -from __future__ import annotations
    -
    -import json
    -import re
    -import sys
    -from html.parser import HTMLParser
    -from pathlib import Path
    -from urllib.parse import unquote, urlparse
    -
    -# Schemes we care about for wallet / settlement QRs
    -TALER_RE = re.compile(r"taler://[^\s\"'<>]+", re.I)
    -PAYTO_RE = re.compile(r"payto://[^\s\"'<>]+", re.I)
    -HTTPS_RE = re.compile(r"https://[^\s\"'<>]+", re.I)
    -
    -WITHDRAW_RE = re.compile(
    -    r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)/?$"
    -)
    -# order pay / template — host may include path segments after
    -PAY_RE = re.compile(r"^taler://pay/([^/?#]+)/", re.I)
    -PAY_TEMPLATE_RE = re.compile(r"^taler://pay-template/([^/?#]+)/", re.I)
    -REFUND_RE = re.compile(r"^taler://refund/", re.I)
    -PAYTO_OK_RE = re.compile(r"^payto://[a-z0-9][a-z0-9+.-]*/", re.I)
    -
    -
    -def looks_like_real_uri(uri: str) -> bool:
    -    """Drop JS regex fragments, docs ellipses, incomplete placeholders."""
    -    if not uri or len(uri) < 12:
    -        return False
    -    if any(c in uri for c in "[]{}<>\n\r\t"):
    -        return False
    -    if "…" in uri or "..." in uri:
    -        return False
    -    # incomplete withdraw stubs in docs
    -    if re.match(r"^taler://withdraw/?$", uri, re.I):
    -        return False
    -    if re.match(r"^taler://pay/?$", uri, re.I):
    -        return False
    -    # trailing junk / JS regex fragments (allow ? & = in query strings)
    -    if uri.endswith((")", "(", "\\", "^", "*")):
    -        return False
    -    if re.search(r"[\\^$*]", uri):
    -        return False
    -    # lone incomplete `taler://…?` without path
    -    if re.match(r"^taler://[^/]+$", uri, re.I):
    -        return False
    -    return True
    -
    -
    -def strip_default_port_host(host: str) -> str | None:
    -    if not host or host.startswith(":"):
    -        return None
    -    if ":" in host:
    -        h, _, p = host.rpartition(":")
    -        if not h or not p.isdigit():
    -            return None
    -        if p in ("443", "80"):
    -            return None  # must be stripped for wallets
    -    return host
    -
    -
    -def classify_and_validate(uri: str) -> tuple[str, str, str]:
    -    """Return (kind, status, detail)."""
    -    uri = (uri or "").strip()
    -    if not uri:
    -        return "empty", "bad", "empty"
    -    # strip accidental trailing punctuation from HTML scrape
    -    uri = uri.rstrip(").,;]")
    -    low = uri.lower()
    -
    -    if low.startswith("taler://withdraw/"):
    -        m = WITHDRAW_RE.match(uri)
    -        if not m:
    -            return "withdraw", "bad", "shape need taler://withdraw/HOST/taler-integration/UUID"
    -        host = m.group(1)
    -        if strip_default_port_host(host) is None:
    -            return "withdraw", "bad", "host/port invalid or default :443/:80 not stripped"
    -        return "withdraw", "ok", f"host={host} id={m.group(2)[:12]}…"
    -
    -    if low.startswith("taler://pay-template/"):
    -        m = PAY_TEMPLATE_RE.match(uri)
    -        if not m:
    -            return "pay-template", "bad", "need taler://pay-template/HOST/…"
    -        host = m.group(1)
    -        if strip_default_port_host(host) is None:
    -            return "pay-template", "bad", "host/port invalid or default port not stripped"
    -        return "pay-template", "ok", f"host={host}"
    -
    -    if low.startswith("taler://pay/"):
    -        m = PAY_RE.match(uri)
    -        if not m:
    -            return "pay", "bad", "need taler://pay/HOST/…"
    -        host = m.group(1)
    -        if strip_default_port_host(host) is None:
    -            return "pay", "bad", "host/port invalid or default port not stripped"
    -        if "instances" not in uri and "/orders/" not in uri:
    -            # still often valid private order URIs
    -            return "pay", "ok", f"host={host}"
    -        return "pay", "ok", f"host={host}"
    -
    -    if low.startswith("taler://refund/"):
    -        if not REFUND_RE.match(uri):
    -            return "refund", "bad", "bad refund URI"
    -        return "refund", "ok", "refund"
    -
    -    if low.startswith("taler://"):
    -        return "other", "ok", "other taler:// (accepted)"
    -
    -    if low.startswith("payto://"):
    -        if not PAYTO_OK_RE.match(uri):
    -            return "payto", "bad", "need payto://method/…"
    -        # wallet pay QR must not be payto for GOA demo-withdraw (checked elsewhere)
    -        return "payto", "ok", uri.split("?", 1)[0][:72]
    -
    -    if low.startswith("https://") or low.startswith("http://"):
    -        p = urlparse(uri)
    -        if p.scheme not in ("http", "https") or not p.netloc:
    -            return "https", "bad", "bad URL"
    -        return "https", "ok", p.netloc + (p.path or "/")[:40]
    -
    -    return "other", "bad", "unsupported scheme"
    -
    -
    -class LinkHarvester(HTMLParser):
    -    def __init__(self) -> None:
    -        super().__init__()
    -        self.uris: list[tuple[str, str]] = []  # (source_hint, uri)
    -
    -    def handle_starttag(self, tag, attrs):
    -        ad = {k.lower(): (v or "") for k, v in attrs}
    -        for key in ("href", "src", "data-qr-url", "data-qr-taler", "data-uri", "data-payto"):
    -            if key in ad and ad[key]:
    -                self.uris.append((f"attr:{key}", ad[key].strip()))
    -        for k, v in ad.items():
    -            if k.startswith("data-") and v and (
    -                "taler://" in v.lower() or "payto://" in v.lower() or k.endswith("qr-url")
    -            ):
    -                self.uris.append((f"attr:{k}", v.strip()))
    -
    -    def handle_data(self, data):
    -        if not data or "://" not in data:
    -            return
    -        for m in TALER_RE.finditer(data):
    -            self.uris.append(("text", m.group(0)))
    -        for m in PAYTO_RE.finditer(data):
    -            self.uris.append(("text", m.group(0)))
    -
    -
    -def harvest_html(path: Path, source: str) -> list[tuple[str, str, str]]:
    -    raw = path.read_text(encoding="utf-8", errors="replace")
    -    h = LinkHarvester()
    -    try:
    -        h.feed(raw)
    -    except Exception:
    -        pass
    -    out: list[tuple[str, str, str]] = []
    -    for hint, u in h.uris:
    -        lu = u.lower()
    -        if lu.startswith(("taler://", "payto://", "https://", "http://")):
    -            if looks_like_real_uri(u):
    -                out.append((f"{source}:{hint}", u, "html"))
    -        # raster image paths that look like QR assets (not .js)
    -        elif re.search(r"\.(png|jpe?g|gif)(\?|$)", lu) and "qr" in lu:
    -            out.append((f"{source}:imgpath", u, "imgpath"))
    -    # Prefer attribute/text harvest; light regex only for complete-looking URIs in pre/code
    -    for m in TALER_RE.finditer(raw):
    -        u = m.group(0).rstrip(").,;]'\"")
    -        if looks_like_real_uri(u) and (
    -            WITHDRAW_RE.match(u)
    -            or PAY_RE.match(u)
    -            or PAY_TEMPLATE_RE.match(u)
    -            or u.lower().startswith("taler://refund/")
    -        ):
    -            out.append((f"{source}:re", u, "html"))
    -    for m in PAYTO_RE.finditer(raw):
    -        u = m.group(0).rstrip(").,;]'\"")
    -        if looks_like_real_uri(u) and PAYTO_OK_RE.match(u):
    -            out.append((f"{source}:re", u, "html"))
    -    return out
    -
    -
    -def harvest_json(path: Path, source: str) -> list[tuple[str, str, str]]:
    -    try:
    -        d = json.loads(path.read_text(encoding="utf-8", errors="replace"))
    -    except Exception as e:
    -        return [(source, "", f"json-error:{e}")]
    -    out: list[tuple[str, str, str]] = []
    -    if not isinstance(d, dict):
    -        return out
    -    for key in (
    -        "taler_withdraw_uri",
    -        "qr_payload",
    -        "taler_pay_uri",
    -        "payto_uri",
    -        "payto",
    -        "uri",
    -    ):
    -        v = d.get(key)
    -        if isinstance(v, str) and v.strip():
    -            out.append((f"{source}:{key}", v.strip(), "json"))
    -    # nested
    -    for k, v in d.items():
    -        if isinstance(v, str) and (
    -            v.startswith("taler://") or v.startswith("payto://")
    -        ):
    -            out.append((f"{source}:{k}", v.strip(), "json"))
    -    return out
    -
    -
    -def main(argv: list[str]) -> int:
    -    # args: pairs of  source_label  path
    -    if len(argv) < 3 or len(argv) % 2 == 0:
    -        print(
    -            "usage: check_qr_payloads.py LABEL path [LABEL path ...]",
    -            file=sys.stderr,
    -        )
    -        return 2
    -    seen: set[str] = set()
    -    rows: list[tuple[str, str, str, str, str]] = []
    -    i = 1
    -    while i < len(argv):
    -        label, path_s = argv[i], argv[i + 1]
    -        i += 2
    -        path = Path(path_s)
    -        if not path.is_file():
    -            rows.append((label, "empty", "", "bad", f"missing file {path_s}"))
    -            continue
    -        if path.suffix.lower() in (".html", ".htm") or "html" in path.name:
    -            items = harvest_html(path, label)
    -        elif path.suffix.lower() == ".json" or path.name.endswith(".json"):
    -            items = harvest_json(path, label)
    -        else:
    -            # plain text: one URI per line or raw
    -            text = path.read_text(encoding="utf-8", errors="replace").strip()
    -            items = []
    -            for line in text.splitlines():
    -                line = line.strip()
    -                if line:
    -                    items.append((label, line, "text"))
    -            if not items and text:
    -                items.append((label, text, "text"))
    -
    -        for src, uri, _origin in items:
    -            if not uri:
    -                continue
    -            # skip pure fragment / relative without scheme for validate
    -            if uri.startswith("#") or uri == "/":
    -                continue
    -            lu = uri.lower()
    -            if lu.startswith("/") or "imgpath" in src:
    -                continue
    -            if not looks_like_real_uri(uri):
    -                continue
    -            # https: app-store / wallet / explicit data-qr-* only
    -            if lu.startswith("http"):
    -                if "data-qr" not in src and not any(
    -                    x in lu
    -                    for x in (
    -                        "play.google",
    -                        "apps.apple",
    -                        "f-droid",
    -                        "wallet.taler",
    -                        "taler.net/wallet",
    -                    )
    -                ):
    -                    continue
    -            if uri in seen:
    -                continue
    -            seen.add(uri)
    -            kind, status, detail = classify_and_validate(uri)
    -            rows.append((src, kind, uri, status, detail))
    -
    -    for src, kind, uri, status, detail in rows:
    -        # TSV — uri may contain tabs rarely; replace
    -        safe = uri.replace("\t", " ").replace("\n", " ")
    -        print(f"{src}\t{kind}\t{safe}\t{status}\t{detail}")
    -    return 0
    -
    -
    -if __name__ == "__main__":
    -    raise SystemExit(main(sys.argv))
    diff --git a/scripts/taler-monitoring/check_sanity.sh b/scripts/taler-monitoring/check_sanity.sh
    deleted file mode 100755
    index 1e3b922..0000000
    --- a/scripts/taler-monitoring/check_sanity.sh
    +++ /dev/null
    @@ -1,285 +0,0 @@
    -#!/usr/bin/env bash
    -# Sanity checks for bank · exchange · merchant (public + server-side).
    -# Sections are independent; continues after failures.
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -
    -tmp=$(mktemp -d)
    -trap 'rm -rf "$tmp"' EXIT
    -
    -expect_code() {
    -  local label="$1" want="$2" url="$3"
    -  local code
    -  code=$(http_code "$url")
    -  case ",$want," in
    -    *",$code,"*) ok "$label" ;;
    -    *) fail "$label" "HTTP $code (want $want) $url" ;;
    -  esac
    -}
    -
    -json_currency() {
    -  python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("currency") or "")' "$1" 2>/dev/null || true
    -}
    -
    -# Area sanity.* — public + optional server-side per component
    -# Groups: sanity.bank / sanity.exchange / sanity.merchant
    -set_area sanity
    -
    -# ---------------------------------------------------------------------------
    -set_group bank
    -section "sanity · bank"
    -# ---------------------------------------------------------------------------
    -expect_code "bank public /config" 200 "$BANK_PUBLIC/config"
    -expect_code "bank public /taler-integration/config" 200 "$BANK_PUBLIC/taler-integration/config"
    -expect_code "bank public /webui/" 200 "$BANK_PUBLIC/webui/"
    -
    -code=$(http_body "$BANK_PUBLIC/config" "$tmp/bank-config.json")
    -if [ "$code" = "200" ]; then
    -  cur=$(json_currency "$tmp/bank-config.json")
    -  [ "$cur" = "GOA" ] && ok "bank currency GOA" || fail "bank currency" "got ${cur:-?}"
    -  # wire type / name if present
    -  python3 - "$tmp/bank-config.json" <<'PY' 2>/dev/null && ok "bank config JSON object" || fail "bank config JSON"
    -import json,sys
    -d=json.load(open(sys.argv[1]))
    -sys.exit(0 if isinstance(d, dict) and d.get("currency") else 1)
    -PY
    -  if json_has_alt_unit_names "$tmp/bank-config.json" >/tmp/alt-bank-s.$$ 2>&1; then
    -    ok "bank /config alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000)
    -echo "LOCAL_CONFIG=$code"
    -code2=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:9012/taler-integration/config 2>/dev/null || echo 000)
    -echo "LOCAL_INT=$code2"
    -if podman exec "$BANK" bash -c 'pgrep -f "MainKt serve|libeufin-bank serve" >/dev/null' 2>/dev/null; then
    -  echo "LIBEUFIN=1"
    -else
    -  echo "LIBEUFIN=0"
    -fi
    -if podman exec "$BANK" bash -c 'pg_isready -q' 2>/dev/null; then
    -  echo "PG=1"
    -else
    -  echo "PG=0"
    -fi
    -# in-container health if present
    -if podman exec "$BANK" test -x /usr/local/bin/check_bank-health.sh 2>/dev/null; then
    -  podman exec "$BANK" /usr/local/bin/check_bank-health.sh 2>&1 | sed 's/^/HEALTH /' | tail -20
    -  echo "HEALTH_EC=${PIPESTATUS[0]}"
    -fi
    -REMOTE
    -)
    -  echo "$BOUT" | grep -q '^CTR=.\+' && ok "bank container $(echo "$BOUT" | sed -n 's/^CTR=//p' | head -1)" || fail "bank container" "not found"
    -  echo "$BOUT" | grep -q 'LOCAL_CONFIG=200' && ok "bank local :9012/config" || fail "bank local :9012/config"
    -  echo "$BOUT" | grep -q 'LOCAL_INT=200' && ok "bank local :9012/taler-integration/config" || fail "bank local integration"
    -  echo "$BOUT" | grep -q 'LIBEUFIN=1' && ok "bank libeufin-bank process" || fail "bank libeufin-bank process"
    -  echo "$BOUT" | grep -q 'PG=1' && ok "bank postgres ready" || warn "bank postgres" "pg_isready failed"
    -  if echo "$BOUT" | grep -q 'HEALTH '; then
    -    if echo "$BOUT" | grep -qE 'HEALTH_EC=0|ALL CRITICAL CHECKS PASSED'; then
    -      ok "bank check_bank-health.sh"
    -    else
    -      # health script may false-fail process grep; warn not fail if local config ok
    -      warn "bank check_bank-health.sh" "non-zero or incomplete"
    -    fi
    -  fi
    -else
    -  warn "bank server-side" "ssh ${KOOPA_SSH} unavailable"
    -fi
    -
    -# ---------------------------------------------------------------------------
    -set_group exchange
    -section "sanity · exchange"
    -# ---------------------------------------------------------------------------
    -expect_code "exchange public /config" 200 "$EXCHANGE_PUBLIC/config"
    -expect_code "exchange public /keys" 200 "$EXCHANGE_PUBLIC/keys"
    -expect_code "exchange public /terms" 200 "$EXCHANGE_PUBLIC/terms"
    -
    -code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ex-config.json")
    -if [ "$code" = "200" ]; then
    -  cur=$(json_currency "$tmp/ex-config.json")
    -  [ "$cur" = "GOA" ] && ok "exchange currency GOA" || fail "exchange currency" "got ${cur:-?}"
    -  if json_has_alt_unit_names "$tmp/ex-config.json" "GOA" >/tmp/alt-ex-s.$$ 2>&1; then
    -    ok "exchange /config alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000)
    -echo "LOCAL_CONFIG=$code"
    -codek=$(curl -sS -m 8 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000)
    -echo "LOCAL_KEYS=$codek"
    -if [ -n "$EX" ]; then
    -  if podman exec "$EX" bash -c 'pgrep -f taler-exchange-httpd >/dev/null' 2>/dev/null; then
    -    echo "HTTPD=1"
    -  else
    -    echo "HTTPD=0"
    -  fi
    -  for p in taler-exchange-secmod-rsa taler-exchange-secmod-eddsa taler-exchange-wirewatch taler-exchange-aggregator; do
    -    if podman exec "$EX" bash -c "pgrep -f $p >/dev/null" 2>/dev/null; then
    -      echo "PROC_$p=1"
    -    else
    -      echo "PROC_$p=0"
    -    fi
    -  done
    -  if podman exec "$EX" test -x /usr/local/bin/check_exchange-health.sh 2>/dev/null; then
    -    SKIP_ENSURE=1 podman exec -e SKIP_ENSURE=1 "$EX" /usr/local/bin/check_exchange-health.sh 2>&1 | sed 's/^/HEALTH /' | tail -25
    -  fi
    -fi
    -REMOTE
    -)
    -  echo "$EOUT" | grep -q '^CTR=.\+' && ok "exchange container $(echo "$EOUT" | sed -n 's/^CTR=//p' | head -1)" || fail "exchange container"
    -  echo "$EOUT" | grep -q 'LOCAL_CONFIG=200' && ok "exchange local :9011/config" || fail "exchange local :9011/config"
    -  echo "$EOUT" | grep -q 'LOCAL_KEYS=200' && ok "exchange local :9011/keys" || fail "exchange local :9011/keys"
    -  echo "$EOUT" | grep -q 'HTTPD=1' && ok "exchange-httpd process" || warn "exchange-httpd process" "not detected"
    -  echo "$EOUT" | grep -q 'PROC_taler-exchange-wirewatch=1' && ok "exchange wirewatch" || warn "exchange wirewatch" "not running"
    -  echo "$EOUT" | grep -q 'PROC_taler-exchange-aggregator=1' && ok "exchange aggregator" || warn "exchange aggregator" "not running"
    -else
    -  warn "exchange server-side" "ssh unavailable"
    -fi
    -
    -# ---------------------------------------------------------------------------
    -set_group merchant
    -section "sanity · merchant"
    -# ---------------------------------------------------------------------------
    -expect_code "merchant public /config" 200 "$MERCHANT_PUBLIC/config"
    -expect_code "merchant public /webui/" 200 "$MERCHANT_PUBLIC/webui/"
    -
    -code=$(http_body "$MERCHANT_PUBLIC/config" "$tmp/mer-config.json")
    -if [ "$code" = "200" ]; then
    -  if python3 - "$tmp/mer-config.json" "$EXCHANGE_PUBLIC" <<'PY'
    -import json,sys
    -d=json.load(open(sys.argv[1]))
    -want=sys.argv[2].rstrip("/")
    -curs=list((d.get("currencies") or {}).keys())
    -ex=d.get("exchanges") or []
    -urls=[]
    -for e in ex:
    -  if isinstance(e, dict):
    -    u=e.get("base_url") or e.get("url") or e.get("exchange_base_url") or ""
    -    if u: urls.append(u.rstrip("/"))
    -  elif isinstance(e, str):
    -    urls.append(e.rstrip("/"))
    -ok_goa = "GOA" in curs or any((e.get("currency") if isinstance(e, dict) else None)=="GOA" for e in ex)
    -ok_ex = any(want in u or "exchange.hacktivism.ch" in u for u in urls)
    -print("currencies", curs)
    -print("exchanges", urls[:5])
    -sys.exit(0 if ok_goa and ok_ex else 1)
    -PY
    -  then
    -    ok "merchant config GOA + exchange.hacktivism.ch"
    -  else
    -    fail "merchant config GOA + exchange" "see currencies/exchanges"
    -  fi
    -  if json_has_alt_unit_names "$tmp/mer-config.json" >/tmp/alt-mer-s.$$ 2>&1; then
    -    ok "merchant currencies alt_unit_names" "$(tr '\n' '; ' /dev/null || echo 000)
    -echo "LOCAL_CONFIG=$code"
    -if [ -n "$MER" ]; then
    -  if podman exec "$MER" bash -c 'pgrep -f taler-merchant-httpd >/dev/null' 2>/dev/null; then
    -    echo "HTTPD=1"
    -  else
    -    echo "HTTPD=0"
    -  fi
    -  if podman exec "$MER" test -x /usr/local/bin/check_merchant-health.sh 2>/dev/null; then
    -    SKIP_ENSURE=1 podman exec -e SKIP_ENSURE=1 "$MER" /usr/local/bin/check_merchant-health.sh 2>&1 | sed 's/^/HEALTH /' | tail -30
    -  fi
    -fi
    -REMOTE
    -)
    -  echo "$MOUT" | grep -q '^CTR=.\+' && ok "merchant container $(echo "$MOUT" | sed -n 's/^CTR=//p' | head -1)" || fail "merchant container"
    -  echo "$MOUT" | grep -q 'LOCAL_CONFIG=200' && ok "merchant local :9010/config" || fail "merchant local :9010/config"
    -  echo "$MOUT" | grep -q 'HTTPD=1' && ok "merchant-httpd process" || warn "merchant-httpd process" "not detected"
    -  if echo "$MOUT" | grep -q 'HEALTH '; then
    -    if echo "$MOUT" | grep -qiE 'ALL CRITICAL|HEALTH_EC=0|\[OK\]'; then
    -      ok "merchant check_merchant-health.sh (sample OK)"
    -    else
    -      warn "merchant check_merchant-health.sh" "see remote output"
    -    fi
    -  fi
    -else
    -  warn "merchant server-side" "ssh unavailable"
    -fi
    -
    -summary
    diff --git a/scripts/taler-monitoring/check_server.sh b/scripts/taler-monitoring/check_server.sh
    deleted file mode 100755
    index 3a9618a..0000000
    --- a/scripts/taler-monitoring/check_server.sh
    +++ /dev/null
    @@ -1,158 +0,0 @@
    -#!/usr/bin/env bash
    -# Server-side component checks on koopa (via SSH).
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -
    -# Area server.* — host/container ports via SSH
    -set_area server
    -set_group ssh
    -section "server · ssh ${KOOPA_SSH}"
    -
    -if ! koopa_ssh_ok; then
    -  fail "ssh ${KOOPA_SSH}" "unreachable within ${SSH_CONNECT_TIMEOUT}s (SKIP_SSH=1 to skip)"
    -  summary
    -  exit 1
    -fi
    -ok "ssh ${KOOPA_SSH}"
    -
    -# Run a remote script; collect structured lines
    -REMOTE=$(koopa_ssh_bash "${SSH_CMD_TIMEOUT}" <<'REMOTE'
    -set +e
    -report() { printf 'R|%s|%s|%s\n' "$1" "$2" "$3"; }
    -
    -# containers
    -for name in taler-hacktivism-bank taler-hacktivism-exchange-ansible taler-hacktivism; do
    -  if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then
    -    st=$(podman ps --filter "name=^${name}$" --format '{{.Status}}' | head -1)
    -    report OK "container $name" "$st"
    -  else
    -    # soft match
    -    hit=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E "$name|bank|exchange|hacktivism" | head -3 | tr '\n' ' ')
    -    if podman ps --format '{{.Names}}' 2>/dev/null | grep -q bank && [ "$name" = taler-hacktivism-bank ]; then
    -      bn=$(podman ps --format '{{.Names}}' | grep -i bank | head -1)
    -      report OK "container bank ($bn)" "$(podman ps --filter name="$bn" --format '{{.Status}}' | head -1)"
    -    elif podman ps --format '{{.Names}}' 2>/dev/null | grep -qi exchange && echo "$name" | grep -qi exchange; then
    -      en=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1)
    -      report OK "container exchange ($en)" "$(podman ps --filter name="$en" --format '{{.Status}}' | head -1)"
    -    elif podman ps --format '{{.Names}}' 2>/dev/null | grep -qx taler-hacktivism && [ "$name" = taler-hacktivism ]; then
    -      report OK "container taler-hacktivism" "$(podman inspect -f '{{.State.Status}}' taler-hacktivism 2>/dev/null)"
    -    else
    -      report FAIL "container $name" "not running (seen: $hit)"
    -    fi
    -  fi
    -done
    -
    -# local HTTP on pasta ports
    -for spec in \
    -  "bank-api|http://127.0.0.1:9012/config|200" \
    -  "bank-integration|http://127.0.0.1:9012/taler-integration/config|200" \
    -  "landing|http://127.0.0.1:9013/intro/|200" \
    -  "exchange|http://127.0.0.1:9011/config|200" \
    -  "merchant|https://127.0.0.1:9010/config|200"
    -do
    -  IFS='|' read -r id url want <<<"$spec"
    -  code=$(curl -skS -m 5 -o /tmp/mon-s.out -w '%{http_code}' "$url" 2>/dev/null || echo 000)
    -  if [ "$code" = "$want" ]; then
    -    report OK "local $id : ${url#*//}" "HTTP $code"
    -  else
    -    report FAIL "local $id" "HTTP $code want $want"
    -  fi
    -done
    -
    -# processes inside bank
    -BANK=$(podman ps --format '{{.Names}}' | grep -iE 'bank|hacktivism-bank' | head -1)
    -if [ -n "$BANK" ]; then
    -  if podman exec "$BANK" bash -c 'pgrep -f "MainKt serve|libeufin-bank serve" >/dev/null' 2>/dev/null; then
    -    report OK "libeufin-bank process" "in $BANK"
    -  else
    -    report FAIL "libeufin-bank process" "not running in $BANK"
    -  fi
    -  if podman exec "$BANK" bash -c 'pg_isready -q' 2>/dev/null; then
    -    report OK "postgres (bank)" "ready"
    -  else
    -    report WARN "postgres (bank)" "pg_isready failed"
    -  fi
    -  if podman exec "$BANK" bash -c 'pgrep -x nginx >/dev/null' 2>/dev/null; then
    -    report OK "nginx landing" "in $BANK"
    -  else
    -    report WARN "nginx landing" "not seen in $BANK"
    -  fi
    -else
    -  report FAIL "bank container" "none"
    -fi
    -
    -# exchange process / systemd if any
    -EX=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1)
    -if [ -n "$EX" ]; then
    -  if podman exec "$EX" bash -c 'pgrep -f taler-exchange-httpd >/dev/null || systemctl is-active taler-exchange-httpd 2>/dev/null | grep -q active' 2>/dev/null; then
    -    report OK "exchange-httpd" "in $EX"
    -  else
    -    # config answering is enough
    -    report WARN "exchange-httpd process" "not detected; port check above"
    -  fi
    -fi
    -
    -MER=$(podman ps --format '{{.Names}}' | grep -E '^taler-hacktivism$' | head -1)
    -[ -z "$MER" ] && MER=$(podman ps --format '{{.Names}}' | grep -i merchant | head -1)
    -if [ -n "$MER" ]; then
    -  if podman exec "$MER" bash -c 'pgrep -f taler-merchant-httpd >/dev/null || true; curl -sk -m 3 -o /dev/null -w %{http_code} https://127.0.0.1:9010/config' 2>/dev/null | grep -q 200; then
    -    report OK "merchant-httpd" "responds in $MER"
    -  else
    -    report WARN "merchant-httpd" "check manually in $MER"
    -  fi
    -fi
    -
    -# caddy on host
    -if systemctl is-active caddy >/dev/null 2>&1 || pgrep -x caddy >/dev/null 2>&1; then
    -  report OK "caddy" "active"
    -else
    -  report WARN "caddy" "not detected as active"
    -fi
    -
    -# disk free (host + containers) — lines for mon_disk_check_remote_text on laptop side
    -echo "###DISK_HOST###"
    -df -Pk / /var /home /tmp 2>/dev/null || df -Pk
    -echo "###DISK_CTRS###"
    -for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do
    -  echo "###CTR $c###"
    -  podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null
    -done
    -REMOTE
    -)
    -
    -while IFS= read -r line; do
    -  case "$line" in
    -    R\|*)
    -      IFS='|' read -r _ st label detail <<<"$line"
    -      case "$st" in
    -        OK) ok "$label${detail:+ ($detail)}" ;;
    -        FAIL) fail "$label" "$detail" ;;
    -        WARN) warn "$label" "$detail" ;;
    -      esac
    -      ;;
    -  esac
    -done <<<"$REMOTE"
    -
    -set_group disk
    -section "server · disk free space"
    -_host_df=$(printf '%s\n' "$REMOTE" | sed -n '/^###DISK_HOST###$/,/^###DISK_CTRS###$/p' | sed '1d;$d')
    -mon_disk_check_remote_text "ssh:${KOOPA_SSH}" "$_host_df" || true
    -_ctr=""; _buf=""
    -while IFS= read -r _line || [ -n "$_line" ]; do
    -  case "$_line" in
    -    '###CTR '*)
    -      if [ -n "$_ctr" ]; then mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true; fi
    -      _ctr=${_line####CTR }; _ctr=${_ctr%###}; _buf=""
    -      ;;
    -    '###DISK_CTRS###'|'###DISK_HOST###') ;;
    -    *)
    -      # only buffer after we entered CTR section
    -      if [ -n "$_ctr" ]; then _buf="${_buf}${_line}"$'\n'; fi
    -      ;;
    -  esac
    -done <<<"$REMOTE"
    -[ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true
    -
    -summary
    diff --git a/scripts/taler-monitoring/check_surface.sh b/scripts/taler-monitoring/check_surface.sh
    deleted file mode 100755
    index 1452d3b..0000000
    --- a/scripts/taler-monitoring/check_surface.sh
    +++ /dev/null
    @@ -1,692 +0,0 @@
    -#!/usr/bin/env bash
    -# check_surface.sh — REMOTE-ONLY public surface / ecosystem scan.
    -#
    -# NOT in default phases. Explicit:
    -#   ./taler-monitoring.sh surface
    -#   ./taler-monitoring.sh -d hacktivism.ch surface
    -#
    -# Without -d / with generic ecosystem: catalog of taler.net, gnunet.org,
    -# taler-systems.com, taler-ops, mattermost, …
    -# With -d DOMAIN: expand domains.conf stack hosts + catalog entries matching
    -# that domain + common subdomain guesses; port/protocol probes only remote.
    -#
    -# Rules:
    -#   - no SSH into targets, no local podman on targets
    -#   - ICMP/port alone never proves "down" — confirm via expected protocol
    -#   - catalogued host unreachable via protocol → ERROR
    -#   - version unknown after probes → WARN
    -#   - CVE hit (OSV) for identified software+version → ERROR
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -
    -set_area surface
    -section "surface · remote ecosystem / domain inventory (outside-in only)"
    -
    -CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}"
    -# Common ports when scanning a host discovered without explicit list
    -DEFAULT_SCAN_PORTS="${SURFACE_SCAN_PORTS:-22,80,443,993,8443,9418}"
    -PORT_TIMEOUT="${SURFACE_PORT_TIMEOUT:-2}"
    -HTTP_TIMEOUT="${SURFACE_HTTP_TIMEOUT:-12}"
    -# CVE via OSV (public, no key). Disable: SURFACE_CVE=0
    -: "${SURFACE_CVE:=1}"
    -# Extra ports when -d domain mode
    -DOMAIN_EXTRA_PORTS="${SURFACE_DOMAIN_PORTS:-22,80,443,8443}"
    -
    -declare -A HOST_PORTS   # host -> comma ports
    -declare -A HOST_PROTO   # host -> expect proto
    -declare -A HOST_LABEL   # host -> short label
    -declare -A HOST_EXPECT  # host -> 1 if catalogued (must respond)
    -ORDERED_HOSTS=()
    -
    -add_host() {
    -  local h="$1" ports="${2:-}" proto="${3:-https}" label="${4:-}" expect="${5:-1}"
    -  h=$(printf '%s' "$h" | tr 'A-Z' 'a-z' | sed 's#^https\?://##;s#/.*##;s/\.$//')
    -  [ -n "$h" ] || return 0
    -  if [ -z "${HOST_PORTS[$h]+x}" ]; then
    -    ORDERED_HOSTS+=("$h")
    -    HOST_PORTS[$h]="${ports:-443}"
    -    HOST_PROTO[$h]="${proto:-https}"
    -    HOST_LABEL[$h]="${label:-$h}"
    -    HOST_EXPECT[$h]="$expect"
    -  else
    -    # merge ports
    -    local p
    -    for p in ${ports//,/ }; do
    -      case ",${HOST_PORTS[$h]}," in
    -        *",$p,"*) ;;
    -        *) HOST_PORTS[$h]="${HOST_PORTS[$h]},$p" ;;
    -      esac
    -    done
    -    [ "${HOST_EXPECT[$h]}" = "1" ] || HOST_EXPECT[$h]="$expect"
    -  fi
    -}
    -
    -load_catalog() {
    -  local line h ports proto label
    -  [ -f "$CATALOG" ] || {
    -    warn "catalog" "missing $CATALOG — using empty base list"
    -    return 0
    -  }
    -  while IFS= read -r line || [ -n "$line" ]; do
    -    line=${line%%#*}
    -    line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
    -    [ -z "$line" ] && continue
    -    # host ports proto label
    -    h=$(echo "$line" | awk '{print $1}')
    -    ports=$(echo "$line" | awk '{print $2}')
    -    proto=$(echo "$line" | awk '{print $3}')
    -    label=$(echo "$line" | awk '{print $4}')
    -    [ "$ports" = "-" ] && ports=""
    -    add_host "$h" "$ports" "${proto:-https}" "${label:-}" 1
    -  done <"$CATALOG"
    -  info "catalog" "loaded $CATALOG · ${#ORDERED_HOSTS[@]} hosts"
    -}
    -
    -# Clear inventory (used when switching to domain-only scope)
    -clear_hosts() {
    -  ORDERED_HOSTS=()
    -  unset HOST_PORTS HOST_PROTO HOST_LABEL HOST_EXPECT
    -  declare -gA HOST_PORTS HOST_PROTO HOST_LABEL HOST_EXPECT
    -}
    -
    -# Expand for -d domain: ONLY that domain (remote). No whole-ecosystem catalog.
    -expand_domain_scope() {
    -  local d="$1" h
    -  d=${d#https://}; d=${d%/}
    -  d=${d#http://}
    -  info "scope" "domain mode −d $d (remote only · not full ecosystem catalog)"
    -
    -  clear_hosts
    -
    -  # stack endpoints from applied profile (always expected)
    -  for h in \
    -    "${BANK_PUBLIC:-}" \
    -    "${EXCHANGE_PUBLIC:-}" \
    -    "${MERCHANT_PUBLIC:-}" \
    -    "${PAIVANA_PUBLIC:-}"
    -  do
    -    [ -n "$h" ] || continue
    -    add_host "$h" "80,443,$DOMAIN_EXTRA_PORTS" https "stack" 1
    -  done
    -
    -  # apex + common subdomains (expected if DNS exists — set expect after DNS in scan,
    -  # but catalogued stack already expected; guesses start optional)
    -  for sub in "" www bank exchange taler merchant backend shop shops stage \
    -             git docs www2 api static media landing mon401
    -  do
    -    if [ -z "$sub" ]; then
    -      h="$d"
    -    else
    -      h="${sub}.${d}"
    -    fi
    -    # stack hosts already added as expect=1; guesses optional until we promote
    -    if [ -z "${HOST_PORTS[$h]+x}" ]; then
    -      add_host "$h" "$DOMAIN_EXTRA_PORTS" https "guess" 0
    -    fi
    -  done
    -
    -  # also pick catalog lines that belong to this domain only
    -  if [ -f "$CATALOG" ]; then
    -    local line ports proto label
    -    while IFS= read -r line || [ -n "$line" ]; do
    -      line=${line%%#*}
    -      line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
    -      [ -z "$line" ] && continue
    -      h=$(echo "$line" | awk '{print $1}')
    -      h=$(printf '%s' "$h" | tr 'A-Z' 'a-z' | sed 's#^https\?://##;s#/.*##')
    -      if [ "$h" = "$d" ] || [[ "$h" == *".$d" ]]; then
    -        ports=$(echo "$line" | awk '{print $2}')
    -        proto=$(echo "$line" | awk '{print $3}')
    -        label=$(echo "$line" | awk '{print $4}')
    -        [ "$ports" = "-" ] && ports=""
    -        add_host "$h" "$ports" "${proto:-https}" "${label:-dom}" 1
    -      fi
    -    done <"$CATALOG"
    -  fi
    -}
    -
    -# Ecosystem mode: full catalog; all entries expected
    -expand_ecosystem_scope() {
    -  info "scope" "ecosystem mode (taler.net / gnunet.org / taler-systems.com / taler-ops / mattermost, … · remote only)"
    -  local h
    -  for h in "${ORDERED_HOSTS[@]}"; do
    -    HOST_EXPECT[$h]=1
    -  done
    -}
    -
    -# --- remote probes (no SSH) ---
    -dns_ok() {
    -  local h="$1"
    -  getent ahosts "$h" >/dev/null 2>&1 || getent hosts "$h" >/dev/null 2>&1
    -}
    -
    -# TCP connect only (not proof of service)
    -tcp_open() {
    -  local h="$1" port="$2"
    -  if command -v timeout >/dev/null 2>&1; then
    -    timeout "$PORT_TIMEOUT" bash -c "echo >/dev/tcp/${h}/${port}" 2>/dev/null
    -  else
    -    bash -c "echo >/dev/tcp/${h}/${port}" 2>/dev/null
    -  fi
    -}
    -
    -# ICMP optional — never sole grounds for ERROR
    -ping_host() {
    -  local h="$1"
    -  ping -c 1 -W 2 "$h" >/dev/null 2>&1 || ping -c 1 -w 2 "$h" >/dev/null 2>&1
    -}
    -
    -# HTTPS probe: code, server header, version hints, cert subject/dates
    -probe_https() {
    -  local h="$1" port="${2:-443}"
    -  local url hdr body code server via cert_end subj san
    -  url="https://${h}/"
    -  [ "$port" != "443" ] && url="https://${h}:${port}/"
    -  body=$(mktemp)
    -  hdr=$(mktemp)
    -  code=$(curl -skS -m "$HTTP_TIMEOUT" -D "$hdr" -o "$body" -w '%{http_code}' \
    -    --connect-timeout "$PORT_TIMEOUT" "$url" 2>/dev/null || echo 000)
    -  server=$(awk 'BEGIN{IGNORECASE=1} /^server:/{sub(/\r$/,""); sub(/^server:[[:space:]]*/,""); print; exit}' "$hdr" 2>/dev/null || true)
    -  via=$(awk 'BEGIN{IGNORECASE=1} /^x-powered-by:/{sub(/\r$/,""); sub(/^[^:]+:[[:space:]]*/,""); print; exit}' "$hdr" 2>/dev/null || true)
    -  # cert
    -  cert_end=$(echo | openssl s_client -servername "$h" -connect "${h}:${port}" 2>/dev/null \
    -    | openssl x509 -noout -enddate 2>/dev/null | sed 's/notAfter=//')
    -  subj=$(echo | openssl s_client -servername "$h" -connect "${h}:${port}" 2>/dev/null \
    -    | openssl x509 -noout -subject 2>/dev/null | head -1)
    -  # taler version hints in body
    -  local taler_hint
    -  taler_hint=$(grep -oE 'taler[^"[:space:]]{0,40}|GNU Taler|libeufin|gnunet' "$body" 2>/dev/null | head -3 | tr '\n' ' ' || true)
    -  # config JSON version if /config works
    -  local cfg_ver=""
    -  local ccode
    -  ccode=$(curl -skS -m "$HTTP_TIMEOUT" -o "$body" -w '%{http_code}' \
    -    "https://${h}/config" 2>/dev/null || echo 000)
    -  if [ "$ccode" = "200" ]; then
    -    cfg_ver=$(python3 -c 'import json,sys
    -try:
    - d=json.load(open(sys.argv[1]))
    - print(d.get("version") or d.get("name") or d.get("currency") or "config-json")
    -except Exception:
    - print("")' "$body" 2>/dev/null || true)
    -  fi
    -  rm -f "$hdr" "$body"
    -  printf 'code=%s server=%s powered=%s cert_end=%s cfg=%s hint=%s' \
    -    "$code" "${server:-}" "${via:-}" "${cert_end:-}" "${cfg_ver:-}" "${taler_hint:-}"
    -  # return 0 if HTTP answered (any 2xx/3xx/4xx — service is up)
    -  case "$code" in
    -    2??|3??|4??) return 0 ;;
    -    *) return 1 ;;
    -  esac
    -}
    -
    -probe_http() {
    -  local h="$1" port="${2:-80}"
    -  local url code
    -  url="http://${h}/"
    -  [ "$port" != "80" ] && url="http://${h}:${port}/"
    -  code=$(curl -sS -m "$HTTP_TIMEOUT" -o /dev/null -w '%{http_code}' \
    -    --connect-timeout "$PORT_TIMEOUT" "$url" 2>/dev/null || echo 000)
    -  printf 'code=%s' "$code"
    -  case "$code" in 2??|3??|4??) return 0 ;; *) return 1 ;; esac
    -}
    -
    -probe_ssh_banner() {
    -  local h="$1" port="${2:-22}"
    -  local ban
    -  ban=$(timeout "$PORT_TIMEOUT" bash -c "exec 3<>/dev/tcp/${h}/${port}; dd bs=256 count=1 <&3 2>/dev/null" 2>/dev/null \
    -    | tr -d '\r' | head -1 || true)
    -  printf 'banner=%s' "${ban:-}"
    -  [ -n "$ban" ]
    -}
    -
    -# Map Server header → package name guess for OSV
    -guess_package() {
    -  local server="$1"
    -  local s
    -  s=$(printf '%s' "$server" | tr 'A-Z' 'a-z')
    -  case "$s" in
    -    *nginx*) echo "nginx" ;;
    -    *apache*|*httpd*) echo "apache" ;;
    -    *caddy*) echo "caddy" ;;
    -    *openbsd\ httpd*) echo "openbsd-httpd" ;;
    -    *) echo "" ;;
    -  esac
    -}
    -
    -extract_version() {
    -  local server="$1"
    -  # nginx/1.22.1 → 1.22.1
    -  printf '%s' "$server" | sed -n 's/.*\/\([0-9][0-9.]*\).*/\1/p' | head -1
    -}
    -
    -# OSV / CVE check for software version taken from Server headers.
    -#
    -# Important: headers only expose upstream versions (nginx/1.26.3), never the
    -# Debian package revision (1.26.3-3+deb13u7). Querying OSV ecosystem=Debian
    -# with the bare version yields massive false positives (ancient DEBIAN-CVE-*
    -# with introduced:0 and no fixed event, plus every package revision that
    -# merely *starts with* 1.26.3).
    -#
    -# Policy:
    -#   - bare upstream version → upstream SEMVER signal only (WARN by default)
    -#   - full Debian package version (contains '-') → Debian OSV, filtered
    -#   - SURFACE_CVE=0 disables; SURFACE_CVE_LEVEL=error|warn (default warn for bare)
    -check_cves() {
    -  local pkg="$1" ver="$2" host="$3"
    -  [ "$SURFACE_CVE" = "1" ] || return 0
    -  [ -n "$pkg" ] && [ -n "$ver" ] || return 0
    -  local level out rc
    -  # bare header versions are approximate → WARN unless overridden
    -  if [[ "$ver" == *-* ]] || [[ "$ver" == *+* ]]; then
    -    level="${SURFACE_CVE_LEVEL:-error}"
    -  else
    -    level="${SURFACE_CVE_LEVEL:-warn}"
    -  fi
    -  out=$(
    -    SURFACE_CVE_PKG="$pkg" SURFACE_CVE_VER="$ver" SURFACE_CVE_HOST="$host" python3 - <<'PY' 2>/dev/null || true
    -import json, os, re, urllib.request
    -
    -pkg = os.environ.get("SURFACE_CVE_PKG", "")
    -ver = os.environ.get("SURFACE_CVE_VER", "")
    -host = os.environ.get("SURFACE_CVE_HOST", "")
    -
    -def ver_tuple(s: str):
    -    nums = [int(x) for x in re.findall(r"\d+", s or "")[:5]]
    -    return tuple(nums) if nums else ()
    -
    -def vt_cmp(a, b):
    -    n = max(len(a), len(b))
    -    a = a + (0,) * (n - len(a))
    -    b = b + (0,) * (n - len(b))
    -    return (a > b) - (a < b)
    -
    -def in_semver_events(ver, events):
    -    """True if ver is in [introduced, fixed) for SEMVER-like event list."""
    -    vt = ver_tuple(ver)
    -    if not vt:
    -        return False
    -    # process sequential introduced/fixed pairs
    -    intro = None
    -    for e in events or []:
    -        if "introduced" in e:
    -            intro = e.get("introduced")
    -        elif "fixed" in e or "last_affected" in e:
    -            fixed = e.get("fixed")
    -            last = e.get("last_affected")
    -            lo = ver_tuple("0" if intro in (None, "0") else str(intro))
    -            if vt_cmp(vt, lo) < 0:
    -                intro = None
    -                continue
    -            if fixed is not None:
    -                if vt_cmp(vt, ver_tuple(str(fixed))) < 0:
    -                    return True
    -            elif last is not None:
    -                if vt_cmp(vt, ver_tuple(str(last))) <= 0:
    -                    return True
    -            intro = None
    -    # open-ended introduced without fixed → ignore (never clears; Debian noise)
    -    return False
    -
    -def osv_post(body):
    -    req = urllib.request.Request(
    -        "https://api.osv.dev/v1/query",
    -        data=json.dumps(body).encode(),
    -        headers={"Content-Type": "application/json"},
    -        method="POST",
    -    )
    -    try:
    -        with urllib.request.urlopen(req, timeout=20) as r:
    -            return json.load(r)
    -    except Exception:
    -        return {}
    -
    -def osv_get(vid):
    -    try:
    -        with urllib.request.urlopen(f"https://api.osv.dev/v1/vulns/{vid}", timeout=15) as r:
    -            return json.load(r)
    -    except Exception:
    -        return {}
    -
    -bare = not (("-" in ver) or ("+" in ver))
    -hits = []
    -
    -if bare:
    -    # Upstream signal via CVE records that carry extracted_events for this package
    -    # (Server header has no Debian revision — do NOT use ecosystem=Debian).
    -    seed = {
    -        "nginx": [
    -            "CVE-2025-23419",
    -            "CVE-2025-53859",
    -            "CVE-2024-7347",
    -            "CVE-2024-34161",
    -            "CVE-2024-32760",
    -            "CVE-2024-31079",
    -            "CVE-2024-24989",
    -            "CVE-2024-24990",
    -        ],
    -        "apache": [
    -            "CVE-2024-38474",
    -            "CVE-2024-38476",
    -            "CVE-2024-38477",
    -            "CVE-2023-31122",
    -            "CVE-2023-43622",
    -        ],
    -        "caddy": [
    -            "CVE-2022-29718",
    -            "CVE-2023-50463",
    -        ],
    -    }.get(pkg, [])
    -    for vid in seed:
    -        doc = osv_get(vid)
    -        if not doc:
    -            continue
    -        ok_hit = False
    -        for a in doc.get("affected") or []:
    -            # prefer extracted_events (human SEMVER) on GIT/nginx ranges
    -            for rg in a.get("ranges") or []:
    -                db = rg.get("database_specific") or {}
    -                extracted = db.get("extracted_events") or []
    -                if extracted and in_semver_events(ver, extracted):
    -                    # package name filter when present
    -                    cpes = db.get("cpe") or []
    -                    if isinstance(cpes, str):
    -                        cpes = [cpes]
    -                    blob = json.dumps(a).lower() + json.dumps(cpes).lower()
    -                    if pkg == "nginx" and "nginx" not in blob and "f5" not in blob:
    -                        continue
    -                    ok_hit = True
    -                    break
    -                if rg.get("type") == "SEMVER" and in_semver_events(ver, rg.get("events") or []):
    -                    ok_hit = True
    -                    break
    -            if ok_hit:
    -                break
    -        if ok_hit:
    -            hits.append(vid)
    -else:
    -    # Full package version — Debian ecosystem is meaningful
    -    data = osv_post({"package": {"name": pkg, "ecosystem": "Debian"}, "version": ver})
    -    for v in data.get("vulns") or []:
    -        vid = v.get("id") or "?"
    -        actionable = False
    -        for a in v.get("affected") or []:
    -            for rg in a.get("ranges") or []:
    -                events = rg.get("events") or []
    -                has_end = any(("fixed" in e) or ("last_affected" in e) for e in events)
    -                if not has_end:
    -                    continue  # open-ended Debian noise
    -                if rg.get("type") in ("ECOSYSTEM", "SEMVER"):
    -                    # version string is full deb version; trust OSV query match
    -                    # but only if a fixed event exists (actionable)
    -                    actionable = True
    -                    break
    -            if actionable:
    -                break
    -            # exact version listed
    -            versions = a.get("versions") or []
    -            if ver in versions:
    -                actionable = True
    -                break
    -        if actionable:
    -            hits.append(vid)
    -
    -# de-dup preserve order
    -seen = set()
    -uniq = []
    -for h in hits:
    -    if h not in seen:
    -        seen.add(h)
    -        uniq.append(h)
    -
    -if uniq:
    -    print(f"HIT {len(uniq)} " + ",".join(uniq[:12]))
    -else:
    -    print("CLEAN")
    -PY
    -  )
    -  rc=0
    -  case "$out" in
    -    HIT\ *)
    -      local n ids
    -      n=$(printf '%s' "$out" | awk '{print $2}')
    -      ids=$(printf '%s' "$out" | cut -d' ' -f3-)
    -      if [ "$level" = "error" ]; then
    -        err "cve" "$host $pkg $ver — $n actionable vuln(s)" "$ids"
    -        rc=1
    -      else
    -        warn "cve" "$host $pkg $ver — $n actionable vuln(s) (header version · not Debian pkg)" "$ids"
    -        rc=0
    -      fi
    -      ;;
    -    CLEAN)
    -      if [[ "$ver" == *-* ]] || [[ "$ver" == *+* ]]; then
    -        info "cve" "$host $pkg $ver — OSV clean (Debian package version)"
    -      else
    -        info "cve" "$host $pkg $ver — no actionable upstream CVE for bare Server-header version"
    -      fi
    -      ;;
    -    *)
    -      info "cve" "$host $pkg $ver — CVE probe skipped/unavailable"
    -      ;;
    -  esac
    -  return "$rc"
    -}
    -
    -cert_expiry_check() {
    -  local h="$1" end="$2"
    -  [ -n "$end" ] || return 0
    -  local end_epoch now left days
    -  end_epoch=$(date -d "$end" +%s 2>/dev/null || date -j -f "%b %e %T %Y %Z" "$end" +%s 2>/dev/null || echo 0)
    -  now=$(date +%s)
    -  [ "$end_epoch" -gt 0 ] || return 0
    -  left=$((end_epoch - now))
    -  days=$((left / 86400))
    -  if [ "$left" -le 0 ]; then
    -    err "tls" "$h certificate EXPIRED" "notAfter=$end"
    -    return 1
    -  fi
    -  if [ "$days" -le 14 ]; then
    -    warn "tls" "$h certificate expires in ${days}d" "notAfter=$end"
    -  else
    -    info "tls" "$h cert ok · ${days}d left · notAfter=$end"
    -  fi
    -  return 0
    -}
    -
    -scan_host() {
    -  local h="$1"
    -  local ports proto label expect
    -  local p open_ports=() any_proto_ok=0 ping_ok=0 dns=0
    -  local probe_detail server_hdr pkg ver
    -
    -  ports=${HOST_PORTS[$h]:-443}
    -  proto=${HOST_PROTO[$h]:-https}
    -  label=${HOST_LABEL[$h]:-$h}
    -  expect=${HOST_EXPECT[$h]:-0}
    -
    -  set_group "$label"
    -
    -  if dns_ok "$h"; then
    -    dns=1
    -    ok "dns" "$h resolves"
    -  else
    -    if [ "$expect" = "1" ]; then
    -      err "dns" "$h does not resolve (catalogued)"
    -    else
    -      info "dns" "$h no resolve (optional guess) — skip"
    -    fi
    -    return 0
    -  fi
    -
    -  if ping_host "$h"; then
    -    ping_ok=1
    -    info "ping" "$h ICMP ok"
    -  else
    -    info "ping" "$h ICMP no reply (not decisive)"
    -  fi
    -
    -  # port scan (unique ports)
    -  local _seen_ports=" "
    -  for p in ${ports//,/ }; do
    -    [ -n "$p" ] || continue
    -    case "$_seen_ports" in *" $p "*) continue ;; esac
    -    _seen_ports="$_seen_ports$p "
    -    if tcp_open "$h" "$p"; then
    -      open_ports+=("$p")
    -      info "port" "$h:$p open (TCP)"
    -    else
    -      info "port" "$h:$p closed/filtered (TCP)"
    -    fi
    -  done
    -
    -  # Protocol verification on open ports (and always try 443/80 for https/http expect)
    -  local try_ports=("${open_ports[@]}")
    -  if [ ${#try_ports[@]} -eq 0 ]; then
    -    # still try expected protocol ports even if scan said closed (scan false negatives)
    -    case "$proto" in
    -      https) try_ports=(443) ;;
    -      http) try_ports=(80) ;;
    -      ssh) try_ports=(22) ;;
    -      *) try_ports=(443 80) ;;
    -    esac
    -  fi
    -
    -  server_hdr=""
    -  ver=""
    -  for p in "${try_ports[@]}"; do
    -    case "$p" in
    -      443|8443)
    -        if probe_detail=$(probe_https "$h" "$p"); then
    -          any_proto_ok=1
    -          ok "https" "$h:$p up · $probe_detail"
    -          server_hdr=$(printf '%s' "$probe_detail" | sed -n 's/.*server=\([^ ]*\).*/\1/p')
    -          # cert
    -          local cend
    -          cend=$(printf '%s' "$probe_detail" | sed -n 's/.*cert_end=\([^ ]*\).*/\1/p')
    -          cert_expiry_check "$h" "$cend" || true
    -          local cfg
    -          cfg=$(printf '%s' "$probe_detail" | sed -n 's/.*cfg=\([^ ]*\).*/\1/p')
    -          if [ -n "$cfg" ]; then
    -            info "version" "$h config/api hint: $cfg"
    -            ver="$cfg"
    -          fi
    -        else
    -          info "https" "$h:$p no HTTP response · ${probe_detail:-}"
    -        fi
    -        ;;
    -      80|8080)
    -        if probe_detail=$(probe_http "$h" "$p"); then
    -          any_proto_ok=1
    -          ok "http" "$h:$p up · $probe_detail"
    -        else
    -          info "http" "$h:$p no HTTP response"
    -        fi
    -        ;;
    -      22)
    -        if probe_detail=$(probe_ssh_banner "$h" "$p"); then
    -          any_proto_ok=1
    -          ok "ssh" "$h:$p banner · $probe_detail"
    -          ver=$(printf '%s' "$probe_detail" | sed 's/banner=//')
    -        else
    -          info "ssh" "$h:$p no SSH banner"
    -        fi
    -        ;;
    -      *)
    -        if tcp_open "$h" "$p"; then
    -          info "tcp" "$h:$p open · protocol unknown"
    -        fi
    -        ;;
    -    esac
    -  done
    -
    -  # Version summary
    -  if [ -n "$server_hdr" ]; then
    -    info "server-header" "$h · $server_hdr"
    -    pkg=$(guess_package "$server_hdr")
    -    ver_soft=$(extract_version "$server_hdr")
    -    if [ -n "$pkg" ] && [ -n "$ver_soft" ]; then
    -      info "version" "$h software $pkg $ver_soft (from Server header)"
    -      check_cves "$pkg" "$ver_soft" "$h" || true
    -    elif [ -n "$server_hdr" ]; then
    -      warn "version" "$h Server header present but version unknown · $server_hdr"
    -    fi
    -  elif [ -n "$ver" ]; then
    -    info "version" "$h · $ver"
    -  else
    -    if [ "$any_proto_ok" = "1" ]; then
    -      warn "version" "$h reachable but software version unknown"
    -    fi
    -  fi
    -
    -  # Expected service must answer protocol (not just ping/port)
    -  # Note: ${array[*]:-x} is NOT valid default syntax (bash treats : as slice).
    -  local ports_txt="${open_ports[*]}"
    -  ports_txt=${ports_txt:-none}
    -  if [ "$expect" = "1" ]; then
    -    if [ "$any_proto_ok" = "1" ]; then
    -      ok "reachability" "$h catalogued service OK (protocol confirmed)"
    -    else
    -      err "reachability" "$h catalogued but not reachable via ${proto}/protocol" \
    -        "dns=$dns ping=$ping_ok open_ports=${ports_txt} (ICMP/TCP alone not enough; protocol failed)"
    -    fi
    -  else
    -    if [ "$any_proto_ok" = "1" ]; then
    -      info "reachability" "$h optional host responds"
    -    else
    -      info "reachability" "$h optional · no protocol response (ok)"
    -    fi
    -  fi
    -}
    -
    -# --- main ---
    -# Scope (remote only):
    -#   ./taler-monitoring.sh surface              → full ecosystem catalog
    -#   ./taler-monitoring.sh -d hacktivism.ch surface → only that domain
    -if [ "${SURFACE_SCOPE:-}" = "ecosystem" ]; then
    -  load_catalog
    -  expand_ecosystem_scope
    -elif [ "${SURFACE_SCOPE:-}" = "domain" ] || [ "${TALER_DOMAIN_FROM_CLI:-0}" = "1" ]; then
    -  # domain mode: do not load whole ecosystem first
    -  CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}"
    -  expand_domain_scope "${TALER_DOMAIN:-hacktivism.ch}"
    -elif [ "${SURFACE_SCOPE:-auto}" = "auto" ]; then
    -  # no -d → ecosystem; with -d → domain (TALER_DOMAIN_FROM_CLI)
    -  if [ "${TALER_DOMAIN_FROM_CLI:-0}" = "1" ]; then
    -    CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}"
    -    expand_domain_scope "$TALER_DOMAIN"
    -  else
    -    load_catalog
    -    expand_ecosystem_scope
    -  fi
    -else
    -  load_catalog
    -  expand_ecosystem_scope
    -fi
    -
    -info "inventory" "${#ORDERED_HOSTS[@]} hosts to probe (remote only · no SSH · no local podman on targets)"
    -info "flags" "SURFACE_CVE=${SURFACE_CVE:-1} PORT_TIMEOUT=${PORT_TIMEOUT:-2} HTTP_TIMEOUT=${HTTP_TIMEOUT:-12}"
    -
    -for h in "${ORDERED_HOSTS[@]}"; do
    -  [ -n "$h" ] || continue
    -  scan_host "$h" || true
    -done
    -
    -# mon_disk on the *runner* only (optional) — not remote servers' disks
    -if [ "${SURFACE_CHECK_RUNNER_DISK:-0}" = "1" ]; then
    -  set_group disk
    -  if declare -F mon_disk_check_host >/dev/null 2>&1; then
    -    mon_disk_check_host "runner" || true
    -  else
    -    warn "disk" "mon_disk_check_host not available"
    -  fi
    -fi
    -
    -# summary returns non-zero when FAIL_N>0; do not trip set -e mid-script
    -summary || true
    -if [ "${FAIL_N:-0}" -eq 0 ]; then
    -  exit 0
    -fi
    -exit 1
    diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh
    deleted file mode 100755
    index 36520d4..0000000
    --- a/scripts/taler-monitoring/check_urls.sh
    +++ /dev/null
    @@ -1,1595 +0,0 @@
    -#!/usr/bin/env bash
    -# Outside-in public HTTPS checks (no SSH).
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -
    -# Area www.* — public HTTPS (outside-in)
    -# Groups: www.exchange / www.perf / www.stats / www.bank / www.merchant /
    -#         www.paivana / www.landing
    -set_area www
    -section "www · public URLs · ${TALER_DOMAIN:-?} (outside-in, no SSH)"
    -
    -tmp=$(mktemp -d)
    -trap 'rm -rf "$tmp"' EXIT
    -
    -check_url() {
    -  local label="$1" expect="$2" url="$3"
    -  local code
    -  code=$(http_code "$url")
    -  case ",$expect," in
    -    *",$code,"*) ok "$label $url" ;;
    -    *) fail "$label $url" "got $code want $expect" ;;
    -  esac
    -}
    -
    -# Pre-launch remote stack: local=0 and no expected currency (e.g. prod LFP not live yet).
    -# Missing endpoints → INFO, not WARN (infra not supposed to serve yet).
    -is_prelaunch_stack() {
    -  [ "${LOCAL_STACK:-1}" = "0" ] && [ -z "${EXPECT_CURRENCY:-}" ]
    -}
    -
    -# Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack.
    -# Always treats 301/302/303/307/308→200 (follow) as success when 200 is expected.
    -# Pre-launch (no currency): INFO instead of WARN for missing endpoints.
    -check_url_soft() {
    -  local label="$1" expect="$2" url="$3"
    -  local code
    -  code=$(http_code "$url")
    -  case ",$expect," in
    -    *",$code,"*) ok "$label $url" ;;
    -    *)
    -      # Follow redirects (308 Permanent Redirect is common for trailing slash)
    -      case "$code" in
    -        301|302|303|307|308)
    -          code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000)
    -          case ",$expect," in
    -            *",$code,"*) ok "$label $url" "via redirect → HTTP $code"; return ;;
    -          esac
    -          ;;
    -      esac
    -      if [ "${LOCAL_STACK:-1}" = "0" ]; then
    -        if is_prelaunch_stack; then
    -          info "$label $url" "HTTP $code — pre-launch / not serving yet"
    -        else
    -          warn "$label $url" "got $code (optional on remote domain)"
    -        fi
    -      else
    -        fail "$label $url" "got $code want $expect"
    -      fi
    -      ;;
    -  esac
    -}
    -
    -expect_currency() {
    -  local label="$1" file="$2" want="${EXPECT_CURRENCY:-}"
    -  local cur
    -  cur=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("currency",""))' "$file" 2>/dev/null || true)
    -  if [ -z "$want" ]; then
    -    info "$label currency" "${cur:-?}"
    -    return
    -  fi
    -  if [ "$cur" = "$want" ]; then
    -    ok "$label currency=$want"
    -  else
    -    fail "$label currency" "got ${cur:-?} want $want"
    -  fi
    -}
    -
    -# Legal docs: /terms and /privacy must be HTTP 200 with a real document body
    -# (not empty, not "not configured", not JSON API error).
    -# $1=label $2=url $3=optional needle regex (case-insensitive) for local stack
    -check_legal_doc() {
    -  local label="$1" url="$2" needle="${3:-}"
    -  local f code soft
    -  soft=0
    -  [ "${LOCAL_STACK:-1}" = "0" ] && soft=1
    -  f=$(mktemp)
    -  code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" \
    -    -H "Accept: text/html,text/markdown,text/plain,*/*" \
    -    -o "$f" -w '%{http_code}' "$url" 2>/dev/null || echo 000)
    -  if [ "$code" != "200" ]; then
    -    rm -f "$f"
    -    if [ "$soft" = "1" ]; then
    -      if is_prelaunch_stack; then
    -        info "$label" "HTTP $code — pre-launch / not serving yet · $url"
    -      else
    -        warn "$label" "HTTP $code — $url"
    -      fi
    -    else
    -      fail "$label" "HTTP $code — $url"
    -    fi
    -    return
    -  fi
    -  if [ ! -s "$f" ]; then
    -    rm -f "$f"
    -    fail "$label" "empty body — $url"
    -    return
    -  fi
    -  # merchant returns plain "not configured" when PRIVACY_ETAG missing
    -  if grep -qiE '^(not configured)\s*$' "$f" 2>/dev/null \
    -    || grep -qiE '"code"\s*:\s*21' "$f" 2>/dev/null; then
    -    rm -f "$f"
    -    fail "$label" "not configured / API error — $url"
    -    return
    -  fi
    -  if [ -n "$needle" ] && [ "${LOCAL_STACK:-1}" = "1" ]; then
    -    if ! grep -qiE "$needle" "$f" 2>/dev/null; then
    -      warn "$label content" "missing /$needle/ — $url"
    -      rm -f "$f"
    -      return
    -    fi
    -  fi
    -  ok "$label" "HTTP 200 · $(wc -c <"$f" | tr -d ' ') bytes"
    -  rm -f "$f"
    -}
    -
    -# Shared shape for bank mint JSON (demo-withdraw + auto-account).
    -# Args: $1=json file  $2=auto|demo
    -# stdout (ok): one detail line; for demo, optional "\twithdrawal_id" suffix.
    -# stderr (fail): short reason. Exit 0/1.
    -validate_bank_withdraw_json() {
    -  python3 - "$1" "$2" <<'PY'
    -import json, re, sys
    -from urllib.parse import urlparse
    -
    -def check_withdraw_uri(wuri: str):
    -    """HOST or HOST:non-default-port; default :443/:80 must be stripped (wallet fix)."""
    -    m = re.match(
    -        r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)$",
    -        wuri or "",
    -    )
    -    if not m:
    -        print(
    -            "need taler://withdraw/HOST/taler-integration/ID:",
    -            (wuri or "")[:120],
    -            file=sys.stderr,
    -        )
    -        return None
    -    host = m.group(1)
    -    if not host or host.startswith(":"):
    -        print("bad withdraw host:", host, file=sys.stderr)
    -        return None
    -    if ":" in host:
    -        h, _, p = host.rpartition(":")
    -        if not h or not p.isdigit():
    -            print("bad withdraw host:port:", host, file=sys.stderr)
    -            return None
    -        if p in ("443", "80"):
    -            print("withdraw must strip default port :%s:" % p, host, file=sys.stderr)
    -            return None
    -    return host, m.group(2), wuri
    -
    -path, mode = sys.argv[1], sys.argv[2]
    -d = json.load(open(path))
    -wuri = d.get("taler_withdraw_uri") or (
    -    d.get("qr_payload") if mode == "auto" else ""
    -) or ""
    -
    -if mode == "auto":
    -    if not d.get("ok"):
    -        print("ok!=true", file=sys.stderr)
    -        sys.exit(1)
    -    if d.get("payto_uri"):
    -        print("payto_uri must not be present", file=sys.stderr)
    -        sys.exit(1)
    -    if not check_withdraw_uri(wuri):
    -        sys.exit(1)
    -    webui = d.get("login_url") or d.get("webui") or d.get("account_url") or ""
    -    u = urlparse(webui)
    -    if u.scheme not in ("http", "https") or "webui" not in (u.path or ""):
    -        print("login webui missing:", webui[:80], file=sys.stderr)
    -        sys.exit(1)
    -    print("%s · %s" % (d.get("username", ""), wuri[:72]))
    -elif mode == "demo":
    -    if not d.get("ok", True) and "taler_withdraw_uri" not in d:
    -        print("not ok", file=sys.stderr)
    -        sys.exit(1)
    -    if not check_withdraw_uri(wuri):
    -        sys.exit(1)
    -    print("%s\t%s" % (wuri[:80], d.get("withdrawal_id") or ""))
    -else:
    -    print("bad mode:", mode, file=sys.stderr)
    -    sys.exit(2)
    -sys.exit(0)
    -PY
    -}
    -
    -# --- exchange (core; hard on local stack, soft on remote) ---  www.exchange-NN
    -set_group exchange
    -if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -  check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config"
    -else
    -  check_url_soft "exchange /config" 200 "$EXCHANGE_PUBLIC/config"
    -fi
    -code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ec.json")
    -if [ "$code" = "200" ]; then
    -  expect_currency "exchange" "$tmp/ec.json"
    -  # currency_specification.alt_unit_names (wallet codec)
    -  if json_has_alt_unit_names "$tmp/ec.json" "${EXPECT_CURRENCY:-}" >/tmp/alt-ex.$$ 2>&1; then
    -    ok "exchange /config alt_unit_names" "$(tr '\n' '; ' "$PERF_TSV"
    -
    -# Measure one URL: require HTTP expect (default 200), report time_total in ms.
    -# $1=label $2=url $3=optional expected codes (default 200)
    -check_perf() {
    -  local label="$1" url="$2" expect="${3:-200}"
    -  local out code t_s ms
    -  out=$(curl -skS --max-redirs 3 -L -m "${PERF_CURL_TIMEOUT:-25}" \
    -    -o /dev/null -w '%{http_code} %{time_total}' "$url" 2>/dev/null || echo "000 0")
    -  code=$(printf '%s' "$out" | awk '{print $1}')
    -  t_s=$(printf '%s' "$out" | awk '{print $2}')
    -  ms=$(awk -v t="${t_s:-0}" 'BEGIN{
    -    ms=(t+0)*1000
    -    if (ms>0 && ms<1) ms=1
    -    printf "%d", int(ms+0.5)
    -  }')
    -  # Always record sample for rollup (label, ms, http, url)
    -  printf '%s\t%s\t%s\t%s\n' "$label" "$ms" "$code" "$url" >>"$PERF_TSV"
    -  case ",$expect," in
    -    *",$code,"*)
    -      if [ "$ms" -ge "${PERF_FAIL_MS}" ] 2>/dev/null; then
    -        fail "$label" "HTTP $code · ${ms} ms ≥ fail ${PERF_FAIL_MS} ms · $url"
    -      elif [ "$ms" -ge "${PERF_WARN_MS}" ] 2>/dev/null; then
    -        warn "$label" "HTTP $code · ${ms} ms ≥ warn ${PERF_WARN_MS} ms · $url"
    -      else
    -        ok "$label" "HTTP $code · ${ms} ms · $url"
    -      fi
    -      ;;
    -    *)
    -      if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -        fail "$label" "HTTP $code want $expect · ${ms} ms · $url"
    -      elif is_prelaunch_stack; then
    -        info "$label" "HTTP $code · ${ms} ms · pre-launch · $url"
    -      else
    -        warn "$label" "HTTP $code want $expect · ${ms} ms · $url"
    -      fi
    -      ;;
    -  esac
    -}
    -
    -# Bank first (wallet-critical paths before UI chrome)
    -check_perf "perf bank /taler-integration/config" "$BANK_PUBLIC/taler-integration/config"
    -check_perf "perf bank /config" "$BANK_PUBLIC/config"
    -if [ "${CHECK_LANDING:-1}" = "1" ]; then
    -  check_perf "perf bank /intro/" "$BANK_PUBLIC/intro/"
    -  check_perf "perf bank /intro/stats.json" "$BANK_PUBLIC/intro/stats.json" 200
    -fi
    -check_perf "perf bank /webui/" "$BANK_PUBLIC/webui/" 200,301,302
    -
    -# Exchange
    -check_perf "perf exchange /config" "$EXCHANGE_PUBLIC/config"
    -check_perf "perf exchange /keys" "$EXCHANGE_PUBLIC/keys"
    -if [ "${CHECK_LANDING:-1}" = "1" ]; then
    -  check_perf "perf exchange /intro/" "$EXCHANGE_PUBLIC/intro/"
    -fi
    -
    -# Merchant
    -check_perf "perf merchant /config" "$MERCHANT_PUBLIC/config"
    -check_perf "perf merchant /webui/" "$MERCHANT_PUBLIC/webui/" 200,301,302
    -if [ "${CHECK_LANDING:-1}" = "1" ]; then
    -  check_perf "perf merchant /intro/" "$MERCHANT_PUBLIC/intro/"
    -fi
    -
    -info "perf note" "RTT measured from this host (outside-in)"
    -
    -# ---------------------------------------------------------------------------
    -# Landing stats.json (public outside-in) — freshness + display fields.
    -# Timestamps (required for external measurement):
    -#   generated_at_unix  — epoch seconds (preferred)
    -#   generated_at       — ISO-8601
    -#   generated_at_human — footer / “updated” UI string
    -# Bank collector always sets all three; exchange/merchant scripts set unix too.
    -# Env:
    -#   STATS_STALE_SECS   warn if age > this (default 900 = 15m; timer often 5–15m)
    -#   STATS_FAIL_SECS    hard fail if age > this (default 3600); 0 = never hard-fail on age
    -# ---------------------------------------------------------------------------
    -: "${STATS_STALE_SECS:=900}"
    -: "${STATS_FAIL_SECS:=3600}"
    -
    -_fetch_landing_stats_json() {
    -  # $1=name bank|exchange|merchant  $2=base URL  → writes $tmp/stats-$1.json, exit 0 if ok
    -  local name="$1" base="$2"
    -  local f="$tmp/stats-${name}.json"
    -  local code ctr path
    -  rm -f "$f"
    -  code=$(http_body "${base}/intro/stats.json" "$f" 2>/dev/null || echo 000)
    -  if [ "$code" = "200" ] && [ -s "$f" ]; then
    -    return 0
    -  fi
    -  # Optional inside-container fallback (no public path or empty)
    -  if [ "${LOCAL_STACK:-0}" = "1" ] && [ "${SKIP_SSH:-0}" != "1" ] && koopa_ssh_ok 2>/dev/null; then
    -    case "$name" in
    -      bank)     ctr=taler-hacktivism-bank; path=/var/www/bank-landing/stats.json ;;
    -      exchange) ctr=taler-hacktivism-exchange-ansible; path=/var/www/exchange-landing/stats.json ;;
    -      merchant) ctr=taler-hacktivism; path=/var/www/merchant-landing/stats.json ;;
    -      *) return 1 ;;
    -    esac
    -    if koopa_ssh_run 12 "podman exec ${ctr} cat ${path} 2>/dev/null" >"$f" 2>/dev/null \
    -       && [ -s "$f" ]; then
    -      return 0
    -    fi
    -  fi
    -  return 1
    -}
    -
    -# Validate one stats.json for monitoring display + outside age measurement.
    -# Prints lines: OK|WARN|ERR|STALE|FAIL 
    -# Sets $tmp/stats-meta-$name.txt with gen_unix|source for shared-feed compare.
    -check_landing_stats_json() {
    -  local name="$1" base="$2"
    -  local f="$tmp/stats-${name}.json"
    -  local line hard_missing=0
    -
    -  if ! _fetch_landing_stats_json "$name" "$base"; then
    -    if [ "${CHECK_LANDING:-1}" = "1" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -      fail "stats ${name}" "HTTP/body missing · ${base}/intro/stats.json"
    -    else
    -      warn "stats ${name}" "stats.json not available"
    -    fi
    -    return 1
    -  fi
    -  ok "stats ${name} reachable" "${base}/intro/stats.json"
    -
    -  # Multi-line verdict from python (STATUS detail)
    -  while IFS= read -r line; do
    -    case "$line" in
    -      META\ *)
    -        printf '%s\n' "${line#META }" >"$tmp/stats-meta-${name}.txt"
    -        ;;
    -      OK\ *)
    -        ok "stats ${name}" "${line#OK }"
    -        ;;
    -      WARN\ *)
    -        warn "stats ${name}" "${line#WARN }"
    -        ;;
    -      STALE\ *)
    -        warn "stats ${name} freshness" "stale · ${line#STALE }"
    -        ;;
    -      FAIL\ *)
    -        fail "stats ${name}" "${line#FAIL }"
    -        hard_missing=1
    -        ;;
    -      ERR\ *)
    -        if [ "${LOCAL_STACK:-0}" = "1" ] || [ "${CHECK_LANDING:-1}" = "1" ]; then
    -          fail "stats ${name}" "${line#ERR }"
    -        else
    -          warn "stats ${name}" "${line#ERR }"
    -        fi
    -        hard_missing=1
    -        ;;
    -      *)
    -        [ -n "$line" ] && warn "stats ${name}" "unparsed · $line"
    -        ;;
    -    esac
    -  done < <(python3 - "$f" "$name" "${STATS_STALE_SECS}" "${STATS_FAIL_SECS}" <<'PY' 2>/dev/null || echo "ERR python check failed"
    -import json, sys, time, re
    -from datetime import datetime, timezone
    -
    -path, role, stale_s, fail_s = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
    -now = int(time.time())
    -
    -def out(kind, msg):
    -    print("%s %s" % (kind, msg))
    -
    -try:
    -    d = json.load(open(path))
    -except Exception as e:
    -    out("ERR", "JSON parse: %s" % str(e)[:100])
    -    sys.exit(0)
    -
    -if not isinstance(d, dict):
    -    out("ERR", "body is not a JSON object")
    -    sys.exit(0)
    -
    -if d.get("ok") is False:
    -    out("ERR", "ok=false · %s" % str(d.get("error") or d.get("hint") or "")[:80])
    -    sys.exit(0)
    -
    -# --- timestamps (outside-in age measurement) ---
    -gu = d.get("generated_at_unix")
    -giso = d.get("generated_at") or d.get("generated_at_iso")
    -ghum = d.get("generated_at_human")
    -age = None
    -src_u = None
    -
    -if gu is not None and str(gu).strip() != "":
    -    try:
    -        src_u = int(float(gu))
    -        # tolerate ms accidental timestamps
    -        if src_u > 10_000_000_000:
    -            src_u //= 1000
    -        age = now - src_u
    -    except Exception:
    -        out("ERR", "generated_at_unix not numeric: %r" % gu)
    -        src_u = None
    -elif giso:
    -    # parse ISO when unix missing (legacy exchange/merchant before unix field)
    -    try:
    -        s = str(giso).strip()
    -        if s.endswith("Z"):
    -            s = s[:-1] + "+00:00"
    -        # allow +0200 without colon
    -        s = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", s)
    -        dt = datetime.fromisoformat(s)
    -        if dt.tzinfo is None:
    -            dt = dt.replace(tzinfo=timezone.utc)
    -        src_u = int(dt.timestamp())
    -        age = now - src_u
    -        out("WARN", "no generated_at_unix — derived age from generated_at ISO (deploy collector with unix)")
    -    except Exception as e:
    -        out("ERR", "cannot parse generated_at=%r (%s)" % (giso, e))
    -else:
    -    out("ERR", "missing generated_at_unix and generated_at — cannot measure freshness outside")
    -
    -if not ghum and not giso:
    -    out("ERR", "missing generated_at_human/generated_at — UI cannot show “updated” time")
    -elif ghum:
    -    out("OK", "timestamp human=%s" % ghum)
    -else:
    -    out("OK", "timestamp iso=%s" % giso)
    -
    -if src_u is not None:
    -    out("META", "%s|%s|%s" % (src_u, d.get("source") or "", d.get("currency") or ""))
    -    if age is not None and age < -120:
    -        out("WARN", "clock skew · generated_at_unix in the future by %ss" % (-age))
    -    if age is not None:
    -        out("OK", "generated_at_unix=%s age=%ss (now=%s)" % (src_u, age, now))
    -        if fail_s > 0 and age > fail_s:
    -            out("FAIL", "age=%ss > STATS_FAIL_SECS=%s · collector not updating" % (age, fail_s))
    -        elif age > stale_s:
    -            out("STALE", "age=%ss > STATS_STALE_SECS=%s · timer lag or stuck publish" % (age, stale_s))
    -
    -# --- display fields for landings / monitoring ---
    -src = str(d.get("source") or "")
    -# Bank-shaped (incl. stage shared feed on all three landings)
    -bankish = (
    -    role == "bank"
    -    or "collect_bank_stats" in src
    -    or "shared" in src
    -    or isinstance(d.get("withdraws"), dict)
    -    or isinstance(d.get("bank_accounts"), dict)
    -)
    -if bankish:
    -    missing = []
    -    if not isinstance(d.get("withdraws"), dict):
    -        missing.append("withdraws")
    -    ba = d.get("bank_accounts")
    -    wl = d.get("wallets")
    -    if not isinstance(ba, dict) and not isinstance(wl, dict):
    -        missing.append("bank_accounts|wallets")
    -    if d.get("currency") in (None, ""):
    -        missing.append("currency")
    -    if missing:
    -        out("ERR", "bank-shaped display missing: %s" % ",".join(missing))
    -    else:
    -        w = d.get("withdraws") or {}
    -        ba = ba if isinstance(ba, dict) else {}
    -        n_acc = ba.get("total") if ba.get("total") is not None else ba.get("users")
    -        out(
    -            "OK",
    -            "display bank-shape currency=%s accounts=%s withdraws.count=%s source=%s"
    -            % (d.get("currency"), n_acc, w.get("count"), (src or "?")[:48]),
    -        )
    -elif src == "exchange-db" or (role == "exchange" and "reserves" in d):
    -    need = []
    -    for k in ("reserves", "wire_in_count", "known_coins"):
    -        if k not in d and k.replace("known_coins", "coins_live") not in d:
    -            if k == "known_coins" and ("coins_live" in d or "coins_remaining_amount" in d):
    -                continue
    -            need.append(k)
    -    if need:
    -        out("WARN", "exchange display keys missing: %s" % ",".join(need))
    -    else:
    -        out(
    -            "OK",
    -            "display exchange-db reserves=%s wire_in=%s coins=%s"
    -            % (d.get("reserves"), d.get("wire_in_count"), d.get("known_coins") or d.get("coins_live")),
    -        )
    -elif src == "merchant-db" or (role == "merchant" and "instances" in d):
    -    if "instances" not in d and "orders" not in d:
    -        out("ERR", "merchant display missing instances/orders")
    -    else:
    -        out(
    -            "OK",
    -            "display merchant-db instances=%s orders=%s paid=%s"
    -            % (d.get("instances"), d.get("orders"), d.get("paid")),
    -        )
    -else:
    -    out("WARN", "unknown stats schema role=%s source=%s keys=%s" % (role, src[:40], ",".join(list(d.keys())[:8])))
    -
    -# performance block optional (stage shared bank feed may omit heavy probes)
    -p = d.get("performance")
    -if isinstance(p, dict) and p:
    -    mem = p.get("memory") if isinstance(p.get("memory"), dict) else {}
    -    rss = mem.get("container_rss_human") or mem.get("proc_sum_rss_human") or "—"
    -    load = p.get("loadavg") or "—"
    -    bits = []
    -    for k in ("config_ms", "integration_ms", "webui_ms", "keys_ms", "terms_ms"):
    -        if k in p and p[k] is not None:
    -            bits.append("%s=%s" % (k.replace("_ms", ""), p[k]))
    -    lat = " ".join(bits) if bits else "latency=—"
    -    out("OK", "performance loadavg=%s RSS=%s %s" % (load, rss, lat))
    -else:
    -    out("OK", "performance block optional/absent")
    -PY
    -)
    -
    -  return 0
    -}
    -
    -if [ "${CHECK_LANDING:-1}" = "1" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -  set_group stats
    -  section "www · landing stats.json (freshness + display fields · outside-in)"
    -  info "stats policy" "STALE≥${STATS_STALE_SECS}s WARN · FAIL≥${STATS_FAIL_SECS}s ERROR (0=disable fail) · need generated_at_unix"
    -  check_landing_stats_json "bank" "$BANK_PUBLIC" || true
    -  check_landing_stats_json "exchange" "$EXCHANGE_PUBLIC" || true
    -  check_landing_stats_json "merchant" "$MERCHANT_PUBLIC" || true
    -
    -  # Shared feed: stage-style publish copies same bank stats to all three URLs
    -  if [ -f "$tmp/stats-meta-bank.txt" ] && [ -f "$tmp/stats-meta-exchange.txt" ] && [ -f "$tmp/stats-meta-merchant.txt" ]; then
    -    shared_line=$(python3 - "$tmp/stats-meta-bank.txt" "$tmp/stats-meta-exchange.txt" "$tmp/stats-meta-merchant.txt" <<'PY' 2>/dev/null || true
    -import sys
    -def parse(p):
    -    try:
    -        t = open(p).read().strip().split("|", 2)
    -        return t[0], (t[1] if len(t) > 1 else ""), (t[2] if len(t) > 2 else "")
    -    except Exception:
    -        return "", "", ""
    -b, bs, bc = parse(sys.argv[1])
    -e, es, ec = parse(sys.argv[2])
    -m, ms, mc = parse(sys.argv[3])
    -if not b or not e or not m:
    -    print("SKIP incomplete meta")
    -elif b == e == m:
    -    print("SHARED gen_unix=%s bank_src=%s" % (b, (bs or "?")[:50]))
    -else:
    -    # Shared feed only when all three claim the same bank-collector source
    -    # (e.g. stage TESTPAYSAN "… shared"). GOA uses independent exchange-db /
    -    # merchant-db collectors — different gen_unix is expected.
    -    def bankish(s):
    -        s = (s or "").lower()
    -        return "shared" in s or "collect_bank_stats" in s
    -    if bankish(bs) and bankish(es) and bankish(ms):
    -        print("DRIFT bank=%s exchange=%s merchant=%s (expected equal for shared feed)" % (b, e, m))
    -    else:
    -        print("INDEPENDENT bank=%s exchange=%s merchant=%s" % (b, e, m))
    -PY
    -)
    -    case "$shared_line" in
    -      SHARED\ *)
    -        ok "stats shared feed" "${shared_line#SHARED }"
    -        ;;
    -      DRIFT\ *)
    -        warn "stats shared feed" "timestamps differ · ${shared_line#DRIFT }"
    -        ;;
    -      INDEPENDENT\ *)
    -        info "stats feeds" "independent collectors · ${shared_line#INDEPENDENT }"
    -        ;;
    -      *)
    -        info "stats feeds" "${shared_line:-n/a}"
    -        ;;
    -    esac
    -  fi
    -  info "stats note" "public GET /intro/stats.json; measure age via generated_at_unix vs wall clock"
    -fi
    -# Rollup + optional JSON for metrics_print_overall
    -if [ -s "$PERF_TSV" ]; then
    -  PERF_JSON="$tmp/perf-summary.json"
    -  perf_line=$(python3 - "$PERF_TSV" "$PERF_JSON" "$PERF_WARN_MS" "$PERF_FAIL_MS" <<'PY'
    -import json, sys
    -rows = []
    -for line in open(sys.argv[1]):
    -    parts = line.rstrip("\n").split("\t")
    -    if len(parts) < 3:
    -        continue
    -    label, ms_s, code = parts[0], parts[1], parts[2]
    -    try:
    -        ms = int(float(ms_s))
    -    except Exception:
    -        continue
    -    rows.append({"label": label, "ms": ms, "http": code})
    -vals = sorted(r["ms"] for r in rows)
    -n = len(vals)
    -if n == 0:
    -    print("n=0")
    -    json.dump({"n": 0}, open(sys.argv[2], "w"))
    -    raise SystemExit(0)
    -def pct(p):
    -    if n == 1:
    -        return vals[0]
    -    i = min(n - 1, max(0, int(round((p / 100.0) * (n - 1)))))
    -    return vals[i]
    -avg = int(round(sum(vals) / n))
    -warn_ms = int(sys.argv[3])
    -fail_ms = int(sys.argv[4])
    -slow = [r for r in rows if r["ms"] >= warn_ms]
    -rep = {
    -    "n": n,
    -    "min_ms": vals[0],
    -    "p50_ms": pct(50),
    -    "avg_ms": avg,
    -    "max_ms": vals[-1],
    -    "warn_ms": warn_ms,
    -    "fail_ms": fail_ms,
    -    "slow": [{"label": r["label"], "ms": r["ms"]} for r in slow],
    -    "samples": rows,
    -}
    -# metrics_print_overall expects named buckets with n/min/p50/avg/max
    -out = {
    -    "www_public_https": {
    -        "n": n,
    -        "min_ms": vals[0],
    -        "p50_ms": pct(50),
    -        "avg_ms": avg,
    -        "max_ms": vals[-1],
    -    },
    -    **{r["label"].replace(" ", "_"): {"n": 1, "min_ms": r["ms"], "p50_ms": r["ms"], "avg_ms": r["ms"], "max_ms": r["ms"]} for r in rows},
    -}
    -json.dump(out, open(sys.argv[2], "w"), indent=2)
    -extra = ""
    -if slow:
    -    extra = "  slow: " + ", ".join("%s=%dms" % (r["label"].replace("perf ", ""), r["ms"]) for r in slow)
    -print(
    -    "n=%d  min=%dms  p50=%dms  avg=%dms  max=%dms  (warn≥%dms fail≥%dms)%s"
    -    % (n, vals[0], pct(50), avg, vals[-1], warn_ms, fail_ms, extra)
    -)
    -PY
    -  )
    -  info "perf summary" "$perf_line"
    -  # Keep a copy if METRICS_DIR is set (e2e/ladder overall stats)
    -  if [ -n "${METRICS_DIR:-}" ] && [ -d "${METRICS_DIR}" ]; then
    -    cp -f "$PERF_JSON" "${METRICS_DIR}/perf-summary.json" 2>/dev/null || true
    -  fi
    -fi
    -info "perf note" "measured from this host (outside-in); thresholds PERF_WARN_MS=${PERF_WARN_MS} PERF_FAIL_MS=${PERF_FAIL_MS}"
    -
    -
    -# Exchange terms + privacy (same www.exchange group — issues map to exchange)
    -set_group exchange
    -check_legal_doc "exchange /terms" "$EXCHANGE_PUBLIC/terms" "terms|GOA|exploration|FADP|revDSG|privacy"
    -check_legal_doc "exchange /privacy" "$EXCHANGE_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|exploration"
    -# trailing slash: 200 or redirect to bare path
    -code=$(http_code "$EXCHANGE_PUBLIC/terms/")
    -case "$code" in
    -  200|301|302|303|307|308) ok "exchange /terms/" "HTTP $code" ;;
    -  *)
    -    if is_prelaunch_stack; then
    -      info "exchange /terms/" "HTTP $code — pre-launch"
    -    else
    -      warn "exchange /terms/" "HTTP $code"
    -    fi
    -    ;;
    -esac
    -
    -# --- bank ---
    -set_group bank
    -if [ "${LOCAL_STACK:-1}" = "0" ]; then
    -  check_url_soft "bank /config" 200 "$BANK_PUBLIC/config"
    -else
    -  check_url "bank /config" 200 "$BANK_PUBLIC/config"
    -fi
    -code=$(http_body "$BANK_PUBLIC/config" "$tmp/bc.json")
    -if [ "$code" = "200" ]; then
    -  expect_currency "bank" "$tmp/bc.json"
    -  if json_has_alt_unit_names "$tmp/bc.json" >/tmp/alt-bank.$$ 2>&1; then
    -    ok "bank /config alt_unit_names" "$(tr '\n' '; ' "$tmp/aa-val"); then
    -        ok "bank /intro/auto-account.json" "$aa_detail"
    -      else
    -        fail "bank /intro/auto-account.json" "invalid withdraw/login ($(tr '\n' ' ' <"$tmp/aa-val" | sed 's/[[:space:]]*$//'))"
    -      fi
    -      ;;
    -    404|405|501)
    -      if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -        fail "bank /intro/auto-account.json" "HTTP $aa_code (want 200; 405/501 = broken)"
    -      else
    -        info "bank /intro/auto-account.json" "HTTP $aa_code — skip (not a GOA auto-account stack)"
    -      fi
    -      ;;
    -    502|503|000)
    -      fail "bank /intro/auto-account.json" "HTTP $aa_code want 200"
    -      ;;
    -    *)
    -      if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -        fail "bank /intro/auto-account.json" "HTTP $aa_code want 200"
    -      else
    -        warn "bank /intro/auto-account.json" "HTTP $aa_code (optional off-GOA)"
    -      fi
    -      ;;
    -  esac
    -fi
    -
    -# Bank legal docs (landing nginx via Caddy /terms* /privacy* or /intro/*)
    -check_legal_doc "bank /terms" "$BANK_PUBLIC/terms" "terms|GOA|exploration|bank|FADP|revDSG"
    -# Prefer /privacy; fall back to /intro/privacy.html for older deploys
    -code=$(http_code "$BANK_PUBLIC/privacy")
    -if [ "$code" = "200" ]; then
    -  check_legal_doc "bank /privacy" "$BANK_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|bank"
    -else
    -  if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -    check_legal_doc "bank /privacy (or /intro/privacy.html)" \
    -      "$BANK_PUBLIC/intro/privacy.html" "privacy|FADP|revDSG|data|GOA|bank"
    -    # still report bare /privacy failure for local
    -    warn "bank /privacy" "HTTP $code — prefer Caddy handle /privacy* → landing"
    -  else
    -    check_url_soft "bank /privacy" 200 "$BANK_PUBLIC/privacy"
    -  fi
    -fi
    -
    -# --- merchant ---
    -set_group merchant
    -if [ "${LOCAL_STACK:-1}" = "0" ]; then
    -  check_url_soft "merchant /config" 200 "$MERCHANT_PUBLIC/config"
    -else
    -  check_url "merchant /config" 200 "$MERCHANT_PUBLIC/config"
    -fi
    -code=$(http_body "$MERCHANT_PUBLIC/config" "$tmp/mc.json")
    -if [ "$code" = "200" ]; then
    -  want="${EXPECT_CURRENCY:-}"
    -  if python3 - "$tmp/mc.json" "$want" <<'PY'
    -import json,sys
    -try:
    -    d=json.load(open(sys.argv[1]))
    -except Exception:
    -    sys.exit(2)
    -want=sys.argv[2]
    -if not want:
    -    sys.exit(0)
    -curs=list((d.get("currencies") or {}).keys())
    -ex=d.get("exchanges") or []
    -ok = want in curs or any((e.get("currency") if isinstance(e,dict) else None)==want for e in ex)
    -if not ok and isinstance(d.get("currency"), str):
    -    ok = d["currency"]==want
    -sys.exit(0 if ok else 1)
    -PY
    -  then
    -    ok "merchant /config (${want:-currency} ok)"
    -  else
    -    ec=$?
    -    if [ "$ec" = "2" ]; then
    -      warn "merchant /config" "non-JSON body"
    -    elif [ -n "$want" ]; then
    -      fail "merchant /config currency" "want $want"
    -    else
    -      info "merchant /config" "ok"
    -    fi
    -  fi
    -  # merchant-local currency maps (GOA + CHF, …)
    -  if json_has_alt_unit_names "$tmp/mc.json" >/tmp/alt-mer.$$ 2>&1; then
    -    ok "merchant /config currencies alt_unit_names" "$(tr '\n' '; ' /dev/null || true)
    -      # take first 3 digits only (avoid 200000 glue when curl exits non-zero after writing code)
    -      printf '%s' "$raw" | tr -cd '0-9' | head -c 3
    -      [ -n "$(printf '%s' "$raw" | tr -cd '0-9')" ] || printf '000'
    -    }
    -    for asset in index.html index.js; do
    -      tmo="${TIMEOUT}"
    -      [ "$asset" = "index.js" ] && tmo="${WEBUI_INDEX_JS_TIMEOUT:-60}"
    -      acode=$(_webui_asset_code "$MERCHANT_PUBLIC/webui/${asset}" "$tmo")
    -      case "$acode" in
    -        200) ok "merchant /webui/${asset}" "HTTP 200" ;;
    -        *)
    -          if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -            fail "merchant /webui/${asset}" "HTTP ${acode:-000}"
    -          else
    -            warn "merchant /webui/${asset}" "HTTP ${acode:-000}"
    -          fi
    -          ;;
    -      esac
    -    done
    -    # return group for any later merchant checks in this block
    -    set_group merchant
    -  fi
    -fi
    -
    -# Merchant legal docs
    -check_legal_doc "merchant /terms" "$MERCHANT_PUBLIC/terms" "terms|dual|GOA|CHF|explorational|merchant"
    -check_legal_doc "merchant /privacy" "$MERCHANT_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|CHF|merchant"
    -code=$(http_code "$MERCHANT_PUBLIC/terms/")
    -case "$code" in
    -  200|301|302|303|307|308) ok "merchant /terms/" "HTTP $code" ;;
    -  *)
    -    if is_prelaunch_stack; then
    -      info "merchant /terms/" "HTTP $code — pre-launch"
    -    else
    -      warn "merchant /terms/" "HTTP $code (expect 302 → /terms)"
    -    fi
    -    ;;
    -esac
    -
    -# ---------------------------------------------------------------------------
    -# Paivana paywall (local GOA stack) — public front only; pay path is e2e
    -# ---------------------------------------------------------------------------
    -if [ "${LOCAL_STACK:-1}" = "1" ] && [ "${E2E_PAIVANA:-1}" != "0" ]; then
    -  set_group paivana
    -  section "www · paivana paywall"
    -  : "${PAIVANA_PUBLIC:=https://paivana.hacktivism.ch}"
    -  PAIVANA_PUBLIC="${PAIVANA_PUBLIC%/}"
    -  hdr=$(curl -skS -m "${TIMEOUT}" -D - -o /dev/null "${PAIVANA_PUBLIC}/" 2>/dev/null || true)
    -  pcode=$(printf '%s' "$hdr" | awk 'BEGIN{c="000"} /^HTTP/{c=$2} END{print c}')
    -  loc=$(printf '%s' "$hdr" | awk 'BEGIN{IGNORECASE=1} /^location:/{sub(/\r$/,""); sub(/^location:[[:space:]]*/,""); print; exit}')
    -  case "$pcode" in
    -    301|302|303|307|308)
    -      if printf '%s' "$loc" | grep -qiE 'paivana|templates|well-known'; then
    -        ok "paivana /" "HTTP $pcode → template flow"
    -      else
    -        ok "paivana /" "HTTP $pcode redirect"
    -      fi
    -      info "paivana Location" "${loc:0:140}"
    -      ;;
    -    402)
    -      # Paywall may answer Payment Required with body (healthy, not 502)
    -      ok "paivana /" "HTTP 402 Payment Required (paywall up)"
    -      ;;
    -    200)
    -      warn "paivana /" "HTTP 200 (expected paywall redirect/402 to template)"
    -      ;;
    -    *)
    -      warn "paivana /" "HTTP ${pcode:-000} — ${PAIVANA_PUBLIC}/ (e2e pay may still work via template)"
    -      ;;
    -  esac
    -fi
    -
    -# ---------------------------------------------------------------------------
    -# Landing pages: every HTTPS link exposed on bank / merchant / exchange intros
    -# + required static assets + bank withdraw mint (taler://withdraw only)
    -# Skipped when CHECK_LANDING=0 (e.g. taler-ops.ch — no GOA-style landings).
    -# ---------------------------------------------------------------------------
    -if [ "${CHECK_LANDING:-1}" != "1" ]; then
    -  section "www · landing pages"
    -  info "landing checks" "skipped (CHECK_LANDING=0 · stack has no public /intro landings)"
    -  summary
    -  exit 0
    -fi
    -
    -set_group landing
    -section "www · landing exposed links · bank / merchant / exchange"
    -
    -# Probe one URL: print code to stdout (200 after following redirects counts as 200).
    -# Sets _landing_code. Exit 0 if OK (200 or redirect→200), 1 otherwise.
    -_landing_probe() {
    -  local url="$1"
    -  local code
    -  code=$(http_code "$url")
    -  case "$code" in
    -    200) _landing_code=200; return 0 ;;
    -    301|302|303|307|308)
    -      code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000)
    -      _landing_code="$code"
    -      [ "$code" = "200" ] && return 0
    -      return 1
    -      ;;
    -    *)
    -      _landing_code="$code"
    -      return 1
    -      ;;
    -  esac
    -}
    -
    -# Known-good landing static paths — one report line via caller aggregate, or
    -# soft=1 single warn. Returns 0 if ok.
    -check_landing_asset() {
    -  local label="$1" url="$2" soft="${3:-0}"
    -  if _landing_probe "$url"; then
    -    return 0
    -  fi
    -  if [ "$soft" = "1" ]; then
    -    warn "$label" "HTTP ${_landing_code:-?} — $url"
    -  else
    -    fail "$label" "HTTP ${_landing_code:-?} — $url"
    -  fi
    -  return 1
    -}
    -
    -# Soft external: never ERROR; used only for failures in aggregated external probe.
    -check_external_soft() {
    -  local label="$1" url="$2"
    -  local code
    -  code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000)
    -  case "$code" in
    -    200|204|301|302|303|307|308) return 0 ;;
    -    *) warn "$label" "HTTP $code · $url"; return 1 ;;
    -  esac
    -}
    -
    -# Parse one landing HTML: collect absolute https + root-relative href/src;
    -# resolve against base; classify own-stack vs external.
    -# Writes lists: $1.own  $1.ext  (one URL per line)
    -extract_landing_urls() {
    -  local base="$1" html="$2" out_prefix="$3"
    -  python3 - "$base" "$html" "$out_prefix" <<'PY'
    -import re, sys
    -from urllib.parse import urljoin, urlparse
    -
    -base, html_path, out = sys.argv[1], sys.argv[2], sys.argv[3]
    -html = open(html_path, encoding="utf-8", errors="replace").read()
    -base = base.rstrip("/") + "/"
    -parsed_base = urlparse(base)
    -# Hard-check only the three public Taler hosts for this stack (not git.* etc.)
    -own_hosts = {
    -    (parsed_base.hostname or "").lower(),
    -    "bank.hacktivism.ch",
    -    "exchange.hacktivism.ch",
    -    "taler.hacktivism.ch",
    -}
    -# include configured public hosts when domain differs (demo / ops)
    -for envu in (
    -    __import__("os").environ.get("BANK_PUBLIC", ""),
    -    __import__("os").environ.get("EXCHANGE_PUBLIC", ""),
    -    __import__("os").environ.get("MERCHANT_PUBLIC", ""),
    -):
    -    h = urlparse(envu).hostname if envu else None
    -    if h:
    -        own_hosts.add(h.lower())
    -
    -raw = set()
    -for m in re.finditer(
    -    r'''(?:href|src|content)=["']([^"'#]+)["']''', html, re.I
    -):
    -    raw.add(m.group(1).strip())
    -# bare absolute URLs in scripts (fetch, template strings)
    -for m in re.finditer(r'''https://[^\s"'<>\\]+''', html):
    -    u = m.group(0).rstrip("\\).,;'\"")
    -    # strip trailing punctuation leftovers
    -    while u and u[-1] in ".,);]}\"'":
    -        u = u[:-1]
    -    if u.startswith("https://"):
    -        raw.add(u)
    -
    -own, ext = set(), set()
    -skip_prefix = ("data:", "javascript:", "mailto:", "taler://", "blob:")
    -skip_exact = {"website", "summary_large_image", "image/png", "en_US"}
    -for r in raw:
    -    if not r or r in skip_exact:
    -        continue
    -    if r.startswith(skip_prefix):
    -        continue
    -    # meta content noise
    -    if re.fullmatch(r"\d+", r) or r.startswith("width="):
    -        continue
    -    if " " in r and not r.startswith("http"):
    -        continue
    -    if r.startswith("//"):
    -        absu = "https:" + r
    -    elif r.startswith("http://") or r.startswith("https://"):
    -        absu = r
    -    elif r.startswith("/"):
    -        absu = urljoin(base, r)
    -    else:
    -        # relative asset
    -        if "/" in r or r.endswith((".js", ".css", ".png", ".svg", ".html", ".json", ".uri")):
    -            absu = urljoin(base + "intro/", r)
    -        else:
    -            continue
    -    # drop query-only noise / anchors already stripped
    -    p = urlparse(absu)
    -    if p.scheme not in ("http", "https"):
    -        continue
    -    # normalize: drop fragment
    -    absu = absu.split("#", 1)[0]
    -    host = (p.hostname or "").lower()
    -    # og image query ok
    -    if host in own_hosts:
    -        own.add(absu)
    -    else:
    -        ext.add(absu)
    -
    -open(out + ".own", "w").write("\n".join(sorted(own)) + ("\n" if own else ""))
    -open(out + ".ext", "w").write("\n".join(sorted(ext)) + ("\n" if ext else ""))
    -print(f"own={len(own)} ext={len(ext)}")
    -PY
    -}
    -
    -check_one_landing() {
    -  local name="$1" base="$2"
    -  local html="$tmp/landing-${name}.html"
    -  local pref="$tmp/urls-${name}"
    -  local code n own_n ext_n
    -  local a_ok=0 a_fail=0 a_soft=0
    -  local own_ok=0 own_fail=0
    -  local ext_ok=0 ext_fail=0
    -  local fail_sample="" soft_sample=""
    -
    -  code=$(http_body "${base}/intro/" "$html")
    -  if [ "$code" != "200" ]; then
    -    if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -      fail "landing ${name}" "/intro/ HTTP $code — skip assets/links"
    -    else
    -      warn "landing ${name}" "/intro/ HTTP $code — skip assets/links"
    -    fi
    -    return
    -  fi
    -
    -  # Required static: qrcode.min.js. og-goa-shop.png only on GOA/local.
    -  if _landing_probe "${base}/intro/qrcode.min.js"; then
    -    a_ok=$((a_ok + 1))
    -  else
    -    a_fail=$((a_fail + 1))
    -    fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} ${base}/intro/qrcode.min.js"
    -  fi
    -  if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -    if _landing_probe "${base}/intro/og-goa-shop.png"; then
    -      a_ok=$((a_ok + 1))
    -    else
    -      a_fail=$((a_fail + 1))
    -      fail_sample="${fail_sample}${fail_sample:+; }HTTP ${_landing_code} ${base}/intro/og-goa-shop.png"
    -    fi
    -  fi
    -  # qr-logo: hard on GOA (wallet QR frame); soft elsewhere if missing
    -  if _landing_probe "${base}/intro/qr-logo.png"; then
    -    a_ok=$((a_ok + 1))
    -  else
    -    if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -      a_soft=$((a_soft + 1))
    -      soft_sample="${soft_sample}${soft_sample:+; }HTTP ${_landing_code} qr-logo.png"
    -    fi
    -  fi
    -
    -  n=$(extract_landing_urls "$base" "$html" "$pref" 2>/dev/null || echo "own=0 ext=0")
    -  own_n=0
    -  ext_n=0
    -  [ -f "${pref}.own" ] && own_n=$(grep -c . "${pref}.own" 2>/dev/null || echo 0)
    -  [ -f "${pref}.ext" ] && ext_n=$(grep -c . "${pref}.ext" 2>/dev/null || echo 0)
    -  # strip newlines from grep -c edge cases
    -  own_n=${own_n//[^0-9]/}
    -  ext_n=${ext_n//[^0-9]/}
    -  own_n=${own_n:-0}
    -  ext_n=${ext_n:-0}
    -
    -  # Probe own-stack URLs — count only; list failures
    -  if [ -f "${pref}.own" ]; then
    -    while IFS= read -r u; do
    -      [ -n "$u" ] || continue
    -      if _landing_probe "$u"; then
    -        own_ok=$((own_ok + 1))
    -      else
    -        own_fail=$((own_fail + 1))
    -        # keep a few samples (max ~3)
    -        if [ "$own_fail" -le 3 ]; then
    -          fail_sample="${fail_sample}${fail_sample:+; }own HTTP ${_landing_code} $u"
    -        fi
    -      fi
    -    done < "${pref}.own"
    -  fi
    -
    -  # External: soft counts (stores can be slow — longer timeout + browser UA)
    -  if [ -f "${pref}.ext" ]; then
    -    local _ext_to="${EXT_TIMEOUT:-25}"
    -    while IFS= read -r u; do
    -      [ -n "$u" ] || continue
    -      code=$(curl -skS --max-redirs 5 -L -m "${_ext_to}" \
    -        -A "Mozilla/5.0 (compatible; taler-monitoring/1.0)" \
    -        -o /dev/null -w '%{http_code}' "$u" 2>/dev/null || echo 000)
    -      case "$code" in
    -        200|204|301|302|303|307|308) ext_ok=$((ext_ok + 1)) ;;
    -        *)
    -          ext_fail=$((ext_fail + 1))
    -          if [ "$ext_fail" -le 3 ]; then
    -            soft_sample="${soft_sample}${soft_sample:+; }ext HTTP $code $u"
    -          fi
    -          ;;
    -      esac
    -    done < "${pref}.ext"
    -  fi
    -
    -  # One primary line per landing
    -  local detail
    -  detail="/intro $(wc -c <"$html" | tr -d ' ')B · assets ${a_ok}/$((a_ok + a_fail + a_soft)) · own-links ${own_ok}/${own_n} · external ${ext_ok}/${ext_n}"
    -  if [ "$a_fail" -gt 0 ] || [ "$own_fail" -gt 0 ] || [ "${own_n:-0}" -lt 1 ]; then
    -    if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -      fail "landing ${name}" "$detail${fail_sample:+ · $fail_sample}"
    -    else
    -      warn "landing ${name}" "$detail${fail_sample:+ · $fail_sample}"
    -    fi
    -  else
    -    ok "landing ${name}" "$detail"
    -  fi
    -  if [ "$a_soft" -gt 0 ] || [ "$ext_fail" -gt 0 ]; then
    -    warn "landing ${name} soft" "${soft_sample:-soft issues}"
    -  fi
    -}
    -
    -check_one_landing "bank" "$BANK_PUBLIC"
    -check_one_landing "merchant" "$MERCHANT_PUBLIC"
    -check_one_landing "exchange" "$EXCHANGE_PUBLIC"
    -
    -# Cross-links: one line
    -if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -  _cx_ok=0
    -  _cx_fail=0
    -  _cx_detail=""
    -  for pair in \
    -    "bank→merchant|$MERCHANT_PUBLIC/intro/" \
    -    "bank→exchange|$EXCHANGE_PUBLIC/intro/" \
    -    "merchant→bank|$BANK_PUBLIC/intro/" \
    -    "exchange→bank|$BANK_PUBLIC/intro/"
    -  do
    -    _cx_name="${pair%%|*}"
    -    _cx_url="${pair#*|}"
    -    if _landing_probe "$_cx_url"; then
    -      _cx_ok=$((_cx_ok + 1))
    -    else
    -      _cx_fail=$((_cx_fail + 1))
    -      _cx_detail="${_cx_detail}${_cx_detail:+; }${_cx_name} HTTP ${_landing_code}"
    -    fi
    -  done
    -  if [ "$_cx_fail" -eq 0 ]; then
    -    ok "landing cross-links" "${_cx_ok}/4 intros reachable"
    -  else
    -    fail "landing cross-links" "${_cx_ok}/4 ok · ${_cx_detail}"
    -  fi
    -fi
    -
    -# Bank withdraw mint + shop assets — compact
    -if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then
    -  _ba_ok=0
    -  _ba_soft=0
    -  _ba_msg=""
    -  # GOA landings ship shop-pay.*; stage TESTPAYSAN uses monnaies /assets/shop-ui.js
    -  if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -    if _landing_probe "$BANK_PUBLIC/intro/shop-pay.js"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.js; "; fi
    -    if _landing_probe "$BANK_PUBLIC/intro/shop-pay.css"; then _ba_ok=$((_ba_ok + 1)); else _ba_soft=$((_ba_soft + 1)); _ba_msg="${_ba_msg}shop-pay.css; "; fi
    -  fi
    -  dw_code=$(http_body "$BANK_PUBLIC/intro/demo-withdraw.json" "$tmp/dw.json")
    -  case "$dw_code" in
    -    200)
    -      if dw_out=$(validate_bank_withdraw_json "$tmp/dw.json" demo 2>"$tmp/dw-val"); then
    -        dw_detail=${dw_out%%$'\t'*}
    -        wid=${dw_out#*$'\t'}
    -        [ "$wid" = "$dw_out" ] && wid=
    -        ok "bank /intro/demo-withdraw.json" "$dw_detail"
    -        if [ -n "$wid" ]; then
    -          if _landing_probe "$BANK_PUBLIC/taler-integration/withdrawal-operation/${wid}"; then
    -            _ba_ok=$((_ba_ok + 1))
    -          else
    -            fail "landing bank withdraw-op" "HTTP ${_landing_code} · id=$wid"
    -          fi
    -        fi
    -        ok "landing bank withdraw/shop" "demo-withdraw + shop assets ok (${_ba_ok} checks)"
    -      else
    -        fail "landing bank demo-withdraw" "invalid taler://withdraw shape"
    -        fail "bank /intro/demo-withdraw.json" "invalid taler://withdraw ($(tr '\n' ' ' <"$tmp/dw-val" | sed 's/[[:space:]]*$//'))"
    -      fi
    -      ;;
    -    404|405|501)
    -      # GOA bank landing mints demo-withdraw.json; stage TESTPAYSAN uses ATM/wallet guide only
    -      if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -        fail "landing bank demo-withdraw" "HTTP $dw_code (want 200)"
    -      else
    -        info "landing bank demo-withdraw" "HTTP $dw_code — skip (not a GOA demo-withdraw stack)"
    -      fi
    -      ;;
    -    502|503|000)
    -      fail "landing bank demo-withdraw" "HTTP $dw_code (want 200)"
    -      ;;
    -    *)
    -      if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -        fail "landing bank demo-withdraw" "HTTP $dw_code want 200"
    -      else
    -        warn "landing bank demo-withdraw" "HTTP $dw_code"
    -      fi
    -      ;;
    -  esac
    -  if [ "$_ba_soft" -gt 0 ]; then
    -    warn "landing bank shop assets" "${_ba_msg}soft-missing"
    -  fi
    -fi
    -
    -# Merchant shop assets — GOA: shop-pay.*; stage TESTPAYSAN: /assets/shop-ui.js +
    -# shops.css + QR encoder (shop-ui modal needs /intro/qrcode.min.js or /qrcode.min.js).
    -_ma=0
    -_ma_need=2
    -_ma_label="shop-pay.js + .css"
    -if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -  _landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.js" && _ma=$((_ma + 1))
    -  _landing_probe "$MERCHANT_PUBLIC/intro/shop-pay.css" && _ma=$((_ma + 1))
    -else
    -  # stage farmer shops: UI + stylesheet + qrcode (pay modal QR_Taler)
    -  _ma_need=3
    -  _ma_label="shop-ui.js + shops.css + qrcode.min.js"
    -  _landing_probe "$MERCHANT_PUBLIC/assets/shop-ui.js" && _ma=$((_ma + 1))
    -  _landing_probe "$MERCHANT_PUBLIC/assets/shops.css" && _ma=$((_ma + 1))
    -  # Prefer /intro/ (always on landings tree); root alias is Caddy-only convenience
    -  if _landing_probe "$MERCHANT_PUBLIC/intro/qrcode.min.js" \
    -    || _landing_probe "$MERCHANT_PUBLIC/qrcode.min.js"; then
    -    _ma=$((_ma + 1))
    -  fi
    -fi
    -if [ "$_ma" -eq "$_ma_need" ]; then
    -  ok "landing merchant shop assets" "${_ma_label}"
    -else
    -  if [ "${EXPECT_CURRENCY:-}" = "GOA" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
    -    warn "landing merchant shop assets" "${_ma}/${_ma_need} present (soft) · want ${_ma_label}"
    -  else
    -    # stage: hard-ish — missing qrcode broke pay QR («bibliothèque manquante»)
    -    if [ "$_ma" -lt 2 ]; then
    -      fail "landing merchant shop assets" "${_ma}/${_ma_need} · want ${_ma_label}"
    -    elif [ "$_ma" -lt "$_ma_need" ]; then
    -      warn "landing merchant shop assets" "${_ma}/${_ma_need} · want ${_ma_label}"
    -    else
    -      info "landing merchant shop assets" "${_ma}/${_ma_need} · ${_ma_label}"
    -    fi
    -  fi
    -fi
    -
    -# ---------------------------------------------------------------------------
    -# QR payloads — form + encode/decode roundtrip (qrencode + zbarimg)
    -# Collect taler:// + payto:// (+ app-store https from data-qr-url) from landings
    -# and bank mint JSON; re-encode PNG; decode; require exact payload match.
    -# Env: QR_CHECK=0 to skip; QR_ECC=M (default) for qrencode -l
    -# ---------------------------------------------------------------------------
    -if [ "${QR_CHECK:-1}" = "1" ] && [ "${CHECK_LANDING:-1}" = "1" ]; then
    -  set_group qr
    -  section "www · QR payloads (form + encode/decode · taler:// payto://)"
    -
    -  QR_ECC="${QR_ECC:-M}"
    -  qr_dir="$tmp/qr-check"
    -  mkdir -p "$qr_dir"
    -  _qr_tools=1
    -  if ! command -v qrencode >/dev/null 2>&1; then
    -    warn "qr tools" "qrencode missing — form checks only (apt install qrencode)"
    -    _qr_tools=0
    -  fi
    -  if ! command -v zbarimg >/dev/null 2>&1; then
    -    warn "qr tools" "zbarimg missing — form checks only (apt install zbar-tools)"
    -    _qr_tools=0
    -  fi
    -  if [ "$_qr_tools" = "1" ]; then
    -    ok "qr tools" "qrencode + zbarimg"
    -  fi
    -
    -  # Fetch sources (HTML landings + mint JSON when present)
    -  _qr_args=()
    -  for pair in \
    -    "bank-intro|$BANK_PUBLIC/intro/" \
    -    "merchant-intro|$MERCHANT_PUBLIC/intro/" \
    -    "exchange-intro|$EXCHANGE_PUBLIC/intro/"
    -  do
    -    _qn="${pair%%|*}"
    -    _qu="${pair#*|}"
    -    _qf="$qr_dir/${_qn}.html"
    -    _qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000)
    -    if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then
    -      _qr_args+=("$_qn" "$_qf")
    -    fi
    -  done
    -  for pair in \
    -    "demo-withdraw|$BANK_PUBLIC/intro/demo-withdraw.json" \
    -    "auto-account|$BANK_PUBLIC/intro/auto-account.json"
    -  do
    -    _qn="${pair%%|*}"
    -    _qu="${pair#*|}"
    -    _qf="$qr_dir/${_qn}.json"
    -    _qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000)
    -    if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then
    -      _qr_args+=("$_qn" "$_qf")
    -    fi
    -  done
    -
    -  if [ "${#_qr_args[@]}" -eq 0 ]; then
    -    warn "qr sources" "no landing HTML / mint JSON fetched — skip payload checks"
    -  else
    -    _qr_tsv="$qr_dir/payloads.tsv"
    -    if ! python3 "$ROOT/check_qr_payloads.py" "${_qr_args[@]}" >"$_qr_tsv" 2>"$qr_dir/harvest.err"; then
    -      warn "qr harvest" "collector failed · $(tr '\n' ' ' <"$qr_dir/harvest.err" | head -c 160)"
    -    fi
    -
    -    _qr_n=0
    -    _qr_form_ok=0
    -    _qr_form_bad=0
    -    _qr_rt_ok=0
    -    _qr_rt_bad=0
    -    _qr_taler=0
    -    _qr_payto=0
    -    _qr_bad_sample=""
    -    _qr_i=0
    -
    -    while IFS=$'\t' read -r src kind uri status detail || [ -n "${src:-}" ]; do
    -      [ -n "${src:-}" ] || continue
    -      _qr_n=$((_qr_n + 1))
    -      case "$kind" in
    -        withdraw|pay|pay-template|refund|other) _qr_taler=$((_qr_taler + 1)) ;;
    -        payto) _qr_payto=$((_qr_payto + 1)) ;;
    -      esac
    -
    -      if [ "$status" = "ok" ]; then
    -        _qr_form_ok=$((_qr_form_ok + 1))
    -      else
    -        _qr_form_bad=$((_qr_form_bad + 1))
    -        _qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }form/${kind} ${uri:0:56}"
    -        # taler:// and payto:// form errors are hard on local/GOA
    -        case "$kind" in
    -          withdraw|pay|pay-template|payto)
    -            if [ "${LOCAL_STACK:-1}" = "1" ] || [ "${EXPECT_CURRENCY:-}" = "GOA" ]; then
    -              fail "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}"
    -            else
    -              warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}"
    -            fi
    -            ;;
    -          *)
    -            warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}"
    -            ;;
    -        esac
    -        continue
    -      fi
    -
    -      # Encode → PNG → zbarimg (exact match)
    -      if [ "$_qr_tools" = "1" ] && [ -n "$uri" ]; then
    -        _qr_i=$((_qr_i + 1))
    -        _png="$qr_dir/p-${_qr_i}.png"
    -        # ECC: H for wallet-style long URIs, L for long https store links, else QR_ECC
    -        _ecc="$QR_ECC"
    -        case "$kind" in
    -          https) _ecc=L ;;
    -          withdraw|pay|pay-template) _ecc=M ;;
    -          payto) _ecc=M ;;
    -        esac
    -        if ! qrencode -l "$_ecc" -s 4 -m 2 -o "$_png" "$uri" 2>/dev/null; then
    -          _qr_rt_bad=$((_qr_rt_bad + 1))
    -          fail "qr encode ${kind}" "qrencode failed · ${uri:0:72}"
    -          continue
    -        fi
    -        _got=$(zbarimg --raw -q "$_png" 2>/dev/null | head -n1 | tr -d '\r' || true)
    -        if [ "$_got" = "$uri" ]; then
    -          _qr_rt_ok=$((_qr_rt_ok + 1))
    -        else
    -          _qr_rt_bad=$((_qr_rt_bad + 1))
    -          _qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }decode/${kind}"
    -          fail "qr decode ${kind}" "zbar mismatch · want ${uri:0:48}… · got ${_got:0:48}"
    -        fi
    -      fi
    -    done <"$_qr_tsv"
    -
    -    # Download any obvious static QR images from bank intro and decode (soft)
    -    if [ "$_qr_tools" = "1" ] && [ -f "$qr_dir/bank-intro.html" ]; then
    -      python3 - "$qr_dir/bank-intro.html" "$BANK_PUBLIC" "$qr_dir" <<'PY' >"$qr_dir/img-urls.txt" 2>/dev/null || true
    -import re, sys
    -from urllib.parse import urljoin
    -html = open(sys.argv[1], encoding="utf-8", errors="replace").read()
    -base = sys.argv[2].rstrip("/") + "/"
    -out = sys.argv[3]
    -seen = set()
    -for m in re.finditer(r'''(?:src|href)=["']([^"']+\.(?:png|jpe?g|gif|svg))["']''', html, re.I):
    -    u = m.group(1)
    -    if "qr" not in u.lower() and "withdraw" not in u.lower():
    -        continue
    -    if u.startswith("data:"):
    -        continue
    -    absu = urljoin(base + "intro/", u)
    -    if absu in seen:
    -        continue
    -    seen.add(absu)
    -    print(absu)
    -PY
    -      _img_n=0
    -      _img_ok=0
    -      while read -r _img_url; do
    -        [ -n "$_img_url" ] || continue
    -        _img_n=$((_img_n + 1))
    -        _imgf="$qr_dir/img-${_img_n}.bin"
    -        _ic=$(http_body "$_img_url" "$_imgf" 2>/dev/null || echo 000)
    -        if [ "$_ic" != "200" ] || [ ! -s "$_imgf" ]; then
    -          warn "qr image fetch" "HTTP ${_ic} · ${_img_url}"
    -          continue
    -        fi
    -        # zbarimg needs image format; skip non-raster
    -        if ! file "$_imgf" 2>/dev/null | grep -qiE 'PNG|JPEG|PBM|PGM|PPM|GIF'; then
    -          info "qr image skip" "not raster · ${_img_url}"
    -          continue
    -        fi
    -        _dec=$(zbarimg --raw -q "$_imgf" 2>/dev/null | head -n1 | tr -d '\r' || true)
    -        if [ -z "$_dec" ]; then
    -          warn "qr image decode" "no barcode · ${_img_url}"
    -          continue
    -        fi
    -        _img_ok=$((_img_ok + 1))
    -        # validate decoded payload form
    -        printf '%s\n' "$_dec" >"$qr_dir/img-decoded.txt"
    -        _line=$(python3 "$ROOT/check_qr_payloads.py" "img" "$qr_dir/img-decoded.txt" 2>/dev/null | head -1 || true)
    -        if [ -n "$_line" ]; then
    -          _st=$(printf '%s' "$_line" | cut -f4)
    -          _kd=$(printf '%s' "$_line" | cut -f2)
    -          if [ "$_st" = "ok" ]; then
    -            ok "qr image ${_kd}" "${_dec:0:64}"
    -          else
    -            case "$_kd" in
    -              withdraw|pay|pay-template|payto)
    -                fail "qr image ${_kd}" "bad form · ${_dec:0:72}"
    -                ;;
    -              *)
    -                warn "qr image ${_kd}" "bad form · ${_dec:0:72}"
    -                ;;
    -            esac
    -          fi
    -        else
    -          ok "qr image decode" "${_dec:0:64}"
    -        fi
    -      done <"$qr_dir/img-urls.txt"
    -      if [ "$_img_n" -gt 0 ]; then
    -        info "qr images" "fetched ${_img_n} · decoded ${_img_ok}"
    -      fi
    -    fi
    -
    -    # Summaries
    -    if [ "$_qr_n" -eq 0 ]; then
    -      warn "qr payloads" "none found in landings/mint JSON"
    -    else
    -      if [ "$_qr_form_bad" -eq 0 ]; then
    -        ok "qr form" "${_qr_form_ok}/${_qr_n} ok · taler=${_qr_taler} payto=${_qr_payto}"
    -      else
    -        if [ "${LOCAL_STACK:-1}" = "1" ]; then
    -          fail "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad${_qr_bad_sample:+ · $_qr_bad_sample}"
    -        else
    -          warn "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad"
    -        fi
    -      fi
    -      if [ "$_qr_tools" = "1" ]; then
    -        if [ "$_qr_rt_bad" -eq 0 ] && [ "$_qr_rt_ok" -gt 0 ]; then
    -          ok "qr encode/decode" "${_qr_rt_ok} roundtrips (ecc default ${QR_ECC})"
    -        elif [ "$_qr_rt_ok" -eq 0 ] && [ "$_qr_rt_bad" -eq 0 ]; then
    -          info "qr encode/decode" "no payloads to encode"
    -        else
    -          fail "qr encode/decode" "${_qr_rt_ok} ok · ${_qr_rt_bad} fail"
    -        fi
    -      fi
    -    fi
    -  fi
    -else
    -  if [ "${QR_CHECK:-1}" != "1" ]; then
    -    info "qr checks" "skipped (QR_CHECK=0)"
    -  fi
    -fi
    -
    -summary
    diff --git a/scripts/taler-monitoring/check_versions.sh b/scripts/taler-monitoring/check_versions.sh
    deleted file mode 100755
    index 8fc7c66..0000000
    --- a/scripts/taler-monitoring/check_versions.sh
    +++ /dev/null
    @@ -1,563 +0,0 @@
    -#!/usr/bin/env bash
    -# Area versions-### — Taler packages vs deb.taler.net (trixie) + repo availability.
    -#
    -# Checks:
    -#   1) deb.taler.net apt endpoints reachable (InRelease / Packages / sample .deb)
    -#   2) containers can reach deb.taler.net (install path from inside)
    -#   3) installed taler*/libeufin*/libtaler*/libdonau* versions vs suite index
    -#
    -# Env:
    -#   TALER_APT_SUITE=trixie
    -#   TALER_APT_BASE=https://deb.taler.net/apt/debian
    -#   TALER_APT_INDEX=…/dists/trixie/main/binary-amd64/Packages
    -#   TALER_APT_TESTING_INDEX=…/dists/trixie-testing/…
    -#   TALER_PKG_BEHIND=warn|error   (default: core packages ERROR if behind, else warn)
    -#   SKIP_SSH=1                    skip container install checks
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -
    -set_area versions
    -set_group outside
    -
    -SUITE="${TALER_APT_SUITE:-trixie}"
    -APT_BASE="${TALER_APT_BASE:-https://deb.taler.net/apt/debian}"
    -APT_BASE="${APT_BASE%/}"
    -INDEX="${TALER_APT_INDEX:-${APT_BASE}/dists/${SUITE}/main/binary-amd64/Packages}"
    -INRELEASE="${TALER_APT_INRELEASE:-${APT_BASE}/dists/${SUITE}/InRelease}"
    -TESTING_INDEX="${TALER_APT_TESTING_INDEX:-${APT_BASE}/dists/${SUITE}-testing/main/binary-amd64/Packages}"
    -BEHIND_MODE="${TALER_PKG_BEHIND:-warn}"
    -
    -tmp=$(mktemp -d)
    -trap 'rm -rf "$tmp"' EXIT
    -
    -http_get() {
    -  # usage: http_get URL [outfile]  → prints http_code; body to outfile or /dev/null
    -  local url="$1" out="${2:-/dev/null}"
    -  curl -sS --max-time 30 -L -o "$out" -w '%{http_code}' "$url" 2>/dev/null || echo 000
    -}
    -
    -# ---------------------------------------------------------------------------
    -# 1) OUTSIDE — deb.taler.net install source (runner / laptop, no SSH)
    -# ---------------------------------------------------------------------------
    -section "versions · outside · deb.taler.net (${SUITE})"
    -
    -# DNS
    -HOST_APT="${APT_BASE#https://}"; HOST_APT="${HOST_APT#http://}"; HOST_APT="${HOST_APT%%/*}"
    -if getent hosts "$HOST_APT" >/dev/null 2>&1 \
    -  || python3 -c "import socket; socket.getaddrinfo('${HOST_APT}', 443)" >/dev/null 2>&1; then
    -  ip=$(python3 -c "import socket; print(socket.getaddrinfo('${HOST_APT}',443)[0][4][0])" 2>/dev/null || true)
    -  ok "outside DNS ${HOST_APT}" "${ip:-resolved}"
    -else
    -  fail "outside DNS ${HOST_APT}" "unresolvable — cannot use as apt source"
    -fi
    -
    -# HTTPS portal / apt root
    -code=$(http_get "https://${HOST_APT}/")
    -[ "$code" = "200" ] && ok "outside https://${HOST_APT}/" "HTTP $code" \
    -  || warn "outside https://${HOST_APT}/" "HTTP $code"
    -
    -code=$(http_get "${APT_BASE}/")
    -case "$code" in
    -  200|301|302) ok "outside apt base" "${APT_BASE}/ HTTP $code" ;;
    -  *) fail "outside apt base" "${APT_BASE}/ HTTP $code" ;;
    -esac
    -
    -# Suite metadata (what apt update needs)
    -code=$(http_get "$INRELEASE" "$tmp/InRelease")
    -if [ "$code" = "200" ] && [ -s "$tmp/InRelease" ]; then
    -  ok "outside InRelease" "${SUITE} HTTP 200 · $(wc -c <"$tmp/InRelease" | tr -d ' ') bytes"
    -else
    -  fail "outside InRelease" "HTTP $code — $INRELEASE (apt update will fail)"
    -fi
    -
    -RELEASE_URL="${APT_BASE}/dists/${SUITE}/Release"
    -code=$(http_get "$RELEASE_URL" "$tmp/Release")
    -[ "$code" = "200" ] && ok "outside Release" "${SUITE} HTTP 200" \
    -  || warn "outside Release" "HTTP $code — $RELEASE_URL"
    -
    -# Packages (plain) — required for version compare
    -code=$(http_get "$INDEX" "$tmp/Packages")
    -if [ "$code" = "200" ] && [ -s "$tmp/Packages" ] && grep -q '^Package: ' "$tmp/Packages"; then
    -  n_pkg=$(grep -c '^Package: ' "$tmp/Packages" || true)
    -  ok "outside Packages" "${SUITE} HTTP 200 · ${n_pkg} packages"
    -else
    -  fail "outside Packages" "HTTP $code — $INDEX"
    -  # still try more probes, but cannot compare versions without index
    -fi
    -
    -# Packages.gz (apt often prefers this)
    -PKGZ_URL="${APT_BASE}/dists/${SUITE}/main/binary-amd64/Packages.gz"
    -code=$(http_get "$PKGZ_URL" "$tmp/Packages.gz")
    -if [ "$code" = "200" ] && [ -s "$tmp/Packages.gz" ]; then
    -  ok "outside Packages.gz" "${SUITE} HTTP 200 · $(wc -c <"$tmp/Packages.gz" | tr -d ' ') bytes"
    -else
    -  warn "outside Packages.gz" "HTTP $code — apt may still use plain Packages"
    -fi
    -
    -# Sample pool .deb downloadable (proves packages are installable, not just listed)
    -if [ -s "$tmp/Packages" ]; then
    -  DEB_PATH=$(awk '
    -    /^Package: taler-exchange$/ { p=1 }
    -    p && /^Filename: / { sub(/^Filename: /,""); print; exit }
    -    p && /^$/ { p=0 }
    -  ' "$tmp/Packages")
    -  [ -z "$DEB_PATH" ] && DEB_PATH=$(awk '
    -    /^Package: taler-merchant$/ { p=1 }
    -    p && /^Filename: / { sub(/^Filename: /,""); print; exit }
    -    p && /^$/ { p=0 }
    -  ' "$tmp/Packages")
    -  if [ -n "$DEB_PATH" ]; then
    -    DEB_URL="${APT_BASE}/${DEB_PATH}"
    -    code=$(curl -sS --max-time 30 -o /dev/null -w '%{http_code}' -r 0-128 "$DEB_URL" 2>/dev/null || echo 000)
    -    case "$code" in
    -      200|206)
    -        ok "outside pool .deb" "$(basename "$DEB_PATH") HTTP $code"
    -        ;;
    -      *)
    -        fail "outside pool .deb" "HTTP $code — $DEB_URL (index ok but debs not fetchable)"
    -        ;;
    -    esac
    -  else
    -    warn "outside pool .deb" "no Filename for taler-exchange/merchant in index"
    -  fi
    -
    -  for pkg in taler-exchange taler-merchant libeufin-bank; do
    -    ver=$(awk -v p="$pkg" '
    -      $0=="Package: "p { hit=1; next }
    -      hit && /^Version: / { sub(/^Version: /,""); print; exit }
    -      hit && /^$/ { hit=0 }
    -    ' "$tmp/Packages")
    -    if [ -n "$ver" ]; then
    -      ok "outside suite offers ${pkg}" "$ver"
    -    else
    -      fail "outside suite offers ${pkg}" "missing from ${SUITE} Packages"
    -    fi
    -  done
    -fi
    -
    -HAVE_TESTING=0
    -code=$(http_get "$TESTING_INDEX" "$tmp/Packages-testing")
    -if [ "$code" = "200" ] && [ -s "$tmp/Packages-testing" ] && grep -q '^Package: ' "$tmp/Packages-testing"; then
    -  HAVE_TESTING=1
    -  ok "outside ${SUITE}-testing Packages" "HTTP 200 (optional compare)"
    -else
    -  info "outside ${SUITE}-testing Packages" "HTTP ${code:-000} (optional)"
    -fi
    -
    -# TLS: certificate verify (curl default) already used above; explicit openssl probe if available
    -if command -v openssl >/dev/null 2>&1; then
    -  if echo | openssl s_client -servername "$HOST_APT" -connect "${HOST_APT}:443" 2>/dev/null \
    -    | grep -q 'Verify return code: 0'; then
    -    ok "outside TLS ${HOST_APT}" "verify ok"
    -  else
    -    # curl succeeded with default CA — soft warn only
    -    warn "outside TLS ${HOST_APT}" "openssl verify not clean (curl may still work)"
    -  fi
    -fi
    -
    -# Need Packages index for later compare
    -if [ ! -s "$tmp/Packages" ] || ! grep -q '^Package: ' "$tmp/Packages"; then
    -  fail "outside Packages usable" "cannot continue version compare without suite index"
    -  summary
    -  exit 1
    -fi
    -
    -# ---------------------------------------------------------------------------
    -# 2) INSIDE — containers can reach deb.taler.net (pasta / install path)
    -#    host-podman: INSIDE_PODMAN=1 on GOA host · ssh: KOOPA_SSH / INSIDE_SSH from laptop
    -# ---------------------------------------------------------------------------
    -_VERS_LOCAL=0
    -_VERS_SSH_HOST=""
    -_VERS_BANK="taler-hacktivism-bank"
    -_VERS_EX="taler-hacktivism-exchange-ansible"
    -_VERS_MER="taler-hacktivism"
    -if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then
    -  _VERS_LOCAL=1
    -elif [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \
    -  || [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \
    -  || [ -n "${INSIDE_SSH:-}" ]; then
    -  _VERS_SSH_HOST="${INSIDE_SSH:-francpaysan-stage-user}"
    -  _VERS_BANK="${INSIDE_BANK_CTR:-stage-lfp-bank}"
    -  _VERS_EX="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}"
    -  _VERS_MER="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}"
    -elif [ "${LOCAL_STACK:-0}" = "1" ] && [ "${SKIP_SSH:-0}" != "1" ]; then
    -  # laptop → koopa via SSH (not host-podman)
    -  _VERS_SSH_HOST="${KOOPA_SSH}"
    -fi
    -
    -if [ "$_VERS_LOCAL" = "1" ]; then
    -  VERS_ACCESS=host-podman
    -  set_group host
    -else
    -  VERS_ACCESS=ssh
    -  set_group ssh
    -fi
    -
    -section "versions · inside · containers → deb.taler.net (access=${VERS_ACCESS})"
    -info "flags" "VERS_ACCESS=${VERS_ACCESS} INSIDE_PODMAN=${INSIDE_PODMAN:-0} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-0} SKIP_SSH=${SKIP_SSH:-0} KOOPA_SSH=${KOOPA_SSH:-} INSIDE_SSH=${INSIDE_SSH:-}"
    -
    -if [ "$_VERS_LOCAL" != "1" ] && [ -z "$_VERS_SSH_HOST" ]; then
    -  info "access" "no host-podman and no SSH target — outside-only package index"
    -  info "outside-only" "deb.taler.net public checks completed above"
    -  summary
    -  exit 0
    -fi
    -
    -if [ "$_VERS_LOCAL" = "1" ]; then
    -  if ! command -v podman >/dev/null 2>&1; then
    -    err "host" "INSIDE_PODMAN/host-podman but podman missing"
    -    info "outside-only" "deb.taler.net public checks completed above"
    -    summary
    -    exit 1
    -  fi
    -  ok "host→container" "podman exec on this host (VERS_ACCESS=host-podman · no SSH)"
    -elif [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then
    -  if ! koopa_ssh_ok; then
    -    err "ssh" "cannot reach ${KOOPA_SSH} — from laptop set KOOPA_SSH=; on host set INSIDE_PODMAN=1"
    -    info "outside-only" "deb.taler.net public checks completed above"
    -    summary
    -    exit 1
    -  fi
    -  ok "ssh ${KOOPA_SSH}" "remote then podman (VERS_ACCESS=ssh)"
    -else
    -  if ! mon_ssh_ok "$_VERS_SSH_HOST"; then
    -    err "ssh" "cannot reach ${_VERS_SSH_HOST} (stagepaysan) — skip container package compare"
    -    info "outside-only" "deb.taler.net public checks completed above"
    -    summary
    -    exit 1
    -  fi
    -  ok "ssh ${_VERS_SSH_HOST}" "stagepaysan (VERS_ACCESS=ssh)"
    -fi
    -
    -INRELEASE_URL="${APT_BASE}/dists/${SUITE}/InRelease"
    -# Write remote script to a file (avoids bash 3.2 parse bugs with case/;; inside $(…)</dev/null | grep -Fx "$want" | head -1)
    -  if [ -n "$c" ]; then echo "$c"; return; fi
    -  case "$want" in
    -    *exchange*) podman ps --format '{{.Names}}' 2>/dev/null | grep -i exchange | head -1 ;;
    -    *bank*)     podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'stage.*bank|hacktivism-bank|taler-bank|lfp-bank' | head -1 ;;
    -    *)
    -      c=$(podman ps --format '{{.Names}}' 2>/dev/null | grep -E '^taler-hacktivism$|^stage-lfp-merchant$' | head -1)
    -      if [ -n "$c" ]; then echo "$c"; return; fi
    -      podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'merchant|hacktivism' | grep -viE 'bank|exchange' | head -1
    -      ;;
    -  esac
    -}
    -for want in "$WANT_EX" "$WANT_BANK" "$WANT_MER"; do
    -  c=$(resolve_ctr "$want")
    -  [ -n "$c" ] || continue
    -  role=other
    -  case "$c" in
    -    *exchange*) role=exchange ;;
    -    *bank*)     role=bank ;;
    -    *)          role=merchant ;;
    -  esac
    -  code=$(podman exec "$c" curl -sS -m 12 -o /dev/null -w '%{http_code}' "$INRELEASE_URL" 2>/dev/null || echo 000)
    -  echo "R|${role}|${c}|${code}"
    -  if podman exec "$c" bash -lc 'grep -Rqs deb.taler.net /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null'; then
    -    echo "S|${role}|${c}|yes"
    -  else
    -    echo "S|${role}|${c}|no"
    -  fi
    -  podman exec "$c" dpkg-query -W -f '${Package}\t${Version}\n' 2>/dev/null \
    -    | awk -v role="$role" -v ctr="$c" '
    -        $1 ~ /^(taler-|libeufin-|libtaler|libdonau)/ {
    -          printf "P|%s|%s|%s|%s\n", role, ctr, $1, $2
    -        }'
    -done
    -REMOTE
    -} >"$tmp/remote-versions.sh"
    -
    -if [ "$_VERS_LOCAL" = "1" ]; then
    -  REMOTE_OUT=$(bash "$tmp/remote-versions.sh" 2>/dev/null || true)
    -elif [ "$_VERS_SSH_HOST" = "${KOOPA_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "koopa" ]; then
    -  REMOTE_OUT=$(koopa_ssh_bash 60 <"$tmp/remote-versions.sh" || true)
    -else
    -  REMOTE_OUT=$(mon_ssh_bash "$_VERS_SSH_HOST" 60 <"$tmp/remote-versions.sh" || true)
    -fi
    -
    -printf '%s\n' "$REMOTE_OUT" | grep -E '^R\|' >"$tmp/reach.tsv" || true
    -printf '%s\n' "$REMOTE_OUT" | grep -E '^S\|' >"$tmp/sources.tsv" || true
    -printf '%s\n' "$REMOTE_OUT" | grep -E '^P\|' >"$tmp/installed.tsv" || true
    -
    -if [ -s "$tmp/reach.tsv" ]; then
    -  while IFS='|' read -r _ role ctr code; do
    -    if [ "$code" = "200" ]; then
    -      ok "container ${role} → deb.taler.net" "${ctr} InRelease HTTP $code"
    -    else
    -      fail "container ${role} → deb.taler.net" "${ctr} InRelease HTTP $code — apt install/update will fail"
    -    fi
    -  done <"$tmp/reach.tsv"
    -else
    -  warn "container → deb.taler.net" "no reachability rows"
    -fi
    -
    -if [ -s "$tmp/sources.tsv" ]; then
    -  while IFS='|' read -r _ role ctr has; do
    -    if [ "$has" = "yes" ]; then
    -      ok "container ${role} apt source" "${ctr} lists deb.taler.net"
    -    else
    -      warn "container ${role} apt source" "${ctr} no deb.taler.net in sources.list*"
    -    fi
    -  done <"$tmp/sources.tsv"
    -fi
    -
    -if [ ! -s "$tmp/installed.tsv" ]; then
    -  fail "installed packages" "none found in taler containers"
    -  summary
    -  exit 1
    -fi
    -n_inst=$(wc -l <"$tmp/installed.tsv" | tr -d ' ')
    -ok "collected installed packages" "${n_inst} rows"
    -
    -# ---------------------------------------------------------------------------
    -# 3) compare installed vs trixie (and note testing)
    -# ---------------------------------------------------------------------------
    -set_group compare
    -section "versions · compare installed vs ${SUITE}"
    -
    -export BEHIND_MODE HAVE_TESTING
    -export TALER_APT_SUITE="$SUITE"
    -CORE_PKGS="taler-exchange taler-exchange-database taler-merchant taler-merchant-webui libeufin-bank libeufin-common libtalerexchange libtalermerchant taler-terms-generator"
    -export CORE_PKGS
    -
    -python3 - "$tmp/Packages" "$tmp/Packages-testing" "$tmp/installed.tsv" <<'PY' >"$tmp/py.out"
    -import sys, os
    -from collections import defaultdict
    -
    -main_idx, test_idx, inst_path = sys.argv[1:4]
    -have_testing = os.environ.get("HAVE_TESTING", "0") == "1"
    -core = set(os.environ.get("CORE_PKGS", "").split())
    -suite = os.environ.get("TALER_APT_SUITE", "trixie")
    -
    -def _ord_ch(c):
    -    if c == "~":
    -        return -1
    -    if c.isdigit():
    -        return 0
    -    if c.isalpha():
    -        return ord(c)
    -    return ord(c) + 256
    -
    -def _split_ver(v):
    -    epoch = 0
    -    if ":" in v:
    -        e, v = v.split(":", 1)
    -        try:
    -            epoch = int(e)
    -        except ValueError:
    -            epoch = 0
    -    if "-" in v:
    -        upstream, deb = v.rsplit("-", 1)
    -    else:
    -        upstream, deb = v, ""
    -    return epoch, upstream, deb
    -
    -def _cmp_part(a, b):
    -    ia = ib = 0
    -    while ia < len(a) or ib < len(b):
    -        while True:
    -            ca = a[ia] if ia < len(a) and not a[ia].isdigit() else ""
    -            cb = b[ib] if ib < len(b) and not b[ib].isdigit() else ""
    -            if ca == "" and cb == "":
    -                break
    -            if ca == "" and cb:
    -                return -1 if _ord_ch(cb) > 0 else (1 if cb == "~" else -1)
    -            if cb == "" and ca:
    -                return 1 if _ord_ch(ca) > 0 else (-1 if ca == "~" else 1)
    -            oa, ob = _ord_ch(ca), _ord_ch(cb)
    -            ia += 1
    -            ib += 1
    -            if oa != ob:
    -                return (oa > ob) - (oa < ob)
    -        sa = sb = ""
    -        while ia < len(a) and a[ia].isdigit():
    -            sa += a[ia]; ia += 1
    -        while ib < len(b) and b[ib].isdigit():
    -            sb += b[ib]; ib += 1
    -        na = int(sa) if sa else 0
    -        nb = int(sb) if sb else 0
    -        if na != nb:
    -            return (na > nb) - (na < nb)
    -    return 0
    -
    -def deb_cmp(a, b):
    -    if a == b:
    -        return 0
    -    ea, ua, da = _split_ver(a)
    -    eb, ub, db = _split_ver(b)
    -    if ea != eb:
    -        return (ea > eb) - (ea < eb)
    -    c = _cmp_part(ua, ub)
    -    if c:
    -        return c
    -    return _cmp_part(da, db)
    -
    -def parse_packages(path):
    -    pkgs = {}
    -    if not path or not os.path.isfile(path) or os.path.getsize(path) == 0:
    -        return pkgs
    -    cur = ver = None
    -    with open(path, encoding="utf-8", errors="replace") as f:
    -        for line in f:
    -            line = line.rstrip("\n")
    -            if line.startswith("Package: "):
    -                if cur and ver and (cur not in pkgs or deb_cmp(ver, pkgs[cur]) > 0):
    -                    pkgs[cur] = ver
    -                cur = line[9:].strip()
    -                ver = None
    -            elif line.startswith("Version: ") and cur:
    -                ver = line[9:].strip()
    -            elif line == "" and cur:
    -                if ver and (cur not in pkgs or deb_cmp(ver, pkgs[cur]) > 0):
    -                    pkgs[cur] = ver
    -                cur = ver = None
    -        if cur and ver and (cur not in pkgs or deb_cmp(ver, pkgs[cur]) > 0):
    -            pkgs[cur] = ver
    -    return pkgs
    -
    -main = parse_packages(main_idx)
    -testing = parse_packages(test_idx) if have_testing else {}
    -
    -by_pkg = defaultdict(list)
    -with open(inst_path, encoding="utf-8") as f:
    -    for line in f:
    -        line = line.strip()
    -        if not line.startswith("P|"):
    -            continue
    -        parts = line.split("|")
    -        if len(parts) < 5:
    -            continue
    -        _, role, ctr, pkg, ver = parts[:5]
    -        by_pkg[pkg].append((role, ctr, ver))
    -
    -for pkg in sorted(by_pkg.keys()):
    -    versions = sorted({v for _, _, v in by_pkg[pkg]}, key=lambda v: v)
    -    # pick "highest" via deb_cmp
    -    inst = versions[0]
    -    for v in versions[1:]:
    -        if deb_cmp(v, inst) > 0:
    -            inst = v
    -    roles = ",".join(sorted({r for r, _, _ in by_pkg[pkg]}))
    -    multi = len(set(versions)) > 1
    -    trixie = main.get(pkg)
    -    testv = testing.get(pkg)
    -    status = "ok"
    -    detail = ""
    -    if multi:
    -        status = "warn"
    -        detail = "multiple installed: " + ", ".join(sorted(set(versions)))
    -    if trixie is None:
    -        if status == "ok":
    -            status = "info"
    -        detail = (detail + "; " if detail else "") + f"not in {suite} main index"
    -    else:
    -        c = deb_cmp(inst, trixie)
    -        if c == 0:
    -            detail = (detail + "; " if detail else "") + f"= {suite} {trixie}"
    -        elif c < 0:
    -            status = "behind"
    -            detail = f"installed {inst} < {suite} {trixie}"
    -        else:
    -            status = "ahead"
    -            detail = f"installed {inst} > {suite} {trixie}"
    -            if testv and deb_cmp(inst, testv) == 0:
    -                detail += f" (={suite}-testing)"
    -            elif testv:
    -                detail += f" (testing has {testv})"
    -    core_flag = "1" if pkg in core else "0"
    -    print(f"{status}|{pkg}|{inst}|{trixie or '-'}|{roles}|{core_flag}|{detail}")
    -PY
    -
    -ok_n=0; ahead_n=0; behind_n=0
    -while IFS='|' read -r status pkg inst trixie roles core detail; do
    -  [ -n "${status:-}" ] || continue
    -  label="pkg ${pkg} (${roles})"
    -  case "$status" in
    -    ok)
    -      ok "$label" "${inst}"
    -      ok_n=$((ok_n + 1))
    -      ;;
    -    ahead)
    -      info "$label" "$detail"
    -      ahead_n=$((ahead_n + 1))
    -      ;;
    -    behind)
    -      behind_n=$((behind_n + 1))
    -      if [ "$core" = "1" ] || [ "$BEHIND_MODE" = "error" ]; then
    -        fail "$label" "$detail"
    -      else
    -        warn "$label" "$detail"
    -      fi
    -      ;;
    -    warn)
    -      warn "$label" "$detail"
    -      ;;
    -    info)
    -      info "$label" "installed ${inst}${detail:+ — $detail}"
    -      ;;
    -    *)
    -      info "$label" "$status $detail"
    -      ;;
    -  esac
    -done <"$tmp/py.out"
    -
    -# Core packages must exist somewhere
    -for need in taler-exchange libeufin-bank taler-merchant; do
    -  if grep -qE "^P\|[^|]+\|[^|]+\|${need}\|" "$tmp/installed.tsv"; then
    -    ok "core installed ${need}"
    -  else
    -    fail "core installed ${need}" "not present in any taler container"
    -  fi
    -done
    -
    -info "suite" "deb.taler.net ${SUITE} (testing_index=${HAVE_TESTING})"
    -info "tally" "match=${ok_n} ahead=${ahead_n} behind=${behind_n}"
    -
    -# Disk free on the machine(s) we just probed (host-podman or SSH target)
    -set_group disk
    -section "versions · disk free space"
    -if [ "${_VERS_LOCAL:-0}" = "1" ]; then
    -  mon_disk_check_host "host" || true
    -  while read -r _vc; do
    -    [ -n "$_vc" ] || continue
    -    mon_disk_check_podman "$_vc" || true
    -  done < <(podman ps --format '{{.Names}}' 2>/dev/null | grep -iE 'hacktivism|taler-|stage-lfp|lfp-' || true)
    -elif [ -n "${_VERS_SSH_HOST:-}" ]; then
    -  _vdisk=$(mon_ssh_bash "$_VERS_SSH_HOST" 20 <<'DISK' || true
    -set +e
    -echo "###HOST###"
    -df -Pk / /var /home /tmp 2>/dev/null || df -Pk
    -echo "###CTRS###"
    -for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do
    -  echo "###CTR $c###"
    -  podman exec "$c" df -Pk / /var /tmp 2>/dev/null || true
    -done
    -DISK
    -)
    -  mon_disk_check_remote_text "ssh:${_VERS_SSH_HOST}" \
    -    "$(printf '%s\n' "$_vdisk" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d')" || true
    -  _ctr=""; _buf=""
    -  while IFS= read -r _line || [ -n "$_line" ]; do
    -    case "$_line" in
    -      '###CTR '*)
    -        [ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true
    -        _ctr=${_line####CTR }; _ctr=${_ctr%###}; _buf=""
    -        ;;
    -      '###CTRS###'|'###HOST###') ;;
    -      *) [ -n "$_ctr" ] && _buf="${_buf}${_line}"$'\n' ;;
    -    esac
    -  done <<<"$_vdisk"
    -  [ -n "$_ctr" ] && mon_disk_check_remote_text "ctr:${_ctr}" "$_buf" || true
    -fi
    -
    -summary
    diff --git a/scripts/taler-monitoring/domains.conf b/scripts/taler-monitoring/domains.conf
    deleted file mode 100644
    index 1cc12af..0000000
    --- a/scripts/taler-monitoring/domains.conf
    +++ /dev/null
    @@ -1,46 +0,0 @@
    -# taler-monitoring domain profiles
    -#
    -# When you add a stack, declare the three public endpoints here:
    -#   name   bank   exchange   merchant-backend   currency   local   landing   [canonical]
    -#
    -# - name: what -d / TALER_DOMAIN matches (aliases allowed as extra lines)
    -# - bank / exchange / merchant: hostname or full https:// URL
    -# - currency: GOA | KUDOS | CHF | … (empty = report only)
    -# - local: 1 = koopa SSH/inside/e2e stack; 0 = public-only
    -# - landing: 1 = check /intro landings + assets; 0 = skip (no GOA-style landings)
    -# - canonical: optional TALER_DOMAIN label after alias match
    -#
    -# CLI overrides after load: --bank --exchange --merchant --currency
    -# Alternate file: TALER_DOMAINS_CONF=/path/to/domains.conf
    -
    -# Local GOA (koopa) — landings matter
    -koopa              bank.hacktivism.ch   exchange.hacktivism.ch   taler.hacktivism.ch      GOA    1  1  hacktivism.ch
    -hacktivism         bank.hacktivism.ch   exchange.hacktivism.ch   taler.hacktivism.ch      GOA    1  1  hacktivism.ch
    -hacktivism.ch      bank.hacktivism.ch   exchange.hacktivism.ch   taler.hacktivism.ch      GOA    1  1
    -
    -# Public demo (KUDOS) — has intro-style pages; keep soft landing checks
    -taler.net          bank.demo.taler.net  exchange.demo.taler.net  backend.demo.taler.net    KUDOS  0  1  demo.taler.net
    -demo.taler.net     bank.demo.taler.net  exchange.demo.taler.net  backend.demo.taler.net    KUDOS  0  1
    -
    -# Public test stack (TESTKUDOS) — no GOA-style landings
    -test.taler.net     bank.test.taler.net  exchange.test.taler.net  backend.test.taler.net    TESTKUDOS  0  0
    -
    -# TOPS CHF — multi-tenant merchant my.taler-ops.ch; no public GOA landings
    -taler-ops.ch       bank.taler-ops.ch    exchange.taler-ops.ch    my.taler-ops.ch           CHF    0  0
    -my.taler-ops.ch    bank.taler-ops.ch    exchange.taler-ops.ch    my.taler-ops.ch           CHF    0  0  taler-ops.ch
    -stage.taler-ops.ch bank.stage.taler-ops.ch exchange.stage.taler-ops.ch my.stage.taler-ops.ch CHF 0  0
    -my.stage.taler-ops.ch bank.stage.taler-ops.ch exchange.stage.taler-ops.ch my.stage.taler-ops.ch CHF 0 0 stage.taler-ops.ch
    -
    -# Franc Paysan (Infomaniak) — merchant host is monnaie.* (not taler./backend.)
    -# Prod: no monetary launch yet; stage uses TESTPAYSAN (public HTTPS landings + shops)
    -lefrancpaysan.ch   bank.lefrancpaysan.ch  exchange.lefrancpaysan.ch  monnaie.lefrancpaysan.ch  -  0  0
    -monnaie.lefrancpaysan.ch bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch - 0 0 lefrancpaysan.ch
    -bank.lefrancpaysan.ch bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch - 0 0 lefrancpaysan.ch
    -exchange.lefrancpaysan.ch bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch - 0 0 lefrancpaysan.ch
    -
    -# Stage TESTPAYSAN — landings on / + farmer shops under stage.monnaie…/shops/
    -# name bank exchange merchant currency local landing [canonical]
    -stage.lefrancpaysan.ch          stage.bank.lefrancpaysan.ch  stage.exchange.lefrancpaysan.ch  stage.monnaie.lefrancpaysan.ch  TESTPAYSAN  0  1
    -stage.bank.lefrancpaysan.ch     stage.bank.lefrancpaysan.ch  stage.exchange.lefrancpaysan.ch  stage.monnaie.lefrancpaysan.ch  TESTPAYSAN  0  1  stage.lefrancpaysan.ch
    -stage.exchange.lefrancpaysan.ch stage.bank.lefrancpaysan.ch  stage.exchange.lefrancpaysan.ch  stage.monnaie.lefrancpaysan.ch  TESTPAYSAN  0  1  stage.lefrancpaysan.ch
    -stage.monnaie.lefrancpaysan.ch  stage.bank.lefrancpaysan.ch  stage.exchange.lefrancpaysan.ch  stage.monnaie.lefrancpaysan.ch  TESTPAYSAN  0  1  stage.lefrancpaysan.ch
    diff --git a/scripts/taler-monitoring/harness-live.txt b/scripts/taler-monitoring/harness-live.txt
    deleted file mode 100644
    index b7d5500..0000000
    --- a/scripts/taler-monitoring/harness-live.txt
    +++ /dev/null
    @@ -1,7 +0,0 @@
    -# Optional: taler-harness against *our* exchange (needs a harness version
    -# that accepts current /config JSON). Older installs often FAIL codec checks
    -# even when the exchange is fine — trust `./taler-monitoring.sh urls` first.
    -#
    -# idcommand
    -
    -lint-exchange-goa	taler-harness deployment lint-exchange-url https://exchange.hacktivism.ch/
    diff --git a/scripts/taler-monitoring/harness-tests.txt b/scripts/taler-monitoring/harness-tests.txt
    deleted file mode 100644
    index 1c63e3b..0000000
    --- a/scripts/taler-monitoring/harness-tests.txt
    +++ /dev/null
    @@ -1,41 +0,0 @@
    -# Real-life–relevant taler-harness integration tests
    -# (from: taler-harness list-integrationtests)
    -#
    -# Criteria: everyday user/merchant/bank paths — withdraw, pay, deposit, refund,
    -# libeufin bank, templates/paywall. Excludes KYC edge cases, experimental,
    -# timetravel, perf, backup, mailbox, most fault-injection.
    -#
    -# These spin up a local TESTKUDOS stack (same client code paths as production).
    -# For tests against *public* demos, see harness-live.txt / `./taler-monitoring.sh live`.
    -
    -# --- Core path: get money + spend it (the GOA story) ---
    -simple-payment
    -withdrawal-bank-integrated
    -payment
    -
    -# --- Withdraw variants users actually hit ---
    -withdrawal-manual
    -withdrawal-external
    -withdrawal-idempotent
    -
    -# --- Pay variants shops use ---
    -payment-template
    -paywall-flow
    -payment-idempotency
    -payment-zero
    -payment-abort
    -otp
    -
    -# --- After pay: coins / merchant ops ---
    -deposit
    -refund
    -refund-auto
    -wallet-refresh
    -
    -# --- Bank stack we run (libeufin / regional) ---
    -libeufin-bank
    -bank-api
    -
    -# --- Client hygiene ---
    -wallet-config
    -term-of-service-format
    diff --git a/scripts/taler-monitoring/host-agent/README.md b/scripts/taler-monitoring/host-agent/README.md
    deleted file mode 100644
    index 3acfe59..0000000
    --- a/scripts/taler-monitoring/host-agent/README.md
    +++ /dev/null
    @@ -1,183 +0,0 @@
    -# host-agent — reporting generation (GOA + FP + any stack)
    -
    -**Global pipeline** lives in **`run-host-report.sh`** (all stacks share it):
    -
    -| Behaviour | Default |
    -|-----------|---------|
    -| Suite refresh | `update-suite.sh` → `origin/main` |
    -| Wall clock | `RUN_TIMEOUT=600` |
    -| Log | line-buffered `run-*.log` (continuous write) |
    -| HTML | always (first run / empty tree too) + commit URL |
    -| Timeout notice | end of log + top jump on `/monitoring_err/` |
    -
    -Thin wrappers only set stack defaults:
    -
    -| Wrapper | Stack | Typical user |
    -|---------|-------|----------------|
    -| `run-hacktivism-monitoring.sh` | GOA `hacktivism.ch` | `hernani` @ koopa |
    -| `run-fp-stage-monitoring.sh` | FP stage | `stagepaysan` |
    -| `run-fp-prod-monitoring.sh` | FP prod | `francpaysan` |
    -| `run-aptdeploy.sh` | CLI: ensure + **aptdeploy** (no HTML) | local if hostname=koopa, else **ssh koopa-external** |
    -| `run-aptdeploy-monitoring.sh` | **aptdeploy** → `/taler-monitoring-aptdeploy*` | `hernani` @ koopa (4h timer, taler only) |
    -| `run-surface-monitoring.sh` | **surface** → `/taler-monitoring-surface*` | `hernani` @ koopa (hourly, taler only) |
    -
    -### apt-src deploy tests (hacktivism `/monitoring`)
    -
    -| Container | Mode |
    -|-----------|------|
    -| `koopa-taler-deploy-test-apt-src-trixie` | **fresh**: recreate when repo package fingerprint changes |
    -| `koopa-taler-deploy-test-apt-src-trixie-testing` | **fresh** for `trixie-testing` |
    -| `koopa-taler-deploy-test-apt-src-trixie-upgrade` | **upgrade**: initial install, then mytops-style `apt install` + `upgrade` |
    -| `koopa-taler-deploy-test-apt-src-trixie-testing-upgrade` | **upgrade** for testing suite |
    -
    -```bash
    -# from laptop / clementine → triggers on koopa-external
    -./scripts/taler-monitoring/host-agent/run-aptdeploy.sh
    -./scripts/taler-monitoring/host-agent/run-aptdeploy.sh upgrade
    -./scripts/taler-monitoring/host-agent/run-aptdeploy.sh fresh
    -./scripts/taler-monitoring/host-agent/run-aptdeploy.sh check-only
    -
    -# on koopa
    -./taler-monitoring.sh -d hacktivism.ch aptdeploy
    -```
    -
    -Checks: package table, `httpd --version`, libtalerutil/`ldd` on failure, `systemctl` for
    -`taler-merchant.target`, basic local `/config` probe. No nginx/HTTPS in containers.
    -
    -### surface + aptdeploy HTML (taler.hacktivism.ch only)
    -
    -| URL | Role | Timer |
    -|-----|------|-------|
    -| `…/taler-monitoring-surface/` | ecosystem surface scan | 1h |
    -| `…/taler-monitoring-surface_err/` | errors + jump list | ″ |
    -| `…/taler-monitoring-aptdeploy/` | apt-src container deploy tests | 4h |
    -| `…/taler-monitoring-aptdeploy_err/` | errors + jump list | ″ |
    -
    -Not on bank/exchange. Staging:
    -`~/monitoring-sites-staging/taler.hacktivism.ch/taler-monitoring-{surface,aptdeploy}*`.
    -
    -```bash
    -systemctl --user enable --now taler-monitoring-surface.timer
    -systemctl --user enable --now taler-monitoring-aptdeploy.timer
    -systemctl --user start taler-monitoring-surface.service
    -systemctl --user start taler-monitoring-aptdeploy.service
    -# Caddy: handles only in taler.hacktivism.ch block (root)
    -```
    -
    -Env examples: **`env/*.env.example`**. FP docs: `francpaysan-admin-log/docs/taler-monitoring-host-agents.md`.
    -
    -## GOA (koopa) — host-podman
    -
    -Runs **as user `hernani`** (systemd user, linger). **Primary** mode:
    -
    -- **`INSIDE_PODMAN=1`** / **`INSIDE_MODE=local-podman`**: access mode **`host-podman`** —  
    -  `podman exec` on this host into  
    -  `taler-hacktivism-bank` / `taler-hacktivism-exchange-ansible` / `taler-hacktivism`  
    -  (IDs `inside.host-*`, not `inside.ssh-*`).  
    -  From a **laptop**, leave `INSIDE_PODMAN` off → access mode **`ssh`** via `KOOPA_SSH`  
    -  (`inside.ssh-*`).
    -- Public **`urls` + `versions`** for the stack (`-d hacktivism.ch`).
    -- Writes console HTML for all three sites under  
    -  `~/monitoring-sites-staging/{bank,exchange,taler}.hacktivism.ch/`.
    -
    -Caddy (host root) can serve that tree at `/monitoring` — you configure Caddy.
    -
    -## Latest software + config isolation
    -
    -**Source of truth is always Forgejo** (`git.hacktivism.ch`), not a laptop rsync copy.
    -
    -Every host-agent / aptdeploy run:
    -
    -1. **`update-suite.sh`**: `git fetch` + **`SUITE_UPDATE_MODE=reset`** (default) → `origin/main`  
    -   into `SUITE_DIR` (prefer `~/src/koopa-admin-log`).
    -2. **Re-exec** host-agent scripts from that tree so the running code matches `COMMIT_URL`.
    -3. Local overrides only in **`~/.config/taler-monitoring/env`** — never overwritten by git.
    -4. HTML footer = exact commit:  
    -   `https://git.hacktivism.ch/hernani/koopa-admin-log/src/commit/`
    -5. **`RUN_TIMEOUT=600`**, line-buffered logs, HTML always written.
    -
    -If fetch fails → previous tree + WARN.  
    -**To ship features (aptdeploy, surface, …): push to `origin/main`.** Local-only edits vanish on the next reset.
    -
    -If pull fails (no network / auth), the previous tree is used and a WARN is logged.
    -
    -## Install (on koopa as hernani)
    -
    -```bash
    -# once: local config (optional)
    -mkdir -p ~/.config/taler-monitoring
    -cp ~/koopa-admin-log/scripts/taler-monitoring/host-agent/env.example \
    -   ~/.config/taler-monitoring/env
    -
    -# laptop:
    -./scripts/taler-monitoring/host-agent/install-host-agent.sh --remote koopa-external
    -
    -# or on koopa:
    -~/koopa-admin-log/scripts/taler-monitoring/host-agent/install-host-agent.sh
    -```
    -
    -Units:
    -
    -| Unit | Role |
    -|------|------|
    -| `taler-monitoring-hacktivism.path` | fires on software stamp + landing/caddy config changes |
    -| `taler-monitoring-hacktivism.timer` | every **4h** fallback |
    -| `taler-monitoring-hacktivism.service` | oneshot → `/monitoring*` (urls inside versions) |
    -| `taler-monitoring-surface.timer` | every **1h** |
    -| `taler-monitoring-surface.service` | → `/taler-monitoring-surface*` (taler only) |
    -| `taler-monitoring-aptdeploy.timer` | every **4h** |
    -| `taler-monitoring-aptdeploy.service` | → `/taler-monitoring-aptdeploy*` (taler only) |
    -
    -## After package / image / landing upgrades
    -
    -```bash
    -~/koopa-admin-log/scripts/taler-monitoring/host-agent/touch-software-stamp.sh
    -# → path unit starts the service
    -```
    -
    -## Manual
    -
    -```bash
    -systemctl --user start taler-monitoring-hacktivism.service
    -systemctl --user status taler-monitoring-hacktivism.service --no-pager
    -journalctl --user -u taler-monitoring-hacktivism.service -n 50 --no-pager
    -ls ~/monitoring-sites-staging/bank.hacktivism.ch/monitoring_err/
    -```
    -
    -## Relation to firecuda outside-only timer
    -
    -| | host-agent (koopa) | firecuda site-gen |
    -|--|--------------------|-------------------|
    -| Access | **podman on host** | public HTTPS only |
    -| Phases | urls **inside** versions | urls versions |
    -| Trigger | software path + 4h | *could* launchd 4h |
    -| Primary for GOA | **yes** | **off** (optional only) |
    -
    -**firecuda-external is not scheduled** right now (launchd disabled). Scripts under
    -`site-gen/` can still one-shot a public run if needed; see `site-gen/README.md`.
    -
    -No secrets required for default phases. `secrets.env` optional for e2e later.
    -
    -## Git commit on HTML pages
    -
    -Header of each generated page includes a link like:
    -
    -`https://git.hacktivism.ch/hernani/koopa-admin-log/src/commit/`
    -
    -That SHA is the tree **after** `update-suite.sh` for that run.
    -
    -## Packages on koopa
    -
    -See **[../DEPENDENCIES.md](../DEPENDENCIES.md)**.
    -
    -**Default host-agent needs no new packages** if curl/python3/podman/git are present (they are).
    -
    -**Root once** only for full QR checks in `urls`:
    -
    -```bash
    -zypper in qrencode zbar
    -```
    -
    -Without them, monitoring still runs; QR group WARNs and skips encode/decode.
    -
    -Caddy `/var/www/monitoring-sites` is separate (you configured hernani write access).
    diff --git a/scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md b/scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md
    deleted file mode 100644
    index 17dd386..0000000
    --- a/scripts/taler-monitoring/host-agent/ROOT-APPLY-MONITORING.md
    +++ /dev/null
    @@ -1,57 +0,0 @@
    -# Root: make monitoring pages live on koopa (GOA)
    -
    -## Symptom if not applied
    -
    -Public URLs return **merchant/bank JSON** like:
    -
    -```json
    -{ "code": 21, "hint": "There is no endpoint defined for the URL provided..." }
    -```
    -
    -or a **404 HTML** page from the app reverse_proxy.
    -
    -Cause: live `/etc/caddy/Caddyfile` has **no** `handle /monitoring*` (falls through to
    -API). Prepared config is only under **`/home/hernani/koopa-caddy/Caddyfile`**.
    -
    -## One-shot as root (recommended)
    -
    -```bash
    -sudo /home/hernani/koopa-caddy/apply-monitoring-live.sh
    -```
    -
    -That script:
    -
    -1. `rsync` staging HTML → `/var/www/monitoring-sites/` (incl. surface + aptdeploy)
    -2. Makes tree writable for `hernani` (group `caddy` + ACL) so later runs self-sync
    -3. Installs prepared Caddyfile → `/etc/caddy/Caddyfile`, validates, reloads Caddy
    -4. Smoke-curls all monitoring URLs (must **not** be JSON `code:21`)
    -
    -## Paths
    -
    -| Path | Host | Source timer |
    -|------|------|----------------|
    -| `/monitoring/` + `/monitoring_err/` | bank + exchange + taler | `taler-monitoring-hacktivism.timer` (4h) |
    -| `/taler-monitoring-surface/` + `/_err/` | **taler.hacktivism.ch only** | `taler-monitoring-surface.timer` (1h) |
    -| `/taler-monitoring-aptdeploy/` + `/_err/` | **taler.hacktivism.ch only** | `taler-monitoring-aptdeploy.timer` (4h) |
    -
    -HTML is generated as **hernani** into  
    -`/home/hernani/monitoring-sites-staging/`.
    -
    -## Manual equivalent
    -
    -```bash
    -rsync -a --delete /home/hernani/monitoring-sites-staging/ /var/www/monitoring-sites/
    -chown -R hernani:caddy /var/www/monitoring-sites && chmod -R g+rwX /var/www/monitoring-sites
    -sudo /home/hernani/koopa-caddy/apply.sh   # or apply-monitoring-live.sh
    -```
    -
    -Expect **200** (HTML with sticky bar). Not merchant JSON `code:21`.
    -
    -## After each monitoring run (if www is not hernani-writable)
    -
    -```bash
    -rsync -a --delete /home/hernani/monitoring-sites-staging/ /var/www/monitoring-sites/
    -chown -R caddy:caddy /var/www/monitoring-sites
    -```
    -
    -Or grant `hernani` write on `/var/www/monitoring-sites` once so the host-agent can rsync itself.
    diff --git a/scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh b/scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh
    deleted file mode 100755
    index 75fa3e4..0000000
    --- a/scripts/taler-monitoring/host-agent/ensure-apt-deploy-test-containers.sh
    +++ /dev/null
    @@ -1,373 +0,0 @@
    -#!/usr/bin/env bash
    -# ensure-apt-deploy-test-containers.sh
    -#
    -# Four rootless podman containers on koopa (no nginx / no HTTPS):
    -#
    -#   Fresh rebuild when repo fingerprint changes:
    -#     koopa-taler-deploy-test-apt-src-trixie
    -#     koopa-taler-deploy-test-apt-src-trixie-testing
    -#
    -#   Persistent upgrade path (mytops upgrade.sh style):
    -#     koopa-taler-deploy-test-apt-src-trixie-upgrade
    -#     koopa-taler-deploy-test-apt-src-trixie-testing-upgrade
    -#
    -# Run as hernani on koopa. From elsewhere use run-aptdeploy.sh (ssh koopa-external).
    -#
    -set -euo pipefail
    -
    -PODMAN_BIN="${APT_DEPLOY_PODMAN:-podman}"
    -IMG="${APT_DEPLOY_BASE_IMAGE:-docker.io/library/debian:trixie}"
    -STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/taler-apt-deploy"
    -URI_BASE="${APT_DEPLOY_URI:-https://deb.taler.net/apt/debian}"
    -mkdir -p "$STATE_DIR"
    -
    -# Packages we track for "new version → rebuild fresh containers"
    -TRACK_PKGS="${APT_DEPLOY_TRACK_PKGS:-taler-merchant libtalermerchant libtalerexchange libdonau}"
    -
    -# Canonical four containers (keep). Everything else matching legacy names is removed.
    -CANONICAL_CONTAINERS=(
    -  koopa-taler-deploy-test-apt-src-trixie
    -  koopa-taler-deploy-test-apt-src-trixie-testing
    -  koopa-taler-deploy-test-apt-src-trixie-upgrade
    -  koopa-taler-deploy-test-apt-src-trixie-testing-upgrade
    -)
    -
    -# Legacy / typo names from earlier iterations — always delete if present
    -LEGACY_CONTAINERS=(
    -  koopa-taler-build-test-apt-trixie
    -  koopa-taler-build-test-apt-trixie-testing
    -  koopa-taler-deploy-test-apt-trixie
    -  koopa-taler-deploy-test-apt-trixie-testing
    -  koopa-taler-buidl-test-apt-trixie
    -  koopa-taler-buidl-test-apt-trixie-testing
    -)
    -
    -cleanup_legacy() {
    -  local n img
    -  echo "======== cleanup legacy containers / images ========"
    -  for n in "${LEGACY_CONTAINERS[@]}"; do
    -    if "$PODMAN_BIN" container exists "$n" 2>/dev/null; then
    -      echo "remove legacy container $n"
    -      "$PODMAN_BIN" rm -f "$n" 2>/dev/null || true
    -    fi
    -    # old setup images for that name
    -    if "$PODMAN_BIN" image exists "localhost/${n}:setup" 2>/dev/null; then
    -      echo "remove legacy image localhost/${n}:setup"
    -      "$PODMAN_BIN" rmi -f "localhost/${n}:setup" 2>/dev/null || true
    -    fi
    -  done
    -  # dangling setup images not in canonical set
    -  while read -r img; do
    -    [ -n "$img" ] || continue
    -    local base keep=0
    -    base=${img#localhost/}
    -    base=${base%:setup}
    -    for n in "${CANONICAL_CONTAINERS[@]}"; do
    -      [ "$base" = "$n" ] && keep=1 && break
    -    done
    -    if [ "$keep" -eq 0 ] && [[ "$img" == localhost/koopa-taler-*-test-apt* ]]; then
    -      echo "remove non-canonical image $img"
    -      "$PODMAN_BIN" rmi -f "$img" 2>/dev/null || true
    -    fi
    -  done < <("$PODMAN_BIN" images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -E 'localhost/koopa-taler-.*-test-apt' || true)
    -}
    -
    -# Prefer single suite tree: if both ~/src/koopa-admin-log and ~/koopa-admin-log are
    -# full git clones, keep the newer (by HEAD commit time) and replace the older with a symlink.
    -dedupe_suite_repos() {
    -  local a="$HOME/src/koopa-admin-log" b="$HOME/koopa-admin-log"
    -  local ta tb
    -  [ "${APT_DEPLOY_DEDUPE_REPOS:-1}" = "1" ] || return 0
    -  if [ -d "$a/.git" ] && [ -d "$b/.git" ] && [ ! -L "$a" ] && [ ! -L "$b" ]; then
    -    ta=$(git -C "$a" log -1 --format=%ct 2>/dev/null || echo 0)
    -    tb=$(git -C "$b" log -1 --format=%ct 2>/dev/null || echo 0)
    -    echo "======== dedupe suite repos (both are git clones) ========"
    -    echo "  $a HEAD_ts=$ta"
    -    echo "  $b HEAD_ts=$tb"
    -    if [ "$ta" -ge "$tb" ]; then
    -      echo "keep $a · remove $b · symlink $b → $a"
    -      rm -rf "$b"
    -      ln -sfn "$a" "$b"
    -    else
    -      echo "keep $b · remove $a · symlink $a → $b"
    -      rm -rf "$a"
    -      mkdir -p "$(dirname "$a")"
    -      ln -sfn "$b" "$a"
    -    fi
    -  elif [ -d "$a/scripts/taler-monitoring" ] && [ -d "$b/scripts/taler-monitoring" ] \
    -    && [ ! -d "$a/.git" ] && [ -d "$b/.git" ] && [ ! -L "$a" ]; then
    -    echo "remove non-git tree $a · symlink → $b"
    -    rm -rf "$a"
    -    mkdir -p "$(dirname "$a")"
    -    ln -sfn "$b" "$a"
    -  elif [ -d "$b/scripts/taler-monitoring" ] && [ -d "$a/scripts/taler-monitoring" ] \
    -    && [ ! -d "$b/.git" ] && [ -d "$a/.git" ] && [ ! -L "$b" ]; then
    -    echo "remove non-git tree $b · symlink → $a"
    -    rm -rf "$b"
    -    ln -sfn "$a" "$b"
    -  fi
    -}
    -
    -cleanup_legacy
    -dedupe_suite_repos
    -# ---------------------------------------------------------------------------
    -# Repo fingerprint: Version: lines from suite Packages index
    -# ---------------------------------------------------------------------------
    -repo_pkg_version() {
    -  local suite=$1 pkg=$2
    -  curl -fsS --max-time 45 \
    -    "${URI_BASE}/dists/${suite}/main/binary-amd64/Packages" 2>/dev/null \
    -    | awk -v p="$pkg" '
    -        $0 == "Package: " p { want=1; next }
    -        /^Package: / { want=0 }
    -        want && /^Version: / { print $2; exit }
    -      '
    -}
    -
    -repo_fingerprint() {
    -  local suite=$1 pkg v line=""
    -  for pkg in $TRACK_PKGS; do
    -    v=$(repo_pkg_version "$suite" "$pkg" || true)
    -    [ -n "$v" ] || v="?"
    -    line="${line}${pkg}=${v};"
    -  done
    -  printf '%s' "$line"
    -}
    -
    -container_fingerprint() {
    -  local name=$1
    -  "$PODMAN_BIN" exec "$name" bash -lc '
    -    for p in '"$TRACK_PKGS"'; do
    -      v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo missing)
    -      printf "%s=%s;" "$p" "$v"
    -    done
    -  ' 2>/dev/null || echo "missing"
    -}
    -
    -# ---------------------------------------------------------------------------
    -# Bootstrap: install base + taler source + packages (shared)
    -# ---------------------------------------------------------------------------
    -install_base_and_merchant() {
    -  local name=$1 suite=$2 srcfile=$3
    -  "$PODMAN_BIN" exec -u root "$name" bash -lc '
    -    set -euo pipefail
    -    export DEBIAN_FRONTEND=noninteractive
    -    apt-get update -qq
    -    apt-get install -y -qq ca-certificates curl wget gnupg systemd systemd-sysv \
    -      dbus procps iproute2
    -  '
    -  "$PODMAN_BIN" exec -u root "$name" bash -lc "
    -    set -euo pipefail
    -    export DEBIAN_FRONTEND=noninteractive
    -    mkdir -p /etc/apt/keyrings
    -    wget -q -O /etc/apt/keyrings/taler-systems.gpg https://taler.net/taler-systems.gpg
    -    cat >/etc/apt/sources.list.d/${srcfile} </dev/null
    -    systemctl enable taler-merchant.target 2>/dev/null
    -    systemctl start taler-merchant.target 2>/dev/null
    -    sleep 2
    -    systemctl is-active taler-merchant-httpd.service 2>/dev/null || true
    -  ' || true
    -}
    -
    -create_from_scratch() {
    -  local name=$1 suite=$2 srcfile=$3
    -  echo "======== CREATE (fresh) $name  Suites: $suite ========"
    -  "$PODMAN_BIN" pull "$IMG" >/dev/null
    -  "$PODMAN_BIN" rm -f "$name" 2>/dev/null || true
    -  # drop previous setup image so we do not accumulate duplicates
    -  "$PODMAN_BIN" rmi -f "localhost/${name}:setup" 2>/dev/null || true
    -  "$PODMAN_BIN" run -d --name "$name" --hostname "$name" "$IMG" sleep infinity
    -  install_base_and_merchant "$name" "$suite" "$srcfile"
    -  commit_and_systemd "$name"
    -  printf '%s\n' "$(repo_fingerprint "$suite")" >"$STATE_DIR/${name}.fp"
    -  printf '%s\n' "$(container_fingerprint "$name")" >"$STATE_DIR/${name}.installed"
    -  echo "created $name fp=$(cat "$STATE_DIR/${name}.fp")"
    -}
    -
    -# ---------------------------------------------------------------------------
    -# Upgrade path (mytops-admin-log upgrade.sh merchant packages, no restic/nginx)
    -# Prints a markdown-ish table of before → after versions.
    -# ---------------------------------------------------------------------------
    -upgrade_inside() {
    -  local name=$1
    -  local before after pkg v0 v1
    -  echo "======== UPGRADE $name (mytops-style apt install + upgrade) ========"
    -
    -  before=$("$PODMAN_BIN" exec "$name" bash -lc '
    -    for p in taler-merchant libtalermerchant libtalerexchange libdonau libgnunet \
    -             taler-merchant-webui taler-merchant-typst taler-terms-generator; do
    -      v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo "-")
    -      printf "%s %s\n" "$p" "$v"
    -    done
    -  ' 2>/dev/null || true)
    -
    -  # capture upgrade output
    -  set +e
    -  "$PODMAN_BIN" exec -u root "$name" bash -lc '
    -    set -euo pipefail
    -    export DEBIAN_FRONTEND=noninteractive
    -    systemctl stop taler-merchant.target 2>/dev/null || true
    -    systemctl stop taler-merchant-donaukeyupdate.service 2>/dev/null || true
    -    apt-get update -qq
    -    apt-get install -y \
    -      taler-merchant \
    -      taler-merchant-typst \
    -      taler-merchant-webui \
    -      taler-terms-generator \
    -      || true
    -    if dpkg -l challenger-httpd 2>/dev/null | grep -q "^ii"; then
    -      apt-get remove -y challenger-httpd || true
    -    fi
    -    apt-get upgrade -y
    -    systemctl daemon-reload 2>/dev/null || true
    -    systemctl enable taler-merchant.target 2>/dev/null || true
    -    # try dbconfig if present (may no-op without full config)
    -    command -v taler-merchant-dbconfig >/dev/null && taler-merchant-dbconfig 2>/dev/null || true
    -    systemctl reset-failed "taler-merchant*" 2>/dev/null || true
    -    systemctl start taler-merchant.target 2>/dev/null || true
    -    sleep 2
    -  ' 2>&1 | tee "$STATE_DIR/${name}.upgrade.log" | tail -30
    -  set -e
    -
    -  after=$("$PODMAN_BIN" exec "$name" bash -lc '
    -    for p in taler-merchant libtalermerchant libtalerexchange libdonau libgnunet \
    -             taler-merchant-webui taler-merchant-typst taler-terms-generator; do
    -      v=$(dpkg-query -W -f="\${Version}" "$p" 2>/dev/null || echo "-")
    -      printf "%s %s\n" "$p" "$v"
    -    done
    -  ' 2>/dev/null || true)
    -
    -  echo
    -  echo "| package | before | after | change |"
    -  echo "|---------|--------|-------|--------|"
    -  while read -r pkg v0; do
    -    [ -n "${pkg:-}" ] || continue
    -    v1=$(printf '%s\n' "$after" | awk -v p="$pkg" '$1==p {print $2; exit}')
    -    v1=${v1:--}
    -    if [ "$v0" = "$v1" ]; then
    -      ch="="
    -    else
    -      ch="UPGRADED"
    -    fi
    -    echo "| $pkg | $v0 | $v1 | $ch |"
    -  done <<<"$before"
    -
    -  printf '%s\n' "$(container_fingerprint "$name")" >"$STATE_DIR/${name}.installed"
    -}
    -
    -# ---------------------------------------------------------------------------
    -# Fresh mode: recreate if missing / not running / repo fp changed
    -# ---------------------------------------------------------------------------
    -ensure_fresh() {
    -  local name=$1 suite=$2 srcfile=$3
    -  local want have running
    -
    -  want=$(repo_fingerprint "$suite")
    -  echo "repo fingerprint [$suite]: $want"
    -
    -  if ! "$PODMAN_BIN" container exists "$name" 2>/dev/null; then
    -    create_from_scratch "$name" "$suite" "$srcfile"
    -    return
    -  fi
    -
    -  running=$("$PODMAN_BIN" inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false)
    -  have=$(container_fingerprint "$name")
    -  echo "installed in $name: $have"
    -
    -  if [ "$running" != "true" ] \
    -    || ! "$PODMAN_BIN" exec "$name" bash -lc 'command -v taler-merchant-httpd' >/dev/null 2>&1 \
    -    || [ "$want" != "$have" ]; then
    -    echo "rebuild $name (running=$running fp_match=$([ "$want" = "$have" ] && echo yes || echo NO))"
    -    create_from_scratch "$name" "$suite" "$srcfile"
    -  else
    -    echo "OK $name up-to-date (fresh container matches repo)"
    -  fi
    -}
    -
    -# ---------------------------------------------------------------------------
    -# Upgrade mode: create once, then always upgrade_inside
    -# ---------------------------------------------------------------------------
    -ensure_upgrade() {
    -  local name=$1 suite=$2 srcfile=$3
    -  local running
    -
    -  if ! "$PODMAN_BIN" container exists "$name" 2>/dev/null; then
    -    create_from_scratch "$name" "$suite" "$srcfile"
    -    echo "(initial install for upgrade-track container $name)"
    -    return
    -  fi
    -
    -  running=$("$PODMAN_BIN" inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false)
    -  if [ "$running" != "true" ]; then
    -    echo "start $name"
    -    "$PODMAN_BIN" start "$name" || {
    -      echo "recreate broken $name"
    -      create_from_scratch "$name" "$suite" "$srcfile"
    -      return
    -    }
    -    sleep 3
    -  fi
    -
    -  if ! "$PODMAN_BIN" exec "$name" bash -lc 'command -v taler-merchant-httpd' >/dev/null 2>&1; then
    -    echo "recreate $name (no httpd after start)"
    -    create_from_scratch "$name" "$suite" "$srcfile"
    -    return
    -  fi
    -
    -  upgrade_inside "$name"
    -}
    -
    -MODE="${1:-all}"  # all | fresh | upgrade
    -
    -case "$MODE" in
    -  all|fresh)
    -    ensure_fresh   koopa-taler-deploy-test-apt-src-trixie                 trixie         taler.sources
    -    ensure_fresh   koopa-taler-deploy-test-apt-src-trixie-testing         trixie-testing taler-testing.sources
    -    ;;&
    -  all|upgrade)
    -    ensure_upgrade koopa-taler-deploy-test-apt-src-trixie-upgrade         trixie         taler.sources
    -    ensure_upgrade koopa-taler-deploy-test-apt-src-trixie-testing-upgrade trixie-testing taler-testing.sources
    -    ;;
    -  *)
    -    echo "usage: $0 [all|fresh|upgrade]" >&2
    -    exit 2
    -    ;;
    -esac
    -
    -echo "done ensure-apt-deploy-test-containers mode=$MODE"
    diff --git a/scripts/taler-monitoring/host-agent/env.example b/scripts/taler-monitoring/host-agent/env.example
    deleted file mode 100644
    index 15f6bae..0000000
    --- a/scripts/taler-monitoring/host-agent/env.example
    +++ /dev/null
    @@ -1,26 +0,0 @@
    -# Copy to:  ~/.config/taler-monitoring/env
    -# This file is NOT inside the git tree and is never overwritten by update-suite.sh.
    -#
    -# Full stack examples (same reporting generation for all):
    -#   env/hacktivism.env.example
    -#   env/stagepaysan.env.example
    -#   env/francpaysan.env.example
    -#
    -# SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git
    -# SUITE_GIT_REF=main
    -# SUITE_DIR=$HOME/src/koopa-admin-log
    -# SUITE_UPDATE_MODE=reset
    -#
    -# TALER_DOMAIN=hacktivism.ch
    -# INSIDE_PODMAN=1
    -# INSIDE_MODE=local-podman
    -# CONTINUE_ON_ERROR=1
    -# PHASES for the *default* hacktivism timer only (urls inside versions).
    -# Dedicated wrappers always override PHASES + HTML paths:
    -#   run-surface-monitoring.sh  → /taler-monitoring-surface*
    -#   run-aptdeploy-monitoring.sh → /taler-monitoring-aptdeploy*
    -# PHASES="urls inside versions"
    -# RUN_TIMEOUT=600
    -# HTML_OUT=$HOME/monitoring-sites-staging
    -# MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch"
    -# SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    diff --git a/scripts/taler-monitoring/host-agent/env/francpaysan.env.example b/scripts/taler-monitoring/host-agent/env/francpaysan.env.example
    deleted file mode 100644
    index ab1c534..0000000
    --- a/scripts/taler-monitoring/host-agent/env/francpaysan.env.example
    +++ /dev/null
    @@ -1,24 +0,0 @@
    -# Copy to:  ~/.config/taler-monitoring/env  (user francpaysan)
    -# Never inside the git tree — update-suite never overwrites this file.
    -#
    -# Shared reporting generation (run-host-report.sh / run-fp-prod-monitoring.sh):
    -#   RUN_TIMEOUT, line-buffered log, always HTML, commit link on git.hacktivism.ch
    -
    -TALER_DOMAIN=lefrancpaysan.ch
    -INSIDE_PODMAN=1
    -INSIDE_MODE=local-podman
    -LOCAL_STACK=0
    -CONTINUE_ON_ERROR=1
    -PHASES="urls inside versions"
    -RUN_TIMEOUT=600
    -
    -HTML_OUT=$HOME/monitoring-sites-staging
    -MON_HOSTS="bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch"
    -
    -SUITE_DIR=$HOME/src/koopa-admin-log
    -SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git
    -SUITE_GIT_REF=main
    -SUITE_UPDATE_MODE=reset
    -SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    -
    -# DEPLOY_WWW_ROOT=/var/www/monitoring-sites
    diff --git a/scripts/taler-monitoring/host-agent/env/hacktivism.env.example b/scripts/taler-monitoring/host-agent/env/hacktivism.env.example
    deleted file mode 100644
    index 6b57280..0000000
    --- a/scripts/taler-monitoring/host-agent/env/hacktivism.env.example
    +++ /dev/null
    @@ -1,24 +0,0 @@
    -# Copy to:  ~/.config/taler-monitoring/env  (user hernani on koopa)
    -# Shared reporting generation: RUN_TIMEOUT, line-buffered log, always HTML.
    -
    -TALER_DOMAIN=hacktivism.ch
    -INSIDE_PODMAN=1
    -INSIDE_MODE=local-podman
    -LOCAL_STACK=1
    -CONTINUE_ON_ERROR=1
    -PHASES="urls inside versions"
    -# aptdeploy HTML: run-aptdeploy-monitoring.sh → /taler-monitoring-aptdeploy*
    -RUN_TIMEOUT=600
    -# APT_DEPLOY_ENSURE=1
    -# APT_DEPLOY_SKIP=0
    -
    -HTML_OUT=$HOME/monitoring-sites-staging
    -MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch"
    -
    -SUITE_DIR=$HOME/src/koopa-admin-log
    -SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git
    -SUITE_GIT_REF=main
    -SUITE_UPDATE_MODE=reset
    -SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    -
    -DEPLOY_WWW_ROOT=/var/www/monitoring-sites
    diff --git a/scripts/taler-monitoring/host-agent/env/stagepaysan.env.example b/scripts/taler-monitoring/host-agent/env/stagepaysan.env.example
    deleted file mode 100644
    index 0959ebc..0000000
    --- a/scripts/taler-monitoring/host-agent/env/stagepaysan.env.example
    +++ /dev/null
    @@ -1,27 +0,0 @@
    -# Copy to:  ~/.config/taler-monitoring/env  (user stagepaysan)
    -# Never inside the git tree — update-suite never overwrites this file.
    -#
    -# Shared reporting generation (run-host-report.sh / run-fp-stage-monitoring.sh):
    -#   RUN_TIMEOUT, line-buffered log, always HTML, commit link on git.hacktivism.ch
    -
    -TALER_DOMAIN=stage.lefrancpaysan.ch
    -INSIDE_PODMAN=1
    -INSIDE_MODE=local-podman
    -INSIDE_PROFILE=stage-lfp
    -LOCAL_STACK=0
    -CONTINUE_ON_ERROR=1
    -PHASES="urls inside versions"
    -RUN_TIMEOUT=600
    -
    -HTML_OUT=$HOME/monitoring-sites-staging
    -MON_HOSTS="stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch"
    -
    -# Git clone of koopa-admin-log (repo root, not …/scripts/taler-monitoring)
    -SUITE_DIR=$HOME/src/koopa-admin-log
    -SUITE_GIT_URL=https://git.hacktivism.ch/hernani/koopa-admin-log.git
    -SUITE_GIT_REF=main
    -SUITE_UPDATE_MODE=reset
    -SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    -
    -# Optional public docroot if this user can write it
    -# DEPLOY_WWW_ROOT=/var/www/monitoring-sites
    diff --git a/scripts/taler-monitoring/host-agent/install-host-agent.sh b/scripts/taler-monitoring/host-agent/install-host-agent.sh
    deleted file mode 100755
    index adcce66..0000000
    --- a/scripts/taler-monitoring/host-agent/install-host-agent.sh
    +++ /dev/null
    @@ -1,107 +0,0 @@
    -#!/usr/bin/env bash
    -# install-host-agent.sh — install hernani user systemd units on koopa
    -#
    -#   # on koopa as hernani:
    -#   ~/koopa-admin-log/scripts/taler-monitoring/host-agent/install-host-agent.sh
    -#   # or from laptop:
    -#   ./install-host-agent.sh --remote koopa-external
    -#
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -REMOTE="${1:-}"
    -[ "${1:-}" = "--remote" ] && REMOTE="${2:-koopa-external}"
    -
    -install_local() {
    -  local mon home_unit
    -  mon=$(cd "$ROOT/.." && pwd)
    -  # Prefer ~/koopa-admin-log on live host
    -  if [ -d "$HOME/koopa-admin-log/scripts/taler-monitoring" ]; then
    -    mon="$HOME/koopa-admin-log/scripts/taler-monitoring"
    -  fi
    -  home_unit="$HOME/.config/systemd/user"
    -  mkdir -p "$home_unit" \
    -    "$HOME/.local/state/taler-hacktivism-monitoring" \
    -    "$HOME/monitoring-sites-staging" \
    -    "$HOME/.config/taler-monitoring" \
    -    "$HOME/src"
    -
    -  # Local env (never in git) — create from example if missing
    -  if [ ! -f "$HOME/.config/taler-monitoring/env" ] && [ -f "$ROOT/env.example" ]; then
    -    cp "$ROOT/env.example" "$HOME/.config/taler-monitoring/env"
    -    echo "created ~/.config/taler-monitoring/env"
    -  fi
    -
    -  # Prefer a real git clone for update-suite.sh
    -  if [ ! -d "$HOME/src/koopa-admin-log/.git" ] && [ ! -d "$HOME/koopa-admin-log/.git" ]; then
    -    if [ -d "$HOME/koopa-admin-log/scripts/taler-monitoring" ] && [ ! -d "$HOME/koopa-admin-log/.git" ]; then
    -      echo "note: $HOME/koopa-admin-log has no .git — update-suite will clone to ~/src/koopa-admin-log"
    -    fi
    -    git clone https://git.hacktivism.ch/hernani/koopa-admin-log.git "$HOME/src/koopa-admin-log" 2>/dev/null \
    -      || echo "WARN: clone failed (auth/network) — will use existing tree if any"
    -  fi
    -
    -  # Ensure suite is executable
    -  chmod +x "$mon"/taler-monitoring.sh "$mon"/check_*.sh 2>/dev/null || true
    -  chmod +x "$mon/host-agent"/*.sh 2>/dev/null || true
    -  chmod +x "$mon/site-gen"/*.sh "$mon/site-gen/console_to_html.py" 2>/dev/null || true
    -
    -  # Units reference %h/koopa-admin-log — prefer git clone or symlink
    -  if [ -d "$HOME/src/koopa-admin-log/.git" ] && [ ! -e "$HOME/koopa-admin-log" ]; then
    -    ln -sfn "$HOME/src/koopa-admin-log" "$HOME/koopa-admin-log"
    -    echo "symlink $HOME/koopa-admin-log → $HOME/src/koopa-admin-log"
    -  elif [ ! -e "$HOME/koopa-admin-log" ]; then
    -    admin_root=$(cd "$mon/../.." && pwd)
    -    ln -sfn "$admin_root" "$HOME/koopa-admin-log"
    -    echo "symlink $HOME/koopa-admin-log → $admin_root"
    -  fi
    -
    -  install -m 644 "$ROOT/taler-monitoring-hacktivism.service" "$home_unit/"
    -  install -m 644 "$ROOT/taler-monitoring-hacktivism.path" "$home_unit/"
    -  install -m 644 "$ROOT/taler-monitoring-hacktivism.timer" "$home_unit/"
    -  install -m 644 "$ROOT/taler-monitoring-surface.service" "$home_unit/"
    -  install -m 644 "$ROOT/taler-monitoring-surface.timer" "$home_unit/"
    -  install -m 644 "$ROOT/taler-monitoring-aptdeploy.service" "$home_unit/"
    -  install -m 644 "$ROOT/taler-monitoring-aptdeploy.timer" "$home_unit/"
    -
    -  # Fix ExecStart if install path differs (units use %h/koopa-admin-log)
    -  systemctl --user daemon-reload
    -  systemctl --user enable --now taler-monitoring-hacktivism.path
    -  systemctl --user enable --now taler-monitoring-hacktivism.timer
    -  systemctl --user enable --now taler-monitoring-surface.timer
    -  systemctl --user enable --now taler-monitoring-aptdeploy.timer
    -  # initial stamp so path exists
    -  "$ROOT/touch-software-stamp.sh"
    -  systemctl --user start taler-monitoring-hacktivism.service || true
    -  systemctl --user start taler-monitoring-surface.service || true
    -  systemctl --user start taler-monitoring-aptdeploy.service || true
    -
    -  echo "--- status ---"
    -  systemctl --user status taler-monitoring-hacktivism.path --no-pager -l | head -15
    -  systemctl --user status taler-monitoring-hacktivism.timer --no-pager -l | head -12
    -  systemctl --user status taler-monitoring-surface.timer --no-pager -l | head -12
    -  systemctl --user status taler-monitoring-aptdeploy.timer --no-pager -l | head -12
    -  systemctl --user list-timers --all | grep taler-monitoring || true
    -  echo "OK host-agent (hacktivism 4h + surface 1h + aptdeploy 4h)"
    -  echo "manual: systemctl --user start taler-monitoring-hacktivism.service"
    -  echo "manual surface: systemctl --user start taler-monitoring-surface.service"
    -  echo "manual aptdeploy: systemctl --user start taler-monitoring-aptdeploy.service"
    -  echo "after upgrades: $ROOT/touch-software-stamp.sh"
    -}
    -
    -if [ -n "$REMOTE" ] && [ "$REMOTE" != "--remote" ]; then
    -  echo "install on $REMOTE …"
    -  rsync -az --delete \
    -    --exclude secrets.env \
    -    --exclude 'android-test/artifacts' \
    -    "$ROOT/../../" \
    -    "${REMOTE}:koopa-admin-log/scripts/taler-monitoring/"
    -  # ensure full tree for %h/koopa-admin-log if missing configs etc.
    -  ssh -o BatchMode=yes -o ConnectTimeout=20 "$REMOTE" \
    -    'test -d $HOME/koopa-admin-log || git clone https://git.hacktivism.ch/hernani/koopa-admin-log.git $HOME/koopa-admin-log 2>/dev/null || true'
    -  rsync -az "$ROOT/" "${REMOTE}:koopa-admin-log/scripts/taler-monitoring/host-agent/"
    -  rsync -az "$ROOT/../site-gen/" "${REMOTE}:koopa-admin-log/scripts/taler-monitoring/site-gen/" 2>/dev/null || true
    -  ssh -o BatchMode=yes "$REMOTE" \
    -    'bash $HOME/koopa-admin-log/scripts/taler-monitoring/host-agent/install-host-agent.sh'
    -else
    -  install_local
    -fi
    diff --git a/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh b/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh
    deleted file mode 100755
    index fe81257..0000000
    --- a/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh
    +++ /dev/null
    @@ -1,43 +0,0 @@
    -#!/usr/bin/env bash
    -# run-aptdeploy-monitoring.sh — apt-src deploy tests → public HTML
    -# Public only on taler.hacktivism.ch:
    -#   https://taler.hacktivism.ch/taler-monitoring-aptdeploy/
    -#   https://taler.hacktivism.ch/taler-monitoring-aptdeploy_err/
    -#
    -# CLI one-shot without HTML remains: run-aptdeploy.sh [all|fresh|upgrade|check-only]
    -#
    -set -uo pipefail
    -AGENT_DIR=$(cd "$(dirname "$0")" && pwd)
    -
    -export AGENT_LABEL="${AGENT_LABEL:-aptdeploy-host-agent}"
    -export STATE_NAME="${STATE_NAME:-taler-aptdeploy-monitoring}"
    -export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}"
    -# containers are local on koopa
    -export INSIDE_PODMAN=1
    -export INSIDE_MODE=local-podman
    -export LOCAL_STACK=1
    -export SKIP_SSH=0
    -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}"
    -# four containers + checks; allow long wall
    -export RUN_TIMEOUT="${RUN_TIMEOUT:-1800}"
    -# dedicated job: always aptdeploy only
    -export PHASES=aptdeploy
    -# Only merchant public front — not bank/exchange
    -export MON_HOSTS="${MON_HOSTS:-taler.hacktivism.ch}"
    -export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}"
    -export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}"
    -
    -export HTML_OK_DIR="taler-monitoring-aptdeploy"
    -export HTML_ERR_DIR="taler-monitoring-aptdeploy_err"
    -export HTML_URL_OK="/taler-monitoring-aptdeploy/"
    -export HTML_URL_ERR="/taler-monitoring-aptdeploy_err/"
    -export PAGE_LABEL="taler-monitoring-aptdeploy"
    -
    -# Ensure all four apt-src containers (fresh rebuild + upgrade track)
    -if [ "${APT_DEPLOY_ENSURE:-1}" = "1" ] \
    -  && [ -x "$AGENT_DIR/ensure-apt-deploy-test-containers.sh" ]; then
    -  bash "$AGENT_DIR/ensure-apt-deploy-test-containers.sh" all \
    -    || echo "WARN: ensure-apt-deploy-test-containers failed (aptdeploy may ERROR)" >&2
    -fi
    -
    -exec bash "$AGENT_DIR/run-host-report.sh" "$@"
    diff --git a/scripts/taler-monitoring/host-agent/run-aptdeploy.sh b/scripts/taler-monitoring/host-agent/run-aptdeploy.sh
    deleted file mode 100755
    index fbf5afd..0000000
    --- a/scripts/taler-monitoring/host-agent/run-aptdeploy.sh
    +++ /dev/null
    @@ -1,150 +0,0 @@
    -#!/usr/bin/env bash
    -# run-aptdeploy.sh — ensure containers + aptdeploy phase only.
    -#
    -# Keep monitoring current:
    -#   1) Prefer Forgejo origin/main (update-suite reset)
    -#   2) If Forgejo is behind (no aptdeploy yet), re-apply suite-overlay saved
    -#      from this agent tree before reset (rsync from laptop fills that tree)
    -#   3) Run ensure + taler-monitoring.sh aptdeploy
    -#
    -# Host: on koopa → local; else ssh APT_DEPLOY_SSH + rsync mon tree first.
    -#
    -set -euo pipefail
    -
    -AGENT_DIR=$(cd "$(dirname "$0")" && pwd)
    -BOOT_MON=$(cd "$AGENT_DIR/.." && pwd)
    -MODE="${1:-all}"
    -APT_DEPLOY_SSH="${APT_DEPLOY_SSH:-koopa-external}"
    -OVERLAY_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/suite-overlay"
    -
    -on_koopa() {
    -  local h
    -  h=$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo "")
    -  case "$h" in koopa|koopa.*) return 0 ;; esac
    -  [ "${KOOPA_FORCE_LOCAL:-0}" = "1" ] && return 0
    -  return 1
    -}
    -
    -mon_has_aptdeploy() {
    -  local mon=$1
    -  [ -f "$mon/taler-monitoring.sh" ] && grep -qE 'aptdeploy\|apt-deploy' "$mon/taler-monitoring.sh"
    -}
    -
    -save_overlay_from() {
    -  local src=$1
    -  mon_has_aptdeploy "$src" || return 0
    -  mkdir -p "$OVERLAY_DIR"
    -  rsync -a --delete \
    -    --exclude secrets.env \
    -    --exclude 'android-test/artifacts' \
    -    --exclude 'android-test/apks' \
    -    --exclude 'android-test/out*' \
    -    --exclude '__pycache__' \
    -    "$src/" "$OVERLAY_DIR/"
    -  echo "saved suite-overlay from $src → $OVERLAY_DIR"
    -}
    -
    -apply_overlay_to() {
    -  local dest=$1
    -  [ -f "$OVERLAY_DIR/taler-monitoring.sh" ] || return 1
    -  mon_has_aptdeploy "$OVERLAY_DIR" || return 1
    -  mkdir -p "$dest"
    -  rsync -a \
    -    --exclude secrets.env \
    -    --exclude 'android-test/artifacts' \
    -    --exclude 'android-test/apks' \
    -    --exclude 'android-test/out*' \
    -    --exclude '__pycache__' \
    -    "$OVERLAY_DIR/" "$dest/"
    -  echo "applied suite-overlay → $dest (Forgejo lag; push origin/main when possible)"
    -  return 0
    -}
    -
    -pick_mon_tree() {
    -  # Preserve working mon before reset wipes tracked files
    -  save_overlay_from "$BOOT_MON"
    -
    -  # shellcheck source=update-suite.sh
    -  source "$AGENT_DIR/update-suite.sh"
    -
    -  local suite_mon="$SUITE_DIR/scripts/taler-monitoring"
    -  mkdir -p "$suite_mon"
    -
    -  if mon_has_aptdeploy "$suite_mon"; then
    -    MON_DIR="$suite_mon"
    -    echo "using Forgejo suite mon @ ${COMMIT_SHORT:-?} · $MON_DIR"
    -  elif apply_overlay_to "$suite_mon" && mon_has_aptdeploy "$suite_mon"; then
    -    MON_DIR="$suite_mon"
    -    echo "using suite mon + overlay @ ${COMMIT_SHORT:-?} · $MON_DIR"
    -  elif mon_has_aptdeploy "$BOOT_MON"; then
    -    MON_DIR="$BOOT_MON"
    -    echo "WARN: using boot mon $MON_DIR (overlay apply failed)" >&2
    -  else
    -    echo "ERROR: no mon tree with aptdeploy (Forgejo + overlay empty)" >&2
    -    echo "  rsync suite from laptop or push to git.hacktivism.ch" >&2
    -    exit 2
    -  fi
    -
    -  AGENT_DIR="$MON_DIR/host-agent"
    -  export MON_DIR AGENT_DIR SUITE_DIR COMMIT COMMIT_SHORT COMMIT_URL
    -  chmod +x "$AGENT_DIR"/*.sh "$MON_DIR"/taler-monitoring.sh "$MON_DIR"/check_*.sh 2>/dev/null || true
    -}
    -
    -run_local() {
    -  export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"
    -  export PYTHONUNBUFFERED=1
    -  export PROGRESS_OFF="${PROGRESS_OFF:-0}"
    -
    -  echo "======== aptdeploy local on $(hostname) mode=$MODE ========"
    -  pick_mon_tree
    -
    -  if [ "$MODE" != "check-only" ]; then
    -    bash "$AGENT_DIR/ensure-apt-deploy-test-containers.sh" "$MODE"
    -  fi
    -
    -  cd "$MON_DIR"
    -  export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}"
    -  export INSIDE_PODMAN="${INSIDE_PODMAN:-1}"
    -  export LOCAL_STACK="${LOCAL_STACK:-1}"
    -  export RUN_TIMEOUT="${RUN_TIMEOUT:-600}"
    -  echo "run: $MON_DIR/taler-monitoring.sh -d hacktivism.ch aptdeploy · suite=${COMMIT_SHORT:-?}"
    -  ./taler-monitoring.sh -d hacktivism.ch aptdeploy
    -}
    -
    -run_remote() {
    -  echo "======== aptdeploy remote via ssh ${APT_DEPLOY_SSH} mode=$MODE ========"
    -  if [ "${APT_DEPLOY_RSYNC:-1}" = "1" ]; then
    -    echo "rsync mon suite → ${APT_DEPLOY_SSH}:src/koopa-admin-log/scripts/taler-monitoring/"
    -    rsync -az \
    -      --exclude secrets.env \
    -      --exclude 'android-test/artifacts' \
    -      --exclude 'android-test/apks' \
    -      --exclude 'android-test/out*' \
    -      --exclude '__pycache__' \
    -      "$BOOT_MON/" \
    -      "${APT_DEPLOY_SSH}:src/koopa-admin-log/scripts/taler-monitoring/" \
    -      || echo "WARN: rsync failed" >&2
    -  fi
    -  ssh -o BatchMode=yes -o ConnectTimeout=25 "$APT_DEPLOY_SSH" bash -s -- "$MODE" <<'EOS'
    -set -euo pipefail
    -MODE=${1:-all}
    -export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"
    -export KOOPA_FORCE_LOCAL=1
    -AGENT="$HOME/src/koopa-admin-log/scripts/taler-monitoring/host-agent"
    -if [ ! -x "$AGENT/run-aptdeploy.sh" ]; then
    -  AGENT="$HOME/koopa-admin-log/scripts/taler-monitoring/host-agent"
    -fi
    -if [ ! -x "$AGENT/run-aptdeploy.sh" ]; then
    -  echo "ERROR: run-aptdeploy.sh missing on remote" >&2
    -  exit 1
    -fi
    -echo "remote AGENT=$AGENT"
    -exec bash "$AGENT/run-aptdeploy.sh" "$MODE"
    -EOS
    -}
    -
    -if on_koopa; then
    -  run_local
    -else
    -  run_remote
    -fi
    diff --git a/scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh b/scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh
    deleted file mode 100755
    index be876a8..0000000
    --- a/scripts/taler-monitoring/host-agent/run-fp-prod-monitoring.sh
    +++ /dev/null
    @@ -1,20 +0,0 @@
    -#!/usr/bin/env bash
    -# FrancPaysan PROD host-agent (user francpaysan on francpaysan-host).
    -# Shared reporting: run-host-report.sh (timeout, line-buffered log, first-run HTML).
    -set -uo pipefail
    -AGENT_DIR=$(cd "$(dirname "$0")" && pwd)
    -
    -export AGENT_LABEL="${AGENT_LABEL:-fp-prod-host-agent}"
    -export STATE_NAME="${STATE_NAME:-taler-monitoring-lfp-prod}"
    -export TALER_DOMAIN="${TALER_DOMAIN:-lefrancpaysan.ch}"
    -export INSIDE_PODMAN="${INSIDE_PODMAN:-1}"
    -export INSIDE_MODE="${INSIDE_MODE:-local-podman}"
    -export LOCAL_STACK="${LOCAL_STACK:-0}"
    -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}"
    -export RUN_TIMEOUT="${RUN_TIMEOUT:-600}"
    -export PHASES="${PHASES:-urls inside versions}"
    -export MON_HOSTS="${MON_HOSTS:-bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch}"
    -export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}"
    -export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}"
    -
    -exec bash "$AGENT_DIR/run-host-report.sh" "$@"
    diff --git a/scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh b/scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh
    deleted file mode 100755
    index 70518f0..0000000
    --- a/scripts/taler-monitoring/host-agent/run-fp-stage-monitoring.sh
    +++ /dev/null
    @@ -1,21 +0,0 @@
    -#!/usr/bin/env bash
    -# FrancPaysan STAGE host-agent (user stagepaysan on francpaysan-host).
    -# Shared reporting: run-host-report.sh (timeout, line-buffered log, first-run HTML).
    -set -uo pipefail
    -AGENT_DIR=$(cd "$(dirname "$0")" && pwd)
    -
    -export AGENT_LABEL="${AGENT_LABEL:-fp-stage-host-agent}"
    -export STATE_NAME="${STATE_NAME:-taler-monitoring-stage-lfp}"
    -export TALER_DOMAIN="${TALER_DOMAIN:-stage.lefrancpaysan.ch}"
    -export INSIDE_PODMAN="${INSIDE_PODMAN:-1}"
    -export INSIDE_MODE="${INSIDE_MODE:-local-podman}"
    -export INSIDE_PROFILE="${INSIDE_PROFILE:-stage-lfp}"
    -export LOCAL_STACK="${LOCAL_STACK:-0}"
    -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}"
    -export RUN_TIMEOUT="${RUN_TIMEOUT:-600}"
    -export PHASES="${PHASES:-urls inside versions}"
    -export MON_HOSTS="${MON_HOSTS:-stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch}"
    -export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}"
    -export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}"
    -
    -exec bash "$AGENT_DIR/run-host-report.sh" "$@"
    diff --git a/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh b/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh
    deleted file mode 100755
    index 8fd98fd..0000000
    --- a/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh
    +++ /dev/null
    @@ -1,23 +0,0 @@
    -#!/usr/bin/env bash
    -# GOA / hacktivism host-agent on koopa (hernani).
    -# Stack defaults only — reporting pipeline is shared: run-host-report.sh
    -set -uo pipefail
    -AGENT_DIR=$(cd "$(dirname "$0")" && pwd)
    -
    -export AGENT_LABEL="${AGENT_LABEL:-hacktivism-host-agent}"
    -export STATE_NAME="${STATE_NAME:-taler-hacktivism-monitoring}"
    -export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}"
    -export INSIDE_PODMAN="${INSIDE_PODMAN:-1}"
    -export INSIDE_MODE="${INSIDE_MODE:-local-podman}"
    -export LOCAL_STACK="${LOCAL_STACK:-1}"
    -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}"
    -export RUN_TIMEOUT="${RUN_TIMEOUT:-600}"
    -# stack checks only — aptdeploy has its own HTML path + timer:
    -#   run-aptdeploy-monitoring.sh → /taler-monitoring-aptdeploy* (taler.hacktivism.ch)
    -export PHASES="${PHASES:-urls inside versions}"
    -export MON_HOSTS="${MON_HOSTS:-bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch}"
    -export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}"
    -export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}"
    -export APT_DEPLOY_ENSURE=0
    -
    -exec bash "$AGENT_DIR/run-host-report.sh" "$@"
    diff --git a/scripts/taler-monitoring/host-agent/run-host-report.sh b/scripts/taler-monitoring/host-agent/run-host-report.sh
    deleted file mode 100755
    index 1be1def..0000000
    --- a/scripts/taler-monitoring/host-agent/run-host-report.sh
    +++ /dev/null
    @@ -1,358 +0,0 @@
    -#!/usr/bin/env bash
    -# run-host-report.sh — global reporting generation for all stacks
    -# (GOA/hacktivism, FP stage, FP prod, …).
    -#
    -# Shared behaviour (always):
    -#   1) update-suite → latest git.hacktivism.ch (local ~/.config never overwritten)
    -#   2) line-buffered run log (written continuously)
    -#   3) RUN_TIMEOUT wall clock for taler-monitoring.sh (default 600s)
    -#   4) always write HTML (first run / empty staging too) + Forgejo commit link
    -#   5) optional rsync to DEPLOY_WWW_ROOT if writable
    -#
    -# Configure via env file (not in git):
    -#   ${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/env
    -# or TALER_MONITORING_ENV=/path/to/env
    -#
    -# Thin wrappers only set stack defaults then exec this script:
    -#   run-hacktivism-monitoring.sh
    -#   run-fp-stage-monitoring.sh
    -#   run-fp-prod-monitoring.sh
    -#
    -set -uo pipefail
    -
    -export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"
    -export PYTHONUNBUFFERED=1
    -
    -AGENT_DIR=$(cd "$(dirname "$0")" && pwd)
    -AGENT_MON=$(cd "$AGENT_DIR/.." && pwd)
    -CFG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring"
    -ENV_FILE="${TALER_MONITORING_ENV:-$CFG_DIR/env}"
    -mkdir -p "$CFG_DIR"
    -
    -# Thin wrappers (surface, aptdeploy-only, …) export stack knobs before exec.
    -# Snapshot them so ~/.config/taler-monitoring/env cannot clobber dedicated jobs
    -# (env often has PHASES="urls inside versions aptdeploy" for the main timer).
    -_wrap_keys=(
    -  PHASES HTML_OUT HTML_OK_DIR HTML_ERR_DIR HTML_URL_OK HTML_URL_ERR PAGE_LABEL
    -  RUN_TIMEOUT STATE_NAME AGENT_LABEL MON_HOSTS TALER_DOMAIN DEPLOY_WWW_ROOT
    -  INSIDE_PODMAN INSIDE_MODE LOCAL_STACK SKIP_SSH CONTINUE_ON_ERROR AUTH401_CONTINUE
    -  APT_DEPLOY_ENSURE SURFACE_SCOPE TALER_DOMAIN_FROM_CLI STRICT_EXIT SOURCE_SUITE_PATH
    -)
    -for _k in "${_wrap_keys[@]}"; do
    -  if [ -n "${!_k+x}" ]; then
    -    printf -v "_WRAP_SET_${_k}" '%s' 1
    -    printf -v "_WRAP_VAL_${_k}" '%s' "${!_k}"
    -  fi
    -done
    -unset _k
    -
    -if [ -f "$ENV_FILE" ]; then
    -  # shellcheck disable=SC1090
    -  set -a
    -  # shellcheck source=/dev/null
    -  source "$ENV_FILE"
    -  set +a
    -fi
    -# Tell update-suite.sh not to re-source env (would clobber wrapper PHASES etc.)
    -export _TALER_MON_ENV_LOADED=1
    -
    -# Restore wrapper overrides (dedicated jobs win over shared env file)
    -for _k in "${_wrap_keys[@]}"; do
    -  _set_var="_WRAP_SET_${_k}"
    -  _val_var="_WRAP_VAL_${_k}"
    -  if [ "${!_set_var:-}" = "1" ]; then
    -    printf -v "$_k" '%s' "${!_val_var}"
    -    export "$_k"
    -  fi
    -done
    -unset _k _set_var _val_var
    -
    -# --- global report defaults (env file / wrapper wins if already set) ---
    -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}"
    -export AUTH401_CONTINUE="${AUTH401_CONTINUE:-${CONTINUE_ON_ERROR}}"
    -export RUN_TIMEOUT="${RUN_TIMEOUT:-600}"
    -export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}"
    -PHASES="${PHASES:-urls inside versions}"
    -HTML_BASE="${HTML_OUT:-$HOME/monitoring-sites-staging}"
    -MON_HOSTS="${MON_HOSTS:-}"
    -SUITE_PATH="${SOURCE_SUITE_PATH:-scripts/taler-monitoring}"
    -AGENT_LABEL="${AGENT_LABEL:-host-report}"
    -STATE_NAME="${STATE_NAME:-taler-monitoring-${TALER_DOMAIN}}"
    -STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/${STATE_NAME}"
    -LOG_DIR="$STATE_DIR/logs"
    -DEPLOY_WWW="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}"
    -# HTML path layout under each host (monitoring / surface / …)
    -HTML_OK_DIR="${HTML_OK_DIR:-monitoring}"
    -HTML_ERR_DIR="${HTML_ERR_DIR:-monitoring_err}"
    -HTML_URL_OK="${HTML_URL_OK:-/monitoring/}"
    -HTML_URL_ERR="${HTML_URL_ERR:-/monitoring_err/}"
    -PAGE_LABEL="${PAGE_LABEL:-monitoring}"
    -
    -# Sensible MON_HOSTS from domain if not set
    -if [ -z "$MON_HOSTS" ]; then
    -  case "$TALER_DOMAIN" in
    -    hacktivism.ch|koopa)
    -      MON_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch"
    -      ;;
    -    stage.lefrancpaysan.ch|stage.*lefrancpaysan*)
    -      MON_HOSTS="stage.bank.lefrancpaysan.ch stage.exchange.lefrancpaysan.ch stage.monnaie.lefrancpaysan.ch"
    -      ;;
    -    lefrancpaysan.ch)
    -      MON_HOSTS="bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch"
    -      ;;
    -    *)
    -      MON_HOSTS="${TALER_DOMAIN}"
    -      ;;
    -  esac
    -fi
    -
    -mkdir -p "$LOG_DIR" "$HTML_BASE"
    -
    -STAMP=$(date +%Y%m%d-%H%M%S)
    -LOG="$LOG_DIR/run-${STAMP}.log"
    -: >"$LOG"
    -if command -v stdbuf >/dev/null 2>&1; then
    -  exec > >(stdbuf -oL -eL tee -a "$LOG") 2>&1
    -else
    -  exec > >(tee -a "$LOG") 2>&1
    -fi
    -
    -echo "======== $(date -Iseconds 2>/dev/null || date) ${AGENT_LABEL} ========"
    -echo "env_file=$ENV_FILE"
    -echo "domain=$TALER_DOMAIN · phases=$PHASES · RUN_TIMEOUT=${RUN_TIMEOUT}s"
    -echo "hosts=$MON_HOSTS"
    -echo "flags INSIDE_PODMAN=${INSIDE_PODMAN:-} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-} SKIP_SSH=${SKIP_SSH:-} INSIDE_PROFILE=${INSIDE_PROFILE:-}"
    -
    -# --- refresh from Forgejo; re-apply suite-overlay if Forgejo lacks needed phases ---
    -OVERLAY_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/suite-overlay"
    -# preserve capable mon before reset wipes tracked files
    -if [ -f "$AGENT_MON/taler-monitoring.sh" ]; then
    -  mkdir -p "$OVERLAY_DIR"
    -  rsync -a \
    -    --exclude secrets.env --exclude 'android-test/artifacts' --exclude 'android-test/apks' \
    -    --exclude 'android-test/out*' --exclude '__pycache__' \
    -    "$AGENT_MON/" "$OVERLAY_DIR/" 2>/dev/null || true
    -fi
    -
    -# shellcheck source=update-suite.sh
    -source "$AGENT_DIR/update-suite.sh"
    -
    -SUITE_MON="${SUITE_DIR}/scripts/taler-monitoring"
    -ROOT="$SUITE_MON"
    -_need_overlay=0
    -# Detect missing features in Forgejo tree (untracked helper files alone are not enough —
    -# taler-monitoring.sh must accept the phase name).
    -case " $PHASES " in
    -  *" aptdeploy "*|*" apt-deploy "*|*" apt_src "*)
    -    if ! grep -qE 'aptdeploy\|apt-deploy' "$SUITE_MON/taler-monitoring.sh" 2>/dev/null; then
    -      _need_overlay=1
    -    fi
    -    ;;
    -esac
    -case " $PHASES " in
    -  *" surface "*|*" ecosystem "*)
    -    if ! grep -qE 'surface\|ecosystem' "$SUITE_MON/taler-monitoring.sh" 2>/dev/null \
    -      || [ ! -f "$SUITE_MON/check_surface.sh" ]; then
    -      _need_overlay=1
    -    fi
    -    ;;
    -esac
    -# Wrapper-preserve + HTML path knobs may live only in local overlay until pushed
    -if [ -f "$OVERLAY_DIR/host-agent/run-host-report.sh" ] \
    -  && ! grep -q '_wrap_keys\|_WRAP_SET_' "$SUITE_MON/host-agent/run-host-report.sh" 2>/dev/null \
    -  && grep -q '_wrap_keys\|_WRAP_SET_' "$OVERLAY_DIR/host-agent/run-host-report.sh" 2>/dev/null; then
    -  _need_overlay=1
    -fi
    -if [ "$_need_overlay" = "1" ] && [ -f "$OVERLAY_DIR/taler-monitoring.sh" ]; then
    -  echo "WARN: Forgejo @ ${COMMIT_SHORT:-?} lagging local suite-overlay (phases=[$PHASES])" >&2
    -  echo "      re-applying suite-overlay → $SUITE_MON" >&2
    -  mkdir -p "$SUITE_MON"
    -  rsync -a \
    -    --exclude secrets.env --exclude 'android-test/artifacts' --exclude 'android-test/apks' \
    -    --exclude 'android-test/out*' --exclude '__pycache__' \
    -    "$OVERLAY_DIR/" "$SUITE_MON/"
    -  ROOT="$SUITE_MON"
    -elif [ "$_need_overlay" = "1" ]; then
    -  echo "WARN: Forgejo missing features and no suite-overlay — using agent mon $AGENT_MON" >&2
    -  ROOT="$AGENT_MON"
    -else
    -  echo "using suite mon @ ${COMMIT_SHORT:-?} · $ROOT"
    -fi
    -# Always restore host-agent + site-gen from pre-reset snapshot when present
    -# (systemd ExecStart points at koopa-admin-log which may be the reset suite tree).
    -if [ -d "$OVERLAY_DIR/host-agent" ]; then
    -  mkdir -p "$SUITE_MON/host-agent" 2>/dev/null || true
    -  rsync -a "$OVERLAY_DIR/host-agent/" "$SUITE_MON/host-agent/" 2>/dev/null || true
    -fi
    -if [ -d "$OVERLAY_DIR/site-gen" ]; then
    -  mkdir -p "$SUITE_MON/site-gen"
    -  rsync -a "$OVERLAY_DIR/site-gen/" "$SUITE_MON/site-gen/" 2>/dev/null || true
    -fi
    -unset _need_overlay
    -
    -if [ -f "$ROOT/site-gen/console_to_html.py" ]; then
    -  SITE_GEN="$ROOT/site-gen"
    -elif [ -f "$AGENT_MON/site-gen/console_to_html.py" ]; then
    -  SITE_GEN="$AGENT_MON/site-gen"
    -  echo "note: using agent site-gen at $SITE_GEN"
    -elif [ -f "$SUITE_MON/site-gen/console_to_html.py" ]; then
    -  SITE_GEN="$SUITE_MON/site-gen"
    -else
    -  SITE_GEN=""
    -fi
    -
    -if [ ! -x "$ROOT/taler-monitoring.sh" ]; then
    -  if [ -x "$AGENT_MON/taler-monitoring.sh" ]; then
    -    ROOT="$AGENT_MON"
    -    echo "note: using agent mon tree $ROOT"
    -  else
    -    echo "ERROR: suite missing at $ROOT" >&2
    -    COMMIT="${COMMIT:-unknown}"
    -    COMMIT_SHORT="${COMMIT_SHORT:-unknown}"
    -    COMMIT_URL="${COMMIT_URL:-}"
    -  fi
    -fi
    -
    -echo "commit=${COMMIT_SHORT:-?} · ${COMMIT_URL:-}"
    -
    -ec=1
    -if [ -x "${ROOT:-}/taler-monitoring.sh" ]; then
    -  cd "$ROOT" || exit 1
    -  chmod +x taler-monitoring.sh check_*.sh host-agent/*.sh site-gen/*.sh site-gen/*.py 2>/dev/null || true
    -
    -  # Overlay newer inside/versions host-podman bits if suite clone is stale
    -  for f in check_inside.sh check_versions.sh taler-monitoring.sh lib.sh; do
    -    if [ -f "$AGENT_MON/$f" ] && [ -f "$ROOT/$f" ]; then
    -      if ! grep -q 'host-podman\|INSIDE_ACCESS\|RUN_TIMEOUT' "$ROOT/$f" 2>/dev/null \
    -        && grep -q 'host-podman\|INSIDE_ACCESS\|RUN_TIMEOUT' "$AGENT_MON/$f" 2>/dev/null; then
    -        echo "note: overlay $f from agent install"
    -        cp -f "$AGENT_MON/$f" "$ROOT/$f"
    -        chmod +x "$ROOT/$f" 2>/dev/null || true
    -      fi
    -    fi
    -  done
    -
    -  # shellcheck disable=SC2086
    -  ./taler-monitoring.sh -d "$TALER_DOMAIN" $PHASES
    -  ec=$?
    -fi
    -
    -# --- always generate HTML (first run / missing pages / every run) ---
    -write_bootstrap_html() {
    -  local out="$1" host="$2" mode="$3"
    -  mkdir -p "$(dirname "$out")"
    -  local cshort="${COMMIT_SHORT:-unknown}"
    -  local curl="${COMMIT_URL:-}"
    -  local clink="$cshort"
    -  local gen iso
    -  gen=$(date -u +"%Y-%m-%d %H:%M:%SZ" 2>/dev/null || date)
    -  iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -Iseconds)
    -  if [ -n "$curl" ]; then
    -    clink="$cshort"
    -  fi
    -  cat >"$out" <
    -
    -
    -monitoring ${mode} · ${host}
    -
    -
    -
    -

    Bootstrap page (converter missing or first install). See host-agent log.

    -
    $(tail -n 80 "$LOG" 2>/dev/null | sed 's/&/\&/g;s/
    -
    - -EOF - echo "bootstrap html → $out" -} - -htmlify_host() { - local host="$1" - local mon="$HTML_BASE/$host/${HTML_OK_DIR}/index.html" - local mon_err="$HTML_BASE/$host/${HTML_ERR_DIR}/index.html" - mkdir -p "$HTML_BASE/$host/${HTML_OK_DIR}" "$HTML_BASE/$host/${HTML_ERR_DIR}" - - if [ -n "$SITE_GEN" ] && [ -f "$SITE_GEN/console_to_html.py" ]; then - python3 "$SITE_GEN/console_to_html.py" \ - --log "$LOG" \ - --out "$mon_err" \ - --hostname "$host" \ - --mode err \ - --commit "${COMMIT:-}" \ - --commit-url "${COMMIT_URL:-}" \ - --suite-path "$SUITE_PATH" \ - --page-label "$PAGE_LABEL" \ - --path-err "$HTML_URL_ERR" \ - --link-other "$HTML_URL_OK" - if [ "$ec" -eq 0 ]; then - python3 "$SITE_GEN/console_to_html.py" \ - --log "$LOG" \ - --out "$mon" \ - --hostname "$host" \ - --mode ok \ - --commit "${COMMIT:-}" \ - --commit-url "${COMMIT_URL:-}" \ - --suite-path "$SUITE_PATH" \ - --page-label "$PAGE_LABEL" \ - --path-err "$HTML_URL_ERR" \ - --link-other "" - rm -rf "$HTML_BASE/$host/${HTML_ERR_DIR}" - echo "html $host → ${HTML_URL_OK} only (clean · ${COMMIT_SHORT:-?})" - else - python3 "$SITE_GEN/console_to_html.py" \ - --log "$LOG" \ - --out "$mon" \ - --hostname "$host" \ - --mode redirect \ - --commit "${COMMIT:-}" \ - --commit-url "${COMMIT_URL:-}" \ - --suite-path "$SUITE_PATH" \ - --page-label "$PAGE_LABEL" \ - --path-err "$HTML_URL_ERR" - echo "html $host → ${HTML_URL_OK} stub + ${HTML_URL_ERR} (ec=$ec · ${COMMIT_SHORT:-?})" - fi - else - echo "WARN: console_to_html.py missing — bootstrap pages" - write_bootstrap_html "$mon_err" "$host" "err" - if [ "$ec" -eq 0 ]; then - write_bootstrap_html "$mon" "$host" "ok" - rm -rf "$HTML_BASE/$host/${HTML_ERR_DIR}" - else - write_bootstrap_html "$mon" "$host" "redirect" - fi - fi - - if [ ! -f "$mon" ]; then - write_bootstrap_html "$mon" "$host" "err" - fi - - printf '%s\n' "${COMMIT:-unknown}" >"$HTML_BASE/$host/COMMIT" - printf '%s\n' "${COMMIT_URL:-}" >"$HTML_BASE/$host/COMMIT_URL" -} -for host in $MON_HOSTS; do - htmlify_host "$host" -done - -if [ -n "$DEPLOY_WWW" ] && [ -w "$DEPLOY_WWW" ] 2>/dev/null; then - rsync -a --delete "$HTML_BASE/" "$DEPLOY_WWW/" && \ - echo "synced → $DEPLOY_WWW/" -fi - -ls -1t "$LOG_DIR"/run-*.log 2>/dev/null | tail -n +30 | xargs rm -f 2>/dev/null || true - -echo "======== done ec=$ec · log=$LOG · ${COMMIT_URL:-} ========" -if [ "${STRICT_EXIT:-0}" = "1" ]; then - exit "$ec" -fi -exit 0 diff --git a/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh b/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh deleted file mode 100755 index 1b7a8b8..0000000 --- a/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -# run-surface-monitoring.sh — hourly remote-only ecosystem surface scan -# Public only on taler.hacktivism.ch: -# https://taler.hacktivism.ch/taler-monitoring-surface/ -# https://taler.hacktivism.ch/taler-monitoring-surface_err/ -# -set -uo pipefail -AGENT_DIR=$(cd "$(dirname "$0")" && pwd) - -export AGENT_LABEL="${AGENT_LABEL:-surface-host-agent}" -export STATE_NAME="${STATE_NAME:-taler-surface-monitoring}" -export TALER_DOMAIN="${TALER_DOMAIN:-hacktivism.ch}" -# surface is remote-only — force flags (ignore shared env) -export INSIDE_PODMAN=0 -export LOCAL_STACK=0 -export SKIP_SSH=1 -export INSIDE_MODE="${INSIDE_MODE:-none}" -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" -# many hosts; allow up to 45 min wall (phase also has RUN_TIMEOUT) -export RUN_TIMEOUT="${RUN_TIMEOUT:-2400}" -# dedicated job: always surface only (never inherit PHASES from shared env) -export PHASES=surface -# Only merchant public front — not bank/exchange -export MON_HOSTS="${MON_HOSTS:-taler.hacktivism.ch}" -export HTML_OUT="${HTML_OUT:-$HOME/monitoring-sites-staging}" -export DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" - -export HTML_OK_DIR="taler-monitoring-surface" -export HTML_ERR_DIR="taler-monitoring-surface_err" -export HTML_URL_OK="/taler-monitoring-surface/" -export HTML_URL_ERR="/taler-monitoring-surface_err/" -export PAGE_LABEL="taler-monitoring-surface" - -# ecosystem catalog by default (not domain-only) -export SURFACE_SCOPE="${SURFACE_SCOPE:-ecosystem}" -export TALER_DOMAIN_FROM_CLI=0 - -# Do not run apt-deploy ensure for surface-only jobs -export APT_DEPLOY_ENSURE=0 - -exec bash "$AGENT_DIR/run-host-report.sh" "$@" diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service b/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service deleted file mode 100644 index 3d71eb1..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.service +++ /dev/null @@ -1,18 +0,0 @@ -[Unit] -Description=Taler monitoring aptdeploy (apt-src container deploy tests) -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin -Environment=CONTINUE_ON_ERROR=1 -Environment=RUN_TIMEOUT=1800 -WorkingDirectory=%h/koopa-admin-log/scripts/taler-monitoring -ExecStart=%h/koopa-admin-log/scripts/taler-monitoring/host-agent/run-aptdeploy-monitoring.sh -Nice=10 -TimeoutStartSec=45min - -[Install] -WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer b/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer deleted file mode 100644 index 602ce8b..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-aptdeploy.timer +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Periodic taler-monitoring aptdeploy (apt-src deploy tests) -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md - -[Timer] -OnBootSec=35min -OnUnitActiveSec=4h -AccuracySec=5min -Persistent=true -RandomizedDelaySec=10min -Unit=taler-monitoring-aptdeploy.service - -[Install] -WantedBy=timers.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path deleted file mode 100644 index 1cbf003..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.path +++ /dev/null @@ -1,19 +0,0 @@ -[Unit] -Description=Watch hacktivism software/config changes → re-run monitoring -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md - -[Path] -# Explicit stamp after package/image upgrades (touch-software-stamp.sh) -PathChanged=%h/.local/state/taler-hacktivism-monitoring/software.stamp -PathModified=%h/.local/state/taler-hacktivism-monitoring/software.stamp -# Landing + caddy config in admin-log (when checked out under ~) -PathChanged=%h/koopa-admin-log/configs/bank-landing -PathChanged=%h/koopa-admin-log/configs/exchange-landing -PathChanged=%h/koopa-admin-log/configs/merchant-landing -PathChanged=%h/koopa-admin-log/configs/caddy -# Debounce: wait for quiet period before firing -TriggerLimitIntervalSec=120 -TriggerLimitBurst=3 - -[Install] -WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service deleted file mode 100644 index 712920c..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.service +++ /dev/null @@ -1,21 +0,0 @@ -[Unit] -Description=Taler monitoring GOA/hacktivism (host-podman inside + public urls) -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -# Primary: host can podman-exec into taler-hacktivism* containers -Environment=INSIDE_PODMAN=1 -Environment=CONTINUE_ON_ERROR=1 -Environment=RUN_TIMEOUT=600 -Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin -WorkingDirectory=%h/koopa-admin-log/scripts/taler-monitoring -ExecStart=%h/koopa-admin-log/scripts/taler-monitoring/host-agent/run-hacktivism-monitoring.sh -Nice=10 -# Suite budget is RUN_TIMEOUT (10m) + git/HTML overhead -TimeoutStartSec=15min - -[Install] -WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer b/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer deleted file mode 100644 index b65c139..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-hacktivism.timer +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Periodic hacktivism monitoring (fallback if no path events) -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md - -[Timer] -# Every 4h + boot -OnBootSec=10min -OnUnitActiveSec=4h -AccuracySec=5min -Persistent=true -Unit=taler-monitoring-hacktivism.service - -[Install] -WantedBy=timers.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-surface.service b/scripts/taler-monitoring/host-agent/taler-monitoring-surface.service deleted file mode 100644 index 6018dee..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-surface.service +++ /dev/null @@ -1,18 +0,0 @@ -[Unit] -Description=Taler monitoring surface (remote ecosystem inventory) -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin -Environment=CONTINUE_ON_ERROR=1 -Environment=RUN_TIMEOUT=2400 -WorkingDirectory=%h/koopa-admin-log/scripts/taler-monitoring -ExecStart=%h/koopa-admin-log/scripts/taler-monitoring/host-agent/run-surface-monitoring.sh -Nice=10 -TimeoutStartSec=50min - -[Install] -WantedBy=default.target diff --git a/scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer b/scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer deleted file mode 100644 index a9ed8c9..0000000 --- a/scripts/taler-monitoring/host-agent/taler-monitoring-surface.timer +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Hourly taler-monitoring surface scan (ecosystem) -Documentation=file:%h/koopa-admin-log/scripts/taler-monitoring/host-agent/README.md - -[Timer] -OnBootSec=20min -OnUnitActiveSec=1h -AccuracySec=2min -Persistent=true -RandomizedDelaySec=5min -Unit=taler-monitoring-surface.service - -[Install] -WantedBy=timers.target diff --git a/scripts/taler-monitoring/host-agent/touch-software-stamp.sh b/scripts/taler-monitoring/host-agent/touch-software-stamp.sh deleted file mode 100755 index 0aff400..0000000 --- a/scripts/taler-monitoring/host-agent/touch-software-stamp.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Call after upgrading hacktivism packages / redeploying landings / image rebuilds. -# systemd path unit watches this stamp → re-runs monitoring. -set -euo pipefail -STATE="${XDG_STATE_HOME:-$HOME/.local/state}/taler-hacktivism-monitoring" -mkdir -p "$STATE" -STAMP="$STATE/software.stamp" -date -Iseconds >"$STAMP" -# also record container image ids for debugging -if command -v podman >/dev/null 2>&1; then - { - echo "# images $(date -Iseconds)" - podman images --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.Digest}}' 2>/dev/null | grep -i hacktivism || true - podman ps --format '{{.Names}} {{.ImageID}} {{.Status}}' 2>/dev/null | grep -i hacktivism || true - } >"$STATE/software-images.txt" || true -fi -echo "touched $STAMP" diff --git a/scripts/taler-monitoring/host-agent/update-suite.sh b/scripts/taler-monitoring/host-agent/update-suite.sh deleted file mode 100755 index 70d348d..0000000 --- a/scripts/taler-monitoring/host-agent/update-suite.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# update-suite.sh — ensure taler-monitoring code is latest from Forgejo. -# -# Local config (NEVER overwritten by git): -# ${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring/env -# -# Env overrides (also settable in that file): -# SUITE_GIT_URL default https://git.hacktivism.ch/hernani/koopa-admin-log.git -# SUITE_GIT_REF default main -# SUITE_DIR default $HOME/src/koopa-admin-log (or $HOME/koopa-admin-log) -# SUITE_UPDATE_MODE ff (default) | reset (reset = hard to origin/REF) -# -set -uo pipefail - -CFG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/taler-monitoring" -ENV_FILE="${TALER_MONITORING_ENV:-$CFG_DIR/env}" -# When sourced from run-host-report.sh the env file is already loaded and -# wrapper PHASES/HTML_* must not be clobbered. Only load here if needed. -if [ -f "$ENV_FILE" ] && [ "${_TALER_MON_ENV_LOADED:-0}" != "1" ]; then - # shellcheck disable=SC1090 - set -a - # shellcheck source=/dev/null - source "$ENV_FILE" - set +a -fi - -SUITE_GIT_URL="${SUITE_GIT_URL:-https://git.hacktivism.ch/hernani/koopa-admin-log.git}" -SUITE_GIT_REF="${SUITE_GIT_REF:-main}" -# Host agents default to reset so "always latest from Forgejo" wins over local -# diverged trees. Override with SUITE_UPDATE_MODE=ff for careful pulls. -SUITE_UPDATE_MODE="${SUITE_UPDATE_MODE:-reset}" - -if [ -z "${SUITE_DIR:-}" ]; then - if [ -d "$HOME/src/koopa-admin-log/.git" ]; then - SUITE_DIR="$HOME/src/koopa-admin-log" - elif [ -d "$HOME/koopa-admin-log/.git" ]; then - SUITE_DIR="$HOME/koopa-admin-log" - else - SUITE_DIR="$HOME/src/koopa-admin-log" - fi -fi - -export SUITE_DIR - -mkdir -p "$(dirname "$SUITE_DIR")" "$CFG_DIR" - -if [ ! -d "$SUITE_DIR/.git" ]; then - echo "clone $SUITE_GIT_URL → $SUITE_DIR" - git clone --branch "$SUITE_GIT_REF" "$SUITE_GIT_URL" "$SUITE_DIR" \ - || git clone "$SUITE_GIT_URL" "$SUITE_DIR" -fi - -cd "$SUITE_DIR" || exit 1 -git remote set-url origin "$SUITE_GIT_URL" 2>/dev/null \ - || git remote add origin "$SUITE_GIT_URL" 2>/dev/null || true - -echo "fetch origin ($SUITE_GIT_REF) …" -if ! git fetch --tags origin 2>&1; then - echo "WARN: git fetch failed — using existing tree at $SUITE_DIR" >&2 - git rev-parse HEAD 2>/dev/null || true - return 0 2>/dev/null || exit 0 -fi - -case "$SUITE_UPDATE_MODE" in - reset) - git checkout -B "$SUITE_GIT_REF" "origin/$SUITE_GIT_REF" 2>/dev/null \ - || git checkout -B "$SUITE_GIT_REF" "FETCH_HEAD" - git reset --hard "origin/$SUITE_GIT_REF" 2>/dev/null \ - || git reset --hard FETCH_HEAD - ;; - *) - git checkout "$SUITE_GIT_REF" 2>/dev/null || git checkout -B "$SUITE_GIT_REF" "origin/$SUITE_GIT_REF" 2>/dev/null || true - if ! git pull --ff-only origin "$SUITE_GIT_REF" 2>&1; then - echo "WARN: ff-only pull failed — try SUITE_UPDATE_MODE=reset or fix local commits" >&2 - fi - ;; -esac - -COMMIT=$(git rev-parse HEAD) -COMMIT_SHORT=$(git rev-parse --short=12 HEAD) -# Forgejo web UI -SOURCE_REPO_WEB="${SOURCE_REPO_WEB:-https://git.hacktivism.ch/hernani/koopa-admin-log}" -COMMIT_URL="${SOURCE_REPO_WEB}/src/commit/${COMMIT}" -export COMMIT COMMIT_SHORT COMMIT_URL SOURCE_REPO_WEB - -echo "suite @ $COMMIT_SHORT $COMMIT_URL" -echo " dir=$SUITE_DIR" -echo " config=$ENV_FILE (not touched by git)" -echo " note: always latest = origin/${SUITE_GIT_REF} on git.hacktivism.ch (push local work there)" - -# Symlink convenience path if missing or not a git clone -if [ -d "$SUITE_DIR/.git" ]; then - if [ ! -e "$HOME/koopa-admin-log" ]; then - ln -sfn "$SUITE_DIR" "$HOME/koopa-admin-log" 2>/dev/null || true - elif [ -L "$HOME/koopa-admin-log" ]; then - ln -sfn "$SUITE_DIR" "$HOME/koopa-admin-log" 2>/dev/null || true - fi -fi - -# Make suite scripts executable after reset -if [ -d "$SUITE_DIR/scripts/taler-monitoring" ]; then - chmod +x \ - "$SUITE_DIR"/scripts/taler-monitoring/taler-monitoring.sh \ - "$SUITE_DIR"/scripts/taler-monitoring/check_*.sh \ - "$SUITE_DIR"/scripts/taler-monitoring/host-agent/*.sh \ - "$SUITE_DIR"/scripts/taler-monitoring/site-gen/*.sh \ - "$SUITE_DIR"/scripts/taler-monitoring/site-gen/*.py \ - 2>/dev/null || true -fi diff --git a/scripts/taler-monitoring/lib.sh b/scripts/taler-monitoring/lib.sh deleted file mode 100755 index 8455c6b..0000000 --- a/scripts/taler-monitoring/lib.sh +++ /dev/null @@ -1,1257 +0,0 @@ -# shellcheck shell=bash -# Shared helpers for taler-monitoring (laptop or koopa). - -# Default stack = GOA / hacktivism (overridden by TALER_DOMAIN / --domain) -: "${TALER_DOMAIN:=hacktivism.ch}" -: "${BANK_PUBLIC:=https://bank.hacktivism.ch}" -: "${EXCHANGE_PUBLIC:=https://exchange.hacktivism.ch}" -: "${MERCHANT_PUBLIC:=https://taler.hacktivism.ch}" -: "${BANK_LOCAL:=http://127.0.0.1:9012}" -: "${EXCHANGE_LOCAL:=http://127.0.0.1:9011}" -: "${MERCHANT_LOCAL:=https://127.0.0.1:9010}" -: "${LANDING_LOCAL:=http://127.0.0.1:9013}" -: "${KOOPA_SSH:=koopa}" -# 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}" -# Merchant portal /private API: instance ids must be lowercase or you get odd 401s. -# (Display names may use capitals; path segment /instances/{id}/ must not.) -MERCHANT_INSTANCE=$(printf '%s' "${MERCHANT_INSTANCE}" | tr '[:upper:]' '[:lower:]') -: "${WITHDRAW_AMT:=GOA:20}" # single-shot fallback; e2e ladder uses ATM notes -: "${PAY_AMT:=GOA:0.01}" -: "${CREDIT_AMT:=GOA:4700}" # covers ATM ladder 20+50+100+200+4200 (paivana) -: "${TIMEOUT:=12}" -: "${E2E_TIMEOUT:=55}" # whole e2e budget; skip rest when exceeded (e2e raises as needed) -: "${E2E_PAY_SECS:=22}" # dedicated seconds for pay handle-uri (avoid Alarm clock) -# Whole-run wall clock for taler-monitoring.sh (seconds). 0 = unlimited. -# Host-agent default phases (urls/inside/versions) should finish under this. -: "${RUN_TIMEOUT:=600}" -# Local GOA: public paivana paywall (https://paivana.hacktivism.ch · template GOA:4200) -: "${PAIVANA_PUBLIC:=https://paivana.hacktivism.ch}" -: "${E2E_PAIVANA:=1}" # 0 = skip paivana section in e2e -# Devtest: inject reserve credit via wire-gateway admin/add-incoming (optional). -# Default off once wirewatch DNS works; set E2E_FAKE_INCOMING=1 to force. -: "${E2E_FAKE_INCOMING:=0}" -# SSH must never hang the monitoring run -: "${SSH_CONNECT_TIMEOUT:=3}" -: "${SSH_CMD_TIMEOUT:=12}" # hard cap for whole remote script (seconds) -: "${SKIP_SSH:=0}" -# Expected currency for public /config checks (empty = report only, don't fail) -# Only if unset (not if intentionally empty = pre-launch / report-only) -: "${EXPECT_CURRENCY=GOA}" -# 1 = this is the local koopa/hacktivism stack (inside/e2e/SSH make sense) -: "${LOCAL_STACK:=1}" -# Probe merchant host candidates when applying a generic domain (0=off) -: "${TALER_DOMAIN_PROBE:=1}" - -BANK_PUBLIC=${BANK_PUBLIC%/} -EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} -MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} - -# --------------------------------------------------------------------------- -# Domain profiles → bank / exchange / merchant base URLs -# -# Single place to declare a stack: scripts/taler-monitoring/domains.conf -# (or TALER_DOMAINS_CONF). Each profile names the three public endpoints. -# -# CLI still wins after profile load: -# --bank URL --exchange URL --merchant URL --currency CODE -# Env: BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC EXPECT_CURRENCY -# -# Unknown domains fall back to heuristics (see apply_taler_domain). -# --------------------------------------------------------------------------- - -_MONITOR_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -: "${TALER_DOMAINS_CONF:=${_MONITOR_LIB_DIR}/domains.conf}" - -# host_or_url → https://… (no trailing slash) -_to_https_base() { - local x="${1%/}" - case "$x" in - https://*|http://*) printf '%s' "$x" ;; - "") printf '' ;; - *) printf 'https://%s' "$x" ;; - esac -} - -# 1 = check GOA-style /intro landing pages (assets, link crawl, demo-withdraw). -# 0 = skip (e.g. taler-ops.ch / mytops — no public landings). -: "${CHECK_LANDING:=1}" - -# Declare bank + exchange + merchant (+ currency, local stack, landing, domain id). -# Usage: -# set_taler_stack BANK EXCHANGE MERCHANT [CURRENCY] [LOCAL 0|1] [LANDING 0|1] [TALER_DOMAIN] -# BANK/EXCHANGE/MERCHANT: hostname or full URL -set_taler_stack() { - local bank="${1:-}" exchange="${2:-}" merchant="${3:-}" - local currency="${4:-}" local_stack="${5:-0}" landing="${6:-}" domain_id="${7:-}" - - BANK_PUBLIC=$(_to_https_base "$bank") - EXCHANGE_PUBLIC=$(_to_https_base "$exchange") - MERCHANT_PUBLIC=$(_to_https_base "$merchant") - # currency "-" or empty → report-only (do not force GOA) - if [ -n "$currency" ] && [ "$currency" != "-" ]; then - EXPECT_CURRENCY="$currency" - elif [ "$currency" = "-" ]; then - EXPECT_CURRENCY="" - fi - LOCAL_STACK="$local_stack" - if [ "$LOCAL_STACK" = "1" ]; then - SKIP_SSH=0 - else - SKIP_SSH=1 - fi - # default: landings only matter on local GOA stack - if [ -n "$landing" ]; then - CHECK_LANDING="$landing" - elif [ "$LOCAL_STACK" = "1" ]; then - CHECK_LANDING=1 - else - CHECK_LANDING=0 - fi - [ -n "$domain_id" ] && TALER_DOMAIN="$domain_id" - - BANK_PUBLIC=${BANK_PUBLIC%/} - EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} - MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} -} - -# Load first matching profile from domains.conf. -# Fields (whitespace-separated; # comments; blank lines ignored): -# name bank exchange merchant currency local[0|1] landing[0|1] [canonical_domain] -# Returns 0 if found, 1 if not. -load_domain_profile() { - local want="$1" conf="${TALER_DOMAINS_CONF:-}" - local line name bank exchange merchant currency local_stack landing canon - [ -n "$want" ] || return 1 - [ -n "$conf" ] && [ -f "$conf" ] || return 1 - - while IFS= read -r line || [ -n "$line" ]; do - line="${line%%#*}" - # shellcheck disable=SC2086 - set -- $line - [ $# -ge 5 ] || continue - name="$1" - [ "$name" = "$want" ] || continue - bank="$2" - exchange="$3" - merchant="$4" - currency="$5" - local_stack="${6:-0}" - landing="${7:-}" - canon="${8:-$name}" - # Backward compat: old 7th field was canonical domain (contains a dot) - if [ -n "$landing" ] && [[ "$landing" == *.* && "$landing" != "0" && "$landing" != "1" ]]; then - canon="$landing" - landing="" - fi - set_taler_stack "$bank" "$exchange" "$merchant" "$currency" "$local_stack" "$landing" "$canon" - return 0 - done <"$conf" - return 1 -} - -_normalize_domain() { - local d="$1" - d="${d#https://}" - d="${d#http://}" - d="${d%%/*}" - d="${d%%:*}" - # Strip service host prefix only if a real domain remains (has a dot). - # e.g. bank.demo.taler.net → demo.taler.net, taler.hacktivism.ch → hacktivism.ch - # but NOT taler.net → net - case "$d" in - bank.*|exchange.*|taler.*|backend.*|merchant.*|shop.*|libeufin.*|my.*|map.*|monnaie.*) - rest="${d#*.}" - if [[ "$rest" == *.* ]]; then - d="$rest" - fi - ;; - esac - printf '%s' "$d" -} - -_probe_https_config() { - # 0 if https://$1/config returns 200 - local host="$1" code - code=$(curl -skS --max-redirs 0 -m 4 -o /dev/null -w '%{http_code}' "https://${host}/config" 2>/dev/null || echo 000) - [ "$code" = "200" ] -} - -apply_taler_domain() { - local raw="${1:-}" - local d - [ -n "$raw" ] || return 0 - d=$(_normalize_domain "$raw") - TALER_DOMAIN="$d" - - # 1) Explicit profile (domains.conf) — preferred way to add stacks - if load_domain_profile "$d" || load_domain_profile "$raw"; then - # Optional tiny defaults for e2e ladders by currency - case "${EXPECT_CURRENCY:-}" in - GOA) - : "${WITHDRAW_AMT:=GOA:20}" - : "${PAY_AMT:=GOA:0.01}" - : "${CREDIT_AMT:=GOA:400}" - ;; - KUDOS|TESTKUDOS) - MERCHANT_INSTANCE="${MERCHANT_INSTANCE:-sandbox}" - WITHDRAW_AMT="${WITHDRAW_AMT:-${EXPECT_CURRENCY}:20}" - PAY_AMT="${PAY_AMT:-${EXPECT_CURRENCY}:0.01}" - CREDIT_AMT="${CREDIT_AMT:-${EXPECT_CURRENCY}:100}" - ;; - TESTPAYSAN) - # Stage FrancPaysan: farmer shops (override GOA default goa-demo-cp4zqk) - case "${MERCHANT_INSTANCE:-}" in - ""|goa-demo*|goa-shop) - MERCHANT_INSTANCE=fermes-des-collines - ;; - esac - MERCHANT_INSTANCE=$(printf '%s' "${MERCHANT_INSTANCE}" | tr '[:upper:]' '[:lower:]') - WITHDRAW_AMT="${WITHDRAW_AMT:-TESTPAYSAN:50}" - PAY_AMT="${PAY_AMT:-TESTPAYSAN:5}" - # Cover oeufs(5)+fromage(8.5)+jus(6)+shop pick(~5–12) + fees - CREDIT_AMT="${CREDIT_AMT:-TESTPAYSAN:200}" - # Public template pays on by default (shops landings) - : "${E2E_USE_TEMPLATES:=1}" - # Map pay-ladder amounts → public shop templates (instance set in e2e) - : "${E2E_TEMPLATE_MAP:=5=oeufs-6 4.5=pain-seigle 6=jus-pomme 8.5=fromage-chevre 12=miel-printemps 25=panier-legumes}" - : "${E2E_PAY_VALUES:=5 8.5 6}" - : "${E2E_WITHDRAW_VALUES:=20 50}" - # Inside checks as low-priv stagepaysan (podman, no sudo) — not koopa - INSIDE_PROFILE="${INSIDE_PROFILE:-stage-lfp}" - INSIDE_SSH="${INSIDE_SSH:-francpaysan-stage-user}" - INSIDE_BANK_CTR="${INSIDE_BANK_CTR:-stage-lfp-bank}" - INSIDE_EXCHANGE_CTR="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" - INSIDE_MERCHANT_CTR="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" - # Host pasta ports (127.0.0.1 on francpaysan-host) - INSIDE_BANK_PORT="${INSIDE_BANK_PORT:-9032}" - INSIDE_EXCHANGE_PORT="${INSIDE_EXCHANGE_PORT:-9031}" - INSIDE_MERCHANT_PORT="${INSIDE_MERCHANT_PORT:-9030}" - INSIDE_DNS_BANK="${INSIDE_DNS_BANK:-stage.bank.lefrancpaysan.ch}" - INSIDE_DNS_EXCHANGE="${INSIDE_DNS_EXCHANGE:-stage.exchange.lefrancpaysan.ch}" - INSIDE_DNS_MERCHANT="${INSIDE_DNS_MERCHANT:-stage.monnaie.lefrancpaysan.ch}" - ;; - CHF) - WITHDRAW_AMT="${WITHDRAW_AMT:-CHF:20}" - PAY_AMT="${PAY_AMT:-CHF:0.01}" - CREDIT_AMT="${CREDIT_AMT:-CHF:100}" - ;; - esac - else - # 2) Unknown domain — heuristics only (prefer profile in domains.conf) - # Override: --bank / --exchange / --merchant / --currency - # Do not keep default GOA: empty currency = report only, no hard fail. - BANK_PUBLIC="https://bank.${d}" - EXCHANGE_PUBLIC="https://exchange.${d}" - # TOPS-style multi-tenant merchant first, then legacy names - MERCHANT_PUBLIC="https://my.${d}" - EXPECT_CURRENCY="" - LOCAL_STACK=0 - SKIP_SSH=1 - CHECK_LANDING=0 - if [ "${TALER_DOMAIN_PROBE}" = "1" ]; then - local h - for h in "monnaie.${d}" "backend.${d}" "my.${d}" "taler.${d}" "merchant.${d}" "shop.${d}"; do - _probe_https_config "$h" && { MERCHANT_PUBLIC="https://$h"; break; } - done - for h in "bank.${d}" "libeufin.${d}"; do - _probe_https_config "$h" && { BANK_PUBLIC="https://$h"; break; } - done - _probe_https_config "exchange.${d}" || true - fi - fi - - BANK_PUBLIC=${BANK_PUBLIC%/} - EXCHANGE_PUBLIC=${EXCHANGE_PUBLIC%/} - MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/} - - # Koopa SSH only for LOCAL_STACK=1. Stage LFP uses INSIDE_SSH (stagepaysan) separately. - if [ "${LOCAL_STACK}" != "1" ]; then - SKIP_SSH=1 - fi - # Default inside profile for local GOA - if [ "${LOCAL_STACK}" = "1" ]; then - INSIDE_PROFILE="${INSIDE_PROFILE:-koopa}" - fi -} - -# Apply TALER_DOMAIN from env once (CLI exports TALER_DOMAIN_APPLIED=1 after overrides). -if [ "${TALER_DOMAIN_APPLIED:-0}" != "1" ] \ - && [ -n "${TALER_DOMAIN:-}" ] && [ "${TALER_DOMAIN}" != "hacktivism.ch" ]; then - apply_taler_domain "$TALER_DOMAIN" - TALER_DOMAIN_APPLIED=1 -fi - -# Safe SSH: publickey only, short connect, overall alarm so we never block forever. -SSH_BASE_OPTS=( - -o BatchMode=yes - -o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" - -o ConnectionAttempts=1 - -o ServerAliveInterval=3 - -o ServerAliveCountMax=2 - -o StrictHostKeyChecking=accept-new - -o PreferredAuthentications=publickey - -o PasswordAuthentication=no - -o KbdInteractiveAuthentication=no - -o GSSAPIAuthentication=no - -o NumberOfPasswordPrompts=0 -) - -# Hard wall-clock timeout so ssh/curl never block the monitoring run forever. -with_timeout() { - local secs="$1"; shift - if command -v gtimeout >/dev/null 2>&1; then - gtimeout --kill-after=2 "$secs" "$@" - return $? - fi - if command -v timeout >/dev/null 2>&1; then - timeout -k 2 "$secs" "$@" 2>/dev/null || timeout --kill-after=2 "$secs" "$@" - return $? - fi - # Portable: perl alarm + process group kill - perl -e ' - use strict; use warnings; - my $secs = shift @ARGV; - my $pid = fork(); - die "fork: $!" unless defined $pid; - if ($pid == 0) { - setpgrp(0, 0); - exec @ARGV; - exit 127; - } - $SIG{ALRM} = sub { - kill "TERM", -$pid; - select(undef, undef, undef, 1.0); - kill "KILL", -$pid; - exit 124; - }; - alarm $secs; - waitpid($pid, 0); - my $code = $? >> 8; - alarm 0; - exit $code; - ' "$secs" "$@" -} - -# Generic short SSH to a named host (low-priv stagepaysan or koopa). -# usage: mon_ssh_bash HOST [timeout] <<'EOF' ... EOF -mon_ssh_bash() { - local host="$1" t="${2:-$SSH_CMD_TIMEOUT}" - [ -n "$host" ] || return 1 - with_timeout "$t" ssh "${SSH_BASE_OPTS[@]}" "$host" bash -s -} -mon_ssh_ok() { - local host="$1" - [ -n "$host" ] || return 1 - with_timeout $((SSH_CONNECT_TIMEOUT + 5)) \ - ssh "${SSH_BASE_OPTS[@]}" "$host" true >/dev/null 2>&1 -} - -# 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 - resolve_koopa_ssh -} - -# Run remote bash -s with optional stdin script; hard-capped -# usage: koopa_ssh_bash [timeout_secs] <<'EOF' ... EOF -# or: koopa_ssh_run timeout_secs 'remote command' -koopa_ssh_run() { - local t="${1:-$SSH_CMD_TIMEOUT}" - shift - 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 - -} - -# ANSI colours — left badge (clear severity) + body label colour + dim detail. -# Off: NO_COLOR=1 or CLICOLOR=0. -# OK green badge · green label -# INFO blue badge · cyan label -# WARN yellow badge · yellow label (never red) -# ERROR red badge · red label -# BLOCKER magenta badge· magenta label -# progress cyan badge = " 42%" + bar fills left→right -if [ "${NO_COLOR:-0}" = "1" ] || [ "${CLICOLOR:-1}" = "0" ]; then - G= R= Y= C= M= D= W= B= N= - BG_OK= BG_INFO= BG_WARN= BG_ERR= BG_BLK= BG_SEC= BG_PROG= - BOX=0 -else - G=$'\e[1;32m' # green - R=$'\e[1;31m' # red - Y=$'\e[1;33m' # yellow - C=$'\e[1;36m' # cyan - M=$'\e[1;35m' # magenta - D=$'\e[2m' # dim tid / detail - W=$'\e[1;37m' # bold white - B=$'\e[1m' # bold - N=$'\e[0m' # reset - # High-contrast left badges (bright white / black on strong bg) - BG_OK=$'\e[1;97;42m' # white on green - BG_INFO=$'\e[1;97;44m' # white on blue - BG_WARN=$'\e[1;30;103m' # black on bright yellow — not red - BG_ERR=$'\e[1;97;41m' # white on red — errors only - BG_BLK=$'\e[1;97;45m' # white on magenta — blockers - BG_SEC=$'\e[1;97;44m' # white on blue (section) - BG_PROG=$'\e[1;30;106m' # black on bright cyan (progress) - BOX=1 -fi - -PASS_N=0 -FAIL_N=0 -WARN_N=0 -INFO_N=0 -BLOCKERS=() # human-readable payment/withdraw blockers -ERRORS=() # all ERROR lines (component scope) - -# Grouped test IDs: area.group-NN (e.g. www.exchange-01, e2e.pay-03) -# plus global run number: #042 (monotonic for the whole ./taler-monitoring.sh run) -# set_area www # phase: www | e2e | inside | versions | … -# set_group exchange # → www.exchange-01 -# set_group bank # → www.bank-01 -# Without set_group: area-01, area-02, … (flat within area) -# Line shape: ┌ OK ┐ #003 www.exchange-02 label · detail -TEST_AREA="" -TEST_GROUP="" -TEST_N=0 -LAST_TID="" -# Global / progress: may continue across check_*.sh processes via TALER_MON_STATE -GLOBAL_N="${GLOBAL_N:-0}" -LAST_GLOBAL="" -PROGRESS_DONE="${PROGRESS_DONE:-0}" -PROGRESS_TOTAL="${PROGRESS_TOTAL:-0}" -PROGRESS_SHOW_EVERY="${PROGRESS_SHOW_EVERY:-8}" -PROGRESS_LAST_SHOWN="${PROGRESS_LAST_SHOWN:-0}" -_mon_state_load() { - [ -n "${TALER_MON_STATE:-}" ] && [ -f "${TALER_MON_STATE}" ] || return 0 - # shellcheck disable=SC1090 - . "${TALER_MON_STATE}" -} -_mon_state_save() { - [ -n "${TALER_MON_STATE:-}" ] || return 0 - { - printf 'GLOBAL_N=%s\n' "$GLOBAL_N" - printf 'PROGRESS_DONE=%s\n' "$PROGRESS_DONE" - printf 'PROGRESS_TOTAL=%s\n' "$PROGRESS_TOTAL" - printf 'PROGRESS_LAST_SHOWN=%s\n' "$PROGRESS_LAST_SHOWN" - } >"${TALER_MON_STATE}" -} -_mon_state_load -# Per-group counters so re-entering www.exchange after perf continues NN -# (shell vars TEST_CNT__). -_tid_key() { - # $1=area $2=group → safe identifier - printf 'TEST_CNT_%s_%s' "$1" "$2" | tr -c 'A-Za-z0-9_' '_' -} -_tid_save() { - [ -z "${TEST_AREA:-}" ] || [ -z "${TEST_GROUP:-}" ] && return 0 - local k - k=$(_tid_key "$TEST_AREA" "$TEST_GROUP") - eval "$k=\$TEST_N" -} -_tid_load() { - local k - k=$(_tid_key "$TEST_AREA" "$1") - eval "TEST_N=\${$k:-0}" -} -set_area() { - # leave previous group counter saved - _tid_save - TEST_AREA="$1" - TEST_GROUP="" - TEST_N=0 - LAST_TID="" -} -# Start a logical sub-group. Short stable names: -# exchange bank merchant perf stats landing paivana -# prereq wallet atm settle pay shop dig load report -# ssh caddy outside inside compare withdraw -# Counter continues if the same group is re-entered later in the area. -# Prints a compact group chip so log + issue text map cleanly (www.bank-03). -set_group() { - local g="$1" - # no-op if same group already active (e.g. inside emit loop) - if [ "$g" = "${TEST_GROUP:-}" ] && [ -n "$g" ]; then - return 0 - fi - _tid_save - TEST_GROUP="$g" - LAST_TID="" - if [ -z "${TEST_AREA:-}" ] || [ -z "$g" ]; then - TEST_N=0 - return 0 - fi - _tid_load "$g" - local tag="${TEST_AREA}.${g}" - if [ "${BOX:-0}" = "1" ]; then - # cyan-outline group chip: ┌ www.exchange ┐ - printf '%s%s┌ %s ┐%s\n' "$D" "$C" "$tag" "$N" - else - printf -- '-- %s --\n' "$tag" - fi - # show progress when entering a new group (if totals known) - _progress_maybe_show 1 -} -set_progress_total() { - # Optional: expected number of numbered checks in this run. - # PROGRESS_TOTAL=0 → no percent, only "done=N". - # Does not reset GLOBAL_N / PROGRESS_DONE if already mid-run (state file). - local n="${1:-0}" - # Never shrink below already completed work (re-estimate mid-run safe) - if [ "${PROGRESS_DONE:-0}" -gt 0 ] && [ "$n" -gt 0 ] && [ "$n" -lt "$PROGRESS_DONE" ]; then - n=$PROGRESS_DONE - fi - PROGRESS_TOTAL="$n" - if [ "${PROGRESS_DONE:-0}" -eq 0 ]; then - PROGRESS_LAST_SHOWN=0 - fi - _mon_state_save -} -add_progress_total() { - local n="${1:-0}" - PROGRESS_TOTAL=$((PROGRESS_TOTAL + n)) - _mon_state_save -} -# When estimate was short, grow total so we never print done>total (e.g. 224/205). -# Keeps ~10% headroom so the bar is not stuck at 100% while checks continue. -_progress_rebalance() { - [ "${PROGRESS_TOTAL:-0}" -gt 0 ] || return 0 - [ "${PROGRESS_DONE:-0}" -gt 0 ] || return 0 - if [ "$PROGRESS_DONE" -gt "$PROGRESS_TOTAL" ]; then - local head=$((PROGRESS_DONE / 10)) - [ "$head" -lt 12 ] && head=12 - PROGRESS_TOTAL=$((PROGRESS_DONE + head)) - fi -} -_progress_bar_line() { - # Format (grows left → right; badge = percent, not "PROG"): - # ┌ 42% ┐ ████████░░░░░░░░░░░░░░░░ 31/74 - local done="$1" total="$2" width=28 pct=0 filled empty i bar pct_lab - # Defensive: never display done > total - if [ "$total" -gt 0 ] && [ "$done" -gt "$total" ]; then - total=$done - fi - if [ "$total" -gt 0 ]; then - # integer percent; while mid-run past a short estimate, cap display < 100% - pct=$((done * 100 / total)) - [ "$pct" -gt 100 ] && pct=100 - if [ "$done" -lt "$total" ] && [ "$pct" -ge 100 ]; then - pct=99 - fi - # fill cells from the left (progress moves rightward as filled grows) - filled=$((done * width / total)) - [ "$filled" -gt "$width" ] && filled=$width - # show at least 1 block once any work done (unless 0%) - if [ "$done" -gt 0 ] && [ "$filled" -eq 0 ]; then - filled=1 - fi - # at 100% fill complete bar - if [ "$done" -ge "$total" ]; then - filled=$width - pct=100 - fi - empty=$((width - filled)) - bar="" - i=0 - while [ "$i" -lt "$filled" ]; do bar="${bar}█"; i=$((i + 1)); done - i=0 - while [ "$i" -lt "$empty" ]; do bar="${bar}░"; i=$((i + 1)); done - pct_lab=$(printf '%3d%%' "$pct") - if [ "${BOX:-0}" = "1" ]; then - # badge = percent (clearer than "PROG"); bar grows ░→█ left to right - printf '%s┌%s┐%s %s%s%s %s%d/%d%s\n' \ - "$BG_PROG" "$pct_lab" "$N" \ - "$C" "$bar" "$N" \ - "$D" "$done" "$total" "$N" - else - printf -- '%s%4s%s %s %d/%d\n' \ - "$C" "$pct_lab" "$N" "$bar" "$done" "$total" - fi - else - if [ "${BOX:-0}" = "1" ]; then - printf '%s┌ … ┐%s %sdone=%d%s (set PROGRESS_TOTAL= for %%)\n' \ - "$BG_PROG" "$N" "$D" "$done" "$N" - else - printf -- '%sdone=%d%s (set PROGRESS_TOTAL= for %%)\n' "$D" "$done" "$N" - fi - fi -} -_progress_maybe_show() { - local force="${1:-0}" - [ "${PROGRESS_OFF:-0}" = "1" ] && return 0 - [ "$PROGRESS_DONE" -le 0 ] && [ "$force" != "1" ] && return 0 - _progress_rebalance - if [ "$force" = "1" ] || \ - [ $((PROGRESS_DONE - PROGRESS_LAST_SHOWN)) -ge "$PROGRESS_SHOW_EVERY" ] || \ - { [ "$PROGRESS_TOTAL" -gt 0 ] && [ "$PROGRESS_DONE" -ge "$PROGRESS_TOTAL" ]; }; then - _progress_bar_line "$PROGRESS_DONE" "$PROGRESS_TOTAL" - PROGRESS_LAST_SHOWN=$PROGRESS_DONE - fi -} -# Assign next id into LAST_TID (must not run in a subshell). -_take_tid() { - LAST_TID="" - LAST_GLOBAL="" - if [ -z "${TEST_AREA:-}" ]; then - return - fi - # Global monotonic number for the whole monitoring run (#001 …) - GLOBAL_N=$((GLOBAL_N + 1)) - LAST_GLOBAL=$(printf '#%03d' "$GLOBAL_N") - TEST_N=$((TEST_N + 1)) - if [ -n "${TEST_GROUP:-}" ]; then - LAST_TID=$(printf '%s.%s-%02d' "$TEST_AREA" "$TEST_GROUP" "$TEST_N") - _tid_save - else - LAST_TID=$(printf '%s-%02d' "$TEST_AREA" "$TEST_N") - fi - PROGRESS_DONE=$((PROGRESS_DONE + 1)) - _progress_rebalance - _mon_state_save - _progress_maybe_show 0 -} -# Dim ids: "#003 www.exchange-01 " or empty -_fmt_tid() { - if [ -n "${LAST_GLOBAL:-}" ]; then - printf '%s%s%s ' "$D" "$LAST_GLOBAL" "$N" - fi - if [ -n "${LAST_TID:-}" ]; then - printf '%s%s%s ' "$D" "$LAST_TID" "$N" - fi -} -# Boxed badge cell: ┌ OK ┐ with filled bg (tag left-padded, width 7 for BLOCKER) -# $1=badge style $2=tag text -_fmt_badge() { - local badge="$1" tag="$2" inner - inner=$(printf -- '%-7s' "$tag") - if [ "${BOX:-0}" = "1" ]; then - printf '%s┌ %s┐%s' "$badge" "$inner" "$N" - else - printf '%s[ %s]%s' "$badge" "$inner" "$N" - fi -} -# One concrete line: ┌ OK ┐ tid label · detail -# $1=badge style $2=tag text $3=label colour $4=label $5=detail -_msg_line() { - local badge="$1" tag="$2" lcol="$3" label="$4" detail="${5:-}" - if [ -n "$detail" ]; then - printf -- '%s %s%s%s%s %s·%s %s%s%s\n' \ - "$(_fmt_badge "$badge" "$tag")" "$(_fmt_tid)" "$lcol" "$label" "$N" "$D" "$N" "$D" "$detail" "$N" - else - printf -- '%s %s%s%s%s\n' \ - "$(_fmt_badge "$badge" "$tag")" "$(_fmt_tid)" "$lcol" "$label" "$N" - fi -} - -ok() { - # ok "what is good" ["concrete evidence: HTTP 200 · 12ms · …"] - local label="$1" detail="${2:-}" - _take_tid - _msg_line "$BG_OK" "OK" "$G" "$label" "$detail" - PASS_N=$((PASS_N + 1)) -} -# component-scoped error: err bank "what failed" "why / HTTP / path" -err() { - local comp="$1" msg="$2" detail="${3:-}" - _take_tid - _msg_line "$BG_ERR" "ERROR" "$R" "${comp}: ${msg}" "$detail" - FAIL_N=$((FAIL_N + 1)) - ERRORS+=("${LAST_TID:+$LAST_TID }[$comp] $msg${detail:+ · $detail}") -} -# fail "what failed" ["why / HTTP code / path"] -fail() { - local label="$1" detail="${2:-}" - _take_tid - _msg_line "$BG_ERR" "ERROR" "$R" "$label" "$detail" - FAIL_N=$((FAIL_N + 1)) - ERRORS+=("${LAST_TID:+$LAST_TID }$label${detail:+ · $detail}") -} -warn() { - # warn "what is soft-bad" ["why still ok to continue"] - # warn component "what" "why" - # Yellow only — never red (red = ERROR / BLOCKER path) - local a1="${1:-}" a2="${2:-}" a3="${3:-}" - local head detail - _take_tid - if [ -n "$a3" ]; then - head="${a1}: ${a2}" - detail="$a3" - elif [ -n "$a2" ]; then - head="$a1" - detail="$a2" - else - head="$a1" - detail="" - fi - _msg_line "$BG_WARN" "WARN" "$Y" "$head" "$detail" - WARN_N=$((WARN_N + 1)) -} -info() { - # info "topic" ["concrete fact / value / next step"] - local label="$1" detail="${2:-}" - _take_tid - _msg_line "$BG_INFO" "INFO" "$C" "$label" "$detail" - INFO_N=$((INFO_N + 1)) -} -blocker() { - # Hard stop on pay/withdraw path: blocker "step" "why it cannot continue" - local step="$1" msg="$2" - _take_tid - _msg_line "$BG_BLK" "BLOCKER" "$M" "${step}" "$msg" - BLOCKERS+=("${LAST_TID:+$LAST_TID }[$step] $msg") - FAIL_N=$((FAIL_N + 1)) - ERRORS+=("BLOCKER ${LAST_TID:+$LAST_TID }[$step] $msg") -} -section() { - # Boxed section header (3 lines when colour on) - local title="$*" - local w=${#title} - [ "$w" -lt 24 ] && w=24 - [ "$w" -gt 56 ] && w=56 - local pad line i - pad=$(printf -- "%-${w}s" "$title") - if [ "${BOX:-0}" = "1" ]; then - line="" - i=0 - while [ "$i" -lt $((w + 2)) ]; do - line="${line}═" - i=$((i + 1)) - done - printf -- '\n%s╔%s╗%s\n' "$BG_SEC" "$line" "$N" - printf -- '%s║%s %s%s%s %s║%s\n' "$BG_SEC" "$N" "$B" "$pad" "$N" "$BG_SEC" "$N" - printf -- '%s╚%s╝%s\n' "$BG_SEC" "$line" "$N" - else - printf -- '\n== %s ==\n' "$title" - fi -} - -summary() { - local blk_n=${#BLOCKERS[@]} - echo "" - # final progress line (no _take_tid — plain printf) - if [ "${PROGRESS_OFF:-0}" != "1" ] && [ "$PROGRESS_DONE" -gt 0 ]; then - # Snap total to actual so last line is exact N/N 100% - if [ "$PROGRESS_TOTAL" -gt 0 ] && [ "$PROGRESS_DONE" -ne "$PROGRESS_TOTAL" ]; then - PROGRESS_TOTAL=$PROGRESS_DONE - _mon_state_save - fi - _progress_bar_line "$PROGRESS_DONE" "$PROGRESS_TOTAL" - fi - if [ "$GLOBAL_N" -gt 0 ]; then - printf -- '%s numbered checks this run: #001…#%03d%s\n' "$D" "$GLOBAL_N" "$N" - fi - if [ "$blk_n" -gt 0 ]; then - if [ "${BOX:-0}" = "1" ]; then - printf -- '%s┌ BLOCKERS · pay/withdraw cannot finish ┐%s\n' "$BG_BLK" "$N" - else - printf -- '%s--- BLOCKERS (pay/withdraw cannot finish) ---%s\n' "$M" "$N" - fi - local b - for b in "${BLOCKERS[@]}"; do - printf -- ' %s•%s %s%s%s\n' "$M" "$N" "$W" "$b" "$N" - done - fi - if [ "${#ERRORS[@]}" -gt 0 ] && [ "$blk_n" -lt "${#ERRORS[@]}" ]; then - if [ "${BOX:-0}" = "1" ]; then - printf -- '%s┌ ERRORS · failed checks ┐%s\n' "$BG_ERR" "$N" - else - printf -- '%s--- ERRORS (failed checks) ---%s\n' "$R" "$N" - fi - local e - for e in "${ERRORS[@]}"; do - case "$e" in BLOCKER*) continue ;; esac - printf -- ' %s•%s %s%s%s\n' "$R" "$N" "$W" "$e" "$N" - done - fi - - # Coloured totals — same severity badges as check lines (always; NO_COLOR blanks ANSI) - if [ "${BOX:-0}" = "1" ]; then - printf -- '\n%s┌ SUMMARY ┐%s\n' "$BG_SEC" "$N" - _sum_badge() { # $1=bg $2=tag $3=fg $4=n - printf -- ' %s┌ %-5s┐%s %s%4d%s\n' "$1" "$2" "$N" "$3" "$4" "$N" - } - _sum_badge "$BG_OK" "OK" "$G" "$PASS_N" - [ "$FAIL_N" -gt 0 ] && _sum_badge "$BG_ERR" "ERROR" "$R" "$FAIL_N" - [ "$WARN_N" -gt 0 ] && _sum_badge "$BG_WARN" "WARN" "$Y" "$WARN_N" - [ "$INFO_N" -gt 0 ] && _sum_badge "$BG_INFO" "INFO" "$C" "$INFO_N" - [ "$blk_n" -gt 0 ] && _sum_badge "$BG_BLK" "BLOCK" "$M" "$blk_n" - else - printf -- '\n[ SUMMARY ]\n' - printf -- ' [ OK ] %s%4d%s\n' "$G" "$PASS_N" "$N" - [ "$FAIL_N" -gt 0 ] && printf -- ' [ ERROR ] %s%4d%s\n' "$R" "$FAIL_N" "$N" - [ "$WARN_N" -gt 0 ] && printf -- ' [ WARN ] %s%4d%s\n' "$Y" "$WARN_N" "$N" - [ "$INFO_N" -gt 0 ] && printf -- ' [ INFO ] %s%4d%s\n' "$C" "$INFO_N" "$N" - [ "$blk_n" -gt 0 ] && printf -- ' [ BLOCK ] %s%4d%s\n' "$M" "$blk_n" "$N" - fi - # one-line rollup (bold label, severity-coloured counts) - printf -- ' %stotals:%s %s%d OK%s' "$B" "$N" "$G" "$PASS_N" "$N" - [ "$FAIL_N" -gt 0 ] && printf -- ' · %s%d ERROR%s' "$R" "$FAIL_N" "$N" - [ "$WARN_N" -gt 0 ] && printf -- ' · %s%d WARN%s' "$Y" "$WARN_N" "$N" - [ "$INFO_N" -gt 0 ] && printf -- ' · %s%d INFO%s' "$C" "$INFO_N" "$N" - [ "$blk_n" -gt 0 ] && printf -- ' · %s%d BLOCKER%s' "$M" "$blk_n" "$N" - printf '\n' - # overall verdict - if [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ] && [ "$WARN_N" -eq 0 ]; then - if [ "${BOX:-0}" = "1" ]; then - printf -- ' %s┌ OK ┐%s %sall clear%s\n' "$BG_OK" "$N" "$G" "$N" - else - printf -- ' %s[ OK ] all clear%s\n' "$G" "$N" - fi - elif [ "$FAIL_N" -eq 0 ] && [ "$blk_n" -eq 0 ]; then - if [ "${BOX:-0}" = "1" ]; then - printf -- ' %s┌ WARN ┐%s %swarnings only (no hard fail)%s\n' "$BG_WARN" "$N" "$Y" "$N" - else - printf -- ' %s[ WARN ] warnings only (no hard fail)%s\n' "$Y" "$N" - fi - else - if [ "${BOX:-0}" = "1" ]; then - printf -- ' %s┌ ERROR ┐%s %sfailed — see list above%s\n' "$BG_ERR" "$N" "$R" "$N" - else - printf -- ' %s[ ERROR ] failed — see list above%s\n' "$R" "$N" - fi - fi - [ "$FAIL_N" -eq 0 ] -} - -# --------------------------------------------------------------------------- -# Disk space on hosts / containers under test -# WARN when tight, ERROR when full / critically low -# DISK_WARN_USED_PCT=85 # free below 15% → WARN -# DISK_ERR_USED_PCT=95 # free below 5% → ERROR -# DISK_ERR_FREE_BYTES=0 # free == 0 always ERROR -# --------------------------------------------------------------------------- -: "${DISK_WARN_USED_PCT:=85}" -: "${DISK_ERR_USED_PCT:=95}" - -# Parse one POSIX `df -P` body line (no header): fs size used avail capacity mount -# size/used/avail in 1K-blocks when using df -Pk -_mon_disk_eval_line() { - local where="$1" fs="$2" size="$3" used="$4" avail="$5" cap="$6" mnt="$7" - local pct label detail - pct=${cap%%%} - # non-numeric capacity → skip - case "$pct" in - ''|*[!0-9]*) return 0 ;; - esac - label="disk ${where} ${mnt}" - detail="fs=$fs size_1k=$size used_1k=$used avail_1k=$avail use=${pct}%" - if [ "$avail" = "0" ] || [ "$pct" -ge 100 ]; then - err "disk" "${where} ${mnt} FULL / overflow" "$detail" - return 1 - fi - if [ "$pct" -ge "${DISK_ERR_USED_PCT}" ]; then - err "disk" "${where} ${mnt} critically low free space (≥${DISK_ERR_USED_PCT}% used)" "$detail" - return 1 - fi - if [ "$pct" -ge "${DISK_WARN_USED_PCT}" ]; then - warn "disk" "${where} ${mnt} free space tight (≥${DISK_WARN_USED_PCT}% used)" "$detail" - return 0 - fi - ok "$label" "$detail" - return 0 -} - -# Report all filesystems from `df -P` / `df -Pk` output (with header). -# $1 = where label (host, container:name, ssh:host) -# $2 = full df text -mon_disk_report_df() { - local where="$1" text="$2" line fs size used avail cap mnt rest ec=0 - [ -n "$text" ] || { - warn "disk" "${where}: no df output" - return 0 - } - while IFS= read -r line || [ -n "$line" ]; do - [ -n "$line" ] || continue - case "$line" in - Filesystem*|Filesystem*) continue ;; - esac - # df -P: Filesystem 1024-blocks Used Available Capacity Mounted on - # shellcheck disable=SC2086 - set -- $line - [ "$#" -ge 6 ] || continue - fs=$1 - size=$2 - used=$3 - avail=$4 - cap=$5 - shift 5 - mnt=$* - # skip special/pseudo if tiny and not root-ish - case "$fs" in - tmpfs|devtmpfs|overlay|shm|nsfs|proc|sysfs|cgroup*) - # still check root-like mounts that fill (overlay / in containers) - case "$mnt" in - /|/var|/var/*|/home|/home/*|/data|/mnt/*) ;; - *) continue ;; - esac - ;; - esac - if ! _mon_disk_eval_line "$where" "$fs" "$size" "$used" "$avail" "$cap" "$mnt"; then - ec=1 - fi - done <<<"$text" - return "$ec" -} - -# Local host: important mounts -mon_disk_check_host() { - local where text - where="${1:-host}" - text="" - text=$(df -Pk / /var /home /tmp 2>/dev/null | awk 'NR==1 || !seen[$1,$6]++' || true) - # fallback whole table - if [ -z "$text" ]; then - text=$(df -Pk 2>/dev/null || true) - fi - mon_disk_report_df "$where" "$text" -} - -# Run df inside podman container -mon_disk_check_podman() { - local cname="$1" text - local bin="${2:-podman}" - text=$("$bin" exec "$cname" df -Pk / /var /tmp 2>/dev/null || "$bin" exec "$cname" df -Pk 2>/dev/null || true) - mon_disk_report_df "ctr:${cname}" "$text" -} - -# df text already collected remotely -mon_disk_check_remote_text() { - local where="$1" text="$2" - mon_disk_report_df "$where" "$text" -} - -http_code() { - local url="$1"; shift - curl -skS --max-redirs 0 -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000 -} - -http_body() { - local url="$1" out="$2"; shift 2 - curl -skS --max-redirs 0 -m "${TIMEOUT}" -o "$out" -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000 -} - -# --------------------------------------------------------------------------- -# Currency unit map checks (wallet codec: alt_unit_names must include "0") -# --------------------------------------------------------------------------- -# Returns 0 if JSON body at $1 has usable alt_unit_names. -# Supports: -# - exchange/bank: currency_specification.alt_unit_names -# - merchant: currencies..alt_unit_names for each code -# Optional $2 = required currency code for currency_specification.currency -json_has_alt_unit_names() { - local file="$1" want_cur="${2:-}" - python3 - "$file" "$want_cur" <<'PY' -import json, sys -path, want = sys.argv[1], sys.argv[2] -try: - d = json.load(open(path)) -except Exception as e: - print(f"json-error: {e}") - sys.exit(2) - -def check_au(au, label): - if not isinstance(au, dict) or not au: - print(f"{label}: missing/empty alt_unit_names") - return False - if "0" not in au or not str(au.get("0") or "").strip(): - print(f"{label}: alt_unit_names missing non-empty key \"0\" (have {sorted(au.keys())})") - return False - print(f"{label}: alt_unit_names ok (0={au.get('0')!r}, n={len(au)})") - return True - -ok = True -cs = d.get("currency_specification") -if isinstance(cs, dict): - if want and cs.get("currency") and cs.get("currency") != want: - print(f"currency_specification.currency={cs.get('currency')!r} want {want!r}") - ok = False - if not check_au(cs.get("alt_unit_names"), "currency_specification"): - ok = False -elif "currency_specification" in d: - print("currency_specification: not an object") - ok = False - -curs = d.get("currencies") -if isinstance(curs, dict) and curs: - for code, spec in curs.items(): - if not isinstance(spec, dict): - print(f"currencies.{code}: not an object") - ok = False - continue - if not check_au(spec.get("alt_unit_names"), f"currencies.{code}"): - ok = False - -if not isinstance(cs, dict) and not (isinstance(curs, dict) and curs): - # neither shape — fail - print("no currency_specification or currencies map") - ok = False - -sys.exit(0 if ok else 1) -PY -} - -# Check one exchange base URL's /config for alt_unit_names. -# $1=label $2=base_url $3=expected currency (optional) $4=strict(1) or soft(0) -check_exchange_alt_units() { - local label="$1" base="$2" want_cur="${3:-}" strict="${4:-1}" - local f code - base="${base%/}" - f=$(mktemp) - code=$(http_body "${base}/config" "$f") - if [ "$code" != "200" ]; then - rm -f "$f" - if [ "$strict" = "1" ]; then - fail "$label /config" "HTTP $code ($base)" - else - warn "$label /config" "HTTP $code ($base)" - fi - return - fi - local out ec - set +e - out=$(json_has_alt_unit_names "$f" "$want_cur" 2>&1) - ec=$? - set -e - rm -f "$f" - if [ "$ec" -eq 0 ]; then - ok "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" - else - if [ "$strict" = "1" ]; then - fail "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" - else - warn "$label alt_unit_names" "$(echo "$out" | tr '\n' '; ' | sed 's/; $//')" - fi - fi -} - -# From merchant /config JSON file: walk exchanges[] and check each base_url/config. -# Local stack hosts (hacktivism.ch or $EXCHANGE_PUBLIC host) are strict; others soft. -check_merchant_listed_exchanges_alt_units() { - local mer_json="$1" - local list - list=$(python3 - "$mer_json" <<'PY' -import json, sys -d = json.load(open(sys.argv[1])) -for e in d.get("exchanges") or []: - if not isinstance(e, dict): - continue - u = (e.get("base_url") or e.get("url") or "").rstrip("/") - c = e.get("currency") or "" - if u: - print(f"{c}\t{u}") -PY -) - if [ -z "$list" ]; then - fail "merchant exchanges[]" "empty — no exchanges to check for alt_unit_names" - return - fi - local line cur url strict host - while IFS=$'\t' read -r cur url; do - [ -n "$url" ] || continue - host="${url#https://}"; host="${host#http://}"; host="${host%%/*}" - strict=1 - case "$host" in - *hacktivism.ch) strict=1 ;; - *) - # foreign exchange (e.g. taler-ops) — soft unless it is our configured EXCHANGE_PUBLIC - if [ "$url" = "${EXCHANGE_PUBLIC}" ] || [ "$url" = "${EXCHANGE_PUBLIC}/" ]; then - strict=1 - else - strict=0 - fi - ;; - esac - check_exchange_alt_units "exchange ${cur:-?} ${host}" "$url" "$cur" "$strict" - done <<<"$list" -} - -# Live bank/merchant passwords: sibling **koopa-admin-secrets** (never in admin-log). -# Override: SECRETS_ROOT=/path/to/koopa-admin-secrets/koopa/host-root -# or KOOPA_ADMIN_SECRETS=/path/to/koopa-admin-secrets -SECRETS_ROOT="${SECRETS_ROOT:-}" -_secrets_search_log() { :; } # placeholder if we later want debug -if [ -z "$SECRETS_ROOT" ]; then - _mon_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - _admin_log="$(cd "${_mon_dir}/../.." && pwd)" - # Prefer explicit env pointing at the secrets *repo* root - if [ -n "${KOOPA_ADMIN_SECRETS:-}" ] && [ -d "${KOOPA_ADMIN_SECRETS}/koopa/host-root/taler-bank" ]; then - SECRETS_ROOT="${KOOPA_ADMIN_SECRETS}/koopa/host-root" - fi -fi -if [ -z "$SECRETS_ROOT" ]; then - _mon_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - _admin_log="$(cd "${_mon_dir}/../.." && pwd)" - for d in \ - "${_admin_log}/../koopa-admin-secrets/koopa/host-root" \ - "${_admin_log}/../../koopa-admin-secrets/koopa/host-root" \ - "${HOME}/taler/src/koopa-admin-secrets/koopa/host-root" \ - "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root" \ - "${HOME}/koopa-admin-secrets/koopa/host-root" \ - "/Users/newkamek/src/koopa/koopa-admin-secrets/koopa/host-root" \ - "$(cd "${_admin_log}/../.." 2>/dev/null && pwd)/koopa-admin-secrets/koopa/host-root" - do - # resolve .. without requiring the dir to exist first for cd - if [ -d "$d/taler-bank" ]; then - SECRETS_ROOT=$(cd "$d" && pwd) - break - fi - done - unset _mon_dir _admin_log d -fi - -read_secret() { - local rel="$1" base val="" - base=$(basename "$rel") - if [ -n "${SECRETS_ROOT:-}" ]; then - if [ -f "${SECRETS_ROOT}/${rel}" ]; then - tr -d '\n\r' <"${SECRETS_ROOT}/${rel}" - return 0 - fi - # also accept flat layout under host-root/ - if [ -f "${SECRETS_ROOT}/${base}" ]; then - tr -d '\n\r' <"${SECRETS_ROOT}/${base}" - return 0 - fi - fi - # Optional: copy under ~/.config/taler-landing/ (landing-stats style) - if [ -f "${HOME}/.config/taler-landing/${base}" ]; then - tr -d '\n\r' <"${HOME}/.config/taler-landing/${base}" - return 0 - fi - # Live on koopa host /root (same basenames as deploy) - if koopa_ssh_ok; then - val=$(koopa_ssh_run 12 "tr -d '\\n\\r' /dev/null || true" 2>/dev/null || true) - if [ -n "$val" ]; then - printf '%s' "$val" - return 0 - fi - # container path used by some installs - val=$(koopa_ssh_run 12 \ - "podman exec taler-hacktivism-bank tr -d '\\n\\r' /dev/null || true" 2>/dev/null || true) - if [ -n "$val" ]; then - printf '%s' "$val" - return 0 - fi - fi - return 1 -} - -# Human hint when secrets missing (e2e prereq) -secrets_hint() { - cat </dev/null || true) - if [ -n "$cand" ] && [ -f "$cand" ]; then - if head -1 "$cand" 2>/dev/null | grep -q 'node\|mjs'; then - # shebang node script or .mjs - echo "$cand"; return 0 - fi - # follow symlink into package tree - if [ -L "$cand" ]; then - c=$(readlink -f "$cand" 2>/dev/null || true) - [ -n "$c" ] && [ -f "$c" ] && { echo "$c"; return 0; } - fi - fi - return 1 -} diff --git a/scripts/taler-monitoring/metrics.sh b/scripts/taler-monitoring/metrics.sh deleted file mode 100644 index 4c440ea..0000000 --- a/scripts/taler-monitoring/metrics.sh +++ /dev/null @@ -1,1431 +0,0 @@ -# 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 - -# --------------------------------------------------------------------------- -# 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_SPENT, COINS_AMOUNT_CIRC, COINS_SUMMARY. -metrics_wallet_coins() { - local out="${1:-$METRICS_DIR/coins.json}" - 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 (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:-}" ] && [ -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}" "${ALT_UNITS_FILE:-}" <<'PY' -import json, re, sys -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): - 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 [] - -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() -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 c.get("denom_value") or "?" - st = str(c.get("coinStatus") or c.get("status") or "?") - by_denom_all[dv] += 1 - by_status[st] += 1 - 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) - -def denom_key(k): - try: - return float(str(k).split(":", 1)[-1]) - except Exception: - return 0.0 - -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 = [] -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": 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": 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) -open(out_path + ".total", "w").write(str(total)) -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}" - 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 (host + bank/exchange/merchant) --- -# Writes JSON to $1. RAM, process counts, DB sizes, disk I/O counters. -# koopa: KOOPA_SSH (+ fallbacks). stage-lfp: INSIDE_SSH (stagepaysan) + stage-lfp-* names. -metrics_taler_load() { - local out="${1:-$METRICS_DIR/load.json}" - local label="${2:-snap}" - 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 - local _m_bank _m_mer _m_ex _m_ssh - _m_bank="taler-hacktivism-bank" - _m_mer="taler-hacktivism" - _m_ex="taler-hacktivism-exchange-ansible" - _m_ssh="" - if [ "${INSIDE_PROFILE:-}" = "stage-lfp" ] \ - || [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \ - || { [ -n "${INSIDE_SSH:-}" ] && [ "${LOCAL_STACK:-0}" != "1" ]; }; then - _m_bank="${INSIDE_BANK_CTR:-stage-lfp-bank}" - _m_mer="${INSIDE_MERCHANT_CTR:-stage-lfp-merchant}" - _m_ex="${INSIDE_EXCHANGE_CTR:-stage-lfp-exchange-ansible}" - _m_ssh="${INSIDE_SSH:-francpaysan-stage-user}" - fi - remote_py=$(cat </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( - 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 -) - # Prefer stagepaysan (stage-lfp), else koopa SSH, else local podman only on LOCAL_STACK. - if [ -n "${_m_ssh}" ] && type mon_ssh_ok >/dev/null 2>&1 && mon_ssh_ok "${_m_ssh}"; then - raw=$(printf '%s' "$remote_py" | with_timeout "${METRICS_LOAD_SSH_TIMEOUT:-90}" \ - ssh "${SSH_BASE_OPTS[@]}" "${_m_ssh}" python3 - 2>/dev/null || true) - elif [ "${SKIP_SSH:-0}" != "1" ] && type koopa_ssh_ok >/dev/null 2>&1 && koopa_ssh_ok; then - 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 [ "${LOCAL_STACK:-0}" = "1" ] && command -v podman >/dev/null 2>&1; then - raw=$(printf '%s' "$remote_py" | python3 - 2>/dev/null || true) - else - printf '%s\n' "{\"ok\":false,\"reason\":\"no-ssh-for-load\",\"label\":\"$label\"}" >"$out" - return 0 - fi - if [ -z "$raw" ]; then - printf '%s\n' "{\"ok\":false,\"reason\":\"probe-failed\",\"label\":\"$label\",\"ssh\":\"${_m_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():]) - 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}" - 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 -} - -# Record a successful withdraw/pay amount for end statistics (one amount per line). -# $1=withdrawn|spent $2=amount (CUR:n) -metrics_record_flow() { - local kind="$1" amt="$2" - [ -n "$amt" ] || return 0 - mkdir -p "${METRICS_DIR}" 2>/dev/null || true - case "$kind" in - withdrawn|withdraw) printf '%s\n' "$amt" >>"${METRICS_DIR}/flow-withdrawn.txt" ;; - spent|pay) printf '%s\n' "$amt" >>"${METRICS_DIR}/flow-spent.txt" ;; - *) return 1 ;; - esac -} - -# Overall end-of-run statistics block -# Files under METRICS_DIR: -# coins-final.json, coins-history.tsv, perf-summary.json, -# load-before/after.json, flow-withdrawn.txt, flow-spent.txt, -# optional ladder TSV via METRICS_WITHDRAW_TSV / METRICS_PAY_TSV -metrics_print_overall() { - local title="${1:-overall statistics}" - section "statistics · $title" - metrics_print_final_stats || true - [ -n "${METRICS_EXTRA_LINES:-}" ] && printf '%s\n' "$METRICS_EXTRA_LINES" -} - -# Rich final dashboard: coins, withdrawn/spent, performance, tendencies -metrics_print_final_stats() { - python3 - \ - "${METRICS_DIR:-/tmp}" \ - "${ALT_UNITS_FILE:-}" \ - "${METRICS_WITHDRAW_TSV:-}" \ - "${METRICS_PAY_TSV:-}" \ - "${CUR:-GOA}" \ - <<'PY' -import csv, json, os, sys -from decimal import Decimal, InvalidOperation, ROUND_HALF_UP -from pathlib import Path - -mdir = Path(sys.argv[1]) -alt_path = sys.argv[2] or "" -wd_tsv = sys.argv[3] or "" -pay_tsv = sys.argv[4] or "" -cur = sys.argv[5] or "GOA" - -alt = {} -if alt_path and Path(alt_path).is_file(): - try: - alt = json.load(open(alt_path)) - except Exception: - alt = {} -if not alt: - alt = {"0": cur} - -def D(x, default=Decimal(0)): - try: - return Decimal(str(x)) - except Exception: - return default - -def parse_amt(s): - s = str(s or "").strip() - if not 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_alt(c, v: Decimal) -> str: - 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) - for sc, name in scales: - unit = Decimal(10) ** sc - coeff = absval / unit - if coeff >= 1: - if sc == 0: - return "%s %s" % (fmt_num(v), name) - return "%s %s (%s)" % (fmt_num(coeff if v >= 0 else -coeff), name, base_s) - return "%s %s (%s)" % (fmt_num(v), base_name, base_s) - -def sum_amount_file(path: Path): - total = Decimal(0) - ccy = cur - n = 0 - if not path.is_file(): - return ccy, total, n - for line in path.read_text().splitlines(): - line = line.strip() - if not line: - continue - c, v = parse_amt(line) - ccy = c - total += v - n += 1 - return ccy, total, n - -def sum_tsv_amounts(path, amount_col="amount", status_ok=None): - total = Decimal(0) - ccy = cur - n = 0 - if not path or not Path(path).is_file(): - return ccy, total, n - with open(path, newline="") as f: - for row in csv.DictReader(f, delimiter="\t"): - st = (row.get("status") or "").upper() - if status_ok is not None and st not in status_ok: - continue - c, v = parse_amt(row.get(amount_col) or "") - if v <= 0: - continue - ccy = c - total += v - n += 1 - return ccy, total, n - -def trend_label(delta, eps=Decimal("0.01")): - if delta > eps: - return "↑ rising" - if delta < -eps: - return "↓ falling" - return "→ stable" - -def half_trend(values): - """Compare avg of second half vs first half of a list of numbers.""" - xs = [float(x) for x in values if x is not None] - if len(xs) < 4: - if len(xs) < 2: - return "n/a (few samples)", None, None - a, b = xs[0], xs[-1] - return trend_label(Decimal(str(b - a))), a, b - mid = len(xs) // 2 - first = sum(xs[:mid]) / mid - second = sum(xs[mid:]) / (len(xs) - mid) - d = second - first - lab = "↑ slowing (worse)" if d > 0.05 * max(abs(first), 1) else ( - "↓ faster (better)" if d < -0.05 * max(abs(first), 1) else "→ steady" - ) - return lab, first, second - -print("") -print("╔══════════════════════════════════════════════════════════════╗") -print("║ FINAL STATISTICS ║") -print("╚══════════════════════════════════════════════════════════════╝") - -# --- coins snapshot --- -coins = {} -cf = mdir / "coins-final.json" -if cf.is_file(): - try: - coins = json.load(open(cf)) - except Exception: - coins = {} - -print("") -print("── Coins (wallet) ─────────────────────────────────────────────") -if coins.get("ok"): - circ_n = coins.get("in_circulation") or 0 - spent_n = coins.get("spent") or 0 - total_n = coins.get("total_coins") or 0 - amt_alt = coins.get("amount_in_circulation_alt") or coins.get("amount_in_circulation_s") or "?" - spent_alt = coins.get("amount_spent_alt") or coins.get("amount_spent_s") or "0" - print(" total coins %s" % total_n) - print(" in circulation %s coins · %s" % (circ_n, amt_alt)) - print(" spent (in dump) %s coins · %s" % (spent_n, spent_alt)) - circ = coins.get("by_denom_circulation") or [] - if circ: - print(" denoms in circ:") - for x in circ: - print( - " · %s × %s = %s" - % ( - x.get("denom_alt") or x.get("denom"), - x.get("count"), - x.get("amount_alt") or x.get("amount"), - ) - ) - spent = coins.get("by_denom_spent") or [] - if spent: - print(" denoms spent:") - for x in spent: - print( - " · %s × %s = %s" - % ( - x.get("denom_alt") or x.get("denom"), - x.get("count"), - x.get("amount_alt") or x.get("amount"), - ) - ) -else: - print(" (no coins-final.json — run with wallet snaps)") - -# --- withdrawn / spent flows --- -print("") -print("── Money flow (this run) ──────────────────────────────────────") -# Prefer ladder TSV if provided; else flow-*.txt from e2e -wd_ok = {"OK", "OK_BANK", "ZERO_REJECT", "ZERO_SKIP", "SKIP_DENOM", "CEILING_REJECT"} -pay_ok = {"OK", "ZERO_SKIP", "CEILING_SKIP", "CEILING_REJECT"} -c1, wd_sum, wd_n = sum_tsv_amounts(wd_tsv, "amount", None) -# count successful withdraws by status prefix OK or soft -if wd_tsv and Path(wd_tsv).is_file(): - c1, wd_sum, wd_n = Decimal(0), Decimal(0), 0 - ccy = cur - with open(wd_tsv, newline="") as f: - for row in csv.DictReader(f, delimiter="\t"): - st = (row.get("status") or "").upper() - # count value when mint/settle produced money in wallet - if st in ("OK", "OK_BANK"): - c, v = parse_amt(row.get("amount")) - ccy, wd_sum, wd_n = c, wd_sum + v, wd_n + 1 - c1 = ccy -else: - c1, wd_sum, wd_n = sum_amount_file(mdir / "flow-withdrawn.txt") - -if pay_tsv and Path(pay_tsv).is_file(): - c2, pay_sum, pay_n = Decimal(0), Decimal(0), 0 - ccy = cur - with open(pay_tsv, newline="") as f: - for row in csv.DictReader(f, delimiter="\t"): - st = (row.get("status") or "").upper() - if st == "OK": - c, v = parse_amt(row.get("amount")) - ccy, pay_sum, pay_n = c, pay_sum + v, pay_n + 1 - c2 = ccy -else: - c2, pay_sum, pay_n = sum_amount_file(mdir / "flow-spent.txt") - -print(" withdrawn %s events · %s" % (wd_n, fmt_alt(c1, wd_sum))) -print(" spent (paid) %s events · %s" % (pay_n, fmt_alt(c2, pay_sum))) -net = wd_sum - pay_sum -print(" net (wd − spent) %s %s" % (fmt_alt(c1, net), trend_label(net))) -if coins.get("ok"): - # circulation amount vs net flow - circ_map = coins.get("amount_in_circulation") or {} - circ_v = Decimal(0) - for _c, vs in circ_map.items(): - circ_v += D(vs) - print(" wallet circ now %s" % fmt_alt(cur, circ_v)) - print(" residual check wallet_circ vs net: Δ %s" - % fmt_alt(cur, circ_v - net)) - -# --- coin history tendency --- -print("") -print("── Tendency · coins over run ─────────────────────────────────") -hist = mdir / "coins-history.tsv" -if hist.is_file() and hist.stat().st_size > 0: - rows = list(csv.DictReader(open(hist), delimiter="\t")) - if rows: - def circ_amt(row): - # amount_circ column may be "GOA:20" or alt form — try numeric from total/in_circ - a = row.get("amount_circ") or "0" - if ":" in a and " " not in a: - return parse_amt(a)[1] - # try extract GOA: from parentheses - import re - m = re.search(r"\(([^)]+:[0-9.]+)\)", a) - if m: - return parse_amt(m.group(1))[1] - return D(row.get("in_circ") or 0) - - first, last = rows[0], rows[-1] - c0, c1_ = D(first.get("in_circ") or 0), D(last.get("in_circ") or 0) - a0, a1 = circ_amt(first), circ_amt(last) - print(" samples %d snaps (%s → %s)" - % (len(rows), first.get("label"), last.get("label"))) - print(" coins in circ %s → %s (Δ %+d) %s" - % (fmt_num(c0), fmt_num(c1_), int(c1_ - c0), trend_label(c1_ - c0))) - print(" amount in circ %s → %s %s" - % (fmt_alt(cur, a0), fmt_alt(cur, a1), trend_label(a1 - a0))) - # simple linear slope on coin count - if len(rows) >= 3: - ys = [float(D(r.get("in_circ") or 0)) for r in rows] - n = len(ys) - xs = list(range(n)) - xm, ym = sum(xs) / n, sum(ys) / n - num = sum((x - xm) * (y - ym) for x, y in zip(xs, ys)) - den = sum((x - xm) ** 2 for x in xs) or 1 - slope = num / den - print(" trend slope %+.3f coins/snap %s" - % (slope, "↑ accumulating" if slope > 0.05 else ("↓ draining" if slope < -0.05 else "→ flat"))) - print(" history (label · in_circ · amount):") - for r in rows[-12:]: # last 12 - print(" · %-28s circ=%-4s %s" - % (r.get("label", "?")[:28], r.get("in_circ"), r.get("amount_circ"))) - if len(rows) > 12: - print(" · … (%d earlier snaps)" % (len(rows) - 12)) - else: - print(" (empty history)") -else: - print(" (no coins-history.tsv)") - -# --- performance --- -print("") -print("── Performance indicators ───────────────────────────────────") -perf = {} -pf = mdir / "perf-summary.json" -if pf.is_file(): - try: - perf = json.load(open(pf)) - except Exception: - perf = {} - -def print_bucket(name, v): - if not isinstance(v, dict) or not v.get("n"): - return - print( - " %-14s n=%s min=%sms p50=%sms avg=%sms max=%sms" - % (name, v.get("n"), v.get("min_ms"), v.get("p50_ms"), v.get("avg_ms"), v.get("max_ms")) - ) - -if perf: - for k, v in perf.items(): - print_bucket(k, v) -else: - print(" (no perf-summary.json)") - -# per-rung timing tendency from ladder TSV -for label, path, cols in ( - ("withdraw rungs", wd_tsv, ("ms_total", "ms_mint", "ms_settle")), - ("pay rungs", pay_tsv, ("ms_total", "ms_order", "ms_handle", "ms_settle")), -): - if not path or not Path(path).is_file(): - continue - with open(path, newline="") as f: - rrows = list(csv.DictReader(f, delimiter="\t")) - if len(rrows) < 2: - continue - print(" tendency · %s:" % label) - for col in cols: - vals = [] - for r in rrows: - try: - vals.append(int(r.get(col) or 0)) - except Exception: - pass - vals = [v for v in vals if v > 0] - if len(vals) < 2: - continue - lab, a, b = half_trend(vals) - if a is None: - print(" %-12s %s" % (col, lab)) - else: - print(" %-12s first-half avg=%.0fms → second-half avg=%.0fms %s" - % (col, a, b, lab)) - -# --- load --- -print("") -print("── Host / container load ────────────────────────────────────") -lb, la = mdir / "load-before.json", mdir / "load-after.json" - -def load_brief(path, tag): - if not path.is_file(): - print(" %s: (missing)" % tag) - return None - try: - d = json.load(open(path)) - except Exception: - print(" %s: (unreadable)" % tag) - return None - if not d.get("ok"): - print(" %s: %s" % (tag, d.get("reason", "?"))) - return d - h = d.get("host") or {} - mem = h.get("memory") or {} - la_ = h.get("loadavg") or [] - avail = mem.get("mem_available_b") - tot = mem.get("mem_total_b") - def gi(b): - return "?" if b is None else "%.2f GiB" % (b / 1024 / 1024 / 1024) - used = (tot - avail) if (tot is not None and avail is not None) else None - print(" %s loadavg=%s mem_used=%s / %s" - % (tag, la_, gi(used), gi(tot))) - for role in ("bank", "exchange", "merchant"): - c = (d.get("taler") or {}).get(role) or {} - if not c.get("running"): - continue - pr = c.get("processes") or {} - st = c.get("podman_stats") or {} - rss = pr.get("rss_total_b") - rss_s = "?" if rss is None else "%.2f GiB" % (rss / 1024 / 1024 / 1024) - print(" %-8s rss=%s cpu=%s procs=%s" - % (role, rss_s, st.get("cpu_pct") or "?", pr.get("proc_total"))) - return d - -db = load_brief(lb, "before") -da = load_brief(la, "after") -if db and da and db.get("ok") and da.get("ok"): - bla = (db.get("host") or {}).get("loadavg") or [0] - ala = (da.get("host") or {}).get("loadavg") or [0] - if bla and ala: - dload = float(ala[0]) - float(bla[0]) - print(" loadavg1 tendency %.2f → %.2f (Δ %+.2f) %s" - % (float(bla[0]), float(ala[0]), dload, - "↑ higher load" if dload > 0.1 else ("↓ lower load" if dload < -0.1 else "→ steady"))) - bm = (db.get("host") or {}).get("memory") or {} - am = (da.get("host") or {}).get("memory") or {} - if bm.get("mem_available_b") is not None and am.get("mem_available_b") is not None: - dmem = (am["mem_available_b"] - bm["mem_available_b"]) / 1024 / 1024 / 1024 - print(" mem_avail tendency %.2f → %.2f GiB (Δ %+.3f) %s" - % (bm["mem_available_b"] / 1024**3, am["mem_available_b"] / 1024**3, dmem, - "↑ more free" if dmem > 0.05 else ("↓ less free" if dmem < -0.05 else "→ steady"))) - -print("") -print("──────────────────────────────────────────────────────────────") -PY -} diff --git a/scripts/taler-monitoring/secrets.env.example b/scripts/taler-monitoring/secrets.env.example deleted file mode 100644 index 87ec88c..0000000 --- a/scripts/taler-monitoring/secrets.env.example +++ /dev/null @@ -1,87 +0,0 @@ -# taler-monitoring — local secrets template -# -# Copy to secrets.env (gitignored) and fill values, OR export the same vars: -# cp secrets.env.example secrets.env -# $EDITOR secrets.env -# -# Never commit secrets.env. Prefer file paths under koopa-admin-secrets -# for long-term storage; this file is for laptop one-shots / overrides. -# -# Load order: env already set > secrets.env > SECRETS_ROOT files > SSH koopa - -# --- Preferred: point at secrets tree (no passwords in this file) --- -# Sibling of admin-log on this machine: -# KOOPA_ADMIN_SECRETS=$HOME/src/koopa/koopa-admin-secrets -# Or host-root only: -# SECRETS_ROOT=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root - -# --- Or paste passwords / tokens (local GOA e2e) --- -# Bank admin (libeufin-bank admin) — funds / creates accounts in e2e -# E2E_BANK_ADMIN_PASS= -# BANK_ADMIN_PASS= # alias, same meaning - -# Merchant instance token for private orders (goa-demo-cp4zqk on hacktivism) -# Full "secret-token:…" or raw secret (Bearer prefix added by e2e) -# E2E_MERCHANT_TOKEN= -# MERCHANT_TOKEN= -# MERCHANT_INSTANCE_PASSWORD= - -# --- FrancPaysan STAGE (TESTPAYSAN) e2e --- -# Default phases for -d stage.lefrancpaysan.ch: urls + inside + versions + e2e -# inside uses francpaysan-stage-user (stagepaysan); stats from public HTTPS. -# Admin credit (required for ATM withdraw ladder on stage): -# E2E_BANK_ADMIN_PASS=… # or auto-load via SSH francpaysan-host -# FRANCPAYSAN_SSH=francpaysan-host -# FRANCPAYSAN_SECRETS=$HOME/francpaysan-secrets -# Public shop pays use E2E_USE_TEMPLATES=1 (default on TESTPAYSAN) — no merchant token. - -# Optional overrides -# MERCHANT_INSTANCE=goa-demo-cp4zqk -# EXPECT_CURRENCY=GOA -# E2E_TIMEOUT=340 -# PROGRESS_TOTAL=120 -# PROGRESS_SHOW_EVERY=8 -# PROGRESS_OFF=1 - -# --- Merchant SPA /webui/ fingerprints (urls phase, group www.webui) --- -# EXPECT_WEBUI_VERSION=1.6.11 -# EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 -# WEBUI_OVERLAY_DENY=c778af04|master-11340 -# CHECK_WEBUI_SPA=0 - -# --- auth401 phase (WebUI signup/login/pwchange/admin + case matrix) --- -# Default FULL=1 creates throwaway MIX instance + runs all groups. -# Stage also loads durable mon401 for extra durable.* checks: -# AUTH401_FULL=1 -# AUTH401_CONTINUE=1 # optional: hard prereqs soft; full ERROR list always via EXIT trap -# CONTINUE_ON_ERROR=1 # alias for AUTH401_CONTINUE -# (auth401 no longer uses set -e — silent mid-run abort fixed) -# AUTH401_CREATE=1 -# AUTH401_PREFER_DURABLE=0 # 1 = only mon401, skip throwaway signup -# AUTH401_INSTANCE=mon401 -# AUTH401_PASSWORD= -# AUTH401_DURABLE_ID=mon401 -# AUTH401_CREATE_PASSWORD=Mon401-Test-Pass! -# AUTH401_SKIP_IF_MFA=1 -# AUTH401_BEARER= # default-instance-token (not admin) -# AUTH401_ADMIN_TOKEN= # required for admin-create group -# AUTH401_ADMIN_PASSWORD= # or login as AUTH401_ADMIN_USER=admin -# AUTH401_ADMIN_USER=admin -# FRANCPAYSAN_SECRETS=$HOME/francpaysan-secrets - -# --- Ladder (GOA withdraw ladder · explorer pool) --- -# Prefer SECRETS_ROOT / KOOPA_ADMIN_SECRETS (bank-explorer-password.txt). -# Overrides only if needed: -# EXP_USER=explorer -# EXP_PW= -# EXP_PW_FILE=$HOME/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt -# CLI_JS=$HOME/taler/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs - -# Values that typically appear on a full stack (for orientation only): -# bank-admin-password.txt -# bank-explorer-password.txt -# bank-exchange-password.txt -# bank-goa-demo-cp4zqk-password.txt -# merchant-goa-demo-cp4zqk-password.txt -# exchange-attribute-encryption.secret.conf -# (see ../SECRETS.md and koopa-admin-secrets/MAP.md) diff --git a/scripts/taler-monitoring/site-gen/README.md b/scripts/taler-monitoring/site-gen/README.md deleted file mode 100644 index 82e52d2..0000000 --- a/scripts/taler-monitoring/site-gen/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# monitoring site-gen - -Build **console-style HTML** from `taler-monitoring` runs and stage them for -**host Caddy** (`/monitoring`, `/monitoring_err`). - -Same **global reporting defaults** as host-agents (`run-host-report.sh`): - -- **`RUN_TIMEOUT=600`** on each suite run -- **line-buffered** logs (continuous write) -- **always** write HTML (first run / empty logs too) -- Forgejo **commit link** in the page header - -Applies to all nine fronts (GOA + FP stage + FP prod) in the default `SITES` list. - -| Host | Role | -|------|------| -| **firecuda-external** | optional runner (public DNS) | -| **koopa-external** | deploy staging + Caddy (root service) | -| **host-agents** | primary: GOA + FP on-box (`../host-agent/`) | - -No secrets in `settings.conf` — only hosts, paths, site list. - -## firecuda-external (outside-only) — **disabled for now** - -**firecuda-external could** run public-only monitoring (`SKIP_SSH=1`, phases -`urls versions`) on a schedule (launchd every 4h) and write HTML under -`~/var/taler-monitoring-sites-work/html/`. Scripts for that still live here -(`install-firecuda-timer.sh`, `run-on-firecuda.sh`, plist template). - -**Current policy: do not run a real launchd job on firecuda.** -Primary GOA monitoring is **koopa host-agent** (`host-agent/`, user `hernani`, -local `podman exec`). Re-enable firecuda only if you explicitly want a second, -outside-only view. - -```bash -# optional one-shot (no timer): -# ssh firecuda-external '~/taler-monitoring-site-gen/site-gen/run-on-firecuda.sh' - -# if a timer was installed earlier, ensure it is off: -# ssh firecuda-external 'launchctl list | grep taler-monitoring || echo off' -``` - -Disabled plists may remain as `*.plist.disabled-*` under `~/Library/LaunchAgents/`. - -## Quick start (laptop) - -```bash -cd scripts/taler-monitoring/site-gen -cp settings.conf.example settings.conf # optional -chmod +x generate-monitoring-sites.sh deploy-monitoring-sites.sh console_to_html.py - -ONLY_HOSTS="bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch" \ - SKIP_SSH=1 RUNNER_SSH= \ - ./generate-monitoring-sites.sh - -./deploy-monitoring-sites.sh -``` - -Then on koopa **as root**: see **[ROOT-ON-KOOPA.md](./ROOT-ON-KOOPA.md)**. - -## URL rules - -| Path | When | -|------|------| -| `/monitoring_err/` | always: full console log + error list on top (CONTINUE_ON_ERROR run) | -| `/monitoring/` | **clean** full log if last strict run exit 0; else **stub** linking to `/monitoring_err/` | - -Footer links **Forgejo commit** of the admin-log tree used for that run. - -## Layout - -```text -site-gen/ - settings.conf.example - generate-monitoring-sites.sh - deploy-monitoring-sites.sh - console_to_html.py - caddy-monitoring-handles.snippet - ROOT-ON-KOOPA.md - README.md -``` diff --git a/scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md b/scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md deleted file mode 100644 index 604b997..0000000 --- a/scripts/taler-monitoring/site-gen/ROOT-ON-KOOPA.md +++ /dev/null @@ -1,92 +0,0 @@ -# Root on koopa (hacktivism monitoring sites) - -Host Caddy runs as **root/systemd** (`caddy` user). Static monitoring HTML is -**not** put into Taler containers — only host paths + Caddy `handle`. - -After `generate-monitoring-sites.sh` + `deploy-monitoring-sites.sh` (as hernani), -files live in: - -```text -/home/hernani/monitoring-sites-staging//monitoring/index.html -/home/hernani/monitoring-sites-staging//monitoring_err/index.html -``` - -## One-shot as root (copy + Caddy) - -```bash -# on koopa (koopa-external), as root: - -# 1) web root -install -d -o caddy -g caddy -m 755 /var/www/monitoring-sites -rsync -a --delete /home/hernani/monitoring-sites-staging/ /var/www/monitoring-sites/ -chown -R caddy:caddy /var/www/monitoring-sites - -# 2) Caddyfile — merge handles from admin-log mirror, then: -# (either edit /etc/caddy/Caddyfile by hand using snippet below, -# or copy full mirror after review) -# -# install -m 644 /home/hernani/koopa-admin-log/configs/caddy/Caddyfile /etc/caddy/Caddyfile -# # or: ~/koopa-caddy/apply.sh after syncing Caddyfile into ~/koopa-caddy/ - -caddy validate --config /etc/caddy/Caddyfile -systemctl reload caddy -systemctl is-active caddy -``` - -## Snippet per hacktivism site (inside each `*.hacktivism.ch { }` block) - -Place **before** the catch-all `reverse_proxy` (same idea as `/intro*`): - -```caddy - # Public taler-monitoring console HTML (static; host only) - handle /monitoring_err* { - root * /var/www/monitoring-sites/{host} - rewrite * /monitoring_err{uri} - # uri is /monitoring_err or /monitoring_err/ → serve directory index - file_server - } - handle /monitoring* { - root * /var/www/monitoring-sites/{host} - file_server - } -``` - -Simpler layout (recommended): path on disk matches URL under host root: - -```text -/var/www/monitoring-sites/bank.hacktivism.ch/monitoring/index.html -/var/www/monitoring-sites/bank.hacktivism.ch/monitoring_err/index.html -``` - -```caddy - handle_path /monitoring_err/* { - root * /var/www/monitoring-sites/{host}/monitoring_err - file_server - } - handle /monitoring_err { - redir /monitoring_err/ 302 - } - handle_path /monitoring/* { - root * /var/www/monitoring-sites/{host}/monitoring - file_server - } - handle /monitoring { - redir /monitoring/ 302 - } -``` - -Canonical snippet is also in `caddy-monitoring-handles.snippet` and applied -in `configs/caddy/Caddyfile` for the three GOA hosts. - -## Check - -```bash -curl -sS -o /dev/null -w '%{http_code}\n' https://bank.hacktivism.ch/monitoring/ -curl -sS -o /dev/null -w '%{http_code}\n' https://bank.hacktivism.ch/monitoring_err/ -curl -sS https://bank.hacktivism.ch/monitoring_err/ | head -``` - -## Not needed as root - -- Running `taler-monitoring` (use **firecuda-external** / laptop) -- Writing into podman Taler containers diff --git a/scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet b/scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet deleted file mode 100644 index 1c09531..0000000 --- a/scripts/taler-monitoring/site-gen/caddy-monitoring-handles.snippet +++ /dev/null @@ -1,57 +0,0 @@ -# Host Caddy on koopa (root service). Paste BEFORE catch-all reverse_proxy. -# -# Disk layout: -# /var/www/monitoring-sites/{host}/monitoring/index.html -# /var/www/monitoring-sites/{host}/monitoring_err/index.html -# /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface*/index.html -# /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy*/index.html -# -# /monitoring* → bank + exchange + taler.hacktivism.ch -# /taler-monitoring-surface* → taler.hacktivism.ch only -# /taler-monitoring-aptdeploy* → taler.hacktivism.ch only - -# --- only inside taler.hacktivism.ch { ... } --- - handle /taler-monitoring-surface_err { - redir /taler-monitoring-surface_err/ 302 - } - handle /taler-monitoring-surface_err/ { - root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface_err - file_server - } - handle /taler-monitoring-surface { - redir /taler-monitoring-surface/ 302 - } - handle /taler-monitoring-surface/ { - root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface - file_server - } - handle /taler-monitoring-aptdeploy_err { - redir /taler-monitoring-aptdeploy_err/ 302 - } - handle /taler-monitoring-aptdeploy_err/ { - root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy_err - file_server - } - handle /taler-monitoring-aptdeploy { - redir /taler-monitoring-aptdeploy/ 302 - } - handle /taler-monitoring-aptdeploy/ { - root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy - file_server - } - -# --- bank + exchange + taler (each site block) --- - handle /monitoring_err { - redir /monitoring_err/ 302 - } - handle /monitoring_err/ { - root * /var/www/monitoring-sites/{host}/monitoring_err - file_server - } - handle /monitoring { - redir /monitoring/ 302 - } - handle /monitoring/ { - root * /var/www/monitoring-sites/{host}/monitoring - file_server - } diff --git a/scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist b/scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist deleted file mode 100644 index 0b6f68b..0000000 --- a/scripts/taler-monitoring/site-gen/com.hacktivism.taler-monitoring-sites.plist +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - Label - com.hacktivism.taler-monitoring-sites - ProgramArguments - - /bin/bash - __SITE_GEN__/run-on-firecuda.sh - - StartInterval - 14400 - RunAtLoad - - StandardOutPath - __HOME__/Library/Logs/taler-monitoring-sites/launchd.out.log - StandardErrorPath - __HOME__/Library/Logs/taler-monitoring-sites/launchd.err.log - EnvironmentVariables - - PATH - /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin - SKIP_SSH - 1 - CONTINUE_ON_ERROR - 1 - - - diff --git a/scripts/taler-monitoring/site-gen/console_to_html.py b/scripts/taler-monitoring/site-gen/console_to_html.py deleted file mode 100755 index 14aef2a..0000000 --- a/scripts/taler-monitoring/site-gen/console_to_html.py +++ /dev/null @@ -1,935 +0,0 @@ -#!/usr/bin/env python3 -"""Convert taler-monitoring console log to a console-feel HTML page.""" -from __future__ import annotations - -import argparse -import html -import re -from datetime import datetime, timezone -from pathlib import Path - -# ANSI strip -ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") -ERR_BULLET_RE = re.compile(r"^\s*[•·*-]\s+(?P.+)$") -TID_RE = re.compile(r"\b(?P[a-z0-9_.-]+-\d+)\b", re.I) - - -def strip_ansi(s: str) -> str: - return ANSI_RE.sub("", s) - - -def classify(line: str) -> str: - u = line.upper() - # section / summary headers before badge matching (they contain the word ERROR) - if "SUMMARY" in u or "--- ERRORS" in u or "ERRORS ·" in u or "ERRORS (" in u: - return "meta" - if "numbered checks" in line.lower() or "totals:" in line.lower(): - return "meta" - if "BLOCKER" in u or "┌ BLOCKER" in u: - return "blocker" - if "ERROR" in u or "┌ ERROR" in u: - return "error" - if "WARN" in u or "┌ WARN" in u: - return "warn" - if "INFO" in u or "┌ INFO" in u: - return "info" - if " OK" in u or "[OK" in u or "┌ OK" in u or u.strip().startswith("OK"): - return "ok" - if u.strip().startswith("-- ") or u.strip().startswith("=="): - return "section" - return "plain" - - -def is_run_timeout_text(text: str) -> bool: - u = text.upper() - return "RUN.TIMEOUT" in u or "RUN_TIMEOUT" in u or "RUN TIMEOUT" in u - - -def is_summary_error_line(ln: str) -> bool: - """Skip summary/header lines that contain ERROR but are not checks.""" - low = ln.lower() - if "failed — see" in low or "failed - see" in low: - return True - if "errors · failed" in low or "errors (failed" in low: - return True - if "--- errors" in low: - return True - if "┌ summary" in low or "[ summary" in low: - return True - if "totals:" in low: - return True - # header-ish RUN_TIMEOUT= without error badge - if is_run_timeout_text(ln) and "ERROR" not in ln.upper() and "run.timeout" not in low: - return True - return False - - -def extract_errors(lines: list[str]) -> list[str]: - errs: list[str] = [] - timeout_detail = "" - in_block = False - for ln in lines: - if "--- ERRORS" in ln or "ERRORS (failed" in ln or "RUN TIMEOUT · extraordinary" in ln: - in_block = True - continue - if in_block: - if ln.strip().startswith("---") or "[ SUMMARY" in ln or "totals:" in ln: - in_block = False - continue - m = ERR_BULLET_RE.match(ln) - if m: - body = m.group("body").strip() - if is_run_timeout_text(body): - timeout_detail = body - else: - errs.append(body) - elif "ERROR" in ln.upper() and ln.strip() and not is_run_timeout_text(ln): - errs.append(ln.strip()) - if re.search(r"\bERROR\b", ln, re.I) and "--- ERRORS" not in ln: - if is_run_timeout_text(ln): - if "run.timeout-01" in ln.lower() or "RUN_TIMEOUT exceeded" in ln: - timeout_detail = re.sub(r"\s+", " ", strip_ansi(ln)).strip() - continue - if is_summary_error_line(ln): - continue - body = re.sub(r"^.*\bERROR\b\s*\]?\s*", "", ln, flags=re.I).strip(" ·") - if body and body not in errs and "failed — see" not in body.lower(): - if re.search( - r"#\d+|www\.|e2e\.|auth401\.|versions\.|inside\.|surface\.|aptdeploy\.", - body, - ): - errs.append(body) - seen: set[str] = set() - out: list[str] = [] - for e in errs: - if e in seen or is_run_timeout_text(e): - continue - seen.add(e) - out.append(e) - if timeout_detail or any( - is_run_timeout_text(l) and ("ERROR" in l.upper() or "run.timeout" in l.lower()) - for l in lines - ): - # only if real timeout ERROR badge, not mere RUN_TIMEOUT= header - if timeout_detail or any( - "run.timeout-01" in l.lower() or "RUN_TIMEOUT exceeded" in l for l in lines - ): - canon = timeout_detail or "run.timeout-01 [run] RUN_TIMEOUT exceeded" - out = [canon] + out - return out - - -def count_status(lines: list[str]) -> tuple[int, int, int | None, int | None]: - """Return (error_count, warn_count, first_error_idx, first_warn_idx).""" - n_err = 0 - n_warn = 0 - first_err: int | None = None - first_warn: int | None = None - for i, ln in enumerate(lines): - kind = classify(ln) - if kind in ("error", "blocker"): - if is_summary_error_line(ln): - continue - n_err += 1 - if first_err is None: - first_err = i - elif kind == "warn": - n_warn += 1 - if first_warn is None: - first_warn = i - return n_err, n_warn, first_err, first_warn - - -def slug_error(i: int, text: str) -> str: - if is_run_timeout_text(text): - return "err-run-timeout" - m = TID_RE.search(text) - if m: - return "err-" + re.sub(r"[^a-z0-9_-]+", "-", m.group("tid").lower()) - return f"err-{i:03d}" - - -def render_line(ln: str, err_slugs: dict[str, str], extra_ids: list[str] | None = None) -> str: - kind = classify(ln) - esc = html.escape(ln) - for tid, slug in err_slugs.items(): - if tid in ln: - esc = esc.replace( - html.escape(tid), - f'{html.escape(tid)}', - ) - id_attr = "" - if extra_ids: - id_attr = f' id="{" ".join(extra_ids)}"' # invalid multi-id; use first only - # HTML allows one id — join carefully - id_attr = f' id="{html.escape(extra_ids[0])}"' - if len(extra_ids) > 1: - # secondary anchors as empty spans prepended - spans = "".join(f'' for x in extra_ids[1:]) - return f'{spans}{esc}\n' - return f'{esc}\n' - - -def status_level(n_err: int, n_warn: int) -> str: - if n_err > 0: - return "red" - if n_warn > 0: - return "yellow" - return "green" - - -def extract_phases(log_text: str) -> str: - """Best-effort phases string from host-agent / taler-monitoring log header.""" - for pat in ( - r"phases=([a-z0-9 _.-]+)", - r"· phases=([a-z0-9 _.-]+)", - r"phases\s{2,}([a-z0-9 _.-]+)", - r"^\s*phases\s+([a-z0-9 _.-]+)\s*$", - ): - m = re.search(pat, log_text, re.I | re.M) - if m: - return re.sub(r"\s+", " ", m.group(1)).strip(" ·") - return "" - - -def monitoring_scope( - page_label: str, - hostname: str, - log_text: str = "", -) -> dict[str, object]: - """ - Human-readable description of what this monitoring page covers. - Returned keys: title, summary, items (list[str]), kind - """ - label = (page_label or "monitoring").lower().replace("_", "-") - phases = extract_phases(log_text) - host = hostname or "?" - - if "surface" in label: - return { - "kind": "surface", - "title": "Remote ecosystem surface inventory", - "summary": "Public catalog hosts · DNS / TCP / HTTPS / TLS · Server version · CVE", - "items": [ - "Outside-in only: no SSH and no podman exec on remote targets", - "Catalog (surface-catalog.conf): taler.net, demo/test, gnunet.org, " - "taler-ops.ch, taler-systems.com, deb.taler.net, ftp.gnu.org, …", - "Per host: DNS resolve, ICMP (info), open ports, protocol probes, " - "TLS cert expiry, Server-header software version, optional CVE check", - f"HTML published only on taler.hacktivism.ch " - f"(this page host: {host})", - "Timer: taler-monitoring-surface.timer (hourly)", - ], - } - - if "aptdeploy" in label or "apt-deploy" in label or "apt_src" in label: - return { - "kind": "aptdeploy", - "title": "apt-src merchant deploy tests (podman on koopa)", - "summary": "4 containers · fresh + upgrade tracks · trixie & trixie-testing", - "items": [ - "koopa-taler-deploy-test-apt-src-trixie (fresh, suite trixie)", - "koopa-taler-deploy-test-apt-src-trixie-testing (fresh, trixie-testing)", - "koopa-taler-deploy-test-apt-src-trixie-upgrade (upgrade track)", - "koopa-taler-deploy-test-apt-src-trixie-testing-upgrade (upgrade track)", - "Checks: packages, taler-merchant-httpd --version / ldd, systemd unit, " - "optional local /config probe — not public nginx/HTTPS", - f"HTML published only on taler.hacktivism.ch (this page host: {host})", - "Timer: taler-monitoring-aptdeploy.timer (4h)", - ], - } - - # Default: stack /monitoring pages (bank, exchange, merchant) - phase_txt = phases or "urls · inside · versions" - role = "stack" - if host.startswith("bank."): - role = "bank" - focus = "Libeufin bank public HTTPS + container inside checks" - elif host.startswith("exchange.") or host.startswith("stage.exchange."): - role = "exchange" - focus = "Exchange public HTTPS + container inside checks" - elif host.startswith("taler.") or "merchant" in host or host.startswith("monnaie."): - role = "merchant" - focus = "Merchant backend / SPA public HTTPS + container inside checks" - else: - focus = "Taler stack public + inside checks" - - items = [ - f"Focus: {focus}", - f"Phases this run: {phase_txt}", - "Typical: public URLs (HTTPS, QR, perf), inside (podman/ssh), package versions", - f"HTML host: {host}", - "Timer: taler-monitoring-hacktivism (path + 4h) · /monitoring on bank, exchange, taler", - ] - if phases: - items.insert(2, f"Log header phases= {phases}") - - return { - "kind": role, - "title": f"Stack monitoring · {host}", - "summary": f"{phase_txt} · public + inside", - "items": items, - } - - -def sticky_bar_html( - *, - generated_at: str, - generated_iso: str, - n_err: int, - n_warn: int, - hostname: str, - page_label: str, - mode: str, - commit_html: str, - suite_path: str, - link_other: str | None, - first_err_href: str | None, - first_warn_href: str | None, - scope: dict[str, object] | None = None, -) -> str: - level = status_level(n_err, n_warn) - if level == "red": - status_txt = "ERRORS" - elif level == "yellow": - status_txt = "WARNINGS" - else: - status_txt = "OK" - - if first_err_href and n_err: - err_stat = ( - f'{n_err} error{"s" if n_err != 1 else ""}' - ) - else: - err_stat = f'{n_err} errors' - - if first_warn_href and n_warn: - warn_stat = ( - f'{n_warn} warning{"s" if n_warn != 1 else ""}' - ) - else: - warn_stat = f'{n_warn} warnings' - - other = "" - if link_other: - other = ( - f'' - f"{html.escape(link_other)}" - ) - - if scope is None: - scope = monitoring_scope(page_label, hostname, "") - scope_title = html.escape(str(scope.get("title") or "Monitoring")) - scope_summary = html.escape(str(scope.get("summary") or "")) - scope_kind = html.escape(str(scope.get("kind") or "monitoring")) - lis = "\n".join( - f"
  • {html.escape(str(it))}
  • " for it in (scope.get("items") or []) - ) - suite_bit = ( - f" · {html.escape(suite_path)}" if suite_path else "" - ) - - # Compact sticky row always visible; "Was geprüft wird" expands inside sticky-bar - return f""" - -""" - - -STICKY_CSS = """ -.sticky-bar { - position: sticky; top: 0; z-index: 100; - border-bottom: 2px solid var(--border); - padding: 10px 14px 8px; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - box-shadow: 0 2px 12px #0008; -} -.sticky-bar.sticky-green { - background: linear-gradient(180deg, #0d1f14f2, #0a1410ee); - border-bottom-color: #1f6b3a; -} -.sticky-bar.sticky-yellow { - background: linear-gradient(180deg, #2a2210f2, #1a160cee); - border-bottom-color: #a68b2d; -} -.sticky-bar.sticky-red { - background: linear-gradient(180deg, #2a1010f2, #1a0808ee); - border-bottom-color: #a33; -} -.sticky-bar-row { - display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; - max-width: 1200px; -} -.sticky-bar-row.secondary { - margin-top: 8px; font-size: 12px; color: var(--dim); -} -.sticky-bar-row.secondary a { color: var(--info); text-decoration: none; } -.sticky-bar-row.secondary a:hover { text-decoration: underline; } -.status-pill { - display: inline-block; font-size: 11px; font-weight: 800; - letter-spacing: 0.06em; padding: 2px 10px; border-radius: 3px; - border: 1px solid; -} -.status-pill.sticky-green { color: var(--ok); border-color: #1f6b3a; background: #0a1a10; } -.status-pill.sticky-yellow { color: var(--warn); border-color: #a68b2d; background: #1a1608; } -.status-pill.sticky-red { color: var(--err); border-color: #a33; background: #1a0a0a; } -.host { font-weight: 600; color: #eee; font-size: 13px; } -.sep { color: var(--dim); } -.stat { - font-weight: 700; font-size: 12px; text-decoration: none; - padding: 1px 8px; border-radius: 3px; border: 1px solid transparent; -} -.stat.err { color: var(--err); border-color: #522; background: #1a0a0a; } -.stat.warn { color: var(--warn); border-color: #664; background: #1a1608; } -.stat.muted { opacity: 0.55; font-weight: 600; } -a.stat:hover { text-decoration: underline; filter: brightness(1.15); } -.generated { color: var(--dim); font-size: 12px; } -.generated time { color: #bbb; } -.generated .age { color: #888; margin-left: 4px; } -.generated .age:not(:empty)::before { content: "("; } -.generated .age:not(:empty)::after { content: ")"; } -.badge { - display: inline-block; padding: 1px 8px; border-radius: 3px; - font-size: 11px; font-weight: 700; margin-right: 4px; - border: 1px solid var(--border); -} -.badge.mode-err { color: var(--err); border-color: #522; background: #1a0a0a; } -.badge.mode-ok { color: var(--ok); border-color: #143; background: #0a1a10; } -.badge.mode-redirect { color: var(--warn); border-color: #664; background: #1a1608; } -/* collapsible block inside sticky-bar */ -.scope-toggle { - margin-left: auto; - display: inline-flex; - align-items: center; - gap: 8px; - max-width: min(42rem, 100%); - cursor: pointer; - font: inherit; - font-size: 12px; - color: #ddd; - background: #0006; - border: 1px solid #444; - border-radius: 4px; - padding: 4px 10px; - text-align: left; -} -.scope-toggle:hover { border-color: #888; background: #111a; color: #fff; } -.scope-toggle:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; } -.scope-toggle[aria-expanded="true"] { - border-color: #777; - background: #141414; -} -.scope-chevron { - display: inline-block; - width: 0; height: 0; - border-left: 5px solid #aaa; - border-top: 4px solid transparent; - border-bottom: 4px solid transparent; - transition: transform 0.12s ease; - flex-shrink: 0; -} -.scope-toggle[aria-expanded="true"] .scope-chevron { - transform: rotate(90deg); - border-left-color: #eee; -} -.scope-toggle-label { - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; - font-size: 11px; - white-space: nowrap; -} -.scope-toggle-hint { - color: #888; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} -.sticky-scope-panel { - margin-top: 8px; - max-width: 1200px; - border: 1px solid #333; - border-radius: 4px; - background: #080808e6; - padding: 8px 10px 10px; -} -.sticky-scope-panel[hidden] { display: none !important; } -.scope-body { padding: 4px 4px 2px 6px; } -.scope-title { - margin: 4px 0 4px; - font-size: 13px; - font-weight: 600; - color: #eee; -} -.scope-lead { - margin: 0 0 6px; - font-size: 12px; - color: #999; -} -.scope-list { - margin: 0; - padding-left: 1.15em; - color: #b0b0b0; - font-size: 12px; - line-height: 1.5; -} -.scope-list li { margin: 3px 0; } -#first-error, #first-warn, -[id^="err-"][id$="-line"] { - scroll-margin-top: 120px; -} -@media (max-width: 720px) { - .scope-toggle { margin-left: 0; width: 100%; } - .scope-toggle-hint { display: none; } -} -""" - -STICKY_JS = """ - -""" - - -def build_html( - *, - title: str, - hostname: str, - mode: str, - log_text: str, - commit: str, - commit_url: str, - suite_path: str, - generated_at: str, - generated_iso: str, - link_other: str | None, - page_label: str = "monitoring", -) -> str: - raw_lines = [strip_ansi(l).rstrip("\n") for l in log_text.splitlines()] - while raw_lines and not raw_lines[-1].strip(): - raw_lines.pop() - - n_err, n_warn, first_err_i, first_warn_i = count_status(raw_lines) - errors = extract_errors(raw_lines) - - err_slugs: dict[str, str] = {} - for i, e in enumerate(errors, 1): - slug = slug_error(i, e) - err_slugs[e] = slug - m = TID_RE.search(e) - if m: - err_slugs[m.group("tid")] = slug - - tid_to_slug = { - k: v for k, v in err_slugs.items() if re.match(r"^[a-z0-9_.-]+-\d+$", k, re.I) - } - - # Build body with anchors for first error/warn + per-error slugs - body_lines: list[str] = [] - claimed_err: set[str] = set() - for idx, ln in enumerate(raw_lines): - extra: list[str] = [] - if first_err_i is not None and idx == first_err_i: - extra.append("first-error") - if first_warn_i is not None and idx == first_warn_i: - extra.append("first-warn") - # per-error jump targets (all modes) - if classify(ln) in ("error", "blocker") and not is_summary_error_line(ln): - for i, e in enumerate(errors, 1): - slug = slug_error(i, e) - if slug in claimed_err: - continue - tid_m = TID_RE.search(e) - if is_run_timeout_text(e): - if "run.timeout-01" in ln and "ERROR" in ln.upper(): - extra.append(f"{slug}-line") - claimed_err.add(slug) - break - continue - key = tid_m.group(1) if tid_m else e[:40] - if key and key in ln: - extra.append(f"{slug}-line") - claimed_err.add(slug) - break - body_lines.append(render_line(ln, tid_to_slug, extra or None)) - - err_nav = "" - if errors and mode == "err": - items = [] - timeout_items = [] - for i, e in enumerate(errors, 1): - slug = slug_error(i, e) - li = ( - f'
  • ' - f'{html.escape(e)}
  • ' - ) - if is_run_timeout_text(e): - timeout_items.append(li) - else: - items.append(li) - if timeout_items: - err_nav += ( - '
    \n' - "

    EXTRAORDINARY · RUN TIMEOUT

    \n" - "

    Whole-run wall clock exceeded. " - 'Jump to detail →

    \n' - '
      \n' - + "\n".join(timeout_items[:1]) - + "\n
    \n
    \n" - ) - rest = items if timeout_items else (timeout_items + items) - if rest or not timeout_items: - err_nav += ( - '
    \n' - f"

    ERRORS ({len(errors)}) — jump to detail

    \n" - '
      \n' - + "\n".join(rest if rest else items) - + "\n
    \n
    \n" - ) - - commit_short = commit[:12] if commit else "unknown" - commit_html = ( - f'{html.escape(commit_short)}' - if commit_url - else html.escape(commit_short) - ) - - first_err_href = "#first-error" if first_err_i is not None else None - first_warn_href = "#first-warn" if first_warn_i is not None else None - scope = monitoring_scope(page_label, hostname, log_text) - - bar = sticky_bar_html( - generated_at=generated_at, - generated_iso=generated_iso, - n_err=n_err, - n_warn=n_warn, - hostname=hostname, - page_label=page_label, - mode=mode, - commit_html=commit_html, - suite_path=suite_path, - link_other=link_other, - first_err_href=first_err_href, - first_warn_href=first_warn_href, - scope=scope, - ) - - return f""" - - - - - -{html.escape(title)} - - - -{bar} -
    -{err_nav} -
    -{"".join(body_lines)} -
    -
    - Console-style render of taler-monitoring output. - Sticky bar: green = clean · yellow = warnings · red = errors. - Commit pins the exact tree used for this run. -
    -
    -{STICKY_JS} - - -""" - - -def build_redirect_html( - *, - hostname: str, - commit: str, - commit_url: str, - path_err: str = "/monitoring_err/", - page_label: str = "monitoring", - generated_at: str = "", - generated_iso: str = "", - n_err: int = 0, - n_warn: int = 0, - log_text: str = "", -) -> str: - commit_short = commit[:12] if commit else "unknown" - link = path_err if path_err.endswith("/") else path_err + "/" - c = ( - f'{html.escape(commit_short)}' - if commit_url - else html.escape(commit_short) - ) - if not generated_at: - now = datetime.now(timezone.utc) - generated_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ") - generated_at = now.strftime("%Y-%m-%d %H:%M:%SZ") - # Redirect pages exist only when the run failed → treat as red if n_err unknown - if n_err <= 0: - n_err = max(n_err, 1) - scope = monitoring_scope(page_label, hostname, log_text) - bar = sticky_bar_html( - generated_at=generated_at, - generated_iso=generated_iso, - n_err=n_err, - n_warn=n_warn, - hostname=hostname, - page_label=page_label, - mode="redirect", - commit_html=c, - suite_path="", - link_other=link, - first_err_href=link, - first_warn_href=None, - scope=scope, - ) - return f""" - - - - - - -{html.escape(page_label)} · errors · {html.escape(hostname)} - - - -{bar} -
    -

    {html.escape(page_label)} has failures.

    -

    See {html.escape(link)} for the full console log, - error index, and sticky status bar.

    -

    generated {html.escape(generated_at)} · source {c}

    -
    -{STICKY_JS} - - -""" - - -def main() -> None: - ap = argparse.ArgumentParser() - ap.add_argument("--log", required=True, type=Path) - ap.add_argument("--out", required=True, type=Path) - ap.add_argument("--hostname", required=True) - ap.add_argument("--mode", choices=("ok", "err", "redirect"), required=True) - ap.add_argument("--commit", default="") - ap.add_argument("--commit-url", default="") - ap.add_argument("--suite-path", default="scripts/taler-monitoring") - ap.add_argument("--title", default="") - ap.add_argument("--link-other", default="") - ap.add_argument( - "--path-err", - default="/monitoring_err/", - help="URL path for error page (redirect target)", - ) - ap.add_argument( - "--page-label", - default="monitoring", - help="Short name shown in titles/redirect box", - ) - args = ap.parse_args() - - log_text = args.log.read_text(errors="replace") if args.log.is_file() else "" - now = datetime.now(timezone.utc) - generated_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ") - generated_at = now.strftime("%Y-%m-%d %H:%M:%SZ") - title = args.title or f"{args.page_label} {args.mode} · {args.hostname}" - - raw_for_counts = [strip_ansi(l).rstrip("\n") for l in log_text.splitlines()] - n_err, n_warn, _, _ = count_status(raw_for_counts) - - if args.mode == "redirect": - html_out = build_redirect_html( - hostname=args.hostname, - commit=args.commit, - commit_url=args.commit_url, - path_err=args.path_err, - page_label=args.page_label, - generated_at=generated_at, - generated_iso=generated_iso, - n_err=n_err, - n_warn=n_warn, - log_text=log_text, - ) - else: - html_out = build_html( - title=title, - hostname=args.hostname, - mode=args.mode, - log_text=log_text, - commit=args.commit, - commit_url=args.commit_url, - suite_path=args.suite_path, - generated_at=generated_at, - generated_iso=generated_iso, - link_other=args.link_other or None, - page_label=args.page_label, - ) - - args.out.parent.mkdir(parents=True, exist_ok=True) - args.out.write_text(html_out, encoding="utf-8") - print(f"wrote {args.out} (errors={n_err} warnings={n_warn} level={status_level(n_err, n_warn)})") - - -if __name__ == "__main__": - main() diff --git a/scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh b/scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh deleted file mode 100755 index c5718e1..0000000 --- a/scripts/taler-monitoring/site-gen/deploy-monitoring-sites.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# deploy-monitoring-sites.sh — rsync generated HTML to DEPLOY_WWW_ROOT on koopa. -# Does NOT reload Caddy (needs root — see ROOT-ON-KOOPA.md). -set -uo pipefail - -ROOT=$(cd "$(dirname "$0")" && pwd) -SETTINGS="${SITE_GEN_SETTINGS:-$ROOT/settings.conf}" -[ -f "$SETTINGS" ] || SETTINGS="$ROOT/settings.conf.example" -# shellcheck disable=SC1090 -set -a -source <(grep -v '^\s*#' "$SETTINGS" | grep -v '^\s*$' | sed 's/\r$//') -set +a - -WORK_ROOT="${WORK_ROOT:-/tmp/taler-monitoring-sites-work}" -HTML_DIR="$WORK_ROOT/html" -DEPLOY_SSH="${DEPLOY_SSH:-koopa-external}" -DEPLOY_WWW_ROOT="${DEPLOY_WWW_ROOT:-/var/www/monitoring-sites}" - -if [ ! -d "$HTML_DIR" ]; then - echo "no HTML_DIR $HTML_DIR — run generate-monitoring-sites.sh first" >&2 - exit 1 -fi - -echo "deploy $HTML_DIR → ${DEPLOY_SSH}:${DEPLOY_WWW_ROOT}/" -# Prefer writing to a hernani-owned staging dir if /var/www not writable -STAGE_REMOTE="${DEPLOY_STAGING:-/home/hernani/monitoring-sites-staging}" - -ssh -o BatchMode=yes -o ConnectTimeout=20 "$DEPLOY_SSH" \ - "mkdir -p '$STAGE_REMOTE' '$DEPLOY_WWW_ROOT' 2>/dev/null || mkdir -p '$STAGE_REMOTE'" - -rsync -az --delete "$HTML_DIR/" "${DEPLOY_SSH}:${STAGE_REMOTE}/" -echo "staged on $DEPLOY_SSH:$STAGE_REMOTE" -echo -echo "If $DEPLOY_WWW_ROOT is root-owned, as root on koopa:" -echo " mkdir -p $DEPLOY_WWW_ROOT" -echo " rsync -a --delete $STAGE_REMOTE/ $DEPLOY_WWW_ROOT/" -echo " chown -R caddy:caddy $DEPLOY_WWW_ROOT # or root:caddy — match other www" -echo " # then Caddyfile handles + reload — see ROOT-ON-KOOPA.md" -echo -# try direct rsync to DEPLOY_WWW_ROOT if writable -if ssh -o BatchMode=yes "$DEPLOY_SSH" "test -w '$DEPLOY_WWW_ROOT' 2>/dev/null || test -w \"\$(dirname '$DEPLOY_WWW_ROOT')\""; then - rsync -az --delete "$HTML_DIR/" "${DEPLOY_SSH}:${DEPLOY_WWW_ROOT}/" && \ - echo "also synced directly to $DEPLOY_WWW_ROOT" -fi diff --git a/scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh b/scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh deleted file mode 100755 index f45e5bd..0000000 --- a/scripts/taler-monitoring/site-gen/generate-monitoring-sites.sh +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env bash -# generate-monitoring-sites.sh — run taler-monitoring, build console HTML for -# /monitoring and /monitoring_err (static files for host Caddy later). -# -# Usage: -# ./generate-monitoring-sites.sh # all SITES (or ONLY_HOSTS) -# ONLY_HOSTS="bank.hacktivism.ch taler.hacktivism.ch" ./generate-monitoring-sites.sh -# ./generate-monitoring-sites.sh --dry-run # HTML from empty/missing logs only -# ./generate-monitoring-sites.sh --skip-run # only convert existing logs -# -# Settings: settings.conf (from settings.conf.example). No secrets in settings. -# Secrets for e2e: ../secrets.env (optional; default phases are urls versions). -# -set -uo pipefail - -export PYTHONUNBUFFERED=1 -# Global reporting defaults (same as host-agent run-host-report.sh) -: "${RUN_TIMEOUT:=600}" -export RUN_TIMEOUT - -ROOT=$(cd "$(dirname "$0")" && pwd) -SETTINGS="${SITE_GEN_SETTINGS:-$ROOT/settings.conf}" -EXAMPLE="$ROOT/settings.conf.example" - -if [ ! -f "$SETTINGS" ]; then - if [ -f "$EXAMPLE" ]; then - echo "note: no $SETTINGS — using example defaults (copy to settings.conf to customize)" - SETTINGS="$EXAMPLE" - else - echo "missing settings: $EXAMPLE" >&2 - exit 2 - fi -fi - -# Load KEY=value lines; allow SITES already set in environment to win. -# Multiline SITES in the conf file: use SITES_FILE= or env SITES= instead. -while IFS= read -r _line || [ -n "$_line" ]; do - _line=${_line%%#*} - _line=$(echo "$_line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - [ -z "$_line" ] && continue - case "$_line" in - SITES=*|SITES_FILE=*) continue ;; # handled below / env - *=*) - _k=${_line%%=*} - _v=${_line#*=} - # do not override pre-set env - if [ -z "${!_k+x}" ] 2>/dev/null || [ -z "${!_k}" ]; then - export "$_k=$_v" - fi - ;; - esac -done < "$SETTINGS" - -# SITES from dedicated file or keep env; else parse heredoc-style from example list -if [ -n "${SITES_FILE:-}" ] && [ -f "$SITES_FILE" ]; then - SITES=$(grep -v '^\s*#' "$SITES_FILE" | grep -v '^\s*$') - export SITES -elif [ -z "${SITES:-}" ]; then - # default 9 fronts - SITES="bank.hacktivism.ch|hacktivism.ch -exchange.hacktivism.ch|hacktivism.ch -taler.hacktivism.ch|hacktivism.ch -stage.bank.lefrancpaysan.ch|stage.lefrancpaysan.ch -stage.exchange.lefrancpaysan.ch|stage.lefrancpaysan.ch -stage.monnaie.lefrancpaysan.ch|stage.lefrancpaysan.ch -bank.lefrancpaysan.ch|lefrancpaysan.ch -exchange.lefrancpaysan.ch|lefrancpaysan.ch -monnaie.lefrancpaysan.ch|lefrancpaysan.ch" - export SITES -fi - -SKIP_RUN=0 -DRY=0 -while [ $# -gt 0 ]; do - case "$1" in - --skip-run) SKIP_RUN=1; shift ;; - --dry-run) DRY=1; SKIP_RUN=1; shift ;; - -h|--help) sed -n '1,20p' "$0"; exit 0 ;; - *) echo "unknown arg: $1" >&2; exit 2 ;; - esac -done - -MONITORING_ROOT=$(cd "$ROOT/${MONITORING_ROOT:-..}" && pwd) -WORK_ROOT="${WORK_ROOT:-/tmp/taler-monitoring-sites-work}" -LOG_DIR="$WORK_ROOT/logs" -HTML_DIR="$WORK_ROOT/html" -mkdir -p "$LOG_DIR" "$HTML_DIR" - -# commit of monitoring suite used for this run -if git -C "$MONITORING_ROOT/../.." rev-parse HEAD >/dev/null 2>&1; then - REPO_ROOT=$(cd "$MONITORING_ROOT/../.." && pwd) -elif git -C "$MONITORING_ROOT" rev-parse HEAD >/dev/null 2>&1; then - REPO_ROOT=$(cd "$MONITORING_ROOT" && pwd) -else - REPO_ROOT=$(cd "$ROOT/../../.." && pwd) -fi -COMMIT=$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown) -COMMIT_SHORT=$(git -C "$REPO_ROOT" rev-parse --short=12 HEAD 2>/dev/null || echo unknown) -SOURCE_REPO_WEB="${SOURCE_REPO_WEB:-https://git.hacktivism.ch/hernani/koopa-admin-log}" -TMPL="${SOURCE_COMMIT_URL_TMPL:-\{repo\}/src/commit/\{commit\}}" -COMMIT_URL=$(printf '%s' "$TMPL" | sed "s|{repo}|$SOURCE_REPO_WEB|g; s|{commit}|$COMMIT|g") -SUITE_PATH="${SOURCE_SUITE_PATH:-scripts/taler-monitoring}" - -# Outside-only public checks (no SSH into koopa/stage containers) -PHASES_ERR="${PHASES_ERR:-urls versions}" -PHASES_OK="${PHASES_OK:-urls versions}" -CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" -SKIP_SSH="${SKIP_SSH:-1}" -# Drop SSH phases if someone passes "all" / inside / server / e2e by mistake -filter_phases_outside() { - local out=() p - for p in "$@"; do - case "$p" in - inside|server|e2e|ladder|goa-ladder|auth401) continue ;; - all) out+=(urls versions);; - *) out+=("$p") ;; - esac - done - printf '%s\n' "${out[@]}" -} - -run_mon() { - local domain="$1" phases="$2" logfile="$3" cont="$4" - # shellcheck disable=SC2206 - local ph=( $phases ) - mapfile -t ph < <(filter_phases_outside "${ph[@]}") - [ "${#ph[@]}" -gt 0 ] || ph=(urls versions) - - : >"$logfile" - echo "+ -d $domain · phases=${ph[*]} · CONTINUE=$cont · SKIP_SSH=$SKIP_SSH · RUN_TIMEOUT=$RUN_TIMEOUT" - - if [ -n "${RUNNER_SSH:-}" ]; then - echo "+ via ssh $RUNNER_SSH" - rsync -az --delete \ - --exclude secrets.env \ - --exclude 'android-test/artifacts' \ - --exclude '.git' \ - "$MONITORING_ROOT/" \ - "${RUNNER_SSH}:${RUNNER_REMOTE_WORKDIR}/" \ - || return 1 - # shellcheck disable=SC2029 - if command -v stdbuf >/dev/null 2>&1; then - ssh -o BatchMode=yes -o ConnectTimeout=30 "$RUNNER_SSH" \ - "cd '${RUNNER_REMOTE_WORKDIR}' && chmod +x taler-monitoring.sh check_*.sh 2>/dev/null; \ - export CONTINUE_ON_ERROR='$cont' AUTH401_CONTINUE='$cont' SKIP_SSH='$SKIP_SSH' RUN_TIMEOUT='$RUN_TIMEOUT'; \ - stdbuf -oL -eL ./taler-monitoring.sh -d '$domain' ${ph[*]}" \ - 2>&1 | stdbuf -oL -eL tee -a "$logfile" - else - ssh -o BatchMode=yes -o ConnectTimeout=30 "$RUNNER_SSH" \ - "cd '${RUNNER_REMOTE_WORKDIR}' && chmod +x taler-monitoring.sh check_*.sh 2>/dev/null; \ - export CONTINUE_ON_ERROR='$cont' AUTH401_CONTINUE='$cont' SKIP_SSH='$SKIP_SSH' RUN_TIMEOUT='$RUN_TIMEOUT'; \ - ./taler-monitoring.sh -d '$domain' ${ph[*]}" \ - 2>&1 | tee -a "$logfile" - fi - return "${PIPESTATUS[0]}" - fi - - if command -v stdbuf >/dev/null 2>&1; then - ( cd "$MONITORING_ROOT" && \ - CONTINUE_ON_ERROR="$cont" AUTH401_CONTINUE="$cont" SKIP_SSH="$SKIP_SSH" \ - RUN_TIMEOUT="$RUN_TIMEOUT" \ - stdbuf -oL -eL ./taler-monitoring.sh -d "$domain" "${ph[@]}" ) \ - 2>&1 | stdbuf -oL -eL tee -a "$logfile" - else - ( cd "$MONITORING_ROOT" && \ - CONTINUE_ON_ERROR="$cont" AUTH401_CONTINUE="$cont" SKIP_SSH="$SKIP_SSH" \ - RUN_TIMEOUT="$RUN_TIMEOUT" \ - ./taler-monitoring.sh -d "$domain" "${ph[@]}" ) \ - 2>&1 | tee -a "$logfile" - fi - return "${PIPESTATUS[0]}" -} - -htmlify() { - local host="$1" mode="$2" log="$3" out="$4" other="$5" - mkdir -p "$(dirname "$out")" - if [ ! -f "$ROOT/console_to_html.py" ]; then - echo "WARN: console_to_html.py missing — bootstrap $out" - { - echo "$host $mode" - echo "

    $host · $mode

    commit $COMMIT_SHORT

    " - echo "
    "
    -      tail -n 100 "$log" 2>/dev/null | sed 's/&/\&/g;s/"
    -    } >"$out"
    -    return 0
    -  fi
    -  python3 "$ROOT/console_to_html.py" \
    -    --log "$log" \
    -    --out "$out" \
    -    --hostname "$host" \
    -    --mode "$mode" \
    -    --commit "$COMMIT" \
    -    --commit-url "$COMMIT_URL" \
    -    --suite-path "$SUITE_PATH" \
    -    --link-other "$other"
    -}
    -
    -# Parse SITES
    -mapfile -t SITE_LINES < <(printf '%s\n' "$SITES" | sed '/^\s*$/d' | sed 's/#.*//')
    -ONLY="${ONLY_HOSTS:-}"
    -
    -for line in "${SITE_LINES[@]}"; do
    -  line=$(echo "$line" | tr -d ' \t')
    -  [ -z "$line" ] && continue
    -  host=${line%%|*}
    -  domain=${line#*|}
    -  if [ -n "$ONLY" ]; then
    -    echo " $ONLY " | grep -q " $host " || continue
    -  fi
    -
    -  echo "======== $host  (domain=$domain)  commit=$COMMIT_SHORT ========"
    -  base_html="$HTML_DIR/$host"
    -  mkdir -p "$base_html/monitoring" "$base_html/monitoring_err"
    -  log_err="$LOG_DIR/${host}.err.log"
    -  log_ok="$LOG_DIR/${host}.ok.log"
    -  ec_err=1
    -  ec_ok=1
    -
    -  if [ "$SKIP_RUN" != "1" ]; then
    -    run_mon "$domain" "$PHASES_ERR" "$log_err" "$CONTINUE_ON_ERROR"
    -    ec_err=$?
    -    # ok-run without continue (strict)
    -    run_mon "$domain" "$PHASES_OK" "$log_ok" "0"
    -    ec_ok=$?
    -  else
    -    [ -f "$log_err" ] || : >"$log_err"
    -    [ -f "$log_ok" ] || : >"$log_ok"
    -    # infer exit from log if present
    -    if grep -q '\[ ERROR \]' "$log_err" 2>/dev/null || grep -q 'ERROR' "$log_err" 2>/dev/null; then
    -      ec_err=1
    -    else
    -      ec_err=0
    -    fi
    -    ec_ok=$ec_err
    -  fi
    -
    -  # Always write pages (first run / empty logs too)
    -  [ -f "$log_err" ] || : >"$log_err"
    -  [ -f "$log_ok" ] || : >"$log_ok"
    -
    -  htmlify "$host" "err" "$log_err" \
    -    "$base_html/monitoring_err/index.html" "/monitoring/"
    -
    -  if [ "$ec_ok" -eq 0 ] && [ "$ec_err" -eq 0 ]; then
    -    htmlify "$host" "ok" "$log_ok" \
    -      "$base_html/monitoring/index.html" "/monitoring_err/"
    -    echo "  → /monitoring (clean) + /monitoring_err"
    -  else
    -    htmlify "$host" "redirect" "$log_err" \
    -      "$base_html/monitoring/index.html" "/monitoring_err/"
    -    echo "  → /monitoring (redirect stub) + /monitoring_err (errors)  ec_err=$ec_err ec_ok=$ec_ok"
    -  fi
    -
    -  # first-run safety
    -  if [ ! -f "$base_html/monitoring/index.html" ]; then
    -    htmlify "$host" "err" "$log_err" \
    -      "$base_html/monitoring/index.html" "/monitoring_err/"
    -  fi
    -
    -  printf '%s\n' "$COMMIT" >"$base_html/COMMIT"
    -  printf '%s\n' "$COMMIT_URL" >"$base_html/COMMIT_URL"
    -  printf 'host=%s domain=%s ec_err=%s ec_ok=%s commit=%s run_timeout=%s\n' \
    -    "$host" "$domain" "$ec_err" "$ec_ok" "$COMMIT" "$RUN_TIMEOUT" | tee "$base_html/STATUS.txt"
    -done
    -
    -echo
    -echo "HTML under $HTML_DIR"
    -echo "commit $COMMIT_SHORT  $COMMIT_URL"
    -echo "RUN_TIMEOUT=${RUN_TIMEOUT}s (global; 0=unlimited)"
    -echo "Deploy (as hernani, may need sudo for /var/www):"
    -echo "  ./deploy-monitoring-sites.sh"
    -echo "Root on koopa: see ROOT-ON-KOOPA.md"
    diff --git a/scripts/taler-monitoring/site-gen/install-firecuda-timer.sh b/scripts/taler-monitoring/site-gen/install-firecuda-timer.sh
    deleted file mode 100755
    index 9615b96..0000000
    --- a/scripts/taler-monitoring/site-gen/install-firecuda-timer.sh
    +++ /dev/null
    @@ -1,79 +0,0 @@
    -#!/usr/bin/env bash
    -# install-firecuda-timer.sh — OPTIONAL: rsync suite to firecuda + launchd every 4h
    -#
    -# POLICY (2026-07): firecuda launchd is **disabled**. Prefer koopa host-agent.
    -# This script remains for documentation / future re-enable only.
    -# Do not run unless you intentionally want a second outside-only scheduler.
    -#
    -#   ./install-firecuda-timer.sh
    -#   ./install-firecuda-timer.sh --run-now
    -#
    -set -euo pipefail
    -
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -MON_ROOT=$(cd "$ROOT/.." && pwd)
    -FIRECUDA="${FIRECUDA_SSH:-firecuda-external}"
    -REMOTE_BASE="${FIRECUDA_INSTALL_DIR:-taler-monitoring-site-gen}"
    -RUN_NOW=0
    -[ "${1:-}" = "--run-now" ] && RUN_NOW=1
    -
    -echo "install → $FIRECUDA:~/$REMOTE_BASE (outside-only, every 4h)"
    -
    -ssh -o BatchMode=yes -o ConnectTimeout=20 "$FIRECUDA" \
    -  "mkdir -p \"\$HOME/$REMOTE_BASE\" \"\$HOME/Library/Logs/taler-monitoring-sites\" \"\$HOME/var/taler-monitoring-sites-work\" \"\$HOME/Library/LaunchAgents\""
    -
    -# Sync full taler-monitoring suite (no secrets.env)
    -rsync -az --delete \
    -  --exclude secrets.env \
    -  --exclude 'android-test/artifacts' \
    -  --exclude '.git' \
    -  --exclude 'site-gen/settings.conf' \
    -  "$MON_ROOT/" \
    -  "${FIRECUDA}:$REMOTE_BASE/"
    -
    -# settings on firecuda: outside only, run locally on firecuda, deploy to koopa
    -ssh -o BatchMode=yes "$FIRECUDA" "cat > \"\$HOME/$REMOTE_BASE/site-gen/settings.conf\" <<'EOF'
    -# auto-installed by install-firecuda-timer.sh — no secrets
    -RUNNER_SSH=
    -SKIP_SSH=1
    -CONTINUE_ON_ERROR=1
    -PHASES_ERR=urls versions
    -PHASES_OK=urls versions
    -DEPLOY_SSH=koopa-external
    -DEPLOY_STAGING=/home/hernani/monitoring-sites-staging
    -WORK_ROOT=
    -MONITORING_ROOT=..
    -SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    -SOURCE_COMMIT_URL_TMPL={repo}/src/commit/{commit}
    -SOURCE_SUITE_PATH=scripts/taler-monitoring
    -EOF
    -# WORK_ROOT expanded on remote
    -sed -i.bak \"s|^WORK_ROOT=.*|WORK_ROOT=\$HOME/var/taler-monitoring-sites-work|\" \"\$HOME/$REMOTE_BASE/site-gen/settings.conf\" 2>/dev/null || \
    -  sed -i '' \"s|^WORK_ROOT=.*|WORK_ROOT=\$HOME/var/taler-monitoring-sites-work|\" \"\$HOME/$REMOTE_BASE/site-gen/settings.conf\"
    -"
    -
    -# launchd plist with absolute paths
    -ssh -o BatchMode=yes "$FIRECUDA" bash -s < "\$PLIST_DST"
    -chmod +x "\$SITE_GEN"/*.sh "\$SITE_GEN"/console_to_html.py 2>/dev/null || true
    -chmod +x "\$SITE_GEN/run-on-firecuda.sh"
    -launchctl bootout "gui/\$(id -u)/com.hacktivism.taler-monitoring-sites" 2>/dev/null || true
    -launchctl unload "\$PLIST_DST" 2>/dev/null || true
    -launchctl bootstrap "gui/\$(id -u)" "\$PLIST_DST" 2>/dev/null || launchctl load "\$PLIST_DST"
    -launchctl enable "gui/\$(id -u)/com.hacktivism.taler-monitoring-sites" 2>/dev/null || true
    -launchctl print "gui/\$(id -u)/com.hacktivism.taler-monitoring-sites" 2>/dev/null | head -20 || launchctl list | grep taler-monitoring || true
    -echo "OK: launchd com.hacktivism.taler-monitoring-sites (StartInterval=14400)"
    -REMOTE
    -
    -if [ "$RUN_NOW" = "1" ]; then
    -  echo "run once now…"
    -  ssh -o BatchMode=yes "$FIRECUDA" "\"\$HOME/$REMOTE_BASE/site-gen/run-on-firecuda.sh\"" || true
    -fi
    -
    -echo "done. logs: $FIRECUDA:~/Library/Logs/taler-monitoring-sites/"
    -echo "manual: ssh $FIRECUDA '~/taler-monitoring-site-gen/site-gen/run-on-firecuda.sh'"
    diff --git a/scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh b/scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh
    deleted file mode 100755
    index b1b10ec..0000000
    --- a/scripts/taler-monitoring/site-gen/pull-firecuda-to-koopa.sh
    +++ /dev/null
    @@ -1,15 +0,0 @@
    -#!/usr/bin/env bash
    -# pull-firecuda-to-koopa.sh — copy HTML from firecuda work dir → koopa staging
    -# Run on laptop (hernani) where both SSH aliases work.
    -set -euo pipefail
    -FIRECUDA="${FIRECUDA_SSH:-firecuda-external}"
    -KOOPA="${DEPLOY_SSH:-koopa-external}"
    -REMOTE_HTML="${FIRECUDA_HTML:-var/taler-monitoring-sites-work/html/}"
    -STAGE="${DEPLOY_STAGING:-/home/hernani/monitoring-sites-staging}"
    -
    -echo "firecuda:$REMOTE_HTML → $KOOPA:$STAGE"
    -ssh -o BatchMode=yes -o ConnectTimeout=20 "$KOOPA" "mkdir -p '$STAGE'"
    -rsync -az --delete \
    -  "${FIRECUDA}:${REMOTE_HTML}" \
    -  "${KOOPA}:${STAGE}/"
    -echo "OK staged. Root: rsync to /var/www/monitoring-sites (see ROOT-ON-KOOPA.md)"
    diff --git a/scripts/taler-monitoring/site-gen/run-on-firecuda.sh b/scripts/taler-monitoring/site-gen/run-on-firecuda.sh
    deleted file mode 100755
    index 6f77de6..0000000
    --- a/scripts/taler-monitoring/site-gen/run-on-firecuda.sh
    +++ /dev/null
    @@ -1,59 +0,0 @@
    -#!/usr/bin/env bash
    -# run-on-firecuda.sh — entrypoint for launchd/cron on firecuda-external.
    -# Outside-only monitoring (SKIP_SSH=1), all 9 sites, then stage HTML to koopa.
    -#
    -# Installed path on firecuda (default):
    -#   ~/taler-monitoring-site-gen/site-gen/run-on-firecuda.sh
    -#
    -set -uo pipefail
    -
    -export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"
    -
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -LOG_DIR="${FIRECUDA_LOG_DIR:-$HOME/Library/Logs/taler-monitoring-sites}"
    -mkdir -p "$LOG_DIR"
    -STAMP=$(date +%Y%m%d-%H%M%S)
    -LOG="$LOG_DIR/run-${STAMP}.log"
    -exec >>"$LOG" 2>&1
    -
    -echo "======== $(date -Iseconds 2>/dev/null || date) firecuda monitoring sites ========"
    -
    -# Always: public DNS only, no SSH checks into stacks
    -export SKIP_SSH=1
    -export CONTINUE_ON_ERROR=1
    -export AUTH401_CONTINUE=1
    -export RUNNER_SSH=
    -export PHASES_ERR="${PHASES_ERR:-urls versions}"
    -export PHASES_OK="${PHASES_OK:-urls versions}"
    -export WORK_ROOT="${WORK_ROOT:-$HOME/var/taler-monitoring-sites-work}"
    -export MONITORING_ROOT="${MONITORING_ROOT:-$ROOT/..}"
    -export DEPLOY_SSH="${DEPLOY_SSH:-koopa-external}"
    -export DEPLOY_STAGING="${DEPLOY_STAGING:-/home/hernani/monitoring-sites-staging}"
    -
    -# Prefer full 9-site list from settings or defaults inside generate script
    -unset ONLY_HOSTS
    -
    -cd "$ROOT" || exit 1
    -chmod +x generate-monitoring-sites.sh deploy-monitoring-sites.sh console_to_html.py 2>/dev/null || true
    -
    -./generate-monitoring-sites.sh
    -ec=$?
    -
    -# HTML stays on firecuda under WORK_ROOT/html.
    -# Optional push to koopa if this host can SSH there (often only from laptop):
    -if [ -n "${DEPLOY_SSH:-}" ] && [ "${DEPLOY_FROM_FIRECUDA:-0}" = "1" ]; then
    -  if [ -x ./deploy-monitoring-sites.sh ]; then
    -    ./deploy-monitoring-sites.sh || echo "WARN: deploy staging failed"
    -  fi
    -else
    -  echo "note: HTML at ${WORK_ROOT}/html — pull from laptop:"
    -  echo "  rsync -az firecuda-external:var/taler-monitoring-sites-work/html/ \\"
    -  echo "    koopa-external:monitoring-sites-staging/"
    -  echo "  # or: ./pull-firecuda-to-koopa.sh"
    -fi
    -
    -# keep last 20 logs
    -ls -1t "$LOG_DIR"/run-*.log 2>/dev/null | tail -n +21 | xargs rm -f 2>/dev/null || true
    -
    -echo "======== done ec=$ec ========"
    -exit "$ec"
    diff --git a/scripts/taler-monitoring/site-gen/settings.conf.example b/scripts/taler-monitoring/site-gen/settings.conf.example
    deleted file mode 100644
    index 3d14fc2..0000000
    --- a/scripts/taler-monitoring/site-gen/settings.conf.example
    +++ /dev/null
    @@ -1,55 +0,0 @@
    -# monitoring site-gen — non-secret settings (safe as example)
    -# Copy:  cp settings.conf.example settings.conf
    -# Never put passwords/tokens here.
    -
    -# On firecuda the suite runs *locally* (RUNNER_SSH empty). From a laptop you
    -# can set RUNNER_SSH=firecuda-external to drive a one-off remote run instead.
    -RUNNER_SSH=
    -RUNNER_SSH_FALLBACKS=
    -RUNNER_REMOTE_WORKDIR=/tmp/taler-monitoring-site-gen
    -MONITORING_ROOT=..
    -
    -# Outside-only: never SSH into bank/exchange/merchant containers
    -SKIP_SSH=1
    -
    -# Deploy staging on koopa (host Caddy later — root copies to /var/www)
    -DEPLOY_SSH=koopa-external
    -DEPLOY_SSH_FALLBACKS=koopa
    -# Static files for Caddy file_server (created per hostname)
    -DEPLOY_WWW_ROOT=/var/www/monitoring-sites
    -DEPLOY_SUDO=sudo
    -CADDY_CONFIG=/etc/caddy/Caddyfile
    -CADDY_MIRROR_REL=configs/caddy/Caddyfile
    -# Working tree on deploy host for apply (optional)
    -KOOPA_CADDY_DIR=/home/hernani/koopa-caddy
    -
    -# Source link for HTML footer (Forgejo)
    -SOURCE_REPO_WEB=https://git.hacktivism.ch/hernani/koopa-admin-log
    -SOURCE_COMMIT_URL_TMPL={repo}/src/commit/{commit}
    -SOURCE_SUITE_PATH=scripts/taler-monitoring
    -
    -CONTINUE_ON_ERROR=1
    -PHASES_ERR=urls versions
    -PHASES_OK=urls versions
    -SKIP_E2E=1
    -
    -# Local work dir for logs + HTML
    -WORK_ROOT=/tmp/taler-monitoring-sites-work
    -
    -# Sites: hostname|domain_profile  (9 fronts)
    -# Start with hacktivism; stage + prod LFP follow
    -SITES="
    -bank.hacktivism.ch|hacktivism.ch
    -exchange.hacktivism.ch|hacktivism.ch
    -taler.hacktivism.ch|hacktivism.ch
    -stage.bank.lefrancpaysan.ch|stage.lefrancpaysan.ch
    -stage.exchange.lefrancpaysan.ch|stage.lefrancpaysan.ch
    -stage.monnaie.lefrancpaysan.ch|stage.lefrancpaysan.ch
    -bank.lefrancpaysan.ch|lefrancpaysan.ch
    -exchange.lefrancpaysan.ch|lefrancpaysan.ch
    -monnaie.lefrancpaysan.ch|lefrancpaysan.ch
    -"
    -
    -# Only process this subset (space-separated hostnames). Empty = all SITES.
    -# Example for first deploy: ONLY_HOSTS=bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch
    -ONLY_HOSTS=
    diff --git a/scripts/taler-monitoring/surface-catalog.conf b/scripts/taler-monitoring/surface-catalog.conf
    deleted file mode 100644
    index 16d5755..0000000
    --- a/scripts/taler-monitoring/surface-catalog.conf
    +++ /dev/null
    @@ -1,52 +0,0 @@
    -# surface-catalog.conf — known public Taler / GNUnet / Taler Systems hosts
    -# Format (whitespace-separated; # comments):
    -#   host   ports   expect_proto   label
    -# expect_proto: https | http | ssh | tcp | any
    -# ports: comma-separated (empty = defaults for proto)
    -#
    -# This list is expanded at runtime (DNS discovery, cert SANs, -d domain profile).
    -# Phase `surface` is NOT in default phases — run explicitly:
    -#   ./taler-monitoring.sh surface
    -#   ./taler-monitoring.sh -d hacktivism.ch surface
    -
    -# --- taler.net ecosystem ---
    -www.taler.net                 443     https   taler-www
    -docs.taler.net                443     https   taler-docs
    -git.taler.net                 443,22  https   taler-git
    -bugs.taler.net                443     https   taler-bugs
    -lists.taler.net               443     https   taler-lists
    -tutorials.taler.net           443     https   taler-tutorials
    -bank.demo.taler.net           443     https   demo-bank
    -exchange.demo.taler.net       443     https   demo-exchange
    -backend.demo.taler.net        443     https   demo-merchant
    -shop.demo.taler.net           443     https   demo-shop
    -bank.test.taler.net           443     https   test-bank
    -exchange.test.taler.net       443     https   test-exchange
    -backend.test.taler.net        443     https   test-merchant
    -
    -# --- taler-ops / TOPS ---
    -www.taler-ops.ch              443     https   tops-www
    -taler-ops.ch                  443     https   tops-apex
    -bank.taler-ops.ch             443     https   tops-bank
    -exchange.taler-ops.ch         443     https   tops-exchange
    -my.taler-ops.ch               443     https   tops-merchant
    -stage.taler-ops.ch            443     https   tops-stage-www
    -stage.my.taler-ops.ch         443     https   tops-stage-merchant
    -exchange.stage.taler-ops.ch   443     https   tops-stage-exchange
    -nexus.stage.taler-ops.ch      443     https   tops-stage-nexus
    -shops.taler-ops.ch            443     https   tops-shops
    -mattermost.taler.net          443     https   mattermost
    -
    -# --- gnunet.org ---
    -www.gnunet.org                443,80  https   gnunet-www
    -gnunet.org                    443,80  https   gnunet-apex
    -git.gnunet.org                443,22  https   gnunet-git
    -bugs.gnunet.org               443     https   gnunet-bugs
    -
    -# --- taler-systems.com ---
    -www.taler-systems.com         443     https   tsa-www
    -taler-systems.com             443     https   tsa-apex
    -
    -# --- common infra hostnames (remote only; may 404/redirect) ---
    -deb.taler.net                 443     https   deb-taler
    -ftp.gnu.org                   443     https   gnu-ftp
    diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh
    deleted file mode 100755
    index 557c7a0..0000000
    --- a/scripts/taler-monitoring/taler-monitoring.sh
    +++ /dev/null
    @@ -1,477 +0,0 @@
    -#!/usr/bin/env bash
    -# taler-monitoring — public URL / stack checks for a Taler domain
    -#
    -#   ./taler-monitoring.sh                          # local GOA (urls + inside + e2e)
    -#   ./taler-monitoring.sh -d taler.net urls         # public demo, no SSH
    -#   ./taler-monitoring.sh --domain taler-ops.ch     # public ops, no SSH
    -#   TALER_DOMAIN=demo.taler.net ./taler-monitoring.sh urls
    -#
    -# Tags: [OK] [INFO] [WARN] [ERROR] [BLOCKER]
    -# Exit 0 only if every selected phase exits 0.
    -
    -set -euo pipefail
    -ROOT=$(cd "$(dirname "$0")" && pwd)
    -
    -# Line-buffered stdout/stderr so redirected logs (tee/host-agent) flush each line.
    -if [ "${_TALER_MON_STDBUF:-0}" != "1" ] && command -v stdbuf >/dev/null 2>&1; then
    -  export _TALER_MON_STDBUF=1
    -  exec stdbuf -oL -eL bash "$0" "$@"
    -fi
    -
    -usage() {
    -  cat <<'EOF'
    -taler-monitoring — bank / exchange / merchant checks
    -
    -Usage:
    -  ./taler-monitoring.sh [options] [phases...]
    -
    -Phases:
    -  urls     public HTTPS (no SSH)          ← default with --domain
    -           (includes merchant /webui/ SPA fingerprints: version.txt, overlay, assets)
    -  inside   container status via SSH       (local stack only)
    -  versions taler packages vs deb.taler.net trixie + repo availability
    -  sanity   public + optional server
    -  server   server-side only (SSH)
    -  e2e      withdraw + pay (small amounts; remote aborts on login/KYC)
    -  ladder   withdraw/pay amount ladder (GOA ceiling or stage TESTPAYSAN max_wire)
    -  auth401  merchant Basic-auth / case matrix (HTTP 401 paths; may create throwaway instance)
    -  aptdeploy  koopa podman apt-src smoke: taler-merchant in
    -             koopa-taler-deploy-test-apt-src-trixie{,-testing}
    -  surface  REMOTE-ONLY public inventory (NOT in default/all/full):
    -             ecosystem hosts (taler.net, gnunet.org, taler-systems.com, mattermost, …)
    -             or -d DOMAIN → that domain’s surface; port/protocol/TLS/CVE (OSV)
    -  all      urls + inside + versions + sanity + e2e  (SSH phases only on koopa)
    -  full     all + server + ladder + auth401  (maximum; long-running, needs secrets)
    -           NOTE: surface is never included in all/full — pass it explicitly
    -
    -Options:
    -  -d, --domain DOMAIN   load profile from domains.conf (bank/exchange/merchant)
    -                        presets: koopa | hacktivism.ch | taler.net | taler-ops.ch
    -                                 | my.taler-ops.ch | stage.taler-ops.ch
    -                        unknown domain → heuristic hosts (see README)
    -                        local (koopa) allows SSH; others public-only
    -  --bank URL            bank base (hostname or https://…) — overrides profile
    -  --exchange URL        exchange base — overrides profile
    -  --merchant URL        merchant-backend base — overrides profile
    -  --currency CODE       expected currency (GOA, KUDOS, CHF, …); empty = report only
    -  --no-probe            do not probe alternate hosts (unknown domains only)
    -  -h, --help
    -
    -Add a stack: edit domains.conf (name + bank + exchange + merchant + currency).
    -
    -Examples:
    -  ./taler-monitoring.sh -d taler.net
    -  ./taler-monitoring.sh -d taler-ops.ch urls
    -  ./taler-monitoring.sh -d my.taler-ops.ch urls
    -  ./taler-monitoring.sh -d taler-ops.ch --merchant https://my.taler-ops.ch urls
    -  ./taler-monitoring.sh -d demo.taler.net --currency KUDOS
    -  ./taler-monitoring.sh --exchange https://exchange.taler-ops.ch \
    -      --merchant https://my.taler-ops.ch --bank https://bank.taler-ops.ch urls
    -
    -Env (same meaning):
    -  TALER_DOMAIN  BANK_PUBLIC  EXCHANGE_PUBLIC  MERCHANT_PUBLIC  EXPECT_CURRENCY
    -  TALER_DOMAINS_CONF  SKIP_SSH=1
    -  NO_COLOR=1 / CLICOLOR=0   disable green/yellow/red tags (default: coloured)
    -  SKIP_SSH=1    NO_COLOR=1
    -  RUN_TIMEOUT=600   whole-run wall clock seconds (default 600; 0=unlimited)
    -                    long phases (ladder/full/e2e) need a higher value or 0
    -  DISK_WARN_USED_PCT=85  disk free: WARN when used ≥ this %
    -  DISK_ERR_USED_PCT=95   disk free: ERROR when used ≥ this % (or avail=0 / 100%)
    -  PERF_WARN_MS  PERF_FAIL_MS   (urls latency; default 8000 / 20000)
    -  QR_CHECK=0    skip QR form + qrencode/zbarimg (urls phase)
    -  QR_ECC=M      qrencode ECC level (default M)
    -  KOOPA_SSH  KOOPA_SSH_FALLBACKS   (default koopa → koopa-external)
    -  METRICS_LOAD=0   skip host/container RAM/CPU probes (e2e/ladder/inside)
    -  EXPECT_WEBUI_VERSION=1.6.11   pin merchant /webui/version.txt (urls)
    -  EXPECT_WEBUI_OVERLAY=selfbuild  require substr in version-overlay.txt
    -  WEBUI_OVERLAY_DENY='master-11340'  fail if overlay matches regex
    -  CHECK_WEBUI_SPA=0             skip SPA fingerprint block in urls
    -  AUTH401_*                     see check_auth401.sh / secrets.env.example
    -  AUTH401_CONTINUE=1 / CONTINUE_ON_ERROR=1
    -                        auth401: run all groups, collect every ERROR (no mid-run halt)
    -  APT_DEPLOY_SKIP=1     skip aptdeploy phase
    -  APT_DEPLOY_CONTAINERS="name:suite …"  default trixie + trixie-testing deploy-test pods
    -  SURFACE_CVE=0         disable CVE queries in surface phase
    -  SURFACE_CVE_LEVEL=warn|error  bare Server-header versions default warn
    -                        (Debian package versions default error)
    -  SURFACE_CATALOG=path  override surface-catalog.conf
    -
    -Full load (GOA / hacktivism):
    -  ./taler-monitoring.sh -d hacktivism.ch full
    -  # or explicit:
    -  ./taler-monitoring.sh -d hacktivism.ch urls inside versions sanity server e2e ladder auth401
    -  # hacktivism host-agent also runs aptdeploy (apt-src merchant containers on koopa)
    -
    -Remote surface / ecosystem (explicit only — never default):
    -  ./taler-monitoring.sh surface
    -  ./taler-monitoring.sh -d hacktivism.ch surface
    -  ./taler-monitoring.sh -d lefrancpaysan.ch surface
    -
    -SPA pin after selfbuild:
    -  EXPECT_WEBUI_VERSION=1.6.11 EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \\
    -    WEBUI_OVERLAY_DENY='c778af04|master-11340' \\
    -    ./taler-monitoring.sh -d hacktivism.ch urls
    -EOF
    -}
    -
    -# shellcheck source=lib.sh
    -source "$ROOT/lib.sh"
    -# Optional gitignored secrets.env next to this suite (or MONITORING_SECRETS_ENV=)
    -load_monitoring_secrets_env 2>/dev/null || true
    -
    -# Persist #NNN + progress across check_*.sh child processes
    -TALER_MON_STATE=$(mktemp "${TMPDIR:-/tmp}/taler-mon-state.XXXXXX")
    -export TALER_MON_STATE
    -trap 'rm -f "${TALER_MON_STATE:-}"' EXIT
    -
    -PHASES=()
    -DOMAIN_SET=0
    -BANK_OVERRIDE=""
    -EXCHANGE_OVERRIDE=""
    -MERCHANT_OVERRIDE=""
    -CURRENCY_OVERRIDE=""
    -NO_PROBE=0
    -
    -while [ $# -gt 0 ]; do
    -  case "$1" in
    -    -h|--help) usage; exit 0 ;;
    -    -d|--domain)
    -      [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; }
    -      apply_taler_domain "$2"
    -      DOMAIN_SET=1
    -      TALER_DOMAIN_FROM_CLI=1
    -      shift 2
    -      ;;
    -    --bank)
    -      [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; }
    -      BANK_OVERRIDE="${2%/}"; shift 2
    -      ;;
    -    --exchange)
    -      [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; }
    -      EXCHANGE_OVERRIDE="${2%/}"; shift 2
    -      ;;
    -    --merchant)
    -      [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; }
    -      MERCHANT_OVERRIDE="${2%/}"; shift 2
    -      ;;
    -    --currency)
    -      [ $# -ge 2 ] || { echo "missing arg for $1" >&2; exit 2; }
    -      CURRENCY_OVERRIDE="$2"; shift 2
    -      ;;
    -    --no-probe) NO_PROBE=1; shift ;;
    -    urls|inside|versions|sanity|server|e2e|ladder|goa-ladder|auth401|aptdeploy|apt-deploy|apt_src|surface|ecosystem|all|full) PHASES+=("$1"); shift ;;
    -    *)
    -      # bare domain shorthand: ./taler-monitoring.sh taler.net
    -      if [[ "$1" == *.* && "$1" != *://* && "$1" != -* ]]; then
    -        apply_taler_domain "$1"
    -        DOMAIN_SET=1
    -        TALER_DOMAIN_FROM_CLI=1
    -        shift
    -      else
    -        echo "Unknown: $1" >&2; usage >&2; exit 2
    -      fi
    -      ;;
    -  esac
    -done
    -
    -if [ "$NO_PROBE" = "1" ]; then
    -  TALER_DOMAIN_PROBE=0
    -fi
    -if [ -n "$CURRENCY_OVERRIDE" ]; then
    -  EXPECT_CURRENCY="$CURRENCY_OVERRIDE"
    -fi
    -if [ -n "$BANK_OVERRIDE" ]; then BANK_PUBLIC="$BANK_OVERRIDE"; fi
    -if [ -n "$EXCHANGE_OVERRIDE" ]; then EXCHANGE_PUBLIC="$EXCHANGE_OVERRIDE"; fi
    -if [ -n "$MERCHANT_OVERRIDE" ]; then MERCHANT_PUBLIC="$MERCHANT_OVERRIDE"; fi
    -
    -# Only koopa may use SSH. Remote domains: public + optional e2e (no SSH).
    -if [ "${LOCAL_STACK:-1}" != "1" ]; then
    -  SKIP_SSH=1
    -fi
    -
    -# Remote: no SSH; ATM withdraw ladder set in check_e2e (smaller notes)
    -if [ "${LOCAL_STACK}" != "1" ]; then
    -  E2E_FAKE_INCOMING=0
    -  E2E_REMOTE=1
    -  : "${E2E_WITHDRAW_VALUES:=10 20 50}"
    -  : "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}"
    -fi
    -
    -# Export so check_*.sh (re-source lib) see the same targets via env
    -export TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC
    -export EXPECT_CURRENCY SKIP_SSH LOCAL_STACK TALER_DOMAIN_PROBE CHECK_LANDING
    -export TALER_DOMAIN_FROM_CLI="${TALER_DOMAIN_FROM_CLI:-0}"
    -export DOMAIN_SET="${DOMAIN_SET:-0}"
    -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 E2E_USE_TEMPLATES E2E_TEMPLATE_MAP
    -export PAIVANA_PUBLIC E2E_PAIVANA E2E_PAIVANA_TEMPLATE E2E_PAIVANA_AMOUNT E2E_PAIVANA_INSTANCE
    -export INSIDE_PROFILE INSIDE_SSH
    -export INSIDE_BANK_CTR INSIDE_EXCHANGE_CTR INSIDE_MERCHANT_CTR
    -export INSIDE_BANK_PORT INSIDE_EXCHANGE_PORT INSIDE_MERCHANT_PORT
    -export INSIDE_DNS_BANK INSIDE_DNS_EXCHANGE INSIDE_DNS_MERCHANT
    -# 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}"
    -: "${LADDER_CONFIRM_POLLS:=40}"
    -: "${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}"
    -: "${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
    -# Optional: auth401 and other phases may honor CONTINUE_ON_ERROR
    -export CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-}"
    -export AUTH401_CONTINUE="${AUTH401_CONTINUE:-${CONTINUE_ON_ERROR:-}}"
    -
    -# Default phases
    -# - local GOA (koopa): urls + inside + versions + e2e
    -# - FrancPaysan STAGE: urls + inside (stagepaysan) + versions + e2e
    -# - other remote: urls only (pass e2e/inside explicitly)
    -if [ "${#PHASES[@]}" -eq 0 ]; then
    -  if [ "${LOCAL_STACK}" = "1" ]; then
    -    PHASES=(urls inside versions e2e)
    -  elif [ "${EXPECT_CURRENCY:-}" = "TESTPAYSAN" ] \
    -    || [[ "${TALER_DOMAIN:-}" == stage.*lefrancpaysan* ]] \
    -    || [[ "${TALER_DOMAIN:-}" == *stage.lefrancpaysan* ]]; then
    -    # Like hacktivism breadth: public urls + inside (stagepaysan) + versions + e2e
    -    PHASES=(urls inside versions e2e)
    -  else
    -    PHASES=(urls)
    -  fi
    -fi
    -
    -OUT_PHASES=()
    -for p in "${PHASES[@]}"; do
    -  if [ "$p" = "all" ]; then
    -    if [ "${LOCAL_STACK}" = "1" ]; then
    -      OUT_PHASES+=(urls inside versions sanity e2e)
    -    elif [ -n "${INSIDE_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "stage-lfp" ]; then
    -      OUT_PHASES+=(urls inside versions e2e)
    -    else
    -      # remote without inside SSH: public + optional e2e
    -      OUT_PHASES+=(urls versions e2e)
    -    fi
    -  elif [ "$p" = "full" ]; then
    -    # Maximum coverage. Long. Needs secrets for e2e/ladder/auth401.
    -    if [ "${LOCAL_STACK}" = "1" ]; then
    -      OUT_PHASES+=(urls inside versions sanity server e2e ladder auth401)
    -    elif [ -n "${INSIDE_SSH:-}" ] || [ "${INSIDE_PROFILE:-}" = "stage-lfp" ]; then
    -      OUT_PHASES+=(urls inside versions e2e ladder auth401)
    -    else
    -      OUT_PHASES+=(urls versions e2e auth401)
    -    fi
    -  else
    -    OUT_PHASES+=("$p")
    -  fi
    -done
    -PHASES=()
    -seen=" "
    -for p in "${OUT_PHASES[@]}"; do
    -  # server = koopa-only. inside allowed on koopa OR stagepaysan (INSIDE_SSH).
    -  case "$p" in
    -    server)
    -      if [ "${LOCAL_STACK}" != "1" ]; then
    -        echo "[INFO]  skip phase 'server' (koopa only)" >&2
    -        continue
    -      fi
    -      ;;
    -    inside)
    -      if [ "${LOCAL_STACK}" != "1" ] && [ -z "${INSIDE_SSH:-}" ] && [ "${INSIDE_PROFILE:-}" != "stage-lfp" ]; then
    -        echo "[INFO]  skip phase 'inside' (no INSIDE_SSH / not local)" >&2
    -        continue
    -      fi
    -      ;;
    -  esac
    -  case "$seen" in *" $p "*) ;; *) PHASES+=("$p"); seen="$seen$p " ;; esac
    -done
    -
    -if [ "${#PHASES[@]}" -eq 0 ]; then
    -  PHASES=(urls)
    -fi
    -
    -# Rough expected check counts for progress % (override: PROGRESS_TOTAL=N).
    -# Prefer slightly high — lib.sh rebalances if still short (never shows done>total).
    -if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then
    -  _pt=0
    -  for p in "${PHASES[@]}"; do
    -    case "$p" in
    -      urls)     _pt=$((_pt + 95)) ;;   # +qr + webui SPA fingerprints
    -      inside)   _pt=$((_pt + 30)) ;;
    -      versions) _pt=$((_pt + 25)) ;;
    -      sanity)   _pt=$((_pt + 35)) ;;
    -      server)   _pt=$((_pt + 20)) ;;
    -      # e2e: ATM ladder emits many INFO lines (coins before/after each note)
    -      e2e)      _pt=$((_pt + 240)) ;;
    -      ladder|goa-ladder) _pt=$((_pt + 120)) ;;
    -      auth401)  _pt=$((_pt + 70)) ;;
    -      aptdeploy) _pt=$((_pt + 20)) ;;
    -      surface|ecosystem) _pt=$((_pt + 80)) ;;
    -    esac
    -  done
    -  set_progress_total "$_pt"
    -  unset _pt
    -fi
    -
    -# Whole-run wall clock (default 10 min). Override: RUN_TIMEOUT=0 (unlimited).
    -: "${RUN_TIMEOUT:=600}"
    -export RUN_TIMEOUT
    -MON_T0=$(date +%s)
    -export MON_T0
    -RUN_TIMED_OUT=0
    -RUN_TIMEOUT_AT_PHASE=""
    -RUN_SKIPPED_PHASES=()
    -
    -mon_seconds_left() {
    -  if [ "${RUN_TIMEOUT:-0}" -eq 0 ]; then
    -    printf '%s' "999999"
    -    return 0
    -  fi
    -  local now left
    -  now=$(date +%s)
    -  left=$((RUN_TIMEOUT - (now - MON_T0)))
    -  [ "$left" -lt 0 ] && left=0
    -  printf '%s' "$left"
    -}
    -
    -# Run one phase script under remaining RUN_TIMEOUT budget.
    -# Exit 124 (timeout utility) → mark RUN_TIMED_OUT.
    -run_phase() {
    -  local phase="$1" script="$2" left rc
    -  left=$(mon_seconds_left)
    -  if [ "${RUN_TIMEOUT:-0}" -gt 0 ] && [ "$left" -le 0 ]; then
    -    RUN_TIMED_OUT=1
    -    RUN_TIMEOUT_AT_PHASE="${RUN_TIMEOUT_AT_PHASE:-$phase}"
    -    return 1
    -  fi
    -  if [ "${RUN_TIMEOUT:-0}" -eq 0 ]; then
    -    "$script"
    -    return $?
    -  fi
    -  # Keep at least 2s so timeout(1) can start the child.
    -  [ "$left" -lt 2 ] && left=2
    -  set +e
    -  with_timeout "$left" "$script"
    -  rc=$?
    -  set -e
    -  if [ "$rc" -eq 124 ]; then
    -    RUN_TIMED_OUT=1
    -    RUN_TIMEOUT_AT_PHASE="$phase"
    -    return 1
    -  fi
    -  return "$rc"
    -}
    -
    -# How inside/versions will reach containers (host-podman vs ssh)
    -_INSIDE_ACCESS_HINT=ssh
    -if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then
    -  _INSIDE_ACCESS_HINT=host-podman
    -elif [ "${SKIP_SSH:-0}" = "1" ]; then
    -  _INSIDE_ACCESS_HINT=skipped
    -elif [ "${LOCAL_STACK:-0}" = "1" ] && command -v podman >/dev/null 2>&1 \
    -  && podman ps --format '{{.Names}}' 2>/dev/null | grep -qE 'taler-hacktivism'; then
    -  _INSIDE_ACCESS_HINT=host-podman
    -fi
    -
    -printf 'target domain=%s\n' "${TALER_DOMAIN}"
    -printf '  bank     %s\n' "$BANK_PUBLIC"
    -printf '  exchange %s\n' "$EXCHANGE_PUBLIC"
    -printf '  merchant %s\n' "$MERCHANT_PUBLIC"
    -printf '  currency expect=%s\n' "${EXPECT_CURRENCY:-any}"
    -printf '  phases   %s\n' "${PHASES[*]}"
    -printf '  flags    LOCAL_STACK=%s SKIP_SSH=%s INSIDE_PODMAN=%s INSIDE_MODE=%s\n' \
    -  "${LOCAL_STACK:-}" "${SKIP_SSH:-0}" "${INSIDE_PODMAN:-0}" "${INSIDE_MODE:-}"
    -printf '  flags    KOOPA_SSH=%s INSIDE_SSH=%s INSIDE_PROFILE=%s\n' \
    -  "${KOOPA_SSH:-}" "${INSIDE_SSH:-}" "${INSIDE_PROFILE:-}"
    -printf '  access   inside/versions → %s' "$_INSIDE_ACCESS_HINT"
    -case "$_INSIDE_ACCESS_HINT" in
    -  host-podman) printf ' (podman exec on this host; IDs inside.host-*)\n' ;;
    -  ssh)         printf ' (SSH then podman; IDs inside.ssh-*)\n' ;;
    -  skipped)     printf ' (SKIP_SSH=1)\n' ;;
    -  *)           printf '\n' ;;
    -esac
    -if [ "${RUN_TIMEOUT:-0}" -eq 0 ]; then
    -  printf '  run_timeout unlimited (RUN_TIMEOUT=0)\n'
    -else
    -  printf '  run_timeout %ss wall clock (RUN_TIMEOUT=; 0=unlimited)\n' "$RUN_TIMEOUT"
    -fi
    -printf '  progress total≈%s (set PROGRESS_TOTAL= to override; PROGRESS_OFF=1 to hide)\n' "${PROGRESS_TOTAL:-0}"
    -unset _INSIDE_ACCESS_HINT
    -
    -chmod +x "$ROOT"/check_*.sh 2>/dev/null || true
    -
    -ec=0
    -for p in "${PHASES[@]}"; do
    -  if [ "$RUN_TIMED_OUT" = "1" ]; then
    -    RUN_SKIPPED_PHASES+=("$p")
    -    continue
    -  fi
    -  left=$(mon_seconds_left)
    -  if [ "${RUN_TIMEOUT:-0}" -gt 0 ] && [ "$left" -le 0 ]; then
    -    RUN_TIMED_OUT=1
    -    RUN_TIMEOUT_AT_PHASE="${RUN_TIMEOUT_AT_PHASE:-$p}"
    -    RUN_SKIPPED_PHASES+=("$p")
    -    continue
    -  fi
    -  case "$p" in
    -    urls)     run_phase urls     "$ROOT/check_urls.sh"     || ec=1 ;;
    -    inside)   run_phase inside   "$ROOT/check_inside.sh"   || ec=1 ;;
    -    versions) run_phase versions "$ROOT/check_versions.sh" || ec=1 ;;
    -    sanity)   run_phase sanity   "$ROOT/check_sanity.sh"   || ec=1 ;;
    -    server)   run_phase server   "$ROOT/check_server.sh"   || ec=1 ;;
    -    e2e)      run_phase e2e      "$ROOT/check_e2e.sh"      || ec=1 ;;
    -    ladder|goa-ladder) run_phase ladder "$ROOT/check_goa_ladder.sh" || ec=1 ;;
    -    auth401)  run_phase auth401  "$ROOT/check_auth401.sh"  || ec=1 ;;
    -    aptdeploy|apt-deploy|apt_src) run_phase aptdeploy "$ROOT/check_apt_deploy.sh" || ec=1 ;;
    -    surface|ecosystem) run_phase surface "$ROOT/check_surface.sh" || ec=1 ;;
    -  esac
    -done
    -
    -# Extraordinary run-budget failure: always report at end; HTML links top → here.
    -if [ "$RUN_TIMED_OUT" = "1" ]; then
    -  ec=1
    -  elapsed=$(( $(date +%s) - MON_T0 ))
    -  skipped="${RUN_SKIPPED_PHASES[*]:-}"
    -  # Stable id for jump links (console HTML + err-top banner)
    -  printf '\n'
    -  printf '╔══════════════════════════════════════════════════════════╗\n'
    -  printf '║ RUN TIMEOUT · extraordinary (see jump target below)     ║\n'
    -  printf '╚══════════════════════════════════════════════════════════╝\n'
    -  printf '┌ ERROR  ┐ #run.timeout-01  RUN_TIMEOUT exceeded · budget %ss · elapsed %ss\n' \
    -    "$RUN_TIMEOUT" "$elapsed"
    -  printf '  detail: wall-clock limit hit'
    -  if [ -n "${RUN_TIMEOUT_AT_PHASE:-}" ]; then
    -    printf ' during/after phase "%s"' "$RUN_TIMEOUT_AT_PHASE"
    -  fi
    -  if [ -n "$skipped" ]; then
    -    printf ' · skipped: %s' "$skipped"
    -  fi
    -  printf '\n'
    -  printf '  id=run.timeout-01\n'
    -  printf '  hint: raise RUN_TIMEOUT= (seconds) or set RUN_TIMEOUT=0 for unlimited\n'
    -  printf -- '┌ RUN TIMEOUT · extraordinary ┐\n'
    -  printf -- '  • run.timeout-01 [run] RUN_TIMEOUT=%ss exceeded (elapsed %ss)%s%s\n' \
    -    "$RUN_TIMEOUT" "$elapsed" \
    -    "${RUN_TIMEOUT_AT_PHASE:+ · phase $RUN_TIMEOUT_AT_PHASE}" \
    -    "${skipped:+ · skipped $skipped}"
    -  printf -- '--- ERRORS (failed checks) ---\n'
    -  printf -- '  • run.timeout-01 [run] RUN_TIMEOUT=%ss exceeded (elapsed %ss)\n' \
    -    "$RUN_TIMEOUT" "$elapsed"
    -fi
    -
    -exit "$ec"
    
    From 1a7f4cb1028ae56b985d3cc493465d004666cdb5 Mon Sep 17 00:00:00 2001
    From: hernani 
    Date: Sat, 18 Jul 2026 21:29:45 +0200
    Subject: [PATCH 317/326] docs: monitoring public HTML needs Caddy/www root
     apply (I-09)
    
    Document merchant JSON code 21 on /monitoring* as infra (not suite).
    Topic note, day log, ISSUES I-09, pointer README, sync prepared Caddyfile.
    ---
     2026/2026-07-18--monitoring-public-caddy.md | 66 ++++++++++++++++++
     2026/2026-07-18.md                          |  7 ++
     ISSUES.md                                   | 24 ++++++-
     configs/caddy/Caddyfile                     | 77 +++++++++++++++++++--
     configs/caddy/README.md                     |  8 +++
     scripts/taler-monitoring/README.md          | 27 ++++----
     6 files changed, 189 insertions(+), 20 deletions(-)
     create mode 100644 2026/2026-07-18--monitoring-public-caddy.md
     create mode 100644 2026/2026-07-18.md
    
    diff --git a/2026/2026-07-18--monitoring-public-caddy.md b/2026/2026-07-18--monitoring-public-caddy.md
    new file mode 100644
    index 0000000..2f84af7
    --- /dev/null
    +++ b/2026/2026-07-18--monitoring-public-caddy.md
    @@ -0,0 +1,66 @@
    +# 2026-07-18 — Monitoring-HTML öffentlich: Caddy / www (Infra)
    +
    +## Symptom
    +
    +Öffentliche URLs liefern **Merchant-JSON code 21** (HTTP 404), kein sticky-bar HTML:
    +
    +- `https://taler.hacktivism.ch/monitoring/`
    +- `https://bank.hacktivism.ch/monitoring/`
    +- `https://exchange.hacktivism.ch/monitoring/`
    +- `https://taler.hacktivism.ch/taler-monitoring-surface/`
    +- `https://taler.hacktivism.ch/taler-monitoring-aptdeploy/`
    +- `https://taler.hacktivism.ch/taler-monitoring-mattermost/`
    +- `https://taler.hacktivism.ch/taler-monitoring-mail/`
    +
    +(jeweils auch ohne trailing slash und `*_err/` wo vorhanden)
    +
    +## Nicht die Suite
    +
    +Standalone-Repo **taler-monitoring** (aktuell z. B. **v1.7.1**, `~/src/taler-monitoring`):
    +
    +| Ort | Status |
    +|-----|--------|
    +| Staging `~/monitoring-sites-staging/…/*/index.html` | **vorhanden** (sticky-bar) |
    +| Host-agent Timer | aktiv; meldet Deploy/monpages-Fehler (`STRICT_EXIT=1` ab v1.7.1) |
    +| Live `/var/www/monitoring-sites` | unvollständig / veraltet; **nicht** schreibbar für `hernani` |
    +| Live `/etc/caddy/Caddyfile` | **keine** `handle /monitoring*` (Stand 2026-07-18) |
    +| Vorbereitet | `~/koopa-caddy/Caddyfile` (Handles vor `reverse_proxy`) |
    +
    +Caddy läuft als System-Unit (`User=caddy`, **`ProtectHome=true`**) → Staging unter `/home/hernani` ist für Caddy **nicht** lesbar. Auslieferung muss über **`/var/www/monitoring-sites`**.
    +
    +## Root-Fix (einmal)
    +
    +```bash
    +sudo ~/koopa-caddy/apply-monitoring-live.sh
    +```
    +
    +Das Script:
    +
    +1. `rsync` Staging → `/var/www/monitoring-sites/`
    +2. Ownership/ACL: `hernani` schreibbar, Gruppe `caddy`
    +3. Installiert vorbereitete Caddyfile → `/etc/caddy/Caddyfile`, `caddy validate`, `systemctl reload caddy`
    +4. Smoke-curl der Monitoring-URLs (darf **nicht** code 21 sein)
    +
    +Danach sollten Host-Agent-Läufe selbst nach www rsyncen (wenn ACL greift).
    +
    +## Verifikation
    +
    +```bash
    +curl -sS -o /dev/null -w "%{http_code}\n" https://taler.hacktivism.ch/monitoring/
    +# expect 200 + HTML with sticky-bar, not JSON code 21
    +
    +grep -c "handle /monitoring" /etc/caddy/Caddyfile
    +# expect > 0
    +
    +test -w /var/www/monitoring-sites && echo www_writable
    +```
    +
    +## Suite vs. Ops
    +
    +| Repo | Rolle |
    +|------|--------|
    +| [taler-monitoring](https://git.hacktivism.ch/hernani/taler-monitoring) | Checks, HTML-Generator, host-agent |
    +| **koopa-admin-log** (dieses Repo) | Caddy-Spiegel, Apply-Skript-Hinweise, Issues |
    +| `~/koopa-caddy/` | Live-Apply-Quelle auf dem Host (nicht Secrets) |
    +
    +Siehe Issue **I-09** in `ISSUES.md`.
    diff --git a/2026/2026-07-18.md b/2026/2026-07-18.md
    new file mode 100644
    index 0000000..13feba3
    --- /dev/null
    +++ b/2026/2026-07-18.md
    @@ -0,0 +1,7 @@
    +# 2026-07-18
    +
    +- **taler-monitoring** standalone weiter (Tags bis **v1.7.1**): monpages, mattermost, mail, `--ver`, `STRICT_EXIT=1`
    +- **Infra:** öffentliche Monitoring-HTML weiter **code 21** — live Caddy ohne Handles, www nicht hernani-schreibbar
    +  → Topic: `2026-07-18--monitoring-public-caddy.md`, Issue **I-09**
    +  → Fix: `sudo ~/koopa-caddy/apply-monitoring-live.sh` (noch ausstehend, braucht root)
    +- Suite-Pfad kanonisch: `~/src/taler-monitoring` (nicht unter admin-log)
    diff --git a/ISSUES.md b/ISSUES.md
    index 4e89ea6..efb5921 100644
    --- a/ISSUES.md
    +++ b/ISSUES.md
    @@ -1,6 +1,6 @@
     # Open issues
     
    -Stand **2026-07-17** (koopa, live). Keine Secrets.
    +Stand **2026-07-18** (koopa, live). Keine Secrets.
     
     ## Index
     
    @@ -14,9 +14,31 @@ Stand **2026-07-17** (koopa, live). Keine Secrets.
     | [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-08](#i-2026-07-17-08) | medium | open | Landing-stats → standalone `taler-landing-stats` |
    +| [I-09](#i-2026-07-18-09) | high | open | Monitoring-HTML öffentlich: Caddy/www (code 21) |
     
     ---
     
    +
    +---
    +
    +### I-2026-07-18-09
    +
    +**Monitoring-HTML öffentlich: Caddy handles / www (merchant code 21)**  
    +Severity: high · Host: koopa · Status: open
    +
    +Öffentliche `/monitoring*` und `/taler-monitoring-*` → Merchant **JSON code 21**.  
    +Staging unter `~/monitoring-sites-staging` ist befüllt; live `/etc/caddy/Caddyfile` hat keine Monitoring-Handles; `/var/www/monitoring-sites` nicht schreibbar für `hernani`.
    +
    +**Fix (root):**
    +
    +```bash
    +sudo ~/koopa-caddy/apply-monitoring-live.sh
    +```
    +
    +Details: `2026/2026-07-18--monitoring-public-caddy.md`  
    +Suite: https://git.hacktivism.ch/hernani/taler-monitoring (v1.7.1+)
    +
    +
     ### I-2026-07-16-01
     
     **firewalld: Nym-Ports auf koopa unbestätigt**  
    diff --git a/configs/caddy/Caddyfile b/configs/caddy/Caddyfile
    index 7b960b9..f16cb53 100644
    --- a/configs/caddy/Caddyfile
    +++ b/configs/caddy/Caddyfile
    @@ -37,22 +37,85 @@ taler.hacktivism.ch {
             }
         }
     
    +    # surface + aptdeploy HTML (taler.hacktivism.ch only)
    +    handle /taler-monitoring-surface_err {
    +        redir /taler-monitoring-surface_err/ 302
    +    }
    +    handle /taler-monitoring-surface_err/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface_err
    +        file_server
    +    }
    +    handle /taler-monitoring-surface {
    +        redir /taler-monitoring-surface/ 302
    +    }
    +    handle /taler-monitoring-surface/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface
    +        file_server
    +    }
    +    handle /taler-monitoring-aptdeploy_err {
    +        redir /taler-monitoring-aptdeploy_err/ 302
    +    }
    +    handle /taler-monitoring-aptdeploy_err/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy_err
    +        file_server
    +    }
    +    handle /taler-monitoring-aptdeploy {
    +        redir /taler-monitoring-aptdeploy/ 302
    +    }
    +    handle /taler-monitoring-aptdeploy/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy
    +        file_server
    +    }
    +
    +    handle /taler-monitoring-mattermost_err {
    +        redir /taler-monitoring-mattermost_err/ 302
    +    }
    +    handle /taler-monitoring-mattermost_err/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mattermost_err
    +        file_server
    +    }
    +    handle /taler-monitoring-mattermost {
    +        redir /taler-monitoring-mattermost/ 302
    +    }
    +    handle /taler-monitoring-mattermost/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mattermost
    +        file_server
    +    }
    +
    +    handle /taler-monitoring-mail_err {
    +        redir /taler-monitoring-mail_err/ 302
    +    }
    +    handle /taler-monitoring-mail_err/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mail_err
    +        file_server
    +    }
    +    handle /taler-monitoring-mail {
    +        redir /taler-monitoring-mail/ 302
    +    }
    +    handle /taler-monitoring-mail/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mail
    +        file_server
    +    }
    +
         # taler-monitoring console HTML (host static — not in app containers)
         handle /monitoring_err {
             redir /monitoring_err/ 302
         }
         handle /monitoring_err/ {
    -        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/monitoring_err
             file_server
         }
         handle /monitoring {
             redir /monitoring/ 302
         }
         handle /monitoring/ {
    -        root * /var/www/monitoring-sites/{host}/monitoring
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/monitoring
             file_server
         }
     
    +
    +
    +
         # SPA: /webui → /webui/
         redir /webui /webui/ 302
     
    @@ -83,17 +146,18 @@ exchange.hacktivism.ch {
             redir /monitoring_err/ 302
         }
         handle /monitoring_err/ {
    -        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        root * /var/www/monitoring-sites/exchange.hacktivism.ch/monitoring_err
             file_server
         }
         handle /monitoring {
             redir /monitoring/ 302
         }
         handle /monitoring/ {
    -        root * /var/www/monitoring-sites/{host}/monitoring
    +        root * /var/www/monitoring-sites/exchange.hacktivism.ch/monitoring
             file_server
         }
     
    +
         reverse_proxy 127.0.0.1:9011 {
             import proxy_public
         }
    @@ -116,17 +180,18 @@ bank.hacktivism.ch {
             redir /monitoring_err/ 302
         }
         handle /monitoring_err/ {
    -        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        root * /var/www/monitoring-sites/bank.hacktivism.ch/monitoring_err
             file_server
         }
         handle /monitoring {
             redir /monitoring/ 302
         }
         handle /monitoring/ {
    -        root * /var/www/monitoring-sites/{host}/monitoring
    +        root * /var/www/monitoring-sites/bank.hacktivism.ch/monitoring
             file_server
         }
     
    +
         # Static terms/privacy on landing nginx :9013
         handle /terms* {
             reverse_proxy 127.0.0.1:9013 {
    diff --git a/configs/caddy/README.md b/configs/caddy/README.md
    index 432d73c..db596b8 100644
    --- a/configs/caddy/README.md
    +++ b/configs/caddy/README.md
    @@ -7,3 +7,11 @@
     | `Caddyfile.taler-host` | **Legacy** Taler-only sketch — do not deploy |
     
     Also: `host/caddy/Caddyfile` (same as `Caddyfile` here).
    +
    +## Monitoring static HTML (2026-07-18)
    +
    +Handles for `/monitoring*` and `/taler-monitoring-*` live in this `Caddyfile` (prepared under `~/koopa-caddy/`).  
    +They must appear **before** the merchant `reverse_proxy`.  
    +
    +Live install + www ACL: `sudo ~/koopa-caddy/apply-monitoring-live.sh`  
    +See `2026/2026-07-18--monitoring-public-caddy.md` and ISSUES **I-09**.
    diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md
    index a0acf02..b29815c 100644
    --- a/scripts/taler-monitoring/README.md
    +++ b/scripts/taler-monitoring/README.md
    @@ -1,26 +1,27 @@
     # taler-monitoring has moved
     
    -The monitoring suite is no longer part of **koopa-admin-log**.
    +The monitoring **suite** is a standalone repository (not maintained under this tree).
     
     | | |
     |--|--|
     | **Repository** | https://git.hacktivism.ch/hernani/taler-monitoring |
     | **Clone** | `git clone https://git.hacktivism.ch/hernani/taler-monitoring.git ~/src/taler-monitoring` |
    -| **Tag** | `v1.0` (initial standalone release) |
    -| **History** | `VERSIONS.md` in that repo |
    +| **Install path** | `~/src/taler-monitoring` |
    +| **Version** | `./taler-monitoring.sh --ver` (tags `vMAJOR.FEATURE.FIX`, e.g. v1.7.1) |
     
    -## Quick start
    +## Host-agent (user systemd on koopa)
     
     ```bash
    -git clone https://git.hacktivism.ch/hernani/taler-monitoring.git ~/src/taler-monitoring
    -cd ~/src/taler-monitoring
    -./taler-monitoring.sh -d hacktivism.ch urls
    -# host-agent (koopa):
    -./host-agent/install-host-agent.sh
    +~/src/taler-monitoring/host-agent/install-host-agent.sh
     ```
     
    -Landing configs and Caddy site configs for GOA remain in **koopa-admin-log**
    -(`configs/`). The host-agent path unit still watches those paths when present.
    +## Public HTML (infra on this host)
     
    -Do **not** add monitoring scripts back into this directory — develop and push
    -against **taler-monitoring** only.
    +Generated under `~/monitoring-sites-staging/`.  
    +**Live** requires root publish + Caddy handles — see:
    +
    +- `2026/2026-07-18--monitoring-public-caddy.md`
    +- `ISSUES.md` → **I-09**
    +- Apply: `sudo ~/koopa-caddy/apply-monitoring-live.sh`
    +
    +Do not re-introduce the full suite under `scripts/taler-monitoring/`.
    
    From 6c92cd1c1b4e418ba4e7c646e2da17aab783a38e Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= 
    Date: Sat, 18 Jul 2026 21:29:45 +0200
    Subject: [PATCH 318/326] docs: monitoring public HTML needs Caddy/www root
     apply (I-09)
    
    Document merchant JSON code 21 on /monitoring* as infra (not suite).
    Topic note, day log, ISSUES I-09, pointer README, sync prepared Caddyfile.
    ---
     2026/2026-07-18--monitoring-public-caddy.md | 66 ++++++++++++++++++
     2026/2026-07-18.md                          |  7 ++
     ISSUES.md                                   | 24 ++++++-
     configs/caddy/Caddyfile                     | 77 +++++++++++++++++++--
     configs/caddy/README.md                     |  8 +++
     scripts/taler-monitoring/README.md          | 27 ++++----
     6 files changed, 189 insertions(+), 20 deletions(-)
     create mode 100644 2026/2026-07-18--monitoring-public-caddy.md
     create mode 100644 2026/2026-07-18.md
    
    diff --git a/2026/2026-07-18--monitoring-public-caddy.md b/2026/2026-07-18--monitoring-public-caddy.md
    new file mode 100644
    index 0000000..2f84af7
    --- /dev/null
    +++ b/2026/2026-07-18--monitoring-public-caddy.md
    @@ -0,0 +1,66 @@
    +# 2026-07-18 — Monitoring-HTML öffentlich: Caddy / www (Infra)
    +
    +## Symptom
    +
    +Öffentliche URLs liefern **Merchant-JSON code 21** (HTTP 404), kein sticky-bar HTML:
    +
    +- `https://taler.hacktivism.ch/monitoring/`
    +- `https://bank.hacktivism.ch/monitoring/`
    +- `https://exchange.hacktivism.ch/monitoring/`
    +- `https://taler.hacktivism.ch/taler-monitoring-surface/`
    +- `https://taler.hacktivism.ch/taler-monitoring-aptdeploy/`
    +- `https://taler.hacktivism.ch/taler-monitoring-mattermost/`
    +- `https://taler.hacktivism.ch/taler-monitoring-mail/`
    +
    +(jeweils auch ohne trailing slash und `*_err/` wo vorhanden)
    +
    +## Nicht die Suite
    +
    +Standalone-Repo **taler-monitoring** (aktuell z. B. **v1.7.1**, `~/src/taler-monitoring`):
    +
    +| Ort | Status |
    +|-----|--------|
    +| Staging `~/monitoring-sites-staging/…/*/index.html` | **vorhanden** (sticky-bar) |
    +| Host-agent Timer | aktiv; meldet Deploy/monpages-Fehler (`STRICT_EXIT=1` ab v1.7.1) |
    +| Live `/var/www/monitoring-sites` | unvollständig / veraltet; **nicht** schreibbar für `hernani` |
    +| Live `/etc/caddy/Caddyfile` | **keine** `handle /monitoring*` (Stand 2026-07-18) |
    +| Vorbereitet | `~/koopa-caddy/Caddyfile` (Handles vor `reverse_proxy`) |
    +
    +Caddy läuft als System-Unit (`User=caddy`, **`ProtectHome=true`**) → Staging unter `/home/hernani` ist für Caddy **nicht** lesbar. Auslieferung muss über **`/var/www/monitoring-sites`**.
    +
    +## Root-Fix (einmal)
    +
    +```bash
    +sudo ~/koopa-caddy/apply-monitoring-live.sh
    +```
    +
    +Das Script:
    +
    +1. `rsync` Staging → `/var/www/monitoring-sites/`
    +2. Ownership/ACL: `hernani` schreibbar, Gruppe `caddy`
    +3. Installiert vorbereitete Caddyfile → `/etc/caddy/Caddyfile`, `caddy validate`, `systemctl reload caddy`
    +4. Smoke-curl der Monitoring-URLs (darf **nicht** code 21 sein)
    +
    +Danach sollten Host-Agent-Läufe selbst nach www rsyncen (wenn ACL greift).
    +
    +## Verifikation
    +
    +```bash
    +curl -sS -o /dev/null -w "%{http_code}\n" https://taler.hacktivism.ch/monitoring/
    +# expect 200 + HTML with sticky-bar, not JSON code 21
    +
    +grep -c "handle /monitoring" /etc/caddy/Caddyfile
    +# expect > 0
    +
    +test -w /var/www/monitoring-sites && echo www_writable
    +```
    +
    +## Suite vs. Ops
    +
    +| Repo | Rolle |
    +|------|--------|
    +| [taler-monitoring](https://git.hacktivism.ch/hernani/taler-monitoring) | Checks, HTML-Generator, host-agent |
    +| **koopa-admin-log** (dieses Repo) | Caddy-Spiegel, Apply-Skript-Hinweise, Issues |
    +| `~/koopa-caddy/` | Live-Apply-Quelle auf dem Host (nicht Secrets) |
    +
    +Siehe Issue **I-09** in `ISSUES.md`.
    diff --git a/2026/2026-07-18.md b/2026/2026-07-18.md
    new file mode 100644
    index 0000000..13feba3
    --- /dev/null
    +++ b/2026/2026-07-18.md
    @@ -0,0 +1,7 @@
    +# 2026-07-18
    +
    +- **taler-monitoring** standalone weiter (Tags bis **v1.7.1**): monpages, mattermost, mail, `--ver`, `STRICT_EXIT=1`
    +- **Infra:** öffentliche Monitoring-HTML weiter **code 21** — live Caddy ohne Handles, www nicht hernani-schreibbar
    +  → Topic: `2026-07-18--monitoring-public-caddy.md`, Issue **I-09**
    +  → Fix: `sudo ~/koopa-caddy/apply-monitoring-live.sh` (noch ausstehend, braucht root)
    +- Suite-Pfad kanonisch: `~/src/taler-monitoring` (nicht unter admin-log)
    diff --git a/ISSUES.md b/ISSUES.md
    index 4e89ea6..efb5921 100644
    --- a/ISSUES.md
    +++ b/ISSUES.md
    @@ -1,6 +1,6 @@
     # Open issues
     
    -Stand **2026-07-17** (koopa, live). Keine Secrets.
    +Stand **2026-07-18** (koopa, live). Keine Secrets.
     
     ## Index
     
    @@ -14,9 +14,31 @@ Stand **2026-07-17** (koopa, live). Keine Secrets.
     | [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-08](#i-2026-07-17-08) | medium | open | Landing-stats → standalone `taler-landing-stats` |
    +| [I-09](#i-2026-07-18-09) | high | open | Monitoring-HTML öffentlich: Caddy/www (code 21) |
     
     ---
     
    +
    +---
    +
    +### I-2026-07-18-09
    +
    +**Monitoring-HTML öffentlich: Caddy handles / www (merchant code 21)**  
    +Severity: high · Host: koopa · Status: open
    +
    +Öffentliche `/monitoring*` und `/taler-monitoring-*` → Merchant **JSON code 21**.  
    +Staging unter `~/monitoring-sites-staging` ist befüllt; live `/etc/caddy/Caddyfile` hat keine Monitoring-Handles; `/var/www/monitoring-sites` nicht schreibbar für `hernani`.
    +
    +**Fix (root):**
    +
    +```bash
    +sudo ~/koopa-caddy/apply-monitoring-live.sh
    +```
    +
    +Details: `2026/2026-07-18--monitoring-public-caddy.md`  
    +Suite: https://git.hacktivism.ch/hernani/taler-monitoring (v1.7.1+)
    +
    +
     ### I-2026-07-16-01
     
     **firewalld: Nym-Ports auf koopa unbestätigt**  
    diff --git a/configs/caddy/Caddyfile b/configs/caddy/Caddyfile
    index 7b960b9..f16cb53 100644
    --- a/configs/caddy/Caddyfile
    +++ b/configs/caddy/Caddyfile
    @@ -37,22 +37,85 @@ taler.hacktivism.ch {
             }
         }
     
    +    # surface + aptdeploy HTML (taler.hacktivism.ch only)
    +    handle /taler-monitoring-surface_err {
    +        redir /taler-monitoring-surface_err/ 302
    +    }
    +    handle /taler-monitoring-surface_err/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface_err
    +        file_server
    +    }
    +    handle /taler-monitoring-surface {
    +        redir /taler-monitoring-surface/ 302
    +    }
    +    handle /taler-monitoring-surface/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface
    +        file_server
    +    }
    +    handle /taler-monitoring-aptdeploy_err {
    +        redir /taler-monitoring-aptdeploy_err/ 302
    +    }
    +    handle /taler-monitoring-aptdeploy_err/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy_err
    +        file_server
    +    }
    +    handle /taler-monitoring-aptdeploy {
    +        redir /taler-monitoring-aptdeploy/ 302
    +    }
    +    handle /taler-monitoring-aptdeploy/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy
    +        file_server
    +    }
    +
    +    handle /taler-monitoring-mattermost_err {
    +        redir /taler-monitoring-mattermost_err/ 302
    +    }
    +    handle /taler-monitoring-mattermost_err/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mattermost_err
    +        file_server
    +    }
    +    handle /taler-monitoring-mattermost {
    +        redir /taler-monitoring-mattermost/ 302
    +    }
    +    handle /taler-monitoring-mattermost/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mattermost
    +        file_server
    +    }
    +
    +    handle /taler-monitoring-mail_err {
    +        redir /taler-monitoring-mail_err/ 302
    +    }
    +    handle /taler-monitoring-mail_err/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mail_err
    +        file_server
    +    }
    +    handle /taler-monitoring-mail {
    +        redir /taler-monitoring-mail/ 302
    +    }
    +    handle /taler-monitoring-mail/ {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mail
    +        file_server
    +    }
    +
         # taler-monitoring console HTML (host static — not in app containers)
         handle /monitoring_err {
             redir /monitoring_err/ 302
         }
         handle /monitoring_err/ {
    -        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/monitoring_err
             file_server
         }
         handle /monitoring {
             redir /monitoring/ 302
         }
         handle /monitoring/ {
    -        root * /var/www/monitoring-sites/{host}/monitoring
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch/monitoring
             file_server
         }
     
    +
    +
    +
         # SPA: /webui → /webui/
         redir /webui /webui/ 302
     
    @@ -83,17 +146,18 @@ exchange.hacktivism.ch {
             redir /monitoring_err/ 302
         }
         handle /monitoring_err/ {
    -        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        root * /var/www/monitoring-sites/exchange.hacktivism.ch/monitoring_err
             file_server
         }
         handle /monitoring {
             redir /monitoring/ 302
         }
         handle /monitoring/ {
    -        root * /var/www/monitoring-sites/{host}/monitoring
    +        root * /var/www/monitoring-sites/exchange.hacktivism.ch/monitoring
             file_server
         }
     
    +
         reverse_proxy 127.0.0.1:9011 {
             import proxy_public
         }
    @@ -116,17 +180,18 @@ bank.hacktivism.ch {
             redir /monitoring_err/ 302
         }
         handle /monitoring_err/ {
    -        root * /var/www/monitoring-sites/{host}/monitoring_err
    +        root * /var/www/monitoring-sites/bank.hacktivism.ch/monitoring_err
             file_server
         }
         handle /monitoring {
             redir /monitoring/ 302
         }
         handle /monitoring/ {
    -        root * /var/www/monitoring-sites/{host}/monitoring
    +        root * /var/www/monitoring-sites/bank.hacktivism.ch/monitoring
             file_server
         }
     
    +
         # Static terms/privacy on landing nginx :9013
         handle /terms* {
             reverse_proxy 127.0.0.1:9013 {
    diff --git a/configs/caddy/README.md b/configs/caddy/README.md
    index 432d73c..db596b8 100644
    --- a/configs/caddy/README.md
    +++ b/configs/caddy/README.md
    @@ -7,3 +7,11 @@
     | `Caddyfile.taler-host` | **Legacy** Taler-only sketch — do not deploy |
     
     Also: `host/caddy/Caddyfile` (same as `Caddyfile` here).
    +
    +## Monitoring static HTML (2026-07-18)
    +
    +Handles for `/monitoring*` and `/taler-monitoring-*` live in this `Caddyfile` (prepared under `~/koopa-caddy/`).  
    +They must appear **before** the merchant `reverse_proxy`.  
    +
    +Live install + www ACL: `sudo ~/koopa-caddy/apply-monitoring-live.sh`  
    +See `2026/2026-07-18--monitoring-public-caddy.md` and ISSUES **I-09**.
    diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md
    index a0acf02..b29815c 100644
    --- a/scripts/taler-monitoring/README.md
    +++ b/scripts/taler-monitoring/README.md
    @@ -1,26 +1,27 @@
     # taler-monitoring has moved
     
    -The monitoring suite is no longer part of **koopa-admin-log**.
    +The monitoring **suite** is a standalone repository (not maintained under this tree).
     
     | | |
     |--|--|
     | **Repository** | https://git.hacktivism.ch/hernani/taler-monitoring |
     | **Clone** | `git clone https://git.hacktivism.ch/hernani/taler-monitoring.git ~/src/taler-monitoring` |
    -| **Tag** | `v1.0` (initial standalone release) |
    -| **History** | `VERSIONS.md` in that repo |
    +| **Install path** | `~/src/taler-monitoring` |
    +| **Version** | `./taler-monitoring.sh --ver` (tags `vMAJOR.FEATURE.FIX`, e.g. v1.7.1) |
     
    -## Quick start
    +## Host-agent (user systemd on koopa)
     
     ```bash
    -git clone https://git.hacktivism.ch/hernani/taler-monitoring.git ~/src/taler-monitoring
    -cd ~/src/taler-monitoring
    -./taler-monitoring.sh -d hacktivism.ch urls
    -# host-agent (koopa):
    -./host-agent/install-host-agent.sh
    +~/src/taler-monitoring/host-agent/install-host-agent.sh
     ```
     
    -Landing configs and Caddy site configs for GOA remain in **koopa-admin-log**
    -(`configs/`). The host-agent path unit still watches those paths when present.
    +## Public HTML (infra on this host)
     
    -Do **not** add monitoring scripts back into this directory — develop and push
    -against **taler-monitoring** only.
    +Generated under `~/monitoring-sites-staging/`.  
    +**Live** requires root publish + Caddy handles — see:
    +
    +- `2026/2026-07-18--monitoring-public-caddy.md`
    +- `ISSUES.md` → **I-09**
    +- Apply: `sudo ~/koopa-caddy/apply-monitoring-live.sh`
    +
    +Do not re-introduce the full suite under `scripts/taler-monitoring/`.
    
    From a24823a888da7db1a922cd35509bea6f850d8a46 Mon Sep 17 00:00:00 2001
    From: hernani 
    Date: Sat, 18 Jul 2026 21:40:56 +0200
    Subject: [PATCH 319/326] caddy: fix monitoring file_server root (empty 404);
     note v1.7.2
    
    ---
     2026/2026-07-18--monitoring-public-caddy.md | 12 +++++
     configs/caddy/Caddyfile                     | 56 ++++++++++-----------
     2 files changed, 40 insertions(+), 28 deletions(-)
    
    diff --git a/2026/2026-07-18--monitoring-public-caddy.md b/2026/2026-07-18--monitoring-public-caddy.md
    index 2f84af7..e738d9e 100644
    --- a/2026/2026-07-18--monitoring-public-caddy.md
    +++ b/2026/2026-07-18--monitoring-public-caddy.md
    @@ -64,3 +64,15 @@ test -w /var/www/monitoring-sites && echo www_writable
     | `~/koopa-caddy/` | Live-Apply-Quelle auf dem Host (nicht Secrets) |
     
     Siehe Issue **I-09** in `ISSUES.md`.
    +
    +## Follow-up — empty Caddy 404 after apply (v1.7.2)
    +
    +After apply, Caddy returned **empty HTTP 404** (Server: Caddy).
    +
    +**Cause:** `root * …/monitoring` + `handle /monitoring/` → looks for `…/monitoring/monitoring/`.
    +
    +**Fix:** `root * …/{host}` + `handle /monitoring*` in `~/koopa-caddy/Caddyfile`. Re-apply:
    +
    +```bash
    +sudo ~/koopa-caddy/apply-monitoring-live.sh
    +```
    diff --git a/configs/caddy/Caddyfile b/configs/caddy/Caddyfile
    index f16cb53..e78df90 100644
    --- a/configs/caddy/Caddyfile
    +++ b/configs/caddy/Caddyfile
    @@ -41,59 +41,59 @@ taler.hacktivism.ch {
         handle /taler-monitoring-surface_err {
             redir /taler-monitoring-surface_err/ 302
         }
    -    handle /taler-monitoring-surface_err/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface_err
    +    handle /taler-monitoring-surface_err* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-surface {
             redir /taler-monitoring-surface/ 302
         }
    -    handle /taler-monitoring-surface/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface
    +    handle /taler-monitoring-surface* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-aptdeploy_err {
             redir /taler-monitoring-aptdeploy_err/ 302
         }
    -    handle /taler-monitoring-aptdeploy_err/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy_err
    +    handle /taler-monitoring-aptdeploy_err* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-aptdeploy {
             redir /taler-monitoring-aptdeploy/ 302
         }
    -    handle /taler-monitoring-aptdeploy/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy
    +    handle /taler-monitoring-aptdeploy* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
     
         handle /taler-monitoring-mattermost_err {
             redir /taler-monitoring-mattermost_err/ 302
         }
    -    handle /taler-monitoring-mattermost_err/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mattermost_err
    +    handle /taler-monitoring-mattermost_err* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-mattermost {
             redir /taler-monitoring-mattermost/ 302
         }
    -    handle /taler-monitoring-mattermost/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mattermost
    +    handle /taler-monitoring-mattermost* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
     
         handle /taler-monitoring-mail_err {
             redir /taler-monitoring-mail_err/ 302
         }
    -    handle /taler-monitoring-mail_err/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mail_err
    +    handle /taler-monitoring-mail_err* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-mail {
             redir /taler-monitoring-mail/ 302
         }
    -    handle /taler-monitoring-mail/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mail
    +    handle /taler-monitoring-mail* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
     
    @@ -101,15 +101,15 @@ taler.hacktivism.ch {
         handle /monitoring_err {
             redir /monitoring_err/ 302
         }
    -    handle /monitoring_err/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/monitoring_err
    +    handle /monitoring_err* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /monitoring {
             redir /monitoring/ 302
         }
    -    handle /monitoring/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/monitoring
    +    handle /monitoring* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
     
    @@ -145,15 +145,15 @@ exchange.hacktivism.ch {
         handle /monitoring_err {
             redir /monitoring_err/ 302
         }
    -    handle /monitoring_err/ {
    -        root * /var/www/monitoring-sites/exchange.hacktivism.ch/monitoring_err
    +    handle /monitoring_err* {
    +        root * /var/www/monitoring-sites/exchange.hacktivism.ch
             file_server
         }
         handle /monitoring {
             redir /monitoring/ 302
         }
    -    handle /monitoring/ {
    -        root * /var/www/monitoring-sites/exchange.hacktivism.ch/monitoring
    +    handle /monitoring* {
    +        root * /var/www/monitoring-sites/exchange.hacktivism.ch
             file_server
         }
     
    @@ -179,15 +179,15 @@ bank.hacktivism.ch {
         handle /monitoring_err {
             redir /monitoring_err/ 302
         }
    -    handle /monitoring_err/ {
    -        root * /var/www/monitoring-sites/bank.hacktivism.ch/monitoring_err
    +    handle /monitoring_err* {
    +        root * /var/www/monitoring-sites/bank.hacktivism.ch
             file_server
         }
         handle /monitoring {
             redir /monitoring/ 302
         }
    -    handle /monitoring/ {
    -        root * /var/www/monitoring-sites/bank.hacktivism.ch/monitoring
    +    handle /monitoring* {
    +        root * /var/www/monitoring-sites/bank.hacktivism.ch
             file_server
         }
     
    
    From 61b9dc0a11826addc209509897a8237d8a5753ea Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= 
    Date: Sat, 18 Jul 2026 21:40:56 +0200
    Subject: [PATCH 320/326] caddy: fix monitoring file_server root (empty 404);
     note v1.7.2
    
    ---
     2026/2026-07-18--monitoring-public-caddy.md | 12 +++++
     configs/caddy/Caddyfile                     | 56 ++++++++++-----------
     2 files changed, 40 insertions(+), 28 deletions(-)
    
    diff --git a/2026/2026-07-18--monitoring-public-caddy.md b/2026/2026-07-18--monitoring-public-caddy.md
    index 2f84af7..e738d9e 100644
    --- a/2026/2026-07-18--monitoring-public-caddy.md
    +++ b/2026/2026-07-18--monitoring-public-caddy.md
    @@ -64,3 +64,15 @@ test -w /var/www/monitoring-sites && echo www_writable
     | `~/koopa-caddy/` | Live-Apply-Quelle auf dem Host (nicht Secrets) |
     
     Siehe Issue **I-09** in `ISSUES.md`.
    +
    +## Follow-up — empty Caddy 404 after apply (v1.7.2)
    +
    +After apply, Caddy returned **empty HTTP 404** (Server: Caddy).
    +
    +**Cause:** `root * …/monitoring` + `handle /monitoring/` → looks for `…/monitoring/monitoring/`.
    +
    +**Fix:** `root * …/{host}` + `handle /monitoring*` in `~/koopa-caddy/Caddyfile`. Re-apply:
    +
    +```bash
    +sudo ~/koopa-caddy/apply-monitoring-live.sh
    +```
    diff --git a/configs/caddy/Caddyfile b/configs/caddy/Caddyfile
    index f16cb53..e78df90 100644
    --- a/configs/caddy/Caddyfile
    +++ b/configs/caddy/Caddyfile
    @@ -41,59 +41,59 @@ taler.hacktivism.ch {
         handle /taler-monitoring-surface_err {
             redir /taler-monitoring-surface_err/ 302
         }
    -    handle /taler-monitoring-surface_err/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface_err
    +    handle /taler-monitoring-surface_err* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-surface {
             redir /taler-monitoring-surface/ 302
         }
    -    handle /taler-monitoring-surface/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-surface
    +    handle /taler-monitoring-surface* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-aptdeploy_err {
             redir /taler-monitoring-aptdeploy_err/ 302
         }
    -    handle /taler-monitoring-aptdeploy_err/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy_err
    +    handle /taler-monitoring-aptdeploy_err* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-aptdeploy {
             redir /taler-monitoring-aptdeploy/ 302
         }
    -    handle /taler-monitoring-aptdeploy/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-aptdeploy
    +    handle /taler-monitoring-aptdeploy* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
     
         handle /taler-monitoring-mattermost_err {
             redir /taler-monitoring-mattermost_err/ 302
         }
    -    handle /taler-monitoring-mattermost_err/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mattermost_err
    +    handle /taler-monitoring-mattermost_err* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-mattermost {
             redir /taler-monitoring-mattermost/ 302
         }
    -    handle /taler-monitoring-mattermost/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mattermost
    +    handle /taler-monitoring-mattermost* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
     
         handle /taler-monitoring-mail_err {
             redir /taler-monitoring-mail_err/ 302
         }
    -    handle /taler-monitoring-mail_err/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mail_err
    +    handle /taler-monitoring-mail_err* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-mail {
             redir /taler-monitoring-mail/ 302
         }
    -    handle /taler-monitoring-mail/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/taler-monitoring-mail
    +    handle /taler-monitoring-mail* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
     
    @@ -101,15 +101,15 @@ taler.hacktivism.ch {
         handle /monitoring_err {
             redir /monitoring_err/ 302
         }
    -    handle /monitoring_err/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/monitoring_err
    +    handle /monitoring_err* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /monitoring {
             redir /monitoring/ 302
         }
    -    handle /monitoring/ {
    -        root * /var/www/monitoring-sites/taler.hacktivism.ch/monitoring
    +    handle /monitoring* {
    +        root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
     
    @@ -145,15 +145,15 @@ exchange.hacktivism.ch {
         handle /monitoring_err {
             redir /monitoring_err/ 302
         }
    -    handle /monitoring_err/ {
    -        root * /var/www/monitoring-sites/exchange.hacktivism.ch/monitoring_err
    +    handle /monitoring_err* {
    +        root * /var/www/monitoring-sites/exchange.hacktivism.ch
             file_server
         }
         handle /monitoring {
             redir /monitoring/ 302
         }
    -    handle /monitoring/ {
    -        root * /var/www/monitoring-sites/exchange.hacktivism.ch/monitoring
    +    handle /monitoring* {
    +        root * /var/www/monitoring-sites/exchange.hacktivism.ch
             file_server
         }
     
    @@ -179,15 +179,15 @@ bank.hacktivism.ch {
         handle /monitoring_err {
             redir /monitoring_err/ 302
         }
    -    handle /monitoring_err/ {
    -        root * /var/www/monitoring-sites/bank.hacktivism.ch/monitoring_err
    +    handle /monitoring_err* {
    +        root * /var/www/monitoring-sites/bank.hacktivism.ch
             file_server
         }
         handle /monitoring {
             redir /monitoring/ 302
         }
    -    handle /monitoring/ {
    -        root * /var/www/monitoring-sites/bank.hacktivism.ch/monitoring
    +    handle /monitoring* {
    +        root * /var/www/monitoring-sites/bank.hacktivism.ch
             file_server
         }
     
    
    From 0f91beb867a3e4d79e0cd16e10086325a6e02265 Mon Sep 17 00:00:00 2001
    From: hernani 
    Date: Sat, 18 Jul 2026 21:42:19 +0200
    Subject: [PATCH 321/326] docs(landings): point e2e shop sync at standalone
     taler-monitoring
    
    Merchant landing products stay aligned with check_e2e.sh in
    hernani/taler-monitoring (~/src/taler-monitoring), not scripts/ under this repo.
    Landing HTML source/commit footers still correctly reference koopa-admin-log.
    ---
     configs/merchant-landing/README.md | 7 ++++---
     1 file changed, 4 insertions(+), 3 deletions(-)
    
    diff --git a/configs/merchant-landing/README.md b/configs/merchant-landing/README.md
    index 9a5d998..7e45a66 100644
    --- a/configs/merchant-landing/README.md
    +++ b/configs/merchant-landing/README.md
    @@ -23,9 +23,10 @@ Static intro for the **GOA merchant** backend (dual currency GOA + CHF).
     | Stats | Dual-currency aggregates; **recent activity** split GOA/CHF (≤5 each); refund badge violet |
     | Performance | Live HTTP probes + container RSS |
     
    -Shop product ids/amounts must stay in sync with
    -`scripts/taler-monitoring/check_e2e.sh` → `E2E_SHOP_PRODUCTS`
    -(e2e random-picks **2** products per run by default).
    +Shop product ids/amounts must stay in sync with the standalone suite
    +[`taler-monitoring`](https://git.hacktivism.ch/hernani/taler-monitoring)
    +`check_e2e.sh` → `E2E_SHOP_PRODUCTS` (clone: `~/src/taler-monitoring`;
    +e2e random-picks **2** products per run by default).
     
     ## Deploy
     
    
    From 0df9f1359e1a0d2074d21be16413e97bf26bc215 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= 
    Date: Sat, 18 Jul 2026 21:42:19 +0200
    Subject: [PATCH 322/326] docs(landings): point e2e shop sync at standalone
     taler-monitoring
    
    Merchant landing products stay aligned with check_e2e.sh in
    hernani/taler-monitoring (~/src/taler-monitoring), not scripts/ under this repo.
    Landing HTML source/commit footers still correctly reference koopa-admin-log.
    ---
     configs/merchant-landing/README.md | 7 ++++---
     1 file changed, 4 insertions(+), 3 deletions(-)
    
    diff --git a/configs/merchant-landing/README.md b/configs/merchant-landing/README.md
    index 9a5d998..7e45a66 100644
    --- a/configs/merchant-landing/README.md
    +++ b/configs/merchant-landing/README.md
    @@ -23,9 +23,10 @@ Static intro for the **GOA merchant** backend (dual currency GOA + CHF).
     | Stats | Dual-currency aggregates; **recent activity** split GOA/CHF (≤5 each); refund badge violet |
     | Performance | Live HTTP probes + container RSS |
     
    -Shop product ids/amounts must stay in sync with
    -`scripts/taler-monitoring/check_e2e.sh` → `E2E_SHOP_PRODUCTS`
    -(e2e random-picks **2** products per run by default).
    +Shop product ids/amounts must stay in sync with the standalone suite
    +[`taler-monitoring`](https://git.hacktivism.ch/hernani/taler-monitoring)
    +`check_e2e.sh` → `E2E_SHOP_PRODUCTS` (clone: `~/src/taler-monitoring`;
    +e2e random-picks **2** products per run by default).
     
     ## Deploy
     
    
    From c0a8ea7b30dcbc975af5e7d7713cb1e308440366 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= 
    Date: Sun, 19 Jul 2026 01:08:19 +0200
    Subject: [PATCH 323/326] caddy: bare mon redir * (fix Location 302 / code 21)
     + apply path docs
    
    ---
     2026/2026-07-19.md      |  5 +++++
     configs/caddy/Caddyfile | 28 ++++++++++++++--------------
     configs/caddy/README.md |  5 ++++-
     3 files changed, 23 insertions(+), 15 deletions(-)
     create mode 100644 2026/2026-07-19.md
    
    diff --git a/2026/2026-07-19.md b/2026/2026-07-19.md
    new file mode 100644
    index 0000000..4345769
    --- /dev/null
    +++ b/2026/2026-07-19.md
    @@ -0,0 +1,5 @@
    +# 2026-07-19
    +
    +- **Caddy mon bare paths:** `redir /path/ 302` inside `handle` → Location `302` → merchant code 21; fix `redir * /path/ 302`
    +- **apply-monitoring-live.sh:** smoke for-loop syntax (missing `\`); use absolute `/home/hernani/koopa-caddy/…` (root `~` = `/root`)
    +- Re-apply: `sudo /home/hernani/koopa-caddy/apply-monitoring-live.sh`
    diff --git a/configs/caddy/Caddyfile b/configs/caddy/Caddyfile
    index e78df90..ae039a5 100644
    --- a/configs/caddy/Caddyfile
    +++ b/configs/caddy/Caddyfile
    @@ -39,28 +39,28 @@ taler.hacktivism.ch {
     
         # surface + aptdeploy HTML (taler.hacktivism.ch only)
         handle /taler-monitoring-surface_err {
    -        redir /taler-monitoring-surface_err/ 302
    +        redir * /taler-monitoring-surface_err/ 302
         }
         handle /taler-monitoring-surface_err* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-surface {
    -        redir /taler-monitoring-surface/ 302
    +        redir * /taler-monitoring-surface/ 302
         }
         handle /taler-monitoring-surface* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-aptdeploy_err {
    -        redir /taler-monitoring-aptdeploy_err/ 302
    +        redir * /taler-monitoring-aptdeploy_err/ 302
         }
         handle /taler-monitoring-aptdeploy_err* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-aptdeploy {
    -        redir /taler-monitoring-aptdeploy/ 302
    +        redir * /taler-monitoring-aptdeploy/ 302
         }
         handle /taler-monitoring-aptdeploy* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
    @@ -68,14 +68,14 @@ taler.hacktivism.ch {
         }
     
         handle /taler-monitoring-mattermost_err {
    -        redir /taler-monitoring-mattermost_err/ 302
    +        redir * /taler-monitoring-mattermost_err/ 302
         }
         handle /taler-monitoring-mattermost_err* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-mattermost {
    -        redir /taler-monitoring-mattermost/ 302
    +        redir * /taler-monitoring-mattermost/ 302
         }
         handle /taler-monitoring-mattermost* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
    @@ -83,14 +83,14 @@ taler.hacktivism.ch {
         }
     
         handle /taler-monitoring-mail_err {
    -        redir /taler-monitoring-mail_err/ 302
    +        redir * /taler-monitoring-mail_err/ 302
         }
         handle /taler-monitoring-mail_err* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-mail {
    -        redir /taler-monitoring-mail/ 302
    +        redir * /taler-monitoring-mail/ 302
         }
         handle /taler-monitoring-mail* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
    @@ -99,14 +99,14 @@ taler.hacktivism.ch {
     
         # taler-monitoring console HTML (host static — not in app containers)
         handle /monitoring_err {
    -        redir /monitoring_err/ 302
    +        redir * /monitoring_err/ 302
         }
         handle /monitoring_err* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /monitoring {
    -        redir /monitoring/ 302
    +        redir * /monitoring/ 302
         }
         handle /monitoring* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
    @@ -143,14 +143,14 @@ exchange.hacktivism.ch {
     
         # taler-monitoring console HTML (host static — not in app containers)
         handle /monitoring_err {
    -        redir /monitoring_err/ 302
    +        redir * /monitoring_err/ 302
         }
         handle /monitoring_err* {
             root * /var/www/monitoring-sites/exchange.hacktivism.ch
             file_server
         }
         handle /monitoring {
    -        redir /monitoring/ 302
    +        redir * /monitoring/ 302
         }
         handle /monitoring* {
             root * /var/www/monitoring-sites/exchange.hacktivism.ch
    @@ -177,14 +177,14 @@ bank.hacktivism.ch {
     
         # taler-monitoring console HTML (host static — not in app containers)
         handle /monitoring_err {
    -        redir /monitoring_err/ 302
    +        redir * /monitoring_err/ 302
         }
         handle /monitoring_err* {
             root * /var/www/monitoring-sites/bank.hacktivism.ch
             file_server
         }
         handle /monitoring {
    -        redir /monitoring/ 302
    +        redir * /monitoring/ 302
         }
         handle /monitoring* {
             root * /var/www/monitoring-sites/bank.hacktivism.ch
    diff --git a/configs/caddy/README.md b/configs/caddy/README.md
    index db596b8..c59d05e 100644
    --- a/configs/caddy/README.md
    +++ b/configs/caddy/README.md
    @@ -13,5 +13,8 @@ Also: `host/caddy/Caddyfile` (same as `Caddyfile` here).
     Handles for `/monitoring*` and `/taler-monitoring-*` live in this `Caddyfile` (prepared under `~/koopa-caddy/`).  
     They must appear **before** the merchant `reverse_proxy`.  
     
    -Live install + www ACL: `sudo ~/koopa-caddy/apply-monitoring-live.sh`  
    +Live install + www ACL (absolute path; as root `~` is `/root`):
    +`sudo /home/hernani/koopa-caddy/apply-monitoring-live.sh`
    +
    +Bare mon redirs inside `handle` must be `redir * /path/ 302` (not `redir /path/ 302`).  
     See `2026/2026-07-18--monitoring-public-caddy.md` and ISSUES **I-09**.
    
    From 947c72e595d3d3b28e8c81fd8f39061194bbc98d Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= 
    Date: Sun, 19 Jul 2026 01:08:19 +0200
    Subject: [PATCH 324/326] caddy: bare mon redir * (fix Location 302 / code 21)
     + apply path docs
    
    ---
     2026/2026-07-19.md      |  5 +++++
     configs/caddy/Caddyfile | 28 ++++++++++++++--------------
     configs/caddy/README.md |  5 ++++-
     3 files changed, 23 insertions(+), 15 deletions(-)
     create mode 100644 2026/2026-07-19.md
    
    diff --git a/2026/2026-07-19.md b/2026/2026-07-19.md
    new file mode 100644
    index 0000000..4345769
    --- /dev/null
    +++ b/2026/2026-07-19.md
    @@ -0,0 +1,5 @@
    +# 2026-07-19
    +
    +- **Caddy mon bare paths:** `redir /path/ 302` inside `handle` → Location `302` → merchant code 21; fix `redir * /path/ 302`
    +- **apply-monitoring-live.sh:** smoke for-loop syntax (missing `\`); use absolute `/home/hernani/koopa-caddy/…` (root `~` = `/root`)
    +- Re-apply: `sudo /home/hernani/koopa-caddy/apply-monitoring-live.sh`
    diff --git a/configs/caddy/Caddyfile b/configs/caddy/Caddyfile
    index e78df90..ae039a5 100644
    --- a/configs/caddy/Caddyfile
    +++ b/configs/caddy/Caddyfile
    @@ -39,28 +39,28 @@ taler.hacktivism.ch {
     
         # surface + aptdeploy HTML (taler.hacktivism.ch only)
         handle /taler-monitoring-surface_err {
    -        redir /taler-monitoring-surface_err/ 302
    +        redir * /taler-monitoring-surface_err/ 302
         }
         handle /taler-monitoring-surface_err* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-surface {
    -        redir /taler-monitoring-surface/ 302
    +        redir * /taler-monitoring-surface/ 302
         }
         handle /taler-monitoring-surface* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-aptdeploy_err {
    -        redir /taler-monitoring-aptdeploy_err/ 302
    +        redir * /taler-monitoring-aptdeploy_err/ 302
         }
         handle /taler-monitoring-aptdeploy_err* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-aptdeploy {
    -        redir /taler-monitoring-aptdeploy/ 302
    +        redir * /taler-monitoring-aptdeploy/ 302
         }
         handle /taler-monitoring-aptdeploy* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
    @@ -68,14 +68,14 @@ taler.hacktivism.ch {
         }
     
         handle /taler-monitoring-mattermost_err {
    -        redir /taler-monitoring-mattermost_err/ 302
    +        redir * /taler-monitoring-mattermost_err/ 302
         }
         handle /taler-monitoring-mattermost_err* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-mattermost {
    -        redir /taler-monitoring-mattermost/ 302
    +        redir * /taler-monitoring-mattermost/ 302
         }
         handle /taler-monitoring-mattermost* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
    @@ -83,14 +83,14 @@ taler.hacktivism.ch {
         }
     
         handle /taler-monitoring-mail_err {
    -        redir /taler-monitoring-mail_err/ 302
    +        redir * /taler-monitoring-mail_err/ 302
         }
         handle /taler-monitoring-mail_err* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /taler-monitoring-mail {
    -        redir /taler-monitoring-mail/ 302
    +        redir * /taler-monitoring-mail/ 302
         }
         handle /taler-monitoring-mail* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
    @@ -99,14 +99,14 @@ taler.hacktivism.ch {
     
         # taler-monitoring console HTML (host static — not in app containers)
         handle /monitoring_err {
    -        redir /monitoring_err/ 302
    +        redir * /monitoring_err/ 302
         }
         handle /monitoring_err* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
             file_server
         }
         handle /monitoring {
    -        redir /monitoring/ 302
    +        redir * /monitoring/ 302
         }
         handle /monitoring* {
             root * /var/www/monitoring-sites/taler.hacktivism.ch
    @@ -143,14 +143,14 @@ exchange.hacktivism.ch {
     
         # taler-monitoring console HTML (host static — not in app containers)
         handle /monitoring_err {
    -        redir /monitoring_err/ 302
    +        redir * /monitoring_err/ 302
         }
         handle /monitoring_err* {
             root * /var/www/monitoring-sites/exchange.hacktivism.ch
             file_server
         }
         handle /monitoring {
    -        redir /monitoring/ 302
    +        redir * /monitoring/ 302
         }
         handle /monitoring* {
             root * /var/www/monitoring-sites/exchange.hacktivism.ch
    @@ -177,14 +177,14 @@ bank.hacktivism.ch {
     
         # taler-monitoring console HTML (host static — not in app containers)
         handle /monitoring_err {
    -        redir /monitoring_err/ 302
    +        redir * /monitoring_err/ 302
         }
         handle /monitoring_err* {
             root * /var/www/monitoring-sites/bank.hacktivism.ch
             file_server
         }
         handle /monitoring {
    -        redir /monitoring/ 302
    +        redir * /monitoring/ 302
         }
         handle /monitoring* {
             root * /var/www/monitoring-sites/bank.hacktivism.ch
    diff --git a/configs/caddy/README.md b/configs/caddy/README.md
    index db596b8..c59d05e 100644
    --- a/configs/caddy/README.md
    +++ b/configs/caddy/README.md
    @@ -13,5 +13,8 @@ Also: `host/caddy/Caddyfile` (same as `Caddyfile` here).
     Handles for `/monitoring*` and `/taler-monitoring-*` live in this `Caddyfile` (prepared under `~/koopa-caddy/`).  
     They must appear **before** the merchant `reverse_proxy`.  
     
    -Live install + www ACL: `sudo ~/koopa-caddy/apply-monitoring-live.sh`  
    +Live install + www ACL (absolute path; as root `~` is `/root`):
    +`sudo /home/hernani/koopa-caddy/apply-monitoring-live.sh`
    +
    +Bare mon redirs inside `handle` must be `redir * /path/ 302` (not `redir /path/ 302`).  
     See `2026/2026-07-18--monitoring-public-caddy.md` and ISSUES **I-09**.
    
    From c07a0542ebb41d57eef23c972f6031ed84a9fe0b Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= 
    Date: Sun, 19 Jul 2026 15:38:34 +0200
    Subject: [PATCH 325/326] fix: strip taler:// default :443/:80 in bank mint
     consumers
    
    Align demo-withdraw-api, goa-withdraw-ladder, refresh-demo-withdraw,
    amount-ladder bench, and extreme tests with mon normalize rules so
    libeufin host:443 never reaches wallets or QR payloads.
    ---
     benchmarks/amount-ladder/run.sh               |  5 +++++
     scripts/taler-bank/demo-withdraw-api.py       | 20 +++++++++++++++----
     scripts/taler-bank/goa-withdraw-ladder.sh     | 19 +++++++++++++-----
     scripts/taler-bank/refresh-demo-withdraw.sh   | 12 +++++++++--
     .../run-extreme-amount-tests.sh               |  7 ++++++-
     5 files changed, 51 insertions(+), 12 deletions(-)
    
    diff --git a/benchmarks/amount-ladder/run.sh b/benchmarks/amount-ladder/run.sh
    index b8e337d..be3cc6b 100755
    --- a/benchmarks/amount-ladder/run.sh
    +++ b/benchmarks/amount-ladder/run.sh
    @@ -155,12 +155,17 @@ python3 - <<'PY'
     import json
     d=json.load(open("/tmp/wd.json"))
     print("wd_keys", list(d.keys())[:20])
    +import re
     uri=d.get("taler_withdraw_uri") or d.get("withdrawal_uri") or ""
     wid=d.get("withdrawal_id") or d.get("id") or ""
     if not uri:
       for v in d.values():
         if isinstance(v, str) and v.startswith("taler"):
           uri=v
    +uri=re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):443(?=/|$|\?|#)", r"\1\2", uri or "", flags=re.I)
    +uri=re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):80(?=/|$|\?|#)", r"\1\2", uri or "", flags=re.I)
    +for a,b in ((":443/","/"),(":443?","?"),(":80/","/"),(":80?","?")):
    +  uri=uri.replace(a,b)
     open("/tmp/bench-wuri.txt","w").write(uri)
     open("/tmp/bench-wid.txt","w").write(wid)
     print("WURI", uri)
    diff --git a/scripts/taler-bank/demo-withdraw-api.py b/scripts/taler-bank/demo-withdraw-api.py
    index d8a21de..f31243e 100755
    --- a/scripts/taler-bank/demo-withdraw-api.py
    +++ b/scripts/taler-bank/demo-withdraw-api.py
    @@ -43,24 +43,36 @@ def public_webui_url() -> str:
     
     
     def normalize_taler_withdraw_uri(uri: str) -> str:
    -    """Strip default :443/:80 from taler://withdraw host (wallet base-URL fix).
    +    """Strip default :443/:80 from any taler:// authority (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.
    +    Applies to withdraw, pay, pay-template, etc.
         """
         if not uri:
             return uri
    -    # taler://withdraw/bank.example:443/taler-integration/UUID
    +    # taler://SCHEME/host:443/... or :80
         uri = re.sub(
    -        r"(taler://withdraw/)([^/?#]+):443(?=/|$)",
    +        r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):443(?=/|$|\?|#)",
             r"\1\2",
             uri,
    +        flags=re.I,
         )
         uri = re.sub(
    -        r"(taler://withdraw/)([^/?#]+):80(?=/|$)",
    +        r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):80(?=/|$|\?|#)",
             r"\1\2",
             uri,
    +        flags=re.I,
         )
    +    for a, b in (
    +        (":443/", "/"),
    +        (":443?", "?"),
    +        (":443#", "#"),
    +        (":80/", "/"),
    +        (":80?", "?"),
    +        (":80#", "#"),
    +    ):
    +        uri = uri.replace(a, b)
         return uri
     
     def load_pass() -> str:
    diff --git a/scripts/taler-bank/goa-withdraw-ladder.sh b/scripts/taler-bank/goa-withdraw-ladder.sh
    index f66925c..27b828b 100755
    --- a/scripts/taler-bank/goa-withdraw-ladder.sh
    +++ b/scripts/taler-bank/goa-withdraw-ladder.sh
    @@ -7,9 +7,10 @@
     #   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
    +# Prefer the monitoring amount ladder (modular wd + pay):
    +#   cd ~/src/taler-monitoring && ./taler-monitoring.sh -d hacktivism.ch ladder
    +#   ./taler-monitoring.sh max-ladder    # highest withdrawable + payable
    +#   LADDER_STEPS=10 LADDER_PAY=0 ./taler-monitoring.sh ladder
     #
     # Usage (standalone, fixed ladder):
     #   ./goa-withdraw-ladder.sh
    @@ -53,7 +54,7 @@ wcli() {
     }
     
     # 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).
    +# Prefer taler-monitoring check_amount_ladder.sh (+ ladder/lib_pay.sh) for LADDER_* env.
     default_amounts() {
       python3 - <<'PY'
     import math, random
    @@ -235,7 +236,15 @@ for amt in "${LADDER[@]}"; do
         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/","/"))')
    +  URI=$(echo "$WD" | python3 -c '
    +import json, re, sys
    +u = json.load(sys.stdin).get("taler_withdraw_uri") or ""
    +u = re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):443(?=/|$|\?|#)", r"\1\2", u, flags=re.I)
    +u = re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):80(?=/|$|\?|#)", r"\1\2", u, flags=re.I)
    +for a, b in ((":443/", "/"), (":443?", "?"), (":80/", "/"), (":80?", "?")):
    +    u = u.replace(a, b)
    +print(u)
    +')
       [ -n "$WID" ] && [ -n "$URI" ] || {
         log "FAIL parse mint: $WD"
         echo -e "${n}\t${amt}\tFAIL_PARSE\t-\t" >>"$RESULTS"
    diff --git a/scripts/taler-bank/refresh-demo-withdraw.sh b/scripts/taler-bank/refresh-demo-withdraw.sh
    index 901c368..d00bb74 100755
    --- a/scripts/taler-bank/refresh-demo-withdraw.sh
    +++ b/scripts/taler-bank/refresh-demo-withdraw.sh
    @@ -38,8 +38,16 @@ 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|')
    +# libeufin emits host:443 — strip default HTTPS/HTTP ports for wallet apps
    +URI=$(printf '%s' "$URI" | python3 -c '
    +import re,sys
    +u=sys.stdin.read().strip()
    +u=re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):443(?=/|$|\?|#)", r"\1\2", u, flags=re.I)
    +u=re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):80(?=/|$|\?|#)", r"\1\2", u, flags=re.I)
    +for a,b in ((":443/","/"),(":443?","?"),(":80/","/"),(":80?","?")):
    +    u=u.replace(a,b)
    +print(u)
    +')
     
     mkdir -p "$LANDING_DIR"
     printf '%s\n' "$URI" >"$LANDING_DIR/withdraw.uri"
    diff --git a/scripts/taler-wallet-cli/run-extreme-amount-tests.sh b/scripts/taler-wallet-cli/run-extreme-amount-tests.sh
    index 1774304..6f4c095 100755
    --- a/scripts/taler-wallet-cli/run-extreme-amount-tests.sh
    +++ b/scripts/taler-wallet-cli/run-extreme-amount-tests.sh
    @@ -188,7 +188,12 @@ python3 - <<'PY'
     import json
     d=json.load(open("/tmp/wd-create.json"))
     wid=d.get("withdrawal_id") or d.get("id")
    -uri=d.get("taler_withdraw_uri")
    +uri=d.get("taler_withdraw_uri") or ""
    +import re
    +uri=re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):443(?=/|$|\?|#)", r"\1\2", uri, flags=re.I)
    +uri=re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):80(?=/|$|\?|#)", r"\1\2", uri, flags=re.I)
    +for a,b in ((":443/","/"),(":443?","?"),(":80/","/"),(":80?","?")):
    +  uri=uri.replace(a,b)
     print("WID", wid)
     print("WURI", uri)
     open("/tmp/wid.txt","w").write(wid or "")
    
    From dc94b30fedaa4944bee54b91c1152e319bbc22b0 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= 
    Date: Sun, 19 Jul 2026 15:38:34 +0200
    Subject: [PATCH 326/326] fix: strip taler:// default :443/:80 in bank mint
     consumers
    
    Align demo-withdraw-api, goa-withdraw-ladder, refresh-demo-withdraw,
    amount-ladder bench, and extreme tests with mon normalize rules so
    libeufin host:443 never reaches wallets or QR payloads.
    ---
     benchmarks/amount-ladder/run.sh               |  5 +++++
     scripts/taler-bank/demo-withdraw-api.py       | 20 +++++++++++++++----
     scripts/taler-bank/goa-withdraw-ladder.sh     | 19 +++++++++++++-----
     scripts/taler-bank/refresh-demo-withdraw.sh   | 12 +++++++++--
     .../run-extreme-amount-tests.sh               |  7 ++++++-
     5 files changed, 51 insertions(+), 12 deletions(-)
    
    diff --git a/benchmarks/amount-ladder/run.sh b/benchmarks/amount-ladder/run.sh
    index b8e337d..be3cc6b 100755
    --- a/benchmarks/amount-ladder/run.sh
    +++ b/benchmarks/amount-ladder/run.sh
    @@ -155,12 +155,17 @@ python3 - <<'PY'
     import json
     d=json.load(open("/tmp/wd.json"))
     print("wd_keys", list(d.keys())[:20])
    +import re
     uri=d.get("taler_withdraw_uri") or d.get("withdrawal_uri") or ""
     wid=d.get("withdrawal_id") or d.get("id") or ""
     if not uri:
       for v in d.values():
         if isinstance(v, str) and v.startswith("taler"):
           uri=v
    +uri=re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):443(?=/|$|\?|#)", r"\1\2", uri or "", flags=re.I)
    +uri=re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):80(?=/|$|\?|#)", r"\1\2", uri or "", flags=re.I)
    +for a,b in ((":443/","/"),(":443?","?"),(":80/","/"),(":80?","?")):
    +  uri=uri.replace(a,b)
     open("/tmp/bench-wuri.txt","w").write(uri)
     open("/tmp/bench-wid.txt","w").write(wid)
     print("WURI", uri)
    diff --git a/scripts/taler-bank/demo-withdraw-api.py b/scripts/taler-bank/demo-withdraw-api.py
    index d8a21de..f31243e 100755
    --- a/scripts/taler-bank/demo-withdraw-api.py
    +++ b/scripts/taler-bank/demo-withdraw-api.py
    @@ -43,24 +43,36 @@ def public_webui_url() -> str:
     
     
     def normalize_taler_withdraw_uri(uri: str) -> str:
    -    """Strip default :443/:80 from taler://withdraw host (wallet base-URL fix).
    +    """Strip default :443/:80 from any taler:// authority (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.
    +    Applies to withdraw, pay, pay-template, etc.
         """
         if not uri:
             return uri
    -    # taler://withdraw/bank.example:443/taler-integration/UUID
    +    # taler://SCHEME/host:443/... or :80
         uri = re.sub(
    -        r"(taler://withdraw/)([^/?#]+):443(?=/|$)",
    +        r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):443(?=/|$|\?|#)",
             r"\1\2",
             uri,
    +        flags=re.I,
         )
         uri = re.sub(
    -        r"(taler://withdraw/)([^/?#]+):80(?=/|$)",
    +        r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):80(?=/|$|\?|#)",
             r"\1\2",
             uri,
    +        flags=re.I,
         )
    +    for a, b in (
    +        (":443/", "/"),
    +        (":443?", "?"),
    +        (":443#", "#"),
    +        (":80/", "/"),
    +        (":80?", "?"),
    +        (":80#", "#"),
    +    ):
    +        uri = uri.replace(a, b)
         return uri
     
     def load_pass() -> str:
    diff --git a/scripts/taler-bank/goa-withdraw-ladder.sh b/scripts/taler-bank/goa-withdraw-ladder.sh
    index f66925c..27b828b 100755
    --- a/scripts/taler-bank/goa-withdraw-ladder.sh
    +++ b/scripts/taler-bank/goa-withdraw-ladder.sh
    @@ -7,9 +7,10 @@
     #   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
    +# Prefer the monitoring amount ladder (modular wd + pay):
    +#   cd ~/src/taler-monitoring && ./taler-monitoring.sh -d hacktivism.ch ladder
    +#   ./taler-monitoring.sh max-ladder    # highest withdrawable + payable
    +#   LADDER_STEPS=10 LADDER_PAY=0 ./taler-monitoring.sh ladder
     #
     # Usage (standalone, fixed ladder):
     #   ./goa-withdraw-ladder.sh
    @@ -53,7 +54,7 @@ wcli() {
     }
     
     # 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).
    +# Prefer taler-monitoring check_amount_ladder.sh (+ ladder/lib_pay.sh) for LADDER_* env.
     default_amounts() {
       python3 - <<'PY'
     import math, random
    @@ -235,7 +236,15 @@ for amt in "${LADDER[@]}"; do
         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/","/"))')
    +  URI=$(echo "$WD" | python3 -c '
    +import json, re, sys
    +u = json.load(sys.stdin).get("taler_withdraw_uri") or ""
    +u = re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):443(?=/|$|\?|#)", r"\1\2", u, flags=re.I)
    +u = re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):80(?=/|$|\?|#)", r"\1\2", u, flags=re.I)
    +for a, b in ((":443/", "/"), (":443?", "?"), (":80/", "/"), (":80?", "?")):
    +    u = u.replace(a, b)
    +print(u)
    +')
       [ -n "$WID" ] && [ -n "$URI" ] || {
         log "FAIL parse mint: $WD"
         echo -e "${n}\t${amt}\tFAIL_PARSE\t-\t" >>"$RESULTS"
    diff --git a/scripts/taler-bank/refresh-demo-withdraw.sh b/scripts/taler-bank/refresh-demo-withdraw.sh
    index 901c368..d00bb74 100755
    --- a/scripts/taler-bank/refresh-demo-withdraw.sh
    +++ b/scripts/taler-bank/refresh-demo-withdraw.sh
    @@ -38,8 +38,16 @@ 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|')
    +# libeufin emits host:443 — strip default HTTPS/HTTP ports for wallet apps
    +URI=$(printf '%s' "$URI" | python3 -c '
    +import re,sys
    +u=sys.stdin.read().strip()
    +u=re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):443(?=/|$|\?|#)", r"\1\2", u, flags=re.I)
    +u=re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):80(?=/|$|\?|#)", r"\1\2", u, flags=re.I)
    +for a,b in ((":443/","/"),(":443?","?"),(":80/","/"),(":80?","?")):
    +    u=u.replace(a,b)
    +print(u)
    +')
     
     mkdir -p "$LANDING_DIR"
     printf '%s\n' "$URI" >"$LANDING_DIR/withdraw.uri"
    diff --git a/scripts/taler-wallet-cli/run-extreme-amount-tests.sh b/scripts/taler-wallet-cli/run-extreme-amount-tests.sh
    index 1774304..6f4c095 100755
    --- a/scripts/taler-wallet-cli/run-extreme-amount-tests.sh
    +++ b/scripts/taler-wallet-cli/run-extreme-amount-tests.sh
    @@ -188,7 +188,12 @@ python3 - <<'PY'
     import json
     d=json.load(open("/tmp/wd-create.json"))
     wid=d.get("withdrawal_id") or d.get("id")
    -uri=d.get("taler_withdraw_uri")
    +uri=d.get("taler_withdraw_uri") or ""
    +import re
    +uri=re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):443(?=/|$|\?|#)", r"\1\2", uri, flags=re.I)
    +uri=re.sub(r"(taler://[A-Za-z0-9._-]+/)([^/?#:]+):80(?=/|$|\?|#)", r"\1\2", uri, flags=re.I)
    +for a,b in ((":443/","/"),(":443?","?"),(":80/","/"),(":80?","?")):
    +  uri=uri.replace(a,b)
     print("WID", wid)
     print("WURI", uri)
     open("/tmp/wid.txt","w").write(wid or "")
    

    P_}J0 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%0A%{g58&0mbz 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 z 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

    P_}J0 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%0A%{g58&0mbz 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 z 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

    + stack· + taler-exchange 1.6.6 + · + libtalerexchange 1.6.6 + · + taler-exchange-database 1.6.6 + · + taler-exchange-offline 1.6.6 + · + taler-terms-generator 1.6.6 +

    P_}J0 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%0A%{g58&0mbz zuNoUXr0;_xQDFt8BYfYXe%X*P1%(he!UCOm4(VKqa=AobZ-GisCexTgq`jk=!Tz;8{KT_-|NFnoS;BblWH-I!azQ>xLBFY$CA!%=IBbUjtbm=N4 zOzz^uv%k!~`|Qi2d5gK}#v4$|`j-)vxbEs}*=D;PIN`J}(%jL(WmkQJ6Hh#Yf#M*3 zDuYrXMrga)))HZi?ITH_kH;r%G*TN&<&QTz#9)n97Ss|_&_6IpPj7#`L9xh|G76y# z#)%n{x)zB<2&}rEwg$3LZF~gX=kACSqWC>1M1uD{PGrs7Bs;ds@t zqJN+WIwF(tY(Jz729z=&T!fn?mC6uBWg?~N?YEh%z()v=NZWDE)@4W(mT7I3eD<@S zqHo1Y`uh8L_|d2N@lXFiW7=Woop*;!6RI}^nL&sY0-q=>lJhbUIjmZ`4jnkiw8Nwc zljvO+Af2V;8!}vS$+ws>eH;FE&%NXtGlW4IKix=IM+a+G_mFZtde&8#Hf@S!?nIh} z^B3Yc#P^r5tdfOsT{Jm%sdqgOEnsc$0Bd@BY42#oI8EGi>z`P-cwoe5ySBHCaeeSp zy!GB(Zo2875ziYlZZwW7&^n4K=_)RVOVz{kW13;ruUv)(;F3iqQ1xr9Z6XrpZNfaX zYieDc8*4$2cpejRR?e6WU0;IwElP5h(S}qi&9bFS`QXEO?6%8vww}2IrV>D;nKNe| zFFgMWU8BacY{_y~uU!XWflM|{S4S&B(Xf2+G90CuGA& ze9xg!2nhp)=Xp#VH=06Efnslksgp;MO1a=loBTvNNZ;_<8?OP1Og00q%V0TR#mXY@ zzrBd4U^D8uDHl^IvUu?#8Zr%RHFYWo%Tj#l#kaZi@*koFe7Im4cs?c)l%q0k+NWpT z0I$CC2K(&28-6a!*4u7FrP9L-&%K0-AdD1iR;`6lk#FnZ-S_8l*S!yp_>9Mm??y_4 z?>dB9p_M{7F1RirNM~G3Wau3%(bXkr&gTHh;9x+Z5RlDgV*J)1q#>JvQ5}uIAfB7z z%ByeY;m2Q~y*W#vsF0~V(gdV4DO^9z@@1=eYu3Ar+iU`kQzYf3APjkH_B#v?mdIp% zw226Hfb<-s#9AcON%cHoH+P@-#O{3ZlLzqp^RMvNd+*_iho9uoBlhBZ*IdN-N#kg0 zayay$J$ZN5TvEOV5*f%IPC-=AfFyj_vjW@u3l>APuFMS_%V1M6#GiF zHDwt)W)xH+%7X#xRu7O*H`}>VMQ9y@OBFx@f#cbv99Fpz2W)&v5Q1?$3gt4(R<5RV zbQh^y3y(eZDvv(?s%2Ky0Y44-Jgto>j08=D>o`cMDOW1^zQl3un#XrjlyNAP%0$XQ z6f$w*c#5SmOBXMpy*2kjcdwccY|)%*efZ+f9^LhB>h?bVY# zhBTvO4=@q{v5|6{m>?kQz!5v8)bDj`K(Pg{;w!E~!Du6Y@eT)DC5tns1s5KPOsa26BK8PXUixEj8I#acDN$zcfKu_{NZPu&% zk7E}9@qtiN-6xLZX|*vgsSTORf0T@{{wIe1e4uIrM|=NT9rpj;|fZG+;H z*Va2Du-s}*PB0S5s=f}^V;G6`zqbaA)sCUO`jI-EEz!_|n@xORX5$TGQwN*+2b&<0 z)22kE+pr{m)SL(#Gno_DJorxF+GTrhWB@u8F00T5GfrRw7o%$kN(=Mj0K; z$Lhf*VSRG6ZCXM&Na5g04@X-66%$7_lX6&=hNZAZks=Hu!ce1>j_Zv0cT@MHt5QF} zdQz!G?qon)R9BlCsAcLts|+Y3P$s#yjcBS!#pl~+l1Li0QgJ=4&lrZvDKd7AN< zzi4_qD3S<~Mumd0qsFq$=2KZRe=%v##TbL1vI}fKEBO4EPUNyn zFX8uh{FsX__!5hjEakPApC#o>kb=dF7qNVKFN1{=uImt#3ydGz#^~<$IK~;GjY3F= zrsihKfy-0Ry@v2}D6QCSj~PszHX5VX@#7y}#WycMm%|S`nVHkKVfD&ogkcFBhviF` zap!OT&UM%R(58AU_4C=I4rRtx6M6s5x%98;0Tpn}5r=WcX~&RB!}xI>?7R0aoO0rk z9JudJq+EeimP)T8g>dZVQpfGnabz`hcs2TDw4E4JX|~&D8mT5R*>9h{(AuUdMk#ctdFrW`_~tin;r+KiK!=){Gq>l(ZOroRtIlDU9VU{I0W-GQjKdG!lXJg%I{WXl3w`Sbh$0tl1YM)M zIPUl(X~-E4J9JMD_~iZ!^p}86R5W2cM0ELrw zK^lxBhya`vN=thBHS-rNBGZtgwXKC|(>AAE){L7tj=lHY6B7)^i6aauj-Cl2ttoXX z#dFU;Pbf5)hyxEekbU>w2~Pxk_uH3q_E}$K&rf`sU3T7?S6+OPN)Qr-gPe52XF2(# zV<{DSIOp8YvdgZ!fb=Nzmw02=EYcZ=AOuTqlLSE&&%Jg&sRUsRU^z53w~=x@mM&V# z6OTU!UY04FP2#tA{)WAF+m;=tPvT3b9m!RfpUIuSzL6~_jpE72|3d`io13}f@^A3% zE5E|FQ%AGIwDElH%oF+XkG{qJ`|d(eD&a{Vi5>eeUOcu&Afk^AUQsuVlZ@llz|7D< zm04c|7&FA<1*WMj)DnZT68iYz&c7^!624x^w`~ z%Y(9s7&5smr9s69^OoTH4Gi`UaK;x;<*JL%WT&ksaQvqaCD+sdSTNI1rEpVe7A#)O z+NFzeJO`P}^4Fr(1hw~P&eAya=l=vBsr7M=RV#P|7Hbh}$J(*ldQ?o}V3)6R; z#BRG!hbTlio|U&5ms~@hM<0EHVzG#H1({ri!NEaZdgWDG+FDq(at*U)y=y(>iv4{5 znyWbJ(+ASsnqs@DWBJM#KEsXIUCF0DeF$NsNTpIpX^=u&uqd+ZGoli^@3Iqne`04E z8ze^_c?j)eM?)A8g%OJvF2mDlOv!?#DbJ$l|jDJsQ5 z_S|cC&ivX%+5iJ!{v-8^DlA1=dbOI-tSx941T{O;b~fNEJaw zmX+n$Y^jEtcdG3ZH4nua1CiP$eS|fs6f!pDjoDWs2@=Yf@h+}dAPoBJ;@~2N+O;4{x}dBM;k+DU%wRzQcG=&==Gb-D z8Em!rD31T^{_KC?-Vm0+OR;FlGD4;8_$bz2k8jd={(q&N)YK~RMM%^mX80!DjJV0H z4)-+`4Ap)l>*tN6e+ipaP&!WR3F}w|fMDVl$LdJFUQMLgB z9J$^#H>n*Z(#ARvE&+pvV-OqmzN!VKhRd$&!KjUy)(>Z9jdbmiK(LX<%g89Zf~jM| zMoLjE7Ky@W2-sC$4^$z_uv${4`-sC$HPn8ktsXEyu z=hn~THC9+MDGvvZTji2psLe(D;%Ng^bUb^9_l zQX2JqzGENDdL_n0P4OVeMtCN*rf&%3T95+o^u5jofhx7OQCehZGXZ-jsFq(Be{oHfU17vf}IG!Xd6sVLd z6bFg`tm`QvoE#>Cl-RDuvG%CxmKaL!pLvt+^h_-PNv1!5*mzOjK{ z-SG!LbNmU+n6V|Dogu&d-EAygx`gew-vV@rB_BS`?Z5gR+itr(t}D?|ncm((#*ZD# z-|l&c3(lXz=37ppC7bHB>xzWjCm^7}t?`uUg9(W&_U53b~MCmu~hb0dxU21bn; z&5O^y%<;#bjt&JxARV7D2r*h9J;z2xm5$p~tR?q68zB@9;b1?PUwswFo$@)dDTgT& z#)9&g_1e2U_0*eewT)rbvQ@M+HL}}|Gr9NfUtyGFvx!|qr9p%v7&WR3ZOZ)o7r)~t zx9x_Xs&L^qzRFQY9>)0YW>k3qSETsyEw}RK?78G}8Hgfu2xBKq;e|Jz?|HTb9rE^%Kzi8P&|G593oOsfqOq;$v`+Q;szxmm9%$c)*uI_Fcn_8K+?Q|}= z_;UXI=eN+tLn}!-o95hezRXcaA4X?;8x5H>`HoIrdHyM$eBwoxE$d^^!sT?08;=u3 z+;Z#9EL*;Won~xJL-=|!v zFluxc<)8?T!1cX&thF*1Z4`~o%{=?U3%vIFYwWhi&a|{NbKB2vVO`${!dQ((&==K9>w>6a5blY`4qO;Y&0p~2a)Cn*Zq(M3zyT?)kI%^ z#kPTpvfv4|X{{VY;5C4XFv=m9&JsnMTYvm>4msptat$tf?J<)(Z~qa4eQOy%rkh-@ z5vO@FPd)l5xBu}0E_h=$JMKDz#+1jESDwqKjyi}^vB;#!6KNa0Ii4eV=dJf~ghb;~ zO~N7)P}ES{e15z+GnrR(9q1(2o0m;>?ytTRGp;c4qOfl4ug*z{LPXl(>8GC~<2O()DRK?Xyz%-wJo(rQ9P#PHX>Eib zTz4f4&iEYTyW0`M;AhA4(`zrK5*j==V*_vHGQQNbceHZ%J^$jEPan-Ld(NP^dIcAr z_a#m}^;q)hEJiE(ddgI^CY4IVV2RbM*7Dv5AF}%%y8sR^zcPzbCE~+*ijFPme*y!UwHjoGv|chT3^!`*lPokI@ZpRKo_N=K{94}W+yi-%)Hw9+wg3(D8r%rnowiqaub6ykXfO4yvnuIn-|(8GlBWBJjKuVv-hwY0Ug6NM3e zQ#*Ivb2|%{7ujKI44e{@OZyZG@a|g+?K3KuIsdDtF{V9Fb1p|yHchEiLijHEraan2 zDDCj{Q?GGpxs32#$TaZcf1YB_oO$@E9DM_8F+vbTAsOE$c*vw(!a$*vv1~S1(A3<) zbI-oQdFQPqo%Y#r_uaVS%JV4{dKo=xG$83&-OIxdKS8+?uw=U>R5{?}FPy@t zv6K19&+cUYf))6w9H4PrP%4PmJ#Mv6kgT)nC=xPW|JAqWn)BN@Pqm8cl-Iv-Tz|M z+Ce-whgOE><}U8P{}J~42E!=)O$d-{TbGXr(BX zN*JwiTz7-@p%XBEG+~5QLhys6{ zhm~AQDVtfV9tk$FtU>GgNTwVL9*m$-t^e+g{d+iCI=o!sgt1J68VfI>*P8gJwm1?o zIxmE@QXd)1rQ&B4DUH%`wl!_4**PLjd=C-IXjBYNC6w|S=pFYBM^+_K)d?wC)Fl$f zgoRR-k2toyY8T&cWWmDKoOSkvy!gUvc#)uCJ1}ALcHsDwixFQt`+S~%{x!0V zjg(c0lk#z!JIOXRlg^|;M;IMas#L5wXsU@~DWFs?Gkw=jGJf+duzqiX8-IKoFTL_Q z6DN%(oo&EtZX%V=Q7Q*CHe{Lo_Pad${L7@;N0V-EVapji@yY%7Ca9EYY{*ShaR7jh$myx@-l~aR{~K%V(X-`|mCw+dPr2rqASn!;hkK+(arN zeET~$@W$Ko$>$nT5lFX*{{9NeKrw{h{^4#Stsv)e%<%_s*a6eIUuC9rhdN3=vZ4#IUU^HJBF-)bA5|4md>#c~22OHVvk#N(Gk zW9w*~>^K^mI}tL4M%z0-%spheuT|Qi}lm_2p$?`$Ic={#0{mN|8 z?c>;L*F8Au#N*j@zkS$hrycp=!+AXS(rfe;%Dnyl9I_pwSh{oxt_L$`?8n%#qiJbv zW7)i=Tygn#Sh1=XKbz+I8*ijiERxPQv+XupaL@tA(AwI-habETG=PS}U_VM}d?ybg zPr0P&9@WOf|9PBmUh#c$={81>+nf_WcLt}NbTaei&ZobxNOM~^t!-T>1X8*fEdUoS zJhb#U?}CeY^NqLg^G%G~Y%<$VpGo_u3G@vN^7XG@&O7fcVDy+yuDtSl{OT7!2V7b^ z#<0__`_R-m8kx;-&9}eF@Ba7~y1GYEs%Q#>C7=UJSbwf*` zVUg9upgP%@*lr|-eNVzsHp4d1afgYSk&%H^CdZnd0w9K49k`-gGzwR z_%viQbaszo`Kr}idd0Vx{rW7jo!z87!MLg0Abmq`{~%3Wqwz9%q?ZBXux3p!Pe1oE z!kEA;kbgh$Bwn@~BEkK4|ATNaKnj<(woc|RT*%t7PZ)*dvRUTNoyW^Bzec*L3BrJvUwnmufg+xl!EsXb50nrx zjhAah8<%2P(c4=UO_RTo*Q%5subQ^_251Et$D1@=T znz|mes;+D5f}k+W4q|AVPXdcFCVmMiYx2S*U}<;W{}&lm|K(9dkmCeHhN`?bQkJ2Y z%`|b*H?H83e?Nlbw&MFaw%l?{CT+GEnZ`VoisZ`6zt7!wKg`%k6BsOoTz&O*JpQkT z5UDhix17ozhkuHuwl-4PMsB?JMt=Lpd&o95pd1Icu^F7Pv^2K^P%bI_Y@XKc@rYC> zo|gg)p)&ZXG?~`XWSZMaWztNUG>SLh`GB*(dLgS<50Gu^X8icEY`5b~X6`zZ#(V=e z-0(x@&Yws3s4i~(>91KZZ#fN3qiAg(%a_kOoeAT+sgz2V;f#al=7^NX<(Ge#zut2{ zQ3T!NHfNW84x(vHH+=&U-@N(Z9 z$)*ScyY|lH^9&RQSvY?_Q@7fM@sqbE-!h)%OILCAcYna5r7LJ?Y(z*&5Cs2Uw~pbB zWheuSp^V)siNP4FMu~kRMZ70TevcQFcHB1!qex<;gODzc<63`SOS`jdH0dIwYl9zE z7LrrVY>u$6iJeKpvPsGlb8qT^mTGa=?}tXNM^M=iNU<@Z$i}348}9!#EMik^B@z`) z!r-d1w&M4V0U;fX6W5)o|NBSpsZIS)0)lM>_^QVuhBKiyWz731dEkZ^S{uR=>d$i= zr%C`)$_CGb*kM zD(-aF_sBkX`~9(GOX+G>felr14F~@3j!gk!q9`JjN>!umj$;`xuIo|rx2hTWepIJf zpRM)LW%{^!>)}kY4VCM^t(OfM;tgN8*W?ei|JSIi_20A1AvX-&!t63F>)@yA*PCzW%c4d4|B3>}r z0a%I{fReJ>E|ju{^;(-66*6H6Cz3Iv5mH(|C@iq26V^D7}#mV zb6px5ntA_&g`9cj`RudruI#kabg~Uu28(6p&s)S(Pdvx_?=2wL)NZdM0)G07+nN2& zd-SX;Fl+W(w6-=QT~J6Kc<4!_>+_d;?qcEmMI7+SPqNRxyVBd&%VUo|$v^-32%TM{ zSkv3jFYfpQ`^=rs!UfCuaNZ)?+B#51bM`ss@ys(%F=K~q@X|ha-2Ml0`8=gc+0uBm zB%g1lIMBm4zj+-mJ^w1(PMbz#0#>YC!Q1bB$lLGELu-%5rUr_o0lt0B^}P4iTWqu4 zR9ZV)dG3YRc<7-=nZIy8TW+yA|9apFq5#_3yP3amIj4N?%Y5onhq2xCEifn+FJ8i5 z@A)SmytjlcCQsm*=bqztcaCPe?Wc0*AMU2C1fwTT;7@=3C&i%1w5=wgO@wj4O?#aC z^(*+>0}rtOK0DLZ(LkZl#|Lvh!_l0}PHykreRdKe=?c(~~t7hQBUhaR#Y zp$PcTqfgV;-c2@}WzL6-IPSzRaqxlrv+cH9lg(r(mP@?--UmGS?5lh@cQNUF6My>i zU6`oC?Z5pceT6{|I_N;Q+je^@Ma7-B|A9AVzegsQBbCkY^s~?N+2c;);6wIf?D!5| zdg%ood+14|@c7iHKf}`H>+oEMrHhtx;|)J0m(B9XV^1QbPg{Evcies#bKhUcClA_( za%GUWX1~RYFTY6lm~N&_p2mWOt7vIyB?tp6v)2()NSc~jS+sZsCw=Zq9DU?LY`e`g znj7;hTfUNi{Nq7hc;O9NJH{ZSM_3r(JKz33&p!Jkd+fR+%`JI)`v>^;&wS(7Rf0KsBJdrVEGa3GR-@k~oV)?QaJpROUq|zA_kZo+` z;m4liq!Yi$r#^i!<3=~LYUv7=E?myL?=9x_Sszf)g0@kkS+#Zrr=0v1jy?JS_Ws1K zj2$zMHS5;$+MBcZ$3Grs{(@DcvMm;%j(JkGIY173{qGMkBx+49QawaHQ40js&$ZRz zem%B80$z!sdjV6oBo;~b#M%Z(BzT+Vt4U;X9>u{jPdxD)ufI8mOs(kLOn&1EaUdD~-X8GcU{Oi7l z>FVsZ+1Rv4d*?Xbd1pSSeg3QLx9=`Y+hPLglw@7cTIMZS#fvY^Vr_o~A$^SS5YkG- z9qAAS5#@4)TvIzwKm8}J{^qr8v%^fv<%l_RKIDxz-z3t|KB|L7%a(EeMc-hLPke%h z{{1+aTnk<%&DG!gArJiHL8foJ6`@oV%4L?$TL>E3yGQf(d-E7QYBYsHKV?;B@v^1t z`A+NsnD*yb)zj^oFMYJ?^5=9XYKlU`IoN@-o zeCjABO`OQn&pgFjZ_Os(kh30%QG|3|yA6oth74``HEBoP#-e_mS--9mA`aln#7aWf z6ah&;s&<7DWJ>?fkNOhXge?gr%=0`- z_W=+7>v3Lq;Z3r+7DDaQ*wW3aHLE%Q!izcbumjk7t1U4ImMmSyi?6)NORvtxNoVmq zk9X$G&&4o!t$*^6D&(Jn}dWI_MK@we=*Vh*-UH70*2VGS5E$CdO-_u_@07bC>YB zlfKMXzj``dovl3c?DNc?{T26qaosaN56X=LbG5pwm3vcg3nA(;qmj#6 z^I)row*=zaD^D~+X1MRKkKv@UxUL}5RzGEQykHV>3ha0>l|&#m$igz~12n@AIl`an zwZ9vGF!ef58Eayxf)2^=3HV?uY5g>l_0ge`G9!G{!y4XLM+A|Q4wRV64g;??WW1Ju zL2+&IQHK~7WgdQ?8+#tZ-*=KmxPi;or?5>fI~0I22pRLf%?L<#tPf8pv%||#`!2m6 zsw~9thHe>HuH#rHh*3j6Q(vtKb1(s4t;UvWJxj-?gs;*%H5jeXN~5&0vd4sdV(Lxf zK{|0eis_9;LqYh^)Q%Pz=6*1bH7i!)NrBWdmZT^<0ceYCOUEHlA+GD<7%*Xo3QDLb zutwA=-!j287zOEc9-{@yC>&Q4ghlJo6hhX`6FE@U0Vy|yhzwy^zz`8u$~dk^HkSnq zK@cK@i8BSW5{ zR30Ey0>?=amP$Rg;o_F_~&1k zw(T~Q`g+)F@BLV~d<~wL#)%v}H;e10P(mOa0V-s$zXu@6FhDxxlg_$vI(8SM zt-Q{dSg0!mHp7`^Q5gi?W5=@jl(Cp{A1M)#5&@2=MX)edGNQCXMK;@}#%t6cB*)(Aq=mGSX9o zl@Q-cBW1?=Gr6|^N5v9C2lzP;tzC@H;QJmzhV=EWB@8Qgj>LC;%3(yL4e3mZlbNS_fsh)F!F2_dC`4=U{4|+#15so_Mkys>5FmuWPq_>Z_Mmit z>k3kiLl`Q`m5_$UHni}Fw8D{~!V<+o9}Srlj&vx8ibyMbKTX=t5kv;h6E?X)7?JWb zlp}=`o4Q9)h$ljfjtG?^mC0FS>||V45yj#l!qKF1E=CEW5~x6s&o@yaM0$dZ=P=OQ zOSv*YLqj9kbPG`wF;G|sQj$uiF}OB+s?atWfw2)+5zjy7|HNM{woC}hAq^c(?7a7G z^silsUnzqLFe<`GyXbYaojbJ97y*t>==F|Wgw+6lK!Cr`qOK&866H0J%C++PGtX1* z?ZHcBEwh*?o@@L#F{m}7mqws;gm48$*le?DKSNL|V#@s>6cr)yQVpcsGzLM0Mw$pu zC`y9`!Z0L?A~M-5+JvA5UZw?uhf*Pq2=Sc|tqtXhL3n8#*F&Hv7y5`Q6@1?V7s^3E zDxD#yXc}^jIKo9oid5QVpx8?oMC3A!Xa%n4VH}ByN|cK|c+$af8}U+Ev@|Fcpei9j zWf0GI5T1t;AOeMNB*xKbSK_)kOeAoHBvKW82U|DNMHzwgJSvew7>Vmj2K##%QaDZ;Ybl`d(vtqQEBL}G$8qIV7vndybH^`!%-1ix635TrrZT8N zlgVT;uEBGBL<9c zMsd$yf5DV#(|P2<`#JaA%joNaY_1I>A&LS#9U_EAONkZ+Z6sk>A(Qh^N@A2pD&rEV z3Ilz;xQ-y#kVi#OEEG@?G&JNf#!8K~4(aJ#ODg4)No5G5B90?SdkqMYB8;pTWy02w zBH2`14>p;0*nH|1j2hKNxm>^~g)j;&Gzb|>ZS5*g#p_cC>#d3r)g(K~(p4M4k)-?# zAHF|_RSQ<&B!<&N0<0U~*bO~4eH}ggGq&H^aYPy{Gf!GiJ!J$K7f;&k&p~C7VzEFb zlfm&F!k|nLMi?W>H?-h78OlK!$C0?MqEzZ53QIKPvV@@q;}9xvyfnh|a4@K_L{KVO zZ#gfE<77}mQwat!RLG`M2;(7yi|hFmE7k+kNP~v%{6p49XY^dO~Ufg}u36cAMujrk@b z;;eB>Q7R1L`xBO)t1k{P7`xXLjv)vG#*7`u=G#xBs3TSvDpZ6UGujP~ zacWGR5!!BjyG{a%);q3FeIrGUFd%CiF%j>76DLMv65q0+-)&@KyQ1yhBl$fMhs22? zlHMYcpp9_Ws{xyZ-CjgnX^aKCO|7o$!HNIIAiX9d9!iH9PJS42Z^Op~Z8mtFkwB%P zb*9%l_an=iTsy8e&H7~={`>ksnb@Rj{4d(_NVr42HPmoyV^gk~-1|uNJd)wOiQBLo zfiY%cG7DPc2#FAyD2kXgZY)t4;3kuY7U0xHJFGX4l|mU*3`Eqxp$*+k5{(&-NN(~` z&rI1sW!D2vi5-#SIMw%RAjJ9)XQYYGvbdb8BymG1>iYM&e)&FkwPK01-G z>JpGLiXxkW7vjttH5Oq~FZFHK_J_p0IswTHLb!JSY!ajR`ty@^9pi6W$B_}6B3>(F zv!cY=54AFA$3+?wPq48Gd?E)Cwd@^^<5^%SD&wU+qM!m;nWO7x@%)C^9NRW{-*a&@ zE>Wc72@Ztx@H~k@;iY|4s1Zm~t{=;Y9Hf)Q_aNnGFhUSU_VKx{hvT?J${K1)uL&WH zea}QlN20XGPi1gDyV0(MA?b7;Z8VM}2$juT0}`V`JU@$35>1L!Lj#fs&xseeiWqD( zA8qBMk~@O2gh+S2!crC`Z<(| z7%VHK^l+sl(AIF(@iQO|kw_C6jjli{m8H2Qg~B0H*4R8{KpG3Cr_pFui48i{lr zB4xAnMM@zsc#eZsVRh^oER+C2C5Xu6nn-6g(#haxN!m>j1VQ4GfDsP)mQhq9MbQ}C zT%%?4VG!D~`c!1l%8*Gn;QJZ7=@w#5I%6qm`jp ztRSSubpT|w`zC$Gb~Bp|(o`dMg6cBGh&WS;srnHl5%c0$Z7;wzdqM-C@XNBEWR+LJg9^rp5x=E9F(+DODQ}&$3-?| z(UC@(fVA&}wi!012~fm9I1(?@5RX%K@#x^=`VLOsM;HqV`>sHGk{Gc9AP6I*aBSd7 zsW|PqBn%=l>1LFPFh&t6jdTSq=`>+Hmf|`nVetGM?WqQWAV3SqWc-+NuTWZBz|jbV z^bn39(%^fkgw%@bc=owEl61^7UOboZ0*ceEltMlkc{UMRHB>=kPdW1oU>GFdjgi?re0bJv`+_nHrT?_;icCL%I3vTFk`-iv#W*?zC} zU;nihc3~OS63EFqU5fE2C;%p~wK8MrEiBVlOHqb|2Dxz3W%(5Edm1%SfH{bVZRH4`JQIuoAVzUn8qQDqfTwcLDWi$%LgHC6i&Qey%PtJR? ztjqfP35sHj_d=d8lUb$oCMvL|V!VfmY^dYPNnqi|c|Z!)kd{G|6D!!L=_(UlV=H@l zBUXyYG;4AyrTTkN0+>Z*wKXExETbG3Sd-E1uaI>X7>`SevB%ma7IOiUVI*T*j>$VA zCZQKYr@u_Mzlif5GD|1#K~a!p9SZe$JXyX#W_tnfbP>eJGBVr8Xa_~Hg!SVPjLp%! z7l8y%ZYAvTSk?|5rPPv=pqgj7q1zR#gt=-ZLo6&TW$4(mGb-rxmQgi~UC8Wgj8M7~ zD;esQes2}QP?nC&C}O)Tt>=M>UKCidWO;{fFSx%It|ad*kP+Pborj&B4f1@%aJU1; zG8&E;m!3m?L8MP7vq+XlMp0O!nYFkwWl7~@D4Lzpxk#l}fr#V_$~aA4AxQqlrT zhu(}1_KgZB0D&lwO<$js{#0#=nT^0HU?fmVO=zU6QVUb160MyYQxNuq8rSch*P)ZZ znYEUsr6oqA5j#6OWLY+a083*NBlhRyq#!Mp89-Jgz>1%GX0sow_e5nf#e%AkU-5A~ zr$M55lhmiN^YPF&K~S`j8@^Ts1$Eg)m4uNtiP6UWxw?!QRtmTgPw+)<9gy%9i5&piOItiGdCycr~%vYdR28 z8%%~Vp}&j`LY&b=)AhAgK?EPu8D}O(3&oNJxwsK0$mCgA!V%bWuB_G>k=kx!wl7se zLKpFv=o%@Opg^F(UmoamvIhM%CgkuQYZU3EK*GV&E7)jBHHd*sGYo-cqkuO-7!}n@ z((BlGSgXa0P7sbXGUdM<^2;%QUrk4aVR0F-cmj~%3Qrc}v=zRH@uY(@0s>AQxitZz@Kv7BY8}`t{4U+N zT3ek?ueCSDkmyyWsAk5HHcK$X%SbM>)w|Thelb)zL&W37;{>n^pzBS9!e!rJl9#0)7euG0)ev6;nd42tU^ z6N9S5W+?^&!LaJfz&Oyb!aChNw9_XRvWe@~#C_6)#1tlsT~;Y~BnM3bGbsY_xdH9q z#30sFYN{iMIuhmIAmB|{FNNYvSo=l|ndGPo;Gx=(2^V5)86{@pNpTs+h*3Na#DuJ* zqKGPGS%h_kGDdO6kc|T{Az2o{`6L)ex6%6Cu;iTxUdKI70%lGX*Q1gAB!nlZPu$#e z$tuA+1dL8042%TEmRB&6(aY05Lwuh{eNvn)5Exi#Y*gxV@D^rPHOFn1QIv|Y88Qwr z*Mtlo*MyIaPl9|h2AM3lfA})a#b^LA)&}c*utLPR^F9(0Yh#C0+A|fXjK++iWr;*K zi$g&25YWCXLaayPnL|uAqCpywOe$Ek+PjHaB#j~KbO8zb9KJf=ylTjL701l^kgZTe z(P-^*@tRa@ZYtTKLO4u@k1}RrWc#@PWqGi+<`Fz`Iu8A2Lq=Hdg5oL`oQ}zi4NAVm z^=YGfcd2AP7&`}nI0ZV#O-gP1Kjv{ykLYpcurzyG`chP%%k!)AuJ-+G&`ef=pkW^YUE zSTTy^24~8Ud65AE!Mjr*WaPHJBtt#<{26mj+GF)rxcs!y(oL3Dpj0zMkxZ;b=@ukCL z(~8jkm7|++AC8983I}hvOKFg^gs`5CG2z^co!aepV*BfJ(G!@)>Rl4y;(nE34A{tu zvBuziiN?exHfH=)6fBgK;moyR-xe>ZI>0jaF2uBrrvO;I1CJ#WrSuL1L4GvW zCes}lAv40#LKiEW^!pj35{6^g8I37{yqlqAkSjb5dEHC&6xrQf?VSd%B*}3RfDf`) z)I$N{y7NYAoz#TAjW|+MUM5L5t)`+>SvtiCVofXNQd`%g0%1I9Pp6)bK6)OxV8Bmd zSJLM`W~DZfJ?Xv2N<60;?D%RQ(O`(S#^9Z=+K1Q5sHmzn13Jk74RTd&sEKh?8@NKX zS2p^57ML|T-jyz{?R#2!uWF1-UH?DTB;gqI=FwzK?Wv+#gNYOOPXkc%$tcazbXS~g z>-6jAuZpQYYR;hbA`LySgsv%~5q!q6$fxC!r%fDPwcE$Eqds z-d93;m?+bmeI?X4ESpVmYPvCdQ_ifljY;Jzq&3fTlIvf4K=S>$)~KpbAv= z#l+%{b*fB?kx7Nl*bqa)051;FU|IL#E4Pmz`PDrlnp|>QwT~IvsKMH}<{F`|(Qvy1m4gY#HGWsel z22U9>t3Y7vM7B;DmE@pmMIZ!0ypO4UYe>LL3_yvA8(!KR0}Ckm!m1CM0gRS`f~|G9 z=KzKVCCrNp%oPzt^EiKNtMg=+87!H#jK*U+oeswKfSf#EU}M03g#+Wd+aRzzyL3B64*bx#MxGK$a+8;xL2ai}o zWzl@YAk&7Fs#~QthotOwm6B0CuLc{^J0vo0 zYC}u}I4Zt%+^@XBh{Xp!NsM)&NZd*^D#s-jD`Bl^V}B8!+@qzcLyAM51#LYFpC+vu zjMQt-MBpVb@RD&N0J}1Cmd?R66NxCoq8bf~7}d5f$o zYGF3j(Kq*hAHYK+Cb&i=peqGLEnTF@)@NNZc8Cpubt`43s&m|%Dke&+gC=@D{gLLI znf|W&JNAu4hYB6C(Qk<4-7@uR>Pf;Wqo)U;&yDleLQ?;O8XQna! zZKGr9Jt6^gQVHh-5_S|JxXO3|XOuE3dIrV#c#hc)Y8*~WI;MkqWn*)PkrNh5%a!XJ zIQ6Kp7;EsO0uzH;Yr_yglC_%gL+4nqRXt@nW@(|%;q^m+#e3l&{g3~IuYC1SIey{{ zE-8TNU{2`pE?RqSuuH~8W*TYdD^>GiM|ctw6sdt^h2RoTJol{)P;y)w>DeJ$cmOfH zL?fX{j9pveU1aBFS#)g=@@#G`%B`>C+fh{JoS&_6ooys;ySg==qxJcdN>;I(YxhT! z?5wJ2831Y3@P84-L>JfyV<%H`#VBz&)CYhus)MMOLBG>@{>Li0Xaoq%?f_5+P&8#3 zmk6?j#7#<-ast(`CX(xDSRPtOOq%FEBZ4&e2U^Q=L zmqqYy&4-Eqr}kE>O$3chcnLmb;kpET1;wxebHjIrEDJ_c=R^1s(_~mxEG*Md@+T`2 z*Q23rSf3zEI2UC%39@JaS$(D38dJ}oG;ouyZ$_l0|C$JdB|SdNvX;)YTS*4_efR$! z;LWYL+0Bbvb&%)p71L@z_e{QP79(x9=QQtwX~x+k7&nhF^KLL16L^*W4j^NapK8No zs%th0L;=)+=BOz08Uq*Z=GwR0XH^HqkIS-bRm2#JwU%zT6XP=!(aPIYzUB|uUM_aC zD0b}un$Fcb3+YUMmt|RjZgU5tQKyuftI^5T?`k`c8SfQpx3lWrwr{SgR_wF%OcU8y z2?z9XEGsm7;$2NJr>eKp3IR6~m@cW!DVTYn_-Q4ePcDY=OkV|It2WMdN7XKVR3cKV zADY5;0z)Q+W@C~+Iw&ReQ6kl5Trd9GGT3$*4fRRvn7WEC0%*xHDb@Yq-su=?8X0V+ z1zx2GGV3g#Dh&{SU({3fdF?@bn&&?x1ktCV+*N>ivythbi=ApB3&@M&L@C7tHkSle zf{(0~5KT!iYO8sY$L-2cd2U4a=LD{~>dhID+;c<%1ZFgt=>?(y7A$)esHTdD$7m21 zTof9OPBg1gj1k7eF~)W%)$`x}zy5pfy6Y(CFP!7;w=TpiLA5fmpYgff<)H8LUSsl( zFn60^v8v+ARhqtMJ<_RqCR3Ae_7!e;q zK!M}D)#{lHCltif{QG!7^GjqUTvd7LDhVnc3dk5szWFa7)JyNaU4uwNg88tTE;b` zU`$Lj5YPIGF(>Uax$5h8w(LikKFG7+=I4QEWanZ~5=+CQ5+E((wK@khDz#1|s^=yK zH8vHa$7*Eb;V}wc49*yw5xfb#o)ra|TD*2By~B3|R~BSrVSVKg-}vTt_)q@R|0A7V zkEg!>U7QsbdOeEafCV}jZz!a}F4&`QCX2}o-F}ZtSFZBE{rbP;lb`%J|I`2cKk@6o z@f)nI9SQ5y2lI|p0DsE1e6VrZ@8^4!N=}^h9W}P5Iz6|vGE~&hsWV>@5;J)XOs1wG z3VDxDhnp`q)6YU+Nd>N~M`Nbo;6@0!0LEZc;@l3&E39)^4UB&mjdvtfKgQ?c!8$Hn7WVJZFrLavB8Jx2OZP;^Uwp ziyl4x;XnLu`0QuD!Hvx=zW3CRIeg?e&I>lnt2oCAe52wRH8qC!Vt|8(!R)bC#6~u> zm;f#s6Ah(7<_Sk?^aYgQ2_OW*Kr>YADN)y8 z7sW9V8$xRoK_|~`hLLE9Oe70UhOF4Kh9OQg5;%+rAv=bnz}Ql)Li3o0h}R@kB(VKv z2r7G>HHi&A@7fA1(HQq?58&uP(_)etmd0X|Br@xL&3pEEep9EO#h#i4woQA?HFfpb z*vUcr{$@)Pvft0nnbnnScVV!uNIiLPR-kH(Mpy*3u+|K^9s**Q42MZkYG!LV!0TqP ziCP1ovyWbFNsP16ViBnpbr$19q)D^eLA9H^TwSmEmYWs>CT?1lMYXb2Qd``pC#Uzm zrtZh~`=+(NLN=%4Y&8+vX_q9eWQ-UMBf5%AFPPxVU5V*)86qtNSfsW2wA&T{qncq< z|8_nB7@>~IV42$Cql-~>cr=P@IC57hWu<*U)NT}{5eWA>AFZ5;?vR*A$?9DzB?nR0 zZ+7!M=(RVyA*r<6(z4^mx#X_+-97|gOu9|MBVE1ZvNirW~w2Uq^h8j zMK+qR*dAJ)45qQa#JyjA=xYRzjZ9sQ1`&fx3Lk=KP_~8eJ~5VYS@hT}1vEMqh(`7i&={|C-FN?)?RzK)SBdLbm{ zcr8B%R^rVwRd0PCYs=-q^{kGUSncjfW-`IuD~k+UAA=NTSx6#MmVq(U@AVlKC2m~C zSfUbL>im0pF1(xloQ(W|onspFHL`V$R_@`V5+R)6&KNUBRc_NNE+&o$QjXOCUX@bI zO-2J}UgAli)Uo*>8P5YqRC`|p*1OTb0tzxh2sL4hBCZCn>V`Gsf7&3HS3{we7higV z@BiQ*Q#$3)`e8c##b|(UD&|SO)yCiNMTz=c3b`}O#VqO%qp zRn>5oIE{S$dTSs{1TTiX(_w9GjqBI1S45S$x^1iO^UHqEl$+9*_Em#}y?biy@8Xs>ugqo19<`7qSU<(j-%9mOECPJv3h_s_HED#?WNC zB_+FgUlq_y>TwGhBa<+vI9I#SrGWh5@8sJr^^9rtbpsxoa5HRu7Qu;Xn@gZld{omW zq6C&oGhK4C?UfP$RV)S3nDcVxxCeHVU=Z78J=JX4^vvi^(*&%v1_E3EZ_ZIoiySKV zDox5}viwv~Z^dd*7t&6t3eIR)U_uoTJ5aLOcR5JFzujV_?e{&mst5(QyXf{B$1u1r zxr%{RB$h$~uaxL65XaplB|O9!K{PT^z^TEh$9NaOsR(oik=RFXL9OHi<3k*<0BMDB z6Uh)MQGAF?rosN`ZStEpasCB&xtU^8H76uwX;LUkhcO+F9zRW%1(v3Fp3>{gJ?fnT zMs7z5;+BbhY~mgitg&J&#&}!|whwbw;BR+Z)>yK8%dDHpIh7hCKCz z*d3`4Zox?MPWQz-Ha?~XS0x1o4~n^}RP03SGIAf{i>PtAOSh#bO*>2yFml$`lFv7PvuqmIaQDH>@8%f%>2b7G;rmeMrpar{bv-DMb+Ly;y^i z;&(P=j>?EySw#bzL{nUxkX7(juW9?5!2`=9$Mq3{G z))c|iAQELr!AB#W5Hkr|N8~$2(IkKPz1B5)7$z{TdvBYW>pK(SrYM*yFrJhe#6wwcF&?5&=gS4SvR$;IbU&g)ve+-$z4m zWd@8PJ0h(s=v|2fVnQ5b<6~WX$O37zjM9~$u(Gs5ZZodmxK7~;qzb(e_1=b=tI5bx z2(fdc0n$0gU@&O870zP+RCT_I72qhwvWr)X@Wmy&SBYifjA9w5HX5a30-=5tl)JBkM+}hx2C11tAvvg6b zg7uvfy&jo5@uH@y;dCpt0tV(azGfxWOe|w=A%o#Q2C^B$V|SM;GtElVIwL|tmQ&F5 z`Oit8S4l}}Zg&-xv*A`#8AuYKQrDmb?zO?Cz%WSyPV>-DE}8&7`4l!7O-;*h-6cX% zgw*y~mQ^m3Y5c4;RlJX?(uoEZ>#7>2K?=T}8dJp-i3e~BV((pWHL@`vcp?fX0JQZA zX&j6#m!!%5uY;5armmUU=a8~5CO1M*bpcFCGpEsKb=$+|*4hvYz`Nisr8ZhuV(>-k z)4OR$z8z&Zm)YA@W^GIX6sqT2HBTiSoPi~hV$`UFOnD~8gb_{0uU3k780+{{P0R!( z7sW^SLL@_M1jrh}jU}d8HJTtx(*iElhRPWifC80}4ajJQ6^UR~j0+?#TEap+Cus=n zb^o?4V9}9_Vq6kPNWsNY?Bsp#%oyFu*iL_zz@}i)3$Yk{0HlgCvO2^1^$AeclCmtx zM;&|$R*c}B$9or&fMnXBkW0Ov-2q+k?lXQAZ^C|TG`KxUw1BG#F@20GCc175DHc-# z7Y%|VkUqnjvZ~aPt-{K*$skf$x(p$(I}`@IvC&bbiZW6Q4HAOIr?r(JtY(?zetGAy zr*6-9#ofPBPLgZ8H8v`6REcPeF{lgimy!)H#DHrCw7)%lb*n6_SwiWpKJQ@fRMh$^ z>kwk_Mi3v|osz3}NGReWKpXdKk&va#3)TlE-bDinYHHvlMpU$f*jI^N;>zmpIaIM`cn-0S*@DKBWixW zC!1=9{xBJTd$+zx7)H}8tch#v#>#5;?>nKp)L^%^KC8l5HHlQQ4p|*oyWOS9{V8pp z)!$pzw=nScEGul2)ApI$Kul z?RL9ZYuVb~Mxp?T1Yl@Vxlad}QH}1QQHV@nj@B6}LZ-;5ZW$Ye!tjwvQ7%O4-GS&1 zlMZjykcc3%s49kTgFc&R?$!KOt!U~<*Z@kc`&a;@9`BR#R;w>5-U*G3uMY4VSxC|C zD!6d&LV=&yU=LcR;+F9aZ7wq@r73b%!~Dskue|pd zb(H|1F{_c5ic<@j3E8c@3xZAQ!6;eP4}z6ExHMELEW;Q%O{jga0y`1BSTxJgatuXD zmgSffd(V_4K7SClBqm~N*jb_!mfWqawGrGFjLCv9&-^{&&MirRPDu-(EIngiAf=0O z9Rld+Vw@R?Yw_~sD*@Pd9yLM=#;b9CRm^&6kcAuI_j}gjcN%<|kcd<~1trE>a?SCj z;GAG{L5xGR#A*yq9-X5!#;O_@9`8%C%wY2n(D} zT&ogm)oK!y6YDU1;gGl{LbCP**3Rm?KZ_-?o@>KV1QoPSDw06i;0|5KCP~V-M4?gD zBi1&Q{G@)&H}t(*<#xRDW}l%ZLR9L>yaB6}5?p4jS8`*pV!;8aTCLcQu+dGJ@_$r$V`tyg{;%VJI`qB@R_C4>*EU- z0>u;M+g;}W?0rRWzC;zW%mg<&u_5bnOogKAs0}4rg<%K^%dL&m&4O6d@_LHR6~Zbw zO+c2f;w%WZGE7S&Ifg{mX)=nJWr4}^%7B@42E7B$hIhMEK&5w;6<7(G?h5A-@gcUR zdeAbs#F7#c1o@>X3QDJBCMU~7OfB&q>w_D*QG*jjZ32pgEV`+C69J@3CkW3D|0@yk zcqlQ_G1}&zSn5)t+2? zM7-;zX~S=x5mAFi3?{cB&X-UFK`~~rB+v&courE0WMXDtU#mWv0LG}dS+Q6fb(==q zIU_`1GZwFPLMT;wy&m0emn&DUg!SeA>i9KRR&G^nodDAq|k0w}^`f(;(szJusvosqR$+7!A3+I7Cvtm`n z?yhHAL;p(w_;tqp^Zsrp%VXyI-|5l{U9`(9YfP&C$&zKZUD*hP2z9dXX!+f%^QT?s z>chSL-83ROufv*I6oAHLk!oR1)PClIsgPE!5mwd2Y-*s4P1Z)D`9Q@aNq?s{HQmo9 zE7_hnFt4Vq_)ohG@N_>l2ES3)v>ApsY6SF?`y14z5d~MXyPAtN1mIJOqKp|LJ0a+% z>{M~F0z;!Y0C59NLD?t4qzd(|yTZGWwz)!v6LU}N)+MrRXu7G7E=`P%8XWTt;VM;0 zM9~fl(O|$q>U~3n^(i8Ax@T<5cIoruF9tAVhY_5W2pgds*<(9 z!3H~Sj$pQ%kMUmRo!oRLT}o9H(-`;K!6v1{m5L?j1-!}d#;{S8j6|W|4N^>5AX{4; z@Ak1?@Dfr>JCy6{*+J+mEtA;};1&}7;!@a80li#dyPFBtf`Ti(VLuS8%`nEs{WBUmWZsT_kovao zza8w6Ta5!WGmvMC$?M26!%#fe#$(E?PqwneXlsMcV8luXI^v@1amdO!QrG}k>C^9b z*xKI4Z;e=6=#hJe_hrZk=0Y~Fp|xnghmOYN-p4GS!CG&P!IwgwVja2?j4;S7n_}=L z3*%{fNKvvlF6cKz$~$@f-pAJtd7Ac&WCtSr5oZq9X7q-`wM8N z%TBqC8*X#LWb|Uvk=8n&bDp={{1fmK4IHW=GoNe76B>;`@d=T!?x`o+dooNxv2DIQ z>JMIH*1;IB%*Pm|Rtz?JSSVB(x6^v6E&q^Zc`#sV7_hXSZpFa&{w;y|-LV;CUoh~V z?Xskl9J{=Xip6hiaws?SjE3YKBB;sPcFJZkrn9z4-s>|Mj2Ygz#^HXKt~Hce0s$CP zBNPJk7EtHtyHNmhOOJ`NtZIQ+!I0q{kQ=sj#7Hc@+ryUyGTPyIe-T?0!S&Ec0Ge$u zqT2-TXv0LDbg6iK3P@s7LaIi_SxM`mNsVb3?QhnP@KzZm$y6I37V0li9+aR%skIeI zxm|fM6&EW58eOJ2TOj6P8;Ni*O2K$$lC!dw#AnK%y zse8+J38QH0{gZR0)+cJ+Ip{yGz30!UXLWzmkULd}_IRc9V5aeAG-KK`4q^paK{Sh8SZa z;vetvwdFb780L_VhmTK_FI`Wat;|pdG*qQQU7cW}DPWWo%PR!T)(Ff+8o#k*2u_1A z+{CYhfSV=o%X>%RODq`Dsq%`5+50OJLpQf(w* zTs1K7Obl|GKpim{^_4|ra=Z^x4iW%xKV>)n=Be*B2^~JBRj$X(#Pq!hJo0KNGQ*}< z7VbLDBOm%8-6P95<0-axIRD)ru=C<8taKM}uEbbjB!W41j1N8eF^-HL0@N;gh>tommu`Y}L?zlPqjNs!Hifd~)fOkTt56zc?x) z8pl2zsmZuxTMW)P9}?3sDL&yr%oI%C#d0%js+$IVRd5Jngn?03@4bhQKKT*s(h7wa zMmIKj`TO5zc=-Yg3Qh$z8QTTi_r!;}?_(cfVRaR@2b_E56@Kui-(*k@Lae@_BQqVc zZqDV=4(pFU$`hadG*A83?=XDx9DNClI70zsi0NS@xHR7I1q-L|<=!Vh&hlL+D5T_# zXP@THuYH{?8aYB-Cm)4&GqWFBYoqDbBQZEO9+R0`UPd5hQcUw9JTt|4e2?ZvG&mRx zt}-YFxu{Z51vG`=Xv%Ww_)Lio8Z0;?1~Ktm@g6lvS&Itfdm6C&_LsekBq9D+FgOfV z87*|V|B;V!^1g>yIC_GyD(9blh8Mo^HLR8)gENMa2;;>?KK4_e;>@G>(OX{R#--~# z{mpOj#`k}~YB$KOMu^!w9XqAxlVAKfj97l~2fxjN_hcq87qd+8ZiGr148W$#z<9dH zj`NXEe1;=;-^Y!OO@92%KjrERFL20OvZ9W6RwaP)1i3)c&Zd>!Dx8QZVSx}hzm;l@ zG=SWhbeRM8`|gB*{TI_PwkNpyvY-FJ<@C$F#AW%V^O@r;A4iv&`pXNBFU*>^hCjz(0WX^d!Q zJJQ8k5moFj{LF{{p*0#NbbYCjsQJ?M{!CY5FoY@0|^x zH(N;S9W$?%l(D5Tc~a%8Iod$48(%gbIWzd#3>xIDMcDjF&zhWw2i-86@xdlRF)ATq zdQt5gRc#^DX@G>}d!C->oC}+sF%>G^GKN&Y(k9I`P^RgPaS{5saVwPtqGAP7TOmRR zG!L~J5DI{n31C)Y{WUhCcHakJ#XC&fqvLq;0!us_to zYC_ACvhGaA4OLbjc!)3j>i>?F6UP~CU&R>1;Zt{W&jXKh;mRf(S1+?@g$?h}lXvkm zzxH=I^U+VTeeDKX6x{Rp!yG$(k~dy?88;l#Gg*icWZ;S`dFXRL#V`Hszs=R*fR~>B zF7}*=z1Reg`ei9e)U)A7{irU&T`^|AE0yk6mLBL4E^ztEP;<= zn!L=m)v5Yv*rp{a8@u%5olxeEaRm8R*4J5CS;Cbgq#R?kBptb`QYkUfqcHVOb=ZgY z+i39;T;7B365BVn*tmQxcBQn0tM_}E2l`H~t1WemjveKRU-(5n@Uc&Wz~J&Fj@@%N zXP$hLa~IEZuFxvwpGh80+@Yv^nj-UONU&Y}#fBtP&mREWB ziI31(Kg8KrUnDEH=w>-8hCxxVm0KSApb1%Kp9 z+|iSK?63YcPTqThH_txHxGeejr$5VB46nWYHXZNj5@q9B$eiC!h_M|fu9KPt-VRxt zWbP)gsr7fatN-_Vef3Y!6KtNde*6fXZjLV>8E2{Mt3 z=Z;9uwaeGoxv_aW2)cJ%*{k1$5GrA3JY=xE$diBVZ}5?y`Wy>c&h=|oSzKM^p^tx@ zVJGL@3(v5cWeg?bs?GWNzx#Li@XvmZjq8_q>#a9fKX!}{eEbuJp#-n}41AA9;vZUVV|Z zyH4?`&wrk?XJ2Id#&xm~)d6X2VJQJfvT<3BpJ`e%O+}6N{~?B}@e{IEj3s*ubK~aa z4Xtx!CpYoPq*z{CWBJG-3M=?5MOA*1e>oj6a&X<|C9??q<13PyZ)>a&;=yK)5-^{lV1g7=LWSo3y)YT?v2kR~YA zq|k+N2;kL}8%1OCOb3H*_}I1pviSqD9}DolU9l1~>j3L#a$?N_s+xb#qTKBl(5i#9 zwby-(K<~fa6U|N!YS8{_(9qbxFjDWanI*HfO>?a_vL?_q?M|7frUrsV+O+$m72`lc z(B}Y>#myEU*jk>-Qs8|ZL_fYs=L|94lzMb!jn97RON?{F@BfQ`&hYgY@cj-)AAXE4e(6_u;B!CAx869*c(B3d zLe4M#%3o#S^l5(g*Z&0Px@C)33bDSO1s)A3HCe zW$C9s%NPFMf5bhHKgOl6egz{r*5uJeu-)F-rFm$+OWl97qqb`W)4pDx55b5UTrn}p zMX+8sNiK8h8)tTml|`b!6B)A&A!fJkd>@us)~2RQJ&|QF6vMrr{XF-6=5svtyZ?@t zzVc<%4dK`ke)@m^xA}!%{Z;UBo65AUC4y~-xUq8(Eo`0T~zWN{N54R{6dMqz4QEpz3E+v0CR(Bm_!*U9d7n!%*UkCon6$B?&=|*X-)Pa6n_#LCU)shf4@CREkcAkA3kk z;5!Ta)<65l49{O8(~P69y~UUQ?%(B!kAIpU{MNtb`gn(xd(QBgU;YdH_{Ep_%D??r zjBjj^(c$A?`~^Pxb3ezmt8en`Z~qJW%Q+t5#&FEZ4}OHjlc#z1;suU8^bpT{^&5fx z;XEZhN)J23F-x6{qEya2_92!}-OJbi;MaNatN#wPgZ-=U$v^*>c; zH`2tSOYHLLvp%v}Q+(5uO)&WG(3lYM@?;%o#`?KEsLrjkCeztaXHnDCmgZVeDh7Qz zTf|sg8RA_g50Jp>CiX*P@BFiLK~PP= zKF^^?Kge(WqyG=DJoQ~V_dmw}?Z5iJ^Z74*k$?F={Ld^J17%3CV~kLgWdJTTX5Z74 zT{=WnoZ{B0X=%UV&*VPa;=7WJX$L9ubjJgaQG9<=Uqn*I?IiC6fyhg6VXFWn^|j7i zQx*X#7E}5bHc|CEiKq>IDtS3o@qdj}2_YN}R4a5$Ok||hnr-Q}A`OqD zx;+VasH-wV*KHybCh(0k1*)GImw9^o%>Z1p$S75y^;z3q>0nzIjg9E@G{h zB#^HeTRGhuNNc*w<8S}jJog-kV}FqMC3e|Neu|41$5m`C1PrPy22QE2}*G3U5K&c1@8iD$~lKi!k>hcpySafWY2I8 z7h{KpgHdoAXuhS_K<%|5oU4jZI7bn)qe*o2Bl>xJ?<$Ky2zKXLU0r2uZ4IFE9WPvo z)~hHy+Yn*5AlS_lG)39db^lg2sdrNOm~vB{#3Et_A*=-~gR$f2{r9te>MqW|@IyAA zd5WwU<1bz0{CB^>+s}NT`yalC-a?N{moIbVf%`fB*h75#4}P2TfAl-77F+Z-uk-5H z|CEcbyun?MJ<aQTP(P#|=HF?s)*aw#qlY^KF)w7df=NMuuTnl$4p}nk(oXKg@Pk za&5TDy(jL)T-oH`|Kop3{?;Y#?yj)$+Bt?dHds1(gi)tM5vi)7alZbkT5D$hh?puP z&z)9wi?^3b3O(PzvtxkB5XRfbV3DQ@PA%2iLxX#q3wmK#>l)om0u3dS3Yr-S?>r3a zb^$aMy}EI5t16OrdElcT=dDW@`SBn8HvYyXmT`<;dx01K@V9vA$YCD+z!O~E-a!{~ zKJb~3^U~{Q`P1M2P4X+3IF@%AzI};j|M;sMSzP44CqBT2FHrB<8II|#t#kjq_w&}v zukqa3*Es&*1E^Z0aELFtGTx#*bC~r{Ji$d@Fc4wvJ>_W3o8SE|8&7`^|Hkvki$7-Y z@dIsZy zCqDUU2BRIm_2oZCFJEANG$OxxjURsF8@zSp68C@LgY+_o8NhgH@@+G)^-g2?IEu}^IUyiGdK=T`8ApQr>^!iU>o%FRya)fk(Lf_IZB%wXZUM z`)!W75_$72zW;l_%Np>34}6F#gE4aS7>|A86TI|;AM(t9_zK;v9o7qFboOnY`SuT3 zK6Zkm_uR{1G$OYa=aize!08WunCsWBa^>Phj@)-I<4%|ELLXIS;0ijcYjh4BW8f8~ z;N<-e^6HzHdF|O(SSt+K#j9L-`4!IGeTMF0zmgkV=_yMey>T=$sI*q`XsCJO;{r%N z*Czasymm~jcZx^F#h>?fm_mIf6zhg0Q+b8xL}-*W*VEjOKPjeEVkv5rud4G$!kX|R zLH~|AxEcdC{HP5vd4hYgsWKE}8UjeFUqjV4BCR;D)fhDasp?1Cs(M_&Ta&lz0bvlE>P)!^>2|B2iCMcry&h!HP*A?kb}ep z+fUJXKO*zLV;57t5RyU6kYQ&2Ew%or&Eqcqo&`plW&>O9ezb)p-vCTnE^=u@g6a@* z>L^P$SNLlFow60wKl|$IxzC;T*`ig5q}S`Qy1I(BmhpIus>VR~0m$=}HOSN( zf5l3xL;USEH=5<0Z~?qsrKZ;U`bU$sA?^j*vsI25Ucbmy2&a5y&{ zaYLT@>L2m7-}`N3Jf?7-m6N9!cMX?cdX8las}vl`4eP)g&p!`KORU~~n!#`odemA@ z-giIJ>+{?XzDMQ??z-y)&KkTb+dDfPzx!_fumANw)EaU1p`OLG^RdpypIMS4Z+*1 zF>V;al1oDRe>3(*njv+Y?yQ(UH*=mGk@Z&8YBUPwy{q z?7oNCdg}sjfA0r$7M5AK>lB4AaibBco{xRzv;4>Z#edGv{>8t5$qX-j|GS+1);C!g zmn>&p7V{3{t?L*!X1u*!DMLmCGgG9$U1xBzLfiOrXOzSo)C#mRkwK7vs@Mya(Im;N zk-;v3i3K()-KOGm*S&eCo(}I|(UQWKm1HA7K)PNy)IXteTIMj z|NZZI{gs!n{XX5Jhp|fwTzmZu76t>>$XF?qH4InZI?v_nn;gIA3@$SiWx>wofYrP2 z<>&(s^TH2)#MvKykCP8P$f(z6YiA2<48yYEr+(oV`FsEQf5D-nhuPY^PM+s%?+oa7 zmRQRBm}1Pvl}m_sco#DgHdY-uwCEFVbYqN z3$;E2$ruq0{mIg?k5b1*sspJ{>RPp1US#$fsod6$4PH>z*hs<%p@Eugs#*H+@=Tt2^Ums71ebXr(kzn6EE36H$%OiV|$e}|I%EP$>+?#IkNOe<%*V+_!)XvwJ;lR zY%PNDN32E4UQnmu+)rn;8okH0`?+pEZiTaYQvGxmsb&Wrq^|Q8(m^Lw`&|(Sp8(aH zj2;c#?EODq+RsRJt5UlrS0W8Ab|%KivRM}V!F>R@Ok`<3OjtJVjvf-x3Xdc+EO*2Q-_j5|4l z;SfV`Xuf=YRAx*Uw#My}Q89TjzP> z&GRhU3}wWQ8L)KsF<4&a+U2YC#x6imYVpRSE#_4NiJ9t=rXA(ZQ$(A;=Ut;3lnG1{ zM5SU@5{8+$OVn+1<=S}` z*4D_nU37bkakq~>eVW16He9^G<@26}kAH&hp~DQWU58@CVyDM)e-$Ib$@L?g^Usi5 zVH5E8Kg6S_{vL-$8(ew$HQspYd)R9?SWu-fK>^vTkKRcawA~Ct2N{=xlqTp@W!Lqd zkc2nIE3pBvPZ&dV14wbNgc{6Jk($h@jSRt6-S5n`uv?iIMq%3M)^3{D8PI8@zy z`_7eHT_+XmFv(t#O_KJyS|$@9WVcfU?;ME5m;?w1fZrRbxCk})@sm?TAs0(WOPn~I z?%+ooP!7p#j#p(+j5yM@T>8;d>~t)2l>YJ(TZ0|YAryl^>QPTG&$zL@joTj4U+6>M zG8`8m87Ck8FvG0R8$b9V#}BX3Svf-Q_(^1Y3w0xgW5>qMi1p*gSYBR4h9h45-gh|h zsn6pV7J2jPB`_lv?>@x2x87iUW0PDK0*IekR6M0~WEy3G2wtU`91_2HtOh=7&^>H} zMIqI1oik5w$1Z>K=hx&tlOF{cQ3>&WA+8nKh43(`^jFtK_uE-+8!cTYCLA(BzG~_q zf{96X8hfxruMr>IK2>HXQ=4|Fld1RD%$dfXuYswblK3>++Un!Uvtjc6GeENDb6WQv zVBGh*Pv+EPQr6f%#xZ^0ym~a>?WJby-pYPB%lJ5}4dF7Gdtd7r>4wQZXf(HC^zEba z(s))CX}O}W*Xt-tY>}!Wvlie1I%0YdYwP=N1O84b={GAx&qIl431oGasreIRR^RQ0 zNbl7*Q}H-v)bCbG6Sy)>>UZ+(j+tkShPYC`X(iajNLVqpVxP62J-MKKCD==qvb;wB zohn)st$v#Q9A#l)fwC;w-rlAx%SH-ksgn+nEE}PI)*8CqE(;3_jK^cFF~P0O`=F3( zi7^@KqL3**Qij9*?iQH=`%xI)D}XDUqhahq;>H*iK*44PuLZ^i#?IJ#Itxn-oyP-N zCudX&J7Xw&OLV)dh|5t%xXhFHx^(h>1PDt+Eg6vcf^sxMvJRbIA63V;FF1D3-SiJF zbM5)3VL0I88?SNC2Oh_sJc6D*N6*29S6}C!{`hI67_!{&AviJ#86-E=v9q?sr#|xu zv>0*i&DU9$j2zow*3}uI_AfRy?|c6g(U$BOg`m)$5YIvIuE49qm}omm>C)r6_K9E; z*FZ_M==pR4zd!rtAf=h57V7gi0C*myM9U$lvNJ3gcUR~gT@RU1ceXLcFxc54>va)4 zIU(z6sft)bW(_-o9frdlx~r>Xy$+ifZ(vWK;m|`5a_!1xcHVjumixHz4o6R)=CwE9 z#8R?#`7*!$Kl~$(tRJGdaGvFE2IY`WCLCH>z->GFt4n<3p~pCU`~*+`#=pT22XS6Z zkK3$s@!gE0^iC83yt)7+h_4s}k!OW!gJ~7#ziGIkry#s3riM3dOX?H_U(FraX9o#c za8uV9p)dy1U%-n}YRS-hN+am!nP?R9zm<>&bEi{EESY{+1% z4qps7{LsDJfA^2Zt8 z*sf)3^9o&HR18rAStrB00_VqIJ7FIx3w$xcjl&%GUCYjBNN0JK6ZhT6**D%`eE9-o zFcw%nd6Mx<&(O6QYb$-e`tSdcFMs2!*c+EQyt>ZwKl(l^k3Y&^|3CbFip@=SmOR5e z=lkFI4jm_u3r>q5yOjktWu5dAgSH13xJorW&;U2V-gi!R*n1DFUC9%u5ES@m!i{b6 z5<{BA@CoU;CVFhlB3Df5#Zey-)dsjdQQvCb0N$ewW>R8r0ocm$1-^8xXVfCubcl&S zLIEU@1)4_MWP~>}K$sMAC}pGHO&6X!78CumA41AO&Y<0N=9#*@Nc#-m!#FeftQO;~ zfg7aznlj_mv#NPE8+5vvvbQmNe*j?$wa|X1OaZRD)j7dWi3}w%K3q^E;*2x3fjmnV zaPJkHNy9}fbdssEM8pHSs;y}#tZk-C{4grGvxQohYL)JrK@Y3Gn^>>`$&=O&V#+qpg@Z3s0Gh**V#Ugxt69Pm?3D zBdQz21gJJIH#I&@WPG|l)X2BGD!67^XhsC@L(1EPs=Knp5tvP4Q$~*2{X7Zu%>y#q z6wuhOK3WQrU{kd@eD9|gMPyyYIXE1RgGaKYJRXJ=$IGjRtksh09^3=-UXQ%jr&PdZ zNZvt5BLo{PjgdR-qlH89(SqUQJQWHb01!cN-eZN}$4|{_n>FtHDF?fL^1#1&_coyn z{jSMPE-GlmIpQ2*g9{;I$Ze0JR8T`vju7o0$@%o#r zV;Br~Sjj9|*1>#o{t)*nd*pv*_lb<-jlfU$y z!f-r}7S_EHjkGGcxetCw_{1Kl8I_-ea`2#nXTI zZ#ehdkH~C+k#S&Nj~wDX7El}FU5YBcbd+9kncyHihkR=0l(0=mU@7OUL$)Uq;C=8MW6!6;LDg zl=_+N)_u98PsjykjzV*b<)G`NnUo;Oy&Bnvn)WqZZ{@)2b5hEK>;t8Qav{BP&iOEcnm|M-2a)M;l7{y660Qn?Hd<)`FDRE zUU&wNQu+{)7!!1?%t~;x@dd6J1fUqp7{}@3hdFWb2w(p8chT)DY+fz6v~ii^_n+bA ze^=7$^%;-G`d2aE(ATuH8 zy2N<58iTn*BR*)*N*g`UU@tc1t9N)zX0dv9Q?|VaG0_tv#h0p>zmIH%r&bL-2~%{ybngkSeKLq*ZbPF)8--kiI3hZVoeC6 zQYDp$ec^*q-bU(AjanteX2 z-98>U;-~v>?i!H#y>0!cdO&Dn?DJHY3czd3ccY+@ecS4z@NJkwmP$NPh*_Xk~;&~xILxb zV2zsS>Pc*;!TS<1fjaGcffgkl0gF0l(s6%KJ@7S>O*eE$QS-x@O3F1=%Sb9u*ee#dj_kq>g|kG_hC z(8~;?@rXPVoDZ(B&UU!Gv&~%}`6QqCb6?_(XMV)lKmIDm#G>kO60$3qy`t^kq2*Q> zdXn6pTK5~&yBN@Z#~&5D;+QaIQ?cL8oEzWy{44d~sEyKYW zS1Km&aO#1F7<7BQT_~NC_rZ$cnb*(r@y9+(|I|H{FI{6VR@>wT3ND|}xErZsVU z3_!c3R1HZ<2#QZ8Qib?->3Y?JEQLQaMHIAjAryfs{Z0?TQf!Sl`_fs4{YCD3_(7ih z*`Mck-+Ya-C@6~pn{_aq4rZuW993vwYw$QqKVUfA;rK)Mvwr#>UfkHESU!SvhHIme zp(=Mh{1~sE`x@)U({IE;Rcje~AzN7It+&qc;y?YT96EVFAN=q~c=!WPaOvzTY(DdJ zVCXg5GGUau9p#SO^ID!7!C0Ny0IaKb2p*>fu@V#$t-&qleTd5wjT*^_m>yim&vFN@ z)}NK)yhp5|7!K*!4w>mwy1=H&GodI3Y#2Cp&x5Rd`e(TI_S+Oi8MHU27!8IdQIC!t zA{p2o-i`3SWayNWk33G9bvb|aI&SqS_O83QIv#QS*b(f?GMn2w7%TL>p__HV2v-L? z96NmvYln{V&;Q9kWAg{ku=3H5@oT^KYkcfepXR&2|9e3$^C2U6P!l8qBcW}IREoB= zqAT4w@j@rh>v`;)z>Fd_EPh^xnh5vB{znk+aA?R}9{VGOAenqaOFsz@4lFF%y0j#H z+W9Dxg|5`7r)diWwAFns{86<90#mFRLM4GH>g$j|RezY8O@^87huxS+^BBDe=#h!* z=axP8#a)?0Gh74rD=*J;XS|tu9F#?MGwzbjKHZb?_6{+?=Y~;Ae^VbR)8Uc`vKJ`{ zM9|B!c6^?6bF!aLVu>|nG7Pkx_Sq!TBjbkEo`?;aTr=LoWM@y-XZniCZkXzrYJnu0 zBYy6+^E!9xS&a#9jG-tBwzjtR80gzb?t7kb)he(qu+1T9T0h3s1HAnByLTPR}mE@0eajhA} zm@I5O&Qoq}u&9ugo}Mb3Z@tXx&-{Rq2**D2(;WUQf1Uo~GRvkcNL z9JCAoWdgkC0DBc{l<02N2tZmVnU#uGpU#x`YuOL;s`@($#bxOkTrcR`6;>8jaaXVM z#y9_%?b5S+;v{$d@~<+=b4nvfQBV#%-ToRKYp{NdW__F*oXzPht+2H*WOQSj(}z!T zE=vwaPU2qv5k|+TP1#(5ypQRwv0_V>MndLFHg&|M zb2r$zutA=8F$+E1U`YNhl7w#7Fm$_ESSqF#veZ^?BI!7i36R%BMMFez&M__uaDlBN zw$3U=G{&^eKtHKPu?AdOVAQhIUBZnVZ$9-suK(ai43?MSmwt(lKlmUEhfi_+t+y#l zPrlg4cY2Hm1zqXjM?)mfFbfNe{SX;%0kHPKqm1a2FE8{hQxu%<4%v2wrBnA( zyypJp$Vqs~GZ2JnGt;>vm^evu#>vc~4{eQrT$Dic-3ojz&aJn^j z_+EA^Yon5Zo9urHiEzX#{a!c36dR8!_JlDOl?X^RJTH=@SSiX)qq0!P>-jUrz}mA& z)RD$RWK@vZ1+3}Pl>yIv^>_IG55Gon_#_|y{1VHwGnC`Sbgc}`9jyv3Tp%<0%J zB7KxaM#C%Ucua3)oowYe7d8qW`rH>e^^1QA=SH0K+vL5R{#_@y^z1X7SY2i8N2unM zZivf-CqD6cj5$ns?g|TK$?&PC_}=O=pZ(=8@}sApg0mL`gIl6J2xML`DijjpUP*Mj zt=-Y@9Pp|df{Al`W{j)~t)V&#Ofqy(+arXafQtqfXXopzeN9$O`~T^|HDRPxC)MiQ zNNcF7V@w5^yr~jX#?}Zy+eOgKCZ#mnDg^FIi=2}FNtx$km7B4B#n0F8Qkn9&(zd=m zcR#IMER)f|Go@fQ-d#KGm{uKApX^3zX@Q2fVicZuUaNkIk-bkc$c&Dhx<->XT#{3C zwtq;Iz14I9r0Th8W|rB>c70b(&zpF{9u=HPZ8R}pH?^N?Q53Dkf4g14_S-k}CP}@iHLZfv zG{P`(d<(LitXr#{)Z?6hpLS3`*LZK8X{?n)rC%N+cRXTZ(!R7|*y>ATf=Pzy*_{bZ~iL1F8s#-_}@{y_9}@)ymFzv(-o8bmi@T&gz=1POp{e2T0+Et23KNyg4k1_aw-MB3fLY6))^#l-tVgW zUTohfp(y_CMq@TFU1$B!5x!q~GUHiY>M<^M$V^GUZ`s-@=?V<4USm`gEF3<>h1>*p zTuPkHv1^A}T3zGyAAbY3x9NZAagH26&c@C*=U#e+^A|3Xd*#rv6D&RcAr9Spii~!e;V`&9yi4UySZ|U7X*Z70 zG^bPX(G?^aD~NKDAQWX8_77vQM#!S!FJ#9B;#uOO0pKSQ%`yn4aBN+_j#|UQ`XSyb z#vIBlhw_|3F{JdKp$c|!8Ae0K*RFHqp$F+5K7`-gz!fE9HTZs?)#G=e-f{JfS7B+H zBaeI#>q-VMKF3QpMwEjgons5EK71c19)E=IpIB$-t+&7!wnmDxAsGJ$KJyti$a(b} zf5dv0vpE>Exp5ue?X!i{EQa9cUQLjs8{KSh;mLA4&z{@j*^rc?>dSghw=wH50YG130H|TF;+DWbxXj z{7mil?|{L0S9i>P+zXYrIY>*?s(`nqjuHNXOg$mC(A*s)FGLK?xUdZy;ecMt+kY83D61(ep~7_PcfSLaf9O7 zx>jl=Tuj(t)PvNt?En%5Hc|RO8bN7hGRNAzH+5YM-&x5c&HI|c{{pk6zQk4Z;#v@_ zUsc!HSAhl_UyC$o=QCUk3A?M0(>O8ds?1=K)+?%br-%<$B#ST{6+jUJwnyb%y`u3r z@uF0F&xAy#8Zzt9s&BByq|7YuzY@Vr34%y)%#Q-Rx0M&AcP?x+)-t|wiJdFgIq}#> z`0)>Z%*MIb@z%p#$2j@meQaL5z|{-q=`Qtn{k0c(WakDCeE1W*{oKoJUAw~Adlnvk zl*9Mm$JVvCIrru(tgIj5^rIi(?N?vsso(e~EM9t>z6ySEm8(Zja^FArPk8XjC-}qH zUu1PZiFo}ECxhJ6Fyz9$aTTha;!&qO*{*bM{rTogGFaIQ#lboVf2a zuPm;xaczVvHyE$1^Uz10K-6*l+3$fZS$XUsI-QI+e&=^M`_(_@&~O{;9B=1KJoIaS zpU0kfoVR}8FtlCN_9=&3=y;P&H^3Y}%G!y$c>cz9HeY<6tQfL>-#zSfx?I09%ZX6*xKBL;SM)me~G(4{2`9t zdoQoQ@HD9$Vfq z{MbkNh5zIqu>R0JTz&l|mRC=(vU-fo%?&m-ud#Ug7>h?w@WOY$!u5+g3{^OG|9!a3 zvbnWcCuyl-dxkhP#?0P(_p3DQ7B=FbipT9VYDp5&8#ZL|AtqwTM(r7lhM029xEzyZ z8Ax=KvVo8YoM!!!PbyhUwf5DKKv;6eDpD1 zdi_O2a)vu24n1@?hff{n`uR(^VvO6m#@jDH&+0v=>EC;XOV9q01*2^B45#ltj3{W`JpbiCp_e&qZYXRQ^K-w*V?X;#eD|B*p}h1a>&K6CxpWlQ zhZN&6IHlL^;*Dctyg_#O6sJGO)rg{|!!7K|mVXdCy+l!3CIJHoWC@_sAnOsM|f z{G;;92>OiE5J$?#hR<5dUVSB38DkpDEG0Cs(Ey)3Wa9eJq-%vcJVD+{`jNJXNy%^$ z!WSgcfb%W{*pKVG!8xvskd@fhbR7(hNJ?ZCT1LbKLWJKXVE3#&t_eWRVG2z?qy7BE z>h!JeTfQ-Kz^!C>s=c?S-!Ti+I%wG~?p6Z)kXia`1AmeF?`BEi3zWUji&n3Rv|?Y~ zt~TxV{sf{;lZ;}59HbFPy^hOPg%JYm&wc*G|4@wOx#wOXGdWeXw+h9qQRTYR!skiM z*)DCXO)KZVd${rb8sv=bF#CIsZ$g9(ggF5r2OZdb6`g|hc6YT) z)XVDj#=@)iCnd%7&=!TeF#;2lf7eAvo5I?fuoW0JDe_azugUMyhSMD5ME9&i`tP>b zYU=Fdk0eclhR_5!4QYQv;6$oGdNf>q@PUEnTx3v1wA)Ab!_WsVK2z|L(djQ?@-Ek| z-(ax0jrAcwcA7$ITy96%d#=s0R^0U4>OLi`SOh#V@T3cCLSle!HHPhRfj2q#J@HXa z-1`vHS!DH|2f61HpXSWz`*{9OzsZf4p5;h4=i;TyEG;bY;g5Zs#bd|NUZ2(bALJun z_<0U3E%3sh{Ch4v_bg`~d5lm0^cQ&gsULCaxgT=(LdKHmu-IMX@{LVSp1O+vnFgU}jQj;yn^*vAb97*}8;U0X#lVlYGyTAKTYR_i`l zgc3WU>~t~xMRsm%vw8J;VC*FUD^06C+Ba!6yi?00^Qw797;7oibA5AzyYD{3=|>(w z78mHPALsZZPw?=QpCT_j&wuT!aQO<0ogNo9Z}8wFk8twX-DrQAV~;+{Lm&GjA9(Bu z-unLc`SHL1Lsri`$m2ir=h)e}!E=A|M;smxIhI*gO@~gm!=-V-7k=UAxctgexIINhK1+eC!S=f-{a}8{RtoZ=qLEg|M7pucxRhee)t`Rsyz7N z4|D3&eQdd$qmMksM}PWfxUqGEAAj>JENyPkuQc(!2W)cxn)h=n&zQXSc9hAG9Eb^u za-Oq(9@H2(u*v(5!bI? z=Dx>2$bFAKMz*lR(&-0y@ROhA@sB*o#j~&Q;vfGB{c*t)zwkxO;dP$>lRswR#ubkC zIxLD{Mg@gf&V1_As2_9odwk=Gt4xlTNS#P;{uv~5{@%U)`|Tr$ zqSnOLAT$745kx)fD=TQ}$UNF$yhN5r9L|Jug*;=@u(7Ab+72|HVhW`)&&PAGF7;CL zy*y1cI213wu8qv*M@(X}4FszNfF$~=nZBWcEZ4R3F7%4Eft}#J3&JVW8TOj(tp~D= z+E3McM*k$-%uwMTyLDlevbY@wtLV@`mpdAhD+C=@vOEMNOzfH& zji?!R^O~9_>Er}&F(VuEDS|?w4Kd%HOk3Df;(;U_HL4UX5Yc*I>haOE`Ndr_0F^<3 zFc?a&7#}inI^scqsn}K`32aU4CN#om7&Ftt?Gn}YPRUbulcgz;P_I>o(lI7jUcATV z8KoN|;>m|YUViGkY&p2=k;ggnsh>xU;L9P;edl{z{?T_?bYqMPcT3KzzxM}}gAwZ= zc$5cz{)?1l$!L3vZ~U9zWcc(CIbu3=@`yGCbWjn&6blI6gILL;7uW@cdOv8o%G7lD28CS&kFT=~nRqz*n5~5Ru&U`;Qa$U>Qkg|dcg|t0 zWkC$vZ@tae|INST{?C1eyFc_1GP^+DSzxre&5ytNWr{c7Vztu;Zy29_ji-P2cX;#@ zU*P^ve*vEhW;EcHZ+?fDzV>C7J3ZD`jxgBtY`pzC{bEE%ay(f$r?i8=c!^iP^-Wfm zmf69u>4n3+RkU-6_3jWo`y4;|cfZTYPk)i4ANnNL=3Kk-GB17ekFe+7=BP1@cqbE> z@7{m!)fk393`m_WJbsC21bij144e8`HK$c$+zqJ(vZ!Pvtv$&z5SWfJ%`^kZOh%E1QD|LM0m`|WRXgp$mc6dPMS^@o4RV`Dh;Q~6GU#GXQjEo8{zVa%?#x~3SRXmo#xWMKeY@s~!AHL4&`XZfPpB)j- zzIB0fZ=U05zr)sx&-0z%{I@*%Q(xqa3L9Gy{B{fxX8mtecD??T&NoGEOF?VdFaYp1L1VWyyM8`_@;W)8)itPvUzk_`wd}``*)>{pPn> z8H5;iKH>~EGO#M@g9Zc?qBiu@B+sb}6fGaYwMpY!C09;* z_$2qQ3N5H>R>_6&>`S30Dmjwb`_t^X&_W;DZ_!#euE!|-uY%13Xdc1T)Xv&#AY{cB z+8GAtl}VP?ym_+QXUSu0W^A=Nb(o%i6F?Nxa{lD=X5Cw{%4gg++2=_&ZjHkpXW7hg zX7a?0Ky8ZHkaUF#ZrgH?h`Aw?tlZmlwFj1?DwZCu1A!;nRCWx6*y_$xCoh%I# z{>$x{UV@2fWvl)Ws`gjbjmS1pUvKPz?pxDVLzcE2%{nZuA4e8fcwxbpg2bX~}3 zr;Pyex5I5@e?sZxdc6f#k(7y4B3Du!UOXk1OmmDQ^oR903d9&nGPXTzsKqX?(p_95 z&vN{53%7ZhMK@ppj}%JBcH+~BBHBykkq0IYahmJASF(WqslZVV;!vT8Z z1`C4`wkWaIv<}u;0UqBAM$ja^d3Mr-{byHc`J;C-?tb__jvra0xPFz)jj`TE>$zYI zSoNsHvmt@Qfoc+-VF^lqKsPBCR$%QYm(N|{truUVC`M!=s0;BIFtanD?$(C)8sOMZ zpH}-sWNcb%aey(6&8*hrqf>}GdVXdTtly1;*b_(21<35x#7lWOul%8&hi>N zT2c-GJ%@3q-+>Y_N*=O7y29`w(jLVYc6GwUU z;~!yo?Gj6VL{3mdO7YmtVl^lT2>`Yx)O8e2?Lkt1NaG zLbgJA7bE500mX{Ni(#9REwN;4N68kKu_nXsY%;ifjzvFY0f(pqn{$0E410YRj-4Ru zWb9nMfV**>B@q^~Zb-VadYE!ymGbI&`oj$}b%8~wmZ4@0J6$9bGCyMIpft;L%Mpuy z3onN2g&{wBjO7!@84ZVAzjz+mzRqf9=sF)`!qtqT{g#^nu=W#TVz2kz(wgv5b~Bbz zJ+jc{fkz%@X?2;h9Fd`TT%^<)P>+oYu}WfNgb)=;YD|no6w1=!Fm&@CufF&)=U;u3 zj@Wm6E!?hHWw)g+8iQ?1#>hjF30pE`)alSWa*Xcs8om@pSFTW8xx}gpOCneqQlMND zOWEzQeDpXb@3OTsWar8``o#{bHm4&Q*~yb^q4*0ISr`|H3-0@Bluc{NPn;&t3_IuE zM#)%QIYM#e9DTRLP%Im^OZVh`*rhdwSFbX-bdJU0kkw8eW#Na{*Qx&WBrfx|=$LFz~+LL1LgrQzk!6r04nVJfeH758GM?~2qR#y#d)+lb8e7@Ny z?S8G?3XG|#XRQvY|D4*WnC`|_Kd97}UYLq))$9Y`UnBOZIjy18Y zn38hyBIUKqh*LVoljR}1r7EZiSqB$-eYz;)3+Hifk3f{;HmB3c5Im*#*v(BguWiyX zIUQ@D3~7LkI(%8MAeQpVCM(7uQsS>(r<-NuCZil|L%D&Tzf8|Kx;iH9QAPw~GOT8( z&Cm$}`kivsI8fiupbF2c*M2PelVGyJ)G^|mkB3p%phPT*el8l4GrI;;(@s1)9`9j2 ztH&7dCeec9e$z&;=eBp==XZkgv7h=SAj^t?I@YbhZw@G~zrsid4Arn83v_JAO4WO1G1Oqq5gjgx>K@0DM;!*I9B@8`r z46eL|zvxkv+$*b@#biS1Qea@^XeVPd-eK_ib2wkJCeZ10Aj@&;P_dNPF5xa;q-#xN zL<{O2;OQDqRus5VK-(8ZP}u+j-dd~|jv6R0on>(DSwuP<>+}Kx-}wm!>A~82v++|V zt?BzUOxzCURZv7sloyq()t^+#uQ4gMXPw}u9fX)lo@yEr6$uP@4enH0-TM<%1XNv+ z$8ClcWyNGL9^uZu%-EHHVbSLF@(xxr@JbPP<-^iNcSe*i{RrnAD_M`#eh29paARCq zFnIMP)MVtQgJMt{MwDFWI){7pWkwFVc@IGu-*}TwE_j>M_d?H??7Z?kg)dn`IBG2h zi!a8MHh3x+rOuR>7JWEK1_Es#P*wBh|q|pE$X=oCehyeJQ zdCn`tot;X@WTHPRaG}9QYQ~I9|F74J)(YGtW2m99Hu+4oo)c*qk+dK_FqE25Q=Mo_ z+V86LHtKjzuBa5%q|e6+H!EL(7*LZ==BRNkLV};vqZ*79ZPu0MB-G|>Yh5hW)0eF} z3xd}+n5Fg2ao-E0>SMCE763)Nin9X3&yvOGGN|U1c{+ejtg~tFrTNdAW@05`Mu|)1k1ejT&wyLT$J;S1>Q#D=^T8aHBINiZ`h#fs|nL&sQD>| zoz`vAHp+an!qL=XYEIHdy~EV2JgHmvw_J}j9TwF#TW>D-`zU2;tCcc;KG3JoF$C3n6Q$)PG=*zm40lNcM?3T{^93G3odPPb{E(RMUaE$4Ic7YNTYJfvgcjuyuw@oRT@plt^4@|_MTp9@h zNyDVpiWM235SKt?W5odN$vs!BLDZ9r0RxUkRvVK7!{59RdnXIQv+5SCULfk|DD<-o zXP1DmR-7+!nBd;-6tPj6&n=nlASS02kMjjiJz}6E(91F~7DaIg-axKO?ghb-8-t;b zQKf64L~+$d7Zz+M%jx7E5`QSDVs_WVUQPcQ{gn4Z07)(k4@}|b62}Fs^tXtBr&BXre_u9aeRD5>oQ5Wo{3g^${=Rn@cGnrdZr*eAL> z)g2QHQs=fYzEcQUGG??*8)KQW7F5i)#sDZGzEy5Bx_Jk$o}ws$kabY)UUi@Tygg^M z+7tt0NAa!%RcxLGphT#HDJC}GL0oYUP~KU$wzQe2@dS z^gCCHDaj+?p`HL{NpR7z8sch+jaj|C197N$R9s}h#pg>z$Gyd)6pSfth|4Bku-?S5 zGhy#jadj7;v-NkxR5RK4$>%Z*O>0MKPH7e&oZ1VgL2|Ml1b%8QeCUQ;=@8@ z7*$4)`WH%I-j}&6EL<5Q%vZNqt0FV{IX|MuY^}#I#k9wi-!EGl?d#VY{I6?3tl+``S z=wYxbQDMI``*&Ws<$W*-v}kY>M99Qo#o)aQ&o>rp46by+HM5eb!Xv!Ic!-s39$6|X zIPWn)x7%TmbWrCp_P{lLAD3|cnmAu0ULN-U#E2CeE#MhSj`s#A!&YDVxbIs~6AT>c zusECz{VYBT#NJR^1vQcJU0^(D%7|>D%&HDAiW7rq4w``$2tLF*Q-wVf0M!cm`=#u) zeiS9cn|gkNZvvO;M=Bdrzm^t=@M#%WjI?opN?~ zlbGNR;{@xoDANRXlWQ_x;uq4A9 zClMSo!5!552sp+cqflzd8g9j4Ox$C|qqy2)Pg5tH1{s=JSl@TWE0Wt_;8BN-EGlbr zB+GE+n1<6}%sTEXg&f-Y&y-HoVk}M@zbZn)MAs#T)2wrEUc9mC#9Q@$H0mmCW)!uo zKF3|I#W>o>x{{b2CIz5x01olJ=~1)y)21%geD5qGOViOQ0bEo4l?+!+Kxay?Z0o)* zdL%UK)Q*={gIM#xs9pPL1~F$=AEZj^F8ws!ZwUmI-S2BnmB!xLFh)y-9Z8M#RP~u? zL!}QllnM#4LX)wy-aneViI6pej3&LmW=J&$wVpHqFo>~GVg+U`ib({yHDKaf9o$6P zb{o{Ncp6c@CMMB-1y$cn8>x7bIF;M9fSfzjGNIcN4We)}o!xJ#SFBS6#m z8R{gi1YoI#IRUA|Df*zyRk7VPXlj8qZ4kB5;v?aiMncMM>tl*wK}`THy(k3k7T$z3 z#YTf!(WOE05jYGgXb{eVgIwS$rg$@A!gn!AgjI>k%sbna`|k30cubpnuJfL5r%R{P zr7VhQDfGBVIq-OLk=k!Qfm9+D)!d1Rqh3dO69rck^!t6fi+u(=qnZ8tevEZ>GyRN~ zcp~qw!IloI%OezN=fQZvYP9M~eLfikD|t}Di2#$72m$yt8UV#Y98O|-;s^}5$fUGt z5Jz~fc+~hR6RX9dYAEHs2d~}>fU-&>F3Qr%5?Yp6QM?4;$ru}BjWmT}0?=#IIC~P; zwu<*6As(XmU`$$GUgr9_x2tx(FO|BRETWMipy6*7k5C2yR>CY-A5%$)VAK->QG(d$ z*5>P2D}?bHi^GYclHsj}^@lnB0y!x>NvUSi6k#`zPB7#7tGCl zs~#`m-URxpn95tg5ZG8rW%NL4Jem3+H^sBV_^_{)CV^y0kE?@ZOJY!@ey%$F&UTpI zRSy$^GQ|Xq$)56@{^CMoZwbuAV5myZO#eh?RMKT9-TY|YFhg&weznOTEuErbS0r{k zQ}R~A4)zn*GO>}H_o}HDa!u5T|E;}~OseTL_e3L0EV4TjnB>(5<6X5TZP>NNw}6w$ zb~p93S#qRLfYgMZIL&g39$q*gc-AC_+Z2bUYP?qRRjbb?z^X(#nHRryuAaHa{?r=7 z$x7-Trhud_CFLwJ(;9nu81MRdOubI(XZQvQXv#ycxd+5wYG(9ly2iw{)a(l|RnRd> zoic~vLLHcGaw6U(kT@UhZ^x#SNugR9|1u9KG`ykDk_pHfh?geBe z%Hu(3&`osS7*qYW)>i+A-4%uXf`zl{wb%cr?w_$;&3gajW+XEcb)m&B#@^XE%H%Wab-zS7A!3G>GgWS)u%x*b#C`eQ~Lq%WX?wXP8FT;SM7e3 z;;1#pG`MIY79<17!32RyBJ(f6r%I-R@dojxx;Fso9w&;|G6+33fJ*@~bEp)7(M5?? z>c0stg=(;+khuV^AT~ZX?iG>Yl9V1y4a^v3ocQlznKi!eMp0FkmzL>vbIPKu)?Zl$ z;8&xtY$PRg@E9BR5cMgIb7Y66!%%`yR~9AxevifFWz;7S!0d}%wV%FtFXE9MwA5`$ zLSYDC9e^nbXf9F#;%dSHZ#5{`np~B|W5gp~G0q}BLnT8ir6(#Bl46d9=bCu0Hx9&O z;~FF?JVqeIRhC4BjBJ$K&4;+TzB^bS-~4d?xlsKD!-Jnv&)c^u61nt2jg^H9h&)04=Vk(BO zMmMht>>L|hZ4&bqqF5Hr|srP~sV4Yw25p$3~SwTLKGSq7xzz0pJ8)5kEJzfomWAey5z@ zyXrq0Kt$&PqbkcX%Ch9p`Z}wJ))d|zOY?NIS#8;C z%v11tB0Ds*S#X(7dXChwz$-VoS-{$aKHtKaX6USY)z3${;lQOK56${%)uWLFt@VAy zf}7CrX4SVP_b|m?tFYDmtoaHbtu-?Zuza0OZ{7f;cWeF1G{p0IDzIH+Op{rt?c|9R zzK_pWsg<4?1K6eohQ>4(a>bcB&_>umkf%V2^#5c5oCxR~5im~>{I&j~zGkAmdm^bb zKA!~3k`R5fHjL&XPm3Z54=w^QbKD_GKx=Blbh{=S+@xn7n6j;rf-ACbw#KaQQ*5-P z9HfPlKr%DA(M&#fMqN!r6@x3UfnvapC7#YTyb0p#4nULjCFpc<2R0pY2gf(akA(xCrr5jk0qJhPw zAlf1F*W+>L5Gm0ZJRAvbw1NTWDyw^8eC?J1+@j^_wv_!?DQ{m1?LT_r7~NhEw=p2M zHcb1JSuiQzCdBQ~7$1%a!O(;tV$|IzW>7)O%SHkZUx=QgT(ShH#!5^(Y>%1|t?jhDc!BIwuj_C6WQ9 zVq_ULWRc0}E4xyd>u$p5B;YM&%n+$qRczUaL|bK|mY6xwQ~;!(rh>#(9d*JgpXWhZjXJ`va*Zahj0hecpzW15-jMA?r?bc< z!}{Ek3@h{W?nd2RwPVK5lH#jL#E5N(YCzK<({@+X$l8*|4pJxgv9x8>P)!@P-u>&& z%~B;@+pAxNDVUGXD286T$%s@F}0QZU+o46 z>eE6?(4J`_T=QH>rhlftyXDe&mTRpQ!Svo;+cW5n#;?>EPYYJzFb zMH=!hi$Qmr$a}DfOdQrgw&&XM3osBzqV4VVWvQWGug+IYs^){u9!kKZBiDz$HJ zU&ZS>{SIquYp9m^a*Qus=pSP$8oE=Di+hvz;JvFrSv7)07pn?r@jjkA1><7C>e?!c zYb%rzT&S{LK*HO$r{7^Es3B=>1n@N2^=xok3Ve|muO!aLYV7KBRt1~{%er?iX61?f zDT(D7t!cz0Cnnt+V$wPF4Y%G%o^0WupS!j58emYHft>=^;vR95F5rjt|g3+;W4hD+sRm2U&o21@KiEV9oJDB zzyIu&0FX|cg(8T}0~=66=0H^!7(3!oDeO)cV0WV~pM5gPso z2EL}VnyH?V`aFz@@zlD_gtrRKnAwtKv=ArMsFGD|EgKm0iG7k78{sW2f`Kv9WP&8N zsp=iOo$bX8@!p4Qqz1MJ1Nw`LEFM~8$CWr^DQT=3)!Jie4|2losBbfVR`cgp-=i85 zEL6Ic51>OGl4e@Jn-|ul`aV*sW-)?h&5ud;+Fadj7wllSK1`+dMhau?oU6zVEpo~( zY^L4Ds9B%=%C}8c+kW3U*|u5ZGpPg53~#gtdutY3Za)UkxZ~FuE2a5ej3=!MNxwGH zl@uRhUR?&t^$ENWV)zeG!rdXUgk`a=96kLEC>pMx zOHy*u_QDL;_^K^4ff_%tm@4Agq+l*cCOGFP%7RX(69rRHH!-kjfkvnP)^jE(gY(L) zMZT9G@AP-=&qRV-!>qn&zN7js*1hT{GY#;}TP)N5yVDC`7VE72EMI+}5c@347>~zj zL&24(B;!o!Y11abpT}_>uY?-YGh;F z24gLQ!4CPxHVX?YtQ_hsF$t&JrFt<@AaqMDWQcU1-7`n);s_MCIK zV$oDnWz!;MutBLZ&|%`bFjTRu2z`|l5!6VO_-e8{@7e z*eECtKJj3&o|-W-!FE(^M2Qm{E}l8sP;eqEGQF!d_@uRE;@T+d&Xr^hohS#CfkoJt za5nXti7}kbz6}*>A4xj%S&}k7Y5Vh#gDc!;0<9;2b;dAuB?AM;Pu|7K(kjKy7Cob6 zafupZXhu=2WC5d~v7alY!rA zX|+tX-(?2Cf@{j!&;ry-!<4guV1T(kUD$35?5ueVzLK=KR%q9i*?R< zRJ}>H%A{3+QPJq9#s+217>=L33lS)a0+U&sx_TueJU)=yWN|k}S)rdRc36E}l2Z0M=wqq%(e2H|%moB*Mr%U1F-6 zTg6xV%5J;fEI?rrfJp35TTvmBo+6Q59;ZSDrkaqU5b#kZOED=WG7WvSDg_pZiSc-n znB}q2-BY5IPSq+COtV%0MIEkI_bM(>>%UEXpjK#OD^fqxymo(OUk3Y1Z=G@fjPYxE z3dOzLh{ZXr2pJ)BX2|rYasFrB4#&^j4gCc+MnlFXi&}HY)@-QtKoLuu5l^xEv;N5)Iwc!vzd7yajlI6! zSR-4N8Ed9d>p5dR@3VO%jL9G+az>jt5_fQUz$pqrY?!rdC5=5I9(;o3wH ztiEOLN{j*7D{Eg>-}?Q#t>X6FnB1$HdzMUm*8FWI-aVu5CO%7|=&f2Nt9fq(Y)B7Z zN^qrd>O!V7fy`RKFd7$>*06MFjbq1-;oJ~$WAdorr9(%366TWSye)uE3Q~v#W8<0V zP+!1!j1Gsa9a`n+T_-3A^FPEH2%!;0{%O=X^hJzQLQF=`05^2z+RH#Q@WF?(z0b((e&k?3 z6`+04AD#HQnP)VfmG->pb>%RmST2gk;Wf-h6+>@%nfo4mkYY4Oyu;!tiy_uXU|CVS z@H8i8Qly2L{RG?+H~E=5I5>q?Vh zBZB=?fa=`(Cy;Fy`AOSzaq@Rk7H5^I{q-hG^Nej_;!(b7%0XpOVzOW`9C^o?d(N=5 zy28$2NG1|p?SpIcIq_Su4H6Fc1Ex<24zjQm<1_Mt@GIE-9Vq+ z=4O^r0{zIXDzG|Q5qf0FPpPmXP|cMzt!K?5&#vL5w)()#}- z8)#plWm?3T{XJtp`@;PAQtju?(@ozAoM9GI?I7>i4WroYtZM4bP5rrIxEZY&oWbkH z)ML{G>V%khEB+rB;Pt77RsS{+|D^cNvSiEzVD?fE_voxSpU+xQ&ELt2BQs91Nfypz zCr^Sd)@C^87>!0)Yhz5W`SsduB1|soS#7!5GHU~BZZ5OhJF_om?4$c}37q-4O!xOB z6KcBDKxdwJDGJA>OP6uJB+oOf7|LjEOYV+&o>zd^yty&Y?|0UMF3ZS3Ffj|=3@YqY z`^AS;#RzIVMQlR|ChO>lFxuW^kY}tMKEjbxCwP4@WH2aL$QLO5m@E(Jzss_uQ~_(_ z0UbbwX_qfwD`cdgUxF}TdDS&;lfToL& zY&A84Gr_G!V=q*!1s_yplVx}nI?IckxbFG`f0HK+WjIo z&7eyCwqK{iS*vM}ZrkT`qadWj45Wa#U=`OGrdXt6VWl_=Dh7x0@B{bJHHOlc7nK`wXJ@+Nvv+wRuD?mBgnyYD_j>4T)>)l>$(?r6m2EG1ch^QyPxx8)Oo9-*F>GelaUPO5 zIRMrMKw)EJqhbZ@Ri-G4viAa+H$iUM!q%MB&F98$IcPMKz0lV7#k=3P7uc7s2^Lon z2?0SNP-BA?j&J}^p#FRH?_8g0Q@`6xAn~bQ^T5BR>kk5IYImRzh_GZ-7LZw%msb#z zvAsE@7?)(mgt#@{$G9jK5=B1VAB2GzpH4Jn6l<=*q@^~s)z(<(#P9k`Y5bn`GkHxV zs05UfbnrA$bD3axh3{-7L`*z!*GVL;O=-VBQ8}m}n+Z!~>ZO}u8P;MhDS-nM|4yNC z2daBhc$SIw@7`DK--+4QWJ5{J;w3m&AY!n2#>QZW?&2c%KJ*~#M-MaFy2g0xI$eQ| z2|;5qzVi4Ek2Cc+im8c7$zs`15>ub4D&l=u6aRne{;b)yBsmYnK95*?pW%)T{oU`{@v10J>aQ}R&6;uiL9zMYC@)DmvzrykA z3PZ}sIiXfY;VQXp{GekG%#i7+{+G{`g)6QV0&!ux-9y%Ar&aske1EO1FMuYrZ2hDY z-!=MfmiBqS*PcyYDXr!2IsIN&xPytR%@Hkz+_Vz%IWsvxex>f`>O32?*|pJra>!q| zByYOYPg;BL>^%mwN_DSBLuNUa%1{ab4Fhh*f^@dSSHJ!N-hA^_Ts?n^U1IDA$i&#? zgsBLS#VjGv!uRBi7)U}5?CeTe&oJ}pA&8Mp!>bsC-F}bC6^C)cgNF}Lzg`2@?q_6A_E}O3DuGmrC#{sW_AEEeRKr}^ zeumD?>B+75(MV~uYrCGp?;ZTQq!h2Pq=e_5!7TiRlxk(2YS${kDhwM)xtPrM{)KL8 zfM)8Hgf*&OaQqcH?u%X#99VQw-D+zUO0_Va3dUFpUVH0Jy#K*hk%o-JcmO30qva~| z8bXT@&K%MTwUtBO96gt0J(7khO4t)aD!W+jG$)ok>(!1Gp~qM&xJ6lPGNH~;I_VapI&e+qtf~XiKGv^TyMrGe~qQAc|+ZK zyucTF)`7Lz=WVa>_Oo_A#p5=hxlw{85^q7N)J~X6!J$kzfAA3B{5!vm-Gc}C{5ayk z1tm#U;ZAf>s^Z<(*PxsM)Y zgwu1rK35i5bnDc|R`kbgoOfWpPJa@6s`e;7VafWW`L9v}C<&jh1&<$GqE2H7a9(NL zm=dG8fo`48i!t+ho7)e>`ygPMB{taVUjUre6U9ZgRyUuGXyF{S7G^*iXMYC|4%h2X z_3L&~q?@a>0n*(CB&7A_5T#<0ss^7S?R1Y~J5vG%VQDf%Va(qKpw_&b0}%>u4M zK4mAsH7iLx8004VL9VpkO=Niyv1axXJ2ftTo`9yQ14lWC3Pj_{RO5n;d(mY@oTT(OYgmh2QNLu)$^y|=bvLo zjC~>joU+8omnznBGRr!XJKAwkJ-naX;YEqeuAa2k+zO z-}?~HKl=>F@rbjtGY}_C0O1alC_mz+kJJ7}I)mtHaTf$KYrAz>ixPN(u zQm$}QM!fO$_c8AGc=FR<;CP&Hmi9oADux5?WG|3{3}kYeoa>yeVzSN43R(DPQDe36 zx$0|WYZ!Be*k_e%M+*YZSk=+GYV2%~HnnGAb4H;pTU9-WT~VzLel;a21+NRV`KMrH zu)fsDpQSG@-`d`f-_Mkkj_ZfHSO>S~S3?)&bu)#-d4YtMuCl6IuXV+@UA1i?09~#o zhLnm=!e3JsulJNQUVG&geDycJ0VKjU(OBVYb1M@%e^2=|Iv~HfWWn0sIRljkGk-_+dLj*$lUmpPXllEx%GY3OsAq2 zb?y8wclFg?tq zdt)s<$;1F6Va73CLi8qzy>hQFo$swVu$*m+G5L8SJZRkkV&+wJV%|+w^J1ZkIb6@7 zygDCuSl{{myu2~czwGnP2%^8G>r|?uQ($e(Qx|E*&*E^#nxty?4;Ht&Hf^q$T4H8% zf$o&gwShFD|BHmn`LKBYmeXx*#R>PyG%wnS0EPh}RTU)*C9IWCjv+Ejn;zHMObFs$ z+>Q&lHP#oMyBE2ZL=RuH_??||79z%I2j3=2j5(6q6YD7ut4sxz#o`giUP~xJ4z=7# zOh)K$`2LO_Uu~Y@3P|+CYPpt{2Y~!7{WAHu8Co^rBg7&-fPv|N>rX$z<>Q1m-g*_g zi#>k+;~(Ja+2`Qth|B#Mb~&Mp0%8#*6ihaw0&2mE7?@whQh4r%%j-WG3bK)O}2x` zLQdGmIai3a52}bU>dJc~UCmv9qw3R`uC!TJ`#Fcc8Xz(!XSLe97fb-Ux^dXNYK{@N ztcUS6Z3iYJ`zE&J=^wC zPq*gDtaNChYM#d`>v9;KRy2>Ywp6fZn8LkteqjX{;%-~cRU&GmbMG0iW!3*b3VseG zL_rRk>>LihbeqY6!w4X*fmlFO#8!S%RhqP=NU|r8+u4>f4t2t@PI&ajtN7Bp@8bT$ z2RIzBurEgtRSY?SYD`LNc?Qv4&3Gk7FSmP92eko2NI7dxIYOOAV*R-%g(vzTfihMY?I>rTXsROu3fw!FNIDwMGtV_)ZDOh8q?*=BCs(6Je^l z7jwd)OgK9`!#nT1gE!uO3nNt=$0JfUV3<&AL4km=wq*@Ks7f!XGHz~e#Neni({l8X zR)y5DC-p96;Ys5(A@35N9j@@;rN{W@Z~qp4{MX;dlTSVc*9rS!0CMW0x%)kNw>{v- z=0GmD*`36D5*-lKyuC%LntxB>|J^6yj5!S=HHdN^z&=mFy>&IPj$B7M70q%N?DU1S z6mz3gIy{x*$vsO}av|L|KJL!mwHw>K*;!9$`-!!Ulrst#H-`h#Zoo^gy^42#qeT^Hg7zwCp(6HMCBu26b1%C&+A_FgWg-yM6N0^`eeviZ90A}urAFM@|tN~ba z-907&kBPu1bt34-H0$SWpz0|Ar#EgziFrlu*3VMhOdBN7_%~)cq&2x*quatS*GGB# z&bTeqZvOr?kFAr>(}u_ zYlyvpQ-QecWr;*iuKfmyzc)0q@3h~?VKLdjF!+HkvPPXv$~yQq_r?-{VtsS2#mlmW zsafu;n3rblefF#|z0RWTG4-*EN3YwQg2z_TO#@79y}XsXC!aIVh{)8qWf5)`SF<7n zaw7yrF`M%=SH!K4WFQtdMzcWB6HtQ{%RsNB-a-se&Nxn^Cnl8m?Bq?+%0YJu;ACbb zWYkhYsrovZq5%X28FQwQ)nZBjNt!ugpcY-6EY*>yGWIEByuQYD`3U>Ruj28m53#%d zb^QA0zrfR9euU|IlnM(ZRrx4MjXyy0^5n4hd~WWv-gMWTWWo6g80gw?kb_xh&SA6z$)eSDD5tJrTAed!sYsFLfk#lw2xTSIuK&j*40N|~L59dY~f3I-2s>KT&5vU|vypks3t=K=jhu7YC8Lz$ZI(EB+C&arZWAK#Bvku*B_xRSHlSfuM{;?6Sp>2!(~OrUX2^*drf-=f?vc zzxFaN9zDP>fAmW{`Q=BzSTR-t(SU545fxC<;vrO#qpfF!(oS6#mfEd<@OEm-XZ&vV z(_mmnu%dcl`*HqE+`qA(j!3WObpse)2gI??e&3pPsu>OKGpW|LJs15cBPHK6s>wne zL2m=bNWmA1yRQQtlmz_*X>-#IIznM~IO<-DIHCPyNIuLc>=Tyn_OpY6EY>EIVWES% z%M#(0*Ivgv?|cal?%&5@I^t$Lpn@^*4$vrtoD)i&P)kLoLGmQlf||q=of$Ze6Ap*t zia|SR4?kw_wbG1`WAli6ESpLKYNY@!Y;M2t)7S0 zRgWqM6c7}p?w&h_(=6m_d8h`1UfpWP_?_`Yr?V5!Y+M?_+ZX(JfomgEnDDyb9XOxg zI1e(HCp_UaWRyT1jczHqE=RdO)%UoC56^gvpOG_tGj0g9`m?ho@q!sh356NcIN|K# z9FHD7!Yi-6iZ8wQ9*)BvA3b}H0>*(0csIZ!48l5kb@8MU3%9x6@=%GNHIT)^m)G-H z#b_U9#%G^>=3#_ZO?~Z|wab7iyC*t#tR{E2X$_t}aZIrYn*P$i4}qU`pyC|N4RToc zC`vCTgTg)bLmLrPn}0BS4fzICogv8CDH)(Tn@=*jngM7*K3_aDTetc8Z>`h({^TBz z8~2+R+ydvQo;wf#P{>f+ML&G*=gi5^Iy(*H+Q=`yW;2gA4r^-0q&WxSaDc^E5A%uw zrj{8P<-$x9U{omaM2DI+o~wZ;xcv$Y+a0@j>%Q2o5lsNoFx%)e!sx$ii`@UM?x9?} zXCl(9+R*xNBH~N4&o=thE#@h}DG0t1FkI8gh^!*tS|^h*)2{sn3TXpw5zaY)ZMAnn zOtX1)hNYXl>eSNefk&>4m9U|F9%^wy=D_&408F(@AlR>MFZ@~v;@Ui~9UShVqcE=> zdcv&WeV)fCGpkk8bX(X#qb;y1KFYlYFv@g;;Of z9+!i{ofsm36wMD4RU6M3%;+uMQR7-?ZVi~{e#}n9R?=Dr%Qd{${#V;t>3gL#c|ocG znB->~aC!e8&hFpCqgP(WORv3*-JX!jgz>W{D9@f?m?jL_=DmVAg9(@dcxz`#QbjE# zLggafT_j#!t`M;RfgUPCa-LM(_LU2E!vN-j@%b~<2{^mBz{6Kx!^`&{;@7|a7@vOp z2@cmcsHLJ#6(|J>8Bm^-HUCzv1)bO2Er3;&W~oY8WpdhJ)D%4^qIcn(WYznH`!?qu zoy-+E4^S4jPV2x-m?8CWG*BpzRFo0|@AJLj-XU#Htb$t;D2q{_DI8k`w#Eq-opoY`1n3ve(3?~;RfT?XSm1%b_AqSFbt%*c_f5gG{t@$JQaa{ z$YlH4atQ&Tgp^f0t6K4kL^w{GBa5dIDKqZxcgSGelp}`SfVaQ$CLX-}5Ko^x!}YT( z9G+ccIvk|51TnZ&XV7GVdzAAV(>QUNH=ytyV`iN}o9CXOCU2N8uX+L|1@EZw0W$C; z(v5m8HE+Wd?&U_K`pP*jD_v3r!whmhcAm)q25&L)svI6eheJhBZewrf0YEirj~SI3 zn4g2v-@NtWIc58~DYBixmkq!QidMi89Y%I}E*lV{IxfE3B0VE}8TSM58G3b&2$j)X#k*YD*Q;_tYI!Ds^v& z{qDwOm;uLVllGpa{;mMdik`XzrRJbli27LI9!~5{fOVkRqD^d$+pR96MH+Gay~#HX zl(G~N;jxasp#XN6)oLjL?B1wv+Z5_M5%L`pSq>}?IZEJ)MmJpn6mgFjneV)9|N9nr)#aKB7V_CCfiHRq zt%W(>Y-$Ar>%MIsC^r6xvbUe@pl{08>HaSfd6)Pg-X#XU&8(nUK9}rKwaO8PXM^=2 zS{1fv#6grzX3_fZOpZw+d{n4&dhh0_ANvx3Y)T%bgzA+O2#FYk0v@k$9B=UX)eZI! z9%1+39AEy8ui{WEKKtZT)Z>JDGa^;7)VFFXNpfQWU@A_Q%Oz@xh4sm3)da^1V8~hB z`Qg?vN|qcSt_>I%WY3pUT)-<;f7ia)LDGi@D)7Pm+Bo<#r{Zu%G{BGn1(}_fcrF;0 zFjEBn0+pidR|G(zHeqmncso5+cIDVTZ)O&z-T(jj+9a)(RV(}NPVB*m6fMThtC#z|1fch-Y8q+;x0tK*0iq zCtM3GjIySTe748#-X(UI=Qz7K!)~9D#v^W?Kfy2^ai4%4RrMUKs3n%Xm%SLEJebgB zpBmU_I!6N?$>%_tGr+eZX~J+2P|Jim72MzLagj5g-W)Iy;o&RyaQ?~z)WW!Va)raw z=PJZWRo)>1Tq@X%JkIkX&CiUCD~gfJd(i{Wf!T;41v*1TBJoDHa1){PCi6AWMHIy* zOS6Pis__7c8ki6S`sjgWy{Oto<*Kr4s07)aFKY?_0$_r+CrF=ltCZ1yqb(^c6X@Nc zB`1dlW?ICs$y;CuL{h`q*n=qm8laA5NRfV<+}3T}evR160wU%+V)Ym$3`kk{LCOS5 z8N+Ued-pDJe(w^6E3R&CP&Ee@lYqdKk^mh#U$wG$vJi=PZ8)o?id(CG*2`R$z`*kU zsYfD({hgB1QdS-z-yEk2Q?9st{17j__A0KPJ;Ns-e*$m?PZiTR0;QtVqQ(;L*3Q;B zp((XdIrH^4C0gf?i2Q7iXM6;)jBjS08(Z5`5_IEQ96-uhO|!7&oVTu^RGgX|wtNdO z&%?GGR5p$KJLF|^x{7DiEX_lY+y$EFuAh;&m=av8c;3kt(Wr=QMMw0WPNr4ao>KRB z>yN&xM)}On7#UfCcte{^+Ct<8&@N-x?XkbO!2a?AX}8C9skk{FaXpQwc?Zt1if9|R zM6#e6PfaeK?bkJ6qH=WrUe?*G&TqEkor zTzIDCfs_I~Cqo1sJZ@y_DHI?ea;A?&AL+^mbhNUycD>U|JWtwsGPmy#<_iEQ1rRcTdX^!19Xt!| z`8vpFJOg#2_v1VWz-h*D9gasp`#Zs%q^vtb- zc%oqG)=;a~)$MkprME3?-|yJ1xcE`QHlyCEgT;wqb%eeN5H&(vbqPe<{4Hlu>~jDG zqIFm=i)CJmfsTm%Deth`V!4g6Rcpr>q4z7y23y#Z%#0JZ9%w0Yh`U}ku9VSl*=-=o z1a7ws>47PZSv{a<2ZNPu_Qd#J9ZeX6W2=cZ(RIPVz<>c0j=1^sS4huxIJ<-9Z$bU?qsO1q+igXR~PH-YleH zGi^`gBs7qTk&(bfLfM9tFqMjgo$C4Wc~vFN&xR^qiSmxB>C`j#nJUj<> zd+g8lxV$_A-M_@~r3tkFIS*RpP5M_#!C*>)gEO{ZPG#XxY=8GBTl>2y9TvK71py+@ zwf&)CF9`3p3V0DHVnDoZB-}-m?)DB7TCFFhFbU`dU1tFaMb=S8buHA(_N{t{nX=)P z*Hlf4Y;Y;yHG$vuhX6^YpmtgKogh?kFq5EaRNwyRhv^= z9yY*5WfuiqDyoJk8s8G-pL53L#eiX)Fii(k%AnmI_0{{RFW;BYfFf(VL&8)Gkdk}M zHrCyHigZ|Dr^G2;_#P432`sh~?!}O4z%{qLK#;tZ(ac(x~8==nOw%P zdG~Wv*U!dneIy#3KlmQtuxe}(1%1bwyxH#?o5M{bZi;wm-dFa5 zs*U6{09N!UYk1%|fwFlaMz3SEeZ#VH4w{Ggwzah*mcf`%fyy`@3a%%~$@S73uVO#! zP{)Eg6;Yh4oN3kOo1x*`6xfLJ?4XkXExaZg=4#-DX4qIWV4AG$ERhJ2plnsBH6w`I^mTQv-bZweYb3-JX%})N?fGCZvS~DuBX-!!+UgaKNak zGkAZ7sS%6M-dr0ldcJ3fQMiGV>}UOA9k{IoDEw}FIDnY*Yje%RXKVLtIgMBd;uiSj za~6s>bo%OSe08JG6xyR%A56!J9%1X=>f7^j+dM630(!pa8Ph#$>k??P>HqNz^_MwM z3~QNb2N=@&o9XoC($!f^{ujBzKAEAhy<)ta;JMmDw?l6}HPrpx zwR+pQwX?U4wkZ$o4NXa3qM~>4kZRzD{2c|#d2+wGi}Gopsxj5A`?h0v!$kJe+}cLS zsYUB;uOaO&B)BldtDA3)q4%_^^y!fdJ&;Qz`AbYOZ1@97ZVKj>nns1d1Yke~FoAH8 z@r@J8N6&Hn=_kN0qYfGQTn@@iSwgmIa%{%cC3CZO%3KS{X{2N>=2FQqx3!-M9t|9s zCtA00%iL5JVUlxlfU*Xw7y+0F$XdaLp;pWl(6#djv2}IpyN=HeGRNt-2bC7Ur2H&k zOo^OVP)e940l=zSmIt+3`C+Mjq|ypqtRD8XWb7_*s|BWoC%}OXqxUYCoC=T*XwTG%fMIBJ~};8s9LCPp3-cEb*r z`yGaqaC3Eo!*LV^tEv|S2onjNsw1U>zq+4vUqLH%W-XVyOJWrT6UqeC;}Luf9EJgD zHvq!`${9=v$D$CioP6{5|E}cZpItjpn~&&!sextkkSqVDkh$gOi5)6X%CQ!%PuP+;9LQe z!1JA`XFLyKz7?3P6_kq81ZSZ?^ZJW2hm2pw!z61|j8<(PtA-^h&t^1`9<_R267&3O zigrt&-NHv2oyc$$@Jb>Sw^eO2ujD1KNSTl*`y7&i8X{o_N@yNC)jc1@1FKY4&{EEu z#0Kir@K)C3?(EAXu>}eOIzV20t=t18$lYx7%I$pPxORSSN>#C{miMU^lv0q>4k=|d z9+@{j&vKJSajea8Efq!cABugfmnS9P9$J4{C6~Z&qvXrXYP=&4`K)NlaH$0rN8v+zo2Z=(uf|=&m8L|R+WP7;*{?QY4!L*T? z9AKAlyeP*-`U1?;$PJFl)~$F$QUEUM)}8XmA)~FztzBe{(KmCqciN!ML&lWxNPlQ+ zsl0qe4T8y;%#+`{8nvUe<(?e@fLrK6)N{M89siV^Z#gewF|JxZASOZq$-afiv^)%c zrm}jiv?3M3NkGAbd5+*t-3ef3PX-c-2&`+Qx9Q7kA&LFGZ$G;hvSwj|rIdx?zMu2; zKHf<;?SOb*e7^;-twjiRZEeiW{O77OgBs*h@aSuxXIm|Yz9U2QLLSjr&@{BU#JRPO zOWHuE!_V)premIHYu@fQJz1Z_yPek8=XL8kMYA0+A10@>#~N5ImLu&;H4m&nINhe2 z<8$&F=F?s|TOIxzd0M@|<(8I+UR&aKb*ru#J`}lob6VV91ZltGMJ{1#dH$)txBrv_ zkr?A|0=_o5nA20h(H!Bn%Ych{I8(dFK+RJOv(0g%8Nf=Ao+9jRaB&VG$xPIMYDde| z);is`spj{rg_*|b_HYqZ#v1^^V9^$k3W2D4c%D?iN}!y)HVx?kSeU_7P^JQ+0hE%I zjph+36KW+)H&-HvBod1cH!qOrmhP4v?k%`YsS?4a`cD;1hrz8U_JFzkn#I*!>(Kx^ z=;2H(Ibkw2CDSYc%@QvCEc$+{!m<{EXP-xFOIwKAfXEpQ8sNKhrv;X|iP!YhIW zAWV9X392bu4yphEWk8z08f?(f+JPd^nN_kW^N8m(X<>L}*accj*c5^QSgpNxjm%k- zJhNI!D+-{VRs#Wf$4FekT*Qk{XNRgPp@RveCNMKV2}whH&UR;b`Q^vB7zTXw$!9oz z_L(Nb9w6QY;h;7v6Nfnvittqtz_Z|35AZ5o4w5qRl)xoP@QVxo$$7k3{Buj2>$KGa zyAg!6=@+k&K9_*edkspl13ackkOFXu?sU%wMGKIMK$J7a;}O#|g-SlQuh91^cs8_N zE7)M5s*ZhC^jHy&J6@;spNLw)HvJo6e;N&2_*aGGm6BLfwfKclT%%PekBfQoO!2Qg zDF)pV!Vhr|hrJ-qk>&;H3np_BfP0&$1OjEudz#{?JHTyS+7+JBYlnAMk@?{BE ztVtB7QY*4nyA@C)wGcN2WxDV@O%3eeh5w($dnM?<;@y!U9!}n28BCFCgP93rx%ZOH z`Ho1GU7plFBCC_Md^r-$_-y1K?7*r49~9=_WDqNb-ZnBC5Ts*E3Oo?RE641hDJ7g= zoPhuwj>pE&)qr3|2T5H~H7R-v{$=QAw*L%hb`VLupA0zW5tf^a5+pCHqgg5s>UfEJ zo|&@HZ#&Z)9SSJONJGLdHSfcu-g~<|bo<4Utpf?A76mJTQW#^IP$mJ53IS6oUZu~B zDpki2t?9b*6zEwSc+BqZM z;6YA8V~+%QxZ!@k$2cA_6$t~Y3Q~4^+={BK$zU_tpxPQnqlEI6lrktM@MP5xGlU{p zyb*Jf5J;u5O2Yp<7ZHc#`>A<+f(wu-u-}q{v;$35Bcc8U(G{RtyBR zx3=-t2nIm_EQ5py36@Jo#!|KK<^|L8wh<~&v;ua>8ECQ%I25*#1dwXfRcU1RD*)E>^2#cUBBM#sgwVj;~ZwI7oJx>7% z<~^HIa?h*%eh+5G;c&n(3{7TsP`i1Tujx|J*t&MD;s|Sf)AgI*v#KYojCBr@obpgx zgmkL=>%tQ&f^^C<8XnrG<{{Tf%-c5b=@Xbk6c(CCVw;0G5N3NSwrL(-r}W`9ow@Ci zVrLS9n!foSy(f6Ke zXA5{T#G6eBr4VYcF)wpw+R)>E%%}Pt@0&Bdcn1TL$dLiKtyFb9wLH24$K)3LqLq_! zviSds$|T`bEP~mdA=Ra-3HZuPIQ&yV+g!4HK&UD44c<16Siy&b=DV`Pi!e<^tu84j zR6U(=0D#XDc@oz+b>wqm`bP^G@%uTrq)Xunb~O=QJpo9O zq*5!+_Iq4j-ox>D#PjFRk#q9g8<^}+$O|~XJH$aA%E)1!k_qfmRoT4>+IpY^4!}Tm z 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

    P_}J0 z)C7T|90Zgq5hz6v2Ixu&nek}I`FO4*3PT*};7FG!&^XdX8gODJnw6wE=t$dfKstn# z5Jw7%c%tGUs#LJ!pHfIEKt$HqUV!v8*_>;cl|hOA zfqueB5h#Hc4(DI+4LlFZMZ-lGosKaM0?o?RYpH~qasb(!&%F8bz-V0GWz?uHl&*h_ z4l!|+aycZ3=M^dJTv99+7#u9q+R{u@Q=Wmrm2`DB@x55Je%m zOp3n#0`I*ymmPN4l9o|jbaph;-I=GQtqB}SPj3(Jzdwhj<_!6!92f-}R`(1tScXzX zQ3;^8XAp3Z2D14yrL}!P1>r)mG=S?tON#~3F)&yzQH&J2Y)F?w%7sB>zKM*VA`BIa z7O!OO${yM_8_k%}9n74*71>l8oD5-L*95!owmD-)HG=SX_uV-Z3I#el$FShT`CM|& z>3r^_&ydNd=@`|92-mS@$x>RoHY4S_7~ths-s0$E4#&@W%$Tt?3l}W{9D=?Y}5w z8KNTPeud-u7zL}=4D!-zv)F3J){JUz>b$qlI zARVH}V1yGV^|7o?jmA%>c;}t>dHbEY>@Z_z%7r!TyZ`>|yz8#~<8Ke~!<&A?2XjB5 zvvU+-7}1c%_L%WEK&` z2{n|RQ)Hd#bZv247bF*AlY8#}0>l{MTY=cKu`ZaK=-Y; z0i>|Oqe?jdAt{z3N@c@f2{P$C()Wq_dvJv!pY>_XHKGcAjB0P^y6Y~Xr@ze9DO=!s zl4UEFL!@YFYA2|GP!g1tuNR9Y!UznmT_qTeLE}hhYsure5|kzi4W&}Vz(9quV<+;= z^RMvFe?18<f{_wA;XUOr)LoD6e+nl!au zQv;f+lBka;e}KZ z88_?dyf#qQQky4Vuj>Zd1b~vldUQqFO1z|q{jws3@3|Q15Jn0wmI#8ta~)inva-E6 z>!oFoSjVlZFG_q(VPeo%SPv~BZF|)5PJS3at=b3dT1doAL86VJP%06G5otF^LoQ1y zv2l zIb%AAGP~@)9lP$a2Yc?bJ3qMQRu+A@7%3g1P?5@dxQ>I0tmMoU9zqFHeugkqL|WG( zLB>arrd-h&BS__PbY{}5Sh14czCk=cYqR7?D`!I+Oju^8-F9WS-KO#U^Y7B!;L_NT z0l;-qNa@hlHIcTiF2K-AH;`w|vIVF}prQyIiHT8>b~12X*R}^`7kNl) z^6WZ3z9Z35fa?HCfs-bi%8~{yzThm*zTgZ9N>C}YXyN;m`g#zfI>FHtD+4HLKud@; zDijzjQzr-ojZJN&Q(2;*jKsxte9#fn6$t4=RHnUS6#MPFFV8>wGbT)GqfiXu=w7|~ zbln721+iieZ-75r?Ufj`c){wZ8UQ zRwFt!fP47jW2ETXNaO0zaoDmUO&t9Lwb`YKg-$NaN6m*UrMWr-Ixio+_9+1Xp zLnYFL$`BfjkeX7Z2q+xarMaaQOhQF>Nao)9=c1Le*=(svO8u0?_gy>@5te&t8r{u~ zqr2mA$c1PC7{?Y{OeG~fP!1U{OQyAxw$Yn0ZMus!)Qf;dq$??w%ShLYSxXT^T@TjJ zRQ2B+rn=A-)#N1F7^KnG8}3c!(+%Vo-;1sEb_ zvl~Y$UN`wJp{n3`*!mR;gq`nPDL{oFC0b}=fh%FCP$~e4ZKr{zRI2da+Mz#easY&KmIrxQW;PoZ@w{$NCh}QZHEY3am%cSyIqH@5Z zMN4Q*LpGB^dMR{Z*D23D^Af99uV&1IQ5<;4fjs%tlYqn0RXsfP*poE2wUNo^z=VWh zg|6;SI!BGdO=aTtng#@uCYA|8Yo;%x6SHU}5~s?DcZ9%q9D+#UB&>J=7=`0PxmW?E z!AarEGyvO9--ciP>IY1jvN-^SzO^izKacMAW}Cg3G)6guQOMKJzQSpz978JObL1fh z6U0!_2Xp2!f8ip8fG7;(u~>j`2_ud4oH(dpsDuFq!@LCxx%~2L`TQ47=8!}7C7n)_ z5sH()^m%sPV;64u@o%{8w%@VENS7#*NRdJi+6-eGKnrvd z;I7+5);3J4W~D$a1DcScy3r-s110Q6A?mgtn+{6-Z`oK$_@y>a30)^x6VTVcj$&yL z>Ev)-hg2#9svm@daKTU}o0iPnVLQr&5+A-dm$aJ!H$_+u3FC1i5)Kg%C_85g1Fn<^ zB~hWoRq=BN{B+zFlp>wAfUuNlB>euD+^h&VBZkoc}dsR##X%;)1VR^WMAbOesDLEh%(R%9kc zCDIfId%5(ov$*uauLA*u11y-ofI?qCN@~C(h-w>DAsmcw36&rOJOmhtFmBwoY*tHw z_-~rUM%&8&hJ95RVi-wcRVO{IXBtsAEz}q_)Nf5|ti*(wwI1Ea)b^3_eGy9nha}Sy z*fnu=uUso@Vuo*^ns_Andu`uks?nO^wlX5VM@^hL&roe27-|w>9SzBh-Di zzM=cTO?WCJF}93}C6OQT9UE&G@*~=f82Jl_Vvidbr$&-Wj-+0yA7?+R9_#D7YJe_n zlAn!@nI@K-I${_Ts`@+(i2}e``Ob(oJ| zg{Ki}$ghd!Wsc2mO4sRPXFzCu^oTJhjg zH=5Y*se5lDP;Hf=W~d>VqArRmHaxJ|au4N&)tB|7>iDCK-iA{Xr6sAZhp1|s^S%Zg z)el@)nx9PWL0G`UdTQ0uDaRM7HtZUTUk@PKXCdP>s3RHJiC8aDWql2cLR1I(m@Sqd z$x#&3W6iL7lW|AW#wz=yuresut;!9JvFlF@V8v`28JiKtdTp(3Vgn&GQpS757yzt} z)katQNrH51G^&aoSCPpYT{f0sDr3(YEc8){DuAJ_t%c@>94qAjxr~RDiauc&-PHw- z14^@a!D8zz;aI@Najg+!q*RT9ZxkvDanl~IV@RhY%D6N%WI-y>%GQqpA*~_1L8Gvg zigLL`rCcW8-V8$E`99i&NY_UgjTBbnZj_-?g1hg1f~TH&g%giIo}-RBfEhEkLzo`E zaPk>+HjU!6lTW9wr{6A`wPgl+v3a&J9q%X;4Gc;s#q|-jD zd)Ly_v)am-`YiaB@>A$2v_OU<5z5ffJ(?SCx}MK`=JTvrzM9Zzz^9?1iRYeug~uL! z0<8?`bdE~dvSQwQ?_GKdMe=Pe7F2aCWnUY+fpUaE84&|LV3fjf9UKXS6|_<`H#Om{ z=p&?nlL9X%Ko?lkvx@C^n8xeCD`gnY8&f_>O~{@`f%OAu5jm3wJxX(lQgii1V6nKU>qCKL?x4bqTlKxXoM@k?Lgu_s>Q zl~>=TbJTbWg|c0r#u2cpa5x6=NldU!!Y;Jw)3wpI4z#Xr>~%bTInqHo9y(4Up<||r z6b2z3>w}d<<%kjSxIb6toehrl8yDf)@D%f#iCj!g}1R0J-d6+QjEOc7uQT(<_ai^Rn!O}V7OZ)0`OhurehKha+(qoqU$A7cc)#R_k~J&VSs zHjGh7W7A|uVMsQcLixsK9y0-;2~_~vpd(>{Q{m!BO(rGq+*m%1LsXFn?Vuu6Ew>7b zlm?4jaM9N|>D15ByJ`tP&MXXuZkK?9@bchhvlTIM51RSa|pO1S~ z@z%PGRxLS>S~@oRR5N+Qnaqk8`TSn;U%!o4q=(YQ54eD)XZyd%wpy0 z)oez{?z`^D7Mo8594eJEcinw2xqO;H8Or4%5Me}wFd=?Q(2#8)=vjg1g77ptEU~Vy zpH#{NG*KAhc|K8qW%kKeg_GcV?@P6#H`(Di4c_;c-uLcwG{(JA@7{T73_yiYSejd?aAFCGk@%umh zoqPZGFE04nX?*%q2Y?DFMriyYUA%@#-5N(AC{dQ)46TEp7O&%aUaa7$^lunI;Ua?SSKYL`GnYZP#HG;JOi> z?~w96GHD+WxH3f;TIn2)03zJ9mEC{xfW4S9Wk*&me~0-W%;y)sx|6rxd6&QZ?lzio zd7?^4Lqi@vo#wqc^I5!f1>?qavgHfto~T3lmx$ZoG)EEae|>Zz+CkO6~yw~ zp)CC22EH4g2mZ&0sAFEm$^#K6N^)GEHS5+fI9R~T<;dkL2w}+Pe3Z89O4pN|f9~lV zapWN^UbKYkzw-kI2YLZ;9gkctXMcAMz6ZJL{4J@JBJ1DidQfkQ@7Q$mAS~leNThV} zT?Z`;ku-!Vq8vp;Mo=h}=p56*%4PHT^^bnT(iQ6%KXDS^lg*^*>lxsIhaRK7vk^mt zHWAWu5l%9zT5mO_Yz9p!1)k@|0a}f8U0ly4o6fPecQu$Y9gSHQE?UXq2kg&R&-fCh z;#y|C`8t33%YEGc_ec2R=a1#Q^UsXG-7?p7q`Z(NEgx5L#BsaG0ReS=2=`iAxYYMsUN zqvJRv_BZu`&*b~7mNN0Ww)**zm{5BCXTLFK*M`okZ%e}grHwH|>-~{O0-=)klKpM{ z=QgZL8wO-<Lh-KL+~iW`eSq58YgMjRZM}A>O8@mY-37@ z9qT2^i@&Fyjp*8s)Eb)|oa5-yh?7R`CO$GE4t1p%P6ysZ@N0yJFNCnE!9Ba-Rk8G| z9?VGYO9&b7u;NW7HC=Aa@xJyDs?kcmb0PArvFI)^Ku#g87O>o+aw%9a|eQ6QFE zDiwRp*hwavZQ#hG4rTQCP9A*daej33uekT#2RZBX&vE7#PK0znyUm3iPZ>{fu$O`URWxKJr<{BmpV(_R?!D_i zQf|g(xOAbouE4ji{x0W!?JOovp1_QqXK>MF=kk@WT)~P}E5Y=WPB%0A%{g58&0mbz zuNoUXr0;_xQDFt8BYfYXe%X*P1%(he!UCOm4(VKqa=AobZ-GisCexTgq`jk=!Tz;8{KT_-|NFnoS;BblWH-I!azQ>xLBFY$CA!%=IBbUjtbm=N4 zOzz^uv%k!~`|Qi2d5gK}#v4$|`j-)vxbEs}*=D;PIN`J}(%jL(WmkQJ6Hh#Yf#M*3 zDuYrXMrga)))HZi?ITH_kH;r%G*TN&<&QTz#9)n97Ss|_&_6IpPj7#`L9xh|G76y# z#)%n{x)zB<2&}rEwg$3LZF~gX=kACSqWC>1M1uD{PGrs7Bs;ds@t zqJN+WIwF(tY(Jz729z=&T!fn?mC6uBWg?~N?YEh%z()v=NZWDE)@4W(mT7I3eD<@S zqHo1Y`uh8L_|d2N@lXFiW7=Woop*;!6RI}^nL&sY0-q=>lJhbUIjmZ`4jnkiw8Nwc zljvO+Af2V;8!}vS$+ws>eH;FE&%NXtGlW4IKix=IM+a+G_mFZtde&8#Hf@S!?nIh} z^B3Yc#P^r5tdfOsT{Jm%sdqgOEnsc$0Bd@BY42#oI8EGi>z`P-cwoe5ySBHCaeeSp zy!GB(Zo2875ziYlZZwW7&^n4K=_)RVOVz{kW13;ruUv)(;F3iqQ1xr9Z6XrpZNfaX zYieDc8*4$2cpejRR?e6WU0;IwElP5h(S}qi&9bFS`QXEO?6%8vww}2IrV>D;nKNe| zFFgMWU8BacY{_y~uU!XWflM|{S4S&B(Xf2+G90CuGA& ze9xg!2nhp)=Xp#VH=06Efnslksgp;MO1a=loBTvNNZ;_<8?OP1Og00q%V0TR#mXY@ zzrBd4U^D8uDHl^IvUu?#8Zr%RHFYWo%Tj#l#kaZi@*koFe7Im4cs?c)l%q0k+NWpT z0I$CC2K(&28-6a!*4u7FrP9L-&%K0-AdD1iR;`6lk#FnZ-S_8l*S!yp_>9Mm??y_4 z?>dB9p_M{7F1RirNM~G3Wau3%(bXkr&gTHh;9x+Z5RlDgV*J)1q#>JvQ5}uIAfB7z z%ByeY;m2Q~y*W#vsF0~V(gdV4DO^9z@@1=eYu3Ar+iU`kQzYf3APjkH_B#v?mdIp% zw226Hfb<-s#9AcON%cHoH+P@-#O{3ZlLzqp^RMvNd+*_iho9uoBlhBZ*IdN-N#kg0 zayay$J$ZN5TvEOV5*f%IPC-=AfFyj_vjW@u3l>APuFMS_%V1M6#GiF zHDwt)W)xH+%7X#xRu7O*H`}>VMQ9y@OBFx@f#cbv99Fpz2W)&v5Q1?$3gt4(R<5RV zbQh^y3y(eZDvv(?s%2Ky0Y44-Jgto>j08=D>o`cMDOW1^zQl3un#XrjlyNAP%0$XQ z6f$w*c#5SmOBXMpy*2kjcdwccY|)%*efZ+f9^LhB>h?bVY# zhBTvO4=@q{v5|6{m>?kQz!5v8)bDj`K(Pg{;w!E~!Du6Y@eT)DC5tns1s5KPOsa26BK8PXUixEj8I#acDN$zcfKu_{NZPu&% zk7E}9@qtiN-6xLZX|*vgsSTORf0T@{{wIe1e4uIrM|=NT9rpj;|fZG+;H z*Va2Du-s}*PB0S5s=f}^V;G6`zqbaA)sCUO`jI-EEz!_|n@xORX5$TGQwN*+2b&<0 z)22kE+pr{m)SL(#Gno_DJorxF+GTrhWB@u8F00T5GfrRw7o%$kN(=Mj0K; z$Lhf*VSRG6ZCXM&Na5g04@X-66%$7_lX6&=hNZAZks=Hu!ce1>j_Zv0cT@MHt5QF} zdQz!G?qon)R9BlCsAcLts|+Y3P$s#yjcBS!#pl~+l1Li0QgJ=4&lrZvDKd7AN< zzi4_qD3S<~Mumd0qsFq$=2KZRe=%v##TbL1vI}fKEBO4EPUNyn zFX8uh{FsX__!5hjEakPApC#o>kb=dF7qNVKFN1{=uImt#3ydGz#^~<$IK~;GjY3F= zrsihKfy-0Ry@v2}D6QCSj~PszHX5VX@#7y}#WycMm%|S`nVHkKVfD&ogkcFBhviF` zap!OT&UM%R(58AU_4C=I4rRtx6M6s5x%98;0Tpn}5r=WcX~&RB!}xI>?7R0aoO0rk z9JudJq+EeimP)T8g>dZVQpfGnabz`hcs2TDw4E4JX|~&D8mT5R*>9h{(AuUdMk#ctdFrW`_~tin;r+KiK!=){Gq>l(ZOroRtIlDU9VU{I0W-GQjKdG!lXJg%I{WXl3w`Sbh$0tl1YM)M zIPUl(X~-E4J9JMD_~iZ!^p}86R5W2cM0ELrw zK^lxBhya`vN=thBHS-rNBGZtgwXKC|(>AAE){L7tj=lHY6B7)^i6aauj-Cl2ttoXX z#dFU;Pbf5)hyxEekbU>w2~Pxk_uH3q_E}$K&rf`sU3T7?S6+OPN)Qr-gPe52XF2(# zV<{DSIOp8YvdgZ!fb=Nzmw02=EYcZ=AOuTqlLSE&&%Jg&sRUsRU^z53w~=x@mM&V# z6OTU!UY04FP2#tA{)WAF+m;=tPvT3b9m!RfpUIuSzL6~_jpE72|3d`io13}f@^A3% zE5E|FQ%AGIwDElH%oF+XkG{qJ`|d(eD&a{Vi5>eeUOcu&Afk^AUQsuVlZ@llz|7D< zm04c|7&FA<1*WMj)DnZT68iYz&c7^!624x^w`~ z%Y(9s7&5smr9s69^OoTH4Gi`UaK;x;<*JL%WT&ksaQvqaCD+sdSTNI1rEpVe7A#)O z+NFzeJO`P}^4Fr(1hw~P&eAya=l=vBsr7M=RV#P|7Hbh}$J(*ldQ?o}V3)6R; z#BRG!hbTlio|U&5ms~@hM<0EHVzG#H1({ri!NEaZdgWDG+FDq(at*U)y=y(>iv4{5 znyWbJ(+ASsnqs@DWBJM#KEsXIUCF0DeF$NsNTpIpX^=u&uqd+ZGoli^@3Iqne`04E z8ze^_c?j)eM?)A8g%OJvF2mDlOv!?#DbJ$l|jDJsQ5 z_S|cC&ivX%+5iJ!{v-8^DlA1=dbOI-tSx941T{O;b~fNEJaw zmX+n$Y^jEtcdG3ZH4nua1CiP$eS|fs6f!pDjoDWs2@=Yf@h+}dAPoBJ;@~2N+O;4{x}dBM;k+DU%wRzQcG=&==Gb-D z8Em!rD31T^{_KC?-Vm0+OR;FlGD4;8_$bz2k8jd={(q&N)YK~RMM%^mX80!DjJV0H z4)-+`4Ap)l>*tN6e+ipaP&!WR3F}w|fMDVl$LdJFUQMLgB z9J$^#H>n*Z(#ARvE&+pvV-OqmzN!VKhRd$&!KjUy)(>Z9jdbmiK(LX<%g89Zf~jM| zMoLjE7Ky@W2-sC$4^$z_uv${4`-sC$HPn8ktsXEyu z=hn~THC9+MDGvvZTji2psLe(D;%Ng^bUb^9_l zQX2JqzGENDdL_n0P4OVeMtCN*rf&%3T95+o^u5jofhx7OQCehZGXZ-jsFq(Be{oHfU17vf}IG!Xd6sVLd z6bFg`tm`QvoE#>Cl-RDuvG%CxmKaL!pLvt+^h_-PNv1!5*mzOjK{ z-SG!LbNmU+n6V|Dogu&d-EAygx`gew-vV@rB_BS`?Z5gR+itr(t}D?|ncm((#*ZD# z-|l&c3(lXz=37ppC7bHB>xzWjCm^7}t?`uUg9(W&_U53b~MCmu~hb0dxU21bn; z&5O^y%<;#bjt&JxARV7D2r*h9J;z2xm5$p~tR?q68zB@9;b1?PUwswFo$@)dDTgT& z#)9&g_1e2U_0*eewT)rbvQ@M+HL}}|Gr9NfUtyGFvx!|qr9p%v7&WR3ZOZ)o7r)~t zx9x_Xs&L^qzRFQY9>)0YW>k3qSETsyEw}RK?78G}8Hgfu2xBKq;e|Jz?|HTb9rE^%Kzi8P&|G593oOsfqOq;$v`+Q;szxmm9%$c)*uI_Fcn_8K+?Q|}= z_;UXI=eN+tLn}!-o95hezRXcaA4X?;8x5H>`HoIrdHyM$eBwoxE$d^^!sT?08;=u3 z+;Z#9EL*;Won~xJL-=|!v zFluxc<)8?T!1cX&thF*1Z4`~o%{=?U3%vIFYwWhi&a|{NbKB2vVO`${!dQ((&==K9>w>6a5blY`4qO;Y&0p~2a)Cn*Zq(M3zyT?)kI%^ z#kPTpvfv4|X{{VY;5C4XFv=m9&JsnMTYvm>4msptat$tf?J<)(Z~qa4eQOy%rkh-@ z5vO@FPd)l5xBu}0E_h=$JMKDz#+1jESDwqKjyi}^vB;#!6KNa0Ii4eV=dJf~ghb;~ zO~N7)P}ES{e15z+GnrR(9q1(2o0m;>?ytTRGp;c4qOfl4ug*z{LPXl(>8GC~<2O()DRK?Xyz%-wJo(rQ9P#PHX>Eib zTz4f4&iEYTyW0`M;AhA4(`zrK5*j==V*_vHGQQNbceHZ%J^$jEPan-Ld(NP^dIcAr z_a#m}^;q)hEJiE(ddgI^CY4IVV2RbM*7Dv5AF}%%y8sR^zcPzbCE~+*ijFPme*y!UwHjoGv|chT3^!`*lPokI@ZpRKo_N=K{94}W+yi-%)Hw9+wg3(D8r%rnowiqaub6ykXfO4yvnuIn-|(8GlBWBJjKuVv-hwY0Ug6NM3e zQ#*Ivb2|%{7ujKI44e{@OZyZG@a|g+?K3KuIsdDtF{V9Fb1p|yHchEiLijHEraan2 zDDCj{Q?GGpxs32#$TaZcf1YB_oO$@E9DM_8F+vbTAsOE$c*vw(!a$*vv1~S1(A3<) zbI-oQdFQPqo%Y#r_uaVS%JV4{dKo=xG$83&-OIxdKS8+?uw=U>R5{?}FPy@t zv6K19&+cUYf))6w9H4PrP%4PmJ#Mv6kgT)nC=xPW|JAqWn)BN@Pqm8cl-Iv-Tz|M z+Ce-whgOE><}U8P{}J~42E!=)O$d-{TbGXr(BX zN*JwiTz7-@p%XBEG+~5QLhys6{ zhm~AQDVtfV9tk$FtU>GgNTwVL9*m$-t^e+g{d+iCI=o!sgt1J68VfI>*P8gJwm1?o zIxmE@QXd)1rQ&B4DUH%`wl!_4**PLjd=C-IXjBYNC6w|S=pFYBM^+_K)d?wC)Fl$f zgoRR-k2toyY8T&cWWmDKoOSkvy!gUvc#)uCJ1}ALcHsDwixFQt`+S~%{x!0V zjg(c0lk#z!JIOXRlg^|;M;IMas#L5wXsU@~DWFs?Gkw=jGJf+duzqiX8-IKoFTL_Q z6DN%(oo&EtZX%V=Q7Q*CHe{Lo_Pad${L7@;N0V-EVapji@yY%7Ca9EYY{*ShaR7jh$myx@-l~aR{~K%V(X-`|mCw+dPr2rqASn!;hkK+(arN zeET~$@W$Ko$>$nT5lFX*{{9NeKrw{h{^4#Stsv)e%<%_s*a6eIUuC9rhdN3=vZ4#IUU^HJBF-)bA5|4md>#c~22OHVvk#N(Gk zW9w*~>^K^mI}tL4M%z0-%spheuT|Qi}lm_2p$?`$Ic={#0{mN|8 z?c>;L*F8Au#N*j@zkS$hrycp=!+AXS(rfe;%Dnyl9I_pwSh{oxt_L$`?8n%#qiJbv zW7)i=Tygn#Sh1=XKbz+I8*ijiERxPQv+XupaL@tA(AwI-habETG=PS}U_VM}d?ybg zPr0P&9@WOf|9PBmUh#c$={81>+nf_WcLt}NbTaei&ZobxNOM~^t!-T>1X8*fEdUoS zJhb#U?}CeY^NqLg^G%G~Y%<$VpGo_u3G@vN^7XG@&O7fcVDy+yuDtSl{OT7!2V7b^ z#<0__`_R-m8kx;-&9}eF@Ba7~y1GYEs%Q#>C7=UJSbwf*` zVUg9upgP%@*lr|-eNVzsHp4d1afgYSk&%H^CdZnd0w9K49k`-gGzwR z_%viQbaszo`Kr}idd0Vx{rW7jo!z87!MLg0Abmq`{~%3Wqwz9%q?ZBXux3p!Pe1oE z!kEA;kbgh$Bwn@~BEkK4|ATNaKnj<(woc|RT*%t7PZ)*dvRUTNoyW^Bzec*L3BrJvUwnmufg+xl!EsXb50nrx zjhAah8<%2P(c4=UO_RTo*Q%5subQ^_251Et$D1@=T znz|mes;+D5f}k+W4q|AVPXdcFCVmMiYx2S*U}<;W{}&lm|K(9dkmCeHhN`?bQkJ2Y z%`|b*H?H83e?Nlbw&MFaw%l?{CT+GEnZ`VoisZ`6zt7!wKg`%k6BsOoTz&O*JpQkT z5UDhix17ozhkuHuwl-4PMsB?JMt=Lpd&o95pd1Icu^F7Pv^2K^P%bI_Y@XKc@rYC> zo|gg)p)&ZXG?~`XWSZMaWztNUG>SLh`GB*(dLgS<50Gu^X8icEY`5b~X6`zZ#(V=e z-0(x@&Yws3s4i~(>91KZZ#fN3qiAg(%a_kOoeAT+sgz2V;f#al=7^NX<(Ge#zut2{ zQ3T!NHfNW84x(vHH+=&U-@N(Z9 z$)*ScyY|lH^9&RQSvY?_Q@7fM@sqbE-!h)%OILCAcYna5r7LJ?Y(z*&5Cs2Uw~pbB zWheuSp^V)siNP4FMu~kRMZ70TevcQFcHB1!qex<;gODzc<63`SOS`jdH0dIwYl9zE z7LrrVY>u$6iJeKpvPsGlb8qT^mTGa=?}tXNM^M=iNU<@Z$i}348}9!#EMik^B@z`) z!r-d1w&M4V0U;fX6W5)o|NBSpsZIS)0)lM>_^QVuhBKiyWz731dEkZ^S{uR=>d$i= zr%C`)$_CGb*kM zD(-aF_sBkX`~9(GOX+G>felr14F~@3j!gk!q9`JjN>!umj$;`xuIo|rx2hTWepIJf zpRM)LW%{^!>)}kY4VCM^t(OfM;tgN8*W?ei|JSIi_20A1AvX-&!t63F>)@yA*PCzW%c4d4|B3>}r z0a%I{fReJ>E|ju{^;(-66*6H6Cz3Iv5mH(|C@iq26V^D7}#mV zb6px5ntA_&g`9cj`RudruI#kabg~Uu28(6p&s)S(Pdvx_?=2wL)NZdM0)G07+nN2& zd-SX;Fl+W(w6-=QT~J6Kc<4!_>+_d;?qcEmMI7+SPqNRxyVBd&%VUo|$v^-32%TM{ zSkv3jFYfpQ`^=rs!UfCuaNZ)?+B#51bM`ss@ys(%F=K~q@X|ha-2Ml0`8=gc+0uBm zB%g1lIMBm4zj+-mJ^w1(PMbz#0#>YC!Q1bB$lLGELu-%5rUr_o0lt0B^}P4iTWqu4 zR9ZV)dG3YRc<7-=nZIy8TW+yA|9apFq5#_3yP3amIj4N?%Y5onhq2xCEifn+FJ8i5 z@A)SmytjlcCQsm*=bqztcaCPe?Wc0*AMU2C1fwTT;7@=3C&i%1w5=wgO@wj4O?#aC z^(*+>0}rtOK0DLZ(LkZl#|Lvh!_l0}PHykreRdKe=?c(~~t7hQBUhaR#Y zp$PcTqfgV;-c2@}WzL6-IPSzRaqxlrv+cH9lg(r(mP@?--UmGS?5lh@cQNUF6My>i zU6`oC?Z5pceT6{|I_N;Q+je^@Ma7-B|A9AVzegsQBbCkY^s~?N+2c;);6wIf?D!5| zdg%ood+14|@c7iHKf}`H>+oEMrHhtx;|)J0m(B9XV^1QbPg{Evcies#bKhUcClA_( za%GUWX1~RYFTY6lm~N&_p2mWOt7vIyB?tp6v)2()NSc~jS+sZsCw=Zq9DU?LY`e`g znj7;hTfUNi{Nq7hc;O9NJH{ZSM_3r(JKz33&p!Jkd+fR+%`JI)`v>^;&wS(7Rf0KsBJdrVEGa3GR-@k~oV)?QaJpROUq|zA_kZo+` z;m4liq!Yi$r#^i!<3=~LYUv7=E?myL?=9x_Sszf)g0@kkS+#Zrr=0v1jy?JS_Ws1K zj2$zMHS5;$+MBcZ$3Grs{(@DcvMm;%j(JkGIY173{qGMkBx+49QawaHQ40js&$ZRz zem%B80$z!sdjV6oBo;~b#M%Z(BzT+Vt4U;X9>u{jPdxD)ufI8mOs(kLOn&1EaUdD~-X8GcU{Oi7l z>FVsZ+1Rv4d*?Xbd1pSSeg3QLx9=`Y+hPLglw@7cTIMZS#fvY^Vr_o~A$^SS5YkG- z9qAAS5#@4)TvIzwKm8}J{^qr8v%^fv<%l_RKIDxz-z3t|KB|L7%a(EeMc-hLPke%h z{{1+aTnk<%&DG!gArJiHL8foJ6`@oV%4L?$TL>E3yGQf(d-E7QYBYsHKV?;B@v^1t z`A+NsnD*yb)zj^oFMYJ?^5=9XYKlU`IoN@-o zeCjABO`OQn&pgFjZ_Os(kh30%QG|3|yA6oth74``HEBoP#-e_mS--9mA`aln#7aWf z6ah&;s&<7DWJ>?fkNOhXge?gr%=0`- z_W=+7>v3Lq;Z3r+7DDaQ*wW3aHLE%Q!izcbumjk7t1U4ImMmSyi?6)NORvtxNoVmq zk9X$G&&4o!t$*^6D&(Jn}dWI_MK@we=*Vh*-UH70*2VGS5E$CdO-_u_@07bC>YB zlfKMXzj``dovl3c?DNc?{T26qaosaN56X=LbG5pwm3vcg3nA(;qmj#6 z^I)row*=zaD^D~+X1MRKkKv@UxUL}5RzGEQykHV>3ha0>l|&#m$igz~12n@AIl`an zwZ9vGF!ef58Eayxf)2^=3HV?uY5g>l_0ge`G9!G{!y4XLM+A|Q4wRV64g;??WW1Ju zL2+&IQHK~7WgdQ?8+#tZ-*=KmxPi;or?5>fI~0I22pRLf%?L<#tPf8pv%||#`!2m6 zsw~9thHe>HuH#rHh*3j6Q(vtKb1(s4t;UvWJxj-?gs;*%H5jeXN~5&0vd4sdV(Lxf zK{|0eis_9;LqYh^)Q%Pz=6*1bH7i!)NrBWdmZT^<0ceYCOUEHlA+GD<7%*Xo3QDLb zutwA=-!j287zOEc9-{@yC>&Q4ghlJo6hhX`6FE@U0Vy|yhzwy^zz`8u$~dk^HkSnq zK@cK@i8BSW5{ zR30Ey0>?=amP$Rg;o_F_~&1k zw(T~Q`g+)F@BLV~d<~wL#)%v}H;e10P(mOa0V-s$zXu@6FhDxxlg_$vI(8SM zt-Q{dSg0!mHp7`^Q5gi?W5=@jl(Cp{A1M)#5&@2=MX)edGNQCXMK;@}#%t6cB*)(Aq=mGSX9o zl@Q-cBW1?=Gr6|^N5v9C2lzP;tzC@H;QJmzhV=EWB@8Qgj>LC;%3(yL4e3mZlbNS_fsh)F!F2_dC`4=U{4|+#15so_Mkys>5FmuWPq_>Z_Mmit z>k3kiLl`Q`m5_$UHni}Fw8D{~!V<+o9}Srlj&vx8ibyMbKTX=t5kv;h6E?X)7?JWb zlp}=`o4Q9)h$ljfjtG?^mC0FS>||V45yj#l!qKF1E=CEW5~x6s&o@yaM0$dZ=P=OQ zOSv*YLqj9kbPG`wF;G|sQj$uiF}OB+s?atWfw2)+5zjy7|HNM{woC}hAq^c(?7a7G z^silsUnzqLFe<`GyXbYaojbJ97y*t>==F|Wgw+6lK!Cr`qOK&866H0J%C++PGtX1* z?ZHcBEwh*?o@@L#F{m}7mqws;gm48$*le?DKSNL|V#@s>6cr)yQVpcsGzLM0Mw$pu zC`y9`!Z0L?A~M-5+JvA5UZw?uhf*Pq2=Sc|tqtXhL3n8#*F&Hv7y5`Q6@1?V7s^3E zDxD#yXc}^jIKo9oid5QVpx8?oMC3A!Xa%n4VH}ByN|cK|c+$af8}U+Ev@|Fcpei9j zWf0GI5T1t;AOeMNB*xKbSK_)kOeAoHBvKW82U|DNMHzwgJSvew7>Vmj2K##%QaDZ;Ybl`d(vtqQEBL}G$8qIV7vndybH^`!%-1ix635TrrZT8N zlgVT;uEBGBL<9c zMsd$yf5DV#(|P2<`#JaA%joNaY_1I>A&LS#9U_EAONkZ+Z6sk>A(Qh^N@A2pD&rEV z3Ilz;xQ-y#kVi#OEEG@?G&JNf#!8K~4(aJ#ODg4)No5G5B90?SdkqMYB8;pTWy02w zBH2`14>p;0*nH|1j2hKNxm>^~g)j;&Gzb|>ZS5*g#p_cC>#d3r)g(K~(p4M4k)-?# zAHF|_RSQ<&B!<&N0<0U~*bO~4eH}ggGq&H^aYPy{Gf!GiJ!J$K7f;&k&p~C7VzEFb zlfm&F!k|nLMi?W>H?-h78OlK!$C0?MqEzZ53QIKPvV@@q;}9xvyfnh|a4@K_L{KVO zZ#gfE<77}mQwat!RLG`M2;(7yi|hFmE7k+kNP~v%{6p49XY^dO~Ufg}u36cAMujrk@b z;;eB>Q7R1L`xBO)t1k{P7`xXLjv)vG#*7`u=G#xBs3TSvDpZ6UGujP~ zacWGR5!!BjyG{a%);q3FeIrGUFd%CiF%j>76DLMv65q0+-)&@KyQ1yhBl$fMhs22? zlHMYcpp9_Ws{xyZ-CjgnX^aKCO|7o$!HNIIAiX9d9!iH9PJS42Z^Op~Z8mtFkwB%P zb*9%l_an=iTsy8e&H7~={`>ksnb@Rj{4d(_NVr42HPmoyV^gk~-1|uNJd)wOiQBLo zfiY%cG7DPc2#FAyD2kXgZY)t4;3kuY7U0xHJFGX4l|mU*3`Eqxp$*+k5{(&-NN(~` z&rI1sW!D2vi5-#SIMw%RAjJ9)XQYYGvbdb8BymG1>iYM&e)&FkwPK01-G z>JpGLiXxkW7vjttH5Oq~FZFHK_J_p0IswTHLb!JSY!ajR`ty@^9pi6W$B_}6B3>(F zv!cY=54AFA$3+?wPq48Gd?E)Cwd@^^<5^%SD&wU+qM!m;nWO7x@%)C^9NRW{-*a&@ zE>Wc72@Ztx@H~k@;iY|4s1Zm~t{=;Y9Hf)Q_aNnGFhUSU_VKx{hvT?J${K1)uL&WH zea}QlN20XGPi1gDyV0(MA?b7;Z8VM}2$juT0}`V`JU@$35>1L!Lj#fs&xseeiWqD( zA8qBMk~@O2gh+S2!crC`Z<(| z7%VHK^l+sl(AIF(@iQO|kw_C6jjli{m8H2Qg~B0H*4R8{KpG3Cr_pFui48i{lr zB4xAnMM@zsc#eZsVRh^oER+C2C5Xu6nn-6g(#haxN!m>j1VQ4GfDsP)mQhq9MbQ}C zT%%?4VG!D~`c!1l%8*Gn;QJZ7=@w#5I%6qm`jp ztRSSubpT|w`zC$Gb~Bp|(o`dMg6cBGh&WS;srnHl5%c0$Z7;wzdqM-C@XNBEWR+LJg9^rp5x=E9F(+DODQ}&$3-?| z(UC@(fVA&}wi!012~fm9I1(?@5RX%K@#x^=`VLOsM;HqV`>sHGk{Gc9AP6I*aBSd7 zsW|PqBn%=l>1LFPFh&t6jdTSq=`>+Hmf|`nVetGM?WqQWAV3SqWc-+NuTWZBz|jbV z^bn39(%^fkgw%@bc=owEl61^7UOboZ0*ceEltMlkc{UMRHB>=kPdW1oU>GFdjgi?re0bJv`+_nHrT?_;icCL%I3vTFk`-iv#W*?zC} zU;nihc3~OS63EFqU5fE2C;%p~wK8MrEiBVlOHqb|2Dxz3W%(5Edm1%SfH{bVZRH4`JQIuoAVzUn8qQDqfTwcLDWi$%LgHC6i&Qey%PtJR? ztjqfP35sHj_d=d8lUb$oCMvL|V!VfmY^dYPNnqi|c|Z!)kd{G|6D!!L=_(UlV=H@l zBUXyYG;4AyrTTkN0+>Z*wKXExETbG3Sd-E1uaI>X7>`SevB%ma7IOiUVI*T*j>$VA zCZQKYr@u_Mzlif5GD|1#K~a!p9SZe$JXyX#W_tnfbP>eJGBVr8Xa_~Hg!SVPjLp%! z7l8y%ZYAvTSk?|5rPPv=pqgj7q1zR#gt=-ZLo6&TW$4(mGb-rxmQgi~UC8Wgj8M7~ zD;esQes2}QP?nC&C}O)Tt>=M>UKCidWO;{fFSx%It|ad*kP+Pborj&B4f1@%aJU1; zG8&E;m!3m?L8MP7vq+XlMp0O!nYFkwWl7~@D4Lzpxk#l}fr#V_$~aA4AxQqlrT zhu(}1_KgZB0D&lwO<$js{#0#=nT^0HU?fmVO=zU6QVUb160MyYQxNuq8rSch*P)ZZ znYEUsr6oqA5j#6OWLY+a083*NBlhRyq#!Mp89-Jgz>1%GX0sow_e5nf#e%AkU-5A~ zr$M55lhmiN^YPF&K~S`j8@^Ts1$Eg)m4uNtiP6UWxw?!QRtmTgPw+)<9gy%9i5&piOItiGdCycr~%vYdR28 z8%%~Vp}&j`LY&b=)AhAgK?EPu8D}O(3&oNJxwsK0$mCgA!V%bWuB_G>k=kx!wl7se zLKpFv=o%@Opg^F(UmoamvIhM%CgkuQYZU3EK*GV&E7)jBHHd*sGYo-cqkuO-7!}n@ z((BlGSgXa0P7sbXGUdM<^2;%QUrk4aVR0F-cmj~%3Qrc}v=zRH@uY(@0s>AQxitZz@Kv7BY8}`t{4U+N zT3ek?ueCSDkmyyWsAk5HHcK$X%SbM>)w|Thelb)zL&W37;{>n^pzBS9!e!rJl9#0)7euG0)ev6;nd42tU^ z6N9S5W+?^&!LaJfz&Oyb!aChNw9_XRvWe@~#C_6)#1tlsT~;Y~BnM3bGbsY_xdH9q z#30sFYN{iMIuhmIAmB|{FNNYvSo=l|ndGPo;Gx=(2^V5)86{@pNpTs+h*3Na#DuJ* zqKGPGS%h_kGDdO6kc|T{Az2o{`6L)ex6%6Cu;iTxUdKI70%lGX*Q1gAB!nlZPu$#e z$tuA+1dL8042%TEmRB&6(aY05Lwuh{eNvn)5Exi#Y*gxV@D^rPHOFn1QIv|Y88Qwr z*Mtlo*MyIaPl9|h2AM3lfA})a#b^LA)&}c*utLPR^F9(0Yh#C0+A|fXjK++iWr;*K zi$g&25YWCXLaayPnL|uAqCpywOe$Ek+PjHaB#j~KbO8zb9KJf=ylTjL701l^kgZTe z(P-^*@tRa@ZYtTKLO4u@k1}RrWc#@PWqGi+<`Fz`Iu8A2Lq=Hdg5oL`oQ}zi4NAVm z^=YGfcd2AP7&`}nI0ZV#O-gP1Kjv{ykLYpcurzyG`chP%%k!)AuJ-+G&`ef=pkW^YUE zSTTy^24~8Ud65AE!Mjr*WaPHJBtt#<{26mj+GF)rxcs!y(oL3Dpj0zMkxZ;b=@ukCL z(~8jkm7|++AC8983I}hvOKFg^gs`5CG2z^co!aepV*BfJ(G!@)>Rl4y;(nE34A{tu zvBuziiN?exHfH=)6fBgK;moyR-xe>ZI>0jaF2uBrrvO;I1CJ#WrSuL1L4GvW zCes}lAv40#LKiEW^!pj35{6^g8I37{yqlqAkSjb5dEHC&6xrQf?VSd%B*}3RfDf`) z)I$N{y7NYAoz#TAjW|+MUM5L5t)`+>SvtiCVofXNQd`%g0%1I9Pp6)bK6)OxV8Bmd zSJLM`W~DZfJ?Xv2N<60;?D%RQ(O`(S#^9Z=+K1Q5sHmzn13Jk74RTd&sEKh?8@NKX zS2p^57ML|T-jyz{?R#2!uWF1-UH?DTB;gqI=FwzK?Wv+#gNYOOPXkc%$tcazbXS~g z>-6jAuZpQYYR;hbA`LySgsv%~5q!q6$fxC!r%fDPwcE$Eqds z-d93;m?+bmeI?X4ESpVmYPvCdQ_ifljY;Jzq&3fTlIvf4K=S>$)~KpbAv= z#l+%{b*fB?kx7Nl*bqa)051;FU|IL#E4Pmz`PDrlnp|>QwT~IvsKMH}<{F`|(Qvy1m4gY#HGWsel z22U9>t3Y7vM7B;DmE@pmMIZ!0ypO4UYe>LL3_yvA8(!KR0}Ckm!m1CM0gRS`f~|G9 z=KzKVCCrNp%oPzt^EiKNtMg=+87!H#jK*U+oeswKfSf#EU}M03g#+Wd+aRzzyL3B64*bx#MxGK$a+8;xL2ai}o zWzl@YAk&7Fs#~QthotOwm6B0CuLc{^J0vo0 zYC}u}I4Zt%+^@XBh{Xp!NsM)&NZd*^D#s-jD`Bl^V}B8!+@qzcLyAM51#LYFpC+vu zjMQt-MBpVb@RD&N0J}1Cmd?R66NxCoq8bf~7}d5f$o zYGF3j(Kq*hAHYK+Cb&i=peqGLEnTF@)@NNZc8Cpubt`43s&m|%Dke&+gC=@D{gLLI znf|W&JNAu4hYB6C(Qk<4-7@uR>Pf;Wqo)U;&yDleLQ?;O8XQna! zZKGr9Jt6^gQVHh-5_S|JxXO3|XOuE3dIrV#c#hc)Y8*~WI;MkqWn*)PkrNh5%a!XJ zIQ6Kp7;EsO0uzH;Yr_yglC_%gL+4nqRXt@nW@(|%;q^m+#e3l&{g3~IuYC1SIey{{ zE-8TNU{2`pE?RqSuuH~8W*TYdD^>GiM|ctw6sdt^h2RoTJol{)P;y)w>DeJ$cmOfH zL?fX{j9pveU1aBFS#)g=@@#G`%B`>C+fh{JoS&_6ooys;ySg==qxJcdN>;I(YxhT! z?5wJ2831Y3@P84-L>JfyV<%H`#VBz&)CYhus)MMOLBG>@{>Li0Xaoq%?f_5+P&8#3 zmk6?j#7#<-ast(`CX(xDSRPtOOq%FEBZ4&e2U^Q=L zmqqYy&4-Eqr}kE>O$3chcnLmb;kpET1;wxebHjIrEDJ_c=R^1s(_~mxEG*Md@+T`2 z*Q23rSf3zEI2UC%39@JaS$(D38dJ}oG;ouyZ$_l0|C$JdB|SdNvX;)YTS*4_efR$! z;LWYL+0Bbvb&%)p71L@z_e{QP79(x9=QQtwX~x+k7&nhF^KLL16L^*W4j^NapK8No zs%th0L;=)+=BOz08Uq*Z=GwR0XH^HqkIS-bRm2#JwU%zT6XP=!(aPIYzUB|uUM_aC zD0b}un$Fcb3+YUMmt|RjZgU5tQKyuftI^5T?`k`c8SfQpx3lWrwr{SgR_wF%OcU8y z2?z9XEGsm7;$2NJr>eKp3IR6~m@cW!DVTYn_-Q4ePcDY=OkV|It2WMdN7XKVR3cKV zADY5;0z)Q+W@C~+Iw&ReQ6kl5Trd9GGT3$*4fRRvn7WEC0%*xHDb@Yq-su=?8X0V+ z1zx2GGV3g#Dh&{SU({3fdF?@bn&&?x1ktCV+*N>ivythbi=ApB3&@M&L@C7tHkSle zf{(0~5KT!iYO8sY$L-2cd2U4a=LD{~>dhID+;c<%1ZFgt=>?(y7A$)esHTdD$7m21 zTof9OPBg1gj1k7eF~)W%)$`x}zy5pfy6Y(CFP!7;w=TpiLA5fmpYgff<)H8LUSsl( zFn60^v8v+ARhqtMJ<_RqCR3Ae_7!e;q zK!M}D)#{lHCltif{QG!7^GjqUTvd7LDhVnc3dk5szWFa7)JyNaU4uwNg88tTE;b` zU`$Lj5YPIGF(>Uax$5h8w(LikKFG7+=I4QEWanZ~5=+CQ5+E((wK@khDz#1|s^=yK zH8vHa$7*Eb;V}wc49*yw5xfb#o)ra|TD*2By~B3|R~BSrVSVKg-}vTt_)q@R|0A7V zkEg!>U7QsbdOeEafCV}jZz!a}F4&`QCX2}o-F}ZtSFZBE{rbP;lb`%J|I`2cKk@6o z@f)nI9SQ5y2lI|p0DsE1e6VrZ@8^4!N=}^h9W}P5Iz6|vGE~&hsWV>@5;J)XOs1wG z3VDxDhnp`q)6YU+Nd>N~M`Nbo;6@0!0LEZc;@l3&E39)^4UB&mjdvtfKgQ?c!8$Hn7WVJZFrLavB8Jx2OZP;^Uwp ziyl4x;XnLu`0QuD!Hvx=zW3CRIeg?e&I>lnt2oCAe52wRH8qC!Vt|8(!R)bC#6~u> zm;f#s6Ah(7<_Sk?^aYgQ2_OW*Kr>YADN)y8 z7sW9V8$xRoK_|~`hLLE9Oe70UhOF4Kh9OQg5;%+rAv=bnz}Ql)Li3o0h}R@kB(VKv z2r7G>HHi&A@7fA1(HQq?58&uP(_)etmd0X|Br@xL&3pEEep9EO#h#i4woQA?HFfpb z*vUcr{$@)Pvft0nnbnnScVV!uNIiLPR-kH(Mpy*3u+|K^9s**Q42MZkYG!LV!0TqP ziCP1ovyWbFNsP16ViBnpbr$19q)D^eLA9H^TwSmEmYWs>CT?1lMYXb2Qd``pC#Uzm zrtZh~`=+(NLN=%4Y&8+vX_q9eWQ-UMBf5%AFPPxVU5V*)86qtNSfsW2wA&T{qncq< z|8_nB7@>~IV42$Cql-~>cr=P@IC57hWu<*U)NT}{5eWA>AFZ5;?vR*A$?9DzB?nR0 zZ+7!M=(RVyA*r<6(z4^mx#X_+-97|gOu9|MBVE1ZvNirW~w2Uq^h8j zMK+qR*dAJ)45qQa#JyjA=xYRzjZ9sQ1`&fx3Lk=KP_~8eJ~5VYS@hT}1vEMqh(`7i&={|C-FN?)?RzK)SBdLbm{ zcr8B%R^rVwRd0PCYs=-q^{kGUSncjfW-`IuD~k+UAA=NTSx6#MmVq(U@AVlKC2m~C zSfUbL>im0pF1(xloQ(W|onspFHL`V$R_@`V5+R)6&KNUBRc_NNE+&o$QjXOCUX@bI zO-2J}UgAli)Uo*>8P5YqRC`|p*1OTb0tzxh2sL4hBCZCn>V`Gsf7&3HS3{we7higV z@BiQ*Q#$3)`e8c##b|(UD&|SO)yCiNMTz=c3b`}O#VqO%qp zRn>5oIE{S$dTSs{1TTiX(_w9GjqBI1S45S$x^1iO^UHqEl$+9*_Em#}y?biy@8Xs>ugqo19<`7qSU<(j-%9mOECPJv3h_s_HED#?WNC zB_+FgUlq_y>TwGhBa<+vI9I#SrGWh5@8sJr^^9rtbpsxoa5HRu7Qu;Xn@gZld{omW zq6C&oGhK4C?UfP$RV)S3nDcVxxCeHVU=Z78J=JX4^vvi^(*&%v1_E3EZ_ZIoiySKV zDox5}viwv~Z^dd*7t&6t3eIR)U_uoTJ5aLOcR5JFzujV_?e{&mst5(QyXf{B$1u1r zxr%{RB$h$~uaxL65XaplB|O9!K{PT^z^TEh$9NaOsR(oik=RFXL9OHi<3k*<0BMDB z6Uh)MQGAF?rosN`ZStEpasCB&xtU^8H76uwX;LUkhcO+F9zRW%1(v3Fp3>{gJ?fnT zMs7z5;+BbhY~mgitg&J&#&}!|whwbw;BR+Z)>yK8%dDHpIh7hCKCz z*d3`4Zox?MPWQz-Ha?~XS0x1o4~n^}RP03SGIAf{i>PtAOSh#bO*>2yFml$`lFv7PvuqmIaQDH>@8%f%>2b7G;rmeMrpar{bv-DMb+Ly;y^i z;&(P=j>?EySw#bzL{nUxkX7(juW9?5!2`=9$Mq3{G z))c|iAQELr!AB#W5Hkr|N8~$2(IkKPz1B5)7$z{TdvBYW>pK(SrYM*yFrJhe#6wwcF&?5&=gS4SvR$;IbU&g)ve+-$z4m zWd@8PJ0h(s=v|2fVnQ5b<6~WX$O37zjM9~$u(Gs5ZZodmxK7~;qzb(e_1=b=tI5bx z2(fdc0n$0gU@&O870zP+RCT_I72qhwvWr)X@Wmy&SBYifjA9w5HX5a30-=5tl)JBkM+}hx2C11tAvvg6b zg7uvfy&jo5@uH@y;dCpt0tV(azGfxWOe|w=A%o#Q2C^B$V|SM;GtElVIwL|tmQ&F5 z`Oit8S4l}}Zg&-xv*A`#8AuYKQrDmb?zO?Cz%WSyPV>-DE}8&7`4l!7O-;*h-6cX% zgw*y~mQ^m3Y5c4;RlJX?(uoEZ>#7>2K?=T}8dJp-i3e~BV((pWHL@`vcp?fX0JQZA zX&j6#m!!%5uY;5armmUU=a8~5CO1M*bpcFCGpEsKb=$+|*4hvYz`Nisr8ZhuV(>-k z)4OR$z8z&Zm)YA@W^GIX6sqT2HBTiSoPi~hV$`UFOnD~8gb_{0uU3k780+{{P0R!( z7sW^SLL@_M1jrh}jU}d8HJTtx(*iElhRPWifC80}4ajJQ6^UR~j0+?#TEap+Cus=n zb^o?4V9}9_Vq6kPNWsNY?Bsp#%oyFu*iL_zz@}i)3$Yk{0HlgCvO2^1^$AeclCmtx zM;&|$R*c}B$9or&fMnXBkW0Ov-2q+k?lXQAZ^C|TG`KxUw1BG#F@20GCc175DHc-# z7Y%|VkUqnjvZ~aPt-{K*$skf$x(p$(I}`@IvC&bbiZW6Q4HAOIr?r(JtY(?zetGAy zr*6-9#ofPBPLgZ8H8v`6REcPeF{lgimy!)H#DHrCw7)%lb*n6_SwiWpKJQ@fRMh$^ z>kwk_Mi3v|osz3}NGReWKpXdKk&va#3)TlE-bDinYHHvlMpU$f*jI^N;>zmpIaIM`cn-0S*@DKBWixW zC!1=9{xBJTd$+zx7)H}8tch#v#>#5;?>nKp)L^%^KC8l5HHlQQ4p|*oyWOS9{V8pp z)!$pzw=nScEGul2)ApI$Kul z?RL9ZYuVb~Mxp?T1Yl@Vxlad}QH}1QQHV@nj@B6}LZ-;5ZW$Ye!tjwvQ7%O4-GS&1 zlMZjykcc3%s49kTgFc&R?$!KOt!U~<*Z@kc`&a;@9`BR#R;w>5-U*G3uMY4VSxC|C zD!6d&LV=&yU=LcR;+F9aZ7wq@r73b%!~Dskue|pd zb(H|1F{_c5ic<@j3E8c@3xZAQ!6;eP4}z6ExHMELEW;Q%O{jga0y`1BSTxJgatuXD zmgSffd(V_4K7SClBqm~N*jb_!mfWqawGrGFjLCv9&-^{&&MirRPDu-(EIngiAf=0O z9Rld+Vw@R?Yw_~sD*@Pd9yLM=#;b9CRm^&6kcAuI_j}gjcN%<|kcd<~1trE>a?SCj z;GAG{L5xGR#A*yq9-X5!#;O_@9`8%C%wY2n(D} zT&ogm)oK!y6YDU1;gGl{LbCP**3Rm?KZ_-?o@>KV1QoPSDw06i;0|5KCP~V-M4?gD zBi1&Q{G@)&H}t(*<#xRDW}l%ZLR9L>yaB6}5?p4jS8`*pV!;8aTCLcQu+dGJ@_$r$V`tyg{;%VJI`qB@R_C4>*EU- z0>u;M+g;}W?0rRWzC;zW%mg<&u_5bnOogKAs0}4rg<%K^%dL&m&4O6d@_LHR6~Zbw zO+c2f;w%WZGE7S&Ifg{mX)=nJWr4}^%7B@42E7B$hIhMEK&5w;6<7(G?h5A-@gcUR zdeAbs#F7#c1o@>X3QDJBCMU~7OfB&q>w_D*QG*jjZ32pgEV`+C69J@3CkW3D|0@yk zcqlQ_G1}&zSn5)t+2? zM7-;zX~S=x5mAFi3?{cB&X-UFK`~~rB+v&courE0WMXDtU#mWv0LG}dS+Q6fb(==q zIU_`1GZwFPLMT;wy&m0emn&DUg!SeA>i9KRR&G^nodDAq|k0w}^`f(;(szJusvosqR$+7!A3+I7Cvtm`n z?yhHAL;p(w_;tqp^Zsrp%VXyI-|5l{U9`(9YfP&C$&zKZUD*hP2z9dXX!+f%^QT?s z>chSL-83ROufv*I6oAHLk!oR1)PClIsgPE!5mwd2Y-*s4P1Z)D`9Q@aNq?s{HQmo9 zE7_hnFt4Vq_)ohG@N_>l2ES3)v>ApsY6SF?`y14z5d~MXyPAtN1mIJOqKp|LJ0a+% z>{M~F0z;!Y0C59NLD?t4qzd(|yTZGWwz)!v6LU}N)+MrRXu7G7E=`P%8XWTt;VM;0 zM9~fl(O|$q>U~3n^(i8Ax@T<5cIoruF9tAVhY_5W2pgds*<(9 z!3H~Sj$pQ%kMUmRo!oRLT}o9H(-`;K!6v1{m5L?j1-!}d#;{S8j6|W|4N^>5AX{4; z@Ak1?@Dfr>JCy6{*+J+mEtA;};1&}7;!@a80li#dyPFBtf`Ti(VLuS8%`nEs{WBUmWZsT_kovao zza8w6Ta5!WGmvMC$?M26!%#fe#$(E?PqwneXlsMcV8luXI^v@1amdO!QrG}k>C^9b z*xKI4Z;e=6=#hJe_hrZk=0Y~Fp|xnghmOYN-p4GS!CG&P!IwgwVja2?j4;S7n_}=L z3*%{fNKvvlF6cKz$~$@f-pAJtd7Ac&WCtSr5oZq9X7q-`wM8N z%TBqC8*X#LWb|Uvk=8n&bDp={{1fmK4IHW=GoNe76B>;`@d=T!?x`o+dooNxv2DIQ z>JMIH*1;IB%*Pm|Rtz?JSSVB(x6^v6E&q^Zc`#sV7_hXSZpFa&{w;y|-LV;CUoh~V z?Xskl9J{=Xip6hiaws?SjE3YKBB;sPcFJZkrn9z4-s>|Mj2Ygz#^HXKt~Hce0s$CP zBNPJk7EtHtyHNmhOOJ`NtZIQ+!I0q{kQ=sj#7Hc@+ryUyGTPyIe-T?0!S&Ec0Ge$u zqT2-TXv0LDbg6iK3P@s7LaIi_SxM`mNsVb3?QhnP@KzZm$y6I37V0li9+aR%skIeI zxm|fM6&EW58eOJ2TOj6P8;Ni*O2K$$lC!dw#AnK%y zse8+J38QH0{gZR0)+cJ+Ip{yGz30!UXLWzmkULd}_IRc9V5aeAG-KK`4q^paK{Sh8SZa z;vetvwdFb780L_VhmTK_FI`Wat;|pdG*qQQU7cW}DPWWo%PR!T)(Ff+8o#k*2u_1A z+{CYhfSV=o%X>%RODq`Dsq%`5+50OJLpQf(w* zTs1K7Obl|GKpim{^_4|ra=Z^x4iW%xKV>)n=Be*B2^~JBRj$X(#Pq!hJo0KNGQ*}< z7VbLDBOm%8-6P95<0-axIRD)ru=C<8taKM}uEbbjB!W41j1N8eF^-HL0@N;gh>tommu`Y}L?zlPqjNs!Hifd~)fOkTt56zc?x) z8pl2zsmZuxTMW)P9}?3sDL&yr%oI%C#d0%js+$IVRd5Jngn?03@4bhQKKT*s(h7wa zMmIKj`TO5zc=-Yg3Qh$z8QTTi_r!;}?_(cfVRaR@2b_E56@Kui-(*k@Lae@_BQqVc zZqDV=4(pFU$`hadG*A83?=XDx9DNClI70zsi0NS@xHR7I1q-L|<=!Vh&hlL+D5T_# zXP@THuYH{?8aYB-Cm)4&GqWFBYoqDbBQZEO9+R0`UPd5hQcUw9JTt|4e2?ZvG&mRx zt}-YFxu{Z51vG`=Xv%Ww_)Lio8Z0;?1~Ktm@g6lvS&Itfdm6C&_LsekBq9D+FgOfV z87*|V|B;V!^1g>yIC_GyD(9blh8Mo^HLR8)gENMa2;;>?KK4_e;>@G>(OX{R#--~# z{mpOj#`k}~YB$KOMu^!w9XqAxlVAKfj97l~2fxjN_hcq87qd+8ZiGr148W$#z<9dH zj`NXEe1;=;-^Y!OO@92%KjrERFL20OvZ9W6RwaP)1i3)c&Zd>!Dx8QZVSx}hzm;l@ zG=SWhbeRM8`|gB*{TI_PwkNpyvY-FJ<@C$F#AW%V^O@r;A4iv&`pXNBFU*>^hCjz(0WX^d!Q zJJQ8k5moFj{LF{{p*0#NbbYCjsQJ?M{!CY5FoY@0|^x zH(N;S9W$?%l(D5Tc~a%8Iod$48(%gbIWzd#3>xIDMcDjF&zhWw2i-86@xdlRF)ATq zdQt5gRc#^DX@G>}d!C->oC}+sF%>G^GKN&Y(k9I`P^RgPaS{5saVwPtqGAP7TOmRR zG!L~J5DI{n31C)Y{WUhCcHakJ#XC&fqvLq;0!us_to zYC_ACvhGaA4OLbjc!)3j>i>?F6UP~CU&R>1;Zt{W&jXKh;mRf(S1+?@g$?h}lXvkm zzxH=I^U+VTeeDKX6x{Rp!yG$(k~dy?88;l#Gg*icWZ;S`dFXRL#V`Hszs=R*fR~>B zF7}*=z1Reg`ei9e)U)A7{irU&T`^|AE0yk6mLBL4E^ztEP;<= zn!L=m)v5Yv*rp{a8@u%5olxeEaRm8R*4J5CS;Cbgq#R?kBptb`QYkUfqcHVOb=ZgY z+i39;T;7B365BVn*tmQxcBQn0tM_}E2l`H~t1WemjveKRU-(5n@Uc&Wz~J&Fj@@%N zXP$hLa~IEZuFxvwpGh80+@Yv^nj-UONU&Y}#fBtP&mREWB ziI31(Kg8KrUnDEH=w>-8hCxxVm0KSApb1%Kp9 z+|iSK?63YcPTqThH_txHxGeejr$5VB46nWYHXZNj5@q9B$eiC!h_M|fu9KPt-VRxt zWbP)gsr7fatN-_Vef3Y!6KtNde*6fXZjLV>8E2{Mt3 z=Z;9uwaeGoxv_aW2)cJ%*{k1$5GrA3JY=xE$diBVZ}5?y`Wy>c&h=|oSzKM^p^tx@ zVJGL@3(v5cWeg?bs?GWNzx#Li@XvmZjq8_q>#a9fKX!}{eEbuJp#-n}41AA9;vZUVV|Z zyH4?`&wrk?XJ2Id#&xm~)d6X2VJQJfvT<3BpJ`e%O+}6N{~?B}@e{IEj3s*ubK~aa z4Xtx!CpYoPq*z{CWBJG-3M=?5MOA*1e>oj6a&X<|C9??q<13PyZ)>a&;=yK)5-^{lV1g7=LWSo3y)YT?v2kR~YA zq|k+N2;kL}8%1OCOb3H*_}I1pviSqD9}DolU9l1~>j3L#a$?N_s+xb#qTKBl(5i#9 zwby-(K<~fa6U|N!YS8{_(9qbxFjDWanI*HfO>?a_vL?_q?M|7frUrsV+O+$m72`lc z(B}Y>#myEU*jk>-Qs8|ZL_fYs=L|94lzMb!jn97RON?{F@BfQ`&hYgY@cj-)AAXE4e(6_u;B!CAx869*c(B3d zLe4M#%3o#S^l5(g*Z&0Px@C)33bDSO1s)A3HCe zW$C9s%NPFMf5bhHKgOl6egz{r*5uJeu-)F-rFm$+OWl97qqb`W)4pDx55b5UTrn}p zMX+8sNiK8h8)tTml|`b!6B)A&A!fJkd>@us)~2RQJ&|QF6vMrr{XF-6=5svtyZ?@t zzVc<%4dK`ke)@m^xA}!%{Z;UBo65AUC4y~-xUq8(Eo`0T~zWN{N54R{6dMqz4QEpz3E+v0CR(Bm_!*U9d7n!%*UkCon6$B?&=|*X-)Pa6n_#LCU)shf4@CREkcAkA3kk z;5!Ta)<65l49{O8(~P69y~UUQ?%(B!kAIpU{MNtb`gn(xd(QBgU;YdH_{Ep_%D??r zjBjj^(c$A?`~^Pxb3ezmt8en`Z~qJW%Q+t5#&FEZ4}OHjlc#z1;suU8^bpT{^&5fx z;XEZhN)J23F-x6{qEya2_92!}-OJbi;MaNatN#wPgZ-=U$v^*>c; zH`2tSOYHLLvp%v}Q+(5uO)&WG(3lYM@?;%o#`?KEsLrjkCeztaXHnDCmgZVeDh7Qz zTf|sg8RA_g50Jp>CiX*P@BFiLK~PP= zKF^^?Kge(WqyG=DJoQ~V_dmw}?Z5iJ^Z74*k$?F={Ld^J17%3CV~kLgWdJTTX5Z74 zT{=WnoZ{B0X=%UV&*VPa;=7WJX$L9ubjJgaQG9<=Uqn*I?IiC6fyhg6VXFWn^|j7i zQx*X#7E}5bHc|CEiKq>IDtS3o@qdj}2_YN}R4a5$Ok||hnr-Q}A`OqD zx;+VasH-wV*KHybCh(0k1*)GImw9^o%>Z1p$S75y^;z3q>0nzIjg9E@G{h zB#^HeTRGhuNNc*w<8S}jJog-kV}FqMC3e|Neu|41$5m`C1PrPy22QE2}*G3U5K&c1@8iD$~lKi!k>hcpySafWY2I8 z7h{KpgHdoAXuhS_K<%|5oU4jZI7bn)qe*o2Bl>xJ?<$Ky2zKXLU0r2uZ4IFE9WPvo z)~hHy+Yn*5AlS_lG)39db^lg2sdrNOm~vB{#3Et_A*=-~gR$f2{r9te>MqW|@IyAA zd5WwU<1bz0{CB^>+s}NT`yalC-a?N{moIbVf%`fB*h75#4}P2TfAl-77F+Z-uk-5H z|CEcbyun?MJ<aQTP(P#|=HF?s)*aw#qlY^KF)w7df=NMuuTnl$4p}nk(oXKg@Pk za&5TDy(jL)T-oH`|Kop3{?;Y#?yj)$+Bt?dHds1(gi)tM5vi)7alZbkT5D$hh?puP z&z)9wi?^3b3O(PzvtxkB5XRfbV3DQ@PA%2iLxX#q3wmK#>l)om0u3dS3Yr-S?>r3a zb^$aMy}EI5t16OrdElcT=dDW@`SBn8HvYyXmT`<;dx01K@V9vA$YCD+z!O~E-a!{~ zKJb~3^U~{Q`P1M2P4X+3IF@%AzI};j|M;sMSzP44CqBT2FHrB<8II|#t#kjq_w&}v zukqa3*Es&*1E^Z0aELFtGTx#*bC~r{Ji$d@Fc4wvJ>_W3o8SE|8&7`^|Hkvki$7-Y z@dIsZy zCqDUU2BRIm_2oZCFJEANG$OxxjURsF8@zSp68C@LgY+_o8NhgH@@+G)^-g2?IEu}^IUyiGdK=T`8ApQr>^!iU>o%FRya)fk(Lf_IZB%wXZUM z`)!W75_$72zW;l_%Np>34}6F#gE4aS7>|A86TI|;AM(t9_zK;v9o7qFboOnY`SuT3 zK6Zkm_uR{1G$OYa=aize!08WunCsWBa^>Phj@)-I<4%|ELLXIS;0ijcYjh4BW8f8~ z;N<-e^6HzHdF|O(SSt+K#j9L-`4!IGeTMF0zmgkV=_yMey>T=$sI*q`XsCJO;{r%N z*Czasymm~jcZx^F#h>?fm_mIf6zhg0Q+b8xL}-*W*VEjOKPjeEVkv5rud4G$!kX|R zLH~|AxEcdC{HP5vd4hYgsWKE}8UjeFUqjV4BCR;D)fhDasp?1Cs(M_&Ta&lz0bvlE>P)!^>2|B2iCMcry&h!HP*A?kb}ep z+fUJXKO*zLV;57t5RyU6kYQ&2Ew%or&Eqcqo&`plW&>O9ezb)p-vCTnE^=u@g6a@* z>L^P$SNLlFow60wKl|$IxzC;T*`ig5q}S`Qy1I(BmhpIus>VR~0m$=}HOSN( zf5l3xL;USEH=5<0Z~?qsrKZ;U`bU$sA?^j*vsI25Ucbmy2&a5y&{ zaYLT@>L2m7-}`N3Jf?7-m6N9!cMX?cdX8las}vl`4eP)g&p!`KORU~~n!#`odemA@ z-giIJ>+{?XzDMQ??z-y)&KkTb+dDfPzx!_fumANw)EaU1p`OLG^RdpypIMS4Z+*1 zF>V;al1oDRe>3(*njv+Y?yQ(UH*=mGk@Z&8YBUPwy{q z?7oNCdg}sjfA0r$7M5AK>lB4AaibBco{xRzv;4>Z#edGv{>8t5$qX-j|GS+1);C!g zmn>&p7V{3{t?L*!X1u*!DMLmCGgG9$U1xBzLfiOrXOzSo)C#mRkwK7vs@Mya(Im;N zk-;v3i3K()-KOGm*S&eCo(}I|(UQWKm1HA7K)PNy)IXteTIMj z|NZZI{gs!n{XX5Jhp|fwTzmZu76t>>$XF?qH4InZI?v_nn;gIA3@$SiWx>wofYrP2 z<>&(s^TH2)#MvKykCP8P$f(z6YiA2<48yYEr+(oV`FsEQf5D-nhuPY^PM+s%?+oa7 zmRQRBm}1Pvl}m_sco#DgHdY-uwCEFVbYqN z3$;E2$ruq0{mIg?k5b1*sspJ{>RPp1US#$fsod6$4PH>z*hs<%p@Eugs#*H+@=Tt2^Ums71ebXr(kzn6EE36H$%OiV|$e}|I%EP$>+?#IkNOe<%*V+_!)XvwJ;lR zY%PNDN32E4UQnmu+)rn;8okH0`?+pEZiTaYQvGxmsb&Wrq^|Q8(m^Lw`&|(Sp8(aH zj2;c#?EODq+RsRJt5UlrS0W8Ab|%KivRM}V!F>R@Ok`<3OjtJVjvf-x3Xdc+EO*2Q-_j5|4l z;SfV`Xuf=YRAx*Uw#My}Q89TjzP> z&GRhU3}wWQ8L)KsF<4&a+U2YC#x6imYVpRSE#_4NiJ9t=rXA(ZQ$(A;=Ut;3lnG1{ zM5SU@5{8+$OVn+1<=S}` z*4D_nU37bkakq~>eVW16He9^G<@26}kAH&hp~DQWU58@CVyDM)e-$Ib$@L?g^Usi5 zVH5E8Kg6S_{vL-$8(ew$HQspYd)R9?SWu-fK>^vTkKRcawA~Ct2N{=xlqTp@W!Lqd zkc2nIE3pBvPZ&dV14wbNgc{6Jk($h@jSRt6-S5n`uv?iIMq%3M)^3{D8PI8@zy z`_7eHT_+XmFv(t#O_KJyS|$@9WVcfU?;ME5m;?w1fZrRbxCk})@sm?TAs0(WOPn~I z?%+ooP!7p#j#p(+j5yM@T>8;d>~t)2l>YJ(TZ0|YAryl^>QPTG&$zL@joTj4U+6>M zG8`8m87Ck8FvG0R8$b9V#}BX3Svf-Q_(^1Y3w0xgW5>qMi1p*gSYBR4h9h45-gh|h zsn6pV7J2jPB`_lv?>@x2x87iUW0PDK0*IekR6M0~WEy3G2wtU`91_2HtOh=7&^>H} zMIqI1oik5w$1Z>K=hx&tlOF{cQ3>&WA+8nKh43(`^jFtK_uE-+8!cTYCLA(BzG~_q zf{96X8hfxruMr>IK2>HXQ=4|Fld1RD%$dfXuYswblK3>++Un!Uvtjc6GeENDb6WQv zVBGh*Pv+EPQr6f%#xZ^0ym~a>?WJby-pYPB%lJ5}4dF7Gdtd7r>4wQZXf(HC^zEba z(s))CX}O}W*Xt-tY>}!Wvlie1I%0YdYwP=N1O84b={GAx&qIl431oGasreIRR^RQ0 zNbl7*Q}H-v)bCbG6Sy)>>UZ+(j+tkShPYC`X(iajNLVqpVxP62J-MKKCD==qvb;wB zohn)st$v#Q9A#l)fwC;w-rlAx%SH-ksgn+nEE}PI)*8CqE(;3_jK^cFF~P0O`=F3( zi7^@KqL3**Qij9*?iQH=`%xI)D}XDUqhahq;>H*iK*44PuLZ^i#?IJ#Itxn-oyP-N zCudX&J7Xw&OLV)dh|5t%xXhFHx^(h>1PDt+Eg6vcf^sxMvJRbIA63V;FF1D3-SiJF zbM5)3VL0I88?SNC2Oh_sJc6D*N6*29S6}C!{`hI67_!{&AviJ#86-E=v9q?sr#|xu zv>0*i&DU9$j2zow*3}uI_AfRy?|c6g(U$BOg`m)$5YIvIuE49qm}omm>C)r6_K9E; z*FZ_M==pR4zd!rtAf=h57V7gi0C*myM9U$lvNJ3gcUR~gT@RU1ceXLcFxc54>va)4 zIU(z6sft)bW(_-o9frdlx~r>Xy$+ifZ(vWK;m|`5a_!1xcHVjumixHz4o6R)=CwE9 z#8R?#`7*!$Kl~$(tRJGdaGvFE2IY`WCLCH>z->GFt4n<3p~pCU`~*+`#=pT22XS6Z zkK3$s@!gE0^iC83yt)7+h_4s}k!OW!gJ~7#ziGIkry#s3riM3dOX?H_U(FraX9o#c za8uV9p)dy1U%-n}YRS-hN+am!nP?R9zm<>&bEi{EESY{+1% z4qps7{LsDJfA^2Zt8 z*sf)3^9o&HR18rAStrB00_VqIJ7FIx3w$xcjl&%GUCYjBNN0JK6ZhT6**D%`eE9-o zFcw%nd6Mx<&(O6QYb$-e`tSdcFMs2!*c+EQyt>ZwKl(l^k3Y&^|3CbFip@=SmOR5e z=lkFI4jm_u3r>q5yOjktWu5dAgSH13xJorW&;U2V-gi!R*n1DFUC9%u5ES@m!i{b6 z5<{BA@CoU;CVFhlB3Df5#Zey-)dsjdQQvCb0N$ewW>R8r0ocm$1-^8xXVfCubcl&S zLIEU@1)4_MWP~>}K$sMAC}pGHO&6X!78CumA41AO&Y<0N=9#*@Nc#-m!#FeftQO;~ zfg7aznlj_mv#NPE8+5vvvbQmNe*j?$wa|X1OaZRD)j7dWi3}w%K3q^E;*2x3fjmnV zaPJkHNy9}fbdssEM8pHSs;y}#tZk-C{4grGvxQohYL)JrK@Y3Gn^>>`$&=O&V#+qpg@Z3s0Gh**V#Ugxt69Pm?3D zBdQz21gJJIH#I&@WPG|l)X2BGD!67^XhsC@L(1EPs=Knp5tvP4Q$~*2{X7Zu%>y#q z6wuhOK3WQrU{kd@eD9|gMPyyYIXE1RgGaKYJRXJ=$IGjRtksh09^3=-UXQ%jr&PdZ zNZvt5BLo{PjgdR-qlH89(SqUQJQWHb01!cN-eZN}$4|{_n>FtHDF?fL^1#1&_coyn z{jSMPE-GlmIpQ2*g9{;I$Ze0JR8T`vju7o0$@%o#r zV;Br~Sjj9|*1>#o{t)*nd*pv*_lb<-jlfU$y z!f-r}7S_EHjkGGcxetCw_{1Kl8I_-ea`2#nXTI zZ#ehdkH~C+k#S&Nj~wDX7El}FU5YBcbd+9kncyHihkR=0l(0=mU@7OUL$)Uq;C=8MW6!6;LDg zl=_+N)_u98PsjykjzV*b<)G`NnUo;Oy&Bnvn)WqZZ{@)2b5hEK>;t8Qav{BP&iOEcnm|M-2a)M;l7{y660Qn?Hd<)`FDRE zUU&wNQu+{)7!!1?%t~;x@dd6J1fUqp7{}@3hdFWb2w(p8chT)DY+fz6v~ii^_n+bA ze^=7$^%;-G`d2aE(ATuH8 zy2N<58iTn*BR*)*N*g`UU@tc1t9N)zX0dv9Q?|VaG0_tv#h0p>zmIH%r&bL-2~%{ybngkSeKLq*ZbPF)8--kiI3hZVoeC6 zQYDp$ec^*q-bU(AjanteX2 z-98>U;-~v>?i!H#y>0!cdO&Dn?DJHY3czd3ccY+@ecS4z@NJkwmP$NPh*_Xk~;&~xILxb zV2zsS>Pc*;!TS<1fjaGcffgkl0gF0l(s6%KJ@7S>O*eE$QS-x@O3F1=%Sb9u*ee#dj_kq>g|kG_hC z(8~;?@rXPVoDZ(B&UU!Gv&~%}`6QqCb6?_(XMV)lKmIDm#G>kO60$3qy`t^kq2*Q> zdXn6pTK5~&yBN@Z#~&5D;+QaIQ?cL8oEzWy{44d~sEyKYW zS1Km&aO#1F7<7BQT_~NC_rZ$cnb*(r@y9+(|I|H{FI{6VR@>wT3ND|}xErZsVU z3_!c3R1HZ<2#QZ8Qib?->3Y?JEQLQaMHIAjAryfs{Z0?TQf!Sl`_fs4{YCD3_(7ih z*`Mck-+Ya-C@6~pn{_aq4rZuW993vwYw$QqKVUfA;rK)Mvwr#>UfkHESU!SvhHIme zp(=Mh{1~sE`x@)U({IE;Rcje~AzN7It+&qc;y?YT96EVFAN=q~c=!WPaOvzTY(DdJ zVCXg5GGUau9p#SO^ID!7!C0Ny0IaKb2p*>fu@V#$t-&qleTd5wjT*^_m>yim&vFN@ z)}NK)yhp5|7!K*!4w>mwy1=H&GodI3Y#2Cp&x5Rd`e(TI_S+Oi8MHU27!8IdQIC!t zA{p2o-i`3SWayNWk33G9bvb|aI&SqS_O83QIv#QS*b(f?GMn2w7%TL>p__HV2v-L? z96NmvYln{V&;Q9kWAg{ku=3H5@oT^KYkcfepXR&2|9e3$^C2U6P!l8qBcW}IREoB= zqAT4w@j@rh>v`;)z>Fd_EPh^xnh5vB{znk+aA?R}9{VGOAenqaOFsz@4lFF%y0j#H z+W9Dxg|5`7r)diWwAFns{86<90#mFRLM4GH>g$j|RezY8O@^87huxS+^BBDe=#h!* z=axP8#a)?0Gh74rD=*J;XS|tu9F#?MGwzbjKHZb?_6{+?=Y~;Ae^VbR)8Uc`vKJ`{ zM9|B!c6^?6bF!aLVu>|nG7Pkx_Sq!TBjbkEo`?;aTr=LoWM@y-XZniCZkXzrYJnu0 zBYy6+^E!9xS&a#9jG-tBwzjtR80gzb?t7kb)he(qu+1T9T0h3s1HAnByLTPR}mE@0eajhA} zm@I5O&Qoq}u&9ugo}Mb3Z@tXx&-{Rq2**D2(;WUQf1Uo~GRvkcNL z9JCAoWdgkC0DBc{l<02N2tZmVnU#uGpU#x`YuOL;s`@($#bxOkTrcR`6;>8jaaXVM z#y9_%?b5S+;v{$d@~<+=b4nvfQBV#%-ToRKYp{NdW__F*oXzPht+2H*WOQSj(}z!T zE=vwaPU2qv5k|+TP1#(5ypQRwv0_V>MndLFHg&|M zb2r$zutA=8F$+E1U`YNhl7w#7Fm$_ESSqF#veZ^?BI!7i36R%BMMFez&M__uaDlBN zw$3U=G{&^eKtHKPu?AdOVAQhIUBZnVZ$9-suK(ai43?MSmwt(lKlmUEhfi_+t+y#l zPrlg4cY2Hm1zqXjM?)mfFbfNe{SX;%0kHPKqm1a2FE8{hQxu%<4%v2wrBnA( zyypJp$Vqs~GZ2JnGt;>vm^evu#>vc~4{eQrT$Dic-3ojz&aJn^j z_+EA^Yon5Zo9urHiEzX#{a!c36dR8!_JlDOl?X^RJTH=@SSiX)qq0!P>-jUrz}mA& z)RD$RWK@vZ1+3}Pl>yIv^>_IG55Gon_#_|y{1VHwGnC`Sbgc}`9jyv3Tp%<0%J zB7KxaM#C%Ucua3)oowYe7d8qW`rH>e^^1QA=SH0K+vL5R{#_@y^z1X7SY2i8N2unM zZivf-CqD6cj5$ns?g|TK$?&PC_}=O=pZ(=8@}sApg0mL`gIl6J2xML`DijjpUP*Mj zt=-Y@9Pp|df{Al`W{j)~t)V&#Ofqy(+arXafQtqfXXopzeN9$O`~T^|HDRPxC)MiQ zNNcF7V@w5^yr~jX#?}Zy+eOgKCZ#mnDg^FIi=2}FNtx$km7B4B#n0F8Qkn9&(zd=m zcR#IMER)f|Go@fQ-d#KGm{uKApX^3zX@Q2fVicZuUaNkIk-bkc$c&Dhx<->XT#{3C zwtq;Iz14I9r0Th8W|rB>c70b(&zpF{9u=HPZ8R}pH?^N?Q53Dkf4g14_S-k}CP}@iHLZfv zG{P`(d<(LitXr#{)Z?6hpLS3`*LZK8X{?n)rC%N+cRXTZ(!R7|*y>ATf=Pzy*_{bZ~iL1F8s#-_}@{y_9}@)ymFzv(-o8bmi@T&gz=1POp{e2T0+Et23KNyg4k1_aw-MB3fLY6))^#l-tVgW zUTohfp(y_CMq@TFU1$B!5x!q~GUHiY>M<^M$V^GUZ`s-@=?V<4USm`gEF3<>h1>*p zTuPkHv1^A}T3zGyAAbY3x9NZAagH26&c@C*=U#e+^A|3Xd*#rv6D&RcAr9Spii~!e;V`&9yi4UySZ|U7X*Z70 zG^bPX(G?^aD~NKDAQWX8_77vQM#!S!FJ#9B;#uOO0pKSQ%`yn4aBN+_j#|UQ`XSyb z#vIBlhw_|3F{JdKp$c|!8Ae0K*RFHqp$F+5K7`-gz!fE9HTZs?)#G=e-f{JfS7B+H zBaeI#>q-VMKF3QpMwEjgons5EK71c19)E=IpIB$-t+&7!wnmDxAsGJ$KJyti$a(b} zf5dv0vpE>Exp5ue?X!i{EQa9cUQLjs8{KSh;mLA4&z{@j*^rc?>dSghw=wH50YG130H|TF;+DWbxXj z{7mil?|{L0S9i>P+zXYrIY>*?s(`nqjuHNXOg$mC(A*s)FGLK?xUdZy;ecMt+kY83D61(ep~7_PcfSLaf9O7 zx>jl=Tuj(t)PvNt?En%5Hc|RO8bN7hGRNAzH+5YM-&x5c&HI|c{{pk6zQk4Z;#v@_ zUsc!HSAhl_UyC$o=QCUk3A?M0(>O8ds?1=K)+?%br-%<$B#ST{6+jUJwnyb%y`u3r z@uF0F&xAy#8Zzt9s&BByq|7YuzY@Vr34%y)%#Q-Rx0M&AcP?x+)-t|wiJdFgIq}#> z`0)>Z%*MIb@z%p#$2j@meQaL5z|{-q=`Qtn{k0c(WakDCeE1W*{oKoJUAw~Adlnvk zl*9Mm$JVvCIrru(tgIj5^rIi(?N?vsso(e~EM9t>z6ySEm8(Zja^FArPk8XjC-}qH zUu1PZiFo}ECxhJ6Fyz9$aTTha;!&qO*{*bM{rTogGFaIQ#lboVf2a zuPm;xaczVvHyE$1^Uz10K-6*l+3$fZS$XUsI-QI+e&=^M`_(_@&~O{;9B=1KJoIaS zpU0kfoVR}8FtlCN_9=&3=y;P&H^3Y}%G!y$c>cz9HeY<6tQfL>-#zSfx?I09%ZX6*xKBL;SM)me~G(4{2`9t zdoQoQ@HD9$Vfq z{MbkNh5zIqu>R0JTz&l|mRC=(vU-fo%?&m-ud#Ug7>h?w@WOY$!u5+g3{^OG|9!a3 zvbnWcCuyl-dxkhP#?0P(_p3DQ7B=FbipT9VYDp5&8#ZL|AtqwTM(r7lhM029xEzyZ z8Ax=KvVo8YoM!!!PbyhUwf5DKKv;6eDpD1 zdi_O2a)vu24n1@?hff{n`uR(^VvO6m#@jDH&+0v=>EC;XOV9q01*2^B45#ltj3{W`JpbiCp_e&qZYXRQ^K-w*V?X;#eD|B*p}h1a>&K6CxpWlQ zhZN&6IHlL^;*Dctyg_#O6sJGO)rg{|!!7K|mVXdCy+l!3CIJHoWC@_sAnOsM|f z{G;;92>OiE5J$?#hR<5dUVSB38DkpDEG0Cs(Ey)3Wa9eJq-%vcJVD+{`jNJXNy%^$ z!WSgcfb%W{*pKVG!8xvskd@fhbR7(hNJ?ZCT1LbKLWJKXVE3#&t_eWRVG2z?qy7BE z>h!JeTfQ-Kz^!C>s=c?S-!Ti+I%wG~?p6Z)kXia`1AmeF?`BEi3zWUji&n3Rv|?Y~ zt~TxV{sf{;lZ;}59HbFPy^hOPg%JYm&wc*G|4@wOx#wOXGdWeXw+h9qQRTYR!skiM z*)DCXO)KZVd${rb8sv=bF#CIsZ$g9(ggF5r2OZdb6`g|hc6YT) z)XVDj#=@)iCnd%7&=!TeF#;2lf7eAvo5I?fuoW0JDe_azugUMyhSMD5ME9&i`tP>b zYU=Fdk0eclhR_5!4QYQv;6$oGdNf>q@PUEnTx3v1wA)Ab!_WsVK2z|L(djQ?@-Ek| z-(ax0jrAcwcA7$ITy96%d#=s0R^0U4>OLi`SOh#V@T3cCLSle!HHPhRfj2q#J@HXa z-1`vHS!DH|2f61HpXSWz`*{9OzsZf4p5;h4=i;TyEG;bY;g5Zs#bd|NUZ2(bALJun z_<0U3E%3sh{Ch4v_bg`~d5lm0^cQ&gsULCaxgT=(LdKHmu-IMX@{LVSp1O+vnFgU}jQj;yn^*vAb97*}8;U0X#lVlYGyTAKTYR_i`l zgc3WU>~t~xMRsm%vw8J;VC*FUD^06C+Ba!6yi?00^Qw797;7oibA5AzyYD{3=|>(w z78mHPALsZZPw?=QpCT_j&wuT!aQO<0ogNo9Z}8wFk8twX-DrQAV~;+{Lm&GjA9(Bu z-unLc`SHL1Lsri`$m2ir=h)e}!E=A|M;smxIhI*gO@~gm!=-V-7k=UAxctgexIINhK1+eC!S=f-{a}8{RtoZ=qLEg|M7pucxRhee)t`Rsyz7N z4|D3&eQdd$qmMksM}PWfxUqGEAAj>JENyPkuQc(!2W)cxn)h=n&zQXSc9hAG9Eb^u za-Oq(9@H2(u*v(5!bI? z=Dx>2$bFAKMz*lR(&-0y@ROhA@sB*o#j~&Q;vfGB{c*t)zwkxO;dP$>lRswR#ubkC zIxLD{Mg@gf&V1_As2_9odwk=Gt4xlTNS#P;{uv~5{@%U)`|Tr$ zqSnOLAT$745kx)fD=TQ}$UNF$yhN5r9L|Jug*;=@u(7Ab+72|HVhW`)&&PAGF7;CL zy*y1cI213wu8qv*M@(X}4FszNfF$~=nZBWcEZ4R3F7%4Eft}#J3&JVW8TOj(tp~D= z+E3McM*k$-%uwMTyLDlevbY@wtLV@`mpdAhD+C=@vOEMNOzfH& zji?!R^O~9_>Er}&F(VuEDS|?w4Kd%HOk3Df;(;U_HL4UX5Yc*I>haOE`Ndr_0F^<3 zFc?a&7#}inI^scqsn}K`32aU4CN#om7&Ftt?Gn}YPRUbulcgz;P_I>o(lI7jUcATV z8KoN|;>m|YUViGkY&p2=k;ggnsh>xU;L9P;edl{z{?T_?bYqMPcT3KzzxM}}gAwZ= zc$5cz{)?1l$!L3vZ~U9zWcc(CIbu3=@`yGCbWjn&6blI6gILL;7uW@cdOv8o%G7lD28CS&kFT=~nRqz*n5~5Ru&U`;Qa$U>Qkg|dcg|t0 zWkC$vZ@tae|INST{?C1eyFc_1GP^+DSzxre&5ytNWr{c7Vztu;Zy29_ji-P2cX;#@ zU*P^ve*vEhW;EcHZ+?fDzV>C7J3ZD`jxgBtY`pzC{bEE%ay(f$r?i8=c!^iP^-Wfm zmf69u>4n3+RkU-6_3jWo`y4;|cfZTYPk)i4ANnNL=3Kk-GB17ekFe+7=BP1@cqbE> z@7{m!)fk393`m_WJbsC21bij144e8`HK$c$+zqJ(vZ!Pvtv$&z5SWfJ%`^kZOh%E1QD|LM0m`|WRXgp$mc6dPMS^@o4RV`Dh;Q~6GU#GXQjEo8{zVa%?#x~3SRXmo#xWMKeY@s~!AHL4&`XZfPpB)j- zzIB0fZ=U05zr)sx&-0z%{I@*%Q(xqa3L9Gy{B{fxX8mtecD??T&NoGEOF?VdFaYp1L1VWyyM8`_@;W)8)itPvUzk_`wd}``*)>{pPn> z8H5;iKH>~EGO#M@g9Zc?qBiu@B+sb}6fGaYwMpY!C09;* z_$2qQ3N5H>R>_6&>`S30Dmjwb`_t^X&_W;DZ_!#euE!|-uY%13Xdc1T)Xv&#AY{cB z+8GAtl}VP?ym_+QXUSu0W^A=Nb(o%i6F?Nxa{lD=X5Cw{%4gg++2=_&ZjHkpXW7hg zX7a?0Ky8ZHkaUF#ZrgH?h`Aw?tlZmlwFj1?DwZCu1A!;nRCWx6*y_$xCoh%I# z{>$x{UV@2fWvl)Ws`gjbjmS1pUvKPz?pxDVLzcE2%{nZuA4e8fcwxbpg2bX~}3 zr;Pyex5I5@e?sZxdc6f#k(7y4B3Du!UOXk1OmmDQ^oR903d9&nGPXTzsKqX?(p_95 z&vN{53%7ZhMK@ppj}%JBcH+~BBHBykkq0IYahmJASF(WqslZVV;!vT8Z z1`C4`wkWaIv<}u;0UqBAM$ja^d3Mr-{byHc`J;C-?tb__jvra0xPFz)jj`TE>$zYI zSoNsHvmt@Qfoc+-VF^lqKsPBCR$%QYm(N|{truUVC`M!=s0;BIFtanD?$(C)8sOMZ zpH}-sWNcb%aey(6&8*hrqf>}GdVXdTtly1;*b_(21<35x#7lWOul%8&hi>N zT2c-GJ%@3q-+>Y_N*=O7y29`w(jLVYc6GwUU z;~!yo?Gj6VL{3mdO7YmtVl^lT2>`Yx)O8e2?Lkt1NaG zLbgJA7bE500mX{Ni(#9REwN;4N68kKu_nXsY%;ifjzvFY0f(pqn{$0E410YRj-4Ru zWb9nMfV**>B@q^~Zb-VadYE!ymGbI&`oj$}b%8~wmZ4@0J6$9bGCyMIpft;L%Mpuy z3onN2g&{wBjO7!@84ZVAzjz+mzRqf9=sF)`!qtqT{g#^nu=W#TVz2kz(wgv5b~Bbz zJ+jc{fkz%@X?2;h9Fd`TT%^<)P>+oYu}WfNgb)=;YD|no6w1=!Fm&@CufF&)=U;u3 zj@Wm6E!?hHWw)g+8iQ?1#>hjF30pE`)alSWa*Xcs8om@pSFTW8xx}gpOCneqQlMND zOWEzQeDpXb@3OTsWar8``o#{bHm4&Q*~yb^q4*0ISr`|H3-0@Bluc{NPn;&t3_IuE zM#)%QIYM#e9DTRLP%Im^OZVh`*rhdwSFbX-bdJU0kkw8eW#Na{*Qx&WBrfx|=$LFz~+LL1LgrQzk!6r04nVJfeH758GM?~2qR#y#d)+lb8e7@Ny z?S8G?3XG|#XRQvY|D4*WnC`|_Kd97}UYLq))$9Y`UnBOZIjy18Y zn38hyBIUKqh*LVoljR}1r7EZiSqB$-eYz;)3+Hifk3f{;HmB3c5Im*#*v(BguWiyX zIUQ@D3~7LkI(%8MAeQpVCM(7uQsS>(r<-NuCZil|L%D&Tzf8|Kx;iH9QAPw~GOT8( z&Cm$}`kivsI8fiupbF2c*M2PelVGyJ)G^|mkB3p%phPT*el8l4GrI;;(@s1)9`9j2 ztH&7dCeec9e$z&;=eBp==XZkgv7h=SAj^t?I@YbhZw@G~zrsid4Arn83v_JAO4WO1G1Oqq5gjgx>K@0DM;!*I9B@8`r z46eL|zvxkv+$*b@#biS1Qea@^XeVPd-eK_ib2wkJCeZ10Aj@&;P_dNPF5xa;q-#xN zL<{O2;OQDqRus5VK-(8ZP}u+j-dd~|jv6R0on>(DSwuP<>+}Kx-}wm!>A~82v++|V zt?BzUOxzCURZv7sloyq()t^+#uQ4gMXPw}u9fX)lo@yEr6$uP@4enH0-TM<%1XNv+ z$8ClcWyNGL9^uZu%-EHHVbSLF@(xxr@JbPP<-^iNcSe*i{RrnAD_M`#eh29paARCq zFnIMP)MVtQgJMt{MwDFWI){7pWkwFVc@IGu-*}TwE_j>M_d?H??7Z?kg)dn`IBG2h zi!a8MHh3x+rOuR>7JWEK1_Es#P*wBh|q|pE$X=oCehyeJQ zdCn`tot;X@WTHPRaG}9QYQ~I9|F74J)(YGtW2m99Hu+4oo)c*qk+dK_FqE25Q=Mo_ z+V86LHtKjzuBa5%q|e6+H!EL(7*LZ==BRNkLV};vqZ*79ZPu0MB-G|>Yh5hW)0eF} z3xd}+n5Fg2ao-E0>SMCE763)Nin9X3&yvOGGN|U1c{+ejtg~tFrTNdAW@05`Mu|)1k1ejT&wyLT$J;S1>Q#D=^T8aHBINiZ`h#fs|nL&sQD>| zoz`vAHp+an!qL=XYEIHdy~EV2JgHmvw_J}j9TwF#TW>D-`zU2;tCcc;KG3JoF$C3n6Q$)PG=*zm40lNcM?3T{^93G3odPPb{E(RMUaE$4Ic7YNTYJfvgcjuyuw@oRT@plt^4@|_MTp9@h zNyDVpiWM235SKt?W5odN$vs!BLDZ9r0RxUkRvVK7!{59RdnXIQv+5SCULfk|DD<-o zXP1DmR-7+!nBd;-6tPj6&n=nlASS02kMjjiJz}6E(91F~7DaIg-axKO?ghb-8-t;b zQKf64L~+$d7Zz+M%jx7E5`QSDVs_WVUQPcQ{gn4Z07)(k4@}|b62}Fs^tXtBr&BXre_u9aeRD5>oQ5Wo{3g^${=Rn@cGnrdZr*eAL> z)g2QHQs=fYzEcQUGG??*8)KQW7F5i)#sDZGzEy5Bx_Jk$o}ws$kabY)UUi@Tygg^M z+7tt0NAa!%RcxLGphT#HDJC}GL0oYUP~KU$wzQe2@dS z^gCCHDaj+?p`HL{NpR7z8sch+jaj|C197N$R9s}h#pg>z$Gyd)6pSfth|4Bku-?S5 zGhy#jadj7;v-NkxR5RK4$>%Z*O>0MKPH7e&oZ1VgL2|Ml1b%8QeCUQ;=@8@ z7*$4)`WH%I-j}&6EL<5Q%vZNqt0FV{IX|MuY^}#I#k9wi-!EGl?d#VY{I6?3tl+``S z=wYxbQDMI``*&Ws<$W*-v}kY>M99Qo#o)aQ&o>rp46by+HM5eb!Xv!Ic!-s39$6|X zIPWn)x7%TmbWrCp_P{lLAD3|cnmAu0ULN-U#E2CeE#MhSj`s#A!&YDVxbIs~6AT>c zusECz{VYBT#NJR^1vQcJU0^(D%7|>D%&HDAiW7rq4w``$2tLF*Q-wVf0M!cm`=#u) zeiS9cn|gkNZvvO;M=Bdrzm^t=@M#%WjI?opN?~ zlbGNR;{@xoDANRXlWQ_x;uq4A9 zClMSo!5!552sp+cqflzd8g9j4Ox$C|qqy2)Pg5tH1{s=JSl@TWE0Wt_;8BN-EGlbr zB+GE+n1<6}%sTEXg&f-Y&y-HoVk}M@zbZn)MAs#T)2wrEUc9mC#9Q@$H0mmCW)!uo zKF3|I#W>o>x{{b2CIz5x01olJ=~1)y)21%geD5qGOViOQ0bEo4l?+!+Kxay?Z0o)* zdL%UK)Q*={gIM#xs9pPL1~F$=AEZj^F8ws!ZwUmI-S2BnmB!xLFh)y-9Z8M#RP~u? zL!}QllnM#4LX)wy-aneViI6pej3&LmW=J&$wVpHqFo>~GVg+U`ib({yHDKaf9o$6P zb{o{Ncp6c@CMMB-1y$cn8>x7bIF;M9fSfzjGNIcN4We)}o!xJ#SFBS6#m z8R{gi1YoI#IRUA|Df*zyRk7VPXlj8qZ4kB5;v?aiMncMM>tl*wK}`THy(k3k7T$z3 z#YTf!(WOE05jYGgXb{eVgIwS$rg$@A!gn!AgjI>k%sbna`|k30cubpnuJfL5r%R{P zr7VhQDfGBVIq-OLk=k!Qfm9+D)!d1Rqh3dO69rck^!t6fi+u(=qnZ8tevEZ>GyRN~ zcp~qw!IloI%OezN=fQZvYP9M~eLfikD|t}Di2#$72m$yt8UV#Y98O|-;s^}5$fUGt z5Jz~fc+~hR6RX9dYAEHs2d~}>fU-&>F3Qr%5?Yp6QM?4;$ru}BjWmT}0?=#IIC~P; zwu<*6As(XmU`$$GUgr9_x2tx(FO|BRETWMipy6*7k5C2yR>CY-A5%$)VAK->QG(d$ z*5>P2D}?bHi^GYclHsj}^@lnB0y!x>NvUSi6k#`zPB7#7tGCl zs~#`m-URxpn95tg5ZG8rW%NL4Jem3+H^sBV_^_{)CV^y0kE?@ZOJY!@ey%$F&UTpI zRSy$^GQ|Xq$)56@{^CMoZwbuAV5myZO#eh?RMKT9-TY|YFhg&weznOTEuErbS0r{k zQ}R~A4)zn*GO>}H_o}HDa!u5T|E;}~OseTL_e3L0EV4TjnB>(5<6X5TZP>NNw}6w$ zb~p93S#qRLfYgMZIL&g39$q*gc-AC_+Z2bUYP?qRRjbb?z^X(#nHRryuAaHa{?r=7 z$x7-Trhud_CFLwJ(;9nu81MRdOubI(XZQvQXv#ycxd+5wYG(9ly2iw{)a(l|RnRd> zoic~vLLHcGaw6U(kT@UhZ^x#SNugR9|1u9KG`ykDk_pHfh?geBe z%Hu(3&`osS7*qYW)>i+A-4%uXf`zl{wb%cr?w_$;&3gajW+XEcb)m&B#@^XE%H%Wab-zS7A!3G>GgWS)u%x*b#C`eQ~Lq%WX?wXP8FT;SM7e3 z;;1#pG`MIY79<17!32RyBJ(f6r%I-R@dojxx;Fso9w&;|G6+33fJ*@~bEp)7(M5?? z>c0stg=(;+khuV^AT~ZX?iG>Yl9V1y4a^v3ocQlznKi!eMp0FkmzL>vbIPKu)?Zl$ z;8&xtY$PRg@E9BR5cMgIb7Y66!%%`yR~9AxevifFWz;7S!0d}%wV%FtFXE9MwA5`$ zLSYDC9e^nbXf9F#;%dSHZ#5{`np~B|W5gp~G0q}BLnT8ir6(#Bl46d9=bCu0Hx9&O z;~FF?JVqeIRhC4BjBJ$K&4;+TzB^bS-~4d?xlsKD!-Jnv&)c^u61nt2jg^H9h&)04=Vk(BO zMmMht>>L|hZ4&bqqF5Hr|srP~sV4Yw25p$3~SwTLKGSq7xzz0pJ8)5kEJzfomWAey5z@ zyXrq0Kt$&PqbkcX%Ch9p`Z}wJ))d|zOY?NIS#8;C z%v11tB0Ds*S#X(7dXChwz$-VoS-{$aKHtKaX6USY)z3${;lQOK56${%)uWLFt@VAy zf}7CrX4SVP_b|m?tFYDmtoaHbtu-?Zuza0OZ{7f;cWeF1G{p0IDzIH+Op{rt?c|9R zzK_pWsg<4?1K6eohQ>4(a>bcB&_>umkf%V2^#5c5oCxR~5im~>{I&j~zGkAmdm^bb zKA!~3k`R5fHjL&XPm3Z54=w^QbKD_GKx=Blbh{=S+@xn7n6j;rf-ACbw#KaQQ*5-P z9HfPlKr%DA(M&#fMqN!r6@x3UfnvapC7#YTyb0p#4nULjCFpc<2R0pY2gf(akA(xCrr5jk0qJhPw zAlf1F*W+>L5Gm0ZJRAvbw1NTWDyw^8eC?J1+@j^_wv_!?DQ{m1?LT_r7~NhEw=p2M zHcb1JSuiQzCdBQ~7$1%a!O(;tV$|IzW>7)O%SHkZUx=QgT(ShH#!5^(Y>%1|t?jhDc!BIwuj_C6WQ9 zVq_ULWRc0}E4xyd>u$p5B;YM&%n+$qRczUaL|bK|mY6xwQ~;!(rh>#(9d*JgpXWhZjXJ`va*Zahj0hecpzW15-jMA?r?bc< z!}{Ek3@h{W?nd2RwPVK5lH#jL#E5N(YCzK<({@+X$l8*|4pJxgv9x8>P)!@P-u>&& z%~B;@+pAxNDVUGXD286T$%s@F}0QZU+o46 z>eE6?(4J`_T=QH>rhlftyXDe&mTRpQ!Svo;+cW5n#;?>EPYYJzFb zMH=!hi$Qmr$a}DfOdQrgw&&XM3osBzqV4VVWvQWGug+IYs^){u9!kKZBiDz$HJ zU&ZS>{SIquYp9m^a*Qus=pSP$8oE=Di+hvz;JvFrSv7)07pn?r@jjkA1><7C>e?!c zYb%rzT&S{LK*HO$r{7^Es3B=>1n@N2^=xok3Ve|muO!aLYV7KBRt1~{%er?iX61?f zDT(D7t!cz0Cnnt+V$wPF4Y%G%o^0WupS!j58emYHft>=^;vR95F5rjt|g3+;W4hD+sRm2U&o21@KiEV9oJDB zzyIu&0FX|cg(8T}0~=66=0H^!7(3!oDeO)cV0WV~pM5gPso z2EL}VnyH?V`aFz@@zlD_gtrRKnAwtKv=ArMsFGD|EgKm0iG7k78{sW2f`Kv9WP&8N zsp=iOo$bX8@!p4Qqz1MJ1Nw`LEFM~8$CWr^DQT=3)!Jie4|2losBbfVR`cgp-=i85 zEL6Ic51>OGl4e@Jn-|ul`aV*sW-)?h&5ud;+Fadj7wllSK1`+dMhau?oU6zVEpo~( zY^L4Ds9B%=%C}8c+kW3U*|u5ZGpPg53~#gtdutY3Za)UkxZ~FuE2a5ej3=!MNxwGH zl@uRhUR?&t^$ENWV)zeG!rdXUgk`a=96kLEC>pMx zOHy*u_QDL;_^K^4ff_%tm@4Agq+l*cCOGFP%7RX(69rRHH!-kjfkvnP)^jE(gY(L) zMZT9G@AP-=&qRV-!>qn&zN7js*1hT{GY#;}TP)N5yVDC`7VE72EMI+}5c@347>~zj zL&24(B;!o!Y11abpT}_>uY?-YGh;F z24gLQ!4CPxHVX?YtQ_hsF$t&JrFt<@AaqMDWQcU1-7`n);s_MCIK zV$oDnWz!;MutBLZ&|%`bFjTRu2z`|l5!6VO_-e8{@7e z*eECtKJj3&o|-W-!FE(^M2Qm{E}l8sP;eqEGQF!d_@uRE;@T+d&Xr^hohS#CfkoJt za5nXti7}kbz6}*>A4xj%S&}k7Y5Vh#gDc!;0<9;2b;dAuB?AM;Pu|7K(kjKy7Cob6 zafupZXhu=2WC5d~v7alY!rA zX|+tX-(?2Cf@{j!&;ry-!<4guV1T(kUD$35?5ueVzLK=KR%q9i*?R< zRJ}>H%A{3+QPJq9#s+217>=L33lS)a0+U&sx_TueJU)=yWN|k}S)rdRc36E}l2Z0M=wqq%(e2H|%moB*Mr%U1F-6 zTg6xV%5J;fEI?rrfJp35TTvmBo+6Q59;ZSDrkaqU5b#kZOED=WG7WvSDg_pZiSc-n znB}q2-BY5IPSq+COtV%0MIEkI_bM(>>%UEXpjK#OD^fqxymo(OUk3Y1Z=G@fjPYxE z3dOzLh{ZXr2pJ)BX2|rYasFrB4#&^j4gCc+MnlFXi&}HY)@-QtKoLuu5l^xEv;N5)Iwc!vzd7yajlI6! zSR-4N8Ed9d>p5dR@3VO%jL9G+az>jt5_fQUz$pqrY?!rdC5=5I9(;o3wH ztiEOLN{j*7D{Eg>-}?Q#t>X6FnB1$HdzMUm*8FWI-aVu5CO%7|=&f2Nt9fq(Y)B7Z zN^qrd>O!V7fy`RKFd7$>*06MFjbq1-;oJ~$WAdorr9(%366TWSye)uE3Q~v#W8<0V zP+!1!j1Gsa9a`n+T_-3A^FPEH2%!;0{%O=X^hJzQLQF=`05^2z+RH#Q@WF?(z0b((e&k?3 z6`+04AD#HQnP)VfmG->pb>%RmST2gk;Wf-h6+>@%nfo4mkYY4Oyu;!tiy_uXU|CVS z@H8i8Qly2L{RG?+H~E=5I5>q?Vh zBZB=?fa=`(Cy;Fy`AOSzaq@Rk7H5^I{q-hG^Nej_;!(b7%0XpOVzOW`9C^o?d(N=5 zy28$2NG1|p?SpIcIq_Su4H6Fc1Ex<24zjQm<1_Mt@GIE-9Vq+ z=4O^r0{zIXDzG|Q5qf0FPpPmXP|cMzt!K?5&#vL5w)()#}- z8)#plWm?3T{XJtp`@;PAQtju?(@ozAoM9GI?I7>i4WroYtZM4bP5rrIxEZY&oWbkH z)ML{G>V%khEB+rB;Pt77RsS{+|D^cNvSiEzVD?fE_voxSpU+xQ&ELt2BQs91Nfypz zCr^Sd)@C^87>!0)Yhz5W`SsduB1|soS#7!5GHU~BZZ5OhJF_om?4$c}37q-4O!xOB z6KcBDKxdwJDGJA>OP6uJB+oOf7|LjEOYV+&o>zd^yty&Y?|0UMF3ZS3Ffj|=3@YqY z`^AS;#RzIVMQlR|ChO>lFxuW^kY}tMKEjbxCwP4@WH2aL$QLO5m@E(Jzss_uQ~_(_ z0UbbwX_qfwD`cdgUxF}TdDS&;lfToL& zY&A84Gr_G!V=q*!1s_yplVx}nI?IckxbFG`f0HK+WjIo z&7eyCwqK{iS*vM}ZrkT`qadWj45Wa#U=`OGrdXt6VWl_=Dh7x0@B{bJHHOlc7nK`wXJ@+Nvv+wRuD?mBgnyYD_j>4T)>)l>$(?r6m2EG1ch^QyPxx8)Oo9-*F>GelaUPO5 zIRMrMKw)EJqhbZ@Ri-G4viAa+H$iUM!q%MB&F98$IcPMKz0lV7#k=3P7uc7s2^Lon z2?0SNP-BA?j&J}^p#FRH?_8g0Q@`6xAn~bQ^T5BR>kk5IYImRzh_GZ-7LZw%msb#z zvAsE@7?)(mgt#@{$G9jK5=B1VAB2GzpH4Jn6l<=*q@^~s)z(<(#P9k`Y5bn`GkHxV zs05UfbnrA$bD3axh3{-7L`*z!*GVL;O=-VBQ8}m}n+Z!~>ZO}u8P;MhDS-nM|4yNC z2daBhc$SIw@7`DK--+4QWJ5{J;w3m&AY!n2#>QZW?&2c%KJ*~#M-MaFy2g0xI$eQ| z2|;5qzVi4Ek2Cc+im8c7$zs`15>ub4D&l=u6aRne{;b)yBsmYnK95*?pW%)T{oU`{@v10J>aQ}R&6;uiL9zMYC@)DmvzrykA z3PZ}sIiXfY;VQXp{GekG%#i7+{+G{`g)6QV0&!ux-9y%Ar&aske1EO1FMuYrZ2hDY z-!=MfmiBqS*PcyYDXr!2IsIN&xPytR%@Hkz+_Vz%IWsvxex>f`>O32?*|pJra>!q| zByYOYPg;BL>^%mwN_DSBLuNUa%1{ab4Fhh*f^@dSSHJ!N-hA^_Ts?n^U1IDA$i&#? zgsBLS#VjGv!uRBi7)U}5?CeTe&oJ}pA&8Mp!>bsC-F}bC6^C)cgNF}Lzg`2@?q_6A_E}O3DuGmrC#{sW_AEEeRKr}^ zeumD?>B+75(MV~uYrCGp?;ZTQq!h2Pq=e_5!7TiRlxk(2YS${kDhwM)xtPrM{)KL8 zfM)8Hgf*&OaQqcH?u%X#99VQw-D+zUO0_Va3dUFpUVH0Jy#K*hk%o-JcmO30qva~| z8bXT@&K%MTwUtBO96gt0J(7khO4t)aD!W+jG$)ok>(!1Gp~qM&xJ6lPGNH~;I_VapI&e+qtf~XiKGv^TyMrGe~qQAc|+ZK zyucTF)`7Lz=WVa>_Oo_A#p5=hxlw{85^q7N)J~X6!J$kzfAA3B{5!vm-Gc}C{5ayk z1tm#U;ZAf>s^Z<(*PxsM)Y zgwu1rK35i5bnDc|R`kbgoOfWpPJa@6s`e;7VafWW`L9v}C<&jh1&<$GqE2H7a9(NL zm=dG8fo`48i!t+ho7)e>`ygPMB{taVUjUre6U9ZgRyUuGXyF{S7G^*iXMYC|4%h2X z_3L&~q?@a>0n*(CB&7A_5T#<0ss^7S?R1Y~J5vG%VQDf%Va(qKpw_&b0}%>u4M zK4mAsH7iLx8004VL9VpkO=Niyv1axXJ2ftTo`9yQ14lWC3Pj_{RO5n;d(mY@oTT(OYgmh2QNLu)$^y|=bvLo zjC~>joU+8omnznBGRr!XJKAwkJ-naX;YEqeuAa2k+zO z-}?~HKl=>F@rbjtGY}_C0O1alC_mz+kJJ7}I)mtHaTf$KYrAz>ixPN(u zQm$}QM!fO$_c8AGc=FR<;CP&Hmi9oADux5?WG|3{3}kYeoa>yeVzSN43R(DPQDe36 zx$0|WYZ!Be*k_e%M+*YZSk=+GYV2%~HnnGAb4H;pTU9-WT~VzLel;a21+NRV`KMrH zu)fsDpQSG@-`d`f-_Mkkj_ZfHSO>S~S3?)&bu)#-d4YtMuCl6IuXV+@UA1i?09~#o zhLnm=!e3JsulJNQUVG&geDycJ0VKjU(OBVYb1M@%e^2=|Iv~HfWWn0sIRljkGk-_+dLj*$lUmpPXllEx%GY3OsAq2 zb?y8wclFg?tq zdt)s<$;1F6Va73CLi8qzy>hQFo$swVu$*m+G5L8SJZRkkV&+wJV%|+w^J1ZkIb6@7 zygDCuSl{{myu2~czwGnP2%^8G>r|?uQ($e(Qx|E*&*E^#nxty?4;Ht&Hf^q$T4H8% zf$o&gwShFD|BHmn`LKBYmeXx*#R>PyG%wnS0EPh}RTU)*C9IWCjv+Ejn;zHMObFs$ z+>Q&lHP#oMyBE2ZL=RuH_??||79z%I2j3=2j5(6q6YD7ut4sxz#o`giUP~xJ4z=7# zOh)K$`2LO_Uu~Y@3P|+CYPpt{2Y~!7{WAHu8Co^rBg7&-fPv|N>rX$z<>Q1m-g*_g zi#>k+;~(Ja+2`Qth|B#Mb~&Mp0%8#*6ihaw0&2mE7?@whQh4r%%j-WG3bK)O}2x` zLQdGmIai3a52}bU>dJc~UCmv9qw3R`uC!TJ`#Fcc8Xz(!XSLe97fb-Ux^dXNYK{@N ztcUS6Z3iYJ`zE&J=^wC zPq*gDtaNChYM#d`>v9;KRy2>Ywp6fZn8LkteqjX{;%-~cRU&GmbMG0iW!3*b3VseG zL_rRk>>LihbeqY6!w4X*fmlFO#8!S%RhqP=NU|r8+u4>f4t2t@PI&ajtN7Bp@8bT$ z2RIzBurEgtRSY?SYD`LNc?Qv4&3Gk7FSmP92eko2NI7dxIYOOAV*R-%g(vzTfihMY?I>rTXsROu3fw!FNIDwMGtV_)ZDOh8q?*=BCs(6Je^l z7jwd)OgK9`!#nT1gE!uO3nNt=$0JfUV3<&AL4km=wq*@Ks7f!XGHz~e#Neni({l8X zR)y5DC-p96;Ys5(A@35N9j@@;rN{W@Z~qp4{MX;dlTSVc*9rS!0CMW0x%)kNw>{v- z=0GmD*`36D5*-lKyuC%LntxB>|J^6yj5!S=HHdN^z&=mFy>&IPj$B7M70q%N?DU1S z6mz3gIy{x*$vsO}av|L|KJL!mwHw>K*;!9$`-!!Ulrst#H-`h#Zoo^gy^42#qeT^Hg7zwCp(6HMCBu26b1%C&+A_FgWg-yM6N0^`eeviZ90A}urAFM@|tN~ba z-907&kBPu1bt34-H0$SWpz0|Ar#EgziFrlu*3VMhOdBN7_%~)cq&2x*quatS*GGB# z&bTeqZvOr?kFAr>(}u_ zYlyvpQ-QecWr;*iuKfmyzc)0q@3h~?VKLdjF!+HkvPPXv$~yQq_r?-{VtsS2#mlmW zsafu;n3rblefF#|z0RWTG4-*EN3YwQg2z_TO#@79y}XsXC!aIVh{)8qWf5)`SF<7n zaw7yrF`M%=SH!K4WFQtdMzcWB6HtQ{%RsNB-a-se&Nxn^Cnl8m?Bq?+%0YJu;ACbb zWYkhYsrovZq5%X28FQwQ)nZBjNt!ugpcY-6EY*>yGWIEByuQYD`3U>Ruj28m53#%d zb^QA0zrfR9euU|IlnM(ZRrx4MjXyy0^5n4hd~WWv-gMWTWWo6g80gw?kb_xh&SA6z$)eSDD5tJrTAed!sYsFLfk#lw2xTSIuK&j*40N|~L59dY~f3I-2s>KT&5vU|vypks3t=K=jhu7YC8Lz$ZI(EB+C&arZWAK#Bvku*B_xRSHlSfuM{;?6Sp>2!(~OrUX2^*drf-=f?vc zzxFaN9zDP>fAmW{`Q=BzSTR-t(SU545fxC<;vrO#qpfF!(oS6#mfEd<@OEm-XZ&vV z(_mmnu%dcl`*HqE+`qA(j!3WObpse)2gI??e&3pPsu>OKGpW|LJs15cBPHK6s>wne zL2m=bNWmA1yRQQtlmz_*X>-#IIznM~IO<-DIHCPyNIuLc>=Tyn_OpY6EY>EIVWES% z%M#(0*Ivgv?|cal?%&5@I^t$Lpn@^*4$vrtoD)i&P)kLoLGmQlf||q=of$Ze6Ap*t zia|SR4?kw_wbG1`WAli6ESpLKYNY@!Y;M2t)7S0 zRgWqM6c7}p?w&h_(=6m_d8h`1UfpWP_?_`Yr?V5!Y+M?_+ZX(JfomgEnDDyb9XOxg zI1e(HCp_UaWRyT1jczHqE=RdO)%UoC56^gvpOG_tGj0g9`m?ho@q!sh356NcIN|K# z9FHD7!Yi-6iZ8wQ9*)BvA3b}H0>*(0csIZ!48l5kb@8MU3%9x6@=%GNHIT)^m)G-H z#b_U9#%G^>=3#_ZO?~Z|wab7iyC*t#tR{E2X$_t}aZIrYn*P$i4}qU`pyC|N4RToc zC`vCTgTg)bLmLrPn}0BS4fzICogv8CDH)(Tn@=*jngM7*K3_aDTetc8Z>`h({^TBz z8~2+R+ydvQo;wf#P{>f+ML&G*=gi5^Iy(*H+Q=`yW;2gA4r^-0q&WxSaDc^E5A%uw zrj{8P<-$x9U{omaM2DI+o~wZ;xcv$Y+a0@j>%Q2o5lsNoFx%)e!sx$ii`@UM?x9?} zXCl(9+R*xNBH~N4&o=thE#@h}DG0t1FkI8gh^!*tS|^h*)2{sn3TXpw5zaY)ZMAnn zOtX1)hNYXl>eSNefk&>4m9U|F9%^wy=D_&408F(@AlR>MFZ@~v;@Ui~9UShVqcE=> zdcv&WeV)fCGpkk8bX(X#qb;y1KFYlYFv@g;;Of z9+!i{ofsm36wMD4RU6M3%;+uMQR7-?ZVi~{e#}n9R?=Dr%Qd{${#V;t>3gL#c|ocG znB->~aC!e8&hFpCqgP(WORv3*-JX!jgz>W{D9@f?m?jL_=DmVAg9(@dcxz`#QbjE# zLggafT_j#!t`M;RfgUPCa-LM(_LU2E!vN-j@%b~<2{^mBz{6Kx!^`&{;@7|a7@vOp z2@cmcsHLJ#6(|J>8Bm^-HUCzv1)bO2Er3;&W~oY8WpdhJ)D%4^qIcn(WYznH`!?qu zoy-+E4^S4jPV2x-m?8CWG*BpzRFo0|@AJLj-XU#Htb$t;D2q{_DI8k`w#Eq-opoY`1n3ve(3?~;RfT?XSm1%b_AqSFbt%*c_f5gG{t@$JQaa{ z$YlH4atQ&Tgp^f0t6K4kL^w{GBa5dIDKqZxcgSGelp}`SfVaQ$CLX-}5Ko^x!}YT( z9G+ccIvk|51TnZ&XV7GVdzAAV(>QUNH=ytyV`iN}o9CXOCU2N8uX+L|1@EZw0W$C; z(v5m8HE+Wd?&U_K`pP*jD_v3r!whmhcAm)q25&L)svI6eheJhBZewrf0YEirj~SI3 zn4g2v-@NtWIc58~DYBixmkq!QidMi89Y%I}E*lV{IxfE3B0VE}8TSM58G3b&2$j)X#k*YD*Q;_tYI!Ds^v& z{qDwOm;uLVllGpa{;mMdik`XzrRJbli27LI9!~5{fOVkRqD^d$+pR96MH+Gay~#HX zl(G~N;jxasp#XN6)oLjL?B1wv+Z5_M5%L`pSq>}?IZEJ)MmJpn6mgFjneV)9|N9nr)#aKB7V_CCfiHRq zt%W(>Y-$Ar>%MIsC^r6xvbUe@pl{08>HaSfd6)Pg-X#XU&8(nUK9}rKwaO8PXM^=2 zS{1fv#6grzX3_fZOpZw+d{n4&dhh0_ANvx3Y)T%bgzA+O2#FYk0v@k$9B=UX)eZI! z9%1+39AEy8ui{WEKKtZT)Z>JDGa^;7)VFFXNpfQWU@A_Q%Oz@xh4sm3)da^1V8~hB z`Qg?vN|qcSt_>I%WY3pUT)-<;f7ia)LDGi@D)7Pm+Bo<#r{Zu%G{BGn1(}_fcrF;0 zFjEBn0+pidR|G(zHeqmncso5+cIDVTZ)O&z-T(jj+9a)(RV(}NPVB*m6fMThtC#z|1fch-Y8q+;x0tK*0iq zCtM3GjIySTe748#-X(UI=Qz7K!)~9D#v^W?Kfy2^ai4%4RrMUKs3n%Xm%SLEJebgB zpBmU_I!6N?$>%_tGr+eZX~J+2P|Jim72MzLagj5g-W)Iy;o&RyaQ?~z)WW!Va)raw z=PJZWRo)>1Tq@X%JkIkX&CiUCD~gfJd(i{Wf!T;41v*1TBJoDHa1){PCi6AWMHIy* zOS6Pis__7c8ki6S`sjgWy{Oto<*Kr4s07)aFKY?_0$_r+CrF=ltCZ1yqb(^c6X@Nc zB`1dlW?ICs$y;CuL{h`q*n=qm8laA5NRfV<+}3T}evR160wU%+V)Ym$3`kk{LCOS5 z8N+Ued-pDJe(w^6E3R&CP&Ee@lYqdKk^mh#U$wG$vJi=PZ8)o?id(CG*2`R$z`*kU zsYfD({hgB1QdS-z-yEk2Q?9st{17j__A0KPJ;Ns-e*$m?PZiTR0;QtVqQ(;L*3Q;B zp((XdIrH^4C0gf?i2Q7iXM6;)jBjS08(Z5`5_IEQ96-uhO|!7&oVTu^RGgX|wtNdO z&%?GGR5p$KJLF|^x{7DiEX_lY+y$EFuAh;&m=av8c;3kt(Wr=QMMw0WPNr4ao>KRB z>yN&xM)}On7#UfCcte{^+Ct<8&@N-x?XkbO!2a?AX}8C9skk{FaXpQwc?Zt1if9|R zM6#e6PfaeK?bkJ6qH=WrUe?*G&TqEkor zTzIDCfs_I~Cqo1sJZ@y_DHI?ea;A?&AL+^mbhNUycD>U|JWtwsGPmy#<_iEQ1rRcTdX^!19Xt!| z`8vpFJOg#2_v1VWz-h*D9gasp`#Zs%q^vtb- zc%oqG)=;a~)$MkprME3?-|yJ1xcE`QHlyCEgT;wqb%eeN5H&(vbqPe<{4Hlu>~jDG zqIFm=i)CJmfsTm%Deth`V!4g6Rcpr>q4z7y23y#Z%#0JZ9%w0Yh`U}ku9VSl*=-=o z1a7ws>47PZSv{a<2ZNPu_Qd#J9ZeX6W2=cZ(RIPVz<>c0j=1^sS4huxIJ<-9Z$bU?qsO1q+igXR~PH-YleH zGi^`gBs7qTk&(bfLfM9tFqMjgo$C4Wc~vFN&xR^qiSmxB>C`j#nJUj<> zd+g8lxV$_A-M_@~r3tkFIS*RpP5M_#!C*>)gEO{ZPG#XxY=8GBTl>2y9TvK71py+@ zwf&)CF9`3p3V0DHVnDoZB-}-m?)DB7TCFFhFbU`dU1tFaMb=S8buHA(_N{t{nX=)P z*Hlf4Y;Y;yHG$vuhX6^YpmtgKogh?kFq5EaRNwyRhv^= z9yY*5WfuiqDyoJk8s8G-pL53L#eiX)Fii(k%AnmI_0{{RFW;BYfFf(VL&8)Gkdk}M zHrCyHigZ|Dr^G2;_#P432`sh~?!}O4z%{qLK#;tZ(ac(x~8==nOw%P zdG~Wv*U!dneIy#3KlmQtuxe}(1%1bwyxH#?o5M{bZi;wm-dFa5 zs*U6{09N!UYk1%|fwFlaMz3SEeZ#VH4w{Ggwzah*mcf`%fyy`@3a%%~$@S73uVO#! zP{)Eg6;Yh4oN3kOo1x*`6xfLJ?4XkXExaZg=4#-DX4qIWV4AG$ERhJ2plnsBH6w`I^mTQv-bZweYb3-JX%})N?fGCZvS~DuBX-!!+UgaKNak zGkAZ7sS%6M-dr0ldcJ3fQMiGV>}UOA9k{IoDEw}FIDnY*Yje%RXKVLtIgMBd;uiSj za~6s>bo%OSe08JG6xyR%A56!J9%1X=>f7^j+dM630(!pa8Ph#$>k??P>HqNz^_MwM z3~QNb2N=@&o9XoC($!f^{ujBzKAEAhy<)ta;JMmDw?l6}HPrpx zwR+pQwX?U4wkZ$o4NXa3qM~>4kZRzD{2c|#d2+wGi}Gopsxj5A`?h0v!$kJe+}cLS zsYUB;uOaO&B)BldtDA3)q4%_^^y!fdJ&;Qz`AbYOZ1@97ZVKj>nns1d1Yke~FoAH8 z@r@J8N6&Hn=_kN0qYfGQTn@@iSwgmIa%{%cC3CZO%3KS{X{2N>=2FQqx3!-M9t|9s zCtA00%iL5JVUlxlfU*Xw7y+0F$XdaLp;pWl(6#djv2}IpyN=HeGRNt-2bC7Ur2H&k zOo^OVP)e940l=zSmIt+3`C+Mjq|ypqtRD8XWb7_*s|BWoC%}OXqxUYCoC=T*XwTG%fMIBJ~};8s9LCPp3-cEb*r z`yGaqaC3Eo!*LV^tEv|S2onjNsw1U>zq+4vUqLH%W-XVyOJWrT6UqeC;}Luf9EJgD zHvq!`${9=v$D$CioP6{5|E}cZpItjpn~&&!sextkkSqVDkh$gOi5)6X%CQ!%PuP+;9LQe z!1JA`XFLyKz7?3P6_kq81ZSZ?^ZJW2hm2pw!z61|j8<(PtA-^h&t^1`9<_R267&3O zigrt&-NHv2oyc$$@Jb>Sw^eO2ujD1KNSTl*`y7&i8X{o_N@yNC)jc1@1FKY4&{EEu z#0Kir@K)C3?(EAXu>}eOIzV20t=t18$lYx7%I$pPxORSSN>#C{miMU^lv0q>4k=|d z9+@{j&vKJSajea8Efq!cABugfmnS9P9$J4{C6~Z&qvXrXYP=&4`K)NlaH$0rN8v+zo2Z=(uf|=&m8L|R+WP7;*{?QY4!L*T? z9AKAlyeP*-`U1?;$PJFl)~$F$QUEUM)}8XmA)~FztzBe{(KmCqciN!ML&lWxNPlQ+ zsl0qe4T8y;%#+`{8nvUe<(?e@fLrK6)N{M89siV^Z#gewF|JxZASOZq$-afiv^)%c zrm}jiv?3M3NkGAbd5+*t-3ef3PX-c-2&`+Qx9Q7kA&LFGZ$G;hvSwj|rIdx?zMu2; zKHf<;?SOb*e7^;-twjiRZEeiW{O77OgBs*h@aSuxXIm|Yz9U2QLLSjr&@{BU#JRPO zOWHuE!_V)premIHYu@fQJz1Z_yPek8=XL8kMYA0+A10@>#~N5ImLu&;H4m&nINhe2 z<8$&F=F?s|TOIxzd0M@|<(8I+UR&aKb*ru#J`}lob6VV91ZltGMJ{1#dH$)txBrv_ zkr?A|0=_o5nA20h(H!Bn%Ych{I8(dFK+RJOv(0g%8Nf=Ao+9jRaB&VG$xPIMYDde| z);is`spj{rg_*|b_HYqZ#v1^^V9^$k3W2D4c%D?iN}!y)HVx?kSeU_7P^JQ+0hE%I zjph+36KW+)H&-HvBod1cH!qOrmhP4v?k%`YsS?4a`cD;1hrz8U_JFzkn#I*!>(Kx^ z=;2H(Ibkw2CDSYc%@QvCEc$+{!m<{EXP-xFOIwKAfXEpQ8sNKhrv;X|iP!YhIW zAWV9X392bu4yphEWk8z08f?(f+JPd^nN_kW^N8m(X<>L}*accj*c5^QSgpNxjm%k- zJhNI!D+-{VRs#Wf$4FekT*Qk{XNRgPp@RveCNMKV2}whH&UR;b`Q^vB7zTXw$!9oz z_L(Nb9w6QY;h;7v6Nfnvittqtz_Z|35AZ5o4w5qRl)xoP@QVxo$$7k3{Buj2>$KGa zyAg!6=@+k&K9_*edkspl13ackkOFXu?sU%wMGKIMK$J7a;}O#|g-SlQuh91^cs8_N zE7)M5s*ZhC^jHy&J6@;spNLw)HvJo6e;N&2_*aGGm6BLfwfKclT%%PekBfQoO!2Qg zDF)pV!Vhr|hrJ-qk>&;H3np_BfP0&$1OjEudz#{?JHTyS+7+JBYlnAMk@?{BE ztVtB7QY*4nyA@C)wGcN2WxDV@O%3eeh5w($dnM?<;@y!U9!}n28BCFCgP93rx%ZOH z`Ho1GU7plFBCC_Md^r-$_-y1K?7*r49~9=_WDqNb-ZnBC5Ts*E3Oo?RE641hDJ7g= zoPhuwj>pE&)qr3|2T5H~H7R-v{$=QAw*L%hb`VLupA0zW5tf^a5+pCHqgg5s>UfEJ zo|&@HZ#&Z)9SSJONJGLdHSfcu-g~<|bo<4Utpf?A76mJTQW#^IP$mJ53IS6oUZu~B zDpki2t?9b*6zEwSc+BqZM z;6YA8V~+%QxZ!@k$2cA_6$t~Y3Q~4^+={BK$zU_tpxPQnqlEI6lrktM@MP5xGlU{p zyb*Jf5J;u5O2Yp<7ZHc#`>A<+f(wu-u-}q{v;$35Bcc8U(G{RtyBR zx3=-t2nIm_EQ5py36@Jo#!|KK<^|L8wh<~&v;ua>8ECQ%I25*#1dwXfRcU1RD*)E>^2#cUBBM#sgwVj;~ZwI7oJx>7% z<~^HIa?h*%eh+5G;c&n(3{7TsP`i1Tujx|J*t&MD;s|Sf)AgI*v#KYojCBr@obpgx zgmkL=>%tQ&f^^C<8XnrG<{{Tf%-c5b=@Xbk6c(CCVw;0G5N3NSwrL(-r}W`9ow@Ci zVrLS9n!foSy(f6Ke zXA5{T#G6eBr4VYcF)wpw+R)>E%%}Pt@0&Bdcn1TL$dLiKtyFb9wLH24$K)3LqLq_! zviSds$|T`bEP~mdA=Ra-3HZuPIQ&yV+g!4HK&UD44c<16Siy&b=DV`Pi!e<^tu84j zR6U(=0D#XDc@oz+b>wqm`bP^G@%uTrq)Xunb~O=QJpo9O zq*5!+_Iq4j-ox>D#PjFRk#q9g8<^}+$O|~XJH$aA%E)1!k_qfmRoT4>+IpY^4!}Tm z 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

    P_}J0 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%0A%{g58&0mbz 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 z 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

    + stack· + taler-exchange 1.6.6 + · + libtalerexchange 1.6.6 + · + taler-exchange-database 1.6.6 + · + taler-exchange-offline 1.6.6 + · + taler-terms-generator 1.6.6 +

    P_}J0 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%0A%{g58&0mbz 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 z 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