Add selective Postfix SRS installation and management

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