diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..52f08dd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +** +!tests/Dockerfile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f773b59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Credentials and operational backup data must never enter source control. +*.cnf +!*.cnf.example +rclone.conf +*.sql +*.tar +*.tar.gz +*.tar.zst +__pycache__/ +*.pyc diff --git a/Dokumentation.md b/Dokumentation.md new file mode 100644 index 0000000..80d7e09 --- /dev/null +++ b/Dokumentation.md @@ -0,0 +1,283 @@ +# Dokumentation app-backup v2 + +Stand des Reviews: 10. September 2026. Untersucht wurde der Gitea-Stand `099688e` einschließlich Backup, Restore, Installer, Konfiguration, systemd und bisheriger README. Die Erweiterung wird als zusammengehöriges Paket installiert; einzelne Skripte dürfen nicht ohne die gemeinsamen Bibliotheken aktualisiert werden. + +## Ergebnis der Codeanalyse + +| Befund im Ausgangsstand | Auswirkung | Umsetzung | +| --- | --- | --- | +| **Kritisch:** `app-restore.sh` enthielt nur Initialisierung/Hilfsfunktionen und griff am Ende auf das undefinierte `RUN_DIR` zu. | Die dokumentierten Restore-Aufrufe waren nicht implementiert. | Vollständige Prüfung, Entpacken, Restore-Plan und explizite Ausführung. | +| **Kritisch:** Dumps/Kopien wurden teilweise in `&& … || true` bzw. Warnungszweigen ausgeführt. | Ein Lauf konnte trotz fehlender DB oder Dateien erfolgreich erscheinen; Bash-Fehlerbehandlung wurde in solchen Kontexten unterdrückt. | Direkte Aufrufe, `set -Eeuo pipefail`, `inherit_errexit`; Fehlercodes werden weitergegeben. | +| **Kritisch:** Nextcloud-Wartungsmodus wurde nach dem DB-Dump vor der Dateikopie ausgeschaltet. WordPress blieb live. | Datenbank und Dateien konnten unterschiedliche Zustände abbilden. | Gemeinsames Ruhefenster über sämtliche Dumps und Dateikopien. | +| **Hoch:** Maildienste hatten keine zuverlässige Wiederanlaufbehandlung; Fehler und rsync-Teilergebnisse wurden ignoriert. | Unvollständige Mailsicherung oder unbeabsichtigte Dienstausfälle. | Zustandsabhängige Stopps, geprüfter Wiederanlauf, Exit-Code und Wiederanlaufdatei. | +| **Hoch:** Crypt-Prüfung ließ u. a. `no_data_encryption=true` und reine Namensverschleierung zu. Restore verlangte dagegen explizite Optionen, obwohl sichere Defaults gültig sind. | Inhalt konnte trotz Remote-Typ `crypt` unverschlüsselt bleiben; gültige Remotes konnten abgelehnt werden. | Gemeinsame Prüfung von Typ, Inhalt, Dateinamen und Verzeichnisnamen; Defaults werden berücksichtigt. | +| **Hoch:** `--checksum` und Remote-Listing ersetzten keine Prüfung des entschlüsselten Inhalts. | Transfererfolg war kein belastbarer Rücklesetest. | Vollständiger `rclone check --download` vor Veröffentlichung des Abschlussmarkers. | +| **Hoch:** Retention lief vor dem neuen Backup und löschte remote einzelne alte Objekte rekursiv. | Alte Wiederherstellungspunkte konnten verloren gehen, bevor ein neuer verfügbar war; Sätze konnten unvollständig werden. | Retention ausschließlich nach einem neuen erfolgreichen Lauf, nur vollständige v2-Ordner, Mindestanzahl. | +| **Hoch:** Drei nicht als Vorlage gekennzeichnete DB-Dateien enthielten mögliche echte Zugangsdaten. | Geheimnisse im aktuellen Baum und in der Git-Historie. | Dateien entfernt, ausschließlich Vorlagen, `.gitignore`; Passwortrotation bleibt Betriebsaufgabe. Keine Historienumschreibung. | +| **Hoch:** Gitea war im ausgelieferten Config-Beispiel nicht eingeschaltet; `APP_DATA_PATH` allein deckt externe Repositories/custom nicht ab. | Erwartete Gitea-Daten konnten fehlen. | Sichtbarer Aktivierungsschalter und zusätzliche Pfade; erforderliche Verzeichnisse fehlen nicht stillschweigend. | +| **Mittel:** Konfigurierbare WordPress-Excludes wurden nicht vollständig angewendet; Nextcloud `data/` war fest angenommen. | Falscher Umfang bei abweichender Verzeichnisstruktur. | Ableitung relativer Excludes aus den konfigurierten Pfaden, auch beim Restore. | +| **Mittel:** Unkomprimierte Zwischenarchive, zstd `-19 -T0`, flache Ablage und fehlendes Manifest. | Hohe IO-/CPU-/Speicherlast; keine sichere Zuordnung vollständiger Läufe. | Streaming-Kompression, begrenzte Threads, eigene Laufordner und strukturierte Metadaten. | +| **Mittel:** Installer aktivierte den Timer sofort und überschrieb Unit-Dateien ohne Rückweg. | Ungeprüfte Erstläufe; individuelle Zeitpläne konnten verloren gehen. | Config-/Timer-Erhalt, `.dist`-Vorlagen, gemeinsame Sperre, Rollback-Paket, frische Timer zunächst aus. | +| **Mittel:** Eigene Logdatei je Lauf plus `logrotate`/`notifempty`. | Rotierte Dateien konnten wegen leerer Nachfolgedateien dauerhaft liegen bleiben. | Altersbasierte Bereinigung neuer Laufprotokolle nach erfolgreichen Backups. | + +Die vollständige Rückleseprüfung erhöht den Datenverkehr um ungefähr die hochgeladene komprimierte Datenmenge. Dieser Aufwand ist bewusst Teil des Sicherheitskonzepts. + +## Sicherheitsziel und Grenzen + +Das Paket soll Fehler sichtbar machen, vollständige Sicherungssätze erhalten und eine nachprüfbare Wiederherstellung ermöglichen. Es kann keine absolute Datensicherheit garantieren. Folgende Ebenen sind getrennt zu betrachten: + +1. **Vertraulichkeit:** Das Crypt-Remote verschlüsselt Inhalt und Namen vor dem Upload. Lokales Staging, lokale Archive und entpackte Restores sind Klartext und benötigen ein verschlüsseltes Servervolume, sichere Dateirechte und Zugriffsschutz. +2. **Integrität:** SHA-256, Kompressions-/Tar-Prüfung, Manifest und Rückleseprüfung erkennen fehlende bzw. veränderte Bestandteile. Das Manifest ist nicht digital signiert. Wer Archive und Manifest gemeinsam kontrolliert, kann neue passende Prüfsummen erzeugen. +3. **Konsistenz:** Alle relevanten Writer müssen während der Dumps/Kopien ruhen. Nicht erfasste Cronjobs, externe DB-Schreiber, systemd-Sockets, Container, S3-Speicher oder Remote-Mounts werden nicht automatisch eingefroren. Ein erfolgreicher Dateivergleich beweist keine Anwendungskonsistenz. +4. **Verfügbarkeit:** OneDrive mit demselben löschberechtigten Konto ist keine unabhängige, unveränderliche Kopie. Für Schutz vor kompromittiertem Server/Konto, Ransomware oder versehentlicher Löschung eine weitere getrennte bzw. offline/immutable Sicherung vorsehen. +5. **Wiederherstellbarkeit:** Ein getesteter Restore mit Anwendungskontrollen ist erforderlich. Passende Anwendungsversionen, Systempakete, UID/GID, Datenbankrechte und außerhalb des Umfangs liegende Schlüssel/Konfigurationen müssen verfügbar sein. + +Crypt verschlüsselt Daten beim Zugriff über das Crypt-Remote; der darunterliegende OneDrive-Zugriff umgeht diese Schicht. Rclone speichert Crypt-Passwörter standardmäßig nur verschleiert in seiner Konfiguration. Passwort **und gegebenenfalls Salt/zweites Passwort** müssen unabhängig vom gesicherten Server verfügbar sein. Nicht allein innerhalb desselben verschlüsselten Backups aufbewahren. Dateigrößen und Zeitinformationen sind nicht vollständig verborgen. Quelle: [rclone Crypt](https://rclone.org/crypt/). + +## Architektur und Ablauf + +```mermaid +flowchart TD + A[Config und gemeinsame Sperre] --> B[Quellen / Rechte / DB-Verbindungen / Crypt / Speicher prüfen] + B --> C[Wartungsmodus und aktive Writer stoppen] + C --> D[Datenbanken dumpen und Dateien ins private Staging kopieren] + D --> E[Ursprünglichen Dienstzustand wiederherstellen] + E --> F[Je Komponente komprimieren und Tar-Inhalt prüfen] + F --> G[Manifest und lokaler COMPLETE-Marker] + G --> H[Archive und Manifest über Crypt hochladen] + H --> I[Alles entschlüsselt zurücklesen und vergleichen] + I --> J[COMPLETE zuletzt hochladen und zurücklesen] + J --> K[Retention vollständiger Sätze / Status / Bericht] +``` + +`ENABLE_UPLOAD=false` beendet den Datenpfad nach dem vollständigen lokalen Satz und führt nur lokale Retention aus. Dies ist kein Offsite-Backup. + +Dateikopien erhalten mit `rsync -aHAX --numeric-ids` Eigentümer, Modus, Symlinks, Hardlinks innerhalb einer Komponente, ACLs und Extended Attributes. Tar erhält diese Metadaten einschließlich SELinux. Plattform-/Dateisystemunterstützung ist Voraussetzung. Externe Symlink-Ziele werden nicht automatisch gesichert; absolute Symlinks als einzelne Einträge bleiben erhalten. Geräte, FIFOs und andere Spezialdateien werden von der Archivprüfung abgelehnt. Zusätzliche Pfade sind deshalb Datenverzeichnisse, kein Ersatz für ein vollständiges Systemimage. + +Die Kompression schreibt direkt aus tar in `.partial`-Dateien. Erst erfolgreiche Pipelines werden umbenannt. zstd nutzt standardmäßig Stufe 6 und zwei Threads. `sync -f` vor Fortsetzung bzw. Retention fordert die Persistierung des lokalen Satzes an; Hardware-/Storagefehler bleiben eine eigene Ebene. + +## Format und Komponenten + +Ein neuer Lauf heißt beispielsweise `appbackup_2026-09-10_02-30-00_a1b2c3d4`. Zeitangaben im Namen sind UTC; der systemd-Zeitplan nutzt die Serverzeitzone. Der Zufallssuffix verhindert Namenskollisionen. + +```text +/var/backups/app-backup/archives/appbackup_.../ + meta.tar.zst + db-wordpress.tar.zst + db-nextcloud.tar.zst + wordpress.tar.zst + nextcloud.tar.zst + nextcloud-data.tar.zst + ... + manifest.json + COMPLETE + UPLOADED # nur lokal, nach erfolgreicher Remote-Rückleseprüfung +``` + +| Komponente | Inhalt / Ziel aus lokaler Config | +| --- | --- | +| `meta` | Laufkennung, Format und Liste der Quellen; keine vollständige Config-Datei | +| `db-wordpress`, `db-nextcloud`, `db-gitea` | Ein SQL-Dump je aktivierter Anwendung | +| `wordpress` | `WP_DIR`, ohne konfigurierte untergeordnete Nextcloud-Pfade und `WP_EXCLUDES` | +| `nextcloud` | `NC_DIR`, ohne konfiguriertes untergeordnetes `NC_DATA_DIR` | +| `nextcloud-data` | `NC_DATA_DIR`, sofern aktiviert | +| `gitea`, `gitea-etc` | `GITEA_DATA_DIR`, `GITEA_ETC_DIR` | +| `gitea-repos`, `gitea-custom` | Optionale explizite externe Repository-/Custom-Pfade | +| `mail`, `postfix`, `dovecot` | Mailstore und beide Konfigurationsverzeichnisse | +| `mail-home-0`, `mail-home-1`, … | Reihenfolge von `MAIL_HOME_DIRS`, mit Excludes und Dateisystemgrenze | +| `extra-0`, `extra-1`, … | Reihenfolge von `EXTRA_PATHS` | + +Das Manifest enthält Formatversion, Laufkennung, Host, Erstellungszeit und je Archiv Label, Quellbezeichnung, erlaubten Tar-Wurzelpfad, Dateiname, Größe und SHA-256. Der `COMPLETE`-Marker bindet sich an den SHA-256-Wert dieses Manifests. Ein lokaler vollständiger Satz kann existieren, obwohl der Upload danach fehlgeschlagen ist; erst `UPLOADED` belegt den erfolgreichen Remote-Abschluss dieses lokalen Laufs. + +Indexbasierte Pfade müssen ihre Reihenfolge behalten. Bei `extra-*` und `mail-home-*` lehnt der automatische Restore eine vom Manifest abweichende Pfadzuordnung ab. Für bewusst geänderte Ziele zuerst entpacken und die Zuordnung manuell prüfen. + +## Voraussetzungen und Konfiguration + +Zielumgebung: Rocky Linux 10 mit systemd und nativen Anwendungen. Die automatische Datenbankbehandlung ist auf MariaDB/MySQL ausgerichtet; PostgreSQL, SQLite-Livekopien und Container-Volume-Snapshots benötigen ein eigenes Verfahren. + +Erforderliche Werkzeuge: Bash ≥ 4.4 (`inherit_errexit`), Python ≥ 3.9, GNU coreutils/findutils/tar, `flock`, `rsync`, `gzip`/`zstd`, `systemctl`; bei Nextcloud `sudo` und CLI-PHP; für Datenbanken `mariadb-dump`/`mariadb` oder kompatible MySQL-Clients. Ein konfiguriertes Crypt-Remote benötigt rclone mit den verwendeten Crypt-Flags und `check --download`; die Integrationstests laufen auch mit rclone 1.60.1. Für den tatsächlichen Server einen aktuell gepflegten rclone-Stand einsetzen und `--check` ausführen. + +Ein Ausgangspunkt auf Rocky, abhängig von aktivierten Paketquellen und vorhandener PHP-Version: + +```bash +sudo dnf install bash python3 tar rsync zstd gzip util-linux coreutils findutils sudo mariadb +``` + +Rclone und das zur vorhandenen Nextcloud-Installation passende CLI-PHP aus euren gepflegten Paketquellen bereitstellen. Kein automatischer Paket-/PHP-/Anwendungsupgrade durch den Installer. + +`/etc/app-backup/app-backup.conf` ist ausführbare Bash-Konfiguration: ausschließlich root darf sie verändern. DB-CNF und rclone-Konfiguration müssen reguläre, root-eigene Dateien ohne Gruppen-/Fremdrechte sein (`600`); die Hauptkonfiguration darf nicht gruppen-/weltbeschreibbar sein, empfohlen ebenfalls `600`. Arbeitsverzeichnisse gehören root und werden auf `700` gesetzt. Direkte Symlinks als Arbeitsverzeichnisse und überschneidende Quellen/Arbeitsbereiche werden abgelehnt. Übergeordnete Verzeichnisse müssen ebenfalls administrativ geschützt sein. + +Die CLI-Option `--config FILE` erlaubt getrennte Backup- und Restore-Einstellungen. Die Config hat Vorrang vor geerbten Umgebungswerten. Auf `DRY_RUN=true` oder alte `RESTORE_DB`-/`RESTORE_FILES`-Umgebungsvariablen nicht mehr verlassen: Die dokumentierten CLI-Schalter sind maßgeblich. + +### Crypt und Schlüssel + +Beispiel der relevanten Remote-Optionen, ohne Zugangsdaten: + +```ini +[OneDriveCrypt] +type = crypt +remote = OneDrive:GESCHUETZTER_BACKUP_ORDNER +filename_encryption = standard +directory_name_encryption = true +no_data_encryption = false +``` + +Vorhandene Passwörter/Salt und Zuordnung **nicht neu generieren**, wenn bestehende Backups weiter lesbar bleiben sollen. Sichere Defaults dürfen im Config-Block fehlen. `off`, `obfuscate` und deaktivierte Inhaltsverschlüsselung werden abgelehnt. Die Befehle setzen zusätzlich sichere Crypt-/TLS-Flags. Remote-Definitionen über `RCLONE_CONFIG_*`-Umgebungsvariablen sind nicht unterstützt; `RCLONE_CONFIG_PASS` für eine verschlüsselte Konfigurationsdatei ist ausgenommen. Die Config-Ausgabe wird direkt intern geparst und nicht protokolliert. + +`RCLONE_REMOTE_BASE` muss auf ein benanntes Crypt-Remote mit eigenem nichtleerem Unterpfad zeigen, beispielsweise `OneDriveCrypt:gemini`. Dessen verschlüsselter Basisordner muss bereits erreichbar sein. Kein Fallback auf das unverschlüsselte OneDrive-Remote. Den Zugriff immer mit derselben root-Konfiguration prüfen: + +```bash +sudo rclone --config /root/.config/rclone/rclone.conf lsd OneDriveCrypt: +``` + +`rclone check --download` vergleicht die heruntergeladenen Inhalte und benötigt keinen gemeinsamen Backend-Hash. Es verändert die geprüften Dateien nicht. Das Paket verwendet es vor dem abschließenden Marker. Quelle: [rclone check](https://rclone.org/commands/rclone_check/). + +### Datenbanken und Writer + +Es wird je aktivierter Anwendung ein Dump benötigt, sofern `ENABLE_DB_DUMPS=true`. Fehlende DB-Namen oder CNF-Dateien brechen ab. Der Dump enthält Routinen, Events, Trigger und Binärdaten. Leere Ausgaben sowie Fehler beim Schreiben werden abgelehnt. + +Für gemischte Storage Engines nutzt das Paket `--lock-all-tables --quick`. **Die globale Lesesperre kann während jedes Dumps Schreibzugriffe auf alle Datenbanken desselben Servers blockieren.** Eine reine `--single-transaction`-Sicherung schützt insbesondere nicht alle nichttransaktionalen Tabellen. Zusätzlich müssen Anwendungsschreiber über das gesamte Ruhefenster angehalten sein, damit Dateikopien zu den Dumps passen. Quelle: [MariaDB mariadb-dump](https://mariadb.com/docs/server/clients-and-utilities/backup-restore-and-import-clients/mariadb-dump). + +Die konkreten Rechte hängen von Serverversion, Views, Routinen und Sperrmethode ab. Der Backup-Account benötigt Leserechte sowie Rechte für Views, Trigger, Events, Routinen und die globale Sperre; ein früherer einfacher `SELECT`-Account reicht möglicherweise nicht. Die Rechte gezielt auf eure Version zuschneiden. `--check` prüft Anmeldung und DB-Zugriff, **nicht sämtliche später benötigten Dump-Privilegien**. Die Integrationstests verwenden einen isolierten administrativen Testaccount. + +Für einen Restore einen separaten Account mit DDL-/DML-Rechten auf die Ziel-Datenbanken verwenden. Importe sind nicht als Ganzes transaktional. Eine leere vorbereitete Zieldatenbank vermeidet, dass Tabellen oder andere Objekte zurückbleiben, die im Backup nicht vorkommen. Das Skript löscht keine vollständige Datenbank und legt keine Datenbanken/Accounts automatisch an. + +Nextcloud: Daten, Config, Apps/Themes und Datenbank gehören zusammen. Das konfigurierte Datenverzeichnis auf dem Server ermitteln: + +```bash +sudo -u apache php /var/www/html/nextcloud/occ config:system:get datadirectory +``` + +Nextcloud empfiehlt den Wartungsmodus während der Sicherung. Das Paket hält ihn über Dump und Kopie hinweg und erhält einen schon vorher aktiven Wartungsmodus. Zusätzlich tatsächliche Cron-/Worker-Dienste anhalten; `maintenance:mode` ist keine Sperre aller bereits laufenden OS-Prozesse. Quelle: [Nextcloud Backup](https://docs.nextcloud.com/server/latest/admin_manual/maintenance/backup.html). + +Gitea: `APP_DATA_PATH`, `[repository] ROOT`, Custom-Verzeichnis und `app.ini` prüfen; externe Objektablagen, externes SSH-Home und weitere individuell konfigurierte Pfade zusätzlich erfassen. Gitea verlangt für konsistente Sicherungen einen Stillstand, weil Datenbank, Dateien und Repositories zusammengehören. Quelle: [Gitea Backup and Restore](https://docs.gitea.com/administration/backup-and-restore/). + +`QUIESCE_SERVICES=(nextcloud-cron.timer nextcloud-cron.service ...)` enthält reale zusätzliche Writer. Timer zuerst, zugehörigen Service danach nennen. Gestartet wird in umgekehrter Reihenfolge. Bereits inaktive Dienste bleiben inaktiv; ein nicht zuordenbarer Übergangszustand führt zum Abbruch. Cronjobs aus crontab/externen Schedulern und socket-aktivierte Writer müssen separat berücksichtigt werden. Dienste können während einer Dateikopie länger ausfallen, insbesondere bei großen Mail-/Nextcloud-Beständen. + +`MAIL_BACKUP_MODE=live` und abgeschaltete Stopps sind bewusste Ausnahmen mit Konsistenzrisiko. Das Log weist darauf hin. Home-Verzeichnisse werden mit `--one-file-system` kopiert: Daten anderer Mounts fehlen dann absichtlich. Mailqueues, zusätzliche Spamfilter-/DKIM-Verzeichnisse oder Maildatenbanken sind nur gesichert, wenn sie explizit im Umfang stehen. + +### Speicher, Retention und Protokolle + +`MIN_FREE_GB` ist eine Reserveprüfung auf den Staging- und Archivdateisystemen, keine genaue Prognose. Die Kopie braucht Platz für unkomprimierte Quelldaten und Dumps; Archive benötigen zusätzlich komprimierten Platz. Beim Restore kommen die private Kopie des Sicherungssatzes und entpackte Daten hinzu. Eine Platte kann zwischen Prüfungen volllaufen; der Lauf muss dann scheitern. Ausreichend Reserve und ein externes Kapazitätsmonitoring einplanen. + +Neue lokale Sätze liegen zunächst unter `.partial-LAUFNAME`. Bei Fehlern bleiben Arbeitsdaten zur Analyse liegen. Sie sind **keine abgeschlossenen Backups** und werden nicht durch die Retention entfernt. Nach Ursachenklärung gezielt löschen; keine pauschale automatische Löschung gescheiterter Arbeitsstände. + +Retention: + +- Läuft erst nach Abschluss des neuen Backups und gegebenenfalls der kompletten Remote-Prüfung. +- Erkennt nur v2-Laufnamen und einen zum Manifest passenden Abschlussmarker. +- Löscht ganze Sätze, keine einzelnen Archivinhalte. +- Berücksichtigt das UTC-Datum im Laufnamen und behält die jüngsten `RETENTION_KEEP_LAST` geeigneten Sätze; mindestens zwei sind vorgeschrieben, Default drei. +- Lokale Sätze ohne `UPLOADED` werden bei aktiviertem Upload nicht gelöscht. Bei bewusst deaktiviertem Upload sind vollständige lokale Sätze löschbar. +- Alte v1-Läufe, unbekannte Verzeichnisse, unvollständige Uploads und `.partial`-Verzeichnisse bleiben erhalten. +- Remote-Retention ist in neuen Konfigurationen aus. Ein vorhandenes `ENABLE_REMOTE_RETENTION=true` bleibt beim Update erhalten und muss bei Bedarf bewusst geändert werden. + +Retention prüft die Metadaten alter Sätze, nicht erneut deren gesamte Nutzdaten. Ein `COMPLETE`-Marker beweist den erfolgreichen Abschluss zum Erstellungszeitpunkt, keine ewige Verfügbarkeit. Regelmäßige unabhängige Rücklese-/Restore-Proben bleiben erforderlich. Remote-Metadaten, die sich nicht lesen/validieren lassen, werden konservativ ausgelassen; fehlgeschlagene eigentliche Löschungen führen zu einem fehlgeschlagenen Lauf. + +Neue Backup-/Restore-Logs liegen unter `/var/log/app-backup` und werden nach `LOG_RETENTION_DAYS` (Default 30) bei einem erfolgreichen Backup entfernt. Historische v1-Protokolle bleiben zur manuellen Prüfung. Das systemd-Journal unterliegt seiner eigenen Aufbewahrung. Das Mail-Reporting ist zusätzlich; eine fehlende Mailzustellung wird protokolliert, sie ersetzt kein unabhängiges Monitoring. + +## Betrieb und Monitoring + +```bash +sudo /usr/local/sbin/app-backup.sh --check +sudo systemctl start app-backup.service +sudo systemctl status app-backup.service --no-pager +sudo journalctl -u app-backup.service --since today --no-pager +sudo cat /var/backups/app-backup/last-backup.json +sudo cat /var/backups/app-backup/last-successful-backup.json +systemctl list-timers app-backup.timer +``` + +Exit `0` bedeutet, dass die ausgeführten erforderlichen Schritte erfolgreich waren. Jeder Fehlercode ist ein Fehler; `130`/`143` kennzeichnen INT/TERM. `last-backup.json` zeigt den letzten gestarteten operativen Lauf, `last-successful-backup.json` wird ausschließlich nach einem vollständig erfolgreichen Backup aktualisiert. `--check` aktualisiert diese Erfolgsmarkierung nicht. Sehr frühe Fehler vor der Laufinitialisierung/Sperrübernahme können nur auf stderr bzw. im Journal erscheinen; deshalb sowohl Servicefehler als auch Alter des letzten Erfolgs überwachen. + +Für den täglichen Zeitplan z. B. bei mehr als 36 Stunden ohne erfolgreichen Lauf alarmieren, zusätzlich freien Speicher, offene Recovery-Dateien und wachsende Partial-Verzeichnisse prüfen. Diese Überwachung läuft idealerweise außerhalb des gesicherten Servers. `TimeoutStartSec=12h` und `TimeoutStopSec=5min` sind Ausgangswerte und müssen zum ersten gemessenen Vollbackup plus Rückleseprüfung passen. + +Ein geplanter Backupabbruch erfolgt über `systemctl stop app-backup.service`. Normale INT-/TERM-Behandlung versucht die ursprünglichen Dienste wiederherzustellen. SIGKILL, Kernelabsturz oder Stromverlust können keine Shell-Traps ausführen. Dafür liegt `recovery-LAUFNAME.json` mit vorher aufgezeichnetem Wartungs-/Dienstzustand und Arbeitsordner im Workdir. Diese Datei ist eine manuelle Wiederanlaufhilfe, kein automatischer Neustartauftrag. Das System startet nicht blind Anwendungen über einen teilweise restaurierten Datenbestand. + +## Wiederherstellung + +### Prüfphase + +Ein Restore arbeitet zuerst auf einer privaten lokalen Kopie. Bei einer Remote-Quelle wird über dasselbe geprüfte Crypt-Remote heruntergeladen. Auch der Prüflauf benötigt daher lokalen Speicher und verursacht Download-Traffic. + +Vor Anwendung werden alle Archivgrößen und SHA-256-Werte mit dem Manifest verglichen, Kompressionsströme geprüft und sämtliche Tar-Einträge untersucht. Absolute Memberpfade, `..`, doppelte Einträge, unerwartete Komponentenwurzeln, Spezialdateien sowie Schreibpfade durch Symlinks/Hardlinks werden abgewiesen. Jede Komponente wird danach getrennt entpackt. Die Zielpfade stammen ausschließlich aus der lokalen vertrauenswürdigen Konfiguration, nicht aus dem Backup. + +- Ohne Ausführungsoption oder mit `--dry-run`: prüft und zeigt den Plan; keine Dienste, Dateien in Anwendungszielen oder Datenbanken werden geändert. +- `--verify-only`: prüft den gesamten v2-Satz ohne Zuordnung zu Anwendungen. +- `--extract-only`: prüft und entpackt, behält den angegebenen privaten Ergebnisordner. +- `--apply --yes`: schreibt ausgewählte Inhalte in bestehende Zielverzeichnisse und Datenbanken. +- `--components wordpress,db-wordpress`: explizite Komponentenauswahl, nur für in der Config aktivierte Komponenten. +- `--no-db`, `--no-files`: schließen die jeweilige Kategorie aus. + +Auch bei einer Auswahl muss der vollständige Satz prüfbar sein. Fehlt eine laut Config erforderliche ausgewählte Komponente, wird vor Eingriffen abgebrochen. + +### Ausführung und Nachkontrolle + +Einen echten Restore zunächst auf einem isolierten Testsystem mit eigener `restore.conf`, passender App-/DB-Version und vorbereiteten Zielverzeichnissen durchführen. Einen bekannten aktuellen Datenbestand separat sichern, bevor bestehende Anwendungen überschrieben werden. Für einen echten Restore mit aktivierter Nextcloud ist verwalteter Wartungsmodus vorgeschrieben. Alle in der Config aktivierten Anwendungen und zusätzlichen Writer werden für die Konsistenz angehalten, auch bei einer kleineren Komponentenauswahl. + +Dateien werden mit Metadaten zurückkopiert. Standardmäßig bleiben zusätzliche Zieldateien erhalten. `RESTORE_STRICT_DELETE=true` entfernt zusätzliche Dateien innerhalb ausgewählter Zielkomponenten; konfigurierte Excludes bleiben geschützt. Keine atomare Rücksetzung der gesamten Anwendung: Ein Fehler nach Beginn des Zurückschreibens kann einen Teilzustand hinterlassen. + +Nach Nextcloud-Code-/DB-Restore setzt das Skript den Wartungsmodus erneut und führt `occ maintenance:data-fingerprint` aus. Einen vor dem Restore bereits aktiven Wartungsmodus lässt es an. `maintenance:repair` oder ein vollständiger `files:scan` werden nicht pauschal ausgeführt; sie müssen zum konkreten Wiederherstellungsfall passen. Der Fingerprint informiert Clients über den wiederhergestellten Datenzustand. Quelle: [Nextcloud Restore](https://docs.nextcloud.com/server/latest/admin_manual/maintenance/restore.html). + +Nach einem erfolgreichen Restore prüfen: + +- WordPress: Startseite, Admin-Anmeldung, Uploads und Stichproben in Beiträgen. +- Nextcloud: `occ status`, Anmeldung, Dateiinhalt/Download, Freigaben, Apps und einen kontrollierten Client-Test. +- Gitea: Anmeldung, Repository-Clone, LFS/Attachments, kontrollierter Push; bei geänderten Installationspfaden ggf. Git-Hooks nach Gitea-Anleitung neu erzeugen. +- Mail: Zustellung, IMAP-Zugriff, Queue, Rechte und Konfigurationstest von Postfix/Dovecot. +- Datenbanken: relevante Tabellenzahlen, definierte fachliche Stichproben, Events/Routinen und Zeichensatz. +- SELinux: Labels prüfen; `restorecon` nur passend zu den gewünschten Zielpfadregeln anwenden. Ein Umzug auf andere Pfade kann andere Labels erfordern. + +Bei einem Teilfehler: Log und `recovery-*.json` lesen, Arbeitsdaten erhalten, Fehlerursache beheben und Restore kontrolliert wiederholen oder auf einen separaten Ausgangssnapshot zurückgehen. Angehaltene Anwendungen bleiben für diese Prüfung gestoppt. Bereits gestartete Dienste bei einem Fehler im Wiederanlauf müssen gesondert kontrolliert werden. Es gibt keinen automatischen Datenbank-/Datei-Rollback. + +### Alte Sicherungen + +v1-Split-Archive haben weder manifestgebundene Prüfsummen noch einen verlässlichen Abschlussmarker. Sie werden nur mit `--legacy-unverified --extract-only` geöffnet. Der Parser akzeptiert die bisherigen datierten Dateinamen und Komponentenwurzeln; jeder Archivinhalt wird geprüft und separat entpackt. **Vollständigkeit und ursprünglicher Inhalt lassen sich damit nicht nachträglich beweisen.** + +Nur die Dateien **eines** alten Laufs in einen eigenen Ordner kopieren. Alte DB-Dumps liegen gemeinsam unter `db/`, Gitea unter `files/gitea-data`, Mail-Homes ggf. gesammelt unter `files/mail-home`. Nach Prüfung manuell in vorbereitete Testziele kopieren und DB-Dumps mit einem passenden Client importieren. Alte monolithische Formate vor den Split-Archiven unterstützt dieser Entpackmodus nicht. Alte Sicherungen erst nach einer erfolgreichen neuen Offsite-Sicherung und echten Wiederherstellungsprobe freigeben. + +## Installation, Update und Rückweg + +Die ausführbare Schrittfolge steht in [README: bestehende Installation aktualisieren](README.md#bestehende-installation-aktualisieren). + +Der Installer prüft Shell-/Python-Syntax, wartet nicht auf laufende Sicherungen, sondern lehnt ein Update bei gehaltener Sperre oder aktivem Backup-Service ab. Vor Änderungen werden ersetzte Dateien samt Inventar unter `/var/backups/app-backup-update-XXXXXXXX` gesichert. Neue Dateien werden über temporäre Geschwisterdateien installiert und atomar umbenannt. Das Gesamtpaket ist keine atomare Dateisystemtransaktion; während des Austauschs wird die gemeinsame Sperre gehalten und der Timer angehalten. Bei einem Teilfehler bleibt er gestoppt; das Rollback-Skript stellt das vorherige Inventar wieder her. + +| Pfad | Updateverhalten | +| --- | --- | +| `/usr/local/sbin/app-backup.sh`, `app-restore.sh` | Ersetzen, alte Version im Rollback-Paket | +| `/usr/local/lib/app-backup/` | Gemeinsame Bibliothek und Python-Prüfer installieren | +| `/etc/app-backup/app-backup.conf` | Behalten; neue Vorlage als `.conf.dist` | +| `/etc/app-backup/*.cnf` | Bestehende Zugangsdaten behalten; ausschließlich `.example` installieren | +| `/etc/systemd/system/app-backup.service` | Ersetzen; vorhandene systemd-Drop-ins bleiben bestehen und müssen geprüft werden | +| `/etc/systemd/system/app-backup.timer` | Bestehenden Zeitplan behalten; Vorlage als `.timer.dist` | +| `/etc/logrotate.d/app-backup` | Alte Rotationsregel durch Hinweis auf Laufprotokollbereinigung ersetzen | +| `/usr/local/share/doc/app-backup/` | README und diese Dokumentation installieren | + +Der Installer setzt den Timer bei einer Neuinstallation nicht automatisch in Betrieb. `--enable-timer` ist optional für bereits vorbereitete Installationen. Ein zuvor aktiver Timer wird nach einem erfolgreichen gewöhnlichen Update wieder gestartet; beim v1→v2-Wechsel vorher ausdrücklich stoppen, Optionen abgleichen und neu testen. Durch `Persistent=true` kann ein verpasster Termin beim Wiedereinschalten unmittelbar nachgeholt werden. + +Das Rollback-Paket enthält eine geschützte Kopie der Hauptkonfiguration als Referenz. `rollback.sh` verändert die aktuell erhaltene Hauptkonfiguration nicht automatisch. Es setzt ersetzte Paketdateien zurück und entfernt Dateien, die vor dem Update nicht vorhanden waren. Selbst vorgenommene Config-Änderungen und Backup-/Restore-Daten bleiben separate Entscheidungen. Mit einem alten defekten Restore ist der Rückweg zur alten Software keine Verbesserung der Datenrettung. + +### Zugangsdaten aus alten Commits + +Die Entfernung der drei DB-Dateien aus dem aktuellen Baum löscht deren Historie nicht. Betroffene DB-Accounts und Passwörter am Server identifizieren, Passwörter wechseln, die aktiven CNF-Dateien und gegebenenfalls weitere berechtigte Anwendungen anpassen und einen vollständigen Dump testen. Keine Werte in Issues, Chat, Logs oder neue Commits kopieren. Den Gitea-Repositoryzugriff und vorhandene Klone prüfen. Eine koordinierte Historienbereinigung wäre ein eigener Eingriff mit Auswirkungen auf alle Klone; sie ersetzt keine Passwortrotation und ist nicht Teil dieses Updates. + +## Prüfung und Abnahme + +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. + +Ergebnis des lokalen Abschlusslaufs am 10.09.2026: **22 Tests erfolgreich**, 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. + +Geprüfte Fälle umfassen: + +- Shell-Syntax und ShellCheck der Paketdateien. +- MyISAM-Dump und tatsächlicher SQL-Import samt Dateiwiederherstellung. +- gzip/zstd, korrekte Excludes, vorhandener Wartungsmodus und inaktive Dienste. +- Lokaler Crypt-Upload mit vollständigem Rücklesen und Restore aus diesem Remote. +- Fehler bei Dump, rsync, Kompression, Diensten und Remote-Rückleseprüfung. +- Fehlende/beschädigte Archive, Pfadtraversal, Link-Pivots, unzulässige Tar-Dateitypen. +- Gemeinsame Sperre, TERM-Abbruch, Prüflauf ohne falsche Erfolgsmeldung. +- Selektiver Restore mit Löschung und Schutz ausgeschlossener Nextcloud-Daten. +- Lokale/Remote-Retention mit Mindestanzahl und Erhalt alter/unvollständiger Läufe. +- Paketupdate mit Erhalt von Config/Zeitplan und ausführbarem Rollback. + +**Serverabnahme steht separat aus:** echte Rocky-Linux-10-Umgebung, SELinux-/ACL-Verhalten auf den verwendeten Dateisystemen, installierte Nextcloud-/Gitea-Versionen, vollständige Writer-/Pfadliste, Berechtigungen des echten DB-Backupaccounts, tatsächlicher OneDrive-Upload großer Archive, Laufzeit/Speicherbedarf und fachliche Restore-Probe. Es wurde kein Produktivbackup ausgeführt und keine Serverinstallation verändert. diff --git a/README.md b/README.md index ec2aa20..ddfbea9 100644 --- a/README.md +++ b/README.md @@ -1,340 +1,147 @@ -# app-backup (Rocky Linux 10) – Split-Backups für WordPress, Nextcloud, Gitea + DBs (OneDrive/rclone) +# app-backup – geprüfte Sicherungen für den JR IT Server -Dieses Repo enthält ein **Backup- und Restore-Setup** für typische Self-Hosted-Apps auf Rocky Linux 10: +Sichert WordPress, Nextcloud, native Gitea-Installationen mit MariaDB, Maildaten und zusätzliche Verzeichnisse. Jede Komponente erhält ein eigenes Archiv. Uploads gehen ausschließlich über ein **rclone-Crypt-Remote**; anschließend wird der gesamte Inhalt durch dieses Remote zurückgelesen und verglichen. -- **WordPress** (Webroot) -- **Nextcloud** (Code + Data getrennt; Data liegt bei uns unter `.../nextcloud/data`) -- **Gitea** (native Installation via systemd + MariaDB) -- **MariaDB-Dumps** (WordPress DB, Nextcloud DB, Gitea DB) -- Upload nach **OneDrive** per **rclone** +Die vollständige Analyse, Architektur, Konfiguration, Betriebsanleitung und verbleibenden Grenzen stehen in [Dokumentation.md](Dokumentation.md). -Neu in dieser Version: **kein riesiges “Alles-in-eins”-Archiv** mehr, sondern **sauber getrennte Archives pro Komponente**. Das macht Uploads stabiler (große Dateien), Restore selektiver und Fehler leichter eingrenzbar. +## Wesentliche Änderungen ---- +- Funktionierender Restore mit Prüflauf, Entpackmodus, Komponentenauswahl und ausdrücklichem `--apply --yes`. +- Fehler bei Dump, rsync, Kompression und Rückleseprüfung brechen den Lauf ab. +- Wartungsmodus und Dienststopps umfassen Datenbankdump **und** Dateikopie. Danach endet die Ausfallzeit; Kompression und Upload laufen aus dem Staging. +- Vollständige Sicherungssätze mit SHA-256-Manifest, Archivprüfung und zuletzt hochgeladenem `COMPLETE`-Marker. +- Retention löscht nur vollständige neue Sicherungssätze und behält mindestens `RETENTION_KEEP_LAST` Läufe. Alte Sicherungen werden nicht automatisch gelöscht. +- Private Arbeitsverzeichnisse, gemeinsame Sperre für Backup/Restore/Update und Wiederanlaufhinweise nach Abbrüchen. +- Installer erhält die Serverkonfiguration und den Zeitplan und erstellt ein Rollback-Paket. -## ✅ Ziele / Designentscheidungen +## Bestehende Installation aktualisieren -### 1) Split statt „dickes Archiv“ -Es werden pro Lauf mehrere Archive erzeugt (zstd oder gzip): +**Vor dem ersten Lauf:** Für ein konsistentes Backup werden nun standardmäßig `httpd`, `php-fpm`, `postfix` und `dovecot` während der Kopie angehalten, soweit die zugehörigen Anwendungen aktiviert sind und die Dienste laufen. Fehlende Einstellungen in alten Konfigurationen erhalten diese neuen Defaults. Zusätzliche Cronjobs/Worker müssen in `QUIESCE_SERVICES` erfasst oder extern angehalten werden. Zeitfenster und Datenumfang vorher prüfen. -- `..._meta.tar.zst` – Meta (Timestamp, Hostname, Größeninfos) -- `..._db.tar.zst` – DB Dumps (SQL Dateien) -- `..._wordpress.tar.zst` – WordPress Webdaten (ohne Nextcloud-Unterordner) -- `..._nextcloud.tar.zst` – Nextcloud **Code/Web** (mit Exclude `data/`) -- `..._nextcloud-data.tar.zst` – Nextcloud **Data** separat -- `..._gitea.tar.zst` – Gitea Data (APP_DATA_PATH) -- `..._gitea-etc.tar.zst` – `/etc/gitea` (Gitea config) +Die bisherigen echten `*.cnf`-Dateien wurden aus dem aktuellen Repository entfernt. **Vor `git pull` auch mögliche Zugangsdaten im Server-Checkout sichern.** Historische Commits können die alten Werte weiterhin enthalten; betroffene Datenbankpasswörter wechseln. Die Dateien unter `/etc/app-backup` werden vom Installer nicht überschrieben. -Vorteile: -- Upload stabiler bei **12–18 GB** (nicht 1 Monolith) -- Restore selektiv (z.B. nur DB, nur Nextcloud-data, …) -- Fehlerbehebung leichter (ein Archiv kaputt → nicht alles kaputt) - -### 2) Nextcloud data unterhalb von `NC_DIR` -Bei uns liegt Nextcloud so: - -- `NC_DIR=/var/www/html/nextcloud` (Code) -- `NC_DATA_DIR=/var/www/html/nextcloud/data` (Data) - -Damit **Data nicht doppelt** gesichert wird: -- Beim Backup von `NC_DIR` wird `data/` **explizit ausgeschlossen** (`rsync --exclude data/`). -- Danach wird `NC_DATA_DIR` als eigenes Paket gesichert. - -### 3) WordPress liegt im Webroot `/var/www/html` und Nextcloud als Unterordner -Bei uns liegt: -- WordPress im `WP_DIR=/var/www/html` -- Nextcloud in `/var/www/html/nextcloud` - -Damit Nextcloud nicht im WP-Archiv landet: -- WP-Backup schließt `nextcloud/` automatisch aus, wenn `NC_DIR` genau `WP_DIR/nextcloud` ist. - -### 4) rclone / OneDrive robust für große Dateien -Upload wird robust gemacht mit: -- Chunking: `--onedrive-chunk-size 64M` -- Timeouts: `--timeout 1h`, `--contimeout 30s` -- konservativ: `--transfers 2`, `--checkers 4` -- mehr Retries: `--retries 10`, `--low-level-retries 40` - -Außerdem: **Remote-Namen sind case-sensitive!** -Remote ist bei uns z.B. `OneDrive:` (nicht `onedrive:`). - ---- - -## 📦 Dateien & Pfade - -### Skripte -- `app-backup.sh` → Backup erstellen, Archive erzeugen, optional Upload. -- `app-restore.sh` → Restore aus lokalem Ordner oder OneDrive-Run-Folder. -- `app-backup.conf` → Konfiguration (Pfad/Services/DB/Credentials). - -### Zielpfade (Standard) -- Workdir: `/var/backups/app-backup` -- Archive: `/var/backups/app-backup/archives` -- Logs: `/var/log/app-backup` - -### OneDrive Ziel (Beispiel) -Upload in: -``` -OneDrive:Sicherung/JRITServerBackups//appbackup_/ -``` - ---- - -## 🔧 Installation / Setup - -### 1) Skripte installieren -Beispiel: ```bash -sudo install -d /etc/app-backup /usr/local/sbin -sudo install -m 0755 app-backup.sh /usr/local/sbin/app-backup.sh -sudo install -m 0755 app-restore.sh /usr/local/sbin/app-restore.sh -sudo install -m 0640 app-backup.conf /etc/app-backup/app-backup.conf +# Auf dem Server, im vorhandenen Checkout: +cd /pfad/zum/app-backup +sudo systemctl stop app-backup.timer +systemctl is-active app-backup.service +# Falls aktiv: den laufenden Job zuerst regulär fertig werden lassen. + +# Einmalige geschützte Sicherung, bevor git pull alte Dateien aus dem Checkout entfernt: +sudo install -d -m 700 /var/backups/app-backup-before-v2 +sudo cp -a /etc/app-backup /var/backups/app-backup-before-v2/etc-app-backup +sudo cp -a etc/app-backup /var/backups/app-backup-before-v2/checkout-app-backup + +git status --short +# Lokale Anpassungen vorher sichern/auflösen; keinen Reset erzwingen. +git pull --ff-only +sudo ./install.sh ``` -### 2) DB Credential Files anlegen -Für WordPress / Nextcloud / Gitea je eine `.cnf`. +Der Installer nennt anschließend einen Ordner wie `/var/backups/app-backup-update-XXXXXXXX` mit alten Paketdateien und `rollback.sh`. Weil der Timer zuvor gestoppt wurde, bleibt er beim Update gestoppt. Eine sonst aktive Timer-Instanz wird vom Installer nach erfolgreichem Update wieder aktiviert; deshalb ist der vorherige Stopp für die Migration wichtig. -Beispiele: -- `/etc/app-backup/db-wordpress.cnf` -- `/etc/app-backup/db-nextcloud.cnf` -- `/etc/app-backup/db-gitea.cnf` - -Inhalt: -```ini -[client] -user=backup -password=DEIN_PASSWORT -host=localhost -``` - -Rechte: ```bash -sudo chown root:root /etc/app-backup/db-*.cnf -sudo chmod 600 /etc/app-backup/db-*.cnf +# Neue Optionen mit der bestehenden Konfiguration abgleichen (Datei enthält keine DB-Passwörter): +sudo diff -u /etc/app-backup/app-backup.conf /etc/app-backup/app-backup.conf.dist +sudoedit /etc/app-backup/app-backup.conf + +# Vorhandene Dateinamen aus WP_DB_CNF / NC_DB_CNF / GITEA_DB_CNF verwenden: +sudo chown root:root /etc/app-backup/*.cnf +sudo chmod 600 /etc/app-backup/*.cnf +sudo chmod 600 /etc/app-backup/app-backup.conf +# Den tatsächlich konfigurierten rclone-Dateipfad verwenden: +sudo chown root:root /root/.config/rclone/rclone.conf +sudo chmod 600 /root/.config/rclone/rclone.conf + +sudo /usr/local/sbin/app-backup.sh --check +# Im vereinbarten Wartungsfenster den ersten vollständigen Lauf ausführen: +sudo systemctl start app-backup.service +sudo journalctl -u app-backup.service -n 100 --no-pager +sudo cat /var/backups/app-backup/last-backup.json ``` -### 3) MariaDB Backup-User (Minimal-Rechte) -Pro DB (z.B. gitea): -```sql -CREATE USER 'backup'@'localhost' IDENTIFIED BY 'DEIN_PASSWORT'; -GRANT SELECT, SHOW VIEW, TRIGGER, EVENT, LOCK TABLES ON gitea.* TO 'backup'@'localhost'; -FLUSH PRIVILEGES; -``` +`--check` prüft Werkzeuge, Quellpfade, Dateirechte, DB-Verbindungen, Speicherreserve und Crypt-Zugriff. Es stoppt keine Anwendungen und führt weder Backup noch Upload aus. Es ersetzt keinen vollständigen Probelauf. -Optional: denselben User für wordpress/nextcloud verwenden, dann jeweils `GRANT ... ON wordpress.*` etc. +In der Konfiguration insbesondere ergänzen/prüfen: -### 4) rclone Remote prüfen (Case-Sensitive!) -Auf dem Server (als derselbe User, der später rclone nutzt – typischerweise root oder johannes): ```bash -rclone listremotes -rclone lsf "OneDrive:" --max-depth 1 -rclone lsf "OneDrive:Sicherung" --max-depth 1 +RCLONE_CONFIG="/root/.config/rclone/rclone.conf" +RCLONE_REMOTE_BASE="OneDriveCrypt:gemini" # vorhandenes Crypt-Ziel beibehalten! +RETENTION_KEEP_LAST=3 +ENABLE_REMOTE_RETENTION="false" # während der Migration zunächst aus +MAIL_BACKUP_MODE="stop" +# Tatsächliche Timer-/Service-Namen einsetzen; Timer vor seinem Service nennen: +QUIESCE_SERVICES=() +# Gitea war in der bisherigen mitgelieferten Konfiguration NICHT aktiviert: +# ENABLE_GITEA="true" nach Prüfung aller Gitea-Pfade und DB-Einstellungen setzen. ``` -⚠️ Wichtig: Wenn das Backup als `root` läuft, muss `root` auch Zugriff auf die rclone config haben. -Typisch sind zwei Wege: +Den neuen Laufnamen aus Log oder `last-backup.json` übernehmen: -**Option A – root nutzt eigene rclone config** -- `sudo rclone config` als root durchführen und Remote anlegen. - -**Option B – root nutzt Config von johannes** -- im Script/Service `RCLONE_CONFIG=/home/johannes/.config/rclone/rclone.conf` setzen -- oder in systemd Unit `Environment=RCLONE_CONFIG=...` - ---- - -## ⚙️ Konfiguration: app-backup.conf (wichtige Stellen) - -### OneDrive Remote Base ```bash -RCLONE_REMOTE_BASE="OneDrive:Sicherung/JRITServerBackups/$(hostname -s)" +sudo /usr/local/sbin/app-restore.sh --remote-run appbackup_DATUM_UHRZEIT_ID --verify-only +sudo /usr/local/sbin/app-restore.sh --remote-run appbackup_DATUM_UHRZEIT_ID --extract-only ``` -Das Skript legt pro Run einen Unterordner an: +`DATUM_UHRZEIT_ID` ist ein Platzhalter, z. B. `2026-09-10_02-30-00_a1b2c3d4`. Der Entpackmodus nennt seinen geschützten Ergebnisordner. Nach Prüfung der Dateien und einer erfolgreichen Wiederherstellungsprobe auf einem Testsystem den Zeitplan einschalten: + ```bash -remote_run="${RCLONE_REMOTE_BASE}/appbackup_" +sudo systemctl enable --now app-backup.timer +systemctl list-timers app-backup.timer ``` -### WordPress / Nextcloud Layout (wichtig!) +**Rückweg bei einem Paketproblem:** Timer stoppen, aktiven Job fertig werden lassen und das vom Installer erzeugte Skript ausführen: + ```bash -WP_DIR="/var/www/html" -NC_DIR="/var/www/html/nextcloud" -NC_DATA_DIR="/var/www/html/nextcloud/data" +sudo /var/backups/app-backup-update-XXXXXXXX/rollback.sh ``` -Damit wird: -- WordPress = alles in `/var/www/html` **ohne** `nextcloud/` -- Nextcloud Code = `/var/www/html/nextcloud` **ohne** `data/` -- Nextcloud Data = `/var/www/html/nextcloud/data` separat +Das stellt die ersetzten Paketdateien wieder her. Konfigurationsänderungen und bereits zurückgeschriebene Anwendungsdaten werden dadurch nicht rückgängig gemacht. Der Timer bleibt bis zur manuellen Prüfung gestoppt. Der alte Stand enthält die im Review beschriebenen Mängel. -### Gitea (native + MariaDB) -Aus deinem systemd/app.ini: -- `WorkingDirectory=/var/lib/gitea` -- `APP_DATA_PATH=/var/lib/gitea/data` -- config: `/etc/gitea/app.ini` +## Neuinstallation + +Benötigt Linux mit systemd, Bash ≥ 4.4, Python ≥ 3.9, GNU tar/coreutils, rsync mit ACL/xattr-Unterstützung, zstd bzw. gzip, sudo, PHP/occ, MariaDB-Clients und rclone. Details und Rechte siehe [Dokumentation](Dokumentation.md#voraussetzungen-und-konfiguration). -Daher: ```bash -ENABLE_GITEA="true" -GITEA_SERVICE_NAME="gitea" -ENABLE_GITEA_SERVICE_STOP="true" - -GITEA_DATA_DIR="/var/lib/gitea/data" -GITEA_ETC_DIR="/etc/gitea" - -GITEA_DB_NAME="gitea" -GITEA_DB_CNF="/etc/app-backup/db-gitea.cnf" +git clone https://gitea.jr-it-services.de/johannes/app-backup.git +cd app-backup +sudo ./install.sh +sudoedit /etc/app-backup/app-backup.conf +# DB-Dateien aus *.cnf.example erstellen; root:root und chmod 600 setzen. +# rclone Crypt als root konfigurieren, Schlüssel getrennt sicher verwahren. +sudo /usr/local/sbin/app-backup.sh --check ``` ---- +Der Installer aktiviert bei einer Neuinstallation den Timer standardmäßig **erst auf ausdrückliche Anforderung**. Nach Backup- und Restore-Probe: `sudo systemctl enable --now app-backup.timer`. -## ▶️ Backup ausführen +## Backup und Restore ```bash sudo /usr/local/sbin/app-backup.sh + +# Standard ist ein geprüfter Plan, ohne Änderungen an den Anwendungen: +sudo /usr/local/sbin/app-restore.sh --local-run /var/backups/app-backup/archives/LAUFNAME + +# Ausführung überschreibt ausgewählte Dateien und Datenbankobjekte: +sudo /usr/local/sbin/app-restore.sh --config /etc/app-backup/restore.conf \ + --local-run /var/backups/app-backup/archives/LAUFNAME --apply --yes + +# Nur WordPress-Dateien; Konfiguration definiert die Zielpfade: +sudo /usr/local/sbin/app-restore.sh --local-run /var/backups/app-backup/archives/LAUFNAME \ + --components wordpress --no-db --apply --yes + +# Alte Split-Archive ohne Manifest: nur kontrolliert entpacken, manuell prüfen/importieren: +sudo /usr/local/sbin/app-restore.sh --local-run /pfad/zu/einem/alten/lauf \ + --legacy-unverified --extract-only ``` -Erwartetes Verhalten: -1) Lock `/run/app-backup.lock` verhindert Parallelruns -2) optional stoppt es Gitea kurz (konsistenter Snapshot) -3) Dumps: wordpress / nextcloud / gitea -4) rsync in staging -5) mehrere `.tar.zst` werden erzeugt -6) Upload pro Datei nach OneDrive (falls `ENABLE_UPLOAD=true`) -7) Remote retention (best effort) -8) lokale Retention löscht alte Archive +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. ---- +## Tests -## ♻️ Restore ausführen - -### Restore von OneDrive (Run-Ordnername angeben) -Du brauchst den Ordnernamen, z.B.: -`appbackup_2026-02-11_02-31-28` - -Dann: ```bash -sudo /usr/local/sbin/app-restore.sh --remote-run appbackup_2026-02-11_02-31-28 +docker build -t app-backup-test -f tests/Dockerfile . +docker run --rm --network none -v "$PWD:/src:ro" app-backup-test ``` -### Restore aus lokalem Ordner -Wenn du die Archive lokal hast: -```bash -sudo /usr/local/sbin/app-restore.sh --local-run /var/backups/app-backup/archives/ -``` - -### Dry-Run -```bash -sudo /usr/local/sbin/app-restore.sh --remote-run appbackup_... --dry-run -``` - -### Nur Files / nur DB -```bash -sudo /usr/local/sbin/app-restore.sh --remote-run appbackup_... --no-db -sudo /usr/local/sbin/app-restore.sh --remote-run appbackup_... --no-files -``` - ---- - -## 🧠 Restore-Details (was passiert danach?) - -### Nextcloud -- Maintenance Mode wird optional aktiviert (konfigurierbar) -- Restore kopiert Code und Data getrennt zurück -- Danach: - - `occ maintenance:repair` - - optional `occ files:scan --all` (deaktiviert per default; kann lange dauern) - -### Gitea -- Service wird optional gestoppt -- Data wird nach `GITEA_DATA_DIR` zurück synchronisiert -- `/etc/gitea` wird wiederhergestellt -- DB Dump wird importiert -- Service wird wieder gestartet - ---- - -## 🧯 Troubleshooting - -### 1) rclone Remote not reachable / “didn't find section in config file” -Typischer Fehler: -- Remote heißt `OneDrive:` aber Config nutzt `onedrive:` → **case-sensitive**. -- Backup läuft als `root`, aber rclone config ist nur bei `johannes`. - -Prüfen: -```bash -sudo rclone listremotes -sudo -u johannes rclone listremotes -``` - -Fix: -- Entweder rclone remote auch für root konfigurieren, -- oder `RCLONE_CONFIG` auf johannes’ Config zeigen lassen. - -### 2) Nextcloud Data doppelt oder fehlt -Stellen prüfen: -- `NC_DIR` korrekt? -- `NC_DATA_DIR` korrekt? -- Das Skript excludet `data/` bei Nextcloud Code. Data wird separat gesichert. - -### 3) Große Uploads brechen ab -Setze z.B.: -- `RCLONE_TRANSFERS=1` -- `RCLONE_CHECKERS=2` -- `RCLONE_ONEDRIVE_CHUNK_SIZE=32M` -- optional `RCLONE_BWLIMIT="20M"` - -### 4) Restore soll “hart” spiegeln -Standard ist vorsichtig (kein delete). Wenn du wirklich Ziel-Verzeichnisse exakt spiegeln willst: -```bash -RESTORE_STRICT_DELETE="true" -``` -⚠️ Achtung: Das kann Dateien löschen, die nicht im Backup sind. - ---- - -## 🗓️ Optional: systemd Service + Timer (Beispiel) - -> Nur als Beispiel – wenn ihr mögt, legen wir das als Files ins Repo. - -`/etc/systemd/system/app-backup.service` -```ini -[Unit] -Description=app-backup -After=network.target mariadb.service -Wants=mariadb.service - -[Service] -Type=oneshot -ExecStart=/usr/local/sbin/app-backup.sh -``` - -`/etc/systemd/system/app-backup.timer` -```ini -[Unit] -Description=Run app-backup daily - -[Timer] -OnCalendar=daily -Persistent=true - -[Install] -WantedBy=timers.target -``` - -Aktivieren: -```bash -sudo systemctl daemon-reload -sudo systemctl enable --now app-backup.timer -``` - ---- - -## ✅ Empfehlung: Restore-Test -Backups sind erst dann gut, wenn Restore getestet ist: -- Test-VM / Test-Host -- DB importieren -- Archive entpacken / rsync zurückspielen -- Nextcloud startet, Login klappt, Files sichtbar -- WordPress Frontend/Backend ok -- Gitea WebUI ok, Repos vorhanden +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. diff --git a/etc/app-backup/app-backup.conf b/etc/app-backup/app-backup.conf index d8c6f8c..dddeead 100644 --- a/etc/app-backup/app-backup.conf +++ b/etc/app-backup/app-backup.conf @@ -1,122 +1,97 @@ -# app-backup.conf -# ----------------------------------------------------------------------------- -# Konfiguration für app-backup.sh und app-restore.sh -# Pfade/Accounts bitte an eure Umgebung anpassen. -# ----------------------------------------------------------------------------- - -# === Core paths === +# app-backup v2. Trusted Bash configuration, root:root, mode 600. +# Existing installations retain their file; merge new options from .conf.dist. WORKDIR="/var/backups/app-backup" -STAGING_ROOT="${WORKDIR}/staging" -ARCHIVE_DIR="${WORKDIR}/archives" +STAGING_ROOT="$WORKDIR/staging" +ARCHIVE_DIR="$WORKDIR/archives" +RESTORE_ROOT="$WORKDIR/restore" +LOG_DIR="/var/log/app-backup" +LOCKFILE="/run/app-backup.lock" +ARCHIVE_PREFIX="appbackup" -# === Retention === -LOCAL_RETENTION_DAYS=7 - -# === Compression === -# zstd (schnell + gut) oder gzip (kompatibel) +# Local staging and archives contain plaintext: use an encrypted server volume. +MIN_FREE_GB=10 COMPRESSOR="zstd" +ZSTD_LEVEL=6 +ZSTD_THREADS=2 +LOG_RETENTION_DAYS=30 +LOCAL_RETENTION_DAYS=7 +REMOTE_RETENTION_DAYS=30 +RETENTION_KEEP_LAST=3 +# Enable deliberately after a successful restore rehearsal. v1 runs are retained. +ENABLE_REMOTE_RETENTION="false" -# === Remote (rclone) === +ENABLE_UPLOAD="true" RCLONE_BIN="rclone" +RCLONE_CONFIG="/root/.config/rclone/rclone.conf" RCLONE_REMOTE_BASE="OneDriveCrypt:gemini" RCLONE_RETRIES=6 RCLONE_LOW_LEVEL_RETRIES=20 RCLONE_RETRIES_SLEEP="30s" -RCLONE_STATS="1m" -RCLONE_BWLIMIT="0" # "0" = unbegrenzt -ENABLE_REMOTE_RETENTION="true" -REMOTE_RETENTION_DAYS=30 - -# === Safety === -MIN_FREE_GB=10 -NICE_LEVEL=10 -IONICE_CLASS=2 -IONICE_LEVEL=6 - -# === Mail report (lokaler postfix/sendmail) === +RCLONE_TIMEOUT="1h" +RCLONE_CONTIMEOUT="30s" +RCLONE_TRANSFERS=2 +RCLONE_CHECKERS=4 +# OneDrive chunks must be multiples of 320 KiB; 80 MiB satisfies this. +RCLONE_ONEDRIVE_CHUNK_SIZE="80Mi" +RCLONE_BWLIMIT="0" ENABLE_MAIL_REPORT="true" MAIL_TO="johannes" -MAIL_FROM="app-backup@$(hostname -f 2>/dev/null || hostname)" -MAIL_SUBJECT_PREFIX="[app-backup]" -MAIL_INCLUDE_LOG_TAIL_LINES=200 -# ----------------------------------------------------------------------------- -# Was soll gesichert werden? -# ----------------------------------------------------------------------------- ENABLE_WORDPRESS="true" +WP_DIR="/var/www/html" +# Nextcloud paths below WP_DIR are automatically excluded (including on restore). +WP_EXCLUDES=() + ENABLE_NEXTCLOUD="true" ENABLE_NEXTCLOUD_DATA="true" ENABLE_NEXTCLOUD_MAINTENANCE="true" -ENABLE_DB_DUMPS="true" -ENABLE_MAIL="true" # optional - -# ----------------------------------------------------------------------------- -# WordPress / Webroot -# ----------------------------------------------------------------------------- -# In eurem Setup liegt WordPress direkt in /var/www/html -WP_DIR="/var/www/html" - -# Wenn Nextcloud unterhalb von WP_DIR liegt, wird es sonst doppelt gesichert. -# Modus: -# - auto => exclude "nextcloud/" automatisch, wenn NC_DIR == WP_DIR/nextcloud -# - manual => nutzt WP_EXCLUDES Array -WP_EXCLUDES_MODE="auto" -#WP_EXCLUDES_MODE="manual" -#WP_EXCLUDES=("nextcloud/") - -# ----------------------------------------------------------------------------- -# Nextcloud -# ----------------------------------------------------------------------------- NC_DIR="/var/www/html/nextcloud" - -# User für occ-Kommandos (Rocky/Apache meist: apache) +NC_DATA_DIR="$NC_DIR/data" NC_OCC_USER="apache" -# Achtung: Das Dataverzeichnis ist oft NICHT unter /var/www/html/nextcloud/data! -# Ermitteln mit: -# sudo -u apache php /var/www/html/nextcloud/occ config:system:get datadirectory -NC_DATA_DIR="/var/www/html/nextcloud/data" +# Native MariaDB-backed Gitea. Enable only after checking app.ini paths. +ENABLE_GITEA="false" +ENABLE_GITEA_SERVICE_STOP="true" +GITEA_SERVICE_NAME="gitea" +GITEA_DATA_DIR="/var/lib/gitea/data" +GITEA_ETC_DIR="/etc/gitea" +# Repository ROOT and custom directory can be OUTSIDE APP_DATA_PATH. +# GITEA_REPOS_DIR="/var/lib/gitea/repositories" +# GITEA_CUSTOM_DIR="/var/lib/gitea/custom" -# Optional: nach Restore files:scan ausführen (kann lange dauern) -NC_FILES_SCAN_AFTER_RESTORE="false" +# Stop all writers throughout dumps + staging; downtime depends on data size. +ENABLE_HTTPD_STOP="true" +HTTPD_SERVICE_NAME="httpd" +ENABLE_PHPFPM_STOP="true" +PHPFPM_SERVICE_NAME="php-fpm" +# Add actual Nextcloud cron timers/services, queue workers, containers, etc. +# A timer and its currently running service must BOTH be included, timer first. +QUIESCE_SERVICES=() -# ----------------------------------------------------------------------------- -# Datenbanken (MariaDB/MySQL) -# ----------------------------------------------------------------------------- -# DB-Namen +ENABLE_DB_DUMPS="true" +# Auto-detect mariadb-dump/mariadb; fallback to mysqldump/mysql. +# DB_DUMP_BIN="mariadb-dump" +# DB_CLIENT_BIN="mariadb" WP_DB_NAME="wordpress" NC_DB_NAME="nextcloud" - -# Zugangsdaten über my.cnf Dateien (empfohlen, keine Passwörter im Script) -# Beispiel-Inhalt: -# [client] -# user=backup -# password=SEHR_GEHEIM -# host=localhost +GITEA_DB_NAME="gitea" WP_DB_CNF="/etc/app-backup/wp-db.cnf" NC_DB_CNF="/etc/app-backup/nc-db.cnf" +GITEA_DB_CNF="/etc/app-backup/db-gitea.cnf" +# Restore needs accounts with DDL/DML rights. Use a separate --config FILE for it. -# ----------------------------------------------------------------------------- -# Mail (optional) - nur relevant wenn ENABLE_MAIL=true -# ----------------------------------------------------------------------------- +ENABLE_MAIL="true" +MAIL_BACKUP_MODE="stop" MAIL_DIR="/var/vmail" POSTFIX_DIR="/etc/postfix" DOVECOT_DIR="/etc/dovecot" +ENABLE_MAIL_HOME_DIRS="true" +MAIL_HOME_DIRS=("/home/johannes" "/home/luciana") +# Home copies stay on one filesystem; excluded directories are not backed up. +MAIL_HOME_EXCLUDES=("OneDrive/" ".cache/" ".ccache/" ".local/share/Trash/") -# ----------------------------------------------------------------------------- -# Restore Verhalten -# ----------------------------------------------------------------------------- -# Wenn true: rsync --delete beim Restore (macht Ziel 1:1 wie im Backup) +# Additional directories; only files, no automatic DB/container snapshot semantics. +# Keep ordering stable: entries become extra-0, extra-1, ... +EXTRA_PATHS=() +# Restore target directories must already exist. Deletion is off by default. RESTORE_STRICT_DELETE="false" - -# Restore Default-Schalter (können auch per env überschrieben werden) -# DRY_RUN="true" -# RESTORE_FILES="true" -# RESTORE_DB="true" - -# --- Force crypt remote (no fallback) --- -ENABLE_UPLOAD="true" -RCLONE_REMOTE_BASE="OneDriveCrypt:gemini" - -# Optional: log level -RCLONE_LOG_LEVEL="INFO" diff --git a/etc/app-backup/db-gitea.cnf b/etc/app-backup/db-gitea.cnf deleted file mode 100644 index 5b779d4..0000000 --- a/etc/app-backup/db-gitea.cnf +++ /dev/null @@ -1,5 +0,0 @@ -[client] -user=gitea -password='p64AH!uK9' -host=127.0.0.1 -port=3306 diff --git a/etc/app-backup/db-gitea.cnf.example b/etc/app-backup/db-gitea.cnf.example new file mode 100644 index 0000000..7faa19f --- /dev/null +++ b/etc/app-backup/db-gitea.cnf.example @@ -0,0 +1,4 @@ +[client] +user=backup +password=CHANGE_ME +host=localhost diff --git a/etc/app-backup/db-nextcloud.cnf.example b/etc/app-backup/db-nextcloud.cnf.example index c3cca3b..7faa19f 100644 --- a/etc/app-backup/db-nextcloud.cnf.example +++ b/etc/app-backup/db-nextcloud.cnf.example @@ -1,4 +1,4 @@ [client] -user=nc_user +user=backup password=CHANGE_ME host=localhost diff --git a/etc/app-backup/db-restore.cnf.example b/etc/app-backup/db-restore.cnf.example new file mode 100644 index 0000000..35e7c09 --- /dev/null +++ b/etc/app-backup/db-restore.cnf.example @@ -0,0 +1,4 @@ +[client] +user=restore +password=CHANGE_ME +host=localhost diff --git a/etc/app-backup/db-wordpress.cnf.example b/etc/app-backup/db-wordpress.cnf.example index f1f5eaf..7faa19f 100644 --- a/etc/app-backup/db-wordpress.cnf.example +++ b/etc/app-backup/db-wordpress.cnf.example @@ -1,4 +1,4 @@ [client] -user=wp_user +user=backup password=CHANGE_ME host=localhost diff --git a/etc/app-backup/nc-db.cnf b/etc/app-backup/nc-db.cnf deleted file mode 100644 index 8c33fa9..0000000 --- a/etc/app-backup/nc-db.cnf +++ /dev/null @@ -1,4 +0,0 @@ -[client] -user=nc_user -password='XFYuyM9Ryom2GVfSwu4o' -host=localhost diff --git a/etc/app-backup/wp-db.cnf b/etc/app-backup/wp-db.cnf deleted file mode 100644 index c00ea96..0000000 --- a/etc/app-backup/wp-db.cnf +++ /dev/null @@ -1,4 +0,0 @@ -[client] -user=wordpressuser -password='p64AH!uK9' -host=localhost diff --git a/install.sh b/install.sh index a2c4f62..d1c84bd 100755 --- a/install.sh +++ b/install.sh @@ -1,43 +1,114 @@ #!/usr/bin/env bash +# Install/update without overwriting local configuration or starting a backup. +set -Eeuo pipefail +umask 077 +ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +[[ $EUID == 0 ]] || { echo 'Use sudo ./install.sh [--enable-timer]' >&2; exit 1; } +ENABLE_TIMER=false +case ${1:-} in --enable-timer) ENABLE_TIMER=true ;; '') ;; *) echo 'Usage: sudo ./install.sh [--enable-timer]' >&2; exit 1 ;; esac +(( $# <= 1 )) || 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' +import ast,sys +ast.parse(open(sys.argv[1]).read()) +PY +LOCKFILE=/run/app-backup.lock +if [[ -f /etc/app-backup/app-backup.conf ]]; then + [[ ! -L /etc/app-backup/app-backup.conf && $(stat -c %u /etc/app-backup/app-backup.conf) == 0 ]] || { echo 'Unsafe config owner/type'; exit 1; } + (( (8#$(stat -c %a /etc/app-backup/app-backup.conf) & 8#022) == 0 )) || { echo 'Unsafe config permissions'; exit 1; } + # Respect a customized lock path. Config is trusted root-owned Bash. + # shellcheck disable=SC1091 + source /etc/app-backup/app-backup.conf +fi +[[ ! -L $LOCKFILE ]] || exit 1 +exec 9>"$LOCKFILE" +flock -n 9 || { echo 'Backup/restore is running. Retry after it finishes.' >&2; exit 1; } +if systemctl is-active --quiet app-backup.service; then + echo 'app-backup.service is active. Wait for completion before updating.' >&2 + exit 1 +fi +TIMER_ACTIVE=false +if systemctl is-active --quiet app-backup.timer; then TIMER_ACTIVE=true; fi +SNAPSHOT=$(mktemp -d /var/backups/app-backup-update-XXXXXXXX) +chmod 700 "$SNAPSHOT" +declare -a SOURCES TARGETS MODES +add() { SOURCES+=("$ROOT_DIR/$1"); TARGETS+=("$2"); MODES+=("$3"); } +add usr-local-sbin/app-backup.sh /usr/local/sbin/app-backup.sh 750 +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/backup_set.py /usr/local/lib/app-backup/backup_set.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 + add systemd/app-backup.timer /etc/systemd/system/app-backup.timer.dist 644 +else add systemd/app-backup.timer /etc/systemd/system/app-backup.timer 644; fi +add logrotate/app-backup /etc/logrotate.d/app-backup 644 +if [[ -e /etc/app-backup/app-backup.conf ]]; then + add etc/app-backup/app-backup.conf /etc/app-backup/app-backup.conf.dist 600 +else add etc/app-backup/app-backup.conf /etc/app-backup/app-backup.conf 600; fi +for f in "$ROOT_DIR"/etc/app-backup/*.example; do add "etc/app-backup/$(basename "$f")" "/etc/app-backup/$(basename "$f")" 600; done +add README.md /usr/local/share/doc/app-backup/README.md 644 +add Dokumentation.md /usr/local/share/doc/app-backup/Dokumentation.md 644 +# Save the exact replaced files plus a rollback inventory; do not dereference links. +: > "$SNAPSHOT/present.txt"; : > "$SNAPSHOT/absent.txt" +for i in "${!TARGETS[@]}"; do + target=${TARGETS[i]} + [[ -f ${SOURCES[i]} ]] || { echo "Missing packaged file: ${SOURCES[i]}"; exit 1; } + if [[ -e $target || -L $target ]]; then + cp -a --parents -- "$target" "$SNAPSHOT" + echo "$target" >> "$SNAPSHOT/present.txt" + else echo "$target" >> "$SNAPSHOT/absent.txt"; fi +done +# Include config in the protected snapshot even when it is intentionally preserved. +if [[ -f /etc/app-backup/app-backup.conf ]]; then cp -a --parents /etc/app-backup/app-backup.conf "$SNAPSHOT"; fi +cat > "$SNAPSHOT/rollback.sh" <<'ROLLBACK' +#!/usr/bin/env bash set -euo pipefail -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -echo "-- Installing app-backup..." - -sudo mkdir -p /etc/app-backup -sudo mkdir -p /usr/local/sbin -sudo mkdir -p /var/log/app-backup -sudo mkdir -p /var/backups/app-backup - -# Scripts -sudo install -m 0750 -o root -g root "$ROOT_DIR/usr-local-sbin/app-backup.sh" /usr/local/sbin/app-backup.sh -sudo install -m 0750 -o root -g root "$ROOT_DIR/usr-local-sbin/app-restore.sh" /usr/local/sbin/app-restore.sh - -# Config (copy once) -if [[ ! -f /etc/app-backup/app-backup.conf ]]; then - sudo install -m 0640 -o root -g root "$ROOT_DIR/etc/app-backup/app-backup.conf" /etc/app-backup/app-backup.conf - echo " - installed /etc/app-backup/app-backup.conf (please adjust paths/db names)" -else - echo " - /etc/app-backup/app-backup.conf exists, not overwriting" -fi - -# DB examples (do not overwrite real files) -sudo install -m 0640 -o root -g root "$ROOT_DIR/etc/app-backup/db-wordpress.cnf.example" /etc/app-backup/db-wordpress.cnf.example -sudo install -m 0640 -o root -g root "$ROOT_DIR/etc/app-backup/db-nextcloud.cnf.example" /etc/app-backup/db-nextcloud.cnf.example - -# systemd -sudo install -m 0644 -o root -g root "$ROOT_DIR/systemd/app-backup.service" /etc/systemd/system/app-backup.service -sudo install -m 0644 -o root -g root "$ROOT_DIR/systemd/app-backup.timer" /etc/systemd/system/app-backup.timer - -# logrotate -sudo install -m 0644 -o root -g root "$ROOT_DIR/logrotate/app-backup" /etc/logrotate.d/app-backup - -sudo systemctl daemon-reload -sudo systemctl enable --now app-backup.timer - -echo "-- Done." -echo "Next steps:" -echo "1) Adjust /etc/app-backup/app-backup.conf" -echo "2) Create DB cnf files from examples (chmod 600, root:root)" -echo "3) Ensure rclone works as root: sudo rclone lsd onedrive:" -echo "4) Test run: sudo systemctl start app-backup.service" +[[ $EUID == 0 ]] || exit 1 +here=$(cd -- "$(dirname -- "$0")" && pwd) +LOCKFILE=/run/app-backup.lock +if [[ -f /etc/app-backup/app-backup.conf ]]; then source /etc/app-backup/app-backup.conf; fi +exec 9>"$LOCKFILE" +flock -n 9 || { echo 'Backup/restore/update is running'; exit 1; } +if systemctl is-active --quiet app-backup.service; then echo 'Backup service is active'; exit 1; fi +systemctl stop app-backup.timer +while IFS= read -r target; do cp -a --remove-destination -- "$here$target" "$target"; done < "$here/present.txt" +while IFS= read -r target; do rm -f -- "$target"; done < "$here/absent.txt" +systemctl daemon-reload +echo 'Previous package restored; config and timer state require manual review. Timer remains stopped.' +ROLLBACK +chmod 700 "$SNAPSHOT/rollback.sh" +CHANGING=false +on_exit() { + local result=$? + if (( result != 0 )) && [[ $CHANGING == true ]]; then + echo "UPDATE FAILED. Timer remains stopped. Roll back with: sudo $SNAPSHOT/rollback.sh" >&2 + fi +} +trap on_exit EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +systemctl stop app-backup.timer +CHANGING=true +for i in "${!TARGETS[@]}"; do + target=${TARGETS[i]} + install -d -m 755 -- "$(dirname -- "$target")" + [[ $(dirname -- "$target") != /etc/app-backup ]] || chmod 700 /etc/app-backup + # Install via a sibling temp file + rename, avoiding partially-written scripts. + tmp=$(mktemp "${target}.update-XXXXXX") + install -m "${MODES[i]}" -o root -g root -- "${SOURCES[i]}" "$tmp" + mv -fT -- "$tmp" "$target" +done +install -d -m 700 /var/log/app-backup /var/backups/app-backup +systemctl daemon-reload +# A persistent timer can fire immediately. Release the package lock only after +# all files are installed, but before that timer can start the new backup. +flock -u 9 +if [[ $ENABLE_TIMER == true ]]; then systemctl enable --now app-backup.timer +elif [[ $TIMER_ACTIVE == true ]]; then systemctl start app-backup.timer; fi +CHANGING=false +echo "Installed. Rollback package: $SNAPSHOT" +echo 'Existing config and timer schedule were preserved. Compare *.dist files and review migration documentation.' +echo 'Validate: sudo /usr/local/sbin/app-backup.sh --check' +echo 'Fresh installations: configure first, test a backup + restore, then enable app-backup.timer.' diff --git a/lib/backup_set.py b/lib/backup_set.py new file mode 100644 index 0000000..c0867bf --- /dev/null +++ b/lib/backup_set.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Strict backup-set metadata and tar validation; never execute backup metadata. + +GNU tar performs extraction to preserve ACLs/xattrs. This helper first validates +all member names and link relationships, independent of archive member order. +""" +import configparser +import datetime as dt +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys +import tarfile + +FORMAT = 2 +LABEL = re.compile(r"(?:meta|wordpress|nextcloud(?:-data)?|gitea(?:-etc|-repos|-custom)?|mail|postfix|dovecot|mail-home-\d+|extra-\d+|db-(?:wordpress|nextcloud|gitea))\Z") +RUN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})_[0-9a-f]{8}\Z") + + +def digest(path): + h = hashlib.sha256() + with open(path, "rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def regular(path): + if path.is_symlink() or not path.is_file(): + raise ValueError(f"Not a regular file: {path}") + + +def expected_root(label): + if label == "meta": + return "meta" + if label.startswith("db-"): + return f"db/{label[3:]}.sql" + return f"files/{label}" + + +def load_set(folder, metadata_only=False): + regular(folder / "manifest.json") + regular(folder / "COMPLETE") + raw = (folder / "manifest.json").read_bytes() + if len(raw) > 1024 * 1024: + raise ValueError("Manifest too large") + if (folder / "COMPLETE").read_text() != f"APP_BACKUP_V2 {hashlib.sha256(raw).hexdigest()}\n": + raise ValueError("Missing or mismatched completion marker") + data = json.loads(raw) + if data["format"] != FORMAT or not RUN.fullmatch(data["run_id"]): + raise ValueError("Unsupported backup format/run ID") + if not isinstance(data["created_epoch"], int) or data["created_epoch"] <= 0: + raise ValueError("Invalid creation time") + seen = set() + if not isinstance(data["archives"], list) or not data["archives"]: + raise ValueError("Empty backup set") + for item in data["archives"]: + label = item["label"] + if not LABEL.fullmatch(label) or label in seen: + raise ValueError(f"Invalid/duplicate component: {label}") + seen.add(label) + if item["root"] != expected_root(label): + raise ValueError("Unexpected archive root") + if item["file"] not in (f"{label}.tar.gz", f"{label}.tar.zst"): + raise ValueError("Unsafe archive filename") + if not re.fullmatch(r"[0-9a-f]{64}", item["sha256"]): + raise ValueError("Invalid SHA256") + if not isinstance(item["size"], int) or item["size"] <= 0: + raise ValueError("Invalid archive size") + if not metadata_only: + archive = folder / item["file"] + regular(archive) + if archive.stat().st_size != item["size"] or digest(archive) != item["sha256"]: + raise ValueError(f"Archive checksum/size mismatch: {archive.name}") + if "meta" not in seen: + raise ValueError("Missing metadata component") + if not metadata_only: + allowed = {a["file"] for a in data["archives"]} | {"manifest.json", "COMPLETE", "UPLOADED"} + if {p.name for p in folder.iterdir()} - allowed: + raise ValueError("Unexpected files in backup set") + return data + + +def member_name(value): + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts or not path.parts: + raise ValueError(f"Unsafe tar path: {value!r}") + if any(ord(c) < 32 for c in value): + raise ValueError("Control character in tar path") + return str(path) + + +def check_tar(path, roots): + command = ["zstd", "-dc", "--", str(path)] if str(path).endswith(".zst") else ["gzip", "-dc", "--", str(path)] + proc = subprocess.Popen(command, stdout=subprocess.PIPE) + entries = {} + hardlinks = [] + try: + with tarfile.open(fileobj=proc.stdout, mode="r|") as archive: + for item in archive: + name = member_name(item.name) + if not any(name == root or name.startswith(root + "/") for root in roots): + raise ValueError(f"Member outside component root: {name}") + if name in entries: + raise ValueError(f"Duplicate tar member: {name}") + if not (item.isdir() or item.isfile() or item.issym() or item.islnk()): + raise ValueError(f"Special device/FIFO/socket not supported: {name}") + entries[name] = "dir" if item.isdir() else "file" if item.isfile() else "link" + if item.islnk(): + hardlinks.append((name, member_name(item.linkname))) + # Symlinks may point outside the archive (e.g. /etc/alternatives), + # but no member may be extracted THROUGH a symlink or hardlink. + for name in entries: + for parent in PurePosixPath(name).parents: + if str(parent) in entries and entries[str(parent)] != "dir": + raise ValueError(f"Tar member descends through a non-directory: {name}") + for name, target in hardlinks: + if entries.get(target) != "file": + raise ValueError(f"Hardlink target is not an archived regular file: {name}") + if any(entries.get(root) != ("file" if root.endswith(".sql") else "dir") for root in roots): + raise ValueError("Required archive root missing") + # Consume the compression trailer, including errors after the tar EOF. + while proc.stdout.read(1024 * 1024): + pass + if proc.wait() != 0: + raise ValueError(f"Decompression failed: {path}") + finally: + proc.stdout.close() + if proc.poll() is None: + proc.terminate() + proc.wait() + + +def create(folder, run_id, plan): + entries = [] + for line in Path(plan).read_text().splitlines(): + label, source = line.split("\t") + matches = list(folder.glob(f"{label}.tar.*")) + if len(matches) != 1: + raise ValueError(f"Missing/ambiguous archive: {label}") + path = matches[0] + check_tar(path, [expected_root(label)]) + entries.append(dict(label=label, source=source, root=expected_root(label), file=path.name, + size=path.stat().st_size, sha256=digest(path))) + manifest = dict(format=FORMAT, run_id=run_id, created_epoch=int(dt.datetime.now(dt.timezone.utc).timestamp()), + hostname=os.uname().nodename, archives=entries) + (folder / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=True) + "\n") + (folder / "COMPLETE").write_text(f"APP_BACKUP_V2 {digest(folder / 'manifest.json')}\n") + + +def crypt(name): + cfg = configparser.ConfigParser(interpolation=None) + try: + cfg.read_string(sys.stdin.read()) + except configparser.Error: + raise ValueError("Invalid rclone configuration (details suppressed)") from None + remote = cfg[name] + if remote.get("type") != "crypt": + raise ValueError("Remote must have type=crypt") + if remote.get("filename_encryption", "standard") != "standard": + raise ValueError("Require filename_encryption=standard") + if not remote.getboolean("directory_name_encryption", True): + raise ValueError("Directory encryption is disabled") + if remote.getboolean("no_data_encryption", False): + raise ValueError("Content encryption is disabled") + print("Crypt configuration accepted (contents, filenames, directories)") + + +def candidates(prefix, days, keep): + # Only v2 names supplied AFTER completion-marker validation are eligible. + names = set(sys.stdin.read().splitlines()) + valid = [] + for name in names: + match = RUN.fullmatch(name) + if match and name.startswith(prefix + "_"): + date = dt.datetime.strptime(match[1], "%Y-%m-%d_%H-%M-%S").replace(tzinfo=dt.timezone.utc) + valid.append((date, name)) + valid.sort(reverse=True) + cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=int(days)) + for date, name in valid[int(keep):]: + if date < cutoff: + print(name) + + +def write_status(folder, operation, run_id, code): + data = dict(operation=operation, run=run_id, exit_code=int(code), + time=int(dt.datetime.now(dt.timezone.utc).timestamp())) + names = [f"last-{operation}.json"] + if operation == "backup" and int(code) == 0: + names.append("last-successful-backup.json") + for name in names: + path = Path(folder) / name + with open(str(path) + ".tmp", "w") as stream: + json.dump(data, stream) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(str(path) + ".tmp", path) + + +def main(): + op, *args = sys.argv[1:] + if op == "status": + write_status(*args) + elif op == "crypt": + crypt(*args) + elif op == "create": + create(Path(args[0]), *args[1:]) + elif op in ("verify", "metadata", "list"): + data = load_set(Path(args[0]), metadata_only=op == "metadata") + if op == "verify": + for item in data["archives"]: + check_tar(Path(args[0]) / item["file"], [item["root"]]) + print(f"Verified: {data['run_id']} ({len(data['archives'])} archives)") + elif op == "list": + for item in data["archives"]: + print("\t".join([item["label"], item["file"], item["root"]])) + elif op == "candidates": + candidates(*args) + elif op == "tar": + check_tar(Path(args[0]), args[1:]) + else: + raise ValueError("Unknown operation") + + +if __name__ == "__main__": + try: + main() + except (ValueError, KeyError, OSError, tarfile.TarError, configparser.Error) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..5488800 --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,345 @@ +#!/usr/bin/env bash +# Shared safety rules. Configuration is trusted root-owned Bash, never archive input. +set -Eeuo pipefail +shopt -s inherit_errexit +umask 077 +export LC_ALL=C + +die() { echo "ERROR: $*" >&2; exit 1; } +need() { command -v "$1" >/dev/null || die "Missing tool: $1"; } +helper() { python3 "$LIB_DIR/backup_set.py" "$@"; } + +secure_file() { + [[ -f "$1" && ! -L "$1" ]] || die "Expected regular file: $1" + [[ $(stat -c %u "$1") == 0 ]] || die "File must belong to root: $1" + (( (8#$(stat -c %a "$1") & 8#022) == 0 )) || die "File writable by group/others: $1" +} + +load_config() { + [[ $EUID == 0 ]] || die "Run as root." + secure_file "$CONFIG_FILE" + # shellcheck disable=SC1090 + source "$CONFIG_FILE" + : "${WORKDIR:=/var/backups/app-backup}" + : "${STAGING_ROOT:=$WORKDIR/staging}" "${ARCHIVE_DIR:=$WORKDIR/archives}" + : "${RESTORE_ROOT:=$WORKDIR/restore}" "${LOG_DIR:=/var/log/app-backup}" + : "${LOCKFILE:=/run/app-backup.lock}" "${ARCHIVE_PREFIX:=appbackup}" + : "${COMPRESSOR:=zstd}" "${ZSTD_LEVEL:=6}" "${ZSTD_THREADS:=2}" + : "${LOG_RETENTION_DAYS:=30}" + : "${MIN_FREE_GB:=10}" "${LOCAL_RETENTION_DAYS:=7}" "${REMOTE_RETENTION_DAYS:=30}" + : "${RETENTION_KEEP_LAST:=3}" "${ENABLE_REMOTE_RETENTION:=false}" + : "${ENABLE_UPLOAD:=true}" "${RCLONE_BIN:=rclone}" + : "${RCLONE_REMOTE_BASE:=}" "${RCLONE_CONFIG:=/root/.config/rclone/rclone.conf}" + : "${RCLONE_RETRIES:=6}" "${RCLONE_LOW_LEVEL_RETRIES:=20}" + : "${RCLONE_RETRIES_SLEEP:=30s}" "${RCLONE_TIMEOUT:=1h}" "${RCLONE_CONTIMEOUT:=30s}" + : "${RCLONE_TRANSFERS:=2}" "${RCLONE_CHECKERS:=4}" "${RCLONE_ONEDRIVE_CHUNK_SIZE:=80Mi}" + : "${RCLONE_BWLIMIT:=0}" "${ENABLE_MAIL_REPORT:=true}" "${MAIL_TO:=johannes}" + : "${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}" + : "${ENABLE_GITEA_SERVICE_STOP:=true}" "${GITEA_SERVICE_NAME:=gitea}" + : "${GITEA_DATA_DIR:=/var/lib/gitea/data}" "${GITEA_ETC_DIR:=/etc/gitea}" + : "${ENABLE_HTTPD_STOP:=true}" "${HTTPD_SERVICE_NAME:=httpd}" + : "${ENABLE_PHPFPM_STOP:=true}" "${PHPFPM_SERVICE_NAME:=php-fpm}" + : "${MAIL_BACKUP_MODE:=stop}" "${ENABLE_MAIL_HOME_DIRS:=true}" + : "${MAIL_DIR:=/var/vmail}" "${POSTFIX_DIR:=/etc/postfix}" "${DOVECOT_DIR:=/etc/dovecot}" + : "${RESTORE_STRICT_DELETE:=false}" + # Preserve old space-separated lists; arrays additionally support spaces in paths. + if ! declare -p MAIL_HOME_DIRS &>/dev/null; then MAIL_HOME_DIRS=(/home/johannes /home/luciana); fi + if [[ $(declare -p MAIL_HOME_DIRS) != 'declare -a '* ]]; then + read -r -a MAIL_HOME_DIRS <<< "${MAIL_HOME_DIRS[0]}" + fi + if ! declare -p MAIL_HOME_EXCLUDES &>/dev/null; then + MAIL_HOME_EXCLUDES=(OneDrive/ .cache/ .ccache/ .local/share/Trash/) + elif [[ $(declare -p MAIL_HOME_EXCLUDES) != 'declare -a '* ]]; then + read -r -a MAIL_HOME_EXCLUDES <<< "${MAIL_HOME_EXCLUDES[0]}" + fi + for v in ENABLE_UPLOAD ENABLE_REMOTE_RETENTION ENABLE_MAIL_REPORT ENABLE_WORDPRESS ENABLE_NEXTCLOUD \ + ENABLE_NEXTCLOUD_DATA ENABLE_GITEA ENABLE_MAIL ENABLE_DB_DUMPS ENABLE_NEXTCLOUD_MAINTENANCE \ + ENABLE_GITEA_SERVICE_STOP ENABLE_HTTPD_STOP ENABLE_PHPFPM_STOP ENABLE_MAIL_HOME_DIRS RESTORE_STRICT_DELETE; do + [[ ${!v} == true || ${!v} == false ]] || die "$v must be true or false" + done + for v in LOG_RETENTION_DAYS MIN_FREE_GB LOCAL_RETENTION_DAYS REMOTE_RETENTION_DAYS RETENTION_KEEP_LAST ZSTD_LEVEL ZSTD_THREADS; do + [[ ${!v} =~ ^(0|[1-9][0-9]*)$ ]] || die "$v must be a non-negative integer" + done + (( RETENTION_KEEP_LAST >= 2 )) || die "RETENTION_KEEP_LAST must be >= 2" + (( ZSTD_LEVEL >= 1 && ZSTD_LEVEL <= 19 && ZSTD_THREADS >= 1 )) || die "Invalid zstd settings" + [[ $ARCHIVE_PREFIX =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]] || die "Unsafe ARCHIVE_PREFIX" + [[ $COMPRESSOR == zstd || $COMPRESSOR == gzip ]] || die "COMPRESSOR must be zstd or gzip" + [[ $MAIL_BACKUP_MODE == stop || $MAIL_BACKUP_MODE == live ]] || die "MAIL_BACKUP_MODE must be stop or live" + if [[ -z ${DB_DUMP_BIN:-} ]]; then DB_DUMP_BIN=$(command -v mariadb-dump || command -v mysqldump || true); fi + if [[ -z ${DB_CLIENT_BIN:-} ]]; then DB_CLIENT_BIN=$(command -v mariadb || command -v mysql || true); fi +} + +guard_workspaces() { + local p src resolved other + local sources=("${EXTRA_PATHS[@]}") + if [[ $ENABLE_WORDPRESS == true ]]; then sources+=("${WP_DIR:-}"); fi + if [[ $ENABLE_NEXTCLOUD == true ]]; then sources+=("${NC_DIR:-}" "${NC_DATA_DIR:-}"); fi + if [[ $ENABLE_GITEA == true ]]; then sources+=("$GITEA_DATA_DIR" "$GITEA_ETC_DIR" "${GITEA_REPOS_DIR:-}" "${GITEA_CUSTOM_DIR:-}"); fi + if [[ $ENABLE_MAIL == true ]]; then + sources+=("$MAIL_DIR" "$POSTFIX_DIR" "$DOVECOT_DIR") + if [[ $ENABLE_MAIL_HOME_DIRS == true ]]; then sources+=("${MAIL_HOME_DIRS[@]}"); fi + fi + # Validate the WHOLE plan before mkdir/chmod touches any configured directory. + for p in "$WORKDIR" "$STAGING_ROOT" "$ARCHIVE_DIR" "$RESTORE_ROOT" "$LOG_DIR"; do + resolved=$(realpath -m -- "$p") + case $resolved in /|/etc|/usr|/usr/local|/var|/var/lib|/var/log|/var/backups|/home|/root|/tmp|/run|/opt|/srv) + die "Use a dedicated working directory, not $resolved" ;; + esac + for src in "${sources[@]}"; do + [[ -n $src ]] || continue + other=$(realpath -m -- "$src") + [[ $resolved != "$other" && $resolved != "$other/"* && $other != "$resolved/"* ]] || die "Source and working path overlap: $src / $p" + done + done +} + +init_runtime() { + need python3; need flock + guard_workspaces + # Both backup and restore, and the installer, take the same lock. + [[ $LOCKFILE == /* && ! -L $LOCKFILE ]] || die "Unsafe lock path" + if [[ -e $LOCKFILE ]]; then + [[ -f $LOCKFILE && ! -s $LOCKFILE && $(stat -c %u "$LOCKFILE") == 0 ]] || die "Unsafe existing lock file" + fi + exec 9>"$LOCKFILE" + flock -n 9 || die "Another backup/restore/update holds $LOCKFILE" + for p in "$WORKDIR" "$STAGING_ROOT" "$ARCHIVE_DIR" "$RESTORE_ROOT" "$LOG_DIR"; do + [[ $p == /* && $p != / && $p != *$'\n'* && $p != *$'\t'* ]] || die "Unsafe working path: $p" + [[ ! -L $p ]] || die "Symlink working directory refused: $p" + mkdir -p -- "$p" + [[ $(stat -c %u "$p") == 0 ]] || die "Working directory must belong to root: $p" + chmod 700 -- "$p" + 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" + # Journald already captures service stdout. No dependency on systemd-cat in terminals. + exec > >(tee -a "$LOG_FILE") 2>&1 + echo "== $OPERATION $RUN_ID ==" + need rsync; need tar; need "$COMPRESSOR" + STOPPED_SERVICES=(); NC_CHANGED=false; NC_WAS_ON=false; MUTATION_STARTED=false + trap 'exit 130' INT + trap 'exit 143' TERM + trap finish EXIT +} + +# Same canonical paths must not contain one another when backup writes into them. +check_source_path() { + local src p + src=$(realpath -e -- "$1") || die "Missing source: $1" + [[ -d $src && $src != / ]] || die "Source must be a directory other than /: $1" + [[ $src != *$'\n'* && $src != *$'\t'* ]] || die "Unsupported control character in path" + for p in "$WORKDIR" "$STAGING_ROOT" "$ARCHIVE_DIR" "$RESTORE_ROOT" "$LOG_DIR"; do + p=$(realpath -m -- "$p") + [[ $p != "$src" && $p != "$src/"* && $src != "$p/"* ]] || die "Source and working path overlap: $src / $p" + done +} + +# Build a stable component mapping. Restore destinations come ONLY from local config. +build_plan() { + LABELS=(); SOURCES=(); ROOTS=(); DB_LABELS=(); DB_NAMES=(); DB_CNFS=() + if [[ $ENABLE_WORDPRESS == true ]]; then add_source wordpress "${WP_DIR:?Set WP_DIR}"; add_db wordpress WP; fi + if [[ $ENABLE_NEXTCLOUD == true ]]; then + add_source nextcloud "${NC_DIR:?Set NC_DIR}" + : "${NC_DATA_DIR:=$NC_DIR/data}" + if [[ $ENABLE_NEXTCLOUD_DATA == true ]]; then add_source nextcloud-data "$NC_DATA_DIR"; fi + add_db nextcloud NC + fi + if [[ $ENABLE_GITEA == true ]]; then + add_source gitea "$GITEA_DATA_DIR"; add_source gitea-etc "$GITEA_ETC_DIR" + [[ -z ${GITEA_REPOS_DIR:-} ]] || add_source gitea-repos "$GITEA_REPOS_DIR" + [[ -z ${GITEA_CUSTOM_DIR:-} ]] || add_source gitea-custom "$GITEA_CUSTOM_DIR" + add_db gitea GITEA + fi + if [[ $ENABLE_MAIL == true ]]; then + add_source mail "$MAIL_DIR"; add_source postfix "$POSTFIX_DIR"; add_source dovecot "$DOVECOT_DIR" + if [[ $ENABLE_MAIL_HOME_DIRS == true ]]; then + local i=0 + for p in "${MAIL_HOME_DIRS[@]}"; do add_source "mail-home-$i" "$p"; i=$((i+1)); done + fi + fi + local i=0 + for p in "${EXTRA_PATHS[@]}"; do add_source "extra-$i" "$p"; i=$((i+1)); done + (( ${#LABELS[@]} > 0 )) || die "No enabled sources" + if [[ $ENABLE_NEXTCLOUD == true ]]; then + local nc_path data_path + nc_path=$(realpath -m -- "$NC_DIR"); data_path=$(realpath -m -- "$NC_DATA_DIR") + [[ $nc_path != "$data_path" && $nc_path != "$data_path/"* ]] || die 'Nextcloud data must not contain the code directory' + if [[ $ENABLE_WORDPRESS == true ]]; then + [[ $(realpath -m -- "$WP_DIR") != "$nc_path" ]] || die 'WordPress and Nextcloud must not share the same directory' + fi + fi +} +add_source() { LABELS+=("$1"); SOURCES+=("$2"); ROOTS+=("files/$1"); } +add_db() { + [[ $ENABLE_DB_DUMPS == true ]] || return 0 + local n="${2}_DB_NAME" c="${2}_DB_CNF" + [[ -n ${!n:-} && -n ${!c:-} ]] || die "Set $n and $c for enabled application" + [[ ${!n} =~ ^[a-zA-Z0-9_][a-zA-Z0-9_-]*$ ]] || die "Unsupported database name: ${!n}" + DB_LABELS+=("db-$1"); DB_NAMES+=("${!n}"); DB_CNFS+=("${!c}") +} + +check_db_credentials() { + local f + for f in "${DB_CNFS[@]}"; do + secure_file "$f" + (( (8#$(stat -c %a "$f") & 8#077) == 0 )) || die "Use chmod 600 for DB credentials: $f" + done +} + +preflight() { + for p in "${SOURCES[@]}"; do check_source_path "$p"; done + check_db_credentials + if (( ${#DB_LABELS[@]} )); then + need "$DB_DUMP_BIN"; need "$DB_CLIENT_BIN" + for i in "${!DB_LABELS[@]}"; do + "$DB_CLIENT_BIN" --defaults-extra-file="${DB_CNFS[i]}" --database="${DB_NAMES[i]}" --execute='SELECT 1' >/dev/null + done + fi + if [[ $ENABLE_NEXTCLOUD == true ]]; then need sudo; need php; [[ -f $NC_DIR/occ ]] || die "Nextcloud occ missing"; fi + need systemctl + if [[ $ENABLE_UPLOAD == true ]]; then check_remote; fi + check_space "$STAGING_ROOT"; check_space "$ARCHIVE_DIR" +} +check_space() { + local avail + avail=$(df -PB1 -- "$1" | awk 'END{print $4}') + (( avail >= MIN_FREE_GB * 1024 * 1024 * 1024 )) || die "Free-space reserve (${MIN_FREE_GB} GiB) unavailable on $1" +} + +check_remote() { + need "$RCLONE_BIN" + [[ $RCLONE_REMOTE_BASE =~ ^[a-zA-Z0-9_-]+:[^:]+$ ]] || die "Use a named crypt remote with a dedicated non-empty path" + [[ $RCLONE_REMOTE_BASE != *$'\n'* ]] || die "Invalid remote path" + secure_file "$RCLONE_CONFIG" + (( (8#$(stat -c %a "$RCLONE_CONFIG") & 8#077) == 0 )) || die "Use chmod 600 for rclone config" + # Environment-defined backend types/settings could invalidate a config-only audit. + local key name=${RCLONE_REMOTE_BASE%%:*} + for key in ${!RCLONE_CONFIG_@}; do + [[ $key == RCLONE_CONFIG_PASS ]] || die "Remote overrides through $key are unsupported; use the config file" + done + # Pipe directly into the parser: compatibility with older rclone without + # config redacted. Secret values never go to the log or a temporary file. + "$RCLONE_BIN" --config "$RCLONE_CONFIG" config show "$name" | helper crypt "$name" + RCLONE_ARGS=(--config "$RCLONE_CONFIG" --no-check-certificate=false --crypt-filename-encryption standard + --crypt-directory-name-encryption=true --crypt-no-data-encryption=false + --retries "$RCLONE_RETRIES" --low-level-retries "$RCLONE_LOW_LEVEL_RETRIES" + --retries-sleep "$RCLONE_RETRIES_SLEEP" --timeout "$RCLONE_TIMEOUT" --contimeout "$RCLONE_CONTIMEOUT" + --transfers "$RCLONE_TRANSFERS" --checkers "$RCLONE_CHECKERS" --onedrive-chunk-size "$RCLONE_ONEDRIVE_CHUNK_SIZE" + --bwlimit "$RCLONE_BWLIMIT") + rc lsd "${name}:" --max-depth 1 >/dev/null +} +rc() { "$RCLONE_BIN" "${RCLONE_ARGS[@]}" "$@"; } + +# Exclusions are rooted so a nested directory named data is not accidentally lost. +exclusions() { + EXCLUDES=() + local rel x + if [[ $1 == wordpress ]]; then + for x in "${NC_DIR:-}" "${NC_DATA_DIR:-}"; do + [[ -n $x ]] || continue + rel=$(realpath -m --relative-to="${WP_DIR}" -- "$x") + if [[ $rel != .. && $rel != ../* && $rel != . ]]; then EXCLUDES+=("--exclude=/$rel/***"); fi + done + for x in "${WP_EXCLUDES[@]}"; do EXCLUDES+=("--exclude=$x"); done + elif [[ $1 == nextcloud ]]; then + rel=$(realpath -m --relative-to="$NC_DIR" -- "$NC_DATA_DIR") + if [[ $rel != .. && $rel != ../* && $rel != . ]]; then EXCLUDES+=("--exclude=/$rel/***"); fi + elif [[ $1 == mail-home-* ]]; then + EXCLUDES+=(--one-file-system) + for x in "${MAIL_HOME_EXCLUDES[@]}"; do EXCLUDES+=("--exclude=${x#--exclude=}"); done + fi +} + +stop_service() { + local state + state=$(systemctl show "$1" --property=ActiveState --value) + case $state in + active|activating|reloading) + # Record intent before stop: a failed/partial stop still needs recovery. + STOPPED_SERVICES+=("$1") + save_recovery + systemctl stop "$1" + ;; + inactive|failed) ;; + *) die "Unknown/transitioning service state for $1: $state" ;; + esac +} +occ() { sudo -u "$NC_OCC_USER" php "$NC_DIR/occ" "$@"; } +quiesce() { + if [[ $ENABLE_NEXTCLOUD == true ]]; then + if [[ $ENABLE_NEXTCLOUD_MAINTENANCE == true ]]; then + local status + status=$(occ status --output=json) + NC_WAS_ON=$(printf '%s' "$status" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert isinstance(d["maintenance"], bool); print(str(d["maintenance"]).lower())') + if [[ $NC_WAS_ON == false ]]; then NC_CHANGED=true; save_recovery; occ maintenance:mode --on; fi + else echo "WARNING: Nextcloud maintenance disabled; consistency depends on external writer shutdown"; fi + fi + # Include application cron/worker services in this array; crond itself is not stopped. + for p in "${QUIESCE_SERVICES[@]}"; do stop_service "$p"; done + if [[ $ENABLE_WORDPRESS == true || $ENABLE_NEXTCLOUD == true ]]; then + if [[ $ENABLE_HTTPD_STOP == true ]]; then stop_service "$HTTPD_SERVICE_NAME"; fi + if [[ $ENABLE_PHPFPM_STOP == true ]]; then stop_service "$PHPFPM_SERVICE_NAME"; fi + fi + if [[ $ENABLE_GITEA == true ]]; then + if [[ $ENABLE_GITEA_SERVICE_STOP == true ]]; then stop_service "$GITEA_SERVICE_NAME"; else echo "WARNING: Gitea is not stopped"; fi + fi + if [[ $ENABLE_MAIL == true ]]; then + if [[ $MAIL_BACKUP_MODE == stop ]]; then stop_service postfix; stop_service dovecot + else echo "WARNING: live mail copy is not a consistent snapshot"; fi + fi +} +# Survives SIGKILL/power loss, which Bash traps cannot handle. This is an +# instruction record for the operator, not an automatically executed recovery. +save_recovery() { + python3 - "$WORKDIR/recovery-$RUN_ID.json" "$OPERATION" "$NC_CHANGED" "$NC_WAS_ON" "${STAGING_DIR:-}" "${STOPPED_SERVICES[@]}" <<'PYREC' +import json,os,sys +path,operation,changed,was_on,staging,*services=sys.argv[1:] +with open(path+'.tmp','w') as stream: + json.dump(dict(operation=operation, staging=staging, nextcloud_changed=changed=='true', nextcloud_was_on=was_on=='true', stopped_services=services),stream,indent=2) + stream.flush() + os.fsync(stream.fileno()) +os.replace(path+'.tmp',path) +PYREC +} +resume_services() { + local failed=0 i + if [[ $NC_CHANGED == true ]]; then + if occ maintenance:mode --off; then NC_CHANGED=false; else failed=1; fi + fi + for ((i=${#STOPPED_SERVICES[@]}-1; i>=0; i--)); do + [[ -n ${STOPPED_SERVICES[i]} ]] || continue + if systemctl start "${STOPPED_SERVICES[i]}"; then STOPPED_SERVICES[i]=''; else failed=1; fi + done + if (( failed == 0 )); then + rm -f -- "$WORKDIR/recovery-$RUN_ID.json" + else save_recovery; fi + return "$failed" +} +finish() { + local code=$? + trap - EXIT INT TERM + set +e + if [[ $OPERATION == restore && $MUTATION_STARTED == true && $code != 0 ]]; then + if [[ $ENABLE_NEXTCLOUD == true ]]; then occ maintenance:mode --on || echo 'ERROR: Could not enforce Nextcloud maintenance'; fi + save_recovery + echo "ERROR: Partial restore. Service recovery requires manual review: ${STOPPED_SERVICES[*]}" + echo "ERROR: Keep Nextcloud in maintenance; review $LOG_FILE before restarting." + else + resume_services || code=1 + 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 + # 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 + fi + exit "$code" +} diff --git a/logrotate/app-backup b/logrotate/app-backup index e800c72..1542eab 100644 --- a/logrotate/app-backup +++ b/logrotate/app-backup @@ -1,9 +1,5 @@ -/var/log/app-backup/*.log { - daily - rotate 7 - missingok - notifempty - compress - delaycompress - create 0640 root root -} +# app-backup v2 writes one private log per run. Successful backups remove v2 +# logs older than LOG_RETENTION_DAYS; journald has its own system-wide policy. +# Historic v1 app-backup_*.log / rclone_*.log files are retained for manual review. +# No rotating rule: rotating an already completed run leaves empty successors +# and can prevent maxage cleanup when notifempty is used. diff --git a/systemd/app-backup.service b/systemd/app-backup.service index 0825d41..a5a7030 100644 --- a/systemd/app-backup.service +++ b/systemd/app-backup.service @@ -1,19 +1,24 @@ [Unit] -Description=Resilient daily app backup (WP/Nextcloud/Mail) to OneDrive via rclone + mail report +Description=Verified application backup to rclone crypt storage Wants=network-online.target After=network-online.target [Service] Type=oneshot ExecStart=/usr/local/sbin/app-backup.sh -TimeoutStartSec=6h - +# Timeout sends TERM: Bash runs its recovery trap; grace time permits service restart. +TimeoutStartSec=12h +TimeoutStopSec=5min +KillMode=mixed +UMask=0077 Nice=10 IOSchedulingClass=best-effort IOSchedulingPriority=6 - +PrivateTmp=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true Environment=LANG=C Environment=LC_ALL=C - -[Install] -WantedBy=multi-user.target +StandardOutput=journal +StandardError=journal diff --git a/tests/Dockerfile b/tests/Dockerfile new file mode 100644 index 0000000..7569d4c --- /dev/null +++ b/tests/Dockerfile @@ -0,0 +1,4 @@ +FROM debian:trixie-slim +RUN apt-get update && apt-get install -y --no-install-recommends bash python3 rsync zstd gzip tar rclone shellcheck sudo mariadb-server mariadb-client ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /src +CMD ["bash", "tests/run.sh"] diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..00e4f82 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +cd "$(dirname "$0")/.." +for script in lib/common.sh usr-local-sbin/*.sh install.sh tests/run.sh; do bash -n "$script"; done +shellcheck -x lib/common.sh usr-local-sbin/*.sh install.sh tests/run.sh +python3 -m unittest discover -s tests -v diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..63d659a --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,423 @@ +"""Integration tests use real tar/rsync/rclone crypt and a real isolated MariaDB. +Only systemd/occ are simulated: production services never receive test commands. +""" +import gzip +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import time +import unittest + +REPO = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("backup_set", REPO / "lib/backup_set.py") +HELPER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(HELPER) + + +class ArchiveValidation(unittest.TestCase): + def archive(self, entries): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + path = Path(temp.name) / 'test.tar.gz' + with tarfile.open(path, 'w:gz') as archive: + for name, kind, target in entries: + info = tarfile.TarInfo(name) + info.type = kind + info.linkname = target + archive.addfile(info) + return path + + def test_traversal_and_link_pivot_rejected(self): + for entries in [ + [('files/wordpress', tarfile.DIRTYPE, ''), ('files/wordpress/../../evil', tarfile.REGTYPE, '')], + [('files/wordpress', tarfile.DIRTYPE, ''), ('files/wordpress/link', tarfile.SYMTYPE, '/tmp'), ('files/wordpress/link/evil', tarfile.REGTYPE, '')], + [('files/wordpress', tarfile.DIRTYPE, ''), ('files/wordpress/hard', tarfile.LNKTYPE, '/etc/shadow')], + [('files/wordpress', tarfile.SYMTYPE, '/tmp')], + [('files/wordpress', tarfile.DIRTYPE, ''), ('files/wordpress/dev', tarfile.CHRTYPE, '')], + ]: + with self.subTest(entries=entries), self.assertRaises(ValueError): + HELPER.check_tar(self.archive(entries), ['files/wordpress']) + + def test_leaf_symlinks_allowed(self): + HELPER.check_tar(self.archive([('files/wordpress', tarfile.DIRTYPE, ''), + ('files/wordpress/cert', tarfile.SYMTYPE, '/etc/ssl/cert.pem')]), ['files/wordpress']) + + def test_truncated_compression_rejected(self): + path = self.archive([('files/wordpress', tarfile.DIRTYPE, '')]) + path.write_bytes(path.read_bytes()[:-5]) + with self.assertRaises((ValueError, tarfile.TarError)): + HELPER.check_tar(path, ['files/wordpress']) + + def test_retention_keeps_minimum_and_ignores_v1(self): + result = subprocess.run(['python3', str(REPO/'lib/backup_set.py'), 'candidates', 'appbackup', '7', '2'], + input='appbackup_2020-01-01_00-00-00_aaaaaaaa\nappbackup_2020-01-02_00-00-00_bbbbbbbb\nappbackup_2020-01-03_00-00-00_cccccccc\nappbackup_2019-01-01_00-00-00\n../evil\n', + text=True, capture_output=True, check=True) + self.assertEqual(result.stdout.strip(), 'appbackup_2020-01-01_00-00-00_aaaaaaaa') + + +@unittest.skipUnless(os.geteuid() == 0 and shutil.which('mariadbd'), 'run integration tests in tests/Dockerfile container') +class Integration(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.dbtemp = tempfile.TemporaryDirectory(prefix='app-backup-mariadb-') + cls.dbdir = Path(cls.dbtemp.name) + cls.socket = cls.dbdir/'mysql.sock' + subprocess.run(['mariadb-install-db', '--no-defaults', f'--datadir={cls.dbdir}/data', '--auth-root-authentication-method=normal', '--skip-test-db'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + cls.server = subprocess.Popen(['mariadbd','--no-defaults','--user=root',f'--datadir={cls.dbdir}/data', f'--socket={cls.socket}', f'--pid-file={cls.dbdir}/pid', '--skip-networking'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(100): + if subprocess.run(['mariadb','--no-defaults',f'--socket={cls.socket}','-uroot','-e','SELECT 1'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0: + break + time.sleep(.1) + else: + raise RuntimeError('Test MariaDB did not start') + + @classmethod + def tearDownClass(cls): + cls.server.terminate() + cls.server.wait(timeout=20) + cls.dbtemp.cleanup() + + def sql(self, query): + return subprocess.check_output(['mariadb','--no-defaults', f'--socket={self.socket}','-uroot','-N','-e',query], text=True) + + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='app-backup-test-') + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.src = self.base/'sources' + self.wp = self.src/'web' + self.nc = self.wp/'cloud' + self.data = self.nc/'userdata' + self.bin = self.base/'bin' + self.bin.mkdir() + for p in [self.wp,self.nc,self.data,self.src/'gitea',self.src/'gitea-etc',self.src/'mail',self.src/'postfix',self.src/'dovecot']: + p.mkdir(parents=True, exist_ok=True) + (p/'payload.txt').write_text(f'payload for {p.name}') + (self.wp/'nested/data').mkdir(parents=True) + (self.wp/'nested/data/keep.txt').write_text('nested data must survive') + (self.nc/'occ').write_text('test stub') + self.state=self.base/'state.json' + self.initial=dict(services={s:'active' for s in ['httpd','php-fpm','gitea','postfix','dovecot','nc-cron']}, maintenance=False) + self.state.write_text(json.dumps(self.initial)) + self.events=self.base/'events' + self.events.touch() + mock='''#!/usr/bin/env python3 +import json,os,sys +from pathlib import Path +name=Path(sys.argv[0]).name +args=sys.argv[1:] +p=Path(os.environ['TEST_STATE']) +s=json.loads(p.read_text()) +with open(os.environ['TEST_EVENTS'],'a') as f: f.write(name+' '+' '.join(args)+'\\n') +if name=='systemctl': + cmd=args[0] + service=args[1] if len(args)>1 else '' + if cmd=='show': print(s['services'].get(service,'inactive')) + elif cmd=='is-active': sys.exit(0 if s['services'].get(args[-1])=='active' else 3) + elif cmd in ('stop','start'): + if os.environ.get('TEST_FAIL_SERVICE')==cmd+':'+service: sys.exit(1) + s['services'][service]='active' if cmd=='start' else 'inactive' + elif cmd in ('daemon-reload','enable'): pass +elif name=='sudo': + os.execvp(args[2],args[2:]) +elif name=='php': + cmd=args[1] + if cmd=='status': print(json.dumps({'maintenance':s['maintenance']})) + elif cmd=='maintenance:mode': s['maintenance']='--on' in args +p.write_text(json.dumps(s)) +''' + for name in ['systemctl','php','sudo']: + path=self.bin/name + path.write_text(mock) + path.chmod(0o755) + # Wrappers inject failures but normally delegate to the real tools. + for name in ['rsync','mariadb-dump','mariadb','zstd','rclone']: + real=shutil.which(name) + script=f'''#!/usr/bin/env bash +printf '%s\\n' '{name}' >> "$TEST_EVENTS" +if [[ ${{TEST_FAIL_TOOL:-}} == {name} ]]; then exit 42; fi +if [[ {name} == rclone && ${{TEST_FAIL_CHECK:-}} == true && " $* " == *' check '* ]]; then exit 43; fi +exec {real} "$@" +''' + path=self.bin/name + path.write_text(script) + path.chmod(0o755) + self.cnf=self.base/'db.cnf' + self.cnf.write_text(f'[client]\nuser=root\nsocket={self.socket}\n') + self.cnf.chmod(0o600) + self.sql('DROP DATABASE IF EXISTS backup_test; CREATE DATABASE backup_test; CREATE TABLE backup_test.t (id INT PRIMARY KEY, body TEXT) ENGINE=MyISAM; INSERT INTO backup_test.t VALUES (1,"original");') + self.config=self.base/'config' + self.config.write_text(f'''WORKDIR='{self.base}/work' +LOG_DIR='{self.base}/logs' +LOCKFILE='{self.base}/lock' +MIN_FREE_GB=0 +COMPRESSOR=zstd +ENABLE_UPLOAD=false +ENABLE_MAIL_REPORT=false +ENABLE_REMOTE_RETENTION=false +ENABLE_WORDPRESS=true +WP_DIR='{self.wp}' +ENABLE_NEXTCLOUD=true +NC_DIR='{self.nc}' +NC_DATA_DIR='{self.data}' +NC_OCC_USER=root +ENABLE_GITEA=true +GITEA_DATA_DIR='{self.src}/gitea' +GITEA_ETC_DIR='{self.src}/gitea-etc' +ENABLE_MAIL=true +ENABLE_MAIL_HOME_DIRS=false +MAIL_DIR='{self.src}/mail' +POSTFIX_DIR='{self.src}/postfix' +DOVECOT_DIR='{self.src}/dovecot' +WP_DB_NAME=backup_test +NC_DB_NAME=backup_test +GITEA_DB_NAME=backup_test +WP_DB_CNF='{self.cnf}' +NC_DB_CNF='{self.cnf}' +GITEA_DB_CNF='{self.cnf}' +QUIESCE_SERVICES=(nc-cron) +''') + self.config.chmod(0o600) + self.env=dict(os.environ, PATH=str(self.bin)+':'+os.environ['PATH'],TEST_STATE=str(self.state),TEST_EVENTS=str(self.events)) + + def run_script(self, script, *args, ok=True, **env): + proc=subprocess.run(['bash',str(REPO/'usr-local-sbin'/script),'--config',str(self.config),*map(str,args)],env=dict(self.env,**env),text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,timeout=90) + if ok: self.assertEqual(proc.returncode,0,proc.stdout) + else: self.assertNotEqual(proc.returncode,0,proc.stdout) + return proc + + def backup(self, **env): + self.run_script('app-backup.sh',**env) + return next((self.base/'work/archives').glob('appbackup_*')) + + def test_real_database_and_files_round_trip(self): + run=self.backup() + self.assertEqual(json.loads(self.state.read_text()),self.initial) + data=HELPER.load_set(run) + self.assertEqual(len(data['archives']),12) + with subprocess.Popen(['zstd','-dc',str(run/'wordpress.tar.zst')],stdout=subprocess.PIPE) as p: + with tarfile.open(fileobj=p.stdout,mode='r|') as t: names=[m.name for m in t] + self.assertNotIn('files/wordpress/cloud',names) + self.assertIn('files/wordpress/nested/data/keep.txt',names) + self.sql('UPDATE backup_test.t SET body="changed"') + (self.wp/'payload.txt').write_text('changed') + self.events.write_text('') + self.run_script('app-restore.sh','--local-run',run,'--dry-run') + self.assertNotIn('systemctl stop',self.events.read_text()) + self.assertEqual((self.wp/'payload.txt').read_text(),'changed') + self.run_script('app-restore.sh','--local-run',run,'--apply','--yes') + self.assertEqual(self.sql('SELECT body FROM backup_test.t').strip(),'original') + self.assertEqual((self.wp/'payload.txt').read_text(),'payload for web') + self.assertEqual(json.loads(self.state.read_text()),self.initial) + + def test_dump_and_copy_failures_do_not_publish(self): + for tool in ['mariadb-dump','rsync','zstd']: + with self.subTest(tool=tool): + self.run_script('app-backup.sh',ok=False,TEST_FAIL_TOOL=tool) + self.assertFalse(list((self.base/'work/archives').glob('appbackup_*'))) + self.assertEqual(json.loads(self.state.read_text()),self.initial) + + def test_maintenance_and_inactive_service_preserved(self): + state=dict(services=dict(self.initial['services'],gitea='inactive'),maintenance=True) + self.state.write_text(json.dumps(state)) + self.backup() + self.assertEqual(json.loads(self.state.read_text()),state) + + def test_corruption_and_missing_archive_block_restore(self): + run=self.backup() + path=run/'wordpress.tar.zst' + content=path.read_bytes() + path.write_bytes(content[:-10]) + self.events.write_text('') + self.run_script('app-restore.sh','--local-run',run,'--apply','--yes',ok=False) + self.assertNotIn('systemctl stop',self.events.read_text()) + path.unlink() + self.run_script('app-restore.sh','--local-run',run,'--apply','--yes',ok=False) + self.assertNotIn('systemctl stop',self.events.read_text()) + + def test_partial_restore_keeps_services_stopped(self): + run=self.backup() + self.run_script('app-restore.sh','--local-run',run,'--apply','--yes',ok=False,TEST_FAIL_TOOL='mariadb') + # Connection preflight fails BEFORE mutation: service state stays unchanged. + self.assertEqual(json.loads(self.state.read_text()),self.initial) + wrapper=self.bin/'mariadb' + wrapper.write_text('#!/usr/bin/env bash\nif [[ " $* " != *" --execute=SELECT 1 "* ]]; then exit 44; fi\nexec /usr/bin/mariadb "$@"\n') + result=self.run_script('app-restore.sh','--local-run',run,'--apply','--yes',ok=False) + self.assertIn('Partial restore',result.stdout) + self.assertTrue(json.loads(self.state.read_text())['maintenance']) + self.assertEqual(json.loads(self.state.read_text())['services']['httpd'],'inactive') + + def test_check_does_not_claim_successful_backup(self): + self.run_script('app-backup.sh','--check') + self.assertFalse((self.base/'work/last-successful-backup.json').exists()) + self.assertNotIn('systemctl stop',self.events.read_text()) + + def test_service_stop_and_start_failures_are_reported(self): + self.run_script('app-backup.sh',ok=False,TEST_FAIL_SERVICE='stop:gitea') + self.assertEqual(json.loads(self.state.read_text()),self.initial) + self.run_script('app-backup.sh',ok=False,TEST_FAIL_SERVICE='start:gitea') + self.assertTrue(list((self.base/'work').glob('recovery-*.json'))) + self.assertNotEqual(json.loads((self.base/'work/last-backup.json').read_text())['exit_code'],0) + self.assertFalse(list((self.base/'work/archives').glob('appbackup_*'))) + + def test_term_recovers_services(self): + wrapper=self.bin/'rsync' + wrapper.write_text('#!/usr/bin/env bash\necho waiting > "$TEST_EVENTS.waiting"\nsleep 2\nexec /usr/bin/rsync "$@"\n') + proc=subprocess.Popen(['bash',str(REPO/'usr-local-sbin/app-backup.sh'),'--config',str(self.config)],env=self.env,text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT) + try: + for _ in range(100): + if Path(str(self.events)+'.waiting').exists(): break + time.sleep(.05) + else: self.fail('Backup did not reach rsync') + proc.terminate() + output=proc.communicate(timeout=20)[0] + self.assertEqual(proc.returncode,143,output) + self.assertEqual(json.loads(self.state.read_text()),self.initial) + self.assertFalse(list((self.base/'work/archives').glob('appbackup_*'))) + finally: + if proc.poll() is None: proc.kill(); proc.wait() + + def test_selected_strict_restore_preserves_excluded_nextcloud(self): + run=self.backup() + (self.wp/'extra.txt').write_text('delete me') + (self.data/'outside-selection.txt').write_text('keep me') + with self.config.open('a') as f: f.write('RESTORE_STRICT_DELETE=true\n') + self.run_script('app-restore.sh','--local-run',run,'--apply','--yes','--no-db','--components','wordpress') + self.assertFalse((self.wp/'extra.txt').exists()) + self.assertEqual((self.data/'outside-selection.txt').read_text(),'keep me') + self.assertEqual(json.loads(self.state.read_text()),self.initial) + + def test_local_and_remote_retention_preserve_partial_and_v1(self): + self.crypt_config() + current=self.backup() + archives=current.parent + old=[] + for day in range(1,4): + name=f'appbackup_2020-01-0{day}_00-00-00_aaaaaaaa' + path=archives/name + shutil.copytree(current,path) + manifest=json.loads((path/'manifest.json').read_text()) + manifest['run_id']=name + (path/'manifest.json').write_text(json.dumps(manifest)) + (path/'COMPLETE').write_text('APP_BACKUP_V2 '+HELPER.digest(path/'manifest.json')+'\n') + subprocess.run(['/usr/bin/rclone','--config',str(self.base/'rclone.conf'),'copy',str(path),'crypt:sets/'+name],check=True,capture_output=True) + old.append(path) + partial=archives/'appbackup_2019-01-01_00-00-00_aaaaaaaa' + partial.mkdir() + v1=archives/'appbackup_2019-01-01_00-00-00' + v1.mkdir() + with self.config.open('a') as f: f.write('ENABLE_REMOTE_RETENTION=true\nRETENTION_KEEP_LAST=2\nLOCAL_RETENTION_DAYS=1\nREMOTE_RETENTION_DAYS=1\n') + self.run_script('app-backup.sh') + self.assertTrue(current.is_dir()) + self.assertTrue(partial.is_dir()) + self.assertTrue(v1.is_dir()) + self.assertTrue(all(not p.exists() for p in old)) + remote=subprocess.check_output(['/usr/bin/rclone','--config',str(self.base/'rclone.conf'),'lsf','crypt:sets','--dirs-only'],text=True) + self.assertNotIn('2020-',remote) + self.assertIn(current.name,remote) + + def test_update_preserves_configuration_schedule_and_rolls_back(self): + # Container-only test of the actual absolute installation paths. + config=Path('/etc/app-backup/app-backup.conf') + config.parent.mkdir(exist_ok=True) + original=f'LOCKFILE="{self.base}/install.lock"\n# existing custom configuration\n' + config.write_text(original) + config.chmod(0o600) + old_script=Path('/usr/local/sbin/app-backup.sh') + old_script.parent.mkdir(parents=True,exist_ok=True) + old_script.write_text('#!/bin/bash\necho previous-version\n') + old_script.chmod(0o750) + timer=Path('/etc/systemd/system/app-backup.timer') + timer.parent.mkdir(parents=True,exist_ok=True) + timer_text='[Timer]\nOnCalendar=*-*-* 04:10:00\n' + timer.write_text(timer_text) + state=json.loads(self.state.read_text()) + state['services']['app-backup.timer']='active' + self.state.write_text(json.dumps(state)) + proc=subprocess.run(['bash',str(REPO/'install.sh')],env=self.env,capture_output=True,text=True) + self.assertEqual(proc.returncode,0,proc.stdout+proc.stderr) + self.assertEqual(config.read_text(),original) + self.assertEqual(timer.read_text(),timer_text) + self.assertTrue(Path(str(config)+'.dist').exists()) + self.assertEqual(old_script.read_text(),(REPO/'usr-local-sbin/app-backup.sh').read_text()) + self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'active') + snapshot=Path(proc.stdout.split('Rollback package: ')[1].splitlines()[0]) + rollback=subprocess.run(['bash',str(snapshot/'rollback.sh')],env=self.env,capture_output=True,text=True) + self.assertEqual(rollback.returncode,0,rollback.stdout+rollback.stderr) + self.assertIn('previous-version',old_script.read_text()) + self.assertEqual(config.read_text(),original) + self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'inactive') + + def test_legacy_extraction_is_explicit_and_isolated(self): + folder=self.base/'legacy' + folder.mkdir() + archive=folder/'appbackup_2026-02-11_02-30-00_wordpress.tar.gz' + with tarfile.open(archive,'w:gz') as tar: + tar.add(self.wp,arcname='files/wordpress') + self.run_script('app-restore.sh','--local-run',folder,'--extract-only',ok=False) + self.run_script('app-restore.sh','--local-run',folder,'--legacy-unverified','--extract-only') + self.assertTrue(list((self.base/'work/restore').glob('*/extracted/*.tar.gz/files/wordpress/payload.txt'))) + self.run_script('app-restore.sh','--local-run',folder,'--legacy-unverified','--apply','--yes',ok=False) + self.assertNotIn('systemctl stop',self.events.read_text()) + + def test_workspace_overlap_does_not_change_source_permissions(self): + self.wp.chmod(0o755) + with self.config.open('a') as f: f.write(f'WORKDIR="{self.wp}"\n') + self.run_script('app-backup.sh','--check',ok=False) + self.assertEqual(self.wp.stat().st_mode & 0o777,0o755) + + def crypt_config(self): + (self.base/'encrypted').mkdir() + password=subprocess.check_output(['/usr/bin/rclone','obscure','integration-test-only'],text=True).strip() + path=self.base/'rclone.conf' + path.write_text(f'[crypt]\ntype = crypt\nremote = {self.base}/encrypted\npassword = {password}\n') + path.chmod(0o600) + 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 test_real_crypt_upload_readback_remote_restore(self): + self.crypt_config() + run=self.backup() + self.assertTrue((run/'UPLOADED').is_file()) + self.assertFalse(list((self.base/'encrypted').rglob('wordpress.tar.zst'))) + self.run_script('app-restore.sh','--remote-run',run.name,'--verify-only') + + def test_failed_readback_does_not_publish_remote_marker(self): + self.crypt_config() + self.run_script('app-backup.sh',ok=False,TEST_FAIL_CHECK='true') + run=next((self.base/'work/archives').glob('appbackup_*')) + self.assertFalse((run/'UPLOADED').exists()) + proc=subprocess.run(['/usr/bin/rclone','--config',str(self.base/'rclone.conf'),'cat',f'crypt:sets/{run.name}/COMPLETE'],capture_output=True) + self.assertNotEqual(proc.returncode,0) + + def test_unsafe_crypt_and_source_overlap_rejected(self): + path=self.crypt_config() + with path.open('a') as f: f.write('no_data_encryption = true\n') + self.run_script('app-backup.sh','--check',ok=False) + self.assertNotIn('systemctl stop',self.events.read_text()) + with self.config.open('a') as f: f.write(f'ENABLE_UPLOAD=false\nWP_DIR="{self.base}"\n') + self.run_script('app-backup.sh','--check',ok=False) + + def test_lock_excludes_concurrent_run(self): + import fcntl + with (self.base/'lock').open('w') as lock: + fcntl.flock(lock,fcntl.LOCK_EX | fcntl.LOCK_NB) + result=self.run_script('app-backup.sh','--check',ok=False) + self.assertIn('Another backup/restore/update',result.stdout) + + def test_gzip_and_extract_only(self): + with self.config.open('a') as f: f.write('COMPRESSOR=gzip\n') + run=self.backup() + self.run_script('app-restore.sh','--local-run',run,'--extract-only') + self.assertTrue(list((self.base/'work/restore').glob('*/extracted/wordpress/files/wordpress/payload.txt'))) + + +if __name__ == '__main__': + unittest.main() diff --git a/usr-local-sbin/app-backup.sh b/usr-local-sbin/app-backup.sh index b3883a4..72a52f8 100755 --- a/usr-local-sbin/app-backup.sh +++ b/usr-local-sbin/app-backup.sh @@ -1,556 +1,143 @@ #!/usr/bin/env bash +# A backup is published only after every source, archive and remote readback passed. set -Eeuo pipefail -umask 027 - -# ============================================================================== -# app-backup.sh -# - Separate archives per component (db / wordpress / nextcloud-code / nextcloud-data / gitea) -# - rclone tuned for large files (OneDrive chunking + timeouts + conservative concurrency) -# - Nextcloud "data" excluded from code backup (layout: ${NC_DIR}/data) -# - Gitea native install (systemd), data path configurable (default: /var/lib/gitea/data) -# ============================================================================== - -# ---------- Logging ---------- -LOG_DIR="/var/log/app-backup" -mkdir -p "$LOG_DIR" -ts="$(date '+%Y-%m-%d_%H-%M-%S')" -LOG_FILE="${LOG_DIR}/app-backup_${ts}.log" -exec > >(tee -a "$LOG_FILE" | systemd-cat -t app-backup -p info) 2>&1 - -# ---------- Config ---------- -CONFIG_FILE="/etc/app-backup/app-backup.conf" -if [[ -r "$CONFIG_FILE" ]]; then - # shellcheck disable=SC1090 - source "$CONFIG_FILE" -else - echo "ERROR: Config not found/readable: $CONFIG_FILE" - exit 2 -fi - -# ---------- Defaults (if not set in conf) ---------- -: "${ENABLE_MAIL:=false}" -: "${MAIL_BACKUP_MODE:=live}" # live|stop (stop briefly stops postfix/dovecot) -: "${MAIL_DIR:=/var/vmail}" -: "${POSTFIX_DIR:=/etc/postfix}" -: "${DOVECOT_DIR:=/etc/dovecot}" -: "${ENABLE_MAIL_HOME_DIRS:=true}" -: "${MAIL_HOME_DIRS:=/home/johannes /home/luciana}" -: "${MAIL_HOME_EXCLUDES:=--exclude=OneDrive/ --exclude=.cache/ --exclude=.ccache/ --exclude=.local/share/Trash/}" - -: "${WORKDIR:=/var/backups/app-backup}" -: "${STAGING_ROOT:=${WORKDIR}/staging}" -: "${ARCHIVE_DIR:=${WORKDIR}/archives}" -: "${LOCAL_RETENTION_DAYS:=7}" - -: "${COMPRESSOR:=zstd}" # zstd|gzip -: "${ARCHIVE_PREFIX:=appbackup}" # file prefix - -# rclone -: "${RCLONE_BIN:=rclone}" -: "${RCLONE_REMOTE_BASE:=OneDrive:Sicherung/JRITServerBackups/$(hostname -s)}" # remote folder -: "${RCLONE_RETRIES:=10}" -: "${RCLONE_LOW_LEVEL_RETRIES:=40}" -: "${RCLONE_RETRIES_SLEEP:=30s}" -: "${RCLONE_STATS:=1m}" -: "${RCLONE_BWLIMIT:=0}" # "0" = no limit -: "${ENABLE_UPLOAD:=true}" - -# large-file stability (OneDrive) -: "${RCLONE_ONEDRIVE_CHUNK_SIZE:=80Mi}" -: "${RCLONE_TIMEOUT:=1h}" -: "${RCLONE_CONTIMEOUT:=30s}" -: "${RCLONE_TRANSFERS:=2}" -: "${RCLONE_CHECKERS:=4}" - -: "${REMOTE_RETENTION_DAYS:=30}" -: "${ENABLE_REMOTE_RETENTION:=true}" - -# Disk-space safety -: "${MIN_FREE_GB:=12}" - -# Process niceness -: "${NICE_LEVEL:=10}" -: "${IONICE_CLASS:=2}" -: "${IONICE_LEVEL:=6}" - -# Mail reporting -: "${ENABLE_MAIL_REPORT:=true}" -: "${MAIL_TO:=johannes}" -: "${MAIL_FROM:=app-backup@$(hostname -f 2>/dev/null || hostname)}" -: "${MAIL_SUBJECT_PREFIX:=[app-backup]}" -: "${MAIL_INCLUDE_LOG_TAIL_LINES:=200}" - -# ---------- State for report ---------- -START_EPOCH="$(date +%s)" -STATUS="SUCCESS" -ERROR_SUMMARY="" -RCLONE_STATUS="SKIPPED" -RCLONE_OUTPUT_FILE="" -SIZES_FILE="" - -# ---------- Helpers ---------- -die() { STATUS="FAIL"; ERROR_SUMMARY="${1:-Unknown error}"; echo "ERROR: $*"; exit 1; } -have() { command -v "$1" >/dev/null 2>&1; } - -require_crypt_remote() { - local remote_name="$1" - local cfg - cfg="$("$RCLONE_BIN" config show "$remote_name" 2>/dev/null || true)" - [[ -n "$cfg" ]] || die "rclone remote \"$remote_name\" not found in config" - echo "$cfg" | grep -Eq "^type\s*=\s*crypt\s*$" || die "Remote \"$remote_name\" is not a crypt remote (type!=crypt). Refusing to upload unencrypted." - # These options may be omitted if defaults are used. If present and set to disable encryption, fail. - if echo "$cfg" | grep -Eq "^filename_encryption\s*=\s*off\s*$"; then - die "Remote \"$remote_name\" has filename_encryption=off. Filenames would be visible in OneDrive." - fi - if echo "$cfg" | grep -Eq "^directory_name_encryption\s*=\s*(false|off)\s*$"; then - die "Remote \"$remote_name\" has directory_name_encryption disabled. Directory names would be visible in OneDrive." - fi -} - - -human_bytes() { local b="${1:-0}"; if have numfmt; then numfmt --to=iec-i --suffix=B "$b"; else echo "${b}B"; fi; } -bytes_of_path() { local p="$1"; [[ -e "$p" ]] && (du -sb "$p" 2>/dev/null | awk '{print $1}' || du -sB1 "$p" | awk '{print $1}') || echo 0; } -free_bytes_workdir_fs() { df -PB1 "$WORKDIR" | awk 'NR==2{print $4}'; } - -ensure_min_free_space() { - local min_bytes=$((MIN_FREE_GB * 1024 * 1024 * 1024)) - local avail; avail="$(free_bytes_workdir_fs)" - echo "-- Free space on WORKDIR filesystem: $(human_bytes "$avail") (min required: ${MIN_FREE_GB}GiB)" - [[ "$avail" -ge "$min_bytes" ]] || die "Not enough free space on WORKDIR filesystem (need >= ${MIN_FREE_GB}GiB)." -} - -cleanup_old_local_archives() { - mkdir -p "$ARCHIVE_DIR" - echo "-- Local retention: deleting archives older than ${LOCAL_RETENTION_DAYS} day(s) from ${ARCHIVE_DIR}" - find "$ARCHIVE_DIR" -type f -name "${ARCHIVE_PREFIX}_*.tar.*" -mtime "+${LOCAL_RETENTION_DAYS}" -print -delete 2>/dev/null || true -} - -send_report_mail() { - [[ "${ENABLE_MAIL_REPORT}" == "true" ]] || return 0 - local SENDMAIL_BIN="/usr/sbin/sendmail" - [[ -x "$SENDMAIL_BIN" ]] || { echo "WARN: sendmail missing at $SENDMAIL_BIN"; return 0; } - - local end_epoch now duration subject host - end_epoch="$(date +%s)" - now="$(date -Is)" - duration="$((end_epoch - START_EPOCH))" - host="$(hostname -f 2>/dev/null || hostname)" - subject="${MAIL_SUBJECT_PREFIX} ${STATUS} ${host} ${ts}" - - { - echo "From: ${MAIL_FROM}" - echo "To: ${MAIL_TO}" - echo "Subject: ${subject}" - echo "Date: $(date -R)" - echo "MIME-Version: 1.0" - echo "Content-Type: text/plain; charset=UTF-8" - echo - echo "Backup Report" - echo "=============" - echo "Zeit: ${now}" - echo "Host: ${host}" - echo "Status: ${STATUS}" - [[ -n "${ERROR_SUMMARY}" ]] && echo "Fehler: ${ERROR_SUMMARY}" - echo "Dauer: ${duration}s" - echo - echo "Config: ${CONFIG_FILE}" - echo "Log: ${LOG_FILE}" - echo "Workdir: ${WORKDIR}" - echo "Archive dir: ${ARCHIVE_DIR}" - echo "Kompression: ${COMPRESSOR}" - echo - echo "Remote" - echo "------" - echo "Upload: ${ENABLE_UPLOAD}" - echo "Remote base: ${RCLONE_REMOTE_BASE}" - echo "Upload Status: ${RCLONE_STATUS}" - [[ -n "${RCLONE_OUTPUT_FILE}" && -f "${RCLONE_OUTPUT_FILE}" ]] && { echo; echo "rclone Tail:"; tail -n 60 "${RCLONE_OUTPUT_FILE}" || true; } - echo - echo "Größen" - echo "------" - [[ -n "${SIZES_FILE}" && -f "${SIZES_FILE}" ]] && cat "${SIZES_FILE}" || echo "(keine Größeninfos verfügbar)" - echo - echo "Log-Auszug (Tail)" - echo "-----------------" - tail -n "${MAIL_INCLUDE_LOG_TAIL_LINES}" "${LOG_FILE}" || true - } | "$SENDMAIL_BIN" -t || echo "WARN: sending mail failed" -} - -cleanup_staging() { [[ -n "${STAGING_DIR:-}" && -d "${STAGING_DIR:-}" ]] && rm -rf "${STAGING_DIR:?}"; } - -# Nextcloud maintenance-mode safety trap -NC_MAINTENANCE_ON=false -nc_maintenance_off() { - if [[ "${NC_MAINTENANCE_ON}" == "true" ]]; then - echo "-- Nextcloud maintenance mode OFF (trap)..." - sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:mode --off || true - NC_MAINTENANCE_ON=false - fi -} - -# Gitea service safety trap -GITEA_WAS_STOPPED=false -gitea_service_start() { - if [[ "${GITEA_WAS_STOPPED}" == "true" ]]; then - echo "-- Starting Gitea service (trap)..." - systemctl start "${GITEA_SERVICE_NAME}" || true - GITEA_WAS_STOPPED=false - fi -} - -on_error() { local ec=$?; STATUS="FAIL"; ERROR_SUMMARY="Exit code ${ec} (see log)"; return 0; } -on_exit() { local ec=$?; send_report_mail; nc_maintenance_off; gitea_service_start; cleanup_staging; exit "${ec}"; } -trap on_error ERR -trap on_exit EXIT - -# ---------- Start ---------- -echo "== app-backup start: ${ts} ==" -echo "-- Host: $(hostname -f 2>/dev/null || hostname)" -echo "-- Config: ${CONFIG_FILE}" -echo "-- Log: ${LOG_FILE}" - -# ---------- Preconditions ---------- -[[ $EUID -eq 0 ]] || die "Must run as root." -mkdir -p "$WORKDIR" "$ARCHIVE_DIR" "$STAGING_ROOT" "$LOG_DIR" - -# ---------- Locking ---------- -LOCKFILE="/run/app-backup.lock" -exec 9>"$LOCKFILE" -if ! flock -n 9; then - STATUS="FAIL" - ERROR_SUMMARY="Another backup already running (lock: $LOCKFILE)" - exit 3 -fi - -# ---------- Tools ---------- -for t in tar rsync flock df find stat; do have "$t" || die "Missing required tool: $t"; done -if [[ "$COMPRESSOR" == "zstd" ]]; then have zstd || die "COMPRESSOR=zstd but zstd is missing" -elif [[ "$COMPRESSOR" == "gzip" ]]; then have gzip || die "COMPRESSOR=gzip but gzip is missing" -else die "Unsupported COMPRESSOR=$COMPRESSOR (use zstd or gzip)"; fi - -if [[ "${ENABLE_DB_DUMPS}" == "true" ]]; then - have mysqldump || die "ENABLE_DB_DUMPS=true but mysqldump missing" - have mysql || die "ENABLE_DB_DUMPS=true but mysql client missing" -fi -have "$RCLONE_BIN" || die "rclone not installed (missing: $RCLONE_BIN)" - -# ---------- Disk safety ---------- -cleanup_old_local_archives -ensure_min_free_space - -# ---------- Staging ---------- -STAGING_DIR="${STAGING_ROOT}/run_${ts}" -mkdir -p "$STAGING_DIR"/{db,files,meta} -echo "$(date -Is)" > "$STAGING_DIR/meta/created_at.txt" -echo "$(hostname -f 2>/dev/null || hostname)" > "$STAGING_DIR/meta/hostname.txt" -echo "${ts}" > "$STAGING_DIR/meta/timestamp.txt" - -# ---------- Services consistency ---------- -if [[ "${ENABLE_GITEA:-false}" == "true" && "${ENABLE_GITEA_SERVICE_STOP:-true}" == "true" ]]; then - if systemctl is-active --quiet "${GITEA_SERVICE_NAME}"; then - echo "-- Stopping Gitea service for consistent backup: ${GITEA_SERVICE_NAME}" - systemctl stop "${GITEA_SERVICE_NAME}" - GITEA_WAS_STOPPED=true - fi -fi - -# ---------- DB Dumps ---------- -if [[ "${ENABLE_DB_DUMPS}" == "true" ]]; then - echo "-- DB dumps enabled" - - dump_mysql_db() { - local cnf="$1" db="$2" out="$3" - [[ -r "$cnf" ]] || die "DB CNF not readable: $cnf" - echo "-- Dump MySQL/MariaDB DB: ${db}" - mysqldump --defaults-extra-file="$cnf" --single-transaction --routines --triggers --hex-blob "$db" > "$out" - } - - [[ -n "${WP_DB_NAME:-}" ]] && dump_mysql_db "${WP_DB_CNF}" "${WP_DB_NAME}" "$STAGING_DIR/db/wordpress_${ts}.sql" || true - - if [[ -n "${NC_DB_NAME:-}" ]]; then - if [[ "${ENABLE_NEXTCLOUD_MAINTENANCE:-true}" == "true" ]]; then - echo "-- Nextcloud maintenance mode ON..." - sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:mode --on - NC_MAINTENANCE_ON=true - fi - dump_mysql_db "${NC_DB_CNF}" "${NC_DB_NAME}" "$STAGING_DIR/db/nextcloud_${ts}.sql" - if [[ "${ENABLE_NEXTCLOUD_MAINTENANCE:-true}" == "true" ]]; then - echo "-- Nextcloud maintenance mode OFF..." - sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:mode --off || true - NC_MAINTENANCE_ON=false - fi - fi - - if [[ "${ENABLE_GITEA:-false}" == "true" ]]; then - # native gitea with MariaDB - [[ -n "${GITEA_DB_NAME:-}" ]] && dump_mysql_db "${GITEA_DB_CNF}" "${GITEA_DB_NAME}" "$STAGING_DIR/db/gitea_${ts}.sql" || echo "WARN: ENABLE_GITEA=true but GITEA_DB_NAME empty - skipping gitea DB" - fi -else - echo "-- DB dumps disabled" -fi - -# ---------- File copies ---------- -echo "-- Collecting files via rsync..." - -rsync_dir() { - local src="$1" - local dst="$2" - shift 2 || true - [[ -d "$src" ]] || die "Source directory missing: $src" - mkdir -p "$dst" - local excludes=() - while [[ $# -gt 0 ]]; do excludes+=("--exclude=$1"); shift; done - rsync -aHAX --numeric-ids --delete --info=stats2 "${excludes[@]}" "$src"/ "$dst"/ -} - -# WordPress webroot: exclude nextcloud/ if it lives below WP_DIR -if [[ "${ENABLE_WORDPRESS:-false}" == "true" ]]; then - echo "-- WordPress files: ${WP_DIR}" - wp_excludes=() - if [[ "${ENABLE_NEXTCLOUD:-false}" == "true" ]]; then - wp="${WP_DIR%/}"; nc="${NC_DIR%/}" - if [[ "$nc" == "$wp/nextcloud" ]]; then - wp_excludes+=("nextcloud/") - fi - fi - if [[ "${#wp_excludes[@]}" -gt 0 ]]; then - echo "-- WordPress excludes: ${wp_excludes[*]}" - rsync_dir "${WP_DIR}" "$STAGING_DIR/files/wordpress" "${wp_excludes[@]}" - else - rsync_dir "${WP_DIR}" "$STAGING_DIR/files/wordpress" - fi -fi - -# Nextcloud code: exclude data/ -if [[ "${ENABLE_NEXTCLOUD:-false}" == "true" ]]; then - echo "-- Nextcloud code: ${NC_DIR} (excluding data/)" - rsync_dir "${NC_DIR}" "$STAGING_DIR/files/nextcloud" "data/" - - : "${NC_DATA_DIR:=${NC_DIR%/}/data}" - if [[ "${ENABLE_NEXTCLOUD_DATA:-true}" == "true" ]]; then - echo "-- Nextcloud data: ${NC_DATA_DIR}" - rsync_dir "${NC_DATA_DIR}" "$STAGING_DIR/files/nextcloud-data" - fi -fi - -# Gitea files (based on app.ini APP_DATA_PATH) -if [[ "${ENABLE_GITEA:-false}" == "true" ]]; then - : "${GITEA_DATA_DIR:=/var/lib/gitea/data}" - echo "-- Gitea data dir: ${GITEA_DATA_DIR}" - rsync_dir "${GITEA_DATA_DIR}" "$STAGING_DIR/files/gitea-data" - - : "${GITEA_ETC_DIR:=/etc/gitea}" - if [[ -n "${GITEA_ETC_DIR}" && -d "${GITEA_ETC_DIR}" ]]; then - echo "-- Gitea config dir: ${GITEA_ETC_DIR}" - rsync_dir "${GITEA_ETC_DIR}" "$STAGING_DIR/files/gitea-etc" - fi -fi - -\ -if [[ "${ENABLE_MAIL}" == "true" ]]; then - echo "-- Mail files..." - mkdir -p "$STAGING_DIR/files/mail" "$STAGING_DIR/files/postfix" "$STAGING_DIR/files/dovecot" "$STAGING_DIR/files/mail-home" - - # Optional short stop for maximum consistency - if [[ "${MAIL_BACKUP_MODE}" == "stop" ]]; then - echo "-- Stopping postfix/dovecot for mail backup..." - systemctl stop postfix || true - systemctl stop dovecot || true - fi - - [[ -n "${MAIL_DIR:-}" && -d "${MAIL_DIR}" ]] && rsync_dir "${MAIL_DIR}" "$STAGING_DIR/files/mail" || true - [[ -n "${POSTFIX_DIR:-}" && -d "${POSTFIX_DIR}" ]] && rsync_dir "${POSTFIX_DIR}" "$STAGING_DIR/files/postfix" || true - [[ -n "${DOVECOT_DIR:-}" && -d "${DOVECOT_DIR}" ]] && rsync_dir "${DOVECOT_DIR}" "$STAGING_DIR/files/dovecot" || true - - # Home mail dirs (per-user mail files under /home) - if [[ "${ENABLE_MAIL_HOME_DIRS}" == "true" ]]; then - for h in ${MAIL_HOME_DIRS}; do - if [[ -d "${h}" ]]; then - user="$(basename "${h}")" - echo "-- Mail (home): ${h}" - dest="$STAGING_DIR/files/mail-home/${user}" - mkdir -p "$dest" - - # Some home subdirs (e.g. FUSE mounts like OneDrive) may deny access or cause - # noise during backup. We keep this resilient: - # - skip if the user does not exist - # - run rsync as root (so we can write into the root-owned staging dir) - # - prevent crossing filesystem boundaries (skip mounts) - # - exclude noisy/unwanted dirs via MAIL_HOME_EXCLUDES - if ! id "${user}" >/dev/null 2>&1; then - echo "WARNING: mail-home user '${user}' does not exist (skipping ${h})" >&2 - continue - fi - - chown root:root "$dest" 2>/dev/null || true - chmod 0750 "$dest" 2>/dev/null || true - - set +e - rsync -aHAX --numeric-ids --info=stats2 --one-file-system ${MAIL_HOME_EXCLUDES} "${h}/" "${dest}/" - rc=$? - set -e - - if [[ $rc -ne 0 ]]; then - echo "WARNING: rsync for ${h} returned code ${rc} (continuing)" >&2 - fi - fi - done - fi - - if [[ "${MAIL_BACKUP_MODE}" == "stop" ]]; then - echo "-- Starting postfix/dovecot after mail backup..." - systemctl start dovecot || true - systemctl start postfix || true - fi -fi - - -# ---------- Size summary ---------- -SIZES_FILE="${STAGING_DIR}/meta/sizes.txt" -{ - echo "Mail-home staged: $(human_bytes "$(bytes_of_path "$STAGING_DIR/files/mail-home")")" - echo "Mail staged: $(human_bytes "$(bytes_of_path "$STAGING_DIR/files/mail")")" - echo "Postfix staged: $(human_bytes "$(bytes_of_path "$STAGING_DIR/files/postfix")")" - echo "Dovecot staged: $(human_bytes "$(bytes_of_path "$STAGING_DIR/files/dovecot")")" - echo "DB dumps staged: $(human_bytes "$(bytes_of_path "$STAGING_DIR/db")")" - echo "WordPress staged: $(human_bytes "$(bytes_of_path "$STAGING_DIR/files/wordpress")")" - echo "Nextcloud code staged: $(human_bytes "$(bytes_of_path "$STAGING_DIR/files/nextcloud")")" - echo "Nextcloud data staged: $(human_bytes "$(bytes_of_path "$STAGING_DIR/files/nextcloud-data")")" - echo "Gitea data staged: $(human_bytes "$(bytes_of_path "$STAGING_DIR/files/gitea-data")")" - echo "Gitea etc staged: $(human_bytes "$(bytes_of_path "$STAGING_DIR/files/gitea-etc")")" - echo "Staging total: $(human_bytes "$(bytes_of_path "$STAGING_DIR")")" -} > "$SIZES_FILE" || true - -ensure_min_free_space - -# ---------- Create separate archives ---------- -make_archive() { - local label="$1" src_rel="$2" - local tar_file="${ARCHIVE_DIR}/${ARCHIVE_PREFIX}_${ts}_${label}.tar" - local out_file - echo "-- Creating archive (${label}): ${tar_file}" >&2 - ( - cd "$STAGING_DIR" - tar --numeric-owner --xattrs --acls -cf "$tar_file" "$src_rel" - ) - - if [[ "$COMPRESSOR" == "zstd" ]]; then - out_file="${tar_file}.zst" - echo "-- Compressing (zstd): ${out_file}" >&2 - ionice -c "${IONICE_CLASS}" -n "${IONICE_LEVEL}" nice -n "${NICE_LEVEL}" zstd -T0 -19 --rm -q "$tar_file" 1>&2 - zstd -t "$out_file" 1>&2 - else - out_file="${tar_file}.gz" - echo "-- Compressing (gzip): ${out_file}" >&2 - ionice -c "${IONICE_CLASS}" -n "${IONICE_LEVEL}" nice -n "${NICE_LEVEL}" gzip -9 "$tar_file" - gzip -t "$out_file" - fi - echo "$out_file" >&2 - echo "$out_file" -} - -ARCHIVES=() -ARCHIVES+=("$(make_archive "meta" "meta")") - -if [[ -d "$STAGING_DIR/db" && -n "$(ls -A "$STAGING_DIR/db" 2>/dev/null || true)" ]]; then - ARCHIVES+=("$(make_archive "db" "db")") -fi -[[ "${ENABLE_WORDPRESS:-false}" == "true" ]] && ARCHIVES+=("$(make_archive "wordpress" "files/wordpress")") || true -if [[ "${ENABLE_NEXTCLOUD:-false}" == "true" ]]; then - ARCHIVES+=("$(make_archive "nextcloud" "files/nextcloud")") - [[ "${ENABLE_NEXTCLOUD_DATA:-true}" == "true" ]] && ARCHIVES+=("$(make_archive "nextcloud-data" "files/nextcloud-data")") || true -fi -if [[ "${ENABLE_GITEA:-false}" == "true" ]]; then - ARCHIVES+=("$(make_archive "gitea" "files/gitea-data")") - if [[ -d "$STAGING_DIR/files/gitea-etc" && -n "$(ls -A "$STAGING_DIR/files/gitea-etc" 2>/dev/null || true)" ]]; then - ARCHIVES+=("$(make_archive "gitea-etc" "files/gitea-etc")") - fi -fi - -# Filter empty archive entries -ARCHIVES=($(for a in "${ARCHIVES[@]:-}"; do [[ -n "$a" ]] && echo "$a"; done)) - -# Mail archives (vmail/postfix/dovecot + optional /home mail dirs) -if [[ "${ENABLE_MAIL}" == "true" ]]; then - ARCHIVES+=("$(make_archive "mail" "files/mail")") - ARCHIVES+=("$(make_archive "mail-home" "files/mail-home")") - ARCHIVES+=("$(make_archive "postfix" "files/postfix")") - ARCHIVES+=("$(make_archive "dovecot" "files/dovecot")") -fi - -echo "-- Archives created:" -for f in "${ARCHIVES[@]}"; do - echo " - $f ($(du -h "$f" | awk '{print $1}'))" +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +LIB_DIR="$SCRIPT_DIR/../lib/app-backup" +[[ -f $LIB_DIR/common.sh ]] || LIB_DIR="$SCRIPT_DIR/../lib" +# shellcheck source=lib/common.sh +source "$LIB_DIR/common.sh" +CONFIG_FILE=/etc/app-backup/app-backup.conf +CHECK=false +while (( $# )); do + case $1 in + --config) CONFIG_FILE=${2:?Missing config path}; shift 2 ;; + --check) CHECK=true; shift ;; + --help) echo 'Usage: app-backup.sh [--config FILE] [--check]'; exit 0 ;; + *) die "Unknown argument: $1" ;; + esac done +OPERATION=backup +load_config +init_runtime +build_plan +preflight +if [[ $CHECK == true ]]; then + echo 'Preflight passed. No source copy, service stop, dump, upload or deletion performed.' + exit 0 +fi +STAGING_DIR=$(mktemp -d "$STAGING_ROOT/${RUN_ID}.XXXXXX") +mkdir -p "$STAGING_DIR"/{db,files,meta} +PARTIAL_DIR="$ARCHIVE_DIR/.partial-$RUN_ID" +mkdir -m 700 -- "$PARTIAL_DIR" +PLAN="$STAGING_DIR/meta/plan.tsv" +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" +quiesce -# restart gitea before upload -gitea_service_start +for i in "${!DB_LABELS[@]}"; do + label=${DB_LABELS[i]} + out="$STAGING_DIR/db/${label#db-}.sql" + 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). + "$DB_DUMP_BIN" --defaults-extra-file="${DB_CNFS[i]}" --lock-all-tables \ + --quick --routines --events --triggers --hex-blob --default-character-set=utf8mb4 \ + "${DB_NAMES[i]}" > "$out.partial" + [[ -s $out.partial ]] || die "Empty database dump: $label" + mv -- "$out.partial" "$out" + printf '%s\t%s\n' "$label" "${DB_NAMES[i]}" >> "$PLAN" +done +for i in "${!LABELS[@]}"; do + label=${LABELS[i]}; src=${SOURCES[i]}; root=${ROOTS[i]} + check_space "$STAGING_ROOT" + mkdir -p -- "$STAGING_DIR/$root" + exclusions "$label" + echo "-- Copy: $label ($src)" + # Never accept vanished-file (24) or partial-copy (23) as success. + rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$src/" "$STAGING_DIR/$root/" + printf '%s\t%s\n' "$label" "$src" >> "$PLAN" +done +# The full consistency window includes all dumps AND copies. Compression/upload +# use this frozen private staging tree, so application downtime ends here. +resume_services || die 'Failed to resume original service state' -# ---------- Upload via rclone ---------- -if [[ "${ENABLE_UPLOAD}" == "true" ]]; then - RCLONE_OUTPUT_FILE="${LOG_DIR}/rclone_${ts}.log" - RCLONE_STATUS="RUNNING" - remote_run="${RCLONE_REMOTE_BASE}/${ARCHIVE_PREFIX}_${ts}" - - # Split "Remote:path" into remote name + path. Some rclone subcommands require only the remote name. - [[ "${RCLONE_REMOTE_BASE}" == *:* ]] || die "RCLONE_REMOTE_BASE must be in the form Remote:path (got: ${RCLONE_REMOTE_BASE})" - remote_name="${RCLONE_REMOTE_BASE%%:*}" - remote_base_path="${RCLONE_REMOTE_BASE#*:}" - - echo "-- rclone remote check (crypt enforced): ${remote_name}" - require_crypt_remote "${remote_name}" - - # Check remote root reachability (works even if base folder doesn't exist yet) - "$RCLONE_BIN" lsd "${remote_name}:" --max-depth 1 >/dev/null 2>&1 || die "Remote not reachable: ${remote_name}:" - - # Ensure base folder exists (best effort) - echo "-- Ensuring remote base folder exists: ${RCLONE_REMOTE_BASE}" - "$RCLONE_BIN" mkdir "${RCLONE_REMOTE_BASE}" >/dev/null 2>&1 || true - - echo "-- Creating remote folder: ${remote_run}" - "$RCLONE_BIN" mkdir "${remote_run}" >/dev/null 2>&1 || true - - common_args=( - "--checksum" - "--retries" "${RCLONE_RETRIES}" - "--low-level-retries" "${RCLONE_LOW_LEVEL_RETRIES}" - "--retries-sleep" "${RCLONE_RETRIES_SLEEP}" - "--stats" "${RCLONE_STATS}" - "--stats-one-line" - "--log-level" "INFO" - "--transfers" "${RCLONE_TRANSFERS}" - "--checkers" "${RCLONE_CHECKERS}" - "--timeout" "${RCLONE_TIMEOUT}" - "--contimeout" "${RCLONE_CONTIMEOUT}" - "--onedrive-chunk-size" "${RCLONE_ONEDRIVE_CHUNK_SIZE}" - ) - [[ "${RCLONE_BWLIMIT}" != "0" ]] && common_args+=("--bwlimit" "${RCLONE_BWLIMIT}") || true - - echo "-- Uploading archives to: ${remote_run} (log: ${RCLONE_OUTPUT_FILE})" - for f in "${ARCHIVES[@]}"; do - [[ -n "$f" ]] || continue - echo "-- Upload: $(basename "$f")" - if ionice -c "${IONICE_CLASS}" -n "${IONICE_LEVEL}" nice -n "${NICE_LEVEL}" "$RCLONE_BIN" copy "$f" "${remote_run}" "${common_args[@]}" | tee -a "$RCLONE_OUTPUT_FILE" - then - : - else - RCLONE_STATUS="FAIL" - die "Upload failed for $(basename "$f") (see ${RCLONE_OUTPUT_FILE})" - fi - done - RCLONE_STATUS="OK" - - # Show what landed on the remote for this run (small folder: archives only) - echo "-- Remote listing (this run): ${remote_run}" - "$RCLONE_BIN" lsl "${remote_run}" --max-depth 1 --log-level INFO || echo "WARNING: Could not list remote run folder: ${remote_run}" - - if [[ "${ENABLE_REMOTE_RETENTION}" == "true" ]]; then - echo "-- Remote retention: delete objects older than ${REMOTE_RETENTION_DAYS}d (best effort)" - "$RCLONE_BIN" delete "${RCLONE_REMOTE_BASE}" --min-age "${REMOTE_RETENTION_DAYS}d" --log-level INFO || true +while IFS=$'\t' read -r label _; do + if [[ $label == meta ]]; then root=meta + elif [[ $label == db-* ]]; then root="db/${label#db-}.sql" + else root="files/$label"; fi + check_space "$ARCHIVE_DIR" + echo "-- Archive: $label" + if [[ $COMPRESSOR == zstd ]]; then + out="$PARTIAL_DIR/$label.tar.zst" + tar --numeric-owner --acls --xattrs --selinux -C "$STAGING_DIR" -cf - -- "$root" | + zstd -T"$ZSTD_THREADS" -"$ZSTD_LEVEL" -q -c > "$out.partial" + else + out="$PARTIAL_DIR/$label.tar.gz" + tar --numeric-owner --acls --xattrs --selinux -C "$STAGING_DIR" -cf - -- "$root" | + gzip -6 -c > "$out.partial" fi -else - echo "-- Upload disabled (ENABLE_UPLOAD=false)" + mv -- "$out.partial" "$out" +done < "$PLAN" +helper create "$PARTIAL_DIR" "$RUN_ID" "$PLAN" +RUN_DIR="$ARCHIVE_DIR/$RUN_ID" +mv -- "$PARTIAL_DIR" "$RUN_DIR" +sync -f "$ARCHIVE_DIR" +echo "-- Complete local backup: $RUN_DIR" + +if [[ $ENABLE_UPLOAD == true ]]; then + remote_run="${RCLONE_REMOTE_BASE%/}/$RUN_ID" + # Upload COMPLETE last. An interrupted upload cannot look like a valid set. + rc copy "$RUN_DIR" "$remote_run" --exclude /COMPLETE --exclude /UPLOADED --immutable + rc check "$RUN_DIR" "$remote_run" --download --exclude /COMPLETE --exclude /UPLOADED + rc copyto "$RUN_DIR/COMPLETE" "$remote_run/COMPLETE" --immutable + marker=$(rc cat "$remote_run/COMPLETE") + [[ $marker == "$(cat "$RUN_DIR/COMPLETE")" ]] || die 'Remote completion marker mismatch' + date -u -Is > "$RUN_DIR/UPLOADED" + sync -f "$RUN_DIR" + echo '-- Remote backup fully read back and verified' fi -cleanup_old_local_archives -echo "== app-backup done: ${ts} ==" +# Retention works on complete v2 sets only, after a new successful backup. +# Older flat archives, partial runs and unknown folders are deliberately preserved. +valid="$STAGING_DIR/local-retention.txt" +: > "$valid" +for dir in "$ARCHIVE_DIR/${ARCHIVE_PREFIX}_"*; do + [[ -d $dir && ! -L $dir && -f $dir/COMPLETE ]] || continue + [[ $ENABLE_UPLOAD != true || -f $dir/UPLOADED ]] || continue + if helper metadata "$dir" >/dev/null 2>&1; then basename -- "$dir" >> "$valid"; fi +done +helper candidates "$ARCHIVE_PREFIX" "$LOCAL_RETENTION_DAYS" "$RETENTION_KEEP_LAST" < "$valid" > "$valid.delete" +while IFS= read -r name; do + [[ $name != "$RUN_ID" ]] || continue + echo "-- Local retention: $name" + rm -rf -- "${ARCHIVE_DIR:?}/$name" +done < "$valid.delete" +if [[ $ENABLE_UPLOAD == true && $ENABLE_REMOTE_RETENTION == true ]]; then + rc lsf "$RCLONE_REMOTE_BASE" --dirs-only --max-depth 1 > "$STAGING_DIR/remote-dirs.txt" + : > "$STAGING_DIR/remote-valid.txt" + mkdir "$STAGING_DIR/remote-meta" + while IFS= read -r name; do + name=${name%/} + [[ $name =~ ^${ARCHIVE_PREFIX}_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}_[0-9a-f]{8}$ ]] || continue + # Missing markers are expected for interrupted uploads; these are NEVER purged. + if rc cat "$RCLONE_REMOTE_BASE/$name/COMPLETE" > "$STAGING_DIR/remote-meta/COMPLETE" 2>/dev/null && + rc cat "$RCLONE_REMOTE_BASE/$name/manifest.json" > "$STAGING_DIR/remote-meta/manifest.json" 2>/dev/null && + helper metadata "$STAGING_DIR/remote-meta" >/dev/null 2>&1; then + echo "$name" >> "$STAGING_DIR/remote-valid.txt" + fi + done < "$STAGING_DIR/remote-dirs.txt" + helper candidates "$ARCHIVE_PREFIX" "$REMOTE_RETENTION_DAYS" "$RETENTION_KEEP_LAST" < "$STAGING_DIR/remote-valid.txt" > "$STAGING_DIR/remote-delete.txt" + while IFS= read -r name; do + [[ $name != "$RUN_ID" ]] || continue + echo "-- Remote retention: $name" + rc purge "$RCLONE_REMOTE_BASE/$name" + 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" \ + -mtime "+${LOG_RETENTION_DAYS}" ! -name "$(basename "$LOG_FILE")" -delete +done +echo "SUCCESS: $RUN_ID" diff --git a/usr-local-sbin/app-restore.sh b/usr-local-sbin/app-restore.sh index 5571971..61f71ca 100755 --- a/usr-local-sbin/app-restore.sh +++ b/usr-local-sbin/app-restore.sh @@ -1,107 +1,186 @@ #!/usr/bin/env bash +# Restore is intentionally two-phase: verify/extract everything, then touch targets. set -Eeuo pipefail -umask 027 - -# ============================================================================== -# app-restore.sh -# - Restore from a "run folder" (local dir or rclone remote folder) -# - Applies archives per component (meta/db/wordpress/nextcloud/nextcloud-data/gitea...) -# ============================================================================== - -LOG_DIR="/var/log/app-backup" -mkdir -p "$LOG_DIR" -ts="$(date '+%Y-%m-%d_%H-%M-%S')" -LOG_FILE="${LOG_DIR}/app-restore_${ts}.log" -exec > >(tee -a "$LOG_FILE" | systemd-cat -t app-restore -p info) 2>&1 - -CONFIG_FILE="/etc/app-backup/app-backup.conf" -if [[ -r "$CONFIG_FILE" ]]; then - # shellcheck disable=SC1090 - source "$CONFIG_FILE" +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +LIB_DIR="$SCRIPT_DIR/../lib/app-backup" +[[ -f $LIB_DIR/common.sh ]] || LIB_DIR="$SCRIPT_DIR/../lib" +# shellcheck source=lib/common.sh +source "$LIB_DIR/common.sh" +CONFIG_FILE=/etc/app-backup/app-backup.conf +LOCAL_RUN=''; REMOTE_RUN=''; MODE=plan; YES=false; LEGACY=false +FILES=true; DATABASES=true; COMPONENTS='' +while (( $# )); do + case $1 in + --config) CONFIG_FILE=${2:?Missing config}; shift 2 ;; + --local-run) LOCAL_RUN=${2:?Missing local run}; shift 2 ;; + --remote-run) REMOTE_RUN=${2:?Missing remote run}; shift 2 ;; + --verify-only) MODE=verify; shift ;; + --extract-only) MODE=extract; shift ;; + --dry-run) MODE=plan; shift ;; + --apply) MODE=apply; shift ;; + --yes) YES=true; shift ;; + --legacy-unverified) LEGACY=true; shift ;; + --no-db) DATABASES=false; shift ;; + --no-files) FILES=false; shift ;; + --components) COMPONENTS=${2:?Missing comma-separated components}; shift 2 ;; + --help) + echo 'Usage: app-restore.sh [--config FILE] (--local-run DIR | --remote-run NAME)' + echo ' [--dry-run | --verify-only | --extract-only | --apply --yes]' + echo ' [--no-db] [--no-files] [--components wordpress,db-wordpress,...]' + echo ' Legacy archives: --legacy-unverified --extract-only' + exit 0 ;; + *) die "Unknown argument: $1" ;; + esac +done +[[ -n $LOCAL_RUN && -z $REMOTE_RUN || -z $LOCAL_RUN && -n $REMOTE_RUN ]] || die 'Choose exactly one backup source' +[[ $MODE != apply || $YES == true ]] || die 'Applying a restore requires --apply --yes (overwrites files and database objects)' +[[ $LEGACY == false || $MODE == extract ]] || die 'Unverified legacy backups support --extract-only exclusively' +OPERATION=restore +load_config +init_runtime +check_space "$RESTORE_ROOT" +STAGING_DIR=$(mktemp -d "$RESTORE_ROOT/${RUN_ID}.XXXXXX") +INPUT="$STAGING_DIR/input" +mkdir -m 700 "$INPUT" +if [[ -n $REMOTE_RUN ]]; then + [[ $REMOTE_RUN =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]+$ && $REMOTE_RUN == "$ARCHIVE_PREFIX"_* ]] || die 'Unsafe remote run name' + check_remote + rc copy "${RCLONE_REMOTE_BASE%/}/$REMOTE_RUN" "$INPUT" else - echo "ERROR: Config not found/readable: $CONFIG_FILE" - exit 2 + [[ -d $LOCAL_RUN && ! -L $LOCAL_RUN ]] || die 'Local run must be an existing directory' + # Resolve overlap separately: archive sources are allowed under WORKDIR, but + # recursive copies into the freshly created restore workspace are forbidden. + input_real=$(realpath -e "$LOCAL_RUN") + stage_real=$(realpath -e "$STAGING_DIR") + [[ $stage_real != "$input_real" && $stage_real != "$input_real/"* ]] || die 'Backup source contains restore workspace' + rsync -a -- "$LOCAL_RUN/" "$INPUT/" fi - -: "${WORKDIR:=/var/backups/app-backup}" -: "${RESTORE_ROOT:=${WORKDIR}/restore}" -: "${ARCHIVE_PREFIX:=appbackup}" - -: "${RCLONE_BIN:=rclone}" -: "${RCLONE_REMOTE_BASE:=}" - - -# --- Remote base resolution & crypt enforcement --- -# Prefer explicit RCLONE_REMOTE_BASE from config. -# If only RCLONE_REMOTE is provided (e.g. "OneDriveCrypt:Sicherung"), derive the host folder. -: "${RCLONE_REMOTE:=}" -if [[ -z "${RCLONE_REMOTE_BASE}" ]]; then - if [[ -n "${RCLONE_REMOTE}" ]]; then - RCLONE_REMOTE_BASE="${RCLONE_REMOTE%/}/JRITServerBackups/$(hostname -s)" - fi +if [[ $LEGACY == true ]]; then + echo 'WARNING: Legacy backup has no trusted manifest/completeness proof.' + shopt -s nullglob + legacy=("$INPUT"/*.tar.gz "$INPUT"/*.tar.zst) + (( ${#legacy[@]} )) || die 'No legacy archives found' + # Validate the entire batch BEFORE extracting the first file. Legacy roots differ. + for archive in "${legacy[@]}"; do + [[ ! -L $archive ]] || die 'Symlink archive refused' + base=$(basename "$archive") + [[ $base =~ ^${ARCHIVE_PREFIX}_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}_(meta|db|wordpress|nextcloud|nextcloud-data|gitea|gitea-etc|mail|mail-home|postfix|dovecot)\.tar\.(gz|zst)$ ]] || die "Unknown legacy filename: $base" + label=${BASH_REMATCH[1]} + case $label in meta|db) root=$label ;; gitea) root=files/gitea-data ;; *) root=files/$label ;; esac + helper tar "$archive" "$root" + done + mkdir "$STAGING_DIR/extracted" + for archive in "${legacy[@]}"; do + legacy_dest="$STAGING_DIR/extracted/$(basename "$archive")" + mkdir "$legacy_dest" + tar --extract --file "$archive" --acls --xattrs --selinux --numeric-owner --same-owner -C "$legacy_dest" + done + echo "Legacy extraction retained for MANUAL review: $STAGING_DIR/extracted" + STAGING_DIR='' + exit 0 fi - -require_crypt_remote() { - local base="$1" - local remote_name="${base%%:*}" - [[ -n "${remote_name}" && "${remote_name}" != "${base}" ]] || die "RCLONE_REMOTE_BASE must include an rclone remote prefix like 'OneDriveCrypt:...'. Got: ${base}" - have "${RCLONE_BIN}" || die "rclone missing" - - # rclone prints full config; we only need the remote block. - local cfg - cfg="$("${RCLONE_BIN}" config show "${remote_name}" 2>/dev/null || true)" - [[ -n "${cfg}" ]] || die "rclone remote '${remote_name}' not found. Check 'rclone listremotes' and your config." - - local typ - typ="$(printf "%s\n" "${cfg}" | awk -F' = ' '$1=="type"{print $2; exit}')" - [[ "${typ}" == "crypt" ]] || die "Remote '${remote_name}' is type='${typ:-?}', not 'crypt'. Refusing to restore from non-crypt remote." - - # Enforce filename/directory encryption so OneDrive web does not show cleartext names. - local fe de - fe="$(printf "%s\n" "${cfg}" | awk -F' = ' '$1=="filename_encryption"{print $2; exit}')" - de="$(printf "%s\n" "${cfg}" | awk -F' = ' '$1=="directory_name_encryption"{print $2; exit}')" - [[ "${fe}" == "standard" ]] || die "Remote '${remote_name}': filename_encryption='${fe:-?}' (expected 'standard'). Otherwise filenames can appear in cleartext." - [[ "${de}" == "true" ]] || die "Remote '${remote_name}': directory_name_encryption='${de:-?}' (expected 'true'). Otherwise directory names can appear in cleartext." -} - -: "${DRY_RUN:=false}" -: "${RESTORE_DB:=true}" -: "${RESTORE_FILES:=true}" -: "${RESTORE_STRICT_DELETE:=false}" - -: "${ENABLE_NEXTCLOUD_MAINTENANCE:=true}" -: "${NC_OCC_USER:=apache}" -: "${NC_FILES_SCAN_AFTER_RESTORE:=false}" - -: "${ENABLE_GITEA_SERVICE_STOP:=true}" -: "${GITEA_SERVICE_NAME:=gitea}" - -: "${ENABLE_HTTPD_STOP:=false}" -: "${HTTPD_SERVICE_NAME:=httpd}" -: "${ENABLE_PHPFPM_STOP:=false}" -: "${PHPFPM_SERVICE_NAME:=php-fpm}" - -die() { echo "ERROR: $*"; exit 1; } -have() { command -v "$1" >/dev/null 2>&1; } - -run_cmd() { [[ "${DRY_RUN}" == "true" ]] && echo "[DRY_RUN] $*" || "$@"; } - -NC_MAINTENANCE_ON=false -nc_maintenance_off() { - if [[ "${NC_MAINTENANCE_ON}" == "true" ]]; then - echo "-- Nextcloud maintenance mode OFF (trap)..." - run_cmd sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:mode --off || true - NC_MAINTENANCE_ON=false +helper verify "$INPUT" +if [[ $MODE == verify ]]; then echo 'Verification passed; no application changes.'; exit 0; fi +helper list "$INPUT" > "$STAGING_DIR/archive-list.tsv" +if [[ $MODE == extract ]]; then + mkdir "$STAGING_DIR/extracted" + while IFS=$'\t' read -r label file _; do + mkdir "$STAGING_DIR/extracted/$label" + tar --extract --file "$INPUT/$file" --acls --xattrs --selinux --numeric-owner --same-owner -C "$STAGING_DIR/extracted/$label" + done < "$STAGING_DIR/archive-list.tsv" + echo "Verified extraction retained: $STAGING_DIR/extracted" + STAGING_DIR='' + exit 0 +fi +build_plan +# Selection is validated against both the local configuration and the backup set. +SELECTED=(); SELECTED_ROOTS=(); TARGETS=(); IS_DB=(); CNFS=() +selected() { [[ -z $COMPONENTS || ",$COMPONENTS," == *",$1,"* ]]; } +for i in "${!LABELS[@]}"; do + if [[ $FILES == true ]] && selected "${LABELS[i]}"; then + SELECTED+=("${LABELS[i]}"); SELECTED_ROOTS+=("${ROOTS[i]}") + TARGETS+=("${SOURCES[i]}"); IS_DB+=(false); CNFS+=('') fi -} - -GITEA_WAS_STOPPED=false -HTTPD_WAS_STOPPED=false -PHPFPM_WAS_STOPPED=false -gitea_start() { [[ "${GITEA_WAS_STOPPED}" == "true" ]] && { echo "-- Starting gitea (trap)"; run_cmd systemctl start "${GITEA_SERVICE_NAME}" || true; GITEA_WAS_STOPPED=false; }; } -httpd_start() { [[ "${HTTPD_WAS_STOPPED}" == "true" ]] && { echo "-- Starting httpd (trap)"; run_cmd systemctl start "${HTTPD_SERVICE_NAME}" || true; HTTPD_WAS_STOPPED=false; }; } -phpfpm_start(){ [[ "${PHPFPM_WAS_STOPPED}" == "true" ]] && { echo "-- Starting php-fpm (trap)"; run_cmd systemctl start "${PHPFPM_SERVICE_NAME}" || true; PHPFPM_WAS_STOPPED=false; }; } - -echo "== app-restore done: ${ts} ==" -echo "-- Working dir: ${RUN_DIR}" -echo "-- Log: ${LOG_FILE}" +done +for i in "${!DB_LABELS[@]}"; do + if [[ $DATABASES == true ]] && selected "${DB_LABELS[i]}"; then + SELECTED+=("${DB_LABELS[i]}"); SELECTED_ROOTS+=("db/${DB_LABELS[i]#db-}.sql") + TARGETS+=("${DB_NAMES[i]}"); IS_DB+=(true); CNFS+=("${DB_CNFS[i]}") + fi +done +(( ${#SELECTED[@]} )) || die 'No components selected' +if [[ -n $COMPONENTS ]]; then + IFS=, read -r -a requested <<< "$COMPONENTS" + for label in "${requested[@]}"; do + [[ " ${SELECTED[*]} " == *" $label "* ]] || die "Unknown, disabled or excluded component: $label" + done +fi +for i in "${!SELECTED[@]}"; do + label=${SELECTED[i]} + awk -F '\t' -v label="$label" '$1==label {found=1} END {exit !found}' "$STAGING_DIR/archive-list.tsv" || die "Required component absent: $label" + # Indexed paths must retain their meaning when lists in config are edited. + if [[ $label == extra-* || $label == mail-home-* ]]; then + python3 - "$INPUT/manifest.json" "$label" "${TARGETS[i]}" <<'PY' +import json,sys +manifest,label,target=sys.argv[1:] +source=next(a['source'] for a in json.load(open(manifest))['archives'] if a['label']==label) +if source != target: + sys.exit('Indexed source path differs from configuration; restore via --extract-only and review mapping') +PY + fi + echo "RESTORE $label -> ${TARGETS[i]} (database=${IS_DB[i]})" +done +if [[ $MODE == plan ]]; then + echo "Verified restore plan. Strict deletion: $RESTORE_STRICT_DELETE. Use --apply --yes to execute." + exit 0 +fi +mkdir "$STAGING_DIR/extracted" +# Each component gets an independent extraction root, so links in one component +# cannot redirect extraction of a later component into another tree. +while IFS=$'\t' read -r label file _; do + mkdir "$STAGING_DIR/extracted/$label" + tar --extract --file "$INPUT/$file" --acls --xattrs --selinux --numeric-owner --same-owner -C "$STAGING_DIR/extracted/$label" +done < "$STAGING_DIR/archive-list.tsv" +if [[ $MODE == extract ]]; then + echo "Verified extraction retained: $STAGING_DIR/extracted" + STAGING_DIR='' + exit 0 +fi +# Validate every destination and DB connection before stopping a single service. +for i in "${!SELECTED[@]}"; do + if [[ ${IS_DB[i]} == true ]]; then + secure_file "${CNFS[i]}" + (( (8#$(stat -c %a "${CNFS[i]}") & 8#077) == 0 )) || die 'Restore credentials require chmod 600' + need "$DB_CLIENT_BIN" + "$DB_CLIENT_BIN" --defaults-extra-file="${CNFS[i]}" --database="${TARGETS[i]}" --execute='SELECT 1' >/dev/null + else + check_source_path "${TARGETS[i]}" + fi +done +if [[ $ENABLE_NEXTCLOUD == true && $ENABLE_NEXTCLOUD_MAINTENANCE != true ]]; then + die 'Applying a restore with Nextcloud enabled requires maintenance-mode management' +fi +quiesce +MUTATION_STARTED=true +save_recovery +for i in "${!SELECTED[@]}"; do + label=${SELECTED[i]} + source_path="$STAGING_DIR/extracted/$label/${SELECTED_ROOTS[i]}" + if [[ ${IS_DB[i]} == true ]]; then + [[ -f $source_path && ! -L $source_path && -s $source_path ]] || die 'SQL dump missing/empty' + "$DB_CLIENT_BIN" --defaults-extra-file="${CNFS[i]}" --default-character-set=utf8mb4 --database="${TARGETS[i]}" < "$source_path" + else + exclusions "$label" + if [[ $RESTORE_STRICT_DELETE == true ]]; then EXCLUDES+=(--delete-delay); fi + rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$source_path/" "${TARGETS[i]}/" + fi +done +# A backup captured maintenance=true. Enforce the recorded pre-restore state even +# if the existing installation already was in maintenance mode. +if [[ " ${SELECTED[*]} " == *' nextcloud '* || " ${SELECTED[*]} " == *' db-nextcloud '* ]]; then + occ maintenance:mode --on + occ maintenance:data-fingerprint +fi +resume_services || die 'Restore applied, but service recovery failed' +MUTATION_STARTED=false +echo 'SUCCESS: selected components restored. Perform application-level smoke tests.'