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