docs: new root as prior history lost (orphan + GC); ~215 commits not recoverable

This commit is contained in:
Hernâni Marques 2026-07-13 10:12:00 +02:00
commit 96961f23f5
268 changed files with 24161 additions and 0 deletions

47
scripts/README.md Normal file
View file

@ -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/<service>/``/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).

13
scripts/bonfire/README.md Normal file
View file

@ -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.

View file

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

View file

@ -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 <title> 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)."

240
scripts/bonfire/gitbot-mirror.py Executable file
View file

@ -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()

View file

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

View file

@ -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;

15
scripts/caddy/README.md Normal file
View file

@ -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.

View file

@ -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`.

View file

@ -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)"

View file

@ -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/"

116
scripts/castopod/lib.sh Normal file
View file

@ -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
}

View file

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

32
scripts/castopod/up.sh Normal file
View file

@ -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."

249
scripts/monitoring/countries.txt Executable file
View file

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

View file

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

View file

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

View file

@ -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."

View file

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

View file

@ -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/`.

View file

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

View file

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

View file

@ -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'))
"

View file

@ -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()

View file

@ -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"

View file

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

View file

@ -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) → ~01 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"

View file

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

View file

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

109
scripts/taler-bank/start_bank.sh Executable file
View file

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

View file

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

View file

@ -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
```

View file

@ -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."

View file

@ -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)"

View file

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

View file

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

View file

@ -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 125 **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 |

View file

@ -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 (125 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 125 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"

View file

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

View file

@ -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"

View file

@ -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}</title>
<style>
${COMMON_CSS}
</style>
</head>
<body>
<div class="badge">exchange.hacktivism.ch · GOA</div>
<h1>${title}</h1>
<p>This is an <strong>experimental / exploration</strong> GNU Taler exchange for the
currency <strong>GOA</strong> at <code>exchange.hacktivism.ch</code> (hacktivism.ch).</p>
<p><strong>No formal terms of service</strong> are required to use this service.</p>
<div class="cur">
<strong>GOA · explorational</strong>
Not legal tender. No guaranteed real-world value, redemption, or convertibility.
Issued only for exploration and testing on this stack.
</div>
<h2>By withdrawing or using GOA coins you acknowledge</h2>
<ul>
<li>This service is for exploration and testing only.</li>
<li>There is no guaranteed availability, support, or uptime.</li>
<li>Operators may reset balances or change configuration without notice.</li>
<li>Do not use real money you cannot afford to lose.</li>
<li>Software is provided as-is, without warranty.</li>
</ul>
<p>If you do not agree, do not use this exchange.</p>
<h2>Related</h2>
<ul>
<li><a href="https://bank.hacktivism.ch/intro/">Bank intro</a></li>
<li><a href="https://bank.hacktivism.ch/terms">Bank terms</a></li>
<li><a href="https://taler.hacktivism.ch/terms">Merchant terms</a></li>
<li><a href="https://exchange.hacktivism.ch/intro/">Exchange intro</a></li>
<li><a href="https://exchange.hacktivism.ch/privacy">Exchange privacy</a></li>
</ul>
<h2>Privacy</h2>
<p class="muted">Processing under Swiss FADP (revDSG). What data is retained
(reserves, wire-in, coins, logs, …) is listed on
<a href="https://exchange.hacktivism.ch/privacy">/privacy</a>.</p>
<footer class="muted">Version ${base} · hacktivism.ch</footer>
</body>
</html>
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" <<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${title}</title>
<style>
${COMMON_CSS}
</style>
</head>
<body>
<div class="badge">exchange.hacktivism.ch · privacy</div>
<h1>${title}</h1>
<p>This is an <strong>experimental / exploration</strong> GNU Taler exchange for
<strong>GOA</strong> at <code>exchange.hacktivism.ch</code>.</p>
<p><strong>No formal privacy policy</strong> is required for this demo service.</p>
<h2>High-level notes</h2>
<ul>
<li>Wire transfers via the regional bank may identify bank account holders.</li>
<li>The exchange processes withdrawals, deposits, and related protocol operations.</li>
<li>Logs may be kept for operation and debugging.</li>
</ul>
<p>Do not use this service if that is unacceptable.</p>
<h2>Related</h2>
<ul>
<li><a href="https://exchange.hacktivism.ch/terms">Exchange terms</a></li>
<li><a href="https://bank.hacktivism.ch/terms">Bank terms</a></li>
<li><a href="https://taler.hacktivism.ch/terms">Merchant terms</a></li>
</ul>
<footer class="muted">Version ${base}</footer>
</body>
</html>
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/"

View file

@ -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): daysweeks 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 | Daysweeks (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'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Privacy notice · GOA Exchange · Swiss FADP</title>
<style>
: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; }
p, li, td, th { font-size: 0.95rem; }
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;
}
.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; }
</style>
</head>
<body>
<div class="badge">exchange.hacktivism.ch · privacy · CH</div>
<h1>Privacy notice · GOA Exchange</h1>
<p class="note">
Swiss Federal Act on Data Protection (<strong>FADP / revDSG</strong>, since 1&nbsp;Sep&nbsp;2023).
Experimental GOA exchange at <code>exchange.hacktivism.ch</code> — not a licensed bank.
</p>
<h2>1. Controller</h2>
<p>Operators of the hacktivism.ch GNU Taler stack. No separate DPO for this experimental service.</p>
<h2>2. Data retained</h2>
<table>
<thead><tr><th>Data</th><th>Examples</th><th>Typical retention</th></tr></thead>
<tbody>
<tr><td>Reserves</td><td>Reserve public keys, remaining amount, times</td><td>Service life or wipe</td></tr>
<tr><td>Wire-in</td><td>Amounts, timestamps, bank account reference from wire path</td><td>Service life</td></tr>
<tr><td>Coins</td><td>Coin pubs, denominations, remaining value, deposit links</td><td>Service life</td></tr>
<tr><td>Protocol operations</td><td>Withdraw, refresh, deposit, refund, recoup</td><td>Service life</td></tr>
<tr><td>Wire-out</td><td>Merchant settlement amounts / targets</td><td>Service life</td></tr>
<tr><td>Technical logs</td><td>IP, path, status</td><td>Daysweeks (rotation)</td></tr>
</tbody>
</table>
<p><strong>Design note:</strong> Coin spend is intended to be unlinkable to the customer
<em>at the exchange</em>. Funding a reserve via the bank may identify the bank account
holder on the <strong>bank</strong> system.</p>
<h2>3. Purposes</h2>
<ul>
<li>Operate the GOA GNU Taler exchange protocol</li>
<li>Wire settlement with bank and merchants</li>
<li>Security, debugging, capacity monitoring</li>
</ul>
<h2>4. Your rights</h2>
<p>Access, rectification, deletion, objection under the FADP. Complaint:
Swiss <strong>FDPIC / EDÖB</strong>.</p>
<h2>Related</h2>
<ul>
<li><a href="https://exchange.hacktivism.ch/terms">Exchange terms</a></li>
<li><a href="https://bank.hacktivism.ch/intro/privacy.html">Bank privacy</a></li>
<li><a href="https://taler.hacktivism.ch/privacy">Merchant privacy</a></li>
</ul>
<footer class="muted">exchange-pp-swiss-v0 · Swiss FADP (revDSG)</footer>
</body>
</html>
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}.*

View file

@ -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" <<EOF
{
"ok": ${ok_json},
"at": $(json_str "$(now_iso)"),
"at_human": $(json_str "$(now_human)"),
"error": $( [ -n "$msg" ] && json_str "$msg" || echo null )
}
EOF
}
abort() {
write_run false "$*"
printf '%s\n' "abort exchange-stats: $*" | tee -a "$ERRLOG" >&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" <<EOF
{
"ok": true,
"source": "exchange-db",
"schema": "exchange.* @ taler-exchange",
"focus": "coins",
"timezone": $(json_str "$TZ"),
"generated_at": $(json_str "$GEN"),
"generated_at_human": $(json_str "$HUMAN"),
"reserves": ${RESERVES:-0},
"wire_in_count": ${RESERVES_IN:-0},
"wire_in_amount": $(json_str "$WIRE_IN_AMT"),
"wire_out": ${WIRE_OUT:-0},
"wire_accounts": ${WIRE_ACCTS:-0},
"wirewatch_running": $WW,
"known_coins": ${KNOWN_COINS:-0},
"coins_live": ${COINS_LIVE:-0},
"coins_spent": ${COINS_SPENT:-0},
"coins_remaining_amount": $(json_str "$REMAINING_AMT"),
"withdraw_ops": ${WITHDRAW_OPS:-0},
"withdraw_amount": $(json_str "$WITHDRAW_AMT"),
"refresh_ops": ${REFRESH_OPS:-0},
"recoup": ${RECOUP:-0},
"refunds": ${REFUNDS:-0},
"coin_deposits": ${COIN_DEPOSITS:-0},
"batch_deposits": ${BATCH_DEPOSITS:-0},
"coin_history_events": ${COIN_HISTORY:-0},
"denominations": ${DENOMS_TOTAL:-0},
"denom_values": ${DENOM_VALUES:-0},
"denoms_withdrawable": ${DENOMS_WITHDRAWABLE:-0},
"by_denom": $BY_DENOM_JSON,
"denom_ladder": $LADDER_JSON,
"recent_activity": $RECENT_JSON,
"performance": {
"keys_http": $(json_str "$KEYS_HTTP"),
"keys_ms": $(num_or_null "$KEYS_MS"),
"config_http": $(json_str "$CONFIG_HTTP"),
"config_ms": $(num_or_null "$CONFIG_MS"),
"loadavg": $(json_str "${LOADAVG:-}"),
"statistic_counter_events": ${STAT_EVENTS:-0},
"memory": {
${MEM_JSON}
}
}
}
EOF
grep -q '"ok": true' "$TMP" || abort "tmp json missing ok:true"
mv -f "$TMP" "$OUT"
write_run true
echo "ok exchange reserves=$RESERVES wire_in=$WIRE_IN_AMT coins=$KNOWN_COINS withdraw=$WITHDRAW_AMT denoms=$DENOMS_TOTAL keys_ms=$KEYS_MS -> $OUT"

View file

@ -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 </tmp/future-keys.json 2>/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/signed-keys.json)"
echo "=== upload ==="
runuser -u taler-exchange-offline -- \
taler-exchange-offline -c "$CONF" -L INFO upload < /tmp/signed-keys.json 2>/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 </tmp/keys.json 2>/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

View file

@ -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 &
disown 2>/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

View file

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

View file

@ -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 &
disown 2>/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

View file

@ -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 </tmp/keys-out.json 2>/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

View file

@ -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."

View file

@ -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).

View file

@ -0,0 +1,5 @@
#!/bin/sh
while true; do
certbot renew --quiet
sleep 12h
done

View file

@ -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 ==="
# --- 14: 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 <<EOF
$(list_exchanges)
EOF
[ "$exch_count" -eq 0 ] && warn "no enabled exchanges with EXCHANGE_BASE_URL"
fi
# --- 7: helpers — ensure (no systemd) then require ---
if [ "${SKIP_ENSURE:-0}" != "1" ]; then
if [ -x /usr/local/bin/ensure_merchant_helpers.sh ]; then
echo "--- ensure_merchant_helpers ---"
/usr/local/bin/ensure_merchant_helpers.sh || warn "ensure_merchant_helpers exited non-zero"
elif [ -x "$(dirname "$0")/ensure_merchant_helpers.sh" ]; then
echo "--- ensure_merchant_helpers ---"
"$(dirname "$0")/ensure_merchant_helpers.sh" || warn "ensure_merchant_helpers exited non-zero"
fi
fi
live_helper() {
local p="$1"
pgrep -f "(^|/)(${p})( |$)" >/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

View file

@ -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 &
disown 2>/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 &
disown 2>/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 &
disown 2>/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"

View file

@ -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" <<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${TITLE}</title>
<style>
: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;
}
.pair {
display: grid; gap: 0.65rem; margin: 0.85rem 0 1rem;
}
@media (min-width: 520px) { .pair { grid-template-columns: 1fr 1fr; } }
.cur {
border-radius: 12px; padding: 0.75rem 0.9rem;
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; }
.cur.go a, .cur.go strong { color: #5eead4; }
.cur.chf strong { color: #e8c878; }
.muted { color: #a39e98; font-size: 0.88rem; }
footer { margin-top: 2rem; font-size: 0.85rem; color: #a39e98; }
</style>
</head>
<body>
<div class="badge">taler.hacktivism.ch · merchant</div>
<h1>${TITLE}</h1>
<p>This is a <strong>self-hosted GNU Taler merchant backend</strong> at
<code>taler.hacktivism.ch</code> (hacktivism.ch).</p>
<p><strong>No formal terms of service</strong> 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.</p>
<h2>Dual currency</h2>
<p>This backend is configured for <strong>two currencies at once</strong>:</p>
<div class="pair">
<div class="cur go">
<strong>GOA · explorational</strong>
Local stack currency
(<a href="https://exchange.hacktivism.ch/">exchange</a>,
<a href="https://bank.hacktivism.ch/intro/">bank</a>).
Not legal tender. No guaranteed real-world value, redemption, or convertibility.
</div>
<div class="cur chf">
<strong>CHF · real</strong>
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.
</div>
</div>
<h2>By using this service you acknowledge</h2>
<ul>
<li>GOA is for exploration and testing only.</li>
<li>CHF involves real money — only use funds you control and can afford to risk on a self-hosted experimental stack.</li>
<li>There is no guaranteed availability, support, or uptime.</li>
<li>Operators may reset GOA state, change configuration, delete instance data, or interrupt service without notice.</li>
<li>Software is provided as-is, without warranty.</li>
</ul>
<p>If you do not agree, do not use this merchant backend.</p>
<h2>Related</h2>
<ul>
<li><a href="https://exchange.hacktivism.ch/terms">Exchange terms (GOA)</a></li>
<li><a href="https://bank.hacktivism.ch/intro/">Bank intro</a></li>
<li><a href="https://taler.hacktivism.ch/intro/">Merchant intro</a></li>
<li><a href="https://taler.hacktivism.ch/privacy">Merchant privacy</a></li>
</ul>
<h2>Privacy</h2>
<p class="muted">Processing under Swiss FADP (revDSG). What data is retained
(instances, orders, deposits, logs, …) is listed on
<a href="https://taler.hacktivism.ch/privacy">/privacy</a>.</p>
<footer class="muted">Version ${ETAG} · hacktivism.ch</footer>
</body>
</html>
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."

View file

@ -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 daysweeks 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 | Daysweeks (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" <<EOF
$BODY_TXT
EOF
cat >"$LANG_DIR/${ETAG}.md" <<EOF
$BODY_MD
EOF
cat >"$LANG_DIR/${ETAG}.html" <<'HTML'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Privacy notice · GOA/CHF Merchant · Swiss FADP</title>
<style>
: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; }
p, li, td, th { font-size: 0.95rem; }
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;
}
.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; }
</style>
</head>
<body>
<div class="badge">taler.hacktivism.ch · privacy · CH</div>
<h1>Privacy notice · GOA/CHF Merchant</h1>
<p class="note">
Swiss Federal Act on Data Protection (<strong>FADP / revDSG</strong>, since 1&nbsp;Sep&nbsp;2023).
Dual-currency merchant backend at <code>taler.hacktivism.ch</code>:
<strong>GOA</strong> (explorational) and <strong>CHF</strong> (taler-ops path).
</p>
<h2>1. Controller</h2>
<p>Operators of the hacktivism.ch GNU Taler stack. Experimental public deployment;
no separate DPO appointed.</p>
<h2>2. Data retained</h2>
<table>
<thead><tr><th>Data</th><th>Examples</th><th>Typical retention</th></tr></thead>
<tbody>
<tr><td>Instances</td><td>merchant_id, serial, config</td><td>Instance life + up to 12 months after delete/wipe</td></tr>
<tr><td>Authentication</td><td>API / login tokens</td><td>Until expiry, revoke, or instance delete</td></tr>
<tr><td>Orders</td><td>order_id, amount, summary, paid/wired, time</td><td>Service life or wipe; public stats may list recent amounts/summaries</td></tr>
<tr><td>Deposits / refunds</td><td>proofs, amounts, reasons</td><td>Service life or wipe</td></tr>
<tr><td>Settlement accounts</td><td>payto / account labels</td><td>While configured</td></tr>
<tr><td>Exchange metadata</td><td>GOA/CHF exchange used</td><td>With related order records</td></tr>
<tr><td>Technical logs</td><td>IP, path, status</td><td>Daysweeks (rotation)</td></tr>
<tr><td>Public stats.json</td><td>Aggregates, recent activity</td><td>Overwritten continuously</td></tr>
</tbody>
</table>
<p><strong>Not retained:</strong> wallet private keys; payment card PANs; government ID documents
unless a separate KYC feature is enabled by operators.</p>
<h2>3. Purposes</h2>
<ul>
<li>Accept GNU Taler payments (GOA and/or CHF), refunds, and settlement</li>
<li>Authenticate instance operators</li>
<li>Security, abuse prevention, debugging</li>
</ul>
<h2>4. Recipients</h2>
<ul>
<li>Local GOA exchange (<code>exchange.hacktivism.ch</code>)</li>
<li>CHF exchange as configured (e.g. <code>exchange.taler-ops.ch</code>)</li>
<li>Host/infrastructure operators under this deployment</li>
</ul>
<p>No sale of personal data.</p>
<h2>5. Your rights</h2>
<p>Access, rectification, deletion, and objection under the FADP (within legal limits).
Complaint: Swiss Federal Data Protection and Information Commissioner
(<strong>FDPIC / EDÖB</strong>).</p>
<h2>Related</h2>
<ul>
<li><a href="https://taler.hacktivism.ch/terms">Merchant terms</a></li>
<li><a href="https://exchange.hacktivism.ch/privacy">Exchange privacy</a></li>
<li><a href="https://bank.hacktivism.ch/intro/privacy.html">Bank privacy</a></li>
</ul>
<footer class="muted">merchant-pp-swiss-v0 · Swiss FADP (revDSG)</footer>
</body>
</html>
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}.*

View file

@ -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_<serial>.* → 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" <<EOF
{
"ok": ${ok_json},
"at": $(json_str "$(now_iso)"),
"at_human": $(json_str "$(now_human)"),
"error": $( [ -n "$msg" ] && json_str "$msg" || echo null )
}
EOF
}
abort() {
# Do NOT touch $OUT — previous good stats stay live; site shows run note.
write_run false "$*"
printf '%s\n' "abort merchant-stats: $*" | tee -a "$ERRLOG" >&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 <<SQL
SELECT creation_time AS ts,
'payment'::text AS kind,
order_id,
coalesce(contract_terms->>'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 <<SQL
SELECT r.refund_timestamp AS ts,
'refund'::text AS kind,
ct.order_id,
(r.refund_amount).curr || ':' ||
CASE WHEN (r.refund_amount).frac = 0
THEN (r.refund_amount).val::text
ELSE trim(trailing '0' FROM trim(trailing '.' FROM (
((r.refund_amount).val + (r.refund_amount).frac::numeric / 100000000)::numeric(32,8)
)::text))
END AS amount,
left(translate(coalesce(r.reason,''), E'\t\n\r', ' '), 64) AS summary,
'refunded'::text AS status
FROM ${sch}.merchant_refunds r
JOIN ${sch}.merchant_contract_terms ct ON ct.order_serial = r.order_serial
WHERE upper((r.refund_amount).curr) = 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 ") u ORDER BY ts DESC LIMIT ${ACT_PER_CURRENCY}"
echo ") act;"
} | psql_pipe >"$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" <<EOF
{
"ok": true,
"source": "merchant-db",
"schema": "merchant.merchant_instances + merchant_instance_<serial>",
"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"

View file

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

View file

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

View file

@ -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 &
disown 2>/dev/null || true
sleep 2
nohup taler-merchant-webhook >>"$LOG_DIR/taler-merchant-webhook.log" 2>&1 </dev/null &
nohup taler-merchant-kyccheck >>"$LOG_DIR/taler-merchant-kyccheck.log" 2>&1 </dev/null &
nohup taler-merchant-wirewatch -c /etc/taler-merchant/taler-merchant.conf -L INFO \
>>"$LOG_DIR/taler-merchant-wirewatch.log" 2>&1 </dev/null &
nohup taler-merchant-depositcheck >>"$LOG_DIR/taler-merchant-depositcheck.log" 2>&1 </dev/null &
nohup taler-merchant-exchangekeyupdate >>"$LOG_DIR/taler-merchant-exchangekeyupdate.log" 2>&1 </dev/null &
nohup taler-merchant-reconciliation >>"$LOG_DIR/taler-merchant-reconciliation.log" 2>&1 </dev/null &
disown 2>/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

View file

@ -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
'

View file

@ -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"

View file

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

View file

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

View file

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

View file

@ -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 exchanges 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
```

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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' '; ' </tmp/alt-bank-s.$$ | sed 's/; $//')"
else
fail "bank /config alt_unit_names" "$(tr '\n' '; ' </tmp/alt-bank-s.$$ | sed 's/; $//')"
fi
rm -f /tmp/alt-bank-s.$$
fi
# server-side bank
if koopa_ssh_ok; then
BOUT=$(koopa_ssh_bash 40 <<'REMOTE' || true
set +e
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)
echo "CTR=$BANK"
if [ -z "$BANK" ]; then echo "NOCTR"; exit 0; fi
code=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:9012/config 2>/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' '; ' </tmp/alt-ex-s.$$ | sed 's/; $//')"
else
fail "exchange /config alt_unit_names" "$(tr '\n' '; ' </tmp/alt-ex-s.$$ | sed 's/; $//')"
fi
rm -f /tmp/alt-ex-s.$$
fi
code=$(http_body "$EXCHANGE_PUBLIC/keys" "$tmp/ex-keys.json")
if [ "$code" = "200" ]; then
python3 - "$tmp/ex-keys.json" <<'PY'
import json,sys,time
d=json.load(open(sys.argv[1]))
sk=d.get("signkeys") or []
acc=d.get("accounts") or []
den=d.get("denominations") or []
# count denoms roughly
n=0
for g in den:
if isinstance(g, dict):
n += len(g.get("denoms") or [])
print(f"signkeys={len(sk)} accounts={len(acc)} denom_groups={len(den)} denoms~={n}")
sys.exit(0 if sk and (acc or n) else 1)
PY
ec=$?
detail=$(python3 - "$tmp/ex-keys.json" <<'PY'
import json,sys
d=json.load(open(sys.argv[1]))
sk=d.get("signkeys") or []
acc=d.get("accounts") or []
den=d.get("denominations") or []
n=sum(len(g.get("denoms") or []) for g in den if isinstance(g, dict))
print(f"signkeys={len(sk)} accounts={len(acc)} denoms~={n}")
PY
)
[ "$ec" -eq 0 ] && ok "exchange /keys usable ($detail)" || fail "exchange /keys usable" "$detail"
fi
# /wire if exposed
wcode=$(http_code "$EXCHANGE_PUBLIC/wire")
if [ "$wcode" = "200" ]; then
ok "exchange public /wire"
else
warn "exchange public /wire" "HTTP $wcode (accounts may only be in /keys)"
fi
if koopa_ssh_ok; then
EOUT=$(koopa_ssh_bash 40 <<'REMOTE' || true
set +e
EX=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1)
echo "CTR=$EX"
code=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/config 2>/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' '; ' </tmp/alt-mer-s.$$ | sed 's/; $//')"
else
fail "merchant currencies alt_unit_names" "$(tr '\n' '; ' </tmp/alt-mer-s.$$ | sed 's/; $//')"
fi
rm -f /tmp/alt-mer-s.$$
# Each exchange listed on merchant /config must publish alt_unit_names on its own /config
check_merchant_listed_exchanges_alt_units "$tmp/mer-config.json"
fi
# demo instance reachable?
INST="${MERCHANT_INSTANCE}"
icode=$(http_code "$MERCHANT_PUBLIC/instances/${INST}/config")
if [ "$icode" = "200" ]; then
ok "merchant instance ${INST} /config"
else
# some deployments use private only
warn "merchant instance ${INST} /config" "HTTP $icode"
fi
if koopa_ssh_ok; then
MOUT=$(koopa_ssh_bash 40 <<'REMOTE' || true
set +e
MER=$(podman ps --format '{{.Names}}' | grep -E '^taler-hacktivism$' | head -1)
[ -z "$MER" ] && MER=$(podman ps --format '{{.Names}}' | grep -iE 'merchant|hacktivism' | grep -viE 'bank|exchange' | head -1)
echo "CTR=$MER"
code=$(curl -skS -m 5 -o /dev/null -w '%{http_code}' https://127.0.0.1:9010/config 2>/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

View file

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

View file

@ -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' '; ' </tmp/alt-ex.$$ | sed 's/; $//')"
else
fail "exchange /config alt_unit_names" "$(tr '\n' '; ' </tmp/alt-ex.$$ | sed 's/; $//')"
fi
rm -f /tmp/alt-ex.$$
fi
check_url "exchange /keys" 200 "$EXCHANGE_PUBLIC/keys"
# /keys should also expose currency_specification.alt_unit_names when present
code=$(http_body "$EXCHANGE_PUBLIC/keys" "$tmp/ek.json")
if [ "$code" = "200" ]; then
if python3 - "$tmp/ek.json" <<'PY'
import json,sys
d=json.load(open(sys.argv[1]))
cs=d.get("currency_specification") or {}
au=cs.get("alt_unit_names") if isinstance(cs,dict) else None
if not isinstance(au, dict) or "0" not in au:
# older keys without embedded spec: not a hard fail if /config is good
sys.exit(2)
sys.exit(0)
PY
then
ok "exchange /keys alt_unit_names"
else
ec=$?
if [ "$ec" = "2" ]; then
warn "exchange /keys alt_unit_names" "no currency_specification in /keys (ok if /config has it)"
else
fail "exchange /keys alt_unit_names"
fi
fi
fi
check_url_soft "exchange /intro/" 200 "$EXCHANGE_PUBLIC/intro/"
# Root should land on intro (302/301 then 200 on follow is checked separately)
check_url_soft "exchange /" 302,301,200 "$EXCHANGE_PUBLIC/"
# ---------------------------------------------------------------------------
# Performance — outside-in public HTTPS latency (this runner, not loopback)
# Same spirit as bank landing-stats public probes; measured here from outside.
# ---------------------------------------------------------------------------
section "www · performance · public HTTPS latency (outside-in)"
# Latency thresholds (ms), outside-in from this runner.
# ≥ PERF_WARN_MS → WARN. ≥ PERF_FAIL_MS → ERROR (suite fails).
PERF_WARN_MS="${PERF_WARN_MS:-8000}"
PERF_FAIL_MS="${PERF_FAIL_MS:-20000}"
# Measure one URL: require HTTP expect (default 200), report time_total in ms.
# $1=label $2=url $3=optional expected codes (default 200)
check_perf() {
local label="$1" url="$2" expect="${3:-200}"
local out code t_s ms
out=$(curl -skS --max-redirs 3 -L -m "${PERF_CURL_TIMEOUT:-25}" \
-o /dev/null -w '%{http_code} %{time_total}' "$url" 2>/dev/null || echo "000 0")
code=$(printf '%s' "$out" | awk '{print $1}')
t_s=$(printf '%s' "$out" | awk '{print $2}')
ms=$(awk -v t="${t_s:-0}" 'BEGIN{
ms=(t+0)*1000
if (ms>0 && ms<1) ms=1
printf "%d", int(ms+0.5)
}')
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' '; ' </tmp/alt-bank.$$ | sed 's/; $//')"
else
fail "bank /config alt_unit_names" "$(tr '\n' '; ' </tmp/alt-bank.$$ | sed 's/; $//')"
fi
rm -f /tmp/alt-bank.$$
# Wallet probes this; GET must be 200 (POST→405 is normal)
if [ "${LOCAL_STACK:-1}" = "1" ]; then
check_url "bank /taler-integration/config" 200 "$BANK_PUBLIC/taler-integration/config"
else
check_url_soft "bank /taler-integration/config" 200 "$BANK_PUBLIC/taler-integration/config"
fi
check_url_soft "bank /webui/" 200 "$BANK_PUBLIC/webui/"
check_url_soft "bank /intro/" 200 "$BANK_PUBLIC/intro/"
check_url_soft "bank /" 302,301,200 "$BANK_PUBLIC/"
# Auto-account: credentials + shared-pool taler://withdraw (like step 2)
aa_code=$(http_body "$BANK_PUBLIC/intro/auto-account.json" "$tmp/aa.json")
case "$aa_code" in
200)
if python3 - "$tmp/aa.json" <<'PY'
import json, re, sys
from urllib.parse import urlparse
d = json.load(open(sys.argv[1]))
if not d.get("ok"):
print("ok!=true"); sys.exit(1)
if "payto_uri" in d and d.get("payto_uri"):
print("payto_uri must not be present"); sys.exit(1)
wuri = d.get("taler_withdraw_uri") or d.get("qr_payload") or ""
wm = re.match(r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)$", wuri)
if not wm:
print("need taler://withdraw/HOST:PORT/taler-integration/ID:", wuri[:120]); sys.exit(1)
if ":" not in wm.group(1):
print("withdraw missing port:", wm.group(1)); sys.exit(1)
webui = d.get("login_url") or d.get("webui") or d.get("account_url") or ""
u = urlparse(webui)
if u.scheme not in ("http", "https") or "webui" not in (u.path or ""):
print("login webui missing:", webui[:80]); sys.exit(1)
print("user=%s withdraw=%s login=%s" % (d.get("username"), wm.group(1), webui))
sys.exit(0)
PY
then
ok "bank /intro/auto-account.json" "$(python3 -c 'import json;d=json.load(open("'"$tmp/aa.json"'"));print(d.get("username",""),"·",(d.get("taler_withdraw_uri") or "")[:72])' 2>/dev/null || true)"
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' '; ' </tmp/alt-mer.$$ | sed 's/; $//')"
else
fail "merchant /config currencies alt_unit_names" "$(tr '\n' '; ' </tmp/alt-mer.$$ | sed 's/; $//')"
fi
rm -f /tmp/alt-mer.$$
# Follow each exchange listed in merchant /config and require its /config alt_unit_names
check_merchant_listed_exchanges_alt_units "$tmp/mc.json"
check_url_soft "merchant /intro/" 200 "$MERCHANT_PUBLIC/intro/"
check_url_soft "merchant /webui/" 200 "$MERCHANT_PUBLIC/webui/"
check_url_soft "merchant /" 302,301,200 "$MERCHANT_PUBLIC/"
fi
# Merchant legal docs
check_legal_doc "merchant /terms" "$MERCHANT_PUBLIC/terms" "terms|dual|GOA|CHF|explorational|merchant"
check_legal_doc "merchant /privacy" "$MERCHANT_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|CHF|merchant"
code=$(http_code "$MERCHANT_PUBLIC/terms/")
case "$code" in
200|301|302) ok "merchant /terms/" "HTTP $code" ;;
*) warn "merchant /terms/" "HTTP $code (expect 302 → /terms)" ;;
esac
# ---------------------------------------------------------------------------
# Landing pages: every HTTPS link exposed on bank / merchant / exchange intros
# + required static assets + bank withdraw mint (taler://withdraw only)
# ---------------------------------------------------------------------------
section "www · landing exposed links · bank / merchant / exchange"
# Known-good landing static paths (relative to each host base)
# Checked even if HTML parse misses them.
check_landing_asset() {
local label="$1" url="$2" soft="${3:-0}"
local code
code=$(http_code "$url")
case "$code" in
200) ok "$label" "HTTP 200 · $url" ;;
301|302|303|307|308)
# follow once for assets that redirect
code=$(curl -skS --max-redirs 3 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/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

View file

@ -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 $(…)<<heredoc).
{
printf 'INRELEASE_URL=%q\n' "$INRELEASE_URL"
cat <<'REMOTE'
set +e
resolve_ctr() {
local want="$1" c
c=$(podman ps --format '{{.Names}}' 2>/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

View file

@ -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.
#
# id<TAB>command
lint-exchange-goa taler-harness deployment lint-exchange-url https://exchange.hacktivism.ch/

View file

@ -0,0 +1,41 @@
# Real-liferelevant 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

536
scripts/taler-monitoring/lib.sh Executable file
View file

@ -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.<CODE>.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' </root/$(basename "$rel") 2>/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
}

View file

@ -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.<domain>
--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"

View file

@ -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.

View file

@ -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"

View file

@ -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"

View file

@ -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' </root/merchant-goa-demo-cp4zqk-password.txt)
tmp=$(mktemp)
curl -sk -m 12 -H "Authorization: Bearer secret-token:${MPW}" \
-o "$tmp" "${MERCHANT_URL}/instances/goa-demo-cp4zqk/private/" || true
if [ -s "$tmp" ]; then
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'} instance {key}: {s:.0f}s")
if not ok: fails+=1
sys.exit(fails)
PY
FAILS=$((FAILS + $?))
else
warn "could not load instance goa-demo-cp4zqk"
fi
rm -f "$tmp"
fi
echo "=== summary fails=$FAILS ==="
exit "$FAILS"

View file

@ -0,0 +1,119 @@
#!/bin/bash
# Sanity / ops: check paid orders wired? bank transfers? balances?
# Run on koopa as root.
#
# Usage:
# check_settlement.sh # all paid orders for goa-demo
# check_settlement.sh ORDER_ID # one order
# MERCHANT_INSTANCE=foo check_settlement.sh
set -euo pipefail
ROOT=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=lib.sh
source "$ROOT/lib.sh"
need_root
INST="${MERCHANT_INSTANCE:-goa-demo-cp4zqk}"
ORDER_ID="${1:-}"
if ! read_pw MPW "/root/merchant-${INST}-password.txt"; then
read_pw MPW /root/merchant-goa-demo-cp4zqk-password.txt || true
fi
if [ -z "${MPW:-}" ]; then
fail "no merchant password for $INST"
exit 1
fi
AUTH=$(merchant_auth_header "$MPW")
echo "=== Settlement check instance=$INST ==="
tmp=$(mktemp)
curl -sk -m 15 -H "$AUTH" -o "$tmp" \
"${MERCHANT_URL}/instances/${INST}/private/orders?paid=YES"
python3 - "$tmp" "$ORDER_ID" <<'PY'
import json,sys,time
d=json.load(open(sys.argv[1]))
want=sys.argv[2] or None
orders=d.get("orders") or []
if want:
orders=[o for o in orders if o.get("order_id")==want]
if not orders:
print("FAIL no paid orders" + (f" matching {want}" if want else ""))
sys.exit(1)
print(f"INFO paid_orders={len(orders)}")
for o in orders:
print(f" - {o.get('order_id')} amount={o.get('amount')} summary={o.get('summary')!r} paid={o.get('paid')}")
sys.exit(0)
PY
ec=$?
[ "$ec" -eq 0 ] || FAILS=$((FAILS + 1))
# Detail each order
mapfile -t OIDS < <(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); want=sys.argv[2] or None
oids=[o["order_id"] for o in d.get("orders",[])]
print("\n".join([x for x in oids if not want or x==want]))' "$tmp" "$ORDER_ID")
for oid in "${OIDS[@]}"; do
echo "--- order $oid ---"
ot=$(mktemp)
curl -sk -m 15 -H "$AUTH" -o "$ot" \
"${MERCHANT_URL}/instances/${INST}/private/orders/${oid}"
python3 - "$ot" <<'PY'
import json,sys,time
d=json.load(open(sys.argv[1]))
ct=d.get("contract_terms") or {}
ts=(ct.get("timestamp") or {}).get("t_s")
rd=(ct.get("refund_deadline") or {}).get("t_s")
wd=(ct.get("wire_transfer_deadline") or {}).get("t_s")
now=int(time.time())
print(" status", d.get("order_status"), "wired", d.get("wired"), "deposit_total", d.get("deposit_total"))
print(" amount", ct.get("amount"), "summary", ct.get("summary"))
if ts and rd and wd:
print(f" age_s={now-ts} refund_in_s={rd-now} wire_in_s={wd-now}")
print(f" windows: refund={rd-ts}s wire_from_pay={wd-ts}s (wire_after_refund={wd-rd}s)")
if now >= 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"

View file

@ -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"

View file

@ -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"
}

View file

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

View file

@ -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" <<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${title}</title>
<style>
: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; }
</style>
</head>
<body>
<div class="badge">${badge}</div>
<h1>${title}</h1>
${BODY_HTML_MAIN}
<footer class="muted">Version ${etag} · Swiss FADP (revDSG) · hacktivism.ch</footer>
</body>
</html>
HTML
chmod -R a+rX "$dir"
echo "privacy docs: $out/${etag}.{txt,md,html}"
}

View file

@ -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' </sys/fs/cgroup/memory.current 2>/dev/null || true)
if [ -r /sys/fs/cgroup/memory.max ]; then
limit_b=$(tr -d ' \n' </sys/fs/cgroup/memory.max 2>/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' </sys/fs/cgroup/memory/memory.usage_in_bytes 2>/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}
"
}

View file

@ -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; }

View file

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

View file

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

View file

@ -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" "$@"

View file

@ -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" <<EOS
#!/bin/bash
set +e
exec > /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' </root/bank-admin-password.txt)
MPW=\$(tr -d '\\n' </root/merchant-\${INST}-password.txt)
AUTH="Authorization: Bearer secret-token:\${MPW}"
# admin token
AT=\$(curl -sS -u "admin:\${ADMIN_PASS}" -H 'Content-Type: application/json' \\
-d '{"scope":"readwrite"}' "\${BANK}/accounts/admin/token" \\
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))')
[ -n "\$AT" ] || { echo FAIL_admin_token; exit 1; }
# create or reuse bank account
code=\$(curl -sS -o /tmp/acc-create.json -w '%{http_code}' -X POST \\
-H "Authorization: Bearer \${AT}" -H 'Content-Type: application/json' \\
-d "{\\"username\\":\\"\${FAT_USER}\\",\\"password\\":\\"\${FAT_PW}\\",\\"name\\":\\"Extreme Fat Wallet\\",\\"is_public\\":false,\\"is_taler_exchange\\":false,\\"debit_threshold\\":\\"GOA:1000000\\"}" \\
"\${BANK}/accounts")
echo create_account_http=\$code
cat /tmp/acc-create.json; echo
# if exists (409), reset password via admin if possible
if [ "\$code" != "200" ] && [ "\$code" != "201" ] && [ "\$code" != "204" ]; then
echo "account may exist; try credit anyway"
fi
# credit from admin (idempotent enough — we just need lots of GOA)
python3 - <<'PY'
import json, os, urllib.request, base64
bank=os.environ.get("BANK","http://127.0.0.1:9012")
PY
# use host credit-account if present
if [ -x /root/koopa-admin-log/scripts/taler-bank/credit-account.sh ]; then
/root/koopa-admin-log/scripts/taler-bank/credit-account.sh "\$FAT_USER" "\$FAT_CREDIT" || true
elif [ -x /Users/newkamek/src/koopa/koopa-admin-log/scripts/taler-bank/credit-account.sh ]; then
true
else
# inline credit
PAYTO="payto://x-taler-bank/bank.hacktivism.ch/\${FAT_USER}?receiver-name=\${FAT_USER}&message=extreme-credit"
UID=\$(python3 -c 'import os; a="0123456789ABCDEFGHJKMNPQRSTVWXYZ"; b=os.urandom(32); bits=0;v=0;o=[]
for byte in b:
v=(v<<8)|byte; bits+=8
while bits>=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" </root/bank-'"$FAT_USER"'-password.txt); BT=$(curl -sS -u "'"$FAT_USER"':${BPW}" -H "Content-Type: application/json" -d "{\"scope\":\"readwrite\"}" http://127.0.0.1:9012/accounts/'"$FAT_USER"'/token | python3 -c "import sys,json;print(json.load(sys.stdin).get(\"access_token\",\"\"))"); curl -sS -X POST -H "Authorization: Bearer ${BT}" http://127.0.0.1:9012/accounts/'"$FAT_USER"'/withdrawals/'"$WID"'/confirm; echo CONF_DONE\n'
# --- 2) wallet withdraw ---
log "=== wallet: add exchange + ToS ==="
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
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" </root/bank-'"$FAT_USER"'-password.txt); BT=$(curl -sS -u "'"$FAT_USER"':${BPW}" -H "Content-Type: application/json" -d "{\"scope\":\"readwrite\"}" http://127.0.0.1:9012/accounts/'"$FAT_USER"'/token | python3 -c "import sys,json;print(json.load(sys.stdin).get(\"access_token\",\"\"))"); curl -sS -X POST -H "Authorization: Bearer ${BT}" http://127.0.0.1:9012/accounts/'"$FAT_USER"'/withdrawals/'"$WID"'/confirm 2>/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' </root/merchant-${INST}-password.txt); curl -sk -H \"Authorization: Bearer secret-token:\${MPW}\" https://127.0.0.1:9010/instances/${INST}/private/orders/${oid}" \
>"$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 - <<PY
import json,glob,os
scratch="$SCRATCH"
print("scratch", scratch)
for tag in ("high","tiny","atomic"):
p=f"{scratch}/order-{tag}-status.json"
if not os.path.isfile(p):
print(tag, "no status file"); continue
try:
d=json.load(open(p))
print(tag, "paid=", d.get("paid"), "status=", d.get("order_status"),
"amount=", (d.get("contract_terms") or {}).get("amount"),
"deposit_total=", d.get("deposit_total"))
except Exception as e:
print(tag, "err", e)
print("template_uris:")
print(open(f"{scratch}/template-uris.txt").read())
PY
log "done — details in $SCRATCH"

View file

@ -0,0 +1,54 @@
#!/bin/bash
# Local machine: withdraw GOA via bank-integrated QR1 URI (explorer demo account).
# Requires: monorepo wallet, ssh koopa, auto-confirm on host.
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
SCRATCH="${ROOT}/.tmp/session-withdraw-$(date -u +%Y%m%d-%H%M%S)"
mkdir -p "$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/}"
URI="${1:-}"
wcli() { node "$MONO" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"; }
echo "scratch=$SCRATCH"
echo "=== add + accept ToS for $EX ==="
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-tos.out"
if [ -z "$URI" ]; then
URI=$(ssh -o BatchMode=yes koopa 'tr -d "\n" </var/www/bank-landing/withdraw.uri')
fi
echo "URI=$URI"
echo "$URI" >"$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