17 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5.5 93325b84d8 feat(web): Wartungsmodus, Wartezustand und E-Mail-Einstellungen; Doku 0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:47:02 +02:00
Florian WildeandClaude Opus 5.5 a8bf4ff67e feat: E-Mail-Benachrichtigung per SMTP (STARTTLS/SSL), Passwort verschlüsselt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:47:02 +02:00
Florian WildeandClaude Opus 5.5 b6b6bf6bd7 feat: Regeln pausieren automatisch, solange eine Anbindung fehlt; Wartungsmodus
Wartezustand mit einem Protokolleintrag statt eines Fehlers alle 30 s,
automatisches Fortsetzen; Meldung einmal pro Anbindung mit betroffenen
Regeln (auch beim Aushängen von außen). Migration 0003 mit Tests auf
befüllten Datenbanken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:47:02 +02:00
Florian WildeandClaude Opus 5.5 e1a69c5e5f chore(release): 0.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:28:59 +02:00
Florian WildeandClaude Opus 5.5 88f5ad3a38 feat(web): neues Menü mit Gruppen, Statusampel, Handy-Menü und Hilfe-Links
Formulare zeigen nur passende Felder; Jinja-maskierte Attribute behoben;
Web-Fixtures nach conftest.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:28:59 +02:00
Florian WildeandClaude Opus 5.5 a59ce46933 feat: Uptime Kuma – Einzel-Endpunkte, ausführliches /healthz, Push-Monitor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:28:59 +02:00
Florian WildeandClaude Opus 5.5 b8b9f2f25a docs: Anleitung mit Screenshots, ausgeliefert als Hilfe-Seite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:28:59 +02:00
Florian WildeandClaude Opus 5.5 0b8b3e5bcb docs: Roadmap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:28:59 +02:00
Florian WildeandClaude Opus 5.5 a7930c8721 chore(release): 0.2.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:53:07 +02:00
Florian WildeandClaude Opus 5.5 d06bbc6e53 fix(db): Migrationen ohne Fremdschlüsselprüfung und in einer Transaktion
Migration 0002 scheiterte auf einer Datenbank mit verknüpften Daten
(FOREIGN KEY constraint failed beim Neuaufbau von credentials) und
hinterließ einen halben Zustand, weil SQLite-DDL nicht transaktional lief.
Eigene Migrationsverbindung: foreign_keys=OFF, BEGIN IMMEDIATE,
transactional_ddl, foreign_key_check vor dem Commit. Tests mit echten,
verknüpften Daten und mit Abbruch mitten in der Migration.

update.sh: beim Zurückstufen keinen Changelog-Auszug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:51:15 +02:00
Florian WildeandClaude Opus 5.5 fef90fc059 chore(release): 0.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:43:40 +02:00
Florian WildeandClaude Opus 5.5 d46b1ba5c5 test: Wettlauf beim Lesen von /proc/<pid>/stat abfangen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:43:40 +02:00
Florian WildeandClaude Opus 5.5 ce5bef8306 feat(deploy): netmover-update mit Sicherung, Changelog-Auszug und Healthcheck
Installation als Git-Klon unter /opt/netmover/src; install.sh installiert
git und das Update-Skript. Skripte mit shellcheck geprüft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:43:40 +02:00
Florian WildeandClaude Opus 5.5 fb975f1eae feat(web): Seite „Schlüssel“ und Schlüsselauswahl in der Anbindung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:43:40 +02:00
Florian WildeandClaude Opus 5.5 686542ff6f feat: Schlüsselverwaltung für SFTP-Anbindungen
Gespeicherte SSH-Schlüssel (erzeugen oder importieren, auch mit
Passphrase), von mehreren Anbindungen nutzbar; der Benutzername gehört
zur Anbindung. Migration 0002: credentials.managed, mounts.username.
Gespeicherte Schlüssel werden beim Löschen einer Anbindung nie mitgelöscht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:43:40 +02:00
Florian WildeandClaude Opus 5.5 4369faf144 fix(sftp): nach erfolgreichem Test als erreichbar anzeigen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:54:46 +02:00
Florian WildeandClaude Opus 5.5 0a317e2aa9 feat(mounts): Klartext-Hinweis bei mount error(95) – keine gemeinsame SMB-Version
Aufgefallen beim echten Synology-NAS (Maximales SMB-Protokoll: SMB2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:50:19 +02:00
58 changed files with 3599 additions and 214 deletions
+90 -1
View File
@@ -8,6 +8,91 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
## [Unreleased] ## [Unreleased]
## [0.4.0] – 2026-10-11
### Hinzugefügt
- **Automatisch pausieren und fortsetzen:** Ist Quelle oder Ziel nicht erreichbar,
wartet die Regel („wartet seit …“ mit Grund) – ein Protokolleintrag statt eines
Fehlers alle 30 s, „Fehler in Folge“ zählt nicht hoch. Sobald die Anbindung zurück
ist, läuft die Regel von selbst weiter („Fortgesetzt nach … Wartezeit“).
Migration 0003: `rules.waiting_since`, `rules.waiting_reason`.
- **Wartungsmodus** (Knopf auf der Statusseite): alle geplanten Läufe pausiert,
„Jetzt ausführen“ geht weiter, kein Alarm für Regeln in `/healthz`; gelber Hinweis
in der Kopfzeile.
- **E-Mail-Benachrichtigung** per SMTP (STARTTLS, SSL/TLS oder ohne für interne
Relays; Zertifikate werden geprüft), Passwort verschlüsselt, mehrere Empfänger,
Test-Knopf. Webhook und E-Mail lassen sich gleichzeitig nutzen.
### Geändert
- Ausfälle von Anbindungen werden **einmal pro Anbindung** gemeldet, mit der Liste
der betroffenen Regeln, und mit Entwarnung, wenn sie zurück sind – auch wenn eine
Anbindung von außen ausgehängt wurde (vorher keine Meldung).
- Einstellungen neu gegliedert: Benachrichtigungen · Webhook · E-Mail · Überwachung.
- `/healthz`: Eine wartende Regel ist ein Problem (mit Grund); im Wartungsmodus nicht.
## [0.3.0] – 2026-10-11
### Hinzugefügt
- **Anleitung** (`docs/ANLEITUNG.md`) mit Screenshots, zugleich in der Oberfläche
unter *Hilfe*; „Hilfe zu dieser Seite“ in der Kopfzeile und „?“-Links an den
Formularabschnitten führen direkt zum passenden Abschnitt.
- **Roadmap** (`ROADMAP.md`).
- **Uptime Kuma:** `/healthz` mit Version, Scheduler-Herzschlag und Einzelprüfungen;
Einzel-Endpunkte `/healthz/mount/<name>` und `/healthz/rule/<nummer>` (200/503/404);
Push an einen Kuma-Push-Monitor jede Minute (*Einstellungen → Überwachung*, URL
maskiert, Test-Knopf, fertige Adressen für HTTP-Monitore).
### Geändert
- **Menü neu aufgebaut:** Seitenleiste mit Gruppen in der Reihenfolge der
Einrichtung (Übersicht · Verbindungen · Automatisierung · System), Symbole,
Zähler (z. B. „2 gestört“, „1/2 aktiv“), Statusampel in der Kopfzeile; auf dem
Handy ausklappbares Menü.
- SFTP-Formular zeigt nur noch passende Felder und Hinweise (kein Passwortfeld bei
Schlüssel-Anmeldung, keine SMB-Hinweise); kompaktere Schlüsselseite.
- Gesundheit: Eine aktive Regel mit drei fehlgeschlagenen Läufen in Folge gilt jetzt
ebenfalls als Problem (nicht nur „zu lange kein Erfolg“).
### Behoben
- `aria-current` im Menü und der gesperrte Löschknopf auf der Schlüsselseite
wurden von Jinja maskiert (`=&#34;`) und wirkten nicht; Test prüft alle Seiten.
## [0.2.1] – 2026-10-11
### Behoben
- **Update von 0.1.x auf 0.2.0 scheiterte**: Migration 0002 brach auf einer
Datenbank mit Anbindungen ab (`FOREIGN KEY constraint failed` beim Neuaufbau der
Tabelle `credentials`). Weil SQLite-Migrationen nicht in einer Transaktion liefen,
blieb dabei ein halber Zustand zurück. Migrationen laufen jetzt ohne
Fremdschlüsselprüfung, aber mit Integritätsprüfung vor dem Commit und vollständig
in einer Transaktion: Ein Abbruch hinterlässt die Datenbank unverändert.
Geprüft mit Tests auf verknüpften Daten und an einer Kopie einer echten Datenbank.
- `netmover-update` zeigt beim Zurückstufen keinen irreführenden Changelog-Auszug mehr.
## [0.2.0] – 2026-10-11
> ⚠️ **Nicht verwenden** – das Update von 0.1.x scheitert an Migration 0002.
> Stattdessen 0.2.1 installieren. Wer 0.2.0 schon versucht hat: Die Datenbank aus der
> Sicherung von `netmover-update` zurückspielen und auf 0.2.1 aktualisieren.
### Hinzugefügt
- **Schlüsselverwaltung** (Seite „Schlüssel“): SSH-Schlüssel erzeugen (Ed25519) oder
importieren (Ed25519, ECDSA, RSA ≥ 2048 Bit; OpenSSH/PEM, auch mit Passphrase),
Fingerabdruck und `authorized_keys`-Zeile mit SFTP-Beschränkung zum Kopieren,
Umbenennen, Löschen nur wenn unbenutzt. Private Schlüssel werden nie angezeigt.
- SFTP-Anbindungen können einen gespeicherten Schlüssel auswählen; der Benutzername
gehört zur Anbindung (Migration 0002: `credentials.managed`, `mounts.username`).
- **`netmover-update`**: Update aus dem Git-Repo mit Sicherung von Datenbank,
Schlüsseln und Einstellungen, Changelog-Auszug, Neustart und Healthcheck;
Versionswahl, Warnung beim Zurückstufen.
- Installation als Git-Klon unter `/opt/netmover/src`; `install.sh` installiert
`git` und das Update-Skript.
### Geändert
- Klartext-Hinweis bei `mount error(95)` (keine gemeinsame SMB-Version) mit
Synology-Einstellung; aufgefallen bei der Anbindung des echten NAS.
- SFTP-Anbindungen gelten nach erfolgreichem Test als „erreichbar“ (vorher bis
zum ersten Regellauf „nicht geprüft“).
## [0.1.1] – 2026-10-10 ## [0.1.1] – 2026-10-10
Korrekturen aus dem ersten Praxistest auf einer Debian-13-VM mit echten Korrekturen aus dem ersten Praxistest auf einer Debian-13-VM mit echten
@@ -128,6 +213,10 @@ Erste Version.
- Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den - Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den
Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt. Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt.
[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.1...HEAD [Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.4.0...HEAD
[0.4.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.3.0...v0.4.0
[0.3.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.1...v0.3.0
[0.2.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.0...v0.2.1
[0.2.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.1...v0.2.0
[0.1.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.0...v0.1.1 [0.1.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.0...v0.1.1
[0.1.0]: https://git.wilde-server.de/florian.wilde/netmover/releases/tag/v0.1.0 [0.1.0]: https://git.wilde-server.de/florian.wilde/netmover/releases/tag/v0.1.0
+88 -1
View File
@@ -14,10 +14,97 @@ Zeiten in Ortszeit (Europe/Berlin).
| 2026-10-10 | 19:18 | 19:34 | 0:16 | 5 | Weboberfläche und Anmeldung | | 2026-10-10 | 19:18 | 19:34 | 0:16 | 5 | Weboberfläche und Anmeldung |
| 2026-10-10 | 19:40 | 19:50 | 0:10 | 6 | Deployment, README, Release 0.1.0 | | 2026-10-10 | 19:40 | 19:50 | 0:10 | 6 | Deployment, README, Release 0.1.0 |
| 2026-10-10 | 21:36 | 22:05 | 0:29 | – | Praxistest auf VM, Release 0.1.1 | | 2026-10-10 | 21:36 | 22:05 | 0:29 | – | Praxistest auf VM, Release 0.1.1 |
| **Summe** | | | **1:35** | | | | 2026-10-10 | 22:45 | 23:00 | 0:15 | – | Echtes NAS/Paperless anbinden |
| 2026-10-10 | 23:31 | 23:58 | 0:27 | – | Schlüsselverwaltung, Update-Skript, 0.2.x |
| 2026-10-11 | 00:13 | 00:32 | 0:19 | – | Menü, Anleitung, Roadmap, Uptime Kuma (0.3.0) |
| 2026-10-11 | 00:31 | 00:50 | 0:19 | – | Pausieren, Wartungsmodus, E-Mail (0.4.0) |
| **Summe** | | | **2:55** | | |
--- ---
## 2026-10-11 · 00:31–00:50 · Pausieren, Wartungsmodus, E-Mail (0.4.0)
**Erledigt**
- Wartezustand (`rules.waiting_since/_reason`, Migration 0003), Wartungsmodus,
E-Mail (`mail.py`, SMTP), eine Meldung pro Anbindung mit betroffenen Regeln.
- 0.4.0/0.5.0 aufgeteilt: kleinere Updates, schneller Nutzen.
- 21 neue Tests (299 gesamt), u. a. SMTP gegen echten Server (aiosmtpd) mit
STARTTLS, SSL, falschem Passwort und nicht vertrauenswürdigem Zertifikat.
**Entscheidungen**
- Warten statt Deaktivieren; kein Fehlerzähler und nur ein Protokolleintrag beim
Eintritt; Ende des Wartens automatisch beim nächsten durchkommenden Lauf.
- Benachrichtigung pro Anbindung (nicht pro Regel) – sonst bei drei Regeln am NAS
drei gleiche Meldungen. Lücke geschlossen: Aushängen von außen meldete nichts.
- Wartungsmodus unterdrückt nur Regel-Alarme, Anbindungen werden weiter geprüft.
- SMTP prüft Zertifikate immer; für Tests ist nur `mail.tls_context()` ersetzbar.
**Stolpersteine (alle im Testaufbau)**
- Globales Monkeypatchen von `ssl.create_default_context` traf auch den
Testserver → eigene Funktion `tls_context()`.
- aiosmtpd: `AuthResult(success=False)` ohne `handled=False` schickt keine Antwort →
Client hängt bis zum Zeitlimit.
**Migrationen** – 0003 baut `rules` neu; getestet von 0001 und von 0002 mit allen
verknüpften Tabellen sowie an der befüllten Demo-Datenbank; vor dem Release an einer
Kopie der Live-Datenbank auf der VM.
## 2026-10-11 · 00:13–00:32 · Menü, Anleitung, Roadmap, Uptime Kuma (0.3.0)
**Erledigt**
- Seitenleiste mit Gruppen, Symbolen (Inline-SVG), Zählern und Statusampel;
Handy-Menü per Knopf (ohne JS sichtbar).
- `docs/ANLEITUNG.md` (für Anwender) mit Sprungmarken; Hilfe-Seite rendert sie per
Python-Markdown, das Paket liefert `docs/` mit (hatch force-include).
- `ROADMAP.md`; Nutzerwünsche eingeplant: SFTP als Quelle, gespeicherte Zugangsdaten,
automatisches Pausieren/Fortsetzen, E-Mail (0.4.0).
- Uptime Kuma: ausführliches `/healthz`, Einzel-Endpunkte, Push-Job.
- 13 neue Tests (278 gesamt).
**Wie geprüft**
- Erstmals echte Screenshots mit Playwright (Chromium, Scratchpad-venv) gegen eine
Demo-Instanz (Fake-Backend), Desktop 1366 px und Handy 390 px, hell und dunkel.
Daraus gefunden und behoben: überflüssiges Passwortfeld/SMB-Hinweise bei SFTP,
sperriges Umbenennen auf der Schlüsselseite, Tabellen in der Hilfe ragten auf
dem Handy über den Rand. Dieselben Screenshots sind jetzt in der Anleitung
(128-Farben-PNG, zusammen ~150 KB; Demo-Pfad per DOM durch `/mnt/netmover` ersetzt).
- Ein echter Fehler, den erst der Blick auf das HTML zeigte: `{{ 'attr="x"' if … }}`
wird von Jinja maskiert → `aria-current` und `disabled title` wirkten nicht.
**Entscheidungen**
- Automatisches Pausieren: kein Deaktivieren (müsste nach jedem NAS-Neustart von
Hand rückgängig gemacht werden), sondern „wartet auf Anbindung“ mit
automatischem Fortsetzen – für 0.4.0 vorgeschlagen.
- Regel mit 3 Fehlschlägen in Folge zählt jetzt als Problem in `/healthz`.
## 2026-10-10 · 23:31–23:58 · Schlüsselverwaltung, Update-Skript, Releases 0.2.0/0.2.1
**Erledigt**
- Schlüsselverwaltung (`keys.py`, Seite „Schlüssel“, Auswahl in SFTP-Anbindungen),
Migration 0002 (`credentials.managed`, `mounts.username`).
- `deploy/update.sh` → `netmover-update`: Tags holen, Sicherung (SQLite-Online-
Backup + Schlüssel + env), Checkout, `install.sh`, Healthcheck, Rückweg-Anleitung.
- VM auf Git-Klon `/opt/netmover/src` umgestellt (Repo ist öffentlich lesbar).
**Zwischenfall beim Update der VM auf 0.2.0**
- Migration 0002 scheiterte: `FOREIGN KEY constraint failed` bei `DROP TABLE
credentials` (Alembic-Batch baut die Tabelle neu; `foreign_keys=ON`, und die
Anbindungen verweisen darauf). Danach crashte jeder Neustart an der
übriggebliebenen `_alembic_tmp_credentials` (DDL lief ohne Transaktion).
- `netmover-update` hat den Fehler erkannt und die Rückweg-Befehle genannt.
Sicherung zurückgespielt, mit `netmover-update v0.1.1` zurückgestuft – in
~1 Minute wieder lauffähig, NAS-Mount blieb durchgehend eingehängt.
- Ursache, warum die Tests es nicht fanden: Migrationen wurden nur auf **leeren**
Datenbanken getestet. Neu: `tests/test_migrations.py` mit verknüpften Daten und
mit Abbruch mitten in der Migration (Datenbank muss unverändert bleiben).
- Fix: eigene Migrationsverbindung (FK aus, `BEGIN IMMEDIATE`, `transactional_ddl`,
`foreign_key_check` vor dem Commit). Vor dem Release an einer Kopie der echten
Datenbank auf der VM geprüft.
- Tag v0.2.0 bleibt (veröffentlicht), im Changelog als „nicht verwenden“ markiert.
**Lehre**
- Jede künftige Migration bekommt einen Test auf einer befüllten Datenbank.
## 2026-10-10 · 21:36–22:05 · Praxistest auf der Debian-13-VM, Release 0.1.1 ## 2026-10-10 · 21:36–22:05 · Praxistest auf der Debian-13-VM, Release 0.1.1
**Umgebung** **Umgebung**
+56 -13
View File
@@ -8,6 +8,15 @@ unabhängig voneinander per SMB, NFS, lokal oder (nur als Ziel) per SFTP angebun
Typischer Einsatz: Ein Synology-NAS nimmt Scans entgegen, NetMover schiebt sie alle Typischer Einsatz: Ein Synology-NAS nimmt Scans entgegen, NetMover schiebt sie alle
30 Sekunden in den Eingangsordner von Paperless-ngx. 30 Sekunden in den Eingangsordner von Paperless-ngx.
![Statusseite](docs/img/status.png)
| Dokument | Inhalt |
|---|---|
| **[Anleitung](docs/ANLEITUNG.md)** | Bedienung: Anbindungen, Schlüssel, Regeln, Status, Protokoll, Uptime Kuma, Beispiele, Fragen – auch in der Oberfläche unter *Hilfe* |
| **[Roadmap](ROADMAP.md)** | Was in Arbeit und geplant ist |
| [Changelog](CHANGELOG.md) | Änderungen je Version |
| dieses README | Installation, Update, Betrieb, Fehlersuche, Sicherheit |
- [Wie NetMover Datenverlust verhindert](#wie-netmover-datenverlust-verhindert) - [Wie NetMover Datenverlust verhindert](#wie-netmover-datenverlust-verhindert)
- [Installation](#installation) · [Erster Start](#erster-start) - [Installation](#installation) · [Erster Start](#erster-start)
- [Beispiel: Synology → Paperless](#beispiel-synology--paperless) - [Beispiel: Synology → Paperless](#beispiel-synology--paperless)
@@ -47,15 +56,36 @@ heißt deshalb immer **kopieren, prüfen, dann erst die Quelle löschen**:
```bash ```bash
apt install -y git apt install -y git
git clone https://git.wilde-server.de/florian.wilde/netmover.git git clone https://git.wilde-server.de/florian.wilde/netmover.git /opt/netmover/src
cd netmover cd /opt/netmover/src && git checkout "$(git tag -l 'v*' --sort=-v:refname | head -n 1)"
sudo ./deploy/install.sh ./deploy/install.sh
``` ```
Das Skript installiert `python3-venv`, `cifs-utils` und `nfs-common` und richtet Das Skript installiert `python3-venv`, `cifs-utils`, `nfs-common` und `git`. Es
NetMover in `/opt/netmover/venv` ein. Danach installiert es richtet NetMover in `/opt/netmover/venv` ein, installiert
[`deploy/netmover.service`](deploy/netmover.service) und startet den Dienst. [`deploy/netmover.service`](deploy/netmover.service) und das Update-Skript
Ein Update ist derselbe Aufruf nach einem `git pull`. `netmover-update` und startet den Dienst.
### Update
```bash
netmover-update # auf die neueste Version
netmover-update --list # verfügbare Versionen
netmover-update v0.2.0 # bestimmte Version
```
`netmover-update` geht so vor:
1. Neue Versions-Tags aus Gitea holen und die Änderungen laut Changelog anzeigen.
2. **Datenbank, `secret.key` und Einstellungen nach `/var/backups/netmover/` sichern**
(die letzten 10 bleiben).
3. Die Version auschecken, installieren und den Dienst neu starten. Neue
Datenbank-Migrationen laufen dabei automatisch.
4. `/healthz` prüfen.
Die Mounts bleiben die ganze Zeit eingehängt. Geht etwas schief, nennt das Skript
die Befehle, um die Sicherung zurückzuspielen und zur vorherigen Version zu wechseln.
**Zurückstufen** auf eine ältere Version geht nur mit der Datenbank-Sicherung von
vor dem Update, wenn das Update eine Migration enthielt; das Skript fragt deshalb nach.
Was wo liegt: Was wo liegt:
@@ -149,11 +179,18 @@ Lege in NetMover die *Anbindung → Neue Anbindung* an:
| Typ | SFTP (nur Ziel) | | Typ | SFTP (nur Ziel) |
| Server / SSH-Port | IP des LXC / `22` | | Server / SSH-Port | IP des LXC / `22` |
| Freigabe / Pfad | `/opt/paperless_data/consume` | | Freigabe / Pfad | `/opt/paperless_data/consume` |
| Anmeldeart | SSH-Schlüssel (NetMover erzeugt ihn) | | Anmeldeart | SSH-Schlüssel |
| Schlüssel | einen gespeicherten Schlüssel wählen (siehe unten) oder „neuer Schlüssel nur für diese Anbindung“ |
| Benutzername | `paperless` (der Benutzer, unter dem Paperless läuft) | | Benutzername | `paperless` (der Benutzer, unter dem Paperless läuft) |
Nach dem Speichern zeigt NetMover den **öffentlichen Schlüssel**. Trage ihn im LXC **Schlüsselverwaltung:** Unter *Schlüssel* erzeugst du Ed25519-Schlüssel oder
ein, und zwar so, dass der Schlüssel **nur SFTP** darf: importierst vorhandene (Ed25519, ECDSA, RSA ≥ 2048 Bit; OpenSSH- oder PEM-Format,
auch mit Passphrase). Ein Schlüssel kann von mehreren Anbindungen genutzt werden; den
Benutzernamen legt jede Anbindung selbst fest. Private Schlüssel liegen verschlüsselt
in der Datenbank und werden nie angezeigt. Die Seite zeigt den Fingerabdruck und eine
fertige `authorized_keys`-Zeile zum Kopieren.
Trage den öffentlichen Schlüssel im LXC so ein, dass er **nur SFTP** darf:
```bash ```bash
# im Paperless-LXC als root # im Paperless-LXC als root
@@ -312,8 +349,11 @@ Webhook-Vorlagen (Platzhalter `$event $title $message $rule $time $host`):
| Gotify | `https://gotify.lan/message?token=…` | `application/json` | `{"title": "$title", "message": "$message", "priority": 5}` | | Gotify | `https://gotify.lan/message?token=…` | `application/json` | `{"title": "$title", "message": "$message", "priority": 5}` |
| eigener Empfänger | beliebig | `application/json` | Standardvorlage | | eigener Empfänger | beliebig | `application/json` | Standardvorlage |
`GET /healthz` liefert `200` oder `503` mit der Liste der Probleme (ohne Anmeldung), **Überwachung:** `GET /healthz` liefert ohne Anmeldung `200` oder `503` mit allen
z. B. für Uptime Kuma. Einzelprüfungen; `/healthz/mount/<name>` und `/healthz/rule/<nummer>` prüfen eine
einzelne Anbindung oder Regel. Alternativ meldet NetMover jede Minute an einen
Uptime-Kuma-Push-Monitor (*Einstellungen → Überwachung*). Details:
[Anleitung → Uptime Kuma](docs/ANLEITUNG.md#uptime-kuma).
## Fehlersuche ## Fehlersuche
@@ -327,6 +367,7 @@ findmnt -R /mnt/netmover # was ist wirklich eingehängt?
|---|---| |---|---|
| `mount error(13): Permission denied` (CIFS) | Benutzer oder Passwort falsch, oder keine Rechte auf die Freigabe. | | `mount error(13): Permission denied` (CIFS) | Benutzer oder Passwort falsch, oder keine Rechte auf die Freigabe. |
| `mount error(2): No such file or directory` | Freigabename falsch geschrieben (Groß/klein!). | | `mount error(2): No such file or directory` | Freigabename falsch geschrieben (Groß/klein!). |
| `mount error(95): Operation not supported` | Keine gemeinsame SMB-Version (im Kernel-Log: „Dialect not supported by server“). Am NAS das maximale SMB-Protokoll auf SMB3 stellen oder Zusatzoption `vers=2.0`. |
| `mount error(112): Host is down` | NAS nicht erreichbar, oder SMB1 erwartet: `vers=3.0` bzw. `vers=2.1` probieren. | | `mount error(112): Host is down` | NAS nicht erreichbar, oder SMB1 erwartet: `vers=3.0` bzw. `vers=2.1` probieren. |
| `… helper program` / `unknown filesystem type` | `apt install cifs-utils nfs-common` | | `… helper program` / `unknown filesystem type` | `apt install cifs-utils nfs-common` |
| `Operation not permitted` / `only root can do that` | Kein `CAP_SYS_ADMIN`: unprivilegierter LXC oder Docker ohne `cap_add`. | | `Operation not permitted` / `only root can do that` | Kein `CAP_SYS_ADMIN`: unprivilegierter LXC oder Docker ohne `cap_add`. |
@@ -388,7 +429,9 @@ Aufbau:
| `mounting/` | `MountBackend` (Linux/Fake), Mount-Verwaltung, Test, Wiederherstellung, SFTP | | `mounting/` | `MountBackend` (Linux/Fake), Mount-Verwaltung, Test, Wiederherstellung, SFTP |
| `mover/` | Scan, Stabilität, atomares Schreiben, Kollisionen, Buchführung, Regellauf | | `mover/` | Scan, Stabilität, atomares Schreiben, Kollisionen, Buchführung, Regellauf |
| `runs.py`, `scheduler.py`, `health.py`, `notify.py` | Protokoll, Zeitsteuerung, Gesundheit, Webhook | | `runs.py`, `scheduler.py`, `health.py`, `notify.py` | Protokoll, Zeitsteuerung, Gesundheit, Webhook |
| `admin.py`, `transfer.py`, `web/` | Formulare, Export/Import, Oberfläche | | `admin.py`, `keys.py`, `transfer.py`, `web/` | Formulare, Schlüsselverwaltung, Export/Import, Oberfläche |
| `health.py`, `kuma.py` | Gesundheitsprüfung, Uptime-Kuma-Push |
| `docs/` | Anleitung und Bilder; wird als Hilfe-Seite mit ausgeliefert |
Die gesamte Mover-Logik ist mit dem `FakeMountBackend` ohne root testbar. SFTP Die gesamte Mover-Logik ist mit dem `FakeMountBackend` ohne root testbar. SFTP
wird gegen einen echten SFTP-Server im Testprozess getestet. wird gegen einen echten SFTP-Server im Testprozess getestet.
+48
View File
@@ -0,0 +1,48 @@
# Roadmap
Was NetMover kann, woran gearbeitet wird und was geplant ist. Erledigte Punkte
stehen mit Version im [CHANGELOG](CHANGELOG.md); Details zu Entscheidungen im
[DEVLOG](DEVLOG.md).
Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht entschieden)
## In Arbeit (0.5.0)
| Status | Thema | Warum |
|---|---|---|
| 🔨 | **SFTP als Quelle**: Scan, Stabilitätsprüfung und Löschen der Quelle über SFTP, mit denselben Garantien wie bei SMB/NFS | Quellen, die nur per SSH erreichbar sind |
| 🔨 | **Gespeicherte Zugangsdaten** für SMB und SFTP-Passwörter, zusammen mit den SSH-Schlüsseln auf einer Seite „Zugangsdaten“ | Mehrere Freigaben mit demselben NAS-Benutzer an einer Stelle pflegen |
## Geplant
| 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 |
| 📋 | **Statusseite schneller bei hängenden Mounts**: Füllstand zwischenspeichern | Heute bis zu 2 s Wartezeit, wenn ein NFS-Server weg ist |
## Ideen
| Status | Thema | Anmerkung |
|---|---|---|
| 💡 | **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.4.0 | Automatisch pausieren/fortsetzen bei nicht erreichbaren Anbindungen; Wartungsmodus; E-Mail-Benachrichtigung; eine Meldung pro ausgefallener Anbindung |
| 0.3.0 | Neues Menü (gruppierte Seitenleiste, Statusampel, Handy-Menü); Anleitung im Repo und als Hilfe-Seite; Roadmap; Uptime Kuma (Einzel-Endpunkte, Push-Monitor) |
| 0.2.1 | Migrationen in einer Transaktion und ohne Fremdschlüsselfehler auf befüllten Datenbanken |
| 0.2.0 | Schlüsselverwaltung für SFTP; `netmover-update` mit Sicherung; Installation als Git-Klon |
| 0.1.1 | Mounts im System-Namespace sichtbar; frische Dateiattribute für die Stabilitätsprüfung; minütlicher Neuversuch nach NAS-Ausfall |
| 0.1.0 | Erste Version: SMB/NFS/SFTP, Regeln mit sicherem Verschieben, Scheduler, Protokoll, Weboberfläche, systemd/Docker |
## Wünsche einbringen
Neue Punkte bitte als Issue in Gitea anlegen oder direkt hier eintragen. Bei
Umsetzung wandert ein Punkt nach „In Arbeit“ und mit dem Release nach „Erledigt“.
+1 -1
View File
@@ -4,7 +4,7 @@
services: services:
netmover: netmover:
build: . build: .
image: netmover:0.1.1 image: netmover:0.4.0
container_name: netmover container_name: netmover
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
+8 -3
View File
@@ -1,8 +1,10 @@
#!/bin/sh #!/bin/sh
# NetMover auf Debian 13 installieren bzw. aktualisieren (als root ausführen). # NetMover auf Debian 13 installieren bzw. aktualisieren (als root ausführen).
# #
# ./deploy/install.sh # aus diesem Verzeichnis installieren # git clone https://git.wilde-server.de/florian.wilde/netmover.git /opt/netmover/src
# ./deploy/install.sh 'git+https://git.wilde-server.de/florian.wilde/netmover.git@v0.1.0' # /opt/netmover/src/deploy/install.sh
#
# Spätere Updates: netmover-update (siehe deploy/update.sh).
set -eu set -eu
SOURCE="${1:-$(cd "$(dirname "$0")/.." && pwd)}" SOURCE="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
@@ -16,7 +18,7 @@ fi
echo "==> Pakete" echo "==> Pakete"
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update -qq apt-get update -qq
apt-get install -y -qq python3 python3-venv cifs-utils nfs-common >/dev/null apt-get install -y -qq python3 python3-venv cifs-utils nfs-common git >/dev/null
echo "==> Virtuelle Umgebung in $PREFIX/venv" echo "==> Virtuelle Umgebung in $PREFIX/venv"
install -d -m 0755 "$PREFIX" install -d -m 0755 "$PREFIX"
@@ -31,6 +33,9 @@ if [ ! -f /etc/netmover/netmover.env ]; then
fi fi
install -d -m 0755 /mnt/netmover install -d -m 0755 /mnt/netmover
echo "==> Update-Skript"
install -m 0755 "$(dirname "$0")/update.sh" /usr/local/sbin/netmover-update
echo "==> systemd" echo "==> systemd"
install -m 0644 "$(dirname "$0")/netmover.service" /etc/systemd/system/netmover.service install -m 0644 "$(dirname "$0")/netmover.service" /etc/systemd/system/netmover.service
systemctl daemon-reload systemctl daemon-reload
+153
View File
@@ -0,0 +1,153 @@
#!/bin/sh
# NetMover aktualisieren (als root). Wird von install.sh als
# /usr/local/sbin/netmover-update installiert.
#
# netmover-update # auf die neueste Version (neuester Tag vX.Y.Z)
# netmover-update v0.2.0 # auf eine bestimmte Version (auch zurück)
# netmover-update --list # verfügbare Versionen anzeigen
#
# Ablauf: Tags holen → Datenbank und Schlüssel sichern → Version auschecken →
# install.sh (installiert, migriert beim Start, startet den Dienst neu) →
# Gesundheit prüfen. Die Mounts bleiben dabei eingehängt.
set -eu
SRC="${NETMOVER_SRC:-/opt/netmover/src}"
VENV=/opt/netmover/venv
ENV_FILE=/etc/netmover/netmover.env
BACKUP_ROOT=/var/backups/netmover
KEEP_BACKUPS=10
if [ "$(id -u)" -ne 0 ]; then
echo "Bitte als root ausführen." >&2
exit 1
fi
if [ ! -d "$SRC/.git" ]; then
echo "Kein Git-Klon unter $SRC. Einmalig einrichten:" >&2
echo " git clone https://git.wilde-server.de/florian.wilde/netmover.git $SRC" >&2
exit 1
fi
# Einstellungen des Dienstes (Port, Datenverzeichnis)
NETMOVER_PORT=8080
NETMOVER_DATA_DIR=/var/lib/netmover
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
cd "$SRC"
git fetch --tags --quiet origin
if [ "${1:-}" = "--list" ]; then
git tag -l 'v*' --sort=-v:refname
exit 0
fi
TARGET="${1:-$(git tag -l 'v*' --sort=-v:refname | head -n 1)}"
if ! git rev-parse -q --verify "refs/tags/$TARGET" >/dev/null; then
echo "Version $TARGET gibt es nicht. Verfügbar:" >&2
git tag -l 'v*' --sort=-v:refname | head -n 10 >&2
exit 1
fi
CURRENT="v$("$VENV/bin/netmover" version 2>/dev/null || echo "?")"
echo "Installiert: $CURRENT"
echo "Ziel: $TARGET"
if [ "$CURRENT" = "$TARGET" ] && [ "${FORCE:-0}" != "1" ]; then
echo "Bereits auf dem gewünschten Stand (FORCE=1 erzwingt eine Neuinstallation)."
exit 0
fi
NEWEST="$(printf '%s\n%s\n' "$CURRENT" "$TARGET" | sort -V | tail -n 1)"
if [ "$NEWEST" = "$CURRENT" ] && [ "$CURRENT" != "$TARGET" ]; then
echo
echo "!! Zurückstufen auf eine ältere Version: Wurde die Datenbank inzwischen migriert,"
echo " startet die alte Version nicht. Dann vorher die Sicherung von *vor* dem Update"
echo " zurückspielen (siehe $BACKUP_ROOT, Anleitung im README)."
printf " Trotzdem fortfahren? [j/N] "
read -r answer
[ "$answer" = "j" ] || [ "$answer" = "J" ] || exit 1
fi
if [ "$NEWEST" = "$TARGET" ]; then
echo
echo "==> Änderungen laut Changelog"
git show "$TARGET:CHANGELOG.md" | awk -v cur="${CURRENT#v}" '
/^## \[/ { if (index($0, "[" cur "]")) exit; show = ($0 !~ /Unreleased/) }
show' | head -n 60
fi
echo
echo "==> Sicherung"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP="$BACKUP_ROOT/$STAMP-$CURRENT"
install -d -m 0700 "$BACKUP_ROOT" "$BACKUP"
# Konsistente Kopie auch bei laufendem Dienst (SQLite-Online-Backup)
"$VENV/bin/python" - "$NETMOVER_DATA_DIR/netmover.db" "$BACKUP/netmover.db" <<'EOF'
import sqlite3, sys
src, dst = sqlite3.connect(sys.argv[1]), sqlite3.connect(sys.argv[2])
with dst:
src.backup(dst)
src.close(); dst.close()
EOF
for f in secret.key session.key; do
[ -f "$NETMOVER_DATA_DIR/$f" ] && cp -p "$NETMOVER_DATA_DIR/$f" "$BACKUP/"
done
[ -f "$ENV_FILE" ] && cp -p "$ENV_FILE" "$BACKUP/"
chmod 0600 "$BACKUP"/*
echo " $BACKUP"
# alte Sicherungen aufräumen
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -print | sort \
| head -n "-$KEEP_BACKUPS" | xargs -r rm -rf --
echo
echo "==> Version $TARGET auschecken und installieren"
git -c advice.detachedHead=false checkout --quiet "$TARGET"
"$SRC/deploy/install.sh" "$SRC" >/tmp/netmover-update.log 2>&1 || {
echo "install.sh ist fehlgeschlagen – Ausgabe: /tmp/netmover-update.log" >&2
tail -n 20 /tmp/netmover-update.log >&2
FAILED=1
}
echo
echo "==> Prüfung"
OK=0
if [ "${FAILED:-0}" != "1" ]; then
i=0
while [ $i -lt 30 ]; do
if "$VENV/bin/python" - "$NETMOVER_PORT" <<'EOF'
import json, sys, urllib.error, urllib.request
url = f"http://127.0.0.1:{sys.argv[1]}/healthz"
try:
body = urllib.request.urlopen(url, timeout=3).read()
except urllib.error.HTTPError as exc: # 503 = läuft, meldet aber Probleme
body = exc.read()
except OSError:
sys.exit(1)
data = json.loads(body)
print(f" Gesundheit: {data['status']}")
for p in data.get("problems", []):
print(f" – {p}")
EOF
then
OK=1
break
fi
i=$((i + 1))
sleep 1
done
fi
if [ "$OK" = "1" ] && systemctl is-active --quiet netmover; then
echo " Dienst läuft: $("$VENV/bin/netmover" version)"
echo
echo "Fertig. Sicherung: $BACKUP"
exit 0
fi
echo
echo "!! Der Dienst läuft nach dem Update nicht richtig." >&2
echo " Log: journalctl -u netmover -n 50" >&2
echo " Zurück auf $CURRENT (Datenbank aus der Sicherung, weil sie migriert sein kann):" >&2
echo " systemctl stop netmover" >&2
echo " cp $BACKUP/netmover.db $NETMOVER_DATA_DIR/netmover.db" >&2
echo " rm -f $NETMOVER_DATA_DIR/netmover.db-wal $NETMOVER_DATA_DIR/netmover.db-shm" >&2
echo " netmover-update $CURRENT" >&2
exit 1
+382
View File
@@ -0,0 +1,382 @@
# NetMover – Anleitung {#anleitung}
NetMover holt Dateien regelmäßig aus einem Ordner und bringt sie sicher in einen
anderen. Typisch: Scans landen auf dem NAS, NetMover schiebt sie alle 30 Sekunden
in den Eingangsordner von Paperless.
Diese Anleitung erklärt die Bedienung. Installation, Updates und technische Details
stehen im [README](../README.md).
**Inhalt:** [Begriffe](#begriffe) · [Erste Anmeldung](#erste-anmeldung) ·
[Anbindungen](#anbindungen) · [Schlüssel](#schluessel) · [Regeln](#regeln) ·
[Probelauf](#probelauf) · [Status](#status) · [Wartungsmodus](#wartungsmodus) · [Protokoll](#protokoll) ·
[Einstellungen](#einstellungen) · [Uptime Kuma](#uptime-kuma) · [Beispiele](#beispiele) · [Häufige Fragen](#fragen)
## Die vier Begriffe {#begriffe}
| Begriff | Was es ist | Beispiel |
|---|---|---|
| **Anbindung** | Ein Ort, an den NetMover herankommt: eine SMB-Freigabe, ein NFS-Export oder ein Server per SFTP | „NAS“ = `//10.0.10.6/Scans` |
| **Schlüssel** | Ein gespeicherter SSH-Schlüssel für SFTP-Anbindungen | „paperless-lxc“ |
| **Regel** | „Alle N Sekunden aus Anbindung A nach Anbindung B“ – mit Filter, Sicherheitsprüfungen und Verhalten bei gleichen Namen | „Scans → Paperless“ |
| **Lauf** | Eine Ausführung einer Regel; steht mit allen Dateien im Protokoll | 23:41:07, 3 Dateien verschoben |
Die Reihenfolge beim Einrichten ist immer dieselbe – und so ist auch das Menü
aufgebaut: **Anbindungen → (Schlüssel) → Regeln → Status und Protokoll beobachten.**
![Statusseite](img/status.png)
## Erste Anmeldung {#erste-anmeldung}
Beim allerersten Aufruf verlangt NetMover ein Administratorpasswort (mindestens 10
Zeichen) und einen **Einrichtungscode**. Der Code steht im Log des Dienstes:
```bash
journalctl -u netmover | grep setup.required
```
Der Code verhindert, dass jemand anderes im Netz zuerst die Seite öffnet und das
Passwort festlegt. Passwort ändern: *System → Einstellungen*.
## Anbindungen {#anbindungen}
*Verbindungen → Anbindungen → Neue Anbindung*
![Anbindung bearbeiten](img/anbindung.png)
### Typ wählen {#anbindung-typ}
| Typ | Wofür | Kann |
|---|---|---|
| **SMB/CIFS** | Windows-Freigaben, Synology, QNAP, … | Quelle und Ziel |
| **NFS 4 / NFS 3** | Linux-Server, NAS mit NFS | Quelle und Ziel |
| **SFTP** | Server, die nur per SSH erreichbar sind (z. B. ein LXC mit Paperless) | nur Ziel |
SMB- und NFS-Anbindungen werden **eingehängt** und erscheinen unter
`/mnt/netmover/<name>`. SFTP wird nicht eingehängt, NetMover verbindet sich bei
jedem Lauf.
### Felder {#anbindung-felder}
| Feld | Bedeutung |
|---|---|
| **Name** | Frei wählbar. Bestimmt auch das Verzeichnis unter `/mnt/netmover` (wird beim Anlegen festgelegt). |
| **Server** | IP-Adresse oder Hostname, z. B. `10.0.10.6` |
| **Freigabe / Pfad** | SMB: Freigabename, optional mit Unterordner (`Scans` oder `Scans/privat`). NFS: Exportpfad (`/volume1/scans`). SFTP: absoluter Ordner auf dem Server (`/opt/paperless_data/consume`). |
| **Benutzername / Passwort** | Bei SMB leer lassen für Gastzugang. Beim Bearbeiten heißt ein leeres Passwortfeld „unverändert“ – Passwörter werden nie angezeigt. |
| **Anmeldeart, Schlüssel** (SFTP) | SSH-Schlüssel (gespeichert oder nur für diese Anbindung) oder Passwort, siehe [Schlüssel](#schluessel). |
| **uid, gid, file_mode, dir_mode** (SMB) | Wem die Dateien im eingehängten Ordner gehören. Meist leer lassen. |
| **Zusatzoptionen** | Für Sonderfälle, z. B. `vers=3.0` oder `nohandlecache`. Nur erlaubte Optionen; Zugangsdaten setzt NetMover selbst. |
| **Beim Start automatisch einhängen** | Nach einem Neustart des Servers wieder einhängen. Fällt das NAS aus, versucht NetMover es außerdem jede Minute erneut. |
| **Aktiv** | Deaktivierte Anbindungen werden von Regeln nicht benutzt. |
### Testen {#anbindung-testen}
**Testen** prüft drei Dinge einzeln und sagt im Klartext, was nicht klappt:
1. **Einhängen** bzw. **Verbinden** (SFTP)
2. **Lesen** – Inhalt des Ordners auflisten
3. **Schreiben** – Testdatei anlegen, Eigentümer prüfen, wieder löschen
Daraus folgt, wofür die Anbindung taugt: **nur Quelle, kopieren** (nur lesen),
**Quelle mit Löschen** (lesen und löschen – nötig zum Verschieben) oder **Ziel**.
Man kann schon im Formular testen, bevor man speichert.
Bei SFTP hält der erste erfolgreiche Test den **Hostschlüssel** des Servers fest. Den
angezeigten Fingerabdruck einmal auf dem Server vergleichen:
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`. Ändert er sich später,
verweigert NetMover die Verbindung (Schutz gegen untergeschobene Server). Wurde der
Server neu aufgesetzt: bei der Anbindung **Hostschlüssel zurücksetzen** und neu testen.
### Zustände {#anbindung-zustaende}
| Anzeige | Bedeutung |
|---|---|
| **eingehängt** / **erreichbar** | alles gut |
| **ausgehängt** / **nicht geprüft** | nicht in Benutzung |
| **gestört** | Antwortet nicht oder meldet „Stale file handle“; NetMover hängt gerade neu ein |
| **Fehler** | Wiederherstellung gescheitert; NetMover versucht es jede Minute weiter (bei „automatisch einhängen“) |
Regeln, deren Anbindung nicht verfügbar ist, **brechen ab, ohne etwas anzufassen**.
Es wird nie in ein leeres Verzeichnis geschrieben, nur weil die Freigabe fehlt.
Solche Regeln **warten** automatisch, bis die Anbindung zurück ist (siehe
[Status](#status)), und laufen dann von selbst weiter.
## Schlüssel {#schluessel}
*Verbindungen → Schlüssel*
Für SFTP-Ziele ist ein SSH-Schlüssel sicherer als ein Passwort. Einen Schlüssel kann
man für mehrere Anbindungen verwenden; den Benutzernamen legt jede Anbindung fest.
![Schlüssel](img/schluessel.png)
**Neuen Schlüssel erzeugen** – Namen eingeben, *Ed25519-Schlüssel erzeugen*. Dann:
1. Bei dem Schlüssel *Öffentlicher Schlüssel / authorized_keys* aufklappen und die
**authorized_keys-Zeile** kopieren. Sie beginnt mit
`restrict,command="/usr/lib/openssh/sftp-server"` – damit darf der Schlüssel
**nur SFTP**, keine Shell.
2. Auf dem Zielserver an die Datei `~/.ssh/authorized_keys` des Benutzers anhängen
(Ordner `~/.ssh` mit Rechten `700`, Datei `600`, Eigentümer der Benutzer).
3. In der Anbindung *Anmeldeart: SSH-Schlüssel* und den Schlüssel wählen, **Testen**.
**Vorhandenen Schlüssel importieren** – privaten Schlüssel einfügen (OpenSSH- oder
PEM-Format; Ed25519, ECDSA oder RSA ab 2048 Bit). Hat er eine Passphrase, diese
angeben: Sie wird nur zum Entschlüsseln benutzt, der Schlüssel liegt danach
verschlüsselt in NetMovers Datenbank.
Private Schlüssel zeigt NetMover nie an. Ein Schlüssel lässt sich nur löschen, wenn
ihn keine Anbindung mehr nutzt.
## Regeln {#regeln}
*Automatisierung → Regeln → Neue Regel*
![Regel bearbeiten](img/regel.png)
### Quelle und Ziel {#regel-quelle-ziel}
| Feld | Bedeutung |
|---|---|
| **Quelle: Anbindung, Unterordner** | Woher. Unterordner relativ zur Freigabe; leer = Wurzel der Freigabe. |
| **Filter** | Glob: Dateiname, Groß/klein egal, mehrere mit `;` (`*.pdf;*.jpg`). Regulärer Ausdruck: gegen den relativen Pfad (`^rechnungen/.*\.pdf$`). |
| **Unterordner einbeziehen** | Auch in Unterordnern suchen; die Ordnerstruktur bleibt im Ziel erhalten. |
| **Ziel** | Eine Anbindung mit Zielordner (wird angelegt) oder ein **lokaler Pfad** auf dem NetMover-Server (muss existieren). |
| **Aktion** | **verschieben** (Quelle wird nach erfolgreicher Übertragung gelöscht) oder **kopieren** (Quelle bleibt; jede Dateiversion wird nur einmal kopiert). |
| **Wenn Datei existiert** | **umbenennen** (`scan.pdf` → `scan-1.pdf`, `scan-2.pdf` …), **überspringen** oder **überschreiben**. |
### Sicherheit und Grenzen {#regel-sicherheit}
| Feld | Bedeutung | Empfehlung |
|---|---|---|
| **Intervall** | Wie oft geprüft wird (mind. 10 s) | 30 s |
| **Stabilitätsfenster** | Größe **und** Änderungszeit müssen so lange gleich bleiben. Schützt vor halb geschriebenen Scans. | 5 s, bei langsamen Scannern mehr |
| **Mindestalter** | Dateien erst ab diesem Alter anfassen | 0 |
| **Max. Größe** | Größere Dateien überspringen | leer |
| **Zeitlimit ohne Fortschritt** | Bricht einen Lauf ab, der hängt (z. B. NFS-Server weg). Große Dateien, die stetig übertragen werden, sind nicht betroffen. | 120 s |
Immer ignoriert werden versteckte Dateien (`.datei`), unfertige Übertragungen
(`.part`, `.tmp`, `.crdownload`, `.filepart`, `.!ut`), Verknüpfungen und
Synology-Verwaltungsordner (`@eaDir`, `#recycle`).
### Rechte und Quarantäne {#regel-rechte}
| Feld | Bedeutung |
|---|---|
| **Eigentümer setzen** | `uid:gid`, z. B. `1000:1000`. Auf SMB-Zielen wirkungslos (dort bestimmen die Mount-Optionen den Eigentümer), auf NFS nur ohne Root-Squash. |
| **Modus setzen** | z. B. `0644`; leer = wie die Quelldatei |
| **Quarantäneordner** | Dateien, die mehrmals hintereinander scheitern, werden dorthin verschoben (relativ zur Quell-Anbindung), statt es endlos zu versuchen. Leer = keine Quarantäne. |
### Probelauf {#probelauf}
**Probelauf** zeigt in einer Tabelle, welche Datei wohin ginge – **ohne etwas zu
verändern**. Vor dem ersten Aktivieren einer Regel immer einmal machen.
Das Häkchen **Nur Probelauf** in der Regel lässt sie regelmäßig laufen, ohne etwas zu
verändern – gut zum Beobachten über ein paar Tage.
### Wie verschoben wird {#wie-verschoben-wird}
Für jede Datei:
1. zweimal messen (Abstand = Stabilitätsfenster) – nur unveränderte Dateien weiter,
2. freien Platz am Ziel prüfen (Größe + 5 %),
3. unter einem versteckten Namen `.netmover-….part` ins Ziel schreiben und auf die
Platte zwingen,
4. prüfen, dass sich die Quelle in der Zwischenzeit nicht verändert hat,
5. in einem Schritt auf den Endnamen umbenennen – ohne etwas zu überschreiben,
6. **erst dann** die Quelle löschen.
Geht unterwegs etwas schief, bleibt die Quelle liegen und im Ziel steht nichts
Halbes. Ein Programm, das den Zielordner überwacht (Paperless), sieht nie eine
unfertige Datei.
## Status {#status}
*Übersicht → Status* ist die Startseite und aktualisiert sich alle 15 Sekunden.
- **Ampel** oben links: grün = alles in Ordnung, rot = mindestens ein Problem.
- **Anbindungen** mit Zustand, Füllstand und letztem Fehler.
- **Regeln** mit letztem Ergebnis, **letztem Erfolg** und nächstem Lauf.
Eine Regel wird **gelb**, wenn ihr letzter erfolgreicher Lauf länger als das
Dreifache ihres Intervalls zurückliegt. So fällt auf, wenn NetMover still ausfällt
– statt dass es aussieht, als gäbe es nichts zu tun.
### Regel wartet auf eine Anbindung {#wartet}
Ist die Quelle oder das Ziel einer Regel nicht erreichbar (NAS neu gestartet, Server
aus), **pausiert die Regel automatisch**: Sie zeigt „wartet seit …“ mit dem Grund.
- Im Protokoll steht **ein** Eintrag „Pausiert, bis die Anbindung zurück ist“ – nicht
alle 30 Sekunden ein neuer Fehler.
- Es kommt **eine** Benachrichtigung pro Anbindung, mit der Liste der betroffenen
Regeln.
- Sobald die Anbindung zurück ist, **läuft die Regel von selbst weiter**; der nächste
Lauf vermerkt „Fortgesetzt nach … Wartezeit“, und es kommt eine Entwarnung.
Man muss also nach einem NAS-Neustart nichts tun.
### Wartungsmodus {#wartungsmodus}
Der Knopf **Wartungsmodus** auf der Statusseite pausiert **alle geplanten Läufe** –
etwa während eines NAS-Updates. Oben erscheint dann gelb „Wartungsmodus“. „Jetzt
ausführen“ geht weiterhin. Für Regeln meldet `/healthz` in dieser Zeit keinen Alarm
(Anbindungen werden weiter geprüft). Mit **Wartungsmodus beenden** läuft alles
wieder nach Plan.
## Protokoll {#protokoll}
*Automatisierung → Protokoll* zeigt jeden Lauf: Zeitpunkt, Dauer, gesehen /
übertragen / übersprungen / Fehler. *Einzeldateien* aufklappen zeigt jede Datei mit
Ziel und Ergebnis. Leere Läufe sind ausgeblendet („auch leere Läufe“ zeigt sie).
Häufige Einträge:
| Eintrag | Bedeutung |
|---|---|
| *wird noch geschrieben* | Datei ändert sich noch – im nächsten Lauf erneut |
| *Datei ist gerade in Benutzung* | Ein anderes Programm hält sie gesperrt |
| *existiert bereits im Ziel* | Regel steht auf „überspringen“ |
| *Quelle hat sich während der Übertragung geändert* | Abgebrochen, nichts gelöscht; nächster Lauf versucht es erneut |
| *n bereits übertragen* | Kopiermodus: diese Dateiversionen sind schon kopiert |
| *Termin übersprungen – voriger Lauf läuft noch* | Ein Lauf dauert länger als das Intervall |
| *Pausiert, bis die Anbindung zurück ist* | Quelle oder Ziel nicht erreichbar; die Regel wartet, siehe [Regel wartet](#wartet) |
| *Fortgesetzt nach … Wartezeit* | Die Anbindung ist zurück, die Regel läuft wieder |
Protokolle werden nach der eingestellten Frist gelöscht (Standard 30 Tage).
## Einstellungen {#einstellungen}
*System → Einstellungen*
### Benachrichtigungen {#benachrichtigungen}
NetMover meldet Probleme per **E-Mail** und/oder an einen **Webhook** (z. B. ntfy,
Gotify, Home Assistant). Beides lässt sich gleichzeitig nutzen.
| Feld | Bedeutung |
|---|---|
| **Benachrichtigen** | nie / bei jedem fehlgeschlagenen Lauf / erst nach N Fehlschlägen in Folge. Fällt eine **Anbindung** aus, kommt die Meldung immer sofort (einmal, mit den betroffenen Regeln). |
| **Entwarnung** | Nachricht, wenn eine Regel oder Anbindung wieder funktioniert |
**E-Mail:**
| Feld | Bedeutung |
|---|---|
| **SMTP-Server, Port, Verschlüsselung** | z. B. `smtp.example.org`, `587` mit STARTTLS oder `465` mit SSL/TLS. „keine“ nur für interne Relays. Zertifikate werden immer geprüft. |
| **Benutzer, Passwort** | Anmeldung am SMTP-Server; leer = ohne Anmeldung. Das Passwort wird verschlüsselt gespeichert und nie angezeigt. |
| **Absender, Empfänger** | Empfänger mit Komma getrennt |
*Test-E-Mail senden* prüft die gespeicherten Einstellungen und zeigt bei Fehlern die
Antwort des Servers.
**Webhook:**
| Feld | Bedeutung |
|---|---|
| **Webhook-URL** | Adresse des Empfängers. Wird nur gekürzt angezeigt, weil sie oft ein Token enthält. |
| **Content-Type, Vorlage** | Format der Nachricht; Platzhalter `$title`, `$message`, `$rule`, `$event`, `$time`, `$host` |
Beispiele: ntfy → URL `https://ntfy.sh/<thema>`, Content-Type `text/plain`, Vorlage
`$title: $message`. Gotify → URL `https://gotify.lan/message?token=…`, Content-Type
`application/json`, Vorlage `{"title": "$title", "message": "$message"}`.
*Test senden* prüft die gespeicherten Einstellungen.
### Überwachung mit Uptime Kuma {#uptime-kuma}
Zwei Wege, die sich gut ergänzen:
**1. Push (empfohlen)** – NetMover meldet sich jede Minute bei Kuma.
1. In Kuma *Neuer Monitor → Typ „Push“*, Heartbeat-Intervall 60 s. Kuma zeigt eine
Push-URL wie `https://kuma.lan/api/push/AbCdEf?status=up&msg=OK&ping=`.
2. In NetMover *System → Einstellungen → Überwachung* die URL eintragen, speichern,
*Push jetzt testen*.
NetMover meldet „up“ oder „down“ samt Problembeschreibung. Bleibt die Meldung ganz
aus – Dienst oder VM steht –, schlägt Kuma von selbst Alarm. Funktioniert auch, wenn
Kuma NetMover nicht erreichen kann.
**2. HTTP-Monitor** – Kuma fragt NetMover ab. Antwort **200** = in Ordnung,
**503** = Problem; ohne Anmeldung erreichbar.
| Adresse | Prüft |
|---|---|
| `http://<netmover>:8080/healthz` | alles: Scheduler, alle aktiven Anbindungen und Regeln |
| `http://<netmover>:8080/healthz/mount/<name>` | eine Anbindung (Name wie im Verzeichnis unter `/mnt/netmover`, z. B. `nas`) |
| `http://<netmover>:8080/healthz/rule/<nummer>` | eine Regel |
Die fertigen Adressen stehen unter *Einstellungen → Überwachung → Adressen für
HTTP-Monitore*. Mit einem Monitor pro Anbindung und Regel sieht man in Kuma sofort,
*was* gestört ist. `/healthz` liefert außerdem JSON mit allen Einzelprüfungen
(Kuma-Typ „HTTP(s) – JSON-Abfrage“, Ausdruck `status`, erwarteter Wert `ok`).
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.
### Export und Import {#export-import}
*Konfiguration exportieren* lädt Anbindungen, Regeln und Einstellungen als JSON
herunter – **ohne** Passwörter, Schlüssel, Benutzernamen und Webhook-URL. Beim
Import werden vorhandene Namen übersprungen; importierte Regeln sind zunächst
deaktiviert, Zugangsdaten müssen neu eingegeben werden.
## Typische Einrichtungen {#beispiele}
### Scanner → NAS → Paperless {#beispiel-paperless}
1. Anbindung **NAS** (SMB, Freigabe `Scans`), testen, einhängen.
2. Schlüssel **paperless-lxc** erzeugen, Zeile in `authorized_keys` im Paperless-LXC
eintragen.
3. Anbindung **Paperless** (SFTP, Pfad `/opt/paperless_data/consume`, Schlüssel
wählen, Benutzer = der Paperless-Benutzer), testen.
4. Regel **Scans → Paperless**: Quelle NAS, Filter `*.pdf`, Ziel Paperless,
verschieben, umbenennen, 30 s. **Probelauf**, dann aktivieren.
### Jede Person in einen eigenen Ordner {#beispiel-unterordner}
Scannt jede Person in einen eigenen Unterordner (`Scans/max`, `Scans/erika`): pro
Person eine Regel mit eigenem **Unterordner** in der Quelle und eigenem Ziel.
### Kopie als zusätzliche Sicherung {#beispiel-kopie}
Regel mit Aktion **kopieren**, Ziel z. B. ein zweites NAS, Intervall 3600 s. Jede
Dateiversion wird genau einmal kopiert; ändert sich eine Datei, wird die neue Version
nach der Kollisionsregel abgelegt.
## Häufige Fragen {#fragen}
**Eine Datei wird nicht verschoben.** – Im Protokoll der Regel (auch leere Läufe
anzeigen) steht der Grund. Häufig: Filter passt nicht (Groß/klein ist bei Glob egal,
bei regulären Ausdrücken nicht), Datei wird noch geschrieben oder ist größer als
„Max. Größe“.
**Die Anbindung zeigt „Fehler“.** – Die Meldung darunter nennt die Ursache und meist
die Abhilfe. Ist das NAS nur neu gestartet, hängt NetMover die Freigabe innerhalb
einer Minute selbst wieder ein; die Regeln warten so lange und laufen dann weiter.
**Ich mache gleich ein NAS-Update.** – Vorher auf der Statusseite *Wartungsmodus*
einschalten, danach wieder beenden. Nötig ist es nicht (die Regeln warten ohnehin
von selbst), aber so gibt es keine Alarme.
**„mount error(95): Operation not supported“** – NAS und NetMover finden keine
gemeinsame SMB-Version. Am NAS das maximale SMB-Protokoll auf SMB3 stellen
(Synology: Systemsteuerung → Dateidienste → SMB → Erweiterte Einstellungen) oder
Zusatzoption `vers=2.0`.
**„Root-Squash“ beim Testen einer NFS-Anbindung** – Der NFS-Server behandelt
NetMover wie einen Gast. Lesen und Schreiben gehen oft trotzdem, aber „Eigentümer
setzen“ nicht. Abhilfe am NAS in der NFS-Regel: *Squash → Keine Zuordnung*.
**Kann NetMover Dateien verlieren?** – Die Quelle wird erst gelöscht, wenn die Datei
vollständig und unter ihrem Endnamen im Ziel liegt. Fehlt eine Anbindung, wird nichts
angefasst. Einzig „überschreiben“ als Kollisionsregel ersetzt bewusst Dateien im Ziel.
**Wie aktualisiere ich NetMover?** – Auf dem Server `netmover-update`. Vorher wird
automatisch gesichert. Details im README.
**Passwort vergessen?** – Auf dem Server `/opt/netmover/venv/bin/netmover
reset-password`, Dienst neu starten, Einrichtung mit neuem Code aus dem Log.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+8
View File
@@ -25,6 +25,7 @@ dependencies = [
"itsdangerous>=2.2", "itsdangerous>=2.2",
"httpx>=0.27", "httpx>=0.27",
"paramiko>=3.4", "paramiko>=3.4",
"markdown>=3.5",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -33,6 +34,9 @@ dev = [
"ruff>=0.6", "ruff>=0.6",
"mypy>=1.11", "mypy>=1.11",
"types-paramiko", "types-paramiko",
"types-Markdown",
"shellcheck-py",
"aiosmtpd",
] ]
[project.scripts] [project.scripts]
@@ -44,6 +48,10 @@ path = "src/netmover/__init__.py"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/netmover"] packages = ["src/netmover"]
# Die Anleitung liegt für Gitea unter docs/ und wird für die Hilfe-Seite mitgeliefert.
[tool.hatch.build.targets.wheel.force-include]
"docs" = "netmover/web/help"
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
addopts = "-ra" addopts = "-ra"
+1 -1
View File
@@ -1,3 +1,3 @@
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
__version__ = "0.1.1" __version__ = "0.4.0"
+53 -14
View File
@@ -84,6 +84,9 @@ def _int(
# -- Anbindungen ------------------------------------------------------------------------ # -- Anbindungen ------------------------------------------------------------------------
NEW_KEY = "new"
@dataclass @dataclass
class MountInput: class MountInput:
name: str = "" name: str = ""
@@ -95,6 +98,9 @@ class MountInput:
password: str = "" # leer = unverändert password: str = "" # leer = unverändert
domain: str = "" domain: str = ""
auth: CredentialKind = CredentialKind.PASSWORD # nur sftp: Schlüssel oder Passwort auth: CredentialKind = CredentialKind.PASSWORD # nur sftp: Schlüssel oder Passwort
# nur sftp + Schlüssel: "new" = eigener Schlüssel nur für diese Anbindung,
# sonst ID eines Schlüssels aus der Schlüsselverwaltung
key_id: str = NEW_KEY
uid: int | None = None uid: int | None = None
gid: int | None = None gid: int | None = None
file_mode: str | None = None file_mode: str | None = None
@@ -126,9 +132,10 @@ def mount_input_from_model(m: Mount) -> MountInput:
server=m.server, server=m.server,
share=m.share, share=m.share,
port=m.port, port=m.port,
username=c.username if c else "", username=m.username or (c.username if c else ""),
domain=(c.domain or "") if c else "", domain=(c.domain or "") if c else "",
auth=c.kind if c else CredentialKind.PASSWORD, auth=c.kind if c else CredentialKind.PASSWORD,
key_id=str(c.id) if c is not None and c.managed else NEW_KEY,
uid=m.uid, uid=m.uid,
gid=m.gid, gid=m.gid,
file_mode=m.file_mode, file_mode=m.file_mode,
@@ -151,7 +158,11 @@ def parse_mount_form(form: Mapping[str, str]) -> tuple[MountInput, Errors]:
extra_options=(form.get("extra_options") or "").strip(), extra_options=(form.get("extra_options") or "").strip(),
auto_mount=_bool(form, "auto_mount"), auto_mount=_bool(form, "auto_mount"),
enabled=_bool(form, "enabled"), enabled=_bool(form, "enabled"),
key_id=(form.get("key_id") or NEW_KEY).strip(),
) )
if data.key_id != NEW_KEY and not data.key_id.isdigit():
errors["key_id"] = "Ungültige Schlüsselauswahl."
data.key_id = NEW_KEY
try: try:
data.type = MountType(form.get("type", "cifs")) data.type = MountType(form.get("type", "cifs"))
except ValueError: except ValueError:
@@ -203,17 +214,27 @@ def parse_mount_form(form: Mapping[str, str]) -> tuple[MountInput, Errors]:
errors["username"] = "SFTP braucht einen Benutzernamen." errors["username"] = "SFTP braucht einen Benutzernamen."
if data.type is not MountType.SFTP: if data.type is not MountType.SFTP:
data.auth = CredentialKind.PASSWORD data.auth = CredentialKind.PASSWORD
if data.auth is not CredentialKind.SSH_KEY:
data.key_id = NEW_KEY
return data, errors return data, errors
def credential_for_test( def credential_for_test(
data: MountInput, existing: Mount | None, secrets: SecretBox data: MountInput, existing: Mount | None, secrets: SecretBox, s: Session | None = None
) -> CredentialData | None: ) -> CredentialData | None:
"""Zugangsdaten für „Testen“ aus dem Formular – leeres Passwort = gespeichertes.""" """Zugangsdaten für „Testen“ aus dem Formular – leeres Passwort = gespeichertes,
gewählter Schlüssel aus der Schlüsselverwaltung."""
if data.type in (MountType.NFS3, MountType.NFS4) or not data.username: if data.type in (MountType.NFS3, MountType.NFS4) or not data.username:
return None return None
if data.auth is CredentialKind.SSH_KEY and data.key_id != NEW_KEY and s is not None:
key = s.get(Credential, int(data.key_id))
if key is None or not key.managed:
return None
return CredentialData(data.auth, data.username, secrets.decrypt(key.secret_enc))
secret = data.password secret = data.password
stored = existing.credential if existing is not None else None stored = existing.credential if existing is not None else None
if stored is not None and stored.managed:
stored = None # eigener Schlüssel gewählt, gespeicherter ist nicht gemeint
if not secret and stored is not None and stored.kind is data.auth: if not secret and stored is not None and stored.kind is data.auth:
secret = secrets.decrypt(stored.secret_enc) secret = secrets.decrypt(stored.secret_enc)
if not secret: if not secret:
@@ -296,20 +317,32 @@ def save_mount(
if not data.type.is_kernel_mount: if not data.type.is_kernel_mount:
m.auto_mount = False m.auto_mount = False
# Zugangsdaten: jede Anbindung pflegt ihre eigenen. # Zugangsdaten: entweder ein gespeicherter Schlüssel aus der Schlüsselverwaltung
cred = m.credential # (geteilt, wird hier nie verändert) oder eigene Zugangsdaten nur dieser Anbindung.
current = m.credential
own = current if current is not None and not current.managed else None
needs_cred = data.type is MountType.SFTP or ( needs_cred = data.type is MountType.SFTP or (
data.type is MountType.CIFS and bool(data.username) data.type is MountType.CIFS and bool(data.username)
) )
use_managed = (
data.type is MountType.SFTP
and data.auth is CredentialKind.SSH_KEY
and data.key_id != NEW_KEY
)
if not needs_cred: if not needs_cred:
if cred is not None:
m.credential = None m.credential = None
s.flush() m.username = None
if not cred.mounts: elif use_managed:
s.delete(cred) key = s.get(Credential, int(data.key_id))
if key is None or not key.managed or key.kind is not CredentialKind.SSH_KEY:
s.rollback()
return None, {"key_id": "Schlüssel existiert nicht."}
m.credential = key
m.username = data.username
else: else:
kind_changed = cred is not None and cred.kind is not data.auth m.username = None
if cred is None or kind_changed: cred = own
if cred is None or cred.kind is not data.auth:
if data.auth is CredentialKind.SSH_KEY: if data.auth is CredentialKind.SSH_KEY:
private, public = generate_ssh_keypair(f"netmover@{m.slug}") private, public = generate_ssh_keypair(f"netmover@{m.slug}")
secret, pub = private, public secret, pub = private, public
@@ -320,9 +353,8 @@ def save_mount(
return None, errors return None, errors
secret, pub = data.password, None secret, pub = data.password, None
if cred is None: if cred is None:
cred = Credential(name=f"mount:{m.slug}") cred = Credential(name=f"mount:{m.slug}", managed=False)
s.add(cred) s.add(cred)
m.credential = cred
cred.kind = data.auth cred.kind = data.auth
cred.secret_enc = secrets.encrypt(secret) cred.secret_enc = secrets.encrypt(secret)
cred.public_key = pub cred.public_key = pub
@@ -330,6 +362,12 @@ def save_mount(
cred.secret_enc = secrets.encrypt(data.password) cred.secret_enc = secrets.encrypt(data.password)
cred.username = data.username cred.username = data.username
cred.domain = data.domain or None cred.domain = data.domain or None
m.credential = cred
# Eigene Zugangsdaten, die nicht mehr gebraucht werden, entfernen.
if own is not None and m.credential is not own:
s.flush()
if not own.mounts:
s.delete(own)
try: try:
s.flush() s.flush()
except IntegrityError: except IntegrityError:
@@ -353,7 +391,8 @@ def delete_mount(s: Session, mounts: MountManager, mount_id: int) -> None:
cred = m.credential cred = m.credential
s.delete(m) s.delete(m)
s.flush() s.flush()
if cred is not None and not cred.mounts: # Eigene Zugangsdaten gehen mit; Schlüssel aus der Schlüsselverwaltung bleiben.
if cred is not None and not cred.managed and not cred.mounts:
s.delete(cred) s.delete(cred)
+14 -2
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from netmover import kuma
from netmover.config import Settings from netmover.config import Settings
from netmover.crypto import SecretBox, load_or_create_key from netmover.crypto import SecretBox, load_or_create_key
from netmover.db import make_engine, make_session_factory, upgrade from netmover.db import make_engine, make_session_factory, upgrade
@@ -23,6 +24,7 @@ from netmover.mover.runner import RuleRunner
from netmover.notify import Notifier from netmover.notify import Notifier
from netmover.runs import RunService from netmover.runs import RunService
from netmover.scheduler import RuleScheduler from netmover.scheduler import RuleScheduler
from netmover.settings_store import load_settings
log = get_logger(__name__) log = get_logger(__name__)
@@ -48,7 +50,7 @@ class NetMover:
self.session_factory = make_session_factory(self.db_engine) self.session_factory = make_session_factory(self.db_engine)
self.secrets = SecretBox(load_or_create_key(settings.secret_key_path)) self.secrets = SecretBox(load_or_create_key(settings.secret_key_path))
self.backend = backend or make_backend(settings) self.backend = backend or make_backend(settings)
self.notifier = notifier or Notifier(self.session_factory) self.notifier = notifier or Notifier(self.session_factory, secrets=self.secrets)
self.mounts = MountManager( self.mounts = MountManager(
self.session_factory, self.session_factory,
self.backend, self.backend,
@@ -59,7 +61,10 @@ class NetMover:
self.runner = RuleRunner(self.session_factory, self.mounts) self.runner = RuleRunner(self.session_factory, self.mounts)
self.runs = RunService(self.session_factory, self.runner, self.notifier) self.runs = RunService(self.session_factory, self.runner, self.notifier)
self.scheduler = RuleScheduler( self.scheduler = RuleScheduler(
self.session_factory, self.runs, retry_mounts=self.mounts.retry_pending self.session_factory,
self.runs,
retry_mounts=self.mounts.retry_pending,
push_health=self.push_health,
) )
self.started_at: datetime = utcnow() self.started_at: datetime = utcnow()
self.restore_results: list[RestoreResult] = [] self.restore_results: list[RestoreResult] = []
@@ -76,10 +81,17 @@ class NetMover:
self.scheduler.shutdown() self.scheduler.shutdown()
self.db_engine.dispose() self.db_engine.dispose()
def push_health(self) -> None:
"""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())
def health(self) -> Health: def health(self) -> Health:
return check_health( return check_health(
self.session_factory, self.session_factory,
scheduler_running=self.scheduler.running, scheduler_running=self.scheduler.running,
last_heartbeat=self.scheduler.last_heartbeat, last_heartbeat=self.scheduler.last_heartbeat,
service_started=self.started_at, service_started=self.started_at,
maintenance=load_settings(self.session_factory).maintenance,
) )
+29
View File
@@ -33,6 +33,35 @@ def make_engine(url: str) -> Engine:
return engine return engine
def make_migration_engine(url: str) -> Engine:
"""Verbindung nur für Migrationen.
* Fremdschlüssel AUS: SQLite baut Tabellen bei Änderungen neu auf (Alembic
„batch mode“) und löscht dabei die alte Tabelle. Mit eingeschalteter Prüfung
scheitert das, sobald andere Tabellen darauf verweisen (Praxisbefund beim
Update auf 0.2.0). Die Integrität prüft ``PRAGMA foreign_key_check`` danach.
* Echte Transaktion: pysqlite eigene Transaktionssteuerung abnehmen und selbst
``BEGIN`` senden. So wird eine abgebrochene Migration vollständig
zurückgerollt, statt einen halben Zustand zu hinterlassen.
"""
engine = create_engine(url, connect_args={"check_same_thread": False})
if url.startswith("sqlite"):
def on_connect(dbapi_conn: Any, _record: Any) -> None:
dbapi_conn.isolation_level = None
cur = dbapi_conn.cursor()
cur.execute("PRAGMA foreign_keys=OFF") # außerhalb einer Transaktion setzen
cur.execute("PRAGMA busy_timeout=5000")
cur.close()
def on_begin(conn: Any) -> None:
conn.exec_driver_sql("BEGIN IMMEDIATE")
event.listen(engine, "connect", on_connect)
event.listen(engine, "begin", on_begin)
return engine
def make_session_factory(engine: Engine) -> sessionmaker[Session]: def make_session_factory(engine: Engine) -> sessionmaker[Session]:
return sessionmaker(bind=engine, expire_on_commit=False) return sessionmaker(bind=engine, expire_on_commit=False)
+97 -24
View File
@@ -1,18 +1,22 @@
"""Gesundheitszustand für ``/healthz`` und die Statusseite (Fallstrick 9). """Gesundheitszustand für ``/healthz``, Uptime Kuma und die Statusseite (Fallstrick 9).
Ein Werkzeug, das nichts mehr tut, darf nicht wie eines aussehen, das nichts zu Ein Werkzeug, das nichts mehr tut, darf nicht wie eines aussehen, das nichts zu
tun hat. Deshalb: tun hat. Geprüft wird:
* der Scheduler muss leben (Herzschlag nicht älter als 3 Minuten), * der Scheduler lebt (Herzschlag nicht älter als 3 Minuten),
* keine aktive Anbindung im Fehlerzustand, * jede aktive Anbindung ist nicht im Fehlerzustand,
* keine aktive Regel, deren letzter erfolgreicher Lauf länger als das Dreifache * jede aktive Regel hatte vor höchstens dem Dreifachen ihres Intervalls einen
ihres Intervalls zurückliegt. Bezugspunkt ist der späteste von letztem Erfolg, erfolgreichen Lauf (Bezug: spätester von letztem Erfolg, Anlage der Regel und
Anlage der Regel und Dienststart – sonst wäre direkt nach einem Neustart alles rot. Dienststart – sonst wäre direkt nach einem Neustart alles rot) und ist nicht
mehrmals hintereinander gescheitert.
Jeder Punkt ist ein eigener :class:`Check` – so kann Uptime Kuma auch einzelne
Anbindungen und Regeln überwachen (``/healthz/mount/<slug>``, ``/healthz/rule/<id>``).
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy import select from sqlalchemy import select
@@ -22,18 +26,42 @@ from netmover.db import session_scope
from netmover.models import Mount, MountState, Rule, utcnow from netmover.models import Mount, MountState, Rule, utcnow
STALE_FACTOR = 3 STALE_FACTOR = 3
FAILURES_ALERT = 3
HEARTBEAT_MAX_AGE = timedelta(minutes=3) HEARTBEAT_MAX_AGE = timedelta(minutes=3)
@dataclass
class Check:
kind: str # "scheduler" | "mount" | "rule"
name: str
ok: bool
message: str
id: int | None = None
slug: str | None = None
def as_dict(self) -> dict[str, object]:
return {k: v for k, v in asdict(self).items() if v is not None}
@dataclass @dataclass
class Health: class Health:
ok: bool = True ok: bool = True
problems: list[str] = field(default_factory=list) problems: list[str] = field(default_factory=list)
stale_rules: set[int] = field(default_factory=set) stale_rules: set[int] = field(default_factory=set)
checks: list[Check] = field(default_factory=list)
maintenance: bool = False
def fail(self, problem: str) -> None: def add(self, check: Check) -> None:
self.checks.append(check)
if not check.ok:
self.ok = False self.ok = False
self.problems.append(problem) self.problems.append(check.message)
def find(self, kind: str, *, slug: str | None = None, id: int | None = None) -> Check | None:
for c in self.checks:
if c.kind == kind and (slug is None or c.slug == slug) and (id is None or c.id == id):
return c
return None
def rule_is_stale(rule: Rule, service_started: datetime, now: datetime) -> bool: def rule_is_stale(rule: Rule, service_started: datetime, now: datetime) -> bool:
@@ -45,6 +73,50 @@ def rule_is_stale(rule: Rule, service_started: datetime, now: datetime) -> bool:
return now - reference > timedelta(seconds=STALE_FACTOR * rule.interval_seconds) return now - reference > timedelta(seconds=STALE_FACTOR * rule.interval_seconds)
def _mount_check(m: Mount) -> Check:
def mk(ok: bool, message: str) -> Check:
return Check("mount", m.name, ok, message, id=m.id, slug=m.slug)
if not m.enabled:
return mk(True, f"Anbindung „{m.name}“ deaktiviert")
if m.state is MountState.ERROR:
detail = (m.last_error or "").strip()
return mk(False, f"Anbindung „{m.name}“ im Fehlerzustand: {detail}".strip())
if m.state is MountState.DEGRADED:
return mk(True, f"Anbindung „{m.name}“ gestört, wird neu eingehängt")
return mk(True, f"Anbindung „{m.name}“ in Ordnung")
def _rule_check(
r: Rule, service_started: datetime, now: datetime, maintenance: bool = False
) -> Check:
def mk(ok: bool, message: str) -> Check:
return Check("rule", r.name, ok, message, id=r.id)
if not r.enabled:
return mk(True, f"Regel „{r.name}“ deaktiviert")
if maintenance:
return mk(True, f"Regel „{r.name}“ pausiert (Wartungsmodus)")
if r.waiting_since is not None:
return mk(
False,
f"Regel „{r.name}“ wartet seit {r.waiting_since:%Y-%m-%d %H:%M} UTC auf eine "
f"Anbindung: {r.waiting_reason or ''}".strip(),
)
if rule_is_stale(r, service_started, now):
ago = (
"noch nie"
if r.last_success_at is None
else f"seit {r.last_success_at:%Y-%m-%d %H:%M} UTC"
)
return mk(False, f"Regel „{r.name}“: kein erfolgreicher Lauf {ago}")
if r.consecutive_failures >= FAILURES_ALERT:
return mk(
False, f"Regel „{r.name}“: {r.consecutive_failures} fehlgeschlagene Läufe in Folge"
)
return mk(True, f"Regel „{r.name}“ in Ordnung")
def check_health( def check_health(
session_factory: sessionmaker[Session], session_factory: sessionmaker[Session],
*, *,
@@ -52,24 +124,25 @@ def check_health(
last_heartbeat: datetime | None, last_heartbeat: datetime | None,
service_started: datetime, service_started: datetime,
now: datetime | None = None, now: datetime | None = None,
maintenance: bool = False,
) -> Health: ) -> Health:
now = now or utcnow() now = now or utcnow()
health = Health() health = Health()
health.maintenance = maintenance
if not scheduler_running: if not scheduler_running:
health.fail("Scheduler läuft nicht") health.add(Check("scheduler", "Scheduler", False, "Scheduler läuft nicht"))
elif last_heartbeat is None or now - last_heartbeat > HEARTBEAT_MAX_AGE: elif last_heartbeat is None or now - last_heartbeat > HEARTBEAT_MAX_AGE:
health.fail("Scheduler reagiert nicht (kein Herzschlag)") health.add(
with session_scope(session_factory) as s: Check("scheduler", "Scheduler", False, "Scheduler reagiert nicht (kein Herzschlag)")
for m in s.scalars(select(Mount).where(Mount.enabled.is_(True))):
if m.state is MountState.ERROR:
health.fail(f"Anbindung „{m.name}“ im Fehlerzustand: {m.last_error or ''}".strip())
for r in s.scalars(select(Rule).where(Rule.enabled.is_(True))):
if rule_is_stale(r, service_started, now):
health.stale_rules.add(r.id)
ago = (
"noch nie"
if r.last_success_at is None
else f"seit {r.last_success_at:%Y-%m-%d %H:%M} UTC"
) )
health.fail(f"Regel „{r.name}“: kein erfolgreicher Lauf {ago}") else:
health.add(Check("scheduler", "Scheduler", True, "Scheduler läuft"))
with session_scope(session_factory) as s:
for m in s.scalars(select(Mount).order_by(Mount.name)):
health.add(_mount_check(m))
for r in s.scalars(select(Rule).order_by(Rule.name)):
check = _rule_check(r, service_started, now, maintenance)
if r.enabled and not maintenance and rule_is_stale(r, service_started, now):
health.stale_rules.add(r.id)
health.add(check)
return health return health
+197
View File
@@ -0,0 +1,197 @@
"""Schlüsselverwaltung: gespeicherte SSH-Schlüssel für SFTP-Anbindungen.
* Erzeugen (Ed25519) oder Importieren (Ed25519, ECDSA, RSA ≥ 2048 Bit; OpenSSH-
oder PEM-Format, optional mit Passphrase). Eine Passphrase wird beim Import
einmal benutzt; gespeichert wird der Schlüssel ohne Passphrase, aber mit
Fernet verschlüsselt – wie alle Geheimnisse.
* Der private Schlüssel verlässt NetMover nie: Die Oberfläche zeigt nur den
öffentlichen Schlüssel und den Fingerabdruck.
* Ein Schlüssel kann von mehreren Anbindungen genutzt werden; den Benutzernamen
legt jede Anbindung selbst fest. Löschen geht nur, solange ihn keine nutzt.
"""
from __future__ import annotations
import base64
import hashlib
from dataclasses import dataclass, field
from datetime import datetime
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa
from sqlalchemy import select
from sqlalchemy.orm import Session
from netmover.admin import AdminError
from netmover.crypto import SecretBox
from netmover.models import Credential, CredentialKind, Mount
MAX_KEY_BYTES = 16 * 1024
MIN_RSA_BITS = 2048
SFTP_ONLY_PREFIX = 'restrict,command="/usr/lib/openssh/sftp-server" '
@dataclass
class KeyInfo:
id: int
name: str
key_type: str
fingerprint: str
public_key: str
created_at: datetime
used_by: list[str] = field(default_factory=list)
@property
def authorized_keys_line(self) -> str:
"""Zeile für ~/.ssh/authorized_keys, auf SFTP beschränkt."""
return SFTP_ONLY_PREFIX + self.public_key
def fingerprint(public_key: str) -> str:
"""SHA256-Fingerabdruck wie ``ssh-keygen -lf``."""
blob = base64.b64decode(public_key.split()[1])
return "SHA256:" + base64.b64encode(hashlib.sha256(blob).digest()).decode().rstrip("=")
def _validate_name(s: Session, name: str, exclude_id: int | None = None) -> str:
name = name.strip()
if not name:
raise AdminError("Name fehlt.")
if len(name) > 100:
raise AdminError("Name höchstens 100 Zeichen.")
if name.startswith("mount:"):
raise AdminError("Namen mit „mount:“ sind für NetMover reserviert.")
dup = s.scalar(select(Credential.id).where(Credential.name == name))
if dup is not None and dup != exclude_id:
raise AdminError(f"Ein Schlüssel „{name}“ existiert bereits.")
return name
def _comment(name: str) -> str:
return "netmover:" + "".join(c if c.isalnum() or c in "-_." else "-" for c in name)
PrivateKey = ed25519.Ed25519PrivateKey | ec.EllipticCurvePrivateKey | rsa.RSAPrivateKey
def _store(s: Session, secrets: SecretBox, name: str, key: PrivateKey) -> Credential:
private = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.OpenSSH,
serialization.NoEncryption(),
).decode()
public = (
key.public_key()
.public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH)
.decode()
)
cred = Credential(
name=name,
kind=CredentialKind.SSH_KEY,
username="",
secret_enc=secrets.encrypt(private),
public_key=f"{public} {_comment(name)}",
managed=True,
)
s.add(cred)
s.flush()
return cred
def generate_key(s: Session, secrets: SecretBox, name: str) -> Credential:
return _store(s, secrets, _validate_name(s, name), ed25519.Ed25519PrivateKey.generate())
def import_key(
s: Session, secrets: SecretBox, name: str, private_key: str, passphrase: str = ""
) -> Credential:
name = _validate_name(s, name)
data = private_key.strip().encode()
if not data:
raise AdminError("Privaten Schlüssel einfügen.")
if len(data) > MAX_KEY_BYTES:
raise AdminError("Schlüssel ist zu groß.")
if b"PUBLIC KEY" in data or data.startswith((b"ssh-", b"ecdsa-")):
raise AdminError("Das ist ein öffentlicher Schlüssel – benötigt wird der private.")
password = passphrase.encode() if passphrase else None
loaders = (
[serialization.load_ssh_private_key]
if b"OPENSSH PRIVATE KEY" in data
else [serialization.load_pem_private_key]
)
try:
key = loaders[0](data, password)
except TypeError:
raise AdminError(
"Der Schlüssel ist mit einer Passphrase geschützt – bitte Passphrase angeben."
if password is None
else "Der Schlüssel hat keine Passphrase – Feld bitte leer lassen."
) from None
except ValueError as exc:
msg = str(exc).lower()
if password is not None or "password" in msg or "decrypt" in msg:
# Bei OpenSSH-Schlüsseln meldet cryptography eine falsche Passphrase
# als „Corrupt data“ – beides lässt sich nicht unterscheiden.
raise AdminError("Passphrase falsch (oder Schlüssel beschädigt).") from None
raise AdminError(
"Kein lesbarer privater Schlüssel (erwartet OpenSSH- oder PEM-Format)."
) from None
except Exception: # unbekannte Formate/Algorithmen sauber melden
raise AdminError("Schlüsselformat wird nicht unterstützt.") from None
if not isinstance(
key, ed25519.Ed25519PrivateKey | ec.EllipticCurvePrivateKey | rsa.RSAPrivateKey
):
raise AdminError("Unterstützt werden Ed25519, ECDSA und RSA.")
if isinstance(key, rsa.RSAPrivateKey) and key.key_size < MIN_RSA_BITS:
raise AdminError(f"RSA-Schlüssel mit {key.key_size} Bit ist zu schwach (mind. 2048).")
return _store(s, secrets, name, key)
def _users(s: Session, key_id: int) -> list[str]:
# Direkt abfragen statt key.mounts: die Beziehung ist nach dem Löschen von
# Anbindungen in derselben Session veraltet (passive_deletes).
return sorted(s.scalars(select(Mount.name).where(Mount.cred_id == key_id)))
def list_keys(s: Session) -> list[KeyInfo]:
keys = s.scalars(
select(Credential).where(Credential.managed.is_(True)).order_by(Credential.name)
).all()
result = []
for k in keys:
pub = k.public_key or ""
result.append(
KeyInfo(
id=k.id,
name=k.name,
key_type=pub.split()[0] if pub else "?",
fingerprint=fingerprint(pub) if pub else "?",
public_key=pub,
created_at=k.created_at,
used_by=_users(s, k.id),
)
)
return result
def managed_key(s: Session, key_id: int) -> Credential:
key = s.get(Credential, key_id)
if key is None or not key.managed or key.kind is not CredentialKind.SSH_KEY:
raise AdminError("Schlüssel existiert nicht.")
return key
def rename_key(s: Session, key_id: int, name: str) -> None:
key = managed_key(s, key_id)
key.name = _validate_name(s, name, exclude_id=key_id)
def delete_key(s: Session, key_id: int) -> str:
key = managed_key(s, key_id)
users = _users(s, key_id)
if users:
raise AdminError(f"Schlüssel „{key.name}“ wird noch benutzt von: {', '.join(users)}")
name = key.name
s.delete(key)
s.flush()
return name
+50
View File
@@ -0,0 +1,50 @@
"""Uptime Kuma: NetMover meldet sich regelmäßig bei einem „Push“-Monitor.
Vorteil gegenüber dem Abfragen von ``/healthz``: Es funktioniert auch, wenn Kuma
NetMover nicht erreichen kann (z. B. Bindung an 127.0.0.1 hinter einem Proxy). Und
steht der Dienst oder die ganze VM, bleibt die Meldung aus – dann schlägt Kuma
von selbst Alarm.
Die Push-URL kopiert man aus Kuma (``https://kuma/api/push/<token>?status=up&msg=OK&ping=``);
NetMover ersetzt ``status`` und ``msg`` bei jeder Meldung.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import httpx
from netmover.health import Health
from netmover.logging_setup import event, get_logger
log = get_logger(__name__)
MAX_MESSAGE = 250
Getter = Callable[..., Any]
def push_url(url: str, health: Health) -> str:
"""Push-URL mit aktuellem Zustand (vorhandene Parameter werden ersetzt)."""
msg = "OK" if health.ok else "; ".join(health.problems)
if len(msg) > MAX_MESSAGE:
msg = msg[: MAX_MESSAGE - 1] + "…"
target = httpx.URL(url)
for key, value in (("status", "up" if health.ok else "down"), ("msg", msg), ("ping", "")):
target = target.copy_set_param(key, value)
return str(target)
def push(url: str, health: Health, getter: Getter | 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.raise_for_status()
except Exception as exc:
event(log, "kuma.push_failed", level=30, error=type(exc).__name__)
return False
event(log, "kuma.pushed", level=10, status="up" if health.ok else "down")
return True
+91
View File
@@ -0,0 +1,91 @@
"""E-Mail-Benachrichtigung per SMTP.
* Verschlüsselung: ``starttls`` (Port 587, Standard), ``ssl`` (Port 465) oder ``none``
(nur für interne Relays ohne TLS). Zertifikate werden immer geprüft.
* Das Passwort liegt Fernet-verschlüsselt in den Einstellungen.
"""
from __future__ import annotations
import re
import smtplib
import socket
import ssl
from datetime import datetime
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
from netmover.settings_store import AppSettings
SECURITY_MODES = ("starttls", "ssl", "none")
_ADDRESS = re.compile(r"^[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+$")
class MailConfigError(ValueError):
pass
def parse_recipients(raw: str) -> list[str]:
recipients = [r.strip() for r in re.split(r"[,;\n]", raw) if r.strip()]
bad = [r for r in recipients if not _ADDRESS.fullmatch(r)]
if bad:
raise MailConfigError(f"Ungültige Adresse: {', '.join(bad)}")
return recipients
def valid_address(value: str) -> bool:
return bool(_ADDRESS.fullmatch(value.strip()))
def mail_configured(cfg: AppSettings) -> bool:
return bool(cfg.email_enabled and cfg.smtp_host and cfg.smtp_from and cfg.smtp_to)
def build_message(cfg: AppSettings, subject: str, body: str) -> EmailMessage:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = cfg.smtp_from
msg["To"] = ", ".join(parse_recipients(cfg.smtp_to))
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=cfg.smtp_from.rsplit("@", 1)[-1])
msg["X-Mailer"] = "NetMover"
footer = (
f"\n\n-- \nNetMover auf {socket.gethostname()}, "
f"{datetime.now().astimezone():%d.%m.%Y %H:%M:%S %Z}"
)
msg.set_content(body + footer)
return msg
def tls_context() -> ssl.SSLContext:
"""Zertifikatsprüfung gegen die System-CAs (eigene Funktion, damit Tests ihr
Test-Zertifikat einsetzen können, ohne die Prüfung abzuschalten)."""
return ssl.create_default_context()
def smtp_send(
cfg: AppSettings, password: str | None, subject: str, body: str, timeout: float = 20
) -> None:
"""Verschickt eine Nachricht; Fehler werden als Ausnahme weitergereicht."""
if cfg.smtp_security not in SECURITY_MODES:
raise MailConfigError(f"Unbekannte Verschlüsselung: {cfg.smtp_security}")
msg = build_message(cfg, subject, body)
context = tls_context()
smtp: smtplib.SMTP
if cfg.smtp_security == "ssl":
smtp = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=timeout, context=context)
else:
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=timeout)
try:
smtp.ehlo()
if cfg.smtp_security == "starttls":
smtp.starttls(context=context)
smtp.ehlo()
if cfg.smtp_user:
smtp.login(cfg.smtp_user, password or "")
smtp.send_message(msg)
finally:
try:
smtp.quit()
except smtplib.SMTPException:
smtp.close()
+13 -2
View File
@@ -7,7 +7,7 @@ from logging.config import fileConfig
from alembic import context from alembic import context
from netmover.config import get_settings from netmover.config import get_settings
from netmover.db import make_engine from netmover.db import make_migration_engine
from netmover.models import Base from netmover.models import Base
config = context.config config = context.config
@@ -33,16 +33,27 @@ def run_migrations_offline() -> None:
def run_migrations_online() -> None: def run_migrations_online() -> None:
engine = make_engine(_url()) url = _url()
engine = make_migration_engine(url)
with engine.connect() as connection: with engine.connect() as connection:
context.configure( context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,
render_as_batch=True, render_as_batch=True,
compare_type=True, compare_type=True,
# alles oder nichts: auch SQLite kann DDL in einer Transaktion
transactional_ddl=True,
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
if url.startswith("sqlite"):
# Fremdschlüssel waren während der Migration aus – vor dem Commit prüfen.
bad = connection.exec_driver_sql("PRAGMA foreign_key_check").fetchall()
if bad:
raise RuntimeError(
f"Migration verletzt Fremdschlüssel ({len(bad)} Zeilen), "
f"zurückgerollt: {bad[:5]}"
)
engine.dispose() engine.dispose()
@@ -0,0 +1,34 @@
"""key management
Revision ID: 0002
Revises: 0001
Create Date: 2026-10-10
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0002"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("credentials", schema=None) as batch_op:
batch_op.add_column(
sa.Column("managed", sa.Boolean(), server_default=sa.text("0"), nullable=False)
)
with op.batch_alter_table("mounts", schema=None) as batch_op:
batch_op.add_column(sa.Column("username", sa.String(length=255), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("mounts", schema=None) as batch_op:
batch_op.drop_column("username")
with op.batch_alter_table("credentials", schema=None) as batch_op:
batch_op.drop_column("managed")
@@ -0,0 +1,32 @@
"""rule waiting state
Revision ID: 0003
Revises: 0002
Create Date: 2026-10-11
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import netmover.models
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("rules", schema=None) as batch_op:
batch_op.add_column(
sa.Column("waiting_since", netmover.models.UTCDateTime(), nullable=True)
)
batch_op.add_column(sa.Column("waiting_reason", sa.Text(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("rules", schema=None) as batch_op:
batch_op.drop_column("waiting_reason")
batch_op.drop_column("waiting_since")
+15
View File
@@ -7,6 +7,11 @@ Abweichungen bzw. Ergänzungen gegenüber dem Mindestumfang aus der Spezifikatio
(beim ersten Test festgehaltener Hostschlüssel, danach verbindlich). (beim ersten Test festgehaltener Hostschlüssel, danach verbindlich).
* ``credentials.kind`` (``password`` | ``ssh_key``) und ``credentials.public_key``. * ``credentials.kind`` (``password`` | ``ssh_key``) und ``credentials.public_key``.
* ``credentials.domain`` – Windows-/AD-Freigaben brauchen oft eine Domäne. * ``credentials.domain`` – Windows-/AD-Freigaben brauchen oft eine Domäne.
* ``credentials.managed`` + ``mounts.username`` (Migration 0002) –
Schlüsselverwaltung: gespeicherte SSH-Schlüssel, die mehrere Anbindungen mit
jeweils eigenem Benutzernamen nutzen.
* ``rules.waiting_since`` / ``rules.waiting_reason`` (Migration 0003) – Regel wartet
auf eine nicht erreichbare Anbindung (automatisch pausiert).
* ``mounts.state_since`` – für „hängt seit …“ / „gestört seit …“ auf der Statusseite. * ``mounts.state_since`` – für „hängt seit …“ / „gestört seit …“ auf der Statusseite.
* ``mounts.write_ok`` / ``mounts.root_squash`` / ``mounts.last_test_*`` – Ergebnis * ``mounts.write_ok`` / ``mounts.root_squash`` / ``mounts.last_test_*`` – Ergebnis
des letzten Mount-Tests; ``root_squash`` entscheidet, ob ``chown`` auf NFS-Zielen des letzten Mount-Tests; ``root_squash`` entscheidet, ob ``chown`` auf NFS-Zielen
@@ -38,6 +43,7 @@ from sqlalchemy import (
TypeDecorator, TypeDecorator,
UniqueConstraint, UniqueConstraint,
) )
from sqlalchemy import false as sa_false
from sqlalchemy.engine import Dialect from sqlalchemy.engine import Dialect
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -166,6 +172,9 @@ class Credential(Base):
domain: Mapped[str | None] = mapped_column(String(255)) domain: Mapped[str | None] = mapped_column(String(255))
secret_enc: Mapped[str] = mapped_column(Text) # Passwort bzw. privater Schlüssel (Fernet) secret_enc: Mapped[str] = mapped_column(Text) # Passwort bzw. privater Schlüssel (Fernet)
public_key: Mapped[str | None] = mapped_column(Text) # nur ssh_key, OpenSSH-Format public_key: Mapped[str | None] = mapped_column(Text) # nur ssh_key, OpenSSH-Format
# True: in der Schlüsselverwaltung gepflegt, von mehreren Anbindungen nutzbar.
# False: gehört genau einer Anbindung und wird mit ihr gelöscht.
managed: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa_false())
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow) created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
# passive_deletes="all": das ORM soll cred_id nicht still auf NULL setzen, # passive_deletes="all": das ORM soll cred_id nicht still auf NULL setzen,
@@ -183,6 +192,9 @@ class Mount(Base):
server: Mapped[str] = mapped_column(String(255)) server: Mapped[str] = mapped_column(String(255))
share: Mapped[str] = mapped_column(String(1024)) share: Mapped[str] = mapped_column(String(1024))
port: Mapped[int | None] = mapped_column(Integer) # nur sftp port: Mapped[int | None] = mapped_column(Integer) # nur sftp
# Benutzer für die Anmeldung; überschreibt credentials.username. Nötig, weil ein
# gespeicherter SSH-Schlüssel für verschiedene Server/Benutzer gelten kann.
username: Mapped[str | None] = mapped_column(String(255))
host_key: Mapped[str | None] = mapped_column(Text) # nur sftp, "<typ> <base64>" host_key: Mapped[str | None] = mapped_column(Text) # nur sftp, "<typ> <base64>"
mountpoint: Mapped[str | None] = mapped_column(String(1024), unique=True) # NULL bei sftp mountpoint: Mapped[str | None] = mapped_column(String(1024), unique=True) # NULL bei sftp
cred_id: Mapped[int | None] = mapped_column(ForeignKey("credentials.id", ondelete="RESTRICT")) cred_id: Mapped[int | None] = mapped_column(ForeignKey("credentials.id", ondelete="RESTRICT"))
@@ -250,6 +262,9 @@ class Rule(Base):
last_run_at: Mapped[datetime | None] = mapped_column(UTCDateTime) last_run_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
last_success_at: Mapped[datetime | None] = mapped_column(UTCDateTime) last_success_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
consecutive_failures: Mapped[int] = mapped_column(Integer, default=0) consecutive_failures: Mapped[int] = mapped_column(Integer, default=0)
# Automatisch pausiert, solange Quelle oder Ziel nicht erreichbar ist (Migration 0003)
waiting_since: Mapped[datetime | None] = mapped_column(UTCDateTime)
waiting_reason: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow) created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
src_mount: Mapped[Mount] = relationship(foreign_keys=[src_mount_id]) src_mount: Mapped[Mount] = relationship(foreign_keys=[src_mount_id])
+7
View File
@@ -58,6 +58,13 @@ def explain_mount_error(fstype: str, message: str) -> str:
if "helper program" in low or "unknown filesystem type" in low: if "helper program" in low or "unknown filesystem type" in low:
pkg = "cifs-utils" if fstype == "cifs" else "nfs-common" pkg = "cifs-utils" if fstype == "cifs" else "nfs-common"
hint = f"Das Paket „{pkg}“ fehlt: apt install {pkg}" hint = f"Das Paket „{pkg}“ fehlt: apt install {pkg}"
elif fstype == "cifs" and ("error(95)" in low or "operation not supported" in low):
hint = (
"Server und Client finden keine gemeinsame SMB-Version. Am besten am NAS "
"SMB2/SMB3 erlauben (Synology: Systemsteuerung → Dateidienste → SMB → "
"Erweiterte Einstellungen → Maximales SMB-Protokoll: SMB3). Alternativ "
"Zusatzoption vers=2.0 – vers=1.0 nur im Notfall, SMB1 ist unsicher."
)
elif "permission denied" in low and fstype == "cifs": elif "permission denied" in low and fstype == "cifs":
hint = "Benutzername/Passwort falsch oder der Benutzer hat keine Rechte auf die Freigabe." hint = "Benutzername/Passwort falsch oder der Benutzer hat keine Rechte auf die Freigabe."
elif "permission denied" in low or "access denied by server" in low: elif "permission denied" in low or "access denied by server" in low:
+36 -8
View File
@@ -32,7 +32,7 @@ from sqlalchemy.orm import Session, sessionmaker
from netmover.crypto import SecretBox, redact from netmover.crypto import SecretBox, redact
from netmover.db import session_scope from netmover.db import session_scope
from netmover.logging_setup import event, get_logger from netmover.logging_setup import event, get_logger
from netmover.models import CredentialKind, Mount, MountState, MountType, utcnow from netmover.models import CredentialKind, Mount, MountState, MountType, Rule, utcnow
from netmover.mounting.backend import CifsCredentials, MountBackend, MountError, MountSpec from netmover.mounting.backend import CifsCredentials, MountBackend, MountError, MountSpec
from netmover.mounting.report import CheckReport, assess_write from netmover.mounting.report import CheckReport, assess_write
from netmover.mounting.sftp import SftpConfig, SftpError, check_connection, connect from netmover.mounting.sftp import SftpConfig, SftpError, check_connection, connect
@@ -203,13 +203,32 @@ class MountManager:
with self._locks_guard: with self._locks_guard:
return self._locks.setdefault(mount_id, threading.RLock()) return self._locks.setdefault(mount_id, threading.RLock())
def _notify(self, kind: str, name: str, message: str) -> None: def _notify(self, kind: str, name: str, message: str, mount_id: int | None = None) -> None:
if self._notifier is not None: if self._notifier is None:
return
if mount_id is not None:
rules = self._affected_rules(mount_id)
if rules and kind != "mount.recovered":
waiting = "\nBetroffene Regeln (warten, bis die Anbindung zurück ist): "
message += waiting + ", ".join(rules)
elif rules:
message += f"\nRegeln laufen wieder: {', '.join(rules)}"
try: try:
self._notifier(kind, name, message) self._notifier(kind, name, message)
except Exception: except Exception:
log.exception("notifier.failed") log.exception("notifier.failed")
def _affected_rules(self, mount_id: int) -> list[str]:
with session_scope(self.session_factory) as s:
return list(
s.scalars(
select(Rule.name)
.where(Rule.enabled.is_(True))
.where((Rule.src_mount_id == mount_id) | (Rule.dst_mount_id == mount_id))
.order_by(Rule.name)
)
)
def mountpoint_for(self, slug: str) -> Path: def mountpoint_for(self, slug: str) -> Path:
if slugify(slug) != slug: if slugify(slug) != slug:
raise ValidationError("slug", f"Ungültiger Slug: {slug!r}") raise ValidationError("slug", f"Ungültiger Slug: {slug!r}")
@@ -228,7 +247,7 @@ class MountManager:
if m.credential is not None: if m.credential is not None:
c = m.credential c = m.credential
cred = CredentialData( cred = CredentialData(
c.kind, c.username, self.secrets.decrypt(c.secret_enc), c.domain c.kind, m.username or c.username, self.secrets.decrypt(c.secret_enc), c.domain
) )
s.expunge(m) s.expunge(m)
return m, cred return m, cred
@@ -347,7 +366,7 @@ class MountManager:
# z. B. Stromausfall: die VM bootet schneller als das NAS # z. B. Stromausfall: die VM bootet schneller als das NAS
self._retry_at[mount_id] = self._clock() + self.retry_interval self._retry_at[mount_id] = self._clock() + self.retry_interval
results.append(RestoreResult(name, False, str(exc))) results.append(RestoreResult(name, False, str(exc)))
self._notify("mount.restore_failed", name, str(exc)) self._notify("mount.restore_failed", name, str(exc), mount_id)
for r in results: for r in results:
event( event(
log, log,
@@ -507,6 +526,11 @@ class MountManager:
if cfg.type is MountType.SFTP and m.host_key is None and report.can_read: if cfg.type is MountType.SFTP and m.host_key is None and report.can_read:
m.host_key = report.host_key m.host_key = report.host_key
event(log, "sftp.host_key_pinned", mount=m.name, fp=report.host_key_fingerprint) event(log, "sftp.host_key_pinned", mount=m.name, fp=report.host_key_fingerprint)
# SFTP hat keinen Mount-Zustand – ein erfolgreicher Test heißt „erreichbar“.
if cfg.type is MountType.SFTP and report.ok and m.state is not MountState.MOUNTED:
m.state = MountState.MOUNTED
m.state_since = utcnow()
m.last_error = None
# -- Lebenszeichen und Wiederherstellung -------------------------------------- # -- Lebenszeichen und Wiederherstellung --------------------------------------
@@ -526,6 +550,8 @@ class MountManager:
self._retry_mount(mount_id, m, cred, mp) self._retry_mount(mount_id, m, cred, mp)
return mp return mp
msg = f"„{m.name}“ ist nicht eingehängt – Lauf abgebrochen, nichts angefasst." msg = f"„{m.name}“ ist nicht eingehängt – Lauf abgebrochen, nichts angefasst."
if m.state is not MountState.ERROR:
self._notify("mount.error", m.name, msg, mount_id)
self._set_state(mount_id, MountState.ERROR, msg) self._set_state(mount_id, MountState.ERROR, msg)
raise MountUnavailable(msg) raise MountUnavailable(msg)
try: try:
@@ -558,8 +584,10 @@ class MountManager:
msg = f"„{m.name}“ nicht erreichbar: {exc}" msg = f"„{m.name}“ nicht erreichbar: {exc}"
self._set_state(mount_id, MountState.ERROR, msg) self._set_state(mount_id, MountState.ERROR, msg)
if m.state is not MountState.ERROR: if m.state is not MountState.ERROR:
self._notify("mount.error", m.name, msg) self._notify("mount.error", m.name, msg, mount_id)
raise MountUnavailable(msg) from None raise MountUnavailable(msg) from None
if m.state is MountState.ERROR:
self._notify("mount.recovered", m.name, f"„{m.name}“ ist wieder erreichbar.", mount_id)
if m.state is not MountState.MOUNTED: if m.state is not MountState.MOUNTED:
self._set_state(mount_id, MountState.MOUNTED) self._set_state(mount_id, MountState.MOUNTED)
@@ -596,7 +624,7 @@ class MountManager:
self._retry_at[mount_id] = self._clock() + self.retry_interval self._retry_at[mount_id] = self._clock() + self.retry_interval
msg += f"\n→ Neuer Versuch automatisch alle {self.retry_interval:g} s." msg += f"\n→ Neuer Versuch automatisch alle {self.retry_interval:g} s."
self._set_state(mount_id, MountState.ERROR, msg) self._set_state(mount_id, MountState.ERROR, msg)
self._notify("mount.error", m.name, msg) self._notify("mount.error", m.name, msg, mount_id)
raise MountUnavailable(msg) raise MountUnavailable(msg)
def retry_pending(self) -> list[str]: def retry_pending(self) -> list[str]:
@@ -658,7 +686,7 @@ class MountManager:
del self._retry_at[mount_id] del self._retry_at[mount_id]
self._set_state(mount_id, MountState.MOUNTED, mounted=True) self._set_state(mount_id, MountState.MOUNTED, mounted=True)
event(log, "mount.recovered", mount=m.name, attempt="retry") event(log, "mount.recovered", mount=m.name, attempt="retry")
self._notify("mount.recovered", m.name, f"„{m.name}“ ist wieder eingehängt.") self._notify("mount.recovered", m.name, f"„{m.name}“ ist wieder eingehängt.", mount_id)
def usage(self, mount_id: int, timeout: float | None = None) -> tuple[int, int] | None: def usage(self, mount_id: int, timeout: float | None = None) -> tuple[int, int] | None:
"""(gesamt, frei) in Bytes – nur bei eingehängten Kernel-Mounts.""" """(gesamt, frei) in Bytes – nur bei eingehängten Kernel-Mounts."""
+2
View File
@@ -92,6 +92,8 @@ class RunResult:
failed: int = 0 failed: int = 0
message: str | None = None message: str | None = None
items: list[ItemResult] = field(default_factory=list) items: list[ItemResult] = field(default_factory=list)
# Quelle oder Ziel nicht erreichbar → Regel wartet (automatisch pausiert)
unavailable: bool = False
def add(self, item: ItemResult) -> None: def add(self, item: ItemResult) -> None:
self.items.append(item) self.items.append(item)
+1 -1
View File
@@ -178,7 +178,7 @@ class RuleRunner:
else None else None
) )
except MountUnavailable as exc: except MountUnavailable as exc:
return RunResult(RunStatus.ERROR, dry, message=str(exc)) return RunResult(RunStatus.ERROR, dry, message=str(exc), unavailable=True)
if src_mp is None: # nur bei SFTP, oben bereits ausgeschlossen if src_mp is None: # nur bei SFTP, oben bereits ausgeschlossen
return RunResult(RunStatus.ERROR, dry, message="Quelle ist kein Kernel-Mount") return RunResult(RunStatus.ERROR, dry, message="Quelle ist kein Kernel-Mount")
src_root = src_mp / plan.src_subpath if plan.src_subpath else src_mp src_root = src_mp / plan.src_subpath if plan.src_subpath else src_mp
+44 -5
View File
@@ -22,7 +22,9 @@ from string import Template
import httpx import httpx
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from netmover.crypto import SecretBox
from netmover.logging_setup import event, get_logger from netmover.logging_setup import event, get_logger
from netmover.mail import mail_configured, smtp_send
from netmover.settings_store import AppSettings, NotifyMode, load_settings from netmover.settings_store import AppSettings, NotifyMode, load_settings
log = get_logger(__name__) log = get_logger(__name__)
@@ -60,34 +62,71 @@ def http_sender(url: str, content_type: str, body: str) -> None:
resp.raise_for_status() resp.raise_for_status()
Mailer = Callable[[AppSettings, "str | None", str, str], None] # (cfg, passwort, betreff, text)
class Notifier: class Notifier:
"""Verteilt Benachrichtigungen auf die eingerichteten Kanäle (Webhook, E-Mail)."""
def __init__( def __init__(
self, self,
session_factory: sessionmaker[Session], session_factory: sessionmaker[Session],
*, *,
sender: Sender = http_sender, sender: Sender = http_sender,
mailer: Mailer | None = None,
secrets: SecretBox | None = None,
background: bool = True, background: bool = True,
) -> None: ) -> None:
self.session_factory = session_factory self.session_factory = session_factory
self._sender = sender self._sender = sender
self._mailer: Mailer = mailer or smtp_send
self._secrets = secrets
self._background = background self._background = background
def settings(self) -> AppSettings: def settings(self) -> AppSettings:
return load_settings(self.session_factory) return load_settings(self.session_factory)
def smtp_password(self, cfg: AppSettings) -> str | None:
if not cfg.smtp_password_enc or self._secrets is None:
return None
return self._secrets.decrypt(cfg.smtp_password_enc)
def send_mail_now(self, subject: str, body: str, settings: AppSettings | None = None) -> None:
"""Synchron senden (Test-Knopf); Fehler werden weitergereicht."""
cfg = settings or self.settings()
self._mailer(cfg, self.smtp_password(cfg), subject, body)
def send(self, n: Notification, settings: AppSettings | None = None) -> None: def send(self, n: Notification, settings: AppSettings | None = None) -> None:
cfg = settings or self.settings() cfg = settings or self.settings()
if cfg.notify_mode is NotifyMode.OFF or not cfg.webhook_url: if cfg.notify_mode is NotifyMode.OFF:
return return
jobs: list[tuple[str, Callable[[], None]]] = []
if cfg.webhook_url:
body = render(cfg.webhook_template, cfg.webhook_content_type, n) body = render(cfg.webhook_template, cfg.webhook_content_type, n)
jobs.append(
("webhook", lambda: self._sender(cfg.webhook_url, cfg.webhook_content_type, body))
)
if mail_configured(cfg):
password = self.smtp_password(cfg)
jobs.append(("email", lambda: self._mailer(cfg, password, n.title, n.message)))
if not jobs:
return
def deliver() -> None: def deliver() -> None:
for channel, job in jobs:
try: try:
self._sender(cfg.webhook_url, cfg.webhook_content_type, body) job()
event(log, "notify.sent", kind=n.event, rule=n.rule) event(log, "notify.sent", kind=n.event, rule=n.rule, channel=channel)
except Exception as exc: # Benachrichtigung darf nie den Dienst stören except Exception as exc: # Benachrichtigung darf nie den Dienst stören
# URL nicht protokollieren – sie enthält oft ein Token. # Weder URL noch Zugangsdaten protokollieren.
event(log, "notify.failed", level=30, kind=n.event, error=type(exc).__name__) event(
log,
"notify.failed",
level=30,
kind=n.event,
channel=channel,
error=type(exc).__name__,
)
if self._background: if self._background:
threading.Thread(target=deliver, name="nm-notify", daemon=True).start() threading.Thread(target=deliver, name="nm-notify", daemon=True).start()
+42
View File
@@ -8,6 +8,14 @@ die Einzeldateien in ``run_items``. Danach die Regelzähler:
* ``consecutive_failures`` – ``error`` und ``partial`` zählen hoch, ``ok`` setzt zurück. * ``consecutive_failures`` – ``error`` und ``partial`` zählen hoch, ``ok`` setzt zurück.
Ein manuell gestarteter Probelauf verändert keine Zähler. Ein manuell gestarteter Probelauf verändert keine Zähler.
Automatisch pausieren (0.4.0): Ist Quelle oder Ziel nicht erreichbar, wechselt die
Regel in „wartet auf Anbindung“. Protokolliert wird nur der Beginn, nicht jeder
weitere Termin, und „Fehler in Folge“ zählt nicht hoch – die Benachrichtigung
kommt einmal pro Anbindung vom MountManager. Sobald ein Lauf wieder durchkommt,
endet das Warten von selbst, und der Lauf vermerkt die Wartezeit.
Wartungsmodus: geplante Läufe werden gar nicht erst ausgeführt.
""" """
from __future__ import annotations from __future__ import annotations
@@ -51,6 +59,8 @@ class RunService:
) -> int | None: ) -> int | None:
"""Führt die Regel aus, protokolliert den Lauf und liefert die Run-ID """Führt die Regel aus, protokolliert den Lauf und liefert die Run-ID
(None, wenn die Regel inzwischen gelöscht wurde).""" (None, wenn die Regel inzwischen gelöscht wurde)."""
if trigger is RunTrigger.SCHEDULE and load_settings(self.session_factory).maintenance:
return None
started = utcnow() started = utcnow()
result = self.runner.run(rule_id, dry_run=dry_run) result = self.runner.run(rule_id, dry_run=dry_run)
finished = utcnow() finished = utcnow()
@@ -73,6 +83,29 @@ class RunService:
if rule is None: if rule is None:
return None return None
message = result.message message = result.message
if update_counters and result.unavailable:
if rule.waiting_since is not None:
# wartet bereits – nur den Grund aktualisieren, kein neuer Eintrag
rule.waiting_reason = result.message
event(log, "rule.still_waiting", level=10, rule=rule.name)
return None
rule.waiting_since = finished
rule.waiting_reason = result.message
rule.last_run_at = finished
message = f"Pausiert, bis die Anbindung zurück ist: {result.message}"
update_counters = False # „Fehler in Folge“ nicht hochzählen
event(log, "rule.waiting", level=30, rule=rule.name, reason=result.message or "")
elif (
update_counters
and rule.waiting_since is not None
and result.status is not RunStatus.SKIPPED
):
waited = _human(finished - rule.waiting_since)
rule.waiting_since = None
rule.waiting_reason = None
note = f"Fortgesetzt nach {waited} Wartezeit"
message = f"{note}; {message}" if message else note
event(log, "rule.resumed", rule=rule.name, waited=waited)
items = result.items items = result.items
if len(items) > MAX_ITEMS_PER_RUN: if len(items) > MAX_ITEMS_PER_RUN:
extra = len(items) - MAX_ITEMS_PER_RUN extra = len(items) - MAX_ITEMS_PER_RUN
@@ -153,6 +186,15 @@ class RunService:
return "\n".join(lines) return "\n".join(lines)
def _human(delta: timedelta) -> str:
seconds = int(delta.total_seconds())
if seconds < 90:
return f"{seconds} s"
if seconds < 5400:
return f"{round(seconds / 60)} min"
return f"{seconds / 3600:.1f} h".replace(".", ",")
def cleanup(session_factory: sessionmaker[Session], now: datetime | None = None) -> dict[str, int]: def cleanup(session_factory: sessionmaker[Session], now: datetime | None = None) -> dict[str, int]:
"""Protokolle älter als die Aufbewahrungsfrist löschen (Items per CASCADE), """Protokolle älter als die Aufbewahrungsfrist löschen (Items per CASCADE),
dazu Fehlerzähler, die seitdem nicht mehr angefasst wurden.""" dazu Fehlerzähler, die seitdem nicht mehr angefasst wurden."""
+12
View File
@@ -33,6 +33,7 @@ MAX_START_OFFSET = 10.0
HEARTBEAT_SECONDS = 30 HEARTBEAT_SECONDS = 30
CLEANUP_SECONDS = 3600 CLEANUP_SECONDS = 3600
RETRY_SECONDS = 60 RETRY_SECONDS = 60
PUSH_SECONDS = 60
def _job_id(rule_id: int) -> str: def _job_id(rule_id: int) -> str:
@@ -46,12 +47,14 @@ class RuleScheduler:
service: RunService, service: RunService,
*, *,
retry_mounts: Callable[[], object] | None = None, retry_mounts: Callable[[], object] | None = None,
push_health: Callable[[], object] | None = None,
rng: random.Random | None = None, rng: random.Random | None = None,
workers: int = 8, workers: int = 8,
) -> None: ) -> None:
self.session_factory = session_factory self.session_factory = session_factory
self.service = service self.service = service
self._retry_mounts = retry_mounts self._retry_mounts = retry_mounts
self._push_health = push_health
self._rng = rng or random.Random() # noqa: S311 – nur Startversatz self._rng = rng or random.Random() # noqa: S311 – nur Startversatz
self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="nm-run") self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="nm-run")
self.scheduler = AsyncIOScheduler( self.scheduler = AsyncIOScheduler(
@@ -78,6 +81,10 @@ class RuleScheduler:
self.scheduler.add_job( self.scheduler.add_job(
self._retry, IntervalTrigger(seconds=RETRY_SECONDS), id="mount-retry" self._retry, IntervalTrigger(seconds=RETRY_SECONDS), id="mount-retry"
) )
if self._push_health is not None:
self.scheduler.add_job(
self._push, IntervalTrigger(seconds=PUSH_SECONDS), id="kuma-push"
)
self.sync() self.sync()
event(log, "scheduler.started", rules=len(self._intervals)) event(log, "scheduler.started", rules=len(self._intervals))
@@ -154,6 +161,11 @@ class RuleScheduler:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(self._executor, self._retry_mounts) await loop.run_in_executor(self._executor, self._retry_mounts)
async def _push(self) -> None:
if self._push_health is not None:
loop = asyncio.get_running_loop()
await loop.run_in_executor(self._executor, self._push_health)
async def _cleanup(self) -> None: async def _cleanup(self) -> None:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(self._executor, cleanup, self.session_factory) await loop.run_in_executor(self._executor, cleanup, self.session_factory)
+12
View File
@@ -34,6 +34,18 @@ class AppSettings:
notify_recovery: bool = True notify_recovery: bool = True
webhook_template: str = DEFAULT_TEMPLATE webhook_template: str = DEFAULT_TEMPLATE
webhook_content_type: str = "application/json" webhook_content_type: str = "application/json"
kuma_push_url: str = ""
# Wartungsmodus: geplante Läufe pausiert, kein Alarm für Regeln
maintenance: bool = False
# E-Mail (SMTP); Passwort Fernet-verschlüsselt
email_enabled: bool = False
smtp_host: str = ""
smtp_port: int = 587
smtp_security: str = "starttls"
smtp_user: str = ""
smtp_password_enc: str = ""
smtp_from: str = ""
smtp_to: str = ""
_PREFIX = "app." _PREFIX = "app."
+342 -11
View File
@@ -12,17 +12,22 @@ import asyncio
import dataclasses import dataclasses
import json import json
import math import math
import re
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from functools import lru_cache
from importlib.resources import files from importlib.resources import files
from pathlib import Path
from typing import Any from typing import Any
import markdown
from fastapi import APIRouter, Depends, FastAPI, Request from fastapi import APIRouter, Depends, FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from markupsafe import escape
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from starlette.datastructures import UploadFile from starlette.datastructures import UploadFile
@@ -45,9 +50,13 @@ from netmover.admin import (
from netmover.app_state import NetMover from netmover.app_state import NetMover
from netmover.config import Settings, get_settings from netmover.config import Settings, get_settings
from netmover.db import session_scope from netmover.db import session_scope
from netmover.keys import delete_key, generate_key, import_key, list_keys, rename_key
from netmover.kuma import push as kuma_push
from netmover.logging_setup import event, get_logger from netmover.logging_setup import event, get_logger
from netmover.mail import SECURITY_MODES, MailConfigError, parse_recipients, valid_address
from netmover.models import ( from netmover.models import (
CollisionPolicy, CollisionPolicy,
Credential,
CredentialKind, CredentialKind,
Mount, Mount,
MountState, MountState,
@@ -158,8 +167,61 @@ def flash(request: Request, message: str, kind: str = "ok") -> None:
request.session["flash"] = [*request.session.get("flash", []), [kind, message]] request.session["flash"] = [*request.session.get("flash", []), [kind, message]]
# Hilfe-Abschnitt passend zur Seite (Link „Hilfe zu dieser Seite“)
HELP_ANCHORS = {
"status": "status",
"mounts": "anbindungen",
"keys": "schluessel",
"rules": "regeln",
"log": "protokoll",
"settings": "einstellungen",
}
@dataclass
class NavState:
healthy: bool
problems: int
mounts: int
mounts_bad: int
keys: int
rules: int
rules_active: int
maintenance: bool = False
def _nav_state(request: Request) -> NavState | None:
if not auth.is_logged_in(request):
return None
state = nm(request)
health = state.health()
with state.session_factory() as s:
mounts = s.execute(select(Mount.state, Mount.enabled)).all()
rules = s.execute(select(Rule.enabled)).all()
keys = s.scalar(
select(func.count()).select_from(Credential).where(Credential.managed.is_(True))
)
return NavState(
healthy=health.ok,
problems=len(health.problems),
mounts=len(mounts),
mounts_bad=sum(
1 for st, en in mounts if en and st in (MountState.ERROR, MountState.DEGRADED)
),
keys=keys or 0,
rules=len(rules),
rules_active=sum(1 for (en,) in rules if en),
maintenance=health.maintenance,
)
def render(request: Request, name: str, status_code: int = 200, **ctx: Any) -> HTMLResponse: def render(request: Request, name: str, status_code: int = 200, **ctx: Any) -> HTMLResponse:
flashes = request.session.pop("flash", []) if not name.startswith("partials/") else [] partial = name.startswith("partials/")
flashes = request.session.pop("flash", []) if not partial else []
active = ctx.get("active", "")
if not partial:
ctx.setdefault("nav", _nav_state(request))
ctx.setdefault("help_anchor", HELP_ANCHORS.get(active))
return TEMPLATES.TemplateResponse( return TEMPLATES.TemplateResponse(
request, request,
name, name,
@@ -208,11 +270,45 @@ public = APIRouter()
@public.get("/healthz") @public.get("/healthz")
async def healthz(request: Request) -> JSONResponse: async def healthz(request: Request) -> JSONResponse:
health = await run_in_threadpool(nm(request).health) """Für Uptime Kuma & Co.: 200 = alles in Ordnung, 503 = mindestens ein Problem.
body = {"status": "ok" if health.ok else "error", "problems": health.problems} Ohne Anmeldung erreichbar; enthält Namen und Fehlermeldungen, keine Geheimnisse."""
state = nm(request)
health = await run_in_threadpool(state.health)
beat = state.scheduler.last_heartbeat
body = {
"status": "ok" if health.ok else "error",
"version": __version__,
"problems": health.problems,
"scheduler": {
"running": state.scheduler.running,
"last_heartbeat": beat.isoformat() if beat else None,
},
"checks": [c.as_dict() for c in health.checks],
}
return JSONResponse(body, status_code=200 if health.ok else 503) return JSONResponse(body, status_code=200 if health.ok else 503)
async def _single_check(request: Request, kind: str, **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}
return JSONResponse(body, status_code=200 if check.ok else 503)
@public.get("/healthz/mount/{slug}")
async def healthz_mount(request: Request, slug: str) -> JSONResponse:
"""Eigener Kuma-Monitor pro Anbindung."""
return await _single_check(request, "mount", slug=slug)
@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)
@public.get("/setup", response_class=HTMLResponse) @public.get("/setup", response_class=HTMLResponse)
async def setup_form(request: Request) -> Response: async def setup_form(request: Request) -> Response:
if auth.password_is_set(nm(request).session_factory): if auth.password_is_set(nm(request).session_factory):
@@ -367,6 +463,23 @@ async def status_partial(request: Request) -> Response:
return render(request, "partials/status_body.html", **await _status_context(request)) return render(request, "partials/status_body.html", **await _status_context(request))
@protected.post("/maintenance")
async def toggle_maintenance(request: Request) -> Response:
state = nm(request)
cfg = load_settings(state.session_factory)
cfg.maintenance = not cfg.maintenance
save_settings(state.session_factory, cfg)
event(log, "maintenance", enabled=cfg.maintenance)
flash(
request,
"Wartungsmodus eingeschaltet – geplante Läufe sind pausiert."
if cfg.maintenance
else "Wartungsmodus beendet – Regeln laufen wieder nach Plan.",
"info",
)
return redirect("/")
@protected.post("/rules/{rule_id}/run") @protected.post("/rules/{rule_id}/run")
async def rule_run_now(request: Request, rule_id: int) -> Response: async def rule_run_now(request: Request, rule_id: int) -> Response:
state = nm(request) state = nm(request)
@@ -392,10 +505,21 @@ async def mounts_page(request: Request) -> Response:
pubkeys = { pubkeys = {
m.id: m.credential.public_key m.id: m.credential.public_key
for m in mounts for m in mounts
if m.credential is not None and m.credential.public_key if m.credential is not None and m.credential.public_key and not m.credential.managed
}
key_names = {
m.id: m.credential.name
for m in mounts
if m.credential is not None and m.credential.managed
} }
return render( return render(
request, "mounts.html", active="mounts", mounts=mounts, reports=reports, pubkeys=pubkeys request,
"mounts.html",
active="mounts",
mounts=mounts,
reports=reports,
pubkeys=pubkeys,
key_names=key_names,
) )
@@ -409,6 +533,14 @@ def _report_data(raw: str | None) -> dict[str, Any]:
return data return data
def _key_options(request: Request) -> list[tuple[str, str]]:
with nm(request).session_factory() as s:
keys = list_keys(s)
return [("new", "neuer Schlüssel nur für diese Anbindung")] + [
(str(k.id), f"{k.name} ({k.key_type.removeprefix('ssh-')})") for k in keys
]
def _mount_form( def _mount_form(
request: Request, request: Request,
data: MountInput, data: MountInput,
@@ -416,7 +548,11 @@ def _mount_form(
mount: Mount | None = None, mount: Mount | None = None,
status_code: int = 200, status_code: int = 200,
) -> Response: ) -> Response:
pub = mount.credential.public_key if mount is not None and mount.credential else None pub = (
mount.credential.public_key
if mount is not None and mount.credential and not mount.credential.managed
else None
)
return render( return render(
request, request,
"mount_form.html", "mount_form.html",
@@ -426,7 +562,10 @@ def _mount_form(
errors=errors, errors=errors,
mount=mount, mount=mount,
public_key=pub, public_key=pub,
has_secret=bool(mount is not None and mount.credential is not None), has_secret=bool(
mount is not None and mount.credential is not None and not mount.credential.managed
),
key_options=_key_options(request),
) )
@@ -487,7 +626,7 @@ async def mount_test_form(request: Request) -> Response:
existing_id = int(form["mount_id"]) if form.get("mount_id", "").isdigit() else None existing_id = int(form["mount_id"]) if form.get("mount_id", "").isdigit() else None
with state.session_factory() as s: with state.session_factory() as s:
existing = s.get(Mount, existing_id) if existing_id else None existing = s.get(Mount, existing_id) if existing_id else None
cred = credential_for_test(data, existing, state.secrets) cred = credential_for_test(data, existing, state.secrets, s)
cfg = data.config() cfg = data.config()
if data.type is MountType.SFTP and existing is not None: if data.type is MountType.SFTP and existing is not None:
cfg = dataclasses.replace(cfg, host_key=existing.host_key) cfg = dataclasses.replace(cfg, host_key=existing.host_key)
@@ -503,7 +642,7 @@ async def mount_test(request: Request, mount_id: int) -> Response:
if m is None: if m is None:
return HTMLResponse("Anbindung existiert nicht.", status_code=404) return HTMLResponse("Anbindung existiert nicht.", status_code=404)
data = mount_input_from_model(m) data = mount_input_from_model(m)
cred = credential_for_test(data, m, state.secrets) cred = credential_for_test(data, m, state.secrets, s)
from netmover.mounting.manager import MountConfig from netmover.mounting.manager import MountConfig
cfg = MountConfig.from_model(m) cfg = MountConfig.from_model(m)
@@ -563,6 +702,74 @@ async def mount_reset_hostkey(request: Request, mount_id: int) -> Response:
return redirect(f"/mounts#mount-{mount_id}") return redirect(f"/mounts#mount-{mount_id}")
# -- Schlüsselverwaltung ----------------------------------------------------------------------
@protected.get("/keys", response_class=HTMLResponse)
async def keys_page(request: Request) -> Response:
with nm(request).session_factory() as s:
return render(request, "keys.html", active="keys", keys=list_keys(s))
async def _key_action(request: Request, work: Callable[[Any], str]) -> Response:
state = nm(request)
try:
with session_scope(state.session_factory) as s:
flash(request, work(s))
except AdminError as exc:
flash(request, str(exc), "error")
return redirect("/keys")
@protected.post("/keys/generate")
async def key_generate(request: Request) -> Response:
form = _form_dict(await request.form())
secrets = nm(request).secrets
def work(s: Any) -> str:
key = generate_key(s, secrets, form.get("name", ""))
return f"Schlüssel „{key.name}“ erzeugt."
return await _key_action(request, work)
@protected.post("/keys/import")
async def key_import(request: Request) -> Response:
form = _form_dict(await request.form())
secrets = nm(request).secrets
def work(s: Any) -> str:
key = import_key(
s,
secrets,
form.get("name", ""),
form.get("private_key", ""),
form.get("passphrase", ""),
)
return f"Schlüssel „{key.name}“ importiert."
return await _key_action(request, work)
@protected.post("/keys/{key_id}/rename")
async def key_rename(request: Request, key_id: int) -> Response:
form = _form_dict(await request.form())
def work(s: Any) -> str:
rename_key(s, key_id, form.get("name", ""))
return "Schlüssel umbenannt."
return await _key_action(request, work)
@protected.post("/keys/{key_id}/delete")
async def key_delete(request: Request, key_id: int) -> Response:
def work(s: Any) -> str:
return f"Schlüssel „{delete_key(s, key_id)}“ gelöscht."
return await _key_action(request, work)
# -- Regeln ----------------------------------------------------------------------------------- # -- Regeln -----------------------------------------------------------------------------------
@@ -721,13 +928,73 @@ async def log_items(request: Request, run_id: int) -> Response:
return render(request, "partials/run_items.html", items=items, run=run) return render(request, "partials/run_items.html", items=items, run=run)
# -- Hilfe --------------------------------------------------------------------------------------
REPO_URL = "https://git.wilde-server.de/florian.wilde/netmover"
_IMG_NAME = re.compile(r"^[a-z0-9_-]+\.png$")
def _help_dir() -> Path:
"""Im installierten Paket mitgeliefert (siehe pyproject), sonst docs/ im Repo."""
packaged = Path(str(files("netmover.web") / "help"))
if (packaged / "ANLEITUNG.md").is_file():
return packaged
return Path(__file__).resolve().parents[3] / "docs"
@lru_cache(maxsize=1)
def _help_html() -> str:
text = (_help_dir() / "ANLEITUNG.md").read_text(encoding="utf-8")
html = markdown.markdown(text, extensions=["tables", "attr_list", "fenced_code"])
html = html.replace('src="img/', 'src="/help/img/')
return html.replace(
'href="../README.md"', f'href="{REPO_URL}#readme" target="_blank" rel="noopener"'
)
@protected.get("/help", response_class=HTMLResponse)
async def help_page(request: Request) -> Response:
return render(request, "help.html", active="help", content=_help_html())
@protected.get("/help/img/{name}")
async def help_image(request: Request, name: str) -> Response:
path = _help_dir() / "img" / name
if not _IMG_NAME.fullmatch(name) or not path.is_file():
return Response(status_code=404)
return FileResponse(path, media_type="image/png")
# -- Einstellungen ------------------------------------------------------------------------------ # -- Einstellungen ------------------------------------------------------------------------------
def _monitor_urls(request: Request) -> list[tuple[str, str]]:
"""Adressen für Uptime-Kuma-HTTP-Monitore (Gesamtzustand und Einzel-Endpunkte)."""
base = str(request.base_url).rstrip("/")
urls = [("Gesamtzustand", f"{base}/healthz")]
with nm(request).session_factory() as s:
urls += [
(f"Anbindung „{n}“", f"{base}/healthz/mount/{slug}")
for n, slug in s.execute(select(Mount.name, Mount.slug).order_by(Mount.name))
]
urls += [
(f"Regel „{n}“", f"{base}/healthz/rule/{rid}")
for rid, n in s.execute(select(Rule.id, Rule.name).order_by(Rule.name))
]
return urls
@protected.get("/settings", response_class=HTMLResponse) @protected.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request) -> Response: async def settings_page(request: Request) -> Response:
cfg = load_settings(nm(request).session_factory) cfg = load_settings(nm(request).session_factory)
return render(request, "settings.html", active="settings", cfg=cfg, errors={}) return render(
request,
"settings.html",
active="settings",
cfg=cfg,
errors={},
monitor_urls=_monitor_urls(request),
)
@protected.post("/settings", response_class=HTMLResponse) @protected.post("/settings", response_class=HTMLResponse)
@@ -754,6 +1021,37 @@ async def settings_save(request: Request) -> Response:
if url and not url.startswith(("http://", "https://")): if url and not url.startswith(("http://", "https://")):
errors["webhook_url"] = "URL muss mit http:// oder https:// beginnen." errors["webhook_url"] = "URL muss mit http:// oder https:// beginnen."
cfg.webhook_url = url cfg.webhook_url = url
cfg.email_enabled = form.get("email_enabled") == "on"
cfg.smtp_host = (form.get("smtp_host") or "").strip()
cfg.smtp_user = (form.get("smtp_user") or "").strip()
cfg.smtp_from = (form.get("smtp_from") or "").strip()
cfg.smtp_to = (form.get("smtp_to") or "").strip()
cfg.smtp_security = form.get("smtp_security") or "starttls"
try:
cfg.smtp_port = int(form.get("smtp_port") or 587)
if not 1 <= cfg.smtp_port <= 65535:
raise ValueError
except ValueError:
errors["smtp_port"] = "Port zwischen 1 und 65535."
if cfg.smtp_security not in SECURITY_MODES:
errors["smtp_security"] = "Ungültig."
if cfg.smtp_host and not re.fullmatch(r"[A-Za-z0-9.-]+", cfg.smtp_host):
errors["smtp_host"] = "Hostname oder IP-Adresse."
if cfg.smtp_from and not valid_address(cfg.smtp_from):
errors["smtp_from"] = "Keine gültige E-Mail-Adresse."
try:
parse_recipients(cfg.smtp_to)
except MailConfigError as exc:
errors["smtp_to"] = str(exc)
if cfg.email_enabled and not (cfg.smtp_host and cfg.smtp_from and cfg.smtp_to):
errors["smtp_host"] = "Für E-Mail werden Server, Absender und Empfänger benötigt."
if form.get("smtp_password"):
cfg.smtp_password_enc = state.secrets.encrypt(form["smtp_password"])
kuma = (form.get("kuma_push_url") or "").strip()
if kuma != auth_mask(cfg.kuma_push_url):
if kuma and not kuma.startswith(("http://", "https://")):
errors["kuma_push_url"] = "URL muss mit http:// oder https:// beginnen."
cfg.kuma_push_url = kuma
cfg.notify_recovery = form.get("notify_recovery") == "on" cfg.notify_recovery = form.get("notify_recovery") == "on"
cfg.webhook_template = form.get("webhook_template") or cfg.webhook_template cfg.webhook_template = form.get("webhook_template") or cfg.webhook_template
cfg.webhook_content_type = (form.get("webhook_content_type") or "application/json").strip() cfg.webhook_content_type = (form.get("webhook_content_type") or "application/json").strip()
@@ -799,6 +1097,39 @@ async def settings_webhook_test(request: Request) -> Response:
return HTMLResponse('<p class="msg ok">Testnachricht gesendet.</p>') return HTMLResponse('<p class="msg ok">Testnachricht gesendet.</p>')
@protected.post("/settings/mail-test", response_class=HTMLResponse)
async def settings_mail_test(request: Request) -> Response:
state = nm(request)
cfg = load_settings(state.session_factory)
if not (cfg.smtp_host and cfg.smtp_from and cfg.smtp_to):
return HTMLResponse('<p class="msg error">Server, Absender und Empfänger speichern.</p>')
try:
await run_in_threadpool(
state.notifier.send_mail_now,
"NetMover: Testnachricht",
"Die E-Mail-Benachrichtigung funktioniert.",
cfg,
)
except Exception as exc:
detail = escape(str(exc))[:300] or type(exc).__name__
return HTMLResponse(f'<p class="msg error">Fehlgeschlagen: {detail}</p>')
return HTMLResponse(f'<p class="msg ok">Gesendet an {escape(cfg.smtp_to)}.</p>')
@protected.post("/settings/kuma-test", response_class=HTMLResponse)
async def settings_kuma_test(request: Request) -> Response:
state = nm(request)
url = load_settings(state.session_factory).kuma_push_url
if not url:
return HTMLResponse('<p class="msg error">Keine Push-URL gespeichert.</p>')
ok = await run_in_threadpool(kuma_push, url, await run_in_threadpool(state.health))
if not ok:
return HTMLResponse('<p class="msg error">Kuma nicht erreichbar oder URL falsch.</p>')
return HTMLResponse(
'<p class="msg ok">Gemeldet – in Kuma sollte der Monitor jetzt grün/rot sein.</p>'
)
@protected.post("/settings/password", response_class=HTMLResponse) @protected.post("/settings/password", response_class=HTMLResponse)
async def settings_password(request: Request) -> Response: async def settings_password(request: Request) -> Response:
state = nm(request) state = nm(request)
+76 -9
View File
@@ -19,14 +19,68 @@ a { color: var(--accent); }
code, pre, .mono { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: .9em; } code, pre, .mono { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: .9em; }
pre { white-space: pre-wrap; word-break: break-word; margin: 0; } pre { white-space: pre-wrap; word-break: break-word; margin: 0; }
header.top { background: var(--card); border-bottom: 1px solid var(--line); } /* --- Layout: Kopfzeile + Seitenleiste (Desktop), aufklappbares Menü (Handy) --- */
header.top .inner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; :root { --sidebar: 228px; --topbar: 52px; }
gap: var(--gap); padding: 8px 16px; flex-wrap: wrap; } .topbar { position: sticky; top: 0; z-index: 20; height: var(--topbar); display: flex; align-items: center;
header.top .brand { font-weight: 700; text-decoration: none; color: var(--fg); margin-right: 8px; } gap: 12px; padding: 0 16px; background: var(--card); border-bottom: 1px solid var(--line); }
nav.main { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; } .topbar .brand { font-weight: 700; font-size: 1.05rem; text-decoration: none; color: var(--fg); }
nav.main a { padding: 6px 10px; border-radius: var(--radius); text-decoration: none; color: var(--fg); } .topbar .spacer { flex: 1; }
nav.main a.active, nav.main a:hover { background: var(--bg); } .health { display: inline-flex; align-items: center; gap: 7px; font-size: .85em; font-weight: 600;
main { max-width: 1200px; margin: 0 auto; padding: 16px; } padding: 4px 10px; border-radius: 99px; text-decoration: none; }
.health .dot { width: 9px; height: 9px; border-radius: 50%; background: currentColor; }
.health.ok { color: var(--ok); background: var(--ok-bg); }
.health.bad { color: var(--err); background: var(--err-bg); }
.health.warn { color: var(--warn); background: var(--warn-bg); }
.help-link { display: inline-flex; align-items: center; gap: 6px; font-size: .9em; text-decoration: none; }
.menu-toggle { display: none; align-items: center; gap: 6px; }
.ico { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 2;
stroke-linecap: round; stroke-linejoin: round; }
.sidebar { position: fixed; top: var(--topbar); left: 0; bottom: 0; width: var(--sidebar); overflow-y: auto;
padding: 14px 10px; background: var(--card); border-right: 1px solid var(--line);
display: flex; flex-direction: column; gap: 14px; }
.nav-title { font-size: .72em; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
color: var(--muted); padding: 0 10px 4px; }
.sidebar a, .nav-button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 10px;
border-radius: var(--radius); color: var(--fg); text-decoration: none; font: inherit;
background: none; border: 0; cursor: pointer; text-align: left; }
.sidebar a:hover, .nav-button:hover { background: var(--bg); }
.sidebar a.active { background: var(--info-bg); color: var(--accent); font-weight: 600; }
.sidebar .count { margin-left: auto; font-size: .75em; color: var(--muted); background: var(--bg);
border-radius: 99px; padding: 1px 7px; white-space: nowrap; }
.sidebar .count.bad { color: var(--err); background: var(--err-bg); }
.nav-foot { margin-top: auto; border-top: 1px solid var(--line); padding-top: 10px; }
.nav-foot form { margin: 0; }
.nav-foot .version { font-size: .75em; color: var(--muted); padding: 6px 10px 0; }
main { max-width: 1200px; margin: 0 auto; padding: 18px 20px 40px; }
body.with-nav main { margin-left: var(--sidebar); }
@media (min-width: 1430px) { body.with-nav main { margin-left: calc(var(--sidebar) + (100vw - var(--sidebar) - 1200px) / 2); } }
@media (max-width: 900px) {
.menu-toggle { display: inline-flex; }
.help-link span { display: none; }
body.with-nav main { margin-left: 0; }
/* Ohne JavaScript bleibt das Menü sichtbar (oben, nicht fixiert). */
.sidebar { position: static; width: auto; border-right: 0; border-bottom: 1px solid var(--line); }
.js .sidebar { position: fixed; width: min(280px, 85vw); transform: translateX(-105%);
transition: transform .18s ease; border-right: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0,0,0,.18); z-index: 30; }
.js .sidebar.open { transform: none; }
}
@media (max-width: 480px) { .health { font-size: 0; padding: 6px; } .health .dot { width: 11px; height: 11px; } }
/* --- Anleitung --- */
.doc { max-width: 860px; line-height: 1.6; padding: 18px 26px 26px; }
.doc h1 { font-size: 1.6rem; margin-top: 4px; }
.doc h2 { font-size: 1.25rem; margin-top: 34px; padding-top: 12px; border-top: 1px solid var(--line); }
.doc h3 { font-size: 1.05rem; margin-top: 22px; }
.doc table { margin: 10px 0 14px; font-size: .93em; }
.doc img { max-width: 100%; border: 1px solid var(--line); border-radius: var(--radius); margin: 8px 0; }
.doc pre { background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px; overflow-x: auto; }
.doc code { background: var(--bg); border-radius: 4px; padding: 0 4px; }
.doc pre code { background: none; padding: 0; }
.doc :target { scroll-margin-top: calc(var(--topbar) + 12px); }
h1 { font-size: 1.4rem; margin: 4px 0 16px; } h1 { font-size: 1.4rem; margin: 4px 0 16px; }
h2 { font-size: 1.1rem; margin: 24px 0 8px; } h2 { font-size: 1.1rem; margin: 24px 0 8px; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
@@ -95,7 +149,7 @@ details > summary { cursor: pointer; }
[hidden] { display: none !important; } [hidden] { display: none !important; }
@media (max-width: 640px) { @media (max-width: 640px) {
main { padding: 10px; } main { padding: 12px 12px 32px; }
th, td { padding: 6px; } th, td { padding: 6px; }
table.responsive thead { display: none; } table.responsive thead { display: none; }
table.responsive tr { display: block; border-bottom: 1px solid var(--line); padding: 6px 0; } table.responsive tr { display: block; border-bottom: 1px solid var(--line); padding: 6px 0; }
@@ -109,3 +163,16 @@ pre.text-err { margin-top: 6px; }
ul.tight { margin: 4px 0 0; padding-left: 20px; } ul.tight { margin: 4px 0 0; padding-left: 20px; }
.foot { text-align: center; padding: 16px; } .foot { text-align: center; padding: 16px; }
meter.fill { width: 100%; height: 8px; margin: 6px 0 2px; display: block; } meter.fill { width: 100%; height: 8px; margin: 6px 0 2px; display: block; }
.doc table { display: block; overflow-x: auto; max-width: 100%; }
.legend-help { display: inline-grid; place-items: center; width: 18px; height: 18px; margin-left: 4px;
border-radius: 50%; font-size: .75em; font-weight: 700; text-decoration: none; color: var(--muted);
border: 1px solid var(--line); vertical-align: 1px; }
.legend-help:hover { color: var(--accent); border-color: var(--accent); }
details.rename { display: inline-block; position: relative; }
details.rename > summary { list-style: none; }
details.rename > summary::-webkit-details-marker { display: none; }
details.rename[open] > form { position: absolute; right: 0; top: calc(100% + 4px); z-index: 5; width: 300px;
padding: 8px; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: 0 6px 18px rgba(0,0,0,.12); flex-wrap: nowrap; }
input.w-auto { width: auto; flex: 1; min-width: 200px; max-width: 360px; }
+26 -4
View File
@@ -1,6 +1,27 @@
// NetMover – kleine Helfer. Keine Inline-Skripte (CSP: script-src 'self'). // NetMover – kleine Helfer. Keine Inline-Skripte (CSP: script-src 'self').
(function () { (function () {
"use strict"; "use strict";
document.documentElement.classList.add("js");
// Menü auf dem Handy auf-/zuklappen
var toggle = document.querySelector(".menu-toggle");
var sidebar = document.getElementById("sidebar");
if (toggle && sidebar) {
toggle.addEventListener("click", function (e) {
e.stopPropagation();
var open = sidebar.classList.toggle("open");
toggle.setAttribute("aria-expanded", open ? "true" : "false");
});
document.addEventListener("click", function (e) {
if (sidebar.classList.contains("open") && !sidebar.contains(e.target)) {
sidebar.classList.remove("open");
toggle.setAttribute("aria-expanded", "false");
}
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") { sidebar.classList.remove("open"); toggle.setAttribute("aria-expanded", "false"); }
});
}
var meta = document.querySelector('meta[name="csrf-token"]'); var meta = document.querySelector('meta[name="csrf-token"]');
var csrf = meta ? meta.getAttribute("content") : ""; var csrf = meta ? meta.getAttribute("content") : "";
@@ -17,13 +38,14 @@
// Felder je nach Auswahl ein-/ausblenden: data-show-for="select-name:wert1 wert2" // Felder je nach Auswahl ein-/ausblenden: data-show-for="select-name:wert1 wert2"
function refresh(form) { function refresh(form) {
// data-show-for="feld:wert1 wert2" – mehrere Bedingungen mit „|“ (ODER)
form.querySelectorAll("[data-show-for]").forEach(function (el) { form.querySelectorAll("[data-show-for]").forEach(function (el) {
var spec = el.getAttribute("data-show-for").split(":"); el.hidden = !el.getAttribute("data-show-for").split("|").some(function (cond) {
var spec = cond.split(":");
var ctl = form.querySelector('[name="' + spec[0] + '"]:checked') || var ctl = form.querySelector('[name="' + spec[0] + '"]:checked') ||
form.querySelector('select[name="' + spec[0] + '"]'); form.querySelector('select[name="' + spec[0] + '"]');
var value = ctl ? ctl.value : ""; return spec[1].split(" ").indexOf(ctl ? ctl.value : "") !== -1;
var show = spec[1].split(" ").indexOf(value) !== -1; });
el.hidden = !show;
}); });
} }
document.querySelectorAll("form[data-dynamic]").forEach(function (form) { document.querySelectorAll("form[data-dynamic]").forEach(function (form) {
+42 -13
View File
@@ -1,3 +1,4 @@
{% from "icons.html" import icon %}
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
@@ -10,27 +11,55 @@
<script src="/static/htmx.min.js?v=2.0.4" defer></script> <script src="/static/htmx.min.js?v=2.0.4" defer></script>
<script src="/static/app.js?v={{ version }}" defer></script> <script src="/static/app.js?v={{ version }}" defer></script>
</head> </head>
<body> <body class="{{ 'with-nav' if logged_in else 'no-nav' }}">
{% if logged_in %} {% if logged_in %}
<header class="top"><div class="inner"> {% macro item(key, href, label, badge='', badge_cls='') -%}
<a href="{{ href }}" class="{{ 'active' if active == key }}"{% if active == key %} aria-current="page"{% endif %}>
{{ icon(key) }}<span>{{ label }}</span>{% if badge %}<span class="count {{ badge_cls }}">{{ badge }}</span>{% endif %}</a>
{%- endmacro %}
<header class="topbar">
<button type="button" class="menu-toggle" aria-controls="sidebar" aria-expanded="false">{{ icon('menu') }}<span>Menü</span></button>
<a class="brand" href="/">NetMover</a> <a class="brand" href="/">NetMover</a>
<nav class="main"> {% if nav %}
<a href="/" class="{{ 'active' if active == 'status' }}">Status</a> <a class="health {{ 'ok' if nav.healthy else 'bad' }}" href="/" title="Gesundheitszustand">
<a href="/mounts" class="{{ 'active' if active == 'mounts' }}">Anbindungen</a> <span class="dot"></span>{{ 'Alles in Ordnung' if nav.healthy else (nav.problems ~ (' Problem' if nav.problems == 1 else ' Probleme')) }}</a>
<a href="/rules" class="{{ 'active' if active == 'rules' }}">Regeln</a> {% if nav.maintenance %}<a class="health warn" href="/" title="Geplante Läufe sind pausiert"><span class="dot"></span>Wartungsmodus</a>{% endif %}
<a href="/log" class="{{ 'active' if active == 'log' }}">Protokoll</a> {% endif %}
<a href="/settings" class="{{ 'active' if active == 'settings' }}">Einstellungen</a> <span class="spacer"></span>
</nav> {% if help_anchor %}<a class="help-link" href="/help#{{ help_anchor }}">{{ icon('help') }}<span>Hilfe zu dieser Seite</span></a>{% endif %}
<form class="inline" method="post" action="/logout"> </header>
<nav id="sidebar" class="sidebar" aria-label="Hauptmenü">
<div class="nav-group">
<div class="nav-title">Übersicht</div>
{{ item('status', '/', 'Status') }}
</div>
<div class="nav-group">
<div class="nav-title">Verbindungen</div>
{{ item('mounts', '/mounts', 'Anbindungen', (nav.mounts_bad ~ ' gestört') if nav and nav.mounts_bad else (nav.mounts if nav and nav.mounts else ''), 'bad' if nav and nav.mounts_bad else '') }}
{{ item('keys', '/keys', 'Schlüssel', nav.keys if nav and nav.keys else '') }}
</div>
<div class="nav-group">
<div class="nav-title">Automatisierung</div>
{{ item('rules', '/rules', 'Regeln', (nav.rules_active ~ '/' ~ nav.rules ~ ' aktiv') if nav and nav.rules else '') }}
{{ item('log', '/log', 'Protokoll') }}
</div>
<div class="nav-group">
<div class="nav-title">System</div>
{{ item('settings', '/settings', 'Einstellungen') }}
{{ item('help', '/help', 'Hilfe') }}
</div>
<div class="nav-foot">
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf }}"> <input type="hidden" name="csrf_token" value="{{ csrf }}">
<button class="small">Abmelden</button> <button class="nav-button">{{ icon('logout') }}<span>Abmelden</span></button>
</form> </form>
</div></header> <div class="version">NetMover {{ version }}</div>
</div>
</nav>
{% endif %} {% endif %}
<main> <main>
{% for kind, text in flashes %}<p class="msg {{ kind }}">{{ text }}</p>{% endfor %} {% for kind, text in flashes %}<p class="msg {{ kind }}">{{ text }}</p>{% endfor %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer class="foot muted small">NetMover {{ version }}</footer>
</body> </body>
</html> </html>
+7
View File
@@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block title %}Hilfe{% endblock %}
{% block content %}
<article class="doc card">
{{ content|safe }}
</article>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{# Symbole als Inline-SVG (keine Fremd-Ressourcen, CSP-verträglich). Pfade im Stil von Feather/Lucide. #}
{% macro icon(name) -%}
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
{%- if name == 'status' -%}<path d="M3 12h4l3-8 4 16 3-8h4"/>
{%- elif name == 'mounts' -%}<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>
{%- elif name == 'keys' -%}<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>
{%- elif name == 'rules' -%}<path d="M4 7h13l-3-3M20 17H7l3 3"/>
{%- elif name == 'log' -%}<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
{%- elif name == 'settings' -%}<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>
{%- elif name == 'help' -%}<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.7.3-1 .9-1 1.7M12 17h.01"/>
{%- elif name == 'logout' -%}<path d="M15 4h4v16h-4M10 8l-4 4 4 4M6 12h11"/>
{%- elif name == 'menu' -%}<path d="M4 6h16M4 12h16M4 18h16"/>
{%- endif -%}
</svg>
{%- endmacro %}
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% from "macros.html" import csrf_field %}
{% block title %}Schlüssel{% endblock %}
{% block content %}
<h1>Schlüssel</h1>
<p class="muted small">Gespeicherte Schlüssel für SFTP-Anbindungen. Ein Schlüssel kann von mehreren
Anbindungen benutzt werden; den Benutzernamen legt jede Anbindung selbst fest. Private Schlüssel
liegen verschlüsselt in der Datenbank und werden nie angezeigt.</p>
{% if not keys %}<p class="muted">Noch keine Schlüssel gespeichert.</p>{% endif %}
<div class="stack">
{% for k in keys %}
<div class="card" id="key-{{ k.id }}">
<div class="row spread">
<div><strong>{{ k.name }}</strong> <span class="badge">{{ k.key_type.removeprefix('ssh-') }}</span>
{% if k.used_by %}<span class="badge ok">benutzt von {{ k.used_by|join(', ') }}</span>{% else %}<span class="badge">unbenutzt</span>{% endif %}</div>
<div class="row">
<details class="rename"><summary class="btn small">Umbenennen</summary>
<form class="row" method="post" action="/keys/{{ k.id }}/rename">{{ csrf_field(csrf) }}
<input type="text" name="name" value="{{ k.name }}" aria-label="Neuer Name" required maxlength="100">
<button class="small primary">Speichern</button></form></details>
<form class="inline" method="post" action="/keys/{{ k.id }}/delete" data-confirm="Schlüssel „{{ k.name }}“ löschen?">{{ csrf_field(csrf) }}
<button class="small danger"{% if k.used_by %} disabled title="Wird noch benutzt"{% endif %}>Löschen</button></form>
</div>
</div>
<div class="small muted">Fingerabdruck <span class="mono">{{ k.fingerprint }}</span> · angelegt {{ k.created_at|localdt }}</div>
<details class="small"><summary>Öffentlicher Schlüssel / authorized_keys</summary>
<div class="field"><label>Zeile für <code>~/.ssh/authorized_keys</code> (nur SFTP erlaubt, empfohlen)</label>
<pre class="mono card" id="auth-{{ k.id }}">{{ k.authorized_keys_line }}</pre>
<button class="small" type="button" data-copy="auth-{{ k.id }}">kopieren</button></div>
<div class="field"><label>Öffentlicher Schlüssel</label>
<pre class="mono card" id="pub-{{ k.id }}">{{ k.public_key }}</pre>
<button class="small" type="button" data-copy="pub-{{ k.id }}">kopieren</button></div>
</details>
</div>
{% endfor %}
</div>
<h2>Neuen Schlüssel erzeugen</h2>
<form class="form card row" method="post" action="/keys/generate">{{ csrf_field(csrf) }}
<input class="w-auto" type="text" name="name" placeholder="Name, z. B. paperless-lxc" aria-label="Name" required maxlength="100">
<button class="primary">Ed25519-Schlüssel erzeugen</button>
</form>
<h2>Vorhandenen Schlüssel importieren</h2>
<form class="form card stack" method="post" action="/keys/import">{{ csrf_field(csrf) }}
<div class="fields">
<div class="field"><label for="k-name">Name</label><input id="k-name" type="text" name="name" required maxlength="100"></div>
<div class="field"><label for="k-pass">Passphrase (falls vorhanden)</label><input id="k-pass" type="password" name="passphrase" autocomplete="off"></div>
<div class="field wide"><label for="k-priv">Privater Schlüssel</label>
<textarea id="k-priv" name="private_key" class="mono" rows="8" autocomplete="off" spellcheck="false"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;…&#10;-----END OPENSSH PRIVATE KEY-----" required></textarea>
<span class="hint">Ed25519, ECDSA oder RSA (mind. 2048 Bit), OpenSSH- oder PEM-Format. Eine Passphrase wird nur zum Entschlüsseln benutzt.</span></div>
</div>
<button>Importieren</button>
</form>
{% endblock %}
+2 -2
View File
@@ -21,8 +21,8 @@
</div> </div>
{%- endmacro %} {%- endmacro %}
{% macro check(name, label, checked, hint='') -%} {% macro check(name, label, checked, hint='', wide=false) -%}
<div class="field check"> <div class="field check{{ ' wide' if wide }}">
<input id="f-{{ name }}" type="checkbox" name="{{ name }}" {{ 'checked' if checked }}> <input id="f-{{ name }}" type="checkbox" name="{{ name }}" {{ 'checked' if checked }}>
<label for="f-{{ name }}">{{ label }}</label> <label for="f-{{ name }}">{{ label }}</label>
{% if hint %}<span class="hint">{{ hint }}</span>{% endif %} {% if hint %}<span class="hint">{{ hint }}</span>{% endif %}
+14 -11
View File
@@ -7,8 +7,8 @@
<form class="form" method="post" data-dynamic> <form class="form" method="post" data-dynamic>
<input type="hidden" name="csrf_token" value="{{ csrf }}"> <input type="hidden" name="csrf_token" value="{{ csrf }}">
{% if mount %}<input type="hidden" name="mount_id" value="{{ mount.id }}">{% endif %} {% if mount %}<input type="hidden" name="mount_id" value="{{ mount.id }}">{% endif %}
<fieldset><legend>Verbindung</legend><div class="fields"> <fieldset><legend>Verbindung <a class="legend-help" href="/help#anbindung-felder" title="Hilfe">?</a></legend><div class="fields">
{{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"', hint=('Verzeichnis: ' ~ mount.mountpoint if mount and mount.mountpoint else 'Bestimmt das Verzeichnis unter /mnt/netmover')) }} {{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"', hint=('Verzeichnis: ' ~ mount.mountpoint if mount and mount.mountpoint else '')) }}
{{ select('type', 'Typ', [('cifs', 'SMB/CIFS'), ('nfs4', 'NFS 4'), ('nfs3', 'NFS 3'), ('sftp', 'SFTP (nur Ziel)')], d.type.value, errors=errors) }} {{ select('type', 'Typ', [('cifs', 'SMB/CIFS'), ('nfs4', 'NFS 4'), ('nfs3', 'NFS 3'), ('sftp', 'SFTP (nur Ziel)')], d.type.value, errors=errors) }}
{{ field('server', 'Server', d.server, errors=errors, placeholder='192.168.1.10 oder nas.lan', attrs='required') }} {{ field('server', 'Server', d.server, errors=errors, placeholder='192.168.1.10 oder nas.lan', attrs='required') }}
<div class="field{{ ' has-error' if errors.get('share') }}"> <div class="field{{ ' has-error' if errors.get('share') }}">
@@ -22,29 +22,32 @@
<div data-show-for="type:sftp">{{ field('port', 'SSH-Port', d.port or '', type='number', errors=errors, placeholder='22') }}</div> <div data-show-for="type:sftp">{{ field('port', 'SSH-Port', d.port or '', type='number', errors=errors, placeholder='22') }}</div>
</div></fieldset> </div></fieldset>
<fieldset data-show-for="type:cifs sftp"><legend>Anmeldung</legend><div class="fields"> <fieldset data-show-for="type:cifs sftp"><legend>Anmeldung <a class="legend-help" href="/help#anbindung-felder" title="Hilfe">?</a></legend><div class="fields">
<div data-show-for="type:sftp">{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel (NetMover erzeugt ihn)'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div> <div data-show-for="type:sftp">{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div>
{{ field('username', 'Benutzername', d.username, errors=errors, attrs='autocomplete="off"', hint='Bei CIFS leer lassen für Gastzugang') }} <div data-show-for="type:sftp"><div data-show-for="auth:ssh_key">
{{ select('key_id', 'Schlüssel', key_options, d.key_id, errors=errors, hint='Gespeicherte Schlüssel verwalten unter „Schlüssel“.') }}
</div></div>
{{ field('username', 'Benutzername', d.username, errors=errors, attrs='autocomplete="off"') }}
<div data-show-for="type:cifs">{{ field('domain', 'Domäne (optional)', d.domain, errors=errors) }}</div> <div data-show-for="type:cifs">{{ field('domain', 'Domäne (optional)', d.domain, errors=errors) }}</div>
<div class="field{{ ' has-error' if errors.get('password') }}" id="pw-field"> <div class="field{{ ' has-error' if errors.get('password') }}" id="pw-field" data-show-for="type:cifs|auth:password">
<label for="f-password">Passwort</label> <label for="f-password">Passwort</label>
<input id="f-password" type="password" name="password" autocomplete="new-password" placeholder="{{ '••••••' if has_secret }}"> <input id="f-password" type="password" name="password" autocomplete="new-password" placeholder="{{ '••••••' if has_secret }}">
{% if errors.get('password') %}<span class="error">{{ errors['password'] }}</span>{% endif %} {% if errors.get('password') %}<span class="error">{{ errors['password'] }}</span>{% endif %}
<span class="hint">{{ 'Leer lassen = unverändert. ' if has_secret }}Bei SFTP mit Schlüssel leer lassen.</span> <span class="hint">{{ 'Leer lassen = unverändert. ' if has_secret }}<span data-show-for="type:cifs">Benutzer und Passwort leer = Gastzugang.</span></span>
</div> </div>
</div> </div>
{% if public_key %}<div class="field wide"><label>Öffentlicher Schlüssel (in ~/.ssh/authorized_keys des Zielbenutzers eintragen)</label> {% if public_key %}<div class="field wide"><label>Öffentlicher Schlüssel dieser Anbindung (in ~/.ssh/authorized_keys des Zielbenutzers eintragen)</label>
<pre class="mono card" id="pubkey">{{ public_key }}</pre><button class="small" type="button" data-copy="pubkey">kopieren</button></div>{% endif %} <pre class="mono card" id="pubkey">{{ public_key }}</pre><button class="small" type="button" data-copy="pubkey">kopieren</button></div>{% endif %}
</fieldset> </fieldset>
<fieldset data-show-for="type:cifs"><legend>Eigentümer und Rechte (CIFS)</legend><div class="fields"> <fieldset data-show-for="type:cifs"><legend>Eigentümer und Rechte (CIFS) <a class="legend-help" href="/help#anbindung-felder" title="Hilfe">?</a></legend><div class="fields">
{{ field('uid', 'uid', d.uid, type='number', errors=errors, hint='Eigentümer aller Dateien im Mount') }} {{ field('uid', 'uid', d.uid, type='number', errors=errors, hint='Eigentümer aller Dateien im Mount') }}
{{ field('gid', 'gid', d.gid, type='number', errors=errors) }} {{ field('gid', 'gid', d.gid, type='number', errors=errors) }}
{{ field('file_mode', 'file_mode', d.file_mode, errors=errors, placeholder='0644') }} {{ field('file_mode', 'file_mode', d.file_mode, errors=errors, placeholder='0644') }}
{{ field('dir_mode', 'dir_mode', d.dir_mode, errors=errors, placeholder='0755') }} {{ field('dir_mode', 'dir_mode', d.dir_mode, errors=errors, placeholder='0755') }}
</div></fieldset> </div></fieldset>
<fieldset data-show-for="type:cifs nfs3 nfs4"><legend>Erweitert</legend><div class="fields"> <fieldset data-show-for="type:cifs nfs3 nfs4"><legend>Erweitert <a class="legend-help" href="/help#anbindung-felder" title="Hilfe">?</a></legend><div class="fields">
{{ field('extra_options', 'Zusatzoptionen', d.extra_options, errors=errors, wide=true, placeholder='vers=3.0,noserverino', hint='Kommagetrennt; nur erlaubte Optionen. Zugangsdaten setzt NetMover selbst.') }} {{ field('extra_options', 'Zusatzoptionen', d.extra_options, errors=errors, wide=true, placeholder='vers=3.0,noserverino', hint='Kommagetrennt; nur erlaubte Optionen. Zugangsdaten setzt NetMover selbst.') }}
{{ check('auto_mount', 'Beim Start automatisch einhängen', d.auto_mount) }} {{ check('auto_mount', 'Beim Start automatisch einhängen', d.auto_mount) }}
</div></fieldset> </div></fieldset>
@@ -52,7 +55,7 @@
<div class="row"> <div class="row">
<button class="primary">Speichern</button> <button class="primary">Speichern</button>
<button type="button" hx-post="/mounts/test" hx-include="closest form" hx-target="#form-check" hx-swap="innerHTML"> <button type="button" title="Einhängen, lesen, schreiben – ohne zu speichern" hx-post="/mounts/test" hx-include="closest form" hx-target="#form-check" hx-swap="innerHTML">
Testen <span class="htmx-indicator">(läuft …)</span></button> Testen <span class="htmx-indicator">(läuft …)</span></button>
<a class="btn" href="/mounts">Abbrechen</a> <a class="btn" href="/mounts">Abbrechen</a>
</div> </div>
+3
View File
@@ -32,6 +32,9 @@
<div class="small">Hostschlüssel: {% if m.host_key %}<span class="mono">{{ m.host_key.split()[0] }}</span> festgehalten <div class="small">Hostschlüssel: {% if m.host_key %}<span class="mono">{{ m.host_key.split()[0] }}</span> festgehalten
<form class="inline" method="post" action="/mounts/{{ m.id }}/reset-hostkey" data-confirm="Hostschlüssel zurücksetzen? Nur tun, wenn der Server neu aufgesetzt wurde.">{{ csrf_field(csrf) }}<button class="small">zurücksetzen</button></form> <form class="inline" method="post" action="/mounts/{{ m.id }}/reset-hostkey" data-confirm="Hostschlüssel zurücksetzen? Nur tun, wenn der Server neu aufgesetzt wurde.">{{ csrf_field(csrf) }}<button class="small">zurücksetzen</button></form>
{% else %}<span class="text-warn">noch nicht bestätigt – bitte „Testen“</span>{% endif %}</div> {% else %}<span class="text-warn">noch nicht bestätigt – bitte „Testen“</span>{% endif %}</div>
{% if key_names.get(m.id) %}
<div class="small">Anmeldung mit gespeichertem Schlüssel <a href="/keys">„{{ key_names[m.id] }}“</a></div>
{% endif %}
{% if pubkeys.get(m.id) %} {% if pubkeys.get(m.id) %}
<details class="small"><summary>Öffentlicher Schlüssel für authorized_keys</summary> <details class="small"><summary>Öffentlicher Schlüssel für authorized_keys</summary>
<pre class="mono" id="pub-{{ m.id }}">{{ pubkeys[m.id] }}</pre> <pre class="mono" id="pub-{{ m.id }}">{{ pubkeys[m.id] }}</pre>
@@ -33,17 +33,19 @@
<thead><tr><th>Regel</th><th>Letztes Ergebnis</th><th>Letzter Erfolg</th><th>Nächster Lauf</th><th></th></tr></thead> <thead><tr><th>Regel</th><th>Letztes Ergebnis</th><th>Letzter Erfolg</th><th>Nächster Lauf</th><th></th></tr></thead>
<tbody> <tbody>
{% for v in rules %}{% set r = v.r %} {% for v in rules %}{% set r = v.r %}
<tr class="{{ 'warn' if v.stale }}"> <tr class="{{ 'warn' if v.stale or r.waiting_since }}">
<td data-label="Regel"><strong>{{ r.name }}</strong>{% if not r.enabled %} <span class="badge">aus</span>{% endif %}{% if r.dry_run %} <span class="badge warn">Probelauf</span>{% endif %} <td data-label="Regel"><strong>{{ r.name }}</strong>{% if not r.enabled %} <span class="badge">aus</span>{% endif %}{% if r.dry_run %} <span class="badge warn">Probelauf</span>{% endif %}
<div class="muted small mono">{{ v.target }}</div></td> <div class="muted small mono">{{ v.target }}</div></td>
<td data-label="Letztes Ergebnis"> <td data-label="Letztes Ergebnis">
{% if v.running_since %}<span class="badge warn">läuft seit {{ v.running_since|ago|replace('vor ', '') }}</span> {% if r.waiting_since %}<span class="badge warn">wartet seit {{ r.waiting_since|ago|replace('vor ', '') }}</span>
<div class="small text-warn">{{ r.waiting_reason }}</div>
{% elif v.running_since %}<span class="badge warn">läuft seit {{ v.running_since|ago|replace('vor ', '') }}</span>
{% elif v.last_run %}{{ run_badge(v.last_run.status) }} {% elif v.last_run %}{{ run_badge(v.last_run.status) }}
<span class="small muted">{{ v.last_run.finished_at|ago }}{% if v.last_run.seen %} · {{ v.last_run.moved }}/{{ v.last_run.seen }}{% endif %}</span> <span class="small muted">{{ v.last_run.finished_at|ago }}{% if v.last_run.seen %} · {{ v.last_run.moved }}/{{ v.last_run.seen }}{% endif %}</span>
{% if v.last_run.status in (RunStatus.ERROR, RunStatus.PARTIAL) and v.last_run.message %}<div class="small text-err">{{ v.last_run.message }}</div>{% endif %} {% if v.last_run.status in (RunStatus.ERROR, RunStatus.PARTIAL) and v.last_run.message %}<div class="small text-err">{{ v.last_run.message }}</div>{% endif %}
{% else %}<span class="muted">–</span>{% endif %}</td> {% else %}<span class="muted">–</span>{% endif %}</td>
<td data-label="Letzter Erfolg">{{ r.last_success_at|ago }}{% if v.stale %}<div class="small text-warn">länger als 3 × Intervall!</div>{% endif %} <td data-label="Letzter Erfolg">{{ r.last_success_at|ago }}{% if v.stale %}<div class="small text-warn">länger als 3 × Intervall!</div>{% endif %}
{% if r.consecutive_failures %}<div class="small text-err">{{ r.consecutive_failures }} Fehler in Folge</div>{% endif %}</td> {% if r.consecutive_failures and not r.waiting_since %}<div class="small text-err">{{ r.consecutive_failures }} Fehler in Folge</div>{% endif %}</td>
<td data-label="Nächster Lauf">{{ v.next_run|ago if v.next_run else '–' }}</td> <td data-label="Nächster Lauf">{{ v.next_run|ago if v.next_run else '–' }}</td>
<td><form class="inline" method="post" action="/rules/{{ r.id }}/run"> <td><form class="inline" method="post" action="/rules/{{ r.id }}/run">
<input type="hidden" name="csrf_token" value="{{ csrf }}"> <input type="hidden" name="csrf_token" value="{{ csrf }}">
+5 -5
View File
@@ -7,14 +7,14 @@
{% if not src_mounts %}<p class="msg info">Zuerst eine SMB- oder NFS-Anbindung als Quelle <a href="/mounts/new">anlegen</a>.</p>{% endif %} {% if not src_mounts %}<p class="msg info">Zuerst eine SMB- oder NFS-Anbindung als Quelle <a href="/mounts/new">anlegen</a>.</p>{% endif %}
<form class="form" method="post" data-dynamic> <form class="form" method="post" data-dynamic>
<input type="hidden" name="csrf_token" value="{{ csrf }}"> <input type="hidden" name="csrf_token" value="{{ csrf }}">
<fieldset><legend>Allgemein</legend><div class="fields"> <fieldset><legend>Allgemein <a class="legend-help" href="/help#regeln" title="Hilfe">?</a></legend><div class="fields">
{{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"') }} {{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"') }}
{{ field('interval_seconds', 'Intervall (Sekunden)', d.interval_seconds, type='number', errors=errors, attrs='min="10" required', hint='mindestens 10') }} {{ field('interval_seconds', 'Intervall (Sekunden)', d.interval_seconds, type='number', errors=errors, attrs='min="10" required', hint='mindestens 10') }}
{{ check('enabled', 'Aktiv', d.enabled) }} {{ check('enabled', 'Aktiv', d.enabled) }}
{{ check('dry_run', 'Nur Probelauf (nichts verändern)', d.dry_run) }} {{ check('dry_run', 'Nur Probelauf (nichts verändern)', d.dry_run) }}
</div></fieldset> </div></fieldset>
<fieldset><legend>Quelle</legend><div class="fields"> <fieldset><legend>Quelle <a class="legend-help" href="/help#regel-quelle-ziel" title="Hilfe">?</a></legend><div class="fields">
{{ select('src_mount_id', 'Anbindung', src_options, d.src_mount_id or '', errors=errors) }} {{ select('src_mount_id', 'Anbindung', src_options, d.src_mount_id or '', errors=errors) }}
{{ field('src_subpath', 'Unterordner', d.src_subpath, errors=errors, placeholder='z. B. Scans/Max', hint='relativ zur Freigabe; leer = Wurzel') }} {{ field('src_subpath', 'Unterordner', d.src_subpath, errors=errors, placeholder='z. B. Scans/Max', hint='relativ zur Freigabe; leer = Wurzel') }}
{{ select('pattern_type', 'Filterart', [('glob', 'Glob'), ('regex', 'Regulärer Ausdruck')], d.pattern_type.value, errors=errors) }} {{ select('pattern_type', 'Filterart', [('glob', 'Glob'), ('regex', 'Regulärer Ausdruck')], d.pattern_type.value, errors=errors) }}
@@ -22,7 +22,7 @@
{{ check('recursive', 'Unterordner einbeziehen (Struktur bleibt erhalten)', d.recursive) }} {{ check('recursive', 'Unterordner einbeziehen (Struktur bleibt erhalten)', d.recursive) }}
</div></fieldset> </div></fieldset>
<fieldset><legend>Ziel</legend><div class="fields"> <fieldset><legend>Ziel <a class="legend-help" href="/help#regel-quelle-ziel" title="Hilfe">?</a></legend><div class="fields">
{{ select('dst_kind', 'Zielart', [('mount', 'Anbindung'), ('local', 'Lokaler Pfad')], d.dst_kind, errors=errors) }} {{ select('dst_kind', 'Zielart', [('mount', 'Anbindung'), ('local', 'Lokaler Pfad')], d.dst_kind, errors=errors) }}
<div data-show-for="dst_kind:mount">{{ select('dst_mount_id', 'Anbindung', dst_options, d.dst_mount_id or '', errors=errors) }}</div> <div data-show-for="dst_kind:mount">{{ select('dst_mount_id', 'Anbindung', dst_options, d.dst_mount_id or '', errors=errors) }}</div>
{{ field('dst_path', 'Zielordner', d.dst_path, errors=errors, hint='Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren.') }} {{ field('dst_path', 'Zielordner', d.dst_path, errors=errors, hint='Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren.') }}
@@ -30,14 +30,14 @@
{{ select('on_collision', 'Wenn Datei existiert', [('rename', 'umbenennen (scan-1.pdf)'), ('skip', 'überspringen'), ('overwrite', 'überschreiben')], d.on_collision.value, errors=errors) }} {{ select('on_collision', 'Wenn Datei existiert', [('rename', 'umbenennen (scan-1.pdf)'), ('skip', 'überspringen'), ('overwrite', 'überschreiben')], d.on_collision.value, errors=errors) }}
</div></fieldset> </div></fieldset>
<fieldset><legend>Sicherheit und Grenzen</legend><div class="fields"> <fieldset><legend>Sicherheit und Grenzen <a class="legend-help" href="/help#regel-sicherheit" title="Hilfe">?</a></legend><div class="fields">
{{ field('stability_seconds', 'Stabilitätsfenster (s)', d.stability_seconds, type='number', errors=errors, attrs='min="0"', hint='Größe und Zeit müssen so lange gleich bleiben') }} {{ field('stability_seconds', 'Stabilitätsfenster (s)', d.stability_seconds, type='number', errors=errors, attrs='min="0"', hint='Größe und Zeit müssen so lange gleich bleiben') }}
{{ field('min_age_seconds', 'Mindestalter (s)', d.min_age_seconds, type='number', errors=errors, attrs='min="0"') }} {{ field('min_age_seconds', 'Mindestalter (s)', d.min_age_seconds, type='number', errors=errors, attrs='min="0"') }}
{{ field('max_size_mb', 'Max. Größe (MB)', d.max_size_mb or '', type='number', errors=errors, hint='leer = unbegrenzt') }} {{ field('max_size_mb', 'Max. Größe (MB)', d.max_size_mb or '', type='number', errors=errors, hint='leer = unbegrenzt') }}
{{ field('op_timeout_seconds', 'Zeitlimit ohne Fortschritt (s)', d.op_timeout_seconds, type='number', errors=errors, attrs='min="10"') }} {{ field('op_timeout_seconds', 'Zeitlimit ohne Fortschritt (s)', d.op_timeout_seconds, type='number', errors=errors, attrs='min="10"') }}
</div></fieldset> </div></fieldset>
<fieldset><legend>Rechte und Quarantäne</legend><div class="fields"> <fieldset><legend>Rechte und Quarantäne <a class="legend-help" href="/help#regel-rechte" title="Hilfe">?</a></legend><div class="fields">
{{ field('set_owner', 'Eigentümer setzen', d.set_owner, errors=errors, placeholder='1000:1000', hint='leer = unverändert; auf CIFS wirkungslos') }} {{ field('set_owner', 'Eigentümer setzen', d.set_owner, errors=errors, placeholder='1000:1000', hint='leer = unverändert; auf CIFS wirkungslos') }}
{{ field('set_mode', 'Modus setzen', d.set_mode, errors=errors, placeholder='0644', hint='leer = wie Quelle') }} {{ field('set_mode', 'Modus setzen', d.set_mode, errors=errors, placeholder='0644', hint='leer = wie Quelle') }}
{{ field('quarantine_path', 'Quarantäneordner', d.quarantine_path, errors=errors, placeholder='_quarantaene', hint='relativ zur Quell-Anbindung; leer = keine Quarantäne') }} {{ field('quarantine_path', 'Quarantäneordner', d.quarantine_path, errors=errors, placeholder='_quarantaene', hint='relativ zur Quell-Anbindung; leer = keine Quarantäne') }}
+2
View File
@@ -22,6 +22,8 @@
</div> </div>
</div> </div>
<div class="muted small mono">{{ v.target }} · Filter {{ r.pattern }}{{ ' (rekursiv)' if r.recursive }}</div> <div class="muted small mono">{{ v.target }} · Filter {{ r.pattern }}{{ ' (rekursiv)' if r.recursive }}</div>
{% if r.waiting_since %}<p class="msg warn small">Wartet seit {{ r.waiting_since|localdt }} ({{ r.waiting_since|ago }}) – {{ r.waiting_reason }}
Die Regel läuft von selbst weiter, sobald die Anbindung zurück ist.</p>{% endif %}
<div class="small">Letzter Lauf: {% if v.running_since %}<span class="badge warn">läuft seit {{ v.running_since|ago|replace('vor ', '') }}</span>{% elif v.last_run %}{{ run_badge(v.last_run.status) }} {{ v.last_run.finished_at|ago }}{% else %}–{% endif %} <div class="small">Letzter Lauf: {% if v.running_since %}<span class="badge warn">läuft seit {{ v.running_since|ago|replace('vor ', '') }}</span>{% elif v.last_run %}{{ run_badge(v.last_run.status) }} {{ v.last_run.finished_at|ago }}{% else %}–{% endif %}
· letzter Erfolg: <span class="{{ 'text-warn' if v.stale }}">{{ r.last_success_at|ago }}</span> · letzter Erfolg: <span class="{{ 'text-warn' if v.stale }}">{{ r.last_success_at|ago }}</span>
· nächster: {{ v.next_run|ago if v.next_run else '–' }}</div> · nächster: {{ v.next_run|ago if v.next_run else '–' }}</div>
+35 -4
View File
@@ -8,18 +8,49 @@
<fieldset><legend>Protokoll</legend><div class="fields"> <fieldset><legend>Protokoll</legend><div class="fields">
{{ field('retention_days', 'Aufbewahrung (Tage)', cfg.retention_days, type='number', errors=errors, attrs='min="1" max="3650"') }} {{ field('retention_days', 'Aufbewahrung (Tage)', cfg.retention_days, type='number', errors=errors, attrs='min="1" max="3650"') }}
</div></fieldset> </div></fieldset>
<fieldset><legend>Benachrichtigung (Webhook)</legend><div class="fields"> <fieldset><legend>Benachrichtigungen <a class="legend-help" href="/help#benachrichtigungen" title="Hilfe">?</a></legend><div class="fields">
{{ select('notify_mode', 'Benachrichtigen', [('off', 'nie'), ('every', 'bei jedem fehlgeschlagenen Lauf'), ('after_n', 'nach N Fehlschlägen in Folge')], cfg.notify_mode.value, errors=errors) }} {{ select('notify_mode', 'Benachrichtigen', [('off', 'nie'), ('every', 'bei jedem fehlgeschlagenen Lauf'), ('after_n', 'nach N Fehlschlägen in Folge')], cfg.notify_mode.value, errors=errors, hint='Gilt für Webhook und E-Mail. Ausfälle von Anbindungen werden immer sofort gemeldet (außer bei „nie“).') }}
<div data-show-for="notify_mode:after_n">{{ field('notify_after', 'N', cfg.notify_after, type='number', errors=errors, attrs='min="1"') }}</div> <div data-show-for="notify_mode:after_n">{{ field('notify_after', 'N', cfg.notify_after, type='number', errors=errors, attrs='min="1"') }}</div>
{{ field('webhook_url', 'Webhook-URL', mask_url(cfg.webhook_url), errors=errors, wide=true, placeholder='https://ntfy.sh/mein-topic', hint='Wird nur gekürzt angezeigt; unverändert lassen = behalten, leeren = entfernen.') }} {{ check('notify_recovery', 'Entwarnung senden, wenn es wieder läuft', cfg.notify_recovery, wide=true) }}
</div></fieldset>
<fieldset><legend>Webhook</legend><div class="fields">
{{ field('webhook_url', 'Webhook-URL', mask_url(cfg.webhook_url), errors=errors, wide=true, placeholder='https://ntfy.sh/mein-topic', hint='Leer = kein Webhook. Wird nur gekürzt angezeigt; unverändert lassen = behalten.') }}
{{ field('webhook_content_type', 'Content-Type', cfg.webhook_content_type, errors=errors) }} {{ field('webhook_content_type', 'Content-Type', cfg.webhook_content_type, errors=errors) }}
{{ check('notify_recovery', 'Entwarnung senden, wenn es wieder läuft', cfg.notify_recovery) }}
<div class="field wide"><label for="f-tpl">Vorlage</label> <div class="field wide"><label for="f-tpl">Vorlage</label>
<textarea id="f-tpl" name="webhook_template" class="mono">{{ cfg.webhook_template }}</textarea> <textarea id="f-tpl" name="webhook_template" class="mono">{{ cfg.webhook_template }}</textarea>
<span class="hint">Platzhalter: $event $title $message $rule $time $host – bei JSON automatisch maskiert.</span></div> <span class="hint">Platzhalter: $event $title $message $rule $time $host – bei JSON automatisch maskiert.</span></div>
</div> </div>
<div class="row"><button type="button" hx-post="/settings/webhook-test" hx-target="#hook-result">Test senden (gespeicherte Einstellungen)</button><span id="hook-result"></span></div> <div class="row"><button type="button" hx-post="/settings/webhook-test" hx-target="#hook-result">Test senden (gespeicherte Einstellungen)</button><span id="hook-result"></span></div>
</fieldset> </fieldset>
<fieldset><legend>E-Mail</legend><div class="fields">
{{ check('email_enabled', 'E-Mail-Benachrichtigung einschalten', cfg.email_enabled, wide=true) }}
{{ field('smtp_host', 'SMTP-Server', cfg.smtp_host, errors=errors, placeholder='smtp.example.org') }}
{{ field('smtp_port', 'Port', cfg.smtp_port, type='number', errors=errors, attrs='min="1" max="65535"', hint='587 (STARTTLS) oder 465 (SSL)') }}
{{ select('smtp_security', 'Verschlüsselung', [('starttls', 'STARTTLS'), ('ssl', 'SSL/TLS'), ('none', 'keine (nur interne Relays)')], cfg.smtp_security, errors=errors) }}
{{ field('smtp_user', 'Benutzer', cfg.smtp_user, errors=errors, attrs='autocomplete="off"', hint='leer = ohne Anmeldung') }}
<div class="field{{ ' has-error' if errors.get('smtp_password') }}"><label for="f-smtp_password">Passwort</label>
<input id="f-smtp_password" type="password" name="smtp_password" autocomplete="new-password" placeholder="{{ '••••••' if cfg.smtp_password_enc }}">
<span class="hint">{{ 'Leer lassen = unverändert.' if cfg.smtp_password_enc else '' }} Wird verschlüsselt gespeichert.</span></div>
{{ field('smtp_from', 'Absender', cfg.smtp_from, errors=errors, placeholder='netmover@example.org') }}
{{ field('smtp_to', 'Empfänger', cfg.smtp_to, errors=errors, wide=true, placeholder='ich@example.org, admin@example.org', hint='mehrere mit Komma getrennt') }}
</div>
<div class="row"><button type="button" hx-post="/settings/mail-test" hx-target="#mail-result">Test-E-Mail senden (gespeicherte Einstellungen)</button><span id="mail-result"></span></div>
</fieldset>
<fieldset><legend>Überwachung (Uptime Kuma) <a class="legend-help" href="/help#uptime-kuma" title="Hilfe">?</a></legend>
<div class="fields">
{{ field('kuma_push_url', 'Push-URL (Kuma-Monitor vom Typ „Push“)', mask_url(cfg.kuma_push_url), errors=errors, wide=true, placeholder='https://kuma.lan/api/push/AbCdEf?status=up&msg=OK&ping=', hint='NetMover meldet jede Minute den Zustand. Leer = aus. Wird nur gekürzt angezeigt.') }}
</div>
<div class="row"><button type="button" hx-post="/settings/kuma-test" hx-target="#kuma-result">Push jetzt testen (gespeicherte URL)</button><span id="kuma-result"></span></div>
<details class="small"><summary>Adressen für HTTP-Monitore (Kuma fragt NetMover ab)</summary>
<table class="small"><tbody>
{% for label, url in monitor_urls %}<tr><td>{{ label }}</td><td class="mono">{{ url }}</td></tr>{% endfor %}
</tbody></table>
<p class="muted small">200 = in Ordnung, 503 = Problem. Ohne Anmeldung erreichbar.</p>
</details>
</fieldset>
<button class="primary">Speichern</button> <button class="primary">Speichern</button>
</form> </form>
+8 -1
View File
@@ -2,7 +2,14 @@
{% block title %}Status{% endblock %} {% block title %}Status{% endblock %}
{% block content %} {% block content %}
<div class="row spread"><h1>Status</h1> <div class="row spread"><h1>Status</h1>
<span class="muted small">aktualisiert sich alle 15 s</span></div> <div class="row"><span class="muted small">aktualisiert sich alle 15 s</span>
<form class="inline" method="post" action="/maintenance"
{% if not (nav and nav.maintenance) %}data-confirm="Alle geplanten Läufe pausieren?"{% endif %}>
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button class="small">{{ 'Wartungsmodus beenden' if nav and nav.maintenance else 'Wartungsmodus' }}</button>
</form></div></div>
{% if nav and nav.maintenance %}<p class="msg warn"><strong>Wartungsmodus:</strong> Geplante Läufe sind pausiert.
„Jetzt ausführen“ geht weiterhin. Für Regeln schlägt <code>/healthz</code> keinen Alarm.</p>{% endif %}
<div id="status-body" hx-get="/partials/status" hx-trigger="every 15s" hx-swap="innerHTML"> <div id="status-body" hx-get="/partials/status" hx-trigger="every 15s" hx-swap="innerHTML">
{% include "partials/status_body.html" %} {% include "partials/status_body.html" %}
</div> </div>
+42
View File
@@ -5,13 +5,18 @@ from pathlib import Path
import pytest import pytest
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from netmover.app_state import NetMover
from netmover.config import Settings
from netmover.crypto import SecretBox from netmover.crypto import SecretBox
from netmover.db import make_engine, make_session_factory, session_scope, upgrade from netmover.db import make_engine, make_session_factory, session_scope, upgrade
from netmover.models import Credential, CredentialKind, Mount, MountType from netmover.models import Credential, CredentialKind, Mount, MountType
from netmover.mounting.fake import FakeMountBackend from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.manager import MountManager from netmover.mounting.manager import MountManager
from netmover.web.app import create_app
from tests.webutil import do_setup
@pytest.fixture @pytest.fixture
@@ -124,3 +129,40 @@ def make_mount(
return m.id return m.id
return factory return factory
# -- Weboberfläche -----------------------------------------------------------------------------
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
return Settings(
data_dir=tmp_path / "lib",
run_dir=tmp_path / "run",
mount_base=tmp_path / "mnt",
mount_backend="fake",
)
@pytest.fixture
def backend(tmp_path: Path) -> Iterator[FakeMountBackend]:
b = FakeMountBackend(tmp_path / "fake")
yield b
b.release_hangs()
@pytest.fixture
def state(settings: Settings, backend: FakeMountBackend) -> NetMover:
return NetMover(settings, backend=backend)
@pytest.fixture
def raw_client(settings: Settings, state: NetMover) -> Iterator[TestClient]:
with TestClient(create_app(settings, state), follow_redirects=False) as c:
yield c
@pytest.fixture
def client(raw_client: TestClient) -> TestClient:
raw_client.headers["X-Test-CSRF"] = do_setup(raw_client)
return raw_client
+277
View File
@@ -0,0 +1,277 @@
"""Schlüsselverwaltung: erzeugen, importieren, auswählen, schützen."""
from __future__ import annotations
import subprocess
from collections.abc import Iterator
from pathlib import Path
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.admin import AdminError, MountInput, delete_mount, save_mount
from netmover.crypto import SecretBox, generate_ssh_keypair
from netmover.db import session_scope
from netmover.keys import delete_key, fingerprint, generate_key, import_key, list_keys, rename_key
from netmover.models import Credential, CredentialKind, Mount, MountType
from netmover.mounting.manager import MountConfig, MountManager
from tests.sftp_stub import StubSFTPServer
def _sftp_input(**kw: object) -> MountInput:
data: dict[str, object] = {
"name": "paperless",
"type": MountType.SFTP,
"server": "127.0.0.1",
"share": "/consume",
"port": 22,
"username": "paperless",
"auth": CredentialKind.SSH_KEY,
}
data.update(kw)
return MountInput(**data) # type: ignore[arg-type]
# -- Modul --------------------------------------------------------------------------------------
def test_generate_key(session: Session, secretbox: SecretBox) -> None:
key = generate_key(session, secretbox, "paperless-lxc")
assert key.managed and key.kind is CredentialKind.SSH_KEY
assert key.public_key and key.public_key.startswith("ssh-ed25519 ")
assert key.public_key.endswith(" netmover:paperless-lxc")
assert "PRIVATE" not in key.public_key
assert "OPENSSH PRIVATE KEY" in secretbox.decrypt(key.secret_enc)
info = list_keys(session)[0]
assert info.fingerprint.startswith("SHA256:") and info.used_by == []
assert info.authorized_keys_line.startswith(
'restrict,command="/usr/lib/openssh/sftp-server" ssh-ed25519 '
)
def test_fingerprint_matches_ssh_keygen(tmp_path: Path) -> None:
_, pub = generate_ssh_keypair("x")
f = tmp_path / "k.pub"
f.write_text(pub + "\n")
try:
out = subprocess.run(
["ssh-keygen", "-lf", str(f)], # noqa: S607 – Abgleich mit OpenSSH
capture_output=True,
text=True,
check=True,
).stdout
except FileNotFoundError:
pytest.skip("ssh-keygen nicht installiert")
assert fingerprint(pub) == out.split()[1]
@pytest.mark.parametrize(
"kind", ["ed25519-openssh-pass", "rsa-pem", "ecdsa-pem-pass", "rsa-openssh"]
)
def test_import_formats(session: Session, secretbox: SecretBox, kind: str) -> None:
from cryptography.hazmat.primitives.asymmetric import ed25519
if kind.startswith("ed25519"):
key = ed25519.Ed25519PrivateKey.generate()
elif kind.startswith("rsa"):
key = rsa.generate_private_key(public_exponent=65537, key_size=2048) # type: ignore[assignment]
else:
key = ec.generate_private_key(ec.SECP256R1()) # type: ignore[assignment]
enc = (
serialization.BestAvailableEncryption(b"geheim")
if kind.endswith("pass")
else serialization.NoEncryption()
)
fmt = (
serialization.PrivateFormat.OpenSSH
if "openssh" in kind
else serialization.PrivateFormat.PKCS8
)
pem = key.private_bytes(serialization.Encoding.PEM, fmt, enc).decode()
cred = import_key(session, secretbox, kind, pem, "geheim" if kind.endswith("pass") else "")
expected_pub = (
key.public_key()
.public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH)
.decode()
)
assert cred.public_key is not None and cred.public_key.startswith(expected_pub)
stored = secretbox.decrypt(cred.secret_enc)
assert "OPENSSH PRIVATE KEY" in stored
# ohne Passphrase gespeichert → von paramiko direkt nutzbar
from netmover.mounting.sftp import load_private_key
assert load_private_key(stored).get_base64() == expected_pub.split()[1]
def test_import_errors(session: Session, secretbox: SecretBox) -> None:
from cryptography.hazmat.primitives.asymmetric import ed25519
key = ed25519.Ed25519PrivateKey.generate()
protected = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.OpenSSH,
serialization.BestAvailableEncryption(b"richtig"),
).decode()
with pytest.raises(AdminError, match="Passphrase angeben"):
import_key(session, secretbox, "a", protected, "")
with pytest.raises(AdminError, match="Passphrase falsch"):
import_key(session, secretbox, "a", protected, "falsch")
_, pub = generate_ssh_keypair()
with pytest.raises(AdminError, match="öffentlicher Schlüssel"):
import_key(session, secretbox, "a", pub)
with pytest.raises(AdminError, match="Kein lesbarer"):
import_key(
session, secretbox, "a", "-----BEGIN PRIVATE KEY-----\nMüll\n-----END PRIVATE KEY-----"
)
weak = (
rsa.generate_private_key(public_exponent=65537, key_size=1024) # noqa: S505 – absichtlich schwach
.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
.decode()
)
with pytest.raises(AdminError, match="zu schwach"):
import_key(session, secretbox, "a", weak)
assert list_keys(session) == []
def test_names(session: Session, secretbox: SecretBox) -> None:
generate_key(session, secretbox, "eins")
with pytest.raises(AdminError, match="existiert bereits"):
generate_key(session, secretbox, "eins")
with pytest.raises(AdminError, match="reserviert"):
generate_key(session, secretbox, "mount:x")
zwei = generate_key(session, secretbox, "zwei")
rename_key(session, zwei.id, "drei")
assert [k.name for k in list_keys(session)] == ["drei", "eins"]
with pytest.raises(AdminError):
rename_key(session, zwei.id, "eins")
# -- Zusammenspiel mit Anbindungen ----------------------------------------------------------
def test_mount_uses_shared_key_with_own_username(
session: Session, secretbox: SecretBox, manager: MountManager
) -> None:
key = generate_key(session, secretbox, "lxc")
a, err = save_mount(session, secretbox, manager, _sftp_input(key_id=str(key.id)))
assert not err and a is not None
b, err = save_mount(
session,
secretbox,
manager,
_sftp_input(name="anderer", server="10.0.0.9", username="root", key_id=str(key.id)),
)
assert not err and b is not None
assert a.credential is key and b.credential is key
assert (a.username, b.username) == ("paperless", "root")
assert key.username == "" # der Schlüssel selbst bleibt unverändert
assert list_keys(session)[0].used_by == ["anderer", "paperless"]
# Schlüssel in Benutzung → nicht löschbar
with pytest.raises(AdminError, match="noch benutzt"):
delete_key(session, key.id)
# Anbindung löschen lässt den gespeicherten Schlüssel stehen
delete_mount(session, manager, a.id)
delete_mount(session, manager, b.id)
assert session.get(Credential, key.id) is not None
delete_key(session, key.id)
assert session.get(Credential, key.id) is None
def test_switching_between_own_and_shared_key(
session: Session, secretbox: SecretBox, manager: MountManager
) -> None:
m, _ = save_mount(session, secretbox, manager, _sftp_input()) # eigener Schlüssel
assert m is not None and m.credential is not None and not m.credential.managed
own_id = m.credential.id
key = generate_key(session, secretbox, "geteilt")
m, _ = save_mount(session, secretbox, manager, _sftp_input(key_id=str(key.id)), m.id)
assert m is not None and m.credential is key
assert session.get(Credential, own_id) is None # eigener Schlüssel aufgeräumt
# Wechsel auf Passwort darf den gespeicherten Schlüssel nicht verändern
secret_before = key.secret_enc
m, _ = save_mount(
session,
secretbox,
manager,
_sftp_input(auth=CredentialKind.PASSWORD, password="pw123"),
m.id,
)
assert m is not None and m.credential is not None and m.credential is not key
assert not m.credential.managed and m.credential.username == "paperless"
assert m.username is None
assert key.secret_enc == secret_before and key.kind is CredentialKind.SSH_KEY
def test_unknown_or_foreign_key_id_is_rejected(
session: Session, secretbox: SecretBox, manager: MountManager
) -> None:
own, _ = save_mount(session, secretbox, manager, _sftp_input(name="a"))
assert own is not None and own.credential is not None
for bad in ("4711", str(own.credential.id)): # nicht vorhanden / kein gespeicherter Schlüssel
m, err = save_mount(session, secretbox, manager, _sftp_input(name="b", key_id=bad))
assert m is None and "key_id" in err
@pytest.fixture
def server(tmp_path: Path) -> Iterator[StubSFTPServer]:
root = tmp_path / "sftp-root"
(root / "consume").mkdir(parents=True)
srv = StubSFTPServer(root, username="paperless").start()
yield srv
srv.stop()
def test_manager_connects_with_shared_key_and_mount_username(
server: StubSFTPServer,
session_factory: sessionmaker[Session],
secretbox: SecretBox,
manager: MountManager,
) -> None:
with session_scope(session_factory) as s:
key = import_key(s, secretbox, "lxc", server.client_private_key)
m, err = save_mount(
s, secretbox, manager, _sftp_input(port=server.port, key_id=str(key.id))
)
assert not err and m is not None
mid = m.id
with session_factory() as s:
mm = s.get(Mount, mid)
assert mm is not None
cfg = MountConfig.from_model(mm)
from netmover.admin import credential_for_test, mount_input_from_model
with session_factory() as s:
mm = s.get(Mount, mid)
assert mm is not None
data = mount_input_from_model(mm)
assert data.key_id == str(mm.cred_id) and data.username == "paperless"
cred = credential_for_test(data, mm, secretbox, s)
assert cred is not None and cred.username == "paperless"
assert manager.check(cfg, cred, mount_id=mid).ok
assert manager.check_alive(mid) is None # Regellauf-Weg: Benutzer der Anbindung
# Gegenprobe: falscher Benutzer an der Anbindung → Anmeldung abgelehnt
with session_scope(session_factory) as s:
mm = s.get(Mount, mid)
assert mm is not None
mm.username = "jemand"
from netmover.mounting.manager import MountUnavailable
with pytest.raises(MountUnavailable, match="Anmeldung abgelehnt"):
manager.check_alive(mid)
def test_list_keys_ignores_per_mount_credentials(
session: Session, secretbox: SecretBox, manager: MountManager
) -> None:
save_mount(session, secretbox, manager, _sftp_input())
assert list_keys(session) == []
assert session.scalars(select(Credential)).one().managed is False
+7 -1
View File
@@ -110,6 +110,12 @@ def test_is_mounted_reads_mountinfo(tmp_path: Path) -> None:
), ),
("nfs4", "mount.nfs4: access denied by server while mounting nas:/x", "NFS-Berechtigungen"), ("nfs4", "mount.nfs4: access denied by server while mounting nas:/x", "NFS-Berechtigungen"),
("cifs", "mount error(112): Host is down", "nicht erreichbar"), ("cifs", "mount error(112): Host is down", "nicht erreichbar"),
# Praxisfall: Synology mit „Maximales SMB-Protokoll: SMB2“
(
"cifs",
"mount error(95): Operation not supported\nRefer to the mount.cifs(8) manual page",
"vers=2.0",
),
("cifs", "mount: only root can do that", "CAP_SYS_ADMIN"), ("cifs", "mount: only root can do that", "CAP_SYS_ADMIN"),
], ],
) )
@@ -121,7 +127,7 @@ def _alive(pid: int) -> bool:
try: try:
with open(f"/proc/{pid}/stat") as fh: with open(f"/proc/{pid}/stat") as fh:
return fh.read().split(")")[-1].split()[0] != "Z" return fh.read().split(")")[-1].split()[0] != "Z"
except FileNotFoundError: except (FileNotFoundError, ProcessLookupError): # verschwindet beim Lesen → ESRCH
return False return False
+304
View File
@@ -0,0 +1,304 @@
"""E-Mail-Benachrichtigung gegen einen echten SMTP-Server im Testprozess (aiosmtpd)."""
from __future__ import annotations
import datetime
import socket
import ssl
from collections.abc import Iterator
from email import message_from_bytes
from email.policy import default
from pathlib import Path
from typing import Any
import pytest
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import AuthResult, LoginPassword
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session, sessionmaker
from netmover.crypto import SecretBox
from netmover.mail import MailConfigError, parse_recipients, smtp_send
from netmover.notify import Notification, Notifier
from netmover.settings_store import AppSettings, NotifyMode, load_settings, save_settings
from tests.webutil import post
class Inbox:
def __init__(self) -> None:
self.messages: list[tuple[list[str], Any]] = []
async def handle_DATA(self, server: Any, session: Any, envelope: Any) -> str:
self.messages.append(
(envelope.rcpt_tos, message_from_bytes(envelope.content, policy=default))
)
return "250 OK"
def _free_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port: int = s.getsockname()[1]
s.close()
return port
def _auth(server: Any, session: Any, envelope: Any, mechanism: str, data: Any) -> AuthResult:
ok = (
isinstance(data, LoginPassword)
and data.login == b"netmover"
and data.password == b"Pa$$,wort"
)
return AuthResult(success=ok, handled=False)
@pytest.fixture(scope="module")
def tls_files(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]:
d = tmp_path_factory.mktemp("tls")
key = ec.generate_private_key(ec.SECP256R1())
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
now = datetime.datetime.now(datetime.UTC)
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=1))
.add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False)
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(key, hashes.SHA256())
)
(d / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
(d / "key.pem").write_bytes(
key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
)
return d / "cert.pem", d / "key.pem"
@pytest.fixture
def trust(tls_files: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch) -> None:
"""Client vertraut unserem Test-Zertifikat (Prüfung bleibt eingeschaltet)."""
monkeypatch.setattr(
"netmover.mail.tls_context", lambda: ssl.create_default_context(cafile=str(tls_files[0]))
)
def _server(mode: str, tls_files: tuple[Path, Path]) -> tuple[Controller, Inbox, int]:
inbox, port = Inbox(), _free_port()
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain(*map(str, tls_files))
kw: dict[str, Any] = {
"hostname": "localhost",
"port": port,
"authenticator": _auth,
"auth_exclude_mechanism": ["LOGIN"],
}
if mode == "starttls":
kw.update(tls_context=ctx, require_starttls=True)
elif mode == "ssl":
# implizites TLS erkennt aiosmtpd nicht als „verschlüsselt“ – AUTH trotzdem anbieten
kw.update(ssl_context=ctx, auth_require_tls=False)
else:
kw.update(auth_require_tls=False)
controller = Controller(inbox, **kw)
controller.start()
return controller, inbox, port
@pytest.fixture(params=["starttls", "ssl", "none"])
def smtp_server(
request: pytest.FixtureRequest, tls_files: tuple[Path, Path], trust: None
) -> Iterator[tuple[str, Inbox, int]]:
controller, inbox, port = _server(request.param, tls_files)
yield request.param, inbox, port
controller.stop()
def _cfg(mode: str, port: int, **kw: Any) -> AppSettings:
data: dict[str, Any] = {
"email_enabled": True,
"smtp_host": "localhost",
"smtp_port": port,
"smtp_security": mode,
"smtp_user": "netmover",
"smtp_from": "netmover@example.org",
"smtp_to": "ich@example.org; admin@example.org",
"notify_mode": NotifyMode.EVERY,
}
data.update(kw)
return AppSettings(**data)
def test_smtp_send_all_security_modes(smtp_server: tuple[str, Inbox, int]) -> None:
mode, inbox, port = smtp_server
smtp_send(_cfg(mode, port), "Pa$$,wort", "NetMover: Anbindung „NAS“ gestört", "Host is down")
((rcpts, msg),) = inbox.messages
assert rcpts == ["ich@example.org", "admin@example.org"]
assert msg["Subject"] == "NetMover: Anbindung „NAS“ gestört"
assert msg["From"] == "netmover@example.org"
body = msg.get_content()
assert "Host is down" in body and "NetMover auf" in body
def test_wrong_password_raises(smtp_server: tuple[str, Inbox, int]) -> None:
import smtplib
mode, inbox, port = smtp_server
with pytest.raises(smtplib.SMTPAuthenticationError):
smtp_send(_cfg(mode, port), "falsch", "x", "y")
assert inbox.messages == []
def test_certificate_is_verified(tls_files: tuple[Path, Path]) -> None:
"""Ohne Vertrauen in das Zertifikat wird abgelehnt – keine stille Herabstufung."""
controller, inbox, port = _server("starttls", tls_files)
try:
with pytest.raises(ssl.SSLCertVerificationError):
smtp_send(_cfg("starttls", port), "Pa$$,wort", "x", "y")
finally:
controller.stop()
assert inbox.messages == []
def test_recipients() -> None:
assert parse_recipients("a@b.de, c@d.org;e@f.net\n") == ["a@b.de", "c@d.org", "e@f.net"]
with pytest.raises(MailConfigError, match="kein-mail"):
parse_recipients("a@b.de, kein-mail")
def test_notifier_sends_webhook_and_mail(
session_factory: sessionmaker[Session], secretbox: SecretBox
) -> None:
hooks: list[Any] = []
mails: list[Any] = []
cfg = _cfg(
"starttls",
587,
smtp_password_enc=secretbox.encrypt("Pa$$,wort"),
webhook_url="http://hook.invalid/x",
)
save_settings(session_factory, cfg)
n = Notifier(
session_factory,
sender=lambda *a: hooks.append(a),
mailer=lambda *a: mails.append(a),
secrets=secretbox,
background=False,
)
n.send(Notification("mount.error", "NetMover: Anbindung „NAS“ gestört", "weg"))
assert len(hooks) == 1 and len(mails) == 1
_cfg_used, password, subject, body = mails[0]
assert password == "Pa$$,wort" and subject.endswith("gestört") and body == "weg"
def test_mail_only_and_mode_off(
session_factory: sessionmaker[Session], secretbox: SecretBox
) -> None:
mails: list[Any] = []
n = Notifier(
session_factory, mailer=lambda *a: mails.append(a), secrets=secretbox, background=False
)
save_settings(session_factory, _cfg("starttls", 587, notify_mode=NotifyMode.OFF))
n.send(Notification("x", "t", "m"))
save_settings(session_factory, _cfg("starttls", 587, email_enabled=False))
n.send(Notification("x", "t", "m"))
assert mails == []
save_settings(session_factory, _cfg("starttls", 587)) # nur E-Mail, kein Webhook
n.send(Notification("x", "t", "m"))
assert len(mails) == 1
def test_mail_failure_is_swallowed(
session_factory: sessionmaker[Session], secretbox: SecretBox
) -> None:
def broken(*a: Any) -> None:
raise ConnectionRefusedError("weg")
save_settings(session_factory, _cfg("starttls", 587))
Notifier(session_factory, mailer=broken, secrets=secretbox, background=False).send(
Notification("x", "t", "m")
)
# -- Einstellungsseite ------------------------------------------------------------------------
def _form(**kw: str) -> dict[str, str]:
data = {
"retention_days": "30",
"notify_mode": "every",
"notify_after": "3",
"webhook_url": "",
"webhook_content_type": "application/json",
"webhook_template": "$message",
"email_enabled": "on",
"smtp_host": "smtp.example.org",
"smtp_port": "587",
"smtp_security": "starttls",
"smtp_user": "netmover",
"smtp_password": "Geheim,123!",
"smtp_from": "netmover@example.org",
"smtp_to": "ich@example.org",
}
data.update(kw)
return data
def test_settings_store_encrypted_password(client: TestClient, state: Any) -> None:
assert post(client, "/settings", _form()).status_code == 303
cfg = load_settings(state.session_factory)
assert cfg.email_enabled and "Geheim" not in cfg.smtp_password_enc
assert state.secrets.decrypt(cfg.smtp_password_enc) == "Geheim,123!"
page = client.get("/settings").text
assert "Geheim,123!" not in page and 'placeholder="••••••"' in page
# leer lassen = unverändert
post(client, "/settings", _form(smtp_password=""))
assert (
state.secrets.decrypt(load_settings(state.session_factory).smtp_password_enc)
== "Geheim,123!"
)
def test_settings_validation(client: TestClient) -> None:
r = post(
client,
"/settings",
_form(smtp_to="ich@example.org, quatsch", smtp_port="99999", smtp_from="kein-absender"),
)
assert r.status_code == 400
for msg in ("Ungültige Adresse: quatsch", "Port zwischen", "Keine gültige E-Mail-Adresse"):
assert msg in r.text
def test_mail_test_button(
client: TestClient, state: Any, tls_files: tuple[Path, Path], trust: None
) -> None:
controller, inbox, port = _server("starttls", tls_files)
try:
post(
client,
"/settings",
_form(smtp_host="localhost", smtp_port=str(port), smtp_password="Pa$$,wort"),
)
r = post(client, "/settings/mail-test")
assert "Gesendet an ich@example.org" in r.text
post(
client,
"/settings",
_form(smtp_host="localhost", smtp_port=str(port), smtp_password="falsch"),
)
r = post(client, "/settings/mail-test")
assert "Fehlgeschlagen" in r.text and "falsch" not in r.text
finally:
controller.stop()
assert len(inbox.messages) == 1
+177
View File
@@ -0,0 +1,177 @@
"""Migrationen auf Datenbanken MIT Daten.
Praxisbefund beim Update 0.1.1 → 0.2.0: Migration 0002 baut auf SQLite Tabellen neu
auf (batch mode) und löscht dafür die alte Tabelle. Mit eingeschalteter
Fremdschlüsselprüfung scheiterte das, sobald Anbindungen auf Zugangsdaten
verwiesen – die Tests liefen bis dahin nur auf leeren Datenbanken. Und weil die
Migration nicht in einer Transaktion lief, blieb ein halber Zustand zurück.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
from alembic import command
from netmover.db import alembic_config, upgrade
def _head() -> str:
from alembic.script import ScriptDirectory
head = ScriptDirectory.from_config(alembic_config("sqlite://")).get_current_head()
assert head is not None
return head
def _seed(db: Path) -> None:
"""Datenbank auf Stand 0001 mit verknüpften Daten, wie auf einem echten System."""
c = sqlite3.connect(db)
c.execute("PRAGMA foreign_keys=ON")
c.executescript(
"""
INSERT INTO credentials (id, name, kind, username, secret_enc, created_at)
VALUES (1, 'mount:nas', 'password', 'scanner', 'gAAAA-x', '2026-10-10 20:00:00'),
(2, 'mount:paperless', 'ssh_key', 'root', 'gAAAA-y', '2026-10-10 20:00:00');
INSERT INTO mounts (id, name, slug, type, server, share, mountpoint, cred_id,
extra_options, auto_mount, enabled, state)
VALUES (1, 'NAS', 'nas', 'cifs', '10.0.10.6', 'Scans', '/mnt/netmover/nas', 1,
'', 1, 1, 'mounted'),
(2, 'paperless', 'paperless', 'sftp', '10.0.10.17', '/consume', NULL, 2,
'', 0, 1, 'mounted');
INSERT INTO rules (id, name, enabled, src_mount_id, src_subpath, dst_mount_id, dst_path,
pattern, pattern_type, recursive, interval_seconds, stability_seconds,
min_age_seconds, op_timeout_seconds, action, on_collision, dry_run,
quarantine_after, consecutive_failures, created_at)
VALUES (1, 'Scans → Paperless', 0, 1, '', 2, '', '*.pdf', 'glob', 0, 30, 5, 0, 120,
'move', 'rename', 0, 3, 0, '2026-10-10 20:00:00');
INSERT INTO runs (id, rule_id, started_at, status, "trigger", dry_run, seen, moved,
skipped, failed)
VALUES (1, 1, '2026-10-10 20:00:00', 'ok', 'manual', 1, 3, 3, 0, 0);
INSERT INTO run_items (run_id, src, status) VALUES (1, 'a.pdf', 'planned');
"""
)
c.commit()
c.close()
@pytest.fixture
def db_0001(tmp_path: Path) -> tuple[str, Path]:
db = tmp_path / "alt.db"
url = f"sqlite:///{db}"
upgrade(url, "0001")
_seed(db)
return url, db
def _state(db: Path) -> dict[str, object]:
c = sqlite3.connect(db)
try:
return {
"version": c.execute("SELECT version_num FROM alembic_version").fetchone()[0],
"tmp_tables": c.execute(
"SELECT name FROM sqlite_master WHERE name LIKE '_alembic_tmp%'"
).fetchall(),
"cred_columns": [r[1] for r in c.execute("PRAGMA table_info(credentials)")],
"mounts": c.execute("SELECT name, cred_id FROM mounts ORDER BY id").fetchall(),
"rules": c.execute("SELECT name, src_mount_id, dst_mount_id FROM rules").fetchall(),
"run_items": c.execute("SELECT COUNT(*) FROM run_items").fetchone()[0],
"fk_violations": c.execute("PRAGMA foreign_key_check").fetchall(),
}
finally:
c.close()
def test_upgrade_with_linked_data(db_0001: tuple[str, Path]) -> None:
url, db = db_0001
upgrade(url)
st = _state(db)
assert st["version"] == _head()
assert st["tmp_tables"] == [] and st["fk_violations"] == []
assert "managed" in st["cred_columns"] # type: ignore[operator]
assert st["mounts"] == [("NAS", 1), ("paperless", 2)]
assert st["rules"] == [("Scans → Paperless", 1, 2)]
assert st["run_items"] == 1
c = sqlite3.connect(db)
assert c.execute("SELECT managed FROM credentials ORDER BY id").fetchall() == [(0,), (0,)]
assert c.execute("SELECT username FROM mounts ORDER BY id").fetchall() == [(None,), (None,)]
c.close()
def test_downgrade_with_linked_data(db_0001: tuple[str, Path]) -> None:
url, db = db_0001
upgrade(url)
command.downgrade(alembic_config(url), "0001")
st = _state(db)
assert st["version"] == "0001" and st["fk_violations"] == []
assert st["mounts"] == [("NAS", 1), ("paperless", 2)]
def test_failed_migration_leaves_database_untouched(
db_0001: tuple[str, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Bricht eine Migration mittendrin ab, muss alles beim Alten bleiben – keine
halb umgebauten Tabellen, kein _alembic_tmp-Rest, Version unverändert."""
from alembic.ddl.impl import DefaultImpl
url, db = db_0001
before = _state(db)
real_drop = DefaultImpl.drop_table
def boom(self: DefaultImpl, *a: object, **kw: object) -> None:
raise RuntimeError("Absturz mitten in der Migration")
monkeypatch.setattr(DefaultImpl, "drop_table", boom)
with pytest.raises(RuntimeError, match="Absturz"):
upgrade(url)
assert _state(db) == before
monkeypatch.setattr(DefaultImpl, "drop_table", real_drop)
upgrade(url) # danach klappt es ohne Handarbeit
assert _state(db)["version"] == _head()
def test_app_engine_still_enforces_foreign_keys(db_url: str) -> None:
from sqlalchemy import text
from netmover.db import make_engine
engine = make_engine(db_url)
with engine.connect() as conn:
assert conn.execute(text("PRAGMA foreign_keys")).scalar() == 1
engine.dispose()
def test_upgrade_from_0002_keeps_everything_linked_to_rules(tmp_path: Path) -> None:
"""0003 baut ``rules`` neu auf – Läufe, Buchführung und Fehlerzähler verweisen darauf."""
db = tmp_path / "v02.db"
url = f"sqlite:///{db}"
upgrade(url, "0001")
_seed(db)
upgrade(url, "0002")
c = sqlite3.connect(db)
c.execute("PRAGMA foreign_keys=ON")
c.executescript(
"""
UPDATE credentials SET managed = 1 WHERE id = 2;
UPDATE mounts SET username = 'paperless' WHERE id = 2;
INSERT INTO copied_files (rule_id, src_relpath, size, mtime_ns, dst_path, copied_at)
VALUES (1, 'a.pdf', 1, 1, '/x/a.pdf', '2026-10-10 20:00:00');
INSERT INTO file_failures (rule_id, src_relpath, size, mtime_ns, attempts, last_attempt_at)
VALUES (1, 'b.pdf', 1, 1, 2, '2026-10-10 20:00:00');
"""
)
c.commit()
c.close()
upgrade(url)
st = _state(db)
assert st["version"] == _head() and st["fk_violations"] == [] and st["tmp_tables"] == []
assert st["rules"] == [("Scans → Paperless", 1, 2)] and st["run_items"] == 1
c = sqlite3.connect(db)
assert c.execute("SELECT COUNT(*) FROM copied_files").fetchone()[0] == 1
assert c.execute("SELECT attempts FROM file_failures").fetchone()[0] == 2
assert c.execute("SELECT waiting_since FROM rules").fetchone()[0] is None
assert c.execute("SELECT username FROM mounts WHERE id = 2").fetchone()[0] == "paperless"
c.close()
+218
View File
@@ -0,0 +1,218 @@
"""Uptime Kuma: /healthz, Einzel-Endpunkte, Push; Hilfe-Seite und Menü."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
import httpx
import pytest
from fastapi.testclient import TestClient
from netmover.db import session_scope
from netmover.health import Check, Health
from netmover.kuma import push, push_url
from netmover.models import Mount, MountState, MountType, Rule, utcnow
from netmover.settings_store import AppSettings, load_settings, save_settings
from tests.webutil import post
# -- push_url / push ---------------------------------------------------------------------------
def _health(ok: bool, *problems: str) -> Health:
h = Health()
h.add(Check("scheduler", "Scheduler", True, "läuft"))
for p in problems:
h.add(Check("mount", "x", False, p))
assert h.ok is ok
return h
def test_push_url_replaces_kuma_defaults() -> None:
url = push_url(
"https://kuma.lan/api/push/AbC?status=up&msg=OK&ping=", _health(False, "NAS weg")
)
parsed = httpx.URL(url)
assert parsed.path == "/api/push/AbC"
assert parsed.params["status"] == "down" and parsed.params["msg"] == "NAS weg"
assert push_url("https://kuma.lan/api/push/AbC", _health(True)).endswith(
"status=up&msg=OK&ping="
)
def test_push_message_is_truncated() -> None:
url = push_url("https://k/api/push/x", _health(False, "x" * 1000))
assert len(httpx.URL(url).params["msg"]) == 250
def test_push_swallows_errors_and_reports_result() -> None:
calls: list[str] = []
def ok_get(url: str, **kw: Any) -> httpx.Response:
calls.append(url)
return httpx.Response(200, request=httpx.Request("GET", url))
def bad_get(url: str, **kw: Any) -> httpx.Response:
raise httpx.ConnectError("weg")
assert push("https://k/api/push/x", _health(True), getter=ok_get) is True
assert calls and "status=up" in calls[0]
assert push("https://k/api/push/x", _health(True), getter=bad_get) is False
# -- Endpunkte ----------------------------------------------------------------------------------
def _add_mount(
state: Any, name: str, slug: str, mstate: MountState, error: str | None = None
) -> int:
with session_scope(state.session_factory) as s:
m = Mount(
name=name,
slug=slug,
type=MountType.CIFS,
server="nas",
share=slug,
mountpoint=f"/mnt/netmover/{slug}",
state=mstate,
last_error=error,
)
s.add(m)
s.flush()
return m.id
def test_healthz_details_and_single_endpoints(client: TestClient, state: Any) -> None:
ok_id = _add_mount(state, "NAS", "nas", MountState.MOUNTED)
_add_mount(state, "Archiv", "archiv", MountState.ERROR, "Host is down")
with session_scope(state.session_factory) as s:
r = Rule(name="Scans", src_mount_id=ok_id, dst_path="/tmp", consecutive_failures=3)
s.add(r)
s.flush()
rid = r.id
r = client.get("/healthz")
assert r.status_code == 503
body = r.json()
assert body["status"] == "error" and body["version"]
assert body["scheduler"]["running"] is True
kinds = {(c["kind"], c["name"], c["ok"]) for c in body["checks"]}
assert ("mount", "NAS", True) in kinds and ("mount", "Archiv", False) in kinds
assert ("rule", "Scans", False) in kinds # drei Fehlschläge in Folge
assert client.get("/healthz/mount/nas").status_code == 200
r = client.get("/healthz/mount/archiv")
assert r.status_code == 503 and "Host is down" in r.json()["message"]
r = client.get(f"/healthz/rule/{rid}")
assert r.status_code == 503 and "3 fehlgeschlagene Läufe" in r.json()["message"]
assert client.get("/healthz/mount/gibtsnicht").status_code == 404
assert client.get("/healthz/rule/4711").status_code == 404
def test_healthz_needs_no_login(raw_client: TestClient) -> None:
assert raw_client.get("/healthz").status_code == 200
assert raw_client.get("/healthz/mount/x").status_code == 404
def test_disabled_rule_and_degraded_mount_are_not_alarms(client: TestClient, state: Any) -> None:
mid = _add_mount(state, "NAS", "nas", MountState.DEGRADED)
with session_scope(state.session_factory) as s:
s.add(
Rule(
name="aus",
src_mount_id=mid,
dst_path="/tmp",
enabled=False,
created_at=utcnow() - timedelta(days=3),
)
)
assert client.get("/healthz").status_code == 200
def test_kuma_push_job_uses_saved_url(state: Any, monkeypatch: pytest.MonkeyPatch) -> None:
sent: list[str] = []
monkeypatch.setattr(
"netmover.kuma.httpx.get",
lambda url, **kw: (
sent.append(url),
httpx.Response(200, request=httpx.Request("GET", url)),
)[1],
)
state.push_health() # ohne URL: nichts
assert sent == []
save_settings(
state.session_factory, AppSettings(kuma_push_url="https://kuma.lan/api/push/T0k3n")
)
state.push_health()
assert len(sent) == 1 and sent[0].startswith("https://kuma.lan/api/push/T0k3n?status=")
def test_settings_kuma_url_masked_and_monitor_urls_listed(client: TestClient, state: Any) -> None:
_add_mount(state, "NAS", "nas", MountState.MOUNTED)
post(
client,
"/settings",
{
"retention_days": "30",
"notify_mode": "off",
"notify_after": "3",
"webhook_url": "",
"webhook_content_type": "application/json",
"webhook_template": "$message",
"kuma_push_url": "https://kuma.lan/api/push/GeHeIm?status=up",
},
)
assert load_settings(state.session_factory).kuma_push_url.endswith("GeHeIm?status=up")
page = client.get("/settings").text
assert "GeHeIm" not in page and "kuma.lan" in page
assert "/healthz/mount/nas" in page and "/healthz" in page
# -- Hilfe und Menü ---------------------------------------------------------------------------
def test_help_page_renders_guide_with_anchors(client: TestClient) -> None:
html = client.get("/help").text
for anchor in (
"begriffe",
"anbindungen",
"schluessel",
"regeln",
"regel-sicherheit",
"probelauf",
"status",
"protokoll",
"uptime-kuma",
"fragen",
):
assert f'id="{anchor}"' in html, anchor
assert "<table>" in html and "{#" not in html
assert 'src="/help/img/' in html
def test_help_images_and_path_safety(client: TestClient) -> None:
from netmover.web.app import _help_dir
if (_help_dir() / "img" / "status.png").is_file():
r = client.get("/help/img/status.png")
assert r.status_code == 200 and r.headers["content-type"] == "image/png"
for bad in ("../ANLEITUNG.md", "..%2F..%2Fpyproject.toml", "x.svg"):
assert client.get(f"/help/img/{bad}").status_code == 404
def test_navigation_groups_health_and_help_links(client: TestClient, state: Any) -> None:
_add_mount(state, "Archiv", "archiv", MountState.ERROR, "weg")
html = client.get("/mounts").text
for group in ("Übersicht", "Verbindungen", "Automatisierung", "System"):
assert group in html
assert 'class="health bad"' in html and "1 Problem" in html
assert "1 gestört" in html
assert 'href="/help#anbindungen"' in html # Hilfe zu dieser Seite
assert 'aria-current="page"' in html and 'class="menu-toggle"' in html
form = client.get("/rules/new").text
assert 'href="/help#regel-sicherheit"' in form
def test_logged_out_pages_have_no_menu(raw_client: TestClient) -> None:
html = raw_client.get("/setup").text
assert 'id="sidebar"' not in html
+2
View File
@@ -147,6 +147,8 @@ def test_manager_pins_host_key_and_checks_alive(
with session_factory() as s: with session_factory() as s:
mm = s.get(Mount, mid) mm = s.get(Mount, mid)
assert mm is not None and mm.host_key == server.host_key_string assert mm is not None and mm.host_key == server.host_key_string
# Praxisbefund: nach erfolgreichem Test „erreichbar“ statt „nicht geprüft“
assert mm.state is MountState.MOUNTED
assert manager.check_alive(mid) is None assert manager.check_alive(mid) is None
with session_factory() as s: with session_factory() as s:
+184
View File
@@ -0,0 +1,184 @@
"""0.4.0: automatisch pausieren/fortsetzen, Wartungsmodus, eine Meldung pro Anbindung."""
from __future__ import annotations
from datetime import timedelta
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.db import session_scope
from netmover.health import check_health
from netmover.models import Rule, Run, RunStatus, RunTrigger, utcnow
from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.manager import MountManager
from netmover.mover.engine import RunResult
from netmover.mover.runner import RuleRunner
from netmover.runs import RunService
from netmover.settings_store import AppSettings, save_settings
from tests.conftest import MountFactory
class FakeRunner:
def __init__(self) -> None:
self.results: list[RunResult] = []
self.calls = 0
def run(self, rule_id: int, *, dry_run: bool | None = None) -> RunResult:
self.calls += 1
return self.results.pop(0)
@pytest.fixture
def rule_id(make_mount: MountFactory, session_factory: sessionmaker[Session]) -> int:
mid = make_mount()
with session_scope(session_factory) as s:
r = Rule(name="Scans", src_mount_id=mid, dst_path="/x", interval_seconds=30)
s.add(r)
s.flush()
return r.id
def _rule(sf: sessionmaker[Session], rid: int) -> Rule:
with sf() as s:
r = s.get(Rule, rid)
assert r is not None
s.expunge(r)
return r
def _runs(sf: sessionmaker[Session]) -> list[Run]:
with sf() as s:
return list(s.scalars(select(Run).order_by(Run.id)))
def unavailable() -> RunResult:
return RunResult(
RunStatus.ERROR,
message="„NAS“ ist nicht eingehängt – Lauf abgebrochen, nichts angefasst.",
unavailable=True,
)
def test_waiting_logs_once_and_resumes_by_itself(
session_factory: sessionmaker[Session], rule_id: int
) -> None:
runner = FakeRunner()
service = RunService(session_factory, runner) # type: ignore[arg-type]
runner.results += [unavailable(), unavailable(), unavailable(), RunResult(RunStatus.OK)]
first = service.execute(rule_id)
assert first is not None
r = _rule(session_factory, rule_id)
assert r.waiting_since is not None and "nicht eingehängt" in (r.waiting_reason or "")
assert r.consecutive_failures == 0 # Warten ist kein Fehlschlag
assert service.execute(rule_id) is None # kein neuer Eintrag alle 30 s
assert service.execute(rule_id) is None
assert len(_runs(session_factory)) == 1
assert _runs(session_factory)[0].message.startswith("Pausiert, bis die Anbindung zurück ist")
service.execute(rule_id) # Anbindung wieder da
r = _rule(session_factory, rule_id)
assert r.waiting_since is None and r.waiting_reason is None and r.last_success_at is not None
runs = _runs(session_factory)
assert len(runs) == 2 and runs[1].message and runs[1].message.startswith("Fortgesetzt nach")
assert r.consecutive_failures == 0
def test_real_errors_still_count(session_factory: sessionmaker[Session], rule_id: int) -> None:
runner = FakeRunner()
service = RunService(session_factory, runner) # type: ignore[arg-type]
runner.results += [RunResult(RunStatus.ERROR, message="kaputt")] * 2
service.execute(rule_id)
service.execute(rule_id)
r = _rule(session_factory, rule_id)
assert r.consecutive_failures == 2 and r.waiting_since is None
assert len(_runs(session_factory)) == 2
def test_manual_dry_run_does_not_start_waiting(
session_factory: sessionmaker[Session], rule_id: int
) -> None:
runner = FakeRunner()
service = RunService(session_factory, runner) # type: ignore[arg-type]
runner.results.append(unavailable())
service.execute(rule_id, RunTrigger.MANUAL, dry_run=True)
assert _rule(session_factory, rule_id).waiting_since is None
assert len(_runs(session_factory)) == 1 # Probelauf sieht man trotzdem
def test_maintenance_skips_scheduled_runs_but_not_manual(
session_factory: sessionmaker[Session], rule_id: int
) -> None:
runner = FakeRunner()
service = RunService(session_factory, runner) # type: ignore[arg-type]
save_settings(session_factory, AppSettings(maintenance=True))
assert service.execute(rule_id) is None
assert runner.calls == 0 and _runs(session_factory) == []
runner.results.append(RunResult(RunStatus.OK))
assert service.execute(rule_id, RunTrigger.MANUAL) is not None
assert runner.calls == 1
def _health(sf: sessionmaker[Session], **kw: object): # type: ignore[no-untyped-def]
now = utcnow()
return check_health(
sf,
scheduler_running=True,
last_heartbeat=now,
service_started=now - timedelta(hours=1),
now=now,
**kw,
) # type: ignore[arg-type]
def test_health_waiting_and_maintenance(
session_factory: sessionmaker[Session], rule_id: int
) -> None:
with session_scope(session_factory) as s:
r = s.get(Rule, rule_id)
assert r is not None
r.created_at = utcnow() - timedelta(days=1)
r.last_success_at = utcnow() - timedelta(hours=2)
r.waiting_since = utcnow() - timedelta(minutes=12)
r.waiting_reason = "„NAS“ nicht verfügbar"
h = _health(session_factory)
assert not h.ok and "wartet seit" in h.problems[0] and "NAS" in h.problems[0]
h = _health(session_factory, maintenance=True)
assert h.ok and h.maintenance and h.stale_rules == set()
check = h.find("rule", id=rule_id)
assert check is not None and "Wartungsmodus" in check.message
# -- Integration mit MountManager und Runner ------------------------------------------------
def test_vanished_mount_notifies_once_with_affected_rules(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
notifications: list[tuple[str, str, str]],
tmp_path: Path,
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount("nas", share="scans")
manager.mount(mid)
with session_scope(session_factory) as s:
s.add(Rule(name="Scans → Paperless", src_mount_id=mid, dst_path=str(tmp_path)))
s.add(Rule(name="Archiv", src_mount_id=mid, dst_path=str(tmp_path), enabled=False))
fake_backend.vanish(manager.mountpoint_for("nas"))
runner = RuleRunner(session_factory, manager, sleep=lambda _: None)
with session_factory() as s:
rid = s.scalars(select(Rule.id).where(Rule.name == "Scans → Paperless")).one()
for _ in range(3):
result = runner.run(rid)
assert result.unavailable and result.status is RunStatus.ERROR
errors = [n for n in notifications if n[0] == "mount.error"]
assert len(errors) == 1
assert "Betroffene Regeln" in errors[0][2] and "Scans → Paperless" in errors[0][2]
assert "Archiv" not in errors[0][2] # deaktivierte Regel nicht erwähnt
+88 -62
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import json import json
import re import re
from collections.abc import Iterator
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -17,66 +16,7 @@ from netmover.db import session_scope
from netmover.models import Credential, Mount, MountState, Rule, Run, RunStatus from netmover.models import Credential, Mount, MountState, Rule, Run, RunStatus
from netmover.mounting.fake import FakeMountBackend from netmover.mounting.fake import FakeMountBackend
from netmover.web.app import create_app from netmover.web.app import create_app
from tests.webutil import PASSWORD, csrf_of, do_setup, post
PASSWORD = "sehr-geheim-123"
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
return Settings(
data_dir=tmp_path / "lib",
run_dir=tmp_path / "run",
mount_base=tmp_path / "mnt",
mount_backend="fake",
)
@pytest.fixture
def backend(tmp_path: Path) -> Iterator[FakeMountBackend]:
b = FakeMountBackend(tmp_path / "fake")
yield b
b.release_hangs()
@pytest.fixture
def state(settings: Settings, backend: FakeMountBackend) -> NetMover:
return NetMover(settings, backend=backend)
@pytest.fixture
def raw_client(settings: Settings, state: NetMover) -> Iterator[TestClient]:
with TestClient(create_app(settings, state), follow_redirects=False) as c:
yield c
def csrf_of(html: str) -> str:
m = re.search(r'name="csrf-token" content="([^"]+)"', html)
assert m, "kein CSRF-Token in der Seite"
return m.group(1)
def do_setup(c: TestClient) -> str:
page = c.get("/setup")
token = csrf_of(page.text)
code = c.app.state.setup_code.code # type: ignore[attr-defined]
r = c.post(
"/setup",
data={"csrf_token": token, "code": code, "password": PASSWORD, "confirm": PASSWORD},
)
assert r.status_code == 303, r.text
return csrf_of(c.get("/").text)
@pytest.fixture
def client(raw_client: TestClient) -> TestClient:
raw_client.headers["X-Test-CSRF"] = do_setup(raw_client)
return raw_client
def post(c: TestClient, url: str, data: dict[str, str] | None = None, **kw: object): # type: ignore[no-untyped-def]
payload = {"csrf_token": c.headers["X-Test-CSRF"], **(data or {})}
return c.post(url, data=payload, **kw) # type: ignore[arg-type]
# -- Einrichtung und Anmeldung -------------------------------------------------------------- # -- Einrichtung und Anmeldung --------------------------------------------------------------
@@ -154,7 +94,16 @@ def test_security_headers(client: TestClient) -> None:
def test_no_inline_scripts_or_styles(client: TestClient) -> None: def test_no_inline_scripts_or_styles(client: TestClient) -> None:
for url in ("/", "/mounts", "/mounts/new", "/rules", "/rules/new", "/log", "/settings"): for url in (
"/",
"/mounts",
"/mounts/new",
"/keys",
"/rules",
"/rules/new",
"/log",
"/settings",
):
html = client.get(url).text html = client.get(url).text
assert "style=" not in html, url assert "style=" not in html, url
assert not re.search(r"<script(?![^>]*\bsrc=)", html), url assert not re.search(r"<script(?![^>]*\bsrc=)", html), url
@@ -555,3 +504,80 @@ def test_export_excludes_secrets_and_import_roundtrip(
files={"file": ("export.json", text, "application/json")}, files={"file": ("export.json", text, "application/json")},
) )
assert "Übersprungen" in c2.get("/settings").text assert "Übersprungen" in c2.get("/settings").text
# -- Schlüsselverwaltung -------------------------------------------------------------------
def test_keys_page_generate_import_select_delete(client: TestClient, state: NetMover) -> None:
from netmover.crypto import generate_ssh_keypair
r = post(client, "/keys/generate", {"name": "paperless-lxc"})
assert r.status_code == 303
private, public = generate_ssh_keypair()
r = post(client, "/keys/import", {"name": "alt", "private_key": private, "passphrase": ""})
assert r.status_code == 303
page = client.get("/keys").text
assert "paperless-lxc" in page and "alt" in page
assert public.split()[1] in page # öffentlicher Teil sichtbar
for line in private.splitlines()[1:-1]: # keine Zeile des privaten Schlüssels
assert line not in page
assert "restrict,command=&#34;/usr/lib/openssh/sftp-server&#34;" in page
with state.session_factory() as s:
key_id = s.scalar(select(Credential.id).where(Credential.name == "alt"))
form = client.get("/mounts/new").text
assert f'value="{key_id}"' in form and "neuer Schlüssel nur für diese Anbindung" in form
r = post(
client,
"/mounts/new",
{
"name": "paperless",
"type": "sftp",
"server": "10.0.0.5",
"share": "/consume",
"port": "22",
"auth": "ssh_key",
"key_id": str(key_id),
"username": "root",
"enabled": "on",
},
)
assert r.status_code == 303, r.text
mounts = client.get("/mounts").text
assert "gespeichertem Schlüssel" in mounts and "„alt“" in mounts
edit = client.get(f"/mounts/{_mount_id(state, 'paperless')}/edit").text
assert f'<option value="{key_id}" selected>' in edit and 'value="root"' in edit
post(client, f"/keys/{key_id}/delete")
assert "noch benutzt" in client.get("/keys").text
post(client, f"/mounts/{_mount_id(state, 'paperless')}/delete")
post(client, f"/keys/{key_id}/delete")
page = client.get("/keys").text
assert "gelöscht" in page and ">alt<" not in page
def test_key_import_error_is_shown(client: TestClient) -> None:
post(client, "/keys/import", {"name": "x", "private_key": "kein schlüssel", "passphrase": ""})
assert "Kein lesbarer privater Schlüssel" in client.get("/keys").text
def test_no_escaped_attributes_in_pages(client: TestClient, state: NetMover) -> None:
"""Attribute in {{ … }} werden von Jinja maskiert – dann landet &#34; im HTML."""
post(client, "/keys/generate", {"name": "k"})
for url in (
"/",
"/mounts",
"/mounts/new",
"/keys",
"/rules",
"/rules/new",
"/log",
"/settings",
"/help",
):
html = client.get(url).text
broken = re.findall(r"<[a-z][^<>]*=&#34;[^<>]*>", html) # nur in Tags, nicht im Text
assert not broken, (url, broken[:2])
+33
View File
@@ -0,0 +1,33 @@
"""Hilfsfunktionen für Tests der Weboberfläche."""
from __future__ import annotations
import re
from typing import Any
from fastapi.testclient import TestClient
PASSWORD = "sehr-geheim-123"
def csrf_of(html: str) -> str:
m = re.search(r'name="csrf-token" content="([^"]+)"', html)
assert m, "kein CSRF-Token in der Seite"
return m.group(1)
def do_setup(c: TestClient) -> str:
page = c.get("/setup")
token = csrf_of(page.text)
code = c.app.state.setup_code.code # type: ignore[attr-defined]
r = c.post(
"/setup",
data={"csrf_token": token, "code": code, "password": PASSWORD, "confirm": PASSWORD},
)
assert r.status_code == 303, r.text
return csrf_of(c.get("/").text)
def post(c: TestClient, url: str, data: dict[str, str] | None = None, **kw: Any) -> Any:
payload = {"csrf_token": c.headers["X-Test-CSRF"], **(data or {})}
return c.post(url, data=payload, **kw)