Restore detailed backup mail reports with bounded local delivery

This commit is contained in:
admin committed 2026-09-10 16:45:18 +02:00
1 parent 3374019ad6
commit 99da6460d2
10 files changed
+396 -13

No files matched your search

+57 -1
View File
@@ -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@<FQDN>` 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.
+27 -1
View File
@@ -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@<FQDN>
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.
+20 -1
View File
@@ -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
+6
View File
@@ -38,6 +38,12 @@ RCLONE_ONEDRIVE_CHUNK_SIZE="80Mi"
RCLONE_BWLIMIT="0"
ENABLE_MAIL_REPORT="true"
MAIL_TO="johannes"
# Empty means app-backup@<FQDN>. 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"
+3 -2
View File
@@ -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
+28 -4
View File
@@ -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@<FQDN>.
: "${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"
}
+136
View File
@@ -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)
+102
View File
@@ -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 <backup@example.invalid>'
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
+1 -1
View File
@@ -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']
+16 -3
View File
@@ -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"