From 99da6460d291ba681e03f2449afa5b6131ccc0be Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Thu, 10 Sep 2026 16:45:18 +0200 Subject: [PATCH] Restore detailed backup mail reports with bounded local delivery --- Dokumentation.md | 58 +++++++++++++- README.md | 28 ++++++- UPDATE_ANLEITUNG.md | 21 ++++- etc/app-backup/app-backup.conf | 6 ++ install.sh | 5 +- lib/common.sh | 32 +++++++- lib/report.py | 136 +++++++++++++++++++++++++++++++++ tests/test_backup.py | 102 +++++++++++++++++++++++++ tools/build-update-package.py | 2 +- usr-local-sbin/app-backup.sh | 19 ++++- 10 files changed, 396 insertions(+), 13 deletions(-) create mode 100644 lib/report.py diff --git a/Dokumentation.md b/Dokumentation.md index 4c7e745..e6bd2a8 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -338,6 +338,59 @@ Neue Backup-/Restore-Logs liegen unter `/var/log/app-backup` und werden nach `LO ## Betrieb und Monitoring +### Ausführliche Mailberichte + +`ENABLE_MAIL_REPORT=true` sendet nach jedem regulär beendeten Backup einen Bericht +über den lokalen sendmail-kompatiblen MTA. Die bisherigen Optionen `MAIL_TO`, +`MAIL_FROM`, `MAIL_SUBJECT_PREFIX` und `MAIL_INCLUDE_LOG_TAIL_LINES` werden wieder +ausgewertet und bei einem Update beibehalten. Defaults: Empfänger `johannes`, +Absender `app-backup@` bei leerem/fehlendem `MAIL_FROM`, Betreffpräfix +`[app-backup]`, 200 Protokollzeilen. `MAIL_SENDMAIL_BIN` ist standardmäßig +`/usr/sbin/sendmail`; `MAIL_SEND_TIMEOUT` begrenzt dessen Aufruf auf 30 Sekunden +(zulässig 1–300). Der Log-Auszug darf 0–10000 Zeilen umfassen. + +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 +bytegenauer Markerbestätigung `OK`. Bei deaktiviertem Upload steht ausdrücklich +„nur lokal“; bei Fehlern bleibt die nicht abgeschlossene Uploadphase sichtbar. +Ein Fehler bei nachgelagerter Retention kann deshalb korrekt `Status: FAIL` bei +bereits verifiziertem Upload ergeben. +Die Dateikopien protokollieren außerdem rsync-Statistiken zu Dateianzahl und +übertragenen Bytes; laufende rclone-Statistiken erscheinen im gleichen Log. + +Die Größen werden nach Wiederaufnahme der Dienste und vor erfolgreicher +Staging-Bereinigung erfasst: belegter Speicher pro vorhandener Komponente und +insgesamt, dazu Archiv-Dateigrößen und deren Summe. Komponentengrößen werden +einzeln gemessen; bei gemeinsamen Hardlinks müssen sie sich nicht zum Gesamtwert +addieren. Es werden keine Live-Quellen erneut kopiert. Fehlende Messwerte heißen +„nicht verfügbar“, nicht fälschlich `0`. Bei Abbruch können aufgeführte Archive +unvollständig sein; Status und letzte Phase sind maßgeblich. Die Messung erfolgt +mit `du`, je Aufruf auf 30 Sekunden begrenzt, und verlängert nicht die Dienstpause. + +Vor dem Lesen des Log-Auszuges wird der Logger beendet und auf vollständiges +Schreiben gewartet. Mailheader werden über die Python-Mailbibliothek erzeugt; +eingeschleuste Header-Zeilenumbrüche werden abgewiesen. `sendmail -oi -t` schützt +gegen vorzeitiges Nachrichtenende durch eine einzelne Punktzeile im Log. +Konfigurationsinhalte und Zugangsdaten werden nicht für den Bericht gelesen; +enthaltene Dateinamen/Pfade und der Log-Auszug sind dennoch vertraulich. + +Die MIME-Nachricht bleibt mit Modus `600` unter +`$LOG_DIR/report_LAUF.eml` erhalten, auch bei MTA-Fehlern oder Zustellungs-Timeout. +Alte Berichte werden wie Logs nach `LOG_RETENTION_DAYS` bei erfolgreichem regulärem +Backup bereinigt; der Update-Abnahmelauf überspringt diese Retention. +Ein Mailfehler ist eine protokollierte Warnung und ändert den Backup-Exit-Code nicht. +Erfolgreiche Übergabe an den MTA bestätigt noch keine Zustellung im Postfach. + +`--check`, Restore und reine Restore-Prüfungen erzeugen keine Backup-Mail. +Sehr frühe Fehler vor der Laufinitialisierung und SIGKILL/Stromausfall können +keinen Abschlussbericht erzeugen. Beim Update bezieht sich die Mail auf dessen +Backup-Teil; `SUCCESS` in der Update-Ergebnisdatei bestätigt zusätzlich den +anschließenden separaten Remote-Restore-Prüflauf und die Timer-Aktivierung. +Unabhängige Überwachung bleibt deshalb erforderlich. + +### Status und Zeitplan prüfen + ```bash sudo /usr/local/sbin/app-backup.sh --check sudo systemctl start app-backup.service @@ -483,12 +536,15 @@ 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: **59 Testfälle erfolgreich** — 58 im vollständigen Durchlauf (524,6 Sekunden), dazu der ergänzte Test für zusätzliche Marker-Zeilenumbrüche. Die vier betroffenen Markertests und der echte Crypt-Upload mit Remote-Restore wurden nach der Marker-Korrektur nochmals gezielt erfolgreich ausgeführt. 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. +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. 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. Geprüfte Fälle umfassen: +- Ausführliche UTF-8-Erfolgs-/Fehlermails mit erhaltenen Header-Einstellungen, Größen vor Bereinigung, finalem Fehlerauszug und korrektem Upload-Prüfstatus. +- MTA-Fehler und begrenzter Versand-Timeout verändern den Backup-Exit-Code nicht; die lokale Nachricht bleibt erhalten. Header-Injection wird abgewiesen; Prüfläufe/Restore versenden keine Backup-Mail. +- Paketupdate installiert die Berichtskomponente, erhält alte Mail-Einstellungen und erzeugt beim Abnahmebackup einen Bericht über die MTA-Attrappe. - Endgültiger Upload-Fehler trotz bereits übertragener Bytes: vollständiger lokaler Satz, vorheriger Erfolgsstatus und alte lokale/entfernte Retention-Kandidaten bleiben erhalten. - Veränderte Remote-Datei gleicher Größe, Marker-Schreibfehler, abweichender Marker-Inhalt und zusätzliche Zeilenumbrüche verhindern den lokalen Upload-Erfolg und die Retention. - Nicht unterstützte rclone-Umgebungsoptionen werden vor Remote-Zugriff und Writer-Stopp abgewiesen; unterstützte exportierte Einstellungen bleiben nutzbar. diff --git a/README.md b/README.md index 1643805..48a141f 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,32 @@ sudo /usr/local/sbin/app-restore.sh --local-run /pfad/zu/einem/alten/lauf \ Ein echter Restore benötigt passende Zielverzeichnisse, Anwendungsstände und Datenbanken sowie einen DB-Account mit Wiederherstellungsrechten. `--yes` ist keine automatische Datenrettung bei Fehlern; bei einem Teilfehler bleiben angehaltene Dienste für die manuelle Prüfung stehen. +## Mailberichte + +Jeder beendete Backup-Lauf kann wieder einen ausführlichen UTF-8-Bericht senden: +Status `SUCCESS`/`FAIL`, Host, Laufzeit, letzte Phase, Upload-/Prüfergebnis, +Staging-Größen, Archivgrößen und Protokollauszug. Der Betreff verwendet wieder +das Schema `[app-backup] SUCCESS/FAIL HOST LAUF`. + +In `/etc/app-backup/app-backup.conf` werden vorhandene Einstellungen weiterverwendet: + +```bash +ENABLE_MAIL_REPORT=true +MAIL_TO="johannes" +MAIL_FROM="" # Standard: app-backup@ +MAIL_SUBJECT_PREFIX="[app-backup]" +MAIL_INCLUDE_LOG_TAIL_LINES=200 +MAIL_SEND_TIMEOUT=30 +``` + +`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 +Modus `600` erhalten. Versandfehler werden protokolliert und verändern das +Backup-Ergebnis nicht. `--check` und Restore-Prüfungen senden keine Backup-Mail. +Die Mail bestätigt den Backup-Lauf; beim Update ist weiterhin dessen abschließende +`result.txt` nach der zusätzlichen Remote-Restore-Prüfung maßgeblich. + ## Tests ```bash @@ -222,4 +248,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 und Nextcloud-occ sind simuliert. Eine echte Rocky-/Nextcloud-/Gitea-/OneDrive-Wiederherstellungsprobe bleibt 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 10.09.2026 bestanden alle 65 Tests. 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 c7d2058..ac14062 100644 --- a/UPDATE_ANLEITUNG.md +++ b/UPDATE_ANLEITUNG.md @@ -90,6 +90,25 @@ für Tests/Diagnose gedacht; dabei besteht keine Absicherung gegen SSH-Abbruch. ## Gezielte Rückfragen und Grenzen +### Mailberichte nach dem Update + +Das Paket stellt die ausführlichen Mailberichte wieder bereit. Vorhandene Werte +für `ENABLE_MAIL_REPORT`, `MAIL_TO`, `MAIL_FROM`, `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. +Die neue Python-Berichtskomponente wird vom Installer samt Rollback installiert. + +Beim Abnahmebackup entsteht eine Mail mit Laufzeit, Größen, Upload-Prüfstatus und +Log-Auszug. Das Update ist erst mit `SUCCESS` in seiner `result.txt` nach der +zusätzlichen Restore-Prüfung abgeschlossen. Fehlt die Mail, im Backup-Log nach +`mail report failed` suchen und die lokale `report_LAUF.eml`, sendmail-Verfügbarkeit, +MTA-Queue und Empfänger-/Aliaszustellung prüfen. Keine erneute Sicherung allein +zum Testen der Mailzustellung starten. Die Entwicklungstests verwenden eine lokale +MTA-Attrappe und versenden keine echten Nachrichten. + +### Quellen und Writer + - **Zentrales Mailverzeichnis fehlt, etwa `/var/vmail`:** Der Installer fragt vor der Installation nach dem tatsächlichen absoluten Pfad. Alternativ lässt sich dieser mit `--mail-dir /tatsaechliche/mailablage` übergeben. Kein leeres @@ -433,7 +452,7 @@ Das ausführbare Paket ausschließlich über euren vertrauenswürdigen Kanal bez ## Verifikation der Lieferung -59 Testfälle erfolgreich: 58 im vollständigen Durchlauf (524,6 Sekunden), dazu der neue Test für zusätzliche Marker-Zeilenumbrüche. Nach der Marker-Korrektur bestanden die vier betroffenen Markertests und der echte Crypt-Upload mit Remote-Restore nochmals gezielt. ShellCheck und Shell-Syntaxprüfung erfolgreich. 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. +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. 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 22e0302..e9a28f6 100644 --- a/etc/app-backup/app-backup.conf +++ b/etc/app-backup/app-backup.conf @@ -38,6 +38,12 @@ RCLONE_ONEDRIVE_CHUNK_SIZE="80Mi" RCLONE_BWLIMIT="0" ENABLE_MAIL_REPORT="true" MAIL_TO="johannes" +# Empty means app-backup@. Existing sender/subject/tail settings are preserved. +MAIL_FROM="" +MAIL_SUBJECT_PREFIX="[app-backup]" +MAIL_INCLUDE_LOG_TAIL_LINES=200 +MAIL_SENDMAIL_BIN="/usr/sbin/sendmail" +MAIL_SEND_TIMEOUT=30 ENABLE_WORDPRESS="true" WP_DIR="/var/www/html" diff --git a/install.sh b/install.sh index c81d20a..0a44355 100755 --- a/install.sh +++ b/install.sh @@ -18,9 +18,9 @@ done [[ $NO_TIMER != true || $ENABLE_TIMER != true ]] || exit 1 # Syntax and packaging errors must be caught BEFORE changing the running installation. for file in "$ROOT_DIR"/usr-local-sbin/*.sh "$ROOT_DIR/lib/common.sh"; do bash -n "$file"; done -python3 - "$ROOT_DIR/lib/backup_set.py" <<'PY' +python3 - "$ROOT_DIR/lib/backup_set.py" "$ROOT_DIR/lib/report.py" <<'PY' import ast,sys -ast.parse(open(sys.argv[1]).read()) +for path in sys.argv[1:]: ast.parse(open(path).read()) PY LOCKFILE=/run/app-backup.lock if [[ -f /etc/app-backup/app-backup.conf ]]; then @@ -52,6 +52,7 @@ add usr-local-sbin/app-restore.sh /usr/local/sbin/app-restore.sh 750 add lib/common.sh /usr/local/lib/app-backup/common.sh 640 add lib/locking.sh /usr/local/lib/app-backup/locking.sh 640 add lib/backup_set.py /usr/local/lib/app-backup/backup_set.py 640 +add lib/report.py /usr/local/lib/app-backup/report.py 640 add systemd/app-backup.service /etc/systemd/system/app-backup.service 644 # Preserve existing schedules. The new timer template remains available as .dist. if [[ -e /etc/systemd/system/app-backup.timer ]]; then diff --git a/lib/common.sh b/lib/common.sh index 2912906..8a774f2 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -35,6 +35,9 @@ load_config() { : "${RCLONE_STATS_INTERVAL:=1m}" : "${RCLONE_TRANSFERS:=2}" "${RCLONE_CHECKERS:=4}" "${RCLONE_ONEDRIVE_CHUNK_SIZE:=80Mi}" : "${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_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}" : "${ENABLE_NEXTCLOUD_MAINTENANCE:=true}" "${NC_OCC_USER:=apache}" @@ -116,10 +119,16 @@ init_runtime() { done RUN_ID="${ARCHIVE_PREFIX}_$(date -u +%Y-%m-%d_%H-%M-%S)_$(python3 -c 'import secrets; print(secrets.token_hex(4))')" LOG_FILE="$LOG_DIR/${OPERATION}_${RUN_ID}.log" + RUN_STARTED_EPOCH=$(date +%s) + BACKUP_PHASE=preflight + UPLOAD_STATUS=NOT_STARTED + if [[ $ENABLE_UPLOAD != true ]]; then UPLOAD_STATUS=DISABLED; fi # Journald already captures service stdout. No dependency on systemd-cat in terminals. # Keep the log pipe alive while systemd sends TERM to the whole control group. # tee exits on EOF after the recovery traps close their writers. + exec {REPORT_STDOUT}>&1 {REPORT_STDERR}>&2 exec > >(trap '' INT TERM; exec tee -a "$LOG_FILE") 2>&1 + REPORT_LOGGER_PID=$! echo "== $OPERATION $RUN_ID ==" local security_context if security_context=$(id -Z 2>/dev/null); then echo "-- SELinux process context: $security_context"; fi @@ -484,17 +493,32 @@ finish() { else resume_services || code=1 fi + local report_sizes="$LOG_DIR/report_$RUN_ID.sizes.json" + if [[ ${CHECK:-false} != true && $ENABLE_MAIL_REPORT == true && $OPERATION == backup ]]; then + # Collect after service recovery but before successful staging cleanup. + python3 "$LIB_DIR/report.py" snapshot "$report_sizes" "${STAGING_DIR:-}" "${RUN_DIR:-${PARTIAL_DIR:-}}" || + echo 'WARNING: Report sizes unavailable' + fi if [[ $code == 0 && -n ${STAGING_DIR:-} ]]; then rm -rf -- "$STAGING_DIR" || code=1; fi if [[ $code != 0 ]]; then echo "FAILED (exit $code). Retained work: ${STAGING_DIR:-none}"; fi + # Close the logger's writers and wait for EOF so the mail tail includes the + # final recovery/status messages. Reporting itself uses the original stdout. + exec 1>&"$REPORT_STDOUT" 2>&"$REPORT_STDERR" + exec {REPORT_STDOUT}>&- {REPORT_STDERR}>&- + wait "$REPORT_LOGGER_PID" || code=1 # Persist status separately from --check; failed status writes also fail the job. if [[ ${CHECK:-false} != true ]]; then helper status "$WORKDIR" "$OPERATION" "$RUN_ID" "$code" || code=1 fi if [[ ${CHECK:-false} != true && $ENABLE_MAIL_REPORT == true && $OPERATION == backup ]]; then - if [[ -x /usr/sbin/sendmail && $MAIL_TO != *$'\n'* && $MAIL_TO != *$'\r'* ]]; then - { printf 'To: %s\nSubject: [app-backup] exit=%s %s\nContent-Type: text/plain; charset=UTF-8\n\nLog: %s\n' "$MAIL_TO" "$code" "$RUN_ID" "$LOG_FILE"; tail -n 80 "$LOG_FILE"; } | - /usr/sbin/sendmail -t || echo "WARNING: report delivery failed" - else echo "WARNING: mail report unavailable (sendmail/recipient)"; fi + 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" \ + --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" \ + --tail-lines "$MAIL_INCLUDE_LOG_TAIL_LINES" --timeout "$MAIL_SEND_TIMEOUT" 2>&1 | tee -a "$LOG_FILE" + rm -f -- "$report_sizes" fi exit "$code" } diff --git a/lib/report.py b/lib/report.py new file mode 100644 index 0000000..6b62872 --- /dev/null +++ b/lib/report.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Build private UTF-8 backup reports; never read executable config/credentials. + +Sizes are captured before staging cleanup. Delivery uses only the local MTA and +has a timeout; a reporting failure never turns a failed backup into a success. +""" +import argparse +from collections import deque +import datetime as dt +from email.message import EmailMessage +from email.policy import SMTP +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import time + + +def allocated(path): + """Report disk allocation, like du in the old report, without following links.""" + result = subprocess.run(['du', '-s', '-B1', '--', str(path)], + capture_output=True, text=True, timeout=30, check=True) + return int(result.stdout.split()[0]) + + +def snapshot(output, staging, archives): + data = {'staged': {}, 'archives': {}} + if staging and Path(staging).is_dir(): + root = Path(staging) + # Measure components independently; the total accounts for shared links. + paths = sorted((root/'files').glob('*')) + [root/'db', root/'meta'] + for path in paths: + if path.exists(): + data['staged'][path.name] = allocated(path) + data['staging_total'] = allocated(root) + if archives and Path(archives).is_dir(): + for path in sorted(Path(archives).glob('*.tar.*')): + if path.is_file() and not path.is_symlink(): + data['archives'][path.name] = path.stat().st_size + Path(output).write_text(json.dumps(data), encoding='utf-8') + + +def human(value): + for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB'): + if value < 1024 or unit == 'TiB': + return f'{value:.1f} {unit}' + value /= 1024 + + +def deliver(args): + host = socket.getfqdn() + now = dt.datetime.now().astimezone() + state = 'SUCCESS' if args.exit_code == 0 else 'FAIL' + try: + sizes = json.loads(Path(args.sizes).read_text()) + except (OSError, ValueError): + sizes = {} + # Bounded memory even for long-running rclone logs. Tail length is configurable. + with open(args.log, encoding='utf-8', errors='replace') as stream: + tail = ''.join(deque(stream, maxlen=args.tail_lines)) + upload = {'NOT_STARTED': 'Nicht gestartet', 'DISABLED': 'Deaktiviert (nur lokal)', + 'COPY': 'Nicht abgeschlossen: Upload', 'CHECK': 'Nicht abgeschlossen: Rückleseprüfung', + 'MARKER': 'Nicht abgeschlossen: Abschlussmarker', + 'VERIFIED': 'OK (Inhalt und Abschlussmarker zurückgelesen)'}[args.upload_status] + body = [ + 'Backup Report', '=============', f'Zeit: {now.isoformat(timespec="seconds")}', + f'Host: {host}', f'Status: {state}', f'Exit code: {args.exit_code}', + f'Dauer: {max(0, int(time.time()) - args.started)}s', f'Lauf: {args.run}', + f'Letzte Phase: {args.phase}', '', f'Config: {args.config}', f'Log: {args.log}', + f'Workdir: {args.workdir}', f'Archive dir: {args.archive_dir}', + f'Kompression: {args.compression}', '', 'Remote', '------', + f'Upload: {str(args.upload_status != "DISABLED").lower()}', + f'Remote base: {args.remote or "nicht konfiguriert"}', f'Upload Status: {upload}', + 'Die separate Update-Abnahme steht in result.txt; dies ist der Backup-Bericht.', + '', 'Größen', '------', 'Staging: belegter Speicher vor Bereinigung; Archive: Dateigrößen.', + ] + for label, value in sizes.get('staged', {}).items(): + body.append(f'{label} staged: {human(value)}') + body.append('Staging total: '+(human(sizes['staging_total']) if 'staging_total' in sizes else 'nicht verfügbar')) + body.extend(['', 'Archive', '-------']) + for name, value in sizes.get('archives', {}).items(): + body.append(f'{name}: {human(value)} ({value} Bytes)') + if not sizes.get('archives'): + body.append('Keine Archive erfasst.') + else: + body.append('Archive total: '+human(sum(sizes['archives'].values()))) + 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}' + if not args.to.strip() or not sender.strip(): + raise ValueError('Mail recipient and sender must not be empty') + message['From'] = sender + message['To'] = args.to + message['Subject'] = f'{args.subject_prefix} {state} {host} {args.run}' + message['Date'] = now + message.set_content('\n'.join(body), charset='utf-8') + raw = message.as_bytes() + Path(args.output).write_bytes(raw) + # -oi prevents a single dot in a log line from terminating the message. + # Headers supply recipients; no shell or SMTP credentials are involved. + subprocess.run([args.sendmail, '-oi', '-t'], input=raw, check=True, timeout=args.timeout) + print(f'Mail report handed to local MTA: {args.output}') + + +def main(): + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest='command', required=True) + snap = commands.add_parser('snapshot') + for name in ('output', 'staging', 'archives'): + snap.add_argument(name) + mail = commands.add_parser('deliver') + 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) + for name in ('exit-code', 'started', 'tail-lines', 'timeout'): + mail.add_argument('--'+name, type=int, required=True) + args = parser.parse_args() + if args.command == 'snapshot': + snapshot(args.output, args.staging, args.archives) + else: + if not 0 <= args.tail_lines <= 10000 or not 1 <= args.timeout <= 300: + raise ValueError('Invalid mail tail length or timeout') + deliver(args) + + +if __name__ == '__main__': + os.umask(0o077) + try: + main() + except Exception as exc: + # Do not echo header values or MTA arguments which might include addresses. + print(f'WARNING: mail report failed ({type(exc).__name__}); inspect local report/MTA.', file=sys.stderr) + sys.exit(1) diff --git a/tests/test_backup.py b/tests/test_backup.py index 6e79537..faf2e97 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -16,6 +16,8 @@ import tarfile import tempfile import time import unittest +from email import policy +from email.parser import BytesParser REPO = Path(__file__).resolve().parents[1] SPEC = importlib.util.spec_from_file_location("backup_set", REPO / "lib/backup_set.py") @@ -581,6 +583,7 @@ if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv: return canonical def test_automatic_package_upgrade_and_combined_rollback(self): + self.enable_report() canonical=self.configure_upgrade() original=canonical.read_bytes() package=self.build_package() @@ -588,6 +591,10 @@ if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv: self.assertEqual(result.returncode,0,result.stdout+result.stderr) state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0]) self.assertIn('SUCCESS',(state/'result.txt').read_text()) + message,body=self.read_report() + self.assertEqual(str(message['From']),'app-backup@example.invalid') + 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()) self.assertIn('ENABLE_REMOTE_RETENTION=false',canonical.read_text()) self.assertEqual(self.cnf.stat().st_mode & 0o777,0o600) @@ -948,6 +955,101 @@ if '--no-block' not in args: with self.config.open('a') as f: f.write(f'ENABLE_UPLOAD=true\nRCLONE_CONFIG="{path}"\nRCLONE_REMOTE_BASE="crypt:sets"\nRCLONE_RETRIES=1\nRCLONE_LOW_LEVEL_RETRIES=1\n') return path + def enable_report(self, behaviour='capture'): + # This fake MTA captures stdin locally. Tests never send any real mail. + path=self.bin/'capture-sendmail' + path.write_text(f'''#!/usr/bin/env python3 +import pathlib, sys, time +if {behaviour!r} == 'timeout': time.sleep(10) +pathlib.Path({str(self.base/'mail.eml')!r}).write_bytes(sys.stdin.buffer.read()) +pathlib.Path({str(self.base/'mail-args')!r}).write_text(' '.join(sys.argv[1:])) +sys.exit(42 if {behaviour!r} == 'fail' else 0) +''') + path.chmod(0o755) + with self.config.open('a') as f: + f.write(f'''ENABLE_MAIL_REPORT=true +MAIL_TO='Johannes ' +MAIL_FROM='app-backup@example.invalid' +MAIL_SUBJECT_PREFIX='[Backup-Test]' +MAIL_INCLUDE_LOG_TAIL_LINES=200 +MAIL_SENDMAIL_BIN='{path}' +MAIL_SEND_TIMEOUT=1 +REPORT_SECRET='config-content-must-not-enter-the-mail' +''') + + def read_report(self): + raw=(self.base/'mail.eml').read_bytes() + message=BytesParser(policy=policy.default).parsebytes(raw) + return message,message.get_content() + + def test_report_success_has_sizes_after_cleanup_and_preserves_headers(self): + self.enable_report() + run=self.backup() + message,body=self.read_report() + self.assertIn('[Backup-Test] SUCCESS',str(message['Subject'])) + 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:', + 'wordpress staged:','Staging total:','wordpress.tar.zst:', + 'Deaktiviert (nur lokal)',f'SUCCESS: {run.name}']: + self.assertIn(value,body) + self.assertNotIn('config-content-must-not-enter-the-mail',body) + self.assertFalse(list((self.base/'work/staging').iterdir())) + self.assertEqual((self.base/'mail-args').read_text(),'-oi -t') + saved=next((self.base/'logs').glob('report_*.eml')) + self.assertEqual(saved.read_bytes(),(self.base/'mail.eml').read_bytes()) + self.assertEqual(saved.stat().st_mode & 0o777,0o600) + + def test_report_failure_names_phase_and_has_final_error(self): + self.enable_report() + 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('Exit code: 42',body) + self.assertIn('Letzte Phase: DB-Dump:',body) + self.assertIn('FAILED (exit 42)',body) + self.assertEqual(json.loads(self.state.read_text()),self.initial) + + def test_report_upload_readback_failure_is_not_reported_as_ok(self): + self.enable_report() + self.crypt_config() + self.run_script('app-backup.sh',ok=False,TEST_FAIL_CHECK='true') + _,body=self.read_report() + self.assertIn('Status: FAIL',body) + self.assertIn('Upload Status: Nicht abgeschlossen: Rückleseprüfung',body) + self.assertNotIn('Upload Status: OK',body) + + def test_report_mta_failure_keeps_backup_result_and_local_message(self): + self.enable_report('fail') + result=self.run_script('app-backup.sh') + self.assertIn('WARNING: mail report failed',result.stdout) + self.assertEqual(json.loads((self.base/'work/last-backup.json').read_text())['exit_code'],0) + self.assertTrue(list((self.base/'logs').glob('report_*.eml'))) + failed=self.run_script('app-backup.sh',ok=False,TEST_FAIL_TOOL='mariadb-dump') + self.assertEqual(failed.returncode,42) + self.assertIn('WARNING: mail report failed',failed.stdout) + + def test_report_timeout_is_bounded_and_header_injection_is_rejected(self): + self.enable_report('timeout') + result=self.run_script('app-backup.sh') + self.assertIn('mail report failed (TimeoutExpired)',result.stdout) + self.assertFalse((self.base/'mail.eml').exists()) + self.enable_report() + with self.config.open('a') as f: f.write("MAIL_TO=$'recipient\\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_check_and_restore_do_not_send_backup_messages(self): + self.enable_report() + self.run_script('app-backup.sh','--check') + self.assertFalse((self.base/'mail.eml').exists()) + run=self.backup() + (self.base/'mail.eml').unlink() + self.run_script('app-restore.sh','--local-run',run,'--verify-only') + self.assertFalse((self.base/'mail.eml').exists()) + def test_real_crypt_upload_readback_remote_restore(self): self.crypt_config() # Exercise periodic stderr statistics during a real throttled crypt diff --git a/tools/build-update-package.py b/tools/build-update-package.py index 5637e64..72c3b44 100644 --- a/tools/build-update-package.py +++ b/tools/build-update-package.py @@ -11,7 +11,7 @@ import tarfile ROOT = Path(__file__).resolve().parents[1] FILES = ['install.sh', 'update.sh', 'README.md', 'Dokumentation.md', 'UPDATE_ANLEITUNG.md', - 'lib/common.sh', 'lib/locking.sh', 'lib/backup_set.py', 'lib/update-discover.py', + 'lib/common.sh', 'lib/locking.sh', 'lib/backup_set.py', 'lib/report.py', 'lib/update-discover.py', 'usr-local-sbin/app-backup.sh', 'usr-local-sbin/app-restore.sh', 'systemd/app-backup.service', 'systemd/app-backup.timer', 'logrotate/app-backup', 'etc/app-backup/app-backup.conf'] diff --git a/usr-local-sbin/app-backup.sh b/usr-local-sbin/app-backup.sh index 8949341..b189e22 100755 --- a/usr-local-sbin/app-backup.sh +++ b/usr-local-sbin/app-backup.sh @@ -36,12 +36,14 @@ printf 'meta\tmetadata\n' > "$PLAN" printf '%s\n' "$RUN_ID" > "$STAGING_DIR/meta/run-id.txt" # The config may contain secrets or executable substitutions; never copy it into reports. printf 'format=2\ncompressor=%s\n' "$COMPRESSOR" > "$STAGING_DIR/meta/format.txt" +BACKUP_PHASE='Dienste anhalten' quiesce for i in "${!DB_LABELS[@]}"; do assert_services_quiescent label=${DB_LABELS[i]} out="$STAGING_DIR/db/${label#db-}.sql" + BACKUP_PHASE="DB-Dump: ${DB_NAMES[i]}" echo "-- Dump: ${DB_NAMES[i]}" # Defaults-extra-file must be the first option. lock-all-tables also covers MyISAM; # this can block ALL server databases during each dump (see documentation). @@ -59,10 +61,11 @@ for i in "${!LABELS[@]}"; do check_space "$STAGING_ROOT" mkdir -p -- "$STAGING_DIR/$root" exclusions "$label" + BACKUP_PHASE="Dateikopie: $label" echo "-- Copy: $label ($src)" # Never accept vanished-file (24) or partial-copy (23) as success. # Parent retains both locks; rsync itself needs neither inherited descriptor. - rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$src/" "$STAGING_DIR/$root/" 8>&- 9>&- + rsync -aHAX --stats --numeric-ids "${EXCLUDES[@]}" -- "$src/" "$STAGING_DIR/$root/" 8>&- 9>&- assert_services_quiescent printf '%s\t%s\n' "$label" "$src" >> "$PLAN" done @@ -70,6 +73,7 @@ done # use this frozen private staging tree, so application downtime ends here. assert_services_quiescent echo '-- Copy window complete; writer guards verified' +BACKUP_PHASE='Dienste wiederherstellen' resume_services || die 'Failed to resume original service state' while IFS=$'\t' read -r label _; do @@ -77,6 +81,7 @@ while IFS=$'\t' read -r label _; do elif [[ $label == db-* ]]; then root="db/${label#db-}.sql" else root="files/$label"; fi check_space "$ARCHIVE_DIR" + BACKUP_PHASE="Archiv: $label" echo "-- Archive: $label" if [[ $COMPRESSOR == zstd ]]; then out="$PARTIAL_DIR/$label.tar.zst" @@ -89,6 +94,7 @@ while IFS=$'\t' read -r label _; do fi mv -- "$out.partial" "$out" done < "$PLAN" +BACKUP_PHASE='Lokale Archivprüfung' helper create "$PARTIAL_DIR" "$RUN_ID" "$PLAN" RUN_DIR="$ARCHIVE_DIR/$RUN_ID" mv -- "$PARTIAL_DIR" "$RUN_DIR" @@ -99,10 +105,13 @@ if [[ $ENABLE_UPLOAD == true ]]; then remote_run="${RCLONE_REMOTE_BASE%/}/$RUN_ID" # Upload COMPLETE last. An interrupted upload cannot look like a valid set. echo '-- Remote upload started (archives and manifest; completion marker withheld)' + BACKUP_PHASE='Remote-Upload'; UPLOAD_STATUS=COPY rc copy "$RUN_DIR" "$remote_run" --exclude /COMPLETE --exclude /UPLOADED --immutable echo '-- Remote upload finished; reading all uploaded files back for verification' + BACKUP_PHASE='Remote-Rückleseprüfung'; UPLOAD_STATUS=CHECK rc check "$RUN_DIR" "$remote_run" --download --exclude /COMPLETE --exclude /UPLOADED echo '-- Remote readback passed; publishing completion marker' + BACKUP_PHASE='Remote-Abschlussmarker'; UPLOAD_STATUS=MARKER rc copyto "$RUN_DIR/COMPLETE" "$remote_run/COMPLETE" --immutable # Command substitution strips trailing newlines, which could accept a marker # that the strict restore parser rejects. Compare the exact returned bytes. @@ -115,16 +124,19 @@ sys.exit(0 if local.stat().st_size == remote.stat().st_size and local.read_bytes PY date -u -Is > "$RUN_DIR/UPLOADED" sync -f "$RUN_DIR" + UPLOAD_STATUS=VERIFIED echo '-- Remote backup fully read back and verified' fi if [[ $SKIP_RETENTION == true ]]; then + BACKUP_PHASE='Abgeschlossen (Retention übersprungen)' echo "SUCCESS: $RUN_ID (retention skipped for acceptance run)" exit 0 fi # Retention works on complete v2 sets only, after a new successful backup. # Older flat archives, partial runs and unknown folders are deliberately preserved. +BACKUP_PHASE=Retention valid="$STAGING_DIR/local-retention.txt" : > "$valid" for dir in "$ARCHIVE_DIR/${ARCHIVE_PREFIX}_"*; do @@ -160,8 +172,9 @@ if [[ $ENABLE_UPLOAD == true && $ENABLE_REMOTE_RETENTION == true ]]; then done < "$STAGING_DIR/remote-delete.txt" fi # Per-run filenames need age-based cleanup, not rotating an empty successor file. -for operation in backup restore; do - find "$LOG_DIR" -maxdepth 1 -type f -name "${operation}_${ARCHIVE_PREFIX}_*.log" \ +for operation in backup restore report; do + find "$LOG_DIR" -maxdepth 1 -type f \( -name "${operation}_${ARCHIVE_PREFIX}_*.log" -o -name "${operation}_${ARCHIVE_PREFIX}_*.eml" \) \ -mtime "+${LOG_RETENTION_DAYS}" ! -name "$(basename "$LOG_FILE")" -delete done +BACKUP_PHASE=Abgeschlossen echo "SUCCESS: $RUN_ID"