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() {
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
+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() {
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 \
+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_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"
+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')