commit f7e666a023a6c4fb05fe29ce8d6e7b64588923d0 parent 23ba0a29d1bf38467f58c0a97baca7d363b646ca Author: Florian Dold <dold@taler.net> Date: Wed, 2 Sep 2026 20:59:40 +0200 regional-currency-ng: add Ansible-based deployment Diffstat:
45 files changed, 2616 insertions(+), 0 deletions(-)
diff --git a/README.md b/README.md @@ -32,6 +32,7 @@ netzbon/ -- scripts to assist netzbon with their deployment postmortem/ -- postmortem reports about major incidents we have had with lessons learned private-keys/ -- deployment private keys so that keys do not change if we migrate elsewhere regional-currency/ -- scripts to setup a regional currency with minimum effort +regional-currency-ng/ -- Ansible deployment for a regional currency sign-gnu-artefact/ -- shell script for making releases and preparing upload to ftp.gnu.org splitops/ -- shell for multi-sig access control to lock down root on production systems systemd-services/ -- service definitions for buildbot (master and slaves) diff --git a/regional-currency-ng/.gitignore b/regional-currency-ng/.gitignore @@ -0,0 +1,5 @@ +config/ +setup.log +db.json +__pycache__/ +inventory/* diff --git a/regional-currency-ng/.shellcheckrc b/regional-currency-ng/.shellcheckrc @@ -0,0 +1 @@ +disable=SC2018,SC2019 diff --git a/regional-currency-ng/ChangeLog b/regional-currency-ng/ChangeLog @@ -0,0 +1,44 @@ +Wed Sep 2 2026 + Updated the regional-currency role for the current trixie-testing + LibEuFin WebUI settings and packaged exchange legal documents. + + Extended fresh-exchange readiness checks for initial RSA key + generation, configured the merchant report generator base URL, and + cleaned disabled Nexus worker failure states. + + Require Ansible privilege escalation so service-user tasks also work + when the SSH connection itself uses root, and report administrator + password setup failures without exposing the password. + +Tue Aug 25 2026 + Replaced the root shell installer with an operator-run Ansible + playbook and an inventory-only configuration wizard. Removed the + custom encrypted configuration format in favor of optional + ansible-vault usage. + + Made repeated wizard runs create or update a separate inventory file + for each target while preserving its existing variables. + + Configure the GNU Taler package source in Deb822 format with Ansible's + built-in deb822_repository module and the distribution-specific URL. + + Upgrade managed packages with services stopped, guard immutable + deployment identity, and generate one-month withdrawal denominations + only during fresh exchange setup. + + Add daily online exchange signing and annual fee maintenance. Document + the deliberate online-master-key tradeoff for this deployment. + + Gate Nexus startup on completed EBICS enrollment, bind LibEuFin to + loopback, disable compatibility password authentication, add proxy + rate limits and forwarded headers, and make inventory writes atomic. + + Remove the obsolete server-side upgrade and migration scripts. + +Sun Mar 10 12:15:15 PM CET 2024 + Changed the scripts to enable (!) taler-merchant.target + instead of just the taler-merchant-httpd service. + + Added automatically setting the wire-fee for IBAN. + + Added code to automatically run taler-exchange-offline daily (#8623). diff --git a/regional-currency-ng/README.md b/regional-currency-ng/README.md @@ -0,0 +1,141 @@ +GNU Taler regional currency deployment +====================================== + +This directory contains a normal Ansible deployment for a single-host GNU +Taler regional currency. An operator runs Ansible over SSH; no repository +checkout or setup script is needed on the managed server. + +Requirements +------------ + +The operator machine needs Python 3 and `ansible-core` 2.20 or newer. The +managed host must: + +- run a supported Debian or Ubuntu release with PostgreSQL 15 or newer; +- be reachable over SSH by a user that can become root; +- have `bank.DOMAIN`, `exchange.DOMAIN`, and `backend.DOMAIN` resolve to it; +- allow inbound HTTP and, when TLS is enabled, HTTPS traffic. + +Create an inventory +------------------- + +Run the inventory wizard on the operator machine: + +```console +$ cd regional-currency-ng +$ ./inventory-wizard.py +``` + +`./main.sh` is retained as a compatibility alias for the wizard. The wizard +only asks questions and creates one ordinary Ansible inventory file for each +target: + +```text +inventory/HOST.yml +``` + +Each file contains that target's SSH connection and regional-currency +variables, is created with mode `0600`, and generated inventories are ignored +by Git. The file is plain YAML. There is no custom secret format or encryption +layer. + +The wizard is incremental. Run it again with the same inventory directory to +add another target file. Enter an existing inventory host name to update only +that target's file; the current settings become the prompt defaults and custom +variables are preserved. An encrypted target file must be decrypted with +`ansible-vault decrypt` before the wizard can update it. + +If the inventory should be encrypted, use Ansible Vault directly: + +```console +$ ansible-vault encrypt inventory/HOST.yml +``` + +Use `--ask-vault-pass` or the usual Ansible vault-password options on later +commands. + +Deploy +------ + +Check SSH and privilege escalation first, then run the playbook: + +```console +$ ansible -i inventory regional_currency -m ping +$ ansible-playbook -i inventory site.yml +``` + +Add `--ask-become-pass` when the SSH user's sudo configuration requires a +password. + +For an encrypted target inventory file: + +```console +$ ansible-playbook --ask-vault-pass \ + -i inventory site.yml +``` + +Running the playbook again reconciles the host with the inventory. Ansible +installs the Taler packages, configures nginx and optional Let's Encrypt TLS, +initializes LibEuFin, the exchange, and the merchant backend, and starts their +systemd units. Every normal playbook run upgrades the packages managed by this +role to the newest versions offered by the configured APT repositories. Take a +database and configuration backup before running the playbook when an upstream +upgrade may require operational rollback. + +The deployed currency and domain are recorded on the server after the first +successful run. Once fiat conversion is active, its currency and account +identity are recorded as well. The role refuses later inventory changes to +these identity values because changing them in place requires a separately +planned migration. Enabling conversion on a deployment that did not previously +use it is supported. + +Runtime-generated values +------------------------ + +The LibEuFin exchange-account payto URI and its wire-gateway token are created +on the managed host. They are stored root-only below +`/var/lib/taler-regional-currency` so subsequent Ansible runs reuse them. The +exchange offline private key remains owned by the +`taler-exchange-offline` account. These values are never copied into the +operator inventory. + +This deployment intentionally performs exchange master-key signing online on +the regional-currency host. A daily systemd timer signs newly generated keys +and keeps fee schedules available for the current and following calendar year. +This favors unattended operation for a small regional deployment, but an +attacker who compromises the host may also gain the master signing key. It is +not the split online/offline arrangement recommended for higher-value exchange +deployments. + +On a fresh exchange, generated denominations use a one-month withdrawal +period. The role deliberately never rewrites an installed denomination +configuration; denomination attributes are immutable after their first use. + +After deployment +---------------- + +The services are available at: + +- `https://bank.DOMAIN/` (or HTTP when TLS is disabled); +- `https://exchange.DOMAIN/`; +- `https://backend.DOMAIN/`. + +When fiat conversion is enabled, complete the LibEuFin Nexus EBICS enrollment +with the bank while `regional_currency_enable_nexus_services` remains `false`. +Then set it to `true` in that host's inventory and run the playbook again. The +role first checks one transient fetch and submit before enabling the continuous +Nexus services. Setting the variable back to `false` stops and disables them. +The inventory wizard asks about this switch only for conversion deployments. + +The nginx proxy limits token creation to 30 requests per minute per client IP +and challenge requests to 10 requests per minute by default. Operators can +tune `regional_currency_bank_token_rate`, +`regional_currency_bank_token_burst`, +`regional_currency_bank_challenge_rate`, and +`regional_currency_bank_challenge_burst` in the host inventory. + +Merchant instances and their credentials must be created separately. + +The `diagnose.sh` and `list-incoming.sh` scripts are server-side diagnostic +utilities, not deployment or upgrade entry points. All deployment and package +upgrade work is performed by `ansible-playbook` from the operator machine. diff --git a/regional-currency-ng/ansible.cfg b/regional-currency-ng/ansible.cfg @@ -0,0 +1,8 @@ +[defaults] +interpreter_python = auto_silent +retry_files_enabled = False +roles_path = roles +remote_tmp = /var/tmp + +[ssh_connection] +pipelining = True diff --git a/regional-currency-ng/container/Dockerfile b/regional-currency-ng/container/Dockerfile @@ -0,0 +1,23 @@ +FROM debian + +ENV container podman +ENV DEBIAN_FRONTEND noninteractive + +# Enable systemd. +RUN apt-get update ; \ + apt-get install -y nginx postgresql postgresql-client inetutils-ping procps vim less systemd systemd-sysv; \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* ; \ + rm -rf /lib/systemd/system/multi-user.target.wants/* ; \ + rm -rf /etc/systemd/system/*.wants/* ; \ + rm -rf /lib/systemd/system/local-fs.target.wants/* ; \ + rm -rf /lib/systemd/system/sockets.target.wants/*udev* ; \ + rm -rf /lib/systemd/system/sockets.target.wants/*initctl* ; \ + rm -rf /lib/systemd/system/sysinit.target.wants/systemd-tmpfiles-setup* ; \ + rm -rf /lib/systemd/system/systemd-update-utmp* + +RUN systemctl enable postgresql.service +RUN systemctl enable nginx.service + +EXPOSE 80/tcp + +CMD ["/lib/systemd/systemd"] diff --git a/regional-currency-ng/container/README.md b/regional-currency-ng/container/README.md @@ -0,0 +1,10 @@ +Legacy container helper +======================= + +The remaining scripts build and start a disposable systemd container for +manual development. They do not deploy the regional-currency stack. + +Regional-currency deployment is intentionally performed from an operator +machine over SSH with the Ansible playbook in the parent directory. Use a VM +or container with SSH access and add that target through +`../inventory-wizard.py` when testing the playbook. diff --git a/regional-currency-ng/container/build.sh b/regional-currency-ng/container/build.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# This file is in the public domain. + +THIS_FILE=$(realpath "$0") +DIR=$(dirname "$THIS_FILE") + +podman build -f $DIR/Dockerfile -t taler diff --git a/regional-currency-ng/container/enter.sh b/regional-currency-ng/container/enter.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# This file is in the public domain. + +podman exec -it -w /root taler /bin/bash diff --git a/regional-currency-ng/container/start.sh b/regional-currency-ng/container/start.sh @@ -0,0 +1,15 @@ +#/bin/bash +# This file is in the public domain. + +THIS_FILE=$(realpath "$0") +DIR=$(dirname "$THIS_FILE") + +podman run --name taler --replace -p 80:80 -v $DIR/..:/root:O -ti taler + +# About cap_sys_admin +# https://www.freedesktop.org/wiki/Software/systemd/ContainerInterface/ +# +# Do not drop CAP_SYS_ADMIN from the container. +# A number of fs namespacing related settings. +# You break all services that make use of these flags if you drop the flag. +#podman run --name taler --replace -p 80:80 --cap-add sys_admin -v $DIR/..:/root:O -ti taler diff --git a/regional-currency-ng/diagnose.sh b/regional-currency-ng/diagnose.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash + +# This file is in the public domain. + +# Script for basic diagnostics of a Taler regio deployment. +# @author Florian Dold <dold@taler.net> + +if [ "$(id -u)" -ne 0 ]; then + echo "FATAL: Please run as root." >&2 + exit 1 +fi + +libeufin_bank_db=$(libeufin-bank config get libeufin-bankdb-postgres config) +libeufin_nexus_db=$(libeufin-nexus config get libeufin-nexusdb-postgres config) +exchange_db=$(taler-exchange-config -s exchangedb-postgres -o config) + +if [[ $libeufin_nexus_db != $libeufin_bank_db ]]; then + echo "FATAL: libeufin-bank and libeufin-nexus don't share the same database" >&2 + exit 1 +fi + +libeufin_db=$libeufin_bank_db + +# runsql db RESNAME < query +function runsql() { + local sql + read -r -d '' sql + res=$(cd / && sudo -u postgres psql "$1" -t --csv -c "$sql") + printf -v "$2" '%s' "$res" +} + +# +# Check for conversion trigger +# + +runsql "$libeufin_db" have_conversion_triggers <<EOF +select count(*) from information_schema.triggers + where trigger_schema='libeufin_nexus' + and trigger_name='cashin_link'; +EOF + +echo "have_conversion_triggers" $have_conversion_triggers + +# +# Check for transactions +# +runsql "$libeufin_db" num_nexus_incoming_transactions <<EOF +select count(*) from libeufin_nexus.incoming_transactions; +EOF +echo num_nexus_incoming_transactions: $num_nexus_incoming_transactions + +runsql "$libeufin_db" num_nexus_talerable_transactions <<EOF +select count(*) from libeufin_nexus.talerable_incoming_transactions; +EOF +echo "num_nexus_talerable_transactions:" $num_nexus_talerable_transactions + +runsql "$libeufin_db" num_nexus_bounced_transactions <<EOF +select count(*) from libeufin_nexus.bounced_transactions; +EOF +echo "num_nexus_bounced_transactions:" $num_nexus_bounced_transactions + +runsql "$libeufin_db" num_bank_exchange_incoming <<EOF +select count(*) from libeufin_bank.taler_exchange_incoming; +EOF +echo "num_bank_exchange_incoming:" $num_bank_exchange_incoming + +runsql "$exchange_db" num_exchange_reserves_in <<EOF +select count(*) from exchange.reserves_in; +EOF +echo num_exchange_reserves_in: $num_exchange_reserves_in + +runsql "$exchange_db" num_exchange_reserves <<EOF +select count(*) from exchange.reserves; +EOF +echo num_exchange_reserves: $num_exchange_reserves + + +function expect_unit_active() { + systemctl --quiet is-active "$1" + if [[ $? -ne 0 ]]; then + echo "WARNING: expected unit $1 to be active, but it is not active" + fi +} + +libeufin_units=( +libeufin-bank.service +libeufin-nexus-ebics-fetch.service +libeufin-nexus-ebics-submit.service +) + +exchange_units=( +taler-exchange-aggregator.service +taler-exchange-closer.service +taler-exchange-expire.service +taler-exchange-httpd.service +taler-exchange-secmod-cs.service +taler-exchange-secmod-eddsa.service +taler-exchange-secmod-rsa.service +taler-exchange-transfer.service +taler-exchange-wirewatch.service +) + + +merchant_units=( +taler-merchant-httpd.service +) + +all_units=() +all_units+=( "${libeufin_units[@]}" "${exchange_units[@]}" "${merchant_units[@]}" ) + +for unit in ${all_units[@]}; do + expect_unit_active "$unit" +done + +SINCE="7 days ago" +nlogs=20000 +echo "analysing logs since $SINCE, max $nlogs entries" + +for unit in ${all_units[@]}; do + num_warnings=$(journalctl -u "$unit" --since "$SINCE" -n $nlogs | grep WARNING | wc -l) + num_errors=$(journalctl -u "$unit" --since "$SINCE" -n $nlogs | grep ERROR | wc -l) + if [[ ( $num_errors -eq 0 ) && ( $num_warnings -eq 0 ) ]]; then + continue + fi + echo "Please check logs for $unit ($num_warnings warnings, $num_errors errors)" +done diff --git a/regional-currency-ng/inventory-wizard.py b/regional-currency-ng/inventory-wizard.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +"""Interactively create an Ansible inventory entry for a regional currency.""" + +import argparse +import getpass +import os +import re +import secrets +import tempfile +from pathlib import Path + +try: + import yaml +except ImportError as exc: + raise SystemExit( + "PyYAML is required. Install ansible-core before running this wizard." + ) from exc + + +HOST_PATTERN = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*") +CURRENCY_PATTERN = re.compile(r"[A-Z]{3,11}") +BIC_PATTERN = re.compile(r"[A-Z0-9]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?") +IBAN_PATTERN = re.compile(r"[A-Z]{2}[0-9]{2}[A-Z0-9]{1,28}") + + +def ask(message: str, default: str | None = None, *, secret: bool = False) -> str: + hint = f" [{default}]" if default is not None and not secret else "" + while True: + prompt = f"{message}{hint}: " + value = getpass.getpass(prompt) if secret else input(prompt) + value = value.strip() + if value: + return value + if default is not None: + return default + print("A value is required.") + + +def ask_matching( + message: str, + pattern: re.Pattern[str], + default: str | None = None, + *, + normalize=lambda value: value, +) -> str: + while True: + value = normalize(ask(message, default)) + if pattern.fullmatch(value): + return value + print("The value has an invalid format.") + + +def ask_yes_no(message: str, default: bool) -> bool: + hint = "Y/n" if default else "y/N" + while True: + value = input(f"{message} [{hint}]: ").strip().lower() + if not value: + return default + if value in {"y", "yes"}: + return True + if value in {"n", "no"}: + return False + print("Please answer yes or no.") + + +def ask_int(message: str, default: int, minimum: int = 1, maximum: int = 65535) -> int: + while True: + raw = ask(message, str(default)) + try: + value = int(raw) + except ValueError: + value = 0 + if minimum <= value <= maximum: + return value + print(f"Enter a number between {minimum} and {maximum}.") + + +def bool_value(value: object, default: bool) -> bool: + return value if isinstance(value, bool) else default + + +def string_value(value: object, default: str | None = None) -> str | None: + return str(value) if value is not None else default + + +def load_yaml(path: Path) -> dict: + if not path.exists(): + return {} + content = path.read_text(encoding="utf-8") + if content.startswith("$ANSIBLE_VAULT;"): + raise SystemExit( + f"{path} is encrypted. Decrypt it with ansible-vault before editing it." + ) + try: + data = yaml.safe_load(content) + except yaml.YAMLError as exc: + raise SystemExit(f"Cannot parse {path}: {exc}") from exc + if data is None: + return {} + if not isinstance(data, dict): + raise SystemExit(f"Expected a YAML mapping in {path}.") + return data + + +def atomic_write(path: Path, content: str, mode: int = 0o600) -> None: + """Durably replace path without exposing a partially written inventory.""" + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", dir=path.parent + ) + temporary_path = Path(temporary_name) + try: + os.fchmod(descriptor, mode) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + descriptor = -1 + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary_path, path) + directory_descriptor = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + finally: + if descriptor >= 0: + os.close(descriptor) + temporary_path.unlink(missing_ok=True) + + +def inventory_hosts(inventory: dict, path: Path) -> dict: + try: + hosts = inventory["all"]["children"]["regional_currency"]["hosts"] + except (KeyError, TypeError) as exc: + raise SystemExit(f"{path} is not a regional-currency inventory file.") from exc + if not isinstance(hosts, dict): + raise SystemExit(f"The regional_currency hosts in {path} are not a mapping.") + return hosts + + +def discover_aliases(directory: Path) -> list[str]: + aliases = { + path.stem for path in directory.glob("*.yml") if path.name != "hosts.yml" + } + legacy_file = directory / "hosts.yml" + if legacy_file.exists(): + aliases.update(inventory_hosts(load_yaml(legacy_file), legacy_file)) + return sorted(aliases) + + +def load_target(directory: Path, alias: str) -> tuple[dict, bool]: + target_file = directory / f"{alias}.yml" + if target_file.exists(): + hosts = inventory_hosts(load_yaml(target_file), target_file) + if set(hosts) != {alias} or not isinstance(hosts[alias], dict): + raise SystemExit( + f"{target_file} must contain exactly the inventory host '{alias}'." + ) + return dict(hosts[alias]), False + + legacy_file = directory / "hosts.yml" + if legacy_file.exists(): + hosts = inventory_hosts(load_yaml(legacy_file), legacy_file) + if alias in hosts: + if not isinstance(hosts[alias], dict): + raise SystemExit(f"The inventory entry for {alias} is not a mapping.") + values = dict(hosts[alias]) + values.update(load_yaml(directory / "host_vars" / f"{alias}.yml")) + return values, True + return {}, False + + +def write_target(directory: Path, alias: str, values: dict) -> Path: + directory.mkdir(parents=True, exist_ok=True) + target_file = directory / f"{alias}.yml" + inventory = { + "all": { + "children": { + "regional_currency": { + "hosts": {alias: values}, + } + } + } + } + atomic_write( + target_file, + "---\n" + yaml.safe_dump(inventory, sort_keys=False), + ) + return target_file + + +def remove_legacy_target(directory: Path, alias: str) -> None: + legacy_file = directory / "hosts.yml" + inventory = load_yaml(legacy_file) + hosts = inventory_hosts(inventory, legacy_file) + hosts.pop(alias) + empty_generated_inventory = { + "all": {"children": {"regional_currency": {"hosts": {}}}} + } + if inventory == empty_generated_inventory: + legacy_file.unlink() + else: + atomic_write( + legacy_file, + "---\n" + yaml.safe_dump(inventory, sort_keys=False), + ) + legacy_variables = directory / "host_vars" / f"{alias}.yml" + if legacy_variables.exists(): + legacy_variables.unlink() + + +def collect_configuration( + directory: Path, +) -> tuple[str, dict, dict, bool, bool, bool]: + aliases = discover_aliases(directory) + if aliases: + print(f"Existing inventory hosts: {', '.join(aliases)}") + alias_default = aliases[0] if len(aliases) == 1 else "regional-currency" + alias = ask_matching( + "Inventory host name to add or update", HOST_PATTERN, alias_default + ) + updating = alias in aliases + if updating: + print( + f"Updating existing host '{alias}'. Press enter to keep each current value." + ) + + existing, legacy = load_target(directory, alias) + connection_keys = {"ansible_host", "ansible_user", "ansible_port", "ansible_become"} + existing_connection = { + key: value for key, value in existing.items() if key in connection_keys + } + variables = { + key: value for key, value in existing.items() if key not in connection_keys + } + + connection = { + **existing_connection, + "ansible_host": ask( + "SSH host name or address", + string_value(existing_connection.get("ansible_host")), + ), + "ansible_user": ask( + "SSH user", + string_value(existing_connection.get("ansible_user"), getpass.getuser()), + ), + "ansible_port": ask_int( + "SSH port", int(existing_connection.get("ansible_port", 22)) + ), + # The role also becomes dedicated service users when SSH connects as root. + "ansible_become": True, + } + + currency = ask_matching( + "Regional currency code", + CURRENCY_PATTERN, + string_value(variables.get("regional_currency_currency"), "NETZBON"), + normalize=str.upper, + ) + testing = ask_yes_no( + "Configure a testing deployment", + bool_value(variables.get("regional_currency_testing_deployment"), False), + ) + variables.update( + { + "regional_currency_currency": currency, + "regional_currency_domain": ask( + "Base domain (bank.*, exchange.* and backend.* must resolve)", + string_value(variables.get("regional_currency_domain")), + ), + "regional_currency_bank_name": ask( + "Human-readable bank name", + string_value( + variables.get("regional_currency_bank_name"), + "Taler Test Bank" if testing else "Taler Bank", + ), + ), + "regional_currency_testing_deployment": testing, + "regional_currency_bank_port": ask_int( + "Internal LibEuFin bank port", + int(variables.get("regional_currency_bank_port", 8080)), + ), + } + ) + + existing_password = string_value( + variables.get("regional_currency_bank_admin_password") + ) + if existing_password: + bank_password = getpass.getpass( + "Bank administrator password (leave empty to keep current): " + ).strip() + password_generated = False + if bank_password: + variables["regional_currency_bank_admin_password"] = bank_password + else: + bank_password = getpass.getpass( + "Bank administrator password (leave empty to generate one): " + ).strip() + password_generated = not bank_password + variables["regional_currency_bank_admin_password"] = ( + bank_password or secrets.token_urlsafe(24) + ) + + tls_was_enabled = variables.get("regional_currency_enable_tls") is True + existing_tls = bool_value(variables.get("regional_currency_enable_tls"), True) + tls = ask_yes_no("Obtain TLS certificates using Let's Encrypt", existing_tls) + variables["regional_currency_enable_tls"] = tls + if tls: + variables["regional_currency_tls_email"] = ask( + "Let's Encrypt contact email", + string_value(variables.get("regional_currency_tls_email")), + ) + if not tls_was_enabled: + print("Read the Let's Encrypt subscriber agreement before continuing:") + print("https://letsencrypt.org/repository/") + if not ask_yes_no("Do you agree to the Let's Encrypt terms", False): + raise SystemExit( + "TLS setup requires agreement to the Let's Encrypt terms." + ) + + conversion = ask_yes_no( + "Configure conversion to a fiat currency", + bool_value(variables.get("regional_currency_enable_conversion"), True), + ) + variables["regional_currency_enable_conversion"] = conversion + if conversion: + variables.update( + { + "regional_currency_fiat_currency": ask_matching( + "Fiat currency code", + CURRENCY_PATTERN, + string_value( + variables.get("regional_currency_fiat_currency"), "CHF" + ), + normalize=str.upper, + ), + "regional_currency_fiat_bank_name": ask( + "Fiat bank name", + string_value(variables.get("regional_currency_fiat_bank_name")), + ), + "regional_currency_fiat_account_iban": ask_matching( + "Fiat account IBAN", + IBAN_PATTERN, + string_value(variables.get("regional_currency_fiat_account_iban")), + normalize=lambda value: ( + value.replace(" ", "").replace("-", "").upper() + ), + ), + "regional_currency_fiat_account_bic": ask_matching( + "Fiat account BIC", + BIC_PATTERN, + string_value(variables.get("regional_currency_fiat_account_bic")), + normalize=lambda value: ( + value.replace(" ", "").replace("-", "").upper() + ), + ), + "regional_currency_fiat_account_name": ask( + "Fiat account legal name", + string_value(variables.get("regional_currency_fiat_account_name")), + ), + } + ) + variables["regional_currency_enable_nexus_services"] = ask_yes_no( + "Enable Nexus services (only after EBICS enrollment is complete)", + bool_value(variables.get("regional_currency_enable_nexus_services"), False), + ) + else: + variables["regional_currency_enable_nexus_services"] = False + + telesign = ask_yes_no( + "Configure Telesign SMS authentication", + bool_value(variables.get("regional_currency_enable_telesign"), False), + ) + variables["regional_currency_enable_telesign"] = telesign + if telesign: + existing_token = string_value( + variables.get("regional_currency_telesign_auth_token") + ) + token_prompt = "Base64-encoded Telesign customer-ID/API-key token" + if existing_token: + token_prompt += " (leave empty to keep current)" + token = getpass.getpass(f"{token_prompt}: ").strip() + if token: + variables["regional_currency_telesign_auth_token"] = token + elif not existing_token: + raise SystemExit( + "A Telesign token is required when SMS authentication is enabled." + ) + + terms = ask_yes_no( + "Configure exchange terms of service", + bool_value(variables.get("regional_currency_enable_exchange_terms"), True), + ) + variables["regional_currency_enable_exchange_terms"] = terms + if terms: + variables["regional_currency_exchange_terms_file"] = ask( + "Terms file on the managed host", + string_value( + variables.get("regional_currency_exchange_terms_file"), + "/usr/share/taler-exchange/terms/exchange-tos-v0.en.rst", + ), + ) + + privacy = ask_yes_no( + "Configure an exchange privacy policy", + bool_value(variables.get("regional_currency_enable_exchange_privacy"), True), + ) + variables["regional_currency_enable_exchange_privacy"] = privacy + if privacy: + variables["regional_currency_exchange_privacy_file"] = ask( + "Privacy file on the managed host", + string_value( + variables.get("regional_currency_exchange_privacy_file"), + "/usr/share/taler-exchange/terms/exchange-pp-v0.en.rst", + ), + ) + + variables["regional_currency_apt_testing"] = ask_yes_no( + "Use the GNU Taler testing APT repository (Debian only)", + bool_value(variables.get("regional_currency_apt_testing"), False), + ) + return alias, connection, variables, password_generated, updating, legacy + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Create an operator-side Ansible inventory for regional-currency" + ) + parser.add_argument( + "directory", + nargs="?", + type=Path, + default=Path("inventory"), + help="inventory directory to use (default: inventory)", + ) + args = parser.parse_args() + directory = args.directory + alias, connection, variables, password_generated, updating, legacy = ( + collect_configuration(directory) + ) + target_file = write_target(directory, alias, {**connection, **variables}) + if legacy: + remove_legacy_target(directory, alias) + + action = "Updated" if updating else "Added" + print(f"\n{action} inventory target '{alias}' in {target_file}") + if legacy: + print("Migrated the target from the legacy shared inventory layout.") + if password_generated: + print( + "A bank administrator password was generated and stored in the target file." + ) + print("\nCheck connectivity:") + print(f" ansible -i {directory} regional_currency -m ping") + print("Deploy:") + print(f" ansible-playbook -i {directory} site.yml") + print("Optional inventory encryption:") + print(f" ansible-vault encrypt {target_file}") + + +if __name__ == "__main__": + main() diff --git a/regional-currency-ng/list-incoming.sh b/regional-currency-ng/list-incoming.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +# This file is in the public domain. + +# Script for basic diagnostics of a Taler regio deployment. +# @author Florian Dold <dold@taler.net> + +if [ "$(id -u)" -ne 0 ]; then + echo "FATAL: Please run as root." >&2 + exit 1 +fi + +exchange_db=$(taler-config -s exchangedb-postgres -o config) + +# runsql db RESNAME < query +function runsql() { + local sql + read -r -d '' sql + res=$(cd / && sudo -u postgres psql "$1" -t --csv -c "$sql") + printf -v "$2" '%s' "$res" +} + +runsql "$exchange_db" reserves_in <<EOF +select reserve_pub from exchange.reserves_in; +EOF + +mapfile -t lines <<<$reserves_in + +for line in "${lines[@]}"; do + python3 -c "import binascii; import sys; sys.stdout.buffer.write(binascii.a2b_hex(sys.argv[1][2:]))" "$line" | gnunet-base32 + echo +done diff --git a/regional-currency-ng/main.sh b/regional-currency-ng/main.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Compatibility entry point. This only creates operator-side Ansible inventory. +set -eu +exec "$(dirname "$0")/inventory-wizard.py" "$@" diff --git a/regional-currency-ng/roles/regional_currency/defaults/main.yml b/regional-currency-ng/roles/regional_currency/defaults/main.yml @@ -0,0 +1,60 @@ +--- +regional_currency_currency: NETZBON +regional_currency_domain: "" +regional_currency_bank_name: Taler Bank +regional_currency_bank_admin_password: "" +regional_currency_bank_port: 8080 +regional_currency_testing_deployment: false +regional_currency_enable_conversion: false +regional_currency_fiat_currency: CHF +regional_currency_fiat_bank_name: "" +regional_currency_fiat_account_iban: "" +regional_currency_fiat_account_bic: "" +regional_currency_fiat_account_name: "" +regional_currency_enable_tls: true +regional_currency_tls_email: "" +regional_currency_enable_telesign: false +regional_currency_telesign_auth_token: "" +regional_currency_exchange_terms_file: /usr/share/taler-exchange/terms/exchange-tos-v0.en.rst +regional_currency_exchange_privacy_file: /usr/share/taler-exchange/terms/exchange-pp-v0.en.rst +regional_currency_enable_exchange_terms: true +regional_currency_enable_exchange_privacy: true +regional_currency_coin_withdraw_duration: 1 month +regional_currency_apt_testing: false +regional_currency_enable_nexus_services: false +regional_currency_bank_token_rate: 30r/m +regional_currency_bank_token_burst: 10 +regional_currency_bank_challenge_rate: 10r/m +regional_currency_bank_challenge_burst: 5 +regional_currency_state_directory: /var/lib/taler-regional-currency +regional_currency_protocol: "{{ 'https' if regional_currency_enable_tls else 'http' }}" +regional_currency_exchange_base_url: "{{ regional_currency_protocol }}://exchange.{{ regional_currency_domain }}/" +regional_currency_managed_services: + - libeufin-bank.service + - libeufin-nexus.target + - taler-exchange.target + - taler-merchant.target +regional_currency_managed_packages: + - certbot + - curl + - dbconfig-pgsql + - gettext-base + - jq + - libeufin-bank + - libeufin-nexus + - make + - nginx + - postgresql + - postgresql-client + - python3-certbot-nginx + - python3-pip + - python3-sphinx + - sudo + - taler-exchange + - taler-exchange-offline + - taler-harness + - taler-merchant + - taler-terms-generator + - taler-wallet-cli + - uuid-runtime + - wget diff --git a/regional-currency-ng/roles/regional_currency/handlers/main.yml b/regional-currency-ng/roles/regional_currency/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: Reload nginx + ansible.builtin.service: + name: nginx + state: reloaded + +- name: Reload systemd + ansible.builtin.systemd_service: + daemon_reload: true diff --git a/regional-currency-ng/roles/regional_currency/tasks/exchange.yml b/regional-currency-ng/roles/regional_currency/tasks/exchange.yml @@ -0,0 +1,322 @@ +--- +- name: Install offline exchange key configuration + ansible.builtin.template: + src: exchange-offline.conf.j2 + dest: /etc/taler-exchange/conf.d/offline-setup.conf + owner: root + group: root + mode: "0644" + register: regional_currency_exchange_offline_config + +- name: Install regional-currency exchange maintenance service + ansible.builtin.template: + src: taler-exchange-offline.service.j2 + dest: /etc/systemd/system/taler-exchange-offline.service + owner: root + group: root + mode: "0644" + register: regional_currency_exchange_maintenance_service + notify: Reload systemd + +- name: Install regional-currency exchange maintenance timer + ansible.builtin.template: + src: taler-exchange-offline.timer.j2 + dest: /etc/systemd/system/taler-exchange-offline.timer + owner: root + group: root + mode: "0644" + notify: Reload systemd + +- name: Create or read the exchange offline master key + become: true + become_user: taler-exchange-offline + ansible.builtin.command: + argv: + - taler-exchange-offline + - -LDEBUG + - setup + args: + chdir: / + register: regional_currency_exchange_master_key_command + changed_when: false + +- name: Record the exchange master public key for this play + ansible.builtin.set_fact: + regional_currency_exchange_master_public_key: "{{ regional_currency_exchange_master_key_command.stdout | trim }}" + +- name: Find stale optimized exchange SPA files + ansible.builtin.find: + paths: + - /usr/share/taler-exchange/aml-spa + - /usr/share/taler-exchange/kyc-spa + patterns: + - "*.gz" + - "*.zstd" + file_type: file + register: regional_currency_exchange_optimized_files + +- name: Remove stale optimized exchange SPA files + ansible.builtin.file: + path: "{{ item.path }}" + state: absent + loop: "{{ regional_currency_exchange_optimized_files.files }}" + loop_control: + label: "{{ item.path }}" + +- name: Generate exchange terms of service + ansible.builtin.command: + argv: + - taler-terms-generator + - -i + - "{{ regional_currency_exchange_terms_file }}" + when: regional_currency_enable_exchange_terms + changed_when: false + +- name: Generate exchange privacy policy + ansible.builtin.command: + argv: + - taler-terms-generator + - -i + - "{{ regional_currency_exchange_privacy_file }}" + when: regional_currency_enable_exchange_privacy + changed_when: false + +- name: Install exchange configuration + ansible.builtin.template: + src: exchange.conf.j2 + dest: /etc/taler-exchange/conf.d/setup.conf + owner: root + group: root + mode: "0644" + register: regional_currency_exchange_config + +- name: Install exchange database credentials + ansible.builtin.template: + src: exchange-db.secret.conf.j2 + dest: /etc/taler-exchange/secrets/exchange-db.secret.conf + owner: root + group: taler-exchange-db + mode: "0440" + register: regional_currency_exchange_db_credentials + +- name: Install exchange bank-account credentials + ansible.builtin.template: + src: exchange-account.secret.conf.j2 + dest: /etc/taler-exchange/secrets/exchange-accountcredentials-default.secret.conf + owner: taler-exchange-wire + group: taler-exchange-db + mode: "0400" + register: regional_currency_exchange_account_credentials + no_log: true + +- name: Check for an existing exchange coin configuration + ansible.builtin.stat: + path: "/etc/taler-exchange/conf.d/{{ regional_currency_currency }}-coins.conf" + register: regional_currency_exchange_coin_config_file + +- name: Generate exchange coin configuration + ansible.builtin.command: + argv: + - taler-harness + - deployment + - gen-coin-config + - --min-amount + - "{{ regional_currency_currency }}:0.01" + - --max-amount + - "{{ regional_currency_currency }}:100" + register: regional_currency_generated_coin_config + args: + creates: "/etc/taler-exchange/conf.d/{{ regional_currency_currency }}-coins.conf" + when: not regional_currency_exchange_coin_config_file.stat.exists + +- name: Install exchange coin configuration + ansible.builtin.copy: + content: >- + {{ regional_currency_generated_coin_config.stdout + | replace('FEE_DEPOSIT = ' ~ regional_currency_currency ~ ':0.01', 'FEE_DEPOSIT = ' ~ regional_currency_currency ~ ':0') + | regex_replace('(?m)^DURATION_WITHDRAW\\s*=.*$', 'DURATION_WITHDRAW = ' ~ regional_currency_coin_withdraw_duration) + | regex_replace('(?i)coin-', 'coin_') }} + dest: "/etc/taler-exchange/conf.d/{{ regional_currency_currency }}-coins.conf" + owner: root + group: root + mode: "0644" + register: regional_currency_exchange_coin_config + when: not regional_currency_exchange_coin_config_file.stat.exists + +- name: Read the installed exchange coin configuration checksum + ansible.builtin.stat: + path: "/etc/taler-exchange/conf.d/{{ regional_currency_currency }}-coins.conf" + checksum_algorithm: sha256 + register: regional_currency_exchange_coin_config_current + +- name: Initialize or upgrade the exchange database + ansible.builtin.command: taler-exchange-dbconfig + args: + chdir: / + changed_when: false + +- name: Enable and start the exchange + ansible.builtin.service: + name: taler-exchange.target + enabled: true + state: >- + {{ 'restarted' + if regional_currency_exchange_offline_config.changed + or regional_currency_exchange_config.changed + or regional_currency_exchange_db_credentials.changed + or regional_currency_exchange_account_credentials.changed + or (regional_currency_exchange_coin_config.changed | default(false)) + else 'started' }} + +- name: Wait for the exchange configuration endpoint + ansible.builtin.uri: + url: "{{ regional_currency_exchange_base_url }}config" + status_code: 200 + timeout: 5 + register: regional_currency_exchange_config_ready + until: regional_currency_exchange_config_ready.status == 200 + retries: 60 + delay: 2 + +- name: Wait for exchange management keys + ansible.builtin.uri: + url: "{{ regional_currency_exchange_base_url }}management/keys" + status_code: 200 + timeout: 5 + register: regional_currency_exchange_management_ready + until: regional_currency_exchange_management_ready.status == 200 + retries: 60 + delay: 2 + +- name: Check whether initial offline exchange setup is complete + ansible.builtin.stat: + path: "{{ regional_currency_state_directory }}/exchange-account-setup" + register: regional_currency_exchange_account_setup + +- name: Enable the exchange bank account + become: true + become_user: taler-exchange-offline + ansible.builtin.command: + argv: + - taler-exchange-offline + - enable-account + - "{{ regional_currency_exchange_payto }}" + - display-hint + - "0" + - "{{ regional_currency_currency }} Exchange" + - upload + args: + chdir: / + when: not regional_currency_exchange_account_setup.stat.exists + changed_when: true + +- name: Mark initial exchange account setup complete + ansible.builtin.file: + path: "{{ regional_currency_state_directory }}/exchange-account-setup" + state: touch + owner: root + group: root + mode: "0600" + modification_time: preserve + access_time: preserve + when: not regional_currency_exchange_account_setup.stat.exists + +- name: Check whether the fiat conversion account is configured + ansible.builtin.stat: + path: "{{ regional_currency_state_directory }}/conversion-account-setup" + register: regional_currency_conversion_account_setup + when: regional_currency_enable_conversion + +- name: Enable the fiat conversion account + become: true + become_user: taler-exchange-offline + ansible.builtin.command: + argv: + - taler-exchange-offline + - enable-account + - >- + payto://iban/{{ regional_currency_fiat_account_bic }}/{{ regional_currency_fiat_account_iban + }}?receiver-name={{ regional_currency_fiat_account_name | urlencode }} + - display-hint + - "10" + - "{{ regional_currency_fiat_bank_name }}" + - conversion-url + - "{{ regional_currency_protocol }}://bank.{{ regional_currency_domain }}/conversion-info/" + - debit-restriction + - deny + - upload + args: + chdir: / + when: + - regional_currency_enable_conversion + - not regional_currency_conversion_account_setup.stat.exists + changed_when: true + +- name: Mark fiat conversion account setup complete + ansible.builtin.file: + path: "{{ regional_currency_state_directory }}/conversion-account-setup" + state: touch + owner: root + group: root + mode: "0600" + modification_time: preserve + access_time: preserve + when: + - regional_currency_enable_conversion + - not regional_currency_conversion_account_setup.stat.exists + +- name: Check whether this coin configuration has been signed + ansible.builtin.stat: + path: "{{ regional_currency_state_directory }}/exchange-signed-coin-config" + register: regional_currency_exchange_signed_coin_config_file + +- name: Read the signed coin configuration checksum + ansible.builtin.slurp: + src: "{{ regional_currency_state_directory }}/exchange-signed-coin-config" + register: regional_currency_exchange_signed_coin_config_data + when: regional_currency_exchange_signed_coin_config_file.stat.exists + +- name: Decide whether exchange maintenance must run now + ansible.builtin.set_fact: + regional_currency_exchange_maintenance_required: >- + {{ regional_currency_exchange_maintenance_service.changed + or not regional_currency_exchange_signed_coin_config_file.stat.exists + or ( + regional_currency_exchange_signed_coin_config_file.stat.exists + and (regional_currency_exchange_signed_coin_config_data.content | b64decode | trim) + != regional_currency_exchange_coin_config_current.stat.checksum + ) }} + +- name: Activate the regional-currency systemd units + ansible.builtin.meta: flush_handlers + +- name: Sign exchange keys and provision current and future fees + ansible.builtin.systemd_service: + name: taler-exchange-offline.service + state: started + when: regional_currency_exchange_maintenance_required + +- name: Record the signed coin configuration checksum + ansible.builtin.copy: + content: "{{ regional_currency_exchange_coin_config_current.stat.checksum }}\n" + dest: "{{ regional_currency_state_directory }}/exchange-signed-coin-config" + owner: root + group: root + mode: "0600" + when: regional_currency_exchange_maintenance_required + +- name: Enable the online signing timer + ansible.builtin.systemd_service: + name: taler-exchange-offline.timer + enabled: true + state: started + +- name: Wait for public exchange keys + ansible.builtin.uri: + url: "{{ regional_currency_exchange_base_url }}keys" + status_code: 200 + timeout: 5 + register: regional_currency_exchange_keys_ready + until: regional_currency_exchange_keys_ready.status == 200 + retries: 10 + delay: 2 diff --git a/regional-currency-ng/roles/regional_currency/tasks/identity.yml b/regional-currency-ng/roles/regional_currency/tasks/identity.yml @@ -0,0 +1,103 @@ +--- +- name: Check for a recorded deployment identity + ansible.builtin.stat: + path: "{{ regional_currency_state_directory }}/deployment-identity.json" + register: regional_currency_identity_file + +- name: Read the recorded deployment identity + ansible.builtin.slurp: + src: "{{ regional_currency_state_directory }}/deployment-identity.json" + register: regional_currency_identity_data + when: regional_currency_identity_file.stat.exists + +- name: Decode the recorded deployment identity + ansible.builtin.set_fact: + regional_currency_recorded_identity: "{{ regional_currency_identity_data.content | b64decode | from_json }}" + when: regional_currency_identity_file.stat.exists + +- name: Reject incompatible deployment identity changes + ansible.builtin.assert: + that: + - regional_currency_recorded_identity.currency == regional_currency_currency + - regional_currency_recorded_identity.domain == regional_currency_domain + - not regional_currency_recorded_identity.conversion_enabled or regional_currency_enable_conversion + - >- + not regional_currency_recorded_identity.conversion_enabled + or regional_currency_recorded_identity.fiat_currency == regional_currency_fiat_currency + - >- + not regional_currency_recorded_identity.conversion_enabled + or regional_currency_recorded_identity.fiat_account_iban == regional_currency_fiat_account_iban + - >- + not regional_currency_recorded_identity.conversion_enabled + or regional_currency_recorded_identity.fiat_account_bic == regional_currency_fiat_account_bic + - >- + not regional_currency_recorded_identity.conversion_enabled + or regional_currency_recorded_identity.fiat_account_name == regional_currency_fiat_account_name + fail_msg: >- + The inventory changes the deployed currency, domain, or established fiat-conversion identity. + Those values require a separately planned migration and cannot be reconciled in place. + when: regional_currency_identity_file.stat.exists + +- name: Check for an exchange configuration predating identity tracking + ansible.builtin.stat: + path: /etc/taler-exchange/conf.d/setup.conf + register: regional_currency_legacy_exchange_config + when: not regional_currency_identity_file.stat.exists + +- name: Read the legacy exchange currency + ansible.builtin.command: + argv: + - taler-exchange-config + - -s + - exchange + - -o + - CURRENCY + register: regional_currency_legacy_exchange_currency + changed_when: false + when: + - not regional_currency_identity_file.stat.exists + - regional_currency_legacy_exchange_config.stat.exists + +- name: Read the legacy exchange base URL + ansible.builtin.command: + argv: + - taler-exchange-config + - -s + - exchange + - -o + - BASE_URL + register: regional_currency_legacy_exchange_base_url + changed_when: false + when: + - not regional_currency_identity_file.stat.exists + - regional_currency_legacy_exchange_config.stat.exists + +- name: Check for a persisted legacy exchange payto URI + ansible.builtin.stat: + path: "{{ regional_currency_state_directory }}/exchange-payto" + register: regional_currency_legacy_exchange_payto_file + when: not regional_currency_identity_file.stat.exists + +- name: Read the persisted legacy exchange payto URI + ansible.builtin.slurp: + src: "{{ regional_currency_state_directory }}/exchange-payto" + register: regional_currency_legacy_exchange_payto_data + when: + - not regional_currency_identity_file.stat.exists + - regional_currency_legacy_exchange_payto_file.stat.exists + +- name: Verify the legacy deployment identity before adoption + ansible.builtin.assert: + that: + - (regional_currency_legacy_exchange_currency.stdout | trim) == regional_currency_currency + - (regional_currency_legacy_exchange_base_url.stdout | trim) == regional_currency_exchange_base_url + - >- + not regional_currency_legacy_exchange_payto_file.stat.exists + or ('bank.' ~ regional_currency_domain) in + (regional_currency_legacy_exchange_payto_data.content | b64decode | trim) + fail_msg: >- + Existing regional-currency state does not match this inventory. Restore the original identity + values or perform a separately planned migration before running this role. + when: + - not regional_currency_identity_file.stat.exists + - regional_currency_legacy_exchange_config.stat.exists diff --git a/regional-currency-ng/roles/regional_currency/tasks/libeufin.yml b/regional-currency-ng/roles/regional_currency/tasks/libeufin.yml @@ -0,0 +1,507 @@ +--- +- name: Configure LibEuFin Nexus for fiat conversion + ansible.builtin.command: + argv: + - taler-harness + - config + - -c + - /etc/libeufin/libeufin-nexus.conf + - set + - nexus-ebics + - "{{ item.key }}" + - "{{ item.value }}" + loop: + - { key: CURRENCY, value: "{{ regional_currency_fiat_currency }}" } + - { key: IBAN, value: "{{ regional_currency_fiat_account_iban }}" } + - { key: BIC, value: "{{ regional_currency_fiat_account_bic }}" } + - { key: NAME, value: "{{ regional_currency_fiat_account_name }}" } + when: regional_currency_enable_conversion + changed_when: false + +- name: Install LibEuFin bank configuration + ansible.builtin.template: + src: libeufin-bank.conf.j2 + dest: /etc/libeufin/libeufin-bank.conf + owner: root + group: libeufin-bank + mode: "0640" + register: regional_currency_libeufin_bank_config + no_log: "{{ regional_currency_enable_telesign }}" + +- name: Set LibEuFin SPA branding + ansible.builtin.template: + src: libeufin-settings.js.j2 + dest: /etc/libeufin/settings.js + owner: root + group: root + mode: "0644" + +- name: Install LibEuFin SPA settings + ansible.builtin.template: + src: libeufin-settings.json.j2 + dest: /usr/share/libeufin-bank-webui/settings.json + owner: root + group: root + mode: "0644" + validate: python3 -m json.tool %s + +- name: Initialize or upgrade the LibEuFin database + ansible.builtin.command: libeufin-dbconfig + args: + chdir: / + changed_when: false + +- name: Enable and start LibEuFin bank + ansible.builtin.service: + name: libeufin-bank + enabled: true + state: "{{ 'restarted' if regional_currency_libeufin_bank_config.changed else 'started' }}" + +- name: Configure the LibEuFin bank administrator password + become: true + become_user: libeufin-bank + ansible.builtin.command: + argv: + - libeufin-bank + - passwd + - admin + - "{{ regional_currency_bank_admin_password }}" + args: + chdir: / + register: regional_currency_bank_admin_password_result + changed_when: false + failed_when: false + no_log: true + +- name: Report LibEuFin bank administrator password failure + ansible.builtin.fail: + msg: >- + Unable to configure the LibEuFin bank administrator password: + {{ regional_currency_bank_admin_password_result.stderr + | default(regional_currency_bank_admin_password_result.stdout, true) + | default('no error details returned', true) + | replace(regional_currency_bank_admin_password, '<redacted>') }} + when: regional_currency_bank_admin_password_result.rc != 0 + +- name: Configure the administrator debt limit + become: true + become_user: libeufin-bank + ansible.builtin.command: + argv: + - libeufin-bank + - edit-account + - admin + - "--debit_threshold={{ regional_currency_currency }}:200000000" + args: + chdir: / + changed_when: false + +- name: Check for the persisted exchange account + ansible.builtin.stat: + path: "{{ regional_currency_state_directory }}/exchange-payto" + register: regional_currency_exchange_payto_file + +- name: Set the expected exchange account payto URI + ansible.builtin.set_fact: + regional_currency_exchange_payto_candidate: >- + payto://x-taler-bank/bank.{{ regional_currency_domain }}/exchange?receiver-name=Exchange + when: not regional_currency_exchange_payto_file.stat.exists + +- name: Check whether the exchange bank account already exists + become: true + become_user: libeufin-bank + ansible.builtin.command: + argv: + - libeufin-bank + - edit-account + - exchange + - --name + - Exchange + - --exchange + - "true" + args: + chdir: / + register: regional_currency_exchange_account_probe + when: not regional_currency_exchange_payto_file.stat.exists + changed_when: false + failed_when: false + +- name: Generate a password for the exchange bank account + ansible.builtin.command: uuidgen + register: regional_currency_exchange_bank_password + when: + - not regional_currency_exchange_payto_file.stat.exists + - regional_currency_exchange_account_probe.rc != 0 + changed_when: false + no_log: true + +- name: Create the exchange bank account + become: true + become_user: libeufin-bank + ansible.builtin.command: + argv: + - libeufin-bank + - create-account + - --username + - exchange + - --password + - "{{ regional_currency_exchange_bank_password.stdout }}" + - --name + - Exchange + - --exchange + - --payto_uri + - "{{ regional_currency_exchange_payto_candidate }}" + args: + chdir: / + register: regional_currency_created_exchange_payto + when: + - not regional_currency_exchange_payto_file.stat.exists + - regional_currency_exchange_account_probe.rc != 0 + changed_when: regional_currency_created_exchange_payto.rc == 0 + failed_when: false + no_log: true + +- name: Report exchange bank account creation failure + ansible.builtin.fail: + msg: >- + Unable to create the LibEuFin exchange account: + {{ regional_currency_created_exchange_payto.stderr + | default('no error details returned') + | replace(regional_currency_exchange_bank_password.stdout, '<redacted>') }} + when: + - not regional_currency_exchange_payto_file.stat.exists + - regional_currency_exchange_account_probe.rc != 0 + - regional_currency_created_exchange_payto.rc != 0 + +- name: Use the newly created exchange account payto URI + ansible.builtin.set_fact: + regional_currency_exchange_payto_candidate: >- + {{ regional_currency_created_exchange_payto.stdout | trim }} + when: + - not regional_currency_exchange_payto_file.stat.exists + - regional_currency_exchange_account_probe.rc != 0 + +- name: Persist the exchange account payto URI + ansible.builtin.copy: + content: "{{ regional_currency_exchange_payto_candidate }}\n" + dest: "{{ regional_currency_state_directory }}/exchange-payto" + owner: root + group: root + mode: "0600" + when: not regional_currency_exchange_payto_file.stat.exists + no_log: true + +- name: Read the exchange account payto URI + ansible.builtin.slurp: + src: "{{ regional_currency_state_directory }}/exchange-payto" + register: regional_currency_exchange_payto_data + no_log: true + +- name: Set the exchange account payto URI + ansible.builtin.set_fact: + regional_currency_exchange_payto_stored: "{{ regional_currency_exchange_payto_data.content | b64decode | trim }}" + no_log: true + +- name: Add the receiver name to legacy exchange account payto URIs + ansible.builtin.set_fact: + regional_currency_exchange_payto: >- + {{ regional_currency_exchange_payto_stored + if 'receiver-name=' in regional_currency_exchange_payto_stored + else regional_currency_exchange_payto_stored + ~ ('&' if '?' in regional_currency_exchange_payto_stored else '?') + ~ 'receiver-name=Exchange' }} + no_log: true + +- name: Persist the normalized exchange account payto URI + ansible.builtin.copy: + content: "{{ regional_currency_exchange_payto }}\n" + dest: "{{ regional_currency_state_directory }}/exchange-payto" + owner: root + group: root + mode: "0600" + no_log: true + +- name: Check for the persisted exchange wire gateway token + ansible.builtin.stat: + path: "{{ regional_currency_state_directory }}/exchange-bank-token" + register: regional_currency_exchange_bank_token_file + +- name: Create the exchange wire gateway token + become: true + become_user: libeufin-bank + ansible.builtin.command: + argv: + - libeufin-bank + - create-token + - -u + - exchange + - -s + - wiregateway + - -d + - forever + args: + chdir: / + register: regional_currency_created_exchange_bank_token + when: not regional_currency_exchange_bank_token_file.stat.exists + changed_when: true + no_log: true + +- name: Persist the exchange wire gateway token + ansible.builtin.copy: + content: "{{ regional_currency_created_exchange_bank_token.stdout | trim }}\n" + dest: "{{ regional_currency_state_directory }}/exchange-bank-token" + owner: root + group: root + mode: "0600" + when: not regional_currency_exchange_bank_token_file.stat.exists + no_log: true + +- name: Read the exchange wire gateway token + ansible.builtin.slurp: + src: "{{ regional_currency_state_directory }}/exchange-bank-token" + register: regional_currency_exchange_bank_token_data + no_log: true + +- name: Set the exchange wire gateway token + ansible.builtin.set_fact: + regional_currency_exchange_bank_token: "{{ regional_currency_exchange_bank_token_data.content | b64decode | trim }}" + no_log: true + +- name: Wait for the LibEuFin bank API + ansible.builtin.uri: + url: "{{ regional_currency_protocol }}://bank.{{ regional_currency_domain }}/config" + status_code: 200 + register: regional_currency_bank_ready + until: regional_currency_bank_ready.status == 200 + retries: 10 + delay: 2 + +- name: Probe the LibEuFin conversion configuration + ansible.builtin.uri: + url: "{{ regional_currency_protocol }}://bank.{{ regional_currency_domain }}/conversion-info/config" + register: regional_currency_conversion_config_probe + when: regional_currency_enable_conversion + changed_when: false + failed_when: false + +- name: Restart LibEuFin bank to apply its current configuration + ansible.builtin.service: + name: libeufin-bank + state: restarted + when: >- + (regional_currency_bank_ready.json.currency | default('')) != regional_currency_currency + or + (regional_currency_bank_ready.json.allow_conversion | default(false) | bool) + != regional_currency_enable_conversion + or + (regional_currency_enable_conversion and ( + (regional_currency_conversion_config_probe.status | default(-1) | int) != 200 + or + (regional_currency_conversion_config_probe.json.regional_currency | default('')) + != regional_currency_currency + or + (regional_currency_conversion_config_probe.json.fiat_currency | default('')) + != regional_currency_fiat_currency + )) + +- name: Read the effective LibEuFin bank configuration + ansible.builtin.uri: + url: "{{ regional_currency_protocol }}://bank.{{ regional_currency_domain }}/config" + status_code: 200 + register: regional_currency_effective_bank_config + until: regional_currency_effective_bank_config.status == 200 + retries: 10 + delay: 2 + +- name: Verify the effective LibEuFin bank configuration + ansible.builtin.assert: + that: + - regional_currency_effective_bank_config.json.currency == regional_currency_currency + - >- + (regional_currency_effective_bank_config.json.allow_conversion | bool) + == regional_currency_enable_conversion + fail_msg: >- + The running LibEuFin bank reports currency + {{ regional_currency_effective_bank_config.json.currency | default('unknown') }} and + conversion={{ regional_currency_effective_bank_config.json.allow_conversion | default('unknown') }}, + but the inventory configures currency {{ regional_currency_currency }} and + conversion={{ regional_currency_enable_conversion }}. + quiet: true + +- name: Read the effective LibEuFin conversion configuration + ansible.builtin.uri: + url: "{{ regional_currency_protocol }}://bank.{{ regional_currency_domain }}/conversion-info/config" + status_code: 200 + register: regional_currency_conversion_config + when: regional_currency_enable_conversion + +- name: Verify the LibEuFin conversion currencies + ansible.builtin.assert: + that: + - regional_currency_conversion_config.json.regional_currency == regional_currency_currency + - regional_currency_conversion_config.json.fiat_currency == regional_currency_fiat_currency + fail_msg: >- + The running LibEuFin bank reports conversion from + {{ regional_currency_conversion_config.json.fiat_currency | default('unknown') }} to + {{ regional_currency_conversion_config.json.regional_currency | default('unknown') }}, + but the inventory configures conversion from {{ regional_currency_fiat_currency }} to + {{ regional_currency_currency }}. + quiet: true + when: regional_currency_enable_conversion + +- name: Check whether the initial conversion rate has been configured + ansible.builtin.stat: + path: "{{ regional_currency_state_directory }}/conversion-rate-setup" + register: regional_currency_conversion_rate_setup + when: regional_currency_enable_conversion + +- name: Create a temporary administrator token for conversion setup + become: true + become_user: libeufin-bank + ansible.builtin.command: + argv: + - libeufin-bank + - create-token + - -u + - admin + - -s + - readwrite + args: + chdir: / + register: regional_currency_bank_admin_token + when: + - regional_currency_enable_conversion + - not regional_currency_conversion_rate_setup.stat.exists + changed_when: false + no_log: true + +- name: Configure the initial one-to-one conversion rate + ansible.builtin.uri: + url: "{{ regional_currency_protocol }}://bank.{{ regional_currency_domain }}/conversion-info/conversion-rate" + method: POST + headers: + Authorization: "Bearer {{ regional_currency_bank_admin_token.stdout | trim }}" + body_format: json + body: + cashin_ratio: "1" + cashin_fee: "{{ regional_currency_currency }}:0" + cashin_tiny_amount: "{{ regional_currency_currency }}:0.01" + cashin_rounding_mode: nearest + cashin_min_amount: "{{ regional_currency_fiat_currency }}:0" + cashout_ratio: "1" + cashout_fee: "{{ regional_currency_fiat_currency }}:0" + cashout_tiny_amount: "{{ regional_currency_fiat_currency }}:0.01" + cashout_rounding_mode: nearest + cashout_min_amount: "{{ regional_currency_currency }}:0" + return_content: true + status_code: [200, 204] + register: regional_currency_conversion_rate_response + when: + - regional_currency_enable_conversion + - not regional_currency_conversion_rate_setup.stat.exists + failed_when: false + no_log: true + +- name: Report initial conversion rate configuration failure + ansible.builtin.fail: + msg: >- + LibEuFin rejected the initial conversion rate request + (HTTP {{ regional_currency_conversion_rate_response.status | default('unknown') }}): + {{ regional_currency_conversion_rate_response.json.hint + | default(regional_currency_conversion_rate_response.json.detail, true) + | default(regional_currency_conversion_rate_response.content, true) + | default(regional_currency_conversion_rate_response.msg, true) + | default('no response details', true) }} + when: + - regional_currency_enable_conversion + - not regional_currency_conversion_rate_setup.stat.exists + - (regional_currency_conversion_rate_response.status | default(-1) | int) not in [200, 204] + +- name: Mark the initial conversion rate configured + ansible.builtin.file: + path: "{{ regional_currency_state_directory }}/conversion-rate-setup" + state: touch + owner: root + group: root + mode: "0600" + modification_time: preserve + access_time: preserve + when: + - regional_currency_enable_conversion + - not regional_currency_conversion_rate_setup.stat.exists + +- name: Validate EBICS transaction fetching before enabling Nexus + become: true + become_user: libeufin-nexus + ansible.builtin.command: + argv: + - libeufin-nexus + - ebics-fetch + - -c + - /etc/libeufin/libeufin-nexus.conf + - --transient + args: + chdir: / + changed_when: false + when: + - regional_currency_enable_nexus_services + - not regional_currency_nexus_was_active + +- name: Validate EBICS payment submission before enabling Nexus + become: true + become_user: libeufin-nexus + ansible.builtin.command: + argv: + - libeufin-nexus + - ebics-submit + - -c + - /etc/libeufin/libeufin-nexus.conf + - --transient + args: + chdir: / + changed_when: false + when: + - regional_currency_enable_nexus_services + - not regional_currency_nexus_was_active + +- name: Enable and start LibEuFin Nexus + ansible.builtin.service: + name: libeufin-nexus.target + enabled: true + state: started + when: regional_currency_enable_nexus_services + +- name: Keep LibEuFin Nexus disabled until EBICS enrollment is complete + ansible.builtin.service: + name: libeufin-nexus.target + enabled: false + state: stopped + when: not regional_currency_enable_nexus_services + +- name: Stop LibEuFin Nexus workers when Nexus is disabled + ansible.builtin.service: + name: "{{ item }}" + state: stopped + loop: + - libeufin-nexus-ebics-fetch.service + - libeufin-nexus-ebics-submit.service + when: not regional_currency_enable_nexus_services + +- name: Refresh service state after stopping LibEuFin Nexus workers + ansible.builtin.service_facts: + when: not regional_currency_enable_nexus_services + +- name: Clear LibEuFin Nexus worker failure states when Nexus is disabled + ansible.builtin.command: + argv: + - systemctl + - reset-failed + - "{{ item }}" + loop: + - libeufin-nexus-ebics-fetch.service + - libeufin-nexus-ebics-submit.service + when: + - not regional_currency_enable_nexus_services + - item in ansible_facts.services + - ansible_facts.services[item].state == "failed" + changed_when: false diff --git a/regional-currency-ng/roles/regional_currency/tasks/main.yml b/regional-currency-ng/roles/regional_currency/tasks/main.yml @@ -0,0 +1,56 @@ +--- +- name: Validate deployment variables + ansible.builtin.assert: + that: + - ansible_facts["distribution"] in ["Debian", "Ubuntu"] + - ansible_become | default(true) | bool + - regional_currency_domain | length > 0 + - regional_currency_currency is match('^[A-Z]{3,11}$') + - regional_currency_bank_admin_password | length > 0 + - not regional_currency_enable_tls or regional_currency_tls_email | length > 0 + - not regional_currency_enable_conversion or regional_currency_fiat_bank_name | length > 0 + - not regional_currency_enable_conversion or regional_currency_fiat_account_iban | length > 0 + - not regional_currency_enable_conversion or regional_currency_fiat_account_bic | length > 0 + - not regional_currency_enable_conversion or regional_currency_fiat_account_name | length > 0 + - not regional_currency_enable_telesign or regional_currency_telesign_auth_token | length > 0 + - not regional_currency_enable_nexus_services or regional_currency_enable_conversion + - regional_currency_bank_token_rate is match('^[1-9][0-9]*r/[smh]$') + - (regional_currency_bank_token_burst | int) > 0 + - regional_currency_bank_challenge_rate is match('^[1-9][0-9]*r/[smh]$') + - (regional_currency_bank_challenge_burst | int) > 0 + fail_msg: The inventory is incomplete or contains invalid regional-currency variables. + +- name: Validate the deployment identity + ansible.builtin.import_tasks: identity.yml + +- name: Install packages + ansible.builtin.import_tasks: packages.yml + +- name: Configure nginx + ansible.builtin.import_tasks: nginx.yml + +- name: Configure LibEuFin + ansible.builtin.import_tasks: libeufin.yml + +- name: Configure the exchange + ansible.builtin.import_tasks: exchange.yml + +- name: Configure the merchant backend + ansible.builtin.import_tasks: merchant.yml + +- name: Record the successfully deployed identity + ansible.builtin.copy: + content: >- + {{ { + 'currency': regional_currency_currency, + 'domain': regional_currency_domain, + 'conversion_enabled': regional_currency_enable_conversion, + 'fiat_currency': regional_currency_fiat_currency if regional_currency_enable_conversion else '', + 'fiat_account_iban': regional_currency_fiat_account_iban if regional_currency_enable_conversion else '', + 'fiat_account_bic': regional_currency_fiat_account_bic if regional_currency_enable_conversion else '', + 'fiat_account_name': regional_currency_fiat_account_name if regional_currency_enable_conversion else '' + } | to_nice_json }} + dest: "{{ regional_currency_state_directory }}/deployment-identity.json" + owner: root + group: root + mode: "0600" diff --git a/regional-currency-ng/roles/regional_currency/tasks/merchant.yml b/regional-currency-ng/roles/regional_currency/tasks/merchant.yml @@ -0,0 +1,47 @@ +--- +- name: Initialize or upgrade the merchant database + ansible.builtin.command: taler-merchant-dbconfig + args: + chdir: / + changed_when: false + +- name: Find stale optimized merchant SPA files + ansible.builtin.find: + paths: /usr/share/taler-merchant/spa + patterns: + - "*.gz" + - "*.zstd" + file_type: file + register: regional_currency_merchant_optimized_files + +- name: Remove stale optimized merchant SPA files + ansible.builtin.file: + path: "{{ item.path }}" + state: absent + loop: "{{ regional_currency_merchant_optimized_files.files }}" + loop_control: + label: "{{ item.path }}" + +- name: Install merchant configuration + ansible.builtin.template: + src: merchant.conf.j2 + dest: /etc/taler-merchant/conf.d/setup.conf + owner: root + group: root + mode: "0644" + register: regional_currency_merchant_config + +- name: Enable and start the merchant backend + ansible.builtin.service: + name: taler-merchant.target + enabled: true + state: "{{ 'restarted' if regional_currency_merchant_config.changed else 'started' }}" + +- name: Wait for the merchant backend + ansible.builtin.uri: + url: "{{ regional_currency_protocol }}://backend.{{ regional_currency_domain }}/config" + status_code: 200 + register: regional_currency_merchant_ready + until: regional_currency_merchant_ready.status == 200 + retries: 10 + delay: 2 diff --git a/regional-currency-ng/roles/regional_currency/tasks/nginx.yml b/regional-currency-ng/roles/regional_currency/tasks/nginx.yml @@ -0,0 +1,107 @@ +--- +- name: Check for an existing TLS certificate + ansible.builtin.stat: + path: "/etc/letsencrypt/live/backend.{{ regional_currency_domain }}/fullchain.pem" + register: regional_currency_tls_certificate + +- name: Select the initial nginx protocol + ansible.builtin.set_fact: + regional_currency_nginx_tls_active: "{{ regional_currency_enable_tls and regional_currency_tls_certificate.stat.exists }}" + +- name: Install LibEuFin bank rate-limit zones + ansible.builtin.template: + src: nginx-rate-limits.conf.j2 + dest: /etc/nginx/conf.d/regional-currency-rate-limits.conf + owner: root + group: root + mode: "0644" + notify: Reload nginx + +- name: Install the LibEuFin bank proxy settings + ansible.builtin.template: + src: nginx-bank-proxy.conf.j2 + dest: /etc/nginx/snippets/regional-currency-bank-proxy.conf + owner: root + group: root + mode: "0644" + notify: Reload nginx + +- name: Install nginx virtual hosts + ansible.builtin.template: + src: "nginx-{{ item }}.conf.j2" + dest: "/etc/nginx/sites-available/{{ item }}.{{ regional_currency_domain }}" + owner: root + group: root + mode: "0644" + loop: + - backend + - bank + - exchange + notify: Reload nginx + +- name: Enable nginx virtual hosts + ansible.builtin.file: + src: "/etc/nginx/sites-available/{{ item }}.{{ regional_currency_domain }}" + dest: "/etc/nginx/sites-enabled/{{ item }}.{{ regional_currency_domain }}" + state: link + loop: + - backend + - bank + - exchange + notify: Reload nginx + +- name: Enable and start nginx + ansible.builtin.service: + name: nginx + enabled: true + state: started + +- name: Activate the initial HTTP configuration + ansible.builtin.meta: flush_handlers + +- name: Obtain the Let's Encrypt certificate + ansible.builtin.command: + argv: + - certbot + - certonly + - --nginx + - --non-interactive + - --agree-tos + - --email + - "{{ regional_currency_tls_email }}" + - --cert-name + - "backend.{{ regional_currency_domain }}" + - --domain + - "backend.{{ regional_currency_domain }}" + - --domain + - "bank.{{ regional_currency_domain }}" + - --domain + - "exchange.{{ regional_currency_domain }}" + creates: "/etc/letsencrypt/live/backend.{{ regional_currency_domain }}/fullchain.pem" + when: regional_currency_enable_tls + +- name: Enable TLS in nginx configuration + ansible.builtin.set_fact: + regional_currency_nginx_tls_active: true + when: regional_currency_enable_tls + +- name: Install TLS-enabled nginx virtual hosts + ansible.builtin.template: + src: "nginx-{{ item }}.conf.j2" + dest: "/etc/nginx/sites-available/{{ item }}.{{ regional_currency_domain }}" + owner: root + group: root + mode: "0644" + loop: + - backend + - bank + - exchange + when: regional_currency_enable_tls + notify: Reload nginx + +- name: Validate nginx configuration + ansible.builtin.command: nginx -t + changed_when: false + +- name: Activate the final nginx configuration + ansible.builtin.meta: flush_handlers diff --git a/regional-currency-ng/roles/regional_currency/tasks/packages.yml b/regional-currency-ng/roles/regional_currency/tasks/packages.yml @@ -0,0 +1,141 @@ +--- +- name: Create the APT keyring directory + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + owner: root + group: root + mode: "0755" + +- name: Install the GNU Taler archive signing key + ansible.builtin.get_url: + url: https://taler.net/taler-systems.gpg + dest: /etc/apt/keyrings/taler-systems.gpg + owner: root + group: root + mode: "0644" + +- name: Remove the legacy one-line GNU Taler repository + ansible.builtin.file: + path: /etc/apt/sources.list.d/taler.list + state: absent + +- name: Configure the GNU Taler APT repository on Debian + ansible.builtin.deb822_repository: + name: Taler + types: + - deb + uris: + - https://deb.taler.net/apt/debian + suites: + - >- + {{ ansible_facts["distribution_release"] }}{{ '-testing' if regional_currency_apt_testing else '' }} + components: + - main + architectures: + - amd64 + signed_by: /etc/apt/keyrings/taler-systems.gpg + state: present + install_python_debian: true + when: ansible_facts["distribution"] == "Debian" + +- name: Configure the GNU Taler APT repository on Ubuntu + ansible.builtin.deb822_repository: + name: Taler + types: + - deb + uris: + - https://deb.taler.net/apt/ubuntu + suites: + - "{{ ansible_facts['distribution_release'] }}" + components: + - main + architectures: + - amd64 + signed_by: /etc/apt/keyrings/taler-systems.gpg + state: present + install_python_debian: true + when: ansible_facts["distribution"] == "Ubuntu" + +- name: Refresh the APT package index + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + +- name: Check for managed package changes + ansible.builtin.apt: + name: "{{ regional_currency_managed_packages }}" + state: latest # noqa package-latest + check_mode: true + register: regional_currency_package_plan + +- name: Read service state before package changes + ansible.builtin.service_facts: + +- name: Record whether Nexus was active before package changes + ansible.builtin.set_fact: + regional_currency_nexus_was_active: >- + {{ ansible_facts.services['libeufin-nexus.target'].state == 'active' + if 'libeufin-nexus.target' in ansible_facts.services + else false }} + +- name: Stop running application services before package changes + ansible.builtin.service: + name: "{{ item }}" + state: stopped + loop: "{{ regional_currency_managed_services }}" + when: + - regional_currency_package_plan.changed + - item in ansible_facts.services + - ansible_facts.services[item].state == "active" + +- name: Install or upgrade operating-system and GNU Taler packages + ansible.builtin.apt: + name: "{{ regional_currency_managed_packages }}" + state: latest # noqa package-latest + register: regional_currency_package_changes + +- name: Refresh service state after package changes + ansible.builtin.service_facts: + +- name: Stop services started by package installation + ansible.builtin.service: + name: "{{ item }}" + state: stopped + loop: "{{ regional_currency_managed_services }}" + when: + - regional_currency_package_changes.changed + - item in ansible_facts.services + - ansible_facts.services[item].state == "active" + +- name: Read the installed PostgreSQL client version + ansible.builtin.command: psql --version + register: regional_currency_psql_version + changed_when: false + +- name: Require PostgreSQL 15 or newer + ansible.builtin.assert: + that: + - regional_currency_psql_version.stdout | regex_search('[0-9]+') | int >= 15 + fail_msg: "PostgreSQL 15 or newer is required (found: {{ regional_currency_psql_version.stdout }})." + +- name: Install Python support used by the terms generator + ansible.builtin.pip: + name: sphinx-markdown-builder + extra_args: --break-system-packages + +- name: Create persistent deployment state directory + ansible.builtin.file: + path: "{{ regional_currency_state_directory }}" + state: directory + owner: root + group: root + mode: "0700" + +- name: Configure the regional currency for LibEuFin + ansible.builtin.template: + src: currency.conf.j2 + dest: /usr/share/libeufin/config.d/regional-currency.conf + owner: root + group: root + mode: "0644" diff --git a/regional-currency-ng/roles/regional_currency/templates/currency.conf.j2 b/regional-currency-ng/roles/regional_currency/templates/currency.conf.j2 @@ -0,0 +1,8 @@ +[CURRENCY-{{ regional_currency_currency }}] +enabled=yes +name={{ regional_currency_currency }} +code={{ regional_currency_currency }} +fractional_input_digits=2 +fractional_normal_digits=2 +fractional_trailing_zero_digits=2 +alt_unit_names={"0":"{{ regional_currency_currency }}"} diff --git a/regional-currency-ng/roles/regional_currency/templates/exchange-account.secret.conf.j2 b/regional-currency-ng/roles/regional_currency/templates/exchange-account.secret.conf.j2 @@ -0,0 +1,4 @@ +[exchange-accountcredentials-default] +WIRE_GATEWAY_URL={{ regional_currency_protocol }}://bank.{{ regional_currency_domain }}/accounts/exchange/taler-wire-gateway/ +WIRE_GATEWAY_AUTH_METHOD=bearer +TOKEN={{ regional_currency_exchange_bank_token }} diff --git a/regional-currency-ng/roles/regional_currency/templates/exchange-db.secret.conf.j2 b/regional-currency-ng/roles/regional_currency/templates/exchange-db.secret.conf.j2 @@ -0,0 +1,2 @@ +[exchangedb-postgres] +CONFIG=postgres:///exchange diff --git a/regional-currency-ng/roles/regional_currency/templates/exchange-offline.conf.j2 b/regional-currency-ng/roles/regional_currency/templates/exchange-offline.conf.j2 @@ -0,0 +1,3 @@ +[exchange-offline] +MASTER_PRIV_FILE=$HOME/.local/share/taler/exchange/offline-keys/master.priv +SECM_TOFU_FILE=$HOME/.local/share/taler/exchange/offline-keys/secm_tofus.pub diff --git a/regional-currency-ng/roles/regional_currency/templates/exchange.conf.j2 b/regional-currency-ng/roles/regional_currency/templates/exchange.conf.j2 @@ -0,0 +1,23 @@ +[exchange] +CURRENCY={{ regional_currency_currency }} +CURRENCY_ROUND_UNIT={{ regional_currency_currency }}:0.01 +TINY_AMOUNT={{ regional_currency_currency }}:0.01 +AML_THRESHOLD={{ regional_currency_currency }}:1000000 +MASTER_PUBLIC_KEY={{ regional_currency_exchange_master_public_key }} +BASE_URL={{ regional_currency_exchange_base_url }} +STEFAN_ABS={{ regional_currency_currency }}:0 +STEFAN_LOG={{ regional_currency_currency }}:0 +STEFAN_LIN=0 +TERMS_ETAG={{ (regional_currency_exchange_terms_file | basename | regex_replace('\.[a-z]{2}\.rst$', '')) if regional_currency_enable_exchange_terms else '' }} +PRIVACY_ETAG={{ (regional_currency_exchange_privacy_file | basename | regex_replace('\.[a-z]{2}\.rst$', '')) if regional_currency_enable_exchange_privacy else '' }} + +[merchant-exchange-{{ regional_currency_domain }}] +MASTER_KEY={{ regional_currency_exchange_master_public_key }} +CURRENCY={{ regional_currency_currency }} +EXCHANGE_BASE_URL={{ regional_currency_exchange_base_url }} + +[exchange-account-default] +PAYTO_URI={{ regional_currency_exchange_payto }} +ENABLE_DEBIT=YES +ENABLE_CREDIT=YES +@inline-secret@ exchange-accountcredentials-default ../secrets/exchange-accountcredentials-default.secret.conf diff --git a/regional-currency-ng/roles/regional_currency/templates/libeufin-bank.conf.j2 b/regional-currency-ng/roles/regional_currency/templates/libeufin-bank.conf.j2 @@ -0,0 +1,26 @@ +[libeufin-bank] +CURRENCY={{ regional_currency_currency }} +NAME="{{ regional_currency_bank_name }}" +BASE_URL={{ regional_currency_protocol }}://bank.{{ regional_currency_domain }}/ +WIRE_TYPE=x-taler-bank +X_TALER_BANK_PAYTO_HOSTNAME=bank.{{ regional_currency_domain }} +SUGGESTED_WITHDRAWAL_EXCHANGE={{ regional_currency_exchange_base_url }} +SERVE=tcp +BIND_TO=127.0.0.1 +PORT={{ regional_currency_bank_port }} +PWD_AUTH_COMPAT=no +{% if regional_currency_enable_conversion %} +ALLOW_CONVERSION=yes +FIAT_CURRENCY={{ regional_currency_fiat_currency }} +ALLOW_EDIT_CASHOUT_PAYTO_URI=yes +{% endif %} +{% if regional_currency_testing_deployment %} +ALLOW_REGISTRATION=yes +ALLOW_ACCOUNT_DELETION=yes +ALLOW_EDIT_NAME=yes +DEFAULT_DEBT_LIMIT={{ regional_currency_currency }}:100 +{% endif %} +{% if regional_currency_enable_telesign %} +TAN_SMS=libeufin-tan-sms.sh +TAN_SMS_ENV={{ {'TELESIGN_AUTH_TOKEN': regional_currency_telesign_auth_token} | to_json }} +{% endif %} diff --git a/regional-currency-ng/roles/regional_currency/templates/libeufin-settings.js.j2 b/regional-currency-ng/roles/regional_currency/templates/libeufin-settings.js.j2 @@ -0,0 +1 @@ +settings = { bankName: {{ regional_currency_bank_name | to_json }} }; diff --git a/regional-currency-ng/roles/regional_currency/templates/libeufin-settings.json.j2 b/regional-currency-ng/roles/regional_currency/templates/libeufin-settings.json.j2 @@ -0,0 +1,6 @@ +{ + "backendBaseURL": {{ (regional_currency_protocol ~ '://bank.' ~ regional_currency_domain ~ '/') | to_json }}, + "bankName": {{ regional_currency_bank_name | to_json }}, + "enableDemoHeader": {{ regional_currency_testing_deployment | bool | to_json }}, + "showPublicAccounts": {{ regional_currency_testing_deployment | bool | to_json }} +} diff --git a/regional-currency-ng/roles/regional_currency/templates/merchant.conf.j2 b/regional-currency-ng/roles/regional_currency/templates/merchant.conf.j2 @@ -0,0 +1,10 @@ +[merchant] +CURRENCY={{ regional_currency_currency }} +BASE_URL={{ regional_currency_protocol }}://backend.{{ regional_currency_domain }}/ +PAYMENT_TARGET_TYPES=x-taler-bank +PAYMENT_TARGET_REGEX=payto://x-taler-bank/bank.{{ regional_currency_domain }}/.* + +[merchant-exchange-{{ regional_currency_domain }}] +MASTER_KEY={{ regional_currency_exchange_master_public_key }} +CURRENCY={{ regional_currency_currency }} +EXCHANGE_BASE_URL={{ regional_currency_exchange_base_url }} diff --git a/regional-currency-ng/roles/regional_currency/templates/nginx-backend.conf.j2 b/regional-currency-ng/roles/regional_currency/templates/nginx-backend.conf.j2 @@ -0,0 +1,34 @@ +{% if regional_currency_nginx_tls_active %} +server { + listen 80; + listen [::]:80; + server_name backend.{{ regional_currency_domain }}; + return 308 https://$host$request_uri; +} +{% endif %} +server { + listen {{ '443 ssl' if regional_currency_nginx_tls_active else '80' }}; + listen [::]:{{ '443 ssl' if regional_currency_nginx_tls_active else '80' }}; + server_name backend.{{ regional_currency_domain }}; +{% if regional_currency_nginx_tls_active %} + ssl_certificate /etc/letsencrypt/live/backend.{{ regional_currency_domain }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/backend.{{ regional_currency_domain }}/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +{% endif %} + proxy_read_timeout 6500s; + keepalive_requests 1000000; + keepalive_timeout 6500s; + + location / { + proxy_pass http://unix:/var/run/taler-merchant/httpd/merchant-http.sock; + proxy_set_header Host "backend.{{ regional_currency_domain }}"; + proxy_set_header Forwarded ""; + proxy_set_header X-Forwarded-Proto "{{ regional_currency_protocol }}"; + proxy_set_header X-Forwarded-Host "backend.{{ regional_currency_domain }}"; + proxy_set_header X-Forwarded-Port "{{ '443' if regional_currency_nginx_tls_active else '80' }}"; + proxy_set_header X-Forwarded-Prefix /; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + } +} diff --git a/regional-currency-ng/roles/regional_currency/templates/nginx-bank-proxy.conf.j2 b/regional-currency-ng/roles/regional_currency/templates/nginx-bank-proxy.conf.j2 @@ -0,0 +1,9 @@ +proxy_pass http://127.0.0.1:{{ regional_currency_bank_port }}; +proxy_set_header Host "bank.{{ regional_currency_domain }}"; +proxy_set_header Forwarded ""; +proxy_set_header X-Forwarded-Proto "{{ regional_currency_protocol }}"; +proxy_set_header X-Forwarded-Host "bank.{{ regional_currency_domain }}"; +proxy_set_header X-Forwarded-Port "{{ '443' if regional_currency_nginx_tls_active else '80' }}"; +proxy_set_header X-Forwarded-Prefix /; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $remote_addr; diff --git a/regional-currency-ng/roles/regional_currency/templates/nginx-bank.conf.j2 b/regional-currency-ng/roles/regional_currency/templates/nginx-bank.conf.j2 @@ -0,0 +1,38 @@ +{% if regional_currency_nginx_tls_active %} +server { + listen 80; + listen [::]:80; + server_name bank.{{ regional_currency_domain }}; + return 308 https://$host$request_uri; +} +{% endif %} +server { + listen {{ '443 ssl' if regional_currency_nginx_tls_active else '80' }}; + listen [::]:{{ '443 ssl' if regional_currency_nginx_tls_active else '80' }}; + server_name bank.{{ regional_currency_domain }}; +{% if regional_currency_nginx_tls_active %} + ssl_certificate /etc/letsencrypt/live/backend.{{ regional_currency_domain }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/backend.{{ regional_currency_domain }}/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +{% endif %} + access_log /var/log/nginx/libeufin-bank.log; + error_log /var/log/nginx/libeufin-bank.err; + proxy_read_timeout 6500s; + keepalive_requests 1000000; + keepalive_timeout 6500s; + + location ~ ^/accounts/[^/]+/token$ { + limit_req zone=regional_currency_bank_token burst={{ regional_currency_bank_token_burst }} nodelay; + include /etc/nginx/snippets/regional-currency-bank-proxy.conf; + } + + location ~ ^/accounts/[^/]+/challenge/[^/]+(?:/confirm)?$ { + limit_req zone=regional_currency_bank_challenge burst={{ regional_currency_bank_challenge_burst }} nodelay; + include /etc/nginx/snippets/regional-currency-bank-proxy.conf; + } + + location / { + include /etc/nginx/snippets/regional-currency-bank-proxy.conf; + } +} diff --git a/regional-currency-ng/roles/regional_currency/templates/nginx-exchange.conf.j2 b/regional-currency-ng/roles/regional_currency/templates/nginx-exchange.conf.j2 @@ -0,0 +1,34 @@ +{% if regional_currency_nginx_tls_active %} +server { + listen 80; + listen [::]:80; + server_name exchange.{{ regional_currency_domain }}; + return 308 https://$host$request_uri; +} +{% endif %} +server { + listen {{ '443 ssl' if regional_currency_nginx_tls_active else '80' }}; + listen [::]:{{ '443 ssl' if regional_currency_nginx_tls_active else '80' }}; + server_name exchange.{{ regional_currency_domain }}; +{% if regional_currency_nginx_tls_active %} + ssl_certificate /etc/letsencrypt/live/backend.{{ regional_currency_domain }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/backend.{{ regional_currency_domain }}/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +{% endif %} + proxy_read_timeout 6500s; + keepalive_requests 1000000; + keepalive_timeout 6500s; + + location / { + proxy_pass http://unix:/var/run/taler-exchange/httpd/exchange-http.sock; + proxy_set_header Host "exchange.{{ regional_currency_domain }}"; + proxy_set_header Forwarded ""; + proxy_set_header X-Forwarded-Proto "{{ regional_currency_protocol }}"; + proxy_set_header X-Forwarded-Host "exchange.{{ regional_currency_domain }}"; + proxy_set_header X-Forwarded-Port "{{ '443' if regional_currency_nginx_tls_active else '80' }}"; + proxy_set_header X-Forwarded-Prefix /; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + } +} diff --git a/regional-currency-ng/roles/regional_currency/templates/nginx-rate-limits.conf.j2 b/regional-currency-ng/roles/regional_currency/templates/nginx-rate-limits.conf.j2 @@ -0,0 +1,3 @@ +limit_req_zone $binary_remote_addr zone=regional_currency_bank_token:10m rate={{ regional_currency_bank_token_rate }}; +limit_req_zone $binary_remote_addr zone=regional_currency_bank_challenge:10m rate={{ regional_currency_bank_challenge_rate }}; +limit_req_status 429; diff --git a/regional-currency-ng/roles/regional_currency/templates/taler-exchange-offline.service.j2 b/regional-currency-ng/roles/regional_currency/templates/taler-exchange-offline.service.j2 @@ -0,0 +1,15 @@ +[Unit] +Description=Regional-currency exchange online signing maintenance +Documentation=man:taler-exchange-offline(1) +After=network-online.target taler-exchange.target +Wants=network-online.target +Requires=taler-exchange.target + +[Service] +Type=oneshot +User=taler-exchange-offline +StateDirectory=taler-exchange-offline-maintenance +PrivateDevices=yes +PrivateTmp=yes +ProtectSystem=full +ExecStart=/bin/bash -ec 'year=$$(date -u +%%Y); next_year=$$((year + 1)); marker="v1:$$year:x-taler-bank{% if regional_currency_enable_conversion %},iban{% endif %}:1h:6a:0"; set -- download sign; update_marker=no; if test "$$(cat "$$STATE_DIRECTORY/fee-year" 2>/dev/null || true)" != "$$marker"; then set -- "$$@" wire-fee "$$year" x-taler-bank "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" wire-fee "$$next_year" x-taler-bank "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0"{% if regional_currency_enable_conversion %} wire-fee "$$year" iban "{{ regional_currency_fiat_currency }}:0" "{{ regional_currency_fiat_currency }}:0" wire-fee "$$next_year" iban "{{ regional_currency_fiat_currency }}:0" "{{ regional_currency_fiat_currency }}:0"{% endif %} global-fee "$$year" "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" 1h 6a 0 global-fee "$$next_year" "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" 1h 6a 0; update_marker=yes; fi; /usr/bin/taler-exchange-offline "$$@" upload; if test "$$update_marker" = yes; then echo "$$marker" > "$$STATE_DIRECTORY/fee-year"; fi' diff --git a/regional-currency-ng/roles/regional_currency/templates/taler-exchange-offline.timer.j2 b/regional-currency-ng/roles/regional_currency/templates/taler-exchange-offline.timer.j2 @@ -0,0 +1,11 @@ +[Unit] +Description=Daily regional-currency exchange online signing maintenance +Documentation=man:taler-exchange-offline(1) + +[Timer] +OnCalendar=daily +AccuracySec=12h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/regional-currency-ng/site.yml b/regional-currency-ng/site.yml @@ -0,0 +1,8 @@ +--- +- name: Deploy a GNU Taler regional currency + hosts: regional_currency + become: true + gather_facts: true + + roles: + - regional_currency diff --git a/regional-currency-ng/tests/test_inventory_wizard.py b/regional-currency-ng/tests/test_inventory_wizard.py @@ -0,0 +1,66 @@ +import importlib.util +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +MODULE_PATH = Path(__file__).parents[1] / "inventory-wizard.py" +SPEC = importlib.util.spec_from_file_location("inventory_wizard", MODULE_PATH) +assert SPEC and SPEC.loader +inventory_wizard = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(inventory_wizard) + + +class InventoryWriteTests(unittest.TestCase): + def test_targets_are_independent_and_private(self): + with tempfile.TemporaryDirectory() as temporary_directory: + directory = Path(temporary_directory) + first = inventory_wizard.write_target( + directory, + "first", + {"ansible_host": "first.example", "custom_value": "preserved"}, + ) + second = inventory_wizard.write_target( + directory, "second", {"ansible_host": "second.example"} + ) + + first_values, first_legacy = inventory_wizard.load_target( + directory, "first" + ) + first_values["ansible_host"] = "new-first.example" + inventory_wizard.write_target(directory, "first", first_values) + first_values, _ = inventory_wizard.load_target(directory, "first") + second_values, second_legacy = inventory_wizard.load_target( + directory, "second" + ) + + self.assertEqual(first_values["ansible_host"], "new-first.example") + self.assertEqual(first_values["custom_value"], "preserved") + self.assertEqual(second_values["ansible_host"], "second.example") + self.assertFalse(first_legacy) + self.assertFalse(second_legacy) + self.assertEqual(first.stat().st_mode & 0o777, 0o600) + self.assertEqual(second.stat().st_mode & 0o777, 0o600) + + def test_failed_replace_preserves_existing_inventory(self): + with tempfile.TemporaryDirectory() as temporary_directory: + directory = Path(temporary_directory) + target = inventory_wizard.write_target( + directory, "target", {"ansible_host": "old.example"} + ) + original = target.read_bytes() + + with mock.patch.object(os, "replace", side_effect=OSError("failed")): + with self.assertRaises(OSError): + inventory_wizard.write_target( + directory, "target", {"ansible_host": "new.example"} + ) + + self.assertEqual(target.read_bytes(), original) + self.assertEqual(list(directory.glob(".target.yml.*")), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/regional-currency-ng/vagrant/.gitignore b/regional-currency-ng/vagrant/.gitignore @@ -0,0 +1 @@ +.vagrant diff --git a/regional-currency-ng/vagrant/README b/regional-currency-ng/vagrant/README @@ -0,0 +1,2 @@ +This folder contains a vagrant configuration (https://developer.hashicorp.com/vagrant) +that allows us to easily spin up a virtual machine to test the setup instructions. diff --git a/regional-currency-ng/vagrant/Vagrantfile b/regional-currency-ng/vagrant/Vagrantfile @@ -0,0 +1,77 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# All Vagrant configuration is done below. The "2" in Vagrant.configure +# configures the configuration version (we support older styles for +# backwards compatibility). Please don't change it unless you know what +# you're doing. +Vagrant.configure("2") do |config| + # The most common configuration options are documented and commented below. + # For a complete reference, please see the online documentation at + # https://docs.vagrantup.com. + + # Every Vagrant development environment requires a box. You can search for + # boxes at https://vagrantcloud.com/search. + config.vm.box = "ubuntu/kinetic64" + + config.ssh.forward_agent = true + config.ssh.forward_x11 = true + + # Disable automatic box update checking. If you disable this, then + # boxes will only be checked for updates when the user runs + # `vagrant box outdated`. This is not recommended. + # config.vm.box_check_update = false + + # Create a forwarded port mapping which allows access to a specific port + # within the machine from a port on the host machine. In the example below, + # accessing "localhost:8080" will access port 80 on the guest machine. + # NOTE: This will enable public access to the opened port + # config.vm.network "forwarded_port", guest: 80, host: 8080 + + # Create a forwarded port mapping which allows access to a specific port + # within the machine from a port on the host machine and only allow access + # via 127.0.0.1 to disable public access + # config.vm.network "forwarded_port", guest: 80, host: 8080, host_ip: "127.0.0.1" + + # Create a private network, which allows host-only access to the machine + # using a specific IP. + # config.vm.network "private_network", ip: "192.168.33.10" + + # Create a public network, which generally matched to bridged network. + # Bridged networks make the machine appear as another physical device on + # your network. + # config.vm.network "public_network" + + # Share an additional folder to the guest VM. The first argument is + # the path on the host to the actual folder. The second argument is + # the path on the guest to mount the folder. And the optional third + # argument is a set of non-required options. + # config.vm.synced_folder "../data", "/vagrant_data" + + # Provider-specific configuration so you can fine-tune various + # backing providers for Vagrant. These expose provider-specific options. + # Example for VirtualBox: + # + config.vm.provider "virtualbox" do |vb| + # Display the VirtualBox GUI when booting the machine + vb.gui = true + + # Customize the amount of memory on the VM: + vb.memory = "4096" + + # Required, or wayland doesn't seem to work + vb.customize ['modifyvm', :id, '--graphicscontroller', 'vmsvga'] + end + # + # View the documentation for the provider you are using for more + # information on available options. + + # Enable provisioning with a shell script. Additional provisioners such as + # Ansible, Chef, Docker, Puppet and Salt are also available. Please see the + # documentation for more information about their specific syntax and use. + config.vm.provision "shell", reboot: true, inline: <<-SHELL + apt-get update + apt-get upgrade -y + apt-get install -y ubuntu-desktop gnome-shell firefox virtualbox-guest-additions-iso git + SHELL +end