From be4b1c1af840609ad8d06570f2866d1ae51bed49 Mon Sep 17 00:00:00 2001 From: Pascal Bouquet Date: Tue, 26 May 2026 12:41:31 +0200 Subject: [PATCH] Inital Commit --- .gitignore | 10 +++ README.md | 18 ++++- etc/pyarc/milter.conf.template | 20 +++++ install.sh | 65 ++++++++++++++++ pyarc-milter.service | 12 +++ usr/local/bin/pyarc-gen | 87 +++++++++++++++++++++ usr/local/bin/pyarc-milter | 137 +++++++++++++++++++++++++++++++++ 7 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 etc/pyarc/milter.conf.template create mode 100755 install.sh create mode 100644 pyarc-milter.service create mode 100644 usr/local/bin/pyarc-gen create mode 100644 usr/local/bin/pyarc-milter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35b562c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Lokale Krypto-Schlüssel und Configs ignorieren +etc/pyarc/certs/ +etc/pyarc/milter.conf + +# Python Cache +__pycache__/ +*.pyc + +# Logs +var/log/ diff --git a/README.md b/README.md index 70f7f9e..9df59a9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ -# pyarc +# pyarc-milter + +Ein schlanker, multidomain-fähiger **ARC (Authenticated Received Chain) Milter** für Postfix, geschrieben in Python. Er validiert eingehende ARC-Signaturen und signiert ausgehende E-Mails dynamisch basierend auf der Absender-Domain. + +## Features +- 🛡 **Eingehende ARC-Validierung:** Prüft ARC-Ketten und setzt `Authentication-Results` sowie `X-ARC: TRUE/FALSE`. +- 🔑 **Ausgehende ARC-Signierung:** Signiert Mails dynamisch basierend auf der From-Domain. +- 🌐 **Multidomain-fähig:** Unterschiedliche Schlüssel und Selektoren pro Domain über eine INI-Config steuerbar. +- 📝 **Eigenes Datei-Logging:** Schreibt übersichtliche Logs separat nach `/var/log/pyarc/pyarc.log`. +- ⚡ **Standalone Key-Generator:** Bringt das CLI-Tool `pyarc-gen` mit, um Keys zu erzeugen und fertige DNS-Records auszugeben. + +## Installation + +```bash +git clone [https://dein-gitea.tld/username/pyarc-milter.git](https://dein-gitea.tld/username/pyarc-milter.git) +cd pyarc +sudo ./install.sh diff --git a/etc/pyarc/milter.conf.template b/etc/pyarc/milter.conf.template new file mode 100644 index 0000000..58fa34d --- /dev/null +++ b/etc/pyarc/milter.conf.template @@ -0,0 +1,20 @@ +[general] +auth_serv_id = mx01.domain.tld +listen_socket = inet:8899@127.0.0.1 + +[logging] +file_logging = True +log_file = /var/log/pyarc/pyarc.log + +[validation] +reject_on_fail = False + +# --- Beispiel für eine Domain --- +#[domain.tld] +#selector = arc2026 +#private_key_path = /etc/pyarc/certs/domain.tld.private.key + +# +#[domain2.tld] +#selector = arc2026 +#private_key_path = /etc/pyarc/certs/domain2.tld.private.key diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..0a050f2 --- /dev/null +++ b/install.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -e + +# Sicherstellen, dass das Skript als root ausgeführt wird +if [ "$EUID" -ne 0 ]; then + echo "Bitte als root ausführen (sudo ./install.sh)" + exit 1 +fi + +echo "=== Installiere pyarc-milter ===" + +# 1. System-Abhängigkeiten installieren +echo "--> Installiere System-Abhängigkeiten..." +if [ -f /etc/debian_version ]; then + apt-get update && apt-get install -y libmilter-dev python3-dev build-essential python3-pip +elif [ -f /etc/redhat-release ]; then + dnf install -y sendmail-devel python3-devel gcc python3-pip +fi + +# 2. Python-Pakete installieren +echo "--> Installiere Python-Pakete..." +pip3 install pymilter dkimpy authres cryptography --break-system-packages || pip3 install pymilter dkimpy authres cryptography + +# 3. Ordnerstrukturen anlegen +echo "--> Erstelle Verzeichnisse und setze Rechte..." +mkdir -p /etc/pyarc/certs +mkdir -p /var/log/pyarc + +# 4. Dateien kopieren +echo "--> Kopiere Skripte und Konfiguration..." +cp usr/local/bin/my_arc_milter.py /usr/local/bin/ +cp usr/local/bin/pyarc-gen /usr/local/bin/ +chmod '+x' /usr/local/bin/my_arc_milter.py +chmod '+x' /usr/local/bin/pyarc-gen + +# Nur kopieren, wenn noch keine Config existiert (Überschreibschutz) +if [ ! -f /etc/pyarc/milter.conf ]; then + cp etc/pyarc/milter.conf.template /etc/pyarc/milter.conf + echo "✔ Standard-Konfiguration unter /etc/pyarc/milter.conf angelegt." +else + echo "ℹ /etc/pyarc/milter.conf existiert bereits. Übersprungen." +fi + +# Rechte für Postfix anpassen +chown -R postfix:postfix /etc/pyarc +chown -R postfix:postfix /var/log/pyarc +chmod 750 /etc/pyarc +chmod 755 /var/log/pyarc + +# 5. Systemd Service einrichten +echo "--> Erstelle Systemd-Service..." +cp pyarc-milter.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable pyarc-milter + +echo "==================================================" +echo "✔ Installation abgeschlossen!" +echo "==================================================" +echo "Nächste Schritte:" +echo "1. Passe /etc/pyarc/milter.conf an (auth_serv_id & Domains)." +echo "2. Generiere Keys mit: sudo pyarc-gen deine-domain.de" +echo "3. Starte den Service: sudo systemctl start pyarc-milter" +echo "4. Binde den Milter in die Postfix main.cf ein:" +echo " smtpd_milters = inet:127.0.0.1:8899" +echo "==================================================" diff --git a/pyarc-milter.service b/pyarc-milter.service new file mode 100644 index 0000000..e83d988 --- /dev/null +++ b/pyarc-milter.service @@ -0,0 +1,12 @@ +[Unit] +Description=Custom Postfix ARC Milter +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/my_arc_milter.py +Restart=on-failure +User=postfix + +[Install] +WantedBy=multi-user.target diff --git a/usr/local/bin/pyarc-gen b/usr/local/bin/pyarc-gen new file mode 100644 index 0000000..c509664 --- /dev/null +++ b/usr/local/bin/pyarc-gen @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +import sys +import os +import configparser +import argparse +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization + +CONFIG_PATH = "/etc/pyarc/milter.conf" + +def main(): + parser = argparse.ArgumentParser(description="Schlüsselgenerator für den Custom ARC Milter") + parser.add_argument("domain", help="Die Domain, für die ein ARC-Schlüsselpaar generiert werden soll") + args = parser.parse_args() + + domain = args.domain.lower().strip() + config = configparser.ConfigParser() + + if not os.path.exists(CONFIG_PATH): + print(f"Fehler: Konfigurationsdatei {CONFIG_PATH} nicht gefunden.", file=sys.stderr) + sys.exit(1) + + config.read(CONFIG_PATH) + + if not config.has_section(domain): + print(f"Fehler: Domain '{domain}' ist nicht in der {CONFIG_PATH} eingetragen.", file=sys.stderr) + print("Bitte lege zuerst die Sektion für die Domain in der Config an. Beispiel:", file=sys.stderr) + print(f"\n[{domain}]\nselector = arc2026\nprivate_key_path = /etc/pyarc/certs/{domain}.private.key\n", file=sys.stderr) + sys.exit(1) + + selector = config.get(domain, "selector", fallback="arc2026") + key_path = config.get(domain, "private_key_path") + + if os.path.exists(key_path): + print(f"Abbruch: Key-Datei existiert bereits unter: {key_path}", file=sys.stderr) + sys.exit(1) + + print(f"Generiere 2048-Bit RSA Schlüssel für {domain}...") + + try: + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048 + ) + + log_dir = os.path.dirname(key_path) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ) + + with open(key_path, "wb") as f: + f.write(pem) + + os.chmod(key_path, 0o640) + + if os.path.exists("/etc/pyarc"): + stat_info = os.stat("/etc/pyarc") + os.chown(key_path, stat_info.st_uid, stat_info.st_gid) + + print(f"✔ Private Key erfolgreich gespeichert unter: {key_path}") + + public_key = private_key.public_key() + pub_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + + pub_lines = pub_pem.decode('utf-8').splitlines() + pub_key_clean = "".join(pub_lines[1:-1]) + + print("\n" + "="*60) + print(f"DEIN DNS-TXT-RECORD FÜR {domain}:") + print("="*60) + print(f"{selector}._domainkey.{domain}. IN TXT \"v=DKIM1; k=rsa; p={pub_key_clean}\"") + print("="*60 + "\n") + + except Exception as e: + print(f"Ein unerwarteter Fehler ist aufgetreten: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/usr/local/bin/pyarc-milter b/usr/local/bin/pyarc-milter new file mode 100644 index 0000000..fb35c4e --- /dev/null +++ b/usr/local/bin/pyarc-milter @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +import Milter +import dkim +import authres +import sys +import configparser +import os +import re +import logging +from io import BytesIO + +CONFIG_PATH = "/etc/pyarc/milter.conf" +config = configparser.ConfigParser() + +if not os.path.exists(CONFIG_PATH): + print(f"Fehler: Konfigurationsdatei nicht gefunden unter {CONFIG_PATH}", file=sys.stderr) + sys.exit(1) + +config.read(CONFIG_PATH) + +# --- Logging Setup --- +LOG_ENABLED = config.getboolean("logging", "file_logging", fallback=False) +LOG_FILE = config.get("logging", "log_file", fallback="/var/log/pyarc/pyarc.log") + +logger = logging.getLogger("pyarc_milter") +logger.setLevel(logging.INFO) + +stdout_handler = logging.StreamHandler(sys.stdout) +stdout_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') +stdout_handler.setFormatter(stdout_formatter) +logger.addHandler(stdout_handler) + +if LOG_ENABLED: + log_dir = os.path.dirname(LOG_FILE) + if not os.path.exists(log_dir): + try: + os.makedirs(log_dir, exist_ok=True) + except Exception as e: + logger.error(f"Konnte Log-Verzeichnis {log_dir} nicht erstellen: {e}") + + if os.path.exists(log_dir): + try: + file_handler = logging.FileHandler(LOG_FILE) + file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - [%(process)d] - %(message)s') + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + except Exception as e: + logger.error(f"Konnte Log-Datei {LOG_FILE} nicht öffnen: {e}") + +# --- Allgemeine Config laden --- +AUTH_SERV_ID = config.get("general", "auth_serv_id", fallback="mx01.domain.tld") +LISTEN_SOCKET = config.get("general", "listen_socket", fallback="inet:8899@127.0.0.1") +REJECT_ON_FAIL = config.getboolean("validation", "reject_on_fail", fallback=False) + +DOMAIN_REGEX = re.compile(r'@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})') + + +class ArcMilter(Milter.Base): + def __init__(self): + self.id = Milter.uniqueID() + self.headers = [] + self.body_buffer = BytesIO() + self.from_domain = None + + def header(self, name, hval): + self.headers.append((name.encode('utf-8'), hval.encode('utf-8'))) + if name.lower() == 'from': + match = DOMAIN_REGEX.search(hval) + if match: + self.from_domain = match.group(1).lower().strip() + return Milter.CONTINUE + + def body(self, chunk): + self.body_buffer.write(chunk) + return Milter.CONTINUE + + def eom(self): + self.body_buffer.seek(0) + full_mail = b"".join([b"%s: %s\r\n" % (k, v) for k, v in self.headers]) + b"\r\n" + self.body_buffer.read() + + try: + # --- 1. Eingehend: ARC Validierung --- + arc_verifier = dkim.ARC(full_mail) + cv, results, comment = arc_verifier.verify() + cv_str = cv.decode('utf-8') if isinstance(cv, bytes) else str(cv) + + logger.info(f"[{self.id}] Validierung für Domain '{self.from_domain}': CV={cv_str} ({comment})") + self.addheader('Authentication-Results', f"{AUTH_SERV_ID}; arc={cv_str}") + + is_arc_valid = (cv_str == "pass") + + if cv_str == "fail" and REJECT_ON_FAIL: + logger.warning(f"[{self.id}] Mail abgewiesen: ARC Validation fehlgeschlagen.") + self.setreply('550', '5.7.1', 'ARC validation failed') + return Milter.REJECT + + # --- 2. Ausgehend: Dynamische ARC Signierung --- + if self.from_domain and config.has_section(self.from_domain): + selector = config.get(self.from_domain, "selector") + key_path = config.get(self.from_domain, "private_key_path") + + if os.path.exists(key_path): + with open(key_path, "rb") as f: + private_key = f.read() + + sig_headers = dkim.arc_sign( + full_mail, + selector=selector.encode('utf-8'), + domain=self.from_domain.encode('utf-8'), + privkey=private_key, + srv_id=AUTH_SERV_ID.encode('utf-8') + ) + + for header_line in sig_headers: + if b':' in header_line: + name, val = header_line.split(b':', 1) + self.addheader(name.decode('utf-8').strip(), val.decode('utf-8').strip()) + + logger.info(f"[{self.id}] Mail für {self.from_domain} erfolgreich signiert (Selector: {selector}).") + is_arc_valid = True + else: + logger.error(f"[{self.id}] Key für {self.from_domain} nicht gefunden unter {key_path}") + + self.addheader('X-ARC', 'TRUE' if is_arc_valid else 'FALSE') + + except Exception as e: + logger.error(f"[{self.id}] Fehler beim ARC-Processing: {e}", exc_info=True) + + return Milter.CONTINUE + +def main(): + Milter.factory = ArcMilter + logger.info(f"Multidomain ARC Milter startet auf {LISTEN_SOCKET}...") + Milter.runmilter("ArcMilter", LISTEN_SOCKET, timeout=30) + +if __name__ == "__main__": + main()