Add selective Postfix SRS installation and management
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user