The private direction of the mode switch is exercised end to end. The public one has only ever run against a throwaway .test domain, where Let's Encrypt rejects the contact address before the provider is created — so everything past that call is reasoned rather than observed. The plan shape IS confirmed up to that point: contact is a set, matchOn is the directory URL, and a domain cannot reference automatic certificate management without an acmeProviderId. What is unproven is the link holding once the provider actually exists. Saying so in the file beats leaving it in a chat log nobody reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
8.1 KiB
Bash
159 lines
8.1 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".
|
|
#
|
|
# NOT FULLY VERIFIED. The private direction below is exercised end to
|
|
# end, but this one has only ever been run against a throwaway .test
|
|
# domain, where Let's Encrypt refuses the contact address before the
|
|
# provider is created — so the AcmeProvider -> Domain link past that
|
|
# point is reasoned, not observed. The plan shape is confirmed up to
|
|
# the LE call (contact is a set, matchOn is the directory URL, an
|
|
# acmeProviderId is required). Worth one run on a real domain.
|
|
#
|
|
# 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
|
|
}
|