diff --git a/README-SRS.md b/README-SRS.md new file mode 100644 index 0000000..6da1082 --- /dev/null +++ b/README-SRS.md @@ -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-` 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. diff --git a/README-install-SRS.md b/README-install-SRS.md new file mode 100644 index 0000000..0c62967 --- /dev/null +++ b/README-install-SRS.md @@ -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.` 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. diff --git a/README.md b/README.md index df5f4d6..3b173be 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/archive/selective-srs-2026/activate-srs.sh b/activate-srs.sh similarity index 83% rename from archive/selective-srs-2026/activate-srs.sh rename to activate-srs.sh index 5580448..89b879a 100755 --- a/archive/selective-srs-2026/activate-srs.sh +++ b/activate-srs.sh @@ -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 < "$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 diff --git a/archive/selective-srs-2026/conf/srs.conf.sample b/conf/srs.conf.sample similarity index 55% rename from archive/selective-srs-2026/conf/srs.conf.sample rename to conf/srs.conf.sample index 085217d..cb69eb2 100644 --- a/archive/selective-srs-2026/conf/srs.conf.sample +++ b/conf/srs.conf.sample @@ -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. diff --git a/archive/selective-srs-2026/deactivate-srs.sh b/deactivate-srs.sh similarity index 76% rename from archive/selective-srs-2026/deactivate-srs.sh rename to deactivate-srs.sh index 32fbdb4..d762cf2 100755 --- a/archive/selective-srs-2026/deactivate-srs.sh +++ b/deactivate-srs.sh @@ -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 < "$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 diff --git a/archive/selective-srs-2026/files/srs/srs-alias-decision.py b/files/srs/srs-alias-decision.py similarity index 100% rename from archive/selective-srs-2026/files/srs/srs-alias-decision.py rename to files/srs/srs-alias-decision.py diff --git a/archive/selective-srs-2026/files/srs/srs-forward.py b/files/srs/srs-forward.py similarity index 100% rename from archive/selective-srs-2026/files/srs/srs-forward.py rename to files/srs/srs-forward.py diff --git a/archive/selective-srs-2026/files/srs/srs-reverse-map.py b/files/srs/srs-reverse-map.py similarity index 100% rename from archive/selective-srs-2026/files/srs/srs-reverse-map.py rename to files/srs/srs-reverse-map.py diff --git a/archive/selective-srs-2026/files/srs/srs-reverse-policy.py b/files/srs/srs-reverse-policy.py similarity index 100% rename from archive/selective-srs-2026/files/srs/srs-reverse-policy.py rename to files/srs/srs-reverse-policy.py diff --git a/archive/selective-srs-2026/files/srs/templates/delivery-main.cf.in b/files/srs/templates/delivery-main.cf.in similarity index 100% rename from archive/selective-srs-2026/files/srs/templates/delivery-main.cf.in rename to files/srs/templates/delivery-main.cf.in diff --git a/archive/selective-srs-2026/files/srs/templates/delivery-master.cf b/files/srs/templates/delivery-master.cf similarity index 100% rename from archive/selective-srs-2026/files/srs/templates/delivery-master.cf rename to files/srs/templates/delivery-master.cf diff --git a/archive/selective-srs-2026/files/srs/templates/postsrsd-srs.service b/files/srs/templates/postsrsd-srs.service similarity index 100% rename from archive/selective-srs-2026/files/srs/templates/postsrsd-srs.service rename to files/srs/templates/postsrsd-srs.service diff --git a/archive/selective-srs-2026/files/srs/templates/postsrsd.conf.in b/files/srs/templates/postsrsd.conf.in similarity index 100% rename from archive/selective-srs-2026/files/srs/templates/postsrsd.conf.in rename to files/srs/templates/postsrsd.conf.in diff --git a/archive/selective-srs-2026/files/srs/templates/srs-reverse-map.service b/files/srs/templates/srs-reverse-map.service similarity index 100% rename from archive/selective-srs-2026/files/srs/templates/srs-reverse-map.service rename to files/srs/templates/srs-reverse-map.service diff --git a/archive/selective-srs-2026/files/srs/templates/transport.in b/files/srs/templates/transport.in similarity index 100% rename from archive/selective-srs-2026/files/srs/templates/transport.in rename to files/srs/templates/transport.in diff --git a/archive/selective-srs-2026/helpers/srs-common.sh b/helpers/srs-common.sh similarity index 97% rename from archive/selective-srs-2026/helpers/srs-common.sh rename to helpers/srs-common.sh index aa06546..ef2d576 100755 --- a/archive/selective-srs-2026/helpers/srs-common.sh +++ b/helpers/srs-common.sh @@ -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 } diff --git a/archive/selective-srs-2026/helpers/srs-dns-check.py b/helpers/srs-dns-check.py similarity index 100% rename from archive/selective-srs-2026/helpers/srs-dns-check.py rename to helpers/srs-dns-check.py diff --git a/archive/selective-srs-2026/helpers/srs-virtual-alias.py b/helpers/srs-virtual-alias.py similarity index 100% rename from archive/selective-srs-2026/helpers/srs-virtual-alias.py rename to helpers/srs-virtual-alias.py diff --git a/archive/selective-srs-2026/install-update-srs.sh b/install-update-srs.sh similarity index 90% rename from archive/selective-srs-2026/install-update-srs.sh rename to install-update-srs.sh index 419b97b..9bb51ca 100755 --- a/archive/selective-srs-2026/install-update-srs.sh +++ b/install-update-srs.sh @@ -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.. - # 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 diff --git a/tests/test_activation_status_mock.py b/tests/test_activation_status_mock.py new file mode 100644 index 0000000..c4e1dba --- /dev/null +++ b/tests/test_activation_status_mock.py @@ -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) diff --git a/tests/test_deactivation_managed_block_mock.py b/tests/test_deactivation_managed_block_mock.py new file mode 100644 index 0000000..7c4d1ad --- /dev/null +++ b/tests/test_deactivation_managed_block_mock.py @@ -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) diff --git a/tests/test_deactivation_rollback_mock.py b/tests/test_deactivation_rollback_mock.py new file mode 100644 index 0000000..e73b4a0 --- /dev/null +++ b/tests/test_deactivation_rollback_mock.py @@ -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) diff --git a/tests/test_deactivation_status_mock.py b/tests/test_deactivation_status_mock.py new file mode 100644 index 0000000..75776b4 --- /dev/null +++ b/tests/test_deactivation_status_mock.py @@ -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) diff --git a/archive/selective-srs-2026/tests/test_installer_mock.py b/tests/test_installer_mock.py similarity index 100% rename from archive/selective-srs-2026/tests/test_installer_mock.py rename to tests/test_installer_mock.py diff --git a/archive/selective-srs-2026/tests/test_installer_mock_safe.py b/tests/test_installer_mock_safe.py similarity index 100% rename from archive/selective-srs-2026/tests/test_installer_mock_safe.py rename to tests/test_installer_mock_safe.py diff --git a/tests/test_installer_mx_host_mock.py b/tests/test_installer_mx_host_mock.py new file mode 100644 index 0000000..f9eb79e --- /dev/null +++ b/tests/test_installer_mx_host_mock.py @@ -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) diff --git a/archive/selective-srs-2026/tests/test_srs_dns.py b/tests/test_srs_dns.py similarity index 100% rename from archive/selective-srs-2026/tests/test_srs_dns.py rename to tests/test_srs_dns.py