diff --git a/activate-srs.sh b/activate-srs.sh index 6f87eb4..5580448 100755 --- a/activate-srs.sh +++ b/activate-srs.sh @@ -191,6 +191,45 @@ postsrsd_srs_domain() { } +srs_alias_tool() { + local action="$1" + python3 "$SRS_ALIAS_HELPER" "$action" \ + --main-cf "$POSTFIX_MAIN_CF" \ + --domain "$_SRS_DOMAIN" \ + --reverse-map "socketmap:inet:${SRS_REVERSE_SOCKET_HOST}:${SRS_REVERSE_SOCKET_PORT}:reverse" +} + +# Upgrade an already running instance without touching binaries/systemd units. +# A new snapshot is taken on every attempt and restored on any failure. +upgrade_existing_srs_aliases() { + local action="$1" folder snapshot + folder="/root/postfix-srs-alias-upgrade-${backup_date}" + snapshot="${folder}/main.cf" + mkdir -m 0700 "$folder" || fatal "Cannot create alias upgrade backup directory." + cp -a "$POSTFIX_MAIN_CF" "$snapshot" || fatal "Cannot snapshot main.cf for alias upgrade." + echononl "Track incoming SRS bounce aliases (${action}).." + if srs_alias_tool "$action" > "$log_file" 2>&1 && + postfix check >> "$log_file" 2>&1 && + [[ "$(srs_alias_tool status 2>>"$log_file")" == 'managed' ]] && + { [[ "$action" == 'adopt' ]] || systemctl reload "$POSTFIX_SERVICE" >> "$log_file" 2>&1; } + then + echo_ok + info "SRS inbound alias settings now managed. Backup: ${snapshot}" + clean_up 0 + fi + echo_failed + error "Alias upgrade failed: $(cat "$log_file")" + # A pure adoption changes only a comment. Never reload Postfix on + # either the success or rollback path for that operation. + if cp -a "$snapshot" "$POSTFIX_MAIN_CF" && + postfix check > "$log_file" 2>&1 && + { [[ "$action" == 'adopt' ]] || systemctl reload "$POSTFIX_SERVICE" >> "$log_file" 2>&1; } + then + fatal "Alias upgrade failed; previous main.cf restored successfully." + fi + fatal "Alias upgrade failed; automatic restore incomplete. Manual review: ${snapshot}" +} + state_main_cf_matches() { local parameter="$1" local was_set="$2" @@ -747,6 +786,8 @@ check_required_commands \ load_srs_config load_srs_state +[[ -f "$SRS_ALIAS_HELPER" && ! -L "$SRS_ALIAS_HELPER" ]] || + fatal "Missing regular SRS alias helper: ${SRS_ALIAS_HELPER}" smtp_pre_value="" amavis_pre_value="" @@ -1255,9 +1296,31 @@ blank_line # Handle detected state # ---------- +# A running v1 installation can be upgraded without reinstalling PostSRSd. +srs_alias_status="$(srs_alias_tool status 2>"$log_file")" || + fatal "Unsafe virtual alias configuration: $(cat "$log_file")" + if [[ "$srs_state" == "enabled" ]]; then - info "Selective SRS is already enabled. Nothing to do." - clean_up 0 + case "$srs_alias_status" in + managed) + info "Selective SRS with inbound bounce reception is already enabled. Nothing to do." + clean_up 0 + ;; + absent|legacy) + warn "SRS is enabled but bounce alias ownership is not tracked (${srs_alias_status})." + info "Only virtual alias parameters will be updated; PostSRSd binaries and services remain untouched." + if ! ask_yes_no "Upgrade incoming SRS alias management" "no"; then + info "Upgrade cancelled without changes." + clean_up 0 + fi + if [[ "$srs_alias_status" == 'legacy' ]]; then + upgrade_existing_srs_aliases adopt + else + upgrade_existing_srs_aliases add + fi + ;; + *) fatal "Unexpected SRS alias status: ${srs_alias_status}" ;; + esac fi if [[ "$srs_state" == "inconsistent" ]]; then @@ -1268,6 +1331,8 @@ fi if ! $activation_ready ; then fatal "Selective SRS is disabled, but the installation is incomplete. Activation is not possible." fi +[[ "$srs_alias_status" == 'absent' ]] || + fatal "SRS disabled but alias entries are not absent. Refusing activation." # ---------- @@ -1362,6 +1427,14 @@ else fatal_with_activation_rollback "Could not add managed SRS block to '${POSTFIX_MASTER_CF}'." fi +echononl "Add incoming SRS virtual alias domain and reverse map.." +if srs_alias_tool add > "$log_file" 2>&1 ; then + echo_ok +else + echo_failed + fatal_with_activation_rollback "Could not add tracked virtual alias items: $(cat "$log_file")" +fi + echononl "Set port 25 receive_override_options.." if update_master_service_option \ smtp \ @@ -1527,6 +1600,14 @@ else verification_ok=false fi +echononl "Verify incoming SRS alias ownership.." +if [[ "$(srs_alias_tool status 2>"$log_file")" == 'managed' ]]; then + echo_ok +else + echo_failed + verification_ok=false +fi + echononl "Verify 'srs-forward' pipe service.." if master_srs_forward_present ; then echo_ok diff --git a/deactivate-srs.sh b/deactivate-srs.sh index 8a0558c..32fbdb4 100755 --- a/deactivate-srs.sh +++ b/deactivate-srs.sh @@ -203,6 +203,14 @@ postsrsd_srs_domain() { } +srs_alias_tool() { + local action="$1" + python3 "$SRS_ALIAS_HELPER" "$action" \ + --main-cf "$POSTFIX_MAIN_CF" \ + --domain "$_SRS_DOMAIN" \ + --reverse-map "socketmap:inet:${SRS_REVERSE_SOCKET_HOST}:${SRS_REVERSE_SOCKET_PORT}:reverse" +} + state_main_cf_matches() { local parameter="$1" local was_set="$2" @@ -919,6 +927,8 @@ check_required_commands \ load_srs_config load_srs_state +[[ -f "$SRS_ALIAS_HELPER" && ! -L "$SRS_ALIAS_HELPER" ]] || + fatal "Missing regular SRS alias helper: ${SRS_ALIAS_HELPER}" # ---------- @@ -1412,7 +1422,12 @@ blank_line # Handle detected state # ---------- +srs_alias_status="$(srs_alias_tool status 2>"$log_file")" || + fatal "Unsafe virtual alias configuration: $(cat "$log_file")" + if [[ "$srs_state" == "disabled" ]]; then + [[ "$srs_alias_status" == 'absent' ]] || + fatal "SRS marked disabled but virtual alias items remain (${srs_alias_status})." info "Selective SRS is already disabled. Nothing to do." clean_up 0 fi @@ -1425,6 +1440,11 @@ fi if ! $installation_ok || ! $services_exist ; then fatal "Selective SRS is enabled, but the installation is incomplete. Deactivation was not attempted." fi +if [[ "$srs_alias_status" == 'legacy' ]]; then + fatal "SRS virtual alias settings are not tracked. Run the revised activate-srs.sh once to adopt them first." +fi +[[ "$srs_alias_status" == 'managed' || "$srs_alias_status" == 'absent' ]] || + fatal "Unsafe SRS alias status: ${srs_alias_status}" # ---------- @@ -1583,6 +1603,19 @@ else fi +# ---------- +# Remove only SRS-owned virtual alias list items; preserve later admin edits +# ---------- +if [[ "$srs_alias_status" == 'managed' ]]; then + echononl "Remove tracked SRS virtual alias items.." + if srs_alias_tool remove > "$log_file" 2>&1 ; then + echo_ok + else + echo_failed + fatal_with_deactivation_rollback "Cannot safely remove SRS alias items: $(cat "$log_file")" + fi +fi + # ---------- # Restore master.cf # ---------- @@ -1782,6 +1815,14 @@ else verification_ok=false fi +echononl "Verify SRS virtual alias items are absent.." +if [[ "$(srs_alias_tool status 2>"$log_file")" == 'absent' ]]; then + echo_ok +else + echo_failed + verification_ok=false +fi + echononl "Verify pre-SRS master.cf state.." if state_master_option_matches \ smtp \ diff --git a/files/srs/templates/delivery-main.cf.in b/files/srs/templates/delivery-main.cf.in new file mode 100644 index 0000000..b899b58 --- /dev/null +++ b/files/srs/templates/delivery-main.cf.in @@ -0,0 +1,108 @@ +# See /usr/share/postfix/main.cf.dist for a commented, more complete version + +# See http://www.postfix.org/COMPATIBILITY_README.html +compatibility_level = 3.9 + +# Which domain that locally-originated mail appears to come from. +# Debian policy suggests to read this value from /etc/mailname. +#XX needs a review in postinst&config +#myorigin = /etc/mailname +#myorigin = $mydomain +myorigin = @MYORIGIN@ + +# Text that follows the 220 code in the SMTP server's greeting banner. +# You MUST specify $myhostname at the start due to an RFC requirement. +smtpd_banner = $myhostname ESMTP $mail_name (Debian) + +# IP protocols to use: ipv4, ipv6, or all +# (set this explicitly so `post-install upgrade-configuration' wont complain) +inet_protocols = all + +# List of "trusted" SMTP clients (maptype:mapname allowed) that have more +# privileges than "strangers". If mynetworks is not specified (the default), +# mynetworks_style is used to compute its value. +#mynetworks_style = class +#mynetworks_style = subnet +mynetworks_style = host +# +mynetworks = 127.0.0.0/8 + +# Uncomment the next line to generate "delayed mail" warnings +#delay_warning_time = 4h + +# List of domains (maptype:mapname allowed) that this machine considers +# itself the final destination for. +mydestination = @MYDESTINATION@ + +# Maximum size of a user mailbox +mailbox_size_limit = 51200000 + +# Optional external command to use instead of mailbox delivery. If set, +# you must set up an alias to forward root mail to a real user. +#mailbox_command = /usr/bin/procmail +#mailbox_command = /usr/bin/procmail -a "$EXTENSION" +mailbox_command = + +# List of alias maps to use to lookup local addresses. +# Per Debian Policy it should be /etc/aliases. +alias_maps = $alias_database, nis:mail.aliases + +# List of alias maps to make indexes on, when running newaliases. +alias_database = hash:/etc/aliases + +# Notify (or not) local biff service when new mail arrives. +# Rarely used these days. +biff = no + +# Separator between user name and address extension (user+foo@domain) +#recipient_delimiter = + +recipient_delimiter = + +# A host to send "other" mail to +#relayhost = $mydomain +#relayhost = [gateway.example.com] +#relayhost = [ip.add.re.ss]:port +#relayhost = uucphost +relayhost = + +# Where to look for Cyrus SASL configuration files. Upstream default is unset +# (use compiled-in SASL library default), Debian Policy says it should be +# /etc/postfix/sasl. +cyrus_sasl_config_path = /etc/postfix/sasl + +authorized_submit_users = root +data_directory = /var/lib/postfix-srs-delivery +html_directory = /usr/share/doc/postfix/html +master_service_disable = +queue_directory = /var/spool/postfix-srs-delivery +readme_directory = /usr/share/doc/postfix +multi_instance_group = srs-delivery +multi_instance_name = postfix-srs-delivery +inet_interfaces = loopback-only +default_transport = smtp +local_transport = error:SRS-LOCAL-DELIVERY-BLOCKED +mailbox_transport = error:SRS-MAILBOX-DELIVERY-BLOCKED +relay_transport = error:SRS-RELAY-DELIVERY-BLOCKED +virtual_transport = srs-lmtp:unix:/var/spool/postfix/private/dovecot-lmtp +virtual_alias_maps = @VIRTUAL_ALIAS_MAPS@ +virtual_alias_domains = @VIRTUAL_ALIAS_DOMAINS@ +virtual_mailbox_maps = @VIRTUAL_MAILBOX_MAPS@ +virtual_mailbox_domains = @VIRTUAL_MAILBOX_DOMAINS@ +relay_domains = @RELAY_DOMAINS@ +transport_maps = btree:/etc/postfix-srs/delivery/transport +mydomain = @MYDOMAIN@ +myhostname = @MYHOSTNAME@ +2bounce_notice_recipient = postmaster +bounce_notice_recipient = postmaster +delay_notice_recipient = postmaster +error_notice_recipient = postmaster +notify_classes = +smtp_bind_address = @SMTP_BIND_ADDRESS@ +smtp_bind_address6 = @SMTP_BIND_ADDRESS6@ +smtp_dns_support_level = @SMTP_DNS_SUPPORT_LEVEL@ +smtp_host_lookup = @SMTP_HOST_LOOKUP@ +smtp_tls_CAfile = @SMTP_TLS_CAFILE@ +smtp_tls_mandatory_protocols = @SMTP_TLS_MANDATORY_PROTOCOLS@ +smtp_tls_protocols = @SMTP_TLS_PROTOCOLS@ +smtp_tls_security_level = @SMTP_TLS_SECURITY_LEVEL@ +config_directory = /etc/postfix-srs/delivery diff --git a/files/srs/templates/delivery-master.cf b/files/srs/templates/delivery-master.cf new file mode 100644 index 0000000..3cdee4e --- /dev/null +++ b/files/srs/templates/delivery-master.cf @@ -0,0 +1,199 @@ +# +# Postfix master process configuration file. For details on the format +# of the file, see the master(5) manual page (command: "man 5 master" or +# on-line: https://www.postfix.org/master.5.html). +# +# Do not forget to execute "postfix reload" after editing this file. +# +# ========================================================================== +# service type private unpriv chroot wakeup maxproc command + args +# (yes) (yes) (no) (never) (100) +# ========================================================================== +#smtp inet n - y - - smtpd +#smtp inet n - y - 1 postscreen +#smtpd pass - - y - - smtpd +#dnsblog unix - - y - 0 dnsblog +#tlsproxy unix - - y - 0 tlsproxy +# Choose one: enable submission for loopback clients only, or for any client. +#127.0.0.1:submission inet n - y - - smtpd +#submission inet n - y - - smtpd +# -o syslog_name=postfix/submission +# -o smtpd_forbid_unauth_pipelining=no +# -o smtpd_tls_security_level=encrypt +# -o smtpd_sasl_auth_enable=yes +# -o smtpd_tls_auth_only=yes +# -o local_header_rewrite_clients=static:all +# -o smtpd_hide_client_session=yes +# -o smtpd_reject_unlisted_recipient=no +# Instead of specifying complex smtpd__restrictions here, +# specify "smtpd__restrictions=$mua__restrictions" +# here, and specify mua__restrictions in main.cf (where +# "" is "client", "helo", "sender", "relay", or "recipient"). +# -o smtpd_client_restrictions= +# -o smtpd_helo_restrictions= +# -o smtpd_sender_restrictions= +# -o smtpd_relay_restrictions= +# -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject +# -o milter_macro_daemon_name=ORIGINATING +# Choose one: enable submissions for loopback clients only, or for any client. +#127.0.0.1:submissions inet n - y - - smtpd +#submissions inet n - y - - smtpd +# -o syslog_name=postfix/submissions +# -o smtpd_forbid_unauth_pipelining=no +# -o smtpd_tls_wrappermode=yes +# -o smtpd_sasl_auth_enable=yes +# -o local_header_rewrite_clients=static:all +# -o smtpd_hide_client_session=yes +# -o smtpd_reject_unlisted_recipient=no +# Instead of specifying complex smtpd__restrictions here, +# specify "smtpd__restrictions=$mua__restrictions" +# here, and specify mua__restrictions in main.cf (where +# "" is "client", "helo", "sender", "relay", or "recipient"). +# -o smtpd_client_restrictions= +# -o smtpd_helo_restrictions= +# -o smtpd_sender_restrictions= +# -o smtpd_relay_restrictions= +# -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject +# -o milter_macro_daemon_name=ORIGINATING +#628 inet n - y - - qmqpd +pickup unix n - y 60 1 pickup +cleanup unix n - y - 0 cleanup +qmgr unix n - n 300 1 qmgr +#qmgr unix n - n 300 1 oqmgr +tlsmgr unix - - y 1000? 1 tlsmgr +rewrite unix - - y - - trivial-rewrite +bounce unix - - y - 0 bounce +defer unix - - y - 0 bounce +trace unix - - y - 0 bounce +verify unix - - y - 1 verify +flush unix n - y 1000? 0 flush +proxymap unix - - n - - proxymap +proxywrite unix - - n - 1 proxymap +#smtp unix - - y - - smtp +#relay unix - - y - - smtp +# -o syslog_name=${multi_instance_name?{$multi_instance_name}:{postfix}}/$service_name +# -o smtp_helo_timeout=5 -o smtp_connect_timeout=5 +showq unix n - y - - showq +error unix - - y - - error +retry unix - - y - - error +discard unix - - y - - discard +#local unix - n n - - local +#virtual unix - n n - - virtual +#lmtp unix - - y - - lmtp +anvil unix - - y - 1 anvil +scache unix - - y - 1 scache +postlog unix-dgram n - n - 1 postlogd +# +# ==================================================================== +# Interfaces to non-Postfix software. Be sure to examine the manual +# pages of the non-Postfix software to find out what options it wants. +# +# Many of the following services use the Postfix pipe(8) delivery +# agent. See the pipe(8) man page for information about ${recipient} +# and other message envelope options. +# ==================================================================== +# +# maildrop. See the Postfix MAILDROP_README file for details. +# Also specify in main.cf: maildrop_destination_recipient_limit=1 +# +#maildrop unix - n n - - pipe +# flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient} +# +# ==================================================================== +# +# Recent Cyrus versions can use the existing "lmtp" master.cf entry. +# +# Specify in cyrus.conf: +# lmtp cmd="lmtpd -a" listen="localhost:lmtp" proto=tcp4 +# +# Specify in main.cf one or more of the following: +# mailbox_transport = lmtp:inet:localhost +# virtual_transport = lmtp:inet:localhost +# +# ==================================================================== +# +# Cyrus 2.1.5 (Amos Gouaux) +# Also specify in main.cf: cyrus_destination_recipient_limit=1 +# +#cyrus unix - n n - - pipe +# flags=DRX user=cyrus argv=/cyrus/bin/deliver -e -r ${sender} -m ${extension} ${user} +# +# ==================================================================== +# +# Old example of delivery via Cyrus. +# +#old-cyrus unix - n n - - pipe +# flags=R user=cyrus argv=/cyrus/bin/deliver -e -m ${extension} ${user} +# +# ==================================================================== +# +# See the Postfix UUCP_README file for configuration details. +# +#uucp unix - n n - - pipe +# flags=Fqhu user=uucp argv=uux -r -n -z -a$sender - $nexthop!rmail ($recipient) +# +# ==================================================================== +# +# Other external delivery methods. +# +#ifmail unix - n n - - pipe +# flags=F user=ftn argv=/usr/lib/ifmail/ifmail -r $nexthop ($recipient) +# +#bsmtp unix - n n - - pipe +# flags=Fq. user=bsmtp argv=/usr/lib/bsmtp/bsmtp -f $sender $nexthop $recipient +# +#scalemail-backend unix - n n - 2 pipe +# flags=R user=scalemail argv=/usr/lib/scalemail/bin/scalemail-store +# ${nexthop} ${user} ${extension} +# +#mailman unix - n n - - pipe +# flags=FRX user=list argv=/usr/lib/mailman/bin/postfix-to-mailman.py +# ${nexthop} ${user} + +# SRS reinjection - loopback only, no alias expansion +127.0.0.1:10028 inet n - y - - smtpd + -o syslog_name=postfix-srs-delivery/reinject + -o receive_override_options=no_address_mappings,no_unknown_recipient_checks + -o smtpd_recipient_restrictions=permit_mynetworks,reject + -o mynetworks=127.0.0.0/8 + +# SRS reverse cleanup - envelope recipients only, no virtual alias expansion +srs-reverse-cleanup unix n - y - 0 cleanup + -o syslog_name=postfix-srs-delivery/reverse-cleanup + -o recipient_canonical_maps=socketmap:inet:127.0.0.1:10003:reverse + -o recipient_canonical_classes=envelope_recipient + -o virtual_alias_maps= + -o canonical_maps= + -o sender_canonical_maps= + +# SRS reverse listener - loopback only + +# SRS reverse recipient verification policy +127.0.0.1:10029 inet n - y - - smtpd + -o syslog_name=postfix-srs-delivery/reverse-smtpd + -o cleanup_service_name=srs-reverse-cleanup + -o content_filter= + -o smtpd_milters= + -o mynetworks=127.0.0.0/8 + -o smtpd_relay_restrictions=permit_mynetworks,reject + -o smtpd_recipient_restrictions=check_policy_service,unix:private/srs-reverse-policy,reject + -o smtpd_reject_unlisted_recipient=no +srs-reverse-policy unix - n n - 0 spawn + user=nobody argv=/usr/local/sbin/srs-reverse-policy.py + +# ============================================================= +# LMTP delivery to productive Dovecot +# ============================================================= +srs-lmtp unix - - n - - lmtp + +# ============================================================= +# Outbound SMTP delivery +# ============================================================= +smtp unix - - n - - smtp + +# ============================================================= +# Internal SMTP transport for SRS reverse processing +# ============================================================= +srs-reverse-smtp unix - - n - - smtp + -o smtp_bind_address=127.0.0.1 + -o smtp_bind_address6=::1 diff --git a/files/srs/templates/postsrsd-srs.service b/files/srs/templates/postsrsd-srs.service new file mode 100644 index 0000000..6b7d19c --- /dev/null +++ b/files/srs/templates/postsrsd-srs.service @@ -0,0 +1,13 @@ +[Unit] +Description=PostSRSd SRS Service +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/postsrsd/sbin/postsrsd -C /etc/postsrsd-srs/postsrsd.conf +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/files/srs/templates/postsrsd.conf.in b/files/srs/templates/postsrsd.conf.in new file mode 100644 index 0000000..e9cbce2 --- /dev/null +++ b/files/srs/templates/postsrsd.conf.in @@ -0,0 +1,7 @@ +domains = {} +srs-domain = "@SRS_DOMAIN@" +socketmap = inet:127.0.0.1:10003 +secrets-file = "/etc/postsrsd-srs/secret" +milter-rewrite-local = off +original-envelope = embedded +debug = off diff --git a/files/srs/templates/srs-reverse-map.service b/files/srs/templates/srs-reverse-map.service new file mode 100644 index 0000000..db9faba --- /dev/null +++ b/files/srs/templates/srs-reverse-map.service @@ -0,0 +1,19 @@ +[Unit] +Description=Domain-restricted SRS reverse socketmap +After=network.target postsrsd-srs.service +Wants=postsrsd-srs.service + +[Service] +Type=simple +User=nobody +Group=nogroup +ExecStart=/usr/bin/python3 /usr/local/sbin/srs-reverse-map.py +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/files/srs/templates/transport.in b/files/srs/templates/transport.in new file mode 100644 index 0000000..1599e0d --- /dev/null +++ b/files/srs/templates/transport.in @@ -0,0 +1,2 @@ +# SRS-Bounces zurück an den produktiven Postfix +@SRS_DOMAIN@ srs-reverse-smtp:[127.0.0.1]:10029 diff --git a/helpers/srs-common.sh b/helpers/srs-common.sh old mode 100644 new mode 100755 index af1dd87..aa06546 --- a/helpers/srs-common.sh +++ b/helpers/srs-common.sh @@ -34,6 +34,9 @@ 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" diff --git a/helpers/srs-dns-check.py b/helpers/srs-dns-check.py new file mode 100644 index 0000000..9b94b61 --- /dev/null +++ b/helpers/srs-dns-check.py @@ -0,0 +1,178 @@ +#!/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)) + if len(spf_records) != 1 or spf_records[0].lower().split() != ['v=spf1', 'mx', '-all']: + 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' Authoritative NS {ns_name} ({ns_ip}): ' + ('OK' if not errors else 'FAILED')) + for err in errors: + print(f' [ error ] {err}') + return not errors + + +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"}') + if len(nameservers) > 8: + raise ValueError('More than eight authoritative nameservers; manual review required') + ok = True + for ns in nameservers: + try: + ip = addr_of_ns(ns) + if not check_server(ns, ip, domain, hostname, bind4, bind6): + ok = False + except ValueError as exc: + print(f' Authoritative NS {ns}: FAILED') + print(f' [ error ] {exc}') + ok = False + 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()) diff --git a/helpers/srs-virtual-alias.py b/helpers/srs-virtual-alias.py new file mode 100755 index 0000000..6ffb8f5 --- /dev/null +++ b/helpers/srs-virtual-alias.py @@ -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() diff --git a/install-update-srs.sh b/install-update-srs.sh new file mode 100755 index 0000000..3de993f --- /dev/null +++ b/install-update-srs.sh @@ -0,0 +1,1153 @@ +#!/usr/bin/env bash +# O.OPEN selective SRS installer - REVIEW DRAFT 0.17.1 +# This script is not yet approved for production execution. +# Reference: mx.warenform.de, Debian 13, PostSRSd 2.4.1. +# Install != activate: the main Postfix configuration is never modified here. +# Virtual alias bounce integration is tracked via a main.cf ownership marker. +# Persistent v1 state.conf remains immutable, including during live adoption. + +set -o pipefail + +working_dir="$(dirname "$(realpath "$0")")" +# shellcheck source=helpers/srs-common.sh +source "${working_dir}/helpers/srs-common.sh" + +POSTSRSD_REPOSITORY='https://github.com/roehling/postsrsd.git' +POSTSRSD_VERSION='2.4.1' +POSTSRSD_LINK='/usr/local/postsrsd' +POSTSRSD_SOURCE_BASE='/usr/local/src/postsrsd' +SRS_TEMPLATE_DIR="${working_dir}/files/srs/templates" +SRS_PYTHON_SOURCE_DIR="${working_dir}/files/srs" +SRS_DNS_CHECKER="${working_dir}/helpers/srs-dns-check.py" +SRS_BACKUP_BASE='/root/srs-installer-backups' + +INSTALL_MODE='' +BUILD_REQUIRED=true +LINK_SWITCHED=false +SRS_ENABLED=false +STAGE_DIR='' +BACKUP_DIR='' +CHANGES_STARTED=false +PREVIOUS_LINK='' +PREVIOUS_LINK_RAW='' +NEW_PREFIX='' +ROLLBACK_FAILED=false +NEW_PREFIX_INSTALLED=false +POSTFIX_MAIL_UID='' +POSTFIX_MAIL_GID='' +# Existing runtime directories whose ownership is corrected are tracked so +# a later failed maintenance update can restore their original ownership. +declare -a MODIFIED_POSTFIX_RUNTIME_DIRS=() +declare -a ORIGINAL_POSTFIX_RUNTIME_OWNERS=() +# New configuration directories are tracked separately from their files. +declare -a CREATED_CONFIG_DIRS=() +# Postfix check can create queue/data trees. Never recursively delete them +# during rollback: nonempty directories could contain mail or logs. +declare -a NEW_POSTFIX_RUNTIME_DIRS=() +SELECTED_SRS_DOMAIN='' +CONFIG_DOMAIN_HINT='' +CMAKE_PACKAGE_REQUIRED=false +APT_CMAKE_SIMULATION='' + +declare -a MODIFIED_PATHS=() +declare -a PATH_WAS_PRESENT=() + +# Postfix needs a root-owned queue base, but its data_directory must be +# writable by mail_owner (normally postfix). Postfix check can succeed even +# when data_directory has the wrong owner; postfix master then fails to create +# master.lock. Never recursively chown a directory or modify queue contents. +# Existing directory ownership changes are tracked for rollback. +ensure_postfix_runtime_directory() { + local path="$1" owner_uid="$2" owner_gid="$3" parent="${1%/*}" + local current_uid current_gid current_mode + [[ -d "$parent" && ! -L "$parent" ]] || + fatal_rollback "Missing or unsafe Postfix runtime parent: ${parent}" + if [[ -L "$path" ]]; then + fatal_rollback "Refusing symlinked Postfix runtime directory: ${path}" + elif [[ -d "$path" ]]; then + current_uid="$(stat -c '%u' "$path")" || fatal_rollback "Cannot stat ${path}" + current_gid="$(stat -c '%g' "$path")" || fatal_rollback "Cannot stat ${path}" + current_mode="$(stat -c '%a' "$path")" || fatal_rollback "Cannot stat ${path}" + if [[ "${current_uid}:${current_gid}" == "${owner_uid}:${owner_gid}" ]]; then + return 0 + fi + # Only the known, previous-installer error (root-owned data_directory) + # is repaired automatically. Do not seize directories owned by others. + [[ "$path" == /var/lib/postfix-srs-delivery && "${current_uid}:${current_gid}" == '0:0' ]] || + fatal_rollback "Unexpected owner of existing Postfix runtime directory: ${path}" + MODIFIED_POSTFIX_RUNTIME_DIRS+=("$path") + ORIGINAL_POSTFIX_RUNTIME_OWNERS+=("${current_uid}:${current_gid}") + chown "${owner_uid}:${owner_gid}" -- "$path" || + fatal_rollback "Cannot correct Postfix data_directory ownership: ${path}" + [[ "$(stat -c '%u:%g' "$path")" == "${owner_uid}:${owner_gid}" ]] || + fatal_rollback "Incorrect Postfix runtime directory ownership: ${path}" + return 0 + elif [[ -e "$path" ]]; then + fatal_rollback "Postfix runtime path is not a directory: ${path}" + fi + mkdir -- "$path" || fatal_rollback "Cannot create Postfix runtime directory: ${path}" + NEW_POSTFIX_RUNTIME_DIRS+=("$path") + chown "${owner_uid}:${owner_gid}" -- "$path" || + fatal_rollback "Cannot set Postfix runtime ownership: ${path}" + # Clear inherited special bits (e.g. setgid) on new directories. + chmod 00755 -- "$path" || + fatal_rollback "Cannot set Postfix runtime permissions: ${path}" + [[ "$(stat -c '%u:%g:%a' "$path")" == "${owner_uid}:${owner_gid}:755" ]] || + fatal_rollback "Unexpected Postfix runtime owner or permissions: ${path}" +} + +# Roll back only root directory ownership changes made by this installer. +# Do not touch any child paths or nonempty mail queues. +restore_postfix_runtime_directory_owners() { + local i path + for ((i=${#MODIFIED_POSTFIX_RUNTIME_DIRS[@]}-1;i>=0;i--)); do + path="${MODIFIED_POSTFIX_RUNTIME_DIRS[i]}" + if [[ -d "$path" && ! -L "$path" ]]; then + chown "${ORIGINAL_POSTFIX_RUNTIME_OWNERS[i]}" -- "$path" || + return 1 + else + return 1 + fi + done +} + +# Only remove completely empty configuration directories we created. +# In particular, never recursively delete a Postfix spool/data tree. +remove_new_directories_if_empty() { + local path + for path in "${NEW_POSTFIX_RUNTIME_DIRS[@]}"; do + if [[ -d "$path" && ! -L "$path" ]]; then + rmdir -- "$path" 2>/dev/null || + warn "New Postfix runtime directory remains (manual review): ${path}" + fi + done + local i + for ((i=${#CREATED_CONFIG_DIRS[@]}-1;i>=0;i--)); do + path="${CREATED_CONFIG_DIRS[i]}" + if [[ -d "$path" && ! -L "$path" ]]; then + rmdir -- "$path" 2>/dev/null || + warn "New configuration directory remains (manual review): ${path}" + fi + done +} + +# Ensure only normal directories are used for SRS config destinations. +# Newly created paths are recorded, so rollback can remove them if empty. +ensure_config_directory() { + local path="$1" mode="${2:-}" + if [[ -L "$path" ]]; then + fatal_rollback "Refusing symlinked configuration directory: ${path}" + elif [[ -d "$path" ]]; then + return 0 + elif [[ -e "$path" ]]; then + fatal_rollback "Configuration directory path is not a directory: ${path}" + fi + local parent="${path%/*}" + if [[ "$parent" != "$path" ]]; then + ensure_config_directory "$parent" + fi + if [[ -n "$mode" ]]; then + install -d -o root -g root -m "$mode" "$path" || + fatal_rollback "Cannot create configuration directory: ${path}" + else + mkdir -- "$path" || fatal_rollback "Cannot create configuration directory: ${path}" + fi + # Track the directory immediately, so permission-setting failures can + # also be rolled back. Some parent directories carry the setgid bit and + # install -d -m 0700 may inherit it (resulting in mode 2700). + CREATED_CONFIG_DIRS+=("$path") + if [[ -n "$mode" ]]; then + # The extra leading zero clears inherited setgid/sticky bits on Linux + # directories; chmod 0700 alone may preserve a directory's setgid bit. + chmod "0${mode}" "$path" || + fatal_rollback "Cannot enforce directory permissions: ${path}" + [[ "$(stat -c '%u:%g:%a' "$path")" == "0:0:${mode##0}" ]] || + fatal_rollback "Unexpected directory owner or mode: ${path}" + fi +} + +usage() { + cat <<'USAGE' +Usage: install-update-srs.sh --check + install-update-srs.sh --install [--version X.Y.Z] + install-update-srs.sh --help + + --check Ask for SRS domain, then read-only inventory; no lock files, + package installation, + Postfix operations, service changes or filesystem writes. + --install Ask for SRS domain first, run all read-only preflight checks, + then request approval before installing missing Debian CMake + build packages (only for version builds) and preparing installation. + Supports inactive integration updates even if PostSRSd is current; + refuses changes whenever SRS is activated or SRS services run. + Does NOT activate selective SRS in the main Postfix instance. + --version PostSRSd release to install (default: 2.4.1). + +WARNING: REVIEW DRAFT - do not execute --install on production servers +until its behavior and rollback have been reviewed and tested. +USAGE +} + +MODE='' +while (($#)); do + case "$1" in + --check|--install|--help|-h) + [[ -z "$MODE" ]] || { usage >&2; exit 2; } + MODE="$1"; shift ;; + --version) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + POSTSRSD_VERSION="$2"; shift 2 ;; + *) usage >&2; exit 2 ;; + esac +done + +[[ "$MODE" == '--help' || "$MODE" == '-h' ]] && { usage; exit 0; } +[[ "$POSTSRSD_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Invalid PostSRSd version: ${POSTSRSD_VERSION}" >&2 + exit 2 +} +[[ "$MODE" == '--check' || "$MODE" == '--install' ]] || { usage >&2; exit 2; } + +NEW_PREFIX="/usr/local/postsrsd-${POSTSRSD_VERSION}" + +# A local configuration may provide a *display-only* prompt default. +# Do not source shell code before checking its root ownership/permissions. +read_config_domain_hint() { + local line candidate + CONFIG_DOMAIN_HINT='' + [[ -f "$SRS_CONF_FILE" && -r "$SRS_CONF_FILE" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*_SRS_DOMAIN[[:space:]]*= ]] || continue + candidate="${line#*=}" + candidate="${candidate%%#*}" + candidate="${candidate//[[:space:]]/}" + candidate="${candidate//\"/}" + candidate="${candidate//\'/}" + if [[ "$candidate" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]]; then + CONFIG_DOMAIN_HINT="${candidate,,}" + fi + done < "$SRS_CONF_FILE" +} + +# First user interaction, before any installation/prerequisite checks. +# The shared Git script never has a server-specific domain hardcoded. +prompt_srs_domain() { + local answer='' + read_config_domain_hint + echo + echo 'Selective SRS - domain selection' + echo + echo " Host..............................: $(hostname -f 2>/dev/null || hostname)" + echo ' Enter the envelope sender domain for SRS addresses.' + echo + while true; do + if [[ -n "$CONFIG_DOMAIN_HINT" ]]; then + printf ' SRS domain [%s]: ' "$CONFIG_DOMAIN_HINT" >&2 + else + printf ' SRS domain: ' >&2 + fi + if ! IFS= read -r answer; then + echo 'Input ended before an SRS domain was selected.' >&2 + exit 2 + fi + [[ -n "$answer" ]] || answer="$CONFIG_DOMAIN_HINT" + answer="${answer,,}" + if [[ "$answer" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]]; then + SELECTED_SRS_DOMAIN="$answer" + break + fi + echo ' [ invalid ] Enter a full DNS domain, e.g. srs.example.org.' >&2 + done + echo " Selected SRS domain...............: $SELECTED_SRS_DOMAIN" + echo +} + +# Consistent installer-only confirmation prompts. Keep the question indented +# and visually separate from the surrounding steps. Use bold only on an +# interactive terminal so redirected logs contain plain ASCII without ESC. +# An empty response means no. Unrecognized responses are prompted again. +ask_installer_confirmation() { + local question="$1" answer='' + echo >&2 + while true; do + if [[ -t 2 ]]; then + printf ' \033[1m%s [yes/no, default: no]: \033[0m' "$question" >&2 + else + printf ' %s [yes/no, default: no]: ' "$question" >&2 + fi + if ! IFS= read -r answer; then + echo >&2 + return 1 + fi + echo >&2 + case "${answer,,}" in + yes) return 0 ;; + no|'') return 1 ;; + *) echo " [ invalid ] Please enter 'yes' or 'no'." >&2 ;; + esac + done +} + +# Ask for the domain before --check and --install inspect prerequisites. +prompt_srs_domain + +check_templates() { + local relative + for relative in \ + 'delivery-main.cf.in' 'delivery-master.cf' 'transport.in' \ + 'postsrsd.conf.in' 'postsrsd-srs.service' 'srs-reverse-map.service'; do + [[ -r "${SRS_TEMPLATE_DIR}/${relative}" ]] || return 1 + done + return 0 +} + +check_python_sources() { + local script + for script in srs-alias-decision.py srs-forward.py srs-reverse-map.py srs-reverse-policy.py; do + [[ -r "${SRS_PYTHON_SOURCE_DIR}/${script}" ]] || return 1 + done + return 0 +} + +# DNS readiness is verified against authoritative nameservers, bypassing +# /etc/hosts and the local systemd-resolved stub. The helper is intentionally +# fail-closed for the agreed single-MX + "v=spf1 mx -all" SRS setup. +verify_srs_domain_dns() { + [[ -r "$SRS_DNS_CHECKER" ]] || { + echo " [ error ] Missing DNS preflight helper: $SRS_DNS_CHECKER" >&2 + return 1 + } + local command + for command in python3 postconf dig; do + command -v "$command" >/dev/null 2>&1 || { + echo " [ error ] Missing DNS validation command: $command" >&2 + return 1 + } + done + + # Collect all postconf stdout/stderr separately, without creating files. + # This also keeps --check strictly read-only and prevents main.cf warnings + # from interrupting the formatted DNS report. Any unexpected diagnostic + # remains blocking: only existing unused main.cf parameters are classified + # as unrelated/nonblocking. Deduplicate warnings repeated by postconf. + python3 - "$SRS_DNS_CHECKER" "$SELECTED_SRS_DOMAIN" <<'PYDNS' +import re +import subprocess +import sys + +checker, domain = sys.argv[1:] +parameters = ( + ('myhostname', '--hostname'), + ('smtp_bind_address', '--ipv4'), + ('smtp_bind_address6', '--ipv6'), +) +values = {} +nonblocking = [] +blocking = [] +for name, option in parameters: + try: + result = subprocess.run( + ['postconf', '-h', name], capture_output=True, text=True, + check=False) + except OSError as exc: + blocking.append(f'Cannot execute postconf for {name}: {exc}') + continue + if result.returncode != 0: + blocking.append(f'postconf -h {name} returned {result.returncode}') + value = result.stdout.rstrip('\n') + if '\n' in value or '\r' in value: + blocking.append(f'postconf -h {name} returned a multiline value') + else: + values[option] = value + for line in result.stderr.splitlines(): + line = line.strip() + if not line: + continue + known = re.fullmatch( + r'(?:\S*postconf:\s*)?warning:\s*' + r'(?P/etc/postfix/main\.cf:\s*unused parameter:\s*.+)', + line + ) + if known: + message = known.group('message') + if message not in nonblocking: + nonblocking.append(message) + elif line not in blocking: + blocking.append(line) + +if blocking: + print(' Postfix parameter retrieval........: FAILED') + print(' Public SRS DNS preflight...........: NOT RUN') + result_code = 1 +else: + args = ['python3', checker, '--domain', domain] + for _name, option in parameters: + args.extend([option, values[option]]) + result = subprocess.run(args, capture_output=True, text=True, check=False) + if result.stdout: + print(result.stdout, end='' if result.stdout.endswith('\n') else '\n') + if result.stderr.strip(): + blocking.extend(result.stderr.strip().splitlines()) + result_code = result.returncode or (1 if blocking else 0) + +print() +print(' Postfix diagnostics:') +if nonblocking: + print(' [ assessment ] Non-blocking for this SRS setup:') + print(' Postfix ignores the following main.cf settings.') + print(' Review separately if they were meant to be active.') + for line in nonblocking: + print(f' [ warning ] {line}') +else: + print(' [ ok ] No Postfix warnings') +if blocking: + print(' [ error ] Unexpected Postfix/DNS diagnostics - review required;') + print(' SRS installation is blocked.') + for line in blocking: + print(f' [ error ] {line}') + +sys.exit(result_code) +PYDNS +} + +# Compact display of APT simulations; the full text is retained for +# approval checks and printed unchanged if its format is unexpected. +show_apt_plan() { + local simulation="$1" summary packages + summary="$(grep -E '^[0-9]+ upgraded, [0-9]+ newly installed, [0-9]+ to remove and [0-9]+ not upgraded\.$' <<< "$simulation" | tail -n 1)" + packages="$(awk '$1 == "Inst" { if (out != "") out = out ", "; out = out $2 } END { print out }' <<< "$simulation")" + if [[ -z "$summary" || -z "$packages" ]]; then + echo ' [ warning ] APT plan could not be summarized; original output follows:' + printf '%s\n' "$simulation" + return 1 + fi + printf ' APT transaction...................: %s\n' "$summary" + printf ' New packages......................: %s\n' "$packages" +} + +# Read-only inventory. Deliberately does not call init_srs_runtime(). +read_only_check() { + local cmd file unit + echo + echo 'Selective SRS installer - read-only inventory' + echo " Host..............................: $(hostname -f)" + echo " Requested PostSRSd version........: ${POSTSRSD_VERSION}" + echo " Selected SRS domain...............: ${SELECTED_SRS_DOMAIN}" + echo " Repository........................: ${working_dir}" + echo + echo ' Public SRS DNS validation (authoritative nameservers):' + if ! verify_srs_domain_dns; then + echo ' [ warning ] Public DNS is not ready; --install is blocked.' + fi + echo + + for cmd in git cmake ctest make cc python3 postconf postmap postfix systemctl openssl; do + if command -v "$cmd" >/dev/null 2>&1; then + printf ' [ ok ] %s\n' "$cmd" + else + printf ' [ missing ] %s\n' "$cmd" + fi + done + if ! command -v cmake >/dev/null 2>&1; then + echo + echo ' Read-only APT simulation for missing cmake/ctest:' + if command -v apt-get >/dev/null 2>&1; then + local apt_preview + if apt_preview="$(LC_ALL=C apt-get -s install --no-install-recommends cmake)"; then + show_apt_plan "$apt_preview" || true + else + echo ' [ warning ] APT simulation failed; prerequisite cannot yet be installed.' + fi + else + echo ' [ warning ] apt-get is unavailable.' + fi + echo + fi + check_templates && echo ' [ ok ] SRS configuration templates' || echo ' [ missing ] SRS configuration templates' + check_python_sources && echo ' [ ok ] Four SRS Python source files' || echo ' [ missing ] SRS Python sources' + [[ -r "$SRS_DNS_CHECKER" ]] && echo ' [ ok ] Public DNS preflight helper' || echo ' [ missing ] Public DNS preflight helper' + echo + + if [[ -L "$POSTSRSD_LINK" ]]; then + echo " Current PostSRSd link............: $(readlink "$POSTSRSD_LINK")" + else + echo ' Current PostSRSd link............: absent or not a symlink' + fi + + for file in "$SRS_CONF_FILE" "$SRS_STATE_FILE" "$POSTSRSD_CONF_FILE" \ + "$POSTSRSD_SECRET_FILE" "${POSTFIX_SRS_DELIVERY_DIR}/main.cf" \ + "${POSTFIX_SRS_DELIVERY_DIR}/master.cf" "${POSTFIX_SRS_DELIVERY_DIR}/transport.db"; do + [[ -f "$file" ]] && printf ' [ present ] %s\n' "$file" || printf ' [ absent ] %s\n' "$file" + done + if [[ -f "$SRS_CONF_FILE" && -z "$CONFIG_DOMAIN_HINT" ]]; then + printf ' [ warning ] Existing SRS config has no simple readable _SRS_DOMAIN setting\n' + elif [[ -n "$CONFIG_DOMAIN_HINT" && "$CONFIG_DOMAIN_HINT" != "$SELECTED_SRS_DOMAIN" ]]; then + printf ' [ conflict ] Existing config domain: %s\n' "$CONFIG_DOMAIN_HINT" + fi + echo + for unit in "$POSTSRSD_SERVICE" "$SRS_REVERSE_MAP_SERVICE"; do + systemctl cat "$unit" >/dev/null 2>&1 && printf ' [ present ] %s\n' "$unit" || printf ' [ absent ] %s\n' "$unit" + done + # An instantiated template can be loadable even if its own configuration + # directory does not exist. Distinguish unit availability from readiness. + if systemctl cat "$SRS_DELIVERY_SERVICE" >/dev/null 2>&1; then + printf ' [ available template ] %s\n' "$SRS_DELIVERY_SERVICE" + else + printf ' [ absent ] %s\n' "$SRS_DELIVERY_SERVICE" + fi + if [[ -f "${POSTFIX_SRS_DELIVERY_DIR}/main.cf" ]]; then + printf ' [ present ] Delivery instance configuration\n' + else + printf ' [ absent ] Delivery instance configuration (service is not ready)\n' + fi + echo + echo ' Delivery service MAIL_CONFIG (if defined):' + systemctl show -P Environment "$SRS_DELIVERY_SERVICE" 2>/dev/null || true + echo + echo 'No changes made.' +} + +[[ "$MODE" == '--check' ]] && { read_only_check; exit 0; } + +# CMake 3.24+ is required by the pinned PostSRSd build. The Debian +# package "cmake" also supplies ctest. Never install any packages in --check. +# Before asking for approval, only SIMULATE the precise APT transaction. +# Reject simulated upgrades/removals: this installer must not quietly change +# otherwise unrelated packages on production mail servers. +check_cmake_version() { + local ver major minor patch + ver="$(cmake --version 2>/dev/null | head -n 1)" || return 1 + [[ "$ver" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]] || return 1 + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + ((10#$major > 3 || (10#$major == 3 && 10#$minor >= 24))) || return 1 +} + +prepare_cmake_package_plan() { + CMAKE_PACKAGE_REQUIRED=false + APT_CMAKE_SIMULATION='' + if command -v cmake >/dev/null 2>&1; then + check_cmake_version || fatal 'Installed CMake is older than 3.24 or cannot be identified. Review manually.' + command -v ctest >/dev/null 2>&1 || fatal 'CMake installed, but CTest is missing. Review Debian CMake installation manually.' + echo " CMake build dependency.............: $(cmake --version | head -n 1); ctest available" + return 0 + fi + + check_required_commands apt-get apt-cache dpkg-query + # Locale is fixed for reliable summary matching. No APT repository refresh + # is performed; Debian's currently configured package indexes are used. + APT_CMAKE_SIMULATION="$(LC_ALL=C apt-get -s install --no-install-recommends cmake)" || + fatal 'Cannot simulate the installation of the Debian cmake package.' + # Fail closed unless the summary unambiguously reports zero upgrades and + # zero removals. The simulation is only a preflight snapshot; APT will + # resolve dependencies again when the real transaction starts. + if ! grep -Eq '^0 upgraded, [1-9][0-9]* newly installed, 0 to remove and [0-9]+ not upgraded\.$' <<< "$APT_CMAKE_SIMULATION"; then + echo "$APT_CMAKE_SIMULATION" + fatal 'CMake APT transaction would upgrade/remove packages or has an unexpected summary. Review manually.' + fi + CMAKE_PACKAGE_REQUIRED=true + echo + echo ' CMake/CTest are missing. Proposed APT transaction:' + show_apt_plan "$APT_CMAKE_SIMULATION" || fatal 'APT plan format cannot be summarized reliably.' + echo + echo ' Package source.....................: configured Debian APT sources (no backports requested)' + echo ' No APT operation has been performed.' +} + +install_missing_build_packages() { + $CMAKE_PACKAGE_REQUIRED || return 0 + echo + echo ' CMake and CTest are required to build PostSRSd.' + echo ' APT will install cmake and its automatically selected dependencies.' + echo ' APT package changes are NOT part of the SRS configuration rollback.' + echo ' This approval does not authorize activating SRS or changing main Postfix.' + if ! ask_installer_confirmation 'Install the proposed Debian CMake packages now?'; then + echo 'Package installation cancelled. No SRS files installed.' + clean_up 0 + fi + # Second simulation immediately before package installation: prevent + # proceeding if a changing package index would trigger upgrades/removals. + local current_simulation + current_simulation="$(LC_ALL=C apt-get -s install --no-install-recommends cmake)" || + fatal 'APT re-simulation failed; package installation cancelled.' + if [[ "$current_simulation" != "$APT_CMAKE_SIMULATION" ]]; then + echo "$current_simulation" + fatal 'APT dependency plan changed after approval; package installation cancelled.' + fi + echo ' Installing Debian CMake package (explicitly approved)..' + LC_ALL=C DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends cmake || + fatal 'APT installation failed. Debian package state may need manual review.' + command -v cmake >/dev/null 2>&1 && check_cmake_version && + command -v ctest >/dev/null 2>&1 || + fatal 'APT completed but a supported CMake and CTest are still unavailable.' + echo ' [ ok ] CMake/CTest dependency installed.' +} + +# Everything below is reached only for the explicitly requested --install mode. +# PRE-FLIGHT: no lock, temporary files, package changes, Postfix operations, +# or filesystem writes before ALL checks and the first user confirmation. +# Unlike the reporting-only --check, --install fails closed on DNS errors. +check_root +check_required_commands \ + awk chown cp dig git grep hostname id install ln make mkdir mktemp mv openssl \ + postconf postfix postmap python3 realpath sed stat systemctl +# Determine the Postfix owner on the target host, not by assuming numeric IDs. +# Preflight this before any installation files are written. +[[ -f "${working_dir}/helpers/srs-virtual-alias.py" && ! -L "${working_dir}/helpers/srs-virtual-alias.py" ]] || + fatal 'Missing regular helpers/srs-virtual-alias.py file.' +python3 - "${working_dir}/helpers/srs-virtual-alias.py" <<'PY_HELPER' || fatal 'SRS alias helper syntax check failed.' +import pathlib, sys +source = pathlib.Path(sys.argv[1]).read_text() +compile(source, sys.argv[1], 'exec') +PY_HELPER +POSTFIX_MAIL_OWNER="$(postconf -h mail_owner 2>/dev/null)" || + fatal 'Cannot determine Postfix mail_owner.' +[[ "$POSTFIX_MAIL_OWNER" =~ ^[a-z_][a-z0-9_-]*$ ]] || + fatal "Unexpected Postfix mail_owner value: ${POSTFIX_MAIL_OWNER}" +POSTFIX_MAIL_UID="$(id -u "$POSTFIX_MAIL_OWNER")" || + fatal "Postfix mail_owner user does not exist: ${POSTFIX_MAIL_OWNER}" +POSTFIX_MAIL_GID="$(id -g "$POSTFIX_MAIL_OWNER")" || + fatal "Cannot determine primary group for Postfix mail_owner: ${POSTFIX_MAIL_OWNER}" +[[ "$POSTFIX_MAIL_UID" =~ ^[0-9]+$ && "$POSTFIX_MAIL_GID" =~ ^[0-9]+$ ]] || + fatal 'Invalid Postfix mail_owner UID or GID.' +check_python_sources || fatal 'SRS Python files are missing from the Git checkout.' +check_templates || fatal 'SRS reference templates are missing from the Git checkout.' +[[ -r "$SRS_DNS_CHECKER" ]] || fatal 'Public DNS verification helper is missing.' +echo ' Public SRS DNS validation (authoritative nameservers):' +verify_srs_domain_dns || fatal 'Public SRS DNS validation failed. Nothing installed.' +echo +# Existing sites retain their authoritative configuration. On a first +# installation the chosen domain is prepared and stored *after* approval, +# together with the rest of the SRS infrastructure. +if [[ -f "$SRS_CONF_FILE" ]]; then + load_srs_config + _SRS_DOMAIN="${_SRS_DOMAIN,,}" + [[ "$_SRS_DOMAIN" == "$SELECTED_SRS_DOMAIN" ]] || + fatal "Chosen domain '$SELECTED_SRS_DOMAIN' conflicts with existing '$SRS_CONF_FILE' ($_SRS_DOMAIN). Domain migration is not automatic." +else + [[ ! -e "$SRS_CONF_FILE" && ! -L "$SRS_CONF_FILE" ]] || + fatal "SRS configuration path '$SRS_CONF_FILE' is not a regular file." + _SRS_DOMAIN="$SELECTED_SRS_DOMAIN" +fi + +fatal_rollback() { + local why="$1" i dest id + local failed=false + trap - SIGHUP SIGINT SIGTERM EXIT + warn "Installation failed: ${why}" + + if $CHANGES_STARTED; then + warn 'Attempting rollback of tracked files and PostSRSd version link.' + for ((i=${#MODIFIED_PATHS[@]}-1;i>=0;i--)); do + dest="${MODIFIED_PATHS[i]}" + id="${i}" + if [[ "${PATH_WAS_PRESENT[i]}" == 'yes' ]]; then + cp -a "${BACKUP_DIR}/file-${id}" "$dest" || failed=true + else + rm -f -- "$dest" || failed=true + fi + done + if $LINK_SWITCHED; then + if [[ -n "$PREVIOUS_LINK" ]]; then + ln -s "${PREVIOUS_LINK_RAW}" "${STAGE_DIR}/previous-link" && + mv -Tf "${STAGE_DIR}/previous-link" "$POSTSRSD_LINK" || failed=true + elif [[ -L "$POSTSRSD_LINK" ]]; then + rm -f -- "$POSTSRSD_LINK" || failed=true + fi + fi + systemctl daemon-reload || failed=true + # A failed first installation must not leave the version path occupied, + # otherwise an identical retry would abort before any build or tests. + # Never remove an existing installation or a path currently selected + # by the stable symlink. + if $NEW_PREFIX_INSTALLED && [[ -d "$NEW_PREFIX" ]] && + [[ "$(readlink -f "$POSTSRSD_LINK" 2>/dev/null || true)" != "$NEW_PREFIX" ]]; then + rm -rf -- "$NEW_PREFIX" || failed=true + fi + # The Postfix check may create new queue/data subdirectories, even + # when it fails. Remove only empty roots; preserve anything nonempty. + remove_new_directories_if_empty + restore_postfix_runtime_directory_owners || failed=true + if $failed; then + error "Rollback incomplete. Preserved backup: ${BACKUP_DIR}" + else + info 'Previous files and symlink restored.' + fi + fi + [[ -n "$STAGE_DIR" && -d "$STAGE_DIR" ]] && rm -rf -- "$STAGE_DIR" + fatal "$why" +} + +handle_signal() { + trap - SIGHUP SIGINT SIGTERM EXIT + fatal_rollback 'Installation interrupted by signal.' +} + +# Called for EACH file we may install or replace. Snapshots retained as +# root-only disaster-recovery backups; do not overwrite them on later runs. +# A subshell/error that terminates unexpectedly during file deployment must +# not silently skip tracked-file rollback. The normal success path disables +# this handler before exiting. +on_unexpected_exit() { + local rc="$1" + trap - EXIT + if $CHANGES_STARTED; then + fatal_rollback "Unexpected script exit (status: ${rc}) after filesystem changes." + fi +} + +snapshot_file() { + local dest="$1" index="${#MODIFIED_PATHS[@]}" + [[ ! -d "$dest" ]] || fatal_rollback "Cannot overwrite directory '${dest}'." + [[ ! -L "$dest" ]] || fatal_rollback "Refusing to overwrite symlink '${dest}'." + if [[ -e "$dest" ]]; then + # Do not register a rollback entry until the backup is complete. + cp -a -- "$dest" "${BACKUP_DIR}/file-${index}" || + fatal_rollback "Backup failed for '${dest}'." + PATH_WAS_PRESENT+=(yes) + else + PATH_WAS_PRESENT+=(no) + fi + MODIFIED_PATHS+=("$dest") +} + +install_regular_file() { + local source="$1" dest="$2" mode="$3" + snapshot_file "$dest" + install -o root -g root -m "$mode" -- "$source" "$dest" || fatal_rollback "Cannot install '${dest}'." +} + +# Resolve the original PRE-SRS configuration, preserving explicit/absent +# semantics. Refuse unsupported multi-line assignments or ambiguous stanzas. +generate_initial_state() { + local output="$1" + python3 - "$POSTFIX_MAIN_CF" "$POSTFIX_MASTER_CF" "$output" "$(hostname -f)" <<'PY' +import pathlib, re, shlex, sys +main_path, master_path, output, hostname = sys.argv[1:] +main = pathlib.Path(main_path).read_text().splitlines() +master = pathlib.Path(master_path).read_text().splitlines() + +state = {'_STATE_VERSION': '1', '_STATE_ORIGIN': 'install-update-srs.sh', '_STATE_HOSTNAME': hostname} +main_params = ( + ('default_transport', 'DEFAULT_TRANSPORT'), + ('recipient_canonical_maps', 'RECIPIENT_CANONICAL_MAPS'), + ('srs-forward_destination_recipient_limit', 'SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT'), +) +for param, suffix in main_params: + match = [i for i, line in enumerate(main) + if re.match(r'^\s*' + re.escape(param) + r'\s*=', line) and not line.lstrip().startswith('#')] + if len(match) > 1: + raise SystemExit(f'Duplicate main.cf assignment for {param}') + key = '_PRE_SRS_' + suffix + state[key + '_SET'] = 'true' if match else 'false' + if match: + i = match[0] + if i + 1 < len(main) and main[i+1].startswith((' ', '\t')) and not main[i+1].lstrip().startswith('#'): + raise SystemExit(f'Multiline main.cf assignment unsupported: {param}') + state[key] = main[i].split('=',1)[1].strip() + else: + state[key] = '' + +for service, suffix in (('smtp', 'SMTP'), ('localhost:10025', 'AMAVIS')): + starts = [i for i, line in enumerate(master) + if re.match(r'^' + re.escape(service) + r'\s+inet\s+', line) and not line.startswith('#')] + if len(starts) != 1: + raise SystemExit(f'Expected exactly one active {service}/inet service in master.cf') + start = starts[0] + matches = [] + for line in master[start+1:]: + if line.strip() and not line[0].isspace() and not line.startswith('#'): + break + if line.lstrip().startswith('#'): + continue + m = re.match(r'^\s*-o\s+receive_override_options\s*=\s*(.*?)\s*$', line) + if m: + matches.append(m.group(1)) + if len(matches) > 1 or (matches and not matches[0]): + raise SystemExit(f'Ambiguous receive_override_options in {service}/inet') + key = f'_PRE_SRS_{suffix}_RECEIVE_OVERRIDE_OPTIONS' + state[key + '_SET'] = 'true' if matches else 'false' + state[key] = matches[0] if matches else '' + +# shlex.quote safely serializes only data, never SQL credentials or secrets. +with open(output, 'w', encoding='utf-8') as f: + f.write('# Generated once by install-update-srs.sh; NEVER reset on update.\n') + for key, value in state.items(): + f.write(f'{key}={shlex.quote(value)}\n') +PY +} + +# Prepare a site-specific delivery configuration from the exact reference +# template, preserving the same service/queue/routing architecture. +render_templates() { + local output="$1" + python3 - "$POSTFIX_MAIN_CF" "$SRS_TEMPLATE_DIR" "$output" "$_SRS_DOMAIN" <<'PY' +import pathlib, re, subprocess, sys +_, main_path, tmpl_path, dst_path, domain = sys.argv +src = pathlib.Path(tmpl_path) +dst = pathlib.Path(dst_path) + +reference_domain = 'srs.warenform.de' +if not re.fullmatch(r'(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}',domain): + raise SystemExit('Invalid SRS domain') + +for input_name, output_name in (('postsrsd.conf.in', 'postsrsd.conf'), ('transport.in', 'transport')): + template = (src/input_name).read_text() + if template.count('@SRS_DOMAIN@') != 1: + raise SystemExit(f'Unexpected template placeholder count in {input_name}') + (dst/output_name).write_text(template.replace('@SRS_DOMAIN@', domain)) + +text = (src/'delivery-main.cf.in').read_text() +# Derive Postfix parameter names from the template's left-hand side, not +# from an upper-case placeholder. Postfix names are case-sensitive: +# "smtp_tls_CAfile" must NOT be queried as "smtp_tls_cafile". +mapping = re.findall( + r'(?m)^[ \t]*([A-Za-z0-9_]+)[ \t]*=[ \t]*@([A-Z][A-Z0-9_]*)@[ \t]*$', text) +placeholders = re.findall(r'@([A-Z][A-Z0-9_]*)@', text) +if sorted(token for _, token in mapping) != sorted(placeholders): + raise SystemExit('Delivery placeholder not mapped to one Postfix setting') +for postfix_name, token in mapping: + result = subprocess.run( + ['/usr/sbin/postconf', '-c', '/etc/postfix', '-h', postfix_name], + capture_output=True, text=True, check=False) + # A pre-existing unused parameter elsewhere in main.cf can trigger a + # warning on *every* postconf invocation. Do not turn unrelated warnings + # into a deployment failure. Unknown queried parameters and all other + # warnings still fail closed (notably smtp_tls_cafile vs smtp_tls_CAfile). + diagnostics = [line.strip() for line in result.stderr.splitlines() + if line.strip()] + def unrelated_unused_main_cf_warning(line): + return re.fullmatch( + r'(?:\S*postconf:\s*)?warning:\s*' + r'/etc/postfix/main\.cf:\s*unused parameter:\s*.+', + line) is not None + unexpected = [line for line in diagnostics + if not unrelated_unused_main_cf_warning(line)] + if result.returncode != 0 or unexpected: + detail = '; '.join(unexpected or diagnostics) + raise SystemExit( + f'Cannot read Postfix parameter {postfix_name}: ' + f'{detail or "postconf exited unsuccessfully"}') + value = result.stdout.rstrip('\n') + if '\n' in value or '\r' in value: + raise SystemExit(f'Unexpected multiline Postfix value: {postfix_name}') + text = text.replace('@'+token+'@', value) +if re.search(r'@[A-Z][A-Z0-9_]*@', text): + raise SystemExit('Unexpanded delivery main.cf placeholders') +(dst/'delivery-main.cf').write_text(text) + +# Render Python helpers from the Git working tree, not from /usr/local/sbin. +project = src.parent +for filename in ('srs-alias-decision.py','srs-forward.py','srs-reverse-map.py','srs-reverse-policy.py'): + code = (project/filename).read_text() + if filename != 'srs-alias-decision.py': + if code.count(reference_domain) != 1: + raise SystemExit(f'Unexpected SRS domain occurrence count in {filename}') + code = code.replace(reference_domain, domain) + # Syntax check WITHOUT creating __pycache__ files on the Git checkout. + compile(code, filename, 'exec') + (dst/filename).write_text(code) +PY +} + +# Error if static PostgreSQL lookup map paths required by our current +# reference scripts are unavailable. Never silently alter alias semantics. +validate_reference_lookups() { + python3 - "${SRS_PYTHON_SOURCE_DIR}/srs-alias-decision.py" <<'PY' +import pathlib, re, sys +src = pathlib.Path(sys.argv[1]).read_text() +for backend, name in sorted(set(re.findall(r'"(pgsql|btree):(/etc/postfix/[^" ]+)"', src))): + f = pathlib.Path(name) + if backend == 'pgsql' and not f.is_file(): + raise SystemExit(f'Missing PostgreSQL lookup map: {f}') + if backend == 'btree' and not f.with_name(f.name + '.db').is_file(): + raise SystemExit(f'Missing btree index: {f}.db') +PY +} + +# Detect a deployed versioned binary using the stable symlink. +if [[ -L "$POSTSRSD_LINK" ]]; then + PREVIOUS_LINK_RAW="$(readlink "$POSTSRSD_LINK")" + PREVIOUS_LINK="$(readlink -f "$POSTSRSD_LINK")" + [[ "$PREVIOUS_LINK" == /usr/local/postsrsd-* ]] || fatal "Unexpected PostSRSd link target: $PREVIOUS_LINK" + [[ -x "$POSTSRSD_LINK/sbin/postsrsd" ]] || fatal 'Existing PostSRSd link is broken.' + INSTALL_MODE='upgrade' +elif [[ -e "$POSTSRSD_LINK" ]]; then + fatal "Non-symlink path '${POSTSRSD_LINK}' exists." +else + INSTALL_MODE='initial-installation' +fi + +# A missing stable link is not proof of a pristine installation. Such a +# situation requires manual review instead of overwriting possibly live files. +if [[ "$INSTALL_MODE" == 'initial-installation' ]]; then + for existing in "$SRS_STATE_FILE" "$POSTSRSD_CONF_FILE" "$POSTSRSD_SECRET_FILE" \ + "${POSTFIX_SRS_DELIVERY_DIR}/main.cf" "${POSTFIX_SRS_DELIVERY_DIR}/master.cf"; do + [[ ! -e "$existing" && ! -L "$existing" ]] || + fatal "Partial SRS installation detected at '${existing}' without ${POSTSRSD_LINK}. Review before installing." + done +fi + +if [[ -f "$SRS_STATE_FILE" ]]; then + load_srs_state + [[ -z "${_STATE_HOSTNAME:-}" || "$_STATE_HOSTNAME" == "$(hostname -f)" ]] || fatal 'Persistent SRS state belongs to another host.' +else + [[ "$INSTALL_MODE" == 'initial-installation' ]] || fatal 'Existing PostSRSd installation without state.conf: refusing to recapture PRE-SRS values.' + for file in "$POSTFIX_MAIN_CF" "$POSTFIX_MASTER_CF"; do + grep -Fqx "$SRS_MAIN_CF_BEGIN_MARKER" "$file" && fatal 'SRS managed configuration exists but state.conf is missing.' + done +fi + +SRS_ENABLED=false +if grep -Fqx "$SRS_MAIN_CF_BEGIN_MARKER" "$POSTFIX_MAIN_CF" || + grep -Fqx "$SRS_MASTER_CF_BEGIN_MARKER" "$POSTFIX_MASTER_CF"; then + SRS_ENABLED=true +fi + +# The same-version case is an *integration-only maintenance update*: leave +# the selected PostSRSd binary and its stable symlink untouched. Python helpers, +# delivery templates, systemd units and transport maps can still be refreshed. +# For a version change, build and stage a new immutable prefix as before. +if [[ "$INSTALL_MODE" == 'upgrade' && "$PREVIOUS_LINK" == "$NEW_PREFIX" ]]; then + INSTALL_MODE='maintenance-update' + BUILD_REQUIRED=false +else + BUILD_REQUIRED=true +fi + +# NEVER replace scripts, systemd units or delivery maps while SRS can be in +# production use. This applies to same-version updates too. Coordinate changes +# through deactivate-srs.sh and verify the services have stopped first. +if [[ "$INSTALL_MODE" != 'initial-installation' && "$SRS_ENABLED" == true ]]; then + fatal 'SRS active: only the revised activate-srs.sh may adopt SRS bounce alias settings live. Binary/service updates still require deactivation.' +fi +for running_unit in "$POSTSRSD_SERVICE" "$SRS_REVERSE_MAP_SERVICE" "$SRS_DELIVERY_SERVICE"; do + if systemctl is-active --quiet "$running_unit"; then + fatal "Service '$running_unit' is active. Stop/deactivate SRS before installing or updating any integration files." + fi +done + +if [[ "$INSTALL_MODE" != 'initial-installation' ]]; then + # An existing secret/state cannot be safely regenerated: doing so may break + # the reverse mapping of SRS senders that have already been sent. + [[ -f "$POSTSRSD_SECRET_FILE" && ! -L "$POSTSRSD_SECRET_FILE" ]] || + fatal 'Existing installation has no regular SRS secret. Refusing automatic regeneration.' + [[ -f "$SRS_STATE_FILE" && ! -L "$SRS_STATE_FILE" ]] || + fatal 'Existing installation has no regular PRE-SRS state file.' +fi + +check_python_sources || fatal 'SRS Python files are missing from the Git checkout.' +check_templates || fatal 'SRS reference templates are missing from the Git checkout.' +validate_reference_lookups || fatal 'Required Postfix lookup tables are not ready.' + +# Fail before build/confirmation on unsafe directory structures. An existing +# private secret directory must not be silently chmod'ed during installation. +for path in "$POSTSRSD_CONF_DIR" "$POSTFIX_SRS_DIR" "$POSTFIX_SRS_DELIVERY_DIR" /usr/local/sbin; do + [[ ! -L "$path" ]] || fatal "Refusing symlinked configuration directory: $path" + [[ ! -e "$path" || -d "$path" ]] || fatal "Expected directory at: $path" +done +if [[ -d "$POSTSRSD_CONF_DIR" ]]; then + [[ "$(stat -c '%u:%g:%a' "$POSTSRSD_CONF_DIR")" == '0:0:700' ]] || + fatal "Existing '$POSTSRSD_CONF_DIR' must be root:root mode 0700." +fi +# Prevent obvious clashes with a partial Postfix runtime installation. +for path in /var/spool/postfix-srs-delivery /var/lib/postfix-srs-delivery; do + [[ ! -L "$path" ]] || fatal "Refusing symlinked Postfix runtime path: $path" + [[ ! -e "$path" || -d "$path" ]] || fatal "Invalid Postfix runtime directory path: $path" +done + +if $BUILD_REQUIRED; then + [[ ! -e "$NEW_PREFIX" && ! -L "$NEW_PREFIX" ]] || + fatal "Target version prefix already exists: $NEW_PREFIX" + prepare_cmake_package_plan +else + [[ -x "$NEW_PREFIX/sbin/postsrsd" ]] || + fatal "Current PostSRSd binary is not executable: $NEW_PREFIX/sbin/postsrsd" + echo " CMake/CTest.........................: not needed for same-version integration update" +fi + +blank_line +echo " Mode..............................: ${INSTALL_MODE}" +echo " Host..............................: $(hostname -f)" +echo " SRS domain........................: ${_SRS_DOMAIN}" +echo " Current version path..............: ${PREVIOUS_LINK:-absent}" +echo " Target version path...............: ${NEW_PREFIX}" +echo " PostSRSd build required...........: ${BUILD_REQUIRED}" +echo ' Main Postfix SRS activation.......: unchanged' +echo ' Running SRS services..............: not started/restarted by this script' +echo + +# No side effects until ALL preflight checks have passed and the operator +# expressly approves preparation. Obtain the shared SRS lock only now. +# Ask using plain read: the helper UI is activated by init_srs_runtime(). +if ! ask_installer_confirmation 'Proceed with installation preparation?'; then + echo 'Installation preparation cancelled; no changes made.' + exit 0 +fi + +trap 'handle_signal' SIGHUP SIGINT SIGTERM +init_srs_runtime +if $BUILD_REQUIRED; then + install_missing_build_packages +fi + +mkdir -p "$POSTSRSD_SOURCE_BASE" || fatal 'Cannot create source directory.' +STAGE_DIR="$(mktemp -d "${POSTSRSD_SOURCE_BASE}/.stage-${POSTSRSD_VERSION}.XXXXXXXX")" || fatal 'Cannot create staging directory.' +chmod 0700 "$STAGE_DIR" || fatal 'Cannot secure staging directory.' +mkdir -p "${STAGE_DIR}/rendered" "${STAGE_DIR}/destdir" || fatal 'Cannot prepare staging directories.' + +# Preparation/build: absolutely NO installed files are changed before this succeeds. +if $BUILD_REQUIRED; then + echononl "Clone PostSRSd ${POSTSRSD_VERSION} release.." + if git clone --depth 1 --branch "${POSTSRSD_VERSION}" "$POSTSRSD_REPOSITORY" "${STAGE_DIR}/src" > "$log_file" 2>&1; then + echo_ok + else + echo_failed + fatal_rollback "Git clone failed: $(tail -n 12 "$log_file")" + fi + + echononl 'Configure PostSRSd build with reference settings..' + if cmake -S "${STAGE_DIR}/src" -B "${STAGE_DIR}/build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$NEW_PREFIX" \ + -DBUILD_TESTING=ON \ + -DWITH_REDIS=OFF -DWITH_SQLITE=OFF -DWITH_SECCOMP=OFF \ + -DGENERATE_SRS_SECRET=OFF \ + -DINSTALL_SYSTEMD_SERVICE=OFF -DINSTALL_SYSTEMD_SYSUSERS=OFF \ + > "$log_file" 2>&1; then + echo_ok + else + echo_failed + fatal_rollback "CMake configure failed: $(tail -n 12 "$log_file")" + fi + + echononl 'Build PostSRSd..' + if cmake --build "${STAGE_DIR}/build" -j 2 > "$log_file" 2>&1; then + echo_ok + else + echo_failed + fatal_rollback "Build failed: $(tail -n 12 "$log_file")" + fi + + echononl 'Run upstream CTest suite..' + if ctest --test-dir "${STAGE_DIR}/build" --output-on-failure > "$log_file" 2>&1; then + echo_ok + else + echo_failed + fatal_rollback "CTest failed: $(tail -n 20 "$log_file")" + fi + + echononl 'Stage versioned PostSRSd installation..' + if DESTDIR="${STAGE_DIR}/destdir" cmake --install "${STAGE_DIR}/build" > "$log_file" 2>&1 && + [[ -x "${STAGE_DIR}/destdir/${NEW_PREFIX#/}/sbin/postsrsd" ]]; then + echo_ok + else + echo_failed + fatal_rollback 'Staged PostSRSd binary was not installed as expected.' + fi +else + info "Keeping installed PostSRSd binary at ${NEW_PREFIX}; rendering integration files only." +fi + +render_templates "${STAGE_DIR}/rendered" || fatal_rollback 'Cannot render SRS configuration templates.' +if [[ ! -f "$SRS_CONF_FILE" ]]; then + printf '# Local SRS domain - created by install-update-srs.sh; do not commit.\n_SRS_DOMAIN="%s"\n' \ + "$SELECTED_SRS_DOMAIN" > "${STAGE_DIR}/rendered/srs.conf" || + fatal_rollback 'Cannot prepare local conf/srs.conf.' +fi +cp "$SRS_TEMPLATE_DIR/delivery-master.cf" "${STAGE_DIR}/rendered/delivery-master.cf" || fatal_rollback 'Cannot stage delivery master.cf.' + +if [[ ! -f "$SRS_STATE_FILE" ]]; then + generate_initial_state "${STAGE_DIR}/rendered/state.conf" || fatal_rollback 'Cannot capture the original PRE-SRS Postfix configuration.' +fi + +if [[ ! -f "$POSTSRSD_SECRET_FILE" ]]; then + (umask 077; openssl rand -hex 32 > "${STAGE_DIR}/rendered/secret") || fatal_rollback 'Cannot generate SRS secret.' +fi + +if ! ask_installer_confirmation 'Preparation succeeded. Install the staged SRS files now'; then + rm -rf -- "$STAGE_DIR" + clean_up 0 +fi + +# Backups are created only after the operator approves file deployment. +mkdir -p "$SRS_BACKUP_BASE" || fatal_rollback 'Cannot create root backup directory.' +BACKUP_DIR="$(mktemp -d "${SRS_BACKUP_BASE}/srs-${backup_date}.XXXXXXXX")" || fatal_rollback 'Cannot create backup directory.' +chmod 0700 "$BACKUP_DIR" || fatal_rollback 'Cannot protect backup directory.' + +CHANGES_STARTED=true +trap 'on_unexpected_exit "$?"' EXIT +if [[ ! -f "$SRS_CONF_FILE" ]]; then + install_regular_file "${STAGE_DIR}/rendered/srs.conf" "$SRS_CONF_FILE" 0644 +fi +# Keep the secret directory private, as on mx.warenform.de. An existing +# directory with weaker permissions must be reviewed manually, not silently +# chmod'ed in place (which would evade file-only rollback tracking). +ensure_config_directory "$POSTSRSD_CONF_DIR" 0700 +ensure_config_directory "$POSTFIX_SRS_DELIVERY_DIR" +ensure_config_directory /usr/local/sbin + +# The new release is fully present before changing the stable symlink. +if $BUILD_REQUIRED; then + NEW_PREFIX_INSTALLED=true + mv "${STAGE_DIR}/destdir/${NEW_PREFIX#/}" "$NEW_PREFIX" || + fatal_rollback 'Cannot install new PostSRSd version prefix.' +fi + +if [[ ! -f "$SRS_STATE_FILE" ]]; then + install_regular_file "${STAGE_DIR}/rendered/state.conf" "$SRS_STATE_FILE" 0600 +fi +if [[ ! -f "$POSTSRSD_SECRET_FILE" ]]; then + install_regular_file "${STAGE_DIR}/rendered/secret" "$POSTSRSD_SECRET_FILE" 0600 +fi +install_regular_file "${STAGE_DIR}/rendered/postsrsd.conf" "$POSTSRSD_CONF_FILE" 0644 + +for name in srs-alias-decision.py srs-forward.py srs-reverse-map.py srs-reverse-policy.py; do + install_regular_file "${STAGE_DIR}/rendered/$name" "/usr/local/sbin/$name" 0755 +done + +install_regular_file "${STAGE_DIR}/rendered/delivery-main.cf" "${POSTFIX_SRS_DELIVERY_DIR}/main.cf" 0644 +install_regular_file "${STAGE_DIR}/rendered/delivery-master.cf" "${POSTFIX_SRS_DELIVERY_DIR}/master.cf" 0644 +install_regular_file "${STAGE_DIR}/rendered/transport" "${POSTFIX_SRS_DELIVERY_DIR}/transport" 0644 + +# Rebuild the database after the actual transport file has been installed. +snapshot_file "${POSTFIX_SRS_DELIVERY_DIR}/transport.db" +postmap "btree:${POSTFIX_SRS_DELIVERY_DIR}/transport" > "$log_file" 2>&1 || fatal_rollback "postmap failed: $(tail -n 10 "$log_file")" + +install_regular_file "${SRS_TEMPLATE_DIR}/postsrsd-srs.service" "/etc/systemd/system/postsrsd-srs.service" 0644 +install_regular_file "${SRS_TEMPLATE_DIR}/srs-reverse-map.service" "/etc/systemd/system/srs-reverse-map.service" 0644 + +if $BUILD_REQUIRED; then + LINK_SWITCHED=true + ln -s "postsrsd-${POSTSRSD_VERSION}" "${STAGE_DIR}/new-link" && + mv -Tf "${STAGE_DIR}/new-link" "$POSTSRSD_LINK" || + fatal_rollback 'Cannot switch PostSRSd symlink.' +fi + +# The Debian postfix@.service uses /etc/postfix-%I; for instance +# 'srs-delivery', systemd unescapes %I to 'srs/delivery'. NO symlink is needed. +systemctl daemon-reload > "$log_file" 2>&1 || fatal_rollback "systemd reload failed: $(tail -n 12 "$log_file")" + +# Postfix needs its separate queue root before "postfix check": it performs +# chdir(queue_directory) before creating/validating the queue subdirectories. +# Only newly created roots are tracked. Never recursively remove a queue on +# rollback: Postfix can leave nonempty internal directories, or even messages. +# No changes or restarts to the main Postfix instance occur here. +ensure_postfix_runtime_directory /var/spool/postfix-srs-delivery 0 0 +ensure_postfix_runtime_directory /var/lib/postfix-srs-delivery "$POSTFIX_MAIL_UID" "$POSTFIX_MAIL_GID" +postfix -c "$POSTFIX_SRS_DELIVERY_DIR" check > "$log_file" 2>&1 || fatal_rollback "Delivery Postfix check failed: $(tail -n 12 "$log_file")" + +[[ -x "$POSTSRSD_LINK/sbin/postsrsd" && -r "$POSTSRSD_CONF_FILE" && -r "$SRS_STATE_FILE" ]] || + fatal_rollback 'Installation verification failed.' + +rm -rf -- "$STAGE_DIR" +STAGE_DIR='' +CHANGES_STARTED=false +trap - EXIT SIGHUP SIGINT SIGTERM + +blank_line +info "SRS ${INSTALL_MODE} completed (PostSRSd ${POSTSRSD_VERSION}). Backup: ${BACKUP_DIR}" +info 'Selective SRS is NOT activated by this installer.' +info "To activate after review, use: ${working_dir}/activate-srs.sh" +clean_up 0 diff --git a/tests/test_installer_mock_safe.py b/tests/test_installer_mock_safe.py new file mode 100755 index 0000000..b2cc39b --- /dev/null +++ b/tests/test_installer_mock_safe.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""Offline, path-relocated installer contract tests. All external commands mocked. +Do not equate these tests with genuine Postfix/PostSRSd integration. +""" +from pathlib import Path +import os, subprocess, tempfile, re, sys, shutil, signal, types + +SOURCE = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parents[1] + +def putexe(file, content): + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text('#!/usr/bin/env bash\n' + content) + file.chmod(0o755) + +def setup(root): + root.mkdir(parents=True, exist_ok=True) + repo = root / 'repo' + repo.mkdir() + # Copy only public SRS source files needed by the test. Never copy the + # complete mailserver repository, .git, credentials, local conf/srs.conf, + # or unrelated applications into an executable test fixture. + if not SOURCE.is_dir(): + raise RuntimeError(f'SOURCE must be a repository directory: {SOURCE}') + required = [ + 'install-update-srs.sh', + 'helpers/srs-common.sh', + 'helpers/srs-dns-check.py', + 'helpers/srs-virtual-alias.py', + 'files/srs/srs-alias-decision.py', + 'files/srs/srs-forward.py', + 'files/srs/srs-reverse-map.py', + 'files/srs/srs-reverse-policy.py', + ] + templates = SOURCE/'files/srs/templates' + required += [str(f.relative_to(SOURCE)) for f in templates.iterdir() if f.is_file() and not f.is_symlink()] + for rel in required: + src = SOURCE/rel + if not src.is_file() or src.is_symlink(): + raise RuntimeError(f'Missing or symlinked source file: {src}') + dst = repo/rel + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + for path in (repo/'install-update-srs.sh', repo/'helpers/srs-common.sh', + *list((repo/'files/srs').glob('*.py')), + *list((repo/'files/srs/templates').glob('*'))): + s = path.read_text() + # Keep all managed install/config/queue paths in an unprivileged fixture tree. + # The postconf absolute command must also be relocated. + s = s.replace('/usr/sbin/postconf', str(root/'bin/postconf')) + for prefix in ['/etc/', '/usr/local/', '/var/', '/run/lock/', '/root/srs-installer-backups']: + new = str(root) + prefix + s = s.replace(prefix, new) + # prevent reading the real original root Postfix config in our fixtures + if path == repo/'helpers/srs-common.sh': + # The test runner uses a pipe for scripted confirmations; + # allow that only inside the copied, isolated helper fixture. + needle = 'if [[ -t 1 ]]; then' + if s.count(needle) != 1: + raise RuntimeError('Unexpected terminal gate in helper') + s = s.replace(needle, 'if true; then', 1) + path.write_text(s) + (repo/'install-update-srs.sh').chmod(0o755) + for p in ['etc/postfix','usr/local/sbin','run/lock','var/spool','var/lib','etc/systemd/system','flags']: + (root/p).mkdir(parents=True, exist_ok=True) + (root/'etc/postfix/main.cf').write_text('myhostname = mail.example.org\n') + (root/'etc/postfix/master.cf').write_text('smtp inet n - y - - smtpd\nlocalhost:10025 inet n - y - - smtpd\n -o receive_override_options=no_unknown_recipient_checks\n') + (repo/'conf').mkdir(exist_ok=True) + # All expected lookup paths are extracted from the (relocated) reference source. + alias = (repo/'files/srs/srs-alias-decision.py').read_text() + for typ,name in set(re.findall(r'"(pgsql|btree):([^" ]+)"', alias)): + f = Path(name + ('.db' if typ == 'btree' else '')) + f.parent.mkdir(parents=True,exist_ok=True) + f.touch() + bin = root/'bin' + bin.mkdir() + putexe(bin/'postconf', ''' +name="${@: -1}" +# e.mx.oopen.de has two pre-existing unrelated warnings in main.cf. +# They are emitted on every postconf call while the requested value is valid. +echo "postconf: warning: ${SRS_TEST_ROOT}/etc/postfix/main.cf: unused parameter: policyd-spf_time_limit=3600" >&2 +# The warning path matches the source main.cf in the relocated fixture. +# Existing warnings must be distinguished from queried-param errors. +if [[ -f "${SRS_TEST_ROOT}/flags/unknown_parameter_warning" ]]; then + echo "postconf: warning: smtp_tls_cafile: unknown parameter" >&2 +fi +case "$name" in +mail_owner) echo postfix;; +myhostname) echo mail.example.org;; +mydomain) echo example.org;; +myorigin) echo /etc/mailname;; +mydestination) echo 'mail.example.org localhost';; +relay_domains) echo '\u0024mydestination';; +virtual_alias_maps) echo 'pgsql:some-alias';; +virtual_alias_domains) echo 'btree:some-domain';; +virtual_mailbox_maps) echo 'pgsql:some-mailbox';; +virtual_mailbox_domains) echo 'pgsql:some-domain';; +smtp_bind_address) echo 192.0.2.22;; +smtp_bind_address6) echo 2001:db8::22;; +smtp_dns_support_level) echo dnssec;; +smtp_host_lookup) echo dns;; +smtp_tls_CAfile) echo /etc/ssl/certs/ca-certificates.crt;; +smtp_tls_mandatory_protocols|smtp_tls_protocols) echo '>=TLSv1.2';; +smtp_tls_security_level) echo dane;; +*) echo "unknown $name" >&2; exit 1;; +esac +''') + putexe(bin/'id', ''' +if [[ "$1" == -u && "$2" == postfix ]]; then echo 123; exit 0; fi +if [[ "$1" == -g && "$2" == postfix ]]; then echo 123; exit 0; fi +exec /usr/bin/id "$@" +''') + putexe(bin/'systemctl', ''' +if [[ "$1" == is-active ]]; then exit 3; fi +exit 0 +''') + putexe(bin/'git', ''' +if [[ "$1" == clone ]]; then + mkdir -p "${@: -1}"; exit 0 +fi +exec /usr/bin/git "$@" +''') + putexe(bin/'cmake', ''' +if [[ "$1" == --version ]]; then echo 'cmake version 3.31.6'; exit 0; fi +if [[ "$1" == -S ]]; then + for ((i=1; i<=$#; i++)); do + arg="${!i}" + if [[ "$arg" == -B ]]; then n=$((i+1)); build="${!n}"; fi + if [[ "$arg" == -DCMAKE_INSTALL_PREFIX=* ]]; then prefix="${arg#*=}"; fi + done + mkdir -p "$build"; printf '%s\\n' "$prefix" > "$build/prefix"; exit 0 +fi +if [[ "$1" == --build ]]; then exit 0; fi +if [[ "$1" == --install ]]; then + prefix="$(cat "$2/prefix")" + mkdir -p "${DESTDIR}${prefix}/sbin" + printf '#!/bin/sh\\nexit 0\\n' > "${DESTDIR}${prefix}/sbin/postsrsd" + chmod +x "${DESTDIR}${prefix}/sbin/postsrsd" + exit 0 +fi +exit 9 +''') + putexe(bin/'ctest','exit 0\n') + putexe(bin/'dig','exit 0\n') + putexe(bin/'postmap',''' +case "$1" in + btree:*) dest="${1#btree:}"; touch "${dest}.db";; + *) exit 1;; +esac +if [[ -f "${SRS_TEST_ROOT}/flags/fail_postmap" ]]; then exit 1; fi +exit 0 +''') + putexe(bin/'postfix',''' +if [[ "$*" == *check* ]]; then + mkdir -p "${SRS_TEST_ROOT}/var/spool/postfix-srs-delivery/private" "${SRS_TEST_ROOT}/var/lib/postfix-srs-delivery" + if [[ -f "${SRS_TEST_ROOT}/flags/fail_postfix" ]]; then echo 'forced postfix check error' >&2; exit 1; fi +fi +exit 0 +''') + (repo/'helpers/srs-dns-check.py').write_text('print(" Simulated authoritative DNS: OK")\n') + return repo + +def run(root, repo, *, fail=None, version=None): + if fail: (root/'flags'/('fail_'+fail)).touch() + argv = [str(repo/'install-update-srs.sh'), '--install'] + if version: argv.extend(['--version', version]) + env = os.environ.copy() + env.update({'PATH':str(root/'bin') + ':' + env['PATH'], 'SRS_TEST_ROOT':str(root), 'LC_ALL':'C'}) + # No real PTY is needed: the terminal requirement is disabled only in the + # relocated helper copy, never in the actual repository. + # File-backed capture prevents subprocess pipe hangs from descendant + # processes retaining output fds. A timeout kills the entire fixture group. + with tempfile.TemporaryFile(mode='w+t') as out: + proc = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=out, + stderr=subprocess.STDOUT, text=True, env=env, + start_new_session=True) + try: + proc.communicate(input='srs.example.org\nyes\nyes\n', timeout=20) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait(timeout=5) + raise RuntimeError('Mock installer timed out') + out.seek(0) + return types.SimpleNamespace(returncode=proc.returncode, stdout=out.read()) + +def assert_ok(condition, msg): + if not condition: raise AssertionError(msg) + +with tempfile.TemporaryDirectory(prefix='oopen-srs-test-') as tmp: + base=Path(tmp) + os.chmod(base, 0o2700) # Test SGID inheritance without granting group access. + root=base/'initial' + repo=setup(root) + result=run(root,repo) + print('initial install', result.returncode) + if result.returncode: print(result.stdout[-2500:]) + assert_ok(result.returncode == 0,'initial installation failed') + assert_ok((root/'usr/local/postsrsd').is_symlink(), 'missing symlink') + assert_ok((root/'usr/local/sbin/srs-forward.py').exists(), 'missing srs forward') + assert_ok((root/'etc/postsrsd-srs/secret').stat().st_mode & 0o777 == 0o600, 'secret mode') + assert_ok('smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt' in (root/'etc/postfix-srs/delivery/main.cf').read_text(),'rendered TLS CA') + print(" secret dir mode:", oct((root/"etc/postsrsd-srs").stat().st_mode & 0o777), "uid", (root/"etc/postsrsd-srs").stat().st_uid) + print(" stat check:", subprocess.check_output(["stat", "-c", "%u:%g:%a", str(root/"etc/postsrsd-srs")], text=True).strip()) + print(' initial install: state, secret, config, symlink, CAfile verified') + original_secret=(root/'etc/postsrsd-srs/secret').read_bytes() + original_forward=(root/'usr/local/sbin/srs-forward.py').read_bytes() + original_link=os.readlink(root/'usr/local/postsrsd') + result=run(root,repo) + print('maintenance update', result.returncode) + if result.returncode: print(result.stdout[-2500:]) + assert_ok(result.returncode == 0,'maintenance update failed') + assert_ok((root/'etc/postsrsd-srs/secret').read_bytes()==original_secret,'secret changed') + assert_ok(os.readlink(root/'usr/local/postsrsd')==original_link,'link changed') + print(' maintenance update: original secret and binary retained') + # Force late failure, after Postfix configuration and all units have been deployed. + source=repo/'files/srs/srs-forward.py' + source.write_text(source.read_text()+'\n# injected test change\n') + result=run(root,repo,fail='postfix') + print('failed maintenance rollback', result.returncode) + if result.returncode==0: print(result.stdout[-2500:]) + assert_ok(result.returncode != 0,'injected failure not detected') + assert_ok((root/'usr/local/sbin/srs-forward.py').read_bytes()==original_forward,'rollback did not restore file') + assert_ok((root/'etc/postsrsd-srs/secret').read_bytes()==original_secret,'rollback changed secret') + assert_ok(os.readlink(root/'usr/local/postsrsd')==original_link,'rollback changed symlink') + print(' failure rollback: file restored; secret and symlink preserved') + (root/'flags/fail_postfix').unlink() + result=run(root,repo,version='2.4.2') + print('version upgrade', result.returncode) + if result.returncode: print(result.stdout[-2500:]) + assert_ok(result.returncode == 0,'version upgrade failed') + assert_ok((root/'usr/local/postsrsd-2.4.1').exists(),'prior prefix missing') + assert_ok(os.readlink(root/'usr/local/postsrsd')=='postsrsd-2.4.2','new link incorrect') + print(' version upgrade: previous prefix retained; link updated') + (root/'flags/fail_postfix').touch() + result=run(root,repo,version='2.4.3') + print('failed version upgrade rollback', result.returncode) + assert_ok(result.returncode != 0,'injected failure not detected') + assert_ok(os.readlink(root/'usr/local/postsrsd')=='postsrsd-2.4.2','previous version link not restored') + assert_ok(not (root/'usr/local/postsrsd-2.4.3').exists(),'failed version prefix not removed') + print(' failed version upgrade: symlink restored, new prefix removed') + + # Active SRS configuration must prohibit any maintenance update before writes. + (root/'flags/fail_postfix').unlink() + active_marker = '# BEGIN OOPEN-SRS-MANAGED' + existing_main=(root/'etc/postfix/main.cf') + original_main=existing_main.read_text() + existing_main.write_text(original_main + active_marker + '\n') + before=(root/'usr/local/sbin/srs-forward.py').read_bytes() + result=run(root,repo,version='2.4.2') + print('active integration guard', result.returncode) + assert_ok(result.returncode != 0,'active SRS was not refused') + assert_ok((root/'usr/local/sbin/srs-forward.py').read_bytes() == before, 'active check changed scripts') + existing_main.write_text(original_main) + print(' active integration guard: blocked before file changes') + + # Independent initial installation with late failure. New SRS files must be removed. + root=base/'failed-first' + repo=setup(root) + result=run(root,repo,fail='postfix') + print('failed first install', result.returncode) + assert_ok(result.returncode != 0,'injected first-install failure not detected') + assert_ok(not (root/'usr/local/postsrsd').exists(), 'first-install rollback left symlink') + assert_ok(not (root/'usr/local/postsrsd-2.4.1').exists(), 'first-install rollback left prefix') + assert_ok(not (root/'etc/postsrsd-srs/secret').exists(), 'first-install rollback left secret') + assert_ok(not (root/'etc/postfix-srs/delivery/main.cf').exists(), 'first-install rollback left config') + assert_ok((root/'var/spool/postfix-srs-delivery/private').is_dir(), 'test Postfix runtime queue unexpectedly deleted') + print(' failed first install: tracked files removed; Postfix runtime directory safely preserved') + + # Fail at postmap, before systemd and Postfix checks. + root=base/'failed-map' + repo=setup(root) + result=run(root,repo,fail='postmap') + print('failed postmap rollback', result.returncode) + assert_ok(result.returncode != 0, 'injected postmap failure not detected') + assert_ok(not (root/'etc/postfix-srs/delivery/transport.db').exists(),'failed postmap index remains') + assert_ok(not (root/'etc/postsrsd-srs/secret').exists(),'failed postmap secret remains') + assert_ok(not (root/'usr/local/postsrsd').exists(),'failed postmap link remains') + print(' failed postmap: generated index and secret removed') + + # Unexpected warnings must never be silently swallowed. + root=base/'unexpected-warning' + repo=setup(root) + (root/'flags/unknown_parameter_warning').touch() + result=run(root, repo) + print('unexpected postconf warning', result.returncode) + assert_ok(result.returncode != 0, 'unexpected postconf warning accepted') + assert_ok(not (root/'usr/local/postsrsd').exists(), 'unexpected warning allowed installation') + print(' unrelated existing warnings tolerated, queried-parameter warning rejected') + + # A template referencing an unknown Postfix parameter must fail closed. + root=base/'bad-template-parameter' + repo=setup(root) + template=root/'repo/files/srs/templates/delivery-main.cf.in' + contents=template.read_text() + assert_ok('smtp_tls_CAfile = @SMTP_TLS_CAFILE@' in contents, 'missing expected template parameter') + template.write_text(contents.replace('smtp_tls_CAfile = @SMTP_TLS_CAFILE@', + 'smtp_tls_cafile = @SMTP_TLS_CAFILE@')) + result=run(root, repo) + print('invalid template parameter', result.returncode) + assert_ok(result.returncode != 0, 'unknown template Postfix parameter accepted') + assert_ok(not (root/'usr/local/postsrsd').exists(), 'invalid template allowed installation') + print(' unknown requested Postfix parameter rejected') + +print('ALL OFFLINE MOCK CONTRACT TESTS PASSED')