Add selective Postfix SRS installation and management

This commit is contained in:
2026-10-25 22:20:12 +01:00
parent e732e6d9d8
commit efaa58fcf9
28 changed files with 2325 additions and 202 deletions
File diff suppressed because it is too large Load Diff
@@ -1,13 +0,0 @@
# ---
# - Selective SRS configuration
# ---
# Domain used as envelope sender domain for SRS rewritten messages.
#
# DNS/SPF for this domain must authorize the outgoing mail server.
#
# Example:
#
# _SRS_DOMAIN="srs.example.org"
#
_SRS_DOMAIN=""
File diff suppressed because it is too large Load Diff
@@ -1,258 +0,0 @@
#!/usr/bin/python3
import subprocess
from collections import deque
import sys
ALIAS_MAPS = [
"pgsql:/etc/postfix/pgsql_virtual_alias_maps.cf",
"pgsql:/etc/postfix/pgsql_virtual_alias_domain_maps.cf",
"pgsql:/etc/postfix/pgsql_virtual_alias_address_rewriting.cf",
"pgsql:/etc/postfix/pgsql_virtual_alias_domain_catchall_maps.cf",
"btree:/etc/postfix/virtual_alias_maps",
]
MAILBOX_MAPS = [
"pgsql:/etc/postfix/pgsql_virtual_mailbox_maps.cf",
"pgsql:/etc/postfix/pgsql_virtual_alias_domain_mailbox_maps.cf",
]
DOMAIN_MAPS = [
"pgsql:/etc/postfix/pgsql_virtual_domains_maps.cf",
"pgsql:/etc/postfix/pgsql_relay-domain-maps.cf",
"btree:/etc/postfix/virtual_alias_domains",
]
TRANSPORT_MAPS = [
"pgsql:/etc/postfix/pgsql_transport_maps.cf",
"btree:/etc/postfix/transport",
"btree:/etc/postfix/relay_domains",
]
def lookup(key, table):
result = subprocess.run(
["/usr/sbin/postmap", "-q", key, table],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
return result.stdout.strip() or None
if result.returncode == 1 and not result.stderr.strip():
return None
raise RuntimeError(
f"{table}: {result.stderr.strip() or 'Lookup fehlgeschlagen'}"
)
def lookup_any(key, tables):
for table in tables:
value = lookup(key, table)
if value is not None:
return value
return None
def postconf(name):
result = subprocess.run(
["/usr/sbin/postconf", "-c", "/etc/postfix", "-x", "-h", name],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode:
raise RuntimeError(result.stderr.strip())
return result.stdout.strip()
def transport_keys(address):
localpart, domain = address.rsplit("@", 1)
keys = [address, f"{localpart}@"]
parts = domain.split(".")
for i in range(len(parts)):
suffix = ".".join(parts[i:])
keys.extend([suffix, "." + suffix])
return list(dict.fromkeys(keys))
def classify_destination(address):
domain = address.rsplit("@", 1)[1]
if lookup_any(address, MAILBOX_MAPS):
return "LOCAL"
if domain in postconf("mydestination").lower().split():
return "LOCAL"
if lookup_any(domain, DOMAIN_MAPS):
return "UNKNOWN"
for key in transport_keys(address):
transport = lookup_any(key, TRANSPORT_MAPS)
if transport:
transport_type = transport.split(":", 1)[0].lower()
if transport_type in ("smtp", "srs-forward"):
return "EXTERNAL"
return "UNKNOWN"
default_transport = postconf("default_transport").strip().lower()
if (
default_transport in ("smtp", "srs-forward")
or default_transport.startswith(("smtp:", "srs-forward:"))
):
return "EXTERNAL"
return "UNKNOWN"
MAX_ALIAS_DEPTH = 10
MAX_ALIAS_NODES = 200
def alias_reaches(original, recipient):
"""Find alias paths with BFS and detect cycles in the explored graph."""
pending = deque([(original, 0)])
visited = {original}
edges = {}
nodes = 0
depth_exceeded = False
while pending:
current, depth = pending.popleft()
nodes += 1
if nodes > MAX_ALIAS_NODES:
return None, "Zu viele Alias-Knoten"
aliases = lookup_any(current, ALIAS_MAPS)
if not aliases:
edges[current] = []
continue
targets = [
x.strip().lower()
for x in aliases.split(",")
if (
x.strip()
and x.strip().count("@") == 1
and x.strip().lower() != current
)
]
edges[current] = targets
for target in targets:
if target == recipient:
if depth + 1 <= MAX_ALIAS_DEPTH:
return True, "Alias-Kette erkannt"
depth_exceeded = True
continue
if target in visited:
continue
if depth + 1 >= MAX_ALIAS_DEPTH:
depth_exceeded = True
continue
visited.add(target)
pending.append((target, depth + 1))
if depth_exceeded:
return None, "Maximale Alias-Tiefe erreicht"
# Detect cycles in the explored directed graph.
# 0 = unseen, 1 = active, 2 = completed
state = {}
def has_cycle(node):
state[node] = 1
for target in edges.get(node, []):
if target not in edges:
continue
status = state.get(target, 0)
if status == 1:
return True
if status == 0 and has_cycle(target):
return True
state[node] = 2
return False
for node in edges:
if state.get(node, 0) == 0 and has_cycle(node):
return None, "Alias-Schleife erkannt"
return False, "Empfaenger nicht in direkter Alias-Kette"
def decide(sender, recipient, original):
sender = sender.strip().lower()
recipient = recipient.strip().lower()
original = original.strip().lower()
if not sender or sender == "<>":
return "NO_SRS", "Null-Absender"
if sender.count("@") != 1 or recipient.count("@") != 1:
return "UNKNOWN", "Ungueltige Empfaengeradresse"
if original.count("@") == 0:
return "NO_SRS", "Lokaler Alias"
if original.count("@") != 1:
return "UNKNOWN", "Ungueltige Empfaengeradresse"
if recipient == original:
return "NO_SRS", "Keine Empfaengerumschreibung"
reaches, reason = alias_reaches(original, recipient)
if reaches is not True:
return "UNKNOWN", reason
destination = classify_destination(recipient)
if destination == "LOCAL":
return "NO_SRS", "Lokales Postfach"
if destination == "EXTERNAL":
return "SRS", "PostfixAdmin-Alias-Kette mit SMTP-Zustellung"
return "UNKNOWN", "Zustellweg nicht eindeutig"
def main():
if len(sys.argv) != 4:
print(
"Aufruf: srs-alias-decision.py "
"<sender> <recipient> <original_recipient>",
file=sys.stderr,
)
return 2
try:
decision, reason = decide(*sys.argv[1:])
except (RuntimeError, subprocess.TimeoutExpired) as exc:
print(f"UNKNOWN: Lookup-Fehler: {exc}")
return 1
print(f"{decision}: {reason}")
return 1 if decision == "UNKNOWN" else 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,177 +0,0 @@
#!/usr/bin/python3
import smtplib
import subprocess
import sys
SMTP_HOST = "127.0.0.1"
SMTP_PORT = 10028
DECISION_SCRIPT = "/usr/local/sbin/srs-alias-decision.py"
SRS_MAP = "socketmap:inet:127.0.0.1:10003:forward"
EX_TEMPFAIL = 75
EX_UNAVAILABLE = 69
def run_command(args):
result = subprocess.run(
args,
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
raise RuntimeError(
result.stderr.strip()
or result.stdout.strip()
or f"Exit-Code {result.returncode}"
)
return result.stdout.strip()
def determine_sender(sender, recipient, original_recipient):
decision = run_command([
DECISION_SCRIPT,
sender,
recipient,
original_recipient
])
if decision.startswith("NO_SRS:"):
return sender
if not decision.startswith("SRS:"):
raise RuntimeError(
f"Keine eindeutige SRS-Entscheidung: {decision}"
)
rewritten_sender = run_command([
"/usr/sbin/postmap",
"-q",
sender,
SRS_MAP
])
if not rewritten_sender:
raise RuntimeError("PostSRSd lieferte keinen Absender")
if not rewritten_sender.lower().endswith("@srs.warenform.de"):
raise RuntimeError(
f"Unerwartete SRS-Domain: {rewritten_sender}"
)
if not rewritten_sender.upper().startswith(("SRS0=", "SRS1=")):
raise RuntimeError(
f"Ungueltige SRS-Adresse: {rewritten_sender}"
)
return rewritten_sender
def main():
if len(sys.argv) not in (5, 6):
print(
"Aufruf: srs-forward.py "
"sender recipient original_recipient queue_id [envid]",
file=sys.stderr
)
return EX_TEMPFAIL
sender, recipient, original_recipient, queue_id = sys.argv[1:5]
envid = sys.argv[5] if len(sys.argv) == 6 else ""
if envid.startswith("envid="):
envid = envid[len("envid="):]
try:
envelope_sender = determine_sender(
sender,
recipient,
original_recipient
)
message = sys.stdin.buffer.read()
with smtplib.SMTP(
SMTP_HOST,
SMTP_PORT,
timeout=30
) as smtp:
smtp.ehlo()
mail_options = []
rcpt_options = []
if envid:
if not smtp.has_extn("dsn"):
raise RuntimeError(
"SMTP-Ziel unterstuetzt kein DSN/ENVID"
)
mail_options.append(f"ENVID={envid}")
if original_recipient and smtp.has_extn("dsn"):
rcpt_options.append(
f"ORCPT=rfc822;{original_recipient}"
)
smtp.sendmail(
envelope_sender,
[recipient],
message,
mail_options=mail_options,
rcpt_options=rcpt_options
)
except smtplib.SMTPRecipientsRefused as exc:
codes = [
code
for code, response in exc.recipients.values()
]
permanent = bool(codes) and all(
500 <= code < 600 for code in codes
)
result = EX_UNAVAILABLE if permanent else EX_TEMPFAIL
enhanced_status = "5.0.0" if permanent else "4.0.0"
print(
f"{enhanced_status} SRS queue_id={queue_id}: "
f"SMTP-Empfaengerablehnung: {exc.recipients!r}",
file=sys.stderr
)
return result
except smtplib.SMTPResponseException as exc:
code = exc.smtp_code
result = EX_UNAVAILABLE if 500 <= code < 600 else EX_TEMPFAIL
enhanced_status = "5.0.0" if result == EX_UNAVAILABLE else "4.0.0"
print(
f"{enhanced_status} SRS queue_id={queue_id}: "
f"SMTP {code}: {exc.smtp_error!r}",
file=sys.stderr
)
return result
except (
subprocess.TimeoutExpired,
RuntimeError,
smtplib.SMTPException,
OSError
) as exc:
print(
f"SRS queue_id={queue_id}: temporaerer Fehler: {exc}",
file=sys.stderr
)
return EX_TEMPFAIL
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,112 +0,0 @@
#!/usr/bin/python3
import socket
import socketserver
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 10004
SRS_HOST = "127.0.0.1"
SRS_PORT = 10003
SRS_DOMAIN = "srs.warenform.de"
MAX_REQUEST = 8192
TIMEOUT = 3
def read_netstring(sock):
length = bytearray()
while True:
c = sock.recv(1)
if not c:
raise EOFError()
if c == b":":
break
if not c.isdigit() or len(length) >= 8:
raise ValueError("Invalid netstring length")
length.extend(c)
if not length:
raise ValueError("Empty netstring length")
size = int(length)
if size > MAX_REQUEST:
raise ValueError("Netstring too large")
data = bytearray()
while len(data) < size:
chunk = sock.recv(size - len(data))
if not chunk:
raise EOFError()
data.extend(chunk)
if sock.recv(1) != b",":
raise ValueError("Invalid netstring terminator")
return bytes(data)
def netstring(data):
return str(len(data)).encode() + b":" + data + b","
class Handler(socketserver.BaseRequestHandler):
def handle(self):
self.request.settimeout(TIMEOUT)
while True:
try:
request = read_netstring(self.request)
except (EOFError, OSError):
return
except ValueError:
self.request.sendall(netstring(b"PERM Invalid request"))
return
try:
operation, address = request.decode("utf-8").split(" ", 1)
except (UnicodeError, ValueError):
self.request.sendall(netstring(b"PERM Invalid request"))
continue
if operation != "reverse":
self.request.sendall(netstring(b"NOTFOUND "))
continue
if "@" not in address:
self.request.sendall(netstring(b"NOTFOUND "))
continue
domain = address.rsplit("@", 1)[1].lower()
if domain != SRS_DOMAIN:
self.request.sendall(netstring(b"NOTFOUND "))
continue
try:
with socket.create_connection(
(SRS_HOST, SRS_PORT), timeout=TIMEOUT
) as upstream:
upstream.settimeout(TIMEOUT)
upstream.sendall(netstring(request))
response = read_netstring(upstream)
if response.startswith((b"OK ", b"NOTFOUND ", b"TEMP ", b"PERM ")):
result = response
else:
result = b"TEMP Invalid upstream response"
except (OSError, EOFError, ValueError):
result = b"TEMP SRS backend unavailable"
self.request.sendall(netstring(result))
class Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
if __name__ == "__main__":
with Server((LISTEN_HOST, LISTEN_PORT), Handler) as server:
server.serve_forever()
@@ -1,113 +0,0 @@
#!/usr/bin/env python3
import socket
import sys
SRS_DOMAIN = "srs.warenform.de"
SOCKET_HOST = "127.0.0.1"
SOCKET_PORT = 10003
TIMEOUT = 5
MAX_RESPONSE = 4096
def read_netstring(sock):
length_bytes = bytearray()
while True:
char = sock.recv(1)
if not char:
raise ValueError("Incomplete netstring length")
if char == b":":
break
if char < b"0" or char > b"9" or len(length_bytes) >= 8:
raise ValueError("Invalid netstring length")
length_bytes.extend(char)
if not length_bytes:
raise ValueError("Empty netstring length")
length = int(length_bytes)
if length > MAX_RESPONSE:
raise ValueError("Netstring response too large")
data = bytearray()
while len(data) < length + 1:
chunk = sock.recv(length + 1 - len(data))
if not chunk:
raise ValueError("Incomplete netstring response")
data.extend(chunk)
if data[-1:] != b",":
raise ValueError("Invalid netstring terminator")
return bytes(data[:-1]).decode("utf-8")
def verify_srs(address):
request = f"reverse {address}".encode("utf-8")
packet = str(len(request)).encode("ascii") + b":" + request + b","
with socket.create_connection(
(SOCKET_HOST, SOCKET_PORT), timeout=TIMEOUT
) as sock:
sock.settimeout(TIMEOUT)
sock.sendall(packet)
response = read_netstring(sock)
if response.startswith("OK "):
original = response[3:]
if not original or "@" not in original:
raise ValueError("Invalid reverse result")
return "OK"
if response.startswith("NOTFOUND "):
return "REJECT 550 5.1.1 Invalid SRS address"
raise ValueError(f"Unexpected socketmap response: {response}")
def check_recipient(recipient):
if "@" not in recipient:
return "DUNNO"
localpart, domain = recipient.rsplit("@", 1)
if domain.lower() != SRS_DOMAIN:
return "DUNNO"
if not localpart.upper().startswith(("SRS0=", "SRS1=")):
return "REJECT 550 5.1.1 Invalid SRS address"
try:
return verify_srs(recipient)
except (OSError, UnicodeError, ValueError) as exc:
print(f"SRS verification error: {exc}", file=sys.stderr)
return "DEFER 451 4.3.0 SRS verification unavailable"
def main():
while True:
attributes = {}
for line in sys.stdin:
line = line.rstrip("\r\n")
if not line:
break
if "=" in line:
key, value = line.split("=", 1)
attributes[key] = value
else:
return
recipient = attributes.get("recipient", "")
action = check_recipient(recipient)
print(f"action={action}\n", flush=True)
if __name__ == "__main__":
main()
@@ -1,108 +0,0 @@
# 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
@@ -1,199 +0,0 @@
#
# 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
@@ -1,13 +0,0 @@
[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
@@ -1,7 +0,0 @@
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
@@ -1,19 +0,0 @@
[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
@@ -1,2 +0,0 @@
# SRS-Bounces zurück an den produktiven Postfix
@SRS_DOMAIN@ srs-reverse-smtp:[127.0.0.1]:10029
@@ -1,685 +0,0 @@
#!/usr/bin/env bash
# ----------
# Common functions and settings for selective SRS management scripts
#
# This file is intended to be sourced by:
#
# activate-srs.sh
# deactivate-srs.sh
# install-update-srs.sh
#
# It must not make configuration changes by itself.
# ----------
# ----------
# Script paths
# ----------
SRS_COMMON_FILE="$(realpath "${BASH_SOURCE[0]}")"
SRS_HELPERS_DIR="$(dirname "$SRS_COMMON_FILE")"
SRS_SCRIPT_DIR="$(dirname "$SRS_HELPERS_DIR")"
SRS_CONF_FILE="${SRS_SCRIPT_DIR}/conf/srs.conf"
SRS_CONF_SAMPLE_FILE="${SRS_SCRIPT_DIR}/conf/srs.conf.sample"
# ----------
# System paths
# ----------
POSTFIX_CONF_DIR="/etc/postfix"
POSTFIX_MAIN_CF="${POSTFIX_CONF_DIR}/main.cf"
POSTFIX_MASTER_CF="${POSTFIX_CONF_DIR}/master.cf"
POSTFIX_SRS_DIR="/etc/postfix-srs"
POSTFIX_SRS_DELIVERY_DIR="${POSTFIX_SRS_DIR}/delivery"
SRS_STATE_FILE="${POSTFIX_SRS_DIR}/state.conf"
# SRS bounce alias ownership is tracked by a comment marker in main.cf;
# original version-1 state.conf is immutable and needs no unsafe migration.
SRS_ALIAS_HELPER="${SRS_HELPERS_DIR}/srs-virtual-alias.py"
POSTSRSD_CONF_DIR="/etc/postsrsd-srs"
POSTSRSD_CONF_FILE="${POSTSRSD_CONF_DIR}/postsrsd.conf"
POSTSRSD_SECRET_FILE="${POSTSRSD_CONF_DIR}/secret"
SRS_ALIAS_DECISION_SCRIPT="/usr/local/sbin/srs-alias-decision.py"
SRS_FORWARD_SCRIPT="/usr/local/sbin/srs-forward.py"
SRS_REVERSE_MAP_SCRIPT="/usr/local/sbin/srs-reverse-map.py"
SRS_REVERSE_POLICY_SCRIPT="/usr/local/sbin/srs-reverse-policy.py"
# ----------
# Services
# ----------
POSTFIX_SERVICE="postfix.service"
POSTSRSD_SERVICE="postsrsd-srs.service"
SRS_REVERSE_MAP_SERVICE="srs-reverse-map.service"
SRS_DELIVERY_SERVICE="postfix@srs-delivery.service"
POSTFIX_SRS_DROPIN_DIR="/etc/systemd/system/postfix.service.d"
POSTFIX_SRS_DROPIN_FILE="${POSTFIX_SRS_DROPIN_DIR}/srs.conf"
SRS_DELIVERY_DROPIN_DIR="/etc/systemd/system/postfix@srs-delivery.service.d"
SRS_DELIVERY_DROPIN_FILE="${SRS_DELIVERY_DROPIN_DIR}/srs.conf"
# ----------
# SRS network endpoints
# ----------
POSTSRSD_SOCKET_HOST="127.0.0.1"
POSTSRSD_SOCKET_PORT="10003"
SRS_REVERSE_SOCKET_HOST="127.0.0.1"
SRS_REVERSE_SOCKET_PORT="10004"
SRS_REINJECT_HOST="127.0.0.1"
SRS_REINJECT_PORT="10028"
SRS_REVERSE_HOST="127.0.0.1"
SRS_REVERSE_PORT="10029"
# ----------
# Managed block markers
# ----------
SRS_MAIN_CF_BEGIN_MARKER="# BEGIN OOPEN-SRS-MANAGED"
SRS_MAIN_CF_END_MARKER="# END OOPEN-SRS-MANAGED"
SRS_MASTER_CF_BEGIN_MARKER="# BEGIN OOPEN-SRS-MANAGED"
SRS_MASTER_CF_END_MARKER="# END OOPEN-SRS-MANAGED"
# ----------
# Runtime variables
# ----------
script_name="$(basename "$0")"
terminal=false
SRS_LOCK_DIR="/run/lock/oopen-srs.lock"
SRS_LOCK_OWNER_FILE="${SRS_LOCK_DIR}/pid"
log_file=""
backup_date="$(date +%Y-%m-%d-%H%M%S)"
SRS_LOCK_ACQUIRED=false
SRS_CONFIG_LOADED=false
SRS_STATE_LOADED=false
# ----------
# Base functions
# ----------
blank_line() {
if $terminal ; then
echo ""
fi
}
echononl() {
if $terminal ; then
echo -e -n " $*" 1>&2
fi
}
echo_ok() {
if $terminal ; then
echo -e "\033[85G[ \033[32mok\033[m ]"
fi
}
echo_failed() {
if $terminal ; then
echo -e "\033[85G[ \033[1;31mfailed\033[m ]"
fi
}
echo_skipped() {
if $terminal ; then
echo -e "\033[85G[ \033[33m\033[1mskipped\033[m ]"
fi
}
echo_warning() {
if $terminal ; then
echo -e "\033[85G[ \033[33m\033[1mwarn\033[m ]"
fi
}
echo_differs() {
if $terminal ; then
echo -e "\033[85G[ \033[33m\033[1mdiffers\033[m ]"
fi
}
echo_inactive() {
if $terminal ; then
echo -e "\033[85G[ \033[33m\033[1minactive\033[m ]"
fi
}
echo_absent() {
if $terminal ; then
echo -e "\033[85G[ \033[33m\033[1mabsent\033[m ]"
fi
}
info() {
if $terminal ; then
echo ""
echo -e " [ \033[32m\033[1mInfo\033[m ] $*"
echo ""
else
echo " [ Info ] $*"
fi
}
warn() {
echo ""
if $terminal ; then
echo -e " [ \033[33m\033[1mWarning\033[m ] $*"
else
echo " [ Warning ] $*"
fi
echo ""
}
error() {
echo ""
if $terminal ; then
echo -e " [ \033[31m\033[1mError\033[m ] $*"
else
echo " [ Error ] $*"
fi
echo ""
}
clean_up() {
local rc="${1:-0}"
if $SRS_LOCK_ACQUIRED && [[ -d "$SRS_LOCK_DIR" ]]; then
rm -rf "$SRS_LOCK_DIR"
SRS_LOCK_ACQUIRED=false
fi
[[ -n "$log_file" && -f "$log_file" ]] && rm -f "$log_file"
blank_line
exit "$rc"
}
fatal() {
echo ""
if $terminal ; then
echo -e " [ \033[31m\033[1mFatal\033[m ] $*"
echo ""
echo -e " \033[1mScript terminated\033[m.."
else
echo " [ Fatal ] $*"
echo ""
echo " Script terminated.."
fi
echo ""
clean_up 1
}
fatal_with_command_hint() {
local message="$1"
local hint="$2"
local command="$3"
echo ""
if $terminal ; then
echo -e " [ \033[31m\033[1mFatal\033[m ] ${message}"
echo ""
echo -e " \033[1mScript terminated\033[m.."
else
echo " [ Fatal ] ${message}"
echo ""
echo " Script terminated.."
fi
echo ""
if $terminal ; then
echo -e " [ \033[33m\033[1mWarning\033[m ] ${hint}"
else
echo " [ Warning ] ${hint}"
fi
echo ""
echo " ${command}"
echo ""
clean_up 1
}
fatal_with_queue_help() {
local message="$1"
local queue_config_dir="$2"
echo ""
if $terminal ; then
echo -e " [ \033[31m\033[1mFatal\033[m ] ${message}"
echo ""
echo -e " \033[1mScript terminated\033[m.."
else
echo " [ Fatal ] ${message}"
echo ""
echo " Script terminated.."
fi
echo ""
if $terminal ; then
echo -e " [ \033[33m\033[1mWarning\033[m ] To inspect the SRS delivery queue, run:"
else
echo " [ Warning ] To inspect the SRS delivery queue, run:"
fi
echo ""
echo " postqueue -c ${queue_config_dir} -p"
echo ""
if $terminal ; then
echo -e " [ \033[33m\033[1mWarning\033[m ] To inspect one queued message, replace QUEUE_ID and run:"
else
echo " [ Warning ] To inspect one queued message, replace QUEUE_ID and run:"
fi
echo ""
echo " postcat -c ${queue_config_dir} -q QUEUE_ID"
echo ""
if $terminal ; then
echo -e " [ \033[33m\033[1mWarning\033[m ] If you really want to delete ALL messages from the SRS delivery queue, run:"
else
echo " [ Warning ] If you really want to delete ALL messages from the SRS delivery queue, run:"
fi
echo ""
echo " postsuper -c ${queue_config_dir} -d ALL"
echo ""
clean_up 1
}
ask_yes_no() {
local question="$1"
local default_answer="${2:-no}"
local answer=""
local prompt="[yes/no]"
if [[ "$default_answer" = "yes" ]]; then
prompt="[yes/no, default: yes]"
elif [[ "$default_answer" = "no" ]]; then
prompt="[yes/no, default: no]"
fi
while true ; do
echononl "$question $prompt: "
read -r answer
answer="${answer,,}"
[[ -z "$answer" ]] && answer="$default_answer"
case "$answer" in
yes) return 0 ;;
no) return 1 ;;
*) warn "Wrong entry! Please enter 'yes' or 'no'." ;;
esac
done
}
# ----------
# Runtime handling
# ----------
init_srs_runtime() {
local script_path=""
local transcript_file=""
if [[ -t 1 ]]; then
terminal=true
else
script_path="$(realpath "$0" 2>/dev/null || printf '%s' "$0")"
transcript_file="/tmp/${script_name%.sh}.out"
echo ""
echo " [ Fatal ] Script requires an interactive terminal."
echo ""
echo " Script terminated.."
echo ""
echo " [ Warning ] Piping the script output, for example through 'tee',"
echo " removes the terminal from stdout."
echo ""
echo " To run the script interactively and record its complete"
echo " terminal session, use:"
echo ""
echo " script -q -e -c \"${script_path}\" \\"
echo " ${transcript_file}"
echo ""
clean_up 1
fi
if ! mkdir "$SRS_LOCK_DIR" 2>/dev/null ; then
if [[ -r "$SRS_LOCK_OWNER_FILE" ]]; then
fatal "Another SRS management process seems to be running (PID $(cat "$SRS_LOCK_OWNER_FILE"))."
else
fatal "Cannot create SRS lock directory '${SRS_LOCK_DIR}'."
fi
fi
SRS_LOCK_ACQUIRED=true
printf '%s\n' "$$" > "$SRS_LOCK_OWNER_FILE" || fatal "Cannot write SRS lock owner file '${SRS_LOCK_OWNER_FILE}'."
log_file="${SRS_LOCK_DIR}/${script_name%%.*}.log"
: > "$log_file" || fatal "Cannot create log file '${log_file}'."
}
handle_signal() {
trap - SIGHUP SIGINT SIGTERM
warn "Script interrupted by signal."
clean_up 1
}
# ----------
# Basic checks
# ----------
check_root() {
echononl "Check if script is running as root.."
if [[ "$(id -u)" -eq 0 ]]; then
echo_ok
else
echo_failed
fatal "This script must be run as root."
fi
}
check_required_commands() {
local cmd
for cmd in "$@" ; do
echononl "Check for command '${cmd}'.."
if command -v "$cmd" > /dev/null 2>&1 ; then
echo_ok
else
echo_failed
fatal "Required command '${cmd}' was not found."
fi
done
}
check_readable_file() {
local file="$1"
local description="${2:-file}"
echononl "Check ${description} '${file}'.."
if [[ -r "$file" ]]; then
echo_ok
else
echo_failed
fatal "${description^} '${file}' does not exist or is not readable."
fi
}
check_service_exists() {
local service="$1"
echononl "Check systemd service '${service}'.."
if systemctl cat "$service" > /dev/null 2>&1 ; then
echo_ok
else
echo_failed
fatal "Systemd service '${service}' was not found."
fi
}
check_service_active() {
local service="$1"
echononl "Check systemd service '${service}' is active.."
if systemctl is-active --quiet "$service" ; then
echo_ok
else
echo_failed
fatal "Systemd service '${service}' is not active."
fi
}
# ----------
# Security checks for sourced files
# ----------
check_secure_source_file() {
local file="$1"
local description="${2:-configuration file}"
local owner_uid
local owner_gid
local mode
echononl "Check security of ${description} '${file}'.."
if [[ ! -f "$file" || ! -r "$file" ]]; then
echo_failed
fatal "${description^} '${file}' does not exist or is not readable."
fi
owner_uid="$(stat -c '%u' "$file" 2>/dev/null)" || {
echo_failed
fatal "Cannot determine owner of '${file}'."
}
owner_gid="$(stat -c '%g' "$file" 2>/dev/null)" || {
echo_failed
fatal "Cannot determine group of '${file}'."
}
mode="$(stat -c '%a' "$file" 2>/dev/null)" || {
echo_failed
fatal "Cannot determine permissions of '${file}'."
}
if [[ "$owner_uid" -ne 0 || "$owner_gid" -ne 0 ]]; then
echo_failed
fatal "${description^} '${file}' must be owned by root:root."
fi
if (( (8#$mode & 0022) != 0 )); then
echo_failed
fatal "${description^} '${file}' must not be writable by group or others."
fi
echo_ok
}
check_state_file_permissions() {
local mode
echononl "Check permissions of persistent SRS state '${SRS_STATE_FILE}'.."
if [[ ! -f "$SRS_STATE_FILE" ]]; then
echo_failed
fatal "Persistent SRS state '${SRS_STATE_FILE}' does not exist."
fi
if [[ "$(stat -c '%U:%G' "$SRS_STATE_FILE" 2>/dev/null)" != "root:root" ]]; then
echo_failed
fatal "Persistent SRS state '${SRS_STATE_FILE}' must be owned by root:root."
fi
mode="$(stat -c '%a' "$SRS_STATE_FILE" 2>/dev/null)" || {
echo_failed
fatal "Cannot determine permissions of '${SRS_STATE_FILE}'."
}
if [[ "$mode" != "600" ]]; then
echo_failed
fatal "Persistent SRS state '${SRS_STATE_FILE}' must have mode 0600 (current: ${mode})."
fi
echo_ok
}
# ----------
# SRS configuration
# ----------
load_srs_config() {
if [[ ! -r "$SRS_CONF_FILE" ]]; then
fatal "SRS configuration '${SRS_CONF_FILE}' does not exist or is not readable. Use '${SRS_CONF_SAMPLE_FILE}' as template."
fi
check_secure_source_file "$SRS_CONF_FILE" "SRS configuration"
echononl "Read SRS configuration '${SRS_CONF_FILE}'.."
unset _SRS_DOMAIN
# shellcheck source=/dev/null
source "$SRS_CONF_FILE"
if [[ -z "${_SRS_DOMAIN:-}" ]]; then
echo_failed
fatal "Variable '_SRS_DOMAIN' is not set in '${SRS_CONF_FILE}'."
fi
if [[ "$_SRS_DOMAIN" =~ [[:space:]] ]]; then
echo_failed
fatal "Invalid SRS domain '${_SRS_DOMAIN}': whitespace is not allowed."
fi
if [[ ! "$_SRS_DOMAIN" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]]; then
echo_failed
fatal "Invalid SRS domain '${_SRS_DOMAIN}'."
fi
SRS_CONFIG_LOADED=true
echo_ok
}
# ----------
# Persistent pre-SRS state
# ----------
_is_boolean_value() {
[[ "$1" = "true" || "$1" = "false" ]]
}
load_srs_state() {
if [[ ! -r "$SRS_STATE_FILE" ]]; then
fatal "Persistent SRS state '${SRS_STATE_FILE}' does not exist or is not readable."
fi
check_state_file_permissions
check_secure_source_file "$SRS_STATE_FILE" "persistent SRS state"
echononl "Read persistent SRS state '${SRS_STATE_FILE}'.."
unset _STATE_VERSION
unset _STATE_ORIGIN
unset _STATE_HOSTNAME
unset _PRE_SRS_DEFAULT_TRANSPORT_SET
unset _PRE_SRS_DEFAULT_TRANSPORT
unset _PRE_SRS_RECIPIENT_CANONICAL_MAPS_SET
unset _PRE_SRS_RECIPIENT_CANONICAL_MAPS
unset _PRE_SRS_SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT_SET
unset _PRE_SRS_SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT
unset _PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS_SET
unset _PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS
unset _PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS_SET
unset _PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS
# shellcheck source=/dev/null
source "$SRS_STATE_FILE"
if [[ "${_STATE_VERSION:-}" != "1" ]]; then
echo_failed
fatal "Unsupported or missing SRS state version in '${SRS_STATE_FILE}'."
fi
local bool_var
for bool_var in _PRE_SRS_DEFAULT_TRANSPORT_SET _PRE_SRS_RECIPIENT_CANONICAL_MAPS_SET _PRE_SRS_SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT_SET _PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS_SET _PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS_SET
do
if ! _is_boolean_value "${!bool_var:-}" ; then
echo_failed
fatal "Invalid boolean value for '${bool_var}' in '${SRS_STATE_FILE}'."
fi
done
if [[ -n "${_STATE_HOSTNAME:-}" && "${_STATE_HOSTNAME}" != "$(hostname -f)" ]]; then
echo_warning
warn "SRS state was created for host '${_STATE_HOSTNAME}', current hostname is '$(hostname -f)'."
else
echo_ok
fi
SRS_STATE_LOADED=true
}
# ----------
# Managed block helpers
# ----------
count_exact_line() {
local file="$1"
local line="$2"
grep -Fxc "$line" "$file" 2>/dev/null || true
}
check_managed_block_markers() {
local file="$1"
local begin_marker="$2"
local end_marker="$3"
local begin_count
local end_count
local begin_line
local end_line
begin_count="$(count_exact_line "$file" "$begin_marker")"
end_count="$(count_exact_line "$file" "$end_marker")"
if [[ "$begin_count" -eq 0 && "$end_count" -eq 0 ]]; then
return 1
fi
if [[ "$begin_count" -ne 1 || "$end_count" -ne 1 ]]; then
return 2
fi
begin_line="$(grep -Fnx "$begin_marker" "$file" | cut -d: -f1)"
end_line="$(grep -Fnx "$end_marker" "$file" | cut -d: -f1)"
if [[ -z "$begin_line" || -z "$end_line" ]]; then
return 2
fi
if (( begin_line >= end_line )); then
return 2
fi
return 0
}
@@ -1,195 +0,0 @@
#!/usr/bin/env python3
"""Read-only, fail-closed public-DNS preflight for the approved SRS DNS profile.
Checks the authoritative nameservers, bypassing local hosts/systemd-resolved.
This deliberately validates only the approved single-host DNS profile:
exactly one MX pointing to this host and one SPF record `v=spf1 mx -all`.
It is NOT a general-purpose SPF evaluator.
"""
import argparse
import ipaddress
import re
import shutil
import subprocess
import sys
# A public resolver is used only to discover the delegated authoritative NS and
# their numeric IPs. The actual TXT/MX/A/AAAA values are read from each NS.
BOOTSTRAP_RESOLVERS = ('1.1.1.1', '9.9.9.9')
DNS_TIMEOUT = 5
def dig(server, name, kind, authoritative=False):
command = ['dig', '@' + server, '+time=2', '+tries=1', '+noall', '+comments', '+answer']
if authoritative:
command.append('+norecurse')
command.extend([name, kind])
try:
proc = subprocess.run(command, capture_output=True, text=True, timeout=DNS_TIMEOUT, check=False)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ValueError(f'DNS request failed ({server}, {name}, {kind}): {exc}') from exc
if proc.returncode != 0:
raise ValueError(f'DNS request failed ({server}, {name}, {kind}): {proc.stderr.strip() or proc.returncode}')
header = re.search(r'HEADER.*?status:\s*(\w+)', proc.stdout)
if not header or header.group(1) != 'NOERROR':
raise ValueError(f'DNS returned {header.group(1) if header else "no status"}: {server} {name} {kind}')
flags = re.search(r'^;; flags:\s*(.*?);\s*QUERY:', proc.stdout, re.M)
if authoritative and (not flags or 'aa' not in flags.group(1).split()):
raise ValueError(f'Authoritative response missing AA flag: {server} {name} {kind}')
rows = []
for line in proc.stdout.splitlines():
parts = line.split(None, 4)
if len(parts) != 5 or parts[2].upper() != 'IN':
continue
owner, _ttl, _cls, rr_type, data = parts
if rr_type.upper() == kind.upper() and owner.rstrip('.').lower() == name.rstrip('.').lower():
rows.append(data.strip())
return rows
def public_lookup(name, kind):
failures = []
for server in BOOTSTRAP_RESOLVERS:
try:
return dig(server, name, kind)
except ValueError as exc:
failures.append(str(exc))
raise ValueError('Public NS bootstrap failed: ' + ' | '.join(failures))
def discover_nameservers(domain):
labels = domain.rstrip('.').split('.')
# Pick closest delegated zone. A zone without its own delegation has no NS.
for i in range(len(labels) - 1):
zone = '.'.join(labels[i:])
ns = public_lookup(zone, 'NS')
if ns:
names = sorted({n.rstrip('.').lower() for n in ns})
return zone, names
raise ValueError(f'Cannot discover authoritative nameservers for {domain}')
def addr_of_ns(ns):
for kind in ('A', 'AAAA'):
for value in public_lookup(ns, kind):
try:
return str(ipaddress.ip_address(value))
except ValueError:
continue
raise ValueError(f'Cannot resolve nameserver {ns} to a public IP address')
def parse_txt(value):
# DNS may split a single TXT resource record into multiple quoted strings.
parts = re.findall(r'"((?:\\.|[^"\\])*)"', value)
if not parts:
raise ValueError('Unexpected TXT presentation format: ' + value)
return ''.join(parts).replace('\\"', '"')
def check_server(ns_name, ns_ip, domain, hostname, bind4, bind6):
errors = []
def query(name, kind):
try:
return dig(ns_ip, name, kind, authoritative=True)
except ValueError as exc:
errors.append(str(exc))
return []
spf_records = []
for raw in query(domain, 'TXT'):
try:
text = parse_txt(raw)
if text.lower().startswith('v=spf1'):
spf_records.append(text)
except ValueError as exc:
errors.append(str(exc))
spf_ok = (
len(spf_records) == 1
and spf_records[0].lower().split() == ['v=spf1', 'mx', '-all']
)
if not spf_ok:
errors.append('SPF is not the approved single-host profile: v=spf1 mx -all')
mx = query(domain, 'MX')
if len(mx) != 1:
errors.append(f'Expected exactly one MX pointing to {hostname}; found {mx}')
else:
parts = mx[0].split()
if len(parts) != 2 or not parts[0].isdigit() or parts[1].rstrip('.').lower() != hostname:
errors.append(f'MX is not the expected mailserver {hostname}: {mx[0]}')
for kind, configured in [('A', bind4), ('AAAA', bind6)]:
answers = query(hostname, kind)
normalized = []
for entry in answers:
try:
normalized.append(str(ipaddress.ip_address(entry)))
except ValueError:
errors.append(f'Invalid {kind} address returned for {hostname}: {entry}')
if not configured:
errors.append(f'Postfix smtp_bind_address{("6" if kind == "AAAA" else "")} is empty; cannot verify outbound IP')
elif configured not in normalized:
errors.append(f'Postfix outbound {kind} address {configured} is not published for {hostname}: {normalized}')
print(f' [ {"ok" if not errors else "failed"} ] {ns_name} ({ns_ip})')
for err in errors:
print(f' [ error ] {err}')
return not errors, spf_ok
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--domain', required=True)
ap.add_argument('--hostname', required=True)
ap.add_argument('--ipv4', required=True)
ap.add_argument('--ipv6', required=True)
args = ap.parse_args()
domain = args.domain.lower().rstrip('.')
hostname = args.hostname.lower().rstrip('.')
if not shutil.which('dig'):
print(' [ error ] dig is required to verify public DNS')
return 1
try:
bind4 = str(ipaddress.ip_address(args.ipv4)) if args.ipv4 else ''
bind6 = str(ipaddress.ip_address(args.ipv6)) if args.ipv6 else ''
zone, nameservers = discover_nameservers(domain)
print(f' DNS delegation zone................: {zone}')
print(f' Expected MX host...................: {hostname}')
print(f' Expected SMTP IPv4.................: {bind4 or "unknown"}')
print(f' Expected SMTP IPv6.................: {bind6 or "unknown"}')
print(' Required SPF record...............: v=spf1 mx -all')
if len(nameservers) > 8:
raise ValueError('More than eight authoritative nameservers; manual review required')
print()
print(' Authoritative nameservers:')
ok = True
spf_ok_all = True
for ns in nameservers:
try:
ip = addr_of_ns(ns)
server_ok, server_spf_ok = check_server(
ns, ip, domain, hostname, bind4, bind6
)
if not server_ok:
ok = False
if not server_spf_ok:
spf_ok_all = False
except ValueError as exc:
print(f' [ failed ] {ns}')
print(f' [ error ] {exc}')
ok = False
spf_ok_all = False
print()
print(' SPF validation.....................: ' +
('OK' if spf_ok_all else 'FAILED / NOT VERIFIED'))
print(' Public SRS DNS preflight...........: ' + ('OK' if ok else 'FAILED'))
return 0 if ok else 1
except ValueError as exc:
print(f' [ error ] {exc}')
print(' Public SRS DNS preflight...........: FAILED')
return 1
if __name__ == '__main__':
sys.exit(main())
@@ -1,223 +0,0 @@
#!/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()
File diff suppressed because it is too large Load Diff
@@ -1,263 +0,0 @@
#!/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, zipfile, re, sys, shutil
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()
if SOURCE.is_file():
with zipfile.ZipFile(SOURCE) as z:
z.extractall(repo)
else:
for source_file in SOURCE.rglob('*'):
if source_file.is_file():
dst = repo / source_file.relative_to(SOURCE)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_file, 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
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
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/'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()
cmd = f"{repo/'install-update-srs.sh'} --install"
if version: cmd += f' --version {version}'
env = os.environ.copy()
env.update({'PATH':str(root/'bin') + ':' + env['PATH'], 'SRS_TEST_ROOT':str(root), 'LC_ALL':'C'})
# Running inside util-linux script supplies a real PTY for init_srs_runtime.
result = subprocess.run(['script','-q','-e','-c',cmd,'/dev/null'], input='srs.example.org\nyes\nyes\n',
text=True, capture_output=True, timeout=35, env=env)
return result
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, 0o2770) # Deliberately inherit SGID on fixture subdirectories.
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')
@@ -1,303 +0,0 @@
#!/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')
@@ -1,106 +0,0 @@
#!/usr/bin/env python3
"""Offline regression tests for the selective SRS DNS preflight helper.
All DNS responses are mocked. No live DNS queries, Postfix changes,
or SRS installation operations are performed.
"""
import contextlib
import io
import runpy
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
HELPER = ROOT / "helpers/srs-dns-check.py"
module = runpy.run_path(str(HELPER))
check_server = module["check_server"]
class TestSrsDnsSpf(unittest.TestCase):
DOMAIN = "srs.example.org"
HOSTNAME = "mail.example.org"
IPV4 = "192.0.2.25"
IPV6 = "2001:db8::25"
def check_with_spf(self, txt_records):
answers = {
(self.DOMAIN, "TXT"): txt_records,
(self.DOMAIN, "MX"): [
f"10 {self.HOSTNAME}."
],
(self.HOSTNAME, "A"): [
self.IPV4
],
(self.HOSTNAME, "AAAA"): [
self.IPV6
],
}
def fake_dig(server, name, kind, authoritative=False):
self.assertEqual(server, "192.0.2.53")
self.assertTrue(
authoritative,
"DNS checks must query authoritative nameservers"
)
return answers.get((name, kind), [])
output = io.StringIO()
with patch.dict(
check_server.__globals__,
{"dig": fake_dig}
):
with contextlib.redirect_stdout(output):
result = check_server(
"ns.example.org",
"192.0.2.53",
self.DOMAIN,
self.HOSTNAME,
self.IPV4,
self.IPV6,
)
return result, output.getvalue()
def test_valid_spf(self):
result, output = self.check_with_spf(
['"v=spf1 mx -all"']
)
self.assertEqual(result, (True, True))
self.assertIn("[ ok ]", output)
def test_missing_spf(self):
result, output = self.check_with_spf([])
self.assertEqual(result, (False, False))
self.assertIn("SPF is not the approved", output)
self.assertIn("[ failed ]", output)
def test_invalid_spf(self):
result, output = self.check_with_spf(
['"v=spf1 -all"']
)
self.assertEqual(result, (False, False))
self.assertIn("SPF is not the approved", output)
self.assertIn("[ failed ]", output)
def test_multiple_spf_records(self):
result, output = self.check_with_spf([
'"v=spf1 mx -all"',
'"v=spf1 ip4:192.0.2.1 -all"',
])
self.assertEqual(result, (False, False))
self.assertIn("SPF is not the approved", output)
self.assertIn("[ failed ]", output)
if __name__ == "__main__":
unittest.main(verbosity=2)