A mail server is two quite different products wearing one name, and until
now LibrePortal only offered the hard one. Installing Stalwart meant being
handed a wall of DNS records, a red error about port 25 and a warning about
reverse DNS — all of it correct, none of it fixable by the installer, and
most of it irrelevant to someone who wanted mailboxes and a shared calendar
on their own network.
CFG_STALWART_MODE now names which one you are running:
private mailboxes, IMAP, CalDAV and CardDAV on your own network. Port 25
is not published at all; the client ports stay bound to the host
but are never opened through the firewall. No MX, no PTR, no
deliverability. Nothing to publish, so nothing is printed.
public the internet mail server, as before.
auto public if Traefik is installed, private if not, resolved at
install and written back so it reads as a real answer afterwards.
DKIM keys are generated in both modes even though private has no use for
them today — that is what makes switching later a setting change rather
than a key ceremony. The WebUI gets a "Mail Exposure" tool that flips the
setting both ways and reconfigures the server, plus a "Show DNS Records"
tool that prints the live zone including current DKIM keys.
Two things this had to get right, both found by testing rather than
reading. Port access lives in the shell as CFG_<APP>_PORT_n, not just in
the config file, and the compose file is built from the parsed shell
values — editing only the file left the config claiming port 25 was
disabled while the container published it anyway. And going public needs
an AcmeProvider to exist before a domain can reference one, so the switch
creates it; note that doing so registers an account with Let's Encrypt.
Verified through real installs: auto resolves to private with no Traefik,
port 25 is genuinely unpublished and absent from the compose file, the
client ports are skipped by the firewall as host-bound, and the tool
round-trips private -> public -> private with the config landing back
exactly where it started.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
6.4 KiB
Bash
130 lines
6.4 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. The firewall --------------------------------------------------
|
|
if declare -F firewallRebuildFromDatabase >/dev/null 2>&1; then
|
|
firewallRebuildFromDatabase >/dev/null 2>&1 \
|
|
&& isSuccessful "Firewall rules rebuilt."
|
|
fi
|
|
|
|
# ---- 4. What is left ---------------------------------------------------
|
|
# Port publishing lives in the compose file, which is written from the app
|
|
# config at install time. Saying this plainly beats letting someone believe
|
|
# port 25 closed when it is still bound.
|
|
echo ""
|
|
isNotice "One step left: reinstall the app so the port changes reach the container."
|
|
isNotice " libreportal app install stalwart"
|
|
isNotice " Your mail, mailboxes and DKIM keys are untouched by this."
|
|
|
|
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
|
|
}
|