From 47ccf90b56e5dda048bf84d46d357b129770934c Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 23 Oct 2026 14:05:18 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20t=C3=A4gliche=20verschl=C3=BCsselte=20S?= =?UTF-8?q?icherung,=20Kuma-Kennzahlen,=20CI=20in=20Gitea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sicherung von Datenbank und Schlüsseln auf eine Anbindung (scrypt+Fernet), Seite „Sicherung“, /healthz/backup, netmover backup/restore - metrics in /healthz und /healthz/rule/ für Kuma-JSON-Abfragen - .gitea/workflows/ci.yml; jede Migration auf befüllter DB getestet - HTTP im internen Netz als bewusste Entscheidung dokumentiert Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/ci.yml | 37 ++ CHANGELOG.md | 33 +- DEVLOG.md | 31 +- README.md | 26 +- ROADMAP.md | 17 +- compose.yml | 2 +- deploy/install.sh | 11 +- docs/ANLEITUNG.md | 78 +++- src/netmover/__init__.py | 2 +- src/netmover/app_state.py | 48 ++- src/netmover/backup.py | 514 +++++++++++++++++++++++++ src/netmover/cli.py | 119 +++++- src/netmover/config.py | 2 + src/netmover/health.py | 107 ++++- src/netmover/kuma.py | 24 +- src/netmover/mover/destinations.py | 17 + src/netmover/mover/runner.py | 8 +- src/netmover/scheduler.py | 19 +- src/netmover/settings_store.py | 7 + src/netmover/web/app.py | 129 ++++++- src/netmover/web/templates/backup.html | 55 +++ src/netmover/web/templates/base.html | 1 + src/netmover/web/templates/icons.html | 1 + tests/test_backup.py | 307 +++++++++++++++ tests/test_migrations.py | 44 +++ tests/test_monitoring.py | 44 +++ 26 files changed, 1644 insertions(+), 39 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 src/netmover/backup.py create mode 100644 src/netmover/web/templates/backup.html create mode 100644 tests/test_backup.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..ec4585f --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,37 @@ +# Prüfungen bei jedem Push und Pull Request (Gitea Actions, act_runner). +# Gleiche Befehle wie lokal vor einem Release – siehe README, „Entwicklung“. +name: CI + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + +jobs: + pruefen: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 3.12 = Mindestversion, 3.13 = Debian 13 (Zielsystem) + python: ["3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Abhängigkeiten + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: ruff + run: | + ruff check . + ruff format --check . + - name: mypy + run: mypy src + - name: shellcheck + run: shellcheck deploy/*.sh + - name: pytest (inkl. Migrationen auf befüllter Datenbank) + run: pytest -q diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b3560..b54335c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,36 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`. ## [Unreleased] +## [0.10.0] – 2026-10-23 + +### Hinzugefügt +- **Tägliche Datensicherung** (*System → Sicherung*): Datenbank, `secret.key`, + `session.key` und `netmover.env` als mit Passphrase verschlüsselte Datei + (scrypt + Fernet) auf eine Anbindung (SMB, NFS oder SFTP). Momentaufnahme über die + SQLite-Backup-API mit Integritätsprüfung; die Datei wird vor dem Hochladen + probeweise geöffnet und atomar geschrieben; ältere NetMover-Sicherungen über die + eingestellte Anzahl hinaus werden gelöscht, andere Dateien nie. Verpasste Termine + werden nachgeholt, nach einem Fehler stündlich neuer Versuch, erster Fehlschlag + per E-Mail/Webhook. Knopf „Jetzt sichern“. +- `netmover restore `: spielt eine Sicherung ein (nur bei gestopptem Dienst), + legt die bisherigen Dateien vorher nach `vor-wiederherstellung-/` und + migriert die Datenbank. `netmover backup ` sichert von Hand in eine Datei. +- Befehl `netmover` im Pfad (`/usr/local/sbin/netmover`, lädt `netmover.env`). +- **Kennzahlen für Uptime Kuma**: `/healthz` und `/healthz/rule/` liefern unter + `metrics` Dateien, Fehler, Quarantäne und abgebrochene Läufe der letzten 24 Stunden + sowie die letzte verschobene Datei – für Kuma-Monitore vom Typ „JSON-Abfrage“. + Der Push meldet z. B. „OK · 24 h: 12 Dateien“. +- `/healthz/backup`: eigener Monitor; eine seit zwei Tagen überfällige Sicherung gilt + als Problem (Statusampel, `/healthz`, Push). +- **CI in Gitea** (`.gitea/workflows/ci.yml`): ruff, mypy, shellcheck und Tests auf + Python 3.12 und 3.13 bei jedem Push. +- Test-Pflicht für Migrationen: jede Migration wird automatisch auf einer befüllten + Datenbank hoch- und heruntergestuft. + +### Geändert +- README: HTTP im internen Netz als bewusste Entscheidung dokumentiert; TLS über + Reverse Proxy bleibt der Weg für nicht vertrauenswürdige Netze. + ## [0.9.0] – 2026-10-15 ### Hinzugefügt @@ -277,7 +307,8 @@ Erste Version. - Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt. -[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.9.0...HEAD +[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.10.0...HEAD +[0.10.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.9.0...v0.10.0 [0.9.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.8.0...v0.9.0 [0.8.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.7.0...v0.8.0 [0.7.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.6.0...v0.7.0 diff --git a/DEVLOG.md b/DEVLOG.md index 943ba3f..7178cde 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -23,10 +23,39 @@ Zeiten in Ortszeit (Europe/Berlin). | 2026-10-12 | 00:14 | 00:34 | 0:20 | – | Archiv je Regel (0.7.0) | | 2026-10-12 | 00:46 | 00:55 | 0:09 | – | Anbindungen kopieren (0.8.0) | | 2026-10-15 | 09:17 | 09:26 | 0:09 | – | Regeln kopieren (0.9.0) | -| **Summe** | | | **4:07** | | | +| 2026-10-23 | 13:42 | 14:10 | 0:28 | – | Sicherung, Kuma-Kennzahlen, CI (0.10.0) | +| **Summe** | | | **4:35** | | | --- +## 2026-10-23 · 13:42–14:10 · Sicherung, Kuma-Kennzahlen, CI (0.10.0) + +**Bestandsaufnahme Live-Server** – 0.9.0 seit 15.10. ohne Neustart, 287 Dateien +verschoben, keine Datei gescheitert oder in Quarantäne, Archivkopien vollständig +(159 + 122). 18 kurze Pausen, alle wegen SSH zu den Paperless-Servern +(„No existing session“, „protocol banner“, Timeout) – automatisches Pausieren und +Fortsetzen hat jedes Mal gegriffen. Ursache liegt vermutlich auf den Paperless-Hosts +→ Roadmap 0.11.0. + +**Erledigt** – `backup.py` (Momentaufnahme per SQLite-Backup-API, tar.gz, +scrypt+Fernet, Probe-Öffnen vor dem Upload, Upload über `Destination.commit`, +Ausdünnen), Seite „Sicherung“, `/healthz/backup`, `netmover backup`/`restore`, +Wrapper `/usr/local/sbin/netmover`; Kennzahlen unter `metrics` in `/healthz`; +Gitea-CI; generischer Migrationstest. 381 Tests. Gegenproben: Ausdünnen ohne +Namensfilter und Sicherung ohne Verschlüsselung schlagen jeweils an (2 Tests). + +**Entscheidungen** +- *Passphrase verschlüsselt in der DB*: sonst bräuchte die tägliche Sicherung eine + Eingabe. Auf dem NAS schützt die Passphrase; auf dem Server ist ohnehin alles da. +- *Termin per „fällig?“-Prüfung alle 5 min* statt Cron-Job: holt verpasste Termine + nach, braucht kein Umplanen bei Änderungen und regelt den stündlichen Neuversuch. +- *Ziel über dieselbe Fabrik wie Regelziele* (`destination_factory`): gleiche + Mount-Prüfung, `.part`-Logik und SFTP-Unterstützung, kein zweiter Schreibweg. +- *Ausdünnen nur nach Namensschema* – fremde Dateien im Ordner bleiben unangetastet. +- *Kein TLS* im internen Netz (Nutzerentscheidung), im README begründet. +- *Kuma*: Endpunkte gab es seit 0.3.0; neu sind auslesbare Kennzahlen für + „JSON-Abfrage“-Monitore, z. B. Alarm bei `metrics.quarantined_24h != 0`. + ## 2026-10-15 · 09:17–09:26 · Regeln kopieren (0.9.0) **Erledigt** – Knopf „Kopieren“, Route `/rules/{id}/copy` (GET Formular, POST diff --git a/README.md b/README.md index 4a1bd45..3a388d5 100644 --- a/README.md +++ b/README.md @@ -388,12 +388,20 @@ findmnt -R /mnt/netmover # was ist wirklich eingehängt? ## Sicherheit - **Nicht ins Internet stellen** (siehe [Reverse Proxy](#reverse-proxy-mit-tls)). +- **HTTP im internen Netz ist eine bewusste Entscheidung**, wenn das Netz + vertrauenswürdig ist (Heimnetz, eigenes VLAN): Die Oberfläche läuft dann + unverschlüsselt auf Port 8080, die Anmeldung kann per Netz abgeschaltet werden + (*Einstellungen → Anmeldung*). Wer im selben Netz mitlesen kann, könnte Passwörter + sehen, die man in Formulare eingibt. Für fremde oder gemischte Netze den + [Reverse Proxy mit TLS](#reverse-proxy-mit-tls) davorsetzen. - Zugangsdaten liegen nur verschlüsselt in der Datenbank (Fernet, Schlüssel `secret.key`, 0600). Die Oberfläche zeigt sie nie an; in der Prozessliste stehen sie ebenfalls nie, weil `mount.cifs` eine kurzlebige Credentials-Datei bekommt. Ausgaben von `mount` werden geschwärzt, bevor sie protokolliert werden. - **Sichere `secret.key` zusammen mit der Datenbank.** Ohne den Schlüssel sind die - gespeicherten Passwörter verloren. + gespeicherten Passwörter verloren. Das erledigt die tägliche, mit Passphrase + verschlüsselte [Sicherung](docs/ANLEITUNG.md#sicherung) (*System → Sicherung*); + Wiederherstellen mit `netmover restore `. - Anmeldung: - Ein Administratorkonto mit Argon2-Passworthash. - Sperre nach 5 Fehlversuchen. @@ -430,7 +438,8 @@ Aufbau: | `mover/` | Scan, Stabilität, atomares Schreiben, Kollisionen, Buchführung, Regellauf | | `runs.py`, `scheduler.py`, `health.py`, `notify.py` | Protokoll, Zeitsteuerung, Gesundheit, Webhook | | `admin.py`, `keys.py`, `transfer.py`, `web/` | Formulare, Schlüsselverwaltung, Export/Import, Oberfläche | -| `health.py`, `kuma.py` | Gesundheitsprüfung, Uptime-Kuma-Push | +| `health.py`, `kuma.py` | Gesundheitsprüfung, Kennzahlen, Uptime-Kuma-Push | +| `backup.py` | tägliche verschlüsselte Sicherung, `netmover backup`/`restore` | | `docs/` | Anleitung und Bilder; wird als Hilfe-Seite mit ausgeliefert | Die gesamte Mover-Logik ist mit dem `FakeMountBackend` ohne root testbar. SFTP @@ -443,6 +452,17 @@ NETMOVER_DATA_DIR=$PWD/data .venv/bin/alembic revision --autogenerate -m "…" ``` Veröffentlichte Migrationen (ab v0.1.0) werden nie mehr geändert, nur ergänzt. +`tests/test_migrations.py` stuft **jede** Migration automatisch auf einer +Datenbank mit verknüpften Daten hoch und wieder herunter – eine neue Migration ist +damit ohne weiteres Zutun abgedeckt; ergänzt sie Spalten mit Daten, gehört ein +eigener Test dazu. + +### CI + +`.gitea/workflows/ci.yml` führt bei jedem Push und Pull Request ruff, mypy, +shellcheck und alle Tests aus (Python 3.12 und 3.13). Voraussetzung: Actions sind +im Repository aktiviert und ein `act_runner` mit dem Label `ubuntu-latest` ist +registriert. ### Arbeitsweise @@ -450,7 +470,7 @@ Veröffentlichte Migrationen (ab v0.1.0) werden nie mehr geändert, nur ergänzt (`feat:`, `fix:`, `docs:`, `test:`, `chore:` …). - Jede nutzerrelevante Änderung kommt unter `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md). Bei einem Release: - 1. Den Abschnitt `[Unreleased]` mit Versionsnummer versehen. + 1. CI grün? Dann den Abschnitt `[Unreleased]` mit Versionsnummer versehen. 2. Die Version in `src/netmover/__init__.py` anheben. 3. Einen Tag `vX.Y.Z` setzen. - Arbeitszeit und Entscheidungen stehen in [DEVLOG.md](DEVLOG.md). diff --git a/ROADMAP.md b/ROADMAP.md index d1df648..99d98aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,10 +10,19 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht | Status | Thema | Warum | |---|---|---| -| 📋 | **CI in Gitea** (Actions): Tests, ruff, mypy, shellcheck bei jedem Push | Fehler fallen vor dem Update auf, nicht auf dem Server | -| 📋 | **Migrationstest-Pflicht**: jede neue Migration mit Test auf befüllter Datenbank | Lehre aus dem gescheiterten Update auf 0.2.0 | -| 📋 | **Reverse Proxy mit TLS** auf der Ziel-VM (Caddy), danach Bindung an `127.0.0.1` | Anmeldung nicht unverschlüsselt im LAN | +| 📋 | **SSH-Ausfälle bei Paperless klären**, Anbindungen auf SSH-Schlüssel umstellen | 18 kurze Ausfälle seit 11.10. („No existing session“, „protocol banner“) | | 📋 | **Statusseite schneller bei hängenden Mounts**: Füllstand zwischenspeichern | Heute bis zu 2 s Wartezeit, wenn ein NFS-Server weg ist | +| 📋 | **Benachrichtigung bei Quarantäne** einer Datei | Unbeaufsichtigter Betrieb; heute über Kuma (`metrics.quarantined_24h`) sichtbar | +| 📋 | **Kompatibilitätszusage für 1.0**: Einstellungen, Export-Format, Update aus jeder 0.x | Danach keine brechenden Änderungen ohne 2.0 | + +### Weg zu 1.0 + +0.10.0 Sicherung + CI → 0.11.0 restliche Punkte oben → ein bis zwei Wochen ruhiger +Betrieb → 1.0.0 ohne neue Funktionen. + +**Entschieden:** Kein TLS im internen Netz – HTTP auf Port 8080 ist eine bewusste +Entscheidung (README, „Sicherheit“). Der Reverse Proxy bleibt dokumentiert für +nicht vertrauenswürdige Netze. ## Ideen @@ -22,12 +31,12 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht | 💡 | **Eine Regel, mehrere Ziele** je Quell-Unterordner | Heute: eine Regel pro Unterordner | | 💡 | Metriken für Prometheus (`/metrics`) | Ergänzt `/healthz`, z. B. Dateien pro Tag | | 💡 | Deinstallationsskript | Gegenstück zu `install.sh` | -| 💡 | Benachrichtigung bei Quarantäne einer Datei | Heute nur im Protokoll sichtbar | ## Erledigt | Version | Thema | |---|---| +| 0.10.0 | Tägliche verschlüsselte Sicherung + `netmover restore`; Kennzahlen für Kuma; CI in Gitea; Migrationstest-Pflicht | | 0.9.0 | Regeln kopieren (Kopie zunächst deaktiviert) | | 0.8.0 | Anbindungen kopieren inklusive Zugangsdaten | | 0.7.0 | Archiv je Regel: Kopie ins Ziel und ins Archiv, Quelle erst danach löschen | diff --git a/compose.yml b/compose.yml index 0d4f8c5..42686c1 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: netmover: build: . - image: netmover:0.9.0 + image: netmover:0.10.0 container_name: netmover restart: unless-stopped cap_add: diff --git a/deploy/install.sh b/deploy/install.sh index 3984704..3339b6f 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -33,8 +33,17 @@ if [ ! -f /etc/netmover/netmover.env ]; then fi install -d -m 0755 /mnt/netmover -echo "==> Update-Skript" +echo "==> Update-Skript und Kommandozeile" install -m 0755 "$(dirname "$0")/update.sh" /usr/local/sbin/netmover-update +# netmover backup/restore/… mit derselben Konfiguration wie der Dienst +cat > /usr/local/sbin/netmover < systemd" install -m 0644 "$(dirname "$0")/netmover.service" /etc/systemd/system/netmover.service diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index a093200..41d7409 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -10,7 +10,8 @@ stehen im [README](../README.md). **Inhalt:** [Begriffe](#begriffe) · [Erste Anmeldung](#erste-anmeldung) · [Zugangsdaten](#zugangsdaten) · [Anbindungen](#anbindungen) · [Regeln](#regeln) · [Archiv](#archiv) · [Probelauf](#probelauf) · [Status](#status) · [Wartungsmodus](#wartungsmodus) · [Protokoll](#protokoll) · -[Einstellungen](#einstellungen) · [Anmeldung](#anmeldung) · [Uptime Kuma](#uptime-kuma) · [Beispiele](#beispiele) · [Häufige Fragen](#fragen) +[Einstellungen](#einstellungen) · [Anmeldung](#anmeldung) · [Uptime Kuma](#uptime-kuma) · +[Sicherung](#sicherung) · [Beispiele](#beispiele) · [Häufige Fragen](#fragen) ## Die vier Begriffe {#begriffe} @@ -384,7 +385,36 @@ HTTP-Monitore*. Mit einem Monitor pro Anbindung und Regel sieht man in Kuma sofo Als Problem gilt: Scheduler steht, eine aktive Anbindung ist im Fehlerzustand, eine aktive Regel hatte länger als das Dreifache ihres Intervalls keinen Erfolg oder ist -dreimal hintereinander gescheitert. +dreimal hintereinander gescheitert, oder die [Sicherung](#sicherung) ist überfällig. + +**3. Werte auslesen (JSON-Abfrage)** – `/healthz` und `/healthz/rule/` +liefern unter `metrics` Kennzahlen der letzten 24 Stunden (Probeläufe zählen nicht): + +| Feld | Bedeutung | +|---|---| +| `files_24h` | verschobene bzw. kopierte Dateien | +| `failed_24h` | Dateien, die gescheitert sind (werden erneut versucht) | +| `quarantined_24h` | Dateien, die in Quarantäne verschoben wurden | +| `errors_24h` | abgebrochene Läufe (z. B. Anbindung nicht erreichbar) | +| `pending_failures` | Dateien, die gerade auf einen neuen Versuch warten | +| `last_moved_at` | Zeitpunkt der letzten verschobenen Datei (UTC) | + +`/healthz` enthält die Summe und unter `metrics.rules.` jede Regel einzeln. +In Kuma: Typ *HTTP(s) – JSON-Abfrage*, dann Ausdruck, Bedingung und erwarteter Wert, +zum Beispiel: + +| Monitor | Adresse | Ausdruck | Bedingung | Wert | +|---|---|---|---|---| +| Gesamtzustand | `/healthz` | `status` | `==` | `ok` | +| Alarm bei Quarantäne | `/healthz` | `metrics.quarantined_24h` | `==` | `0` | +| Regel ohne Dateifehler | `/healthz/rule/1` | `metrics.failed_24h` | `==` | `0` | +| Sicherung | `/healthz/backup` | `status` | `==` | `ok` | + +Ältere Kuma-Versionen (1.x) kennen nur „erwarteter Wert = Ergebnis“; die Beispiele +funktionieren dort genauso. Antwortet NetMover mit 503 (irgendein Problem), meldet +Kuma auch einen Werte-Monitor als gestört – das ist gewollt. + +Beim Push zeigt Kuma als Nachricht z. B. „OK · 24 h: 12 Dateien“. ### Anmeldung {#anmeldung} @@ -416,6 +446,50 @@ Ausgesperrt oder Passwort vergessen? Auf dem Server `/opt/netmover/venv/bin/netmover reset-password` – setzt das Passwort zurück, schaltet die Anmeldung überall wieder ein und hebt Netz-Sperren auf. +### Sicherung {#sicherung} + +*System → Sicherung*. NetMover sichert täglich seine Datenbank **und** den Schlüssel +`secret.key` – also alle Anbindungen, Regeln, Zugangsdaten und Einstellungen – auf +eine Anbindung, am besten eine eigene Freigabe auf dem NAS. + +1. Auf dem NAS eine Freigabe anlegen (z. B. `Backup`), in NetMover als Anbindung + eintragen und testen. +2. *Sicherung*: Anbindung wählen, Unterordner (z. B. `netmover`), Uhrzeit, wie viele + Sicherungen behalten werden, und eine **Passphrase** (mindestens 12 Zeichen). +3. *Tägliche Sicherung einschalten*, speichern, **Jetzt sichern**. +4. **Passphrase im Passwortmanager ablegen.** Ohne sie ist die Sicherung wertlos. + +Die Sicherung ist mit der Passphrase verschlüsselt (scrypt + Fernet): Wer nur Zugriff +aufs NAS hat, kommt nicht an die Zugangsdaten. Vor dem Hochladen öffnet NetMover die +Datei probeweise und prüft die Datenbank; hochgeladen wird wie bei Regeln über eine +`.part`-Datei. Danach werden ältere NetMover-Sicherungen über die eingestellte Anzahl +hinaus gelöscht – andere Dateien im Ordner nie. + +Fällt der Termin aus (Server aus, NAS weg), holt NetMover ihn nach; nach einem +Fehler versucht es stündlich erneut und meldet den ersten Fehlschlag per E-Mail bzw. +Webhook. Gab es zwei Tage lang keine erfolgreiche Sicherung, gilt das als Problem +(Statusampel, `/healthz`, eigener Monitor `/healthz/backup`). + +Eine neue Passphrase gilt nur für künftige Sicherungen; ältere brauchen weiter die +alte. + +**Wiederherstellen** (neuer Server, gleiche oder neuere NetMover-Version): + +```sh +# NetMover wie im README installieren, dann: +systemctl stop netmover +netmover restore /pfad/zu/netmover--.nmbak # fragt nach der Passphrase +systemctl start netmover +``` + +Die bisherigen Dateien legt `restore` vorher in +`/var/lib/netmover/vor-wiederherstellung-/` ab. Die Datenbank wird anschließend +auf den Stand der installierten Version migriert. Die Sicherungsdatei holst du z. B. +mit `scp` vom NAS oder hängst die Freigabe kurz von Hand ein. + +Eine Sicherung von Hand in eine Datei: `netmover backup /root/` (verwendet die +Passphrase aus den Einstellungen). + ### Export und Import {#export-import} *Konfiguration exportieren* lädt Anbindungen, Regeln und Einstellungen als JSON diff --git a/src/netmover/__init__.py b/src/netmover/__init__.py index 8d52d17..a7bb35d 100644 --- a/src/netmover/__init__.py +++ b/src/netmover/__init__.py @@ -1,3 +1,3 @@ """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" -__version__ = "0.9.0" +__version__ = "0.10.0" diff --git a/src/netmover/app_state.py b/src/netmover/app_state.py index 1f59756..a2f86b1 100644 --- a/src/netmover/app_state.py +++ b/src/netmover/app_state.py @@ -7,21 +7,25 @@ Ende: Scheduler stoppen, Mounts **nicht** aushängen (andere könnten sie nutzen from __future__ import annotations +from collections.abc import Callable from datetime import datetime from netmover import kuma +from netmover.backup import STALE_AFTER, BackupError, BackupService, load_state from netmover.config import Settings from netmover.crypto import SecretBox, load_or_create_key -from netmover.db import make_engine, make_session_factory, upgrade -from netmover.health import Health, check_health +from netmover.db import make_engine, make_session_factory, session_scope, upgrade +from netmover.health import Health, backup_check, check_health +from netmover.health import metrics as health_metrics from netmover.logging_setup import event, get_logger -from netmover.models import utcnow +from netmover.models import Mount, utcnow from netmover.mounting.backend import MountBackend from netmover.mounting.fake import FakeMountBackend from netmover.mounting.linux import LinuxMountBackend from netmover.mounting.manager import MountManager, RestoreResult +from netmover.mover.destinations import Destination from netmover.mover.runner import RuleRunner -from netmover.notify import Notifier +from netmover.notify import Notification, Notifier from netmover.runs import RunService from netmover.scheduler import RuleScheduler from netmover.settings_store import load_settings @@ -60,11 +64,19 @@ class NetMover: ) self.runner = RuleRunner(self.session_factory, self.mounts) self.runs = RunService(self.session_factory, self.runner, self.notifier) + self.backups = BackupService( + settings, + self.session_factory, + self.backup_destination, + self.secrets, + notify=lambda kind, title, msg: self.notifier.send(Notification(kind, title, msg)), + ) self.scheduler = RuleScheduler( self.session_factory, self.runs, retry_mounts=self.mounts.retry_pending, push_health=self.push_health, + backup_tick=self.backups.tick, ) self.started_at: datetime = utcnow() self.restore_results: list[RestoreResult] = [] @@ -85,13 +97,35 @@ class NetMover: """Scheduler-Job (jede Minute): Zustand an Uptime Kuma melden, falls eingerichtet.""" url = load_settings(self.session_factory).kuma_push_url if url: - kuma.push(url, self.health()) + kuma.push(url, self.health(), metrics=health_metrics(self.session_factory)) + + def backup_destination(self, mount_id: int, path: str) -> Callable[[], Destination]: + """Ziel der Datensicherung – dieselbe Prüfung wie für Regelziele.""" + with session_scope(self.session_factory) as s: + m = s.get(Mount, mount_id) + if m is None: + raise BackupError("Die Anbindung für die Sicherung existiert nicht mehr") + mtype, name = m.type, m.name + mp = self.mounts.check_alive(mount_id) + return self.runner.destination_factory("backup_path", mount_id, mtype, name, path, mp, None) def health(self) -> Health: - return check_health( + cfg = load_settings(self.session_factory) + health = check_health( self.session_factory, scheduler_running=self.scheduler.running, last_heartbeat=self.scheduler.last_heartbeat, service_started=self.started_at, - maintenance=load_settings(self.session_factory).maintenance, + maintenance=cfg.maintenance, ) + st = load_state(self.session_factory) + health.add( + backup_check( + enabled=cfg.backup_enabled and bool(cfg.backup_mount_id), + last_success=st.last_success, + last_error=st.last_error, + stale_after=STALE_AFTER, + service_started=self.started_at, + ) + ) + return health diff --git a/src/netmover/backup.py b/src/netmover/backup.py new file mode 100644 index 0000000..8b8e45c --- /dev/null +++ b/src/netmover/backup.py @@ -0,0 +1,514 @@ +"""Datensicherung: Datenbank und Schlüssel täglich auf eine Anbindung (z. B. das NAS). + +Ohne ``secret.key`` sind alle gespeicherten Zugangsdaten verloren – die Sicherung +enthält deshalb Datenbank **und** Schlüssel. Damit die Sicherung auf dem NAS nicht +selbst zum Schlüsselbund für alle Freigaben wird, verschlüsselt NetMover sie mit +einer Passphrase (scrypt + Fernet). Die Passphrase liegt verschlüsselt in der +Datenbank, damit die tägliche Sicherung ohne Eingabe läuft; zum Wiederherstellen +braucht man sie aus dem Passwortmanager. + +Ablauf einer Sicherung: + +1. Momentaufnahme der Datenbank über die SQLite-Backup-API (konsistent, auch + während Regeln laufen) und ``PRAGMA integrity_check``. +2. tar.gz mit ``manifest.json``, Datenbank, Schlüsseln und ``netmover.env``. +3. Verschlüsseln und sofort probeweise wieder öffnen – eine Sicherung, die sich + nicht lesen lässt, wird gar nicht erst hochgeladen. +4. Hochladen wie jede andere Datei (``.part`` → prüfen → atomar umbenennen). +5. Älteste Sicherungen über ``backup_keep`` hinaus löschen – erst nach Erfolg. + +Format verschlüsselt (``.nmbak``): ``MAGIC`` + 16 Byte Salz + Fernet-Token. +Unverschlüsselt: ein gewöhnliches ``.tar.gz``. +""" + +from __future__ import annotations + +import base64 +import contextlib +import io +import json +import os +import re +import socket +import sqlite3 +import tarfile +import tempfile +import time +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives.kdf.scrypt import Scrypt +from sqlalchemy.orm import Session, sessionmaker + +from netmover import __version__ +from netmover.config import Settings +from netmover.crypto import SecretBox +from netmover.logging_setup import event, get_logger +from netmover.models import CollisionPolicy, utcnow +from netmover.mover.destinations import MetaOptions +from netmover.settings_store import AppSettings, get_value, load_settings, set_value +from netmover.timeouts import CancelToken, run_with_watchdog + +log = get_logger(__name__) + +MAGIC = b"NETMOVER-BACKUP-1\n" +SALT_BYTES = 16 +SCRYPT_N = 2**15 +SCRYPT_R = 8 +SCRYPT_P = 1 +MIN_PASSPHRASE = 12 + +SUFFIX_ENC = ".nmbak" +SUFFIX_PLAIN = ".tar.gz" +NAME_RE = re.compile(r"^netmover-[A-Za-z0-9._-]+-\d{8}-\d{6}(\.nmbak|\.tar\.gz)$") + +DB_NAME = "netmover.db" +KEY_FILES = ("secret.key", "session.key") +ENV_NAME = "netmover.env" + +# Wann eine Sicherung als überfällig gilt (für /healthz und Kuma): zwei Tage plus +# Puffer – ein einzelner Ausfall des NAS über Nacht ist noch kein Alarm. +STALE_AFTER = timedelta(hours=50) +RETRY_AFTER = timedelta(hours=1) +IDLE_TIMEOUT = 120.0 + +STATE_PREFIX = "backup." + + +class BackupError(RuntimeError): + pass + + +# -- Verschlüsselung ----------------------------------------------------------------------- + + +def _derive(passphrase: str, salt: bytes) -> bytes: + kdf = Scrypt(salt=salt, length=32, n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P) + return base64.urlsafe_b64encode(kdf.derive(passphrase.encode())) + + +def encrypt(data: bytes, passphrase: str) -> bytes: + salt = os.urandom(SALT_BYTES) + return MAGIC + salt + Fernet(_derive(passphrase, salt)).encrypt(data) + + +def is_encrypted(blob: bytes) -> bool: + return blob.startswith(MAGIC) + + +def decrypt(blob: bytes, passphrase: str) -> bytes: + if not is_encrypted(blob): + raise BackupError("Keine verschlüsselte NetMover-Sicherung") + salt = blob[len(MAGIC) : len(MAGIC) + SALT_BYTES] + token = blob[len(MAGIC) + SALT_BYTES :] + try: + return Fernet(_derive(passphrase, salt)).decrypt(token) + except InvalidToken: + raise BackupError("Passphrase falsch oder Sicherung beschädigt") from None + + +def passphrase_problem(passphrase: str, confirm: str) -> str | None: + if len(passphrase) < MIN_PASSPHRASE: + return f"Passphrase: mindestens {MIN_PASSPHRASE} Zeichen." + if passphrase != confirm: + return "Passphrasen stimmen nicht überein." + return None + + +# -- Archiv bauen und lesen ------------------------------------------------------------------ + + +@dataclass +class Archive: + manifest: dict[str, Any] + files: dict[str, bytes] + + +def snapshot_db(db_path: Path, target: Path) -> str: + """Konsistente Kopie der laufenden Datenbank; liefert die Alembic-Revision.""" + src = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + dst = sqlite3.connect(target) + try: + src.backup(dst) + status = dst.execute("PRAGMA integrity_check").fetchone()[0] + if status != "ok": + raise BackupError(f"Datenbank beschädigt: {status}") + row = dst.execute("SELECT version_num FROM alembic_version").fetchone() + finally: + dst.close() + src.close() + return str(row[0]) if row else "" + + +def _add(tar: tarfile.TarFile, name: str, data: bytes, mtime: float) -> None: + info = tarfile.TarInfo(name) + info.size = len(data) + info.mode = 0o600 + info.mtime = int(mtime) + tar.addfile(info, io.BytesIO(data)) + + +def build_archive(settings: Settings, now: datetime | None = None) -> tuple[bytes, dict[str, Any]]: + """tar.gz mit Datenbank, Schlüsseln und Umgebungsdatei (unverschlüsselt).""" + now = now or utcnow() + with tempfile.TemporaryDirectory(dir=settings.data_dir, prefix=".backup-") as tmp: + snap = Path(tmp) / DB_NAME + revision = snapshot_db(settings.db_path, snap) + files: dict[str, bytes] = {DB_NAME: snap.read_bytes()} + for name in KEY_FILES: + path = settings.data_dir / name + if path.is_file(): + files[name] = path.read_bytes() + if settings.config_file.is_file(): + files[ENV_NAME] = settings.config_file.read_bytes() + manifest: dict[str, Any] = { + "format": 1, + "version": __version__, + "revision": revision, + "created": now.isoformat(), + "host": socket.gethostname(), + "files": sorted(files), + } + buf = io.BytesIO() + stamp = time.time() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + _add(tar, "manifest.json", json.dumps(manifest, indent=2).encode(), stamp) + for name, data in files.items(): + _add(tar, name, data, stamp) + return buf.getvalue(), manifest + + +def open_archive(blob: bytes, passphrase: str | None) -> Archive: + """Sicherung lesen und prüfen (Manifest, Datenbank-Integrität).""" + if is_encrypted(blob): + if not passphrase: + raise BackupError("Sicherung ist verschlüsselt – Passphrase angeben") + blob = decrypt(blob, passphrase) + files: dict[str, bytes] = {} + try: + with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar: + for member in tar.getmembers(): + if not member.isfile() or "/" in member.name or member.name.startswith("."): + raise BackupError(f"Unerwarteter Eintrag in der Sicherung: {member.name}") + fh = tar.extractfile(member) + if fh is not None: + files[member.name] = fh.read() + except (tarfile.TarError, OSError, EOFError) as exc: + raise BackupError(f"Keine lesbare Sicherung: {exc}") from None + try: + manifest = json.loads(files.pop("manifest.json")) + except (KeyError, ValueError): + raise BackupError("Sicherung ohne gültiges manifest.json") from None + if manifest.get("format") != 1: + raise BackupError(f"Unbekanntes Sicherungsformat {manifest.get('format')!r}") + if DB_NAME not in files or "secret.key" not in files: + raise BackupError("Sicherung unvollständig: Datenbank oder secret.key fehlt") + _check_db_bytes(files[DB_NAME]) + return Archive(manifest, files) + + +def _check_db_bytes(data: bytes) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / DB_NAME + path.write_bytes(data) + try: + con = sqlite3.connect(path) + try: + status = con.execute("PRAGMA integrity_check").fetchone()[0] + finally: + con.close() + except sqlite3.DatabaseError as exc: + raise BackupError(f"Datenbank in der Sicherung unlesbar: {exc}") from None + if status != "ok": + raise BackupError(f"Datenbank in der Sicherung beschädigt: {status}") + + +def backup_name(now: datetime, encrypted: bool, host: str | None = None) -> str: + host = re.sub(r"[^A-Za-z0-9._-]", "-", host or socket.gethostname()) or "host" + local = now.astimezone() + return f"netmover-{host}-{local:%Y%m%d-%H%M%S}{SUFFIX_ENC if encrypted else SUFFIX_PLAIN}" + + +def make_backup( + settings: Settings, passphrase: str | None, now: datetime | None = None +) -> tuple[bytes, dict[str, Any]]: + """Fertige Sicherung (verschlüsselt, falls Passphrase) – bereits probeweise geöffnet.""" + raw, manifest = build_archive(settings, now) + blob = encrypt(raw, passphrase) if passphrase else raw + open_archive(blob, passphrase) + return blob, manifest + + +def prune_names(names: list[str], keep: int) -> list[str]: + """Die zu löschenden Sicherungen: alles über die ``keep`` neuesten hinaus. + Nur Dateien mit NetMover-Namensschema – sonst wird nichts angefasst.""" + ours = sorted((n for n in names if NAME_RE.match(n)), key=_stamp_of, reverse=True) + return ours[max(keep, 1) :] + + +def _stamp_of(name: str) -> str: + m = re.search(r"-(\d{8}-\d{6})\.", name) + return m.group(1) if m else "" + + +# -- Wiederherstellen ---------------------------------------------------------------------- + + +def _version_tuple(v: str) -> tuple[int, ...]: + return tuple(int(p) for p in re.findall(r"\d+", v)[:3]) + + +def restore(settings: Settings, archive: Archive, now: datetime | None = None) -> Path: + """Spielt eine Sicherung ein. Der Dienst muss gestoppt sein. Vorhandene Dateien + wandern vorher nach ``data_dir/vor-wiederherstellung-/`` – nichts geht + verloren. Liefert diesen Ordner.""" + version = str(archive.manifest.get("version", "0")) + if _version_tuple(version) > _version_tuple(__version__): + raise BackupError( + f"Sicherung stammt von NetMover {version}, installiert ist {__version__} – " + "erst aktualisieren, dann wiederherstellen." + ) + now = now or utcnow() + data_dir = settings.data_dir + data_dir.mkdir(parents=True, exist_ok=True) + keep = data_dir / f"vor-wiederherstellung-{now.astimezone():%Y%m%d-%H%M%S}" + keep.mkdir(mode=0o700) + for name in (DB_NAME, f"{DB_NAME}-wal", f"{DB_NAME}-shm", *KEY_FILES): + path = data_dir / name + if path.exists(): + path.rename(keep / name) + for name, data in archive.files.items(): + if name == ENV_NAME: + continue + _write_private(data_dir / name, data) + if ENV_NAME in archive.files: + env = settings.config_file + if env.is_file() and env.read_bytes() != archive.files[ENV_NAME]: + _write_private(keep / ENV_NAME, env.read_bytes()) + if env.parent.is_dir(): + _write_private(env, archive.files[ENV_NAME]) + event(log, "backup.restored", version=version, kept=str(keep)) + return keep + + +def _write_private(path: Path, data: bytes) -> None: + tmp = path.with_name(f".{path.name}.restore") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as fh: + fh.write(data) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + + +# -- Dienst: tägliche Sicherung ------------------------------------------------------------ + + +@dataclass +class BackupState: + last_attempt: datetime | None = None + last_success: datetime | None = None + last_error: str = "" + last_file: str = "" + last_size: int = 0 + failures: int = 0 + + +def _dt(raw: str | None) -> datetime | None: + if not raw: + return None + value = json.loads(raw) + return datetime.fromisoformat(value) if value else None + + +def load_state(factory: sessionmaker[Session]) -> BackupState: + def get(key: str) -> str | None: + return get_value(factory, STATE_PREFIX + key) + + return BackupState( + last_attempt=_dt(get("last_attempt")), + last_success=_dt(get("last_success")), + last_error=json.loads(get("last_error") or '""'), + last_file=json.loads(get("last_file") or '""'), + last_size=json.loads(get("last_size") or "0"), + failures=json.loads(get("failures") or "0"), + ) + + +def save_state(factory: sessionmaker[Session], st: BackupState) -> None: + values: dict[str, Any] = { + "last_attempt": st.last_attempt.isoformat() if st.last_attempt else None, + "last_success": st.last_success.isoformat() if st.last_success else None, + "last_error": st.last_error, + "last_file": st.last_file, + "last_size": st.last_size, + "failures": st.failures, + } + for key, value in values.items(): + set_value(factory, STATE_PREFIX + key, json.dumps(value)) + + +def parse_time(value: str) -> tuple[int, int]: + m = re.fullmatch(r"([01]?\d|2[0-3]):([0-5]\d)", value.strip()) + if not m: + raise ValueError("Uhrzeit als HH:MM, z. B. 03:30") + return int(m.group(1)), int(m.group(2)) + + +def due_slot(cfg: AppSettings, now: datetime) -> datetime: + """Der letzte Sicherungstermin (Ortszeit des Servers) vor ``now``.""" + hour, minute = parse_time(cfg.backup_time) + local = now.astimezone() + slot = local.replace(hour=hour, minute=minute, second=0, microsecond=0) + if slot > local: + slot -= timedelta(days=1) + return slot + + +def is_due(cfg: AppSettings, st: BackupState, now: datetime) -> bool: + if not cfg.backup_enabled or not cfg.backup_mount_id: + return False + slot = due_slot(cfg, now) + if st.last_success is not None and st.last_success >= slot: + return False + # Nach einem Fehlschlag stündlich erneut, nicht bei jedem Takt + return st.last_attempt is None or now - st.last_attempt >= RETRY_AFTER + + +@dataclass +class BackupResult: + ok: bool + message: str + file: str = "" + size: int = 0 + deleted: int = 0 + + +Notify = Callable[[str, str, str], None] # (event, title, message) + + +class BackupService: + def __init__( + self, + settings: Settings, + session_factory: sessionmaker[Session], + destination_for: Callable[[int, str], Callable[[], Any]], + secrets: SecretBox, + notify: Notify | None = None, + ) -> None: + self.settings = settings + self.session_factory = session_factory + self._destination_for = destination_for + self.secrets = secrets + self._notify = notify + self._running = False + + @property + def running(self) -> bool: + return self._running + + def passphrase(self, cfg: AppSettings) -> str | None: + if not cfg.backup_passphrase_enc: + return None + return self.secrets.decrypt(cfg.backup_passphrase_enc) + + def tick(self, now: datetime | None = None) -> BackupResult | None: + """Scheduler-Job: sichert, wenn ein Termin fällig ist.""" + now = now or utcnow() + cfg = load_settings(self.session_factory) + if not is_due(cfg, load_state(self.session_factory), now): + return None + return self.run(now) + + def run(self, now: datetime | None = None) -> BackupResult: + if self._running: + return BackupResult(False, "Eine Sicherung läuft bereits.") + self._running = True + try: + return self._run(now or utcnow()) + finally: + self._running = False + + def _run(self, now: datetime) -> BackupResult: + cfg = load_settings(self.session_factory) + st = load_state(self.session_factory) + st.last_attempt = now + try: + if not cfg.backup_mount_id: + raise BackupError("Kein Sicherungsziel eingestellt") + if not cfg.backup_passphrase_enc: + raise BackupError("Keine Passphrase gesetzt") + result = self._write(cfg, now) + except Exception as exc: + message = str(exc) or type(exc).__name__ + st.failures += 1 + st.last_error = message + save_state(self.session_factory, st) + event(log, "backup.failed", level=40, error=message, failures=st.failures) + if st.failures == 1 and self._notify: + self._notify( + "backup.failed", + "NetMover: Sicherung fehlgeschlagen", + f"{message}\nNächster Versuch in einer Stunde.", + ) + return BackupResult(False, message) + recovered = st.failures > 0 + st.failures = 0 + st.last_error = "" + st.last_success = now + st.last_file = result.file + st.last_size = result.size + save_state(self.session_factory, st) + event(log, "backup.done", file=result.file, size=result.size, deleted=result.deleted) + if recovered and self._notify: + self._notify( + "backup.recovered", + "NetMover: Sicherung läuft wieder", + f"Sicherung {result.file} geschrieben.", + ) + return result + + def _write(self, cfg: AppSettings, now: datetime) -> BackupResult: + passphrase = self.passphrase(cfg) + blob, _manifest = make_backup(self.settings, passphrase, now) + name = backup_name(now, passphrase is not None) + make_dest = self._destination_for(cfg.backup_mount_id, cfg.backup_path) + + def work(token: CancelToken) -> BackupResult: + dest = make_dest() + try: + dest.prepare() + with tempfile.TemporaryDirectory(dir=self.settings.data_dir) as tmp: + local = Path(tmp) / name + local.write_bytes(blob) + ns = time.time_ns() + result = dest.commit( + local, + len(blob), + name, + CollisionPolicy.RENAME, + MetaOptions(ns, ns, 0o600, None), + lambda: None, + token, + ) + deleted = 0 + for old in prune_names(dest.list_names(), cfg.backup_keep): + token.check() + with contextlib.suppress(OSError): + dest.remove(old) + deleted += 1 + return BackupResult( + True, + f"Sicherung {dest.describe(result.final_rel)} geschrieben", + result.final_rel, + len(blob), + deleted, + ) + finally: + dest.close() + + return run_with_watchdog(work, IDLE_TIMEOUT, "Sicherung") diff --git a/src/netmover/cli.py b/src/netmover/cli.py index d7ca8f1..4cb3b76 100644 --- a/src/netmover/cli.py +++ b/src/netmover/cli.py @@ -1,14 +1,18 @@ """Kommandozeile: ``netmover serve`` / ``netmover migrate`` / ``netmover version`` -/ ``netmover reset-password``.""" +/ ``netmover reset-password`` / ``netmover backup`` / ``netmover restore``.""" from __future__ import annotations import argparse +import getpass +import shutil +import subprocess import sys -from collections.abc import Sequence +from collections.abc import Callable, Sequence +from pathlib import Path from netmover import __version__ -from netmover.config import get_settings +from netmover.config import Settings, get_settings from netmover.db import upgrade from netmover.logging_setup import event, get_logger, setup_logging @@ -25,6 +29,17 @@ def main(argv: Sequence[str] | None = None) -> int: "reset-password", help="Administratorpasswort löschen – beim nächsten Aufruf startet die Einrichtung", ) + b = sub.add_parser("backup", help="Sicherung in eine lokale Datei oder einen Ordner schreiben") + b.add_argument("out", type=Path, help="Zieldatei oder Ordner") + b.add_argument( + "--unverschluesselt", + action="store_true", + help="ohne Passphrase (die Datei enthält dann alle Zugangsdaten lesbar)", + ) + r = sub.add_parser("restore", help="Sicherung einspielen (Dienst vorher stoppen)") + r.add_argument("file", type=Path, help="Sicherungsdatei (.nmbak oder .tar.gz)") + r.add_argument("--passphrase-file", type=Path, help="Passphrase aus Datei statt Abfrage") + r.add_argument("--ja", action="store_true", help="ohne Rückfrage einspielen") args = parser.parse_args(argv) settings = get_settings() @@ -77,8 +92,106 @@ def main(argv: Sequence[str] | None = None) -> int: engine.dispose() print("Passwort gelöscht. Dienst neu starten; der Einrichtungscode steht im Log.") return 0 + if args.cmd == "backup": + return cmd_backup(settings, args.out, encrypted=not args.unverschluesselt) + if args.cmd == "restore": + return cmd_restore(settings, args.file, args.passphrase_file, assume_yes=args.ja) return 2 +def service_active() -> bool: + if shutil.which("systemctl") is None: + return False + result = subprocess.run( + ["systemctl", "is-active", "--quiet", "netmover"], # noqa: S607 + check=False, + ) + return result.returncode == 0 + + +def _ask_passphrase(confirm: bool) -> str: + from netmover.backup import passphrase_problem + + first = getpass.getpass("Passphrase: ") + if not confirm: + return first + problem = passphrase_problem(first, getpass.getpass("Wiederholen: ")) + if problem: + raise SystemExit(problem) + return first + + +def cmd_backup(settings: Settings, out: Path, *, encrypted: bool) -> int: + from netmover.backup import backup_name, make_backup + from netmover.crypto import SecretBox, load_or_create_key + from netmover.db import make_engine, make_session_factory + from netmover.models import utcnow + from netmover.settings_store import load_settings + + passphrase: str | None = None + if encrypted: + engine = make_engine(settings.effective_database_url) + cfg = load_settings(make_session_factory(engine)) + engine.dispose() + if cfg.backup_passphrase_enc: + box = SecretBox(load_or_create_key(settings.secret_key_path)) + passphrase = box.decrypt(cfg.backup_passphrase_enc) + print("Verschlüsselt mit der Passphrase aus den Einstellungen.") + else: + passphrase = _ask_passphrase(confirm=True) + now = utcnow() + blob, manifest = make_backup(settings, passphrase, now) + target = out / backup_name(now, encrypted) if out.is_dir() else out + fd_path = target.with_name(f".{target.name}.part") + fd_path.write_bytes(blob) + fd_path.chmod(0o600) + fd_path.replace(target) + print(f"Sicherung geschrieben: {target} ({len(blob)} Bytes, NetMover {manifest['version']})") + return 0 + + +def cmd_restore( + settings: Settings, + file: Path, + passphrase_file: Path | None, + *, + assume_yes: bool, + active: Callable[[], bool] = service_active, +) -> int: + from netmover.backup import BackupError, is_encrypted, open_archive, restore + + if active(): + print("Der Dienst läuft – erst `systemctl stop netmover`, dann wiederherstellen.") + return 1 + blob = file.read_bytes() + passphrase: str | None = None + if is_encrypted(blob): + passphrase = ( + passphrase_file.read_text().strip() if passphrase_file else _ask_passphrase(False) + ) + try: + archive = open_archive(blob, passphrase) + except BackupError as exc: + print(f"Fehler: {exc}") + return 1 + m = archive.manifest + print( + f"Sicherung von {m.get('host')} vom {m.get('created')} (NetMover {m.get('version')})," + f" enthält: {', '.join(sorted(archive.files))}" + ) + if not assume_yes and input(f"Nach {settings.data_dir} einspielen? [ja/nein] ") != "ja": + print("Abgebrochen, nichts verändert.") + return 1 + try: + kept = restore(settings, archive) + except BackupError as exc: + print(f"Fehler: {exc}") + return 1 + upgrade(settings.effective_database_url) + print(f"Wiederhergestellt. Bisherige Dateien liegen in {kept}.") + print("Jetzt `systemctl start netmover`.") + return 0 + + if __name__ == "__main__": sys.exit(main()) diff --git a/src/netmover/config.py b/src/netmover/config.py index 5b43e0a..53ec913 100644 --- a/src/netmover/config.py +++ b/src/netmover/config.py @@ -21,6 +21,8 @@ class Settings(BaseSettings): data_dir: Path = Path("/var/lib/netmover") run_dir: Path = Path("/run/netmover") mount_base: Path = Path("/mnt/netmover") + # Umgebungsdatei – wird mitgesichert und wiederhergestellt + config_file: Path = Path("/etc/netmover/netmover.env") host: str = "127.0.0.1" port: int = Field(default=8080, ge=1, le=65535) diff --git a/src/netmover/health.py b/src/netmover/health.py index 40c1ed2..f75916b 100644 --- a/src/netmover/health.py +++ b/src/netmover/health.py @@ -19,11 +19,21 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field from datetime import datetime, timedelta -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session, sessionmaker from netmover.db import session_scope -from netmover.models import Mount, MountState, Rule, utcnow +from netmover.models import ( + FileFailure, + ItemStatus, + Mount, + MountState, + Rule, + Run, + RunItem, + RunStatus, + utcnow, +) STALE_FACTOR = 3 FAILURES_ALERT = 3 @@ -32,7 +42,7 @@ HEARTBEAT_MAX_AGE = timedelta(minutes=3) @dataclass class Check: - kind: str # "scheduler" | "mount" | "rule" + kind: str # "scheduler" | "mount" | "rule" | "backup" name: str ok: bool message: str @@ -146,3 +156,94 @@ def check_health( health.stale_rules.add(r.id) health.add(check) return health + + +def backup_check( + *, + enabled: bool, + last_success: datetime | None, + last_error: str, + stale_after: timedelta, + service_started: datetime, + now: datetime | None = None, +) -> Check: + """Datensicherung: überfällig, wenn länger als ``stale_after`` keine gelungen ist + (Bezug wie bei Regeln: spätester von letztem Erfolg und Dienststart).""" + now = now or utcnow() + + def mk(ok: bool, message: str) -> Check: + return Check("backup", "Sicherung", ok, message) + + if not enabled: + return mk(True, "Sicherung ausgeschaltet") + reference = max(d for d in (last_success, service_started) if d is not None) + if now - reference > stale_after: + when = "noch nie" if last_success is None else f"seit {last_success:%Y-%m-%d %H:%M} UTC" + detail = f": {last_error}" if last_error else "" + return mk(False, f"Sicherung überfällig – keine erfolgreiche {when}{detail}") + if last_success is None: + return mk(True, "Sicherung eingerichtet, noch keine gelaufen") + note = f" (letzter Versuch fehlgeschlagen: {last_error})" if last_error else "" + return mk(True, f"Letzte Sicherung {last_success:%Y-%m-%d %H:%M} UTC{note}") + + +METRICS_WINDOW = timedelta(hours=24) +COUNTERS = ("files_24h", "failed_24h", "quarantined_24h", "errors_24h", "pending_failures") + + +def metrics( + session_factory: sessionmaker[Session], now: datetime | None = None +) -> dict[str, object]: + """Kennzahlen für Uptime Kuma („HTTP(s) – JSON-Abfrage“) und andere Monitore: + gesamt und je Regel (Schlüssel = Regel-ID als Text). Probeläufe zählen nicht.""" + since = (now or utcnow()) - METRICS_WINDOW + counts: dict[int, dict[str, int]] = {} + last_moved: dict[int, datetime] = {} + + def entry(rule_id: int) -> dict[str, int]: + return counts.setdefault(rule_id, dict.fromkeys(COUNTERS, 0)) + + with session_scope(session_factory) as s: + for (rid,) in s.execute(select(Rule.id)): + entry(rid) + real = (Run.dry_run.is_(False), Run.started_at >= since) + for rid, moved, failed, errors in s.execute( + select( + Run.rule_id, + func.coalesce(func.sum(Run.moved), 0), + func.coalesce(func.sum(Run.failed), 0), + func.count().filter(Run.status == RunStatus.ERROR), + ) + .where(*real) + .group_by(Run.rule_id) + ): + entry(rid).update(files_24h=moved, failed_24h=failed, errors_24h=errors) + for rid, n in s.execute( + select(Run.rule_id, func.count()) + .join(RunItem, RunItem.run_id == Run.id) + .where(*real, RunItem.status == ItemStatus.QUARANTINED) + .group_by(Run.rule_id) + ): + entry(rid)["quarantined_24h"] = n + for rid, n in s.execute( + select(FileFailure.rule_id, func.count()).group_by(FileFailure.rule_id) + ): + entry(rid)["pending_failures"] = n + for rid, latest in s.execute( + select(Run.rule_id, func.max(Run.finished_at)) + .where(Run.dry_run.is_(False), Run.moved > 0) + .group_by(Run.rule_id) + ): + if latest is not None: + last_moved[rid] = latest + + def iso(d: datetime | None) -> str | None: + return d.isoformat() if d else None + + total: dict[str, object] = {k: sum(c[k] for c in counts.values()) for k in COUNTERS} + total["last_moved_at"] = iso(max(last_moved.values(), default=None)) + total["rules"] = { + str(rid): {**c, "last_moved_at": iso(last_moved.get(rid))} + for rid, c in sorted(counts.items()) + } + return total diff --git a/src/netmover/kuma.py b/src/netmover/kuma.py index e8120b9..a881979 100644 --- a/src/netmover/kuma.py +++ b/src/netmover/kuma.py @@ -26,9 +26,20 @@ MAX_MESSAGE = 250 Getter = Callable[..., Any] -def push_url(url: str, health: Health) -> str: +def ok_message(metrics: dict[str, object] | None) -> str: + """Kurzer Text für die Kuma-Ansicht, z. B. „OK · 24 h: 12 Dateien“.""" + if not metrics: + return "OK" + parts = [f"24 h: {metrics.get('files_24h', 0)} Dateien"] + failed = metrics.get("failed_24h") or 0 + if failed: + parts.append(f"{failed} Fehler") + return "OK · " + ", ".join(parts) + + +def push_url(url: str, health: Health, metrics: dict[str, object] | None = None) -> str: """Push-URL mit aktuellem Zustand (vorhandene Parameter werden ersetzt).""" - msg = "OK" if health.ok else "; ".join(health.problems) + msg = ok_message(metrics) if health.ok else "; ".join(health.problems) if len(msg) > MAX_MESSAGE: msg = msg[: MAX_MESSAGE - 1] + "…" target = httpx.URL(url) @@ -37,11 +48,16 @@ def push_url(url: str, health: Health) -> str: return str(target) -def push(url: str, health: Health, getter: Getter | None = None) -> bool: +def push( + url: str, + health: Health, + getter: Getter | None = None, + metrics: dict[str, object] | None = None, +) -> bool: """Meldet den Zustand an Kuma. Fehler werden protokolliert, nie weitergereicht – die URL nicht, weil sie das Token enthält.""" try: - resp = (getter or httpx.get)(push_url(url, health), timeout=10) + resp = (getter or httpx.get)(push_url(url, health, metrics), timeout=10) resp.raise_for_status() except Exception as exc: event(log, "kuma.push_failed", level=30, error=type(exc).__name__) diff --git a/src/netmover/mover/destinations.py b/src/netmover/mover/destinations.py index 575efcb..53bc3b5 100644 --- a/src/netmover/mover/destinations.py +++ b/src/netmover/mover/destinations.py @@ -109,6 +109,13 @@ class Destination(ABC): def close(self) -> None: # noqa: B027 – absichtlich optional pass + @abstractmethod + def list_names(self) -> list[str]: + """Dateinamen direkt im Zielordner (für das Ausdünnen von Sicherungen).""" + + def remove(self, rel: str) -> None: + self._remove(rel) + # -- Primitive der Unterklassen ------------------------------------------------- @abstractmethod @@ -235,6 +242,9 @@ class LocalDestination(Destination): def exists(self, rel: str) -> bool: return os.path.lexists(self._p(rel)) + def list_names(self) -> list[str]: + return [e.name for e in os.scandir(self.root) if e.is_file(follow_symlinks=False)] + def free_bytes(self) -> int | None: st = os.statvfs(self.root) return st.f_bavail * st.f_frsize @@ -421,6 +431,13 @@ class SftpDestination(Destination): return False return True + def list_names(self) -> list[str]: + return [ + a.filename + for a in self.client.listdir_attr(self.base) + if a.st_mode is not None and stat.S_ISREG(a.st_mode) + ] + def free_bytes(self) -> int | None: usage = self.session.statvfs(self.base) return usage[1] if usage else None diff --git a/src/netmover/mover/runner.py b/src/netmover/mover/runner.py index 521aa62..7c4db90 100644 --- a/src/netmover/mover/runner.py +++ b/src/netmover/mover/runner.py @@ -198,7 +198,7 @@ class RuleRunner: try: make_source = self._source_factory(plan, src_mp) src_root = src_mp / plan.src_subpath if src_mp and plan.src_subpath else src_mp - make_dest = self._target( + make_dest = self.destination_factory( "dst_path", plan.dst_mount_id, plan.dst_type, @@ -208,7 +208,7 @@ class RuleRunner: src_root, ) make_archive = ( - self._target( + self.destination_factory( "archive_path", plan.archive_mount_id, plan.archive_type, @@ -291,7 +291,7 @@ class RuleRunner: return make_local - def _target( + def destination_factory( self, field: str, mount_id: int | None, @@ -302,7 +302,7 @@ class RuleRunner: src_root: Path | None, ) -> Callable[[], Destination]: """Ziel oder Archiv: lokaler Pfad (``mount_id`` None), SFTP oder Kernel-Mount.""" - what = "Archiv" if field == "archive_path" else "Ziel" + what = {"archive_path": "Archiv", "backup_path": "Sicherungsziel"}.get(field, "Ziel") if mount_id is None: root = Path(safe_abspath(field, path)) if self._under_mount_base(root): diff --git a/src/netmover/scheduler.py b/src/netmover/scheduler.py index 2d72719..0431010 100644 --- a/src/netmover/scheduler.py +++ b/src/netmover/scheduler.py @@ -6,7 +6,8 @@ * Die Läufe selbst sind blockierend und laufen in einem eigenen Thread-Pool, nie im Event-Loop. Überlappung regelt der Lock im :class:`RuleRunner`, der den übersprungenen Termin auch protokolliert – deshalb ``max_instances`` > 1. -* Herzschlag-Job für ``/healthz`` und stündliches Aufräumen der Protokolle. +* Herzschlag-Job für ``/healthz``, stündliches Aufräumen der Protokolle und alle + 5 Minuten die Frage, ob die tägliche Datensicherung fällig ist. """ from __future__ import annotations @@ -34,6 +35,7 @@ HEARTBEAT_SECONDS = 30 CLEANUP_SECONDS = 3600 RETRY_SECONDS = 60 PUSH_SECONDS = 60 +BACKUP_CHECK_SECONDS = 300 def _job_id(rule_id: int) -> str: @@ -48,6 +50,7 @@ class RuleScheduler: *, retry_mounts: Callable[[], object] | None = None, push_health: Callable[[], object] | None = None, + backup_tick: Callable[[], object] | None = None, rng: random.Random | None = None, workers: int = 8, ) -> None: @@ -55,6 +58,7 @@ class RuleScheduler: self.service = service self._retry_mounts = retry_mounts self._push_health = push_health + self._backup_tick = backup_tick self._rng = rng or random.Random() # noqa: S311 – nur Startversatz self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="nm-run") self.scheduler = AsyncIOScheduler( @@ -85,6 +89,14 @@ class RuleScheduler: self.scheduler.add_job( self._push, IntervalTrigger(seconds=PUSH_SECONDS), id="kuma-push" ) + if self._backup_tick is not None: + self.scheduler.add_job( + self._backup, + IntervalTrigger( + seconds=BACKUP_CHECK_SECONDS, start_date=utcnow() + timedelta(minutes=2) + ), + id="backup", + ) self.sync() event(log, "scheduler.started", rules=len(self._intervals)) @@ -166,6 +178,11 @@ class RuleScheduler: loop = asyncio.get_running_loop() await loop.run_in_executor(self._executor, self._push_health) + async def _backup(self) -> None: + if self._backup_tick is not None: + loop = asyncio.get_running_loop() + await loop.run_in_executor(self._executor, self._backup_tick) + async def _cleanup(self) -> None: loop = asyncio.get_running_loop() await loop.run_in_executor(self._executor, cleanup, self.session_factory) diff --git a/src/netmover/settings_store.py b/src/netmover/settings_store.py index 4d3ec08..c8bbf9f 100644 --- a/src/netmover/settings_store.py +++ b/src/netmover/settings_store.py @@ -52,6 +52,13 @@ class AppSettings: auth_mode: str = "always" trusted_networks: str = "" allowed_networks: str = "" + # Datensicherung (backup.py); Passphrase Fernet-verschlüsselt + backup_enabled: bool = False + backup_mount_id: int = 0 # 0 = kein Ziel + backup_path: str = "" + backup_time: str = "03:30" + backup_keep: int = 14 + backup_passphrase_enc: str = "" _PREFIX = "app." diff --git a/src/netmover/web/app.py b/src/netmover/web/app.py index 3c71e4a..2a61bd6 100644 --- a/src/netmover/web/app.py +++ b/src/netmover/web/app.py @@ -50,6 +50,9 @@ from netmover.admin import ( save_rule, ) from netmover.app_state import NetMover +from netmover.backup import load_state as load_backup_state +from netmover.backup import parse_time as parse_backup_time +from netmover.backup import passphrase_problem from netmover.config import Settings, get_settings from netmover.credentials import ( create_password, @@ -62,6 +65,7 @@ from netmover.credentials import ( update_password, ) from netmover.db import session_scope +from netmover.health import metrics as health_metrics from netmover.kuma import push as kuma_push from netmover.logging_setup import event, get_logger from netmover.mail import SECURITY_MODES, MailConfigError, parse_recipients, valid_address @@ -85,6 +89,7 @@ from netmover.mounting.report import CheckReport from netmover.notify import Notification from netmover.settings_store import AppSettings, NotifyMode, load_settings, save_settings from netmover.transfer import apply_imported_settings, export_config, import_config +from netmover.validation import ValidationError, safe_relpath from netmover.web import auth log = get_logger(__name__) @@ -186,6 +191,7 @@ HELP_ANCHORS = { "rules": "regeln", "log": "protokoll", "settings": "einstellungen", + "backup": "sicherung", } @@ -199,6 +205,7 @@ class NavState: rules: int rules_active: int maintenance: bool = False + backup_ok: bool = True def _nav_state(request: Request) -> NavState | None: @@ -223,6 +230,7 @@ def _nav_state(request: Request) -> NavState | None: rules=len(rules), rules_active=sum(1 for (en,) in rules if en), maintenance=health.maintenance, + backup_ok=(c := health.find("backup")) is None or c.ok, ) @@ -306,16 +314,20 @@ async def healthz(request: Request) -> JSONResponse: "last_heartbeat": beat.isoformat() if beat else None, }, "checks": [c.as_dict() for c in health.checks], + "metrics": await run_in_threadpool(health_metrics, state.session_factory), } return JSONResponse(body, status_code=200 if health.ok else 503) -async def _single_check(request: Request, kind: str, **key: Any) -> JSONResponse: +async def _single_check( + request: Request, kind: str, extra: dict[str, Any] | None = None, **key: Any +) -> JSONResponse: health = await run_in_threadpool(nm(request).health) check = health.find(kind, **key) if check is None: return JSONResponse({"status": "unknown", "message": "nicht gefunden"}, status_code=404) body = {"status": "ok" if check.ok else "error", "name": check.name, "message": check.message} + body.update(extra or {}) return JSONResponse(body, status_code=200 if check.ok else 503) @@ -327,8 +339,24 @@ async def healthz_mount(request: Request, slug: str) -> JSONResponse: @public.get("/healthz/rule/{rule_id}") async def healthz_rule(request: Request, rule_id: int) -> JSONResponse: - """Eigener Kuma-Monitor pro Regel.""" - return await _single_check(request, "rule", id=rule_id) + """Eigener Kuma-Monitor pro Regel – mit Kennzahlen der letzten 24 Stunden.""" + all_metrics = await run_in_threadpool(health_metrics, nm(request).session_factory) + rules = all_metrics["rules"] + rule_metrics = rules.get(str(rule_id)) if isinstance(rules, dict) else None + return await _single_check(request, "rule", {"metrics": rule_metrics}, id=rule_id) + + +@public.get("/healthz/backup") +async def healthz_backup(request: Request) -> JSONResponse: + """Eigener Kuma-Monitor für die Datensicherung.""" + st = await run_in_threadpool(load_backup_state, nm(request).session_factory) + extra = { + "last_success": st.last_success.isoformat() if st.last_success else None, + "last_file": st.last_file or None, + "last_size": st.last_size, + "failures": st.failures, + } + return await _single_check(request, "backup", extra) @public.get("/setup", response_class=HTMLResponse) @@ -1128,6 +1156,7 @@ def _monitor_urls(request: Request) -> list[tuple[str, str]]: (f"Regel „{n}“", f"{base}/healthz/rule/{rid}") for rid, n in s.execute(select(Rule.id, Rule.name).order_by(Rule.name)) ] + urls.append(("Sicherung", f"{base}/healthz/backup")) return urls @@ -1213,6 +1242,100 @@ async def settings_save(request: Request) -> Response: return redirect("/settings") +# -- Datensicherung ------------------------------------------------------------------------- + + +def _backup_page( + request: Request, cfg: AppSettings, errors: dict[str, str], status_code: int = 200 +) -> Response: + state = nm(request) + with state.session_factory() as s: + mounts = [ + (m.id, f"{m.name} ({m.type.value})") + for m in s.scalars(select(Mount).order_by(Mount.name)) + ] + health = state.health() + return render( + request, + "backup.html", + status_code=status_code, + active="backup", + cfg=cfg, + errors=errors, + mounts=mounts, + st=load_backup_state(state.session_factory), + check=health.find("backup"), + running=state.backups.running, + monitor_url=f"{str(request.base_url).rstrip('/')}/healthz/backup", + ) + + +@protected.get("/backup", response_class=HTMLResponse) +async def backup_page(request: Request) -> Response: + return _backup_page(request, load_settings(nm(request).session_factory), {}) + + +@protected.post("/backup", response_class=HTMLResponse) +async def backup_save(request: Request) -> Response: + state = nm(request) + form = _form_dict(await request.form()) + cfg = load_settings(state.session_factory) + errors: dict[str, str] = {} + cfg.backup_enabled = form.get("backup_enabled") == "on" + try: + cfg.backup_mount_id = int(form.get("backup_mount_id") or 0) + except ValueError: + cfg.backup_mount_id = 0 + with state.session_factory() as s: + if cfg.backup_mount_id and s.get(Mount, cfg.backup_mount_id) is None: + errors["backup_mount_id"] = "Anbindung existiert nicht." + try: + cfg.backup_path = safe_relpath("backup_path", form.get("backup_path") or "") + except ValidationError as exc: + errors["backup_path"] = str(exc) + cfg.backup_time = (form.get("backup_time") or "03:30").strip() + try: + parse_backup_time(cfg.backup_time) + except ValueError as exc: + errors["backup_time"] = str(exc) + try: + cfg.backup_keep = int(form.get("backup_keep") or 14) + if not 1 <= cfg.backup_keep <= 365: + raise ValueError + except ValueError: + errors["backup_keep"] = "Zwischen 1 und 365." + passphrase = form.get("passphrase") or "" + if passphrase or form.get("passphrase_confirm"): + problem = passphrase_problem(passphrase, form.get("passphrase_confirm") or "") + if problem: + errors["passphrase"] = problem + else: + cfg.backup_passphrase_enc = state.secrets.encrypt(passphrase) + if cfg.backup_enabled and not cfg.backup_mount_id: + errors["backup_mount_id"] = "Für die Sicherung eine Anbindung wählen." + if cfg.backup_enabled and not cfg.backup_passphrase_enc and "passphrase" not in errors: + errors["passphrase"] = "Für die Sicherung eine Passphrase festlegen." # noqa: S105 + if errors: + return _backup_page(request, cfg, errors, status_code=400) + save_settings(state.session_factory, cfg) + flash(request, "Sicherung gespeichert.") + if passphrase: + flash( + request, + "Passphrase jetzt im Passwortmanager ablegen – ohne sie lässt sich keine " + "Sicherung wiederherstellen.", + "warn", + ) + return redirect("/backup") + + +@protected.post("/backup/run") +async def backup_run(request: Request) -> Response: + result = await run_in_threadpool(nm(request).backups.run) + flash(request, result.message, "ok" if result.ok else "error") + return redirect("/backup") + + def auth_mask(url: str) -> str: """Webhook-URL nur gekürzt anzeigen – sie enthält oft ein Token.""" if not url: diff --git a/src/netmover/web/templates/backup.html b/src/netmover/web/templates/backup.html new file mode 100644 index 0000000..7c7225e --- /dev/null +++ b/src/netmover/web/templates/backup.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% from "macros.html" import field, select, check, csrf_field %} +{% block title %}Sicherung{% endblock %} +{% block content %} +

Sicherung

+

Täglich Datenbank und Schlüssel – also alle Anbindungen, Regeln, Zugangsdaten und Einstellungen – + verschlüsselt auf eine Anbindung, z. B. eine eigene Freigabe auf dem NAS.

+ +
+

Zustand

+ {% if check %}

{{ check.message }}

{% endif %} + + + + + {% if st.last_error %}{% endif %} +
Letzte erfolgreiche{{ st.last_success|localdt if st.last_success else '–' }}
Datei{{ st.last_file or '–' }}{% if st.last_size %} ({{ st.last_size|bytes }}){% endif %}
Letzter Versuch{{ st.last_attempt|localdt if st.last_attempt else '–' }}
Letzter Fehler{{ st.last_error }}{% if st.failures > 1 %} ({{ st.failures }}× in Folge){% endif %}
+
+ {{ csrf_field(csrf) }} + + Kuma-Monitor: {{ monitor_url }} +
+
+ +

Einstellungen ?

+
+ {{ csrf_field(csrf) }} +
+ {{ check('backup_enabled', 'Tägliche Sicherung einschalten', cfg.backup_enabled, wide=true) }} + {{ select('backup_mount_id', 'Ziel-Anbindung', [(0, '– bitte wählen –')] + mounts, cfg.backup_mount_id, errors=errors, hint='SMB, NFS oder SFTP – am besten eine eigene Freigabe, getrennt von Scans und Archiv.') }} + {{ field('backup_path', 'Unterordner', cfg.backup_path, errors=errors, placeholder='z. B. netmover', hint='Wird angelegt, falls nötig. Leer = direkt in der Anbindung.') }} + {{ field('backup_time', 'Uhrzeit', cfg.backup_time, type='time', errors=errors, hint='Ortszeit des Servers. Verpasste Sicherungen werden nachgeholt, nach einem Fehler stündlich neuer Versuch.') }} + {{ field('backup_keep', 'Anzahl behalten', cfg.backup_keep, type='number', errors=errors, attrs='min="1" max="365"', hint='Ältere NetMover-Sicherungen im Ordner werden gelöscht – andere Dateien nie.') }} +
+ + {% if errors.get('passphrase') %}{{ errors['passphrase'] }}{% endif %} + {{ 'Leer lassen = unverändert. ' if cfg.backup_passphrase_enc }}Mindestens 12 Zeichen.
+
+
+
+

Die Passphrase gehört in deinen Passwortmanager. Fällt der Server aus, ist sie der einzige Weg an die Sicherung – + NetMover kann sie dann nicht mehr verraten. Eine neue Passphrase gilt für künftige Sicherungen; ältere Dateien brauchen weiter die alte.

+
+
+ +

Wiederherstellen

+
+

Auf dem (neu installierten) Server, gleiche oder neuere NetMover-Version:

+
systemctl stop netmover
+netmover restore /pfad/zur/netmover-….nmbak
+systemctl start netmover
+

Die bisherigen Dateien bleiben in /var/lib/netmover/vor-wiederherstellung-… erhalten. + Ausführlich: Hilfe → Sicherung.

+
+{% endblock %} diff --git a/src/netmover/web/templates/base.html b/src/netmover/web/templates/base.html index bcd582e..549bdb1 100644 --- a/src/netmover/web/templates/base.html +++ b/src/netmover/web/templates/base.html @@ -47,6 +47,7 @@