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>
317 lines
16 KiB
Bash
317 lines
16 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")
|
|
|
|
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()
|
|
{
|
|
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."
|
|
}
|