Add selective Postfix SRS installation and management
This commit is contained in:
+147
@@ -0,0 +1,147 @@
|
||||
# Selektives Sender Rewriting Scheme (SRS)
|
||||
|
||||
**Stand:** 23. Oktober 2026
|
||||
**Projekt:** `mailsystem` – Debian 13, Postfix, PostfixAdmin/PostgreSQL
|
||||
**Status:** Integration auf `e.mx.oopen.de` erfolgreich getestet; Resttests ausstehend
|
||||
|
||||
## Ziel und Geltungsbereich
|
||||
|
||||
Das System soll den SMTP-Envelope-Absender **nur bei tatsaechlichen externen
|
||||
Weiterleitungen ueber virtuelle Mail-Aliase** mittels SRS umschreiben.
|
||||
Andere Nachrichten sollen ihren Envelope-Absender behalten. Virtuelle
|
||||
Aliase werden unter anderem ueber PostfixAdmin und PostgreSQL verwaltet.
|
||||
|
||||
Bei einer Weiterleitung kann die urspruengliche Absenderdomain den SPF-Test
|
||||
am Zielserver nicht bestehen: Der weiterleitende Mailserver ist normalerweise
|
||||
nicht fuer diese Domain autorisiert. SRS ersetzt deshalb den SMTP-Envelope-
|
||||
Absender durch eine signierte Adresse der eigenen SRS-Domain. Der sichtbare
|
||||
`From:`-Header wird dadurch nicht veraendert; DKIM und DMARC muessen
|
||||
unabhaengig betrachtet werden.
|
||||
|
||||
## Technischer Aufbau
|
||||
|
||||
- **Regulaerer Postfix:** Annahme, AmaViS-Pruefung, virtuelle Aliasaufloesung.
|
||||
- **`srs-forward` (Pipe-Dienst):** Uebergabe an `srs-forward.py` und
|
||||
selektive Entscheidung ueber externe Alias-Weiterleitungen.
|
||||
- **PostSRSd 2.4.1:** SRS-Umschreibung und Rueckuebersetzung ueber Socketmap.
|
||||
- **Zweite Postfix-Instanz:** SMTP-Reinjektion und anschliessende Zustellung.
|
||||
- **Reverse-Komponenten:** Annahme und Verarbeitung eingehender SRS-Ruecklaeufer
|
||||
waehrend des aktiven SRS-Betriebs.
|
||||
|
||||
Waehrend SRS **aktiviert** ist, wird `default_transport = srs-forward`
|
||||
verwendet. Deshalb durchlaufen auch Nachrichten ohne SRS-Bedarf die eigene
|
||||
Entscheidungs- und Reinjektionsstrecke. **Nur die eigentliche SRS-Umschreibung
|
||||
ist selektiv.** Dieses betriebliche Risiko ist weiterhin Teil der Architektur;
|
||||
der getestete Rueckbau beseitigt diese Abhaengigkeit im deaktivierten Zustand.
|
||||
|
||||
### Lokale Endpunkte
|
||||
|
||||
| Endpunkt | Funktion |
|
||||
| --- | --- |
|
||||
| `127.0.0.1:10003` | PostSRSd-Socketmap |
|
||||
| `127.0.0.1:10004` | Reverse-Socketmap |
|
||||
| `127.0.0.1:10028` | Reinjektion in die SRS-Delivery-Instanz |
|
||||
| `127.0.0.1:10029` | Reverse-SMTP-Eingang fuer Ruecklaeufer |
|
||||
|
||||
### Wichtige Dateien und Dienste
|
||||
|
||||
- `conf/srs.conf` – lokale SRS-Domain; **nicht** unveraendert als allgemeine
|
||||
Vorlage auf andere Hosts uebertragen.
|
||||
- `conf/srs.conf.sample` – Konfigurationsbeispiel.
|
||||
- `/etc/postfix-srs/state.conf` – bei der Installation gespeicherter
|
||||
urspruenglicher Postfix-Zustand; nach Deaktivierung beibehalten.
|
||||
- `/etc/postsrsd-srs/secret` – geheimer SRS-Schluessel; vor fremdem Zugriff
|
||||
schuetzen und beim Update erhalten.
|
||||
- `/etc/postfix-srs/delivery/` – Konfiguration der zweiten Postfix-Instanz.
|
||||
- `/var/spool/postfix-srs-delivery/` – Queue der zweiten Instanz.
|
||||
- `/usr/local/postsrsd-<version>` und `/usr/local/postsrsd` – versionierte
|
||||
Installation und Symlink.
|
||||
- Dienste: `postsrsd-srs.service`, `srs-reverse-map.service`,
|
||||
`postfix@srs-delivery.service`.
|
||||
|
||||
## Betriebszustaende
|
||||
|
||||
**Installiert, aber DISABLED:** SRS-Dateien und der persistente Zustand
|
||||
bleiben erhalten. Der regulaere Postfix verwendet weder `srs-forward`
|
||||
noch die laufenden SRS-Dienste.
|
||||
|
||||
**ENABLED:** Die Postfix-Hauptkonfiguration enthaelt die verwalteten
|
||||
SRS-Bloecke, die drei SRS-Dienste laufen und die Einbindung ist aktiv.
|
||||
|
||||
**Inkonsistenter Zustand:** Die Verwaltungsskripte sollen nicht stillschweigend
|
||||
korrigieren, sondern mit Diagnose abbrechen.
|
||||
|
||||
## Zweiphasige Deaktivierung
|
||||
|
||||
`deactivate-srs.sh` stellt den urspruenglichen Postfix-Zustand wieder her.
|
||||
Vorher muss die SRS-Delivery-Queue leer sein.
|
||||
|
||||
1. **Neue SRS-Uebergaben beenden:** Urspruengliche `main.cf`-Parameter und
|
||||
Empfangseinstellungen wiederherstellen; den alten `srs-forward`-Pipe-Dienst
|
||||
in `master.cf` vorerst belassen; alten Queue-Manager erfassen und den
|
||||
regulaeren Postfix neu laden.
|
||||
2. **Alte Arbeit auslaufen lassen:** Auf den alten Queue-Manager, erkannte
|
||||
`srs-forward`-Prozesse und eine leere SRS-Delivery-Queue warten (maximal
|
||||
180 Sekunden). Die Queue wird wiederholt kontrolliert.
|
||||
3. **Abschalten:** `srs-forward` aus `master.cf` und SRS-systemd-Abhaengigkeiten
|
||||
entfernen; Postfix erneut pruefen und neu laden; Queue erneut pruefen;
|
||||
SRS-Dienste stoppen und deaktivieren; Endzustand verifizieren.
|
||||
|
||||
Kann die Queue nicht geleert oder nicht gelesen werden, oder schlaegt ein
|
||||
Schritt fehl, bricht das Skript ab und **versucht**, den vorherigen aktiven
|
||||
Zustand zurueckzusetzen. Automatischer Rollback ist kein Ersatz fuer
|
||||
Backups und muss fuer Fehlerfaelle noch integriert getestet werden.
|
||||
|
||||
**Wichtiger Unterschied:** Die zwei Phasen dienen der geordneten Umschaltung.
|
||||
Auch bei anfangs leerer SRS-Queue werden beide Phasen durchlaufen.
|
||||
|
||||
### Bewusst akzeptierte Grenze: verspaetete Ruecklaeufer
|
||||
|
||||
Ein Ruecklaeufer an eine bereits versendete SRS-Adresse kann erst Tage nach
|
||||
der Deaktivierung eintreffen. Eine solche Nachricht ist bei der Abschaltung
|
||||
noch nicht in der Queue und kann nicht abgewartet werden.
|
||||
|
||||
**Projektentscheidung:** Nach vollstaendiger Deaktivierung muessen solche
|
||||
spaeten SRS-Ruecklaeufer nicht mehr zugestellt oder rueckuebersetzt werden.
|
||||
Es gibt absichtlich keinen Reverse-only-Modus und keine Uebergangsfrist.
|
||||
Vorrang haben die sichere Behandlung bereits angenommener Nachrichten und
|
||||
ein funktionierender, von SRS unabhaengiger regulaerer Postfix-Betrieb.
|
||||
|
||||
## Nachgewiesene Integrationstests vom 23. Oktober 2026
|
||||
|
||||
Auf `e.mx.oopen.de` mit der SRS-Domain `srs.e.mx.oopen.de`:
|
||||
|
||||
- `install-update-srs.sh --check`: DNS-/SPF- und Installationsvorpruefung OK.
|
||||
- `install-update-srs.sh --install`: PostSRSd 2.4.1 gebaut; Upstream-CTest OK;
|
||||
Installation ohne automatische Aktivierung.
|
||||
- `activate-srs.sh`: Zustand `DISABLED` erkannt; Aktivierung nach Bestaetigung
|
||||
und abschliessende Pruefungen erfolgreich.
|
||||
- Externe virtuelle Weiterleitung `alias@henschelbuch.de` nach
|
||||
`ckubu@oopen.de`: PostSRSd protokollierte die Umschreibung eines GMX-
|
||||
Envelope-Absenders nach `SRS0=...@srs.e.mx.oopen.de`; der empfangende
|
||||
SMTP-Server bestaetigte die Uebernahme (`250 2.0.0`).
|
||||
- `deactivate-srs.sh`: Beide Reloads, Drain-Pruefung, Entfernung des
|
||||
Pipe-Dienstes, Abschaltung aller SRS-Dienste und Verifikation erfolgreich.
|
||||
- Dieselbe externe Weiterleitung nach der Deaktivierung: Regulaerer
|
||||
`postfix/smtp` stellte mit unveraendertem Envelope-Absender an den
|
||||
naechsten SMTP-Server zu (`250 2.0.0`). Die regulaere Postfix-Queue war leer.
|
||||
|
||||
Die genannten SMTP-Ergebnisse belegen die Annahme durch den naechsten
|
||||
Mailserver, nicht automatisch die endgueltige Zustellung ins Postfach.
|
||||
|
||||
**Noch offen:** Erzwungener Rollback-Integrationstest und Deaktivierung
|
||||
unter anhaltender Zustelllast. Eine uneingeschraenkte Freigabe fuer
|
||||
Produktivsysteme wird hieraus nicht abgeleitet.
|
||||
|
||||
## Bekannte Hinweise
|
||||
|
||||
In der Testumgebung erschienen unabhaengige Postfix-Dateiberechtigungswarnungen
|
||||
und Warnungen zur Verwendung von `nobody` durch den Reverse-Map-Dienst bzw.
|
||||
zur fehlenden seccomp-Sandbox von PostSRSd. Diese Meldungen sind bei einer
|
||||
spaeteren Wartung gesondert zu untersuchen. Sie wurden nicht als Nachweis
|
||||
fuer eine fehlerhafte SRS-Zustellung gewertet.
|
||||
|
||||
Fuer Befehle zur Installation, Diagnose und Deaktivierung siehe
|
||||
[README-install-SRS.md](README-install-SRS.md). Der Stand vom 20. Oktober
|
||||
bleibt unter [`archive/selective-srs-2026/`](archive/selective-srs-2026/)
|
||||
unveraendert erhalten.
|
||||
@@ -0,0 +1,221 @@
|
||||
# Selektives SRS – Installation und Betrieb
|
||||
|
||||
**Stand:** 23. Oktober 2026
|
||||
**Geltungsbereich:** Aktuelle Skripte dieses Repositories; Debian 13/Postfix
|
||||
**Status:** Erfolgreicher End-to-End-Test auf `e.mx.oopen.de`; Fehlerfalltests offen
|
||||
|
||||
Siehe auch [README-SRS.md](README-SRS.md) fuer Architektur,
|
||||
Abhaengigkeiten und bewusst akzeptierte Grenzen.
|
||||
|
||||
## 1. Voraussetzungen
|
||||
|
||||
Die regulaere Postfix-Installation muss funktionsfaehig sein. Virtuelle
|
||||
Alias-Weiterleitungen und AmaViS-Reinjektion muessen bekannt und getestet
|
||||
sein. Die SRS-Domain braucht passende oeffentlich aufloesbare DNS-Eintraege.
|
||||
Die Installationspruefung kontrolliert die autoritativen Nameserver sowie
|
||||
SPF und MX im vorgesehenen Setup.
|
||||
|
||||
Die Hostkonfiguration liegt in `conf/srs.conf`; als Ausgangspunkt dient
|
||||
`conf/srs.conf.sample`. Beim Aufruf wird die gewaehlte SRS-Domain angezeigt
|
||||
und bestaetigt. Fuer den Testserver wurde `srs.e.mx.oopen.de` verwendet;
|
||||
diese Domain ist **kein** universeller Standard fuer andere Hosts.
|
||||
|
||||
Die Skripte werden in diesem Repository als `root` ausgefuehrt. Vor
|
||||
Installation oder Aktivierung Backups und einen Wartungs-/Rueckbauplan
|
||||
bereithalten. Keines der Skripte ersetzt die Kontrolle des Mailflusses.
|
||||
|
||||
## 2. Lesende Installationspruefung
|
||||
|
||||
```bash
|
||||
cd /usr/local/src/mailsystem || exit 1
|
||||
./install-update-srs.sh --help
|
||||
./install-update-srs.sh --check
|
||||
```
|
||||
|
||||
`--check` fragt nach der SRS-Domain, prueft Voraussetzungen und inventarisiert
|
||||
die Installation, ohne den Zustand des Systems zu veraendern. Fehlende
|
||||
DNS-Konfiguration zuerst korrigieren, nicht die Vorpruefung umgehen.
|
||||
|
||||
## 3. Installation oder Update
|
||||
|
||||
```bash
|
||||
cd /usr/local/src/mailsystem || exit 1
|
||||
./install-update-srs.sh --install
|
||||
```
|
||||
|
||||
Standardversion: PostSRSd `2.4.1`. Fuer eine andere Version steht
|
||||
`--version X.Y.Z` zur Verfuegung. Der Ablauf enthaelt eine Bestaetigung
|
||||
fuer die Vorbereitung und eine gesonderte Bestaetigung fuer die Installation
|
||||
der vorbereiteten Dateien. Unter anderem werden PostSRSd gebaut, die
|
||||
Upstream-Tests ausgefuehrt, Dateien und Dienste vorbereitet und ein Backup
|
||||
angelegt. Aktive SRS-Integration verhindert laut Installer-Logik ein Update.
|
||||
|
||||
**Wichtig:** `--install` aktiviert **nicht** die selektive SRS-Verarbeitung
|
||||
im regulaeren Postfix und startet die SRS-Dienste nicht. Die Anzeige des
|
||||
Backup-Pfads ist aufzubewahren. Das SRS-Secret bei Updates unveraendert
|
||||
erhalten, damit fruehere SRS-Adressen ueberpruefbar bleiben.
|
||||
|
||||
## 4. Selektives SRS aktivieren
|
||||
|
||||
```bash
|
||||
cd /usr/local/src/mailsystem || exit 1
|
||||
./activate-srs.sh
|
||||
```
|
||||
|
||||
Das Skript prueft die Installation, Laufzeitdienste, Ports und den bisherigen
|
||||
Postfix-Zustand. Bei `DISABLED` zeigt es eine Zusammenfassung und fordert
|
||||
eine ausdrueckliche Bestaetigung. Erst danach werden Sicherungen erstellt,
|
||||
die verwalteten Konfigurationsbloecke eingesetzt, Dienste gestartet und
|
||||
Postfix neu geladen. Bei inkonsistentem Zustand nicht manuell fortfahren,
|
||||
sondern Ursache klaeren.
|
||||
|
||||
### Kontrolle nach Aktivierung
|
||||
|
||||
```bash
|
||||
systemctl is-active postfix.service
|
||||
systemctl is-active postsrsd-srs.service srs-reverse-map.service postfix@srs-delivery.service
|
||||
postconf -h default_transport
|
||||
postconf -M srs-forward/unix
|
||||
postqueue -j
|
||||
postqueue -c /etc/postfix-srs/delivery -j
|
||||
```
|
||||
|
||||
Der aktivierte Transport ist `srs-forward`. Die SRS-Delivery-Instanz ist
|
||||
gesondert zu kontrollieren. Ein erfolgreicher Service-Start beweist noch
|
||||
keine erfolgreiche externe Weiterleitung: Eine reale Testnachricht an eine
|
||||
virtuelle Alias-Adresse mit externem Ziel ist erforderlich. Im Journal
|
||||
sollten die PostSRSd-Umschreibung, die Reinjektion in die zweite Instanz
|
||||
und die SMTP-Uebergabe an den naechsten Mailserver nachvollziehbar sein.
|
||||
|
||||
## 5. Zweiphasig deaktivieren
|
||||
|
||||
```bash
|
||||
cd /usr/local/src/mailsystem || exit 1
|
||||
./deactivate-srs.sh
|
||||
```
|
||||
|
||||
Die Deaktivierung ist interaktiv und prueft vor der Bestaetigung, dass
|
||||
der Zustand `ENABLED` konsistent und die SRS-Delivery-Queue **leer** ist.
|
||||
|
||||
Nach Bestaetigung:
|
||||
|
||||
1. Urspruengliche `main.cf`-Parameter und Empfangseinstellungen
|
||||
wiederherstellen; `srs-forward` zunaechst in `master.cf` belassen;
|
||||
alten Queue-Manager erfassen und Postfix neu laden.
|
||||
2. Auf Beendigung des alten Queue-Managers und erkennbarer
|
||||
`srs-forward`-Arbeit sowie auf leere SRS-Delivery-Queue warten
|
||||
(maximal 180 Sekunden).
|
||||
3. Pipe-Dienst und systemd-Abhaengigkeiten entfernen; erneut pruefen
|
||||
und laden; Queue erneut pruefen; alle drei SRS-Dienste stoppen und
|
||||
deaktivieren; Endzustand verifizieren.
|
||||
|
||||
Wenn beim Start bereits Nachrichten in der SRS-Delivery-Queue stehen,
|
||||
verweigert das Skript die Deaktivierung. Wird die Queue waehrend der
|
||||
Umschaltung nicht leer oder kann nicht gelesen werden, fuehrt das zu
|
||||
Abbruch und einem **versuchten** Rollback. Wartende Mails werden nicht
|
||||
absichtlich geloescht.
|
||||
|
||||
Die Installation, Binaries und `/etc/postfix-srs/state.conf` bleiben
|
||||
nach erfolgreicher Deaktivierung fuer eine spaetere Aktivierung erhalten.
|
||||
|
||||
**Verspaetete SRS-Ruecklaeufer:** Nach vollstaendiger Deaktivierung koennen
|
||||
spaet eintreffende Bounces an SRS-Adressen nicht mehr wie im aktiven Betrieb
|
||||
verarbeitet werden. Das ist eine bewusste Projektentscheidung; ein
|
||||
Reverse-only-Modus ist nicht vorgesehen. Der normale Postfix-Betrieb hat
|
||||
Vorrang. Details: [README-SRS.md](README-SRS.md).
|
||||
|
||||
### Kontrolle nach Deaktivierung
|
||||
|
||||
```bash
|
||||
systemctl is-active postfix.service
|
||||
systemctl is-active postsrsd-srs.service srs-reverse-map.service postfix@srs-delivery.service
|
||||
systemctl is-enabled postsrsd-srs.service srs-reverse-map.service postfix@srs-delivery.service
|
||||
postconf -h default_transport
|
||||
postconf -M srs-forward/unix 2>/dev/null || true
|
||||
postqueue -j
|
||||
```
|
||||
|
||||
Alle SRS-Dienste sollen `inactive` und `disabled` sein; der Pipe-Dienst
|
||||
soll fehlen. Auf dem Testserver war `default_transport` danach wieder
|
||||
`smtp` (impliziter Standard). Die urspruenglichen, nicht explizit gesetzten
|
||||
Parameter sind nicht mehr Teil der lokalen `main.cf`.
|
||||
|
||||
**Keine vermeintliche Null-Queue erzwingen:** Bei gestoppter zweiter Instanz
|
||||
kann `postqueue -c ... -j` mit einer `showq`-Warnung scheitern. Das
|
||||
Kommando nicht blind mit `wc -l` zu einem angeblichen Erfolg zusammenfassen.
|
||||
Der sichere Leerstand wird waehrend der Deaktivierung bei laufender Instanz
|
||||
geprueft. Bei Bedarf die Queue-Dateien offline und mit Bedacht inspizieren.
|
||||
|
||||
### Wichtiger Funktionstest nach der Deaktivierung
|
||||
|
||||
Eine Nachricht erneut an dieselbe externe virtuelle Alias-Weiterleitung
|
||||
senden. Im Mail-Log muss der regulaere `postfix/smtp`-Transport und der
|
||||
urspruengliche Envelope-Absender sichtbar sein. Auch eine lokale
|
||||
Zustellung sollte unveraendert funktionieren. Die erfolgreiche SMTP-Uebergabe
|
||||
an den naechsten Mailserver ist noch kein Beweis fuer den Postfacheingang.
|
||||
|
||||
## 6. Log- und Queue-Diagnose
|
||||
|
||||
```bash
|
||||
journalctl --since "15 minutes ago" --no-pager | grep -Ei 'srs-forward|postfix-srs-delivery|postsrsd|SRS0='
|
||||
|
||||
postqueue -j
|
||||
# Nur waehrend die zweite Instanz laeuft:
|
||||
postqueue -c /etc/postfix-srs/delivery -j
|
||||
```
|
||||
|
||||
Bei Fehlern keine Dateien aus der SRS-Queue entfernen, um die Deaktivierung
|
||||
zu erzwingen. Zuerst Ursache und enthaltene Nachrichten klaeren. Die
|
||||
Backup-Verzeichnisse unter `/root/postfix-srs-activation-*` bzw.
|
||||
`/root/postfix-srs-deactivation-*` zusammen mit den Journalmeldungen sichern.
|
||||
|
||||
## 7. Getesteter Stand und ausstehende Pruefungen
|
||||
|
||||
Am **23. Oktober 2026** auf `e.mx.oopen.de` erfolgreich getestet:
|
||||
Installationsvorpruefung, Installation mit Upstream-CTest, Aktivierung,
|
||||
eine echte externe Alias-Weiterleitung mit SRS, zweiphasige Deaktivierung,
|
||||
anschliessend dieselbe Weiterleitung ohne SRS. Beide Postfix-Queues waren
|
||||
zum Zeitpunkt der abschliessenden Kontrolle leer.
|
||||
|
||||
**Noch nicht praktisch nachgewiesen:** Ein absichtlich ausgeloester
|
||||
Fehler mit vollstaendigem Rollback und die Deaktivierung unter
|
||||
anhaltender Zustelllast. Die isolierten Mock-Tests ersetzen diese
|
||||
Integrationstests nicht.
|
||||
|
||||
## 8. Historische Dokumentation
|
||||
|
||||
Die ausfuehrlichen Texte und frueheren Entwicklungsnotizen vom
|
||||
20. Oktober 2026 verbleiben unveraendert in
|
||||
[`archive/selective-srs-2026/`](archive/selective-srs-2026/).
|
||||
Sie sind keine aktuelle Installationsanleitung.
|
||||
|
||||
## Oeffentlicher MX-Hostname und SRS-Domain
|
||||
|
||||
Der lokale Systemhostname (`hostname -f`) kann vom oeffentlichen
|
||||
MX-Hostnamen abweichen.
|
||||
|
||||
Der Installer fragt deshalb den oeffentlichen MX-Hostnamen separat ab.
|
||||
Als Vorgabe verwendet er `hostname -f`. Bei einer Erstinstallation wird
|
||||
die SRS-Domain aus `srs.<MX-Hostname>` vorgeschlagen.
|
||||
|
||||
Beispiel fuer `mail.interventionistische-linke.org`:
|
||||
|
||||
_MX_HOST="mail.interventionistische-linke.org"
|
||||
_SRS_DOMAIN="srs.mail.interventionistische-linke.org"
|
||||
|
||||
Beide Werte werden in `conf/srs.conf` gespeichert.
|
||||
|
||||
Bei einer bestehenden SRS-Konfiguration bleiben die bereits
|
||||
konfigurierten Werte erhalten. Fehlt lediglich `_MX_HOST`, wird dieser
|
||||
Parameter bei einem Wartungsupdate ergaenzt.
|
||||
|
||||
Erforderliche DNS-Eintraege fuer das Beispiel:
|
||||
|
||||
srs.mail.interventionistische-linke.org. IN MX 10 mail.interventionistische-linke.org.
|
||||
srs.mail.interventionistische-linke.org. IN TXT "v=spf1 mx -all"
|
||||
|
||||
Der Installer prueft die oeffentlichen DNS-Eintraege ueber die
|
||||
autoritativen Nameserver.
|
||||
|
||||
Mit `./install-update-srs.sh --check` kann die System- und
|
||||
DNS-Vorpruefung ohne Aenderung der Installation ausgefuehrt werden.
|
||||
@@ -30,22 +30,40 @@ Ergaenzende Dateien liegen unter anderem in:
|
||||
Weitere Informationen enthalten die vorhandenen
|
||||
README-Dateien, insbesondere README.install.
|
||||
|
||||
## Selektives SRS fuer Postfix
|
||||
|
||||
Die Entwicklung der selektiven SRS-Loesung wurde am 21. Oktober 2026
|
||||
wieder aufgenommen. Die aktuellen Skripte liegen im Hauptverzeichnis:
|
||||
|
||||
- `install-update-srs.sh` – Installation und Aktualisierung
|
||||
- `activate-srs.sh` – SRS-Integration explizit aktivieren
|
||||
- `deactivate-srs.sh` – SRS-Integration kontrolliert deaktivieren
|
||||
|
||||
Aktuelle Dokumentation:
|
||||
|
||||
- [README-SRS.md](README-SRS.md) – Architektur, Grenzen und Teststand
|
||||
- [README-install-SRS.md](README-install-SRS.md) – Installation und Betrieb
|
||||
|
||||
Am 23. Oktober 2026 wurde auf `e.mx.oopen.de` ein vollstaendiger
|
||||
Integrationstest erfolgreich durchgefuehrt: Installation, Aktivierung,
|
||||
externe virtuelle Alias-Weiterleitung mit SRS, zweiphasige Deaktivierung
|
||||
und dieselbe Weiterleitung ohne SRS. Der erzwungene Rollback und die
|
||||
Deaktivierung bei anhaltender Zustelllast sind noch nicht praktisch
|
||||
getestet. Eine uneingeschraenkte Produktivfreigabe wird damit nicht
|
||||
behauptet.
|
||||
|
||||
## Archivierte Entwicklungen
|
||||
|
||||
Abgeschlossene oder eingestellte Entwicklungsarbeiten
|
||||
werden unter archive/ aufbewahrt.
|
||||
Abgeschlossene oder eingestellte historische Entwicklungsstaende
|
||||
werden unter `archive/` aufbewahrt.
|
||||
|
||||
### Selective SRS (2026)
|
||||
### Historischer SRS-Stand (20. Oktober 2026)
|
||||
|
||||
Die im Oktober 2026 entwickelte selektive SRS-Loesung
|
||||
wurde nach abschliessender Architekturpruefung
|
||||
eingestellt und von den Mailservern entfernt.
|
||||
Der damalige Entwicklungsstand wurde am 20. Oktober archiviert. Die
|
||||
Archivtexte beschreiben bewusst die damalige Einstellung und sind keine
|
||||
aktuelle Betriebsanleitung:
|
||||
|
||||
Der vollstaendige historische Entwicklungsstand ist
|
||||
hier dokumentiert:
|
||||
[Selective SRS 2026 – historisches Archiv](archive/selective-srs-2026/README.md)
|
||||
|
||||
[Selective SRS 2026](archive/selective-srs-2026/README.md)
|
||||
|
||||
Die archivierten SRS-Skripte sind nicht fuer den
|
||||
produktiven Einsatz freigegeben und duerfen nicht
|
||||
unveraendert zur Installation verwendet werden.
|
||||
Fuer aktuelle Installationen und Betriebsablaeufe ausschliesslich die
|
||||
oben verlinkte Dokumentation und die Skripte im Hauptverzeichnis verwenden.
|
||||
|
||||
@@ -112,6 +112,11 @@ master_service_option_value() {
|
||||
|
||||
|
||||
master_srs_forward_present() {
|
||||
local master_contents
|
||||
|
||||
# Read failures must not be indistinguishable from an absent service.
|
||||
master_contents="$(< "$POSTFIX_MASTER_CF")" || return 2
|
||||
|
||||
awk '
|
||||
/^[[:space:]]*#/ { next }
|
||||
|
||||
@@ -133,16 +138,20 @@ master_srs_forward_present() {
|
||||
exit 0
|
||||
exit 1
|
||||
}
|
||||
' "$POSTFIX_MASTER_CF"
|
||||
' <<< "$master_contents"
|
||||
}
|
||||
|
||||
|
||||
port_is_listening() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
local sockets rc
|
||||
|
||||
ss -ltnH 2>/dev/null |
|
||||
awk -v host="$host" -v port="$port" '
|
||||
# Capture the status of ss separately: without this, an ss failure
|
||||
# can look like an empty socket listing.
|
||||
sockets="$(ss -ltnH 2>> "$log_file")" || return 2
|
||||
|
||||
awk -v host="$host" -v port="$port" '
|
||||
{
|
||||
local_address=$4
|
||||
|
||||
@@ -156,7 +165,12 @@ port_is_listening() {
|
||||
END {
|
||||
exit(found ? 0 : 1)
|
||||
}
|
||||
'
|
||||
' <<< "$sockets"
|
||||
rc=$?
|
||||
case "$rc" in
|
||||
0|1) return "$rc" ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
||||
@@ -165,13 +179,62 @@ service_exists() {
|
||||
}
|
||||
|
||||
|
||||
read_srs_service_state() {
|
||||
local output line key value
|
||||
local load_state="" active_state="" enabled_state=""
|
||||
|
||||
SRS_SERVICE_ACTIVE_STATE=""
|
||||
SRS_SERVICE_ENABLED_STATE=""
|
||||
|
||||
output="$(systemctl show "$1" --no-pager \
|
||||
-p LoadState -p ActiveState -p UnitFileState 2>> "$log_file")" || return 2
|
||||
|
||||
while IFS= read -r line ; do
|
||||
key="${line%%=*}"
|
||||
value="${line#*=}"
|
||||
[[ "$line" == *=* ]] || return 2
|
||||
|
||||
case "$key" in
|
||||
LoadState)
|
||||
[[ -z "$load_state" ]] || return 2
|
||||
load_state="$value"
|
||||
;;
|
||||
ActiveState)
|
||||
[[ -z "$active_state" ]] || return 2
|
||||
active_state="$value"
|
||||
;;
|
||||
UnitFileState)
|
||||
[[ -z "$enabled_state" ]] || return 2
|
||||
enabled_state="$value"
|
||||
;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
done <<< "$output"
|
||||
|
||||
[[ "$load_state" == "loaded" ]] || return 2
|
||||
case "$active_state" in
|
||||
active|inactive) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
case "$enabled_state" in
|
||||
enabled|enabled-runtime|disabled) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
|
||||
SRS_SERVICE_ACTIVE_STATE="$active_state"
|
||||
SRS_SERVICE_ENABLED_STATE="$enabled_state"
|
||||
return 0
|
||||
}
|
||||
|
||||
service_active() {
|
||||
systemctl is-active --quiet "$1"
|
||||
read_srs_service_state "$1" || return 2
|
||||
[[ "$SRS_SERVICE_ACTIVE_STATE" == "active" ]]
|
||||
}
|
||||
|
||||
|
||||
service_enabled_state() {
|
||||
systemctl is-enabled "$1" 2>/dev/null || true
|
||||
read_srs_service_state "$1" || return 2
|
||||
printf '%s\n' "$SRS_SERVICE_ENABLED_STATE"
|
||||
}
|
||||
|
||||
|
||||
@@ -592,49 +655,90 @@ restore_systemd_dropins() {
|
||||
|
||||
snapshot_service_states() {
|
||||
local service
|
||||
local -A snapshot_active=() snapshot_enabled=()
|
||||
|
||||
for service in \
|
||||
"$POSTSRSD_SERVICE" \
|
||||
"$SRS_REVERSE_MAP_SERVICE" \
|
||||
"$SRS_DELIVERY_SERVICE"
|
||||
do
|
||||
if service_active "$service" ; then
|
||||
SERVICE_WAS_ACTIVE["$service"]="true"
|
||||
else
|
||||
SERVICE_WAS_ACTIVE["$service"]="false"
|
||||
if ! read_srs_service_state "$service" ; then
|
||||
warn "Cannot determine a supported systemd state for '${service}'."
|
||||
return 1
|
||||
fi
|
||||
|
||||
SERVICE_WAS_ENABLED["$service"]="$(service_enabled_state "$service")"
|
||||
# The caller has classified this installation as DISABLED. Reject
|
||||
# a state transition since that check rather than capturing an
|
||||
# unexpectedly started/enabled service as the rollback baseline.
|
||||
if [[ "$SRS_SERVICE_ACTIVE_STATE" != "inactive" ||
|
||||
"$SRS_SERVICE_ENABLED_STATE" != "disabled" ]]; then
|
||||
warn "SRS service '${service}' changed state before activation."
|
||||
return 1
|
||||
fi
|
||||
snapshot_active["$service"]="false"
|
||||
snapshot_enabled["$service"]="$SRS_SERVICE_ENABLED_STATE"
|
||||
done
|
||||
|
||||
# Only publish a complete, validated snapshot.
|
||||
for service in \
|
||||
"$POSTSRSD_SERVICE" \
|
||||
"$SRS_REVERSE_MAP_SERVICE" \
|
||||
"$SRS_DELIVERY_SERVICE"
|
||||
do
|
||||
SERVICE_WAS_ACTIVE["$service"]="${snapshot_active[$service]}"
|
||||
SERVICE_WAS_ENABLED["$service"]="${snapshot_enabled[$service]}"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
restore_service_states() {
|
||||
local service
|
||||
local restore_failed=false
|
||||
local enabled_state
|
||||
local enabled_state active_state
|
||||
|
||||
# Validate the entire snapshot before changing *any* unit.
|
||||
for service in \
|
||||
"$POSTSRSD_SERVICE" \
|
||||
"$SRS_REVERSE_MAP_SERVICE" \
|
||||
"$SRS_DELIVERY_SERVICE"
|
||||
do
|
||||
enabled_state="${SERVICE_WAS_ENABLED[$service]:-}"
|
||||
active_state="${SERVICE_WAS_ACTIVE[$service]:-}"
|
||||
case "$enabled_state" in
|
||||
enabled|enabled-runtime|disabled) ;;
|
||||
*) warn "Invalid saved enable state for '${service}': '${enabled_state}'."; return 1 ;;
|
||||
esac
|
||||
case "$active_state" in
|
||||
true|false) ;;
|
||||
*) warn "Invalid saved active state for '${service}': '${active_state}'."; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
for service in \
|
||||
"$POSTSRSD_SERVICE" \
|
||||
"$SRS_REVERSE_MAP_SERVICE" \
|
||||
"$SRS_DELIVERY_SERVICE"
|
||||
do
|
||||
if [[ "${SERVICE_WAS_ACTIVE[$service]:-false}" == "true" ]]; then
|
||||
systemctl start "$service" > /dev/null 2>> "$log_file" || restore_failed=true
|
||||
else
|
||||
systemctl stop "$service" > /dev/null 2>> "$log_file" || restore_failed=true
|
||||
fi
|
||||
|
||||
enabled_state="${SERVICE_WAS_ENABLED[$service]:-}"
|
||||
enabled_state="${SERVICE_WAS_ENABLED[$service]}"
|
||||
|
||||
case "$enabled_state" in
|
||||
enabled|enabled-runtime)
|
||||
enabled)
|
||||
systemctl enable "$service" > /dev/null 2>> "$log_file" || restore_failed=true
|
||||
;;
|
||||
enabled-runtime)
|
||||
systemctl enable --runtime "$service" > /dev/null 2>> "$log_file" || restore_failed=true
|
||||
;;
|
||||
disabled)
|
||||
systemctl disable "$service" > /dev/null 2>> "$log_file" || restore_failed=true
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${SERVICE_WAS_ACTIVE[$service]}" == "true" ]]; then
|
||||
systemctl start "$service" > /dev/null 2>> "$log_file" || restore_failed=true
|
||||
else
|
||||
systemctl stop "$service" > /dev/null 2>> "$log_file" || restore_failed=true
|
||||
fi
|
||||
done
|
||||
|
||||
! $restore_failed
|
||||
@@ -708,6 +812,11 @@ rollback_activation() {
|
||||
echo_failed
|
||||
rollback_failed=true
|
||||
fi
|
||||
else
|
||||
echononl "Check primary Postfix service '${POSTFIX_SERVICE}' is active.."
|
||||
echo_failed
|
||||
warn "Primary Postfix is inactive; restored configuration could not be reloaded."
|
||||
rollback_failed=true
|
||||
fi
|
||||
|
||||
if $rollback_failed ; then
|
||||
@@ -870,6 +979,9 @@ blank_line
|
||||
|
||||
services_exist=true
|
||||
services_active=true
|
||||
services_enabled=true
|
||||
services_disabled=true
|
||||
services_status_known=true
|
||||
|
||||
for _service in \
|
||||
"$POSTSRSD_SERVICE" \
|
||||
@@ -884,26 +996,42 @@ do
|
||||
echo_failed
|
||||
services_exist=false
|
||||
services_active=false
|
||||
services_enabled=false
|
||||
services_disabled=false
|
||||
services_status_known=false
|
||||
continue
|
||||
fi
|
||||
|
||||
if service_active "$_service" ; then
|
||||
echononl "Check systemd service '${_service}' runtime state (active).."
|
||||
echo_ok
|
||||
else
|
||||
echononl "Check systemd service '${_service}' runtime state (inactive).."
|
||||
echo_ok
|
||||
echononl "Read systemd state of '${_service}'.."
|
||||
if ! read_srs_service_state "$_service" ; then
|
||||
echo_failed
|
||||
services_status_known=false
|
||||
services_active=false
|
||||
services_enabled=false
|
||||
services_disabled=false
|
||||
continue
|
||||
fi
|
||||
echo_ok
|
||||
|
||||
if [[ "$SRS_SERVICE_ACTIVE_STATE" != "active" ]]; then
|
||||
services_active=false
|
||||
fi
|
||||
if [[ "$SRS_SERVICE_ENABLED_STATE" == "disabled" ]]; then
|
||||
services_enabled=false
|
||||
fi
|
||||
if [[ "$SRS_SERVICE_ACTIVE_STATE" != "inactive" ||
|
||||
"$SRS_SERVICE_ENABLED_STATE" != "disabled" ]]; then
|
||||
services_disabled=false
|
||||
fi
|
||||
done
|
||||
|
||||
echononl "Check main Postfix service '${POSTFIX_SERVICE}' is active.."
|
||||
|
||||
if service_active "$POSTFIX_SERVICE" ; then
|
||||
primary_postfix_active=false
|
||||
if systemctl is-active --quiet "$POSTFIX_SERVICE" ; then
|
||||
echo_ok
|
||||
primary_postfix_active=true
|
||||
else
|
||||
echo_failed
|
||||
services_active=false
|
||||
fi
|
||||
|
||||
|
||||
@@ -916,16 +1044,31 @@ echo -e "\033[37m\033[1mCheck selective SRS listeners..\033[m"
|
||||
blank_line
|
||||
|
||||
listeners_ok=true
|
||||
listeners_absent=true
|
||||
listeners_status_known=true
|
||||
|
||||
while IFS='|' read -r _label _host _port ; do
|
||||
if port_is_listening "$_host" "$_port" ; then
|
||||
echononl "Check ${_label} state on ${_host}:${_port} (listening).."
|
||||
echo_ok
|
||||
else
|
||||
echononl "Check ${_label} state on ${_host}:${_port} (inactive).."
|
||||
echo_ok
|
||||
listeners_ok=false
|
||||
fi
|
||||
port_is_listening "$_host" "$_port"
|
||||
_port_rc=$?
|
||||
case "$_port_rc" in
|
||||
0)
|
||||
echononl "Check ${_label} state on ${_host}:${_port} (listening).."
|
||||
echo_ok
|
||||
listeners_absent=false
|
||||
;;
|
||||
1)
|
||||
echononl "Check ${_label} state on ${_host}:${_port} (inactive).."
|
||||
echo_ok
|
||||
listeners_ok=false
|
||||
;;
|
||||
*)
|
||||
echononl "Check ${_label} state on ${_host}:${_port} (query failed).."
|
||||
echo_failed
|
||||
listeners_ok=false
|
||||
listeners_absent=false
|
||||
listeners_status_known=false
|
||||
;;
|
||||
esac
|
||||
done <<EOF
|
||||
PostSRSd socketmap|${POSTSRSD_SOCKET_HOST}|${POSTSRSD_SOCKET_PORT}
|
||||
SRS reverse socketmap|${SRS_REVERSE_SOCKET_HOST}|${SRS_REVERSE_SOCKET_PORT}
|
||||
@@ -933,6 +1076,13 @@ SRS reinjection listener|${SRS_REINJECT_HOST}|${SRS_REINJECT_PORT}
|
||||
SRS reverse listener|${SRS_REVERSE_HOST}|${SRS_REVERSE_PORT}
|
||||
EOF
|
||||
|
||||
dropins_absent=true
|
||||
for _dropin in "$POSTFIX_SRS_DROPIN_FILE" "$SRS_DELIVERY_DROPIN_FILE" ; do
|
||||
if [[ -e "$_dropin" || -L "$_dropin" ]]; then
|
||||
dropins_absent=false
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# ----------
|
||||
# Determine main.cf state
|
||||
@@ -1055,17 +1205,27 @@ case "$_master_marker_rc" in
|
||||
esac
|
||||
|
||||
master_srs_forward_ok=false
|
||||
master_srs_forward_known=true
|
||||
smtp_override_ok=false
|
||||
amavis_override_ok=false
|
||||
|
||||
echononl "Validate 'srs-forward' pipe service state.."
|
||||
if master_srs_forward_present ; then
|
||||
echo_ok
|
||||
master_srs_forward_ok=true
|
||||
else
|
||||
# Absent is valid in the stored pre-SRS state.
|
||||
echo_ok
|
||||
fi
|
||||
master_srs_forward_present
|
||||
_master_service_rc=$?
|
||||
case "$_master_service_rc" in
|
||||
0)
|
||||
echo_ok
|
||||
master_srs_forward_ok=true
|
||||
;;
|
||||
1)
|
||||
# Absent is valid in the stored pre-SRS state.
|
||||
echo_ok
|
||||
;;
|
||||
*)
|
||||
echo_failed
|
||||
master_srs_forward_known=false
|
||||
;;
|
||||
esac
|
||||
|
||||
echononl "Validate port 25 receive_override_options state.."
|
||||
if [[ "$(master_service_option_value smtp receive_override_options)" == "$smtp_active_value" ]]; then
|
||||
@@ -1203,6 +1363,7 @@ srs_state="inconsistent"
|
||||
|
||||
if [[ "$main_block_status" == "present" ]] &&
|
||||
[[ "$master_block_status" != "invalid" ]] &&
|
||||
$master_srs_forward_known &&
|
||||
$main_srs_values_ok &&
|
||||
$master_srs_forward_ok &&
|
||||
$smtp_override_ok &&
|
||||
@@ -1213,6 +1374,7 @@ fi
|
||||
|
||||
if [[ "$main_block_status" == "absent" ]] &&
|
||||
[[ "$master_block_status" == "absent" ]] &&
|
||||
$master_srs_forward_known &&
|
||||
$pre_srs_main_ok &&
|
||||
$pre_srs_master_ok &&
|
||||
! $master_srs_forward_ok
|
||||
@@ -1222,7 +1384,11 @@ fi
|
||||
|
||||
if $installation_ok &&
|
||||
$services_exist &&
|
||||
$services_status_known &&
|
||||
$services_active &&
|
||||
$services_enabled &&
|
||||
$primary_postfix_active &&
|
||||
$listeners_status_known &&
|
||||
$listeners_ok
|
||||
then
|
||||
srs_runtime_ready=true
|
||||
@@ -1236,7 +1402,15 @@ fi
|
||||
|
||||
if $srs_integration_enabled && $srs_runtime_ready ; then
|
||||
srs_state="enabled"
|
||||
elif $srs_integration_disabled ; then
|
||||
elif $srs_integration_disabled &&
|
||||
$services_exist &&
|
||||
$services_status_known &&
|
||||
$services_disabled &&
|
||||
$primary_postfix_active &&
|
||||
$listeners_status_known &&
|
||||
$listeners_absent &&
|
||||
$dropins_absent
|
||||
then
|
||||
srs_state="disabled"
|
||||
else
|
||||
srs_state="inconsistent"
|
||||
@@ -1364,13 +1538,20 @@ fi
|
||||
# Prepare rollback
|
||||
# ----------
|
||||
|
||||
snapshot_service_states
|
||||
echononl "Snapshot disabled SRS service states.."
|
||||
if snapshot_service_states ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal "Cannot safely snapshot disabled SRS services. Activation was not attempted."
|
||||
fi
|
||||
|
||||
ACTIVATION_BACKUP_DIR="/root/postfix-srs-activation-${backup_date}"
|
||||
|
||||
echononl "Create activation backup directory '${ACTIVATION_BACKUP_DIR}'.."
|
||||
echononl "Create activation backup directory.."
|
||||
if mkdir -m 0700 "$ACTIVATION_BACKUP_DIR" > "$log_file" 2>&1 ; then
|
||||
echo_ok
|
||||
printf ' Directory: %s\n' "$ACTIVATION_BACKUP_DIR"
|
||||
else
|
||||
echo_failed
|
||||
fatal "$(cat "$log_file")"
|
||||
@@ -1400,6 +1581,14 @@ else
|
||||
fatal "$(cat "$log_file")"
|
||||
fi
|
||||
|
||||
echononl "Recheck primary Postfix service '${POSTFIX_SERVICE}' is active.."
|
||||
if systemctl is-active --quiet "$POSTFIX_SERVICE" ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal "Primary Postfix became inactive. Activation was not attempted."
|
||||
fi
|
||||
|
||||
ACTIVATION_CHANGES_STARTED=true
|
||||
|
||||
|
||||
@@ -1639,7 +1828,10 @@ for _service in \
|
||||
do
|
||||
echononl "Verify systemd service '${_service}' is active.."
|
||||
|
||||
if service_active "$_service" ; then
|
||||
if read_srs_service_state "$_service" &&
|
||||
[[ "$SRS_SERVICE_ACTIVE_STATE" == "active" ]] &&
|
||||
[[ "$SRS_SERVICE_ENABLED_STATE" != "disabled" ]]
|
||||
then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
@@ -1647,6 +1839,30 @@ do
|
||||
fi
|
||||
done
|
||||
|
||||
for _endpoint in \
|
||||
"PostSRSd socketmap|${POSTSRSD_SOCKET_HOST}|${POSTSRSD_SOCKET_PORT}" \
|
||||
"SRS reverse socketmap|${SRS_REVERSE_SOCKET_HOST}|${SRS_REVERSE_SOCKET_PORT}" \
|
||||
"SRS reinjection listener|${SRS_REINJECT_HOST}|${SRS_REINJECT_PORT}" \
|
||||
"SRS reverse listener|${SRS_REVERSE_HOST}|${SRS_REVERSE_PORT}"
|
||||
do
|
||||
IFS='|' read -r _label _host _port <<< "$_endpoint"
|
||||
echononl "Verify ${_label} remains listening on ${_host}:${_port}.."
|
||||
if port_is_listening "$_host" "$_port" ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
verification_ok=false
|
||||
fi
|
||||
done
|
||||
|
||||
echononl "Verify primary Postfix remains active.."
|
||||
if systemctl is-active --quiet "$POSTFIX_SERVICE" ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
verification_ok=false
|
||||
fi
|
||||
|
||||
echononl "Final Postfix configuration check.."
|
||||
if postfix check > "$log_file" 2>&1 ; then
|
||||
echo_ok
|
||||
@@ -2,6 +2,15 @@
|
||||
# - Selective SRS configuration
|
||||
# ---
|
||||
|
||||
# Public MX hostname used as the MX target of the SRS domain.
|
||||
# The installer proposes `hostname -f`; another public FQDN may be selected.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# _MX_HOST="mail.example.org"
|
||||
#
|
||||
_MX_HOST=""
|
||||
|
||||
# Domain used as envelope sender domain for SRS rewritten messages.
|
||||
#
|
||||
# DNS/SPF for this domain must authorize the outgoing mail server.
|
||||
@@ -47,6 +47,9 @@ SRS_DELIVERY_DROPIN_WAS_PRESENT=false
|
||||
declare -A SERVICE_WAS_ACTIVE
|
||||
declare -A SERVICE_WAS_ENABLED
|
||||
|
||||
# Queue manager processes active before the first (phase-1) reload.
|
||||
SRS_OLD_QMGR_PIDS=()
|
||||
|
||||
|
||||
# ----------
|
||||
# Local helper functions
|
||||
@@ -112,6 +115,11 @@ master_service_option_value() {
|
||||
|
||||
|
||||
master_srs_forward_present() {
|
||||
local master_contents
|
||||
|
||||
# Read failures must not be indistinguishable from an absent service.
|
||||
master_contents="$(< "$POSTFIX_MASTER_CF")" || return 2
|
||||
|
||||
awk '
|
||||
/^[[:space:]]*#/ { next }
|
||||
|
||||
@@ -133,16 +141,20 @@ master_srs_forward_present() {
|
||||
exit 0
|
||||
exit 1
|
||||
}
|
||||
' "$POSTFIX_MASTER_CF"
|
||||
' <<< "$master_contents"
|
||||
}
|
||||
|
||||
|
||||
port_is_listening() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
local sockets rc
|
||||
|
||||
ss -ltnH 2>/dev/null |
|
||||
awk -v host="$host" -v port="$port" '
|
||||
# Capture the status of ss separately: without this, an ss failure
|
||||
# can look like an empty socket listing.
|
||||
sockets="$(ss -ltnH 2>> "$log_file")" || return 2
|
||||
|
||||
awk -v host="$host" -v port="$port" '
|
||||
{
|
||||
local_address=$4
|
||||
|
||||
@@ -156,7 +168,12 @@ port_is_listening() {
|
||||
END {
|
||||
exit(found ? 0 : 1)
|
||||
}
|
||||
'
|
||||
' <<< "$sockets"
|
||||
rc=$?
|
||||
case "$rc" in
|
||||
0|1) return "$rc" ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
||||
@@ -165,24 +182,77 @@ service_exists() {
|
||||
}
|
||||
|
||||
|
||||
# One validated systemd snapshot per call. Neither a failed query nor an
|
||||
# unsupported/transient state may be mistaken for inactive or disabled.
|
||||
# Returns: 0 = understood; 2 = unknown, unsupported, or query error.
|
||||
# Results are set in SRS_SERVICE_ACTIVE_STATE and SRS_SERVICE_ENABLED_STATE.
|
||||
read_srs_service_state() {
|
||||
local output line key value
|
||||
local load_state="" active_state="" enabled_state=""
|
||||
|
||||
SRS_SERVICE_ACTIVE_STATE=""
|
||||
SRS_SERVICE_ENABLED_STATE=""
|
||||
|
||||
output="$(systemctl show "$1" --no-pager \
|
||||
-p LoadState -p ActiveState -p UnitFileState 2>> "$log_file")" || return 2
|
||||
|
||||
while IFS= read -r line ; do
|
||||
key="${line%%=*}"
|
||||
value="${line#*=}"
|
||||
[[ "$line" == *=* ]] || return 2
|
||||
|
||||
case "$key" in
|
||||
LoadState)
|
||||
[[ -z "$load_state" ]] || return 2
|
||||
load_state="$value"
|
||||
;;
|
||||
ActiveState)
|
||||
[[ -z "$active_state" ]] || return 2
|
||||
active_state="$value"
|
||||
;;
|
||||
UnitFileState)
|
||||
[[ -z "$enabled_state" ]] || return 2
|
||||
enabled_state="$value"
|
||||
;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
done <<< "$output"
|
||||
|
||||
[[ "$load_state" == "loaded" ]] || return 2
|
||||
case "$active_state" in
|
||||
active|inactive) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
case "$enabled_state" in
|
||||
enabled|enabled-runtime|disabled) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
|
||||
SRS_SERVICE_ACTIVE_STATE="$active_state"
|
||||
SRS_SERVICE_ENABLED_STATE="$enabled_state"
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
# Returns: 0 = active/enabled, 1 = inactive/disabled, 2 = query error.
|
||||
service_active() {
|
||||
systemctl is-active --quiet "$1"
|
||||
read_srs_service_state "$1" || return 2
|
||||
[[ "$SRS_SERVICE_ACTIVE_STATE" == "active" ]]
|
||||
}
|
||||
|
||||
|
||||
service_enabled_state() {
|
||||
systemctl is-enabled "$1" 2>/dev/null || true
|
||||
read_srs_service_state "$1" || return 2
|
||||
printf '%s\n' "$SRS_SERVICE_ENABLED_STATE"
|
||||
}
|
||||
|
||||
|
||||
service_enabled() {
|
||||
case "$(service_enabled_state "$1")" in
|
||||
enabled|enabled-runtime)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
read_srs_service_state "$1" || return 2
|
||||
case "$SRS_SERVICE_ENABLED_STATE" in
|
||||
enabled|enabled-runtime) return 0 ;;
|
||||
disabled) return 1 ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -453,14 +523,11 @@ if begin_idx >= end_idx:
|
||||
|
||||
new_lines = lines[:begin_idx] + lines[end_idx + 1:]
|
||||
|
||||
# Remove one excessive blank line left at the join point.
|
||||
while (
|
||||
begin_idx > 0
|
||||
and begin_idx < len(new_lines)
|
||||
and new_lines[begin_idx - 1].strip() == ""
|
||||
and new_lines[begin_idx].strip() == ""
|
||||
):
|
||||
del new_lines[begin_idx]
|
||||
# activate-srs.sh appends exactly one separator line before the
|
||||
# managed block. Remove only that separator (also when the block was at
|
||||
# EOF), preserving every blank line that existed before activation.
|
||||
if begin_idx > 0 and lines[begin_idx - 1] == "\n":
|
||||
del new_lines[begin_idx - 1]
|
||||
|
||||
st = os.stat(path)
|
||||
directory = os.path.dirname(path)
|
||||
@@ -729,45 +796,86 @@ restore_systemd_dropins() {
|
||||
|
||||
snapshot_service_states() {
|
||||
local service
|
||||
local -A snapshot_active=() snapshot_enabled=()
|
||||
|
||||
for service in \
|
||||
"$POSTSRSD_SERVICE" \
|
||||
"$SRS_REVERSE_MAP_SERVICE" \
|
||||
"$SRS_DELIVERY_SERVICE"
|
||||
do
|
||||
if service_active "$service" ; then
|
||||
SERVICE_WAS_ACTIVE["$service"]="true"
|
||||
else
|
||||
SERVICE_WAS_ACTIVE["$service"]="false"
|
||||
if ! read_srs_service_state "$service" ; then
|
||||
warn "Cannot determine a supported systemd state for '${service}'."
|
||||
return 1
|
||||
fi
|
||||
|
||||
SERVICE_WAS_ENABLED["$service"]="$(service_enabled_state "$service")"
|
||||
# The caller has classified this installation as ENABLED. Reject
|
||||
# a state transition since that check rather than capturing an
|
||||
# unexpectedly stopped/disabled service as the rollback baseline.
|
||||
if [[ "$SRS_SERVICE_ACTIVE_STATE" != "active" ||
|
||||
"$SRS_SERVICE_ENABLED_STATE" == "disabled" ]]; then
|
||||
warn "SRS service '${service}' changed state before deactivation."
|
||||
return 1
|
||||
fi
|
||||
snapshot_active["$service"]="true"
|
||||
snapshot_enabled["$service"]="$SRS_SERVICE_ENABLED_STATE"
|
||||
done
|
||||
|
||||
# Only publish a complete, validated snapshot.
|
||||
for service in \
|
||||
"$POSTSRSD_SERVICE" \
|
||||
"$SRS_REVERSE_MAP_SERVICE" \
|
||||
"$SRS_DELIVERY_SERVICE"
|
||||
do
|
||||
SERVICE_WAS_ACTIVE["$service"]="${snapshot_active[$service]}"
|
||||
SERVICE_WAS_ENABLED["$service"]="${snapshot_enabled[$service]}"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
restore_service_states() {
|
||||
local service
|
||||
local restore_failed=false
|
||||
local enabled_state
|
||||
local enabled_state active_state
|
||||
|
||||
# Validate the entire snapshot before changing *any* unit.
|
||||
for service in \
|
||||
"$POSTSRSD_SERVICE" \
|
||||
"$SRS_REVERSE_MAP_SERVICE" \
|
||||
"$SRS_DELIVERY_SERVICE"
|
||||
do
|
||||
enabled_state="${SERVICE_WAS_ENABLED[$service]:-}"
|
||||
active_state="${SERVICE_WAS_ACTIVE[$service]:-}"
|
||||
case "$enabled_state" in
|
||||
enabled|enabled-runtime|disabled) ;;
|
||||
*) warn "Invalid saved enable state for '${service}': '${enabled_state}'."; return 1 ;;
|
||||
esac
|
||||
case "$active_state" in
|
||||
true|false) ;;
|
||||
*) warn "Invalid saved active state for '${service}': '${active_state}'."; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
for service in \
|
||||
"$POSTSRSD_SERVICE" \
|
||||
"$SRS_REVERSE_MAP_SERVICE" \
|
||||
"$SRS_DELIVERY_SERVICE"
|
||||
do
|
||||
enabled_state="${SERVICE_WAS_ENABLED[$service]}"
|
||||
|
||||
case "$enabled_state" in
|
||||
enabled|enabled-runtime)
|
||||
enabled)
|
||||
systemctl enable "$service" > /dev/null 2>> "$log_file" || restore_failed=true
|
||||
;;
|
||||
enabled-runtime)
|
||||
systemctl enable --runtime "$service" > /dev/null 2>> "$log_file" || restore_failed=true
|
||||
;;
|
||||
disabled)
|
||||
systemctl disable "$service" > /dev/null 2>> "$log_file" || restore_failed=true
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${SERVICE_WAS_ACTIVE[$service]:-false}" == "true" ]]; then
|
||||
if [[ "${SERVICE_WAS_ACTIVE[$service]}" == "true" ]]; then
|
||||
systemctl start "$service" > /dev/null 2>> "$log_file" || restore_failed=true
|
||||
else
|
||||
systemctl stop "$service" > /dev/null 2>> "$log_file" || restore_failed=true
|
||||
@@ -789,6 +897,130 @@ srs_delivery_queue_is_empty() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
# ----------
|
||||
# Deactivation phase 1: wait for old routing work to finish.
|
||||
# The old qmgr process(es) must exit after the main Postfix reload.
|
||||
# Only then can we assume that no further delivery requests using the
|
||||
# former default_transport=srs-forward will be scheduled by that qmgr.
|
||||
# Also wait for all remaining srs-forward pipe workers, including idle
|
||||
# ones, before removing the service from master.cf.
|
||||
# ----------
|
||||
|
||||
capture_srs_old_queue_managers() {
|
||||
local master_pid pid ppid comm process_list
|
||||
|
||||
process_list="$(ps -eo pid=,ppid=,comm=)" || return 1
|
||||
|
||||
master_pid="$(systemctl show "$POSTFIX_SERVICE" -p MainPID --value 2>> "$log_file")" || return 1
|
||||
[[ "$master_pid" =~ ^[1-9][0-9]*$ ]] || return 1
|
||||
kill -0 "$master_pid" 2>> "$log_file" || return 1
|
||||
|
||||
SRS_OLD_QMGR_PIDS=()
|
||||
while read -r pid ppid comm ; do
|
||||
if [[ "$ppid" == "$master_pid" && "$comm" == 'qmgr' ]]; then
|
||||
SRS_OLD_QMGR_PIDS+=("$pid")
|
||||
fi
|
||||
done <<< "$process_list"
|
||||
|
||||
# Fail closed if the expected running queue manager cannot be identified.
|
||||
(( ${#SRS_OLD_QMGR_PIDS[@]} > 0 ))
|
||||
}
|
||||
|
||||
srs_old_queue_manager_is_running() {
|
||||
local pid comm
|
||||
|
||||
for pid in "${SRS_OLD_QMGR_PIDS[@]}" ; do
|
||||
[[ -d "/proc/$pid" ]] || continue
|
||||
|
||||
if ! comm="$(ps -p "$pid" -o comm= 2>/dev/null)" ; then
|
||||
# The process may have exited between the two checks.
|
||||
[[ -d "/proc/$pid" ]] && return 2
|
||||
continue
|
||||
fi
|
||||
|
||||
# An unexpected process identity must not count as success.
|
||||
[[ "$comm" == 'qmgr' ]] || return 2
|
||||
return 0
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
srs_forward_worker_is_running() {
|
||||
local process_list
|
||||
|
||||
process_list="$(ps -eo comm=,args=)" || return 2
|
||||
|
||||
printf '%s\n' "$process_list" | awk '
|
||||
$1 == "pipe" && /srs-forward/ { found=1 }
|
||||
$1 ~ /^python([0-9.]*)?$/ && /\/usr\/local\/sbin\/srs-forward[.]py/ { found=1 }
|
||||
END { exit(found ? 0 : 1) }
|
||||
'
|
||||
}
|
||||
|
||||
wait_for_srs_forward_drain() {
|
||||
local deadline=$((SECONDS + 180))
|
||||
local _workers_rc _queue_rc _qmgr_rc
|
||||
|
||||
while (( SECONDS < deadline )); do
|
||||
srs_old_queue_manager_is_running
|
||||
_qmgr_rc=$?
|
||||
case "$_qmgr_rc" in
|
||||
0) sleep 2; continue ;;
|
||||
1) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
|
||||
srs_forward_worker_is_running
|
||||
_workers_rc=$?
|
||||
case "$_workers_rc" in
|
||||
0) sleep 2; continue ;;
|
||||
1) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
|
||||
srs_delivery_queue_is_empty
|
||||
_queue_rc=$?
|
||||
case "$_queue_rc" in
|
||||
0) ;;
|
||||
1) sleep 2; continue ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
|
||||
# A second sample helps avoid treating a transient empty queue
|
||||
# as quiescence; it is not a substitute for waiting for old qmgr.
|
||||
sleep 2
|
||||
|
||||
srs_old_queue_manager_is_running
|
||||
_qmgr_rc=$?
|
||||
case "$_qmgr_rc" in
|
||||
0) continue ;;
|
||||
1) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
|
||||
srs_forward_worker_is_running
|
||||
_workers_rc=$?
|
||||
case "$_workers_rc" in
|
||||
0) continue ;;
|
||||
1) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
|
||||
srs_delivery_queue_is_empty
|
||||
_queue_rc=$?
|
||||
case "$_queue_rc" in
|
||||
0) return 0 ;;
|
||||
1) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
rollback_deactivation() {
|
||||
local rollback_failed=false
|
||||
|
||||
@@ -848,6 +1080,11 @@ rollback_deactivation() {
|
||||
echo_failed
|
||||
rollback_failed=true
|
||||
fi
|
||||
else
|
||||
echononl "Check primary Postfix service '${POSTFIX_SERVICE}' is active.."
|
||||
echo_failed
|
||||
warn "Primary Postfix is inactive; restored configuration could not be reloaded."
|
||||
rollback_failed=true
|
||||
fi
|
||||
|
||||
if $rollback_failed ; then
|
||||
@@ -1016,6 +1253,8 @@ blank_line
|
||||
services_exist=true
|
||||
services_active=true
|
||||
services_enabled=true
|
||||
services_inactive=true
|
||||
services_disabled=true
|
||||
|
||||
for _service in \
|
||||
"$POSTSRSD_SERVICE" \
|
||||
@@ -1035,34 +1274,50 @@ do
|
||||
fi
|
||||
|
||||
echononl "Check systemd service '${_service}' is active.."
|
||||
if service_active "$_service" ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_inactive
|
||||
services_active=false
|
||||
fi
|
||||
service_active "$_service"
|
||||
_service_rc=$?
|
||||
case "$_service_rc" in
|
||||
0) echo_ok; services_inactive=false ;;
|
||||
1) echo_inactive; services_active=false ;;
|
||||
*) fatal "Cannot read valid active state for '${_service}'. No changes were made." ;;
|
||||
esac
|
||||
|
||||
if service_enabled "$_service" ; then
|
||||
echononl "Check systemd service '${_service}' enable state (enabled).."
|
||||
echo_ok
|
||||
else
|
||||
echononl "Check systemd service '${_service}' enable state (disabled).."
|
||||
echo_ok
|
||||
services_enabled=false
|
||||
fi
|
||||
service_enabled "$_service"
|
||||
_service_rc=$?
|
||||
case "$_service_rc" in
|
||||
0)
|
||||
echononl "Check systemd service '${_service}' enable state (enabled).."
|
||||
echo_ok
|
||||
services_disabled=false
|
||||
;;
|
||||
1)
|
||||
echononl "Check systemd service '${_service}' enable state (disabled).."
|
||||
echo_ok
|
||||
services_enabled=false
|
||||
;;
|
||||
*) fatal "Cannot read valid enable state for '${_service}'. No changes were made." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
listeners_active=true
|
||||
listeners_inactive=true
|
||||
|
||||
while IFS='|' read -r _label _host _port ; do
|
||||
if port_is_listening "$_host" "$_port" ; then
|
||||
echononl "Check ${_label} state on ${_host}:${_port} (listening).."
|
||||
echo_ok
|
||||
else
|
||||
echononl "Check ${_label} state on ${_host}:${_port} (inactive).."
|
||||
echo_ok
|
||||
listeners_active=false
|
||||
fi
|
||||
port_is_listening "$_host" "$_port"
|
||||
_port_rc=$?
|
||||
case "$_port_rc" in
|
||||
0)
|
||||
echononl "Check ${_label} state on ${_host}:${_port} (listening).."
|
||||
echo_ok
|
||||
listeners_inactive=false
|
||||
;;
|
||||
1)
|
||||
echononl "Check ${_label} state on ${_host}:${_port} (inactive).."
|
||||
echo_ok
|
||||
listeners_active=false
|
||||
;;
|
||||
*) fatal "Cannot inspect listener ${_label} on ${_host}:${_port}. No changes were made." ;;
|
||||
esac
|
||||
done <<EOF
|
||||
PostSRSd socketmap|${POSTSRSD_SOCKET_HOST}|${POSTSRSD_SOCKET_PORT}
|
||||
SRS reverse socketmap|${SRS_REVERSE_SOCKET_HOST}|${SRS_REVERSE_SOCKET_PORT}
|
||||
@@ -1074,10 +1329,13 @@ if systemd_dropins_match_expected ; then
|
||||
echononl "Check systemd SRS dependency drop-ins state (present).."
|
||||
echo_ok
|
||||
systemd_dropins_active=true
|
||||
else
|
||||
elif [[ ! -e "$POSTFIX_SRS_DROPIN_FILE" && ! -L "$POSTFIX_SRS_DROPIN_FILE" &&
|
||||
! -e "$SRS_DELIVERY_DROPIN_FILE" && ! -L "$SRS_DELIVERY_DROPIN_FILE" ]]; then
|
||||
echononl "Check systemd SRS dependency drop-ins state (absent).."
|
||||
echo_ok
|
||||
systemd_dropins_active=false
|
||||
else
|
||||
fatal "SRS dependency drop-ins are present but incomplete or invalid. No changes were made."
|
||||
fi
|
||||
|
||||
|
||||
@@ -1183,13 +1441,13 @@ smtp_override_ok=false
|
||||
amavis_override_ok=false
|
||||
|
||||
echononl "Validate 'srs-forward' pipe service state.."
|
||||
if master_srs_forward_present ; then
|
||||
echo_ok
|
||||
master_srs_forward_ok=true
|
||||
else
|
||||
# Absent is valid in the stored pre-SRS state.
|
||||
echo_ok
|
||||
fi
|
||||
master_srs_forward_present
|
||||
_master_rc=$?
|
||||
case "$_master_rc" in
|
||||
0) echo_ok; master_srs_forward_ok=true ;;
|
||||
1) echo_ok ;; # Absent is valid in the stored pre-SRS state.
|
||||
*) fatal "Cannot inspect the srs-forward master.cf service. No changes were made." ;;
|
||||
esac
|
||||
|
||||
echononl "Validate port 25 receive_override_options state.."
|
||||
if [[ "$(master_service_option_value smtp receive_override_options)" == "$smtp_active_value" ]]; then
|
||||
@@ -1352,9 +1610,9 @@ then
|
||||
runtime_enabled=true
|
||||
fi
|
||||
|
||||
if ! $services_active &&
|
||||
! $services_enabled &&
|
||||
! $listeners_active &&
|
||||
if $services_inactive &&
|
||||
$services_disabled &&
|
||||
$listeners_inactive &&
|
||||
! $systemd_dropins_active
|
||||
then
|
||||
runtime_disabled=true
|
||||
@@ -1503,17 +1761,37 @@ if ! ask_yes_no "einverstanden" "no" ; then
|
||||
fi
|
||||
|
||||
|
||||
# ----------
|
||||
# Require a running primary Postfix before making any changes
|
||||
# ----------
|
||||
|
||||
echononl "Check primary Postfix service '${POSTFIX_SERVICE}' is active.."
|
||||
if systemctl is-active --quiet "$POSTFIX_SERVICE" ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal "Primary Postfix service '${POSTFIX_SERVICE}' is inactive. Deactivation was not attempted."
|
||||
fi
|
||||
|
||||
|
||||
# ----------
|
||||
# Prepare rollback
|
||||
# ----------
|
||||
|
||||
snapshot_service_states
|
||||
echononl "Snapshot previous SRS service states.."
|
||||
if snapshot_service_states ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal "Could not snapshot all SRS systemd service states. Deactivation was not attempted."
|
||||
fi
|
||||
|
||||
DEACTIVATION_BACKUP_DIR="/root/postfix-srs-deactivation-${backup_date}"
|
||||
|
||||
echononl "Create deactivation backup directory '${DEACTIVATION_BACKUP_DIR}'.."
|
||||
echononl "Create deactivation backup directory.."
|
||||
if mkdir -m 0700 "$DEACTIVATION_BACKUP_DIR" > "$log_file" 2>&1 ; then
|
||||
echo_ok
|
||||
printf ' Directory: %s\n' "$DEACTIVATION_BACKUP_DIR"
|
||||
else
|
||||
echo_failed
|
||||
fatal "$(cat "$log_file")"
|
||||
@@ -1617,33 +1895,10 @@ if [[ "$srs_alias_status" == 'managed' ]]; then
|
||||
fi
|
||||
|
||||
# ----------
|
||||
# Restore master.cf
|
||||
# Restore smtpd receive override settings for normal Postfix routing.
|
||||
# Keep srs-forward service until old queue-manager work is drained.
|
||||
# ----------
|
||||
|
||||
if [[ "$master_block_status" == "present" ]]; then
|
||||
echononl "Remove managed SRS block from '${POSTFIX_MASTER_CF}'.."
|
||||
|
||||
if remove_managed_block \
|
||||
"$POSTFIX_MASTER_CF" \
|
||||
"$SRS_MASTER_CF_BEGIN_MARKER" \
|
||||
"$SRS_MASTER_CF_END_MARKER" > "$log_file" 2>&1
|
||||
then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal_with_deactivation_rollback "Could not remove managed SRS block from '${POSTFIX_MASTER_CF}'."
|
||||
fi
|
||||
else
|
||||
echononl "Remove legacy unmarked 'srs-forward' service.."
|
||||
|
||||
if remove_legacy_srs_forward_service > "$log_file" 2>&1 ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal_with_deactivation_rollback "Could not safely remove legacy unmarked 'srs-forward' service."
|
||||
fi
|
||||
fi
|
||||
|
||||
echononl "Restore pre-SRS port 25 receive_override_options.."
|
||||
if update_master_service_option \
|
||||
smtp \
|
||||
@@ -1682,18 +1937,12 @@ else
|
||||
fi
|
||||
|
||||
|
||||
# ----------
|
||||
# Remove systemd SRS dependencies
|
||||
# ----------
|
||||
|
||||
echononl "Remove systemd SRS dependency drop-ins.."
|
||||
if remove_srs_systemd_dropins > "$log_file" 2>&1 ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal_with_deactivation_rollback "Could not remove systemd SRS dependency drop-ins. $(cat "$log_file")"
|
||||
fi
|
||||
|
||||
# ----------
|
||||
# Phase 1: leave srs-forward in master.cf until old routing work
|
||||
# has finished. Removing it before the reload could terminate
|
||||
# running pipe workers abruptly.
|
||||
# ----------
|
||||
|
||||
# ----------
|
||||
# Validate and reload main Postfix
|
||||
@@ -1707,6 +1956,14 @@ else
|
||||
fatal_with_deactivation_rollback "Postfix configuration check failed. $(cat "$log_file")"
|
||||
fi
|
||||
|
||||
echononl "Capture active Postfix queue manager before reload.."
|
||||
if capture_srs_old_queue_managers ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal_with_deactivation_rollback "Could not identify the old Postfix queue manager. Refusing unsafe deactivation."
|
||||
fi
|
||||
|
||||
echononl "Reload main Postfix service '${POSTFIX_SERVICE}'.."
|
||||
if systemctl reload "$POSTFIX_SERVICE" > "$log_file" 2>&1 ; then
|
||||
echo_ok
|
||||
@@ -1716,6 +1973,91 @@ else
|
||||
fi
|
||||
|
||||
|
||||
# ----------
|
||||
# Phase 1: drain all old routing work before removing the pipe service.
|
||||
# ----------
|
||||
|
||||
echononl "Wait for old SRS queue-manager and pipe work to drain (max. 180s).."
|
||||
if wait_for_srs_forward_drain ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal_with_deactivation_rollback \
|
||||
"Old SRS work or SRS delivery queue has not drained safely; SRS remains enabled after rollback."
|
||||
fi
|
||||
|
||||
|
||||
# ----------
|
||||
# Phase 2: remove the now-unused pipe service and SRS dependencies.
|
||||
# ----------
|
||||
|
||||
# ----------
|
||||
# Restore master.cf
|
||||
# ----------
|
||||
|
||||
if [[ "$master_block_status" == "present" ]]; then
|
||||
echononl "Remove managed SRS block from '${POSTFIX_MASTER_CF}'.."
|
||||
|
||||
if remove_managed_block \
|
||||
"$POSTFIX_MASTER_CF" \
|
||||
"$SRS_MASTER_CF_BEGIN_MARKER" \
|
||||
"$SRS_MASTER_CF_END_MARKER" > "$log_file" 2>&1
|
||||
then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal_with_deactivation_rollback "Could not remove managed SRS block from '${POSTFIX_MASTER_CF}'."
|
||||
fi
|
||||
else
|
||||
echononl "Remove legacy unmarked 'srs-forward' service.."
|
||||
|
||||
if remove_legacy_srs_forward_service > "$log_file" 2>&1 ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal_with_deactivation_rollback "Could not safely remove legacy unmarked 'srs-forward' service."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ----------
|
||||
# Remove systemd SRS dependencies
|
||||
# ----------
|
||||
|
||||
echononl "Remove systemd SRS dependency drop-ins.."
|
||||
if remove_srs_systemd_dropins > "$log_file" 2>&1 ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal_with_deactivation_rollback "Could not remove systemd SRS dependency drop-ins. $(cat "$log_file")"
|
||||
fi
|
||||
|
||||
|
||||
echononl "Check final pre-SRS Postfix configuration.."
|
||||
if postfix check > "$log_file" 2>&1 ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal_with_deactivation_rollback "Final Postfix configuration check failed. $(cat "$log_file")"
|
||||
fi
|
||||
|
||||
echononl "Reload main Postfix after removing SRS pipe service.."
|
||||
if systemctl reload "$POSTFIX_SERVICE" > "$log_file" 2>&1 ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal_with_deactivation_rollback "Final Postfix reload failed. $(cat "$log_file")"
|
||||
fi
|
||||
|
||||
echononl "Verify SRS delivery queue remains empty before service shutdown.."
|
||||
if srs_delivery_queue_is_empty ; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
fatal_with_deactivation_rollback \
|
||||
"SRS delivery queue is nonempty or unreadable after final reload. Refusing shutdown."
|
||||
fi
|
||||
|
||||
|
||||
# ----------
|
||||
# Stop and disable SRS services
|
||||
# ----------
|
||||
@@ -1788,7 +2130,9 @@ else
|
||||
fi
|
||||
|
||||
echononl "Verify 'srs-forward' service is absent.."
|
||||
if ! master_srs_forward_present ; then
|
||||
master_srs_forward_present
|
||||
_master_rc=$?
|
||||
if [[ "$_master_rc" -eq 1 ]]; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
@@ -1842,7 +2186,8 @@ else
|
||||
fi
|
||||
|
||||
echononl "Verify systemd SRS dependency drop-ins are absent.."
|
||||
if [[ ! -e "$POSTFIX_SRS_DROPIN_FILE" && ! -e "$SRS_DELIVERY_DROPIN_FILE" ]]; then
|
||||
if [[ ! -e "$POSTFIX_SRS_DROPIN_FILE" && ! -L "$POSTFIX_SRS_DROPIN_FILE" &&
|
||||
! -e "$SRS_DELIVERY_DROPIN_FILE" && ! -L "$SRS_DELIVERY_DROPIN_FILE" ]]; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
@@ -1856,7 +2201,9 @@ for _service in \
|
||||
do
|
||||
echononl "Verify systemd service '${_service}' is inactive.."
|
||||
|
||||
if ! service_active "$_service" ; then
|
||||
service_active "$_service"
|
||||
_service_rc=$?
|
||||
if [[ "$_service_rc" -eq 1 ]]; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
@@ -1865,7 +2212,9 @@ do
|
||||
|
||||
echononl "Verify systemd service '${_service}' is disabled.."
|
||||
|
||||
if ! service_enabled "$_service" ; then
|
||||
service_enabled "$_service"
|
||||
_service_rc=$?
|
||||
if [[ "$_service_rc" -eq 1 ]]; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
@@ -1883,7 +2232,9 @@ do
|
||||
|
||||
echononl "Verify ${_label} is inactive.."
|
||||
|
||||
if ! port_is_listening "$_host" "$_port" ; then
|
||||
port_is_listening "$_host" "$_port"
|
||||
_port_rc=$?
|
||||
if [[ "$_port_rc" -eq 1 ]]; then
|
||||
echo_ok
|
||||
else
|
||||
echo_failed
|
||||
@@ -554,6 +554,7 @@ load_srs_config() {
|
||||
echononl "Read SRS configuration '${SRS_CONF_FILE}'.."
|
||||
|
||||
unset _SRS_DOMAIN
|
||||
unset _MX_HOST
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$SRS_CONF_FILE"
|
||||
@@ -573,6 +574,15 @@ load_srs_config() {
|
||||
fatal "Invalid SRS domain '${_SRS_DOMAIN}'."
|
||||
fi
|
||||
|
||||
# Legacy installations may lack _MX_HOST. It is used by the installer
|
||||
# only; activate/deactivate must remain compatible with older srs.conf files.
|
||||
if [[ -n "${_MX_HOST+x}" ]] &&
|
||||
{ [[ ! "$_MX_HOST" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] ||
|
||||
(( ${#_MX_HOST} > 253 )); }; then
|
||||
echo_failed
|
||||
fatal "Invalid public MX hostname '${_MX_HOST}' in '${SRS_CONF_FILE}'."
|
||||
fi
|
||||
|
||||
SRS_CONFIG_LOADED=true
|
||||
echo_ok
|
||||
}
|
||||
@@ -45,7 +45,9 @@ declare -a CREATED_CONFIG_DIRS=()
|
||||
# during rollback: nonempty directories could contain mail or logs.
|
||||
declare -a NEW_POSTFIX_RUNTIME_DIRS=()
|
||||
SELECTED_SRS_DOMAIN=''
|
||||
SELECTED_MX_HOST=''
|
||||
CONFIG_DOMAIN_HINT=''
|
||||
CONFIG_MX_HINT=''
|
||||
CMAKE_PACKAGE_REQUIRED=false
|
||||
APT_CMAKE_SIMULATION=''
|
||||
|
||||
@@ -172,10 +174,10 @@ Usage: install-update-srs.sh --check
|
||||
install-update-srs.sh --install [--version X.Y.Z]
|
||||
install-update-srs.sh --help
|
||||
|
||||
--check Ask for SRS domain, then read-only inventory; no lock files,
|
||||
--check Ask for public MX host and SRS domain, then read-only inventory; no lock files,
|
||||
package installation,
|
||||
Postfix operations, service changes or filesystem writes.
|
||||
--install Ask for SRS domain first, run all read-only preflight checks,
|
||||
--install Ask for public MX host and SRS domain, run read-only preflight checks,
|
||||
then request approval before installing missing Debian CMake
|
||||
build packages (only for version builds) and preparing installation.
|
||||
Supports inactive integration updates even if PostSRSd is current;
|
||||
@@ -210,50 +212,88 @@ done
|
||||
|
||||
NEW_PREFIX="/usr/local/postsrsd-${POSTSRSD_VERSION}"
|
||||
|
||||
# A local configuration may provide a *display-only* prompt default.
|
||||
# Do not source shell code before checking its root ownership/permissions.
|
||||
read_config_domain_hint() {
|
||||
local line candidate
|
||||
# Existing config values are only used as display hints before the full
|
||||
# root-owned, non-group-writable config is validated by load_srs_config().
|
||||
# Never source local configuration merely to display a prompt default.
|
||||
read_config_domain_hints() {
|
||||
local line candidate name
|
||||
CONFIG_DOMAIN_HINT=''
|
||||
CONFIG_MX_HINT=''
|
||||
[[ -f "$SRS_CONF_FILE" && -r "$SRS_CONF_FILE" ]] || return 0
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
[[ "$line" =~ ^[[:space:]]*_SRS_DOMAIN[[:space:]]*= ]] || continue
|
||||
if [[ "$line" =~ ^[[:space:]]*_SRS_DOMAIN[[:space:]]*= ]]; then
|
||||
name='srs'
|
||||
elif [[ "$line" =~ ^[[:space:]]*_MX_HOST[[:space:]]*= ]]; then
|
||||
name='mx'
|
||||
else
|
||||
continue
|
||||
fi
|
||||
candidate="${line#*=}"
|
||||
candidate="${candidate%%#*}"
|
||||
candidate="${candidate//[[:space:]]/}"
|
||||
candidate="${candidate//\"/}"
|
||||
candidate="${candidate//\'/}"
|
||||
if [[ "$candidate" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]]; then
|
||||
CONFIG_DOMAIN_HINT="${candidate,,}"
|
||||
if [[ "$candidate" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] &&
|
||||
(( ${#candidate} <= 253 )); then
|
||||
if [[ "$name" == 'mx' ]]; then
|
||||
CONFIG_MX_HINT="${candidate,,}"
|
||||
else
|
||||
CONFIG_DOMAIN_HINT="${candidate,,}"
|
||||
fi
|
||||
fi
|
||||
done < "$SRS_CONF_FILE"
|
||||
}
|
||||
|
||||
# First user interaction, before any installation/prerequisite checks.
|
||||
# The shared Git script never has a server-specific domain hardcoded.
|
||||
# An existing configured SRS domain is never derived again from a new MX host.
|
||||
prompt_srs_domain() {
|
||||
local answer='' host_fqdn=''
|
||||
read_config_domain_hint
|
||||
local answer='' host_fqdn='' mx_default='' srs_default=''
|
||||
read_config_domain_hints
|
||||
|
||||
# For a first installation, suggest srs.<hostname -f>.
|
||||
# A configured SRS domain always takes precedence.
|
||||
if [[ -z "$CONFIG_DOMAIN_HINT" ]]; then
|
||||
host_fqdn="$(hostname -f 2>/dev/null || true)"
|
||||
host_fqdn="${host_fqdn,,}"
|
||||
if [[ "$host_fqdn" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?[.])+[a-z]{2,63}$ ]] &&
|
||||
(( ${#host_fqdn} <= 249 )); then
|
||||
CONFIG_DOMAIN_HINT="srs.${host_fqdn}"
|
||||
fi
|
||||
host_fqdn="$(hostname -f 2>/dev/null || true)"
|
||||
host_fqdn="${host_fqdn,,}"
|
||||
if [[ "$host_fqdn" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]] &&
|
||||
(( ${#host_fqdn} <= 253 )); then
|
||||
mx_default="${CONFIG_MX_HINT:-$host_fqdn}"
|
||||
else
|
||||
mx_default="$CONFIG_MX_HINT"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo 'Selective SRS - domain selection'
|
||||
echo
|
||||
echo " Host..............................: $(hostname -f 2>/dev/null || hostname)"
|
||||
echo ' Enter the envelope sender domain for SRS addresses.'
|
||||
echo " System hostname....................: ${host_fqdn:-unknown}"
|
||||
echo ' Enter the public mail exchanger and SRS envelope sender domain.'
|
||||
echo
|
||||
while true; do
|
||||
if [[ -n "$CONFIG_DOMAIN_HINT" ]]; then
|
||||
printf ' SRS domain [%s]: ' "$CONFIG_DOMAIN_HINT" >&2
|
||||
if [[ -n "$mx_default" ]]; then
|
||||
printf ' Public MX hostname [%s]: ' "$mx_default" >&2
|
||||
else
|
||||
printf ' Public MX hostname: ' >&2
|
||||
fi
|
||||
if ! IFS= read -r answer; then
|
||||
echo 'Input ended before an MX hostname was selected.' >&2
|
||||
exit 2
|
||||
fi
|
||||
[[ -n "$answer" ]] || answer="$mx_default"
|
||||
answer="${answer,,}"
|
||||
if [[ "$answer" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]] &&
|
||||
(( ${#answer} <= 253 )); then
|
||||
SELECTED_MX_HOST="$answer"
|
||||
break
|
||||
fi
|
||||
echo ' [ invalid ] Enter a public FQDN, e.g. mail.example.org.' >&2
|
||||
done
|
||||
echo " Selected MX hostname..............: $SELECTED_MX_HOST"
|
||||
echo
|
||||
|
||||
srs_default="$CONFIG_DOMAIN_HINT"
|
||||
if [[ -z "$srs_default" ]] && (( ${#SELECTED_MX_HOST} <= 249 )); then
|
||||
srs_default="srs.${SELECTED_MX_HOST}"
|
||||
fi
|
||||
while true; do
|
||||
if [[ -n "$srs_default" ]]; then
|
||||
printf ' SRS domain [%s]: ' "$srs_default" >&2
|
||||
else
|
||||
printf ' SRS domain: ' >&2
|
||||
fi
|
||||
@@ -261,13 +301,14 @@ prompt_srs_domain() {
|
||||
echo 'Input ended before an SRS domain was selected.' >&2
|
||||
exit 2
|
||||
fi
|
||||
[[ -n "$answer" ]] || answer="$CONFIG_DOMAIN_HINT"
|
||||
[[ -n "$answer" ]] || answer="$srs_default"
|
||||
answer="${answer,,}"
|
||||
if [[ "$answer" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]]; then
|
||||
if [[ "$answer" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]] &&
|
||||
(( ${#answer} <= 253 )); then
|
||||
SELECTED_SRS_DOMAIN="$answer"
|
||||
break
|
||||
fi
|
||||
echo ' [ invalid ] Enter a full DNS domain, e.g. srs.example.org.' >&2
|
||||
echo ' [ invalid ] Enter a full DNS domain, e.g. srs.mail.example.org.' >&2
|
||||
done
|
||||
echo " Selected SRS domain...............: $SELECTED_SRS_DOMAIN"
|
||||
echo
|
||||
@@ -299,7 +340,7 @@ ask_installer_confirmation() {
|
||||
done
|
||||
}
|
||||
|
||||
# Ask for the domain before --check and --install inspect prerequisites.
|
||||
# Ask for MX host and SRS domain before --check and --install inspect prerequisites.
|
||||
prompt_srs_domain
|
||||
|
||||
check_templates() {
|
||||
@@ -341,14 +382,13 @@ verify_srs_domain_dns() {
|
||||
# from interrupting the formatted DNS report. Any unexpected diagnostic
|
||||
# remains blocking: only existing unused main.cf parameters are classified
|
||||
# as unrelated/nonblocking. Deduplicate warnings repeated by postconf.
|
||||
python3 - "$SRS_DNS_CHECKER" "$SELECTED_SRS_DOMAIN" <<'PYDNS'
|
||||
python3 - "$SRS_DNS_CHECKER" "$SELECTED_SRS_DOMAIN" "$SELECTED_MX_HOST" <<'PYDNS'
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
checker, domain = sys.argv[1:]
|
||||
checker, domain, mx_host = sys.argv[1:]
|
||||
parameters = (
|
||||
('myhostname', '--hostname'),
|
||||
('smtp_bind_address', '--ipv4'),
|
||||
('smtp_bind_address6', '--ipv6'),
|
||||
)
|
||||
@@ -391,7 +431,7 @@ if blocking:
|
||||
print(' Public SRS DNS preflight...........: NOT RUN')
|
||||
result_code = 1
|
||||
else:
|
||||
args = ['python3', checker, '--domain', domain]
|
||||
args = ['python3', checker, '--domain', domain, '--hostname', mx_host]
|
||||
for _name, option in parameters:
|
||||
args.extend([option, values[option]])
|
||||
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
||||
@@ -443,6 +483,7 @@ read_only_check() {
|
||||
echo 'Selective SRS installer - read-only inventory'
|
||||
echo " Host..............................: $(hostname -f)"
|
||||
echo " Requested PostSRSd version........: ${POSTSRSD_VERSION}"
|
||||
echo " Selected MX hostname..............: ${SELECTED_MX_HOST}"
|
||||
echo " Selected SRS domain...............: ${SELECTED_SRS_DOMAIN}"
|
||||
echo " Repository........................: ${working_dir}"
|
||||
echo
|
||||
@@ -495,6 +536,11 @@ read_only_check() {
|
||||
elif [[ -n "$CONFIG_DOMAIN_HINT" && "$CONFIG_DOMAIN_HINT" != "$SELECTED_SRS_DOMAIN" ]]; then
|
||||
printf ' [ conflict ] Existing config domain: %s\n' "$CONFIG_DOMAIN_HINT"
|
||||
fi
|
||||
if [[ -f "$SRS_CONF_FILE" && -z "$CONFIG_MX_HINT" ]]; then
|
||||
printf ' [ legacy ] Existing config has no _MX_HOST; maintenance-update will add it.\n'
|
||||
elif [[ -n "$CONFIG_MX_HINT" && "$CONFIG_MX_HINT" != "$SELECTED_MX_HOST" ]]; then
|
||||
printf ' [ conflict ] Existing MX hostname: %s\n' "$CONFIG_MX_HINT"
|
||||
fi
|
||||
echo
|
||||
for unit in "$POSTSRSD_SERVICE" "$SRS_REVERSE_MAP_SERVICE"; do
|
||||
systemctl cat "$unit" >/dev/null 2>&1 && printf ' [ present ] %s\n' "$unit" || printf ' [ absent ] %s\n' "$unit"
|
||||
@@ -636,10 +682,14 @@ if [[ -f "$SRS_CONF_FILE" ]]; then
|
||||
_SRS_DOMAIN="${_SRS_DOMAIN,,}"
|
||||
[[ "$_SRS_DOMAIN" == "$SELECTED_SRS_DOMAIN" ]] ||
|
||||
fatal "Chosen domain '$SELECTED_SRS_DOMAIN' conflicts with existing '$SRS_CONF_FILE' ($_SRS_DOMAIN). Domain migration is not automatic."
|
||||
if [[ -n "${_MX_HOST:-}" && "${_MX_HOST,,}" != "$SELECTED_MX_HOST" ]]; then
|
||||
fatal "Chosen MX host '$SELECTED_MX_HOST' conflicts with existing '$SRS_CONF_FILE' ($_MX_HOST). Update the configuration intentionally; migration is not automatic."
|
||||
fi
|
||||
else
|
||||
[[ ! -e "$SRS_CONF_FILE" && ! -L "$SRS_CONF_FILE" ]] ||
|
||||
fatal "SRS configuration path '$SRS_CONF_FILE' is not a regular file."
|
||||
_SRS_DOMAIN="$SELECTED_SRS_DOMAIN"
|
||||
_MX_HOST="$SELECTED_MX_HOST"
|
||||
fi
|
||||
|
||||
fatal_rollback() {
|
||||
@@ -981,6 +1031,7 @@ fi
|
||||
blank_line
|
||||
echo " Mode..............................: ${INSTALL_MODE}"
|
||||
echo " Host..............................: $(hostname -f)"
|
||||
echo " Public MX hostname................: ${SELECTED_MX_HOST}"
|
||||
echo " SRS domain........................: ${_SRS_DOMAIN}"
|
||||
echo " Current version path..............: ${PREVIOUS_LINK:-absent}"
|
||||
echo " Target version path...............: ${NEW_PREFIX}"
|
||||
@@ -1062,10 +1113,24 @@ else
|
||||
fi
|
||||
|
||||
render_templates "${STAGE_DIR}/rendered" || fatal_rollback 'Cannot render SRS configuration templates.'
|
||||
# Newly installed sites record both names. For older sites without _MX_HOST,
|
||||
# stage an append-only compatibility migration, preserving all existing content.
|
||||
# The installed file is touched only after the deployment confirmation, and
|
||||
# snapshot_file provides a rollback of the original configuration.
|
||||
if [[ ! -f "$SRS_CONF_FILE" ]]; then
|
||||
printf '# Local SRS domain - created by install-update-srs.sh; do not commit.\n_SRS_DOMAIN="%s"\n' \
|
||||
"$SELECTED_SRS_DOMAIN" > "${STAGE_DIR}/rendered/srs.conf" ||
|
||||
printf '# Local SRS configuration - created by install-update-srs.sh; do not commit.\n_MX_HOST="%s"\n_SRS_DOMAIN="%s"\n' \
|
||||
"$SELECTED_MX_HOST" "$SELECTED_SRS_DOMAIN" > "${STAGE_DIR}/rendered/srs.conf" ||
|
||||
fatal_rollback 'Cannot prepare local conf/srs.conf.'
|
||||
elif [[ -z "${_MX_HOST+x}" ]]; then
|
||||
python3 - "$SRS_CONF_FILE" "${STAGE_DIR}/rendered/srs.conf" "$SELECTED_MX_HOST" <<'PY_MX_MIGRATE' || fatal_rollback 'Cannot stage legacy SRS configuration migration.'
|
||||
import pathlib, re, sys
|
||||
source, destination, mx_host = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), sys.argv[3]
|
||||
content = source.read_bytes()
|
||||
if re.search(rb'(?m)^\s*_MX_HOST\s*=', content):
|
||||
raise SystemExit('Legacy config contains unexpected _MX_HOST assignment')
|
||||
separator = b'' if not content or content.endswith(b'\n') else b'\n'
|
||||
destination.write_bytes(content + separator + b'\n# Public mail exchanger for SRS return messages\n_MX_HOST="' + mx_host.encode('ascii') + b'"\n')
|
||||
PY_MX_MIGRATE
|
||||
fi
|
||||
cp "$SRS_TEMPLATE_DIR/delivery-master.cf" "${STAGE_DIR}/rendered/delivery-master.cf" || fatal_rollback 'Cannot stage delivery master.cf.'
|
||||
|
||||
@@ -1091,6 +1156,9 @@ CHANGES_STARTED=true
|
||||
trap 'on_unexpected_exit "$?"' EXIT
|
||||
if [[ ! -f "$SRS_CONF_FILE" ]]; then
|
||||
install_regular_file "${STAGE_DIR}/rendered/srs.conf" "$SRS_CONF_FILE" 0644
|
||||
elif [[ -z "${_MX_HOST+x}" ]]; then
|
||||
# Preserve the previous permissions for migrated legacy configs.
|
||||
install_regular_file "${STAGE_DIR}/rendered/srs.conf" "$SRS_CONF_FILE" "$(stat -c '%a' "$SRS_CONF_FILE")"
|
||||
fi
|
||||
# Keep the secret directory private, as on mx.warenform.de. An existing
|
||||
# directory with weaker permissions must be reviewed manually, not silently
|
||||
@@ -1158,7 +1226,8 @@ CHANGES_STARTED=false
|
||||
trap - EXIT SIGHUP SIGINT SIGTERM
|
||||
|
||||
blank_line
|
||||
info "SRS ${INSTALL_MODE} completed (PostSRSd ${POSTSRSD_VERSION}). Backup: ${BACKUP_DIR}"
|
||||
info "SRS ${INSTALL_MODE} completed (PostSRSd ${POSTSRSD_VERSION})."
|
||||
printf ' Backup: %s\n' "$BACKUP_DIR"
|
||||
info 'Selective SRS is NOT activated by this installer.'
|
||||
info "To activate after review, use: ${working_dir}/activate-srs.sh"
|
||||
clean_up 0
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Isolated status, preflight, snapshot and rollback tests for activate-srs.sh.
|
||||
|
||||
All systemctl and ss operations are mocked. No real services or Postfix
|
||||
configuration are modified. Run from the repository root:
|
||||
python3 tests/test_activation_status_mock.py
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1] / 'activate-srs.sh'
|
||||
TEXT = SOURCE.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def function(name):
|
||||
match = re.search(rf'^{re.escape(name)}\(\) \{{\n.*?^\}}', TEXT, re.M | re.S)
|
||||
if not match:
|
||||
raise ValueError(f'Missing function {name}')
|
||||
return match.group(0)
|
||||
|
||||
|
||||
FUNCTIONS = '\n'.join(function(n) for n in (
|
||||
'master_srs_forward_present', 'port_is_listening', 'service_exists',
|
||||
'read_srs_service_state', 'service_active', 'service_enabled_state',
|
||||
'snapshot_service_states', 'restore_service_states', 'rollback_activation',
|
||||
))
|
||||
|
||||
|
||||
def section(start, end):
|
||||
return TEXT[TEXT.index(start):TEXT.index(end, TEXT.index(start))]
|
||||
|
||||
|
||||
PREFLIGHT = section('services_exist=true\n', '# ----------\n# Determine main.cf state')
|
||||
CLASSIFICATION = section('srs_integration_enabled=false\n', '# ----------\n# Summary')
|
||||
|
||||
|
||||
class ActivationMockTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory(prefix='srs-activation-status-')
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = Path(tmp.name)
|
||||
self.master = self.root / 'master.cf'
|
||||
self.master.write_text('smtp inet n - y - - smtpd\n')
|
||||
self.calls = self.root / 'calls'
|
||||
self.log = self.root / 'log'
|
||||
self.drop1 = self.root / 'postfix.srs.conf'
|
||||
self.drop2 = self.root / 'delivery.srs.conf'
|
||||
|
||||
def run_shell(self, commands, *, extra='', include_funcs=True):
|
||||
sh = f'''#!/bin/bash
|
||||
set -u
|
||||
POSTFIX_MASTER_CF={str(self.master)!r}
|
||||
POSTFIX_SRS_DROPIN_FILE={str(self.drop1)!r}
|
||||
SRS_DELIVERY_DROPIN_FILE={str(self.drop2)!r}
|
||||
POSTSRSD_SERVICE=postsrsd-srs.service
|
||||
SRS_REVERSE_MAP_SERVICE=srs-reverse-map.service
|
||||
SRS_DELIVERY_SERVICE=postfix@srs-delivery.service
|
||||
POSTFIX_SERVICE=postfix.service
|
||||
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
|
||||
CALLS={str(self.calls)!r}
|
||||
log_file={str(self.log)!r}
|
||||
ACTIVATION_CHANGES_STARTED=true
|
||||
ACTIVATION_BACKUP_DIR={str(self.root)!r}
|
||||
declare -A SERVICE_WAS_ACTIVE=() SERVICE_WAS_ENABLED=()
|
||||
blank_line() {{ :; }}
|
||||
warn() {{ :; }}
|
||||
error() {{ :; }}
|
||||
info() {{ :; }}
|
||||
echononl() {{ :; }}
|
||||
echo_ok() {{ :; }}
|
||||
echo_failed() {{ :; }}
|
||||
postfix() {{ return 0; }}
|
||||
restore_systemd_dropins() {{ return 0; }}
|
||||
systemctl() {{
|
||||
printf 'systemctl %s\\n' "$*" >> "$CALLS"
|
||||
case "$1" in
|
||||
cat) return 0 ;;
|
||||
show)
|
||||
[[ "${{SHOW_FAIL_UNIT:-}}" == "$2" ]] && return 4
|
||||
if [[ "${{BAD_STATE_UNIT:-}}" == "$2" ]]; then
|
||||
printf 'LoadState=loaded\\nActiveState=failed\\nUnitFileState=enabled\\n'
|
||||
elif [[ "${{ACTIVE_UNIT:-}}" == "$2" ]]; then
|
||||
printf 'LoadState=loaded\\nActiveState=active\\nUnitFileState=enabled\\n'
|
||||
elif [[ "${{ENABLED_UNIT:-}}" == "$2" ]]; then
|
||||
printf 'LoadState=loaded\\nActiveState=inactive\\nUnitFileState=enabled\\n'
|
||||
else
|
||||
printf '%s\\n' "${{SHOW_DATA:-$'LoadState=loaded\\nActiveState=inactive\\nUnitFileState=disabled'}}"
|
||||
fi ;;
|
||||
is-active) [[ "${{MAIN_INACTIVE:-0}}" != 1 ]] ;;
|
||||
enable|disable|stop|start|reload)
|
||||
[[ "${{FAIL_ACTION:-}}" != "$1" ]] ;;
|
||||
*) return 88 ;;
|
||||
esac
|
||||
}}
|
||||
ss() {{
|
||||
[[ "${{SS_FAIL:-0}}" == 1 ]] && return 7
|
||||
printf '%s\\n' "${{SS_DATA:-}}"
|
||||
}}
|
||||
{FUNCTIONS if include_funcs else ''}
|
||||
{extra}
|
||||
{commands}
|
||||
'''
|
||||
return subprocess.run(['bash','-c',sh],capture_output=True,text=True,timeout=10)
|
||||
|
||||
def check(self, commands, extra=''):
|
||||
result = self.run_shell(commands, extra=extra)
|
||||
self.assertEqual(result.returncode, 0, f'{result.stdout}\n{result.stderr}')
|
||||
return result
|
||||
|
||||
def test_valid_disabled_preflight(self):
|
||||
self.check(PREFLIGHT + '''
|
||||
[[ "$services_disabled" == true && "$listeners_absent" == true && "$dropins_absent" == true && "$services_status_known" == true && "$primary_postfix_active" == true ]]
|
||||
''')
|
||||
|
||||
def test_active_srs_service_is_not_disabled(self):
|
||||
self.check(PREFLIGHT + '[[ "$services_disabled" == false ]]',
|
||||
extra='ACTIVE_UNIT=postsrsd-srs.service')
|
||||
|
||||
def test_enabled_but_inactive_srs_service_is_not_disabled(self):
|
||||
self.check(PREFLIGHT + '[[ "$services_disabled" == false ]]',
|
||||
extra='ENABLED_UNIT=postsrsd-srs.service')
|
||||
|
||||
def test_systemctl_query_error_marks_unknown(self):
|
||||
self.check(PREFLIGHT + '[[ "$services_status_known" == false && "$services_disabled" == false ]]',
|
||||
extra='SHOW_FAIL_UNIT=srs-reverse-map.service')
|
||||
|
||||
def test_transient_systemctl_state_marks_unknown(self):
|
||||
self.check(PREFLIGHT + '[[ "$services_status_known" == false ]]',
|
||||
extra='BAD_STATE_UNIT=postsrsd-srs.service')
|
||||
|
||||
def test_active_port_not_disabled(self):
|
||||
self.check(PREFLIGHT + '[[ "$listeners_absent" == false ]]',
|
||||
extra="SS_DATA='LISTEN 0 5 127.0.0.1:10003 0.0.0.0:*'")
|
||||
|
||||
def test_ss_failure_marks_unknown(self):
|
||||
self.check(PREFLIGHT + '[[ "$listeners_status_known" == false && "$listeners_absent" == false ]]',
|
||||
extra='SS_FAIL=1')
|
||||
|
||||
def test_main_postfix_inactive_is_not_ready(self):
|
||||
self.check(PREFLIGHT + '[[ "$primary_postfix_active" == false ]]',
|
||||
extra='MAIN_INACTIVE=1')
|
||||
|
||||
def test_stray_dropin_not_disabled(self):
|
||||
self.drop1.write_text('[Unit]\n')
|
||||
self.check(PREFLIGHT + '[[ "$dropins_absent" == false ]]')
|
||||
|
||||
def test_broken_symlink_dropin_not_disabled(self):
|
||||
self.drop1.symlink_to(self.root/'not-existing')
|
||||
self.check(PREFLIGHT + '[[ "$dropins_absent" == false ]]')
|
||||
|
||||
def test_master_cf_unreadable_is_query_error(self):
|
||||
self.master.unlink()
|
||||
self.check('master_srs_forward_present; [[ $? -eq 2 ]]')
|
||||
|
||||
def test_master_cf_absent_and_present(self):
|
||||
self.check('master_srs_forward_present; [[ $? -eq 1 ]]')
|
||||
self.master.write_text('srs-forward unix - n n - - pipe\n argv=/usr/local/sbin/srs-forward.py ${sender}\n')
|
||||
self.check('master_srs_forward_present; [[ $? -eq 0 ]]')
|
||||
|
||||
def test_atomic_snapshot_disabled(self):
|
||||
self.check('''snapshot_service_states || exit 1
|
||||
for unit in "$POSTSRSD_SERVICE" "$SRS_REVERSE_MAP_SERVICE" "$SRS_DELIVERY_SERVICE"; do
|
||||
[[ "${SERVICE_WAS_ACTIVE[$unit]}" == false ]] || exit 2
|
||||
[[ "${SERVICE_WAS_ENABLED[$unit]}" == disabled ]] || exit 3
|
||||
done''')
|
||||
self.check('''SHOW_FAIL_UNIT=srs-reverse-map.service
|
||||
snapshot_service_states; [[ $? -eq 1 ]] || exit 1
|
||||
[[ "${#SERVICE_WAS_ACTIVE[@]}" -eq 0 ]] || exit 2''')
|
||||
|
||||
def test_snapshot_rejects_changed_state(self):
|
||||
self.check('''ACTIVE_UNIT=postsrsd-srs.service
|
||||
snapshot_service_states; [[ $? -eq 1 ]] || exit 1
|
||||
[[ "${#SERVICE_WAS_ENABLED[@]}" -eq 0 ]] || exit 2''')
|
||||
|
||||
def test_restore_rejects_invalid_snapshot_without_actions(self):
|
||||
self.check('''for unit in "$POSTSRSD_SERVICE" "$SRS_REVERSE_MAP_SERVICE" "$SRS_DELIVERY_SERVICE"; do
|
||||
SERVICE_WAS_ACTIVE[$unit]=false
|
||||
SERVICE_WAS_ENABLED[$unit]=disabled
|
||||
done
|
||||
SERVICE_WAS_ENABLED[$SRS_REVERSE_MAP_SERVICE]=masked
|
||||
restore_service_states; [[ $? -eq 1 ]]''')
|
||||
self.assertFalse(self.calls.exists() and any(
|
||||
part in self.calls.read_text() for part in ('systemctl start ', 'systemctl stop ', 'systemctl enable ', 'systemctl disable ')
|
||||
))
|
||||
|
||||
def test_restore_respects_enabled_runtime(self):
|
||||
self.check('''for unit in "$POSTSRSD_SERVICE" "$SRS_REVERSE_MAP_SERVICE" "$SRS_DELIVERY_SERVICE"; do
|
||||
SERVICE_WAS_ACTIVE[$unit]=false
|
||||
SERVICE_WAS_ENABLED[$unit]=disabled
|
||||
done
|
||||
SERVICE_WAS_ACTIVE[$POSTSRSD_SERVICE]=true
|
||||
SERVICE_WAS_ENABLED[$POSTSRSD_SERVICE]=enabled-runtime
|
||||
restore_service_states''')
|
||||
self.assertIn('systemctl enable --runtime postsrsd-srs.service',self.calls.read_text())
|
||||
|
||||
def test_rollback_inactive_primary_postfix_is_failure(self):
|
||||
(self.root/'main.cf').write_text('before\n')
|
||||
(self.root/'master.cf').write_text('before\n')
|
||||
self.check('''POSTFIX_MAIN_CF=${ACTIVATION_BACKUP_DIR}/working-main.cf
|
||||
POSTFIX_MASTER_CF=${ACTIVATION_BACKUP_DIR}/working-master.cf
|
||||
for unit in "$POSTSRSD_SERVICE" "$SRS_REVERSE_MAP_SERVICE" "$SRS_DELIVERY_SERVICE"; do
|
||||
SERVICE_WAS_ACTIVE[$unit]=false
|
||||
SERVICE_WAS_ENABLED[$unit]=disabled
|
||||
done
|
||||
MAIN_INACTIVE=1
|
||||
rollback_activation; [[ $? -eq 1 ]]''')
|
||||
self.assertNotIn('systemctl reload postfix.service',self.calls.read_text())
|
||||
|
||||
def test_enabled_classification_still_works(self):
|
||||
baseline = '''main_block_status=present
|
||||
master_block_status=present
|
||||
master_srs_forward_known=true
|
||||
main_srs_values_ok=true
|
||||
master_srs_forward_ok=true
|
||||
smtp_override_ok=true
|
||||
amavis_override_ok=true
|
||||
pre_srs_main_ok=false
|
||||
pre_srs_master_ok=false
|
||||
installation_ok=true
|
||||
services_exist=true
|
||||
services_status_known=true
|
||||
services_disabled=false
|
||||
services_enabled=true
|
||||
services_active=true
|
||||
primary_postfix_active=true
|
||||
listeners_status_known=true
|
||||
listeners_absent=false
|
||||
listeners_ok=true
|
||||
dropins_absent=false
|
||||
'''
|
||||
self.check(CLASSIFICATION + '[[ "$srs_state" == enabled ]]', extra=baseline)
|
||||
|
||||
def test_disabled_classification_requires_every_prerequisite(self):
|
||||
baseline = '''main_block_status=absent
|
||||
master_block_status=absent
|
||||
master_srs_forward_known=true
|
||||
main_srs_values_ok=false
|
||||
master_srs_forward_ok=false
|
||||
smtp_override_ok=false
|
||||
amavis_override_ok=false
|
||||
pre_srs_main_ok=true
|
||||
pre_srs_master_ok=true
|
||||
installation_ok=true
|
||||
services_exist=true
|
||||
services_status_known=true
|
||||
services_disabled=true
|
||||
services_enabled=false
|
||||
services_active=false
|
||||
primary_postfix_active=true
|
||||
listeners_status_known=true
|
||||
listeners_absent=true
|
||||
listeners_ok=false
|
||||
dropins_absent=true
|
||||
'''
|
||||
self.check(CLASSIFICATION+'[[ "$srs_state" == disabled ]]',extra=baseline)
|
||||
for name in ('services_status_known','services_disabled','primary_postfix_active',
|
||||
'listeners_status_known','listeners_absent','dropins_absent',
|
||||
'master_srs_forward_known'):
|
||||
with self.subTest(name=name):
|
||||
self.check(CLASSIFICATION+'[[ "$srs_state" == inconsistent ]]',
|
||||
extra=baseline+f'{name}=false\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Isolated byte-exact managed-block removal tests; no Postfix services used.
|
||||
|
||||
Run from repository: python3 tests/test_deactivation_managed_block_mock.py
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1] / "deactivate-srs.sh"
|
||||
NAME = "remove_managed_block"
|
||||
BEGIN = "# BEGIN OOPEN-SRS-MANAGED"
|
||||
END = "# END OOPEN-SRS-MANAGED"
|
||||
BLOCK = "\n" + BEGIN + "\n# managed block\n" + END + "\n"
|
||||
|
||||
|
||||
def extract_function():
|
||||
source = SOURCE.read_text(encoding="utf-8")
|
||||
m = re.search(rf"^{NAME}\(\) \{{\n.*?^\}}", source, re.M | re.S)
|
||||
if not m:
|
||||
raise RuntimeError(f"Missing {NAME}() in {SOURCE}")
|
||||
return m.group(0)
|
||||
|
||||
|
||||
class ManagedBlockMockTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.function = extract_function()
|
||||
|
||||
def run_remove(self, data, begin=BEGIN, end=END):
|
||||
with tempfile.TemporaryDirectory(prefix="srs-managed-block-") as temp:
|
||||
path = Path(temp) / "main.cf"
|
||||
path.write_bytes(data)
|
||||
# Call the real Bash function, which executes its embedded Python.
|
||||
script = (self.function + "\n" +
|
||||
'remove_managed_block "$1" "$2" "$3"\n')
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "bash", str(path), begin, end],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return result, path.read_bytes()
|
||||
|
||||
def test_exact_restoration_for_zero_to_five_existing_blank_lines(self):
|
||||
for blanks in range(6):
|
||||
with self.subTest(original_blank_lines=blanks):
|
||||
original = b"myhostname = e.mx.oopen.de\n" + b"\n" * blanks
|
||||
result, after = self.run_remove(original + BLOCK.encode())
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(after, original)
|
||||
|
||||
def test_empty_file_is_restored(self):
|
||||
result, after = self.run_remove(BLOCK.encode())
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(after, b"")
|
||||
|
||||
def test_missing_begin_marker_does_not_modify_file(self):
|
||||
original = b"myhostname = e.mx.oopen.de\n" + ("\n# wrong begin\n" + END + "\n").encode()
|
||||
result, after = self.run_remove(original)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(after, original)
|
||||
|
||||
def test_duplicate_begin_marker_does_not_modify_file(self):
|
||||
original = ("myhostname = e.mx.oopen.de\n" + BLOCK + BLOCK).encode()
|
||||
result, after = self.run_remove(original)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(after, original)
|
||||
|
||||
def test_reversed_markers_does_not_modify_file(self):
|
||||
original = (END + "\n" + BEGIN + "\n").encode()
|
||||
result, after = self.run_remove(original)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(after, original)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Isolated tests of functions extracted from deactivate-srs.sh.
|
||||
|
||||
No mail services are started/stopped and no files outside TemporaryDirectory
|
||||
are modified. Commands such as systemctl and postfix are shell mocks.
|
||||
|
||||
Run from the repository:
|
||||
python3 tests/test_deactivation_rollback_mock.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1] / "deactivate-srs.sh"
|
||||
FUNCTIONS = (
|
||||
"rollback_deactivation",
|
||||
"wait_for_srs_forward_drain",
|
||||
)
|
||||
|
||||
|
||||
def extract_functions(source):
|
||||
result = {}
|
||||
for name in FUNCTIONS:
|
||||
match = re.search(rf"^{re.escape(name)}\(\) \{{\n.*?^\}}", source, re.M | re.S)
|
||||
if not match:
|
||||
raise ValueError(f"Function {name}() not found in supplied deactivate-srs.sh")
|
||||
result[name] = match.group(0)
|
||||
return result
|
||||
|
||||
|
||||
class IsolatedShellTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.functions = extract_functions(SOURCE.read_text(encoding="utf-8"))
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="srs-rollback-mock-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.base = Path(self.tmp.name)
|
||||
self.backup = self.base / "backup"
|
||||
self.backup.mkdir()
|
||||
(self.backup / "main.cf").write_text("original main\n")
|
||||
(self.backup / "master.cf").write_text("original master\n")
|
||||
self.main = self.base / "main.cf"
|
||||
self.master = self.base / "master.cf"
|
||||
self.main.write_text("modified main\n")
|
||||
self.master.write_text("modified master\n")
|
||||
self.calls = self.base / "calls"
|
||||
self.log = self.base / "log"
|
||||
|
||||
def run_bash(self, *, function, setup="", fail="", run_args=""):
|
||||
self.assertIn(function, self.functions)
|
||||
script = f'''#!/bin/bash
|
||||
set -u
|
||||
POSTFIX_MAIN_CF={str(self.main)!r}
|
||||
POSTFIX_MASTER_CF={str(self.master)!r}
|
||||
DEACTIVATION_BACKUP_DIR={str(self.backup)!r}
|
||||
POSTFIX_SERVICE=postfix.service
|
||||
log_file={str(self.log)!r}
|
||||
CALLS={str(self.calls)!r}
|
||||
DEACTIVATION_CHANGES_STARTED=true
|
||||
blank_line() {{ :; }}
|
||||
warn() {{ :; }}
|
||||
error() {{ :; }}
|
||||
info() {{ :; }}
|
||||
echononl() {{ :; }}
|
||||
echo_ok() {{ :; }}
|
||||
echo_failed() {{ :; }}
|
||||
cp() {{
|
||||
printf 'cp %s\\n' "${{@: -1}}" >> "$CALLS"
|
||||
if [[ "${{FAIL_CP_MAIN:-0}}" == 1 && "${{@: -1}}" == "$POSTFIX_MAIN_CF" ]]; then return 1; fi
|
||||
command cp "$@"
|
||||
}}
|
||||
restore_systemd_dropins() {{
|
||||
echo restore-dropins >> "$CALLS"
|
||||
[[ "${{FAIL_DROPINS:-0}}" != 1 ]]
|
||||
}}
|
||||
restore_service_states() {{
|
||||
echo restore-services >> "$CALLS"
|
||||
[[ "${{FAIL_SERVICES:-0}}" != 1 ]]
|
||||
}}
|
||||
postfix() {{
|
||||
printf 'postfix %s\\n' "$*" >> "$CALLS"
|
||||
[[ "${{FAIL_POSTFIX_CHECK:-0}}" != 1 ]]
|
||||
}}
|
||||
systemctl() {{
|
||||
printf 'systemctl %s\\n' "$*" >> "$CALLS"
|
||||
case "$1" in
|
||||
is-active) [[ "${{MAIN_INACTIVE:-0}}" != 1 ]] ;;
|
||||
reload) [[ "${{FAIL_RELOAD:-0}}" != 1 ]] ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}}
|
||||
srs_old_queue_manager_is_running() {{ return "${{QMGR_RC:-1}}"; }}
|
||||
srs_forward_worker_is_running() {{ return "${{WORKER_RC:-1}}"; }}
|
||||
srs_delivery_queue_is_empty() {{ return "${{QUEUE_RC:-0}}"; }}
|
||||
sleep() {{ SECONDS=$((SECONDS + 181)); }}
|
||||
{self.functions[function]}
|
||||
{setup}
|
||||
{fail}
|
||||
{function} {run_args}
|
||||
'''
|
||||
return subprocess.run(["bash", "-c", script], capture_output=True, text=True,
|
||||
timeout=10)
|
||||
|
||||
def test_rollback_restores_config_and_reloads(self):
|
||||
result = self.run_bash(function="rollback_deactivation")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(self.main.read_text(), "original main\n")
|
||||
self.assertEqual(self.master.read_text(), "original master\n")
|
||||
calls = self.calls.read_text()
|
||||
for expected in ("restore-dropins", "restore-services", "postfix check",
|
||||
"systemctl reload postfix.service"):
|
||||
self.assertIn(expected, calls)
|
||||
|
||||
def test_rollback_cp_error_is_reported(self):
|
||||
result = self.run_bash(function="rollback_deactivation", fail="FAIL_CP_MAIN=1")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.main.read_text(), "modified main\n")
|
||||
self.assertEqual(self.master.read_text(), "original master\n")
|
||||
|
||||
def test_rollback_dropin_error_is_reported(self):
|
||||
self.assertNotEqual(self.run_bash(function="rollback_deactivation",
|
||||
fail="FAIL_DROPINS=1").returncode, 0)
|
||||
|
||||
def test_rollback_service_restore_error_is_reported(self):
|
||||
self.assertNotEqual(self.run_bash(function="rollback_deactivation",
|
||||
fail="FAIL_SERVICES=1").returncode, 0)
|
||||
|
||||
def test_rollback_postfix_check_error_is_reported(self):
|
||||
self.assertNotEqual(self.run_bash(function="rollback_deactivation",
|
||||
fail="FAIL_POSTFIX_CHECK=1").returncode, 0)
|
||||
|
||||
def test_rollback_reload_error_is_reported(self):
|
||||
self.assertNotEqual(self.run_bash(function="rollback_deactivation",
|
||||
fail="FAIL_RELOAD=1").returncode, 0)
|
||||
|
||||
def test_rollback_inactive_main_postfix_is_not_silently_successful(self):
|
||||
"""Safety regression: an inactive primary Postfix must not mean success."""
|
||||
result = self.run_bash(function="rollback_deactivation", fail="MAIN_INACTIVE=1")
|
||||
self.assertNotEqual(result.returncode, 0,
|
||||
"BUG: inactive primary Postfix incorrectly reported rollback success")
|
||||
self.assertNotIn("systemctl reload postfix.service", self.calls.read_text())
|
||||
|
||||
def test_drain_succeeds_when_qmgr_workers_and_queue_clear(self):
|
||||
result = self.run_bash(function="wait_for_srs_forward_drain",
|
||||
setup="sleep() { :; }")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_drain_times_out_on_running_old_qmgr(self):
|
||||
self.assertEqual(self.run_bash(function="wait_for_srs_forward_drain",
|
||||
fail="QMGR_RC=0").returncode, 1)
|
||||
|
||||
def test_drain_rejects_process_check_error(self):
|
||||
self.assertEqual(self.run_bash(function="wait_for_srs_forward_drain",
|
||||
fail="WORKER_RC=2").returncode, 2)
|
||||
|
||||
def test_drain_rejects_queue_read_error(self):
|
||||
self.assertEqual(self.run_bash(function="wait_for_srs_forward_drain",
|
||||
fail="QUEUE_RC=2").returncode, 2)
|
||||
|
||||
def test_drain_times_out_if_queue_remains_nonempty(self):
|
||||
self.assertEqual(self.run_bash(function="wait_for_srs_forward_drain",
|
||||
fail="QUEUE_RC=1").returncode, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", type=Path, default=SOURCE,
|
||||
help="Path to deactivate-srs.sh (defaults to sibling repo script)")
|
||||
args, unittest_args = parser.parse_known_args()
|
||||
SOURCE = args.source.resolve()
|
||||
if not SOURCE.is_file():
|
||||
parser.error(f"Source script not found: {SOURCE}")
|
||||
unittest.main(argv=[sys.argv[0], *unittest_args], verbosity=2)
|
||||
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Isolated fail-closed SRS service status / listener / verification tests.
|
||||
|
||||
Only extracts shell functions and read-only verification code from deactivate-srs.sh.
|
||||
All calls to systemctl and ss use mocks; no system service is modified.
|
||||
|
||||
Run from repository root:
|
||||
python3 tests/test_deactivation_status_mock.py
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1] / "deactivate-srs.sh"
|
||||
TEXT = SOURCE.read_text(encoding="utf-8")
|
||||
|
||||
FUNCTIONS = (
|
||||
"read_srs_service_state",
|
||||
"service_active",
|
||||
"service_enabled_state",
|
||||
"service_enabled",
|
||||
"snapshot_service_states",
|
||||
"restore_service_states",
|
||||
"port_is_listening",
|
||||
"master_srs_forward_present",
|
||||
)
|
||||
|
||||
|
||||
def extract_function(name):
|
||||
match = re.search(rf"^{re.escape(name)}\(\) \{{\n.*?^\}}", TEXT, re.M | re.S)
|
||||
if not match:
|
||||
raise ValueError(f"Missing shell function {name}")
|
||||
return match.group(0)
|
||||
|
||||
|
||||
FUNCTION_TEXT = "\n".join(extract_function(name) for name in FUNCTIONS)
|
||||
|
||||
|
||||
def section(start, end):
|
||||
begin = TEXT.index(start)
|
||||
finish = TEXT.index(end, begin)
|
||||
return TEXT[begin:finish]
|
||||
|
||||
|
||||
PREFLIGHT = section('services_exist=true\n', '\n\n# ----------\n# Check Postfix integration')
|
||||
FINAL = section('verification_ok=true\n', 'if ! $verification_ok ; then')
|
||||
STATE_CLASSIFICATION = section('srs_integration_enabled=false\n', '\n\n# ----------\n# Summary')
|
||||
|
||||
|
||||
class StatusMockTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="srs-status-mock-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.base = Path(self.tmp.name)
|
||||
self.master = self.base / 'master.cf'
|
||||
self.master.write_text('smtp inet n - y - - smtpd\n')
|
||||
self.calls = self.base / 'calls'
|
||||
self.log = self.base / 'log'
|
||||
|
||||
def run_shell(self, body, config='', functions=True):
|
||||
script = f'''#!/bin/bash
|
||||
set -u
|
||||
log_file={str(self.log)!r}
|
||||
CALLS={str(self.calls)!r}
|
||||
POSTFIX_MASTER_CF={str(self.master)!r}
|
||||
POSTSRSD_SERVICE=postsrsd-srs.service
|
||||
SRS_REVERSE_MAP_SERVICE=srs-reverse-map.service
|
||||
SRS_DELIVERY_SERVICE=postfix@srs-delivery.service
|
||||
declare -A SERVICE_WAS_ACTIVE=()
|
||||
declare -A SERVICE_WAS_ENABLED=()
|
||||
warn() {{ :; }}
|
||||
echononl() {{ :; }}
|
||||
echo_ok() {{ :; }}
|
||||
echo_failed() {{ :; }}
|
||||
echo_inactive() {{ :; }}
|
||||
fatal() {{ echo "FATAL: $*" >&2; exit 70; }}
|
||||
systemctl() {{
|
||||
printf 'systemctl %s\\n' "$*" >> "$CALLS"
|
||||
case "$1" in
|
||||
show)
|
||||
[[ "${{SHOW_FAIL_UNIT:-}}" == "$2" ]] && return 17
|
||||
if [[ "${{SHOW_BAD_UNIT:-}}" == "$2" ]]; then
|
||||
printf 'LoadState=not-found\\nActiveState=inactive\\nUnitFileState=disabled\\n'
|
||||
return 0
|
||||
fi
|
||||
printf '%s\\n' "${{SHOW_DATA:-$'LoadState=loaded\\nActiveState=active\\nUnitFileState=enabled'}}"
|
||||
;;
|
||||
enable|disable|start|stop)
|
||||
[[ "${{FAIL_ACTION:-}}" == "$1" ]] && return 11
|
||||
return 0
|
||||
;;
|
||||
*) return 99 ;;
|
||||
esac
|
||||
}}
|
||||
ss() {{
|
||||
[[ "${{SS_FAIL:-0}}" == 1 ]] && return 7
|
||||
printf '%s\\n' "${{SS_DATA:-}}"
|
||||
}}
|
||||
{FUNCTION_TEXT if functions else ''}
|
||||
{config}
|
||||
{body}
|
||||
'''
|
||||
return subprocess.run(['bash', '-c', script], capture_output=True,
|
||||
text=True, timeout=15)
|
||||
|
||||
def assert_exit(self, body, rc=0, config='', functions=True):
|
||||
p = self.run_shell(body, config=config, functions=functions)
|
||||
self.assertEqual(p.returncode, rc, f"stdout={p.stdout!r}\nstderr={p.stderr!r}")
|
||||
return p
|
||||
|
||||
def test_loaded_active_enabled_and_runtime_enabled(self):
|
||||
self.assert_exit('''
|
||||
read_srs_service_state postsrsd-srs.service || exit 90
|
||||
[[ "$SRS_SERVICE_ACTIVE_STATE" == active && "$SRS_SERVICE_ENABLED_STATE" == enabled ]] || exit 91
|
||||
service_active postsrsd-srs.service || exit 92
|
||||
service_enabled postsrsd-srs.service || exit 93
|
||||
''')
|
||||
self.assert_exit('''
|
||||
SHOW_DATA=$'LoadState=loaded\\nActiveState=active\\nUnitFileState=enabled-runtime'
|
||||
service_enabled postsrsd-srs.service || exit 94
|
||||
[[ "$(service_enabled_state postsrsd-srs.service)" == enabled-runtime ]] || exit 95
|
||||
''')
|
||||
|
||||
def test_inactive_disabled_is_valid_and_has_nonzero_status(self):
|
||||
self.assert_exit('''
|
||||
SHOW_DATA=$'LoadState=loaded\\nActiveState=inactive\\nUnitFileState=disabled'
|
||||
service_active postsrsd-srs.service; [[ $? -eq 1 ]] || exit 1
|
||||
service_enabled postsrsd-srs.service; [[ $? -eq 1 ]] || exit 2
|
||||
[[ "$(service_enabled_state postsrsd-srs.service)" == disabled ]] || exit 3
|
||||
''')
|
||||
|
||||
def test_systemctl_error_and_not_found_return_two(self):
|
||||
self.assert_exit('''
|
||||
SHOW_FAIL_UNIT=postsrsd-srs.service
|
||||
service_active postsrsd-srs.service; [[ $? -eq 2 ]] || exit 1
|
||||
service_enabled postsrsd-srs.service; [[ $? -eq 2 ]] || exit 2
|
||||
service_enabled_state postsrsd-srs.service >/dev/null; [[ $? -eq 2 ]] || exit 3
|
||||
unset SHOW_FAIL_UNIT
|
||||
SHOW_BAD_UNIT=postsrsd-srs.service
|
||||
read_srs_service_state postsrsd-srs.service; [[ $? -eq 2 ]] || exit 4
|
||||
''')
|
||||
|
||||
def test_invalid_and_transient_states_are_rejected(self):
|
||||
for state in [
|
||||
'LoadState=loaded\nActiveState=failed\nUnitFileState=enabled',
|
||||
'LoadState=loaded\nActiveState=activating\nUnitFileState=enabled',
|
||||
'LoadState=loaded\nActiveState=inactive\nUnitFileState=masked',
|
||||
'LoadState=loaded\nActiveState=inactive\nUnitFileState=static',
|
||||
'LoadState=loaded\nActiveState=inactive',
|
||||
'LoadState=loaded\nActiveState=inactive\nUnitFileState=disabled\nUnexpected=yes',
|
||||
]:
|
||||
with self.subTest(state=state):
|
||||
# Quote shell strings safely by passing bash's single-quoted, escaped data.
|
||||
import shlex
|
||||
self.assert_exit('''
|
||||
read_srs_service_state postsrsd-srs.service
|
||||
[[ $? -eq 2 ]]
|
||||
''', config=f'SHOW_DATA={shlex.quote(state)}\n')
|
||||
|
||||
def test_snapshot_complete_and_fail_atomic(self):
|
||||
self.assert_exit('''
|
||||
snapshot_service_states || exit 1
|
||||
for unit in "$POSTSRSD_SERVICE" "$SRS_REVERSE_MAP_SERVICE" "$SRS_DELIVERY_SERVICE"; do
|
||||
[[ "${SERVICE_WAS_ACTIVE[$unit]}" == true ]] || exit 2
|
||||
[[ "${SERVICE_WAS_ENABLED[$unit]}" == enabled ]] || exit 3
|
||||
done
|
||||
''')
|
||||
self.assert_exit('''
|
||||
SHOW_FAIL_UNIT=srs-reverse-map.service
|
||||
snapshot_service_states; [[ $? -eq 1 ]] || exit 1
|
||||
[[ "${#SERVICE_WAS_ACTIVE[@]}" -eq 0 ]] || exit 2
|
||||
[[ "${#SERVICE_WAS_ENABLED[@]}" -eq 0 ]] || exit 3
|
||||
''')
|
||||
|
||||
def test_snapshot_rejects_state_change_after_enabled_preflight(self):
|
||||
self.assert_exit('''
|
||||
SHOW_DATA=$'LoadState=loaded\nActiveState=inactive\nUnitFileState=disabled'
|
||||
snapshot_service_states; [[ $? -eq 1 ]] || exit 1
|
||||
[[ "${#SERVICE_WAS_ACTIVE[@]}" -eq 0 ]] || exit 2
|
||||
''')
|
||||
|
||||
def test_restore_exact_states(self):
|
||||
self.assert_exit('''
|
||||
SERVICE_WAS_ENABLED[postsrsd-srs.service]=enabled
|
||||
SERVICE_WAS_ACTIVE[postsrsd-srs.service]=true
|
||||
SERVICE_WAS_ENABLED[srs-reverse-map.service]=enabled-runtime
|
||||
SERVICE_WAS_ACTIVE[srs-reverse-map.service]=true
|
||||
SERVICE_WAS_ENABLED[postfix@srs-delivery.service]=disabled
|
||||
SERVICE_WAS_ACTIVE[postfix@srs-delivery.service]=false
|
||||
restore_service_states || exit 1
|
||||
''')
|
||||
calls = self.calls.read_text()
|
||||
for expected in (
|
||||
'systemctl enable postsrsd-srs.service',
|
||||
'systemctl enable --runtime srs-reverse-map.service',
|
||||
'systemctl disable postfix@srs-delivery.service',
|
||||
'systemctl start postsrsd-srs.service',
|
||||
'systemctl stop postfix@srs-delivery.service',
|
||||
):
|
||||
self.assertIn(expected, calls)
|
||||
|
||||
def test_restore_rejects_invalid_snapshot_before_actions(self):
|
||||
self.assert_exit('''
|
||||
SERVICE_WAS_ENABLED[postsrsd-srs.service]=enabled
|
||||
SERVICE_WAS_ACTIVE[postsrsd-srs.service]=true
|
||||
SERVICE_WAS_ENABLED[srs-reverse-map.service]=bad-value
|
||||
SERVICE_WAS_ACTIVE[srs-reverse-map.service]=true
|
||||
restore_service_states; [[ $? -eq 1 ]] || exit 1
|
||||
[[ ! -e "$CALLS" ]] || exit 2
|
||||
''')
|
||||
|
||||
def test_restore_propagates_action_failure(self):
|
||||
self.assert_exit('''
|
||||
for unit in "$POSTSRSD_SERVICE" "$SRS_REVERSE_MAP_SERVICE" "$SRS_DELIVERY_SERVICE"; do
|
||||
SERVICE_WAS_ENABLED["$unit"]=enabled
|
||||
SERVICE_WAS_ACTIVE["$unit"]=true
|
||||
done
|
||||
FAIL_ACTION=start
|
||||
restore_service_states; [[ $? -eq 1 ]]
|
||||
''')
|
||||
|
||||
def test_ss_matching_nonmatching_and_command_failure(self):
|
||||
self.assert_exit('''
|
||||
SS_DATA='LISTEN 0 128 127.0.0.1:10003 0.0.0.0:*'
|
||||
port_is_listening 127.0.0.1 10003; [[ $? -eq 0 ]] || exit 1
|
||||
port_is_listening 127.0.0.1 10004; [[ $? -eq 1 ]] || exit 2
|
||||
SS_FAIL=1
|
||||
port_is_listening 127.0.0.1 10003; [[ $? -eq 2 ]] || exit 3
|
||||
''')
|
||||
|
||||
def test_master_service_present_absent_and_read_error(self):
|
||||
self.assert_exit('''
|
||||
master_srs_forward_present; [[ $? -eq 1 ]] || exit 1
|
||||
printf 'srs-forward unix - n n - - pipe\\n argv=/usr/local/sbin/srs-forward.py\\n' > "$POSTFIX_MASTER_CF"
|
||||
master_srs_forward_present; [[ $? -eq 0 ]] || exit 2
|
||||
rm "$POSTFIX_MASTER_CF"
|
||||
master_srs_forward_present 2>/dev/null; [[ $? -eq 2 ]] || exit 3
|
||||
''')
|
||||
|
||||
def verification_script(self):
|
||||
return FINAL + '\n[[ "$verification_ok" == "${EXPECTED}" ]]\n'
|
||||
|
||||
def verification_config(self):
|
||||
return '''
|
||||
check_managed_block_markers() { return 1; }
|
||||
master_srs_forward_present() { return "${MASTER_RC:-1}"; }
|
||||
state_main_cf_matches() { return 0; }
|
||||
state_master_option_matches() { return 0; }
|
||||
srs_alias_tool() { echo absent; }
|
||||
service_active() { return "${ACTIVE_RC:-1}"; }
|
||||
service_enabled() { return "${ENABLED_RC:-1}"; }
|
||||
port_is_listening() { return "${PORT_RC:-1}"; }
|
||||
postfix() { return 0; }
|
||||
POSTFIX_MAIN_CF="${CALLS}.main"
|
||||
POSTFIX_MASTER_CF="${CALLS}.master"
|
||||
POSTFIX_SRS_DROPIN_FILE="${CALLS}.dropin1"
|
||||
SRS_DELIVERY_DROPIN_FILE="${CALLS}.dropin2"
|
||||
SRS_MAIN_CF_BEGIN_MARKER='begin'
|
||||
SRS_MAIN_CF_END_MARKER='end'
|
||||
SRS_MASTER_CF_BEGIN_MARKER='begin'
|
||||
SRS_MASTER_CF_END_MARKER='end'
|
||||
_PRE_SRS_DEFAULT_TRANSPORT_SET=false
|
||||
_PRE_SRS_DEFAULT_TRANSPORT=''
|
||||
_PRE_SRS_RECIPIENT_CANONICAL_MAPS_SET=false
|
||||
_PRE_SRS_RECIPIENT_CANONICAL_MAPS=''
|
||||
_PRE_SRS_SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT_SET=false
|
||||
_PRE_SRS_SRS_FORWARD_DESTINATION_RECIPIENT_LIMIT=''
|
||||
_PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS_SET=false
|
||||
_PRE_SRS_SMTP_RECEIVE_OVERRIDE_OPTIONS=''
|
||||
_PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS_SET=false
|
||||
_PRE_SRS_AMAVIS_RECEIVE_OVERRIDE_OPTIONS=''
|
||||
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
|
||||
'''
|
||||
|
||||
def test_final_verification_accepts_only_known_absence(self):
|
||||
for var in ('MASTER_RC', 'ACTIVE_RC', 'ENABLED_RC', 'PORT_RC'):
|
||||
with self.subTest(var=var):
|
||||
self.assert_exit(self.verification_script(),
|
||||
config=self.verification_config() + f'EXPECTED=false\n{var}=2\n')
|
||||
self.assert_exit(self.verification_script(),
|
||||
config=self.verification_config() + 'EXPECTED=true\n')
|
||||
|
||||
def test_disabled_requires_every_service_and_listener_absent(self):
|
||||
"""Mixed runtime states must never be classified as DISABLED."""
|
||||
mock = '''
|
||||
service_exists() { return 0; }
|
||||
service_active() { [[ "$1" == "${ACTIVE_UNIT:-}" ]]; }
|
||||
service_enabled() { [[ "$1" == "${ENABLED_UNIT:-}" ]]; }
|
||||
port_is_listening() { [[ "$2" == "${LISTENER_PORT:-}" ]]; }
|
||||
systemd_dropins_match_expected() { return 1; }
|
||||
POSTFIX_SRS_DROPIN_FILE="$CALLS.dropin1"
|
||||
SRS_DELIVERY_DROPIN_FILE="$CALLS.dropin2"
|
||||
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
|
||||
main_block_status=absent
|
||||
master_block_status=absent
|
||||
pre_srs_main_ok=true
|
||||
pre_srs_master_ok=true
|
||||
master_srs_forward_ok=false
|
||||
main_srs_values_ok=false
|
||||
smtp_override_ok=false
|
||||
amavis_override_ok=false
|
||||
'''
|
||||
test_body = PREFLIGHT + STATE_CLASSIFICATION + '\nprintf "%s\\n" "$srs_state"\n'
|
||||
for setting, expected in (
|
||||
('', 'disabled'),
|
||||
('ACTIVE_UNIT=postsrsd-srs.service\n', 'inconsistent'),
|
||||
('ENABLED_UNIT=srs-reverse-map.service\n', 'inconsistent'),
|
||||
('LISTENER_PORT=10003\n', 'inconsistent'),
|
||||
):
|
||||
with self.subTest(setting=setting or 'none'):
|
||||
result = self.run_shell(test_body, config=mock + setting)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip().splitlines()[-1], expected)
|
||||
|
||||
def test_initial_preflight_aborts_on_query_errors(self):
|
||||
config = '''
|
||||
service_exists() { return 0; }
|
||||
service_active() { return "${ACTIVE_RC:-1}"; }
|
||||
service_enabled() { return "${ENABLED_RC:-1}"; }
|
||||
port_is_listening() { return "${PORT_RC:-1}"; }
|
||||
systemd_dropins_match_expected() { return 1; }
|
||||
POSTFIX_SRS_DROPIN_FILE="$CALLS.none1"
|
||||
SRS_DELIVERY_DROPIN_FILE="$CALLS.none2"
|
||||
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
|
||||
'''
|
||||
for variable in ('ACTIVE_RC', 'ENABLED_RC', 'PORT_RC'):
|
||||
with self.subTest(variable=variable):
|
||||
self.assert_exit(PREFLIGHT, rc=70,
|
||||
config=config + f'{variable}=2\n')
|
||||
self.assert_exit(PREFLIGHT, config=config)
|
||||
self.assert_exit(PREFLIGHT, rc=70, config=config + '''
|
||||
POSTFIX_SRS_DROPIN_FILE="$CALLS.bad-dropin"
|
||||
SRS_DELIVERY_DROPIN_FILE="$CALLS.other-dropin"
|
||||
touch "$POSTFIX_SRS_DROPIN_FILE"
|
||||
''')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Isolated, non-root tests for the public MX host installer addition.
|
||||
|
||||
No Postfix services, network DNS records or on-host config files are changed.
|
||||
"""
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
INSTALLER = (ROOT / 'install-update-srs.sh').read_text()
|
||||
COMMON = (ROOT / 'helpers/srs-common.sh').read_text()
|
||||
SAMPLE = (ROOT / 'conf/srs.conf.sample').read_text()
|
||||
|
||||
|
||||
def slice_script(source, start, end):
|
||||
return source[source.index(start):source.index(end)]
|
||||
|
||||
|
||||
PROMPTS = slice_script(INSTALLER, 'read_config_domain_hints() {', '# Consistent installer-only confirmation prompts.')
|
||||
DNS = slice_script(INSTALLER, 'verify_srs_domain_dns() {', '# Compact display of APT simulations;')
|
||||
LOAD = slice_script(COMMON, 'load_srs_config() {', '# ----------\n# Persistent pre-SRS state')
|
||||
MIGRATION = re.search(
|
||||
r"<<'PY_MX_MIGRATE'[^\n]*\n(.*?)\nPY_MX_MIGRATE", INSTALLER, re.S
|
||||
)
|
||||
assert MIGRATION is not None
|
||||
|
||||
|
||||
class InstallerMxHostTests(unittest.TestCase):
|
||||
def run_prompt(self, config=None, hostname='o13-mail.oopen.de', choices='\n\n'):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
conf = pathlib.Path(temp) / 'srs.conf'
|
||||
if config is not None:
|
||||
conf.write_text(config)
|
||||
script = f'''SRS_CONF_FILE={str(conf)!r}
|
||||
CONFIG_DOMAIN_HINT=''
|
||||
CONFIG_MX_HINT=''
|
||||
SELECTED_MX_HOST=''
|
||||
SELECTED_SRS_DOMAIN=''
|
||||
hostname() {{ printf '%s\\n' {hostname!r}; }}
|
||||
{PROMPTS}
|
||||
prompt_srs_domain
|
||||
printf 'RESULT_MX=%s\\nRESULT_SRS=%s\\n' "$SELECTED_MX_HOST" "$SELECTED_SRS_DOMAIN"
|
||||
'''
|
||||
return subprocess.run(
|
||||
['bash', '-c', script], input=choices, text=True, capture_output=True
|
||||
)
|
||||
|
||||
def test_first_install_defaults_from_hostname(self):
|
||||
result = self.run_prompt()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('RESULT_MX=o13-mail.oopen.de', result.stdout)
|
||||
self.assertIn('RESULT_SRS=srs.o13-mail.oopen.de', result.stdout)
|
||||
|
||||
def test_public_mx_override_changes_srs_suggestion(self):
|
||||
result = self.run_prompt(choices='mail.interventionistische-linke.org\n\n')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('RESULT_MX=mail.interventionistische-linke.org', result.stdout)
|
||||
self.assertIn('RESULT_SRS=srs.mail.interventionistische-linke.org', result.stdout)
|
||||
|
||||
def test_configured_values_take_precedence(self):
|
||||
config = '_MX_HOST="mail.example.org"\n_SRS_DOMAIN="srs.mail.example.org"\n'
|
||||
result = self.run_prompt(config=config)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('RESULT_MX=mail.example.org', result.stdout)
|
||||
self.assertIn('RESULT_SRS=srs.mail.example.org', result.stdout)
|
||||
|
||||
def test_legacy_srs_domain_preserved(self):
|
||||
config = '_SRS_DOMAIN="srs.original.example.org"\n'
|
||||
result = self.run_prompt(config=config, choices='mail.example.org\n\n')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('RESULT_MX=mail.example.org', result.stdout)
|
||||
self.assertIn('RESULT_SRS=srs.original.example.org', result.stdout)
|
||||
|
||||
def test_invalid_host_reprompted(self):
|
||||
result = self.run_prompt(choices='bad_host\nmail.example.org\n\n')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('[ invalid ]', result.stderr)
|
||||
self.assertIn('RESULT_MX=mail.example.org', result.stdout)
|
||||
|
||||
def test_prompt_eof_fails_without_defaulting(self):
|
||||
result = self.run_prompt(choices='')
|
||||
self.assertEqual(result.returncode, 2)
|
||||
|
||||
def test_dns_preflight_passes_selected_mx_not_postfix_myhostname(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
binpath = pathlib.Path(temp)
|
||||
(binpath / 'dig').write_text('#!/bin/sh\nexit 0\n')
|
||||
(binpath / 'dig').chmod(0o755)
|
||||
postconf = binpath / 'postconf'
|
||||
postconf.write_text('''#!/bin/sh
|
||||
case "$2" in
|
||||
smtp_bind_address) echo 83.223.90.231 ;;
|
||||
smtp_bind_address6) echo 2a01:31:0:2::ad52:a231 ;;
|
||||
*) echo 'unexpected postconf query' >&2; exit 4 ;;
|
||||
esac
|
||||
''')
|
||||
postconf.chmod(0o755)
|
||||
checker = binpath / 'check.py'
|
||||
checker.write_text('''import sys
|
||||
print('ARGS=' + ';'.join(sys.argv[1:]))
|
||||
''')
|
||||
script = f'''SELECTED_MX_HOST=mail.interventionistische-linke.org
|
||||
SELECTED_SRS_DOMAIN=srs.mail.interventionistische-linke.org
|
||||
SRS_DNS_CHECKER={str(checker)!r}
|
||||
{DNS}
|
||||
verify_srs_domain_dns
|
||||
'''
|
||||
env = dict(os.environ, PATH=f'{temp}:' + os.environ['PATH'])
|
||||
proc = subprocess.run(['bash','-c',script], capture_output=True, text=True, env=env)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr + proc.stdout)
|
||||
self.assertIn('--hostname;mail.interventionistische-linke.org', proc.stdout)
|
||||
self.assertIn('--ipv4;83.223.90.231', proc.stdout)
|
||||
self.assertIn('--ipv6;2a01:31:0:2::ad52:a231', proc.stdout)
|
||||
|
||||
def test_existing_config_migration_is_append_only(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = pathlib.Path(tmp) / 'old.conf'
|
||||
dst = pathlib.Path(tmp) / 'new.conf'
|
||||
content = b'# existing comments\n_SRS_DOMAIN="srs.example.org"\n\n'
|
||||
src.write_bytes(content)
|
||||
proc = subprocess.run([sys.executable, '-', str(src), str(dst), 'mail.example.org'],
|
||||
input=MIGRATION.group(1), capture_output=True, text=True)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertTrue(dst.read_bytes().startswith(content))
|
||||
self.assertIn(b'_MX_HOST="mail.example.org"\n', dst.read_bytes())
|
||||
self.assertEqual(src.read_bytes(), content)
|
||||
|
||||
def test_migration_rejects_existing_mx_assignment(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = pathlib.Path(tmp) / 'old.conf'
|
||||
dst = pathlib.Path(tmp) / 'new.conf'
|
||||
src.write_text('_MX_HOST="other.example.org"\n')
|
||||
proc = subprocess.run([sys.executable, '-', str(src), str(dst), 'mail.example.org'],
|
||||
input=MIGRATION.group(1), capture_output=True, text=True)
|
||||
self.assertNotEqual(proc.returncode, 0)
|
||||
self.assertFalse(dst.exists())
|
||||
|
||||
def run_config_load(self, content):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
conf = pathlib.Path(tmp) / 'srs.conf'
|
||||
conf.write_text(content)
|
||||
script = f'''SRS_CONF_FILE={str(conf)!r}
|
||||
SRS_CONF_SAMPLE_FILE=unused
|
||||
check_secure_source_file() {{ :; }}
|
||||
echononl() {{ :; }}
|
||||
echo_failed() {{ :; }}
|
||||
echo_ok() {{ :; }}
|
||||
fatal() {{ echo "FATAL: $*" >&2; exit 1; }}
|
||||
{LOAD}
|
||||
load_srs_config
|
||||
printf 'MX_IS_SET=%s\\n' "${{_MX_HOST+x}}"
|
||||
'''
|
||||
return subprocess.run(['bash','-c',script],text=True,capture_output=True)
|
||||
|
||||
def test_load_config_accepts_legacy_without_mx_host(self):
|
||||
result = self.run_config_load('_SRS_DOMAIN="srs.example.org"\n')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('MX_IS_SET=', result.stdout)
|
||||
|
||||
def test_load_config_accepts_valid_mx_host(self):
|
||||
result = self.run_config_load('_MX_HOST="mail.example.org"\n_SRS_DOMAIN="srs.example.org"\n')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('MX_IS_SET=x', result.stdout)
|
||||
|
||||
def test_load_config_rejects_invalid_mx_host(self):
|
||||
result = self.run_config_load('_MX_HOST="bad_host"\n_SRS_DOMAIN="srs.example.org"\n')
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn('Invalid public MX hostname', result.stderr)
|
||||
|
||||
def test_templates_and_security_contract(self):
|
||||
self.assertIn('_MX_HOST=""', SAMPLE)
|
||||
self.assertIn('_SRS_DOMAIN=""', SAMPLE)
|
||||
self.assertIn('unset _MX_HOST', COMMON)
|
||||
self.assertIn('if [[ -n "${_MX_HOST+x}" ]]', COMMON)
|
||||
self.assertIn('"${_MX_HOST,,}" != "$SELECTED_MX_HOST"', INSTALLER)
|
||||
self.assertIn('install_regular_file "${STAGE_DIR}/rendered/srs.conf" "$SRS_CONF_FILE"', INSTALLER)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user