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