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>
151 lines
7.5 KiB
Bash
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
|
|
}
|