taler-deployment

Deployment scripts and configuration files
Log | Files | Refs | README

commit fa919f1a697e873a0cdd374e7927e6cb7ca826a1
parent c9b1f77872c69a65bfff0144140fb182d1d635d0
Author: Florian Dold <dold@taler.net>
Date:   Thu,  3 Sep 2026 13:54:11 +0200

regional-currency: add simulated conversion testing

Diffstat:
Aregional-currency-ng/CONVERSION-TESTING.md | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mregional-currency-ng/ChangeLog | 8++++++++
Mregional-currency-ng/README.md | 19+++++++++++++++++++
Mregional-currency-ng/inventory-wizard.py | 17++++++++++++++---
Aregional-currency-ng/inventory.example.yml | 45+++++++++++++++++++++++++++++++++++++++++++++
Mregional-currency-ng/roles/regional_currency/defaults/main.yml | 1+
Mregional-currency-ng/roles/regional_currency/tasks/libeufin.yml | 28++++++++++++++++++++++++++++
Mregional-currency-ng/roles/regional_currency/tasks/main.yml | 3+++
Mregional-currency-ng/roles/regional_currency/templates/taler-exchange-offline.service.j2 | 2+-
Mregional-currency-ng/tests/test_inventory_wizard.py | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 393 insertions(+), 4 deletions(-)

diff --git a/regional-currency-ng/CONVERSION-TESTING.md b/regional-currency-ng/CONVERSION-TESTING.md @@ -0,0 +1,210 @@ +# Test currency conversion without a fiat bank + +This runbook tests a complete cash-in and cash-out cycle while the conversion +account is not connected to a real bank. It uses a command-line Taler wallet +and the LibEuFin Nexus testing commands. No EBICS enrollment or payment +submission is needed. + +Only use this mode for a disposable testing deployment. Enabling conversion +records the fiat currency, IBAN, BIC, and account name as part of the +deployment identity. The normal playbook will reject later changes to those +values or an attempt to disable conversion. + +## Configure and deploy test mode + +Set these values in the target's private inventory file: + +```yaml +regional_currency_testing_deployment: true +regional_currency_enable_conversion: true +regional_currency_conversion_test_mode: true +regional_currency_fiat_currency: CHF +regional_currency_fiat_bank_name: Synthetic CHF Test Bank +regional_currency_fiat_account_iban: CH8615624QG38ANY0UVER +regional_currency_fiat_account_bic: TSTRCHZZXXX +regional_currency_fiat_account_name: FLODOS Conversion Test +regional_currency_enable_nexus_services: false +``` + +Generate a fresh valid test IBAN instead of copying the example IBAN: + +```console +$ libeufin-nexus testing iban gen --country CH +CH... +``` + +Conversion test mode is valid only when both testing and conversion are +enabled. It rejects an inventory that enables the Nexus services and configures +Nexus to require manual acknowledgement before an initiated payment can be +submitted. + +Deploy only the intended host: + +```console +$ ansible -i inventory regio -m ping +$ ansible-playbook -i inventory site.yml --limit regio +``` + +Verify the public configuration from the operator machine: + +```console +$ curl -fsS https://bank.regio.taler.net/config | jq '{currency, allow_conversion}' +$ curl -fsS https://bank.regio.taler.net/conversion-info/config | + jq '{regional_currency, fiat_currency, conversion_rate}' +$ curl -fsS https://exchange.regio.taler.net/keys | + jq '[.accounts[] | {payto_uri, conversion_url, bank_label, priority}]' +``` + +The bank must report FLODOS conversion enabled with CHF as the fiat currency. +The exchange must advertise a priority-10 IBAN account whose conversion URL is +`https://bank.regio.taler.net/conversion-info/`. + +On the managed host, verify that no EBICS worker is running: + +```console +# systemctl is-active libeufin-nexus.target \ + libeufin-nexus-ebics-fetch.service \ + libeufin-nexus-ebics-submit.service +inactive +inactive +inactive +``` + +## Simulate a conversion withdrawal + +On the operator machine, create a temporary wallet and start a manual +withdrawal. Keep this directory until the whole test has finished: + +```console +$ REGIO_TEST_DIR=$(mktemp -d) +$ REGIO_WALLET_DB="$REGIO_TEST_DIR/wallet.sqlite3" +$ taler-wallet-cli --wallet-db "$REGIO_WALLET_DB" \ + withdraw \ + --exchange https://exchange.regio.taler.net/ \ + --amount FLODOS:10 | tee "$REGIO_TEST_DIR/withdraw.out" +$ REGIO_TX_ID=$(sed -n 's/^transaction //p' "$REGIO_TEST_DIR/withdraw.out") +$ taler-wallet-cli --wallet-db "$REGIO_WALLET_DB" \ + transactions show "$REGIO_TX_ID" > "$REGIO_TEST_DIR/withdraw.json" +$ REGIO_CREDIT_PAYTO=$(jq -r ' + [.withdrawalDetails.exchangeCreditAccountDetails[] + | select(.transferAmount | startswith("CHF:")) + | .transferOptions[] + | select(.type == "payto") + | .paytoUri][0] + ' "$REGIO_TEST_DIR/withdraw.json") +$ printf '%s\n' "$REGIO_CREDIT_PAYTO" +``` + +The selected URI includes the synthetic conversion IBAN, the CHF amount, and +the reserve public key as its transfer subject. Copy the complete URI to the +managed host and inject it exactly as produced by the wallet: + +```console +# REGIO_CREDIT_PAYTO='payto://iban/...?...' +# sudo -u libeufin-nexus \ + libeufin-nexus testing fake-incoming \ + -c /etc/libeufin/libeufin-nexus.conf \ + --credit-payto "$REGIO_CREDIT_PAYTO" +# sudo -u libeufin-nexus \ + libeufin-nexus testing list incoming \ + -c /etc/libeufin/libeufin-nexus.conf +``` + +Back on the operator machine, wait for the exchange and wallet to process the +reserve, then check the balance: + +```console +$ taler-wallet-cli --wallet-db "$REGIO_WALLET_DB" \ + transactions wait "$REGIO_TX_ID" --state final --timeout 2m +$ taler-wallet-cli --wallet-db "$REGIO_WALLET_DB" balance --pretty +``` + +With the one-to-one rate and zero fees, the wallet balance must be +`FLODOS:10`. + +## Deposit and cash out + +Create a dedicated zero-debt bank account on the managed host. Its cash-out +IBAN must be different from the synthetic conversion account: + +```console +# REGIO_TEST_USERNAME="conversion-test-$(date -u +%Y%m%d%H%M%S)" +# REGIO_TEST_PASSWORD=$(uuidgen) +# REGIO_CASHOUT_IBAN=$(libeufin-nexus testing iban gen --country CH) +# REGIO_ACCOUNT_PAYTO=$(sudo -u libeufin-bank \ + libeufin-bank create-account \ + --username "$REGIO_TEST_USERNAME" \ + --password "$REGIO_TEST_PASSWORD" \ + --name 'Conversion Test Customer' \ + --cashout_payto_uri \ + "payto://iban/TSTRCHZZXXX/$REGIO_CASHOUT_IBAN" \ + --debit_threshold FLODOS:0) +# printf 'username: %s\naccount: %s\ncash-out IBAN: %s\n' \ + "$REGIO_TEST_USERNAME" "$REGIO_ACCOUNT_PAYTO" "$REGIO_CASHOUT_IBAN" +``` + +Copy `REGIO_ACCOUNT_PAYTO` to the operator machine and deposit the complete +wallet balance into it: + +```console +$ REGIO_ACCOUNT_PAYTO='payto://x-taler-bank/...' +$ taler-wallet-cli --wallet-db "$REGIO_WALLET_DB" \ + deposit FLODOS:10 "$REGIO_ACCOUNT_PAYTO" --timeout 2m +``` + +On the managed host, create a one-hour API token and wait until the account +shows a `FLODOS:10` credit balance: + +```console +# REGIO_TEST_TOKEN=$(sudo -u libeufin-bank \ + libeufin-bank create-token \ + --username "$REGIO_TEST_USERNAME" \ + --scope readwrite \ + --duration 3600000000 \ + --description 'conversion test') +# curl -fsS \ + -H "Authorization: Bearer $REGIO_TEST_TOKEN" \ + "https://bank.regio.taler.net/accounts/$REGIO_TEST_USERNAME" | jq .balance +``` + +Ask the bank for the current cash-out quote, then submit exactly that debit and +credit amount with a fresh idempotency key: + +```console +# REGIO_QUOTE=$(curl -fsS \ + "https://bank.regio.taler.net/conversion-info/cashout-rate?amount_debit=FLODOS:10") +# REGIO_CASHOUT_CREDIT=$(printf '%s' "$REGIO_QUOTE" | jq -r .amount_credit) +# REGIO_REQUEST_UID=$(head -c 32 /dev/urandom | gnunet-base32) +# jq -n \ + --arg uid "$REGIO_REQUEST_UID" \ + --arg debit 'FLODOS:10' \ + --arg credit "$REGIO_CASHOUT_CREDIT" \ + --arg subject "regio conversion test $REGIO_TEST_USERNAME" \ + '{request_uid: $uid, amount_debit: $debit, + amount_credit: $credit, subject: $subject}' | + curl -fsS \ + -H "Authorization: Bearer $REGIO_TEST_TOKEN" \ + -H 'Content-Type: application/json' \ + --data-binary @- \ + "https://bank.regio.taler.net/accounts/$REGIO_TEST_USERNAME/cashouts" | + jq . +``` + +Verify that the bank balance has returned to zero and that Nexus contains the +CHF payment it would send to the fiat bank: + +```console +# curl -fsS \ + -H "Authorization: Bearer $REGIO_TEST_TOKEN" \ + "https://bank.regio.taler.net/accounts/$REGIO_TEST_USERNAME" | jq .balance +# sudo -u libeufin-nexus \ + libeufin-nexus testing list initiated \ + -c /etc/libeufin/libeufin-nexus.conf +``` + +The initiated entry must show `CHF:10`, the generated cash-out IBAN, and the +unique test subject. This queued entry is the proof of the outgoing cash-out; +there is deliberately no matching outgoing bank statement in simulated mode. + +Do not run `libeufin-nexus ebics-submit` or manually acknowledge the transfer. +Those operations belong to a deployment connected to a real fiat bank. diff --git a/regional-currency-ng/ChangeLog b/regional-currency-ng/ChangeLog @@ -1,4 +1,12 @@ Thu Sep 3 2026 + Added a guarded conversion test mode, a two-host sample inventory, and + an operator runbook for simulated cash-in and cash-out testing with a + command-line wallet and LibEuFin Nexus testing commands. + Use the regional currency for IBAN wire fees, as required by the + exchange even when the account receives a fiat conversion payment. + Install the LibEuFin conversion database link that turns Nexus fiat + incoming payments into regional-currency exchange credits. + Added a guarded playbook that completely resets and reprovisions an exchange on testing deployments. diff --git a/regional-currency-ng/README.md b/regional-currency-ng/README.md @@ -27,6 +27,19 @@ $ cd regional-currency-ng $ ./inventory-wizard.py ``` +Alternatively, copy `inventory.example.yml`, remove the host example that does +not apply, and adjust every host, domain, contact, and password value: + +```console +$ cp inventory.example.yml inventory/HOST.yml +$ chmod 0600 inventory/HOST.yml +$ editor inventory/HOST.yml +``` + +The example contains one normal deployment without conversion and one testing +deployment with simulated CHF conversion. Do not leave both example hosts in a +live inventory unless both are intentional targets. + `./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: @@ -134,6 +147,12 @@ 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. +For a conversion deployment without a real fiat bank connection, set +`regional_currency_conversion_test_mode: true`. This is restricted to testing +deployments, keeps the Nexus workers disabled, and retains initiated cash-out +payments for inspection. See [CONVERSION-TESTING.md](CONVERSION-TESTING.md) for +the complete command-line wallet, simulated cash-in, and cash-out runbook. + 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`, diff --git a/regional-currency-ng/inventory-wizard.py b/regional-currency-ng/inventory-wizard.py @@ -366,11 +366,22 @@ def collect_configuration( ), } ) - 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), + conversion_test_mode = testing and ask_yes_no( + "Use simulated conversion test mode (no EBICS bank connection)", + bool_value(variables.get("regional_currency_conversion_test_mode"), False), ) + variables["regional_currency_conversion_test_mode"] = conversion_test_mode + if conversion_test_mode: + variables["regional_currency_enable_nexus_services"] = False + else: + 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_conversion_test_mode"] = False variables["regional_currency_enable_nexus_services"] = False telesign = ask_yes_no( diff --git a/regional-currency-ng/inventory.example.yml b/regional-currency-ng/inventory.example.yml @@ -0,0 +1,45 @@ +--- +all: + children: + regional_currency: + hosts: + normal-example: + ansible_host: normal.example.net + ansible_user: root + ansible_port: 22 + ansible_become: true + regional_currency_currency: NETZBON + regional_currency_domain: normal.example.net + regional_currency_bank_name: Example Regional Bank + regional_currency_bank_admin_password: CHANGE-ME + regional_currency_testing_deployment: false + regional_currency_enable_tls: true + regional_currency_tls_email: operator@example.net + regional_currency_enable_conversion: false + regional_currency_conversion_test_mode: false + regional_currency_enable_nexus_services: false + regional_currency_apt_testing: false + + conversion-test-example: + ansible_host: conversion-test.example.net + ansible_user: root + ansible_port: 22 + ansible_become: true + regional_currency_currency: FLODOS + regional_currency_domain: conversion-test.example.net + regional_currency_bank_name: Example Test Bank + regional_currency_bank_admin_password: CHANGE-ME + regional_currency_testing_deployment: true + regional_currency_enable_tls: true + regional_currency_tls_email: operator@example.net + regional_currency_enable_conversion: true + regional_currency_conversion_test_mode: true + regional_currency_fiat_currency: CHF + regional_currency_fiat_bank_name: Synthetic CHF Test Bank + # Generate a fresh value with: + # libeufin-nexus testing iban gen --country CH + regional_currency_fiat_account_iban: CH8615624QG38ANY0UVER + regional_currency_fiat_account_bic: TSTRCHZZXXX + regional_currency_fiat_account_name: FLODOS Conversion Test + regional_currency_enable_nexus_services: false + regional_currency_apt_testing: true diff --git a/regional-currency-ng/roles/regional_currency/defaults/main.yml b/regional-currency-ng/roles/regional_currency/defaults/main.yml @@ -7,6 +7,7 @@ regional_currency_bank_port: 8080 regional_currency_enable_landing_page: true regional_currency_testing_deployment: false regional_currency_enable_conversion: false +regional_currency_conversion_test_mode: false regional_currency_fiat_currency: CHF regional_currency_fiat_bank_name: "" regional_currency_fiat_account_iban: "" diff --git a/regional-currency-ng/roles/regional_currency/tasks/libeufin.yml b/regional-currency-ng/roles/regional_currency/tasks/libeufin.yml @@ -18,6 +18,20 @@ when: regional_currency_enable_conversion changed_when: false +- name: Configure Nexus payment acknowledgement policy + ansible.builtin.command: + argv: + - taler-harness + - config + - -c + - /etc/libeufin/libeufin-nexus.conf + - set + - nexus-submit + - MANUAL_ACK + - "{{ 'YES' if regional_currency_conversion_test_mode else 'NO' }}" + when: regional_currency_enable_conversion + changed_when: false + - name: Install LibEuFin bank configuration ansible.builtin.template: src: libeufin-bank.conf.j2 @@ -51,6 +65,20 @@ chdir: / changed_when: false +- name: Link Nexus fiat payments to regional-currency cash-in + become: true + become_user: postgres + ansible.builtin.command: + argv: + - psql + - --dbname=libeufin + - --set=ON_ERROR_STOP=1 + - --file=/usr/share/libeufin/sql/libeufin-conversion-setup.sql + args: + chdir: / + when: regional_currency_enable_conversion + changed_when: false + - name: Enable and start LibEuFin bank ansible.builtin.service: name: libeufin-bank diff --git a/regional-currency-ng/roles/regional_currency/tasks/main.yml b/regional-currency-ng/roles/regional_currency/tasks/main.yml @@ -14,6 +14,9 @@ - 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 + - not regional_currency_conversion_test_mode or regional_currency_testing_deployment + - not regional_currency_conversion_test_mode or regional_currency_enable_conversion + - not regional_currency_conversion_test_mode or not regional_currency_enable_nexus_services - 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]$') 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 @@ -12,4 +12,4 @@ 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' +ExecStart=/bin/bash -ec 'year=$$(date -u +%%Y); next_year=$$((year + 1)); marker="v2:$$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_currency }}:0" "{{ regional_currency_currency }}:0" wire-fee "$$next_year" iban "{{ regional_currency_currency }}:0" "{{ regional_currency_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/tests/test_inventory_wizard.py b/regional-currency-ng/tests/test_inventory_wizard.py @@ -62,5 +62,69 @@ class InventoryWriteTests(unittest.TestCase): self.assertEqual(list(directory.glob(".target.yml.*")), []) +class InventoryPromptTests(unittest.TestCase): + def test_conversion_test_mode_keeps_nexus_services_disabled(self): + answers = { + "SSH host name or address": "conversion.example", + "SSH user": "root", + "Base domain for the service hosts and optional landing page": "conversion.example", + "Human-readable bank name": "Test Bank", + "Fiat bank name": "Synthetic Bank", + "Fiat account legal name": "Synthetic Account", + } + matching_answers = { + "Inventory host name to add or update": "conversion-test", + "Regional currency code": "FLODOS", + "Fiat currency code": "CHF", + "Fiat account IBAN": "CH8615624QG38ANY0UVER", + "Fiat account BIC": "TSTRCHZZXXX", + } + boolean_answers = { + "Configure a testing deployment": True, + "Configure a landing page on the base domain": True, + "Obtain TLS certificates using Let's Encrypt": False, + "Configure conversion to a fiat currency": True, + "Use simulated conversion test mode (no EBICS bank connection)": True, + "Configure Telesign SMS authentication": False, + "Configure exchange terms of service": False, + "Configure an exchange privacy policy": False, + "Use the GNU Taler testing APT repository (Debian only)": True, + } + + def ask(message, default=None, **_kwargs): + return answers.get(message, default) + + def ask_matching(message, _pattern, default=None, **_kwargs): + return matching_answers.get(message, default) + + def ask_yes_no(message, _default): + if message.startswith("Enable Nexus services"): + self.fail("test mode must not offer to enable Nexus services") + return boolean_answers[message] + + with tempfile.TemporaryDirectory() as temporary_directory: + with ( + mock.patch.object(inventory_wizard, "ask", side_effect=ask), + mock.patch.object( + inventory_wizard, "ask_matching", side_effect=ask_matching + ), + mock.patch.object( + inventory_wizard, "ask_yes_no", side_effect=ask_yes_no + ), + mock.patch.object( + inventory_wizard, "ask_int", side_effect=lambda _m, d: d + ), + mock.patch.object( + inventory_wizard.getpass, "getpass", return_value="secret" + ), + ): + _, _, variables, _, _, _ = inventory_wizard.collect_configuration( + Path(temporary_directory) + ) + + self.assertTrue(variables["regional_currency_conversion_test_mode"]) + self.assertFalse(variables["regional_currency_enable_nexus_services"]) + + if __name__ == "__main__": unittest.main()