commit 96961f23f5f8dba5bac6111379dcdc0870b275f7 Author: Hernâni Marques Date: Mon Jul 13 10:12:00 2026 +0200 docs: new root as prior history lost (orphan + GC); ~215 commits not recoverable 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 0000000..aeaad87 Binary files /dev/null and b/configs/bank-landing/og-goa-shop.png differ 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 + + + +
bank.hacktivism.ch · privacy · CH
+

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 0000000..aeffa23 Binary files /dev/null and b/configs/bank-landing/qr-logo.png differ diff --git a/configs/bank-landing/qr-logo.svg b/configs/bank-landing/qr-logo.svg new file mode 100644 index 0000000..589b2de --- /dev/null +++ b/configs/bank-landing/qr-logo.svg @@ -0,0 +1 @@ + \ 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 0000000..2be70b2 Binary files /dev/null and b/configs/bank-landing/qr-payto.png differ 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 0000000..e01486e Binary files /dev/null and b/configs/bank-landing/qr-product-beacon-badge.png differ diff --git a/configs/bank-landing/qr-product-blue-or-red-pill.png b/configs/bank-landing/qr-product-blue-or-red-pill.png new file mode 100644 index 0000000..ca39be2 Binary files /dev/null and b/configs/bank-landing/qr-product-blue-or-red-pill.png differ 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 0000000..1bea33f Binary files /dev/null and b/configs/bank-landing/qr-product-comet-cap.png differ diff --git a/configs/bank-landing/qr-product-eclipse-shades.png b/configs/bank-landing/qr-product-eclipse-shades.png new file mode 100644 index 0000000..23fda0a Binary files /dev/null and b/configs/bank-landing/qr-product-eclipse-shades.png differ 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 0000000..a56dd30 Binary files /dev/null and b/configs/bank-landing/qr-product-nebula-coffee.png differ diff --git a/configs/bank-landing/qr-product-orbit-sticker.png b/configs/bank-landing/qr-product-orbit-sticker.png new file mode 100644 index 0000000..367ed3f Binary files /dev/null and b/configs/bank-landing/qr-product-orbit-sticker.png differ diff --git a/configs/bank-landing/qr-product-rainbow-pill.png b/configs/bank-landing/qr-product-rainbow-pill.png new file mode 100644 index 0000000..b5b351e Binary files /dev/null and b/configs/bank-landing/qr-product-rainbow-pill.png differ diff --git a/configs/bank-landing/qr-product-relay-pin.png b/configs/bank-landing/qr-product-relay-pin.png new file mode 100644 index 0000000..34d5070 Binary files /dev/null and b/configs/bank-landing/qr-product-relay-pin.png differ diff --git a/configs/bank-landing/qr-product-shuttle-pass.png b/configs/bank-landing/qr-product-shuttle-pass.png new file mode 100644 index 0000000..a4f4599 Binary files /dev/null and b/configs/bank-landing/qr-product-shuttle-pass.png differ diff --git a/configs/bank-landing/qr-product-star-chart.png b/configs/bank-landing/qr-product-star-chart.png new file mode 100644 index 0000000..ee52b07 Binary files /dev/null and b/configs/bank-landing/qr-product-star-chart.png differ 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 0000000..a63ba20 Binary files /dev/null and b/configs/bank-landing/qr-product-voidwave-playlist.png differ diff --git a/configs/bank-landing/qrcode.min.js b/configs/bank-landing/qrcode.min.js new file mode 100644 index 0000000..993e88f --- /dev/null +++ b/configs/bank-landing/qrcode.min.js @@ -0,0 +1 @@ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>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 0000000..03e96c2 Binary files /dev/null and b/configs/bonfire/assets/img/logo.png differ diff --git a/configs/bonfire/assets/img/logo.svg b/configs/bonfire/assets/img/logo.svg new file mode 100644 index 0000000..e1d3dc3 --- /dev/null +++ b/configs/bonfire/assets/img/logo.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..03e96c2 Binary files /dev/null and b/configs/castopod/assets/img/logo.png differ diff --git a/configs/castopod/assets/img/logo.svg b/configs/castopod/assets/img/logo.svg new file mode 100644 index 0000000..e1d3dc3 --- /dev/null +++ b/configs/castopod/assets/img/logo.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + + + + + + + + + + + +

+ 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 +

+
+
+ 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 0000000..aeaad87 Binary files /dev/null and b/configs/exchange-landing/og-goa-shop.png differ diff --git a/configs/exchange-landing/qr-logo.png b/configs/exchange-landing/qr-logo.png new file mode 100644 index 0000000..aeffa23 Binary files /dev/null and b/configs/exchange-landing/qr-logo.png differ diff --git a/configs/exchange-landing/qr-logo.svg b/configs/exchange-landing/qr-logo.svg new file mode 100644 index 0000000..589b2de --- /dev/null +++ b/configs/exchange-landing/qr-logo.svg @@ -0,0 +1 @@ + \ 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 0000000..03e96c2 Binary files /dev/null and b/configs/forgejo/assets/img/logo.png differ diff --git a/configs/forgejo/assets/img/logo.svg b/configs/forgejo/assets/img/logo.svg new file mode 100644 index 0000000..e1d3dc3 --- /dev/null +++ b/configs/forgejo/assets/img/logo.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..aeaad87 Binary files /dev/null and b/configs/merchant-landing/og-goa-shop.png differ diff --git a/configs/merchant-landing/qr-logo.png b/configs/merchant-landing/qr-logo.png new file mode 100644 index 0000000..aeffa23 Binary files /dev/null and b/configs/merchant-landing/qr-logo.png differ diff --git a/configs/merchant-landing/qr-logo.svg b/configs/merchant-landing/qr-logo.svg new file mode 100644 index 0000000..589b2de --- /dev/null +++ b/configs/merchant-landing/qr-logo.svg @@ -0,0 +1 @@ + \ 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 0000000..2be70b2 Binary files /dev/null and b/configs/merchant-landing/qr-payto.png differ 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 0000000..e01486e Binary files /dev/null and b/configs/merchant-landing/qr-product-beacon-badge.png differ diff --git a/configs/merchant-landing/qr-product-blue-or-red-pill.png b/configs/merchant-landing/qr-product-blue-or-red-pill.png new file mode 100644 index 0000000..ca39be2 Binary files /dev/null and b/configs/merchant-landing/qr-product-blue-or-red-pill.png differ 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 0000000..1bea33f Binary files /dev/null and b/configs/merchant-landing/qr-product-comet-cap.png differ diff --git a/configs/merchant-landing/qr-product-eclipse-shades.png b/configs/merchant-landing/qr-product-eclipse-shades.png new file mode 100644 index 0000000..23fda0a Binary files /dev/null and b/configs/merchant-landing/qr-product-eclipse-shades.png differ 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 0000000..a56dd30 Binary files /dev/null and b/configs/merchant-landing/qr-product-nebula-coffee.png differ diff --git a/configs/merchant-landing/qr-product-orbit-sticker.png b/configs/merchant-landing/qr-product-orbit-sticker.png new file mode 100644 index 0000000..367ed3f Binary files /dev/null and b/configs/merchant-landing/qr-product-orbit-sticker.png differ diff --git a/configs/merchant-landing/qr-product-rainbow-pill.png b/configs/merchant-landing/qr-product-rainbow-pill.png new file mode 100644 index 0000000..b5b351e Binary files /dev/null and b/configs/merchant-landing/qr-product-rainbow-pill.png differ diff --git a/configs/merchant-landing/qr-product-relay-pin.png b/configs/merchant-landing/qr-product-relay-pin.png new file mode 100644 index 0000000..34d5070 Binary files /dev/null and b/configs/merchant-landing/qr-product-relay-pin.png differ diff --git a/configs/merchant-landing/qr-product-shuttle-pass.png b/configs/merchant-landing/qr-product-shuttle-pass.png new file mode 100644 index 0000000..a4f4599 Binary files /dev/null and b/configs/merchant-landing/qr-product-shuttle-pass.png differ diff --git a/configs/merchant-landing/qr-product-star-chart.png b/configs/merchant-landing/qr-product-star-chart.png new file mode 100644 index 0000000..ee52b07 Binary files /dev/null and b/configs/merchant-landing/qr-product-star-chart.png differ 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 0000000..a63ba20 Binary files /dev/null and b/configs/merchant-landing/qr-product-voidwave-playlist.png differ diff --git a/configs/merchant-landing/qrcode.min.js b/configs/merchant-landing/qrcode.min.js new file mode 100644 index 0000000..993e88f --- /dev/null +++ b/configs/merchant-landing/qrcode.min.js @@ -0,0 +1 @@ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>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