Archive discontinued selective SRS implementation

This commit is contained in:
2026-10-20 14:41:48 +02:00
parent 3af1c29a16
commit 39b933b798
25 changed files with 488 additions and 0 deletions
+465
View File
@@ -0,0 +1,465 @@
# README-SRS.md
## Selektives Sender Rewriting Scheme (SRS) auf `mx.warenform.de` – Überblick
**Stand:** 17.10.2026
Diese Datei beschreibt bewusst nur, **was** eingerichtet wurde, **warum** es nötig ist und **was im Betrieb zu beachten ist**.
Für die technische Detaildokumentation siehe:
```text
README-install-SRS.md
```
---
## 0. Das Grundproblem: Warum Weiterleitungen heute problematisch sein können
Mailserver wie Gmail, Microsoft, GMX oder Yahoo prüfen eingehende Nachrichten unter anderem mit SPF, DKIM und DMARC.
Bei einer klassischen Weiterleitung entsteht dabei ein grundsätzliches Problem:
```text
ckubu@gmx.de
|
v
test@warenform.de
|
v
ckubu@gmail.com
```
Die ursprüngliche Nachricht kommt korrekt von einem GMX-Mailserver und besteht dort normalerweise den SPF-Test. Bei der Weiterleitung sendet anschließend aber `mx.warenform.de` die Nachricht weiter, während der Envelope-Absender ohne SRS weiterhin `ckubu@gmx.de` lautet.
Der Zielserver sieht damit vereinfacht:
```text
Absender-Domain: gmx.de
sendender Server: mx.warenform.de
```
und prüft:
> Darf `mx.warenform.de` E-Mails für `gmx.de` versenden?
Das ist natürlich nicht der Fall. Die weitergeleitete Nachricht kann deshalb trotz legitimer Herkunft einen SPF-Fehler erhalten.
Wenn zusätzlich die ursprüngliche DKIM-Signatur durch Änderungen an Headern oder Nachrichteninhalt ungültig wird, kann schließlich auch DMARC fehlschlagen. Bei strengen DMARC-Policies können Provider eine solche Nachricht ablehnen oder als Spam behandeln.
SRS löst das SPF-Problem, indem nur der SMTP-Envelope-Absender der weitergeleiteten Nachricht umgeschrieben wird:
```text
ckubu@gmx.de
```
wird beispielsweise zu:
```text
SRS0=...=gmx.de=ckubu@srs.warenform.de
```
Der sichtbare Header bleibt dagegen:
```text
From: ckubu@gmx.de
```
Dadurch kann der Zielserver feststellen:
```text
SPF -> PASS für srs.warenform.de
DKIM -> PASS für die ursprüngliche Absenderdomain gmx.de
DMARC -> PASS über die weiterhin gültige DKIM-Signatur
```
Genau dieses Verhalten wurde bei der hier dokumentierten Installation im End-to-End-Test bestätigt.
Wichtig ist dabei: SRS soll nicht pauschal auf alle ausgehenden Nachrichten angewendet werden, sondern nur auf echte externe Alias-Weiterleitungen. Daraus ergibt sich die selektive Architektur dieser Installation.
---
## 1. Ausgangssituation
`mx.warenform.de` ist ein produktiver Mailserver mit Postfix, AmaViS, SpamAssassin, ClamAV, OpenDKIM/OpenDMARC und Dovecot.
Der Server verwaltet auch Mail-Aliase, die teilweise an externe Empfänger weiterleiten.
Beispiel:
```text
ckubu@gmx.de
|
v
test@warenform.de
|
+--> lokale Empfänger
|
+--> ckubu@oopen.de
```
Das Problem:
Bei einer klassischen Weiterleitung bleibt der ursprüngliche Envelope-Sender erhalten.
Dann würde `mx.warenform.de` eine Nachricht mit:
```text
MAIL FROM:<ckubu@gmx.de>
```
an einen fremden Server weiterleiten.
Der fremde Server prüft SPF und sieht dabei:
```text
Absenderdomain: gmx.de
sendender Server: mx.warenform.de
```
Da `mx.warenform.de` nicht zum SPF von GMX gehört, kann die legitime weitergeleitete Mail einen SPF-Fehler bekommen.
---
## 2. Lösung
Für echte externe Alias-Weiterleitungen wird der Envelope-Sender per SRS umgeschrieben.
Aus:
```text
ckubu@gmx.de
```
wird beispielsweise:
```text
SRS0=...=gmx.de=ckubu@srs.warenform.de
```
Dadurch prüft der Zielserver SPF gegen:
```text
srs.warenform.de
```
und damit gegen eine Domain, für die `mx.warenform.de` tatsächlich senden darf.
---
## 3. Warum nicht einfach alle Nachrichten mit SRS umschreiben?
Weil SRS nur für Weiterleitungen benötigt wird.
Nicht umgeschrieben werden sollen:
- normale ausgehende Mails,
- lokale Mailbox-Zustellungen,
- lokale Aliase,
- `root -> argus@oopen.de`,
- Dovecot/Sieve-Benachrichtigungen,
- Bounces mit leerem Envelope-Sender.
Deshalb wurde eine **selektive** SRS-Lösung gebaut.
Sie entscheidet für jeden Empfänger einzeln:
```text
SRS
oder
NO_SRS
```
---
## 4. Warum die Lösung relativ aufwendig ist
Mehrere technische Probleme mussten gleichzeitig gelöst werden.
### Alias-Information darf nicht verloren gehen
Nach der Viren-/Spamprüfung über AmaViS muss noch erkennbar sein:
```text
ursprünglicher Empfänger: test@warenform.de
endgültiges Ziel: ckubu@oopen.de
```
Sonst könnte die SRS-Logik nicht erkennen, dass überhaupt eine Weiterleitung stattgefunden hat.
Deshalb wurde die Reihenfolge der Postfix-Adressumschreibung bewusst angepasst.
### Ein Alias kann lokale und externe Ziele enthalten
Zum Beispiel:
```text
test@warenform.de
-> lokale Mailbox
-> lokale Mailbox
-> externe Adresse
```
Nur das externe Ziel braucht SRS.
Darum wird die Entscheidung pro Empfänger getroffen.
### Bounces müssen zurückkommen können
Ein fremder Mailserver sieht als Envelope-Sender eine SRS-Adresse.
Wenn er einen Bounce erzeugt, muss diese Adresse wieder auf den ursprünglichen Absender zurückgeführt werden.
Deshalb gibt es zusätzlich einen Reverse-SRS-Pfad.
### DKIM soll erhalten bleiben
SRS verändert nur den Envelope-Sender.
Der sichtbare:
```text
From:
```
Header und die ursprüngliche DKIM-Signatur bleiben erhalten.
Das ist wichtig, damit DKIM und DMARC weiterhin funktionieren.
---
## 5. Produktive Komponenten
PostSRSd:
```text
/usr/local/postsrsd -> /usr/local/postsrsd-2.4.1
```
Skripte:
```text
/usr/local/sbin/srs-forward.py
/usr/local/sbin/srs-alias-decision.py
/usr/local/sbin/srs-reverse-map.py
/usr/local/sbin/srs-reverse-policy.py
```
Dienste:
```text
postsrsd-srs.service
srs-reverse-map.service
postfix@srs-delivery.service
postfix.service
```
SRS-Delivery-Konfiguration:
```text
/etc/postfix-srs/delivery
```
PostSRSd-Konfiguration:
```text
/etc/postsrsd-srs
```
---
## 6. Ports
```text
25 normaler SMTP-Eingang
10003 PostSRSd
10004 Reverse-SRS-Map
10028 SRS-Reinjection
10029 Reverse-SRS-Reinjection
```
Die SRS-internen Ports sind nur lokal erreichbar.
---
## 7. Postfix-Hauptkonfiguration
In `/etc/postfix/main.cf` gibt es einen automatisch verwalteten Bereich:
```text
# BEGIN OOPEN-SRS-MANAGED
...
# END OOPEN-SRS-MANAGED
```
Dieser Block und insbesondere seine Marker sollen nicht manuell verändert werden.
Aktive SRS-Einstellungen:
```text
default_transport = srs-forward
srs-forward_destination_recipient_limit = 1
recipient_canonical_maps = socketmap:inet:127.0.0.1:10004:reverse
```
Die separate SRS-Delivery-Postfix-Instanz wird **nicht mit `postmulti`** verwaltet.
Sie läuft direkt als:
```text
postfix@srs-delivery.service
```
---
## 8. Verifizierter Normalfall
Test:
```text
ckubu@gmx.de
->
test@warenform.de
->
ckubu@oopen.de
```
Am Ziel kam tatsächlich an:
```text
Return-Path:
<SRS0=...=gmx.de=ckubu@srs.warenform.de>
```
Der Zielserver meldete:
```text
SPF_PASS
DKIM_VALID
DMARC_PASS
```
Die originale GMX-DKIM-Signatur blieb also gültig.
---
## 9. Verifizierter lokaler Sonderfall
Lokaler Alias:
```text
root -> argus@oopen.de
```
Ergebnis:
```text
NO_SRS
```
Der Envelope-Sender bleibt:
```text
root@mx.warenform.de
```
Das ist ausdrücklich so gewünscht.
---
## 10. Reboot-Test
Nach einem vollständigen Reboot waren automatisch aktiv:
```text
postsrsd-srs.service
srs-reverse-map.service
postfix@srs-delivery.service
postfix.service
```
und die erwarteten Listener:
```text
25
10003
10004
10028
10029
```
Forward- und Reverse-SRS funktionierten ebenfalls nach dem Reboot.
---
## 11. Was bei Änderungen besonders zu beachten ist
Folgende Punkte sind nicht zufällig, sondern Teil des Designs:
- SRS nur für echte externe Weiterleitungen.
- Entscheidung pro Empfänger.
- ursprünglicher Alias-Empfänger muss erhalten bleiben.
- Reverse-SRS muss erreichbar sein.
- keine globale Umschreibung aller Absender.
- kein `postmulti`.
- interne SRS-Ports nur auf Loopback.
- originale DKIM-Signatur möglichst unverändert lassen.
- Null-Sender niemals mit SRS umschreiben.
- `root` und andere lokale Aliase korrekt erkennen.
- `btree:`-Maps mit `postmap btree:/...` erzeugen.
---
## 12. Betriebskontrolle
Dienste:
```bash
systemctl is-active \
postsrsd-srs.service \
srs-reverse-map.service \
postfix@srs-delivery.service \
postfix.service
```
Ports:
```bash
ss -ltnp | grep -E ':(25|10003|10004|10028|10029)\b'
```
Postfix:
```bash
postfix check
```
Forward-Test:
```bash
postmap -q \
'ckubu@gmx.de' \
'socketmap:inet:127.0.0.1:10003:forward'
```
Journal nach einem Testmail:
```bash
journalctl --since "5 minutes ago" --no-pager | \
grep -E 'test@warenform\.de|ckubu@oopen\.de|SRS0=|srs-forward|postfix-srs-delivery'
```
---
## 13. Spätere Automatisierung
Als nächster Schritt sollen Installations-, Update-, Enable- und Disable-Skripte erstellt werden.
Wichtig dabei:
- bestehende Postfix-Konfiguration nicht pauschal überschreiben,
- vorherige Werte gezielt sichern,
- den `OOPEN-SRS-MANAGED`-Block als maschinenlesbare Schnittstelle verwenden,
- SRS reproduzierbar aktivieren und deaktivieren,
- keine kompletten alten `main.cf`-/`master.cf`-Backups zurückspielen,
- PostSRSd versionsweise unter `/usr/local/postsrsd-<version>` verwalten.
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
# Selective Sender Rewriting Scheme (SRS)
## Projektstatus
**Archiviert am 20. Oktober 2026.**
Die Entwicklung wurde eingestellt. Diese Implementierung ist nicht
fuer den produktiven Einsatz freigegeben.
Dieses Verzeichnis dient ausschliesslich der Archivierung und
Dokumentation des bisherigen Entwicklungsstands.
Die enthaltenen Installations- und Verwaltungsskripte duerfen
nicht unveraendert fuer Neuinstallationen verwendet werden.
## Urspruengliches Ziel
Ziel war eine selektive SRS-Implementierung fuer Postfix.
SRS sollte ausschliesslich bei externen Weiterleitungen zum
Einsatz kommen, die durch lokale virtuelle Mail-Aliase entstehen.
Normale eingehende und ausgehende E-Mails sollten dagegen
unveraendert verarbeitet werden.
Die Verwaltung virtueller Aliase erfolgt unter anderem ueber
PostfixAdmin und PostgreSQL.
## Technische Implementierung
Entwickelt wurde eine Loesung auf Basis von:
- Postfix
- PostSRSd 2.4.1
- einer zusaetzlichen Postfix-Instanz
- eigenen Python-Programmen zur Alias-Entscheidung,
Weiterleitung und Rueckuebersetzung
- Shell-Skripten fuer Installation, Aktivierung und
Deaktivierung
Die Implementierung enthielt unter anderem:
- selektive SRS-Entscheidungslogik
- SRS0-/SRS1-Umschreibung
- Verarbeitung von SRS-Ruecklaeufern
- persistente Speicherung des vorherigen Postfix-Zustands
- kontrollierte Aktivierung und Deaktivierung
- Sicherungs- und Rollback-Funktionen
- DNS- und SPF-Pruefungen
- Offline-Tests
## Entwicklungs- und Testergebnis
Die Implementierung wurde im Oktober 2026 entwickelt und
auf e.mx.oopen.de getestet.
Dabei wurden unter anderem folgende Funktionen erfolgreich
geprueft:
- Aktivierung und Deaktivierung
- externe Alias-Weiterleitungen mit SRS
- SPF-Pruefung weitergeleiteter Nachrichten
- Rueckuebersetzung von SRS-Ruecklaeufern
- verschiedene Fehler- und Rollback-Szenarien
Die vorhandenen Tests belegen keine vollstaendige
Produktionsreife.
Die Loesung wurde zeitweise auch auf mx.warenform.de
produktiv eingesetzt.
## Grund fuer die Einstellung
Bei der abschliessenden Architekturpruefung wurden
grundlegende betriebliche Risiken festgestellt.
Insbesondere:
1. Die Verwendung von
default_transport = srs-forward
fuehrte dazu, dass auch regulaere Postfix-Zustellungen
von der eigenen Python-SRS-Verarbeitung und der
zusaetzlichen Postfix-Instanz abhaengig waren.
2. Die globale Einbindung der SRS-Reverse-Socketmap
in die Empfaengeradressverarbeitung erzeugte eine
weitere Abhaengigkeit von einem eigenen Dienst.
3. Fehler in der SRS-Entscheidungslogik konnten auch
Nachrichten beeintraechtigen, fuer die ueberhaupt
keine SRS-Umschreibung erforderlich war.
4. Der Betriebs- und Wartungsaufwand der Gesamtkonstruktion
war fuer die urspruenglich begrenzte Aufgabenstellung
zu hoch.
Damit wurde die wichtigste betriebliche Anforderung
nicht erfuellt:
Der regulaere Mailbetrieb muss auch bei einem Ausfall
der optionalen SRS-Verarbeitung unabhaengig weiterlaufen.
Eine weitere Ausweitung der eigenen Fehlerbehandlung
wurde nicht als sinnvoll angesehen.
## Entscheidung
Am 20. Oktober 2026 wurde entschieden, diese
SRS-Implementierung nicht weiterzuentwickeln
und nicht fuer weitere produktive Installationen
zu verwenden.
Stattdessen wurde der bisherige Postfix-Betrieb
ohne SRS wiederhergestellt.
## Rueckbau
Die SRS-Integration wurde am 20. Oktober 2026 auf
folgenden Systemen deaktiviert und entfernt:
- e.mx.oopen.de
- mx.warenform.de
Entfernt wurden insbesondere:
- SRS-spezifische Postfix-Konfigurationseintraege
- die zusaetzliche Postfix-Instanz
- SRS-spezifische systemd-Units
- PostSRSd und die eigenen SRS-Python-Programme
- nicht mehr benoetigte SRS-Socket- und PID-Dateien
Die Konfiguration und das jeweilige SRS-Secret
wurden vor der Entfernung lokal gesichert.
Diese Sicherungen sind nicht Bestandteil des
Git-Repositories.
## Inhalt des Archivs
Die bisherigen Programme, Konfigurationsvorlagen,
Verwaltungsskripte, Tests und Dokumentationen
bleiben hier erhalten.
Die urspruenglichen README-Dateien beschreiben
den damaligen Entwicklungsstand und sind als
historische Dokumentation zu verstehen.
Da die Dateien in dieses Archivverzeichnis
verschoben wurden, stimmen einige relative Pfade
und Annahmen der Skripte nicht mehr mit der
urspruenglichen Repository-Struktur ueberein.
Die Skripte und Tests sind daher nicht ohne
weitere Anpassungen ausfuehrbar.
## Moegliche spaetere Neubewertung
Eine erneute Beschaeftigung mit SRS sollte
nur erfolgen, wenn eine deutlich einfachere
und besser isolierte Architektur verfuegbar ist.
Dabei muss insbesondere gewaehrleistet sein,
dass Fehler oder Ausfaelle der SRS-Verarbeitung
keine Auswirkungen auf den regulaeren
Postfix-Mailbetrieb haben.
Eine unveraenderte Wiederaufnahme der hier
archivierten Implementierung wird nicht empfohlen.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
# ---
# - 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
+258
View File
@@ -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 "
"<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())
+177
View File
@@ -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())
+112
View File
@@ -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()
+113
View File
@@ -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()
@@ -0,0 +1,108 @@
# See /usr/share/postfix/main.cf.dist for a commented, more complete version
# See http://www.postfix.org/COMPATIBILITY_README.html
compatibility_level = 3.9
# Which domain that locally-originated mail appears to come from.
# Debian policy suggests to read this value from /etc/mailname.
#XX needs a review in postinst&config
#myorigin = /etc/mailname
#myorigin = $mydomain
myorigin = @MYORIGIN@
# Text that follows the 220 code in the SMTP server's greeting banner.
# You MUST specify $myhostname at the start due to an RFC requirement.
smtpd_banner = $myhostname ESMTP $mail_name (Debian)
# IP protocols to use: ipv4, ipv6, or all
# (set this explicitly so `post-install upgrade-configuration' wont complain)
inet_protocols = all
# List of "trusted" SMTP clients (maptype:mapname allowed) that have more
# privileges than "strangers". If mynetworks is not specified (the default),
# mynetworks_style is used to compute its value.
#mynetworks_style = class
#mynetworks_style = subnet
mynetworks_style = host
#
mynetworks = 127.0.0.0/8
# Uncomment the next line to generate "delayed mail" warnings
#delay_warning_time = 4h
# List of domains (maptype:mapname allowed) that this machine considers
# itself the final destination for.
mydestination = @MYDESTINATION@
# Maximum size of a user mailbox
mailbox_size_limit = 51200000
# Optional external command to use instead of mailbox delivery. If set,
# you must set up an alias to forward root mail to a real user.
#mailbox_command = /usr/bin/procmail
#mailbox_command = /usr/bin/procmail -a "$EXTENSION"
mailbox_command =
# List of alias maps to use to lookup local addresses.
# Per Debian Policy it should be /etc/aliases.
alias_maps = $alias_database, nis:mail.aliases
# List of alias maps to make indexes on, when running newaliases.
alias_database = hash:/etc/aliases
# Notify (or not) local biff service when new mail arrives.
# Rarely used these days.
biff = no
# Separator between user name and address extension (user+foo@domain)
#recipient_delimiter = +
recipient_delimiter =
# A host to send "other" mail to
#relayhost = $mydomain
#relayhost = [gateway.example.com]
#relayhost = [ip.add.re.ss]:port
#relayhost = uucphost
relayhost =
# Where to look for Cyrus SASL configuration files. Upstream default is unset
# (use compiled-in SASL library default), Debian Policy says it should be
# /etc/postfix/sasl.
cyrus_sasl_config_path = /etc/postfix/sasl
authorized_submit_users = root
data_directory = /var/lib/postfix-srs-delivery
html_directory = /usr/share/doc/postfix/html
master_service_disable =
queue_directory = /var/spool/postfix-srs-delivery
readme_directory = /usr/share/doc/postfix
multi_instance_group = srs-delivery
multi_instance_name = postfix-srs-delivery
inet_interfaces = loopback-only
default_transport = smtp
local_transport = error:SRS-LOCAL-DELIVERY-BLOCKED
mailbox_transport = error:SRS-MAILBOX-DELIVERY-BLOCKED
relay_transport = error:SRS-RELAY-DELIVERY-BLOCKED
virtual_transport = srs-lmtp:unix:/var/spool/postfix/private/dovecot-lmtp
virtual_alias_maps = @VIRTUAL_ALIAS_MAPS@
virtual_alias_domains = @VIRTUAL_ALIAS_DOMAINS@
virtual_mailbox_maps = @VIRTUAL_MAILBOX_MAPS@
virtual_mailbox_domains = @VIRTUAL_MAILBOX_DOMAINS@
relay_domains = @RELAY_DOMAINS@
transport_maps = btree:/etc/postfix-srs/delivery/transport
mydomain = @MYDOMAIN@
myhostname = @MYHOSTNAME@
2bounce_notice_recipient = postmaster
bounce_notice_recipient = postmaster
delay_notice_recipient = postmaster
error_notice_recipient = postmaster
notify_classes =
smtp_bind_address = @SMTP_BIND_ADDRESS@
smtp_bind_address6 = @SMTP_BIND_ADDRESS6@
smtp_dns_support_level = @SMTP_DNS_SUPPORT_LEVEL@
smtp_host_lookup = @SMTP_HOST_LOOKUP@
smtp_tls_CAfile = @SMTP_TLS_CAFILE@
smtp_tls_mandatory_protocols = @SMTP_TLS_MANDATORY_PROTOCOLS@
smtp_tls_protocols = @SMTP_TLS_PROTOCOLS@
smtp_tls_security_level = @SMTP_TLS_SECURITY_LEVEL@
config_directory = /etc/postfix-srs/delivery
@@ -0,0 +1,199 @@
#
# Postfix master process configuration file. For details on the format
# of the file, see the master(5) manual page (command: "man 5 master" or
# on-line: https://www.postfix.org/master.5.html).
#
# Do not forget to execute "postfix reload" after editing this file.
#
# ==========================================================================
# service type private unpriv chroot wakeup maxproc command + args
# (yes) (yes) (no) (never) (100)
# ==========================================================================
#smtp inet n - y - - smtpd
#smtp inet n - y - 1 postscreen
#smtpd pass - - y - - smtpd
#dnsblog unix - - y - 0 dnsblog
#tlsproxy unix - - y - 0 tlsproxy
# Choose one: enable submission for loopback clients only, or for any client.
#127.0.0.1:submission inet n - y - - smtpd
#submission inet n - y - - smtpd
# -o syslog_name=postfix/submission
# -o smtpd_forbid_unauth_pipelining=no
# -o smtpd_tls_security_level=encrypt
# -o smtpd_sasl_auth_enable=yes
# -o smtpd_tls_auth_only=yes
# -o local_header_rewrite_clients=static:all
# -o smtpd_hide_client_session=yes
# -o smtpd_reject_unlisted_recipient=no
# Instead of specifying complex smtpd_<xxx>_restrictions here,
# specify "smtpd_<xxx>_restrictions=$mua_<xxx>_restrictions"
# here, and specify mua_<xxx>_restrictions in main.cf (where
# "<xxx>" is "client", "helo", "sender", "relay", or "recipient").
# -o smtpd_client_restrictions=
# -o smtpd_helo_restrictions=
# -o smtpd_sender_restrictions=
# -o smtpd_relay_restrictions=
# -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
# -o milter_macro_daemon_name=ORIGINATING
# Choose one: enable submissions for loopback clients only, or for any client.
#127.0.0.1:submissions inet n - y - - smtpd
#submissions inet n - y - - smtpd
# -o syslog_name=postfix/submissions
# -o smtpd_forbid_unauth_pipelining=no
# -o smtpd_tls_wrappermode=yes
# -o smtpd_sasl_auth_enable=yes
# -o local_header_rewrite_clients=static:all
# -o smtpd_hide_client_session=yes
# -o smtpd_reject_unlisted_recipient=no
# Instead of specifying complex smtpd_<xxx>_restrictions here,
# specify "smtpd_<xxx>_restrictions=$mua_<xxx>_restrictions"
# here, and specify mua_<xxx>_restrictions in main.cf (where
# "<xxx>" is "client", "helo", "sender", "relay", or "recipient").
# -o smtpd_client_restrictions=
# -o smtpd_helo_restrictions=
# -o smtpd_sender_restrictions=
# -o smtpd_relay_restrictions=
# -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
# -o milter_macro_daemon_name=ORIGINATING
#628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup
qmgr unix n - n 300 1 qmgr
#qmgr unix n - n 300 1 oqmgr
tlsmgr unix - - y 1000? 1 tlsmgr
rewrite unix - - y - - trivial-rewrite
bounce unix - - y - 0 bounce
defer unix - - y - 0 bounce
trace unix - - y - 0 bounce
verify unix - - y - 1 verify
flush unix n - y 1000? 0 flush
proxymap unix - - n - - proxymap
proxywrite unix - - n - 1 proxymap
#smtp unix - - y - - smtp
#relay unix - - y - - smtp
# -o syslog_name=${multi_instance_name?{$multi_instance_name}:{postfix}}/$service_name
# -o smtp_helo_timeout=5 -o smtp_connect_timeout=5
showq unix n - y - - showq
error unix - - y - - error
retry unix - - y - - error
discard unix - - y - - discard
#local unix - n n - - local
#virtual unix - n n - - virtual
#lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil
scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd
#
# ====================================================================
# Interfaces to non-Postfix software. Be sure to examine the manual
# pages of the non-Postfix software to find out what options it wants.
#
# Many of the following services use the Postfix pipe(8) delivery
# agent. See the pipe(8) man page for information about ${recipient}
# and other message envelope options.
# ====================================================================
#
# maildrop. See the Postfix MAILDROP_README file for details.
# Also specify in main.cf: maildrop_destination_recipient_limit=1
#
#maildrop unix - n n - - pipe
# flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
#
# ====================================================================
#
# Recent Cyrus versions can use the existing "lmtp" master.cf entry.
#
# Specify in cyrus.conf:
# lmtp cmd="lmtpd -a" listen="localhost:lmtp" proto=tcp4
#
# Specify in main.cf one or more of the following:
# mailbox_transport = lmtp:inet:localhost
# virtual_transport = lmtp:inet:localhost
#
# ====================================================================
#
# Cyrus 2.1.5 (Amos Gouaux)
# Also specify in main.cf: cyrus_destination_recipient_limit=1
#
#cyrus unix - n n - - pipe
# flags=DRX user=cyrus argv=/cyrus/bin/deliver -e -r ${sender} -m ${extension} ${user}
#
# ====================================================================
#
# Old example of delivery via Cyrus.
#
#old-cyrus unix - n n - - pipe
# flags=R user=cyrus argv=/cyrus/bin/deliver -e -m ${extension} ${user}
#
# ====================================================================
#
# See the Postfix UUCP_README file for configuration details.
#
#uucp unix - n n - - pipe
# flags=Fqhu user=uucp argv=uux -r -n -z -a$sender - $nexthop!rmail ($recipient)
#
# ====================================================================
#
# Other external delivery methods.
#
#ifmail unix - n n - - pipe
# flags=F user=ftn argv=/usr/lib/ifmail/ifmail -r $nexthop ($recipient)
#
#bsmtp unix - n n - - pipe
# flags=Fq. user=bsmtp argv=/usr/lib/bsmtp/bsmtp -f $sender $nexthop $recipient
#
#scalemail-backend unix - n n - 2 pipe
# flags=R user=scalemail argv=/usr/lib/scalemail/bin/scalemail-store
# ${nexthop} ${user} ${extension}
#
#mailman unix - n n - - pipe
# flags=FRX user=list argv=/usr/lib/mailman/bin/postfix-to-mailman.py
# ${nexthop} ${user}
# SRS reinjection - loopback only, no alias expansion
127.0.0.1:10028 inet n - y - - smtpd
-o syslog_name=postfix-srs-delivery/reinject
-o receive_override_options=no_address_mappings,no_unknown_recipient_checks
-o smtpd_recipient_restrictions=permit_mynetworks,reject
-o mynetworks=127.0.0.0/8
# SRS reverse cleanup - envelope recipients only, no virtual alias expansion
srs-reverse-cleanup unix n - y - 0 cleanup
-o syslog_name=postfix-srs-delivery/reverse-cleanup
-o recipient_canonical_maps=socketmap:inet:127.0.0.1:10003:reverse
-o recipient_canonical_classes=envelope_recipient
-o virtual_alias_maps=
-o canonical_maps=
-o sender_canonical_maps=
# SRS reverse listener - loopback only
# SRS reverse recipient verification policy
127.0.0.1:10029 inet n - y - - smtpd
-o syslog_name=postfix-srs-delivery/reverse-smtpd
-o cleanup_service_name=srs-reverse-cleanup
-o content_filter=
-o smtpd_milters=
-o mynetworks=127.0.0.0/8
-o smtpd_relay_restrictions=permit_mynetworks,reject
-o smtpd_recipient_restrictions=check_policy_service,unix:private/srs-reverse-policy,reject
-o smtpd_reject_unlisted_recipient=no
srs-reverse-policy unix - n n - 0 spawn
user=nobody argv=/usr/local/sbin/srs-reverse-policy.py
# =============================================================
# LMTP delivery to productive Dovecot
# =============================================================
srs-lmtp unix - - n - - lmtp
# =============================================================
# Outbound SMTP delivery
# =============================================================
smtp unix - - n - - smtp
# =============================================================
# Internal SMTP transport for SRS reverse processing
# =============================================================
srs-reverse-smtp unix - - n - - smtp
-o smtp_bind_address=127.0.0.1
-o smtp_bind_address6=::1
@@ -0,0 +1,13 @@
[Unit]
Description=PostSRSd SRS Service
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/postsrsd/sbin/postsrsd -C /etc/postsrsd-srs/postsrsd.conf
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,7 @@
domains = {}
srs-domain = "@SRS_DOMAIN@"
socketmap = inet:127.0.0.1:10003
secrets-file = "/etc/postsrsd-srs/secret"
milter-rewrite-local = off
original-envelope = embedded
debug = off
@@ -0,0 +1,19 @@
[Unit]
Description=Domain-restricted SRS reverse socketmap
After=network.target postsrsd-srs.service
Wants=postsrsd-srs.service
[Service]
Type=simple
User=nobody
Group=nogroup
ExecStart=/usr/bin/python3 /usr/local/sbin/srs-reverse-map.py
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,2 @@
# SRS-Bounces zurück an den produktiven Postfix
@SRS_DOMAIN@ srs-reverse-smtp:[127.0.0.1]:10029
+685
View File
@@ -0,0 +1,685 @@
#!/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
}
@@ -0,0 +1,195 @@
#!/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())
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Track narrowly-scoped SRS virtual alias additions in a Postfix main.cf.
This helper never restores an old main.cf; it only adds/removes its exact two
list items. An ownership marker is mandatory before removal. This is a
review draft: do not use on a live mail server until the complete integration
and rollback have been tested.
"""
import argparse
import os
import re
import stat
import sys
import tempfile
from pathlib import Path
MARKER_PREFIX = '# OOPEN-SRS-ALIAS-OWNERSHIP v1 '
PARAMS = ('virtual_alias_domains', 'virtual_alias_maps')
class ConfigError(Exception):
pass
def read_entries(lines, param):
"""Return [(first_line, last_line_exclusive, value_lines)] for a parameter.
Continuation lines must be indented, active, and not comments. A comment
ends the immediately preceding value, leaving its original text untouched.
"""
pattern = re.compile(r'^\s*' + re.escape(param) + r'\s*=', re.I)
found = []
for i, line in enumerate(lines):
if not pattern.match(line) or line.lstrip().startswith('#'):
continue
j = i + 1
while j < len(lines) and lines[j].startswith((' ', '\t')) and lines[j].strip() and not lines[j].lstrip().startswith('#'):
j += 1
first_value = line.split('=', 1)[1].strip()
values = [first_value] + [item.strip() for item in lines[i + 1:j]]
found.append((i, j, values))
if len(found) > 1:
raise ConfigError(f'Duplicate active parameter {param}')
return found
def current_tokens(lines, param):
entries = read_entries(lines, param)
if not entries:
return (False, [])
values = entries[0][2]
return (True, [part for part in re.split(r'[\s,]+', ' '.join(values)) if part])
def make_marker(domain, reverse_map, orig):
return (MARKER_PREFIX + f'domain={domain} map={reverse_map} '
f'domains_was_set={int(orig[0])} maps_was_set={int(orig[1])}\n')
def marker_state(lines, domain, reverse_map):
markers = [line for line in lines if line.startswith('# OOPEN-SRS-ALIAS-OWNERSHIP')]
if not markers:
return None
if len(markers) != 1:
raise ConfigError('Multiple SRS alias ownership markers')
marker = markers[0]
expected = re.compile(r'^' + re.escape(MARKER_PREFIX) +
r'domain=' + re.escape(domain) + r' map=' + re.escape(reverse_map) +
r' domains_was_set=([01]) maps_was_set=([01])\n$')
match = expected.fullmatch(marker)
if not match:
raise ConfigError('Foreign or malformed SRS alias ownership marker')
return tuple(item == '1' for item in match.groups())
def inspect(lines, domain, reverse_map):
is_set_domains, domains = current_tokens(lines, PARAMS[0])
is_set_maps, maps = current_tokens(lines, PARAMS[1])
matches_domain = sum(token.lower() == domain.lower() for token in domains)
matches_map = sum(token == reverse_map for token in maps)
if matches_domain > 1 or matches_map > 1:
raise ConfigError('Duplicate SRS domain or reverse map item')
ownership = marker_state(lines, domain, reverse_map)
if ownership is not None:
if matches_domain != 1 or matches_map != 1:
raise ConfigError('Ownership marker present but SRS tokens are incomplete')
return 'managed', ownership
if matches_domain and matches_map:
return 'legacy', None
if not matches_domain and not matches_map:
return 'absent', (is_set_domains, is_set_maps)
raise ConfigError('Partial/unmanaged SRS alias configuration')
def append_token(lines, param, token):
entries = read_entries(lines, param)
if not entries:
if lines and not lines[-1].endswith('\n'):
lines[-1] += '\n'
lines.append(f'{param} = {token}\n')
return
start, end, values = entries[0]
if token in [part for part in re.split(r'[\s,]+', ' '.join(values)) if part]:
raise ConfigError(f'SRS token already present in {param}')
if end > start + 1:
lines.insert(end, f' {token}\n')
else:
line = lines[start].rstrip('\r\n')
lines[start] = line.rstrip() + ' ' + token + '\n'
def remove_token(lines, param, token, was_set):
entries = read_entries(lines, param)
if len(entries) != 1:
raise ConfigError(f'Cannot safely remove token from {param}: missing parameter')
first, end, _ = entries[0]
indices = []
for idx in range(first, end):
value = lines[idx].split('=', 1)[1] if idx == first else lines[idx]
parts = [p for p in re.split(r'[\s,]+', value.strip()) if p]
if token in parts:
indices.append(idx)
if len(indices) != 1:
raise ConfigError(f'Expected exactly one SRS token in {param}')
idx = indices[0]
line = lines[idx]
if idx == first:
before, value = line.split('=', 1)
remaining = [p for p in re.split(r'[\s,]+', value.strip()) if p != token]
if not was_set and end == first + 1 and not remaining:
del lines[first]
else:
lines[idx] = before.rstrip() + ' = ' + ' '.join(remaining) + '\n'
else:
remaining = [p for p in re.split(r'[\s,]+', line.strip()) if p != token]
if remaining:
lines[idx] = ' ' + ' '.join(remaining) + '\n'
else:
del lines[idx]
if not was_set and not current_tokens(lines, param)[1]:
entries = read_entries(lines, param)
if entries:
start, end, _ = entries[0]
del lines[start:end]
def write_atomic(path, content):
st = os.stat(path)
if not stat.S_ISREG(st.st_mode):
raise ConfigError('main.cf is not a regular file')
parent = os.path.dirname(os.path.abspath(path))
fd, temp_path = tempfile.mkstemp(prefix='.main.cf.srs-alias.', dir=parent)
try:
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as f:
f.writelines(content)
f.flush()
os.fsync(f.fileno())
os.chmod(temp_path, stat.S_IMODE(st.st_mode))
os.chown(temp_path, st.st_uid, st.st_gid)
os.replace(temp_path, path)
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
def run(args):
path = Path(args.main_cf)
lines = path.read_text(encoding='utf-8').splitlines(keepends=True)
state, extra = inspect(lines, args.domain, args.reverse_map)
if args.mode == 'status':
print(state)
return
if args.mode == 'add':
if state != 'absent':
raise ConfigError('Add refused: expected absent state, got ' + state)
# Add in reverse order so insertion indexes remain consistent.
append_token(lines, PARAMS[1], args.reverse_map)
append_token(lines, PARAMS[0], args.domain)
lines.append(make_marker(args.domain, args.reverse_map, extra))
elif args.mode == 'adopt':
if state != 'legacy':
raise ConfigError('Adoption refused: expected legacy state, got ' + state)
if lines and not lines[-1].endswith('\n'):
lines[-1] += '\n'
lines.append(make_marker(args.domain, args.reverse_map, (True, True)))
elif args.mode == 'remove':
if state != 'managed':
raise ConfigError('Removal refused: expected managed state, got ' + state)
was_domains, was_maps = extra
remove_token(lines, PARAMS[1], args.reverse_map, was_maps)
remove_token(lines, PARAMS[0], args.domain, was_domains)
lines[:] = [line for line in lines if not line.startswith(MARKER_PREFIX)]
else:
raise ConfigError('Unknown mode')
expected = 'absent' if args.mode == 'remove' else 'managed'
check, _ = inspect(lines, args.domain, args.reverse_map)
if check != expected:
raise ConfigError(f'Post-modification state unexpected: {check}')
write_atomic(path, lines)
print(check)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=['status', 'add', 'adopt', 'remove'])
parser.add_argument('--main-cf', required=True)
parser.add_argument('--domain', required=True)
parser.add_argument('--reverse-map', required=True)
args = parser.parse_args()
if not re.fullmatch(r'[A-Za-z0-9.-]+', args.domain):
parser.error('Unsafe domain')
if not re.fullmatch(r'socketmap:inet:127\.0\.0\.1:[0-9]{1,5}:reverse', args.reverse_map):
parser.error('Unexpected reverse map')
try:
run(args)
except (ConfigError, OSError, UnicodeError) as exc:
print(f'SRS alias configuration error: {exc}', file=sys.stderr)
raise SystemExit(1)
if __name__ == '__main__':
main()
File diff suppressed because it is too large Load Diff
+263
View File
@@ -0,0 +1,263 @@
#!/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')
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""Offline, path-relocated installer contract tests. All external commands mocked.
Do not equate these tests with genuine Postfix/PostSRSd integration.
"""
from pathlib import Path
import os, subprocess, tempfile, re, sys, shutil, signal, types
SOURCE = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parents[1]
def putexe(file, content):
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text('#!/usr/bin/env bash\n' + content)
file.chmod(0o755)
def setup(root):
root.mkdir(parents=True, exist_ok=True)
repo = root / 'repo'
repo.mkdir()
# Copy only public SRS source files needed by the test. Never copy the
# complete mailserver repository, .git, credentials, local conf/srs.conf,
# or unrelated applications into an executable test fixture.
if not SOURCE.is_dir():
raise RuntimeError(f'SOURCE must be a repository directory: {SOURCE}')
required = [
'install-update-srs.sh',
'helpers/srs-common.sh',
'helpers/srs-dns-check.py',
'helpers/srs-virtual-alias.py',
'files/srs/srs-alias-decision.py',
'files/srs/srs-forward.py',
'files/srs/srs-reverse-map.py',
'files/srs/srs-reverse-policy.py',
]
templates = SOURCE/'files/srs/templates'
required += [str(f.relative_to(SOURCE)) for f in templates.iterdir() if f.is_file() and not f.is_symlink()]
for rel in required:
src = SOURCE/rel
if not src.is_file() or src.is_symlink():
raise RuntimeError(f'Missing or symlinked source file: {src}')
dst = repo/rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
for path in (repo/'install-update-srs.sh', repo/'helpers/srs-common.sh',
*list((repo/'files/srs').glob('*.py')),
*list((repo/'files/srs/templates').glob('*'))):
s = path.read_text()
# Keep all managed install/config/queue paths in an unprivileged fixture tree.
# The postconf absolute command must also be relocated.
s = s.replace('/usr/sbin/postconf', str(root/'bin/postconf'))
for prefix in ['/etc/', '/usr/local/', '/var/', '/run/lock/', '/root/srs-installer-backups']:
new = str(root) + prefix
s = s.replace(prefix, new)
# prevent reading the real original root Postfix config in our fixtures
if path == repo/'helpers/srs-common.sh':
# The test runner uses a pipe for scripted confirmations;
# allow that only inside the copied, isolated helper fixture.
needle = 'if [[ -t 1 ]]; then'
if s.count(needle) != 1:
raise RuntimeError('Unexpected terminal gate in helper')
s = s.replace(needle, 'if true; then', 1)
path.write_text(s)
(repo/'install-update-srs.sh').chmod(0o755)
for p in ['etc/postfix','usr/local/sbin','run/lock','var/spool','var/lib','etc/systemd/system','flags']:
(root/p).mkdir(parents=True, exist_ok=True)
(root/'etc/postfix/main.cf').write_text('myhostname = mail.example.org\n')
(root/'etc/postfix/master.cf').write_text('smtp inet n - y - - smtpd\nlocalhost:10025 inet n - y - - smtpd\n -o receive_override_options=no_unknown_recipient_checks\n')
(repo/'conf').mkdir(exist_ok=True)
# All expected lookup paths are extracted from the (relocated) reference source.
alias = (repo/'files/srs/srs-alias-decision.py').read_text()
for typ,name in set(re.findall(r'"(pgsql|btree):([^" ]+)"', alias)):
f = Path(name + ('.db' if typ == 'btree' else ''))
f.parent.mkdir(parents=True,exist_ok=True)
f.touch()
bin = root/'bin'
bin.mkdir()
putexe(bin/'postconf', '''
name="${@: -1}"
# e.mx.oopen.de has two pre-existing unrelated warnings in main.cf.
# They are emitted on every postconf call while the requested value is valid.
echo "postconf: warning: ${SRS_TEST_ROOT}/etc/postfix/main.cf: unused parameter: policyd-spf_time_limit=3600" >&2
# The warning path matches the source main.cf in the relocated fixture.
# Existing warnings must be distinguished from queried-param errors.
if [[ -f "${SRS_TEST_ROOT}/flags/unknown_parameter_warning" ]]; then
echo "postconf: warning: smtp_tls_cafile: unknown parameter" >&2
fi
case "$name" in
mail_owner) echo postfix;;
myhostname) echo mail.example.org;;
mydomain) echo example.org;;
myorigin) echo /etc/mailname;;
mydestination) echo 'mail.example.org localhost';;
relay_domains) echo '\u0024mydestination';;
virtual_alias_maps) echo 'pgsql:some-alias';;
virtual_alias_domains) echo 'btree:some-domain';;
virtual_mailbox_maps) echo 'pgsql:some-mailbox';;
virtual_mailbox_domains) echo 'pgsql:some-domain';;
smtp_bind_address) echo 192.0.2.22;;
smtp_bind_address6) echo 2001:db8::22;;
smtp_dns_support_level) echo dnssec;;
smtp_host_lookup) echo dns;;
smtp_tls_CAfile) echo /etc/ssl/certs/ca-certificates.crt;;
smtp_tls_mandatory_protocols|smtp_tls_protocols) echo '>=TLSv1.2';;
smtp_tls_security_level) echo dane;;
*) echo "unknown $name" >&2; exit 1;;
esac
''')
putexe(bin/'id', '''
if [[ "$1" == -u && "$2" == postfix ]]; then echo 123; exit 0; fi
if [[ "$1" == -g && "$2" == postfix ]]; then echo 123; exit 0; fi
exec /usr/bin/id "$@"
''')
putexe(bin/'systemctl', '''
if [[ "$1" == is-active ]]; then exit 3; fi
exit 0
''')
putexe(bin/'git', '''
if [[ "$1" == clone ]]; then
mkdir -p "${@: -1}"; exit 0
fi
exec /usr/bin/git "$@"
''')
putexe(bin/'cmake', '''
if [[ "$1" == --version ]]; then echo 'cmake version 3.31.6'; exit 0; fi
if [[ "$1" == -S ]]; then
for ((i=1; i<=$#; i++)); do
arg="${!i}"
if [[ "$arg" == -B ]]; then n=$((i+1)); build="${!n}"; fi
if [[ "$arg" == -DCMAKE_INSTALL_PREFIX=* ]]; then prefix="${arg#*=}"; fi
done
mkdir -p "$build"; printf '%s\\n' "$prefix" > "$build/prefix"; exit 0
fi
if [[ "$1" == --build ]]; then exit 0; fi
if [[ "$1" == --install ]]; then
prefix="$(cat "$2/prefix")"
mkdir -p "${DESTDIR}${prefix}/sbin"
printf '#!/bin/sh\\nexit 0\\n' > "${DESTDIR}${prefix}/sbin/postsrsd"
chmod +x "${DESTDIR}${prefix}/sbin/postsrsd"
exit 0
fi
exit 9
''')
putexe(bin/'ctest','exit 0\n')
putexe(bin/'dig','exit 0\n')
putexe(bin/'postmap','''
case "$1" in
btree:*) dest="${1#btree:}"; touch "${dest}.db";;
*) exit 1;;
esac
if [[ -f "${SRS_TEST_ROOT}/flags/fail_postmap" ]]; then exit 1; fi
exit 0
''')
putexe(bin/'postfix','''
if [[ "$*" == *check* ]]; then
mkdir -p "${SRS_TEST_ROOT}/var/spool/postfix-srs-delivery/private" "${SRS_TEST_ROOT}/var/lib/postfix-srs-delivery"
if [[ -f "${SRS_TEST_ROOT}/flags/fail_postfix" ]]; then echo 'forced postfix check error' >&2; exit 1; fi
fi
exit 0
''')
(repo/'helpers/srs-dns-check.py').write_text('print(" Simulated authoritative DNS: OK")\n')
return repo
def run(root, repo, *, fail=None, version=None):
if fail: (root/'flags'/('fail_'+fail)).touch()
argv = [str(repo/'install-update-srs.sh'), '--install']
if version: argv.extend(['--version', version])
env = os.environ.copy()
env.update({'PATH':str(root/'bin') + ':' + env['PATH'], 'SRS_TEST_ROOT':str(root), 'LC_ALL':'C'})
# No real PTY is needed: the terminal requirement is disabled only in the
# relocated helper copy, never in the actual repository.
# File-backed capture prevents subprocess pipe hangs from descendant
# processes retaining output fds. A timeout kills the entire fixture group.
with tempfile.TemporaryFile(mode='w+t') as out:
proc = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=out,
stderr=subprocess.STDOUT, text=True, env=env,
start_new_session=True)
try:
proc.communicate(input='srs.example.org\nyes\nyes\n', timeout=20)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait(timeout=5)
raise RuntimeError('Mock installer timed out')
out.seek(0)
return types.SimpleNamespace(returncode=proc.returncode, stdout=out.read())
def assert_ok(condition, msg):
if not condition: raise AssertionError(msg)
with tempfile.TemporaryDirectory(prefix='oopen-srs-test-') as tmp:
base=Path(tmp)
os.chmod(base, 0o2700) # Test SGID inheritance without granting group access.
root=base/'initial'
repo=setup(root)
result=run(root,repo)
print('initial install', result.returncode)
if result.returncode: print(result.stdout[-2500:])
assert_ok(result.returncode == 0,'initial installation failed')
assert_ok((root/'usr/local/postsrsd').is_symlink(), 'missing symlink')
assert_ok((root/'usr/local/sbin/srs-forward.py').exists(), 'missing srs forward')
assert_ok((root/'etc/postsrsd-srs/secret').stat().st_mode & 0o777 == 0o600, 'secret mode')
assert_ok('smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt' in (root/'etc/postfix-srs/delivery/main.cf').read_text(),'rendered TLS CA')
print(" secret dir mode:", oct((root/"etc/postsrsd-srs").stat().st_mode & 0o777), "uid", (root/"etc/postsrsd-srs").stat().st_uid)
print(" stat check:", subprocess.check_output(["stat", "-c", "%u:%g:%a", str(root/"etc/postsrsd-srs")], text=True).strip())
print(' initial install: state, secret, config, symlink, CAfile verified')
original_secret=(root/'etc/postsrsd-srs/secret').read_bytes()
original_forward=(root/'usr/local/sbin/srs-forward.py').read_bytes()
original_link=os.readlink(root/'usr/local/postsrsd')
result=run(root,repo)
print('maintenance update', result.returncode)
if result.returncode: print(result.stdout[-2500:])
assert_ok(result.returncode == 0,'maintenance update failed')
assert_ok((root/'etc/postsrsd-srs/secret').read_bytes()==original_secret,'secret changed')
assert_ok(os.readlink(root/'usr/local/postsrsd')==original_link,'link changed')
print(' maintenance update: original secret and binary retained')
# Force late failure, after Postfix configuration and all units have been deployed.
source=repo/'files/srs/srs-forward.py'
source.write_text(source.read_text()+'\n# injected test change\n')
result=run(root,repo,fail='postfix')
print('failed maintenance rollback', result.returncode)
if result.returncode==0: print(result.stdout[-2500:])
assert_ok(result.returncode != 0,'injected failure not detected')
assert_ok((root/'usr/local/sbin/srs-forward.py').read_bytes()==original_forward,'rollback did not restore file')
assert_ok((root/'etc/postsrsd-srs/secret').read_bytes()==original_secret,'rollback changed secret')
assert_ok(os.readlink(root/'usr/local/postsrsd')==original_link,'rollback changed symlink')
print(' failure rollback: file restored; secret and symlink preserved')
(root/'flags/fail_postfix').unlink()
result=run(root,repo,version='2.4.2')
print('version upgrade', result.returncode)
if result.returncode: print(result.stdout[-2500:])
assert_ok(result.returncode == 0,'version upgrade failed')
assert_ok((root/'usr/local/postsrsd-2.4.1').exists(),'prior prefix missing')
assert_ok(os.readlink(root/'usr/local/postsrsd')=='postsrsd-2.4.2','new link incorrect')
print(' version upgrade: previous prefix retained; link updated')
(root/'flags/fail_postfix').touch()
result=run(root,repo,version='2.4.3')
print('failed version upgrade rollback', result.returncode)
assert_ok(result.returncode != 0,'injected failure not detected')
assert_ok(os.readlink(root/'usr/local/postsrsd')=='postsrsd-2.4.2','previous version link not restored')
assert_ok(not (root/'usr/local/postsrsd-2.4.3').exists(),'failed version prefix not removed')
print(' failed version upgrade: symlink restored, new prefix removed')
# Active SRS configuration must prohibit any maintenance update before writes.
(root/'flags/fail_postfix').unlink()
active_marker = '# BEGIN OOPEN-SRS-MANAGED'
existing_main=(root/'etc/postfix/main.cf')
original_main=existing_main.read_text()
existing_main.write_text(original_main + active_marker + '\n')
before=(root/'usr/local/sbin/srs-forward.py').read_bytes()
result=run(root,repo,version='2.4.2')
print('active integration guard', result.returncode)
assert_ok(result.returncode != 0,'active SRS was not refused')
assert_ok((root/'usr/local/sbin/srs-forward.py').read_bytes() == before, 'active check changed scripts')
existing_main.write_text(original_main)
print(' active integration guard: blocked before file changes')
# Independent initial installation with late failure. New SRS files must be removed.
root=base/'failed-first'
repo=setup(root)
result=run(root,repo,fail='postfix')
print('failed first install', result.returncode)
assert_ok(result.returncode != 0,'injected first-install failure not detected')
assert_ok(not (root/'usr/local/postsrsd').exists(), 'first-install rollback left symlink')
assert_ok(not (root/'usr/local/postsrsd-2.4.1').exists(), 'first-install rollback left prefix')
assert_ok(not (root/'etc/postsrsd-srs/secret').exists(), 'first-install rollback left secret')
assert_ok(not (root/'etc/postfix-srs/delivery/main.cf').exists(), 'first-install rollback left config')
assert_ok((root/'var/spool/postfix-srs-delivery/private').is_dir(), 'test Postfix runtime queue unexpectedly deleted')
print(' failed first install: tracked files removed; Postfix runtime directory safely preserved')
# Fail at postmap, before systemd and Postfix checks.
root=base/'failed-map'
repo=setup(root)
result=run(root,repo,fail='postmap')
print('failed postmap rollback', result.returncode)
assert_ok(result.returncode != 0, 'injected postmap failure not detected')
assert_ok(not (root/'etc/postfix-srs/delivery/transport.db').exists(),'failed postmap index remains')
assert_ok(not (root/'etc/postsrsd-srs/secret').exists(),'failed postmap secret remains')
assert_ok(not (root/'usr/local/postsrsd').exists(),'failed postmap link remains')
print(' failed postmap: generated index and secret removed')
# Unexpected warnings must never be silently swallowed.
root=base/'unexpected-warning'
repo=setup(root)
(root/'flags/unknown_parameter_warning').touch()
result=run(root, repo)
print('unexpected postconf warning', result.returncode)
assert_ok(result.returncode != 0, 'unexpected postconf warning accepted')
assert_ok(not (root/'usr/local/postsrsd').exists(), 'unexpected warning allowed installation')
print(' unrelated existing warnings tolerated, queried-parameter warning rejected')
# A template referencing an unknown Postfix parameter must fail closed.
root=base/'bad-template-parameter'
repo=setup(root)
template=root/'repo/files/srs/templates/delivery-main.cf.in'
contents=template.read_text()
assert_ok('smtp_tls_CAfile = @SMTP_TLS_CAFILE@' in contents, 'missing expected template parameter')
template.write_text(contents.replace('smtp_tls_CAfile = @SMTP_TLS_CAFILE@',
'smtp_tls_cafile = @SMTP_TLS_CAFILE@'))
result=run(root, repo)
print('invalid template parameter', result.returncode)
assert_ok(result.returncode != 0, 'unknown template Postfix parameter accepted')
assert_ok(not (root/'usr/local/postsrsd').exists(), 'invalid template allowed installation')
print(' unknown requested Postfix parameter rejected')
print('ALL OFFLINE MOCK CONTRACT TESTS PASSED')
@@ -0,0 +1,106 @@
#!/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)