1234 lines
54 KiB
Bash
Executable File
1234 lines
54 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=''
|
|
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<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, '--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
|