LibrePortal/containers/stalwart/tools/stalwart_set_mode.sh
librelad 8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +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="$(appDir 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 "$(appDir 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
}