From 7ed1cddd5ca27e23cb5904184c5735e700dd2564 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 18 Aug 2026 05:05:03 +0100 Subject: [PATCH] stalwart: answer the setup wizard instead of handing it to the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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@. 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 --- .../scripts/stalwart_install_hooks.sh | 243 ++++++++++++++++-- containers/stalwart/stalwart.config | 44 ++++ .../source/files/arrays/function_manifest.sh | 10 + 3 files changed, 275 insertions(+), 22 deletions(-) diff --git a/containers/stalwart/scripts/stalwart_install_hooks.sh b/containers/stalwart/scripts/stalwart_install_hooks.sh index 93c5243..8ebbfb7 100644 --- a/containers/stalwart/scripts/stalwart_install_hooks.sh +++ b/containers/stalwart/scripts/stalwart_install_hooks.sh @@ -17,6 +17,175 @@ 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") + + 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@, 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@, 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" @@ -34,7 +203,10 @@ stalwart_install_post_start() admin_pair=$(tagsManagerGetTagContent "$compose_file" "PORTS_TAG_1" 2>/dev/null) 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 # unblock on request). A blocked port 25 means no mail EVER leaves the box, # 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." fi - # ---- 2. Reverse DNS -------------------------------------------------- + # ---- 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. @@ -62,7 +234,7 @@ stalwart_install_post_start() 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 # 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 @@ -87,29 +259,56 @@ stalwart_install_post_start() isSuccessful "Admin console is being served (HTTP $admin_code)." fi - # ---- 4. The records the admin must add themselves -------------------- - # Printed with real values so they can be pasted at the registrar. DKIM is - # deliberately NOT guessed here: Stalwart generates the keypair on first - # run, and the public key must be copied from its admin UI. + # ---- 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#*.}" - echo "" - isNotice "DNS records to add at your domain registrar:" - echo " MX @ 10 ${mail_host}" - echo " A ${mail_host} ${public_ip_v4:-}" - echo " TXT @ \"v=spf1 mx -all\"" - echo " TXT _dmarc \"v=DMARC1; p=quarantine; rua=mailto:postmaster@${mail_domain}\"" - echo " TXT ._domainkey (DKIM — copy from Stalwart's admin UI once it has" - echo " generated the key; it is not known until then)" - echo "" - # ---- 5. Where to go next --------------------------------------------- - if [[ -n "$admin_port" ]]; then - isNotice "Finish setup in the admin interface:" - [[ -n "$public_ip_v4" ]] && echo " http://$public_ip_v4:$admin_port/" + 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:-}" + echo " PTR ${public_ip_v4:-} ${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 "" - isNotice "Sign in as '${CFG_STALWART_ADMIN_USER:-admin}' with the password shown below," - isNotice "then add your domain and create mailboxes." + if [[ -n "$zone" ]]; then + isNotice "DNS records to add at your domain registrar:" + echo " A ${mail_host} ${public_ip_v4:-}" + 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:-}" + 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" diff --git a/containers/stalwart/stalwart.config b/containers/stalwart/stalwart.config index d876ce5..77d8b96 100644 --- a/containers/stalwart/stalwart.config +++ b/containers/stalwart/stalwart.config @@ -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_ADMIN_USER=admin 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@, 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@, 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= diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index c0ca018..d5f4dec 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -862,8 +862,13 @@ declare -gA LP_FN_MAP=( [showInstructions]="menu/message/instructions.sh" [sourceBackupLocations]="backup/locations/location_loader.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_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_check_admin_ui]="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" [sourceBackupLocations]="scripts" [sshRemote]="scripts" + [stalwart_cli]="containers" + [stalwart_install_dns_provider]="containers" + [stalwart_install_first_mailbox]="containers" [stalwart_install_message_data]="containers" [stalwart_install_post_start]="containers" + [stalwart_install_provision]="containers" + [stalwart_wait_http]="containers" [stalwart_upgrade_admin_ui_code]="containers" [stalwart_upgrade_check_admin_ui]="containers" [stalwart_upgrade_verify]="containers"