Fix SELinux backup execution context and probe source metadata before downtime

This commit is contained in:
admin committed 2026-09-10 15:11:10 +02:00
1 parent 5c8a7a30be
commit c0a24b63fc
9 files changed
+193 -14

No files matched your search

+34 -2
View File
@@ -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. 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. 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 ## 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. 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: 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. - 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 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. - 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. - 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. - 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.
+12 -2
View File
@@ -37,6 +37,13 @@ abfragbar. Diagnose und Details stehen in der Update-Anleitung.
Die folgende manuelle Anleitung bleibt als Alternative für individuelle Updates. 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 ## Wesentliche Änderungen
- Funktionierender Restore mit Prüflauf, Entpackmodus, Komponentenauswahl und ausdrücklichem `--apply --yes`. - 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 `--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, DB-Recht `RELOAD` für die Dump-Sperre, bei MariaDB den Zugriff auf Routinen-Metadaten,
Speicherreserve und Crypt-Zugriff. Speicherreserve und Crypt-Zugriff.
Die Rechteprüfung liest nur Metadaten; sie stoppt keine Anwendungen, sperrt keine Die Prüfung liest Quellverzeichnisse über rsync und überträgt nur deren
Datenbank und führt weder Backup noch Upload aus. Sie ersetzt keinen vollständigen 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 Probelauf. Fehlt `RELOAD`, die tatsächlich verwendeten Backup-Konten ermitteln und
gezielt berechtigen; siehe [DB-Rechte korrigieren](UPDATE_ANLEITUNG.md#fehlendes-db-recht-reload). gezielt berechtigen; siehe [DB-Rechte korrigieren](UPDATE_ANLEITUNG.md#fehlendes-db-recht-reload).
+42 -3
View File
@@ -24,7 +24,9 @@ Backups mit zeitweiliger Unterbrechung der konfigurierten Anwendungen.
5. Crypt-Konfiguration, DB-Dateien, globales DB-Recht `RELOAD` und bei MariaDB 5. Crypt-Konfiguration, DB-Dateien, globales DB-Recht `RELOAD` und bei MariaDB
Zugriff auf Routinen-Metadaten prüfen; Zugriff auf Routinen-Metadaten prüfen;
vorhandene Schlüssel/Passwörter erhalten. Keine automatischen DB-Grants. 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. Paket samt Anwendungskonfiguration und Timer unverändert.
7. Einen von SSH unabhängigen systemd-Auftrag `app-backup-upgrade.service` mit 7. Einen von SSH unabhängigen systemd-Auftrag `app-backup-upgrade.service` mit
`--no-block` einreihen und die Sperren an ihn übergeben. `--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 konkreten Dienst zuordnen; ohne dessen Journal lässt sich die Ursache der Pause
nicht aus ihrer Dauer allein bestimmen. 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 ## Paket reproduzieren
Aus einem sauberen, eingecheckten Repository: Aus einem sauberen, eingecheckten Repository:
@@ -290,12 +327,14 @@ Das ausführbare Paket ausschließlich über euren vertrauenswürdigen Kanal bez
## Verifikation der Lieferung ## 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; Die Tests enthalten echte MariaDB-Dumps/Imports und lokale rclone-Crypt-Roundtrips;
systemd/occ und die systemd-run-Übergabe sind simuliert. Das Übergabemodell systemd/occ und die systemd-run-Übergabe sind simuliert. Das Übergabemodell
berücksichtigt jetzt das Warten auf den oneshot-Startauftrag: Der Regressionstest 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 scheiterte mit dem alten Code und war nach dem Fix erfolgreich. Geprüft sind auch
abgewiesene Übergaben, eingereihte Worker trotz fehlgeschlagener Antwort und abgewiesene Übergaben, eingereihte Worker trotz fehlgeschlagener Antwort und
persistente Fehlerprotokolle bei belegter Update-Sperre. Die tatsächliche 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. auf einem separaten Testsystem bleibt erforderlich.
+25
View File
@@ -118,6 +118,8 @@ init_runtime() {
# Journald already captures service stdout. No dependency on systemd-cat in terminals. # Journald already captures service stdout. No dependency on systemd-cat in terminals.
exec > >(tee -a "$LOG_FILE") 2>&1 exec > >(tee -a "$LOG_FILE") 2>&1
echo "== $OPERATION $RUN_ID ==" 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" need rsync; need tar; need "$COMPRESSOR"
STOPPED_SERVICES=(); NC_CHANGED=false; NC_WAS_ON=false; MUTATION_STARTED=false STOPPED_SERVICES=(); NC_CHANGED=false; NC_WAS_ON=false; MUTATION_STARTED=false
trap 'exit 130' INT trap 'exit 130' INT
@@ -190,8 +192,31 @@ check_db_credentials() {
done 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() { preflight() {
for p in "${SOURCES[@]}"; do check_source_path "$p"; done for p in "${SOURCES[@]}"; do check_source_path "$p"; done
if [[ $OPERATION == backup ]]; then check_source_access; fi
check_db_credentials check_db_credentials
if (( ${#DB_LABELS[@]} )); then if (( ${#DB_LABELS[@]} )); then
need "$DB_DUMP_BIN"; need "$DB_CLIENT_BIN" need "$DB_DUMP_BIN"; need "$DB_CLIENT_BIN"
+8 -1
View File
@@ -5,7 +5,14 @@ After=network-online.target
[Service] [Service]
Type=oneshot 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. # Timeout sends TERM: Bash runs its recovery trap; grace time permits service restart.
TimeoutStartSec=12h TimeoutStartSec=12h
TimeoutStopSec=5min TimeoutStopSec=5min
+66 -2
View File
@@ -251,6 +251,67 @@ QUIESCE_SERVICES=(nc-cron)
self.assertFalse(list((self.base/'work/archives').glob('appbackup_*'))) self.assertFalse(list((self.base/'work/archives').glob('appbackup_*')))
self.assertEqual(json.loads(self.state.read_text()),self.initial) 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): def test_maintenance_and_inactive_service_preserved(self):
state=dict(services=dict(self.initial['services'],gitea='inactive'),maintenance=True) state=dict(services=dict(self.initial['services'],gitea='inactive'),maintenance=True)
self.state.write_text(json.dumps(state)) self.state.write_text(json.dumps(state))
@@ -296,10 +357,12 @@ QUIESCE_SERVICES=(nc-cron)
def test_term_recovers_services(self): def test_term_recovers_services(self):
wrapper=self.bin/'rsync' 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) 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: try:
for _ in range(100): for _ in range(200):
if Path(str(self.events)+'.waiting').exists(): break if Path(str(self.events)+'.waiting').exists(): break
time.sleep(.05) time.sleep(.05)
else: self.fail('Backup did not reach rsync') else: self.fail('Backup did not reach rsync')
@@ -612,6 +675,7 @@ from pathlib import Path
args=sys.argv[1:] args=sys.argv[1:]
assert '--unit=app-backup-upgrade' in args assert '--unit=app-backup-upgrade' in args
assert '--property=Type=oneshot' 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'):] command=args[args.index('/bin/bash'):]
state=Path(command[-1]) state=Path(command[-1])
if os.environ.get('TEST_SYSTEMD_SUBMIT') == 'reject': if os.environ.get('TEST_SYSTEMD_SUBMIT') == 'reject':
+1
View File
@@ -255,6 +255,7 @@ if [[ $UP_WORKER == false ]]; then
# Only enqueue here; result.txt, not systemd-run's exit code, proves success. # 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. # 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 \ 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 \ --property=TimeoutStartSec=24h --property=TimeoutStopSec=5min --property=KillMode=mixed \
/bin/bash "$UPDATE_ROOT/update.sh" --worker "$UP_STATE" 8>&- 9>&- || { /bin/bash "$UPDATE_ROOT/update.sh" --worker "$UP_STATE" 8>&- 9>&- || {
# Revoke the queued state while still holding the coordinator lock. Even # Revoke the queued state while still holding the coordinator lock. Even
+3 -2
View File
@@ -24,7 +24,7 @@ init_runtime
build_plan build_plan
preflight preflight
if [[ $CHECK == true ]]; then 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 exit 0
fi fi
STAGING_DIR=$(mktemp -d "$STAGING_ROOT/${RUN_ID}.XXXXXX") STAGING_DIR=$(mktemp -d "$STAGING_ROOT/${RUN_ID}.XXXXXX")
@@ -58,7 +58,8 @@ for i in "${!LABELS[@]}"; do
exclusions "$label" exclusions "$label"
echo "-- Copy: $label ($src)" echo "-- Copy: $label ($src)"
# Never accept vanished-file (24) or partial-copy (23) as success. # 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" printf '%s\t%s\n' "$label" "$src" >> "$PLAN"
done done
# The full consistency window includes all dumps AND copies. Compression/upload # The full consistency window includes all dumps AND copies. Compression/upload
+2 -2
View File
@@ -53,7 +53,7 @@ else
input_real=$(realpath -e "$LOCAL_RUN") input_real=$(realpath -e "$LOCAL_RUN")
stage_real=$(realpath -e "$STAGING_DIR") stage_real=$(realpath -e "$STAGING_DIR")
[[ $stage_real != "$input_real" && $stage_real != "$input_real/"* ]] || die 'Backup source contains restore workspace' [[ $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 fi
if [[ $LEGACY == true ]]; then if [[ $LEGACY == true ]]; then
echo 'WARNING: Legacy backup has no trusted manifest/completeness proof.' echo 'WARNING: Legacy backup has no trusted manifest/completeness proof.'
@@ -172,7 +172,7 @@ for i in "${!SELECTED[@]}"; do
else else
exclusions "$label" exclusions "$label"
if [[ $RESTORE_STRICT_DELETE == true ]]; then EXCLUDES+=(--delete-delay); fi 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 fi
done done
# A backup captured maintenance=true. Enforce the recorded pre-restore state even # A backup captured maintenance=true. Enforce the recorded pre-restore state even