diff --git a/files/srs/srs-alias-decision.py b/files/srs/srs-alias-decision.py new file mode 100755 index 0000000..190d624 --- /dev/null +++ b/files/srs/srs-alias-decision.py @@ -0,0 +1,258 @@ +#!/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 " + " ", + 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()) diff --git a/files/srs/srs-forward.py b/files/srs/srs-forward.py new file mode 100755 index 0000000..278dcaf --- /dev/null +++ b/files/srs/srs-forward.py @@ -0,0 +1,177 @@ +#!/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()) diff --git a/files/srs/srs-reverse-map.py b/files/srs/srs-reverse-map.py new file mode 100755 index 0000000..4203f4c --- /dev/null +++ b/files/srs/srs-reverse-map.py @@ -0,0 +1,112 @@ +#!/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() diff --git a/files/srs/srs-reverse-policy.py b/files/srs/srs-reverse-policy.py new file mode 100755 index 0000000..30066c3 --- /dev/null +++ b/files/srs/srs-reverse-policy.py @@ -0,0 +1,113 @@ +#!/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()