From 64eab1d3c24f5d6645642087c362f69f926fd630 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Sat, 12 Sep 2026 18:53:01 +0200 Subject: [PATCH] Make backup report hostname configurable --- Dokumentation.md | 21 +++++++++++++++++++- README.md | 32 +++++++++++++++++++++++++++++- UPDATE_ANLEITUNG.md | 27 +++++++++++++++++++++++-- etc/app-backup/app-backup.conf | 3 +++ lib/common.sh | 3 ++- lib/report.py | 6 ++++-- tests/test_backup.py | 36 ++++++++++++++++++++++++++++++++++ 7 files changed, 121 insertions(+), 7 deletions(-) diff --git a/Dokumentation.md b/Dokumentation.md index e6bd2a8..971dcef 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -349,6 +349,25 @@ Absender `app-backup@` bei leerem/fehlendem `MAIL_FROM`, Betreffpräfix `/usr/sbin/sendmail`; `MAIL_SEND_TIMEOUT` begrenzt dessen Aufruf auf 30 Sekunden (zulässig 1–300). Der Log-Auszug darf 0–10000 Zeilen umfassen. +Seit 12.09.2026 überschreibt `MAIL_HOSTNAME` den Hostnamen im Betreff und im +`Host:`-Feld des Berichts. Für Gemini in `/etc/app-backup/app-backup.conf` setzen: + +```bash +MAIL_HOSTNAME="gemini.jr-it-services.de" +``` + +Der Betreff folgt weiterhin dem Schema `[app-backup] SUCCESS/FAIL HOST LAUF`, +zum Beispiel `[app-backup] SUCCESS gemini.jr-it-services.de appbackup_2026-…`. +Ein leerer oder fehlender Wert fällt auf `socket.getfqdn()` zurück. Die Einstellung +gilt für Erfolgs- und Fehlermails und bleibt beim Paketupdate erhalten. Vor dem +Update gesetzt, wirkt sie bereits im Abnahmebackup des neuen Pakets. Spätere +Änderungen werden beim nächsten Lauf eingelesen; ein Dienstneustart entfällt. +Systemhostname, Archivnamen und Manifest-Hostname bleiben unverändert. +Auch der Absender bleibt unabhängig: Ein explizites `MAIL_FROM` wird beibehalten; +bei leerem Wert wird weiterhin `app-backup@` verwendet. +Header-Zeilenumbrüche im Override werden wie bei den anderen Mailheadern +abgewiesen; ein Berichtsfehler verändert den Backup-Exit-Code nicht. + Der UTF-8-Bericht enthält Zeit, Host, Status, Exit-Code, Dauer, Lauf-ID und letzte Phase, Konfigurations-/Arbeits-/Archivpfade, Kompression, Crypt-Ziel und getrennten Uploadstatus. Dieser wird erst nach erfolgreichem Rücklesen aller Inhalte und @@ -536,7 +555,7 @@ Die Entfernung der drei DB-Dateien aus dem aktuellen Baum löscht deren Historie Reproduzierbar mit `tests/Dockerfile` und `tests/run.sh`. Der Container bekommt das Repository nur lesbar und benötigt während der Tests kein Netzwerk. Die MariaDB läuft ohne TCP-Port in einem temporären Verzeichnis. systemd/occ werden simuliert; alle eigentlichen Archive, Dateikopien, Dumps/Imports und Crypt-Operationen sind real. -Verifikation am 10.09.2026: **65 Testfälle im vollständigen Durchlauf erfolgreich** (603,4 Sekunden), einschließlich ausführlicher Mailberichte und der aktualisierten Paketinstallation. Shell-Syntax und ShellCheck ohne Befund. Testumgebung: Debian 13.6, MariaDB 11.8.6, rclone 1.60.1-DEV (Distributionspaket), ShellCheck 0.10.0. Diese Versionsangaben beschreiben die Testumgebung, keine Empfehlung für die Produktivversion. Der MTA wird durch eine lokale Nachrichten-Attrappe ersetzt; es wurden keine echten E-Mails versendet. Die Zustellung auf Gemini ist nach Installation zu prüfen. +Verifikation am 12.09.2026: **68 Testfälle abgedeckt**. Im Gesamtlauf (741,0 Sekunden) bestanden 63 Fälle direkt. Fünf Mail-Testprüfungen erwarteten LF statt der korrekten SMTP-Zeilenumbrüche CRLF; nach Korrektur dieser Prüfungen bestanden alle 13 gezielt wiederholten Mail-/Installertests (155,9 Sekunden), einschließlich der fünf betroffenen Fälle. Geprüft sind Hostname-Override bei Erfolg und Fehler, leerer/fehlender Wert, unveränderter Absender, Header-Schutz und Paketupdate mit erhaltener Einstellung. Shell-Syntax und ShellCheck ohne Befund. Testumgebung: Debian 13.6, MariaDB 11.8.6, rclone 1.60.1-DEV (Distributionspaket), ShellCheck 0.10.0. Diese Versionsangaben beschreiben die Testumgebung, keine Empfehlung für die Produktivversion. Der MTA wird durch eine lokale Nachrichten-Attrappe ersetzt; es wurden keine echten E-Mails versendet. Die Zustellung auf Gemini ist nach Installation zu prüfen. Die Regressionstests für `RCLONE_SIZE_ONLY` und zusätzliche Marker-Zeilenumbrüche scheiterten vor den jeweiligen Korrekturen mit einem fälschlich erfolgreichen Backup und bestanden danach. Upload-/Markerfehler werden an den Befehlsgrenzen injiziert, während Crypt-Übertragung und Inhaltsvergleich real bleiben. Die Tests bilden keine OneDrive-API nach; das tatsächliche HTTP-409-Wiederholungsverhalten ist durch den Betreiberlauf und den rclone-1.74.3-Quellcode separat belegt. diff --git a/README.md b/README.md index 48a141f..bd52d91 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ nicht nachträglich. Der Abschlussmarker wird außerdem bytegenau bestätigt, einschließlich seiner Zeilenumbrüche, damit Backup und Restore dieselbe strikte Prüfung verwenden. +Seit 12.09.2026 lässt sich der Hostname in Mail-Betreff und Bericht mit +`MAIL_HOSTNAME` festlegen, etwa `gemini.jr-it-services.de` statt +`localhost.localdomain`. Einstellung und Update-Schritte stehen unter +[Mailberichte](#mailberichte). + ## Automatisches Update-Paket (empfohlen) Das neue Paket übernimmt Konfigurationsabgleich, Rechteprüfung, Installation, @@ -229,10 +234,35 @@ ENABLE_MAIL_REPORT=true MAIL_TO="johannes" MAIL_FROM="" # Standard: app-backup@ MAIL_SUBJECT_PREFIX="[app-backup]" +MAIL_HOSTNAME="" # Leer: System-FQDN; überschreibt nur Betreff/Bericht MAIL_INCLUDE_LOG_TAIL_LINES=200 MAIL_SEND_TIMEOUT=30 ``` +Für Gemini vor dem Start des neuen Update-Pakets die aktive Konfiguration öffnen: + +```bash +sudoedit /etc/app-backup/app-backup.conf +``` + +Diese Zeile ergänzen beziehungsweise einen vorhandenen Wert ersetzen: + +```bash +MAIL_HOSTNAME="gemini.jr-it-services.de" +``` + +Anschließend das aktuelle `.run`-Paket wie oben beschrieben installieren. Es erhält +die Einstellung; bereits die Mail des Abnahmebackups verwendet dann beispielsweise +`[app-backup] SUCCESS gemini.jr-it-services.de appbackup_2026-…` und +`Host: gemini.jr-it-services.de` im Bericht. Auch Fehlermails verwenden den Wert. +Nach Installation dieser Version genügt für spätere Änderungen das Bearbeiten der +Konfiguration; sie wird bei jedem Lauf neu gelesen, ein Dienstneustart ist nicht nötig. + +Ein leerer oder fehlender Wert verwendet weiterhin den automatisch über Python +`socket.getfqdn()` ermittelten Systemnamen. `MAIL_HOSTNAME` ändert weder den +Systemhostname noch Archivnamen oder Absender. Für den Absender gilt weiterhin +`MAIL_FROM` beziehungsweise bei leerem Wert `app-backup@`. + `MAIL_TO` darf wie bisher ein lokaler Alias oder eine Mailadresse sein. Versand über `/usr/sbin/sendmail`; die lokale Mailzustellung muss dafür eingerichtet sein. Der Bericht bleibt zusätzlich unter `/var/log/app-backup/report_LAUF.eml` mit @@ -248,4 +278,4 @@ docker build -t app-backup-test -f tests/Dockerfile . docker run --rm --network none -v "$PWD:/src:ro" app-backup-test ``` -ShellCheck, echte MariaDB-Dumps/Imports, gzip/zstd, rsync, lokale Crypt-Uploads und Remote-Restore sowie Fehlerfälle laufen im Container. systemd, Nextcloud-occ und der Mailversand sind simuliert; Nachrichten werden nur lokal erfasst. Am 10.09.2026 bestanden alle 65 Tests. Eine echte Rocky-/Nextcloud-/Gitea-/OneDrive-Wiederherstellungsprobe und die tatsächliche Mailzustellung bleiben Teil der Serverabnahme. +ShellCheck, echte MariaDB-Dumps/Imports, gzip/zstd, rsync, lokale Crypt-Uploads und Remote-Restore sowie Fehlerfälle laufen im Container. systemd, Nextcloud-occ und der Mailversand sind simuliert; Nachrichten werden nur lokal erfasst. Am 12.09.2026 wurden 68 Testfälle abgedeckt: 63 bestanden im Gesamtlauf; nach Korrektur von fünf Testprüfungen für SMTP-Zeilenumbrüche bestanden alle 13 gezielt wiederholten Mail-/Installertests, einschließlich dieser fünf Fälle. ShellCheck und Shell-Syntaxprüfung bestanden. Eine echte Rocky-/Nextcloud-/Gitea-/OneDrive-Wiederherstellungsprobe und die tatsächliche Mailzustellung bleiben Teil der Serverabnahme. diff --git a/UPDATE_ANLEITUNG.md b/UPDATE_ANLEITUNG.md index ac14062..38dd786 100644 --- a/UPDATE_ANLEITUNG.md +++ b/UPDATE_ANLEITUNG.md @@ -1,5 +1,8 @@ # Automatisches Update-Paket +Für Gemini zuerst `MAIL_HOSTNAME="gemini.jr-it-services.de"` in der aktiven +Konfiguration setzen: [Mailberichte nach dem Update](#mailberichte-nach-dem-update). + Auf dem Arbeitsplatz die gelieferte `.run`-Datei und ihre `.sha256`-Datei per SCP auf den Server übertragen. Auf dem Server im selben Verzeichnis prüfen/starten: @@ -92,8 +95,28 @@ für Tests/Diagnose gedacht; dabei besteht keine Absicherung gegen SSH-Abbruch. ### Mailberichte nach dem Update +Für den gewünschten Gemini-Hostnamen **vor dem Paketstart** die aktive +Konfiguration mit `sudoedit /etc/app-backup/app-backup.conf` öffnen und folgende +Zeile ergänzen beziehungsweise den vorhandenen Wert ersetzen: + +```bash +MAIL_HOSTNAME="gemini.jr-it-services.de" +``` + +Danach Prüfsumme kontrollieren und das neue `.run`-Paket wie am Anfang der Anleitung +beschrieben starten. Der Installer erhält diesen Wert und die Mail des +Abnahmebackups zeigt bereits +`[app-backup] SUCCESS gemini.jr-it-services.de appbackup_2026-…` sowie +`Host: gemini.jr-it-services.de` im Nachrichtentext. Bei einem Fehler steht +entsprechend `FAIL` im Betreff. Ohne Einstellung oder bei `MAIL_HOSTNAME=""` +bleibt die automatische Ermittlung über `socket.getfqdn()` aktiv. +Der Absender wird weiterhin ausschließlich durch `MAIL_FROM` beziehungsweise +den bisherigen Standard `app-backup@` bestimmt. Systemhostname und +Archivnamen werden nicht geändert. Nach Installation des neuen Codes genügt +für spätere Anpassungen eine Konfigurationsänderung vor dem nächsten Lauf. + Das Paket stellt die ausführlichen Mailberichte wieder bereit. Vorhandene Werte -für `ENABLE_MAIL_REPORT`, `MAIL_TO`, `MAIL_FROM`, `MAIL_SUBJECT_PREFIX` und +für `ENABLE_MAIL_REPORT`, `MAIL_TO`, `MAIL_FROM`, `MAIL_HOSTNAME`, `MAIL_SUBJECT_PREFIX` und `MAIL_INCLUDE_LOG_TAIL_LINES` bleiben erhalten; sie müssen nicht neu eingegeben werden. Eine bislang deaktivierte Berichtsfunktion bleibt deaktiviert. Fehlende Absender-/Betreff-/Tail-Einstellungen erhalten die in README beschriebenen Defaults. @@ -452,7 +475,7 @@ Das ausführbare Paket ausschließlich über euren vertrauenswürdigen Kanal bez ## Verifikation der Lieferung -65 Testfälle im vollständigen Durchlauf erfolgreich (603,4 Sekunden), einschließlich Mailberichte, Versandfehler/-Timeout, Header-Schutz und Paketupdate mit erhaltenen Mail-Einstellungen. ShellCheck und Shell-Syntaxprüfung erfolgreich. Der MTA wird durch eine lokale Attrappe ersetzt; die Tests versenden keine echten E-Mails. Die Tests für eine über Umgebungsvariablen abgeschwächte Inhaltsprüfung und den nicht bytegenauen Marker-Vergleich reproduzierten die Fehler vor der Korrektur und bestanden danach. +Verifikation am 12.09.2026: Gesamtlauf mit 68 Testfällen (741,0 Sekunden), davon 63 direkt erfolgreich. Fünf Mail-Testprüfungen wurden für korrekte SMTP-Zeilenumbrüche (CRLF) angepasst; danach bestanden alle 13 gezielt wiederholten Mail-/Installertests (155,9 Sekunden), darunter sämtliche fünf betroffenen Fälle. Dies umfasst den konfigurierbaren Mail-Hostnamen, automatische FQDN-Ermittlung, erhaltenen Absender, Versandfehler/-Timeout, Header-Schutz und Paketupdate mit erhaltenen Mail-Einstellungen. ShellCheck und Shell-Syntaxprüfung erfolgreich. Der MTA wird durch eine lokale Attrappe ersetzt; die Tests versenden keine echten E-Mails. Die Tests für eine über Umgebungsvariablen abgeschwächte Inhaltsprüfung und den nicht bytegenauen Marker-Vergleich reproduzierten die Fehler vor der Korrektur und bestanden danach. Die Tests enthalten echte MariaDB-Dumps/Imports und lokale rclone-Crypt-Roundtrips; systemd/occ und die systemd-run-Übergabe sind simuliert. Das Übergabemodell diff --git a/etc/app-backup/app-backup.conf b/etc/app-backup/app-backup.conf index e9a28f6..981f10c 100644 --- a/etc/app-backup/app-backup.conf +++ b/etc/app-backup/app-backup.conf @@ -41,6 +41,9 @@ MAIL_TO="johannes" # Empty means app-backup@. Existing sender/subject/tail settings are preserved. MAIL_FROM="" MAIL_SUBJECT_PREFIX="[app-backup]" +# Host in subject/report only; empty uses the system FQDN. Sender is unchanged. +# Example for Gemini: MAIL_HOSTNAME="gemini.jr-it-services.de" +MAIL_HOSTNAME="" MAIL_INCLUDE_LOG_TAIL_LINES=200 MAIL_SENDMAIL_BIN="/usr/sbin/sendmail" MAIL_SEND_TIMEOUT=30 diff --git a/lib/common.sh b/lib/common.sh index 8a774f2..22b9ca7 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -37,6 +37,7 @@ load_config() { : "${RCLONE_BWLIMIT:=0}" "${ENABLE_MAIL_REPORT:=true}" "${MAIL_TO:=johannes}" # Keep the old report settings; an empty sender uses app-backup@. : "${MAIL_FROM:=}" "${MAIL_SUBJECT_PREFIX:=[app-backup]}" "${MAIL_INCLUDE_LOG_TAIL_LINES:=200}" + : "${MAIL_HOSTNAME:=}" : "${MAIL_SENDMAIL_BIN:=/usr/sbin/sendmail}" "${MAIL_SEND_TIMEOUT:=30}" : "${ENABLE_WORDPRESS:=false}" "${ENABLE_NEXTCLOUD:=false}" "${ENABLE_NEXTCLOUD_DATA:=true}" : "${ENABLE_GITEA:=false}" "${ENABLE_MAIL:=false}" "${ENABLE_DB_DUMPS:=true}" @@ -513,7 +514,7 @@ finish() { if [[ ${CHECK:-false} != true && $ENABLE_MAIL_REPORT == true && $OPERATION == backup ]]; then python3 "$LIB_DIR/report.py" deliver --sizes "$report_sizes" --log "$LOG_FILE" \ --output "$LOG_DIR/report_$RUN_ID.eml" --to "$MAIL_TO" --sender "$MAIL_FROM" \ - --subject-prefix "$MAIL_SUBJECT_PREFIX" --run "$RUN_ID" --exit-code "$code" \ + --hostname="$MAIL_HOSTNAME" --subject-prefix "$MAIL_SUBJECT_PREFIX" --run "$RUN_ID" --exit-code "$code" \ --started "$RUN_STARTED_EPOCH" --phase "$BACKUP_PHASE" --upload-status "$UPLOAD_STATUS" \ --config "$CONFIG_FILE" --workdir "$WORKDIR" --archive-dir "$ARCHIVE_DIR" \ --compression "$COMPRESSOR" --remote "$RCLONE_REMOTE_BASE" --sendmail "$MAIL_SENDMAIL_BIN" \ diff --git a/lib/report.py b/lib/report.py index 6b62872..0e3d9aa 100644 --- a/lib/report.py +++ b/lib/report.py @@ -50,7 +50,8 @@ def human(value): def deliver(args): - host = socket.getfqdn() + fqdn = socket.getfqdn() + host = args.hostname or fqdn now = dt.datetime.now().astimezone() state = 'SUCCESS' if args.exit_code == 0 else 'FAIL' try: @@ -89,7 +90,7 @@ def deliver(args): body.extend(['', f'Log-Auszug (letzte {args.tail_lines} Zeilen)', '-----------------', tail]) message = EmailMessage(policy=SMTP) # EmailMessage rejects CR/LF in headers and encodes non-ASCII safely. - sender = args.sender or f'app-backup@{host}' + sender = args.sender or f'app-backup@{fqdn}' if not args.to.strip() or not sender.strip(): raise ValueError('Mail recipient and sender must not be empty') message['From'] = sender @@ -112,6 +113,7 @@ def main(): for name in ('output', 'staging', 'archives'): snap.add_argument(name) mail = commands.add_parser('deliver') + mail.add_argument('--hostname', default='') for name in ('sizes', 'log', 'output', 'to', 'sender', 'subject-prefix', 'run', 'phase', 'upload-status', 'config', 'workdir', 'archive-dir', 'compression', 'remote', 'sendmail'): mail.add_argument('--'+name, required=True) diff --git a/tests/test_backup.py b/tests/test_backup.py index faf2e97..a63d023 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -11,6 +11,7 @@ import os from pathlib import Path import shutil import signal +import socket import subprocess import tarfile import tempfile @@ -584,6 +585,7 @@ if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv: def test_automatic_package_upgrade_and_combined_rollback(self): self.enable_report() + with self.config.open('a') as f: f.write('MAIL_HOSTNAME="gemini.jr-it-services.de"\n') canonical=self.configure_upgrade() original=canonical.read_bytes() package=self.build_package() @@ -593,6 +595,9 @@ if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv: self.assertIn('SUCCESS',(state/'result.txt').read_text()) message,body=self.read_report() self.assertEqual(str(message['From']),'app-backup@example.invalid') + self.assertIn(' SUCCESS gemini.jr-it-services.de ',str(message['Subject'])) + self.assertIn('Host: gemini.jr-it-services.de',body.splitlines()) + self.assertIn('MAIL_HOSTNAME="gemini.jr-it-services.de"',canonical.read_text()) self.assertIn('OK (Inhalt und Abschlussmarker',body) self.assertEqual(Path('/usr/local/lib/app-backup/report.py').read_bytes(),(REPO/'lib/report.py').read_bytes()) self.assertTrue((state/'verified-backup-run').is_file()) @@ -987,6 +992,8 @@ REPORT_SECRET='config-content-must-not-enter-the-mail' run=self.backup() message,body=self.read_report() self.assertIn('[Backup-Test] SUCCESS',str(message['Subject'])) + self.assertIn(f' SUCCESS {socket.getfqdn()} ',str(message['Subject'])) + self.assertIn(f'Host: {socket.getfqdn()}',body.splitlines()) self.assertEqual(str(message['From']),'app-backup@example.invalid') self.assertIn('backup@example.invalid',str(message['To'])) for value in ['Status: SUCCESS','Exit code: 0','Dauer:', 'db staged:', @@ -1000,12 +1007,41 @@ REPORT_SECRET='config-content-must-not-enter-the-mail' self.assertEqual(saved.read_bytes(),(self.base/'mail.eml').read_bytes()) self.assertEqual(saved.stat().st_mode & 0o777,0o600) + def test_report_hostname_override_preserves_default_sender(self): + self.enable_report() + with self.config.open('a') as f: + f.write('MAIL_HOSTNAME="gemini.jr-it-services.de"\nMAIL_FROM=""\n') + run=self.backup() + message,body=self.read_report() + self.assertEqual(str(message['Subject']),f'[Backup-Test] SUCCESS gemini.jr-it-services.de {run.name}') + self.assertIn('Host: gemini.jr-it-services.de',body.splitlines()) + self.assertEqual(str(message['From']),f'app-backup@{socket.getfqdn()}') + + def test_report_empty_hostname_uses_system_fqdn(self): + self.enable_report() + with self.config.open('a') as f: f.write('MAIL_HOSTNAME=""\n') + self.backup() + message,body=self.read_report() + self.assertIn(f' SUCCESS {socket.getfqdn()} ',str(message['Subject'])) + self.assertIn(f'Host: {socket.getfqdn()}',body.splitlines()) + + def test_report_hostname_header_injection_is_rejected(self): + self.enable_report() + with self.config.open('a') as f: + f.write("MAIL_HOSTNAME=$'gemini\\nBcc: attacker@example.invalid'\n") + result=self.run_script('app-backup.sh') + self.assertIn('mail report failed (ValueError)',result.stdout) + self.assertFalse((self.base/'mail.eml').exists()) + def test_report_failure_names_phase_and_has_final_error(self): self.enable_report() + with self.config.open('a') as f: f.write('MAIL_HOSTNAME="gemini.jr-it-services.de"\n') result=self.run_script('app-backup.sh',ok=False,TEST_FAIL_TOOL='mariadb-dump') message,body=self.read_report() self.assertEqual(result.returncode,42) self.assertIn('[Backup-Test] FAIL',str(message['Subject'])) + self.assertIn(' FAIL gemini.jr-it-services.de ',str(message['Subject'])) + self.assertIn('Host: gemini.jr-it-services.de',body.splitlines()) self.assertIn('Exit code: 42',body) self.assertIn('Letzte Phase: DB-Dump:',body) self.assertIn('FAILED (exit 42)',body)