#!/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 }