LibrePortal/containers/stalwart/tools/stalwart_set_mode.sh
librelad e14e295f3f stalwart: record that the public-side ACME path is not fully verified
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>
2026-08-19 00:23:45 +01:00

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
}