Extend selective SRS management and add installer tests

This commit is contained in:
2026-10-20 11:20:38 +02:00
parent 3a7248191c
commit 53b92f7867
13 changed files with 2332 additions and 2 deletions
+83 -2
View File
@@ -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() { state_main_cf_matches() {
local parameter="$1" local parameter="$1"
local was_set="$2" local was_set="$2"
@@ -747,6 +786,8 @@ check_required_commands \
load_srs_config load_srs_config
load_srs_state load_srs_state
[[ -f "$SRS_ALIAS_HELPER" && ! -L "$SRS_ALIAS_HELPER" ]] ||
fatal "Missing regular SRS alias helper: ${SRS_ALIAS_HELPER}"
smtp_pre_value="" smtp_pre_value=""
amavis_pre_value="" amavis_pre_value=""
@@ -1255,9 +1296,31 @@ blank_line
# Handle detected state # 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 if [[ "$srs_state" == "enabled" ]]; then
info "Selective SRS is already enabled. Nothing to do." case "$srs_alias_status" in
clean_up 0 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 fi
if [[ "$srs_state" == "inconsistent" ]]; then if [[ "$srs_state" == "inconsistent" ]]; then
@@ -1268,6 +1331,8 @@ fi
if ! $activation_ready ; then if ! $activation_ready ; then
fatal "Selective SRS is disabled, but the installation is incomplete. Activation is not possible." fatal "Selective SRS is disabled, but the installation is incomplete. Activation is not possible."
fi 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}'." fatal_with_activation_rollback "Could not add managed SRS block to '${POSTFIX_MASTER_CF}'."
fi 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.." echononl "Set port 25 receive_override_options.."
if update_master_service_option \ if update_master_service_option \
smtp \ smtp \
@@ -1527,6 +1600,14 @@ else
verification_ok=false verification_ok=false
fi 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.." echononl "Verify 'srs-forward' pipe service.."
if master_srs_forward_present ; then if master_srs_forward_present ; then
echo_ok echo_ok
+41
View File
@@ -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() { state_main_cf_matches() {
local parameter="$1" local parameter="$1"
local was_set="$2" local was_set="$2"
@@ -919,6 +927,8 @@ check_required_commands \
load_srs_config load_srs_config
load_srs_state 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 # 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 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." info "Selective SRS is already disabled. Nothing to do."
clean_up 0 clean_up 0
fi fi
@@ -1425,6 +1440,11 @@ fi
if ! $installation_ok || ! $services_exist ; then if ! $installation_ok || ! $services_exist ; then
fatal "Selective SRS is enabled, but the installation is incomplete. Deactivation was not attempted." fatal "Selective SRS is enabled, but the installation is incomplete. Deactivation was not attempted."
fi 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 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 # Restore master.cf
# ---------- # ----------
@@ -1782,6 +1815,14 @@ else
verification_ok=false verification_ok=false
fi 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.." echononl "Verify pre-SRS master.cf state.."
if state_master_option_matches \ if state_master_option_matches \
smtp \ smtp \
+108
View File
@@ -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
+199
View File
@@ -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_<xxx>_restrictions here,
# specify "smtpd_<xxx>_restrictions=$mua_<xxx>_restrictions"
# here, and specify mua_<xxx>_restrictions in main.cf (where
# "<xxx>" 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_<xxx>_restrictions here,
# specify "smtpd_<xxx>_restrictions=$mua_<xxx>_restrictions"
# here, and specify mua_<xxx>_restrictions in main.cf (where
# "<xxx>" 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
+13
View File
@@ -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
+7
View File
@@ -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
@@ -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
+2
View File
@@ -0,0 +1,2 @@
# SRS-Bounces zurück an den produktiven Postfix
@SRS_DOMAIN@ srs-reverse-smtp:[127.0.0.1]:10029
Regular → Executable
+3
View File
@@ -34,6 +34,9 @@ POSTFIX_MASTER_CF="${POSTFIX_CONF_DIR}/master.cf"
POSTFIX_SRS_DIR="/etc/postfix-srs" POSTFIX_SRS_DIR="/etc/postfix-srs"
POSTFIX_SRS_DELIVERY_DIR="${POSTFIX_SRS_DIR}/delivery" POSTFIX_SRS_DELIVERY_DIR="${POSTFIX_SRS_DIR}/delivery"
SRS_STATE_FILE="${POSTFIX_SRS_DIR}/state.conf" 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_DIR="/etc/postsrsd-srs"
POSTSRSD_CONF_FILE="${POSTSRSD_CONF_DIR}/postsrsd.conf" POSTSRSD_CONF_FILE="${POSTSRSD_CONF_DIR}/postsrsd.conf"
+178
View File
@@ -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())
+223
View File
@@ -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()
+1153
View File
File diff suppressed because it is too large Load Diff
+303
View File
@@ -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')