Files
mailsystem/install-update-srs.sh
T

1165 lines
51 KiB
Bash
Executable File

#!/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=''
CONFIG_DOMAIN_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 SRS domain, then read-only inventory; no lock files,
package installation,
Postfix operations, service changes or filesystem writes.
--install Ask for SRS domain first, run all 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}"
# A local configuration may provide a *display-only* prompt default.
# Do not source shell code before checking its root ownership/permissions.
read_config_domain_hint() {
local line candidate
CONFIG_DOMAIN_HINT=''
[[ -f "$SRS_CONF_FILE" && -r "$SRS_CONF_FILE" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" =~ ^[[:space:]]*_SRS_DOMAIN[[:space:]]*= ]] || continue
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}$ ]]; then
CONFIG_DOMAIN_HINT="${candidate,,}"
fi
done < "$SRS_CONF_FILE"
}
# First user interaction, before any installation/prerequisite checks.
# The shared Git script never has a server-specific domain hardcoded.
prompt_srs_domain() {
local answer='' host_fqdn=''
read_config_domain_hint
# For a first installation, suggest srs.<hostname -f>.
# A configured SRS domain always takes precedence.
if [[ -z "$CONFIG_DOMAIN_HINT" ]]; then
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} <= 249 )); then
CONFIG_DOMAIN_HINT="srs.${host_fqdn}"
fi
fi
echo
echo 'Selective SRS - domain selection'
echo
echo " Host..............................: $(hostname -f 2>/dev/null || hostname)"
echo ' Enter the envelope sender domain for SRS addresses.'
echo
while true; do
if [[ -n "$CONFIG_DOMAIN_HINT" ]]; then
printf ' SRS domain [%s]: ' "$CONFIG_DOMAIN_HINT" >&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="$CONFIG_DOMAIN_HINT"
answer="${answer,,}"
if [[ "$answer" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]]; then
SELECTED_SRS_DOMAIN="$answer"
break
fi
echo ' [ invalid ] Enter a full DNS domain, e.g. srs.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 the 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" <<'PYDNS'
import re
import subprocess
import sys
checker, domain = sys.argv[1:]
parameters = (
('myhostname', '--hostname'),
('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<message>/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]
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 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
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."
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"
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 " 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.'
if [[ ! -f "$SRS_CONF_FILE" ]]; then
printf '# Local SRS domain - created by install-update-srs.sh; do not commit.\n_SRS_DOMAIN="%s"\n' \
"$SELECTED_SRS_DOMAIN" > "${STAGE_DIR}/rendered/srs.conf" ||
fatal_rollback 'Cannot prepare local conf/srs.conf.'
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
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}). Backup: ${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