sandcastle-ng

Scripts for the deployment of Sandcastle (GNU Taler)
Log | Files | Refs | README

commit e15f831625f92a63133d9568602d53e26f6e5ba2
parent 51b18443eb51daf07e43c809e294f5bc8af079bb
Author: Florian Dold <dold@taler.net>
Date:   Wed, 26 Aug 2026 09:57:04 +0200

sandcastle: expose merchant backend over Unix socket

Diffstat:
M.containerignore | 1+
M.gitignore | 1+
MREADME.md | 45++++++++++++++++++++++++++++++++++++---------
Msandcastle-run | 23+++++++++++++++++++----
Mscripts/demo/setup-sandcastle.sh | 19++++++++++++++-----
Atests/test_sandcastle_run.py | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 183 insertions(+), 18 deletions(-)

diff --git a/.containerignore b/.containerignore @@ -2,6 +2,7 @@ cache/ credentials/ exported/ +sockets/ packages/.state/ packages/.staging/ **/__pycache__/ diff --git a/.gitignore b/.gitignore @@ -2,4 +2,5 @@ /exported/* /cache/ /packages/ +/sockets/ __pycache__/ diff --git a/README.md b/README.md @@ -14,8 +14,8 @@ You need (on your host system): * bash * Python 3.10 or newer -The sandcastle-ng container exposes TCP ports that serve the APIs / Web -interfaces of the respective GNU Taler service via HTTP. +The sandcastle-ng container exposes the merchant backend through a Unix domain +socket and exposes TCP ports for the other GNU Taler APIs / Web interfaces. Some of these services expect to be accessed via a certain hostname, so it is recommended that you set up the following domain names: @@ -106,9 +106,12 @@ Currently there is not much configuration. The main adjustments to be made are: * scripts/demo/setup-sandcastle.sh has the currency on top of the file -* sandcastle-run has variables for the ports that'll be exposed ("published") on - the host. They can be overwritten with environment variables - (``TALER_SANDCASTLE_PORT_$COMPONENT``). +* `sandcastle-run` has variables for the ports that'll be exposed ("published") + on the host. They can be overwritten with environment variables named + `SANDCASTLE_PORT_$COMPONENT`. +* The merchant backend uses the Unix socket + `sockets/merchant-backend.sock` by default. Set `SANDCASTLE_SOCKET_DIR` to + choose a different host directory for the socket. To make adjustments for an environment without changing the ``setup-sandcastle.sh`` script, you can use an override file @@ -162,8 +165,32 @@ These will be passed on to ``podman run``. Example: ./sandcastle-run --log-level=debug -The running container publishes ports to the host as defined in ``./sandcastle-run``. -You can manually verify these port mappings via ``podman port taler-sandcastle``. +The running container publishes ports to the host as defined in +`./sandcastle-run`. You can manually verify these port mappings via +`podman port taler-sandcastle`. + +The merchant backend is instead available through a bind-mounted Unix socket: + + curl --unix-socket ./sockets/merchant-backend.sock http://localhost/config + +The socket directory defaults to `./sockets`, relative to the repository, and +can be changed when starting the deployment: + + SANDCASTLE_SOCKET_DIR=/run/taler-sandcastle ./sandcastle-run + +Missing socket directories are created with mode `1777`, and the merchant +socket has mode `0666`, so a host reverse proxy running as a different user can +connect to it. A pre-existing custom directory must allow the container's +`caddy` user to create the socket. The bind mount uses Podman's shared SELinux +label; deployments with a confined host reverse proxy may additionally need an +appropriate local SELinux policy. + +To retain the previous dedicated TCP exposure for the merchant backend, set +its port explicitly: + + SANDCASTLE_PORT_MERCHANT=127.0.0.1:16000 ./sandcastle-run + +This keeps the Unix socket available as well. When starting sandcastle like @@ -312,8 +339,8 @@ touch exported/import-request * Do self-tests of the deployment using the wallet CLI * Running the auditor * Running a currency conversion setup with multiple libeufin-bank instances -* Instead of exposing HTTP ports, we could expose everything via unix domain sockets, - avoiding port collision problems. +* Migrate the remaining HTTP port exposures to Unix domain sockets, avoiding + port collision problems. * Instead of requiring the reverse proxy to handle TLS, the sandcastle container itself could do TLS termination with caddy. * To improve performance, allow connecting to an external database diff --git a/sandcastle-run b/sandcastle-run @@ -4,8 +4,10 @@ set -exou -# Ports where individual services are published to the host -SANDCASTLE_PORT_MERCHANT=${SANDCASTLE_PORT_MERCHANT:-127.0.0.1:16000} +# Ports where individual services are published to the host. The merchant +# backend is exposed through a Unix socket by default, but can still be +# published on a TCP port by setting SANDCASTLE_PORT_MERCHANT. +SANDCASTLE_PORT_MERCHANT=${SANDCASTLE_PORT_MERCHANT:-} SANDCASTLE_PORT_EXCHANGE=${SANDCASTLE_PORT_EXCHANGE:-127.0.0.1:16001} SANDCASTLE_PORT_BLOG=${SANDCASTLE_PORT_BLOG:-127.0.0.1:16002} SANDCASTLE_PORT_DONATIONS=${SANDCASTLE_PORT_DONATIONS:-127.0.0.1:16003} @@ -31,7 +33,19 @@ PORT_INTERNAL_DRUPAL=8509 PORT_INTERNAL_PAIVANA=8510 SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) -cd $SCRIPT_DIR +cd "$SCRIPT_DIR" + +SANDCASTLE_SOCKET_DIR=${SANDCASTLE_SOCKET_DIR:-$PWD/sockets} +if [[ ! -d $SANDCASTLE_SOCKET_DIR ]]; then + mkdir -p "$SANDCASTLE_SOCKET_DIR" + chmod 1777 "$SANDCASTLE_SOCKET_DIR" +fi +SANDCASTLE_SOCKET_DIR=$(realpath "$SANDCASTLE_SOCKET_DIR") + +PUBLISH_MERCHANT_PORT=() +if [[ -n $SANDCASTLE_PORT_MERCHANT ]]; then + PUBLISH_MERCHANT_PORT=("-p=$SANDCASTLE_PORT_MERCHANT:$PORT_INTERNAL_MERCHANT") +fi existing_id=$(podman ps -q -a -f=name=taler-sandcastle) @@ -78,7 +92,7 @@ NETWORK="--network slirp4netns" exec podman run \ -d \ - -p=$SANDCASTLE_PORT_MERCHANT:$PORT_INTERNAL_MERCHANT \ + "${PUBLISH_MERCHANT_PORT[@]}" \ -p=$SANDCASTLE_PORT_EXCHANGE:$PORT_INTERNAL_EXCHANGE \ -p=$SANDCASTLE_PORT_LIBEUFIN_BANK:$PORT_INTERNAL_LIBEUFIN_BANK \ -p=$SANDCASTLE_PORT_LANDING:$PORT_INTERNAL_LANDING \ @@ -95,6 +109,7 @@ exec podman run \ $NETWORK \ -v talerdata:/talerdata:Z \ -v talerdata_persistent:/talerdata_persistent:Z \ + -v "$SANDCASTLE_SOCKET_DIR:/sockets:z" \ $OVERRIDES \ ${PUBLISH_EXTERNAL_PORT4:-} \ -v $PWD/credentials:/credentials:Z \ diff --git a/scripts/demo/setup-sandcastle.sh b/scripts/demo/setup-sandcastle.sh @@ -716,18 +716,27 @@ cat <<EOF >/etc/caddy/Caddyfile } } +(sandcastle_merchant) { + reverse_proxy unix//run/taler-merchant/httpd/merchant-http.sock { + # Set this, or otherwise wrong taler://pay URIs will be generated. + header_up X-Forwarded-Proto "https" + } +} + # Services that only listen on unix domain sockets -# are reverse-proxied to serve on a TCP port. +# are reverse-proxied to public listeners. :$PORT_INTERNAL_EXCHANGE { reverse_proxy unix//run/taler-exchange/httpd/exchange-http.sock } :$PORT_INTERNAL_MERCHANT { - reverse_proxy unix//run/taler-merchant/httpd/merchant-http.sock { - # Set this, or otherwise wrong taler://pay URIs will be generated. - header_up X-Forwarded-Proto "https" - } + import sandcastle_merchant +} + +http:// { + bind unix//sockets/merchant-backend.sock|0666 + import sandcastle_merchant } :$PORT_INTERNAL_DONAU { diff --git a/tests/test_sandcastle_run.py b/tests/test_sandcastle_run.py @@ -0,0 +1,112 @@ +# This file is in the public domain. + +import json +import os +import shutil +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + +REPOSITORY = Path(__file__).resolve().parents[1] +RUN_SCRIPT = REPOSITORY / "sandcastle-run" + +FAKE_PODMAN = r"""#!/usr/bin/env python3 +import json +import os +import sys +from pathlib import Path + + +arguments = sys.argv[1:] +with Path(os.environ["FAKE_PODMAN_LOG"]).open("a", encoding="utf-8") as log: + log.write(json.dumps(arguments) + "\n") + +if arguments[0] == "ps": + raise SystemExit(0) +if arguments[0] == "run": + raise SystemExit(0) +raise SystemExit(125) +""" + + +class SandcastleRunTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.repository = self.root / "sandcastle-ng" + self.repository.mkdir() + shutil.copy2(RUN_SCRIPT, self.repository / "sandcastle-run") + + self.bin_dir = self.root / "bin" + self.bin_dir.mkdir() + podman = self.bin_dir / "podman" + podman.write_text(FAKE_PODMAN, encoding="utf-8") + podman.chmod(0o755) + self.log = self.root / "podman-log" + + def tearDown(self): + self.temporary.cleanup() + + def run_sandcastle(self, **settings): + environment = os.environ.copy() + environment.update( + { + "PATH": f"{self.bin_dir}:{environment['PATH']}", + "FAKE_PODMAN_LOG": str(self.log), + } + ) + for variable in ( + "SANDCASTLE_OVERRIDE_NAME", + "SANDCASTLE_PORT_MERCHANT", + "SANDCASTLE_SOCKET_DIR", + ): + environment.pop(variable, None) + environment.update(settings) + + result = subprocess.run( + [str(self.repository / "sandcastle-run")], + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=5, + ) + self.assertEqual(0, result.returncode, result.stderr) + calls = [ + json.loads(line) + for line in self.log.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual("ps", calls[0][0]) + self.assertEqual("run", calls[1][0]) + return calls[1] + + def test_default_uses_socket_without_publishing_merchant_port(self): + arguments = self.run_sandcastle() + + socket_dir = self.repository / "sockets" + self.assertTrue(socket_dir.is_dir()) + self.assertEqual(0o1777, stat.S_IMODE(socket_dir.stat().st_mode)) + self.assertIn(f"{socket_dir}:/sockets:z", arguments) + self.assertNotIn("-p=127.0.0.1:16000:8301", arguments) + self.assertIn("-p=127.0.0.1:16001:8201", arguments) + + def test_custom_socket_directory_is_mounted_as_one_argument(self): + socket_dir = self.root / "custom socket directory" + + arguments = self.run_sandcastle(SANDCASTLE_SOCKET_DIR=str(socket_dir)) + + self.assertEqual(0o1777, stat.S_IMODE(socket_dir.stat().st_mode)) + self.assertIn(f"{socket_dir}:/sockets:z", arguments) + + def test_explicit_merchant_port_is_published(self): + arguments = self.run_sandcastle( + SANDCASTLE_PORT_MERCHANT="127.0.0.1:26000" + ) + + self.assertIn("-p=127.0.0.1:26000:8301", arguments) + + +if __name__ == "__main__": + unittest.main()