#!/usr/bin/env bash # O.OPEN selective SRS installer - REVIEW DRAFT 0.17.1 # This script is not yet approved for production execution. # Reference: mx.warenform.de, Debian 13, PostSRSd 2.4.1. # Install != activate: the main Postfix configuration is never modified here. # Virtual alias bounce integration is tracked via a main.cf ownership marker. # Persistent v1 state.conf remains immutable, including during live adoption. set -o pipefail working_dir="$(dirname "$(realpath "$0")")" # shellcheck source=helpers/srs-common.sh source "${working_dir}/helpers/srs-common.sh" POSTSRSD_REPOSITORY='https://github.com/roehling/postsrsd.git' POSTSRSD_VERSION='2.4.1' POSTSRSD_LINK='/usr/local/postsrsd' POSTSRSD_SOURCE_BASE='/usr/local/src/postsrsd' SRS_TEMPLATE_DIR="${working_dir}/files/srs/templates" SRS_PYTHON_SOURCE_DIR="${working_dir}/files/srs" SRS_DNS_CHECKER="${working_dir}/helpers/srs-dns-check.py" SRS_BACKUP_BASE='/root/srs-installer-backups' INSTALL_MODE='' BUILD_REQUIRED=true LINK_SWITCHED=false SRS_ENABLED=false STAGE_DIR='' BACKUP_DIR='' CHANGES_STARTED=false PREVIOUS_LINK='' PREVIOUS_LINK_RAW='' NEW_PREFIX='' ROLLBACK_FAILED=false NEW_PREFIX_INSTALLED=false POSTFIX_MAIL_UID='' POSTFIX_MAIL_GID='' # Existing runtime directories whose ownership is corrected are tracked so # a later failed maintenance update can restore their original ownership. declare -a MODIFIED_POSTFIX_RUNTIME_DIRS=() declare -a ORIGINAL_POSTFIX_RUNTIME_OWNERS=() # New configuration directories are tracked separately from their files. declare -a CREATED_CONFIG_DIRS=() # Postfix check can create queue/data trees. Never recursively delete them # during rollback: nonempty directories could contain mail or logs. declare -a NEW_POSTFIX_RUNTIME_DIRS=() SELECTED_SRS_DOMAIN='' SELECTED_MX_HOST='' CONFIG_DOMAIN_HINT='' CONFIG_MX_HINT='' CMAKE_PACKAGE_REQUIRED=false APT_CMAKE_SIMULATION='' declare -a MODIFIED_PATHS=() declare -a PATH_WAS_PRESENT=() # Postfix needs a root-owned queue base, but its data_directory must be # writable by mail_owner (normally postfix). Postfix check can succeed even # when data_directory has the wrong owner; postfix master then fails to create # master.lock. Never recursively chown a directory or modify queue contents. # Existing directory ownership changes are tracked for rollback. ensure_postfix_runtime_directory() { local path="$1" owner_uid="$2" owner_gid="$3" parent="${1%/*}" local current_uid current_gid current_mode [[ -d "$parent" && ! -L "$parent" ]] || fatal_rollback "Missing or unsafe Postfix runtime parent: ${parent}" if [[ -L "$path" ]]; then fatal_rollback "Refusing symlinked Postfix runtime directory: ${path}" elif [[ -d "$path" ]]; then current_uid="$(stat -c '%u' "$path")" || fatal_rollback "Cannot stat ${path}" current_gid="$(stat -c '%g' "$path")" || fatal_rollback "Cannot stat ${path}" current_mode="$(stat -c '%a' "$path")" || fatal_rollback "Cannot stat ${path}" if [[ "${current_uid}:${current_gid}" == "${owner_uid}:${owner_gid}" ]]; then return 0 fi # Only the known, previous-installer error (root-owned data_directory) # is repaired automatically. Do not seize directories owned by others. [[ "$path" == /var/lib/postfix-srs-delivery && "${current_uid}:${current_gid}" == '0:0' ]] || fatal_rollback "Unexpected owner of existing Postfix runtime directory: ${path}" MODIFIED_POSTFIX_RUNTIME_DIRS+=("$path") ORIGINAL_POSTFIX_RUNTIME_OWNERS+=("${current_uid}:${current_gid}") chown "${owner_uid}:${owner_gid}" -- "$path" || fatal_rollback "Cannot correct Postfix data_directory ownership: ${path}" [[ "$(stat -c '%u:%g' "$path")" == "${owner_uid}:${owner_gid}" ]] || fatal_rollback "Incorrect Postfix runtime directory ownership: ${path}" return 0 elif [[ -e "$path" ]]; then fatal_rollback "Postfix runtime path is not a directory: ${path}" fi mkdir -- "$path" || fatal_rollback "Cannot create Postfix runtime directory: ${path}" NEW_POSTFIX_RUNTIME_DIRS+=("$path") chown "${owner_uid}:${owner_gid}" -- "$path" || fatal_rollback "Cannot set Postfix runtime ownership: ${path}" # Clear inherited special bits (e.g. setgid) on new directories. chmod 00755 -- "$path" || fatal_rollback "Cannot set Postfix runtime permissions: ${path}" [[ "$(stat -c '%u:%g:%a' "$path")" == "${owner_uid}:${owner_gid}:755" ]] || fatal_rollback "Unexpected Postfix runtime owner or permissions: ${path}" } # Roll back only root directory ownership changes made by this installer. # Do not touch any child paths or nonempty mail queues. restore_postfix_runtime_directory_owners() { local i path for ((i=${#MODIFIED_POSTFIX_RUNTIME_DIRS[@]}-1;i>=0;i--)); do path="${MODIFIED_POSTFIX_RUNTIME_DIRS[i]}" if [[ -d "$path" && ! -L "$path" ]]; then chown "${ORIGINAL_POSTFIX_RUNTIME_OWNERS[i]}" -- "$path" || return 1 else return 1 fi done } # Only remove completely empty configuration directories we created. # In particular, never recursively delete a Postfix spool/data tree. remove_new_directories_if_empty() { local path for path in "${NEW_POSTFIX_RUNTIME_DIRS[@]}"; do if [[ -d "$path" && ! -L "$path" ]]; then rmdir -- "$path" 2>/dev/null || warn "New Postfix runtime directory remains (manual review): ${path}" fi done local i for ((i=${#CREATED_CONFIG_DIRS[@]}-1;i>=0;i--)); do path="${CREATED_CONFIG_DIRS[i]}" if [[ -d "$path" && ! -L "$path" ]]; then rmdir -- "$path" 2>/dev/null || warn "New configuration directory remains (manual review): ${path}" fi done } # Ensure only normal directories are used for SRS config destinations. # Newly created paths are recorded, so rollback can remove them if empty. ensure_config_directory() { local path="$1" mode="${2:-}" if [[ -L "$path" ]]; then fatal_rollback "Refusing symlinked configuration directory: ${path}" elif [[ -d "$path" ]]; then return 0 elif [[ -e "$path" ]]; then fatal_rollback "Configuration directory path is not a directory: ${path}" fi local parent="${path%/*}" if [[ "$parent" != "$path" ]]; then ensure_config_directory "$parent" fi if [[ -n "$mode" ]]; then install -d -o root -g root -m "$mode" "$path" || fatal_rollback "Cannot create configuration directory: ${path}" else mkdir -- "$path" || fatal_rollback "Cannot create configuration directory: ${path}" fi # Track the directory immediately, so permission-setting failures can # also be rolled back. Some parent directories carry the setgid bit and # install -d -m 0700 may inherit it (resulting in mode 2700). CREATED_CONFIG_DIRS+=("$path") if [[ -n "$mode" ]]; then # The extra leading zero clears inherited setgid/sticky bits on Linux # directories; chmod 0700 alone may preserve a directory's setgid bit. chmod "0${mode}" "$path" || fatal_rollback "Cannot enforce directory permissions: ${path}" [[ "$(stat -c '%u:%g:%a' "$path")" == "0:0:${mode##0}" ]] || fatal_rollback "Unexpected directory owner or mode: ${path}" fi } usage() { cat <<'USAGE' Usage: install-update-srs.sh --check install-update-srs.sh --install [--version X.Y.Z] install-update-srs.sh --help --check Ask for public MX host and SRS domain, then read-only inventory; no lock files, package installation, Postfix operations, service changes or filesystem writes. --install Ask for public MX host and SRS domain, run read-only preflight checks, then request approval before installing missing Debian CMake build packages (only for version builds) and preparing installation. Supports inactive integration updates even if PostSRSd is current; refuses changes whenever SRS is activated or SRS services run. Does NOT activate selective SRS in the main Postfix instance. --version PostSRSd release to install (default: 2.4.1). WARNING: REVIEW DRAFT - do not execute --install on production servers until its behavior and rollback have been reviewed and tested. USAGE } MODE='' while (($#)); do case "$1" in --check|--install|--help|-h) [[ -z "$MODE" ]] || { usage >&2; exit 2; } MODE="$1"; shift ;; --version) [[ $# -ge 2 ]] || { usage >&2; exit 2; } POSTSRSD_VERSION="$2"; shift 2 ;; *) usage >&2; exit 2 ;; esac done [[ "$MODE" == '--help' || "$MODE" == '-h' ]] && { usage; exit 0; } [[ "$POSTSRSD_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid PostSRSd version: ${POSTSRSD_VERSION}" >&2 exit 2 } [[ "$MODE" == '--check' || "$MODE" == '--install' ]] || { usage >&2; exit 2; } NEW_PREFIX="/usr/local/postsrsd-${POSTSRSD_VERSION}" # Existing config values are only used as display hints before the full # root-owned, non-group-writable config is validated by load_srs_config(). # Never source local configuration merely to display a prompt default. read_config_domain_hints() { local line candidate name CONFIG_DOMAIN_HINT='' CONFIG_MX_HINT='' [[ -f "$SRS_CONF_FILE" && -r "$SRS_CONF_FILE" ]] || return 0 while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" =~ ^[[:space:]]*_SRS_DOMAIN[[:space:]]*= ]]; then name='srs' elif [[ "$line" =~ ^[[:space:]]*_MX_HOST[[:space:]]*= ]]; then name='mx' else continue fi candidate="${line#*=}" candidate="${candidate%%#*}" candidate="${candidate//[[:space:]]/}" candidate="${candidate//\"/}" candidate="${candidate//\'/}" if [[ "$candidate" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] && (( ${#candidate} <= 253 )); then if [[ "$name" == 'mx' ]]; then CONFIG_MX_HINT="${candidate,,}" else CONFIG_DOMAIN_HINT="${candidate,,}" fi fi done < "$SRS_CONF_FILE" } # First user interaction, before any installation/prerequisite checks. # An existing configured SRS domain is never derived again from a new MX host. prompt_srs_domain() { local answer='' host_fqdn='' mx_default='' srs_default='' read_config_domain_hints host_fqdn="$(hostname -f 2>/dev/null || true)" host_fqdn="${host_fqdn,,}" if [[ "$host_fqdn" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]] && (( ${#host_fqdn} <= 253 )); then mx_default="${CONFIG_MX_HINT:-$host_fqdn}" else mx_default="$CONFIG_MX_HINT" fi echo echo 'Selective SRS - domain selection' echo echo " System hostname....................: ${host_fqdn:-unknown}" echo ' Enter the public mail exchanger and SRS envelope sender domain.' echo while true; do if [[ -n "$mx_default" ]]; then printf ' Public MX hostname [%s]: ' "$mx_default" >&2 else printf ' Public MX hostname: ' >&2 fi if ! IFS= read -r answer; then echo 'Input ended before an MX hostname was selected.' >&2 exit 2 fi [[ -n "$answer" ]] || answer="$mx_default" answer="${answer,,}" if [[ "$answer" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]] && (( ${#answer} <= 253 )); then SELECTED_MX_HOST="$answer" break fi echo ' [ invalid ] Enter a public FQDN, e.g. mail.example.org.' >&2 done echo " Selected MX hostname..............: $SELECTED_MX_HOST" echo srs_default="$CONFIG_DOMAIN_HINT" if [[ -z "$srs_default" ]] && (( ${#SELECTED_MX_HOST} <= 249 )); then srs_default="srs.${SELECTED_MX_HOST}" fi while true; do if [[ -n "$srs_default" ]]; then printf ' SRS domain [%s]: ' "$srs_default" >&2 else printf ' SRS domain: ' >&2 fi if ! IFS= read -r answer; then echo 'Input ended before an SRS domain was selected.' >&2 exit 2 fi [[ -n "$answer" ]] || answer="$srs_default" answer="${answer,,}" if [[ "$answer" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]] && (( ${#answer} <= 253 )); then SELECTED_SRS_DOMAIN="$answer" break fi echo ' [ invalid ] Enter a full DNS domain, e.g. srs.mail.example.org.' >&2 done echo " Selected SRS domain...............: $SELECTED_SRS_DOMAIN" echo } # Consistent installer-only confirmation prompts. Keep the question indented # and visually separate from the surrounding steps. Use bold only on an # interactive terminal so redirected logs contain plain ASCII without ESC. # An empty response means no. Unrecognized responses are prompted again. ask_installer_confirmation() { local question="$1" answer='' echo >&2 while true; do if [[ -t 2 ]]; then printf ' \033[1m%s [yes/no, default: no]: \033[0m' "$question" >&2 else printf ' %s [yes/no, default: no]: ' "$question" >&2 fi if ! IFS= read -r answer; then echo >&2 return 1 fi echo >&2 case "${answer,,}" in yes) return 0 ;; no|'') return 1 ;; *) echo " [ invalid ] Please enter 'yes' or 'no'." >&2 ;; esac done } # Ask for MX host and SRS domain before --check and --install inspect prerequisites. prompt_srs_domain check_templates() { local relative for relative in \ 'delivery-main.cf.in' 'delivery-master.cf' 'transport.in' \ 'postsrsd.conf.in' 'postsrsd-srs.service' 'srs-reverse-map.service'; do [[ -r "${SRS_TEMPLATE_DIR}/${relative}" ]] || return 1 done return 0 } check_python_sources() { local script for script in srs-alias-decision.py srs-forward.py srs-reverse-map.py srs-reverse-policy.py; do [[ -r "${SRS_PYTHON_SOURCE_DIR}/${script}" ]] || return 1 done return 0 } # DNS readiness is verified against authoritative nameservers, bypassing # /etc/hosts and the local systemd-resolved stub. The helper is intentionally # fail-closed for the agreed single-MX + "v=spf1 mx -all" SRS setup. verify_srs_domain_dns() { [[ -r "$SRS_DNS_CHECKER" ]] || { echo " [ error ] Missing DNS preflight helper: $SRS_DNS_CHECKER" >&2 return 1 } local command for command in python3 postconf dig; do command -v "$command" >/dev/null 2>&1 || { echo " [ error ] Missing DNS validation command: $command" >&2 return 1 } done # Collect all postconf stdout/stderr separately, without creating files. # This also keeps --check strictly read-only and prevents main.cf warnings # from interrupting the formatted DNS report. Any unexpected diagnostic # remains blocking: only existing unused main.cf parameters are classified # as unrelated/nonblocking. Deduplicate warnings repeated by postconf. python3 - "$SRS_DNS_CHECKER" "$SELECTED_SRS_DOMAIN" "$SELECTED_MX_HOST" <<'PYDNS' import re import subprocess import sys checker, domain, mx_host = sys.argv[1:] parameters = ( ('smtp_bind_address', '--ipv4'), ('smtp_bind_address6', '--ipv6'), ) values = {} nonblocking = [] blocking = [] for name, option in parameters: try: result = subprocess.run( ['postconf', '-h', name], capture_output=True, text=True, check=False) except OSError as exc: blocking.append(f'Cannot execute postconf for {name}: {exc}') continue if result.returncode != 0: blocking.append(f'postconf -h {name} returned {result.returncode}') value = result.stdout.rstrip('\n') if '\n' in value or '\r' in value: blocking.append(f'postconf -h {name} returned a multiline value') else: values[option] = value for line in result.stderr.splitlines(): line = line.strip() if not line: continue known = re.fullmatch( r'(?:\S*postconf:\s*)?warning:\s*' r'(?P/etc/postfix/main\.cf:\s*unused parameter:\s*.+)', line ) if known: message = known.group('message') if message not in nonblocking: nonblocking.append(message) elif line not in blocking: blocking.append(line) if blocking: print(' Postfix parameter retrieval........: FAILED') print(' Public SRS DNS preflight...........: NOT RUN') result_code = 1 else: args = ['python3', checker, '--domain', domain, '--hostname', mx_host] for _name, option in parameters: args.extend([option, values[option]]) result = subprocess.run(args, capture_output=True, text=True, check=False) if result.stdout: print(result.stdout, end='' if result.stdout.endswith('\n') else '\n') if result.stderr.strip(): blocking.extend(result.stderr.strip().splitlines()) result_code = result.returncode or (1 if blocking else 0) print() print(' Postfix diagnostics:') if nonblocking: print(' [ assessment ] Non-blocking for this SRS setup:') print(' Postfix ignores the following main.cf settings.') print(' Review separately if they were meant to be active.') for line in nonblocking: print(f' [ warning ] {line}') else: print(' [ ok ] No Postfix warnings') if blocking: print(' [ error ] Unexpected Postfix/DNS diagnostics - review required;') print(' SRS installation is blocked.') for line in blocking: print(f' [ error ] {line}') sys.exit(result_code) PYDNS } # Compact display of APT simulations; the full text is retained for # approval checks and printed unchanged if its format is unexpected. show_apt_plan() { local simulation="$1" summary packages summary="$(grep -E '^[0-9]+ upgraded, [0-9]+ newly installed, [0-9]+ to remove and [0-9]+ not upgraded\.$' <<< "$simulation" | tail -n 1)" packages="$(awk '$1 == "Inst" { if (out != "") out = out ", "; out = out $2 } END { print out }' <<< "$simulation")" if [[ -z "$summary" || -z "$packages" ]]; then echo ' [ warning ] APT plan could not be summarized; original output follows:' printf '%s\n' "$simulation" return 1 fi printf ' APT transaction...................: %s\n' "$summary" printf ' New packages......................: %s\n' "$packages" } # Read-only inventory. Deliberately does not call init_srs_runtime(). read_only_check() { local cmd file unit echo echo 'Selective SRS installer - read-only inventory' echo " Host..............................: $(hostname -f)" echo " Requested PostSRSd version........: ${POSTSRSD_VERSION}" echo " Selected MX hostname..............: ${SELECTED_MX_HOST}" echo " Selected SRS domain...............: ${SELECTED_SRS_DOMAIN}" echo " Repository........................: ${working_dir}" echo echo ' Public SRS DNS validation (authoritative nameservers):' if ! verify_srs_domain_dns; then echo ' [ warning ] Public DNS is not ready; --install is blocked.' fi echo for cmd in git cmake ctest make cc python3 postconf postmap postfix systemctl openssl; do if command -v "$cmd" >/dev/null 2>&1; then printf ' [ ok ] %s\n' "$cmd" else printf ' [ missing ] %s\n' "$cmd" fi done if ! command -v cmake >/dev/null 2>&1; then echo echo ' Read-only APT simulation for missing cmake/ctest:' if command -v apt-get >/dev/null 2>&1; then local apt_preview if apt_preview="$(LC_ALL=C apt-get -s install --no-install-recommends cmake)"; then show_apt_plan "$apt_preview" || true else echo ' [ warning ] APT simulation failed; prerequisite cannot yet be installed.' fi else echo ' [ warning ] apt-get is unavailable.' fi echo fi check_templates && echo ' [ ok ] SRS configuration templates' || echo ' [ missing ] SRS configuration templates' check_python_sources && echo ' [ ok ] Four SRS Python source files' || echo ' [ missing ] SRS Python sources' [[ -r "$SRS_DNS_CHECKER" ]] && echo ' [ ok ] Public DNS preflight helper' || echo ' [ missing ] Public DNS preflight helper' echo if [[ -L "$POSTSRSD_LINK" ]]; then echo " Current PostSRSd link............: $(readlink "$POSTSRSD_LINK")" else echo ' Current PostSRSd link............: absent or not a symlink' fi for file in "$SRS_CONF_FILE" "$SRS_STATE_FILE" "$POSTSRSD_CONF_FILE" \ "$POSTSRSD_SECRET_FILE" "${POSTFIX_SRS_DELIVERY_DIR}/main.cf" \ "${POSTFIX_SRS_DELIVERY_DIR}/master.cf" "${POSTFIX_SRS_DELIVERY_DIR}/transport.db"; do [[ -f "$file" ]] && printf ' [ present ] %s\n' "$file" || printf ' [ absent ] %s\n' "$file" done if [[ -f "$SRS_CONF_FILE" && -z "$CONFIG_DOMAIN_HINT" ]]; then printf ' [ warning ] Existing SRS config has no simple readable _SRS_DOMAIN setting\n' elif [[ -n "$CONFIG_DOMAIN_HINT" && "$CONFIG_DOMAIN_HINT" != "$SELECTED_SRS_DOMAIN" ]]; then printf ' [ conflict ] Existing config domain: %s\n' "$CONFIG_DOMAIN_HINT" fi if [[ -f "$SRS_CONF_FILE" && -z "$CONFIG_MX_HINT" ]]; then printf ' [ legacy ] Existing config has no _MX_HOST; maintenance-update will add it.\n' elif [[ -n "$CONFIG_MX_HINT" && "$CONFIG_MX_HINT" != "$SELECTED_MX_HOST" ]]; then printf ' [ conflict ] Existing MX hostname: %s\n' "$CONFIG_MX_HINT" fi echo for unit in "$POSTSRSD_SERVICE" "$SRS_REVERSE_MAP_SERVICE"; do systemctl cat "$unit" >/dev/null 2>&1 && printf ' [ present ] %s\n' "$unit" || printf ' [ absent ] %s\n' "$unit" done # An instantiated template can be loadable even if its own configuration # directory does not exist. Distinguish unit availability from readiness. if systemctl cat "$SRS_DELIVERY_SERVICE" >/dev/null 2>&1; then printf ' [ available template ] %s\n' "$SRS_DELIVERY_SERVICE" else printf ' [ absent ] %s\n' "$SRS_DELIVERY_SERVICE" fi if [[ -f "${POSTFIX_SRS_DELIVERY_DIR}/main.cf" ]]; then printf ' [ present ] Delivery instance configuration\n' else printf ' [ absent ] Delivery instance configuration (service is not ready)\n' fi echo echo ' Delivery service MAIL_CONFIG (if defined):' systemctl show -P Environment "$SRS_DELIVERY_SERVICE" 2>/dev/null || true echo echo 'No changes made.' } [[ "$MODE" == '--check' ]] && { read_only_check; exit 0; } # CMake 3.24+ is required by the pinned PostSRSd build. The Debian # package "cmake" also supplies ctest. Never install any packages in --check. # Before asking for approval, only SIMULATE the precise APT transaction. # Reject simulated upgrades/removals: this installer must not quietly change # otherwise unrelated packages on production mail servers. check_cmake_version() { local ver major minor patch ver="$(cmake --version 2>/dev/null | head -n 1)" || return 1 [[ "$ver" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]] || return 1 major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}" patch="${BASH_REMATCH[3]}" ((10#$major > 3 || (10#$major == 3 && 10#$minor >= 24))) || return 1 } prepare_cmake_package_plan() { CMAKE_PACKAGE_REQUIRED=false APT_CMAKE_SIMULATION='' if command -v cmake >/dev/null 2>&1; then check_cmake_version || fatal 'Installed CMake is older than 3.24 or cannot be identified. Review manually.' command -v ctest >/dev/null 2>&1 || fatal 'CMake installed, but CTest is missing. Review Debian CMake installation manually.' echo " CMake build dependency.............: $(cmake --version | head -n 1); ctest available" return 0 fi check_required_commands apt-get apt-cache dpkg-query # Locale is fixed for reliable summary matching. No APT repository refresh # is performed; Debian's currently configured package indexes are used. APT_CMAKE_SIMULATION="$(LC_ALL=C apt-get -s install --no-install-recommends cmake)" || fatal 'Cannot simulate the installation of the Debian cmake package.' # Fail closed unless the summary unambiguously reports zero upgrades and # zero removals. The simulation is only a preflight snapshot; APT will # resolve dependencies again when the real transaction starts. if ! grep -Eq '^0 upgraded, [1-9][0-9]* newly installed, 0 to remove and [0-9]+ not upgraded\.$' <<< "$APT_CMAKE_SIMULATION"; then echo "$APT_CMAKE_SIMULATION" fatal 'CMake APT transaction would upgrade/remove packages or has an unexpected summary. Review manually.' fi CMAKE_PACKAGE_REQUIRED=true echo echo ' CMake/CTest are missing. Proposed APT transaction:' show_apt_plan "$APT_CMAKE_SIMULATION" || fatal 'APT plan format cannot be summarized reliably.' echo echo ' Package source.....................: configured Debian APT sources (no backports requested)' echo ' No APT operation has been performed.' } install_missing_build_packages() { $CMAKE_PACKAGE_REQUIRED || return 0 echo echo ' CMake and CTest are required to build PostSRSd.' echo ' APT will install cmake and its automatically selected dependencies.' echo ' APT package changes are NOT part of the SRS configuration rollback.' echo ' This approval does not authorize activating SRS or changing main Postfix.' if ! ask_installer_confirmation 'Install the proposed Debian CMake packages now?'; then echo 'Package installation cancelled. No SRS files installed.' clean_up 0 fi # Second simulation immediately before package installation: prevent # proceeding if a changing package index would trigger upgrades/removals. local current_simulation current_simulation="$(LC_ALL=C apt-get -s install --no-install-recommends cmake)" || fatal 'APT re-simulation failed; package installation cancelled.' if [[ "$current_simulation" != "$APT_CMAKE_SIMULATION" ]]; then echo "$current_simulation" fatal 'APT dependency plan changed after approval; package installation cancelled.' fi echo ' Installing Debian CMake package (explicitly approved)..' LC_ALL=C DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends cmake || fatal 'APT installation failed. Debian package state may need manual review.' command -v cmake >/dev/null 2>&1 && check_cmake_version && command -v ctest >/dev/null 2>&1 || fatal 'APT completed but a supported CMake and CTest are still unavailable.' echo ' [ ok ] CMake/CTest dependency installed.' } # Everything below is reached only for the explicitly requested --install mode. # PRE-FLIGHT: no lock, temporary files, package changes, Postfix operations, # or filesystem writes before ALL checks and the first user confirmation. # Unlike the reporting-only --check, --install fails closed on DNS errors. check_root check_required_commands \ awk chown cp dig git grep hostname id install ln make mkdir mktemp mv openssl \ postconf postfix postmap python3 realpath sed stat systemctl # Determine the Postfix owner on the target host, not by assuming numeric IDs. # Preflight this before any installation files are written. [[ -f "${working_dir}/helpers/srs-virtual-alias.py" && ! -L "${working_dir}/helpers/srs-virtual-alias.py" ]] || fatal 'Missing regular helpers/srs-virtual-alias.py file.' python3 - "${working_dir}/helpers/srs-virtual-alias.py" <<'PY_HELPER' || fatal 'SRS alias helper syntax check failed.' import pathlib, sys source = pathlib.Path(sys.argv[1]).read_text() compile(source, sys.argv[1], 'exec') PY_HELPER POSTFIX_MAIL_OWNER="$(postconf -h mail_owner 2>/dev/null)" || fatal 'Cannot determine Postfix mail_owner.' [[ "$POSTFIX_MAIL_OWNER" =~ ^[a-z_][a-z0-9_-]*$ ]] || fatal "Unexpected Postfix mail_owner value: ${POSTFIX_MAIL_OWNER}" POSTFIX_MAIL_UID="$(id -u "$POSTFIX_MAIL_OWNER")" || fatal "Postfix mail_owner user does not exist: ${POSTFIX_MAIL_OWNER}" POSTFIX_MAIL_GID="$(id -g "$POSTFIX_MAIL_OWNER")" || fatal "Cannot determine primary group for Postfix mail_owner: ${POSTFIX_MAIL_OWNER}" [[ "$POSTFIX_MAIL_UID" =~ ^[0-9]+$ && "$POSTFIX_MAIL_GID" =~ ^[0-9]+$ ]] || fatal 'Invalid Postfix mail_owner UID or GID.' check_python_sources || fatal 'SRS Python files are missing from the Git checkout.' check_templates || fatal 'SRS reference templates are missing from the Git checkout.' [[ -r "$SRS_DNS_CHECKER" ]] || fatal 'Public DNS verification helper is missing.' echo ' Public SRS DNS validation (authoritative nameservers):' verify_srs_domain_dns || fatal 'Public SRS DNS validation failed. Nothing installed.' echo # Existing sites retain their authoritative configuration. On a first # installation the chosen domain is prepared and stored *after* approval, # together with the rest of the SRS infrastructure. if [[ -f "$SRS_CONF_FILE" ]]; then load_srs_config _SRS_DOMAIN="${_SRS_DOMAIN,,}" [[ "$_SRS_DOMAIN" == "$SELECTED_SRS_DOMAIN" ]] || fatal "Chosen domain '$SELECTED_SRS_DOMAIN' conflicts with existing '$SRS_CONF_FILE' ($_SRS_DOMAIN). Domain migration is not automatic." if [[ -n "${_MX_HOST:-}" && "${_MX_HOST,,}" != "$SELECTED_MX_HOST" ]]; then fatal "Chosen MX host '$SELECTED_MX_HOST' conflicts with existing '$SRS_CONF_FILE' ($_MX_HOST). Update the configuration intentionally; migration is not automatic." fi else [[ ! -e "$SRS_CONF_FILE" && ! -L "$SRS_CONF_FILE" ]] || fatal "SRS configuration path '$SRS_CONF_FILE' is not a regular file." _SRS_DOMAIN="$SELECTED_SRS_DOMAIN" _MX_HOST="$SELECTED_MX_HOST" fi fatal_rollback() { local why="$1" i dest id local failed=false trap - SIGHUP SIGINT SIGTERM EXIT warn "Installation failed: ${why}" if $CHANGES_STARTED; then warn 'Attempting rollback of tracked files and PostSRSd version link.' for ((i=${#MODIFIED_PATHS[@]}-1;i>=0;i--)); do dest="${MODIFIED_PATHS[i]}" id="${i}" if [[ "${PATH_WAS_PRESENT[i]}" == 'yes' ]]; then cp -a "${BACKUP_DIR}/file-${id}" "$dest" || failed=true else rm -f -- "$dest" || failed=true fi done if $LINK_SWITCHED; then if [[ -n "$PREVIOUS_LINK" ]]; then ln -s "${PREVIOUS_LINK_RAW}" "${STAGE_DIR}/previous-link" && mv -Tf "${STAGE_DIR}/previous-link" "$POSTSRSD_LINK" || failed=true elif [[ -L "$POSTSRSD_LINK" ]]; then rm -f -- "$POSTSRSD_LINK" || failed=true fi fi systemctl daemon-reload || failed=true # A failed first installation must not leave the version path occupied, # otherwise an identical retry would abort before any build or tests. # Never remove an existing installation or a path currently selected # by the stable symlink. if $NEW_PREFIX_INSTALLED && [[ -d "$NEW_PREFIX" ]] && [[ "$(readlink -f "$POSTSRSD_LINK" 2>/dev/null || true)" != "$NEW_PREFIX" ]]; then rm -rf -- "$NEW_PREFIX" || failed=true fi # The Postfix check may create new queue/data subdirectories, even # when it fails. Remove only empty roots; preserve anything nonempty. remove_new_directories_if_empty restore_postfix_runtime_directory_owners || failed=true if $failed; then error "Rollback incomplete. Preserved backup: ${BACKUP_DIR}" else info 'Previous files and symlink restored.' fi fi [[ -n "$STAGE_DIR" && -d "$STAGE_DIR" ]] && rm -rf -- "$STAGE_DIR" fatal "$why" } handle_signal() { trap - SIGHUP SIGINT SIGTERM EXIT fatal_rollback 'Installation interrupted by signal.' } # Called for EACH file we may install or replace. Snapshots retained as # root-only disaster-recovery backups; do not overwrite them on later runs. # A subshell/error that terminates unexpectedly during file deployment must # not silently skip tracked-file rollback. The normal success path disables # this handler before exiting. on_unexpected_exit() { local rc="$1" trap - EXIT if $CHANGES_STARTED; then fatal_rollback "Unexpected script exit (status: ${rc}) after filesystem changes." fi } snapshot_file() { local dest="$1" index="${#MODIFIED_PATHS[@]}" [[ ! -d "$dest" ]] || fatal_rollback "Cannot overwrite directory '${dest}'." [[ ! -L "$dest" ]] || fatal_rollback "Refusing to overwrite symlink '${dest}'." if [[ -e "$dest" ]]; then # Do not register a rollback entry until the backup is complete. cp -a -- "$dest" "${BACKUP_DIR}/file-${index}" || fatal_rollback "Backup failed for '${dest}'." PATH_WAS_PRESENT+=(yes) else PATH_WAS_PRESENT+=(no) fi MODIFIED_PATHS+=("$dest") } install_regular_file() { local source="$1" dest="$2" mode="$3" snapshot_file "$dest" install -o root -g root -m "$mode" -- "$source" "$dest" || fatal_rollback "Cannot install '${dest}'." } # Resolve the original PRE-SRS configuration, preserving explicit/absent # semantics. Refuse unsupported multi-line assignments or ambiguous stanzas. generate_initial_state() { local output="$1" python3 - "$POSTFIX_MAIN_CF" "$POSTFIX_MASTER_CF" "$output" "$(hostname -f)" <<'PY' import pathlib, re, shlex, sys main_path, master_path, output, hostname = sys.argv[1:] main = pathlib.Path(main_path).read_text().splitlines() master = pathlib.Path(master_path).read_text().splitlines() state = {'_STATE_VERSION': '1', '_STATE_ORIGIN': 'install-update-srs.sh', '_STATE_HOSTNAME': hostname} main_params = ( ('default_transport', 'DEFAULT_TRANSPORT'), ('recipient_canonical_maps', 'RECIPIENT_CANONICAL_MAPS'), ('srs-forward_destination_recipient_limit', 'SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT'), ) for param, suffix in main_params: match = [i for i, line in enumerate(main) if re.match(r'^\s*' + re.escape(param) + r'\s*=', line) and not line.lstrip().startswith('#')] if len(match) > 1: raise SystemExit(f'Duplicate main.cf assignment for {param}') key = '_PRE_SRS_' + suffix state[key + '_SET'] = 'true' if match else 'false' if match: i = match[0] if i + 1 < len(main) and main[i+1].startswith((' ', '\t')) and not main[i+1].lstrip().startswith('#'): raise SystemExit(f'Multiline main.cf assignment unsupported: {param}') state[key] = main[i].split('=',1)[1].strip() else: state[key] = '' for service, suffix in (('smtp', 'SMTP'), ('localhost:10025', 'AMAVIS')): starts = [i for i, line in enumerate(master) if re.match(r'^' + re.escape(service) + r'\s+inet\s+', line) and not line.startswith('#')] if len(starts) != 1: raise SystemExit(f'Expected exactly one active {service}/inet service in master.cf') start = starts[0] matches = [] for line in master[start+1:]: if line.strip() and not line[0].isspace() and not line.startswith('#'): break if line.lstrip().startswith('#'): continue m = re.match(r'^\s*-o\s+receive_override_options\s*=\s*(.*?)\s*$', line) if m: matches.append(m.group(1)) if len(matches) > 1 or (matches and not matches[0]): raise SystemExit(f'Ambiguous receive_override_options in {service}/inet') key = f'_PRE_SRS_{suffix}_RECEIVE_OVERRIDE_OPTIONS' state[key + '_SET'] = 'true' if matches else 'false' state[key] = matches[0] if matches else '' # shlex.quote safely serializes only data, never SQL credentials or secrets. with open(output, 'w', encoding='utf-8') as f: f.write('# Generated once by install-update-srs.sh; NEVER reset on update.\n') for key, value in state.items(): f.write(f'{key}={shlex.quote(value)}\n') PY } # Prepare a site-specific delivery configuration from the exact reference # template, preserving the same service/queue/routing architecture. render_templates() { local output="$1" python3 - "$POSTFIX_MAIN_CF" "$SRS_TEMPLATE_DIR" "$output" "$_SRS_DOMAIN" <<'PY' import pathlib, re, subprocess, sys _, main_path, tmpl_path, dst_path, domain = sys.argv src = pathlib.Path(tmpl_path) dst = pathlib.Path(dst_path) reference_domain = 'srs.warenform.de' if not re.fullmatch(r'(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}',domain): raise SystemExit('Invalid SRS domain') for input_name, output_name in (('postsrsd.conf.in', 'postsrsd.conf'), ('transport.in', 'transport')): template = (src/input_name).read_text() if template.count('@SRS_DOMAIN@') != 1: raise SystemExit(f'Unexpected template placeholder count in {input_name}') (dst/output_name).write_text(template.replace('@SRS_DOMAIN@', domain)) text = (src/'delivery-main.cf.in').read_text() # Derive Postfix parameter names from the template's left-hand side, not # from an upper-case placeholder. Postfix names are case-sensitive: # "smtp_tls_CAfile" must NOT be queried as "smtp_tls_cafile". mapping = re.findall( r'(?m)^[ \t]*([A-Za-z0-9_]+)[ \t]*=[ \t]*@([A-Z][A-Z0-9_]*)@[ \t]*$', text) placeholders = re.findall(r'@([A-Z][A-Z0-9_]*)@', text) if sorted(token for _, token in mapping) != sorted(placeholders): raise SystemExit('Delivery placeholder not mapped to one Postfix setting') for postfix_name, token in mapping: result = subprocess.run( ['/usr/sbin/postconf', '-c', '/etc/postfix', '-h', postfix_name], capture_output=True, text=True, check=False) # A pre-existing unused parameter elsewhere in main.cf can trigger a # warning on *every* postconf invocation. Do not turn unrelated warnings # into a deployment failure. Unknown queried parameters and all other # warnings still fail closed (notably smtp_tls_cafile vs smtp_tls_CAfile). diagnostics = [line.strip() for line in result.stderr.splitlines() if line.strip()] def unrelated_unused_main_cf_warning(line): return re.fullmatch( r'(?:\S*postconf:\s*)?warning:\s*' r'/etc/postfix/main\.cf:\s*unused parameter:\s*.+', line) is not None unexpected = [line for line in diagnostics if not unrelated_unused_main_cf_warning(line)] if result.returncode != 0 or unexpected: detail = '; '.join(unexpected or diagnostics) raise SystemExit( f'Cannot read Postfix parameter {postfix_name}: ' f'{detail or "postconf exited unsuccessfully"}') value = result.stdout.rstrip('\n') if '\n' in value or '\r' in value: raise SystemExit(f'Unexpected multiline Postfix value: {postfix_name}') text = text.replace('@'+token+'@', value) if re.search(r'@[A-Z][A-Z0-9_]*@', text): raise SystemExit('Unexpanded delivery main.cf placeholders') (dst/'delivery-main.cf').write_text(text) # Render Python helpers from the Git working tree, not from /usr/local/sbin. project = src.parent for filename in ('srs-alias-decision.py','srs-forward.py','srs-reverse-map.py','srs-reverse-policy.py'): code = (project/filename).read_text() if filename != 'srs-alias-decision.py': if code.count(reference_domain) != 1: raise SystemExit(f'Unexpected SRS domain occurrence count in {filename}') code = code.replace(reference_domain, domain) # Syntax check WITHOUT creating __pycache__ files on the Git checkout. compile(code, filename, 'exec') (dst/filename).write_text(code) PY } # Error if static PostgreSQL lookup map paths required by our current # reference scripts are unavailable. Never silently alter alias semantics. validate_reference_lookups() { python3 - "${SRS_PYTHON_SOURCE_DIR}/srs-alias-decision.py" <<'PY' import pathlib, re, sys src = pathlib.Path(sys.argv[1]).read_text() for backend, name in sorted(set(re.findall(r'"(pgsql|btree):(/etc/postfix/[^" ]+)"', src))): f = pathlib.Path(name) if backend == 'pgsql' and not f.is_file(): raise SystemExit(f'Missing PostgreSQL lookup map: {f}') if backend == 'btree' and not f.with_name(f.name + '.db').is_file(): raise SystemExit(f'Missing btree index: {f}.db') PY } # Detect a deployed versioned binary using the stable symlink. if [[ -L "$POSTSRSD_LINK" ]]; then PREVIOUS_LINK_RAW="$(readlink "$POSTSRSD_LINK")" PREVIOUS_LINK="$(readlink -f "$POSTSRSD_LINK")" [[ "$PREVIOUS_LINK" == /usr/local/postsrsd-* ]] || fatal "Unexpected PostSRSd link target: $PREVIOUS_LINK" [[ -x "$POSTSRSD_LINK/sbin/postsrsd" ]] || fatal 'Existing PostSRSd link is broken.' INSTALL_MODE='upgrade' elif [[ -e "$POSTSRSD_LINK" ]]; then fatal "Non-symlink path '${POSTSRSD_LINK}' exists." else INSTALL_MODE='initial-installation' fi # A missing stable link is not proof of a pristine installation. Such a # situation requires manual review instead of overwriting possibly live files. if [[ "$INSTALL_MODE" == 'initial-installation' ]]; then for existing in "$SRS_STATE_FILE" "$POSTSRSD_CONF_FILE" "$POSTSRSD_SECRET_FILE" \ "${POSTFIX_SRS_DELIVERY_DIR}/main.cf" "${POSTFIX_SRS_DELIVERY_DIR}/master.cf"; do [[ ! -e "$existing" && ! -L "$existing" ]] || fatal "Partial SRS installation detected at '${existing}' without ${POSTSRSD_LINK}. Review before installing." done fi if [[ -f "$SRS_STATE_FILE" ]]; then load_srs_state [[ -z "${_STATE_HOSTNAME:-}" || "$_STATE_HOSTNAME" == "$(hostname -f)" ]] || fatal 'Persistent SRS state belongs to another host.' else [[ "$INSTALL_MODE" == 'initial-installation' ]] || fatal 'Existing PostSRSd installation without state.conf: refusing to recapture PRE-SRS values.' for file in "$POSTFIX_MAIN_CF" "$POSTFIX_MASTER_CF"; do grep -Fqx "$SRS_MAIN_CF_BEGIN_MARKER" "$file" && fatal 'SRS managed configuration exists but state.conf is missing.' done fi SRS_ENABLED=false if grep -Fqx "$SRS_MAIN_CF_BEGIN_MARKER" "$POSTFIX_MAIN_CF" || grep -Fqx "$SRS_MASTER_CF_BEGIN_MARKER" "$POSTFIX_MASTER_CF"; then SRS_ENABLED=true fi # The same-version case is an *integration-only maintenance update*: leave # the selected PostSRSd binary and its stable symlink untouched. Python helpers, # delivery templates, systemd units and transport maps can still be refreshed. # For a version change, build and stage a new immutable prefix as before. if [[ "$INSTALL_MODE" == 'upgrade' && "$PREVIOUS_LINK" == "$NEW_PREFIX" ]]; then INSTALL_MODE='maintenance-update' BUILD_REQUIRED=false else BUILD_REQUIRED=true fi # NEVER replace scripts, systemd units or delivery maps while SRS can be in # production use. This applies to same-version updates too. Coordinate changes # through deactivate-srs.sh and verify the services have stopped first. if [[ "$INSTALL_MODE" != 'initial-installation' && "$SRS_ENABLED" == true ]]; then fatal 'SRS active: only the revised activate-srs.sh may adopt SRS bounce alias settings live. Binary/service updates still require deactivation.' fi for running_unit in "$POSTSRSD_SERVICE" "$SRS_REVERSE_MAP_SERVICE" "$SRS_DELIVERY_SERVICE"; do if systemctl is-active --quiet "$running_unit"; then fatal "Service '$running_unit' is active. Stop/deactivate SRS before installing or updating any integration files." fi done if [[ "$INSTALL_MODE" != 'initial-installation' ]]; then # An existing secret/state cannot be safely regenerated: doing so may break # the reverse mapping of SRS senders that have already been sent. [[ -f "$POSTSRSD_SECRET_FILE" && ! -L "$POSTSRSD_SECRET_FILE" ]] || fatal 'Existing installation has no regular SRS secret. Refusing automatic regeneration.' [[ -f "$SRS_STATE_FILE" && ! -L "$SRS_STATE_FILE" ]] || fatal 'Existing installation has no regular PRE-SRS state file.' fi check_python_sources || fatal 'SRS Python files are missing from the Git checkout.' check_templates || fatal 'SRS reference templates are missing from the Git checkout.' validate_reference_lookups || fatal 'Required Postfix lookup tables are not ready.' # Fail before build/confirmation on unsafe directory structures. An existing # private secret directory must not be silently chmod'ed during installation. for path in "$POSTSRSD_CONF_DIR" "$POSTFIX_SRS_DIR" "$POSTFIX_SRS_DELIVERY_DIR" /usr/local/sbin; do [[ ! -L "$path" ]] || fatal "Refusing symlinked configuration directory: $path" [[ ! -e "$path" || -d "$path" ]] || fatal "Expected directory at: $path" done if [[ -d "$POSTSRSD_CONF_DIR" ]]; then [[ "$(stat -c '%u:%g:%a' "$POSTSRSD_CONF_DIR")" == '0:0:700' ]] || fatal "Existing '$POSTSRSD_CONF_DIR' must be root:root mode 0700." fi # Prevent obvious clashes with a partial Postfix runtime installation. for path in /var/spool/postfix-srs-delivery /var/lib/postfix-srs-delivery; do [[ ! -L "$path" ]] || fatal "Refusing symlinked Postfix runtime path: $path" [[ ! -e "$path" || -d "$path" ]] || fatal "Invalid Postfix runtime directory path: $path" done if $BUILD_REQUIRED; then [[ ! -e "$NEW_PREFIX" && ! -L "$NEW_PREFIX" ]] || fatal "Target version prefix already exists: $NEW_PREFIX" prepare_cmake_package_plan else [[ -x "$NEW_PREFIX/sbin/postsrsd" ]] || fatal "Current PostSRSd binary is not executable: $NEW_PREFIX/sbin/postsrsd" echo " CMake/CTest.........................: not needed for same-version integration update" fi blank_line echo " Mode..............................: ${INSTALL_MODE}" echo " Host..............................: $(hostname -f)" echo " Public MX hostname................: ${SELECTED_MX_HOST}" echo " SRS domain........................: ${_SRS_DOMAIN}" echo " Current version path..............: ${PREVIOUS_LINK:-absent}" echo " Target version path...............: ${NEW_PREFIX}" echo " PostSRSd build required...........: ${BUILD_REQUIRED}" echo ' Main Postfix SRS activation.......: unchanged' echo ' Running SRS services..............: not started/restarted by this script' echo # No side effects until ALL preflight checks have passed and the operator # expressly approves preparation. Obtain the shared SRS lock only now. # Ask using plain read: the helper UI is activated by init_srs_runtime(). if ! ask_installer_confirmation 'Proceed with installation preparation?'; then echo 'Installation preparation cancelled; no changes made.' exit 0 fi trap 'handle_signal' SIGHUP SIGINT SIGTERM init_srs_runtime if $BUILD_REQUIRED; then install_missing_build_packages fi mkdir -p "$POSTSRSD_SOURCE_BASE" || fatal 'Cannot create source directory.' STAGE_DIR="$(mktemp -d "${POSTSRSD_SOURCE_BASE}/.stage-${POSTSRSD_VERSION}.XXXXXXXX")" || fatal 'Cannot create staging directory.' chmod 0700 "$STAGE_DIR" || fatal 'Cannot secure staging directory.' mkdir -p "${STAGE_DIR}/rendered" "${STAGE_DIR}/destdir" || fatal 'Cannot prepare staging directories.' # Preparation/build: absolutely NO installed files are changed before this succeeds. if $BUILD_REQUIRED; then echononl "Clone PostSRSd ${POSTSRSD_VERSION} release.." if git clone --depth 1 --branch "${POSTSRSD_VERSION}" "$POSTSRSD_REPOSITORY" "${STAGE_DIR}/src" > "$log_file" 2>&1; then echo_ok else echo_failed fatal_rollback "Git clone failed: $(tail -n 12 "$log_file")" fi echononl 'Configure PostSRSd build with reference settings..' if cmake -S "${STAGE_DIR}/src" -B "${STAGE_DIR}/build" \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX="$NEW_PREFIX" \ -DBUILD_TESTING=ON \ -DWITH_REDIS=OFF -DWITH_SQLITE=OFF -DWITH_SECCOMP=OFF \ -DGENERATE_SRS_SECRET=OFF \ -DINSTALL_SYSTEMD_SERVICE=OFF -DINSTALL_SYSTEMD_SYSUSERS=OFF \ > "$log_file" 2>&1; then echo_ok else echo_failed fatal_rollback "CMake configure failed: $(tail -n 12 "$log_file")" fi echononl 'Build PostSRSd..' if cmake --build "${STAGE_DIR}/build" -j 2 > "$log_file" 2>&1; then echo_ok else echo_failed fatal_rollback "Build failed: $(tail -n 12 "$log_file")" fi echononl 'Run upstream CTest suite..' if ctest --test-dir "${STAGE_DIR}/build" --output-on-failure > "$log_file" 2>&1; then echo_ok else echo_failed fatal_rollback "CTest failed: $(tail -n 20 "$log_file")" fi echononl 'Stage versioned PostSRSd installation..' if DESTDIR="${STAGE_DIR}/destdir" cmake --install "${STAGE_DIR}/build" > "$log_file" 2>&1 && [[ -x "${STAGE_DIR}/destdir/${NEW_PREFIX#/}/sbin/postsrsd" ]]; then echo_ok else echo_failed fatal_rollback 'Staged PostSRSd binary was not installed as expected.' fi else info "Keeping installed PostSRSd binary at ${NEW_PREFIX}; rendering integration files only." fi render_templates "${STAGE_DIR}/rendered" || fatal_rollback 'Cannot render SRS configuration templates.' # Newly installed sites record both names. For older sites without _MX_HOST, # stage an append-only compatibility migration, preserving all existing content. # The installed file is touched only after the deployment confirmation, and # snapshot_file provides a rollback of the original configuration. if [[ ! -f "$SRS_CONF_FILE" ]]; then printf '# Local SRS configuration - created by install-update-srs.sh; do not commit.\n_MX_HOST="%s"\n_SRS_DOMAIN="%s"\n' \ "$SELECTED_MX_HOST" "$SELECTED_SRS_DOMAIN" > "${STAGE_DIR}/rendered/srs.conf" || fatal_rollback 'Cannot prepare local conf/srs.conf.' elif [[ -z "${_MX_HOST+x}" ]]; then python3 - "$SRS_CONF_FILE" "${STAGE_DIR}/rendered/srs.conf" "$SELECTED_MX_HOST" <<'PY_MX_MIGRATE' || fatal_rollback 'Cannot stage legacy SRS configuration migration.' import pathlib, re, sys source, destination, mx_host = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), sys.argv[3] content = source.read_bytes() if re.search(rb'(?m)^\s*_MX_HOST\s*=', content): raise SystemExit('Legacy config contains unexpected _MX_HOST assignment') separator = b'' if not content or content.endswith(b'\n') else b'\n' destination.write_bytes(content + separator + b'\n# Public mail exchanger for SRS return messages\n_MX_HOST="' + mx_host.encode('ascii') + b'"\n') PY_MX_MIGRATE fi cp "$SRS_TEMPLATE_DIR/delivery-master.cf" "${STAGE_DIR}/rendered/delivery-master.cf" || fatal_rollback 'Cannot stage delivery master.cf.' if [[ ! -f "$SRS_STATE_FILE" ]]; then generate_initial_state "${STAGE_DIR}/rendered/state.conf" || fatal_rollback 'Cannot capture the original PRE-SRS Postfix configuration.' fi if [[ ! -f "$POSTSRSD_SECRET_FILE" ]]; then (umask 077; openssl rand -hex 32 > "${STAGE_DIR}/rendered/secret") || fatal_rollback 'Cannot generate SRS secret.' fi if ! ask_installer_confirmation 'Preparation succeeded. Install the staged SRS files now'; then rm -rf -- "$STAGE_DIR" clean_up 0 fi # Backups are created only after the operator approves file deployment. mkdir -p "$SRS_BACKUP_BASE" || fatal_rollback 'Cannot create root backup directory.' BACKUP_DIR="$(mktemp -d "${SRS_BACKUP_BASE}/srs-${backup_date}.XXXXXXXX")" || fatal_rollback 'Cannot create backup directory.' chmod 0700 "$BACKUP_DIR" || fatal_rollback 'Cannot protect backup directory.' CHANGES_STARTED=true trap 'on_unexpected_exit "$?"' EXIT if [[ ! -f "$SRS_CONF_FILE" ]]; then install_regular_file "${STAGE_DIR}/rendered/srs.conf" "$SRS_CONF_FILE" 0644 elif [[ -z "${_MX_HOST+x}" ]]; then # Preserve the previous permissions for migrated legacy configs. install_regular_file "${STAGE_DIR}/rendered/srs.conf" "$SRS_CONF_FILE" "$(stat -c '%a' "$SRS_CONF_FILE")" fi # Keep the secret directory private, as on mx.warenform.de. An existing # directory with weaker permissions must be reviewed manually, not silently # chmod'ed in place (which would evade file-only rollback tracking). ensure_config_directory "$POSTSRSD_CONF_DIR" 0700 ensure_config_directory "$POSTFIX_SRS_DELIVERY_DIR" ensure_config_directory /usr/local/sbin # The new release is fully present before changing the stable symlink. if $BUILD_REQUIRED; then NEW_PREFIX_INSTALLED=true mv "${STAGE_DIR}/destdir/${NEW_PREFIX#/}" "$NEW_PREFIX" || fatal_rollback 'Cannot install new PostSRSd version prefix.' fi if [[ ! -f "$SRS_STATE_FILE" ]]; then install_regular_file "${STAGE_DIR}/rendered/state.conf" "$SRS_STATE_FILE" 0600 fi if [[ ! -f "$POSTSRSD_SECRET_FILE" ]]; then install_regular_file "${STAGE_DIR}/rendered/secret" "$POSTSRSD_SECRET_FILE" 0600 fi install_regular_file "${STAGE_DIR}/rendered/postsrsd.conf" "$POSTSRSD_CONF_FILE" 0644 for name in srs-alias-decision.py srs-forward.py srs-reverse-map.py srs-reverse-policy.py; do install_regular_file "${STAGE_DIR}/rendered/$name" "/usr/local/sbin/$name" 0755 done install_regular_file "${STAGE_DIR}/rendered/delivery-main.cf" "${POSTFIX_SRS_DELIVERY_DIR}/main.cf" 0644 install_regular_file "${STAGE_DIR}/rendered/delivery-master.cf" "${POSTFIX_SRS_DELIVERY_DIR}/master.cf" 0644 install_regular_file "${STAGE_DIR}/rendered/transport" "${POSTFIX_SRS_DELIVERY_DIR}/transport" 0644 # Rebuild the database after the actual transport file has been installed. snapshot_file "${POSTFIX_SRS_DELIVERY_DIR}/transport.db" postmap "btree:${POSTFIX_SRS_DELIVERY_DIR}/transport" > "$log_file" 2>&1 || fatal_rollback "postmap failed: $(tail -n 10 "$log_file")" install_regular_file "${SRS_TEMPLATE_DIR}/postsrsd-srs.service" "/etc/systemd/system/postsrsd-srs.service" 0644 install_regular_file "${SRS_TEMPLATE_DIR}/srs-reverse-map.service" "/etc/systemd/system/srs-reverse-map.service" 0644 if $BUILD_REQUIRED; then LINK_SWITCHED=true ln -s "postsrsd-${POSTSRSD_VERSION}" "${STAGE_DIR}/new-link" && mv -Tf "${STAGE_DIR}/new-link" "$POSTSRSD_LINK" || fatal_rollback 'Cannot switch PostSRSd symlink.' fi # The Debian postfix@.service uses /etc/postfix-%I; for instance # 'srs-delivery', systemd unescapes %I to 'srs/delivery'. NO symlink is needed. systemctl daemon-reload > "$log_file" 2>&1 || fatal_rollback "systemd reload failed: $(tail -n 12 "$log_file")" # Postfix needs its separate queue root before "postfix check": it performs # chdir(queue_directory) before creating/validating the queue subdirectories. # Only newly created roots are tracked. Never recursively remove a queue on # rollback: Postfix can leave nonempty internal directories, or even messages. # No changes or restarts to the main Postfix instance occur here. ensure_postfix_runtime_directory /var/spool/postfix-srs-delivery 0 0 ensure_postfix_runtime_directory /var/lib/postfix-srs-delivery "$POSTFIX_MAIL_UID" "$POSTFIX_MAIL_GID" postfix -c "$POSTFIX_SRS_DELIVERY_DIR" check > "$log_file" 2>&1 || fatal_rollback "Delivery Postfix check failed: $(tail -n 12 "$log_file")" [[ -x "$POSTSRSD_LINK/sbin/postsrsd" && -r "$POSTSRSD_CONF_FILE" && -r "$SRS_STATE_FILE" ]] || fatal_rollback 'Installation verification failed.' rm -rf -- "$STAGE_DIR" STAGE_DIR='' CHANGES_STARTED=false trap - EXIT SIGHUP SIGINT SIGTERM blank_line info "SRS ${INSTALL_MODE} completed (PostSRSd ${POSTSRSD_VERSION})." printf ' Backup: %s\n' "$BACKUP_DIR" info 'Selective SRS is NOT activated by this installer.' info "To activate after review, use: ${working_dir}/activate-srs.sh" clean_up 0