From 63f276523bda4225e940aae7c2b109a6a631e795 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 18 Aug 2026 05:14:19 +0100 Subject: [PATCH] stalwart: run as container-root so it can write its own data directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the installer for real rather than testing the hook in isolation. Stalwart never started: it failed to open its database with "Permission denied" on /var/lib/stalwart, which meant no mail could be stored and the setup wizard could not be completed by hand either. The image runs as its own uid 2000. LibrePortal gives container directories to the docker install user under rootless and to the manager under rooted, and 2000 is neither, so the bind mounts were unwritable in both modes. This was not something the new provisioning introduced — it predates it, and the app has never been able to hold mail. Running as container-root maps to whichever host user owns those directories. Under rootless that is the unprivileged docker install user, not host root. Also stop discarding the server's error when setup fails. Both failures that actually occur — a hostname under a TLD that does not resolve, and the unwritable data directory above — name themselves precisely, and a bare "setup failed" turns a one-line fix into guesswork. Verified end to end through `libreportal app install stalwart` on a clean install: setup applied, DKIM keys generated, postmaster mailbox created, and the full record set printed from the server's own zone data. Co-Authored-By: Claude Opus 5 --- containers/stalwart/docker-compose.yml | 12 ++++++++++++ .../stalwart/scripts/stalwart_install_hooks.sh | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/containers/stalwart/docker-compose.yml b/containers/stalwart/docker-compose.yml index 014d077..42894b0 100644 --- a/containers/stalwart/docker-compose.yml +++ b/containers/stalwart/docker-compose.yml @@ -9,6 +9,18 @@ services: # snapshot, one thing to restore. stalwart-service: #LIBREPORTAL|SERVICE_TAG_1|stalwart-service container_name: stalwart-service + # The image runs as its own uid 2000, which cannot write either of the + # bind mounts below: LibrePortal hands container directories to the + # docker install user in rootless mode and to the manager in rooted mode, + # and 2000 is neither. Stalwart then fails to open its database at all — + # "Permission denied" on /var/lib/stalwart — so no mail can be stored and + # even the setup wizard cannot complete. + # Running as container-root maps to whichever host user owns those + # directories, which is the one uid that is correct in both modes. Under + # rootless this is not host root; it is the unprivileged docker install + # user. It also removes any question about binding port 25 inside the + # container. + user: "0:0" # Pinned to a minor tag, NOT :latest. Stalwart is pre-1.0 and has said the # storage schema is still being finalised, so an unattended jump to a new # minor could carry a data migration. CFG_STALWART_UPDATE_TYPE is likewise diff --git a/containers/stalwart/scripts/stalwart_install_hooks.sh b/containers/stalwart/scripts/stalwart_install_hooks.sh index 8ebbfb7..e985b7d 100644 --- a/containers/stalwart/scripts/stalwart_install_hooks.sh +++ b/containers/stalwart/scripts/stalwart_install_hooks.sh @@ -100,8 +100,16 @@ stalwart_install_provision() plan=$(printf '{"@type":"update","object":"Bootstrap","value":{"serverHostname":"%s","defaultDomain":"%s","generateDkimKeys":true,"requestTlsCertificate":true}}' \ "$mail_host" "$mail_domain") - if ! printf '%s\n' "$plan" | stalwart_cli apply --stdin >/dev/null 2>&1; then + # Keep the server's own error rather than swallowing it. The two that + # actually happen say exactly what is wrong — a hostname under a TLD that + # does not exist, or a data directory the server cannot write — and both are + # unfixable guesswork without the message. + local apply_out + if ! apply_out=$(printf '%s\n' "$plan" | stalwart_cli apply --stdin 2>&1); then isError "Automatic setup failed — open the admin console to finish it by hand." + local reason + reason=$(printf '%s\n' "$apply_out" | grep -oE '(Invalid|Failed|Permission)[^|]*' | head -1) + [[ -n "$reason" ]] && isNotice " Stalwart said: ${reason% }" return 1 fi