diff --git a/activate-srs.sh b/activate-srs.sh new file mode 100755 index 0000000..6f87eb4 --- /dev/null +++ b/activate-srs.sh @@ -0,0 +1,1595 @@ +#!/usr/bin/env bash + +# ---------- +# Selective SRS activation +# +# Activates selective SRS on a prepared mail server. +# +# The script is intentionally conservative: +# +# - ENABLED -> no changes, rc=0 +# - DISABLED -> activate after explicit confirmation +# - INCONSISTENT -> abort without changes +# +# Persistent pre-SRS state is read from: +# +# /etc/postfix-srs/state.conf +# +# That state file is never overwritten by this script. +# ---------- + + +# ---------- +# Script setup +# ---------- + +working_dir="$(dirname "$(realpath "$0")")" + +# shellcheck source=helpers/srs-common.sh +source "${working_dir}/helpers/srs-common.sh" + +trap 'handle_signal' SIGHUP SIGINT SIGTERM + +init_srs_runtime + + +# ---------- +# Activation runtime state +# ---------- + +ACTIVATION_CHANGES_STARTED=false +ACTIVATION_BACKUP_DIR="" +POSTFIX_RELOADED=false + +POSTFIX_SRS_DROPIN_WAS_PRESENT=false +SRS_DELIVERY_DROPIN_WAS_PRESENT=false + +declare -A SERVICE_WAS_ACTIVE +declare -A SERVICE_WAS_ENABLED + + +# ---------- +# Local helper functions +# ---------- + +main_cf_explicit_value() { + local parameter="$1" + + awk -v parameter="$parameter" ' + /^[[:space:]]*#/ { next } + + $0 ~ "^[[:space:]]*" parameter "[[:space:]]*=" { + line=$0 + } + + END { + if (line != "") { + sub("^[[:space:]]*" parameter "[[:space:]]*=[[:space:]]*", "", line) + print line + } + } + ' "$POSTFIX_MAIN_CF" +} + + +main_cf_parameter_is_set() { + local parameter="$1" + + grep -Eq \ + "^[[:space:]]*${parameter}[[:space:]]*=" \ + "$POSTFIX_MAIN_CF" +} + + +master_service_option_value() { + local service="$1" + local option="$2" + + awk -v service="$service" -v option="$option" ' + /^[[:space:]]*#/ { next } + + $1 == service && $2 == "inet" { + in_service=1 + next + } + + in_service && $0 !~ /^[[:space:]]/ { + exit + } + + in_service && $1 == "-o" { + prefix=option "=" + + if (index($2, prefix) == 1) { + value=$2 + sub("^" option "=", "", value) + print value + exit + } + } + ' "$POSTFIX_MASTER_CF" +} + + +master_srs_forward_present() { + awk ' + /^[[:space:]]*#/ { next } + + $1 == "srs-forward" && $2 == "unix" { + found_service=1 + next + } + + found_service && $0 !~ /^[[:space:]]/ { + exit + } + + found_service && $0 ~ /argv=\/usr\/local\/sbin\/srs-forward\.py/ { + found_argv=1 + } + + END { + if (found_service && found_argv) + exit 0 + exit 1 + } + ' "$POSTFIX_MASTER_CF" +} + + +port_is_listening() { + local host="$1" + local port="$2" + + ss -ltnH 2>/dev/null | + awk -v host="$host" -v port="$port" ' + { + local_address=$4 + + if (local_address == host ":" port || + local_address == "[" host "]:" port) { + found=1 + exit + } + } + + END { + exit(found ? 0 : 1) + } + ' +} + + +service_exists() { + systemctl cat "$1" > /dev/null 2>&1 +} + + +service_active() { + systemctl is-active --quiet "$1" +} + + +service_enabled_state() { + systemctl is-enabled "$1" 2>/dev/null || true +} + + +postsrsd_srs_domain() { + awk ' + /^[[:space:]]*#/ { next } + + /^[[:space:]]*srs-domain[[:space:]]*=/ { + line=$0 + sub(/^[^=]*=[[:space:]]*/, "", line) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", line) + gsub(/^"|"$/, "", line) + print line + exit + } + ' "$POSTSRSD_CONF_FILE" +} + + +state_main_cf_matches() { + local parameter="$1" + local was_set="$2" + local expected_value="$3" + local current_value="" + + if "$was_set" ; then + if ! main_cf_parameter_is_set "$parameter" ; then + return 1 + fi + + current_value="$(main_cf_explicit_value "$parameter")" + [[ "$current_value" == "$expected_value" ]] + return + fi + + ! main_cf_parameter_is_set "$parameter" +} + + +state_master_option_matches() { + local service="$1" + local option="$2" + local was_set="$3" + local expected_value="$4" + local current_value="" + + current_value="$(master_service_option_value "$service" "$option")" + + if "$was_set" ; then + [[ "$current_value" == "$expected_value" ]] + return + fi + + [[ -z "$current_value" ]] +} + + +csv_add_value() { + local current="$1" + local add="$2" + local item + local result="" + local found=false + + IFS=',' read -r -a _items <<< "$current" + + for item in "${_items[@]}" ; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + + [[ -z "$item" ]] && continue + + if [[ "$item" == "$add" ]]; then + found=true + fi + + if [[ -z "$result" ]]; then + result="$item" + else + result="${result},${item}" + fi + done + + if ! $found ; then + if [[ -z "$result" ]]; then + result="$add" + else + result="${result},${add}" + fi + fi + + printf '%s\n' "$result" +} + + +csv_remove_value() { + local current="$1" + local remove="$2" + local item + local result="" + + IFS=',' read -r -a _items <<< "$current" + + for item in "${_items[@]}" ; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + + [[ -z "$item" ]] && continue + [[ "$item" == "$remove" ]] && continue + + if [[ -z "$result" ]]; then + result="$item" + else + result="${result},${item}" + fi + done + + printf '%s\n' "$result" +} + + +update_master_service_option() { + local service="$1" + local option="$2" + local value="$3" + + python3 - \ + "$POSTFIX_MASTER_CF" \ + "$service" \ + "$option" \ + "$value" <<'PY_EOF' +import os +import re +import stat +import sys +import tempfile + +path, service, option, value = sys.argv[1:5] + +with open(path, "r", encoding="utf-8") as f: + lines = f.readlines() + +service_index = None + +for idx, line in enumerate(lines): + if not line.strip() or line.lstrip().startswith("#"): + continue + + fields = line.split() + + if len(fields) >= 2 and fields[0] == service and fields[1] == "inet": + service_index = idx + break + +if service_index is None: + raise SystemExit(2) + +end_index = len(lines) + +for idx in range(service_index + 1, len(lines)): + line = lines[idx] + + if line.strip() == "": + end_index = idx + break + + if line[0].isspace(): + continue + + if line.lstrip().startswith("#"): + end_index = idx + break + + end_index = idx + break + +option_re = re.compile( + r"^(?P\s*)-o\s+" + re.escape(option) + r"=.*(?:\n)?$" +) + +option_index = None +option_indent = " " + +for idx in range(service_index + 1, end_index): + match = option_re.match(lines[idx]) + + if match: + option_index = idx + option_indent = match.group("indent") or " " + break + +if value: + new_line = f"{option_indent}-o {option}={value}\n" + + if option_index is not None: + lines[option_index] = new_line + else: + lines.insert(end_index, new_line) +else: + if option_index is not None: + del lines[option_index] + +st = os.stat(path) +directory = os.path.dirname(path) + +fd, tmp_path = tempfile.mkstemp(prefix=".master.cf.srs.", dir=directory) + +try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.writelines(lines) + + os.chmod(tmp_path, stat.S_IMODE(st.st_mode)) + os.chown(tmp_path, st.st_uid, st.st_gid) + os.replace(tmp_path, path) +finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) +PY_EOF +} + + +append_main_cf_managed_block() { + local tmp + + tmp="$(mktemp "${POSTFIX_CONF_DIR}/.main.cf.srs.XXXXXX")" || return 1 + + if ! cp -a "$POSTFIX_MAIN_CF" "$tmp" ; then + rm -f "$tmp" + return 1 + fi + + cat >> "$tmp" <> "$tmp" <<'EOF' + +# BEGIN OOPEN-SRS-MANAGED +# ============================================================================ +# Selective Sender Rewriting Scheme (SRS) +# +# This block is maintained by the SRS management scripts. +# Do not rename or remove the BEGIN/END markers. +# ============================================================================ +srs-forward unix - n n - - pipe + flags=Rq null_sender= user=nobody + argv=/usr/local/sbin/srs-forward.py ${sender} ${recipient} ${original_recipient} ${queue_id} envid=${envid} +# END OOPEN-SRS-MANAGED +EOF + + if ! mv "$tmp" "$POSTFIX_MASTER_CF" ; then + rm -f "$tmp" + return 1 + fi + + return 0 +} + +snapshot_systemd_dropins() { + + if [[ -f "$POSTFIX_SRS_DROPIN_FILE" ]]; then + POSTFIX_SRS_DROPIN_WAS_PRESENT=true + cp -a "$POSTFIX_SRS_DROPIN_FILE" \ + "${ACTIVATION_BACKUP_DIR}/postfix.service-srs.conf" || return 1 + fi + + if [[ -f "$SRS_DELIVERY_DROPIN_FILE" ]]; then + SRS_DELIVERY_DROPIN_WAS_PRESENT=true + cp -a "$SRS_DELIVERY_DROPIN_FILE" \ + "${ACTIVATION_BACKUP_DIR}/postfix@srs-delivery.service-srs.conf" || return 1 + fi + + return 0 +} + + +write_srs_systemd_dropins() { + + mkdir -p "$POSTFIX_SRS_DROPIN_DIR" "$SRS_DELIVERY_DROPIN_DIR" || return 1 + + cat > "$POSTFIX_SRS_DROPIN_FILE" < "$SRS_DELIVERY_DROPIN_FILE" < /dev/null 2>> "$log_file" || restore_failed=true + else + systemctl stop "$service" > /dev/null 2>> "$log_file" || restore_failed=true + fi + + enabled_state="${SERVICE_WAS_ENABLED[$service]:-}" + + case "$enabled_state" in + enabled|enabled-runtime) + systemctl enable "$service" > /dev/null 2>> "$log_file" || restore_failed=true + ;; + disabled) + systemctl disable "$service" > /dev/null 2>> "$log_file" || restore_failed=true + ;; + esac + done + + ! $restore_failed +} + + +rollback_activation() { + local rollback_failed=false + + if ! $ACTIVATION_CHANGES_STARTED ; then + return 0 + fi + + blank_line + warn "SRS activation failed. Trying to restore the previous state." + + if [[ -n "$ACTIVATION_BACKUP_DIR" && + -r "${ACTIVATION_BACKUP_DIR}/main.cf" && + -r "${ACTIVATION_BACKUP_DIR}/master.cf" ]] + then + echononl "Restore '${POSTFIX_MAIN_CF}'.." + if cp -a "${ACTIVATION_BACKUP_DIR}/main.cf" "$POSTFIX_MAIN_CF" > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + rollback_failed=true + fi + + echononl "Restore '${POSTFIX_MASTER_CF}'.." + if cp -a "${ACTIVATION_BACKUP_DIR}/master.cf" "$POSTFIX_MASTER_CF" > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + rollback_failed=true + fi + else + error "Activation backup is incomplete. Cannot automatically restore Postfix configuration." + rollback_failed=true + fi + + echononl "Restore previous systemd SRS drop-ins.." + if restore_systemd_dropins ; then + echo_ok + else + echo_failed + rollback_failed=true + fi + + echononl "Restore previous SRS service states.." + if restore_service_states ; then + echo_ok + else + echo_failed + rollback_failed=true + fi + + echononl "Check restored Postfix configuration.." + if postfix check > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + rollback_failed=true + fi + + if systemctl is-active --quiet "$POSTFIX_SERVICE" ; then + echononl "Reload restored Postfix configuration.." + + if systemctl reload "$POSTFIX_SERVICE" > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + rollback_failed=true + fi + fi + + if $rollback_failed ; then + error "Automatic rollback could not fully restore the previous state. Manual intervention is required." + return 1 + fi + + info "Previous state was successfully restored." + return 0 +} + + +fatal_with_activation_rollback() { + local message="$1" + + if $ACTIVATION_CHANGES_STARTED ; then + if rollback_activation ; then + ACTIVATION_CHANGES_STARTED=false + fatal "${message} The previous state was successfully restored." + else + fatal "${message} Automatic rollback could not fully restore the previous state." + fi + fi + + fatal "$message" +} + + +handle_signal() { + trap - SIGHUP SIGINT SIGTERM + + warn "Activation interrupted by signal." + + if $ACTIVATION_CHANGES_STARTED ; then + rollback_activation + fi + + clean_up 1 +} + + +# ---------- +# Header +# ---------- + +blank_line +echo -e "\033[1m----------\033[m" +echo -e "\033[32m\033[1mRunning script \033[m\033[1m${script_name}\033[32m .. \033[m" +echo -e "\033[1m----------\033[m" +blank_line + + +# ---------- +# Basic checks +# ---------- + +check_root + +check_required_commands \ + awk \ + cp \ + cut \ + grep \ + hostname \ + mkdir \ + mktemp \ + mv \ + postfix \ + rm \ + python3 \ + realpath \ + sed \ + ss \ + stat \ + systemctl + +load_srs_config +load_srs_state + +smtp_pre_value="" +amavis_pre_value="" + +if $_PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS_SET ; then + smtp_pre_value="$_PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS" +fi + +if $_PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS_SET ; then + amavis_pre_value="$_PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS" +fi + +smtp_active_value="$(csv_add_value "$smtp_pre_value" "no_address_mappings")" +amavis_active_value="$(csv_remove_value "$amavis_pre_value" "no_address_mappings")" + + +# ---------- +# Check required installation files +# ---------- + +blank_line +echo -e "\033[37m\033[1mCheck selective SRS installation..\033[m" +blank_line + +installation_ok=true + +for _file in \ + "$POSTFIX_MAIN_CF" \ + "$POSTFIX_MASTER_CF" \ + "$POSTSRSD_CONF_FILE" \ + "$POSTSRSD_SECRET_FILE" \ + "${POSTFIX_SRS_DELIVERY_DIR}/main.cf" \ + "${POSTFIX_SRS_DELIVERY_DIR}/master.cf" \ + "${POSTFIX_SRS_DELIVERY_DIR}/transport" \ + "${POSTFIX_SRS_DELIVERY_DIR}/transport.db" +do + echononl "Check file '${_file}'.." + + if [[ -r "$_file" ]]; then + echo_ok + else + echo_failed + installation_ok=false + fi +done + +for _script in \ + "$SRS_ALIAS_DECISION_SCRIPT" \ + "$SRS_FORWARD_SCRIPT" \ + "$SRS_REVERSE_MAP_SCRIPT" \ + "$SRS_REVERSE_POLICY_SCRIPT" +do + echononl "Check executable '${_script}'.." + + if [[ -x "$_script" ]]; then + echo_ok + else + echo_failed + installation_ok=false + fi +done + +echononl "Check configured PostSRSd SRS domain.." + +if [[ "$(postsrsd_srs_domain)" == "$_SRS_DOMAIN" ]]; then + echo_ok +else + echo_failed + installation_ok=false +fi + + +# ---------- +# Check systemd services +# ---------- + +blank_line +echo -e "\033[37m\033[1mCheck selective SRS services..\033[m" +blank_line + +services_exist=true +services_active=true + +for _service in \ + "$POSTSRSD_SERVICE" \ + "$SRS_REVERSE_MAP_SERVICE" \ + "$SRS_DELIVERY_SERVICE" +do + echononl "Check systemd service '${_service}' exists.." + + if service_exists "$_service" ; then + echo_ok + else + echo_failed + services_exist=false + services_active=false + continue + fi + + if service_active "$_service" ; then + echononl "Check systemd service '${_service}' runtime state (active).." + echo_ok + else + echononl "Check systemd service '${_service}' runtime state (inactive).." + echo_ok + services_active=false + fi +done + +echononl "Check main Postfix service '${POSTFIX_SERVICE}' is active.." + +if service_active "$POSTFIX_SERVICE" ; then + echo_ok +else + echo_failed + services_active=false +fi + + +# ---------- +# Check listeners +# ---------- + +blank_line +echo -e "\033[37m\033[1mCheck selective SRS listeners..\033[m" +blank_line + +listeners_ok=true + +while IFS='|' read -r _label _host _port ; do + if port_is_listening "$_host" "$_port" ; then + echononl "Check ${_label} state on ${_host}:${_port} (listening).." + echo_ok + else + echononl "Check ${_label} state on ${_host}:${_port} (inactive).." + echo_ok + listeners_ok=false + fi +done <}" +echo "" + +if ! ask_yes_no "einverstanden" "no" ; then + warn "Activation cancelled by user. Nothing was changed." + clean_up 0 +fi + + +# ---------- +# Prepare rollback +# ---------- + +snapshot_service_states + +ACTIVATION_BACKUP_DIR="/root/postfix-srs-activation-${backup_date}" + +echononl "Create activation backup directory '${ACTIVATION_BACKUP_DIR}'.." +if mkdir -m 0700 "$ACTIVATION_BACKUP_DIR" > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal "$(cat "$log_file")" +fi + +echononl "Backup '${POSTFIX_MAIN_CF}'.." +if cp -a "$POSTFIX_MAIN_CF" "${ACTIVATION_BACKUP_DIR}/main.cf" > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal "$(cat "$log_file")" +fi + +echononl "Backup '${POSTFIX_MASTER_CF}'.." +if cp -a "$POSTFIX_MASTER_CF" "${ACTIVATION_BACKUP_DIR}/master.cf" > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal "$(cat "$log_file")" +fi + +echononl "Backup existing systemd SRS drop-ins.." +if snapshot_systemd_dropins > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal "$(cat "$log_file")" +fi + +ACTIVATION_CHANGES_STARTED=true + + +# ---------- +# Activate Postfix integration +# ---------- + +blank_line +echo -e "\033[37m\033[1mActivate selective SRS Postfix integration..\033[m" +blank_line + +echononl "Add managed SRS block to '${POSTFIX_MAIN_CF}'.." +if append_main_cf_managed_block > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal_with_activation_rollback "Could not add managed SRS block to '${POSTFIX_MAIN_CF}'." +fi + +echononl "Add managed SRS service block to '${POSTFIX_MASTER_CF}'.." +if append_master_cf_managed_block > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal_with_activation_rollback "Could not add managed SRS block to '${POSTFIX_MASTER_CF}'." +fi + +echononl "Set port 25 receive_override_options.." +if update_master_service_option \ + smtp \ + receive_override_options \ + "$smtp_active_value" > "$log_file" 2>&1 +then + echo_ok +else + echo_failed + fatal_with_activation_rollback "Could not update port 25 receive_override_options." +fi + +echononl "Set AmaViS reinjection receive_override_options.." +if update_master_service_option \ + localhost:10025 \ + receive_override_options \ + "$amavis_active_value" > "$log_file" 2>&1 +then + echo_ok +else + echo_failed + fatal_with_activation_rollback "Could not update AmaViS reinjection receive_override_options." +fi + +echononl "Check Postfix configuration.." +if postfix check > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal_with_activation_rollback "Postfix configuration check failed. $(cat "$log_file")" +fi + + +# ---------- +# Install systemd dependencies and start/enable SRS runtime services +# ---------- + +blank_line +echo -e "\033[37m\033[1mStart selective SRS runtime services..\033[m" +blank_line + +echononl "Install systemd SRS dependency drop-ins.." +if write_srs_systemd_dropins > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal_with_activation_rollback "Could not install systemd SRS dependency drop-ins. $(cat "$log_file")" +fi + +for _service in \ + "$POSTSRSD_SERVICE" \ + "$SRS_REVERSE_MAP_SERVICE" \ + "$SRS_DELIVERY_SERVICE" +do + echononl "Enable systemd service '${_service}'.." + + if systemctl enable "$_service" > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + fatal_with_activation_rollback "Could not enable systemd service '${_service}'. $(cat "$log_file")" + fi + + echononl "Start systemd service '${_service}'.." + + if systemctl start "$_service" > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + fatal_with_activation_rollback "Could not start systemd service '${_service}'. $(cat "$log_file")" + fi +done + + +# ---------- +# Verify listeners before Postfix reload +# ---------- + +for _endpoint in \ + "PostSRSd socketmap|${POSTSRSD_SOCKET_HOST}|${POSTSRSD_SOCKET_PORT}" \ + "SRS reverse socketmap|${SRS_REVERSE_SOCKET_HOST}|${SRS_REVERSE_SOCKET_PORT}" \ + "SRS reinjection listener|${SRS_REINJECT_HOST}|${SRS_REINJECT_PORT}" \ + "SRS reverse listener|${SRS_REVERSE_HOST}|${SRS_REVERSE_PORT}" +do + IFS='|' read -r _label _host _port <<< "$_endpoint" + + echononl "Verify ${_label} on ${_host}:${_port}.." + + if port_is_listening "$_host" "$_port" ; then + echo_ok + else + echo_failed + fatal_with_activation_rollback "${_label} is not listening on ${_host}:${_port}." + fi +done + + +# ---------- +# Reload main Postfix +# ---------- + +blank_line +echononl "Reload main Postfix service '${POSTFIX_SERVICE}'.." + +if systemctl reload "$POSTFIX_SERVICE" > "$log_file" 2>&1 ; then + POSTFIX_RELOADED=true + echo_ok +else + echo_failed + fatal_with_activation_rollback "Could not reload '${POSTFIX_SERVICE}'. $(cat "$log_file")" +fi + + +# ---------- +# Final verification +# ---------- + +blank_line +echo -e "\033[37m\033[1mVerify activated selective SRS configuration..\033[m" +blank_line + +verification_ok=true + +echononl "Verify managed main.cf block.." +if check_managed_block_markers \ + "$POSTFIX_MAIN_CF" \ + "$SRS_MAIN_CF_BEGIN_MARKER" \ + "$SRS_MAIN_CF_END_MARKER" +then + echo_ok +else + echo_failed + verification_ok=false +fi + +echononl "Verify managed master.cf block.." +if check_managed_block_markers \ + "$POSTFIX_MASTER_CF" \ + "$SRS_MASTER_CF_BEGIN_MARKER" \ + "$SRS_MASTER_CF_END_MARKER" +then + echo_ok +else + echo_failed + verification_ok=false +fi + +echononl "Verify 'default_transport = srs-forward'.." +if [[ "$(main_cf_explicit_value default_transport)" == "srs-forward" ]]; then + echo_ok +else + echo_failed + verification_ok=false +fi + +echononl "Verify SRS recipient canonical map.." +if [[ "$(main_cf_explicit_value recipient_canonical_maps)" == \ + "socketmap:inet:${SRS_REVERSE_SOCKET_HOST}:${SRS_REVERSE_SOCKET_PORT}:reverse" ]] +then + echo_ok +else + echo_failed + verification_ok=false +fi + +echononl "Verify 'srs-forward' pipe service.." +if master_srs_forward_present ; then + echo_ok +else + echo_failed + verification_ok=false +fi + +echononl "Verify port 25 receive_override_options.." +if [[ "$(master_service_option_value smtp receive_override_options)" == "$smtp_active_value" ]]; then + echo_ok +else + echo_failed + verification_ok=false +fi + +echononl "Verify AmaViS reinjection receive_override_options.." +if [[ "$(master_service_option_value localhost:10025 receive_override_options)" == "$amavis_active_value" ]]; then + echo_ok +else + echo_failed + verification_ok=false +fi + +for _service in \ + "$POSTSRSD_SERVICE" \ + "$SRS_REVERSE_MAP_SERVICE" \ + "$SRS_DELIVERY_SERVICE" +do + echononl "Verify systemd service '${_service}' is active.." + + if service_active "$_service" ; then + echo_ok + else + echo_failed + verification_ok=false + fi +done + +echononl "Final Postfix configuration check.." +if postfix check > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + verification_ok=false +fi + +if ! $verification_ok ; then + fatal_with_activation_rollback "Final selective SRS verification failed." +fi + + +# ---------- +# Success +# ---------- + +ACTIVATION_CHANGES_STARTED=false + +blank_line +info "Selective SRS was successfully activated." + +echo " Current selective SRS state....................: ENABLED" +echo " Activation backup..............................: ${ACTIVATION_BACKUP_DIR}" +echo "" + +clean_up 0 diff --git a/deactivate-srs.sh b/deactivate-srs.sh new file mode 100755 index 0000000..8a0558c --- /dev/null +++ b/deactivate-srs.sh @@ -0,0 +1,1880 @@ +#!/usr/bin/env bash + +# ---------- +# Selective SRS deactivation +# +# Restores the Postfix configuration state recorded before selective SRS +# was activated. +# +# The script is intentionally conservative: +# +# - DISABLED -> no changes, rc=0 +# - ENABLED -> deactivate after explicit confirmation +# - INCONSISTENT -> abort without changes +# +# Persistent pre-SRS state is read from: +# +# /etc/postfix-srs/state.conf +# +# The state file and installed SRS components are intentionally retained. +# ---------- + + +# ---------- +# Script setup +# ---------- + +working_dir="$(dirname "$(realpath "$0")")" + +# shellcheck source=helpers/srs-common.sh +source "${working_dir}/helpers/srs-common.sh" + +trap 'handle_signal' SIGHUP SIGINT SIGTERM + +init_srs_runtime + + +# ---------- +# Deactivation runtime state +# ---------- + +DEACTIVATION_CHANGES_STARTED=false +DEACTIVATION_BACKUP_DIR="" + +POSTFIX_SRS_DROPIN_WAS_PRESENT=false +SRS_DELIVERY_DROPIN_WAS_PRESENT=false + +declare -A SERVICE_WAS_ACTIVE +declare -A SERVICE_WAS_ENABLED + + +# ---------- +# Local helper functions +# ---------- + +main_cf_explicit_value() { + local parameter="$1" + + awk -v parameter="$parameter" ' + /^[[:space:]]*#/ { next } + + $0 ~ "^[[:space:]]*" parameter "[[:space:]]*=" { + line=$0 + } + + END { + if (line != "") { + sub("^[[:space:]]*" parameter "[[:space:]]*=[[:space:]]*", "", line) + print line + } + } + ' "$POSTFIX_MAIN_CF" +} + + +main_cf_parameter_is_set() { + local parameter="$1" + + grep -Eq \ + "^[[:space:]]*${parameter}[[:space:]]*=" \ + "$POSTFIX_MAIN_CF" +} + + +master_service_option_value() { + local service="$1" + local option="$2" + + awk -v service="$service" -v option="$option" ' + /^[[:space:]]*#/ { next } + + $1 == service && $2 == "inet" { + in_service=1 + next + } + + in_service && $0 !~ /^[[:space:]]/ { + exit + } + + in_service && $1 == "-o" { + prefix=option "=" + + if (index($2, prefix) == 1) { + value=$2 + sub("^" option "=", "", value) + print value + exit + } + } + ' "$POSTFIX_MASTER_CF" +} + + +master_srs_forward_present() { + awk ' + /^[[:space:]]*#/ { next } + + $1 == "srs-forward" && $2 == "unix" { + found_service=1 + next + } + + found_service && $0 !~ /^[[:space:]]/ { + exit + } + + found_service && $0 ~ /argv=\/usr\/local\/sbin\/srs-forward\.py/ { + found_argv=1 + } + + END { + if (found_service && found_argv) + exit 0 + exit 1 + } + ' "$POSTFIX_MASTER_CF" +} + + +port_is_listening() { + local host="$1" + local port="$2" + + ss -ltnH 2>/dev/null | + awk -v host="$host" -v port="$port" ' + { + local_address=$4 + + if (local_address == host ":" port || + local_address == "[" host "]:" port) { + found=1 + exit + } + } + + END { + exit(found ? 0 : 1) + } + ' +} + + +service_exists() { + systemctl cat "$1" > /dev/null 2>&1 +} + + +service_active() { + systemctl is-active --quiet "$1" +} + + +service_enabled_state() { + systemctl is-enabled "$1" 2>/dev/null || true +} + + +service_enabled() { + case "$(service_enabled_state "$1")" in + enabled|enabled-runtime) + return 0 + ;; + *) + return 1 + ;; + esac +} + + +postsrsd_srs_domain() { + awk ' + /^[[:space:]]*#/ { next } + + /^[[:space:]]*srs-domain[[:space:]]*=/ { + line=$0 + sub(/^[^=]*=[[:space:]]*/, "", line) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", line) + gsub(/^"|"$/, "", line) + print line + exit + } + ' "$POSTSRSD_CONF_FILE" +} + + +state_main_cf_matches() { + local parameter="$1" + local was_set="$2" + local expected_value="$3" + local current_value="" + + if "$was_set" ; then + if ! main_cf_parameter_is_set "$parameter" ; then + return 1 + fi + + current_value="$(main_cf_explicit_value "$parameter")" + [[ "$current_value" == "$expected_value" ]] + return + fi + + ! main_cf_parameter_is_set "$parameter" +} + + +state_master_option_matches() { + local service="$1" + local option="$2" + local was_set="$3" + local expected_value="$4" + local current_value="" + + current_value="$(master_service_option_value "$service" "$option")" + + if "$was_set" ; then + [[ "$current_value" == "$expected_value" ]] + return + fi + + [[ -z "$current_value" ]] +} + + +csv_add_value() { + local current="$1" + local add="$2" + local item + local result="" + local found=false + + IFS=',' read -r -a _items <<< "$current" + + for item in "${_items[@]}" ; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + + [[ -z "$item" ]] && continue + + if [[ "$item" == "$add" ]]; then + found=true + fi + + if [[ -z "$result" ]]; then + result="$item" + else + result="${result},${item}" + fi + done + + if ! $found ; then + if [[ -z "$result" ]]; then + result="$add" + else + result="${result},${add}" + fi + fi + + printf '%s\n' "$result" +} + + +csv_remove_value() { + local current="$1" + local remove="$2" + local item + local result="" + + IFS=',' read -r -a _items <<< "$current" + + for item in "${_items[@]}" ; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + + [[ -z "$item" ]] && continue + [[ "$item" == "$remove" ]] && continue + + if [[ -z "$result" ]]; then + result="$item" + else + result="${result},${item}" + fi + done + + printf '%s\n' "$result" +} + + +update_master_service_option() { + local service="$1" + local option="$2" + local value="$3" + + python3 - \ + "$POSTFIX_MASTER_CF" \ + "$service" \ + "$option" \ + "$value" <<'PY_EOF' +import os +import re +import stat +import sys +import tempfile + +path, service, option, value = sys.argv[1:5] + +with open(path, "r", encoding="utf-8") as f: + lines = f.readlines() + +service_index = None + +for idx, line in enumerate(lines): + if not line.strip() or line.lstrip().startswith("#"): + continue + + fields = line.split() + + if len(fields) >= 2 and fields[0] == service and fields[1] == "inet": + service_index = idx + break + +if service_index is None: + raise SystemExit(2) + +end_index = len(lines) + +for idx in range(service_index + 1, len(lines)): + line = lines[idx] + + if line.strip() == "": + end_index = idx + break + + if line[0].isspace(): + continue + + if line.lstrip().startswith("#"): + end_index = idx + break + + end_index = idx + break + +option_re = re.compile( + r"^(?P\s*)-o\s+" + re.escape(option) + r"=.*(?:\n)?$" +) + +option_index = None +option_indent = " " + +for idx in range(service_index + 1, end_index): + match = option_re.match(lines[idx]) + + if match: + option_index = idx + option_indent = match.group("indent") or " " + break + +if value: + new_line = f"{option_indent}-o {option}={value}\n" + + if option_index is not None: + lines[option_index] = new_line + else: + lines.insert(end_index, new_line) +else: + if option_index is not None: + del lines[option_index] + +st = os.stat(path) +directory = os.path.dirname(path) + +fd, tmp_path = tempfile.mkstemp(prefix=".master.cf.srs.", dir=directory) + +try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.writelines(lines) + + os.chmod(tmp_path, stat.S_IMODE(st.st_mode)) + os.chown(tmp_path, st.st_uid, st.st_gid) + os.replace(tmp_path, path) +finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) +PY_EOF +} + + +remove_managed_block() { + local file="$1" + local begin_marker="$2" + local end_marker="$3" + + python3 - \ + "$file" \ + "$begin_marker" \ + "$end_marker" <<'PY_EOF' +import os +import stat +import sys +import tempfile + +path, begin_marker, end_marker = sys.argv[1:4] + +with open(path, "r", encoding="utf-8") as f: + lines = f.readlines() + +begin_matches = [ + idx for idx, line in enumerate(lines) + if line.rstrip("\n") == begin_marker +] + +end_matches = [ + idx for idx, line in enumerate(lines) + if line.rstrip("\n") == end_marker +] + +if len(begin_matches) != 1 or len(end_matches) != 1: + raise SystemExit(2) + +begin_idx = begin_matches[0] +end_idx = end_matches[0] + +if begin_idx >= end_idx: + raise SystemExit(3) + +new_lines = lines[:begin_idx] + lines[end_idx + 1:] + +# Remove one excessive blank line left at the join point. +while ( + begin_idx > 0 + and begin_idx < len(new_lines) + and new_lines[begin_idx - 1].strip() == "" + and new_lines[begin_idx].strip() == "" +): + del new_lines[begin_idx] + +st = os.stat(path) +directory = os.path.dirname(path) + +fd, tmp_path = tempfile.mkstemp(prefix=".srs-managed.", dir=directory) + +try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.writelines(new_lines) + + os.chmod(tmp_path, stat.S_IMODE(st.st_mode)) + os.chown(tmp_path, st.st_uid, st.st_gid) + os.replace(tmp_path, path) +finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) +PY_EOF +} + + +restore_main_cf_parameter() { + local parameter="$1" + local was_set="$2" + local value="$3" + + python3 - \ + "$POSTFIX_MAIN_CF" \ + "$parameter" \ + "$was_set" \ + "$value" <<'PY_EOF' +import os +import re +import stat +import sys +import tempfile + +path, parameter, was_set, value = sys.argv[1:5] + +with open(path, "r", encoding="utf-8") as f: + lines = f.readlines() + +pattern = re.compile( + r"^[ \t]*" + re.escape(parameter) + r"[ \t]*=" +) + +new_lines = [] +skip_continuation = False + +for line in lines: + if skip_continuation: + if line.startswith((" ", "\t")) and not line.lstrip().startswith("#"): + continue + skip_continuation = False + + if line.lstrip().startswith("#"): + new_lines.append(line) + continue + + if pattern.match(line): + skip_continuation = True + continue + + new_lines.append(line) + +if was_set == "true": + if new_lines and new_lines[-1].strip() != "": + new_lines.append("\n") + + new_lines.append(f"{parameter} = {value}\n") + +st = os.stat(path) +directory = os.path.dirname(path) + +fd, tmp_path = tempfile.mkstemp(prefix=".main.cf.srs.", dir=directory) + +try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.writelines(new_lines) + + os.chmod(tmp_path, stat.S_IMODE(st.st_mode)) + os.chown(tmp_path, st.st_uid, st.st_gid) + os.replace(tmp_path, path) +finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) +PY_EOF +} + + +remove_legacy_srs_forward_service() { + + python3 - "$POSTFIX_MASTER_CF" <<'PY_EOF' +import os +import stat +import sys +import tempfile + +path = sys.argv[1] + +with open(path, "r", encoding="utf-8") as f: + lines = f.readlines() + +start = None + +for idx, line in enumerate(lines): + if not line.strip() or line.lstrip().startswith("#"): + continue + + fields = line.split() + + if len(fields) >= 2 and fields[0] == "srs-forward" and fields[1] == "unix": + if start is not None: + raise SystemExit(2) + start = idx + +if start is None: + raise SystemExit(3) + +end = len(lines) + +for idx in range(start + 1, len(lines)): + line = lines[idx] + + if line.strip() == "": + end = idx + break + + if line[0].isspace(): + continue + + if line.lstrip().startswith("#"): + end = idx + break + + end = idx + break + +block = "".join(lines[start:end]) + +required = [ + "flags=Rq null_sender= user=nobody", + "argv=/usr/local/sbin/srs-forward.py", + "${sender}", + "${recipient}", + "${original_recipient}", + "${queue_id}", + "envid=${envid}", +] + +for token in required: + if token not in block: + raise SystemExit(4) + +new_lines = lines[:start] + lines[end:] + +while ( + start > 0 + and start < len(new_lines) + and new_lines[start - 1].strip() == "" + and new_lines[start].strip() == "" +): + del new_lines[start] + +st = os.stat(path) +directory = os.path.dirname(path) + +fd, tmp_path = tempfile.mkstemp(prefix=".master.cf.srs.", dir=directory) + +try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.writelines(new_lines) + + os.chmod(tmp_path, stat.S_IMODE(st.st_mode)) + os.chown(tmp_path, st.st_uid, st.st_gid) + os.replace(tmp_path, path) +finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) +PY_EOF +} + + +systemd_dropins_match_expected() { + + [[ -r "$POSTFIX_SRS_DROPIN_FILE" ]] || return 1 + [[ -r "$SRS_DELIVERY_DROPIN_FILE" ]] || return 1 + + grep -Fxq \ + "Wants=${SRS_REVERSE_MAP_SERVICE} ${SRS_DELIVERY_SERVICE}" \ + "$POSTFIX_SRS_DROPIN_FILE" || return 1 + + grep -Fxq \ + "After=${SRS_REVERSE_MAP_SERVICE} ${SRS_DELIVERY_SERVICE}" \ + "$POSTFIX_SRS_DROPIN_FILE" || return 1 + + grep -Fxq \ + "Wants=${POSTSRSD_SERVICE}" \ + "$SRS_DELIVERY_DROPIN_FILE" || return 1 + + grep -Fxq \ + "After=${POSTSRSD_SERVICE}" \ + "$SRS_DELIVERY_DROPIN_FILE" || return 1 + + return 0 +} + + +snapshot_systemd_dropins() { + + if [[ -f "$POSTFIX_SRS_DROPIN_FILE" ]]; then + POSTFIX_SRS_DROPIN_WAS_PRESENT=true + cp -a "$POSTFIX_SRS_DROPIN_FILE" \ + "${DEACTIVATION_BACKUP_DIR}/postfix.service-srs.conf" || return 1 + fi + + if [[ -f "$SRS_DELIVERY_DROPIN_FILE" ]]; then + SRS_DELIVERY_DROPIN_WAS_PRESENT=true + cp -a "$SRS_DELIVERY_DROPIN_FILE" \ + "${DEACTIVATION_BACKUP_DIR}/postfix@srs-delivery.service-srs.conf" || return 1 + fi + + return 0 +} + + +remove_srs_systemd_dropins() { + + rm -f \ + "$POSTFIX_SRS_DROPIN_FILE" \ + "$SRS_DELIVERY_DROPIN_FILE" || return 1 + + systemctl daemon-reload || return 1 + + return 0 +} + + +restore_systemd_dropins() { + local restore_failed=false + + if $POSTFIX_SRS_DROPIN_WAS_PRESENT ; then + mkdir -p "$POSTFIX_SRS_DROPIN_DIR" || restore_failed=true + + cp -a \ + "${DEACTIVATION_BACKUP_DIR}/postfix.service-srs.conf" \ + "$POSTFIX_SRS_DROPIN_FILE" || restore_failed=true + else + rm -f "$POSTFIX_SRS_DROPIN_FILE" || restore_failed=true + fi + + if $SRS_DELIVERY_DROPIN_WAS_PRESENT ; then + mkdir -p "$SRS_DELIVERY_DROPIN_DIR" || restore_failed=true + + cp -a \ + "${DEACTIVATION_BACKUP_DIR}/postfix@srs-delivery.service-srs.conf" \ + "$SRS_DELIVERY_DROPIN_FILE" || restore_failed=true + else + rm -f "$SRS_DELIVERY_DROPIN_FILE" || restore_failed=true + fi + + systemctl daemon-reload || restore_failed=true + + ! $restore_failed +} + + +snapshot_service_states() { + local service + + for service in \ + "$POSTSRSD_SERVICE" \ + "$SRS_REVERSE_MAP_SERVICE" \ + "$SRS_DELIVERY_SERVICE" + do + if service_active "$service" ; then + SERVICE_WAS_ACTIVE["$service"]="true" + else + SERVICE_WAS_ACTIVE["$service"]="false" + fi + + SERVICE_WAS_ENABLED["$service"]="$(service_enabled_state "$service")" + done +} + + +restore_service_states() { + local service + local restore_failed=false + local enabled_state + + for service in \ + "$POSTSRSD_SERVICE" \ + "$SRS_REVERSE_MAP_SERVICE" \ + "$SRS_DELIVERY_SERVICE" + do + enabled_state="${SERVICE_WAS_ENABLED[$service]:-}" + + case "$enabled_state" in + enabled|enabled-runtime) + systemctl enable "$service" > /dev/null 2>> "$log_file" || restore_failed=true + ;; + disabled) + systemctl disable "$service" > /dev/null 2>> "$log_file" || restore_failed=true + ;; + esac + + if [[ "${SERVICE_WAS_ACTIVE[$service]:-false}" == "true" ]]; then + systemctl start "$service" > /dev/null 2>> "$log_file" || restore_failed=true + else + systemctl stop "$service" > /dev/null 2>> "$log_file" || restore_failed=true + fi + done + + ! $restore_failed +} + + +srs_delivery_queue_is_empty() { + local queue_data + + queue_data="$( + postqueue -c "$POSTFIX_SRS_DELIVERY_DIR" -j 2>> "$log_file" + )" || return 2 + + [[ -z "${queue_data//[[:space:]]/}" ]] +} + + +rollback_deactivation() { + local rollback_failed=false + + if ! $DEACTIVATION_CHANGES_STARTED ; then + return 0 + fi + + blank_line + warn "SRS deactivation failed. Trying to restore the previous state." + + echononl "Restore '${POSTFIX_MAIN_CF}'.." + if cp -a "${DEACTIVATION_BACKUP_DIR}/main.cf" "$POSTFIX_MAIN_CF" > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + rollback_failed=true + fi + + echononl "Restore '${POSTFIX_MASTER_CF}'.." + if cp -a "${DEACTIVATION_BACKUP_DIR}/master.cf" "$POSTFIX_MASTER_CF" > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + rollback_failed=true + fi + + echononl "Restore previous systemd SRS drop-ins.." + if restore_systemd_dropins ; then + echo_ok + else + echo_failed + rollback_failed=true + fi + + echononl "Restore previous SRS service states.." + if restore_service_states ; then + echo_ok + else + echo_failed + rollback_failed=true + fi + + echononl "Check restored Postfix configuration.." + if postfix check > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + rollback_failed=true + fi + + if systemctl is-active --quiet "$POSTFIX_SERVICE" ; then + echononl "Reload restored Postfix configuration.." + + if systemctl reload "$POSTFIX_SERVICE" > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + rollback_failed=true + fi + fi + + if $rollback_failed ; then + error "Automatic rollback could not fully restore the previous state. Manual intervention is required." + return 1 + fi + + info "Previous enabled SRS state was successfully restored." + return 0 +} + + +fatal_with_deactivation_rollback() { + local message="$1" + + if $DEACTIVATION_CHANGES_STARTED ; then + if rollback_deactivation ; then + DEACTIVATION_CHANGES_STARTED=false + fatal "${message} The previous state was successfully restored." + else + fatal "${message} Automatic rollback could not fully restore the previous state." + fi + fi + + fatal "$message" +} + + +handle_signal() { + trap - SIGHUP SIGINT SIGTERM + + warn "Deactivation interrupted by signal." + + if $DEACTIVATION_CHANGES_STARTED ; then + rollback_deactivation + fi + + clean_up 1 +} + + +# ---------- +# Header +# ---------- + +blank_line +echo -e "\033[1m----------\033[m" +echo -e "\033[32m\033[1mRunning script \033[m\033[1m${script_name}\033[32m .. \033[m" +echo -e "\033[1m----------\033[m" +blank_line + + +# ---------- +# Basic checks +# ---------- + +check_root + +check_required_commands \ + awk \ + cp \ + cut \ + grep \ + hostname \ + mkdir \ + mktemp \ + mv \ + postfix \ + postqueue \ + python3 \ + realpath \ + rm \ + sed \ + ss \ + stat \ + systemctl + +load_srs_config +load_srs_state + + +# ---------- +# Expected active master.cf values +# ---------- + +smtp_pre_value="" +amavis_pre_value="" + +if $_PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS_SET ; then + smtp_pre_value="$_PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS" +fi + +if $_PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS_SET ; then + amavis_pre_value="$_PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS" +fi + +smtp_active_value="$(csv_add_value "$smtp_pre_value" "no_address_mappings")" +amavis_active_value="$(csv_remove_value "$amavis_pre_value" "no_address_mappings")" + + +# ---------- +# Check installation +# ---------- + +blank_line +echo -e "\033[37m\033[1mCheck selective SRS installation..\033[m" +blank_line + +installation_ok=true + +for _file in \ + "$POSTFIX_MAIN_CF" \ + "$POSTFIX_MASTER_CF" \ + "$POSTSRSD_CONF_FILE" \ + "$POSTSRSD_SECRET_FILE" \ + "${POSTFIX_SRS_DELIVERY_DIR}/main.cf" \ + "${POSTFIX_SRS_DELIVERY_DIR}/master.cf" \ + "${POSTFIX_SRS_DELIVERY_DIR}/transport" \ + "${POSTFIX_SRS_DELIVERY_DIR}/transport.db" +do + echononl "Check file '${_file}'.." + + if [[ -r "$_file" ]]; then + echo_ok + else + echo_failed + installation_ok=false + fi +done + +for _script in \ + "$SRS_ALIAS_DECISION_SCRIPT" \ + "$SRS_FORWARD_SCRIPT" \ + "$SRS_REVERSE_MAP_SCRIPT" \ + "$SRS_REVERSE_POLICY_SCRIPT" +do + echononl "Check executable '${_script}'.." + + if [[ -x "$_script" ]]; then + echo_ok + else + echo_failed + installation_ok=false + fi +done + +echononl "Check configured PostSRSd SRS domain.." +if [[ "$(postsrsd_srs_domain)" == "$_SRS_DOMAIN" ]]; then + echo_ok +else + echo_failed + installation_ok=false +fi + + +# ---------- +# Check services and listeners +# ---------- + +blank_line +echo -e "\033[37m\033[1mCheck selective SRS runtime..\033[m" +blank_line + +services_exist=true +services_active=true +services_enabled=true + +for _service in \ + "$POSTSRSD_SERVICE" \ + "$SRS_REVERSE_MAP_SERVICE" \ + "$SRS_DELIVERY_SERVICE" +do + echononl "Check systemd service '${_service}' exists.." + + if service_exists "$_service" ; then + echo_ok + else + echo_failed + services_exist=false + services_active=false + services_enabled=false + continue + fi + + echononl "Check systemd service '${_service}' is active.." + if service_active "$_service" ; then + echo_ok + else + echo_inactive + services_active=false + fi + + if service_enabled "$_service" ; then + echononl "Check systemd service '${_service}' enable state (enabled).." + echo_ok + else + echononl "Check systemd service '${_service}' enable state (disabled).." + echo_ok + services_enabled=false + fi +done + +listeners_active=true + +while IFS='|' read -r _label _host _port ; do + if port_is_listening "$_host" "$_port" ; then + echononl "Check ${_label} state on ${_host}:${_port} (listening).." + echo_ok + else + echononl "Check ${_label} state on ${_host}:${_port} (inactive).." + echo_ok + listeners_active=false + fi +done <}" +echo " PRE-SRS AmaViS receive_override_options.......: ${_PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS:-}" +echo "" +echo " SRS configuration and binaries will remain installed." +echo " ${SRS_STATE_FILE} will NOT be deleted." +echo "" + +if ! ask_yes_no "einverstanden" "no" ; then + warn "Deactivation cancelled by user. Nothing was changed." + clean_up 0 +fi + + +# ---------- +# Prepare rollback +# ---------- + +snapshot_service_states + +DEACTIVATION_BACKUP_DIR="/root/postfix-srs-deactivation-${backup_date}" + +echononl "Create deactivation backup directory '${DEACTIVATION_BACKUP_DIR}'.." +if mkdir -m 0700 "$DEACTIVATION_BACKUP_DIR" > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal "$(cat "$log_file")" +fi + +echononl "Backup '${POSTFIX_MAIN_CF}'.." +if cp -a "$POSTFIX_MAIN_CF" "${DEACTIVATION_BACKUP_DIR}/main.cf" > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal "$(cat "$log_file")" +fi + +echononl "Backup '${POSTFIX_MASTER_CF}'.." +if cp -a "$POSTFIX_MASTER_CF" "${DEACTIVATION_BACKUP_DIR}/master.cf" > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal "$(cat "$log_file")" +fi + +echononl "Backup systemd SRS drop-ins.." +if snapshot_systemd_dropins > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal "$(cat "$log_file")" +fi + +DEACTIVATION_CHANGES_STARTED=true + + +# ---------- +# Restore main.cf +# ---------- + +blank_line +echo -e "\033[37m\033[1mRestore pre-SRS Postfix configuration..\033[m" +blank_line + +echononl "Remove managed SRS block from '${POSTFIX_MAIN_CF}'.." +if remove_managed_block \ + "$POSTFIX_MAIN_CF" \ + "$SRS_MAIN_CF_BEGIN_MARKER" \ + "$SRS_MAIN_CF_END_MARKER" > "$log_file" 2>&1 +then + echo_ok +else + echo_failed + fatal_with_deactivation_rollback "Could not remove managed SRS block from '${POSTFIX_MAIN_CF}'." +fi + +echononl "Restore pre-SRS 'default_transport'.." +if restore_main_cf_parameter \ + default_transport \ + "$_PRE_SRS_DEFAULT_TRANSPORT_SET" \ + "$_PRE_SRS_DEFAULT_TRANSPORT" > "$log_file" 2>&1 +then + echo_ok +else + echo_failed + fatal_with_deactivation_rollback "Could not restore pre-SRS 'default_transport'." +fi + +echononl "Restore pre-SRS 'recipient_canonical_maps'.." +if restore_main_cf_parameter \ + recipient_canonical_maps \ + "$_PRE_SRS_RECIPIENT_CANONICAL_MAPS_SET" \ + "$_PRE_SRS_RECIPIENT_CANONICAL_MAPS" > "$log_file" 2>&1 +then + echo_ok +else + echo_failed + fatal_with_deactivation_rollback "Could not restore pre-SRS 'recipient_canonical_maps'." +fi + +echononl "Restore pre-SRS 'srs-forward_destination_recipient_limit'.." +if restore_main_cf_parameter \ + srs-forward_destination_recipient_limit \ + "$_PRE_SRS_SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT_SET" \ + "$_PRE_SRS_SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT" > "$log_file" 2>&1 +then + echo_ok +else + echo_failed + fatal_with_deactivation_rollback "Could not restore pre-SRS recipient limit." +fi + + +# ---------- +# Restore master.cf +# ---------- + +if [[ "$master_block_status" == "present" ]]; then + echononl "Remove managed SRS block from '${POSTFIX_MASTER_CF}'.." + + if remove_managed_block \ + "$POSTFIX_MASTER_CF" \ + "$SRS_MASTER_CF_BEGIN_MARKER" \ + "$SRS_MASTER_CF_END_MARKER" > "$log_file" 2>&1 + then + echo_ok + else + echo_failed + fatal_with_deactivation_rollback "Could not remove managed SRS block from '${POSTFIX_MASTER_CF}'." + fi +else + echononl "Remove legacy unmarked 'srs-forward' service.." + + if remove_legacy_srs_forward_service > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + fatal_with_deactivation_rollback "Could not safely remove legacy unmarked 'srs-forward' service." + fi +fi + +echononl "Restore pre-SRS port 25 receive_override_options.." +if update_master_service_option \ + smtp \ + receive_override_options \ + "$_PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS" > "$log_file" 2>&1 +then + if ! $_PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS_SET ; then + # An empty value means remove the option. + update_master_service_option smtp receive_override_options "" > "$log_file" 2>&1 || { + echo_failed + fatal_with_deactivation_rollback "Could not remove port 25 receive_override_options." + } + fi + echo_ok +else + echo_failed + fatal_with_deactivation_rollback "Could not restore port 25 receive_override_options." +fi + +echononl "Restore pre-SRS AmaViS receive_override_options.." +if $_PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS_SET ; then + _amavis_restore="$_PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS" +else + _amavis_restore="" +fi + +if update_master_service_option \ + localhost:10025 \ + receive_override_options \ + "$_amavis_restore" > "$log_file" 2>&1 +then + echo_ok +else + echo_failed + fatal_with_deactivation_rollback "Could not restore AmaViS receive_override_options." +fi + + +# ---------- +# Remove systemd SRS dependencies +# ---------- + +echononl "Remove systemd SRS dependency drop-ins.." +if remove_srs_systemd_dropins > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal_with_deactivation_rollback "Could not remove systemd SRS dependency drop-ins. $(cat "$log_file")" +fi + + +# ---------- +# Validate and reload main Postfix +# ---------- + +echononl "Check restored Postfix configuration.." +if postfix check > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal_with_deactivation_rollback "Postfix configuration check failed. $(cat "$log_file")" +fi + +echononl "Reload main Postfix service '${POSTFIX_SERVICE}'.." +if systemctl reload "$POSTFIX_SERVICE" > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal_with_deactivation_rollback "Could not reload '${POSTFIX_SERVICE}'. $(cat "$log_file")" +fi + + +# ---------- +# Stop and disable SRS services +# ---------- + +blank_line +echo -e "\033[37m\033[1mStop selective SRS runtime services..\033[m" +blank_line + +for _service in \ + "$SRS_DELIVERY_SERVICE" \ + "$SRS_REVERSE_MAP_SERVICE" \ + "$POSTSRSD_SERVICE" +do + echononl "Stop systemd service '${_service}'.." + + if systemctl stop "$_service" > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + fatal_with_deactivation_rollback "Could not stop systemd service '${_service}'. $(cat "$log_file")" + fi + + echononl "Disable systemd service '${_service}'.." + + if systemctl disable "$_service" > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + fatal_with_deactivation_rollback "Could not disable systemd service '${_service}'. $(cat "$log_file")" + fi +done + + +# ---------- +# Final verification +# ---------- + +blank_line +echo -e "\033[37m\033[1mVerify disabled selective SRS configuration..\033[m" +blank_line + +verification_ok=true + +echononl "Verify managed main.cf block is absent.." +check_managed_block_markers \ + "$POSTFIX_MAIN_CF" \ + "$SRS_MAIN_CF_BEGIN_MARKER" \ + "$SRS_MAIN_CF_END_MARKER" +_marker_rc=$? + +if [[ "$_marker_rc" -eq 1 ]]; then + echo_ok +else + echo_failed + verification_ok=false +fi + +echononl "Verify managed master.cf block is absent.." +check_managed_block_markers \ + "$POSTFIX_MASTER_CF" \ + "$SRS_MASTER_CF_BEGIN_MARKER" \ + "$SRS_MASTER_CF_END_MARKER" +_marker_rc=$? + +if [[ "$_marker_rc" -eq 1 ]]; then + echo_ok +else + echo_failed + verification_ok=false +fi + +echononl "Verify 'srs-forward' service is absent.." +if ! master_srs_forward_present ; then + echo_ok +else + echo_failed + verification_ok=false +fi + +echononl "Verify pre-SRS main.cf state.." +if state_main_cf_matches \ + default_transport \ + "$_PRE_SRS_DEFAULT_TRANSPORT_SET" \ + "$_PRE_SRS_DEFAULT_TRANSPORT" && + state_main_cf_matches \ + recipient_canonical_maps \ + "$_PRE_SRS_RECIPIENT_CANONICAL_MAPS_SET" \ + "$_PRE_SRS_RECIPIENT_CANONICAL_MAPS" && + state_main_cf_matches \ + srs-forward_destination_recipient_limit \ + "$_PRE_SRS_SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT_SET" \ + "$_PRE_SRS_SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT" +then + echo_ok +else + echo_failed + verification_ok=false +fi + +echononl "Verify pre-SRS master.cf state.." +if state_master_option_matches \ + smtp \ + receive_override_options \ + "$_PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS_SET" \ + "$_PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS" && + state_master_option_matches \ + localhost:10025 \ + receive_override_options \ + "$_PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS_SET" \ + "$_PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS" +then + echo_ok +else + echo_failed + verification_ok=false +fi + +echononl "Verify systemd SRS dependency drop-ins are absent.." +if [[ ! -e "$POSTFIX_SRS_DROPIN_FILE" && ! -e "$SRS_DELIVERY_DROPIN_FILE" ]]; then + echo_ok +else + echo_failed + verification_ok=false +fi + +for _service in \ + "$POSTSRSD_SERVICE" \ + "$SRS_REVERSE_MAP_SERVICE" \ + "$SRS_DELIVERY_SERVICE" +do + echononl "Verify systemd service '${_service}' is inactive.." + + if ! service_active "$_service" ; then + echo_ok + else + echo_failed + verification_ok=false + fi + + echononl "Verify systemd service '${_service}' is disabled.." + + if ! service_enabled "$_service" ; then + echo_ok + else + echo_failed + verification_ok=false + fi +done + +for _endpoint in \ + "PostSRSd socketmap|${POSTSRSD_SOCKET_HOST}|${POSTSRSD_SOCKET_PORT}" \ + "SRS reverse socketmap|${SRS_REVERSE_SOCKET_HOST}|${SRS_REVERSE_SOCKET_PORT}" \ + "SRS reinjection listener|${SRS_REINJECT_HOST}|${SRS_REINJECT_PORT}" \ + "SRS reverse listener|${SRS_REVERSE_HOST}|${SRS_REVERSE_PORT}" +do + IFS='|' read -r _label _host _port <<< "$_endpoint" + + echononl "Verify ${_label} is inactive.." + + if ! port_is_listening "$_host" "$_port" ; then + echo_ok + else + echo_failed + verification_ok=false + fi +done + +echononl "Final Postfix configuration check.." +if postfix check > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + verification_ok=false +fi + +if ! $verification_ok ; then + fatal_with_deactivation_rollback "Final selective SRS deactivation verification failed." +fi + + +# ---------- +# Success +# ---------- + +DEACTIVATION_CHANGES_STARTED=false + +blank_line +info "Selective SRS was successfully deactivated." + +echo " Current selective SRS state....................: DISABLED" +echo " Persistent state retained......................: ${SRS_STATE_FILE}" +echo " Deactivation backup............................: ${DEACTIVATION_BACKUP_DIR}" +echo "" + +clean_up 0 diff --git a/helpers/srs-common.sh b/helpers/srs-common.sh new file mode 100644 index 0000000..af1dd87 --- /dev/null +++ b/helpers/srs-common.sh @@ -0,0 +1,682 @@ +#!/usr/bin/env bash + +# ---------- +# Common functions and settings for selective SRS management scripts +# +# This file is intended to be sourced by: +# +# activate-srs.sh +# deactivate-srs.sh +# install-update-srs.sh +# +# It must not make configuration changes by itself. +# ---------- + +# ---------- +# Script paths +# ---------- + +SRS_COMMON_FILE="$(realpath "${BASH_SOURCE[0]}")" +SRS_HELPERS_DIR="$(dirname "$SRS_COMMON_FILE")" +SRS_SCRIPT_DIR="$(dirname "$SRS_HELPERS_DIR")" + +SRS_CONF_FILE="${SRS_SCRIPT_DIR}/conf/srs.conf" +SRS_CONF_SAMPLE_FILE="${SRS_SCRIPT_DIR}/conf/srs.conf.sample" + +# ---------- +# System paths +# ---------- + +POSTFIX_CONF_DIR="/etc/postfix" +POSTFIX_MAIN_CF="${POSTFIX_CONF_DIR}/main.cf" +POSTFIX_MASTER_CF="${POSTFIX_CONF_DIR}/master.cf" + +POSTFIX_SRS_DIR="/etc/postfix-srs" +POSTFIX_SRS_DELIVERY_DIR="${POSTFIX_SRS_DIR}/delivery" +SRS_STATE_FILE="${POSTFIX_SRS_DIR}/state.conf" + +POSTSRSD_CONF_DIR="/etc/postsrsd-srs" +POSTSRSD_CONF_FILE="${POSTSRSD_CONF_DIR}/postsrsd.conf" +POSTSRSD_SECRET_FILE="${POSTSRSD_CONF_DIR}/secret" + +SRS_ALIAS_DECISION_SCRIPT="/usr/local/sbin/srs-alias-decision.py" +SRS_FORWARD_SCRIPT="/usr/local/sbin/srs-forward.py" +SRS_REVERSE_MAP_SCRIPT="/usr/local/sbin/srs-reverse-map.py" +SRS_REVERSE_POLICY_SCRIPT="/usr/local/sbin/srs-reverse-policy.py" + +# ---------- +# Services +# ---------- + +POSTFIX_SERVICE="postfix.service" +POSTSRSD_SERVICE="postsrsd-srs.service" +SRS_REVERSE_MAP_SERVICE="srs-reverse-map.service" +SRS_DELIVERY_SERVICE="postfix@srs-delivery.service" + +POSTFIX_SRS_DROPIN_DIR="/etc/systemd/system/postfix.service.d" +POSTFIX_SRS_DROPIN_FILE="${POSTFIX_SRS_DROPIN_DIR}/srs.conf" + +SRS_DELIVERY_DROPIN_DIR="/etc/systemd/system/postfix@srs-delivery.service.d" +SRS_DELIVERY_DROPIN_FILE="${SRS_DELIVERY_DROPIN_DIR}/srs.conf" + +# ---------- +# SRS network endpoints +# ---------- + +POSTSRSD_SOCKET_HOST="127.0.0.1" +POSTSRSD_SOCKET_PORT="10003" + +SRS_REVERSE_SOCKET_HOST="127.0.0.1" +SRS_REVERSE_SOCKET_PORT="10004" + +SRS_REINJECT_HOST="127.0.0.1" +SRS_REINJECT_PORT="10028" + +SRS_REVERSE_HOST="127.0.0.1" +SRS_REVERSE_PORT="10029" + +# ---------- +# Managed block markers +# ---------- + +SRS_MAIN_CF_BEGIN_MARKER="# BEGIN OOPEN-SRS-MANAGED" +SRS_MAIN_CF_END_MARKER="# END OOPEN-SRS-MANAGED" + +SRS_MASTER_CF_BEGIN_MARKER="# BEGIN OOPEN-SRS-MANAGED" +SRS_MASTER_CF_END_MARKER="# END OOPEN-SRS-MANAGED" + +# ---------- +# Runtime variables +# ---------- + +script_name="$(basename "$0")" +terminal=false + +SRS_LOCK_DIR="/run/lock/oopen-srs.lock" +SRS_LOCK_OWNER_FILE="${SRS_LOCK_DIR}/pid" + +log_file="" +backup_date="$(date +%Y-%m-%d-%H%M%S)" + +SRS_LOCK_ACQUIRED=false +SRS_CONFIG_LOADED=false +SRS_STATE_LOADED=false + +# ---------- +# Base functions +# ---------- + +blank_line() { + if $terminal ; then + echo "" + fi +} + +echononl() { + if $terminal ; then + echo -e -n " $*" 1>&2 + fi +} + +echo_ok() { + if $terminal ; then + echo -e "\033[85G[ \033[32mok\033[m ]" + fi +} + +echo_failed() { + if $terminal ; then + echo -e "\033[85G[ \033[1;31mfailed\033[m ]" + fi +} + +echo_skipped() { + if $terminal ; then + echo -e "\033[85G[ \033[33m\033[1mskipped\033[m ]" + fi +} + +echo_warning() { + if $terminal ; then + echo -e "\033[85G[ \033[33m\033[1mwarn\033[m ]" + fi +} + + +echo_differs() { + if $terminal ; then + echo -e "\033[85G[ \033[33m\033[1mdiffers\033[m ]" + fi +} + + +echo_inactive() { + if $terminal ; then + echo -e "\033[85G[ \033[33m\033[1minactive\033[m ]" + fi +} + + +echo_absent() { + if $terminal ; then + echo -e "\033[85G[ \033[33m\033[1mabsent\033[m ]" + fi +} + +info() { + if $terminal ; then + echo "" + echo -e " [ \033[32m\033[1mInfo\033[m ] $*" + echo "" + else + echo " [ Info ] $*" + fi +} + +warn() { + echo "" + if $terminal ; then + echo -e " [ \033[33m\033[1mWarning\033[m ] $*" + else + echo " [ Warning ] $*" + fi + echo "" +} + +error() { + echo "" + if $terminal ; then + echo -e " [ \033[31m\033[1mError\033[m ] $*" + else + echo " [ Error ] $*" + fi + echo "" +} + +clean_up() { + local rc="${1:-0}" + + if $SRS_LOCK_ACQUIRED && [[ -d "$SRS_LOCK_DIR" ]]; then + rm -rf "$SRS_LOCK_DIR" + SRS_LOCK_ACQUIRED=false + fi + + [[ -n "$log_file" && -f "$log_file" ]] && rm -f "$log_file" + + blank_line + exit "$rc" +} + +fatal() { + echo "" + + if $terminal ; then + echo -e " [ \033[31m\033[1mFatal\033[m ] $*" + echo "" + echo -e " \033[1mScript terminated\033[m.." + else + echo " [ Fatal ] $*" + echo "" + echo " Script terminated.." + fi + + echo "" + clean_up 1 +} + + +fatal_with_command_hint() { + local message="$1" + local hint="$2" + local command="$3" + + echo "" + + if $terminal ; then + echo -e " [ \033[31m\033[1mFatal\033[m ] ${message}" + echo "" + echo -e " \033[1mScript terminated\033[m.." + else + echo " [ Fatal ] ${message}" + echo "" + echo " Script terminated.." + fi + + echo "" + + if $terminal ; then + echo -e " [ \033[33m\033[1mWarning\033[m ] ${hint}" + else + echo " [ Warning ] ${hint}" + fi + + echo "" + echo " ${command}" + echo "" + + clean_up 1 +} + + +fatal_with_queue_help() { + local message="$1" + local queue_config_dir="$2" + + echo "" + + if $terminal ; then + echo -e " [ \033[31m\033[1mFatal\033[m ] ${message}" + echo "" + echo -e " \033[1mScript terminated\033[m.." + else + echo " [ Fatal ] ${message}" + echo "" + echo " Script terminated.." + fi + + echo "" + + if $terminal ; then + echo -e " [ \033[33m\033[1mWarning\033[m ] To inspect the SRS delivery queue, run:" + else + echo " [ Warning ] To inspect the SRS delivery queue, run:" + fi + + echo "" + echo " postqueue -c ${queue_config_dir} -p" + echo "" + + if $terminal ; then + echo -e " [ \033[33m\033[1mWarning\033[m ] To inspect one queued message, replace QUEUE_ID and run:" + else + echo " [ Warning ] To inspect one queued message, replace QUEUE_ID and run:" + fi + + echo "" + echo " postcat -c ${queue_config_dir} -q QUEUE_ID" + echo "" + + if $terminal ; then + echo -e " [ \033[33m\033[1mWarning\033[m ] If you really want to delete ALL messages from the SRS delivery queue, run:" + else + echo " [ Warning ] If you really want to delete ALL messages from the SRS delivery queue, run:" + fi + + echo "" + echo " postsuper -c ${queue_config_dir} -d ALL" + echo "" + + clean_up 1 +} + +ask_yes_no() { + local question="$1" + local default_answer="${2:-no}" + local answer="" + local prompt="[yes/no]" + + if [[ "$default_answer" = "yes" ]]; then + prompt="[yes/no, default: yes]" + elif [[ "$default_answer" = "no" ]]; then + prompt="[yes/no, default: no]" + fi + + while true ; do + echononl "$question $prompt: " + read -r answer + answer="${answer,,}" + + [[ -z "$answer" ]] && answer="$default_answer" + + case "$answer" in + yes) return 0 ;; + no) return 1 ;; + *) warn "Wrong entry! Please enter 'yes' or 'no'." ;; + esac + done +} + +# ---------- +# Runtime handling +# ---------- + +init_srs_runtime() { + local script_path="" + local transcript_file="" + + if [[ -t 1 ]]; then + terminal=true + else + script_path="$(realpath "$0" 2>/dev/null || printf '%s' "$0")" + transcript_file="/tmp/${script_name%.sh}.out" + + echo "" + echo " [ Fatal ] Script requires an interactive terminal." + echo "" + echo " Script terminated.." + echo "" + echo " [ Warning ] Piping the script output, for example through 'tee'," + echo " removes the terminal from stdout." + echo "" + echo " To run the script interactively and record its complete" + echo " terminal session, use:" + echo "" + echo " script -q -e -c \"${script_path}\" \\" + echo " ${transcript_file}" + echo "" + + clean_up 1 + fi + + if ! mkdir "$SRS_LOCK_DIR" 2>/dev/null ; then + if [[ -r "$SRS_LOCK_OWNER_FILE" ]]; then + fatal "Another SRS management process seems to be running (PID $(cat "$SRS_LOCK_OWNER_FILE"))." + else + fatal "Cannot create SRS lock directory '${SRS_LOCK_DIR}'." + fi + fi + + SRS_LOCK_ACQUIRED=true + + printf '%s\n' "$$" > "$SRS_LOCK_OWNER_FILE" || fatal "Cannot write SRS lock owner file '${SRS_LOCK_OWNER_FILE}'." + + log_file="${SRS_LOCK_DIR}/${script_name%%.*}.log" + : > "$log_file" || fatal "Cannot create log file '${log_file}'." +} + +handle_signal() { + trap - SIGHUP SIGINT SIGTERM + warn "Script interrupted by signal." + clean_up 1 +} + +# ---------- +# Basic checks +# ---------- + +check_root() { + echononl "Check if script is running as root.." + + if [[ "$(id -u)" -eq 0 ]]; then + echo_ok + else + echo_failed + fatal "This script must be run as root." + fi +} + +check_required_commands() { + local cmd + + for cmd in "$@" ; do + echononl "Check for command '${cmd}'.." + + if command -v "$cmd" > /dev/null 2>&1 ; then + echo_ok + else + echo_failed + fatal "Required command '${cmd}' was not found." + fi + done +} + +check_readable_file() { + local file="$1" + local description="${2:-file}" + + echononl "Check ${description} '${file}'.." + + if [[ -r "$file" ]]; then + echo_ok + else + echo_failed + fatal "${description^} '${file}' does not exist or is not readable." + fi +} + +check_service_exists() { + local service="$1" + + echononl "Check systemd service '${service}'.." + + if systemctl cat "$service" > /dev/null 2>&1 ; then + echo_ok + else + echo_failed + fatal "Systemd service '${service}' was not found." + fi +} + +check_service_active() { + local service="$1" + + echononl "Check systemd service '${service}' is active.." + + if systemctl is-active --quiet "$service" ; then + echo_ok + else + echo_failed + fatal "Systemd service '${service}' is not active." + fi +} + +# ---------- +# Security checks for sourced files +# ---------- + +check_secure_source_file() { + local file="$1" + local description="${2:-configuration file}" + local owner_uid + local owner_gid + local mode + + echononl "Check security of ${description} '${file}'.." + + if [[ ! -f "$file" || ! -r "$file" ]]; then + echo_failed + fatal "${description^} '${file}' does not exist or is not readable." + fi + + owner_uid="$(stat -c '%u' "$file" 2>/dev/null)" || { + echo_failed + fatal "Cannot determine owner of '${file}'." + } + + owner_gid="$(stat -c '%g' "$file" 2>/dev/null)" || { + echo_failed + fatal "Cannot determine group of '${file}'." + } + + mode="$(stat -c '%a' "$file" 2>/dev/null)" || { + echo_failed + fatal "Cannot determine permissions of '${file}'." + } + + if [[ "$owner_uid" -ne 0 || "$owner_gid" -ne 0 ]]; then + echo_failed + fatal "${description^} '${file}' must be owned by root:root." + fi + + if (( (8#$mode & 0022) != 0 )); then + echo_failed + fatal "${description^} '${file}' must not be writable by group or others." + fi + + echo_ok +} + + +check_state_file_permissions() { + local mode + + echononl "Check permissions of persistent SRS state '${SRS_STATE_FILE}'.." + + if [[ ! -f "$SRS_STATE_FILE" ]]; then + echo_failed + fatal "Persistent SRS state '${SRS_STATE_FILE}' does not exist." + fi + + if [[ "$(stat -c '%U:%G' "$SRS_STATE_FILE" 2>/dev/null)" != "root:root" ]]; then + echo_failed + fatal "Persistent SRS state '${SRS_STATE_FILE}' must be owned by root:root." + fi + + mode="$(stat -c '%a' "$SRS_STATE_FILE" 2>/dev/null)" || { + echo_failed + fatal "Cannot determine permissions of '${SRS_STATE_FILE}'." + } + + if [[ "$mode" != "600" ]]; then + echo_failed + fatal "Persistent SRS state '${SRS_STATE_FILE}' must have mode 0600 (current: ${mode})." + fi + + echo_ok +} + + +# ---------- +# SRS configuration +# ---------- + +load_srs_config() { + + if [[ ! -r "$SRS_CONF_FILE" ]]; then + fatal "SRS configuration '${SRS_CONF_FILE}' does not exist or is not readable. Use '${SRS_CONF_SAMPLE_FILE}' as template." + fi + + check_secure_source_file "$SRS_CONF_FILE" "SRS configuration" + + echononl "Read SRS configuration '${SRS_CONF_FILE}'.." + + unset _SRS_DOMAIN + + # shellcheck source=/dev/null + source "$SRS_CONF_FILE" + + if [[ -z "${_SRS_DOMAIN:-}" ]]; then + echo_failed + fatal "Variable '_SRS_DOMAIN' is not set in '${SRS_CONF_FILE}'." + fi + + if [[ "$_SRS_DOMAIN" =~ [[:space:]] ]]; then + echo_failed + fatal "Invalid SRS domain '${_SRS_DOMAIN}': whitespace is not allowed." + fi + + if [[ ! "$_SRS_DOMAIN" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]]; then + echo_failed + fatal "Invalid SRS domain '${_SRS_DOMAIN}'." + fi + + SRS_CONFIG_LOADED=true + echo_ok +} + +# ---------- +# Persistent pre-SRS state +# ---------- + +_is_boolean_value() { + [[ "$1" = "true" || "$1" = "false" ]] +} + +load_srs_state() { + + if [[ ! -r "$SRS_STATE_FILE" ]]; then + fatal "Persistent SRS state '${SRS_STATE_FILE}' does not exist or is not readable." + fi + + check_state_file_permissions + check_secure_source_file "$SRS_STATE_FILE" "persistent SRS state" + + echononl "Read persistent SRS state '${SRS_STATE_FILE}'.." + + unset _STATE_VERSION + unset _STATE_ORIGIN + unset _STATE_HOSTNAME + + unset _PRE_SRS_DEFAULT_TRANSPORT_SET + unset _PRE_SRS_DEFAULT_TRANSPORT + unset _PRE_SRS_RECIPIENT_CANONICAL_MAPS_SET + unset _PRE_SRS_RECIPIENT_CANONICAL_MAPS + unset _PRE_SRS_SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT_SET + unset _PRE_SRS_SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT + unset _PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS_SET + unset _PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS + unset _PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS_SET + unset _PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS + + # shellcheck source=/dev/null + source "$SRS_STATE_FILE" + + if [[ "${_STATE_VERSION:-}" != "1" ]]; then + echo_failed + fatal "Unsupported or missing SRS state version in '${SRS_STATE_FILE}'." + fi + + local bool_var + + for bool_var in _PRE_SRS_DEFAULT_TRANSPORT_SET _PRE_SRS_RECIPIENT_CANONICAL_MAPS_SET _PRE_SRS_SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT_SET _PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS_SET _PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS_SET + do + if ! _is_boolean_value "${!bool_var:-}" ; then + echo_failed + fatal "Invalid boolean value for '${bool_var}' in '${SRS_STATE_FILE}'." + fi + done + + if [[ -n "${_STATE_HOSTNAME:-}" && "${_STATE_HOSTNAME}" != "$(hostname -f)" ]]; then + echo_warning + warn "SRS state was created for host '${_STATE_HOSTNAME}', current hostname is '$(hostname -f)'." + else + echo_ok + fi + + SRS_STATE_LOADED=true +} + +# ---------- +# Managed block helpers +# ---------- + +count_exact_line() { + local file="$1" + local line="$2" + + grep -Fxc "$line" "$file" 2>/dev/null || true +} + +check_managed_block_markers() { + local file="$1" + local begin_marker="$2" + local end_marker="$3" + local begin_count + local end_count + local begin_line + local end_line + + begin_count="$(count_exact_line "$file" "$begin_marker")" + end_count="$(count_exact_line "$file" "$end_marker")" + + if [[ "$begin_count" -eq 0 && "$end_count" -eq 0 ]]; then + return 1 + fi + + if [[ "$begin_count" -ne 1 || "$end_count" -ne 1 ]]; then + return 2 + fi + + begin_line="$(grep -Fnx "$begin_marker" "$file" | cut -d: -f1)" + end_line="$(grep -Fnx "$end_marker" "$file" | cut -d: -f1)" + + if [[ -z "$begin_line" || -z "$end_line" ]]; then + return 2 + fi + + if (( begin_line >= end_line )); then + return 2 + fi + + return 0 +}