Check database dump privileges before stopping applications

This commit is contained in:
admin committed 2026-09-10 14:52:04 +02:00
1 parent 898df2e926
commit 5c8a7a30be
6 files changed
+241 -7

No files matched your search

+20 -2
View File
@@ -138,7 +138,23 @@ Es wird je aktivierter Anwendung ein Dump benötigt, sofern `ENABLE_DB_DUMPS=tru
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). 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. 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. Für `--lock-all-tables` ist insbesondere globales
`RELOAD` erforderlich; Rechte nur auf `wordpress.*` reichen dafür nicht.
`--check` prüft Anmeldung, DB-Zugriff und dieses globale Recht über lesende
Privileg-Metadaten. Bei MariaDB wird auch `SELECT * FROM mysql.proc LIMIT 0`
geprüft: `--routines` benötigt diesen Lesezugriff. Die Abfrage liefert keine
Routineninhalte. Die Prüfung führt kein `FLUSH` aus und nimmt keine globale DB-Lesesperre.
Authentifizierungsdaten aus `SHOW GRANTS` werden weder ausgegeben noch gespeichert.
Direkte Grants und bei MariaDB aktivierte, auch verschachtelte Rollen werden
berücksichtigt; lediglich zugewiesene, aber nicht aktivierte Rollen reichen nicht.
Andere Rollenmodelle oder nicht auswertbare Rollennamen benötigen einen direkt
nachweisbaren Grant für das Backup-Konto. Die Prüfung belegt **nicht sämtliche
objekt- und versionsabhängigen Dump-Rechte**; der vollständige Probelauf bleibt
erforderlich. Zusätzlich zum administrativen Testkonto prüfen die Integrationstests
einen eingeschränkten Backup-Account mit gezielten Grants und MariaDB-Rollen.
Quelle: [MariaDB FLUSH / RELOAD](https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/flush-commands/flush).
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. 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.
@@ -315,10 +331,12 @@ 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: **37 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: **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.
Geprüfte Fälle umfassen: Geprüfte Fälle umfassen:
- 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. - Fehlender Mailstore ohne Live-Änderung, explizite Pfadkorrektur, Homes-only-Update samt tatsächlichem Maildatei-Restore und Ablehnung unvollständiger Home-Konfigurationen.
- Shell-Syntax und ShellCheck der Paketdateien. - Shell-Syntax und ShellCheck der Paketdateien.
- MyISAM-Dump und tatsächlicher SQL-Import samt Dateiwiederherstellung. - MyISAM-Dump und tatsächlicher SQL-Import samt Dateiwiederherstellung.
+7 -1
View File
@@ -93,7 +93,13 @@ sudo journalctl -u app-backup.service -n 100 --no-pager
sudo cat /var/backups/app-backup/last-backup.json sudo cat /var/backups/app-backup/last-backup.json
``` ```
`--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. `--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
Probelauf. Fehlt `RELOAD`, die tatsächlich verwendeten Backup-Konten ermitteln und
gezielt berechtigen; siehe [DB-Rechte korrigieren](UPDATE_ANLEITUNG.md#fehlendes-db-recht-reload).
In der Konfiguration insbesondere ergänzen/prüfen: In der Konfiguration insbesondere ergänzen/prüfen:
+85 -2
View File
@@ -21,7 +21,9 @@ Backups mit zeitweiliger Unterbrechung der konfigurierten Anwendungen.
2. Bestehende Einstellungen nach `/var/backups/app-backup-upgrade-XXXXXXXX` sichern. 2. Bestehende Einstellungen nach `/var/backups/app-backup-upgrade-XXXXXXXX` sichern.
3. Aktuelle Pfade und Einstellungen übernehmen; sichere Betriebsoptionen ergänzen. 3. Aktuelle Pfade und Einstellungen übernehmen; sichere Betriebsoptionen ergänzen.
4. Vorhandene Webdienste und direkt erkennbare Nextcloud-systemd-Writer zuordnen. 4. Vorhandene Webdienste und direkt erkennbare Nextcloud-systemd-Writer zuordnen.
5. Crypt-Konfiguration und DB-Dateien prüfen; vorhandene Schlüssel/Passwörter erhalten. 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. 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
@@ -191,6 +193,87 @@ startet den Timer nicht ungeprüft. Neu erzeugte root-eigene Kopien von rclone-D
sowie strengere Dateirechte bleiben erhalten. Bei unterbrochener Vorbereitung sowie strengere Dateirechte bleiben erhalten. Bei unterbrochener Vorbereitung
ohne Installationsbeginn ist kein Paket-Rollback erforderlich. ohne Installationsbeginn ist kein Paket-Rollback erforderlich.
### Fehlendes DB-Recht RELOAD
`mariadb-dump: ... FLUSH ... Access denied ... RELOAD ... (1227)` bedeutet:
Die Anmeldung funktioniert, aber dem tatsächlich verwendeten DB-Konto fehlt das
globale Sperrrecht für `--lock-all-tables`. Ein pauschaler Wechsel auf einen Dump
ohne Sperren oder ausschließlich `--single-transaction` ist bei gemischten
Storage Engines kein gleichwertiger Ersatz. Das neue Paket prüft `RELOAD` bereits
vor Installation und Wartungsmodus über lesende Privileg-Metadaten.
Die tatsächlich authentifizierten Konten und Serverversionen ermitteln. Zunächst
eine Root-Shell starten:
```bash
sudo bash
```
Erst in dieser Shell den folgenden Block ausführen. Er gibt keine Passwörter
oder Authentifizierungshashes aus und beendet anschließend die Root-Shell:
```bash
source /etc/app-backup/app-backup.conf
query='SELECT CURRENT_USER(), VERSION()'
echo WordPress
mariadb --defaults-extra-file="$WP_DB_CNF" -N -B -e "$query"
echo Nextcloud
mariadb --defaults-extra-file="$NC_DB_CNF" -N -B -e "$query"
echo Gitea
mariadb --defaults-extra-file="$GITEA_DB_CNF" -N -B -e "$query"
exit
```
Nur die Zeilen für tatsächlich konfigurierte Anwendungen ausführen. Die getrennte
Root-Shell vermeidet Probleme durch kopierte Einrückungen an Here-Doc-Abschlussmarken.
Mit einem DB-Administratorkonto nur die betroffenen, vorhandenen Backup-Konten
berechtigen. Beispiel für das **zuvor tatsächlich identifizierte** Konto
`backup@localhost` (nicht ungeprüft übernehmen):
```sql
GRANT RELOAD ON *.* TO 'backup'@'localhost';
```
`RELOAD` ist ein globales Verwaltungsrecht zum Flush/Reload; deshalb wird es nicht
automatisch durch den Paketinstaller vergeben. Keine pauschalen `ALL PRIVILEGES`
oder `GRANT OPTION` erteilen. Wenn die CNF ein produktives Anwendungskonto verwendet,
ein separates Backup-Konto mit gezielten Rechten vorbereiten. Ein zusätzliches
`FLUSH PRIVILEGES` ist nach `GRANT` nicht notwendig. Quelle:
[MariaDB GRANT](https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/grant).
Für MariaDB prüft das Paket außerdem den Lesezugriff auf `mysql.proc`, den die
verwendete Option `--routines` benötigt. Falls dieser fehlt, gezielt für das
identifizierte Backup-Konto vergeben:
```sql
GRANT SELECT ON mysql.proc TO 'backup'@'localhost';
```
Auch dies ist ein Beispiel mit zu ersetzendem Kontonamen. Es erlaubt den Zugriff
auf Routinen-Metadaten datenbankübergreifend; kein `SELECT ON mysql.*` vergeben.
Quelle: [MariaDB mariadb-dump / routines](https://mariadb.com/docs/server/clients-and-utilities/backup-restore-and-import-clients/mariadb-dump).
Weitere erforderliche Rechte hängen von Views, Triggern und Events ab;
`RELOAD` allein ersetzt keine Leserechte auf den zu sichernden Datenbanken.
Die neue Prüfung deckt die globale Sperrberechtigung und MariaDB-Routinen-Metadaten
ab, nicht sämtliche möglichen Objektrechte. Der vollständige Abnahmelauf bleibt verpflichtend.
Beim gemeldeten Lauf vom 10.09.2026 um 14:41 Uhr war das Paket bereits installiert,
als der erste Dump scheiterte. Nextcloud verließ laut Log den Wartungsmodus und
Cron wurde wieder gestartet. Das ist **kein erfolgreiches Backup**; der Timer
bleibt bis zur Abnahme gestoppt. Nach Rechtekorrektur das aktuelle Paket erneut
starten: Es prüft, sichert und aktiviert den Timer erst nach erfolgreicher
Remote-Prüfung. Ein erneuter Paketlauf erhält die ursprüngliche Sicherung des
vorigen Update-Versuchs. Für einen Rückweg zum Stand vor dem ersten Installationslauf
ist dessen Rollback-Ordner maßgeblich; ein späterer Snapshot enthält bereits die
zuvor installierte Version.
Dienst-Stopps/-Starts und das Warten auf Nextcloud-Writer werden jetzt ausdrücklich
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.
## Paket reproduzieren ## Paket reproduzieren
Aus einem sauberen, eingecheckten Repository: Aus einem sauberen, eingecheckten Repository:
@@ -207,7 +290,7 @@ Das ausführbare Paket ausschließlich über euren vertrauenswürdigen Kanal bez
## Verifikation der Lieferung ## Verifikation der Lieferung
37 Container-Tests sowie ShellCheck und Shell-Syntaxprüfung erfolgreich. 41 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
+52
View File
@@ -170,6 +170,56 @@ def crypt(name):
print("Crypt configuration accepted (contents, filenames, directories)") print("Crypt configuration accepted (contents, filenames, directories)")
def has_reload(grants):
"""Inspect privilege names only; SHOW GRANTS can contain authentication hashes."""
for line in grants.splitlines():
match = re.match(r'^GRANT ([A-Z_ ,]+) ON \*\.\* TO ', line)
if match and {'RELOAD', 'ALL PRIVILEGES'} & set(match[1].split(', ')):
return True
return False
def db_privileges(client, credentials, database):
"""Read privileges without executing FLUSH or acquiring a production DB lock.
Check lock-all-tables and MariaDB's routine metadata prerequisite, not every
object-specific permission. Grants (including auth hashes) stay in memory.
"""
def query(sql, purpose='privilege metadata'):
result = subprocess.run([client, '--defaults-extra-file=' + credentials,
'--database=' + database, '--batch', '--skip-column-names',
'--execute=' + sql], capture_output=True, text=True)
if result.returncode:
# Neither query results nor server errors may disclose auth material.
raise ValueError(f'DB {database}: {purpose} query failed; inspect account permissions')
return result.stdout
grants = query('SHOW GRANTS FOR CURRENT_USER')
allowed = has_reload(grants)
via_role = False
mariadb = 'MariaDB' in query('SELECT VERSION()')
if not allowed and mariadb:
# Only session-enabled roles count; a merely granted but inactive role
# does not help mariadb-dump. ENABLED_ROLES also includes nested roles.
roles = query('SELECT ROLE_NAME FROM information_schema.ENABLED_ROLES WHERE ROLE_NAME IS NOT NULL').splitlines()
for role in roles:
if not re.fullmatch(r'[A-Za-z0-9_.:-]+', role):
raise ValueError(f'DB {database}: unsupported role name; configure direct RELOAD grant for backup account')
if has_reload(query('SHOW GRANTS FOR `' + role + '`')):
allowed = via_role = True
break
if not allowed:
raise ValueError(f'DB {database}: global RELOAD privilege missing or not verifiable; '
'required for --lock-all-tables. Grant RELOAD to the actual backup account; '
'see UPDATE_ANLEITUNG.md. No FLUSH or database lock performed by this check')
print(f'DB {database}: global RELOAD privilege confirmed' + (' through an enabled role' if via_role else ''))
if mariadb:
# --routines needs mysql.proc access. LIMIT 0 checks permissions without
# returning routine bodies or reading the stored routine contents.
query('SELECT * FROM mysql.proc LIMIT 0', 'routine metadata (SELECT on mysql.proc required for --routines)')
print(f'DB {database}: MariaDB routine metadata access confirmed')
def candidates(prefix, days, keep): def candidates(prefix, days, keep):
# Only v2 names supplied AFTER completion-marker validation are eligible. # Only v2 names supplied AFTER completion-marker validation are eligible.
names = set(sys.stdin.read().splitlines()) names = set(sys.stdin.read().splitlines())
@@ -233,6 +283,8 @@ def main():
op, *args = sys.argv[1:] op, *args = sys.argv[1:]
if op == "wait-nextcloud": if op == "wait-nextcloud":
wait_nextcloud(*args) wait_nextcloud(*args)
elif op == "db-privileges":
db_privileges(*args)
elif op == "status": elif op == "status":
write_status(*args) write_status(*args)
elif op == "crypt": elif op == "crypt":
+15 -2
View File
@@ -197,6 +197,11 @@ preflight() {
need "$DB_DUMP_BIN"; need "$DB_CLIENT_BIN" need "$DB_DUMP_BIN"; need "$DB_CLIENT_BIN"
for i in "${!DB_LABELS[@]}"; do for i in "${!DB_LABELS[@]}"; do
"$DB_CLIENT_BIN" --defaults-extra-file="${DB_CNFS[i]}" --database="${DB_NAMES[i]}" --execute='SELECT 1' >/dev/null "$DB_CLIENT_BIN" --defaults-extra-file="${DB_CNFS[i]}" --database="${DB_NAMES[i]}" --execute='SELECT 1' >/dev/null
# Restore uses different privileges; this prerequisite belongs to dumps.
# Metadata only: never briefly lock the live database during --check.
if [[ $OPERATION == backup ]]; then
helper db-privileges "$DB_CLIENT_BIN" "${DB_CNFS[i]}" "${DB_NAMES[i]}"
fi
done done
fi fi
if [[ $ENABLE_NEXTCLOUD == true ]]; then need sudo; need php; [[ -f $NC_DIR/occ ]] || die "Nextcloud occ missing"; fi if [[ $ENABLE_NEXTCLOUD == true ]]; then need sudo; need php; [[ -f $NC_DIR/occ ]] || die "Nextcloud occ missing"; fi
@@ -262,7 +267,9 @@ stop_service() {
# Record intent before stop: a failed/partial stop still needs recovery. # Record intent before stop: a failed/partial stop still needs recovery.
STOPPED_SERVICES+=("$1") STOPPED_SERVICES+=("$1")
save_recovery save_recovery
echo "-- Stopping service: $1 (waiting for systemd)"
systemctl stop "$1" systemctl stop "$1"
echo "-- Service stopped: $1"
;; ;;
inactive|failed) ;; inactive|failed) ;;
*) die "Unknown/transitioning service state for $1: $state" ;; *) die "Unknown/transitioning service state for $1: $state" ;;
@@ -278,7 +285,7 @@ quiesce() {
if [[ $NC_WAS_ON == false ]]; then NC_CHANGED=true; save_recovery; occ maintenance:mode --on; fi 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 else echo "WARNING: Nextcloud maintenance disabled; consistency depends on external writer shutdown"; fi
fi fi
# Include application cron/worker services in this array; crond itself is not stopped. # Cron daemons are included only if explicitly configured by the operator.
for p in "${QUIESCE_SERVICES[@]}"; do stop_service "$p"; done for p in "${QUIESCE_SERVICES[@]}"; do stop_service "$p"; done
if [[ $ENABLE_WORDPRESS == true || $ENABLE_NEXTCLOUD == true ]]; then if [[ $ENABLE_WORDPRESS == true || $ENABLE_NEXTCLOUD == true ]]; then
if [[ $ENABLE_HTTPD_STOP == true ]]; then stop_service "$HTTPD_SERVICE_NAME"; fi if [[ $ENABLE_HTTPD_STOP == true ]]; then stop_service "$HTTPD_SERVICE_NAME"; fi
@@ -294,7 +301,9 @@ quiesce() {
if [[ $ENABLE_NEXTCLOUD == true ]]; then if [[ $ENABLE_NEXTCLOUD == true ]]; then
# Cron daemons may leave an already launched PHP child running after stop. # Cron daemons may leave an already launched PHP child running after stop.
# Wait for direct cron.php / occ processes to finish; never kill them blindly. # Wait for direct cron.php / occ processes to finish; never kill them blindly.
echo '-- Waiting for remaining Nextcloud writers'
helper wait-nextcloud "$NC_DIR" "${WRITER_DRAIN_TIMEOUT:=300}" helper wait-nextcloud "$NC_DIR" "${WRITER_DRAIN_TIMEOUT:=300}"
echo '-- Nextcloud writers finished'
fi fi
} }
# Survives SIGKILL/power loss, which Bash traps cannot handle. This is an # Survives SIGKILL/power loss, which Bash traps cannot handle. This is an
@@ -317,7 +326,11 @@ resume_services() {
fi fi
for ((i=${#STOPPED_SERVICES[@]}-1; i>=0; i--)); do for ((i=${#STOPPED_SERVICES[@]}-1; i>=0; i--)); do
[[ -n ${STOPPED_SERVICES[i]} ]] || continue [[ -n ${STOPPED_SERVICES[i]} ]] || continue
if systemctl start "${STOPPED_SERVICES[i]}"; then STOPPED_SERVICES[i]=''; else failed=1; fi echo "-- Starting service: ${STOPPED_SERVICES[i]} (waiting for systemd)"
if systemctl start "${STOPPED_SERVICES[i]}"; then
echo "-- Service started: ${STOPPED_SERVICES[i]}"
STOPPED_SERVICES[i]=''
else failed=1; fi
done done
if (( failed == 0 )); then if (( failed == 0 )); then
rm -f -- "$WORKDIR/recovery-$RUN_ID.json" rm -f -- "$WORKDIR/recovery-$RUN_ID.json"
+62
View File
@@ -22,6 +22,12 @@ SPEC.loader.exec_module(HELPER)
class ArchiveValidation(unittest.TestCase): class ArchiveValidation(unittest.TestCase):
def test_reload_parser_requires_global_privilege(self):
self.assertTrue(HELPER.has_reload("GRANT SELECT, RELOAD ON *.* TO `backup`@`localhost` IDENTIFIED BY PASSWORD 'secret-hash'"))
self.assertTrue(HELPER.has_reload('GRANT ALL PRIVILEGES ON *.* TO `root`@`localhost`'))
self.assertFalse(HELPER.has_reload('GRANT ALL PRIVILEGES ON `wordpress`.* TO `backup`@`localhost`'))
self.assertFalse(HELPER.has_reload('GRANT `RELOAD` TO `backup`@`localhost`'))
def archive(self, entries): def archive(self, entries):
temp = tempfile.TemporaryDirectory() temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup) self.addCleanup(temp.cleanup)
@@ -440,6 +446,62 @@ QUIESCE_SERVICES=(nc-cron)
self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'active') self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'active')
self.assertNotIn('systemctl stop',self.events.read_text()) self.assertNotIn('systemctl stop',self.events.read_text())
def restricted_backup_account(self):
self.sql("CREATE USER 'app_backup_limited'@'localhost'; "
"GRANT SELECT, SHOW VIEW, EVENT, TRIGGER ON backup_test.* TO 'app_backup_limited'@'localhost'; "
"GRANT SELECT ON mysql.proc TO 'app_backup_limited'@'localhost';")
self.addCleanup(self.sql, "DROP USER 'app_backup_limited'@'localhost'")
self.cnf.write_text(f'[client]\nuser=app_backup_limited\nsocket={self.socket}\n')
def test_missing_reload_blocks_upgrade_then_targeted_grant_allows_backup(self):
canonical=self.configure_upgrade()
self.restricted_backup_account()
original=canonical.read_bytes()
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('global RELOAD privilege missing',result.stdout+result.stderr)
self.assertEqual(canonical.read_bytes(),original)
self.assertEqual(self.cnf.stat().st_mode & 0o777,0o640)
self.assertNotIn('systemctl stop',self.events.read_text())
self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'active')
self.sql("GRANT RELOAD ON *.* TO 'app_backup_limited'@'localhost'")
result=subprocess.run(['bash',str(package),'--foreground'],env=self.env,capture_output=True,text=True,timeout=120)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0])
self.assertTrue((state/'result.txt').read_text().startswith('SUCCESS'))
def test_reload_from_enabled_nested_role_is_accepted_but_inactive_role_is_not(self):
self.restricted_backup_account()
self.sql("CREATE ROLE backup_reload_child; CREATE ROLE backup_reload_parent; "
"GRANT RELOAD ON *.* TO backup_reload_child; "
"GRANT backup_reload_child TO backup_reload_parent; "
"GRANT backup_reload_parent TO 'app_backup_limited'@'localhost';")
self.addCleanup(self.sql, 'DROP ROLE backup_reload_parent; DROP ROLE backup_reload_child')
failed=self.run_script('app-backup.sh','--check',ok=False)
self.assertIn('global RELOAD privilege missing',failed.stdout)
self.sql("SET DEFAULT ROLE backup_reload_parent FOR 'app_backup_limited'@'localhost'")
passed=self.run_script('app-backup.sh','--check')
self.assertIn('confirmed through an enabled role',passed.stdout)
self.assertNotIn('systemctl stop',self.events.read_text())
self.backup()
def test_missing_routine_metadata_access_blocks_before_maintenance(self):
self.restricted_backup_account()
self.sql("GRANT RELOAD ON *.* TO 'app_backup_limited'@'localhost'; "
"REVOKE SELECT ON mysql.proc FROM 'app_backup_limited'@'localhost';")
failed=self.run_script('app-backup.sh','--check',ok=False)
self.assertIn('SELECT on mysql.proc required',failed.stdout)
self.assertNotIn('systemctl stop',self.events.read_text())
self.assertEqual(json.loads(self.state.read_text()),self.initial)
self.sql("GRANT SELECT ON mysql.proc TO 'app_backup_limited'@'localhost'; "
"CREATE PROCEDURE backup_test.backup_probe() SELECT 1;")
run=self.backup()
with subprocess.Popen(['zstd','-dc',str(run/'db-wordpress.tar.zst')],stdout=subprocess.PIPE) as proc:
with tarfile.open(fileobj=proc.stdout,mode='r|') as archive:
routine_dump=archive.extractfile(archive.next()).read()
self.assertIn(b'PROCEDURE `backup_probe`',routine_dump)
def test_missing_mail_store_requires_explicit_resolution(self): def test_missing_mail_store_requires_explicit_resolution(self):
canonical=self.configure_upgrade() canonical=self.configure_upgrade()
with canonical.open('a') as f: f.write(f'MAIL_DIR="{self.src}/missing-mail"\n') with canonical.open('a') as f: f.write(f'MAIL_DIR="{self.src}/missing-mail"\n')