stalwart: answer the setup wizard instead of handing it to the user

A new Stalwart drops you into a five-screen wizard — hostname, domain,
storage backend, directory, logging, DNS — before it will do anything.
LibrePortal already knows the two answers that matter and the rest have
sane defaults, so asking is asking a question we can answer ourselves.

v0.16 exposes those wizard fields as a `Bootstrap` singleton, so the whole
thing is one `update` applied through the Stalwart CLI. The CLI is not in
the server image (upstream split it into its own repo), but it publishes a
multi-arch container, so we borrow the server's network namespace and run
it there — nothing installed on the host, nothing to clean up, arm64 works.

Setup now also:

- generates DKIM keys (Ed25519 + RSA) with rotation left switched on, and
  requests a TLS certificate. That last one is easy to miss: Traefik only
  fronts the admin port, so 25/465/587/993 never see its certificate and
  clients would hit a self-signed one on 993.
- creates postmaster@<domain>. The generated zone points DMARC and TLS-RPT
  reports there and nothing was creating it, so those reports bounced.
- prints the record set read back from the server rather than composed
  here, so it includes the real DKIM public keys, MTA-STS, TLS-RPT and the
  SRV records clients autoconfigure from. This hook used to tell the user
  to go and fetch DKIM themselves; by that point the keys exist.

Optionally hands DNS to a provider API (Cloudflare/DigitalOcean/DeSEC),
which keeps the whole record set in sync and makes DKIM rotation safe to
leave on. Off by default: the token can write to your zone and lives in
the mail server's database.

Re-running is safe — provisioning is skipped once config.json exists, and
the plans use upsert so they reconcile rather than duplicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-18 05:05:03 +01:00
parent 0eda3104c7
commit 7ed1cddd5c
3 changed files with 275 additions and 22 deletions

View File

@ -17,6 +17,175 @@ stalwart_install_message_data()
printf '%s %s' "${CFG_STALWART_ADMIN_USER:-admin}" "${CFG_STALWART_ADMIN_PASSWORD:-}" 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")
if ! printf '%s\n' "$plan" | stalwart_cli apply --stdin >/dev/null 2>&1; then
isError "Automatic setup failed — open the admin console to finish it by hand."
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() stalwart_install_post_start()
{ {
local app_name="$1" local app_name="$1"
@ -34,7 +203,10 @@ stalwart_install_post_start()
admin_pair=$(tagsManagerGetTagContent "$compose_file" "PORTS_TAG_1" 2>/dev/null) admin_pair=$(tagsManagerGetTagContent "$compose_file" "PORTS_TAG_1" 2>/dev/null)
admin_port="${admin_pair%%:*}" admin_port="${admin_pair%%:*}"
# ---- 1. Can this host even send mail? -------------------------------- # ---- 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 # 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, # unblock on request). A blocked port 25 means no mail EVER leaves the box,
# and nothing in the WebUI would otherwise reveal it. # and nothing in the WebUI would otherwise reveal it.
@ -48,7 +220,7 @@ stalwart_install_post_start()
isNotice " outbound 25, or mail will queue and never deliver." isNotice " outbound 25, or mail will queue and never deliver."
fi fi
# ---- 2. Reverse DNS -------------------------------------------------- # ---- 3. Reverse DNS --------------------------------------------------
# Receiving servers check that the sending IP resolves back to a name. A # 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 # 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. # for mail being junked, and it can only be fixed in the provider's panel.
@ -62,7 +234,7 @@ stalwart_install_post_start()
fi fi
fi fi
# ---- 3. Is the admin console actually there? ------------------------- # ---- 4. Is the admin console actually there? -------------------------
# Stalwart v0.16 does not ship the WebUI inside the Docker image: the admin # 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 # 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 # start. If this host had no outbound HTTPS at that moment the download
@ -87,29 +259,56 @@ stalwart_install_post_start()
isSuccessful "Admin console is being served (HTTP $admin_code)." isSuccessful "Admin console is being served (HTTP $admin_code)."
fi fi
# ---- 4. The records the admin must add themselves -------------------- # ---- 5. The records that still have to be published -------------------
# Printed with real values so they can be pasted at the registrar. DKIM is # Read back from the server rather than composed here. Stalwart keeps the
# deliberately NOT guessed here: Stalwart generates the keypair on first # domain's full record set in `dnsZoneFile`, so this prints what it actually
# run, and the public key must be copied from its admin UI. # 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_host="${host_setup:-your-mail-hostname}"
local mail_domain="${mail_host#*.}" 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 "" 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:" isNotice "DNS records to add at your domain registrar:"
echo " MX @ 10 ${mail_host}" echo " MX @ 10 ${mail_host}"
echo " A ${mail_host} ${public_ip_v4:-<the IP of this server>}" echo " A ${mail_host} ${public_ip_v4:-<the IP of this server>}"
echo " TXT @ \"v=spf1 mx -all\"" echo " TXT @ \"v=spf1 mx -all\""
echo " TXT _dmarc \"v=DMARC1; p=quarantine; rua=mailto:postmaster@${mail_domain}\"" echo " TXT _dmarc \"v=DMARC1; p=quarantine; rua=mailto:postmaster@${mail_domain}\""
echo " TXT <selector>._domainkey (DKIM — copy from Stalwart's admin UI once it has" isNotice " DKIM records are shown in the admin console under your domain."
echo " generated the key; it is not known until then)" fi
echo "" echo ""
fi
# ---- 5. Where to go next --------------------------------------------- # ---- 6. Where to go next ---------------------------------------------
if [[ -n "$admin_port" ]]; then if [[ -n "$admin_port" ]]; then
isNotice "Finish setup in the admin interface:" isNotice "Admin console (server settings, domains, queue, reports):"
[[ -n "$public_ip_v4" ]] && echo " http://$public_ip_v4:$admin_port/" [[ -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 "" echo ""
isNotice "Sign in as '${CFG_STALWART_ADMIN_USER:-admin}' with the password shown below," isNotice "Sign in as '${CFG_STALWART_ADMIN_USER:-admin}' with the password shown below."
isNotice "then add your domain and create mailboxes."
fi fi
isNotice "Until MX, PTR, SPF, DKIM and DMARC are all in place, expect delivery" isNotice "Until MX, PTR, SPF, DKIM and DMARC are all in place, expect delivery"

View File

@ -98,3 +98,47 @@ CFG_STALWART_PORT_9="stalwart-service|jmap|443:443|disabled|tcp|false|false|fals
CFG_STALWART_AUTH_PROFILE=user_password CFG_STALWART_AUTH_PROFILE=user_password
CFG_STALWART_ADMIN_USER=admin CFG_STALWART_ADMIN_USER=admin
CFG_STALWART_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1 CFG_STALWART_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1
#
# =============================================================================
# AUTOMATIC SETUP
# =============================================================================
# Out of the box Stalwart opens a five-screen setup wizard on first sign-in and
# waits for a human. LibrePortal already knows the answers that matter — the
# hostname and the domain — so the installer fills the wizard in instead, and
# the user's first sight of Stalwart is a configured mail server.
#
# CLI_VERSION = the version of the Stalwart CLI used to apply that setup.
# Pinned, not ':latest', for the same reason the server image is: this writes
# configuration, so an unattended jump to a new CLI is not something to
# discover during an install. The CLI is a separate upstream project from the
# server and is NOT included in the server image, which is why it is a
# container of its own rather than something bundled.
CFG_STALWART_CLI_VERSION=1.0.12
#
# FIRST_MAILBOX = local part of one real mailbox created during setup.
# Setup leaves you with admin@<domain>, which is an administrator account
# rather than somewhere to read mail. 'postmaster' is the default for a
# concrete reason: the generated zone file points DMARC and TLS-RPT reports at
# postmaster@<domain>, and if nothing creates it those reports bounce.
# Set empty to skip creating it.
CFG_STALWART_FIRST_MAILBOX=postmaster
CFG_STALWART_FIRST_MAILBOX_PASSWORD=RANDOMIZEDPASSWORD2
#
# DNS_PROVIDER = 'manual', or a provider name to let Stalwart publish DNS itself.
# On 'manual' the installer prints the full record set — including the real
# DKIM public keys — for you to paste at your registrar.
# Given a provider, Stalwart writes MX, SPF, DKIM, DMARC, MTA-STS, TLS-RPT,
# SRV and CAA into the zone through the provider's API and keeps them in sync.
# That also makes DKIM key rotation safe to leave switched on, since the server
# republishes the records itself — hand-managed DKIM keys are, in practice,
# keys nobody ever rotates.
#
# Wired up here: Cloudflare, DigitalOcean, DeSEC (token-only providers).
# Route 53 and Google Cloud DNS need more than a token — set those up in the
# admin console instead.
#
# The trade: the token is stored in the mail server's database and can write to
# your zone, so compromising this box means compromising your DNS. Scope the
# token to this one zone if your provider allows it.
CFG_STALWART_DNS_PROVIDER=manual
CFG_STALWART_DNS_API_TOKEN=

View File

@ -862,8 +862,13 @@ declare -gA LP_FN_MAP=(
[showInstructions]="menu/message/instructions.sh" [showInstructions]="menu/message/instructions.sh"
[sourceBackupLocations]="backup/locations/location_loader.sh" [sourceBackupLocations]="backup/locations/location_loader.sh"
[sshRemote]="network/ssh/ssh.sh" [sshRemote]="network/ssh/ssh.sh"
[stalwart_cli]="stalwart/scripts/stalwart_install_hooks.sh"
[stalwart_install_dns_provider]="stalwart/scripts/stalwart_install_hooks.sh"
[stalwart_install_first_mailbox]="stalwart/scripts/stalwart_install_hooks.sh"
[stalwart_install_message_data]="stalwart/scripts/stalwart_install_hooks.sh" [stalwart_install_message_data]="stalwart/scripts/stalwart_install_hooks.sh"
[stalwart_install_post_start]="stalwart/scripts/stalwart_install_hooks.sh" [stalwart_install_post_start]="stalwart/scripts/stalwart_install_hooks.sh"
[stalwart_install_provision]="stalwart/scripts/stalwart_install_hooks.sh"
[stalwart_wait_http]="stalwart/scripts/stalwart_install_hooks.sh"
[stalwart_upgrade_admin_ui_code]="stalwart/scripts/stalwart_upgrade_hooks.sh" [stalwart_upgrade_admin_ui_code]="stalwart/scripts/stalwart_upgrade_hooks.sh"
[stalwart_upgrade_check_admin_ui]="stalwart/scripts/stalwart_upgrade_hooks.sh" [stalwart_upgrade_check_admin_ui]="stalwart/scripts/stalwart_upgrade_hooks.sh"
[stalwart_upgrade_verify]="stalwart/scripts/stalwart_upgrade_hooks.sh" [stalwart_upgrade_verify]="stalwart/scripts/stalwart_upgrade_hooks.sh"
@ -1900,8 +1905,13 @@ declare -gA LP_FN_ROOT=(
[showInstructions]="scripts" [showInstructions]="scripts"
[sourceBackupLocations]="scripts" [sourceBackupLocations]="scripts"
[sshRemote]="scripts" [sshRemote]="scripts"
[stalwart_cli]="containers"
[stalwart_install_dns_provider]="containers"
[stalwart_install_first_mailbox]="containers"
[stalwart_install_message_data]="containers" [stalwart_install_message_data]="containers"
[stalwart_install_post_start]="containers" [stalwart_install_post_start]="containers"
[stalwart_install_provision]="containers"
[stalwart_wait_http]="containers"
[stalwart_upgrade_admin_ui_code]="containers" [stalwart_upgrade_admin_ui_code]="containers"
[stalwart_upgrade_check_admin_ui]="containers" [stalwart_upgrade_check_admin_ui]="containers"
[stalwart_upgrade_verify]="containers" [stalwart_upgrade_verify]="containers"