diff --git a/Dokumentation.md b/Dokumentation.md index 8e05363..4c7e745 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -181,6 +181,56 @@ 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/). +Exportierte `RCLONE_*`-Variablen sind ausschließlich für die vom Paket ausdrücklich +unterstützten Einstellungen und `RCLONE_CONFIG_PASS` erlaubt. Andere Optionen +werden vor dem ersten rclone-Aufruf und vor dem Writer-Stopp mit ihrem Namen, +ohne ihren Wert, abgewiesen. Insbesondere kann `RCLONE_SIZE_ONLY=true` in rclone +auch bei `check --download` den Inhaltsvergleich überspringen. Ein Regressionstest +hat diesen Fehler vor der Härtung mit einer veränderten Remote-Datei gleicher Größe +reproduziert. Zusätzliche Filter, Dry-run- und TLS-Overrides aus der Umgebung werden +ebenfalls abgewiesen. Einstellungen über die dokumentierte Backup-Konfiguration +und Remote-Definitionen verwalten; eine absichtlich manipulierte root-eigene +Bash-Konfiguration liegt außerhalb dieser Absicherung. +Quelle: [rclone 1.74.3 Inhaltsprüfung](https://github.com/rclone/rclone/blob/v1.74.3/fs/operations/check.go). + +### Upload-Fehlerbehandlung und Betriebsgrenzen + +| Ereignis | Verhalten und Konsequenz | +| --- | --- | +| Vorübergehender Upload-Fehler | rclone wiederholt den Kopierauftrag bis zu `RCLONE_RETRIES=6` Versuche, soweit der Fehler wiederholbar ist; Wartezeit standardmäßig `RCLONE_RETRIES_SLEEP=30s`, zusätzlich gegebenenfalls Provider-Backoff. Kein erneuter DB-Dump oder Writer-Stopp innerhalb dieser Versuche. | +| Upload endgültig fehlgeschlagen | Fehlerexit; lokaler vollständiger Satz und Staging bleiben erhalten; kein neuer Remote-Abschlussmarker und keine Retention in diesem Lauf. | +| Rückleseprüfung findet andere oder fehlende Inhalte | Fehlerexit; keine Veröffentlichung des Abschlussmarkers. Auch gleich große Dateien werden inhaltlich verglichen. | +| Abschlussmarker kann nicht geschrieben oder bestätigt werden | Fehlerexit; kein lokales `UPLOADED`, kein neuer Erfolgsstatus, keine Retention. Der Vergleich prüft die exakten Bytes einschließlich abschließender Zeilenumbrüche, passend zum strikten Restore-Parser. Ist der Marker schon geschrieben und nur seine Bestätigung fehlgeschlagen, kann der Remote-Satz vollständig vorliegen; der Lauf meldet konservativ trotzdem einen Fehler. | +| Wiederholungen erfolgreich und Rücklesen bestanden | Remote-Marker wird zuletzt geschrieben und zurückgelesen; erst danach gilt der Upload als erfolgreich. Ein früheres `ERROR` im Log allein ist kein Endergebnis. | +| Prozess hängt oder wird abgebrochen | systemd begrenzt den regulären Backup-Dienst auf 12 Stunden, den Update-Auftrag auf 24 Stunden, mit 5 Minuten Stop-Frist. Direkte CLI-/Foreground-Aufrufe besitzen diese systemd-Gesamtfrist nicht. | + +HTTP 409 benötigt nach dem beobachteten erfolgreichen Wiederholungsversuch keinen +zusätzlichen allgemeinen Retry-Wrapper. Dieser würde Versuche vervielfachen und +dauerhafte Fehler länger verdecken. Der Auslöser des OneDrive-Konflikts bleibt +unbekannt. Häufen sich Konflikte, den betroffenen Objektnamen, Zeitpunkte, rclone-Version, +andere Zugriffe auf den Backup-Ordner und die vollständigen Versuchsergebnisse prüfen. +Keine automatischen Remote-Löschungen oder Überschreibungen zur Konfliktbehebung. +`--immutable` schützt vor Änderungen durch diesen Kopierauftrag, ist aber kein +serverseitiger WORM-/Ransomware-Schutz gegen andere berechtigte Zugriffe. + +Die sechs Gesamtversuche gelten nicht pauschal für jeden rclone-Befehl: +`check --download` verwendet in rclone 1.74.3 keine Wiederholung des ganzen Befehls, +jedoch Wiederholungen fehlgeschlagener Datei-Leseoperationen. Ein verbleibender +Prüffehler beendet das Backup sichtbar. Quellen: +[Kopierversuche](https://github.com/rclone/rclone/blob/v1.74.3/cmd/cmd.go), +[Check-Aufruf](https://github.com/rclone/rclone/blob/v1.74.3/cmd/check/check.go), +[Immutable](https://rclone.org/docs/#immutable). + +Ein automatischer späterer Upload eines bereits lokal gesicherten, aber nicht +veröffentlichten Satzes ist bislang nicht implementiert. Nach ausgeschöpften +Versuchen bleibt er zur gezielten Prüfung erhalten; der nächste Timer-Lauf +erstellt einen neuen Satz. Wiederholte Fehler können deshalb lokalen Speicher +belegen. Erfolgsalter, Fehlerexit, Speicher und Recovery-Dateien unabhängig +überwachen; eine erfolgreiche Mailübergabe an den lokalen Mailserver beweist noch +keine Zustellung. Eine fachliche Restore-Probe und separat gesicherte Zugangsdaten +sind weiterhin erforderlich. Der erfolgreiche Paketlauf allein rechtfertigt keine +uneingeschränkte Zusage für beliebige Serverkonfigurationen oder Katastrophenfälle. + ### 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. @@ -298,7 +348,7 @@ 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. +Exit `0` bedeutet, dass die ausgeführten erforderlichen Schritte erfolgreich waren. Jeder Fehlercode ist ein Fehler; `130`/`143` kennzeichnen INT/TERM. `last-backup.json` wird beim Beenden geschrieben und zeigt während eines laufenden Backups noch den vorherigen beendeten 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. Bei `ENABLE_UPLOAD=false` belegt ein Erfolg nur eine lokale Sicherung; für Offsite-Überwachung zusätzlich die Upload-Konfiguration und `UPLOADED` des gemeldeten Satzes berücksichtigen. 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. @@ -433,12 +483,15 @@ Die Entfernung der drei DB-Dateien aus dem aktuellen Baum löscht deren Historie Reproduzierbar mit `tests/Dockerfile` und `tests/run.sh`. Der Container bekommt das Repository nur lesbar und benötigt während der Tests kein Netzwerk. Die MariaDB läuft ohne TCP-Port in einem temporären Verzeichnis. systemd/occ werden simuliert; alle eigentlichen Archive, Dateikopien, Dumps/Imports und Crypt-Operationen sind real. -Verifikation am 10.09.2026: **52 Testfälle erfolgreich** — 50 im vollständigen Durchlauf, anschließend beide zusätzlichen Abbruchtests für Kopie und Upload auf dem ergänzten Stand. Shell-Syntax und ShellCheck abschließend ohne Befund. Testumgebung: Debian 13.6, MariaDB 11.8.6, rclone 1.60.1-DEV (Distributionspaket), ShellCheck 0.10.0. Diese Versionsangaben beschreiben die Testumgebung, keine Empfehlung für die Produktivversion. +Verifikation am 10.09.2026: **59 Testfälle erfolgreich** — 58 im vollständigen Durchlauf (524,6 Sekunden), dazu der ergänzte Test für zusätzliche Marker-Zeilenumbrüche. Die vier betroffenen Markertests und der echte Crypt-Upload mit Remote-Restore wurden nach der Marker-Korrektur nochmals gezielt erfolgreich ausgeführt. Shell-Syntax und ShellCheck ohne Befund. Testumgebung: Debian 13.6, MariaDB 11.8.6, rclone 1.60.1-DEV (Distributionspaket), ShellCheck 0.10.0. Diese Versionsangaben beschreiben die Testumgebung, keine Empfehlung für die Produktivversion. -Für die anschließende Ergänzung der Upload-Statistiken wurden drei betroffene Tests erneut erfolgreich ausgeführt: echter gedrosselter Crypt-Upload samt Remote-Restore, fehlgeschlagene Rückleseprüfung ohne Remote-Abschlussmarker und TERM während eines Uploads mit gestoppt bleibendem Timer. Shell-Syntax und ShellCheck bestanden ebenfalls erneut. Der vollständige 52-Fälle-Umfang wurde für diese Protokollergänzung nicht nochmals durchlaufen. +Die Regressionstests für `RCLONE_SIZE_ONLY` und zusätzliche Marker-Zeilenumbrüche scheiterten vor den jeweiligen Korrekturen mit einem fälschlich erfolgreichen Backup und bestanden danach. Upload-/Markerfehler werden an den Befehlsgrenzen injiziert, während Crypt-Übertragung und Inhaltsvergleich real bleiben. Die Tests bilden keine OneDrive-API nach; das tatsächliche HTTP-409-Wiederholungsverhalten ist durch den Betreiberlauf und den rclone-1.74.3-Quellcode separat belegt. Geprüfte Fälle umfassen: +- Endgültiger Upload-Fehler trotz bereits übertragener Bytes: vollständiger lokaler Satz, vorheriger Erfolgsstatus und alte lokale/entfernte Retention-Kandidaten bleiben erhalten. +- Veränderte Remote-Datei gleicher Größe, Marker-Schreibfehler, abweichender Marker-Inhalt und zusätzliche Zeilenumbrüche verhindern den lokalen Upload-Erfolg und die Retention. +- Nicht unterstützte rclone-Umgebungsoptionen werden vor Remote-Zugriff und Writer-Stopp abgewiesen; unterstützte exportierte Einstellungen bleiben nutzbar. - Periodische NOTICE-Statistiken bei gedrosseltem echten Crypt-Upload, erfolgreicher Rückleseprüfung und Remote-Restore; die Statistik verändert den maschinell gelesenen Abschlussmarker nicht. - Fremde Startversuche während realer rsync-Kopien werden durch das systemd-Testmodell übersprungen; zuvor inaktive Writer bleiben nach dem Lauf inaktiv. - Aktive sowie zwischenzeitlich gestartete und wieder gestoppte Writer verhindern die Veröffentlichung eines vollständigen Satzes. diff --git a/README.md b/README.md index 8b163ac..1643805 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,30 @@ Sichert WordPress, Nextcloud, native Gitea-Installationen mit MariaDB, Maildaten Die vollständige Analyse, Architektur, Konfiguration, Betriebsanleitung und verbleibenden Grenzen stehen in [Dokumentation.md](Dokumentation.md). +## Produktionsstand und verbleibende Abnahme + +Auf Gemini bestand am 10.09.2026 ein vollständiger Paketlauf mit geschützter +Dateikopie, DB-Dumps, OneDrive-Upload, vollständigem Rücklesen und separater +Archivprüfung (14 Archive). Dienste und Backup-Timer wurden anschließend gestartet. +Das belegt die technische Abnahme für die konfigurierte Umgebung. Eine fachliche +Wiederherstellung mit funktionierenden Anwendungen auf einem Testsystem sowie +unabhängige Alarmierung und separat verfügbare Crypt-Schlüssel bleiben Voraussetzungen +für einen belastbaren Produktionsbetrieb; ihre Abnahme ist noch nicht bestätigt. + +Ein OneDrive-HTTP-409 wurde im zweiten Upload-Versuch überwunden. Der Code erlaubt +standardmäßig bis zu sechs Kopierversuche und veröffentlicht den Remote-Abschlussmarker +erst nach erfolgreichem Inhaltsvergleich. Wiederkehrende Konflikte oder endgültige +Fehler müssen untersucht werden; ein aktiver Timer allein belegt kein aktuelles Backup. +Details: [Fehlerbehandlung und Betriebsgrenzen](Dokumentation.md#upload-fehlerbehandlung-und-betriebsgrenzen). + +Der aktuelle Stand ergänzt sichtbaren Fortschritt und lehnt nicht unterstützte +exportierte `RCLONE_*`-Optionen ab, die etwa die Inhaltsprüfung oder Dateiauswahl +verändern könnten. Diese Ergänzungen sind erst nach Installation des aktuellen +Pakets auf dem Server wirksam; der erfolgreiche ältere Paketlauf installiert sie +nicht nachträglich. +Der Abschlussmarker wird außerdem bytegenau bestätigt, einschließlich seiner +Zeilenumbrüche, damit Backup und Restore dieselbe strikte Prüfung verwenden. + ## Automatisches Update-Paket (empfohlen) Das neue Paket übernimmt Konfigurationsabgleich, Rechteprüfung, Installation, diff --git a/UPDATE_ANLEITUNG.md b/UPDATE_ANLEITUNG.md index c560bc1..c7d2058 100644 --- a/UPDATE_ANLEITUNG.md +++ b/UPDATE_ANLEITUNG.md @@ -17,6 +17,21 @@ Backups mit zeitweiliger Unterbrechung der konfigurierten Anwendungen. ## Was automatisch passiert +Nach dem erfolgreichen Gemini-Lauf wurden Fortschrittsmeldungen, eine Prüfung +exportierter rclone-Optionen und der bytegenaue Vergleich des Abschlussmarkers +ergänzt. Für diese Härtung das aktuelle Paket im +vereinbarten Wartungsfenster über denselben Installer installieren; die bestehenden +Zugangsdaten und der Zeitplan bleiben erhalten. Der Installer führt erneut ein +vollständiges Abnahmebackup durch. Die technische Abnahme des bisherigen Satzes +bleibt gültig; der einmalige, überwundene HTTP-409 allein erfordert keinen Neustart. + +Falls die neue Vorprüfung `Unsupported rclone environment override` meldet, +die genannte Einstellung in Shell oder systemd-Umgebung prüfen und entfernen +beziehungsweise in eine unterstützte Backup-Einstellung überführen. Keine +Optionswerte oder Zugangsdaten ins Protokoll kopieren. Globale rclone-Filter und +`RCLONE_SIZE_ONLY` können Sicherungsumfang beziehungsweise Inhaltsprüfung +abschwächen und sind im Backup-Prozess nicht unterstützt. + 1. Paketinhalt per SHA-256 prüfen und geschützt unter `/var/tmp` entpacken. 2. Bestehende Einstellungen nach `/var/backups/app-backup-upgrade-XXXXXXXX` sichern. 3. Aktuelle Pfade und Einstellungen übernehmen; sichere Betriebsoptionen ergänzen. @@ -418,8 +433,7 @@ Das ausführbare Paket ausschließlich über euren vertrauenswürdigen Kanal bez ## Verifikation der Lieferung -52 Testfälle erfolgreich: vollständiger Durchlauf mit 50 Tests, anschließend die zwei zusätzlichen Abbruchtests für Kopie und Upload. ShellCheck und Shell-Syntaxprüfung abschließend erfolgreich. -Für die danach ergänzten Upload-Statistiken bestanden drei gezielt wiederholte Tests: gedrosselter echter Crypt-Upload mit Remote-Restore, fehlgeschlagene Rückleseprüfung ohne Abschlussmarker und TERM während des Uploads. ShellCheck und Shell-Syntax bestanden erneut; kein erneuter vollständiger Durchlauf für diese Protokollergänzung. +59 Testfälle erfolgreich: 58 im vollständigen Durchlauf (524,6 Sekunden), dazu der neue Test für zusätzliche Marker-Zeilenumbrüche. Nach der Marker-Korrektur bestanden die vier betroffenen Markertests und der echte Crypt-Upload mit Remote-Restore nochmals gezielt. ShellCheck und Shell-Syntaxprüfung erfolgreich. Die Tests für eine über Umgebungsvariablen abgeschwächte Inhaltsprüfung und den nicht bytegenauen Marker-Vergleich reproduzierten die Fehler vor der Korrektur und bestanden danach. Die Tests enthalten echte MariaDB-Dumps/Imports und lokale rclone-Crypt-Roundtrips; systemd/occ und die systemd-run-Übergabe sind simuliert. Das Übergabemodell diff --git a/lib/common.sh b/lib/common.sh index 0c838fd..2912906 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -254,6 +254,20 @@ check_remote() { for key in ${!RCLONE_CONFIG_@}; do [[ $key == RCLONE_CONFIG_PASS ]] || die "Remote overrides through $key are unsupported; use the config file" done + # rclone reads global flags from exported RCLONE_* variables too. For example, + # RCLONE_SIZE_ONLY bypasses byte comparison even with check --download; filters + # can hide missing archives from both copy and check. Refuse such ambient flags + # before the first remote operation, rather than silently weakening verification. + # Allow only our explicit settings (passed as CLI flags) and config decryption. + while IFS= read -r key; do + [[ $key == RCLONE_* ]] || continue + case $key in + RCLONE_BIN|RCLONE_CONFIG|RCLONE_CONFIG_PASS|RCLONE_REMOTE_BASE|RCLONE_RETRIES|\ + RCLONE_LOW_LEVEL_RETRIES|RCLONE_RETRIES_SLEEP|RCLONE_TIMEOUT|RCLONE_CONTIMEOUT|\ + RCLONE_STATS_INTERVAL|RCLONE_TRANSFERS|RCLONE_CHECKERS|RCLONE_ONEDRIVE_CHUNK_SIZE|RCLONE_BWLIMIT) ;; + *) die "Unsupported rclone environment override: $key; use documented app-backup settings and the remote config" ;; + esac + done < <(compgen -e) # 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" --ask-password=false --config "$RCLONE_CONFIG" config show "$name" | helper crypt "$name" diff --git a/tests/test_backup.py b/tests/test_backup.py index 2cc9233..6e79537 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -1,5 +1,6 @@ """Integration tests use real tar/rsync/rclone crypt and a real isolated MariaDB. -Only systemd/occ are simulated: production services never receive test commands. +systemd/occ are simulated; error paths inject failures around real commands. +Production services never receive test commands. """ import gzip import hashlib @@ -971,6 +972,133 @@ if '--no-block' not in args: 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 remote_failure(self, mode, **env): + # Keep real crypt transfers. Inject errors at the command boundary, not + # a pretend OneDrive server: provider retry behaviour is checked separately. + wrapper = self.bin/'rclone' + wrapper.write_text('''#!/usr/bin/env python3 +import os, subprocess, sys +args=sys.argv[1:] +mode=os.environ.get('TEST_REMOTE_FAILURE','') +def rc(arguments, **kw): + return subprocess.run(['/usr/bin/rclone',*arguments], **kw) +if 'copyto' in args and mode=='marker-write': + sys.exit(44) +if 'cat' in args and mode=='marker-read': + print('incorrect completion marker') + sys.exit(0) +if 'cat' in args and mode=='marker-newline': + data=rc(args,capture_output=True,check=True).stdout + sys.stdout.buffer.write(data+b'\\n') + sys.exit(0) +result=rc(args) +if result.returncode: + sys.exit(result.returncode) +if 'copy' in args: + if mode=='upload': + # Models an unsuccessful command even though the remote received bytes. + sys.exit(44) + if mode=='corrupt': + config=args[args.index('--config')+1] + remote=args[args.index('copy')+2]+'/wordpress.tar.zst' + prefix=['--config',config] + data=bytearray(rc([*prefix,'cat',remote],capture_output=True,check=True).stdout) + data[len(data)//2]^=1 + # Same-size plaintext corruption, re-encrypted through the real crypt backend. + # The backup must read contents back, not accept a matching size alone. + rc([*prefix,'rcat',remote],input=bytes(data),check=True) +''') + wrapper.chmod(0o755) + return self.run_script('app-backup.sh',ok=False,TEST_REMOTE_FAILURE=mode,**env) + + def assert_remote_failure_retained(self, result, marker_present=False): + status=json.loads((self.base/'work/last-backup.json').read_text()) + self.assertNotEqual(status['exit_code'],0) + run=self.base/'work/archives'/status['run'] + HELPER.load_set(run) # Local hashes/manifest must still be valid. + self.assertFalse((run/'UPLOADED').exists()) + self.assertEqual(json.loads(self.state.read_text()),self.initial) + self.assertFalse(list(self.runtime.glob('app-backup-guard-*'))) + self.assertNotIn('-- Local retention:',result.stdout) + self.assertNotIn('-- Remote retention:',result.stdout) + marker=subprocess.run(['/usr/bin/rclone','--config',str(self.base/'rclone.conf'), + 'cat',f'crypt:sets/{run.name}/COMPLETE'],capture_output=True) + self.assertEqual(marker.returncode==0,marker_present) + return run + + def test_upload_failure_preserves_previous_success_and_retention_candidates(self): + self.crypt_config() + current=self.backup() + previous=(self.base/'work/last-successful-backup.json').read_bytes() + old=[] + for day in range(1,4): + name=f'appbackup_2020-01-0{day}_00-00-00_aaaaaaaa' + path=current.parent/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) + 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') + result=self.remote_failure('upload') + self.assert_remote_failure_retained(result) + self.assertEqual(result.returncode,44) + self.assertEqual((self.base/'work/last-successful-backup.json').read_bytes(),previous) + remote=subprocess.check_output(['/usr/bin/rclone','--config',str(self.base/'rclone.conf'), + 'lsf','crypt:sets','--dirs-only'],text=True) + for path in old: + self.assertTrue(path.is_dir()) + self.assertIn(path.name,remote) + + def test_same_size_remote_corruption_blocks_completion(self): + self.crypt_config() + result=self.remote_failure('corrupt') + self.assert_remote_failure_retained(result) + self.assertFalse((self.base/'work/last-successful-backup.json').exists()) + + def test_environment_cannot_weaken_remote_readback(self): + self.crypt_config() + result=self.remote_failure('corrupt',RCLONE_SIZE_ONLY='true') + self.assertIn('Unsupported rclone environment override: RCLONE_SIZE_ONLY',result.stdout) + self.assertNotIn('systemctl stop',self.events.read_text()) + + def test_unsupported_rclone_environment_rejected_before_remote_access(self): + self.crypt_config() + for key,value in [('RCLONE_EXCLUDE','*'),('RCLONE_DRY_RUN','true'), + ('RCLONE_IGNORE_ERRORS','true'),('RCLONE_NO_CHECK_CERTIFICATE','true')]: + with self.subTest(key=key): + self.events.write_text('') + result=self.run_script('app-backup.sh','--check',ok=False,**{key:value}) + self.assertIn('Unsupported rclone environment override: '+key,result.stdout) + self.assertNotIn('rclone',self.events.read_text()) + self.assertNotIn('systemctl stop',self.events.read_text()) + # Supported settings remain usable when an administrator exports them. + self.run_script('app-backup.sh','--check',RCLONE_RETRIES='2',RCLONE_STATS_INTERVAL='1m') + + def test_marker_write_failure_blocks_upload_success(self): + self.crypt_config() + result=self.remote_failure('marker-write') + self.assert_remote_failure_retained(result) + self.assertEqual(result.returncode,44) + + def test_marker_read_mismatch_blocks_upload_success(self): + self.crypt_config() + result=self.remote_failure('marker-read') + # Archives passed readback and COMPLETE was written, but its final + # confirmation failed. Local success and retention must remain blocked. + self.assert_remote_failure_retained(result,marker_present=True) + self.assertIn('Remote completion marker mismatch',result.stdout) + + def test_marker_extra_newline_blocks_upload_success(self): + self.crypt_config() + result=self.remote_failure('marker-newline') + self.assert_remote_failure_retained(result,marker_present=True) + self.assertIn('Remote completion marker mismatch',result.stdout) + 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') diff --git a/usr-local-sbin/app-backup.sh b/usr-local-sbin/app-backup.sh index ed5f274..8949341 100755 --- a/usr-local-sbin/app-backup.sh +++ b/usr-local-sbin/app-backup.sh @@ -104,8 +104,15 @@ if [[ $ENABLE_UPLOAD == true ]]; then rc check "$RUN_DIR" "$remote_run" --download --exclude /COMPLETE --exclude /UPLOADED echo '-- Remote readback passed; publishing completion marker' 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' + # Command substitution strips trailing newlines, which could accept a marker + # that the strict restore parser rejects. Compare the exact returned bytes. + rc cat "$remote_run/COMPLETE" > "$STAGING_DIR/remote-COMPLETE" + python3 - "$RUN_DIR/COMPLETE" "$STAGING_DIR/remote-COMPLETE" <<'PY' || die 'Remote completion marker mismatch' +from pathlib import Path +import sys +local, remote = map(Path, sys.argv[1:]) +sys.exit(0 if local.stat().st_size == remote.stat().st_size and local.read_bytes() == remote.read_bytes() else 1) +PY date -u -Is > "$RUN_DIR/UPLOADED" sync -f "$RUN_DIR" echo '-- Remote backup fully read back and verified'