diff --git a/Dokumentation.md b/Dokumentation.md index 83e4968..64fbdca 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -54,6 +54,35 @@ flowchart TD 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. +Vor jedem Backup und bei `--check` prüft rsync das Auflisten der Quellwurzel sowie +das Übertragen ihrer Verzeichnisattribute in einen privaten temporären Prüfbereich +unter `STAGING_ROOT`. Alle Kindobjekte sind von der Attributprobe ausgeschlossen; +Datei-Inhalte werden nicht kopiert. Die Probe wird danach entfernt. Das erkennt +beispielsweise eine SELinux-Ablehnung von `opendir` oder `relabelfrom` vor dem +Wartungsmodus. Es belegt nicht den Zugriff auf jede untergeordnete Datei oder +jedes dort abweichende Attribut. Der echte Kopierlauf bleibt strikt fehlergeprüft. +Die rsync-Kindprozesse erhalten keine geerbten Update-/Backup-Sperrdeskriptoren; +der übergeordnete Prozess hält seine Sperren unverändert weiter. + +Backup-Dienst und abgekoppelter Update-Auftrag starten über `/bin/bash` mit +`SELinuxContext=system_u:system_r:unconfined_service_t:s0`. Das erlaubt dem +privilegierten Backup, unterschiedliche Web-, Mail- und Home-Labels zu lesen +und Sicherheitsattribute zu erhalten. **Diese Prozesse und ihre Kindprozesse +sind damit bewusst nicht durch SELinux eingeschränkt**; die übrigen Dienste +bleiben ihrer bestehenden Policy unterworfen. Das ist keine eigens eingeschränkte +Backup-Policy. Es werden keine globalen rsync-Booleans, Quell-Labels oder +Dateizugriffsrechte geändert. Ein verweigerter Kontext wird nicht mit dem +systemd-Präfix `-` übergangen. Bei deaktiviertem SELinux ignoriert systemd die +Kontexteinstellung. + +Auf Gemini wurde am 10.09.2026 unter Enforcing die Startkette Bash → rsync mit +diesem Kontext und erfolgreichem Auflisten von `/var/www/html` bestätigt. Der +direkte Start von rsync als Unit-Einstiegspunkt scheiterte dagegen mit 203/EXEC; +deshalb verwenden beide Units ausdrücklich Bash. Dieser Lesetest bestätigt +noch keine vollständige Attributübertragung oder Sicherung. Referenzen: +[systemd SELinuxContext](https://github.com/systemd/systemd/blob/v256/man/systemd.exec.xml), +[Fedora-SELinux-Domain und Einstiegspunkte](https://github.com/fedora-selinux/selinux-policy/blob/rawhide/policy/modules/system/unconfined.te). + 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 @@ -331,10 +360,13 @@ Die Entfernung der drei DB-Dateien aus dem aktuellen Baum löscht deren Historie Reproduzierbar mit `tests/Dockerfile` und `tests/run.sh`. Der Container bekommt das Repository nur lesbar und benötigt während der Tests kein Netzwerk. Die MariaDB läuft ohne TCP-Port in einem temporären Verzeichnis. systemd/occ werden simuliert; alle eigentlichen Archive, Dateikopien, Dumps/Imports und Crypt-Operationen sind real. -Ergebnis des lokalen Abschlusslaufs am 10.09.2026: **41 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. +Ergebnis des lokalen Abschlusslaufs am 10.09.2026: **44 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: +- Verweigerter rsync-Quellzugriff bricht das Update vor Installation und Dienst-Stopp ab. +- Verweigerte Verzeichnisattribute werden vor Wartungsmodus erkannt; der private Prüfbereich wird entfernt. +- Attributprobe kopiert keine Kindobjekte; rsync erbt bei Prüfung, Backup und Restore keine Update-/Backup-Sperrdeskriptoren. - Fehlendes globales `RELOAD` bei eingeschränktem Konto: Vorprüfungsabbruch ohne Installation/Writer-Stopp, anschließend erfolgreicher Paketlauf nach gezieltem Grant. Aktive verschachtelte MariaDB-Rollen werden anerkannt, inaktive Rollen abgewiesen. - Fehlender Zugriff auf MariaDB-Routinen-Metadaten vor Wartungsmodus erkannt; nach gezielter Berechtigung tatsächlicher Dump mit gespeicherter Prozedur geprüft. - Fehlender Mailstore ohne Live-Änderung, explizite Pfadkorrektur, Homes-only-Update samt tatsächlichem Maildatei-Restore und Ablehnung unvollständiger Home-Konfigurationen. @@ -354,4 +386,4 @@ Geprüfte Fälle umfassen: - Abgewiesene systemd-Übergabe, zurückgezogene Freigabe bei bereits eingereihtem Auftrag und persistente Fehlerprotokolle bei Sperr-Timeout; jeweils ohne Installationsänderung oder Timer-Stopp. - Ausdrückliche Cron-Policy und Writer-Wartezeit/Timeout. -**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. +**Serverabnahme steht separat aus:** SELinux-/ACL-Verhalten beim vollständigen Kopieren auf den verwendeten Dateisystemen, installierte Nextcloud-/Gitea-Versionen, vollständige Writer-/Pfadliste, tatsächlicher OneDrive-Upload großer Archive, Laufzeit/Speicherbedarf und fachliche Restore-Probe. Die Entwicklungstests verändern keinen Produktivserver. Vom Betreiber ausgeführte Paketläufe haben die Installation auf Gemini bereits aktualisiert; alle drei DB-Dumps waren erfolgreich. Eine vollständige erfolgreiche Sicherung und fachliche Wiederherstellung sind noch nicht bestätigt. diff --git a/README.md b/README.md index eb1db37..f0b1947 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,13 @@ abfragbar. Diagnose und Details stehen in der Update-Anleitung. Die folgende manuelle Anleitung bleibt als Alternative für individuelle Updates. +Auf Rocky/RHEL starten Backup-Dienst und Update-Auftrag über Bash mit +`SELinuxContext=system_u:system_r:unconfined_service_t:s0`. SELinux bleibt systemweit +aktiv; **diese privilegierten Backup-Prozesse sind bewusst nicht durch SELinux +eingeschränkt**. Dadurch können sie Web-/Mail-/Home-Daten samt Sicherheitsattributen +sichern. Der Installer ändert keine globalen rsync-Booleans und keine Quell-Labels. +Details und geprüfte Grenzen stehen in der Update-Anleitung. + ## Wesentliche Änderungen - Funktionierender Restore mit Prüflauf, Entpackmodus, Komponentenauswahl und ausdrücklichem `--apply --yes`. @@ -96,8 +103,11 @@ sudo cat /var/backups/app-backup/last-backup.json `--check` prüft Werkzeuge, Quellpfade, Dateirechte, DB-Verbindungen, das globale DB-Recht `RELOAD` für die Dump-Sperre, bei MariaDB den Zugriff auf Routinen-Metadaten, Speicherreserve und Crypt-Zugriff. -Die Rechteprüfung liest nur Metadaten; sie stoppt keine Anwendungen, sperrt keine -Datenbank und führt weder Backup noch Upload aus. Sie ersetzt keinen vollständigen +Die Prüfung liest Quellverzeichnisse über rsync und überträgt nur deren +Verzeichnisattribute in einen privaten temporären Prüfbereich im Staging. +Dabei werden keine Datei-Inhalte kopiert. Die DB-Rechteprüfung liest Metadaten; +es werden keine Anwendungen gestoppt, keine globale DB-Sperre genommen und kein +Backup oder Upload ausgeführt. Die Vorprüfung ersetzt keinen vollständigen Probelauf. Fehlt `RELOAD`, die tatsächlich verwendeten Backup-Konten ermitteln und gezielt berechtigen; siehe [DB-Rechte korrigieren](UPDATE_ANLEITUNG.md#fehlendes-db-recht-reload). diff --git a/UPDATE_ANLEITUNG.md b/UPDATE_ANLEITUNG.md index 53660fe..67484eb 100644 --- a/UPDATE_ANLEITUNG.md +++ b/UPDATE_ANLEITUNG.md @@ -24,7 +24,9 @@ Backups mit zeitweiliger Unterbrechung der konfigurierten Anwendungen. 5. Crypt-Konfiguration, DB-Dateien, globales DB-Recht `RELOAD` und bei MariaDB Zugriff auf Routinen-Metadaten prüfen; vorhandene Schlüssel/Passwörter erhalten. Keine automatischen DB-Grants. -6. Vorprüfung mit geschützten Kopien durchführen. Bei Fehlern bleibt das installierte +6. Vorprüfung mit geschützten Kopien durchführen. rsync prüft außerdem den Zugriff + auf Quellverzeichnisse und deren Attribute in einem privaten temporären + Prüfbereich, ohne Datei-Inhalte zu kopieren. Bei Fehlern bleibt das installierte Paket samt Anwendungskonfiguration und Timer unverändert. 7. Einen von SSH unabhängigen systemd-Auftrag `app-backup-upgrade.service` mit `--no-block` einreihen und die Sperren an ihn übergeben. @@ -274,6 +276,41 @@ im Journal protokolliert. Eine längere Pause ohne CPU-Last lässt sich so einem konkreten Dienst zuordnen; ohne dessen Journal lässt sich die Ursache der Pause nicht aus ihrer Dauer allein bestimmen. +## SELinux: rsync-Zugriff verweigert (Exit 23) + +Der Gemini-Lauf vom 10.09.2026 um 14:58 Uhr scheiterte nach drei erfolgreichen +DB-Dumps beim Kopieren von `/var/www/html`. Das Audit bestätigt rsync im Kontext +`rsync_t`: Lesen von `httpd_sys_content_t` sowie Attributänderungen und Relabeling +im Staging wurden verweigert. Zusätzlich wurden geerbte Sperrdeskriptoren beanstandet. +Die gestoppten Dienste wurden laut Journal wieder gestartet und Nextcloud verließ +den Wartungsmodus. Es entstand kein vollständiger neuer Sicherungssatz. + +Das aktuelle Paket setzt für den Update-Auftrag und den installierten Backup-Dienst +`SELinuxContext=system_u:system_r:unconfined_service_t:s0` und startet beide über +`/bin/bash`. **Diese privilegierten Prozesse und ihre Kindprozesse laufen damit +ohne SELinux-Einschränkung.** SELinux bleibt systemweit aktiv; andere Dienste +behalten ihre bestehende Policy. Der Installer ändert keine globalen rsync-Booleans, +keine Quell-Labels und keine Quellberechtigungen. Ein nicht zugelassener Kontext +führt zu einem sichtbaren Startfehler. + +Auf Gemini ist die Bash-Startkette mit diesem Kontext und erfolgreichem +rsync-Auflisten des Webverzeichnisses unter Enforcing geprüft. Der direkte +Unit-Start von `/usr/bin/rsync` scheiterte dagegen mit 203/EXEC und bildet die +Startkette des Pakets nicht ab. Manuelle Unit-Anpassungen sind nicht erforderlich: +das aktuelle Paket mit denselben Gitea-/Cron-/Mail-Optionen erneut starten. + +Vor dem Wartungsmodus prüft das Paket über rsync die Quellverzeichnisse und die +Übernahme ihrer Verzeichnisattribute in einen privaten temporären Prüfbereich. +Dabei werden keine Dateiinhalte kopiert. Die Probe erfasst nicht jede untergeordnete +Datei; der vollständige Kopierlauf und die Remote-Prüfung bleiben erforderlich. +rsync erhält keine geerbten Sperrdeskriptoren; die Elternprozesse halten die Sperren +weiter. Der SELinux-Kontext des Backup-Prozesses erscheint im Protokoll. + +Bei einem erneuten Fehler `update.log`, die ausgegebene Ergebnisdatei und die +passenden AVC-Meldungen prüfen. Maßgeblich für den erfolgreichen Abschluss bleibt +`SUCCESS` in `result.txt`; erst danach aktiviert der Installer den Backup-Timer. +Eine Wiederherstellungsprobe auf einem separaten System steht weiterhin aus. + ## Paket reproduzieren Aus einem sauberen, eingecheckten Repository: @@ -290,12 +327,14 @@ Das ausführbare Paket ausschließlich über euren vertrauenswürdigen Kanal bez ## Verifikation der Lieferung -41 Container-Tests sowie ShellCheck und Shell-Syntaxprüfung erfolgreich. +44 Container-Tests sowie ShellCheck und Shell-Syntaxprüfung erfolgreich. Die Tests enthalten echte MariaDB-Dumps/Imports und lokale rclone-Crypt-Roundtrips; systemd/occ und die systemd-run-Übergabe sind simuliert. Das Übergabemodell berücksichtigt jetzt das Warten auf den oneshot-Startauftrag: Der Regressionstest scheiterte mit dem alten Code und war nach dem Fix erfolgreich. Geprüft sind auch abgewiesene Übergaben, eingereihte Worker trotz fehlgeschlagener Antwort und persistente Fehlerprotokolle bei belegter Update-Sperre. Die tatsächliche -Server-/OneDrive-Prüfung erfolgt beim ersten Paketlauf. Ein fachlicher Restore +SELinux-Durchsetzung wird im Debian-Testcontainer nicht nachgebildet; der erfolgreiche +Bash-/rsync-Lesetest auf Gemini wurde separat vom Betreiber durchgeführt. Die vollständige +Server-/OneDrive-Prüfung erfolgt beim Paketlauf. Ein fachlicher Restore auf einem separaten Testsystem bleibt erforderlich. diff --git a/lib/common.sh b/lib/common.sh index 6089ef9..800a86f 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -118,6 +118,8 @@ init_runtime() { # Journald already captures service stdout. No dependency on systemd-cat in terminals. exec > >(tee -a "$LOG_FILE") 2>&1 echo "== $OPERATION $RUN_ID ==" + local security_context + if security_context=$(id -Z 2>/dev/null); then echo "-- SELinux process context: $security_context"; fi need rsync; need tar; need "$COMPRESSOR" STOPPED_SERVICES=(); NC_CHANGED=false; NC_WAS_ON=false; MUTATION_STARTED=false trap 'exit 130' INT @@ -190,8 +192,31 @@ check_db_credentials() { done } +check_source_access() ( + # This subshell owns only a fresh private probe directory. Keep source data + # untouched and clean probe objects even if an attribute operation fails. + local probe p + probe=$(mktemp -d "$STAGING_ROOT/.access-probe-XXXXXXXX") + trap 'rm -rf -- "$probe"' EXIT + # Test through rsync itself: under SELinux it can enter a different domain + # than this root shell. A shell test -r / realpath alone misses that denial. + # --dirs lists one directory level only, without reading file contents, + # recursing through live mailboxes, or creating a destination. + for p in "${SOURCES[@]}"; do + rsync --list-only --dirs -- "$p/" 8>&- 9>&- >/dev/null || die "rsync cannot list source: $p; check permissions and SELinux audit records before retrying" + mkdir "$probe/root" + # Exclude ALL children: copy only root-directory metadata into the private + # probe. Exercise chmod/chown/times/ACLs/xattrs, including SELinux labels, + # before maintenance. This is not a recursive/full content access test. + rsync -aHAX --numeric-ids --exclude='/*' -- "$p/" "$probe/root/" 8>&- 9>&- || die "rsync cannot preserve directory metadata for: $p; inspect SELinux/filesystem permissions" + rm -rf -- "$probe/root" + done + echo '-- Source directory access and staging metadata probe passed' +) + preflight() { for p in "${SOURCES[@]}"; do check_source_path "$p"; done + if [[ $OPERATION == backup ]]; then check_source_access; fi check_db_credentials if (( ${#DB_LABELS[@]} )); then need "$DB_DUMP_BIN"; need "$DB_CLIENT_BIN" diff --git a/systemd/app-backup.service b/systemd/app-backup.service index a5a7030..a36ea45 100644 --- a/systemd/app-backup.service +++ b/systemd/app-backup.service @@ -5,7 +5,14 @@ After=network-online.target [Service] Type=oneshot -ExecStart=/usr/local/sbin/app-backup.sh +# Bash is an allowed entry point for this SELinux service domain. Running rsync +# directly as the entry point can fail even when child execution is permitted. +ExecStart=/bin/bash /usr/local/sbin/app-backup.sh +# A privileged backup crosses web/mail/user labels and preserves those labels. +# Keep enforcing globally; scope unconfined execution to this backup service. +# This deliberately removes SELinux confinement for this unit and its children. +# No '-' prefix: an unavailable/denied context must fail visibly. +SELinuxContext=system_u:system_r:unconfined_service_t:s0 # Timeout sends TERM: Bash runs its recovery trap; grace time permits service restart. TimeoutStartSec=12h TimeoutStopSec=5min diff --git a/tests/test_backup.py b/tests/test_backup.py index 83338eb..1027ed1 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -251,6 +251,67 @@ QUIESCE_SERVICES=(nc-cron) self.assertFalse(list((self.base/'work/archives').glob('appbackup_*'))) self.assertEqual(json.loads(self.state.read_text()),self.initial) + def test_rsync_source_denial_fails_preflight_before_stopping_services(self): + canonical=self.configure_upgrade() + original=canonical.read_bytes() + wrapper=self.bin/'rsync' + wrapper.write_text('''#!/usr/bin/env bash +if [[ " $* " == *" --list-only "* ]]; then + echo 'rsync: simulated source opendir permission denied' >&2 + exit 23 +fi +exec /usr/bin/rsync "$@" +''') + wrapper.chmod(0o755) + package=self.build_package() + result=subprocess.run(['bash',str(package),'--foreground'],env=self.env,capture_output=True,text=True,timeout=60) + self.assertNotEqual(result.returncode,0,result.stdout+result.stderr) + self.assertIn('rsync cannot list source',result.stdout+result.stderr) + self.assertEqual(canonical.read_bytes(),original) + self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'active') + self.assertNotIn('systemctl stop',self.events.read_text()) + self.assertNotIn('maintenance:mode --on',self.events.read_text()) + + def test_directory_attribute_denial_is_caught_before_maintenance(self): + wrapper=self.bin/'rsync' + wrapper.write_text('''#!/usr/bin/env bash +if [[ " $* " == *" --exclude=/* "* ]]; then + echo 'rsync: simulated security.selinux relabel denial' >&2 + exit 23 +fi +exec /usr/bin/rsync "$@" +''') + wrapper.chmod(0o755) + failed=self.run_script('app-backup.sh','--check',ok=False) + self.assertIn('cannot preserve directory metadata',failed.stdout) + self.assertNotIn('systemctl stop',self.events.read_text()) + self.assertEqual(json.loads(self.state.read_text()),self.initial) + self.assertFalse(list((self.base/'work/staging').iterdir())) + + def test_metadata_probe_copies_no_children_and_rsync_inherits_no_locks(self): + wrapper=self.bin/'rsync' + wrapper.write_text('''#!/usr/bin/env python3 +import os,subprocess,sys +from pathlib import Path +for fd in (8,9): + try: os.fstat(fd) + except OSError: pass + else: sys.exit('rsync inherited a coordinator lock descriptor') +result=subprocess.run(['/usr/bin/rsync',*sys.argv[1:]]) +if result.returncode: sys.exit(result.returncode) +if '--exclude=/*' in sys.argv: + destination=Path(sys.argv[-1]) + assert not list(destination.iterdir()), 'probe copied source contents' + source=Path(sys.argv[-2]) + assert destination.stat().st_mode == source.stat().st_mode +''') + wrapper.chmod(0o755) + self.run_script('app-backup.sh','--check') + self.assertFalse(list((self.base/'work/staging').iterdir())) + self.assertNotIn('systemctl stop',self.events.read_text()) + run=self.backup() + self.run_script('app-restore.sh','--local-run',run,'--components','wordpress','--apply','--yes') + 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)) @@ -296,10 +357,12 @@ QUIESCE_SERVICES=(nc-cron) 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') + # Signal only during the real copy, after services have stopped. The + # new access probes run earlier and must not trigger this recovery test. + wrapper.write_text('#!/usr/bin/env bash\nif [[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* ]]; then\n echo waiting > "$TEST_EVENTS.waiting"\n sleep 2\nfi\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): + for _ in range(200): if Path(str(self.events)+'.waiting').exists(): break time.sleep(.05) else: self.fail('Backup did not reach rsync') @@ -612,6 +675,7 @@ from pathlib import Path args=sys.argv[1:] assert '--unit=app-backup-upgrade' in args assert '--property=Type=oneshot' in args +assert '--property=SELinuxContext=system_u:system_r:unconfined_service_t:s0' in args command=args[args.index('/bin/bash'):] state=Path(command[-1]) if os.environ.get('TEST_SYSTEMD_SUBMIT') == 'reject': diff --git a/update.sh b/update.sh index 9febaa0..a186076 100755 --- a/update.sh +++ b/update.sh @@ -255,6 +255,7 @@ if [[ $UP_WORKER == false ]]; then # Only enqueue here; result.txt, not systemd-run's exit code, proves success. # The launcher must not keep copies of our locks; systemd starts a fresh worker. systemd-run --no-block --unit=app-backup-upgrade --collect --property=Type=oneshot \ + --property=SELinuxContext=system_u:system_r:unconfined_service_t:s0 \ --property=TimeoutStartSec=24h --property=TimeoutStopSec=5min --property=KillMode=mixed \ /bin/bash "$UPDATE_ROOT/update.sh" --worker "$UP_STATE" 8>&- 9>&- || { # Revoke the queued state while still holding the coordinator lock. Even diff --git a/usr-local-sbin/app-backup.sh b/usr-local-sbin/app-backup.sh index 78e2b9a..31647bf 100755 --- a/usr-local-sbin/app-backup.sh +++ b/usr-local-sbin/app-backup.sh @@ -24,7 +24,7 @@ init_runtime build_plan preflight if [[ $CHECK == true ]]; then - echo 'Preflight passed. No source copy, service stop, dump, upload or deletion performed.' + echo 'Preflight passed. Directory metadata probed; no application backup, service stop or upload performed.' exit 0 fi STAGING_DIR=$(mktemp -d "$STAGING_ROOT/${RUN_ID}.XXXXXX") @@ -58,7 +58,8 @@ for i in "${!LABELS[@]}"; do 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/" + # Parent retains both locks; rsync itself needs neither inherited descriptor. + rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$src/" "$STAGING_DIR/$root/" 8>&- 9>&- printf '%s\t%s\n' "$label" "$src" >> "$PLAN" done # The full consistency window includes all dumps AND copies. Compression/upload diff --git a/usr-local-sbin/app-restore.sh b/usr-local-sbin/app-restore.sh index 61f71ca..8a467c0 100755 --- a/usr-local-sbin/app-restore.sh +++ b/usr-local-sbin/app-restore.sh @@ -53,7 +53,7 @@ else 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/" + rsync -a -- "$LOCAL_RUN/" "$INPUT/" 8>&- 9>&- fi if [[ $LEGACY == true ]]; then echo 'WARNING: Legacy backup has no trusted manifest/completeness proof.' @@ -172,7 +172,7 @@ for i in "${!SELECTED[@]}"; do else exclusions "$label" if [[ $RESTORE_STRICT_DELETE == true ]]; then EXCLUDES+=(--delete-delay); fi - rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$source_path/" "${TARGETS[i]}/" + rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$source_path/" "${TARGETS[i]}/" 8>&- 9>&- fi done # A backup captured maintenance=true. Enforce the recorded pre-restore state even