Archive discontinued selective SRS implementation
This commit is contained in:
+685
@@ -0,0 +1,685 @@
|
||||
#!/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"
|
||||
# SRS bounce alias ownership is tracked by a comment marker in main.cf;
|
||||
# original version-1 state.conf is immutable and needs no unsafe migration.
|
||||
SRS_ALIAS_HELPER="${SRS_HELPERS_DIR}/srs-virtual-alias.py"
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only, fail-closed public-DNS preflight for the approved SRS DNS profile.
|
||||
|
||||
Checks the authoritative nameservers, bypassing local hosts/systemd-resolved.
|
||||
This deliberately validates only the approved single-host DNS profile:
|
||||
exactly one MX pointing to this host and one SPF record `v=spf1 mx -all`.
|
||||
It is NOT a general-purpose SPF evaluator.
|
||||
"""
|
||||
import argparse
|
||||
import ipaddress
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# A public resolver is used only to discover the delegated authoritative NS and
|
||||
# their numeric IPs. The actual TXT/MX/A/AAAA values are read from each NS.
|
||||
BOOTSTRAP_RESOLVERS = ('1.1.1.1', '9.9.9.9')
|
||||
DNS_TIMEOUT = 5
|
||||
|
||||
|
||||
def dig(server, name, kind, authoritative=False):
|
||||
command = ['dig', '@' + server, '+time=2', '+tries=1', '+noall', '+comments', '+answer']
|
||||
if authoritative:
|
||||
command.append('+norecurse')
|
||||
command.extend([name, kind])
|
||||
try:
|
||||
proc = subprocess.run(command, capture_output=True, text=True, timeout=DNS_TIMEOUT, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise ValueError(f'DNS request failed ({server}, {name}, {kind}): {exc}') from exc
|
||||
if proc.returncode != 0:
|
||||
raise ValueError(f'DNS request failed ({server}, {name}, {kind}): {proc.stderr.strip() or proc.returncode}')
|
||||
header = re.search(r'HEADER.*?status:\s*(\w+)', proc.stdout)
|
||||
if not header or header.group(1) != 'NOERROR':
|
||||
raise ValueError(f'DNS returned {header.group(1) if header else "no status"}: {server} {name} {kind}')
|
||||
flags = re.search(r'^;; flags:\s*(.*?);\s*QUERY:', proc.stdout, re.M)
|
||||
if authoritative and (not flags or 'aa' not in flags.group(1).split()):
|
||||
raise ValueError(f'Authoritative response missing AA flag: {server} {name} {kind}')
|
||||
rows = []
|
||||
for line in proc.stdout.splitlines():
|
||||
parts = line.split(None, 4)
|
||||
if len(parts) != 5 or parts[2].upper() != 'IN':
|
||||
continue
|
||||
owner, _ttl, _cls, rr_type, data = parts
|
||||
if rr_type.upper() == kind.upper() and owner.rstrip('.').lower() == name.rstrip('.').lower():
|
||||
rows.append(data.strip())
|
||||
return rows
|
||||
|
||||
|
||||
def public_lookup(name, kind):
|
||||
failures = []
|
||||
for server in BOOTSTRAP_RESOLVERS:
|
||||
try:
|
||||
return dig(server, name, kind)
|
||||
except ValueError as exc:
|
||||
failures.append(str(exc))
|
||||
raise ValueError('Public NS bootstrap failed: ' + ' | '.join(failures))
|
||||
|
||||
|
||||
def discover_nameservers(domain):
|
||||
labels = domain.rstrip('.').split('.')
|
||||
# Pick closest delegated zone. A zone without its own delegation has no NS.
|
||||
for i in range(len(labels) - 1):
|
||||
zone = '.'.join(labels[i:])
|
||||
ns = public_lookup(zone, 'NS')
|
||||
if ns:
|
||||
names = sorted({n.rstrip('.').lower() for n in ns})
|
||||
return zone, names
|
||||
raise ValueError(f'Cannot discover authoritative nameservers for {domain}')
|
||||
|
||||
|
||||
def addr_of_ns(ns):
|
||||
for kind in ('A', 'AAAA'):
|
||||
for value in public_lookup(ns, kind):
|
||||
try:
|
||||
return str(ipaddress.ip_address(value))
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(f'Cannot resolve nameserver {ns} to a public IP address')
|
||||
|
||||
|
||||
def parse_txt(value):
|
||||
# DNS may split a single TXT resource record into multiple quoted strings.
|
||||
parts = re.findall(r'"((?:\\.|[^"\\])*)"', value)
|
||||
if not parts:
|
||||
raise ValueError('Unexpected TXT presentation format: ' + value)
|
||||
return ''.join(parts).replace('\\"', '"')
|
||||
|
||||
|
||||
def check_server(ns_name, ns_ip, domain, hostname, bind4, bind6):
|
||||
errors = []
|
||||
def query(name, kind):
|
||||
try:
|
||||
return dig(ns_ip, name, kind, authoritative=True)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
return []
|
||||
|
||||
spf_records = []
|
||||
for raw in query(domain, 'TXT'):
|
||||
try:
|
||||
text = parse_txt(raw)
|
||||
if text.lower().startswith('v=spf1'):
|
||||
spf_records.append(text)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
spf_ok = (
|
||||
len(spf_records) == 1
|
||||
and spf_records[0].lower().split() == ['v=spf1', 'mx', '-all']
|
||||
)
|
||||
if not spf_ok:
|
||||
errors.append('SPF is not the approved single-host profile: v=spf1 mx -all')
|
||||
|
||||
mx = query(domain, 'MX')
|
||||
if len(mx) != 1:
|
||||
errors.append(f'Expected exactly one MX pointing to {hostname}; found {mx}')
|
||||
else:
|
||||
parts = mx[0].split()
|
||||
if len(parts) != 2 or not parts[0].isdigit() or parts[1].rstrip('.').lower() != hostname:
|
||||
errors.append(f'MX is not the expected mailserver {hostname}: {mx[0]}')
|
||||
|
||||
for kind, configured in [('A', bind4), ('AAAA', bind6)]:
|
||||
answers = query(hostname, kind)
|
||||
normalized = []
|
||||
for entry in answers:
|
||||
try:
|
||||
normalized.append(str(ipaddress.ip_address(entry)))
|
||||
except ValueError:
|
||||
errors.append(f'Invalid {kind} address returned for {hostname}: {entry}')
|
||||
if not configured:
|
||||
errors.append(f'Postfix smtp_bind_address{("6" if kind == "AAAA" else "")} is empty; cannot verify outbound IP')
|
||||
elif configured not in normalized:
|
||||
errors.append(f'Postfix outbound {kind} address {configured} is not published for {hostname}: {normalized}')
|
||||
|
||||
print(f' [ {"ok" if not errors else "failed"} ] {ns_name} ({ns_ip})')
|
||||
for err in errors:
|
||||
print(f' [ error ] {err}')
|
||||
return not errors, spf_ok
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--domain', required=True)
|
||||
ap.add_argument('--hostname', required=True)
|
||||
ap.add_argument('--ipv4', required=True)
|
||||
ap.add_argument('--ipv6', required=True)
|
||||
args = ap.parse_args()
|
||||
domain = args.domain.lower().rstrip('.')
|
||||
hostname = args.hostname.lower().rstrip('.')
|
||||
if not shutil.which('dig'):
|
||||
print(' [ error ] dig is required to verify public DNS')
|
||||
return 1
|
||||
try:
|
||||
bind4 = str(ipaddress.ip_address(args.ipv4)) if args.ipv4 else ''
|
||||
bind6 = str(ipaddress.ip_address(args.ipv6)) if args.ipv6 else ''
|
||||
zone, nameservers = discover_nameservers(domain)
|
||||
print(f' DNS delegation zone................: {zone}')
|
||||
print(f' Expected MX host...................: {hostname}')
|
||||
print(f' Expected SMTP IPv4.................: {bind4 or "unknown"}')
|
||||
print(f' Expected SMTP IPv6.................: {bind6 or "unknown"}')
|
||||
print(' Required SPF record...............: v=spf1 mx -all')
|
||||
if len(nameservers) > 8:
|
||||
raise ValueError('More than eight authoritative nameservers; manual review required')
|
||||
print()
|
||||
print(' Authoritative nameservers:')
|
||||
ok = True
|
||||
spf_ok_all = True
|
||||
for ns in nameservers:
|
||||
try:
|
||||
ip = addr_of_ns(ns)
|
||||
server_ok, server_spf_ok = check_server(
|
||||
ns, ip, domain, hostname, bind4, bind6
|
||||
)
|
||||
if not server_ok:
|
||||
ok = False
|
||||
if not server_spf_ok:
|
||||
spf_ok_all = False
|
||||
except ValueError as exc:
|
||||
print(f' [ failed ] {ns}')
|
||||
print(f' [ error ] {exc}')
|
||||
ok = False
|
||||
spf_ok_all = False
|
||||
print()
|
||||
print(' SPF validation.....................: ' +
|
||||
('OK' if spf_ok_all else 'FAILED / NOT VERIFIED'))
|
||||
print(' Public SRS DNS preflight...........: ' + ('OK' if ok else 'FAILED'))
|
||||
return 0 if ok else 1
|
||||
except ValueError as exc:
|
||||
print(f' [ error ] {exc}')
|
||||
print(' Public SRS DNS preflight...........: FAILED')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Track narrowly-scoped SRS virtual alias additions in a Postfix main.cf.
|
||||
|
||||
This helper never restores an old main.cf; it only adds/removes its exact two
|
||||
list items. An ownership marker is mandatory before removal. This is a
|
||||
review draft: do not use on a live mail server until the complete integration
|
||||
and rollback have been tested.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
MARKER_PREFIX = '# OOPEN-SRS-ALIAS-OWNERSHIP v1 '
|
||||
PARAMS = ('virtual_alias_domains', 'virtual_alias_maps')
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def read_entries(lines, param):
|
||||
"""Return [(first_line, last_line_exclusive, value_lines)] for a parameter.
|
||||
|
||||
Continuation lines must be indented, active, and not comments. A comment
|
||||
ends the immediately preceding value, leaving its original text untouched.
|
||||
"""
|
||||
pattern = re.compile(r'^\s*' + re.escape(param) + r'\s*=', re.I)
|
||||
found = []
|
||||
for i, line in enumerate(lines):
|
||||
if not pattern.match(line) or line.lstrip().startswith('#'):
|
||||
continue
|
||||
j = i + 1
|
||||
while j < len(lines) and lines[j].startswith((' ', '\t')) and lines[j].strip() and not lines[j].lstrip().startswith('#'):
|
||||
j += 1
|
||||
first_value = line.split('=', 1)[1].strip()
|
||||
values = [first_value] + [item.strip() for item in lines[i + 1:j]]
|
||||
found.append((i, j, values))
|
||||
if len(found) > 1:
|
||||
raise ConfigError(f'Duplicate active parameter {param}')
|
||||
return found
|
||||
|
||||
|
||||
def current_tokens(lines, param):
|
||||
entries = read_entries(lines, param)
|
||||
if not entries:
|
||||
return (False, [])
|
||||
values = entries[0][2]
|
||||
return (True, [part for part in re.split(r'[\s,]+', ' '.join(values)) if part])
|
||||
|
||||
|
||||
def make_marker(domain, reverse_map, orig):
|
||||
return (MARKER_PREFIX + f'domain={domain} map={reverse_map} '
|
||||
f'domains_was_set={int(orig[0])} maps_was_set={int(orig[1])}\n')
|
||||
|
||||
|
||||
def marker_state(lines, domain, reverse_map):
|
||||
markers = [line for line in lines if line.startswith('# OOPEN-SRS-ALIAS-OWNERSHIP')]
|
||||
if not markers:
|
||||
return None
|
||||
if len(markers) != 1:
|
||||
raise ConfigError('Multiple SRS alias ownership markers')
|
||||
marker = markers[0]
|
||||
expected = re.compile(r'^' + re.escape(MARKER_PREFIX) +
|
||||
r'domain=' + re.escape(domain) + r' map=' + re.escape(reverse_map) +
|
||||
r' domains_was_set=([01]) maps_was_set=([01])\n$')
|
||||
match = expected.fullmatch(marker)
|
||||
if not match:
|
||||
raise ConfigError('Foreign or malformed SRS alias ownership marker')
|
||||
return tuple(item == '1' for item in match.groups())
|
||||
|
||||
|
||||
def inspect(lines, domain, reverse_map):
|
||||
is_set_domains, domains = current_tokens(lines, PARAMS[0])
|
||||
is_set_maps, maps = current_tokens(lines, PARAMS[1])
|
||||
matches_domain = sum(token.lower() == domain.lower() for token in domains)
|
||||
matches_map = sum(token == reverse_map for token in maps)
|
||||
if matches_domain > 1 or matches_map > 1:
|
||||
raise ConfigError('Duplicate SRS domain or reverse map item')
|
||||
ownership = marker_state(lines, domain, reverse_map)
|
||||
if ownership is not None:
|
||||
if matches_domain != 1 or matches_map != 1:
|
||||
raise ConfigError('Ownership marker present but SRS tokens are incomplete')
|
||||
return 'managed', ownership
|
||||
if matches_domain and matches_map:
|
||||
return 'legacy', None
|
||||
if not matches_domain and not matches_map:
|
||||
return 'absent', (is_set_domains, is_set_maps)
|
||||
raise ConfigError('Partial/unmanaged SRS alias configuration')
|
||||
|
||||
|
||||
def append_token(lines, param, token):
|
||||
entries = read_entries(lines, param)
|
||||
if not entries:
|
||||
if lines and not lines[-1].endswith('\n'):
|
||||
lines[-1] += '\n'
|
||||
lines.append(f'{param} = {token}\n')
|
||||
return
|
||||
start, end, values = entries[0]
|
||||
if token in [part for part in re.split(r'[\s,]+', ' '.join(values)) if part]:
|
||||
raise ConfigError(f'SRS token already present in {param}')
|
||||
if end > start + 1:
|
||||
lines.insert(end, f' {token}\n')
|
||||
else:
|
||||
line = lines[start].rstrip('\r\n')
|
||||
lines[start] = line.rstrip() + ' ' + token + '\n'
|
||||
|
||||
|
||||
def remove_token(lines, param, token, was_set):
|
||||
entries = read_entries(lines, param)
|
||||
if len(entries) != 1:
|
||||
raise ConfigError(f'Cannot safely remove token from {param}: missing parameter')
|
||||
first, end, _ = entries[0]
|
||||
indices = []
|
||||
for idx in range(first, end):
|
||||
value = lines[idx].split('=', 1)[1] if idx == first else lines[idx]
|
||||
parts = [p for p in re.split(r'[\s,]+', value.strip()) if p]
|
||||
if token in parts:
|
||||
indices.append(idx)
|
||||
if len(indices) != 1:
|
||||
raise ConfigError(f'Expected exactly one SRS token in {param}')
|
||||
idx = indices[0]
|
||||
line = lines[idx]
|
||||
if idx == first:
|
||||
before, value = line.split('=', 1)
|
||||
remaining = [p for p in re.split(r'[\s,]+', value.strip()) if p != token]
|
||||
if not was_set and end == first + 1 and not remaining:
|
||||
del lines[first]
|
||||
else:
|
||||
lines[idx] = before.rstrip() + ' = ' + ' '.join(remaining) + '\n'
|
||||
else:
|
||||
remaining = [p for p in re.split(r'[\s,]+', line.strip()) if p != token]
|
||||
if remaining:
|
||||
lines[idx] = ' ' + ' '.join(remaining) + '\n'
|
||||
else:
|
||||
del lines[idx]
|
||||
if not was_set and not current_tokens(lines, param)[1]:
|
||||
entries = read_entries(lines, param)
|
||||
if entries:
|
||||
start, end, _ = entries[0]
|
||||
del lines[start:end]
|
||||
|
||||
|
||||
def write_atomic(path, content):
|
||||
st = os.stat(path)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
raise ConfigError('main.cf is not a regular file')
|
||||
parent = os.path.dirname(os.path.abspath(path))
|
||||
fd, temp_path = tempfile.mkstemp(prefix='.main.cf.srs-alias.', dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as f:
|
||||
f.writelines(content)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.chmod(temp_path, stat.S_IMODE(st.st_mode))
|
||||
os.chown(temp_path, st.st_uid, st.st_gid)
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
def run(args):
|
||||
path = Path(args.main_cf)
|
||||
lines = path.read_text(encoding='utf-8').splitlines(keepends=True)
|
||||
state, extra = inspect(lines, args.domain, args.reverse_map)
|
||||
if args.mode == 'status':
|
||||
print(state)
|
||||
return
|
||||
if args.mode == 'add':
|
||||
if state != 'absent':
|
||||
raise ConfigError('Add refused: expected absent state, got ' + state)
|
||||
# Add in reverse order so insertion indexes remain consistent.
|
||||
append_token(lines, PARAMS[1], args.reverse_map)
|
||||
append_token(lines, PARAMS[0], args.domain)
|
||||
lines.append(make_marker(args.domain, args.reverse_map, extra))
|
||||
elif args.mode == 'adopt':
|
||||
if state != 'legacy':
|
||||
raise ConfigError('Adoption refused: expected legacy state, got ' + state)
|
||||
if lines and not lines[-1].endswith('\n'):
|
||||
lines[-1] += '\n'
|
||||
lines.append(make_marker(args.domain, args.reverse_map, (True, True)))
|
||||
elif args.mode == 'remove':
|
||||
if state != 'managed':
|
||||
raise ConfigError('Removal refused: expected managed state, got ' + state)
|
||||
was_domains, was_maps = extra
|
||||
remove_token(lines, PARAMS[1], args.reverse_map, was_maps)
|
||||
remove_token(lines, PARAMS[0], args.domain, was_domains)
|
||||
lines[:] = [line for line in lines if not line.startswith(MARKER_PREFIX)]
|
||||
else:
|
||||
raise ConfigError('Unknown mode')
|
||||
expected = 'absent' if args.mode == 'remove' else 'managed'
|
||||
check, _ = inspect(lines, args.domain, args.reverse_map)
|
||||
if check != expected:
|
||||
raise ConfigError(f'Post-modification state unexpected: {check}')
|
||||
write_atomic(path, lines)
|
||||
print(check)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('mode', choices=['status', 'add', 'adopt', 'remove'])
|
||||
parser.add_argument('--main-cf', required=True)
|
||||
parser.add_argument('--domain', required=True)
|
||||
parser.add_argument('--reverse-map', required=True)
|
||||
args = parser.parse_args()
|
||||
if not re.fullmatch(r'[A-Za-z0-9.-]+', args.domain):
|
||||
parser.error('Unsafe domain')
|
||||
if not re.fullmatch(r'socketmap:inet:127\.0\.0\.1:[0-9]{1,5}:reverse', args.reverse_map):
|
||||
parser.error('Unexpected reverse map')
|
||||
try:
|
||||
run(args)
|
||||
except (ConfigError, OSError, UnicodeError) as exc:
|
||||
print(f'SRS alias configuration error: {exc}', file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user