LibrePortal/containers/stalwart/scripts/stalwart_install_hooks.sh
librelad 63f276523b stalwart: run as container-root so it can write its own data directory
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 <noreply@anthropic.com>
2026-08-18 05:14:19 +01:00

325 lines
17 KiB
Bash

#!/bin/bash
# Stalwart install hooks.
#
# Installing a mail server is not like installing any other app: the container
# starting successfully means almost nothing. Mail only works once DNS, reverse
# DNS and outbound port 25 are right, and every one of those lives OUTSIDE the
# box — at the registrar and the VPS provider. So the job of these hooks is to
# say plainly what still has to be done, with the actual values to enter, rather
# than reporting "installed" and leaving the admin to discover weeks later that
# their mail is landing in spam.
# Echo the admin credentials for the standard final-message block (word-split
# by the caller into positional args: user pass).
stalwart_install_message_data()
{
printf '%s %s' "${CFG_STALWART_ADMIN_USER:-admin}" "${CFG_STALWART_ADMIN_PASSWORD:-}"
}
# Run the Stalwart CLI against our own container.
#
# The CLI is deliberately NOT in the server image — upstream split it into its
# own repository so it ships and versions separately. It does publish a
# multi-arch container, which suits us better than a host binary: nothing to
# install, nothing to clean up on uninstall, and arm64 works for Pi installs.
#
# `--network container:stalwart-service` borrows the server's network namespace,
# so the CLI reaches it on localhost:8080 without us having to resolve the
# LibrePortal network name or expose the admin port to get at it.
stalwart_cli()
{
runFileOp docker run --rm -i --network "container:stalwart-service" \
-e STALWART_URL="http://localhost:8080" \
-e STALWART_USER="${CFG_STALWART_ADMIN_USER:-admin}" \
-e STALWART_PASSWORD="${CFG_STALWART_ADMIN_PASSWORD:-}" \
"ghcr.io/stalwartlabs/cli:${CFG_STALWART_CLI_VERSION:-1.0.12}" --no-color "$@"
}
# Wait for the admin HTTP listener. Used twice: once for the bootstrap listener
# before we configure anything, once for the real one after the restart.
stalwart_wait_http()
{
local probe="$1" tries="${2:-40}" i code
for ((i = 0; i < tries; i++)); do
code=$(runFileOp docker exec stalwart-service curl -fsS -o /dev/null -w '%{http_code}' \
--max-time 3 "http://localhost:8080/healthz/$probe" 2>/dev/null | tr -d '\r')
[[ "$code" == "200" ]] && return 0
sleep 2
done
return 1
}
# Fill in the first-run setup wizard instead of making the user do it.
#
# Out of the box Stalwart boots into "bootstrap mode" and waits for a human to
# answer five screens of questions in the WebUI: hostname, domain, which storage
# backend, which directory, where to log, how to handle DNS. LibrePortal already
# knows the answers to the ones that matter and the rest have sane defaults, so
# making the user answer them is asking a question we can answer ourselves.
#
# `Bootstrap` is a singleton object holding exactly those wizard fields, so the
# whole wizard is one `update`. Once it is set the server writes its config.json,
# provisions the domain, generates DKIM keys and leaves bootstrap mode — and the
# user's first sight of Stalwart is a configured mail server, not a form.
stalwart_install_provision()
{
# config.json only exists once setup has completed, which makes it the
# honest "is this already configured?" test. Re-running the installer over a
# working server must not re-answer its setup questions.
if runFileOp docker exec stalwart-service test -f /etc/stalwart/config.json 2>/dev/null; then
isNotice "Stalwart is already configured — leaving its existing settings alone."
return 0
fi
local mail_host="${host_setup:-}"
local mail_domain="${mail_host#*.}"
if [[ -z "$mail_host" || "$mail_host" != *.* ]]; then
isError "No mail hostname is configured, so Stalwart cannot be set up automatically."
isNotice " Set a domain in the general config, then open the admin console to"
isNotice " complete setup by hand."
return 1
fi
isNotice "Waiting for Stalwart's setup listener…"
if ! stalwart_wait_http live; then
isError "Stalwart did not open its setup listener — skipping automatic setup."
return 1
fi
# requestTlsCertificate matters more than it looks. Traefik only fronts the
# admin port; SMTP and IMAP (25/465/587/993) bypass it entirely, so Traefik's
# certificate never reaches a mail client. Without this Stalwart serves a
# self-signed cert on 993 and every mail client throws a warning.
#
# generateDkimKeys makes the server own DKIM: it creates an Ed25519 and an
# RSA key, publishes both, and rotates them on a schedule. Hand-managed DKIM
# keys are, in practice, keys that never get rotated.
isNotice "Configuring Stalwart for ${mail_domain}"
local plan
plan=$(printf '{"@type":"update","object":"Bootstrap","value":{"serverHostname":"%s","defaultDomain":"%s","generateDkimKeys":true,"requestTlsCertificate":true}}' \
"$mail_host" "$mail_domain")
# 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
# Leaving bootstrap mode needs a restart: the server swaps its temporary
# setup listener for the real ones (SMTP, IMAP, submission) on the way back up.
isNotice "Restarting Stalwart to bring up the mail services…"
runFileOp docker restart stalwart-service >/dev/null 2>&1
if ! stalwart_wait_http ready 60; then
isError "Stalwart did not come back up after setup — check: docker logs stalwart-service"
return 1
fi
isSuccessful "Stalwart configured: ${mail_domain} added, DKIM keys generated."
stalwart_install_dns_provider
stalwart_install_first_mailbox
return 0
}
# Optionally hand DNS to the provider's API.
#
# This is the part that turns a page of records-to-paste into nothing at all:
# Stalwart writes MX, SPF, DKIM, DMARC, MTA-STS, TLS-RPT, SRV and CAA into the
# zone itself and keeps them in sync — including republishing DKIM records when
# it rotates the keys, which is the whole reason rotation is safe to automate.
#
# Off by default. It needs an API token with write access to the zone, stored in
# the mail server's database, which widens what a compromise of this box costs.
# Scope the token to the single zone if your provider supports it.
stalwart_install_dns_provider()
{
local provider="${CFG_STALWART_DNS_PROVIDER:-manual}"
[[ "$provider" == "manual" || -z "$provider" ]] && return 0
if [[ -z "${CFG_STALWART_DNS_API_TOKEN:-}" ]]; then
isError "CFG_STALWART_DNS_PROVIDER is set to '$provider' but no API token was given."
isNotice " Falling back to manual DNS — the records are printed below."
return 0
fi
local mail_domain="${host_setup#*.}"
# Verified against Cloudflare; DigitalOcean and DeSEC take the same
# description+secret shape. Providers needing more than a token (Route 53,
# Google Cloud DNS) are not wired up here — add them in the admin console.
local plan
plan=$(printf '{"@type":"upsert","object":"DnsServer","matchOn":["description"],"value":{"dns":{"@type":"%s","description":"LibrePortal managed DNS","secret":{"@type":"Value","secret":"%s"}}}}\n{"@type":"upsert","object":"Domain","matchOn":["name"],"value":{"dom":{"name":"%s","dnsManagement":{"@type":"Automatic","dnsServerId":"#dns"}}}}' \
"$provider" "$CFG_STALWART_DNS_API_TOKEN" "$mail_domain")
if printf '%s\n' "$plan" | stalwart_cli apply --stdin >/dev/null 2>&1; then
isSuccessful "DNS records will be published and kept in sync via $provider."
else
isError "Could not enable automatic DNS via $provider — check the API token."
isNotice " Manual DNS is still in effect; the records are printed below."
fi
}
# Create one real mailbox.
#
# Setup leaves you with admin@<domain>, which is an administrator account — using
# it as a day-to-day mailbox is the wrong habit to start someone on. More
# concretely: the generated zone file points DMARC and TLS-RPT reports at
# postmaster@<domain>, and nothing creates that address, so those reports would
# bounce. Creating it fixes a real gap and gives the user a mailbox to log into.
stalwart_install_first_mailbox()
{
local mailbox="${CFG_STALWART_FIRST_MAILBOX:-postmaster}"
[[ -z "$mailbox" ]] && return 0
local mail_domain="${host_setup#*.}"
local plan
# The domain is upserted by name purely to get a reference to it — it already
# exists, so this matches rather than creates. Credentials are a map keyed by
# slot, not a list. Stalwart enforces password strength, so the password has
# to be a generated one, not a short hand-picked string.
plan=$(printf '{"@type":"upsert","object":"Domain","matchOn":["name"],"value":{"dom":{"name":"%s"}}}\n{"@type":"upsert","object":"Account","matchOn":["name"],"value":{"acc":{"@type":"User","name":"%s","domainId":"#dom","description":"First mailbox","credentials":{"0":{"@type":"Password","secret":"%s"}}}}}' \
"$mail_domain" "$mailbox" "${CFG_STALWART_FIRST_MAILBOX_PASSWORD:-}")
if printf '%s\n' "$plan" | stalwart_cli apply --stdin >/dev/null 2>&1; then
isSuccessful "Created the first mailbox: ${mailbox}@${mail_domain}"
else
isError "Could not create ${mailbox}@${mail_domain} — add it in the admin console."
fi
}
stalwart_install_post_start()
{
local app_name="$1"
((menu_number++))
echo ""
echo "---- $menu_number. Mail server checks + the DNS records you still need"
echo ""
# Resolved admin port comes from the compose tag (format `external:internal`),
# the same source adguard's hook reads — the legacy $usedport1 isn't populated
# by the current install pipeline.
local compose_file="$containers_dir$app_name/docker-compose.yml"
local admin_pair admin_port
admin_pair=$(tagsManagerGetTagContent "$compose_file" "PORTS_TAG_1" 2>/dev/null)
admin_port="${admin_pair%%:*}"
# ---- 1. Answer the setup wizard on the user's behalf ------------------
stalwart_install_provision
# ---- 2. Can this host even send mail? --------------------------------
# Most cheap VPS providers block outbound 25 by default (and several only
# unblock on request). A blocked port 25 means no mail EVER leaves the box,
# and nothing in the WebUI would otherwise reveal it.
isNotice "Checking outbound port 25 (required to deliver mail to other servers)…"
if command -v timeout >/dev/null 2>&1 \
&& timeout 8 bash -c 'exec 3<>/dev/tcp/gmail-smtp-in.l.google.com/25' 2>/dev/null; then
isSuccessful "Outbound port 25 is open."
else
isError "Outbound port 25 appears BLOCKED or filtered on this host."
isNotice " Most VPS providers block it by default. Ask your provider to unblock"
isNotice " outbound 25, or mail will queue and never deliver."
fi
# ---- 3. Reverse DNS --------------------------------------------------
# Receiving servers check that the sending IP resolves back to a name. A
# generic provider PTR (e.g. static.1.2.3.4.provider.net) is a common reason
# for mail being junked, and it can only be fixed in the provider's panel.
if [[ -n "$public_ip_v4" ]] && command -v dig >/dev/null 2>&1; then
local ptr; ptr=$(dig +short -x "$public_ip_v4" 2>/dev/null | head -1)
if [[ -n "$ptr" ]]; then
isNotice "Reverse DNS (PTR) for $public_ip_v4 is: ${ptr%.}"
isNotice " It should match your mail hostname. Set it in your VPS provider's panel."
else
isError "No reverse DNS (PTR) record for $public_ip_v4 — set one at your VPS provider."
fi
fi
# ---- 4. Is the admin console actually there? -------------------------
# Stalwart v0.16 does not ship the WebUI inside the Docker image: the admin
# console is a single-page app the server fetches from GitHub on first
# start. If this host had no outbound HTTPS at that moment the download
# silently fails, the server still comes up healthy, and /admin and
# /account answer 404 forever with nothing to explain why. Checking it here
# turns "the panel is broken" into a one-line, fixable cause.
isNotice "Checking the admin console (WebUI) responds…"
local admin_code
admin_code=$(runFileOp docker exec stalwart-service curl -fsS -o /dev/null -w '%{http_code}' \
--max-time 5 http://localhost:8080/admin 2>/dev/null | tr -d '\r')
if [[ "$admin_code" == "404" ]]; then
isError "The admin console is missing (/admin returns 404)."
isNotice " Stalwart does not bundle the WebUI — it downloads it from"
isNotice " https://github.com/stalwartlabs/webui/releases/latest on first start."
isNotice " That download failed, so /admin and /account will 404 until it succeeds."
isNotice " Allow outbound HTTPS to github.com from this host, then restart the"
isNotice " container: docker restart stalwart-service"
isNotice " The mail server itself is unaffected — only the web interface is."
elif [[ -z "$admin_code" ]]; then
isError "Could not probe the admin console (no response from the container)."
else
isSuccessful "Admin console is being served (HTTP $admin_code)."
fi
# ---- 5. The records that still have to be published -------------------
# Read back from the server rather than composed here. Stalwart keeps the
# domain's full record set in `dnsZoneFile`, so this prints what it actually
# expects — MX, SPF, DMARC, MTA-STS, TLS-RPT, the SRV records clients use to
# autoconfigure, and crucially the real DKIM public keys. The old version of
# this hook had to tell the user to go and fetch DKIM themselves; by this
# point the keys exist, so there is nothing left to look up.
local mail_host="${host_setup:-your-mail-hostname}"
local mail_domain="${mail_host#*.}"
if [[ "${CFG_STALWART_DNS_PROVIDER:-manual}" != "manual" && -n "${CFG_STALWART_DNS_API_TOKEN:-}" ]]; then
isNotice "DNS is managed automatically — no records to add by hand."
isNotice " Still set at your provider, because they are not in the zone:"
echo " A ${mail_host} ${public_ip_v4:-<the IP of this server>}"
echo " PTR ${public_ip_v4:-<the IP of this server>} ${mail_host}"
else
local domain_id zone
domain_id=$(stalwart_cli query Domain 2>/dev/null | awk 'NR==2{print $1}')
if [[ -n "$domain_id" ]]; then
zone=$(stalwart_cli get Domain "$domain_id" 2>/dev/null \
| sed -n '/DNS Zone File:/,$p' | sed 's/^ *DNS Zone File: *//')
fi
echo ""
if [[ -n "$zone" ]]; then
isNotice "DNS records to add at your domain registrar:"
echo " A ${mail_host} ${public_ip_v4:-<the IP of this server>}"
echo ""
printf '%s\n' "$zone" | sed 's/^/ /'
else
# Setup did not complete, so there is no zone to read. Say what is
# needed rather than printing nothing.
isNotice "DNS records to add at your domain registrar:"
echo " MX @ 10 ${mail_host}"
echo " A ${mail_host} ${public_ip_v4:-<the IP of this server>}"
echo " TXT @ \"v=spf1 mx -all\""
echo " TXT _dmarc \"v=DMARC1; p=quarantine; rua=mailto:postmaster@${mail_domain}\""
isNotice " DKIM records are shown in the admin console under your domain."
fi
echo ""
fi
# ---- 6. Where to go next ---------------------------------------------
if [[ -n "$admin_port" ]]; then
isNotice "Admin console (server settings, domains, queue, reports):"
[[ -n "$public_ip_v4" ]] && echo " http://$public_ip_v4:$admin_port/admin"
isNotice "Mailbox settings for everyday users (password, filters, autoresponder):"
[[ -n "$public_ip_v4" ]] && echo " http://$public_ip_v4:$admin_port/account"
echo ""
isNotice "Sign in as '${CFG_STALWART_ADMIN_USER:-admin}' with the password shown below."
fi
isNotice "Until MX, PTR, SPF, DKIM and DMARC are all in place, expect delivery"
isNotice "problems — that is normal for a new mail server, not a fault in the app."
}