LibrePortal/containers/stalwart/tools/stalwart_set_mode.sh
librelad af78ce1681 stalwart: make the mode switch finish the job itself
Switching between private and public wrote the setting, reconfigured the
server and then asked the user to run `libreportal app install stalwart`
to make the ports actually change. That left a window where the WebUI
reported public while port 25 was still closed — or worse, reported
private while 25 was still open and listening. A mode switch that does not
move the ports is not a mode switch.

The tool now runs the install itself. Safe from here: tools are dispatched
inline rather than as their own task, so this is not a nested task and
cannot deadlock on the task lock, and nothing in Stalwart's install hooks
calls back into the tool. Provisioning inside that install is a no-op
because it skips once config.json exists.

Dropped the separate firewall rebuild — the install reallocates the ports
and rebuilds the rules from the result, so doing it beforehand only worked
from the old allocation and was then immediately redone.

Verified both directions on a real install: private -> public publishes 25,
public -> private removes it, the admin port keeps its existing random
allocation across both (no --reset-network, so bookmarked WebUI links do
not move), mailboxes survive with their original creation timestamps, and
re-selecting the current mode is a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:03:23 +01:00

151 lines
7.5 KiB
Bash

#!/bin/bash
# Switch Stalwart between a private mail + calendar server and a real internet
# mail server, in either direction.
#
# The two ends of the switch are genuinely different jobs, and each half is
# applied by whichever layer owns it:
#
# the app config — which mail ports are exposed, and how. Read when the
# compose file is regenerated, so it needs a reinstall to
# reach the running container.
# Stalwart itself — whether it should hold a publicly-trusted certificate and
# publish DNS. Applied live over the management API.
#
# Going public is additive and safe to repeat. Going private deliberately does
# NOT destroy anything: DKIM keys stay, the domain stays, mailboxes stay. It
# closes the doors, it does not burn the house down — which is what makes
# flipping back a setting change rather than a rebuild.
appStalwartSetMode() {
local args="$1"
local mode
mode="$(authToolArg "$args" mode)"
if [[ "$mode" != "private" && "$mode" != "public" ]]; then
isError "Mode must be 'private' or 'public'."
return 1
fi
local cfg="${containers_dir}stalwart/stalwart.config"
if [[ ! -f "$cfg" ]]; then
isError "Stalwart does not look installed — no config at $cfg"
return 1
fi
local current
current=$(runFileOp grep -oE '^CFG_STALWART_MODE=\S*' "$cfg" 2>/dev/null | cut -d= -f2)
if [[ "$current" == "$mode" ]]; then
isNotice "Stalwart is already set to '$mode' — nothing to change."
return 0
fi
# ---- 1. The setting itself -------------------------------------------
runFileOp sed -i -E "s#^CFG_STALWART_MODE=.*#CFG_STALWART_MODE=${mode}#" "$cfg" 2>/dev/null
stalwart_apply_port_access "$cfg" "$mode"
isSuccessful "Mail exposure set to '$mode'."
# ---- 2. What Stalwart itself has to be told --------------------------
# Certificate management is the part that actually differs at runtime. A
# private server has no public name to validate, so asking for a certificate
# only produces a failing renewal loop; a public one needs the opposite.
# Ask the server which domain it serves rather than reconstructing it from
# install-time variables — $host_setup is populated during an install and is
# simply absent when a tool runs on its own.
local domain_id plan
domain_id=$(stalwart_cli query Domain 2>/dev/null | awk 'NR==2{print $1}')
if [[ -z "$domain_id" ]]; then
isNotice "Could not reach Stalwart to reconfigure it — the setting is saved and"
isNotice " will be applied when the app is reinstalled below."
else
if [[ "$mode" == "private" ]]; then
# Also drop automatic DNS: with no public role there is nothing to
# keep in sync, and leaving a zone-write token live is blast radius
# bought for nothing.
plan=$(printf '{"@type":"update","object":"Domain","id":"%s","value":{"certificateManagement":{"@type":"Manual"},"dnsManagement":{"@type":"Manual"}}}' \
"$domain_id")
else
# Automatic certificates need an ACME provider to point at, and a
# server that was private has never had one — so create it here
# rather than failing with "ACME provider not found".
#
# Two things about this are worth knowing. Creating the provider
# REGISTERS AN ACCOUNT with Let's Encrypt there and then, so it is a
# real outbound action, not a local setting. And the challenge type
# is not a free choice: TLS-ALPN-01 wants port 443 and HTTP-01 wants
# 80, both of which Traefik holds on a LibrePortal box, so DNS-01 is
# the only one that can succeed once DNS automation is available.
local challenge="TlsAlpn01"
[[ "${CFG_STALWART_DNS_PROVIDER:-manual}" != "manual" && -n "${CFG_STALWART_DNS_API_TOKEN:-}" ]] \
&& challenge="Dns01"
local domain_name contact
domain_name=$(stalwart_cli query Domain 2>/dev/null | awk 'NR==2{print $2}')
contact="postmaster@${domain_name}"
# `contact` is a set, so it goes as a map of value->true, not a list.
plan=$(printf '{"@type":"upsert","object":"AcmeProvider","matchOn":["directory"],"value":{"acme":{"directory":"https://acme-v02.api.letsencrypt.org/directory","contact":{"%s":true},"challengeType":"%s"}}}\n{"@type":"update","object":"Domain","id":"%s","value":{"certificateManagement":{"@type":"Automatic","acmeProviderId":"#acme"}}}' \
"$contact" "$challenge" "$domain_id")
if [[ "$challenge" == "TlsAlpn01" && -d "${containers_dir}traefik" ]]; then
isNotice "Traefik holds ports 80 and 443, so this certificate request will not"
isNotice " validate. Set CFG_STALWART_DNS_PROVIDER and a token to validate over"
isNotice " DNS instead — that is the only route that works alongside Traefik."
fi
fi
if printf '%s\n' "$plan" | stalwart_cli apply --stdin >/dev/null 2>&1; then
isSuccessful "Stalwart reconfigured for $mode operation."
else
isNotice "Stalwart is running but rejected the change — check it in the admin console."
fi
fi
# ---- 3. Make the port change real --------------------------------------
# No separate firewall rebuild here: the install below reallocates ports and
# rebuilds the rules from the result. Rebuilding first would only work from
# the old allocation and then be immediately redone.
#
# Port publishing lives in the compose file, which is only written from the
# app config during an install — so without this the setting would be saved,
# the WebUI would show the new mode, and the container would carry on
# publishing exactly the ports it did before. A mode switch that leaves port
# 25 open is worse than no mode switch at all.
#
# Safe to call from here: tools are dispatched inline rather than as their
# own task, so this is not a nested task and cannot deadlock on the task
# lock. It also cannot recurse — the install path calls Stalwart's install
# hooks, and none of them call back into this tool. Provisioning inside that
# install is a no-op too, since it skips once config.json exists.
#
# No --reset-network: the admin port keeps its existing random allocation,
# so the WebUI link people have bookmarked does not move underneath them.
echo ""
isNotice "Applying the port changes (reinstalling the app — mail and mailboxes are kept)…"
if ! declare -F dockerInstallApp >/dev/null 2>&1; then
isError "Cannot reinstall automatically from here."
isNotice " Run it yourself to finish the switch: libreportal app install stalwart"
return 1
fi
if dockerInstallApp "stalwart" "" "false"; then
isSuccessful "Stalwart is now running in '$mode' mode."
else
isError "The reinstall did not complete — the setting is saved, but the ports"
isNotice " have not changed yet. Retry with: libreportal app install stalwart"
return 1
fi
if [[ "$mode" == "public" ]]; then
echo ""
isNotice "Going public also needs, outside this box:"
isNotice " • outbound AND inbound port 25 (many providers block outbound by default)"
isNotice " • a reverse DNS (PTR) record matching ${host_setup:-your mail hostname}"
isNotice " • the DNS records — run the 'Show DNS Records' tool to list them"
fi
return 0
}