40 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5.5 660bb70c5d feat(web): Archiv im Regelformular, Export/Import; Doku 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:22:39 +02:00
Florian WildeandClaude Opus 5.5 e59932ab75 feat: Archiv je Regel – erst Archiv, dann Ziel, dann Quelle löschen
Zusätzliche Kopie ins Archiv (Anbindung oder lokaler Pfad, Ablage wie Quelle
oder nach Datum). Buchführung verhindert doppelte Archivkopien bei Neuversuch;
im Archiv wird nie überschrieben. Migration 0004 mit Tests auf befüllten
Datenbanken (inkl. Downgrade).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:22:39 +02:00
Florian WildeandClaude Opus 5.5 cf7a3bcc88 feat: Anmeldung je Netz – ohne Passwort, mit Passwort oder abgewiesen
Zwei Netzlisten statt Modusauswahl; fremde Netze werden abgewiesen, wenn
„mit Passwort“ ausgefüllt ist; /healthz bleibt erreichbar; Schutz vor
Aussperren; reset-password hebt Sperren auf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:46:43 +02:00
Florian WildeandClaude Opus 5.5 579a2c164f feat: Anmeldung abschaltbar (vertraute Netze oder ganz); Doku 0.5.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:12:28 +02:00
Florian WildeandClaude Opus 5.5 f5ea5840d4 feat: gespeicherte Zugangsdaten für SMB und SFTP-Passwörter
Seite „Zugangsdaten“ (vorher „Schlüssel“) mit Passwörtern und SSH-Schlüsseln;
Anbindungen wählen gespeicherte Passwörter aus, ein neues Passwort gilt
sofort für alle. /keys leitet auf /credentials um.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:07:09 +02:00
Florian WildeandClaude Opus 5.5 893fe0c95d feat(web): SFTP-Anbindungen als Quelle wählbar; Doku
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:00:30 +02:00
Florian WildeandClaude Opus 5.5 5786b1e206 feat: SFTP als Quelle mit denselben Garantien wie SMB/NFS
Quell-Abstraktion (LocalSource, SftpSource): Scan, frische Zustände,
Lesen, Löschen und Quarantäne über eine Schnittstelle. Verbindungsabbrüche
brechen den Lauf als gestörte Anbindung ab. Bei SFTP-Quellen mindestens
2 s Stabilitätsfenster (Zeiten nur sekundengenau).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:00:30 +02:00
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
Florian WildeandClaude Opus 5.5 2d2238ce4e chore(release): 0.1.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:03:27 +02:00
Florian WildeandClaude Opus 5.5 4f9c5bdc1d fix(mounts): bei Zeitüberschreitung ganze Prozessgruppe von mount beenden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:56:18 +02:00
Florian WildeandClaude Opus 5.5 1bf0fae2fc fix(mounts): gescheiterte Start-Wiederherstellung ebenfalls minütlich neu versuchen
Bootet die VM schneller als das NAS (Stromausfall), käme die Freigabe sonst
nie zurück. Ein Scheduler-Job prüft fällige Neuversuche jede Minute, auch
für Anbindungen ohne Regel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:51:58 +02:00
Florian WildeandClaude Opus 5.5 ea50eacc9d docs: Unit-Härtung, Neuversuch und install.sh ohne debconf-Warnungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:50:06 +02:00
Florian WildeandClaude Opus 5.5 c836f58189 fix(mounts): nach gescheiterter Wiederherstellung minütlich neu versuchen
Ein NAS-Neustart dauert länger als die drei Wiederherstellungsversuche.
Von NetMover selbst ausgehängte Mounts mit auto_mount werden höchstens
einmal pro Minute erneut eingehängt, mit Entwarnung per Webhook. Von außen
ausgehängte bleiben unangetastet. Hinweistexte werden nicht mehr
mitgeschwärzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:50:06 +02:00
Florian WildeandClaude Opus 5.5 8276ce2afb fix(mover): Stabilität mit frischen Attributen (open + fstat)
CIFS lieferte per stat() sekundenlang eine veraltete Größe einer noch
wachsenden Datei; erst open() erzwingt frische Attribute. Gesperrte
Dateien gelten als in Benutzung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:50:06 +02:00
Florian WildeandClaude Opus 5.5 48a52c5290 fix(deploy): systemd-Unit ohne eigenen Mount-Namespace
ProtectSystem/PrivateTmp/ProtectControlGroups erzeugen einen Namespace als
slave; Mounts blieben dort gefangen (auf der Test-VM nachgewiesen) und
verschwanden bei jedem Neustart. MountFlags=shared hilft nicht. Nur noch
Härtungen ohne Namespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:50:06 +02:00
Florian WildeandClaude Opus 5.5 e1a917b974 chore(release): 0.1.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:49:44 +02:00
Florian WildeandClaude Opus 5.5 c8ee950514 docs: README mit Installation, Synology/Paperless-Beispiel, Fehlersuche und Sicherheit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:49:03 +02:00
Florian WildeandClaude Opus 5.5 dd9177859e feat: systemd-Unit, Installationsskript, Dockerfile und compose.yml
Unit mit StateDirectory/RuntimeDirectory, ProtectSystem=full und
MountFlags=shared; compose mit SYS_ADMIN, DAC_READ_SEARCH,
apparmor:unconfined und rshared-Bind für /mnt/netmover (Propagation zum
Host im Container getestet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:49:03 +02:00
Florian WildeandClaude Opus 5.5 00a0537085 feat: Endname per renameat2(RENAME_NOREPLACE) – echtes rename ohne Überschreiben
Watcher, die nur auf IN_MOVED_TO/IN_CLOSE_WRITE lauschen, sehen damit neue
Dateien wie bei os.replace. Dateisysteme ohne renameat2 (NFS, CIFS) nutzen
weiter link() + unlink() bzw. prüfen + rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:49:03 +02:00
Florian WildeandClaude Opus 5.5 2770c2e204 feat: netmover serve und reset-password, ruhigere Scheduler-Logs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:33:00 +02:00
Florian WildeandClaude Opus 5.5 2b0b039aeb feat: Weboberfläche mit Anmeldung, CSRF und vier Seiten
FastAPI + Jinja2 + lokal eingebundenes htmx, strikte CSP, Ersteinrichtung
mit Einrichtungscode aus dem Log, Argon2, Sperre nach Fehlversuchen,
Status-, Anbindungs-, Regel-, Protokoll- und Einstellungsseite,
/healthz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:33:00 +02:00
Florian WildeandClaude Opus 5.5 e2587551b0 feat: Verwaltung von Anbindungen/Regeln und Export/Import
Formularprüfung und Speichern unabhängig vom Web-Framework; Passwörter
leer = unverändert, SFTP-Schlüsselpaar wird erzeugt. Export/Import als
JSON ohne Zugangsdaten und Webhook-URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:33:00 +02:00
Florian WildeandClaude Opus 5.5 f008c216be feat: Scheduler, Laufprotokoll, Aufräumen und Gesundheitsprüfung
APScheduler-Jobs je Regel mit Startversatz und Jitter, Protokoll in
runs/run_items mit Regelzählern, Aufräumen nach Aufbewahrungsfrist,
Health-Auswertung für /healthz und Verdrahtung des Dienststarts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:15:48 +02:00
Florian WildeandClaude Opus 5.5 f45cb56aa0 feat: Laufzeit-Einstellungen und Webhook-Benachrichtigung
Einstellungen in der Tabelle settings; generischer Webhook mit Vorlage
(JSON-sicher), Modus bei jedem Fehler oder nach N Fehlern in Folge,
optionale Entwarnung, Versand im Hintergrund.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:15:48 +02:00
90 changed files with 11720 additions and 133 deletions
+8
View File
@@ -0,0 +1,8 @@
.venv
.git
data
**/__pycache__
.pytest_cache
.mypy_cache
.ruff_cache
tests
+211
View File
@@ -8,6 +8,170 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
## [Unreleased]
## [0.7.0] – 2026-10-12
### Hinzugefügt
- **Archiv je Regel:** zusätzliche Kopie in ein Archiv (Anbindung SMB/NFS/SFTP oder
lokaler Pfad), Ablage „wie in der Quelle“ oder „nach Datum“ (`Jahr/Monat/…`).
Reihenfolge: erst Archiv, dann Ziel, **erst dann wird die Quelle gelöscht**.
Scheitert das Ziel nach dem Archiv, verhindert eine Buchführung
(`archived_files`) eine zweite Archivkopie. Im Archiv wird nie überschrieben.
Nicht erreichbares Archiv → Regel wartet. Platzprüfung auch fürs Archiv,
Probelauf zeigt das Archivziel. Migration 0004.
- Export/Import enthalten die Archiv-Einstellungen; eine als Archiv benutzte
Anbindung lässt sich nicht löschen.
## [0.6.0] – 2026-10-11
### Geändert
- **Anmeldung je Netz konfigurierbar** (*Einstellungen → Anmeldung*): „Ohne Passwort
aus diesen Netzen“ und „Mit Passwort aus diesen Netzen“ (leer = alle übrigen).
Ist die zweite Liste ausgefüllt, werden alle anderen Netze abgewiesen (403, auch die
Anmeldeseite); `/healthz` und statische Dateien bleiben erreichbar. Ersetzt die
Auswahl „immer / vertraute Netze / nie“ aus 0.5.0 (`0.0.0.0/0, ::/0` = ganz aus;
gespeicherter Modus „nie“ gilt weiter).
- Schutz vor Aussperren: Eine Einstellung, mit der die eigene Adresse keinen Zugang
mehr hätte, wird nicht gespeichert. `netmover reset-password` hebt auch Netz-Sperren auf.
## [0.5.0] – 2026-10-11
### Hinzugefügt
- **SFTP als Quelle** mit denselben Garantien wie bei SMB/NFS: Stabilitätsprüfung,
atomares Schreiben, Löschen der Quelle erst nach vollständiger Übertragung,
Kopier-Buchführung, Quarantäne. Verbindungsabbrüche brechen den Lauf als gestörte
Anbindung ab. Bei SFTP-Quellen mindestens 2 s Stabilitätsfenster (Zeiten nur
sekundengenau).
- **Gespeicherte Zugangsdaten** für SMB und SFTP-Passwörter (Benutzer, Passwort,
Domäne); in Anbindungen auswählbar, ein neues Passwort gilt sofort für alle.
Die Seite „Schlüssel“ heißt jetzt **„Zugangsdaten“** (Passwörter + SSH-Schlüssel);
`/keys` leitet weiter.
- **Anmeldung abschaltbar** (*Einstellungen → Anmeldung*, Bestätigung mit Passwort):
immer / nicht aus vertrauenswürdigen Netzen / nie (z. B. hinter Reverse Proxy mit
eigener Anmeldung). Deutlicher Hinweis in der Kopfzeile; CSRF-Schutz bleibt;
`netmover reset-password` schaltet die Anmeldung wieder ein.
### Geändert
- Mover intern auf eine Quell-Schnittstelle umgestellt (`LocalSource`, `SftpSource`);
bestehendes Verhalten unverändert (alle bisherigen Tests gelten weiter).
## [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
Korrekturen aus dem ersten Praxistest auf einer Debian-13-VM mit echten
SMB-, NFS- und SFTP-Servern.
### Behoben
- **systemd-Unit:** `ProtectSystem`, `PrivateTmp` und `ProtectControlGroups`
gaben dem Dienst einen eigenen Mount-Namespace. Die Mounts waren für das System
und andere Programme unsichtbar und verschwanden bei jedem Neustart des Dienstes
(`MountFlags=shared` half nicht). Jetzt nur noch Härtungen ohne Namespace.
- **Stabilitätsprüfung:** CIFS lieferte per `stat()` sekundenlang eine veraltete
Größe einer noch wachsenden Datei. Gemessen wird jetzt per `open()` + `fstat()`,
das erzwingt frische Attribute. Gesperrte Dateien gelten als „in Benutzung“.
(Die zweite Sicherung, die Byte-Zählung beim Kopieren, hatte den Fall bereits
abgefangen; es kam keine unvollständige Datei an.)
- **Ausfall länger als die Wiederherstellung (z. B. NAS-Neustart, Stromausfall):**
Von NetMover selbst ausgehängte Anbindungen mit „automatisch einhängen“ werden
jetzt jede Minute erneut eingehängt, auch wenn das Einhängen schon beim
Dienststart scheiterte. Entwarnung per Webhook. Von außen ausgehängte Anbindungen
bleiben weiterhin unangetastet.
- Bei einer Zeitüberschreitung von `mount` wird die ganze Prozessgruppe beendet, damit
`mount.nfs` nicht später doch noch einhängt.
- Hinweistexte zu `mount`-Fehlern werden nicht mehr mitgeschwärzt.
- `install.sh` ohne `debconf`-Warnungen, wenn kein Terminal angeschlossen ist.
### Dokumentation
- Fehlersuche: verzögert sichtbare Dateien auf CIFS → `nohandlecache`.
- README erklärt, warum die Unit keine Namespace-Härtung nutzt.
## [0.1.0] – 2026-10-10
Erste Version.
### Hinzugefügt
- Projektgerüst (Python ≥ 3.12, FastAPI-Stack, src-Layout, ruff, mypy strict, pytest).
- Datenmodell mit SQLAlchemy 2.x: `credentials`, `mounts`, `rules`, `runs`,
@@ -54,7 +218,54 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
Inaktivitäts-Zeitlimit; ein hängender Worker blockiert nur seine eigene Regel.
- Lokale Ziele unter dem Mount-Basisverzeichnis werden abgelehnt (dort muss die
Anbindung als Ziel gewählt werden, damit „eingehängt?“ geprüft wird).
- Scheduler (APScheduler): ein Job pro aktiver Regel mit zufälligem
Startversatz und Jitter; Läufe in eigenem Thread-Pool; Abgleich nach Änderungen.
- Protokoll: pro Lauf eine Zeile (auch übersprungene Termine), Einzeldateien
in `run_items` (max. 2000 pro Lauf); Regelzähler letzter Lauf, letzter Erfolg,
Fehler in Folge.
- Aufräumen der Protokolle nach konfigurierbarer Frist (Standard 30 Tage).
- Gesundheitsprüfung: Scheduler-Herzschlag, Anbindungen im Fehlerzustand,
Regeln ohne Erfolg seit mehr als dem Dreifachen ihres Intervalls.
- Webhook-Benachrichtigung mit frei wählbarer Vorlage (JSON-sicher), Modus
„bei jedem Fehler“ oder „nach N Fehlern in Folge“, optional Entwarnung;
auch für Anbindungen, deren Wiederherstellung scheitert.
- Dienststart: Migration → Schlüssel → Anbindungen wiederherstellen → Scheduler;
beim Beenden werden Mounts nicht ausgehängt.
- Endname lokal per `renameat2(RENAME_NOREPLACE)`: echtes `rename` (Watcher sehen
`IN_MOVED_TO`), trotzdem nie überschreibend; Rückfall auf `link` + `unlink`.
- Deployment: systemd-Unit (`ProtectSystem=full`, `MountFlags=shared`),
Installationsskript für Debian 13, `Dockerfile` und `compose.yml` mit
`rshared`-Propagation.
- README mit Installation, Synology→Paperless-Beispiel (SMB-Quelle, SFTP-Ziel),
Docker, Reverse Proxy, Konfiguration, Fehlersuche und Sicherheitshinweisen.
- Weboberfläche (FastAPI, Jinja2, HTMX ohne Build-Schritt, handyfähig, Hell/Dunkel):
Status, Anbindungen, Regeln, Protokoll, Einstellungen.
- Ersteinrichtung mit Pflicht-Passwort (≥ 10 Zeichen) und einmaligem
Einrichtungscode aus dem Log; Anmeldung mit Argon2, Sperre nach 5 Fehlversuchen.
- Session-Cookie `HttpOnly`, `SameSite=Lax` (optional `Secure`), CSRF-Token bei
jedem POST, strikte Content-Security-Policy ohne Inline-Skripte/-Styles.
- Mount-Formular mit „Testen“ vor dem Speichern; Passwörter werden nie
angezeigt (leer = unverändert); SFTP-Schlüsselpaar wird erzeugt und der
öffentliche Schlüssel zum Kopieren angezeigt.
- Probelauf zeigt in einer Tabelle, welche Datei wohin ginge.
- Protokoll filterbar nach Regel und Status, Einzeldateien aufklappbar.
- Export/Import der Konfiguration als JSON (ohne Zugangsdaten und Webhook-URL).
- `GET /healthz`, `netmover serve`, `netmover reset-password`.
### Behoben
- Import-Upload wurde nie erkannt (falsche `UploadFile`-Klasse geprüft).
- Hinweise nach Aktionen gingen verloren, wenn schon ein Hinweis in der
Session stand (Session erkennt nur Neuzuweisungen als Änderung).
- Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den
Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt.
[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.7.0...HEAD
[0.7.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.6.0...v0.7.0
[0.6.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.5.0...v0.6.0
[0.5.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.4.0...v0.5.0
[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.0]: https://git.wilde-server.de/florian.wilde/netmover/releases/tag/v0.1.0
+291 -1
View File
@@ -10,10 +10,300 @@ Zeiten in Ortszeit (Europe/Berlin).
| 2026-10-10 | 17:21 | 17:27 | 0:06 | 1 | Gerüst, Datenmodell, Migrationen |
| 2026-10-10 | 18:35 | 18:48 | 0:13 | 2 | Mount-Schicht, Validierung, SFTP |
| 2026-10-10 | 18:53 | 19:07 | 0:14 | 3 | Mover-Logik |
| **Summe** | | | **0:33** | | |
| 2026-10-10 | 19:10 | 19:17 | 0:07 | 4 | Scheduler, Protokoll, Benachrichtigung |
| 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 | 21:36 | 22:05 | 0:29 | – | Praxistest auf VM, Release 0.1.1 |
| 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) |
| 2026-10-11 | 00:54 | 01:16 | 0:22 | – | SFTP-Quelle, Zugangsdaten, Anmeldung (0.5.0) |
| 2026-10-11 | 08:43 | 08:55 | 0:12 | – | Anmeldung je Netz (0.6.0) |
| 2026-10-12 | 00:14 | 00:34 | 0:20 | – | Archiv je Regel (0.7.0) |
| **Summe** | | | **3:49** | | |
---
## 2026-10-12 · 00:14–00:34 · Archiv je Regel (0.7.0)
**Erledigt**
- Archivfelder an der Regel, Tabelle `archived_files` (Migration 0004).
- Engine: Archiv zuerst, dann Ziel, dann Quelle löschen; Archiv immer „umbenennen“.
- Runner: Ziel- und Archivaufbau über eine gemeinsame Fabrik (`_target`).
- 14 Tests (350 gesamt); Mutationsprobe 4/4 erkannt (Buchführung, Archiv
übersprungen, Kollisionsregel, Platzprüfung).
**Entscheidungen**
- Archiv *vor* Ziel: Paperless holt Dateien sofort ab – andersherum würde ein
gescheitertes Archiv beim Neuversuch zur doppelten Lieferung führen.
- Archiv überschreibt nie (unabhängig von der Kollisionsregel des Ziels).
- Ablage „nach Datum“ nach Verarbeitungsdatum (nicht Datei-mtime) – eindeutig
nachvollziehbar, wann eine Datei durchgelaufen ist.
**Stolperstein**
- Downgrade von 0004 scheiterte an den CHECK-Regeln der neuen Auswahlfelder
(verweisen nach dem Entfernen der Spalten ins Leere) – nur dank der Migrations-
tests mit Daten aufgefallen; Downgrade entfernt sie jetzt zuerst.
## 2026-10-11 · 08:43–08:55 · Anmeldung je Netz (0.6.0)
**Anlass** – Wunsch: Anmeldung abschalten, und zwar konfigurierbar, aus welchen Netzen
mit und ohne Passwort. Zugriffe kamen aus 10.0.10.0/24 und 10.1.5.0/24 (Access-Log).
**Erledigt**
- `auth.access_decision()` als einzige Entscheidung (ohne PW / mit PW / abgewiesen),
benutzt von Middleware (403 für fremde Netze), Guard und Anmeldeseite.
- Schutz vor Aussperren beim Speichern; `reset-password` hebt Sperren auf.
- 19 Tests für die Anmeldung (336 gesamt).
**Entscheidungen**
- `/healthz` und `/static` nie sperren – Uptime Kuma kann in einem anderen Netz stehen.
- Nicht-IP-Clients (z. B. Unix-Socket) gelten als „in keinem Netz“.
## 2026-10-11 · 00:54–01:16 · SFTP-Quelle, Zugangsdaten, Anmeldung abschaltbar (0.5.0)
**Erledigt**
- `mover/sources.py`: `Source` mit `LocalSource`/`SftpSource`; Engine greift nur noch
darüber auf die Quelle zu (Scan, frischer Zustand, Lesen, Löschen, Quarantäne).
Ziele bekommen statt `Path` ein „Readable“ (Adapter) – Destinations fast unverändert.
- `credentials.py` (vorher `keys.py`): gespeicherte Passwörter; Seite „Zugangsdaten“.
- Anmeldung: Modus immer / vertraute Netze / nie, zentral im `guard`.
- 26 neue Tests (325 gesamt); alle bisherigen Mover-Tests bestanden den Umbau
unverändert (nur ein Monkeypatch-Ziel verschoben).
**Entscheidungen**
- SFTP-Quelle: Stabilität über Größe + sekundengenaue mtime, Mindestfenster 2 s;
Verbindungsabbruch → `ENOTCONN` → Lauf abgebrochen (Anbindung gestört).
- Gespeicherte Passwörter tragen den Benutzer selbst (anders als Schlüssel, deren
Benutzer zur Anbindung gehört).
- „Passworteingabe deaktivieren“ als Login-Modus umgesetzt, mit Bestätigung durch
das Passwort, Warnhinweis und Notausgang über `reset-password`. Ein Passwort bleibt
Pflicht (Ersteinrichtung), sonst ließe sich die Anmeldung nie wieder einschalten.
**Stolpersteine**
- paramiko `prefetch()` braucht `fstat`; der Test-Server konnte es nicht – jetzt
kann er es, und die Quelle übergibt die bekannte Größe (spart einen Umlauf).
- Zwei TestClients mit eigenem Lebenszyklus starteten den Scheduler doppelt →
zweiter Client ohne Lebenszyklus auf derselben App.
## 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
**Umgebung**
- KVM-VM `srv-rz-netmover` (10.0.10.223), Debian 13, Python 3.13, 2 GB RAM.
- Installation per `deploy/install.sh` aus einem `git archive` (VM ohne Gitea-Zugang).
- Vorübergehende Testserver auf der VM: Samba (nur `lo`), NFS-Exporte für
`127.0.0.1` (mit/ohne Root-Squash), SFTP-Benutzer `paperless` mit Schlüssel
`restrict,command="/usr/lib/openssh/sftp-server"`.
- Auf Wunsch `NETMOVER_HOST=0.0.0.0`, damit die Oberfläche im LAN erreichbar ist.
**Bestanden**
- Ersteinrichtung mit Code aus dem Log, Cookie-Flags.
- CIFS (Passwort mit Komma/Ausrufezeichen, Credentials-Datei danach weg), NFS 4,
NFS 3, Root-Squash-Erkennung mit Synology-Hinweis, SFTP mit TOFU (Fingerabdruck
= `ssh-keygen -lf`), echtes OpenSSH liefert `statvfs`.
- Probelauf, Kollision `scan001-1.pdf`, `.txt`/`.part` ignoriert, keine `.part`-Reste,
Eigentümer `paperless` im SFTP-Ziel.
- Langsam wachsende Datei (20 × 50 KB/s): erst nach Ende verschoben, Prüfsumme gleich.
- Kopiermodus rekursiv mit Eigentümer/Modus; zweiter Lauf „2 bereits übertragen“.
- Samba gestoppt: Lebenszeichen 15 s → gestört → 3 Versuche → Fehler, `/healthz` 503,
nichts angefasst. NFS-Server gestoppt (hängender `hard`-Mount): Oberfläche und
andere Regel liefen weiter, Rückkehr automatisch.
- Mounts überstehen Stopp/Start des Dienstes (gleiche Mount-IDs).
- Neustart der VM: NFS sofort, SMB per Neuversuch nach ~1 min, Regel läuft weiter.
**Gefunden und behoben (v0.1.1)**
1. **systemd-Unit mit eigenem Mount-Namespace** (ProtectSystem/PrivateTmp/…):
Mounts nur im Dienst sichtbar, `shared,slave`. Härtung ohne Namespace.
2. **CIFS-Attribut-Cache**: `stat()` meldete 3 s lang eine veraltete Größe einer
wachsenden Datei; die Byte-Zählung beim Kopieren fing das ab. Jetzt
`open()` + `fstat()` für beide Messungen.
3. **Nach drei gescheiterten Versuchen endgültig aufgegeben** – zu kurz für einen
NAS-Neustart, und beim Booten vor dem NAS kam die Freigabe nie zurück.
Jetzt minütlicher Neuversuch (nur für von NetMover selbst ausgehängte
Anbindungen mit `auto_mount`) plus Scheduler-Job.
4. `mount`-Zeitlimit beendete nur `mount`, nicht `mount.nfs` → später doch
eingehängt. Jetzt ganze Prozessgruppe.
5. Hinweistext wurde mitgeschwärzt; `debconf`-Warnungen in `install.sh`.
**Nicht nachstellbar**
- Einmalig 46 s Verzögerung direkt nach dem Booten (CIFS-Verzeichnis-Cache bei
lokal statt per SMB angelegter Datei). Messungen danach 9–11 s, mit und ohne
`nohandlecache`. Als Hinweis in die Fehlersuche aufgenommen.
## 2026-10-10 · 19:40–19:50 · Schritt 6: Deployment, README, Release 0.1.0
**Erledigt**
- `deploy/netmover.service`, `deploy/netmover.env`, `deploy/install.sh`,
`Dockerfile` (python:3.12-slim-trixie, mehrstufig), `compose.yml`, `.dockerignore`.
- README vollständig; `LICENSE` (MIT, wie seit Schritt 1 in `pyproject.toml`).
- Version 0.1.0, Changelog-Abschnitt, Tag `v0.1.0`.
**Geprüft**
- Wheel enthält Templates, statische Dateien und Migrationen.
- Docker-Image gebaut; Container startet, `/healthz` = 200, `mount.cifs` 7.4 und
`mount.nfs` vorhanden. **Propagation getestet:** tmpfs im Container unter
`/mnt/netmover` eingehängt → mit `rshared` auf dem Host sichtbar, ohne nicht.
- `systemd-analyze verify` der Unit ohne Beanstandung. Ein echter Start der Unit
als root (insb. `MountFlags=shared` mit `ProtectSystem`) war hier nicht möglich →
beim ersten Einsatz mit `findmnt -R /mnt/netmover` prüfen.
**Entscheidungen**
- Paperless-ngx-Consumer geprüft: nutzt `watchfiles` (`Change.added`) plus
5-Minuten-Rescan – erkennt also auch per `link()` erzeugte Dateien. Für Watcher,
die nur `IN_MOVED_TO`/`IN_CLOSE_WRITE` kennen, trotzdem auf
`renameat2(RENAME_NOREPLACE)` umgestellt (entspricht der Vorgabe `os.replace`,
ohne die Garantie „nie überschreiben“ aufzugeben).
- Härtung der Unit bewusst zurückhaltend (keine `CapabilityBoundingSet`, kein
`ProtectKernelModules`): ungetestete Einschränkungen könnten Mounten brechen
(z. B. NFS mit privilegiertem Quellport, Modul-Autoload für cifs).
- SFTP-Schlüssel im README per `restrict,command="…/sftp-server"` auf SFTP beschränkt.
**Offen für später**
- Erster Praxistest auf der Ziel-VM mit echtem Synology-NAS und Paperless-LXC.
## 2026-10-10 · 19:18–19:34 · Schritt 5: Weboberfläche und Anmeldung
**Erledigt**
- `admin.py` (Formulare prüfen/speichern, frameworkunabhängig), `transfer.py`
(Export/Import), `web/auth.py`, `web/app.py`, 14 Templates, `app.css`, `app.js`.
- htmx 2.0.4 lokal eingebunden (SHA-256
`e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447`).
- 25 Web-Tests (232 gesamt); Rauchtest mit echtem `netmover serve`.
**Entscheidungen**
- Einmaliger Einrichtungscode im Log, damit nicht irgendwer im LAN beim ersten
Aufruf das Admin-Passwort setzt.
- Strikte CSP (`script-src 'self'; style-src 'self'`) – deshalb keine
Inline-Styles; Füllstand als `<meter>`; htmx-Indikator-Styles abgeschaltet.
- Jede Anbindung pflegt eigene Zugangsdaten (Datenmodell erlaubt Teilen, die
Oberfläche bleibt dadurch einfacher).
- Verbindungsdaten nur im ausgehängten Zustand änderbar; SFTP-Hostschlüssel wird
bei Server-/Portwechsel verworfen.
- „Jetzt ausführen“ startet im Hintergrund (Ergebnis im Protokoll); „Probelauf“
wartet und zeigt die Tabelle.
- Webhook-URL wird nur gekürzt angezeigt; unverändert zurückgeschickte Maske = behalten.
- Import: vorhandene Namen überspringen, importierte Regeln deaktiviert,
CIFS-Anbindungen ohne `auto_mount` (Zugangsdaten fehlen).
- APScheduler-Logs auf WARNING (sonst alle 30 s eine Zeile pro Job).
**Stolpersteine**
- Starlette-Session erkennt `setdefault(...).append()` nicht als Änderung →
zweite Flash-Meldung ging verloren. Jetzt Neuzuweisung.
- `request.form()` liefert `starlette.datastructures.UploadFile`, nicht die
FastAPI-Unterklasse → Import erkannte nie eine Datei.
- Settings-Import öffnete eine zweite DB-Verbindung in offener Schreib-
transaktion (SQLite: „database is locked“) → Einstellungen nach dem Commit.
## 2026-10-10 · 19:10–19:17 · Schritt 4: Scheduler und Protokollierung
**Erledigt**
- `settings_store.py` (Laufzeit-Einstellungen), `notify.py` (Webhook),
`runs.py` (Lauf ausführen + protokollieren, Zähler, Aufräumen),
`health.py`, `scheduler.py`, `app_state.py` (Verdrahtung, Start/Stop).
- 24 neue Tests (207 gesamt). Mutationsprobe: 6/6 erkannt.
**Entscheidungen**
- Erfolg = `ok` oder `partial` (Werkzeug arbeitet); Fehlschlag = `error` oder
`partial` (für Benachrichtigungen). Übersprungen zählt nicht; manueller
Probelauf ändert keine Zähler.
- Bezug für „zu lange kein Erfolg“: spätester von letztem Erfolg, Anlage der
Regel und Dienststart – sonst wäre nach einem Neustart sofort alles rot.
- „Nach N Fehlern“ meldet genau einmal beim Erreichen der Schwelle, danach
Entwarnung beim ersten Erfolg (abschaltbar).
- `max_instances=3` im Scheduler, damit überlappende Termine beim eigenen
Regel-Lock ankommen und protokolliert werden, statt dass APScheduler sie still verwirft.
- Leere Läufe (alle 30 s) loggen nur auf DEBUG; in `runs` stehen sie trotzdem
(die Oberfläche filtert standardmäßig auf Läufe mit Aktivität).
- Webhook-URL wird nie geloggt (enthält oft ein Token).
**Stolpersteine**
- Zwei Testfehler lagen in den Tests selbst (Fake-Symlink bei `resolve()`,
Jitter in der Zeitschranke vergessen).
## 2026-10-10 · 18:53–19:07 · Schritt 3: Mover-Logik
**Erledigt**
+29
View File
@@ -0,0 +1,29 @@
# NetMover im Container (Alternative zum systemd-Dienst, siehe README).
# Braucht zur Laufzeit CAP_SYS_ADMIN und ein Mount-Verzeichnis mit
# Propagation „rshared“ – sonst sieht der Host die Mounts nicht.
FROM python:3.12-slim-trixie AS build
WORKDIR /src
COPY pyproject.toml README.md ./
COPY src ./src
RUN pip wheel --no-cache-dir --wheel-dir /wheels .
FROM python:3.12-slim-trixie
RUN apt-get update \
&& apt-get install -y --no-install-recommends cifs-utils nfs-common \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /wheels /wheels
RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels
ENV NETMOVER_HOST=0.0.0.0 \
NETMOVER_PORT=8080 \
NETMOVER_DATA_DIR=/var/lib/netmover \
NETMOVER_RUN_DIR=/run/netmover \
NETMOVER_MOUNT_BASE=/mnt/netmover \
PYTHONUNBUFFERED=1
VOLUME ["/var/lib/netmover"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=4).status == 200 else 1)"
CMD ["netmover", "serve"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Florian Wilde
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+439 -10
View File
@@ -1,9 +1,412 @@
# NetMover
Selbst gehosteter Dienst mit Web-Oberfläche, der SMB/CIFS- und NFS-Freigaben
verwaltet und Dateien zeitgesteuert zwischen ihnen verschiebt.
Selbst gehosteter Dienst mit Weboberfläche, der **SMB/CIFS- und NFS-Freigaben
verwaltet** und **Dateien zeitgesteuert verschiebt** – „alle N Sekunden Ordner 1
prüfen, passende Dateien nach Ordner 2 bringen“. Quelle und Ziel sind
unabhängig voneinander per SMB, NFS, SFTP oder (als Ziel) lokal angebunden.
> Status: in Entwicklung (Schritt 3 von 6). Vollständige Doku folgt mit Schritt 6.
Typischer Einsatz: Ein Synology-NAS nimmt Scans entgegen, NetMover schiebt sie alle
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)
- [Installation](#installation) · [Erster Start](#erster-start)
- [Beispiel: Synology → Paperless](#beispiel-synology--paperless)
- [Docker](#alternative-docker) · [Reverse Proxy](#reverse-proxy-mit-tls)
- [Konfiguration](#konfiguration) · [Fehlersuche](#fehlersuche) · [Sicherheit](#sicherheit)
- [Entwicklung](#entwicklung)
## Wie NetMover Datenverlust verhindert
Quelle und Ziel liegen fast immer auf verschiedenen Dateisystemen. „Verschieben“
heißt deshalb immer **kopieren, prüfen, dann erst die Quelle löschen**:
| Gefahr | Was NetMover tut |
|---|---|
| Halb geschriebene Scans | Datei wird nur angefasst, wenn Größe **und** Änderungszeit über das Stabilitätsfenster (Standard 5 s) gleich bleiben. `.part`, `.tmp`, `.crdownload`, `.filepart`, `.!ut` und versteckte Dateien werden ignoriert. |
| Freigabe nicht eingehängt | Vor jedem Lauf prüft NetMover über `/proc/self/mountinfo`, ob Quelle und Ziel wirklich eingehängt sind – nie daran, ob Dateien da sind. Sonst: Lauf abbrechen, nichts anfassen. |
| Wachsende Datei im Zielordner | Geschrieben wird in `.netmover-<uuid>.part`, dann `fsync`, Größenprüfung, erneute Prüfung der Quelle und atomares Umbenennen ohne Überschreiben. Erst danach wird die Quelle gelöscht. |
| `scan001.pdf` existiert schon | `scan001-1.pdf`, `scan001-2.pdf`, … – jedes Mal neu gegen das Dateisystem geprüft. Überschreiben nur, wenn ausdrücklich eingestellt. |
| Kopiermodus kopiert endlos | Übertragene Dateiversionen (Pfad, Größe, mtime) werden gemerkt. |
| Hängender NFS-Mount, „Stale file handle“ | Jeder Lauf arbeitet in einem eigenen Worker mit Zeitlimit; gestörte Mounts werden per `umount -l` neu eingehängt (max. 3 Versuche). |
| Ziel voll | Vor jeder Kopie: freier Platz ≥ Dateigröße + 5 %. |
| Stiller Ausfall | Statusseite und `/healthz` werden rot, wenn eine Regel länger als das Dreifache ihres Intervalls keinen erfolgreichen Lauf hatte. |
## Installation
### Wo installieren?
| Variante | Empfehlung |
|---|---|
| **VM mit Debian 13** | ✅ Empfohlen. Einhängen funktioniert ohne Sonderkonfiguration. |
| Proxmox-Host direkt | Funktioniert, aber zusätzliche Software auf dem Hypervisor ist meist unerwünscht. |
| **Privilegierter** LXC | Möglich: `pct set <id> -features mount=cifs;nfs`. |
| Unprivilegierter LXC | ❌ Der Kernel erlaubt dort kein CIFS/NFS-Mount. |
| Docker | Möglich, siehe [Alternative: Docker](#alternative-docker). |
### systemd-Dienst (empfohlen)
```bash
apt install -y git
git clone https://git.wilde-server.de/florian.wilde/netmover.git /opt/netmover/src
cd /opt/netmover/src && git checkout "$(git tag -l 'v*' --sort=-v:refname | head -n 1)"
./deploy/install.sh
```
Das Skript installiert `python3-venv`, `cifs-utils`, `nfs-common` und `git`. Es
richtet NetMover in `/opt/netmover/venv` ein, installiert
[`deploy/netmover.service`](deploy/netmover.service) und das Update-Skript
`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:
| Pfad | Inhalt |
|---|---|
| `/var/lib/netmover/netmover.db` | Datenbank (SQLite) |
| `/var/lib/netmover/secret.key` | Schlüssel für die Zugangsdaten (0600) – **mitsichern!** |
| `/etc/netmover/netmover.env` | Einstellungen (Port, Logs, …) |
| `/mnt/netmover/<name>` | Mountpoints – nur NetMover legt hier etwas an |
| `/run/netmover` | kurzlebige Credentials-Dateien für `mount.cifs` |
**Warum als root?** Ein- und Aushängen braucht `CAP_SYS_ADMIN`. Außerdem soll
NetMover Dateien auf beliebigen Freigaben lesen, löschen und mit den richtigen
Eigentümern anlegen können.
**Warum so wenig systemd-Härtung?** Optionen wie `ProtectSystem=`, `PrivateTmp=`
oder `ProtectHome=` geben dem Dienst einen eigenen Mount-Namespace. Die Mounts
landeten dann *nur dort*: Das System und Paperless sähen sie nicht, und jeder
Neustart des Dienstes würde sie verwerfen. `MountFlags=shared` hilft dagegen nicht.
Auf der Test-VM nachgewiesen. Die Unit nutzt deshalb nur Härtungen ohne eigenen
Namespace (`NoNewPrivileges`, `RestrictSUIDSGID`, `LockPersonality`,
`RestrictRealtime`); Datenbank und Schlüssel sind nur für root lesbar. Prüfen:
```bash
findmnt -l | grep /mnt/netmover # die eingehängten Freigaben müssen hier stehen
```
**Beim Beenden bleiben die Mounts eingehängt**, weil andere Programme sie benutzen
könnten. Beim Start werden alle Anbindungen mit „automatisch einhängen“
wiederhergestellt. Erst danach startet der Scheduler.
## Erster Start
NetMover lauscht standardmäßig nur auf `127.0.0.1:8080`. Vom Arbeitsplatz aus
erreichst du die Oberfläche per SSH-Tunnel:
```bash
ssh -L 8080:127.0.0.1:8080 root@netmover-vm
# dann im Browser: http://localhost:8080
```
Beim ersten Aufruf musst du ein Administratorpasswort setzen (mindestens 10 Zeichen).
Dafür brauchst du den **Einrichtungscode** aus dem Log. Damit kann nicht irgendwer
im Netz, der die Seite zuerst öffnet, das Passwort setzen:
```bash
journalctl -u netmover | grep setup.required
# … event=setup.required code=4f2a-91c0-7d3e …
```
Passwort vergessen? `sudo /opt/netmover/venv/bin/netmover reset-password`, dann
`systemctl restart netmover` und die Einrichtung neu durchlaufen.
## Beispiel: Synology → Paperless
Ziel: Scans landen auf dem NAS in `scans`. NetMover schiebt PDFs alle 30 Sekunden
in den Consume-Ordner von Paperless, das in einem LXC per SSH erreichbar ist.
### 1. Synology vorbereiten
1. **Systemsteuerung → Benutzer** → Benutzer `netmover` anlegen, Lese-/Schreibrecht
auf den gemeinsamen Ordner `scans`.
2. **Systemsteuerung → Dateidienste → SMB**: aktiviert. Unter *Erweitert* als
minimales Protokoll SMB2 oder höher.
### 2. Anbindung „Synology Scans“ anlegen
*Anbindungen → Neue Anbindung*:
| Feld | Wert |
|---|---|
| Name | `Synology Scans` |
| Typ | SMB/CIFS |
| Server | `192.168.1.10` (IP des NAS) |
| Freigabe | `scans` |
| Benutzername / Passwort | `netmover` / … |
| Zusatzoptionen | `vers=3.0` |
| Beim Start automatisch einhängen | ✓ |
Klicke auf **Testen**. Alle drei Schritte (Einhängen, Lesen, Schreiben) sollten ✓
zeigen, und die Rolle sollte „Quelle mit Löschen“ lauten. Danach speichern und
**Einhängen**.
### 3. Paperless-LXC vorbereiten (SFTP-Ziel)
Lege in NetMover die *Anbindung → Neue Anbindung* an:
| Feld | Wert |
|---|---|
| Name | `Paperless` |
| Typ | SFTP |
| Server / SSH-Port | IP des LXC / `22` |
| Freigabe / Pfad | `/opt/paperless_data/consume` |
| 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) |
**Schlüsselverwaltung:** Unter *Schlüssel* erzeugst du Ed25519-Schlüssel oder
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
# im Paperless-LXC als root
install -d -m 700 -o paperless -g paperless ~paperless/.ssh
echo 'restrict,command="/usr/lib/openssh/sftp-server" ssh-ed25519 AAAA… netmover@paperless' \
>> ~paperless/.ssh/authorized_keys
chown paperless:paperless ~paperless/.ssh/authorized_keys
chmod 600 ~paperless/.ssh/authorized_keys
```
Dann in NetMover bei der Anbindung **Testen**. Prüfe den angezeigten Fingerabdruck
des Hostschlüssels. Ermitteln kannst du ihn im LXC mit
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`. NetMover hält ihn beim ersten
Test fest und verweigert später jede Verbindung, wenn er sich ändert.
> Weil die Dateien als Benutzer `paperless` ankommen, darf Paperless sie nach dem
> Einlesen auch löschen. Paperless-ngx (aktuelle Versionen mit `watchfiles`)
> bemerkt neue Dateien sofort. Ältere Versionen, die nur auf
> `IN_CLOSE_WRITE`/`IN_MOVED_TO` lauschen, siehe [Fehlersuche](#fehlersuche).
### 4. Regel anlegen
*Regeln → Neue Regel*:
| Feld | Wert |
|---|---|
| Name | `Scans → Paperless` |
| Intervall | `30` |
| Quelle | `Synology Scans`, Unterordner leer |
| Filter | Glob `*.pdf` |
| Ziel | Anbindung `Paperless`, Zielordner leer |
| Aktion | verschieben |
| Wenn Datei existiert | umbenennen |
| Stabilitätsfenster | `5` |
| Quarantäneordner | `_quarantaene` (optional) |
Klicke auf **Probelauf**. Die Tabelle zeigt, welche Datei wohin ginge, ohne etwas
zu verändern. Danach aktivieren und auf der Statusseite verfolgen.
**Getrennt nach Unterordnern?** Scannt z. B. jede Person in einen eigenen
Unterordner (`scans/max`, `scans/erika`), legst du pro Unterordner eine Regel mit
eigenem Quell-Unterordner und eigenem Ziel an.
**Paperless lokal statt per SSH?** Läuft Paperless auf demselben Host, wähle als Ziel
„Lokaler Pfad“ (z. B. `/opt/paperless_data/consume`). Der Ordner muss existieren und
darf nicht unter `/mnt/netmover` liegen. Mit „Eigentümer setzen“ (z. B. `1000:1000`)
gehören die Dateien danach dem Paperless-Benutzer.
### NFS statt SMB
Am NAS: *Dateidienste → NFS* aktivieren. Dann unter *Gemeinsamer Ordner →
Bearbeiten → NFS-Berechtigungen* eine Regel mit der IP des NetMover-Hosts anlegen.
In NetMover Typ „NFS 4“, Freigabe `/volume1/scans`.
**Root-Squash:** NetMover läuft als root. Synology bildet root standardmäßig auf
`nobody` ab, dann scheitert das Schreiben oder `chown`. Der **Testen**-Knopf erkennt
das und sagt es dir. Abhilfe in der NFS-Regel: *Squash → „Keine Zuordnung“* (No
mapping) oder „Alle Benutzer zu admin zuordnen“.
## Alternative: Docker
```bash
mkdir -p /mnt/netmover
docker compose up -d --build
docker compose logs | grep setup.required
```
[`compose.yml`](compose.yml) gibt dem Container die nötigen Rechte:
`cap_add: [SYS_ADMIN, DAC_READ_SEARCH]` und `security_opt: [apparmor:unconfined]`.
> **Wichtig:** `/mnt/netmover` muss als `bind` mit **`propagation: rshared`**
> eingebunden sein. Ohne das bleiben die Mounts im Container gefangen: NetMover
> selbst sieht sie, aber weder der Host noch andere Container (z. B. Paperless).
> Getestet: Mit `rshared` erscheint ein im Container eingehängtes Dateisystem
> sofort unter `/mnt/netmover` auf dem Host, ohne `rshared` nicht.
Lokale Ziele musst du zusätzlich als Volume einbinden. Für NFSv3 im Container die
Option `nolock` setzen oder besser NFSv4 nehmen; NFSv3-Locking braucht
`rpc.statd`, und den gibt es im Container nicht.
**Unterschied zum systemd-Dienst:** Der Dienst direkt auf dem Host braucht keine
Propagation-Tricks, keine AppArmor-Ausnahme und keine zusätzlichen Volumes. Docker
kapselt die Software, hat aber trotz Container `CAP_SYS_ADMIN` und ist damit kaum
weniger mächtig als root auf dem Host.
## Reverse Proxy mit TLS
NetMover gehört **nicht ins Internet**. Es hat root-Rechte, hängt beliebige
Freigaben ein und speichert Zugangsdaten zu deinem NAS. Ein Fehler oder ein
schwaches Passwort wäre ein direkter Weg in dein Netz. Für den Zugriff im LAN gehört
ein Reverse Proxy mit TLS davor; für unterwegs nimm VPN (z. B. WireGuard).
Beispiel Caddy auf demselben Host:
```caddyfile
netmover.lan {
tls internal
reverse_proxy 127.0.0.1:8080
}
```
Beispiel nginx:
```nginx
server {
listen 443 ssl;
server_name netmover.lan;
ssl_certificate /etc/ssl/netmover.crt;
ssl_certificate_key /etc/ssl/netmover.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # Mount-Tests können dauern
}
}
```
Dann in `/etc/netmover/netmover.env`:
```bash
NETMOVER_COOKIE_SECURE=true # Session-Cookie nur noch per HTTPS
NETMOVER_FORWARDED_ALLOW_IPS=127.0.0.1
```
## Konfiguration
Umgebungsvariablen (in `/etc/netmover/netmover.env`); alles Übrige stellst du in
der Oberfläche unter *Einstellungen* ein:
| Variable | Standard | Bedeutung |
|---|---|---|
| `NETMOVER_HOST` | `127.0.0.1` | Adresse, auf der NetMover lauscht |
| `NETMOVER_PORT` | `8080` | Port |
| `NETMOVER_DATA_DIR` | `/var/lib/netmover` | Datenbank und Schlüssel |
| `NETMOVER_MOUNT_BASE` | `/mnt/netmover` | Basisverzeichnis der Mountpoints |
| `NETMOVER_RUN_DIR` | `/run/netmover` | kurzlebige Credentials-Dateien |
| `NETMOVER_COOKIE_SECURE` | `false` | Cookie nur per HTTPS (hinter TLS-Proxy: `true`) |
| `NETMOVER_FORWARDED_ALLOW_IPS` | `127.0.0.1` | Proxy-IPs, deren `X-Forwarded-*` gilt |
| `NETMOVER_SESSION_HOURS` | `12` | Gültigkeit der Anmeldung |
| `NETMOVER_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `NETMOVER_LOG_FORMAT` | `logfmt` | `logfmt` oder `json` (ein Ereignis pro Zeile) |
In der Oberfläche unter *Einstellungen* stellst du ein:
- Aufbewahrung der Protokolle (Standard 30 Tage).
- Webhook-Benachrichtigung: „bei jedem Fehler“ oder „nach N Fehlern in Folge“,
jeweils mit Entwarnung.
- Export und Import der Konfiguration, **ohne** Zugangsdaten und Webhook-URL.
Webhook-Vorlagen (Platzhalter `$event $title $message $rule $time $host`):
| Dienst | URL | Content-Type | Vorlage |
|---|---|---|---|
| ntfy | `https://ntfy.sh/<topic>` | `text/plain` | `$title: $message` |
| Gotify | `https://gotify.lan/message?token=…` | `application/json` | `{"title": "$title", "message": "$message", "priority": 5}` |
| eigener Empfänger | beliebig | `application/json` | Standardvorlage |
**Überwachung:** `GET /healthz` liefert ohne Anmeldung `200` oder `503` mit allen
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
```bash
journalctl -u netmover -f # Log live
journalctl -u netmover | grep level=error
findmnt -R /mnt/netmover # was ist wirklich eingehängt?
```
| Meldung / Symptom | Ursache und Abhilfe |
|---|---|
| `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(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. |
| `… 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`. |
| `access denied by server` (NFS) | NFS-Regel am NAS fehlt für die IP dieses Hosts (oder falsches Subnetz in der Regel). |
| Test: „Root-Squash“ | Siehe [NFS statt SMB](#nfs-statt-smb); `chown` wird bis dahin übersprungen. |
| „… ist nicht leer“ beim Einhängen | Im Mountpoint liegen Dateien, die geschrieben wurden, als nichts eingehängt war. Inhalt prüfen und sichern, Verzeichnis leeren, erneut einhängen. |
| „nicht eingehängt – Lauf abgebrochen“ | Die Freigabe ist weg (z. B. von Hand ausgehängt). Auf der Anbindungsseite **Einhängen**. NetMover hängt verschwundene Mounts absichtlich nicht selbst neu ein. |
| „gestört (ESTALE …)“, danach wieder OK | NAS-Neustart; NetMover hat neu eingehängt. Bleibt der Fehler: Netzwerk/NAS prüfen. |
| „weiterhin nicht verfügbar – neuer Versuch in 60 s“ | Das NAS ist (noch) weg, z. B. während eines DSM-Updates. Bei Anbindungen mit „automatisch einhängen“ versucht NetMover es jede Minute erneut und meldet per Webhook, wenn es wieder klappt. |
| „Keine Reaktion seit 120 s“ | Ein Dateizugriff hängt (meist NFS mit `hard`). Die Regel bleibt blockiert („läuft seit …“), bis der Zugriff zurückkehrt; die Anbindung wird vor dem nächsten Lauf geprüft. |
| „Hostschlüssel … hat sich geändert“ (SFTP) | Wurde der Zielserver neu aufgesetzt? Dann bei der Anbindung „Hostschlüssel zurücksetzen“ und neu testen. Sonst **nicht** verbinden. |
| SFTP: „Anmeldung abgelehnt“ | Öffentlicher Schlüssel fehlt in `authorized_keys`, oder Rechte von `~/.ssh` stimmen nicht (700/600, Eigentümer). |
| Paperless bemerkt neue Dateien nicht | Ältere Paperless-Versionen: `PAPERLESS_CONSUMER_POLLING=10` setzen. |
| Neue Dateien erscheinen erst nach bis zu 30 s | Der CIFS-Client speichert Verzeichnislisten zwischen. Gelangen Dateien nicht per SMB aufs NAS (z. B. per FTP vom Scanner), erfährt er nichts davon. Abhilfe: Zusatzoption `nohandlecache`. |
| Regel gelb „länger als 3 × Intervall“ | Seit dem Dreifachen des Intervalls kein erfolgreicher Lauf; Protokoll der Regel ansehen. |
| Einrichtungscode verloren | `systemctl restart netmover` erzeugt einen neuen. |
## Sicherheit
- **Nicht ins Internet stellen** (siehe [Reverse Proxy](#reverse-proxy-mit-tls)).
- Zugangsdaten liegen nur verschlüsselt in der Datenbank (Fernet, Schlüssel
`secret.key`, 0600). Die Oberfläche zeigt sie nie an; in der Prozessliste stehen
sie ebenfalls nie, weil `mount.cifs` eine kurzlebige Credentials-Datei bekommt.
Ausgaben von `mount` werden geschwärzt, bevor sie protokolliert werden.
- **Sichere `secret.key` zusammen mit der Datenbank.** Ohne den Schlüssel sind die
gespeicherten Passwörter verloren.
- Anmeldung:
- Ein Administratorkonto mit Argon2-Passworthash.
- Sperre nach 5 Fehlversuchen.
- Session-Cookie `HttpOnly`, `SameSite=Lax`.
- CSRF-Token bei jedem POST.
- Strikte Content-Security-Policy ohne Inline-Skripte.
- Eingaben, die in `mount` landen, werden streng geprüft. Zusatzoptionen
sind nur aus einer Allowlist erlaubt, `credentials`, `username`, `password` und
`pass` sind gesperrt, `mount` wird nie über eine Shell aufgerufen.
- Mountpoints entstehen ausschließlich unter `/mnt/netmover`; NetMover ändert
`/etc/fstab` nie.
- SFTP-Ziele: Schlüssel per `restrict,command="/usr/lib/openssh/sftp-server"` auf
SFTP beschränken; der Hostschlüssel wird festgehalten.
## Entwicklung
@@ -12,10 +415,28 @@ python3 -m venv .venv # oder: --without-pip + get-pip.py
.venv/bin/pip install -e '.[dev]'
.venv/bin/pytest
.venv/bin/ruff check . && .venv/bin/ruff format --check . && .venv/bin/mypy
NETMOVER_DATA_DIR=$PWD/data .venv/bin/netmover migrate
# Oberfläche ohne root und ohne NAS ausprobieren (simulierte Mounts):
NETMOVER_DATA_DIR=/tmp/nm NETMOVER_RUN_DIR=/tmp/nm/run NETMOVER_MOUNT_BASE=/tmp/nm/mnt \
NETMOVER_MOUNT_BACKEND=fake .venv/bin/netmover serve
# Freigaben simulieren: mkdir -p /tmp/nm/fake/remote/<server>/<freigabe>
```
Neue Migration nach Modelländerung:
Aufbau:
| Modul | Aufgabe |
|---|---|
| `mounting/` | `MountBackend` (Linux/Fake), Mount-Verwaltung, Test, Wiederherstellung, SFTP |
| `mover/` | Scan, Stabilität, atomares Schreiben, Kollisionen, Buchführung, Regellauf |
| `runs.py`, `scheduler.py`, `health.py`, `notify.py` | Protokoll, Zeitsteuerung, Gesundheit, Webhook |
| `admin.py`, `keys.py`, `transfer.py`, `web/` | Formulare, Schlüsselverwaltung, Export/Import, Oberfläche |
| `health.py`, `kuma.py` | Gesundheitsprüfung, Uptime-Kuma-Push |
| `docs/` | Anleitung und Bilder; wird als Hilfe-Seite mit ausgeliefert |
Die gesamte Mover-Logik ist mit dem `FakeMountBackend` ohne root testbar. SFTP
wird gegen einen echten SFTP-Server im Testprozess getestet.
Neue Migration nach einer Modelländerung:
```bash
NETMOVER_DATA_DIR=$PWD/data .venv/bin/alembic revision --autogenerate -m "…"
@@ -23,11 +444,19 @@ NETMOVER_DATA_DIR=$PWD/data .venv/bin/alembic revision --autogenerate -m "…"
Veröffentlichte Migrationen (ab v0.1.0) werden nie mehr geändert, nur ergänzt.
## Arbeitsweise
### Arbeitsweise
- Commits nach [Conventional Commits](https://www.conventionalcommits.org/de/)
(`feat:`, `fix:`, `docs:`, `test:`, `chore:` …).
- Jede nutzerrelevante Änderung bekommt einen Eintrag unter `[Unreleased]` in
[CHANGELOG.md](CHANGELOG.md). Beim Release wird der Abschnitt versioniert,
die Version in `src/netmover/__init__.py` angehoben und ein Tag `vX.Y.Z` gesetzt.
- Arbeitszeit und Entscheidungen: [DEVLOG.md](DEVLOG.md).
- Jede nutzerrelevante Änderung kommt unter `[Unreleased]` in
[CHANGELOG.md](CHANGELOG.md). Bei einem Release:
1. Den Abschnitt `[Unreleased]` mit Versionsnummer versehen.
2. Die Version in `src/netmover/__init__.py` anheben.
3. Einen Tag `vX.Y.Z` setzen.
- Arbeitszeit und Entscheidungen stehen in [DEVLOG.md](DEVLOG.md).
- htmx 2.0.4 liegt unter `src/netmover/web/static/htmx.min.js`
(SHA-256 `e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447`).
## Lizenz
MIT
+44
View File
@@ -0,0 +1,44 @@
# 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)
## 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.7.0 | Archiv je Regel: Kopie ins Ziel und ins Archiv, Quelle erst danach löschen |
| 0.6.0 | Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen, Schutz vor Aussperren |
| 0.5.0 | SFTP als Quelle; gespeicherte Zugangsdaten für SMB/SFTP-Passwörter (Seite „Zugangsdaten“); Anmeldung abschaltbar (vertrauenswürdige Netze / ganz) |
| 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“.
+37
View File
@@ -0,0 +1,37 @@
# NetMover per Docker Compose. Vorher auf dem Host:
# mkdir -p /mnt/netmover
# Die Weboberfläche ist nur auf dem Host selbst erreichbar (127.0.0.1:8080).
services:
netmover:
build: .
image: netmover:0.7.0
container_name: netmover
restart: unless-stopped
cap_add:
- SYS_ADMIN # mount/umount
- DAC_READ_SEARCH # mount.cifs liest die Credentials-Datei
security_opt:
- apparmor:unconfined # Dockers AppArmor-Profil verbietet mount
ports:
- "127.0.0.1:8080:8080"
environment:
NETMOVER_LOG_LEVEL: INFO
# Hinter einem Reverse Proxy mit HTTPS:
# NETMOVER_COOKIE_SECURE: "true"
# NETMOVER_FORWARDED_ALLOW_IPS: "172.16.0.0/12"
volumes:
- netmover-data:/var/lib/netmover
# WICHTIG: rshared – sonst bleiben die Mounts im Container gefangen und
# weder der Host noch andere Container sehen sie.
- type: bind
source: /mnt/netmover
target: /mnt/netmover
bind:
propagation: rshared
# Lokale Ziele (z. B. Paperless-Consume-Ordner) zusätzlich einbinden:
# - /opt/paperless/consume:/opt/paperless/consume
tmpfs:
- /run/netmover:mode=0700
volumes:
netmover-data:
+52
View File
@@ -0,0 +1,52 @@
#!/bin/sh
# NetMover auf Debian 13 installieren bzw. aktualisieren (als root ausführen).
#
# git clone https://git.wilde-server.de/florian.wilde/netmover.git /opt/netmover/src
# /opt/netmover/src/deploy/install.sh
#
# Spätere Updates: netmover-update (siehe deploy/update.sh).
set -eu
SOURCE="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
PREFIX=/opt/netmover
if [ "$(id -u)" -ne 0 ]; then
echo "Bitte als root ausführen." >&2
exit 1
fi
echo "==> Pakete"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq python3 python3-venv cifs-utils nfs-common git >/dev/null
echo "==> Virtuelle Umgebung in $PREFIX/venv"
install -d -m 0755 "$PREFIX"
[ -x "$PREFIX/venv/bin/python" ] || python3 -m venv "$PREFIX/venv"
"$PREFIX/venv/bin/pip" install -q --upgrade pip
"$PREFIX/venv/bin/pip" install -q --upgrade "$SOURCE"
echo "==> Konfiguration"
install -d -m 0755 /etc/netmover
if [ ! -f /etc/netmover/netmover.env ]; then
install -m 0640 "$(dirname "$0")/netmover.env" /etc/netmover/netmover.env
fi
install -d -m 0755 /mnt/netmover
echo "==> Update-Skript"
install -m 0755 "$(dirname "$0")/update.sh" /usr/local/sbin/netmover-update
echo "==> systemd"
install -m 0644 "$(dirname "$0")/netmover.service" /etc/systemd/system/netmover.service
systemctl daemon-reload
systemctl enable netmover >/dev/null 2>&1
systemctl restart netmover
sleep 2
systemctl --no-pager --lines=0 status netmover || true
echo
if journalctl -u netmover -n 200 --no-pager | grep -q "setup.required"; then
echo "Ersteinrichtung: Einrichtungscode aus dem Log:"
journalctl -u netmover -n 200 --no-pager | grep "setup.required" | tail -1
fi
echo "Fertig. Weboberfläche: http://127.0.0.1:8080 (README: SSH-Tunnel / Reverse Proxy)"
+17
View File
@@ -0,0 +1,17 @@
# Umgebungsvariablen für NetMover (/etc/netmover/netmover.env).
# Nach Änderungen: systemctl restart netmover
# Nur lokal erreichbar – für Zugriff aus dem LAN einen Reverse Proxy mit TLS
# davorsetzen (README, „Reverse Proxy“) oder per SSH-Tunnel arbeiten.
NETMOVER_HOST=127.0.0.1
NETMOVER_PORT=8080
# Hinter einem Reverse Proxy mit HTTPS:
# NETMOVER_COOKIE_SECURE=true
# NETMOVER_FORWARDED_ALLOW_IPS=127.0.0.1
# NETMOVER_MOUNT_BASE=/mnt/netmover
# NETMOVER_SESSION_HOURS=12
NETMOVER_LOG_LEVEL=INFO
# logfmt (gut lesbar im Journal) oder json
NETMOVER_LOG_FORMAT=logfmt
+44
View File
@@ -0,0 +1,44 @@
# NetMover als systemd-Dienst direkt auf dem Host (empfohlen).
# Installation: siehe README, Abschnitt „Installation“.
[Unit]
Description=NetMover – NFS/SMB-Mounts und zeitgesteuertes Verschieben
Documentation=https://git.wilde-server.de/florian.wilde/netmover
Wants=network-online.target
After=network-online.target remote-fs-pre.target
[Service]
Type=exec
# root ist nötig: mount/umount brauchen CAP_SYS_ADMIN, und Dateien auf beliebigen
# Freigaben sollen mit den richtigen Eigentümern geschrieben werden (siehe README).
User=root
EnvironmentFile=-/etc/netmover/netmover.env
ExecStart=/opt/netmover/venv/bin/netmover serve
Restart=on-failure
RestartSec=5
# Beim Stoppen werden Mounts bewusst NICHT ausgehängt.
KillMode=mixed
TimeoutStopSec=20
# /var/lib/netmover (Datenbank, Schlüssel) und /run/netmover (kurzlebige
# Credentials-Dateien für mount.cifs) – beide nur für root lesbar.
StateDirectory=netmover
StateDirectoryMode=0700
RuntimeDirectory=netmover
RuntimeDirectoryMode=0700
# Härtung, soweit mit Mounten vereinbar.
# WICHTIG: Keine Optionen, die einen eigenen Mount-Namespace erzeugen
# (ProtectSystem=, ProtectHome=, PrivateTmp=, ProtectControlGroups=,
# ReadOnlyPaths=, BindPaths= …). Sonst landen die Mounts im Namespace des
# Dienstes: das System und andere Programme (Paperless!) sähen sie nicht, und
# jeder Neustart des Dienstes würde sie verwerfen. MountFlags=shared hilft dagegen
# nicht – systemd setzt den Namespace als „slave“ auf, Mounts fließen nur hinein.
# Auf dem Testsystem nachgewiesen; prüfen mit: findmnt -l | grep /mnt/netmover
NoNewPrivileges=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
UMask=0022
[Install]
WantedBy=multi-user.target
+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
+455
View File
@@ -0,0 +1,455 @@
# 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) ·
[Zugangsdaten](#zugangsdaten) · [Anbindungen](#anbindungen) · [Regeln](#regeln) · [Archiv](#archiv) ·
[Probelauf](#probelauf) · [Status](#status) · [Wartungsmodus](#wartungsmodus) · [Protokoll](#protokoll) ·
[Einstellungen](#einstellungen) · [Anmeldung](#anmeldung) · [Uptime Kuma](#uptime-kuma) · [Beispiele](#beispiele) · [Häufige Fragen](#fragen)
## 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` |
| **Zugangsdaten** | Gespeicherter Benutzer mit Passwort oder SSH-Schlüssel, in mehreren Anbindungen nutzbar | „NAS-Scanner“, „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: **(Zugangsdaten) → Anbindungen → 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) | Quelle und 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. Als **Quelle** gelten bei SFTP dieselben Sicherheitsprüfungen; weil
SFTP-Server Änderungszeiten nur sekundengenau melden, sollte das
Stabilitätsfenster dort mindestens 2 Sekunden betragen.
### 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. |
| **Zugangsdaten / Anmeldeart** | Gespeicherte Zugangsdaten auswählen oder eigene Angaben nur für diese Anbindung; bei SFTP zusätzlich SSH-Schlüssel oder Passwort, siehe [Zugangsdaten](#zugangsdaten). |
| **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.
## Zugangsdaten {#zugangsdaten}
*Verbindungen → Zugangsdaten*
Benutzer und Passwörter für Freigaben sowie SSH-Schlüssel einmal hinterlegen und in
beliebig vielen Anbindungen auswählen. Passwörter und private Schlüssel liegen
verschlüsselt in der Datenbank und werden nie angezeigt.
![Zugangsdaten](img/zugangsdaten.png)
### Passwörter {#zugangsdaten-passwoerter}
Für SMB-Freigaben und SFTP-Server mit Passwort: Name, Benutzer, Passwort und
optional Domäne. In der Anbindung unter *Zugangsdaten* auswählen statt „eigene
Angaben“. **Ändern** setzt Benutzer, Domäne oder ein neues Passwort – das gilt sofort
für alle Anbindungen, die den Eintrag nutzen (praktisch, wenn das NAS-Passwort
wechselt).
### SSH-Schlüssel {#schluessel}
Für SFTP ist ein Schlüssel sicherer als ein Passwort. Einen Schlüssel kann man für
mehrere Anbindungen verwenden; den Benutzernamen legt jede Anbindung fest.
**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 Server 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.
Gespeicherte Zugangsdaten lassen sich nur löschen, wenn sie 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**. |
### Archiv {#archiv}
Optional bekommt jede Datei zusätzlich eine **Kopie ins Archiv** – z. B. Paperless als
Ziel und ein Archivordner auf dem NAS. **Die Quelle wird erst gelöscht, wenn die Datei
im Ziel UND im Archiv angekommen ist.**
| Feld | Bedeutung |
|---|---|
| **Archiv** | kein Archiv / eine Anbindung (SMB, NFS, SFTP) / ein lokaler Pfad |
| **Archivordner** | Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren. |
| **Ablage** | **wie in der Quelle** oder **nach Datum** – `2026/10/scan001.pdf` (Monat der Verarbeitung) |
So läuft es pro Datei:
1. **Zuerst ins Archiv** – mit denselben Sicherungen wie beim Ziel.
2. **Dann ins Ziel.**
3. **Dann erst** wird die Quelle gelöscht (bei „verschieben“).
Das Archiv kommt zuerst, weil ein Ziel wie Paperless Dateien sofort abholt: Scheitert
das Archiv, wurde noch nichts geliefert, und die Datei bleibt in der Quelle. Scheitert
danach das Ziel, merkt sich NetMover, dass die Datei schon archiviert ist – der nächste
Versuch legt keine zweite Archivkopie an.
Im Archiv wird **nie überschrieben**: Gleiche Namen werden immer umbenannt
(`scan-1.pdf`), egal was die Regel fürs Ziel vorsieht. Ist das Archiv nicht erreichbar,
wartet die Regel wie bei Quelle und Ziel. Im Kopiermodus bekommen Ziel und Archiv je
eine Kopie, die Quelle bleibt.
### 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.
### Anmeldung {#anmeldung}
Unter *Einstellungen → Anmeldung* legst du fest, **aus welchen Netzen** es mit und
ohne Passwort geht (Bestätigung mit dem aktuellen Passwort):
| Feld | Wirkung |
|---|---|
| **Ohne Passwort aus diesen Netzen** | z. B. `10.0.10.0/24, 10.1.5.0/24` – von dort keine Anmeldung. Leer = niemand. `0.0.0.0/0, ::/0` = Anmeldung ganz aus. |
| **Mit Passwort aus diesen Netzen** | Leer = **alle übrigen** Netze mit Passwort. Ausgefüllt = nur von dort mit Passwort, **alle anderen Netze werden abgewiesen** (auch die Anmeldeseite). |
Beispiel: Ohne Passwort aus dem Heimnetz `10.0.10.0/24`, mit Passwort aus dem
VPN-Netz `10.8.0.0/24`, alles andere gesperrt.
- Einzelne Adressen gehen auch (`192.168.1.20`).
- Die Seite zeigt deine aktuelle Adresse. Eine Einstellung, mit der du dich selbst
aussperren würdest, wird nicht gespeichert.
- `/healthz` bleibt für die Überwachung von überall erreichbar.
- Solange du ohne Passwort arbeitest, steht oben gelb „ohne Anmeldung“. Der Schutz
gegen untergeschobene Formulare bleibt immer aktiv.
> NetMover läuft als root und kennt die Zugangsdaten deiner Freigaben. Wer die
> Oberfläche ohne Passwort erreicht, kann alles ändern – Netze „ohne Passwort“ daher
> so eng wie möglich wählen. Läuft ein Reverse Proxy auf demselben Server, kommen
> alle Anfragen von `127.0.0.1`: dann `127.0.0.1` nicht eintragen und
> `NETMOVER_FORWARDED_ALLOW_IPS` setzen.
Ausgesperrt oder Passwort vergessen? Auf dem Server
`/opt/netmover/venv/bin/netmover reset-password` – setzt das Passwort zurück, schaltet
die Anmeldung überall wieder ein und hebt Netz-Sperren auf.
### 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. Unter *Zugangsdaten* den 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. Optional **Archiv**: Anbindung NAS, Ordner
`Archiv/Scans`, Ablage nach Datum. **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: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+8
View File
@@ -25,6 +25,7 @@ dependencies = [
"itsdangerous>=2.2",
"httpx>=0.27",
"paramiko>=3.4",
"markdown>=3.5",
]
[project.optional-dependencies]
@@ -33,6 +34,9 @@ dev = [
"ruff>=0.6",
"mypy>=1.11",
"types-paramiko",
"types-Markdown",
"shellcheck-py",
"aiosmtpd",
]
[project.scripts]
@@ -44,6 +48,10 @@ path = "src/netmover/__init__.py"
[tool.hatch.build.targets.wheel]
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]
testpaths = ["tests"]
addopts = "-ra"
+1 -1
View File
@@ -1,3 +1,3 @@
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
__version__ = "0.1.0.dev0"
__version__ = "0.7.0"
+677
View File
@@ -0,0 +1,677 @@
"""Verwaltung von Anbindungen und Regeln – Prüfen und Speichern von Formulardaten.
Unabhängig vom Web-Framework: nimmt ein ``Mapping[str, str]`` (Formular) und
liefert entweder gespeicherte Objekte oder ein Fehler-Dict ``{feld: meldung}``.
Passwörter: leer lassen heißt „unverändert“; sie werden nie zurückgegeben.
"""
from __future__ import annotations
import re
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from netmover.crypto import SecretBox, generate_ssh_keypair
from netmover.models import (
ArchiveKind,
ArchiveLayout,
CollisionPolicy,
Credential,
CredentialKind,
Mount,
MountState,
MountType,
PatternType,
Rule,
RuleAction,
)
from netmover.mounting.manager import CredentialData, MountConfig, MountManager
from netmover.validation import (
MountOption,
ValidationError,
parse_extra_options,
safe_abspath,
safe_relpath,
slugify,
validate_id,
validate_mode,
validate_port,
validate_secret,
validate_server,
validate_share,
validate_username,
)
Errors = dict[str, str]
MIN_INTERVAL = 10
_OWNER_RE = re.compile(r"^\d{1,10}(:\d{1,10})?$")
class AdminError(Exception):
"""Aktion nicht möglich – Meldung für die Oberfläche."""
def _bool(form: Mapping[str, str], key: str) -> bool:
return form.get(key, "") in ("on", "true", "1", "yes")
def _int(
form: Mapping[str, str],
key: str,
errors: Errors,
*,
default: int | None = None,
minimum: int | None = None,
label: str = "",
) -> int | None:
raw = (form.get(key) or "").strip()
if raw == "":
return default
try:
value = int(raw)
except ValueError:
errors[key] = "Bitte eine ganze Zahl angeben."
return default
if minimum is not None and value < minimum:
errors[key] = f"{label or 'Wert'} muss mindestens {minimum} sein."
return value
# -- Anbindungen ------------------------------------------------------------------------
NEW_KEY = "new"
OWN = "own"
@dataclass
class MountInput:
name: str = ""
type: MountType = MountType.CIFS
server: str = ""
share: str = ""
port: int | None = None
username: str = ""
password: str = "" # leer = unverändert
domain: str = ""
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
# Passwort-Anmeldung (SMB, SFTP): "own" = eigene Angaben, sonst ID gespeicherter Zugangsdaten
cred_id: str = OWN
uid: int | None = None
gid: int | None = None
file_mode: str | None = None
dir_mode: str | None = None
extra_options: str = ""
auto_mount: bool = True
enabled: bool = True
def config(self) -> MountConfig:
return MountConfig(
name=self.name or "neu",
type=self.type,
server=self.server,
share=self.share,
port=self.port,
uid=self.uid,
gid=self.gid,
file_mode=self.file_mode,
dir_mode=self.dir_mode,
extra_options=self.extra_options,
)
def mount_input_from_model(m: Mount) -> MountInput:
c = m.credential
return MountInput(
name=m.name,
type=m.type,
server=m.server,
share=m.share,
port=m.port,
username=m.username or (c.username if c else ""),
domain=(c.domain or "") if c else "",
auth=c.kind if c else CredentialKind.PASSWORD,
key_id=str(c.id)
if c is not None and c.managed and c.kind is CredentialKind.SSH_KEY
else NEW_KEY,
cred_id=str(c.id)
if c is not None and c.managed and c.kind is CredentialKind.PASSWORD
else OWN,
uid=m.uid,
gid=m.gid,
file_mode=m.file_mode,
dir_mode=m.dir_mode,
extra_options=m.extra_options,
auto_mount=m.auto_mount,
enabled=m.enabled,
)
def parse_mount_form(form: Mapping[str, str]) -> tuple[MountInput, Errors]:
errors: Errors = {}
data = MountInput(
name=(form.get("name") or "").strip(),
server=(form.get("server") or "").strip(),
share=(form.get("share") or "").strip(),
username=(form.get("username") or "").strip(),
password=form.get("password") or "",
domain=(form.get("domain") or "").strip(),
extra_options=(form.get("extra_options") or "").strip(),
auto_mount=_bool(form, "auto_mount"),
enabled=_bool(form, "enabled"),
key_id=(form.get("key_id") or NEW_KEY).strip(),
cred_id=(form.get("cred_id") or OWN).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
if data.cred_id != OWN and not data.cred_id.isdigit():
errors["cred_id"] = "Ungültige Auswahl."
data.cred_id = OWN
try:
data.type = MountType(form.get("type", "cifs"))
except ValueError:
errors["type"] = "Unbekannter Typ."
return data, errors
try:
data.auth = CredentialKind(form.get("auth") or "password")
except ValueError:
errors["auth"] = "Unbekannte Anmeldeart."
if not data.name:
errors["name"] = "Name fehlt."
elif len(data.name) > 100:
errors["name"] = "Höchstens 100 Zeichen."
else:
try:
slugify(data.name)
except ValidationError as exc:
errors["name"] = str(exc)
def check[T](key: str, fn: Callable[[], T], fallback: T) -> T:
try:
return fn()
except ValidationError as exc:
errors[key] = str(exc)
return fallback
data.server = check("server", lambda: validate_server(data.server), data.server)
data.share = check("share", lambda: validate_share(data.type, data.share), data.share)
no_options: list[MountOption] = []
check("extra_options", lambda: parse_extra_options(data.type, data.extra_options), no_options)
check("password", lambda: validate_secret(data.password), "")
if data.type is MountType.CIFS:
data.uid = check("uid", lambda: validate_id("uid", form.get("uid")), None)
data.gid = check("gid", lambda: validate_id("gid", form.get("gid")), None)
data.file_mode = check(
"file_mode", lambda: validate_mode("file_mode", form.get("file_mode")), None
)
data.dir_mode = check(
"dir_mode", lambda: validate_mode("dir_mode", form.get("dir_mode")), None
)
if data.type is MountType.SFTP:
data.port = check("port", lambda: validate_port(form.get("port")), None)
if data.username:
data.username = check("username", lambda: validate_username(data.username), data.username)
if data.domain:
check("domain", lambda: validate_username(data.domain), "")
if data.auth is CredentialKind.SSH_KEY or data.type in (MountType.NFS3, MountType.NFS4):
data.cred_id = OWN
uses_stored = data.cred_id != OWN
if uses_stored:
data.username = data.domain = data.password = "" # kommen aus den Zugangsdaten
errors.pop("username", None)
errors.pop("password", None)
if data.type is MountType.SFTP and not data.username and not uses_stored:
errors["username"] = "SFTP braucht einen Benutzernamen."
if data.type is not MountType.SFTP:
data.auth = CredentialKind.PASSWORD
if data.auth is not CredentialKind.SSH_KEY:
data.key_id = NEW_KEY
return data, errors
def credential_for_test(
data: MountInput, existing: Mount | None, secrets: SecretBox, s: Session | None = None
) -> CredentialData | None:
"""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):
return None
if data.cred_id != OWN and s is not None:
stored = s.get(Credential, int(data.cred_id))
if stored is None or not stored.managed or stored.kind is not CredentialKind.PASSWORD:
return None
return CredentialData(
CredentialKind.PASSWORD,
stored.username,
secrets.decrypt(stored.secret_enc),
stored.domain,
)
if not data.username:
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
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:
secret = secrets.decrypt(stored.secret_enc)
if not secret:
return None
return CredentialData(data.auth, data.username, secret, data.domain or None)
_CONNECTION_FIELDS = (
"type",
"server",
"share",
"port",
"uid",
"gid",
"file_mode",
"dir_mode",
"extra_options",
)
def save_mount(
s: Session,
secrets: SecretBox,
mounts: MountManager,
data: MountInput,
mount_id: int | None = None,
) -> tuple[Mount | None, Errors]:
errors: Errors = {}
m = s.get(Mount, mount_id) if mount_id is not None else None
if mount_id is not None and m is None:
raise AdminError("Anbindung existiert nicht.")
dup = s.scalar(select(Mount.id).where(Mount.name == data.name))
if dup is not None and dup != mount_id:
errors["name"] = "Name ist bereits vergeben."
return None, errors
if m is None:
base = slugify(data.name)
slug, n = base, 1
while s.scalar(select(Mount.id).where(Mount.slug == slug)) is not None:
n += 1
slug = f"{base[:44]}-{n}"
m = Mount(slug=slug, state=MountState.UNMOUNTED, extra_options="")
if data.type.is_kernel_mount:
m.mountpoint = str(mounts.mountpoint_for(slug))
s.add(m)
else:
changed = [f for f in _CONNECTION_FIELDS if getattr(m, f) != getattr(data, f)]
if (
changed
and m.type.is_kernel_mount
and m.state in (MountState.MOUNTED, MountState.DEGRADED)
):
errors["_"] = (
"Verbindungsdaten lassen sich nur ändern, wenn die Anbindung ausgehängt ist."
)
return None, errors
if m.type is MountType.SFTP and (m.server != data.server or m.port != data.port):
m.host_key = None # anderer Server → Hostschlüssel neu bestätigen
if data.type.is_kernel_mount and not m.mountpoint:
m.mountpoint = str(mounts.mountpoint_for(m.slug))
if not data.type.is_kernel_mount:
m.mountpoint = None
for f in (
"name",
"type",
"server",
"share",
"port",
"uid",
"gid",
"file_mode",
"dir_mode",
"extra_options",
"auto_mount",
"enabled",
):
setattr(m, f, getattr(data, f))
if not data.type.is_kernel_mount:
m.auto_mount = False
# Zugangsdaten: entweder ein gespeicherter Schlüssel aus der Schlüsselverwaltung
# (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
stored_password = data.cred_id != OWN and (
data.type is MountType.CIFS
or (data.type is MountType.SFTP and data.auth is CredentialKind.PASSWORD)
)
needs_cred = data.type is MountType.SFTP or (
data.type is MountType.CIFS and (bool(data.username) or stored_password)
)
use_managed = (
data.type is MountType.SFTP
and data.auth is CredentialKind.SSH_KEY
and data.key_id != NEW_KEY
)
if not needs_cred:
m.credential = None
m.username = None
elif stored_password:
stored = s.get(Credential, int(data.cred_id))
if stored is None or not stored.managed or stored.kind is not CredentialKind.PASSWORD:
s.rollback()
return None, {"cred_id": "Zugangsdaten existieren nicht."}
m.credential = stored
m.username = None
elif use_managed:
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:
m.username = None
cred = own
if cred is None or cred.kind is not data.auth:
if data.auth is CredentialKind.SSH_KEY:
private, public = generate_ssh_keypair(f"netmover@{m.slug}")
secret, pub = private, public
else:
if not data.password:
errors["password"] = "Passwort fehlt." # noqa: S105
s.rollback()
return None, errors
secret, pub = data.password, None
if cred is None:
cred = Credential(name=f"mount:{m.slug}", managed=False)
s.add(cred)
cred.kind = data.auth
cred.secret_enc = secrets.encrypt(secret)
cred.public_key = pub
elif data.password and data.auth is CredentialKind.PASSWORD:
cred.secret_enc = secrets.encrypt(data.password)
cred.username = data.username
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:
s.flush()
except IntegrityError:
s.rollback()
errors["_"] = "Speichern fehlgeschlagen (Name oder Mountpoint doppelt)."
return None, errors
return m, errors
def delete_mount(s: Session, mounts: MountManager, mount_id: int) -> None:
m = s.get(Mount, mount_id)
if m is None:
return
used = s.scalars(
select(Rule.name).where(
(Rule.src_mount_id == mount_id)
| (Rule.dst_mount_id == mount_id)
| (Rule.archive_mount_id == mount_id)
)
).all()
if used:
raise AdminError(f"Wird noch von Regeln benutzt: {', '.join(used)}")
if m.type.is_kernel_mount and m.state is not MountState.UNMOUNTED:
mounts.unmount(mount_id)
cred = m.credential
s.delete(m)
s.flush()
# 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)
# -- Regeln ----------------------------------------------------------------------------------
@dataclass
class RuleInput:
name: str = ""
enabled: bool = True
src_mount_id: int | None = None
src_subpath: str = ""
dst_kind: str = "mount" # "mount" | "local"
dst_mount_id: int | None = None
dst_path: str = ""
pattern: str = "*"
pattern_type: PatternType = PatternType.GLOB
recursive: bool = False
interval_seconds: int = 30
stability_seconds: int = 5
min_age_seconds: int = 0
max_size_mb: int | None = None
op_timeout_seconds: int = 120
action: RuleAction = RuleAction.MOVE
on_collision: CollisionPolicy = CollisionPolicy.RENAME
set_owner: str = ""
set_mode: str = ""
dry_run: bool = False
quarantine_path: str = ""
quarantine_after: int = 3
archive_kind: ArchiveKind = ArchiveKind.NONE
archive_mount_id: int | None = None
archive_path: str = ""
archive_layout: ArchiveLayout = ArchiveLayout.FLAT
extra: dict[str, str] = field(default_factory=dict)
def rule_input_from_model(r: Rule) -> RuleInput:
return RuleInput(
name=r.name,
enabled=r.enabled,
src_mount_id=r.src_mount_id,
src_subpath=r.src_subpath,
dst_kind="mount" if r.dst_mount_id else "local",
dst_mount_id=r.dst_mount_id,
dst_path=r.dst_path,
pattern=r.pattern,
pattern_type=r.pattern_type,
recursive=r.recursive,
interval_seconds=r.interval_seconds,
stability_seconds=r.stability_seconds,
min_age_seconds=r.min_age_seconds,
max_size_mb=r.max_size_mb,
op_timeout_seconds=r.op_timeout_seconds,
action=r.action,
on_collision=r.on_collision,
set_owner=r.set_owner or "",
set_mode=r.set_mode or "",
dry_run=r.dry_run,
quarantine_path=r.quarantine_path or "",
quarantine_after=r.quarantine_after,
archive_kind=r.archive_kind,
archive_mount_id=r.archive_mount_id,
archive_path=r.archive_path,
archive_layout=r.archive_layout,
)
def parse_rule_form(s: Session, form: Mapping[str, str]) -> tuple[RuleInput, Errors]:
errors: Errors = {}
d = RuleInput(
name=(form.get("name") or "").strip(),
enabled=_bool(form, "enabled"),
src_subpath=form.get("src_subpath") or "",
dst_kind=form.get("dst_kind") or "mount",
dst_path=(form.get("dst_path") or "").strip(),
pattern=(form.get("pattern") or "").strip() or "*",
recursive=_bool(form, "recursive"),
set_owner=(form.get("set_owner") or "").strip(),
set_mode=(form.get("set_mode") or "").strip(),
dry_run=_bool(form, "dry_run"),
quarantine_path=form.get("quarantine_path") or "",
)
if not d.name:
errors["name"] = "Name fehlt."
for key, enum_cls in (
("pattern_type", PatternType),
("action", RuleAction),
("on_collision", CollisionPolicy),
):
try:
setattr(d, key, enum_cls(form.get(key) or getattr(d, key).value))
except ValueError:
errors[key] = "Ungültiger Wert."
d.src_mount_id = _int(form, "src_mount_id", errors)
d.dst_mount_id = _int(form, "dst_mount_id", errors) if d.dst_kind == "mount" else None
d.interval_seconds = (
_int(form, "interval_seconds", errors, default=30, minimum=MIN_INTERVAL, label="Intervall")
or 30
)
d.stability_seconds = _int(form, "stability_seconds", errors, default=5, minimum=0) or 0
d.min_age_seconds = _int(form, "min_age_seconds", errors, default=0, minimum=0) or 0
d.max_size_mb = _int(form, "max_size_mb", errors, minimum=1)
d.op_timeout_seconds = (
_int(form, "op_timeout_seconds", errors, default=120, minimum=10, label="Zeitlimit") or 120
)
d.quarantine_after = _int(form, "quarantine_after", errors, default=3, minimum=1) or 3
src = s.get(Mount, d.src_mount_id) if d.src_mount_id else None
if src is None:
errors["src_mount_id"] = "Quelle wählen."
try:
d.src_subpath = safe_relpath("src_subpath", d.src_subpath)
except ValidationError as exc:
errors["src_subpath"] = str(exc)
if d.dst_kind == "mount":
if d.dst_mount_id is None or s.get(Mount, d.dst_mount_id) is None:
errors["dst_mount_id"] = "Ziel wählen."
try:
d.dst_path = safe_relpath("dst_path", d.dst_path)
except ValidationError as exc:
errors["dst_path"] = str(exc)
if (
d.dst_mount_id == d.src_mount_id
and "src_subpath" not in errors
and d.dst_path == d.src_subpath
):
errors["dst_path"] = "Quelle und Ziel sind derselbe Ordner."
elif d.dst_kind == "local":
try:
d.dst_path = safe_abspath("dst_path", d.dst_path)
except ValidationError as exc:
errors["dst_path"] = str(exc)
else:
errors["dst_kind"] = "Ungültige Zielart."
if d.pattern_type is PatternType.REGEX:
try:
re.compile(d.pattern)
except re.error as exc:
errors["pattern"] = f"Ungültiger regulärer Ausdruck: {exc}"
if d.stability_seconds >= d.op_timeout_seconds:
errors["stability_seconds"] = "Muss kleiner als das Zeitlimit sein."
elif src is not None and src.type is MountType.SFTP and d.stability_seconds < 2:
errors["stability_seconds"] = (
"Bei SFTP-Quellen mindestens 2 s – der Server meldet Zeiten nur sekundengenau."
)
if d.set_owner and not _OWNER_RE.fullmatch(d.set_owner):
errors["set_owner"] = "Als „uid:gid“ angeben, z. B. „1000:1000“."
try:
d.set_mode = validate_mode("set_mode", d.set_mode) or ""
except ValidationError as exc:
errors["set_mode"] = str(exc)
if d.quarantine_path:
try:
d.quarantine_path = safe_relpath("quarantine_path", d.quarantine_path)
except ValidationError as exc:
errors["quarantine_path"] = str(exc)
# Archiv
try:
d.archive_kind = ArchiveKind(form.get("archive_kind") or "none")
d.archive_layout = ArchiveLayout(form.get("archive_layout") or "flat")
except ValueError:
errors["archive_kind"] = "Ungültige Auswahl."
d.archive_path = (form.get("archive_path") or "").strip()
if d.archive_kind is ArchiveKind.MOUNT:
d.archive_mount_id = _int(form, "archive_mount_id", errors)
if d.archive_mount_id is None or s.get(Mount, d.archive_mount_id) is None:
errors["archive_mount_id"] = "Archiv-Anbindung wählen."
try:
d.archive_path = safe_relpath("archive_path", d.archive_path)
except ValidationError as exc:
errors["archive_path"] = str(exc)
if (
"archive_path" not in errors
and d.archive_mount_id == d.dst_mount_id
and d.dst_kind == "mount"
and d.archive_path == d.dst_path
):
errors["archive_path"] = "Archiv und Ziel sind derselbe Ordner."
elif d.archive_kind is ArchiveKind.LOCAL:
d.archive_mount_id = None
try:
d.archive_path = safe_abspath("archive_path", d.archive_path)
except ValidationError as exc:
errors["archive_path"] = str(exc)
if d.dst_kind == "local" and d.archive_path == d.dst_path:
errors["archive_path"] = "Archiv und Ziel sind derselbe Ordner."
else:
d.archive_mount_id, d.archive_path = None, ""
return d, errors
def save_rule(s: Session, d: RuleInput, rule_id: int | None = None) -> tuple[Rule | None, Errors]:
dup = s.scalar(select(Rule.id).where(Rule.name == d.name))
if dup is not None and dup != rule_id:
return None, {"name": "Name ist bereits vergeben."}
r = s.get(Rule, rule_id) if rule_id is not None else None
if rule_id is not None and r is None:
raise AdminError("Regel existiert nicht.")
if r is None:
r = Rule()
s.add(r)
for f in (
"name",
"enabled",
"src_mount_id",
"src_subpath",
"dst_mount_id",
"dst_path",
"pattern",
"pattern_type",
"recursive",
"interval_seconds",
"stability_seconds",
"min_age_seconds",
"max_size_mb",
"op_timeout_seconds",
"action",
"on_collision",
"dry_run",
"quarantine_after",
"archive_kind",
"archive_mount_id",
"archive_path",
"archive_layout",
):
setattr(r, f, getattr(d, f))
r.set_owner = d.set_owner or None
r.set_mode = d.set_mode or None
r.quarantine_path = d.quarantine_path or None
s.flush()
return r, {}
+97
View File
@@ -0,0 +1,97 @@
"""Verdrahtung aller Bausteine – einmal pro Prozess.
Start: Datenbank migrieren → Schlüssel laden → Anbindungen mit ``auto_mount``
wiederherstellen (Ergebnis protokollieren) → erst dann Scheduler starten.
Ende: Scheduler stoppen, Mounts **nicht** aushängen (andere könnten sie nutzen).
"""
from __future__ import annotations
from datetime import datetime
from netmover import kuma
from netmover.config import Settings
from netmover.crypto import SecretBox, load_or_create_key
from netmover.db import make_engine, make_session_factory, upgrade
from netmover.health import Health, check_health
from netmover.logging_setup import event, get_logger
from netmover.models import utcnow
from netmover.mounting.backend import MountBackend
from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.linux import LinuxMountBackend
from netmover.mounting.manager import MountManager, RestoreResult
from netmover.mover.runner import RuleRunner
from netmover.notify import Notifier
from netmover.runs import RunService
from netmover.scheduler import RuleScheduler
from netmover.settings_store import load_settings
log = get_logger(__name__)
def make_backend(settings: Settings) -> MountBackend:
if settings.mount_backend == "fake":
return FakeMountBackend(settings.data_dir / "fake")
return LinuxMountBackend(settings.run_dir)
class NetMover:
def __init__(
self,
settings: Settings,
backend: MountBackend | None = None,
notifier: Notifier | None = None,
) -> None:
self.settings = settings
settings.data_dir.mkdir(parents=True, exist_ok=True)
url = settings.effective_database_url
upgrade(url)
self.db_engine = make_engine(url)
self.session_factory = make_session_factory(self.db_engine)
self.secrets = SecretBox(load_or_create_key(settings.secret_key_path))
self.backend = backend or make_backend(settings)
self.notifier = notifier or Notifier(self.session_factory, secrets=self.secrets)
self.mounts = MountManager(
self.session_factory,
self.backend,
self.secrets,
settings.mount_base,
notifier=self.notifier.mount_event,
)
self.runner = RuleRunner(self.session_factory, self.mounts)
self.runs = RunService(self.session_factory, self.runner, self.notifier)
self.scheduler = RuleScheduler(
self.session_factory,
self.runs,
retry_mounts=self.mounts.retry_pending,
push_health=self.push_health,
)
self.started_at: datetime = utcnow()
self.restore_results: list[RestoreResult] = []
def start(self) -> None:
"""Muss im laufenden Event-Loop aufgerufen werden (AsyncIOScheduler)."""
self.started_at = utcnow()
self.restore_results = self.mounts.restore_all()
ok = sum(r.ok for r in self.restore_results)
event(log, "startup.mounts_restored", ok=ok, failed=len(self.restore_results) - ok)
self.scheduler.start()
def stop(self) -> None:
self.scheduler.shutdown()
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:
return check_health(
self.session_factory,
scheduler_running=self.scheduler.running,
last_heartbeat=self.scheduler.last_heartbeat,
service_started=self.started_at,
maintenance=load_settings(self.session_factory).maintenance,
)
+46 -4
View File
@@ -1,7 +1,5 @@
"""Kommandozeile: ``netmover migrate`` / ``netmover version``.
``netmover serve`` folgt mit dem Web-Teil (Schritt 5).
"""
"""Kommandozeile: ``netmover serve`` / ``netmover migrate`` / ``netmover version``
/ ``netmover reset-password``."""
from __future__ import annotations
@@ -22,6 +20,11 @@ def main(argv: Sequence[str] | None = None) -> int:
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("version", help="Version ausgeben")
sub.add_parser("migrate", help="Datenbank auf den neuesten Stand bringen")
sub.add_parser("serve", help="Dienst mit Weboberfläche starten")
sub.add_parser(
"reset-password",
help="Administratorpasswort löschen – beim nächsten Aufruf startet die Einrichtung",
)
args = parser.parse_args(argv)
settings = get_settings()
@@ -35,6 +38,45 @@ def main(argv: Sequence[str] | None = None) -> int:
upgrade(settings.effective_database_url)
event(log, "db.migrated", url=settings.effective_database_url)
return 0
if args.cmd == "serve":
import uvicorn
from netmover.web.app import create_app
uvicorn.run(
create_app(settings),
host=settings.host,
port=settings.port,
log_config=None, # unser strukturiertes Logging
proxy_headers=True,
forwarded_allow_ips=settings.forwarded_allow_ips,
server_header=False,
)
return 0
if args.cmd == "reset-password":
from netmover.db import make_engine, make_session_factory
from netmover.models import Setting
from netmover.web.auth import PASSWORD_KEY
upgrade(settings.effective_database_url)
engine = make_engine(settings.effective_database_url)
with make_session_factory(engine)() as s:
row = s.get(Setting, PASSWORD_KEY)
if row is not None:
s.delete(row)
# Anmeldung wieder überall einschalten und Netz-Sperren aufheben
for key, value in (
("app.auth_mode", '"always"'),
("app.trusted_networks", '""'),
("app.allowed_networks", '""'),
):
setting = s.get(Setting, key)
if setting is not None:
setting.value = value
s.commit()
engine.dispose()
print("Passwort gelöscht. Dienst neu starten; der Einrichtungscode steht im Log.")
return 0
return 2
+10
View File
@@ -25,6 +25,12 @@ class Settings(BaseSettings):
host: str = "127.0.0.1"
port: int = Field(default=8080, ge=1, le=65535)
# Hinter einem Reverse Proxy mit TLS auf true setzen (Cookie nur per HTTPS).
cookie_secure: bool = False
# IPs, deren X-Forwarded-For/-Proto übernommen wird (Reverse Proxy).
forwarded_allow_ips: str = "127.0.0.1"
session_hours: int = Field(default=12, ge=1, le=24 * 30)
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
log_format: Literal["logfmt", "json"] = "logfmt"
@@ -37,6 +43,10 @@ class Settings(BaseSettings):
def db_path(self) -> Path:
return self.data_dir / "netmover.db"
@property
def session_key_path(self) -> Path:
return self.data_dir / "session.key"
@property
def secret_key_path(self) -> Path:
return self.data_dir / "secret.key"
+308
View File
@@ -0,0 +1,308 @@
"""Zugangsdaten-Verwaltung: gespeicherte Passwörter und SSH-Schlüssel.
Passwörter (SMB und SFTP): Benutzer, Passwort, optional Domäne – einmal gepflegt,
von beliebig vielen Anbindungen genutzt. Ein neues Passwort gilt sofort für alle.
SSH-Schlüssel:
* 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"Zugangsdaten mit dem Namen „{name}“ existieren 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)))
# -- Passwörter -------------------------------------------------------------------------------
@dataclass
class PasswordInfo:
id: int
name: str
username: str
domain: str | None
created_at: datetime
used_by: list[str] = field(default_factory=list)
def _validate_login(username: str, password: str | None, domain: str) -> None:
from netmover.validation import ValidationError, validate_secret, validate_username
try:
validate_username(username)
if domain:
validate_username(domain)
if password is not None:
validate_secret(password)
except ValidationError as exc:
raise AdminError(str(exc)) from None
def create_password(
s: Session, secrets: SecretBox, name: str, username: str, password: str, domain: str = ""
) -> Credential:
name = _validate_name(s, name)
username, domain = username.strip(), domain.strip()
if not password:
raise AdminError("Passwort fehlt.")
_validate_login(username, password, domain)
cred = Credential(
name=name,
kind=CredentialKind.PASSWORD,
username=username,
domain=domain or None,
secret_enc=secrets.encrypt(password),
managed=True,
)
s.add(cred)
s.flush()
return cred
def update_password(
s: Session, secrets: SecretBox, cred_id: int, username: str, password: str, domain: str = ""
) -> Credential:
"""Benutzer/Domäne ändern; leeres Passwort = unverändert."""
cred = managed_password(s, cred_id)
username, domain = username.strip(), domain.strip()
_validate_login(username, password or None, domain)
cred.username = username
cred.domain = domain or None
if password:
cred.secret_enc = secrets.encrypt(password)
return cred
def managed_password(s: Session, cred_id: int) -> Credential:
cred = s.get(Credential, cred_id)
if cred is None or not cred.managed or cred.kind is not CredentialKind.PASSWORD:
raise AdminError("Zugangsdaten existieren nicht.")
return cred
def list_passwords(s: Session) -> list[PasswordInfo]:
rows = s.scalars(
select(Credential)
.where(Credential.managed.is_(True), Credential.kind == CredentialKind.PASSWORD)
.order_by(Credential.name)
).all()
return [
PasswordInfo(c.id, c.name, c.username, c.domain, c.created_at, _users(s, c.id))
for c in rows
]
def delete_credential(s: Session, cred_id: int) -> str:
"""Gespeicherte Zugangsdaten (Passwort oder Schlüssel) löschen – nur unbenutzt."""
cred = s.get(Credential, cred_id)
if cred is None or not cred.managed:
raise AdminError("Zugangsdaten existieren nicht.")
users = _users(s, cred_id)
if users:
raise AdminError(f"„{cred.name}“ wird noch benutzt von: {', '.join(users)}")
name = cred.name
s.delete(cred)
s.flush()
return name
def rename_credential(s: Session, cred_id: int, name: str) -> None:
cred = s.get(Credential, cred_id)
if cred is None or not cred.managed:
raise AdminError("Zugangsdaten existieren nicht.")
cred.name = _validate_name(s, name, exclude_id=cred_id)
# -- SSH-Schlüssel ------------------------------------------------------------------------------
def list_keys(s: Session) -> list[KeyInfo]:
keys = s.scalars(
select(Credential)
.where(Credential.managed.is_(True), Credential.kind == CredentialKind.SSH_KEY)
.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
+29
View File
@@ -33,6 +33,35 @@ def make_engine(url: str) -> 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]:
return sessionmaker(bind=engine, expire_on_commit=False)
+148
View File
@@ -0,0 +1,148 @@
"""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
tun hat. Geprüft wird:
* der Scheduler lebt (Herzschlag nicht älter als 3 Minuten),
* jede aktive Anbindung ist nicht im Fehlerzustand,
* jede aktive Regel hatte vor höchstens dem Dreifachen ihres Intervalls einen
erfolgreichen Lauf (Bezug: spätester von letztem Erfolg, Anlage der Regel und
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 dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.db import session_scope
from netmover.models import Mount, MountState, Rule, utcnow
STALE_FACTOR = 3
FAILURES_ALERT = 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
class Health:
ok: bool = True
problems: list[str] = field(default_factory=list)
stale_rules: set[int] = field(default_factory=set)
checks: list[Check] = field(default_factory=list)
maintenance: bool = False
def add(self, check: Check) -> None:
self.checks.append(check)
if not check.ok:
self.ok = False
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:
if not rule.enabled:
return False
reference = max(
d for d in (rule.last_success_at, rule.created_at, service_started) if d is not None
)
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(
session_factory: sessionmaker[Session],
*,
scheduler_running: bool,
last_heartbeat: datetime | None,
service_started: datetime,
now: datetime | None = None,
maintenance: bool = False,
) -> Health:
now = now or utcnow()
health = Health()
health.maintenance = maintenance
if not scheduler_running:
health.add(Check("scheduler", "Scheduler", False, "Scheduler läuft nicht"))
elif last_heartbeat is None or now - last_heartbeat > HEARTBEAT_MAX_AGE:
health.add(
Check("scheduler", "Scheduler", False, "Scheduler reagiert nicht (kein Herzschlag)")
)
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
+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
+3
View File
@@ -62,6 +62,9 @@ def setup_logging(level: str = "INFO", fmt: str = "logfmt") -> None:
lg = logging.getLogger(name)
lg.handlers[:] = []
lg.propagate = True
# APScheduler meldet jeden Job-Start auf INFO – bei 30-s-Intervallen nur Rauschen.
if level != "DEBUG":
logging.getLogger("apscheduler").setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
+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 netmover.config import get_settings
from netmover.db import make_engine
from netmover.db import make_migration_engine
from netmover.models import Base
config = context.config
@@ -33,16 +33,27 @@ def run_migrations_offline() -> None:
def run_migrations_online() -> None:
engine = make_engine(_url())
url = _url()
engine = make_migration_engine(url)
with engine.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
compare_type=True,
# alles oder nichts: auch SQLite kann DDL in einer Transaktion
transactional_ddl=True,
)
with context.begin_transaction():
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()
@@ -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")
@@ -0,0 +1,103 @@
"""archive
Revision ID: 0004
Revises: 0003
Create Date: 2026-10-12
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import netmover.models
revision: str = "0004"
down_revision: str | None = "0003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"archived_files",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("rule_id", sa.Integer(), nullable=False),
sa.Column("src_relpath", sa.String(length=2048), nullable=False),
sa.Column("size", sa.BigInteger(), nullable=False),
sa.Column("mtime_ns", sa.BigInteger(), nullable=False),
sa.Column("archive_path", sa.String(length=2048), nullable=False),
sa.Column("archived_at", netmover.models.UTCDateTime(), nullable=False),
sa.ForeignKeyConstraint(
["rule_id"],
["rules.id"],
name=op.f("fk_archived_files_rule_id_rules"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_archived_files")),
sa.UniqueConstraint(
"rule_id",
"src_relpath",
"size",
"mtime_ns",
name=op.f("uq_archived_files_rule_id_src_relpath_size_mtime_ns"),
),
)
with op.batch_alter_table("rules", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"archive_kind",
sa.Enum(
"none",
"mount",
"local",
name="archive_kind",
native_enum=False,
create_constraint=True,
length=24,
),
server_default="none",
nullable=False,
)
)
batch_op.add_column(sa.Column("archive_mount_id", sa.Integer(), nullable=True))
batch_op.add_column(
sa.Column("archive_path", sa.String(length=1024), server_default="", nullable=False)
)
batch_op.add_column(
sa.Column(
"archive_layout",
sa.Enum(
"flat",
"date",
name="archive_layout",
native_enum=False,
create_constraint=True,
length=24,
),
server_default="flat",
nullable=False,
)
)
batch_op.create_foreign_key(
batch_op.f("fk_rules_archive_mount_id_mounts"),
"mounts",
["archive_mount_id"],
["id"],
ondelete="RESTRICT",
)
def downgrade() -> None:
with op.batch_alter_table("rules", schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f("fk_rules_archive_mount_id_mounts"), type_="foreignkey")
# CHECK-Regeln der Auswahlfelder zuerst entfernen – sonst verweisen sie beim
# Neuaufbau der Tabelle auf Spalten, die es nicht mehr gibt.
batch_op.drop_constraint(batch_op.f("ck_rules_archive_kind"), type_="check")
batch_op.drop_constraint(batch_op.f("ck_rules_archive_layout"), type_="check")
batch_op.drop_column("archive_layout")
batch_op.drop_column("archive_path")
batch_op.drop_column("archive_mount_id")
batch_op.drop_column("archive_kind")
op.drop_table("archived_files")
+56
View File
@@ -7,6 +7,12 @@ Abweichungen bzw. Ergänzungen gegenüber dem Mindestumfang aus der Spezifikatio
(beim ersten Test festgehaltener Hostschlüssel, danach verbindlich).
* ``credentials.kind`` (``password`` | ``ssh_key``) und ``credentials.public_key``.
* ``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.archive_*`` + Tabelle ``archived_files`` (Migration 0004) – Archivkopie.
* ``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.write_ok`` / ``mounts.root_squash`` / ``mounts.last_test_*`` – Ergebnis
des letzten Mount-Tests; ``root_squash`` entscheidet, ob ``chown`` auf NFS-Zielen
@@ -38,6 +44,7 @@ from sqlalchemy import (
TypeDecorator,
UniqueConstraint,
)
from sqlalchemy import false as sa_false
from sqlalchemy.engine import Dialect
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -132,6 +139,17 @@ class CollisionPolicy(enum.StrEnum):
OVERWRITE = "overwrite"
class ArchiveKind(enum.StrEnum):
NONE = "none"
MOUNT = "mount"
LOCAL = "local"
class ArchiveLayout(enum.StrEnum):
FLAT = "flat" # wie in der Quelle
DATE = "date" # Jahr/Monat/…, nach Tag der Verarbeitung
class RunStatus(enum.StrEnum):
RUNNING = "running"
OK = "ok" # keine Dateifehler
@@ -166,6 +184,9 @@ class Credential(Base):
domain: Mapped[str | None] = mapped_column(String(255))
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
# 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)
# passive_deletes="all": das ORM soll cred_id nicht still auf NULL setzen,
@@ -183,6 +204,9 @@ class Mount(Base):
server: Mapped[str] = mapped_column(String(255))
share: Mapped[str] = mapped_column(String(1024))
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>"
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"))
@@ -247,13 +271,29 @@ class Rule(Base):
quarantine_path: Mapped[str | None] = mapped_column(String(1024))
quarantine_after: Mapped[int] = mapped_column(Integer, default=3)
# Archiv (Migration 0004): zusätzliche Kopie; Quelle erst löschen, wenn Ziel UND Archiv da
archive_kind: Mapped[ArchiveKind] = mapped_column(
_enum(ArchiveKind, "archive_kind"), default=ArchiveKind.NONE, server_default="none"
)
archive_mount_id: Mapped[int | None] = mapped_column(
ForeignKey("mounts.id", ondelete="RESTRICT")
)
archive_path: Mapped[str] = mapped_column(String(1024), default="", server_default="")
archive_layout: Mapped[ArchiveLayout] = mapped_column(
_enum(ArchiveLayout, "archive_layout"), default=ArchiveLayout.FLAT, server_default="flat"
)
last_run_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)
# 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)
src_mount: Mapped[Mount] = relationship(foreign_keys=[src_mount_id])
dst_mount: Mapped[Mount | None] = relationship(foreign_keys=[dst_mount_id])
archive_mount: Mapped[Mount | None] = relationship(foreign_keys=[archive_mount_id])
runs: Mapped[list[Run]] = relationship(
back_populates="rule", cascade="all, delete-orphan", passive_deletes=True
)
@@ -315,6 +355,22 @@ class CopiedFile(Base):
copied_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class ArchivedFile(Base):
"""Welche Dateiversion schon im Archiv liegt – damit ein Neuversuch nach einem
gescheiterten Ziel keine zweite Archivkopie anlegt."""
__tablename__ = "archived_files"
__table_args__ = (UniqueConstraint("rule_id", "src_relpath", "size", "mtime_ns"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
rule_id: Mapped[int] = mapped_column(ForeignKey("rules.id", ondelete="CASCADE"))
src_relpath: Mapped[str] = mapped_column(String(2048))
size: Mapped[int] = mapped_column(BigInteger)
mtime_ns: Mapped[int] = mapped_column(BigInteger)
archive_path: Mapped[str] = mapped_column(String(2048))
archived_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class FileFailure(Base):
"""Fehlversuche pro Datei, Grundlage für die Quarantäne."""
+32 -1
View File
@@ -8,7 +8,9 @@ Optionszeile – die stünde sonst in der Prozessliste.
from __future__ import annotations
import contextlib
import os
import signal
import subprocess
import uuid
from collections.abc import Callable, Sequence
@@ -24,6 +26,28 @@ log = get_logger(__name__)
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
def run_process_group(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
"""Wie ``subprocess.run``, aber in eigener Prozessgruppe: Bei Zeitüberschreitung
wird die ganze Gruppe beendet. ``mount`` startet Hilfsprogramme wie
``mount.nfs``; würde nur ``mount`` beendet, liefe ``mount.nfs`` weiter und
hängte womöglich Minuten später doch noch ein (auf der Test-VM beobachtet)."""
timeout = kwargs.pop("timeout", None)
kwargs.pop("check", None)
capture = kwargs.pop("capture_output", False)
if capture:
kwargs["stdout"] = kwargs["stderr"] = subprocess.PIPE
with subprocess.Popen(argv, start_new_session=True, **kwargs) as proc:
try:
out, err = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
with contextlib.suppress(ProcessLookupError):
os.killpg(proc.pid, signal.SIGKILL)
proc.communicate()
raise
return subprocess.CompletedProcess(argv, proc.returncode, out, err)
_ENV = {"LC_ALL": "C", "PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}
@@ -34,6 +58,13 @@ def explain_mount_error(fstype: str, message: str) -> str:
if "helper program" in low or "unknown filesystem type" in low:
pkg = "cifs-utils" if fstype == "cifs" else "nfs-common"
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":
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:
@@ -60,7 +91,7 @@ class LinuxMountBackend(MountBackend):
self,
run_dir: Path,
mountinfo_path: Path = MOUNTINFO,
runner: Runner = subprocess.run,
runner: Runner = run_process_group,
) -> None:
self.run_dir = run_dir
self.mountinfo_path = mountinfo_path
+128 -10
View File
@@ -15,7 +15,9 @@ Fallstricke:
from __future__ import annotations
import contextlib
import errno
import math
import os
import threading
import time
@@ -30,7 +32,7 @@ from sqlalchemy.orm import Session, sessionmaker
from netmover.crypto import SecretBox, redact
from netmover.db import session_scope
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.report import CheckReport, assess_write
from netmover.mounting.sftp import SftpConfig, SftpError, check_connection, connect
@@ -104,6 +106,13 @@ class RestoreResult:
message: str
def _redact_output(message: str, secrets: Sequence[str]) -> str:
"""Schwärzt nur die Ausgabe von ``mount``, nicht den angehängten Hinweistext
(sonst würde z. B. das Passwort „falsch“ auch im Hinweis geschwärzt)."""
output, sep, hint = message.partition("\n→ ")
return redact(output, secrets) + sep + hint
def build_spec(cfg: MountConfig, mountpoint: Path, cred: CredentialData | None) -> MountSpec:
"""Baut den ``mount``-Aufruf. Validiert erneut (Verteidigung in der Tiefe) –
auch Daten aus der Datenbank laufen hier noch einmal durch die Prüfung."""
@@ -166,6 +175,8 @@ class MountManager:
sleep: Callable[[float], None] = time.sleep,
euid: Callable[[], int] = os.geteuid,
notifier: Notifier | None = None,
retry_interval: float = 60,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.session_factory = session_factory
self.backend = backend
@@ -177,6 +188,12 @@ class MountManager:
self._sleep = sleep
self._euid = euid
self._notifier = notifier
self.retry_interval = retry_interval
self._clock = clock
# Mounts, die NetMover nach gescheiterter Wiederherstellung selbst ausgehängt
# hat: nächster erlaubter Versuch (monotone Zeit). Nur diese werden
# automatisch neu eingehängt – von außen ausgehängte nie (Fallstrick 2).
self._retry_at: dict[int, float] = {}
self._locks: dict[int, threading.RLock] = {}
self._locks_guard = threading.Lock()
@@ -186,13 +203,32 @@ class MountManager:
with self._locks_guard:
return self._locks.setdefault(mount_id, threading.RLock())
def _notify(self, kind: str, name: str, message: str) -> None:
if self._notifier is not None:
def _notify(self, kind: str, name: str, message: str, mount_id: int | None = None) -> 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:
self._notifier(kind, name, message)
except Exception:
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:
if slugify(slug) != slug:
raise ValidationError("slug", f"Ungültiger Slug: {slug!r}")
@@ -211,7 +247,7 @@ class MountManager:
if m.credential is not None:
c = m.credential
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)
return m, cred
@@ -267,6 +303,7 @@ class MountManager:
def mount(self, mount_id: int) -> None:
with self._lock(mount_id):
self._retry_at.pop(mount_id, None)
m, cred = self._load(mount_id)
if not m.type.is_kernel_mount:
raise MountError("SFTP-Anbindungen werden nicht eingehängt, nur getestet")
@@ -292,6 +329,7 @@ class MountManager:
def unmount(self, mount_id: int) -> None:
with self._lock(mount_id):
self._retry_at.pop(mount_id, None)
m, _ = self._load(mount_id)
if not m.type.is_kernel_mount:
self._set_state(mount_id, MountState.UNMOUNTED)
@@ -325,8 +363,10 @@ class MountManager:
self.mount(mount_id)
results.append(RestoreResult(name, True, "eingehängt"))
except (MountError, ValidationError, LookupError) as exc:
# z. B. Stromausfall: die VM bootet schneller als das NAS
self._retry_at[mount_id] = self._clock() + self.retry_interval
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:
event(
log,
@@ -391,7 +431,7 @@ class MountManager:
self.backend.mount(build_spec(cfg, temp, cred), timeout=self.mount_timeout)
except (MountError, ValidationError) as exc:
self._remove_mountpoint(temp)
report.step("mount", False, redact(str(exc), secrets))
report.step("mount", False, _redact_output(str(exc), secrets))
report.step("read", None)
report.step("write", None)
return report
@@ -486,6 +526,11 @@ class MountManager:
if cfg.type is MountType.SFTP and m.host_key is None and report.can_read:
m.host_key = report.host_key
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 --------------------------------------
@@ -501,7 +546,12 @@ class MountManager:
return None
mp = self.mountpoint_for(m.slug)
if not self.backend.is_mounted(mp):
if mount_id in self._retry_at:
self._retry_mount(mount_id, m, cred, mp)
return mp
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)
raise MountUnavailable(msg)
try:
@@ -534,8 +584,10 @@ class MountManager:
msg = f"„{m.name}“ nicht erreichbar: {exc}"
self._set_state(mount_id, MountState.ERROR, msg)
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
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:
self._set_state(mount_id, MountState.MOUNTED)
@@ -568,11 +620,75 @@ class MountManager:
f"„{m.name}“ gestört ({reason}); Neu-Einhängen nach "
f"{len(self.recover_backoff)} Versuchen gescheitert: {last}"
)
if m.auto_mount:
self._retry_at[mount_id] = self._clock() + self.retry_interval
msg += f"\n→ Neuer Versuch automatisch alle {self.retry_interval:g} s."
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)
def usage(self, mount_id: int) -> tuple[int, int] | None:
def retry_pending(self) -> list[str]:
"""Fällige Neuversuche ausführen (Scheduler-Job, jede Minute) – auch für
Anbindungen, die gerade keine Regel benutzt. Liefert die zurückgeholten Namen."""
recovered: list[str] = []
for mount_id in list(self._retry_at):
if self._retry_at.get(mount_id, float("inf")) > self._clock():
continue
with self._lock(mount_id):
if mount_id not in self._retry_at:
continue
try:
m, cred = self._load(mount_id)
except LookupError:
self._retry_at.pop(mount_id, None)
continue
if not (m.enabled and m.auto_mount and m.type.is_kernel_mount):
self._retry_at.pop(mount_id, None)
continue
mp = self.mountpoint_for(m.slug)
if self.backend.is_mounted(mp):
self._retry_at.pop(mount_id, None)
continue
try:
self._retry_mount(mount_id, m, cred, mp)
recovered.append(m.name)
except MountUnavailable:
pass
return recovered
def _retry_mount(self, mount_id: int, m: Mount, cred: CredentialData | None, mp: Path) -> None:
"""Ein Versuch, einen von NetMover selbst ausgehängten Mount zurückzuholen –
höchstens einmal pro ``retry_interval``, damit kein Lauf alle 10 s hängt."""
wait = self._retry_at[mount_id] - self._clock()
if wait > 0:
raise MountUnavailable(
f"„{m.name}“ nicht verfügbar – nächster Versuch, neu einzuhängen, "
f"in {math.ceil(wait)} s. Lauf abgebrochen, nichts angefasst."
)
try:
self._prepare_mountpoint(mp)
self.backend.mount(
build_spec(MountConfig.from_model(m), mp, cred), timeout=self.mount_timeout
)
self._probe(m.name, mp)
except (MountError, OSError, OperationTimeout, ValidationError) as exc:
if self.backend.is_mounted(mp):
with contextlib.suppress(MountError):
self.backend.unmount(mp, lazy=True, timeout=self.mount_timeout)
self._retry_at[mount_id] = self._clock() + self.retry_interval
msg = (
f"„{m.name}“ weiterhin nicht verfügbar: {exc}\n"
f"→ Neuer Versuch in {self.retry_interval:g} s."
)
self._set_state(mount_id, MountState.ERROR, msg)
event(log, "mount.retry_failed", level=30, mount=m.name, error=str(exc))
raise MountUnavailable(msg) from None
del self._retry_at[mount_id]
self._set_state(mount_id, MountState.MOUNTED, mounted=True)
event(log, "mount.recovered", mount=m.name, attempt="retry")
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:
"""(gesamt, frei) in Bytes – nur bei eingehängten Kernel-Mounts."""
m, _ = self._load(mount_id)
if not m.type.is_kernel_mount:
@@ -581,7 +697,9 @@ class MountManager:
if not self.backend.is_mounted(mp):
return None
try:
st = run_with_timeout(lambda: os.statvfs(mp), self.probe_timeout, f"statvfs:{m.name}")
st = run_with_timeout(
lambda: os.statvfs(mp), timeout or self.probe_timeout, f"statvfs:{m.name}"
)
except (OSError, OperationTimeout):
return None
return st.f_blocks * st.f_frsize, st.f_bavail * st.f_frsize
+23 -7
View File
@@ -7,7 +7,8 @@ verlieren. Ablauf für jede Datei:
Größe prüfen,
2. Metadaten setzen (mtime, Modus, Eigentümer – Fehler sind nur Hinweise),
3. prüfen, dass sich die Quelle seit der Stabilitätsmessung nicht verändert hat,
4. atomar auf den Endnamen umbenennen – ohne zu überschreiben (``link`` bzw.
4. atomar auf den Endnamen umbenennen – ohne zu überschreiben
(``renameat2(RENAME_NOREPLACE)``, sonst ``link`` + ``unlink`` bzw.
SFTP-``rename``), außer „überschreiben“ ist ausdrücklich eingestellt,
5. erst danach löscht der Aufrufer die Quelle.
@@ -29,11 +30,13 @@ from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from typing import Any, Literal, Protocol
from netmover.models import CollisionPolicy
from netmover.mounting.sftp import SftpSession
from netmover.mover.fsops import rename_noreplace
from netmover.mover.naming import collision_candidates
from netmover.mover.sources import network_errors
from netmover.timeouts import CancelToken
CHUNK = 1024 * 1024
@@ -79,6 +82,13 @@ class CommitResult:
Verify = Callable[[], None]
class Readable(Protocol):
"""Quelle einer Übertragung: ein lokaler ``Path`` oder eine Datei einer
:class:`~netmover.mover.sources.Source` (über einen kleinen Adapter)."""
def open(self, mode: str = ...) -> Any: ...
class Destination(ABC):
kind: FsKind
@@ -105,7 +115,7 @@ class Destination(ABC):
def _ensure_parent(self, rel: str) -> None: ...
@abstractmethod
def _upload(self, src: Path, tmp: str, size: int, token: CancelToken) -> list[str]: ...
def _upload(self, src: Readable, tmp: str, size: int, token: CancelToken) -> list[str]: ...
@abstractmethod
def _apply_meta(self, rel: str, meta: MetaOptions) -> list[str]: ...
@@ -138,7 +148,7 @@ class Destination(ABC):
def commit(
self,
src: Path,
src: Readable,
size: int,
rel: str,
policy: CollisionPolicy,
@@ -232,7 +242,7 @@ class LocalDestination(Destination):
def _ensure_parent(self, rel: str) -> None:
self._p(rel).parent.mkdir(mode=0o755, parents=True, exist_ok=True)
def _upload(self, src: Path, tmp: str, size: int, token: CancelToken) -> list[str]:
def _upload(self, src: Readable, tmp: str, size: int, token: CancelToken) -> list[str]:
written = 0
fd = os.open(self._p(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
@@ -283,6 +293,9 @@ class LocalDestination(Destination):
def _link_noreplace(self, tmp: str, final: str) -> None:
src, dst = self._p(tmp), self._p(final)
# Bevorzugt ein echtes rename ohne Überschreiben (Watcher sehen IN_MOVED_TO).
if rename_noreplace(src, dst):
return
try:
os.link(src, dst)
except FileExistsError:
@@ -340,6 +353,9 @@ class LocalDestination(Destination):
raise CollisionSkip
continue
try:
if rename_noreplace(src, self._p(cand)):
final = cand
break
os.link(src, self._p(cand))
except FileExistsError:
if policy is CollisionPolicy.SKIP:
@@ -421,10 +437,10 @@ class SftpDestination(Destination):
except FileNotFoundError:
self.client.mkdir(current, mode=0o755)
def _upload(self, src: Path, tmp: str, size: int, token: CancelToken) -> list[str]:
def _upload(self, src: Readable, tmp: str, size: int, token: CancelToken) -> list[str]:
notes: list[str] = []
written = 0
with self.client.open(self._p(tmp), "wb") as fh, src.open("rb") as fin:
with network_errors(), self.client.open(self._p(tmp), "wb") as fh, src.open("rb") as fin:
fh.set_pipelined(True)
while chunk := fin.read(CHUNK // 4):
token.check()
+165 -51
View File
@@ -13,13 +13,15 @@ Reihenfolge pro Lauf:
from __future__ import annotations
import dataclasses
import errno
import os
import stat
import posixpath
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
from netmover.logging_setup import event, get_logger
from netmover.models import (
@@ -39,7 +41,8 @@ from netmover.mover.destinations import (
)
from netmover.mover.ledger import FailureInfo, Ledger
from netmover.mover.naming import collision_candidates
from netmover.mover.scan import Candidate, FileState, Matcher, current_state, scan
from netmover.mover.scan import BUSY, Candidate, FileState, Matcher
from netmover.mover.sources import LocalSource, Source, SourceMissing
from netmover.timeouts import Cancelled, CancelToken
log = get_logger(__name__)
@@ -70,6 +73,7 @@ class RuleSnapshot:
set_mode: int | None = None
quarantine_after: int = 3
dst_root_squash: bool | None = None # nur NFS-Ziele: Ergebnis des Schreibtests
archive_layout: str = "flat" # "flat" | "date" (Jahr/Monat/…)
@dataclass
@@ -91,6 +95,8 @@ class RunResult:
failed: int = 0
message: str | None = None
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:
self.items.append(item)
@@ -111,6 +117,17 @@ def _mb(n: int) -> str:
return f"{n / 1024 / 1024:.1f} MB"
@dataclass(frozen=True)
class _SourceFile:
"""Adapter: eine Datei einer :class:`Source` mit ``open()`` wie ein ``Path``."""
source: Source
rel: str
def open(self, mode: str = "rb") -> Any:
return self.source.open(self.rel)
class Engine:
def __init__(
self,
@@ -137,20 +154,28 @@ class Engine:
def execute(
self,
rule: RuleSnapshot,
src_root: Path,
source: Source | Path,
dest: Destination,
token: CancelToken,
*,
dry_run: bool = False,
quarantine_rel: str | None = None,
quarantine_root: Path | None = None,
archive: Destination | None = None,
) -> RunResult:
"""``source``: eine :class:`Source` oder (Kurzform) ein lokales Verzeichnis.
Quarantäne relativ zur Wurzel der Anbindung (``quarantine_rel``) oder – bei
der Kurzform – als Pfad innerhalb des Verzeichnisses (``quarantine_root``)."""
result = RunResult(dry_run=dry_run)
src = source if isinstance(source, Source) else LocalSource(source)
if quarantine_root is not None and isinstance(src, LocalSource):
quarantine_rel = str(quarantine_root.relative_to(src.root))
try:
self._execute(rule, src_root, dest, token, result, dry_run, quarantine_root)
self._execute(rule, src, dest, token, result, dry_run, quarantine_rel, archive)
except Cancelled:
result.status = RunStatus.ERROR
result.message = "Lauf abgebrochen"
except RunAborted as exc:
except (RunAborted, SourceMissing) as exc:
result.status = RunStatus.ERROR
result.message = str(exc)
except TransferError as exc:
@@ -167,34 +192,38 @@ class Engine:
def _execute(
self,
rule: RuleSnapshot,
src_root: Path,
src: Source,
dest: Destination,
token: CancelToken,
result: RunResult,
dry_run: bool,
quarantine_root: Path | None,
quarantine_rel: str | None,
archive: Destination | None = None,
) -> None:
if not src_root.is_dir():
raise RunAborted(f"Quellordner {src_root} existiert nicht")
src.check_root()
if not dry_run:
dest.prepare()
if archive is not None:
archive.prepare()
token.check()
exclude: set[Path] = set()
if quarantine_root is not None:
exclude.add(quarantine_root.resolve())
if isinstance(dest, LocalDestination):
exclude.add(dest.root.resolve()) # Ziel innerhalb der Quelle nie erneut scannen
exclude = frozenset({quarantine_rel}) if quarantine_rel else frozenset()
if isinstance(dest, LocalDestination) and isinstance(src, LocalSource):
src.exclude_paths.add(dest.root.resolve()) # Ziel innerhalb der Quelle auslassen
matcher = Matcher(rule.pattern, rule.pattern_type)
candidates = list(scan(src_root, matcher, rule.recursive, frozenset(exclude)))
candidates = src.scan(matcher, rule.recursive, exclude)
result.seen = len(candidates)
token.check()
# Kopier-Buchführung (Fallstrick 11). Gilt auch für „verschieben“, falls
# dort einmal die Quelle nicht gelöscht werden konnte.
done = self.ledger.copied_keys(rule.id)
archived = self.ledger.archived_keys(rule.id) if archive is not None else set()
if not dry_run:
self.ledger.prune_copied(rule.id, {c.relpath for c in candidates})
present = {c.relpath for c in candidates}
self.ledger.prune_copied(rule.id, present)
if archive is not None:
self.ledger.prune_archived(rule.id, present)
fresh = [c for c in candidates if (c.relpath, c.state.size, c.state.mtime_ns) not in done]
unchanged = len(candidates) - len(fresh)
@@ -222,31 +251,41 @@ class Engine:
else:
pending.append(c)
# Stabilität (Fallstrick 1): zweite Messung nach dem Stabilitätsfenster.
# Stabilität (Fallstrick 1): zwei frische Messungen (öffnen + fstat, nicht
# der Verzeichnis-Cache) im Abstand des Stabilitätsfensters.
first: dict[str, FileState | None] = {c.relpath: src.state(c.relpath) for c in pending}
if pending and rule.stability_seconds > 0:
self._sleep(rule.stability_seconds)
token.check()
stable: list[Candidate] = []
for c in pending:
st = current_state(c.path)
if st is None:
before, st = first[c.relpath], src.state(c.relpath)
if before is None or st is None:
continue # inzwischen verschwunden – nichts zu tun
if st != c.state:
if st != before or st == BUSY:
result.add(
ItemResult(
c.relpath,
ItemStatus.SKIPPED,
size=st.size,
message="wird noch geschrieben (Größe/Zeit geändert)",
size=max(st.size, 0),
message="wird noch geschrieben (Größe/Zeit geändert)"
if st != BUSY
else "Datei ist gerade in Benutzung",
)
)
continue
stable.append(c)
# ab hier gilt der frisch gemessene Zustand (für Prüfung vor dem Umbenennen
# und für die Kopier-Buchführung)
stable.append(dataclasses.replace(c, state=st))
failures = self.ledger.failures(rule.id)
for c in stable:
token.check()
result.add(self._process(rule, c, dest, token, dry_run, quarantine_root, failures))
result.add(
self._process(
rule, c, src, dest, token, dry_run, quarantine_rel, failures, archive, archived
)
)
if unchanged:
result.message = f"{unchanged} bereits übertragen"
@@ -254,9 +293,9 @@ class Engine:
# -- einzelne Datei ------------------------------------------------------------------
def _meta(
self, rule: RuleSnapshot, path: Path, dest: Destination
self, rule: RuleSnapshot, src: Source, rel: str, dest: Destination
) -> tuple[MetaOptions, list[str]]:
st = os.stat(path)
st = src.stat(rel)
notes: list[str] = []
owner = rule.set_owner
if owner is not None:
@@ -271,25 +310,32 @@ class Engine:
if self._warn_once(f"{rule.id}:nfs-chown"):
reason = "Root-Squash aktiv" if rule.dst_root_squash else "Schreibtest fehlt"
notes.append(f"NFS-Ziel: chown übersprungen ({reason})")
mode = rule.set_mode if rule.set_mode is not None else stat.S_IMODE(st.st_mode)
return MetaOptions(st.st_atime_ns, st.st_mtime_ns, mode, owner), notes
mode = rule.set_mode if rule.set_mode is not None else st.mode
return MetaOptions(st.atime_ns, st.mtime_ns, mode, owner), notes
def _process(
self,
rule: RuleSnapshot,
c: Candidate,
src: Source,
dest: Destination,
token: CancelToken,
dry_run: bool,
quarantine_root: Path | None,
quarantine_rel: str | None,
failures: dict[str, FailureInfo],
archive: Destination | None = None,
archived: set[tuple[str, int, int]] | None = None,
) -> ItemResult:
size = c.state.size
arch_rel = self._archive_rel(c.relpath, rule.archive_layout)
need_archive = archive is not None and (
(c.relpath, size, c.state.mtime_ns) not in (archived or set())
)
failure = failures.get(c.relpath)
# Quarantäne für wiederholt scheiternde Dateien
if (
quarantine_root is not None
quarantine_rel is not None
and failure is not None
and failure.attempts >= rule.quarantine_after
and (failure.size, failure.mtime_ns) == (c.state.size, c.state.mtime_ns)
@@ -298,11 +344,11 @@ class Engine:
return ItemResult(
c.relpath,
ItemStatus.PLANNED,
str(quarantine_root / c.relpath),
src.describe_root_rel(posixpath.join(quarantine_rel, c.relpath)),
size,
"würde in Quarantäne verschoben",
)
return self._quarantine(rule, c, quarantine_root, failure.attempts)
return self._quarantine(rule, c, src, quarantine_rel, failure.attempts)
try:
planned = dest.plan(c.relpath, rule.on_collision)
@@ -323,9 +369,14 @@ class Engine:
else None
)
verb = "kopieren" if rule.action is RuleAction.COPY else "verschieben"
return ItemResult(
c.relpath, ItemStatus.PLANNED, dest.describe(planned), size, msg or f"würde {verb}"
)
msg = msg or f"würde {verb}"
if archive is not None:
if need_archive:
arch_planned = archive.plan(arch_rel, CollisionPolicy.RENAME)
msg += f"; Archiv: {archive.describe(arch_planned or arch_rel)}"
else:
msg += "; bereits archiviert"
return ItemResult(c.relpath, ItemStatus.PLANNED, dest.describe(planned), size, msg)
try:
free = dest.free_bytes()
@@ -341,17 +392,67 @@ class Engine:
f"benötigt {_mb(int(size * SPACE_RESERVE))})",
)
if need_archive and archive is not None:
try:
afree = archive.free_bytes()
except OSError:
afree = None
if afree is not None and afree < size * SPACE_RESERVE:
return ItemResult(
c.relpath,
ItemStatus.FAILED,
archive.describe(arch_rel),
size,
f"zu wenig Platz im Archiv (frei {_mb(afree)}, "
f"benötigt {_mb(int(size * SPACE_RESERVE))})",
)
def verify() -> None:
if current_state(c.path) != c.state:
if src.state(c.relpath) != c.state:
raise SourceChanged("Quelle hat sich während der Übertragung geändert")
# Archiv ZUERST: scheitert es, ist noch nichts ans Ziel (z. B. Paperless)
# geliefert. Scheitert danach das Ziel, verhindert die Buchführung eine
# zweite Archivkopie beim nächsten Versuch.
archive_note: str | None = None
if need_archive and archive is not None:
try:
meta, notes = self._meta(rule, c.path, dest)
meta, _ = self._meta(rule, src, c.relpath, dest)
ares = archive.commit(
_SourceFile(src, c.relpath),
size,
arch_rel,
CollisionPolicy.RENAME,
MetaOptions(meta.atime_ns, meta.mtime_ns, meta.mode, None),
verify,
token,
)
except CollisionSkip: # kann bei RENAME nicht auftreten
return ItemResult(c.relpath, ItemStatus.SKIPPED, None, size, "Archiv belegt")
except SourceChanged as exc:
return ItemResult(c.relpath, ItemStatus.SKIPPED, None, size, str(exc))
except (TransferError, OSError) as exc:
return self._fail(rule, c, exc, dry_run, prefix="Archiv: ")
archive_path = archive.describe(ares.final_rel)
self.ledger.record_archive(rule.id, c.relpath, size, c.state.mtime_ns, archive_path)
archive_note = f"archiviert: {archive_path}"
try:
meta, notes = self._meta(rule, src, c.relpath, dest)
res = None
if rule.action is RuleAction.MOVE:
res = dest.move_same_device(c.path, c.relpath, rule.on_collision, meta)
local = src.local_path(c.relpath)
if rule.action is RuleAction.MOVE and local is not None:
res = dest.move_same_device(local, c.relpath, rule.on_collision, meta)
if res is None:
res = dest.commit(c.path, size, c.relpath, rule.on_collision, meta, verify, token)
res = dest.commit(
_SourceFile(src, c.relpath),
size,
c.relpath,
rule.on_collision,
meta,
verify,
token,
)
except CollisionSkip:
return ItemResult(
c.relpath,
@@ -366,6 +467,8 @@ class Engine:
return self._fail(rule, c, exc, dry_run)
notes += res.notes
if archive_note:
notes.insert(0, archive_note)
target = dest.describe(res.final_rel)
if c.relpath in failures:
self.ledger.clear_failure(rule.id, c.relpath)
@@ -376,7 +479,7 @@ class Engine:
if not res.source_consumed:
try:
os.unlink(c.path)
src.delete(c.relpath)
except OSError as exc:
# Ziel ist vollständig – Buchführung verhindert Duplikate im nächsten Lauf.
self.ledger.record_copy(rule.id, c.relpath, size, c.state.mtime_ns, target)
@@ -390,10 +493,12 @@ class Engine:
)
return ItemResult(c.relpath, ItemStatus.MOVED, target, size, "; ".join(notes) or None)
def _fail(self, rule: RuleSnapshot, c: Candidate, exc: Exception, dry_run: bool) -> ItemResult:
def _fail(
self, rule: RuleSnapshot, c: Candidate, exc: Exception, dry_run: bool, prefix: str = ""
) -> ItemResult:
if isinstance(exc, OSError) and exc.errno in MOUNT_ERRNOS:
raise RunAborted(f"Anbindung gestört: {_describe_os_error(exc)}") from exc
msg = _describe_os_error(exc) if isinstance(exc, OSError) else str(exc)
raise RunAborted(f"{prefix}Anbindung gestört: {_describe_os_error(exc)}") from exc
msg = prefix + (_describe_os_error(exc) if isinstance(exc, OSError) else str(exc))
if not dry_run:
attempts = self.ledger.record_failure(
rule.id, c.relpath, c.state.size, c.state.mtime_ns, msg
@@ -402,16 +507,25 @@ class Engine:
event(log, "file.failed", level=30, rule=rule.name, file=c.relpath, error=msg)
return ItemResult(c.relpath, ItemStatus.FAILED, None, c.state.size, msg)
def _archive_rel(self, relpath: str, layout: str) -> str:
if layout == "date":
t = datetime.fromtimestamp(self._clock())
return f"{t:%Y}/{t:%m}/{relpath}"
return relpath
def _quarantine(
self, rule: RuleSnapshot, c: Candidate, root: Path, attempts: int
self, rule: RuleSnapshot, c: Candidate, src: Source, q_rel: str, attempts: int
) -> ItemResult:
target = ""
try:
for cand in collision_candidates(c.relpath):
target = root / cand
if os.path.lexists(target):
target = posixpath.join(q_rel, cand)
if src.exists_root_rel(target):
continue
try:
src.move_to(c.relpath, target)
except FileExistsError:
continue
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
os.rename(c.path, target)
break
else:
raise TransferError("kein freier Name im Quarantäneordner")
@@ -425,7 +539,7 @@ class Engine:
return ItemResult(
c.relpath,
ItemStatus.QUARANTINED,
str(target),
src.describe_root_rel(target),
c.state.size,
f"nach {attempts} Fehlversuchen in Quarantäne verschoben",
)
+53
View File
@@ -0,0 +1,53 @@
"""Atomares Umbenennen ohne Überschreiben.
``renameat2(..., RENAME_NOREPLACE)`` ist ein echtes ``rename`` (Watcher sehen
``IN_MOVED_TO`` wie bei ``os.replace``), scheitert aber mit ``EEXIST``, statt
eine vorhandene Datei zu ersetzen. ext4, xfs, btrfs und tmpfs können das; NFS
und CIFS meist nicht – dann liefert :func:`rename_noreplace` ``False`` und der
Aufrufer weicht auf ``link()`` + ``unlink()`` aus.
"""
from __future__ import annotations
import ctypes
import ctypes.util
import errno
import os
from collections.abc import Callable
from functools import cache
AT_FDCWD = -100
RENAME_NOREPLACE = 1
_UNSUPPORTED = frozenset({errno.EINVAL, errno.ENOSYS, errno.ENOTSUP, errno.EOPNOTSUPP, errno.EPERM})
@cache
def _renameat2() -> Callable[..., int] | None:
name = ctypes.util.find_library("c")
if name is None:
return None
libc = ctypes.CDLL(name, use_errno=True)
fn = getattr(libc, "renameat2", None)
if fn is None:
return None
fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
fn.restype = ctypes.c_int
renameat2: Callable[..., int] = fn
return renameat2
def rename_noreplace(src: str | os.PathLike[str], dst: str | os.PathLike[str]) -> bool:
"""True = umbenannt. False = vom Dateisystem nicht unterstützt (nichts passiert).
``FileExistsError``, wenn ``dst`` existiert; andere Fehler als ``OSError``."""
fn = _renameat2()
if fn is None:
return False
res = fn(AT_FDCWD, os.fsencode(src), AT_FDCWD, os.fsencode(dst), RENAME_NOREPLACE)
if res == 0:
return True
err = ctypes.get_errno()
if err == errno.EEXIST:
raise FileExistsError(err, os.strerror(err), os.fsdecode(dst))
if err in _UNSUPPORTED:
return False
raise OSError(err, os.strerror(err), os.fsdecode(src))
+48 -1
View File
@@ -14,7 +14,7 @@ from sqlalchemy import delete, select
from sqlalchemy.orm import Session, sessionmaker
from netmover.db import session_scope
from netmover.models import CopiedFile, FileFailure, utcnow
from netmover.models import ArchivedFile, CopiedFile, FileFailure, utcnow
CopyKey = tuple[str, int, int] # (relpath, size, mtime_ns)
@@ -72,6 +72,53 @@ class Ledger:
s.execute(delete(CopiedFile).where(CopiedFile.id.in_(stale)))
return len(stale)
# -- Archiv ------------------------------------------------------------------------
def archived_keys(self, rule_id: int) -> set[CopyKey]:
with session_scope(self.session_factory) as s:
rows = s.execute(
select(ArchivedFile.src_relpath, ArchivedFile.size, ArchivedFile.mtime_ns).where(
ArchivedFile.rule_id == rule_id
)
).all()
return {(r[0], r[1], r[2]) for r in rows}
def record_archive(
self, rule_id: int, relpath: str, size: int, mtime_ns: int, archive_path: str
) -> None:
with session_scope(self.session_factory) as s:
exists = s.scalar(
select(ArchivedFile.id).where(
ArchivedFile.rule_id == rule_id,
ArchivedFile.src_relpath == relpath,
ArchivedFile.size == size,
ArchivedFile.mtime_ns == mtime_ns,
)
)
if exists is None:
s.add(
ArchivedFile(
rule_id=rule_id,
src_relpath=relpath,
size=size,
mtime_ns=mtime_ns,
archive_path=archive_path,
)
)
def prune_archived(self, rule_id: int, present: set[str]) -> int:
"""Wie :meth:`prune_copied`: nur nach einem vollständigen Scan aufrufen."""
with session_scope(self.session_factory) as s:
rows = s.execute(
select(ArchivedFile.id, ArchivedFile.src_relpath).where(
ArchivedFile.rule_id == rule_id
)
).all()
stale = [r[0] for r in rows if r[1] not in present]
if stale:
s.execute(delete(ArchivedFile).where(ArchivedFile.id.in_(stale)))
return len(stale)
def failures(self, rule_id: int) -> dict[str, FailureInfo]:
with session_scope(self.session_factory) as s:
rows = s.scalars(select(FileFailure).where(FileFailure.rule_id == rule_id)).all()
+101 -31
View File
@@ -34,6 +34,7 @@ from netmover.mover.destinations import (
)
from netmover.mover.engine import Engine, RuleSnapshot, RunResult
from netmover.mover.ledger import Ledger
from netmover.mover.sources import LocalSource, SftpSource, Source
from netmover.timeouts import CancelToken, OperationTimeout, run_with_watchdog
from netmover.validation import ValidationError, safe_abspath, safe_relpath
@@ -46,6 +47,7 @@ class RulePlan:
name: str
src_mount_id: int
src_type: MountType
src_name: str
src_subpath: str
dst_mount_id: int | None
dst_type: MountType | None
@@ -54,6 +56,11 @@ class RulePlan:
quarantine_path: str | None
op_timeout: int
dry_run: bool
archive_kind: str = "none"
archive_mount_id: int | None = None
archive_type: MountType | None = None
archive_name: str | None = None
archive_path: str = ""
def parse_owner(value: str | None) -> tuple[int, int] | None:
@@ -90,12 +97,14 @@ def load_plan(session: Session, rule_id: int) -> RulePlan:
set_mode=parse_mode(r.set_mode),
quarantine_after=r.quarantine_after,
dst_root_squash=dst.root_squash if dst is not None else None,
archive_layout=r.archive_layout.value,
)
return RulePlan(
snapshot=snap,
name=r.name,
src_mount_id=r.src_mount_id,
src_type=r.src_mount.type,
src_name=r.src_mount.name,
src_subpath=safe_relpath("src_subpath", r.src_subpath),
dst_mount_id=r.dst_mount_id,
dst_type=dst.type if dst is not None else None,
@@ -106,6 +115,11 @@ def load_plan(session: Session, rule_id: int) -> RulePlan:
else None,
op_timeout=r.op_timeout_seconds,
dry_run=r.dry_run,
archive_kind=r.archive_kind.value,
archive_mount_id=r.archive_mount_id if r.archive_kind.value == "mount" else None,
archive_type=r.archive_mount.type if r.archive_mount is not None else None,
archive_name=r.archive_mount.name if r.archive_mount is not None else None,
archive_path=r.archive_path,
)
@@ -166,10 +180,6 @@ class RuleRunner:
rule_id: int,
handed_over: list[bool],
) -> RunResult:
if not plan.src_type.is_kernel_mount:
return RunResult(
RunStatus.ERROR, dry, message="SFTP-Anbindungen sind nur als Ziel möglich"
)
try:
src_mp = self.mounts.check_alive(plan.src_mount_id)
dst_mp = (
@@ -177,31 +187,64 @@ class RuleRunner:
if plan.dst_mount_id is not None
else None
)
arch_mp = (
self.mounts.check_alive(plan.archive_mount_id)
if plan.archive_mount_id is not None
else None
)
except MountUnavailable as exc:
return RunResult(RunStatus.ERROR, dry, message=str(exc))
if src_mp is None: # nur bei SFTP, oben bereits ausgeschlossen
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
quarantine_root = src_mp / plan.quarantine_path if plan.quarantine_path else None
return RunResult(RunStatus.ERROR, dry, message=str(exc), unavailable=True)
try:
make_dest = self._dest_factory(plan, dst_mp, src_root)
make_source = self._source_factory(plan, src_mp)
src_root = src_mp / plan.src_subpath if src_mp and plan.src_subpath else src_mp
make_dest = self._target(
"dst_path",
plan.dst_mount_id,
plan.dst_type,
plan.dst_name,
plan.dst_path,
dst_mp,
src_root,
)
make_archive = (
self._target(
"archive_path",
plan.archive_mount_id,
plan.archive_type,
plan.archive_name,
plan.archive_path,
arch_mp,
src_root,
)
if plan.archive_kind != "none"
else None
)
except (ValidationError, TransferError, SftpError) as exc:
return RunResult(RunStatus.ERROR, dry, message=str(exc))
def work(token: CancelToken) -> RunResult:
dest = make_dest()
opened: list[Source | Destination] = []
try:
source = make_source()
opened.append(source)
dest = make_dest()
opened.append(dest)
archive = make_archive() if make_archive is not None else None
if archive is not None:
opened.append(archive)
return self.engine.execute(
plan.snapshot,
src_root,
source,
dest,
token,
dry_run=dry,
quarantine_root=quarantine_root,
quarantine_rel=plan.quarantine_path,
archive=archive,
)
finally:
dest.close()
for item in reversed(opened):
item.close()
def finish() -> None:
self._started.pop(rule_id, None)
@@ -230,41 +273,68 @@ class RuleRunner:
paths = {path.absolute(), path.resolve()}
return any(p == b or b in p.parents for p in paths for b in bases)
def _dest_factory(
self, plan: RulePlan, dst_mp: Path | None, src_root: Path
def _source_factory(self, plan: RulePlan, src_mp: Path | None) -> Callable[[], Source]:
if plan.src_type is MountType.SFTP:
scfg = self.mounts.sftp_config_for(plan.src_mount_id)
label = plan.src_name or "sftp"
def make_sftp() -> Source:
return SftpSource(connect(scfg), scfg.base_path, plan.src_subpath, label=label)
return make_sftp
if src_mp is None:
raise TransferError("Quell-Anbindung ist nicht eingehängt")
root = src_mp
def make_local() -> Source:
return LocalSource(root, plan.src_subpath)
return make_local
def _target(
self,
field: str,
mount_id: int | None,
mount_type: MountType | None,
mount_name: str | None,
path: str,
mp: Path | None,
src_root: Path | None,
) -> Callable[[], Destination]:
if plan.dst_mount_id is None:
root = Path(safe_abspath("dst_path", plan.dst_path))
"""Ziel oder Archiv: lokaler Pfad (``mount_id`` None), SFTP oder Kernel-Mount."""
what = "Archiv" if field == "archive_path" else "Ziel"
if mount_id is None:
root = Path(safe_abspath(field, path))
if self._under_mount_base(root):
base = self.mounts.mount_base
raise ValidationError(
"dst_path",
f"Lokales Ziel liegt unter {base} – bitte die Anbindung als Ziel wählen, "
field,
f"Lokales {what} liegt unter {base} – bitte die Anbindung als {what} wählen, "
"damit geprüft wird, ob sie eingehängt ist.",
)
kind = "local"
local_root = root
create = False
elif plan.dst_type is MountType.SFTP:
scfg = self.mounts.sftp_config_for(plan.dst_mount_id)
sub = safe_relpath("dst_path", plan.dst_path)
elif mount_type is MountType.SFTP:
scfg = self.mounts.sftp_config_for(mount_id)
sub = safe_relpath(field, path)
remote = posixpath.join(scfg.base_path, sub) if sub else scfg.base_path
label = plan.dst_name or "sftp"
label = mount_name or "sftp"
def make_sftp() -> Destination:
return SftpDestination(connect(scfg), remote, label=label)
return make_sftp
else:
if dst_mp is None:
raise TransferError("Ziel-Anbindung ist nicht eingehängt")
sub = safe_relpath("dst_path", plan.dst_path)
local_root = dst_mp / sub if sub else dst_mp
kind = "cifs" if plan.dst_type is MountType.CIFS else "nfs"
if mp is None:
raise TransferError(f"{what}-Anbindung ist nicht eingehängt")
sub = safe_relpath(field, path)
local_root = mp / sub if sub else mp
kind = "cifs" if mount_type is MountType.CIFS else "nfs"
create = True
if local_root.resolve() == src_root.resolve():
raise ValidationError("dst_path", "Quelle und Ziel sind derselbe Ordner")
if src_root is not None and local_root.resolve() == src_root.resolve():
raise ValidationError(field, f"Quelle und {what} sind derselbe Ordner")
def make_local() -> Destination:
return LocalDestination(local_root, kind, create_root=create) # type: ignore[arg-type]
+23 -2
View File
@@ -10,6 +10,7 @@
from __future__ import annotations
import errno
import fnmatch
import os
import re
@@ -33,7 +34,7 @@ class FileState:
@dataclass(frozen=True)
class Candidate:
relpath: str # POSIX, relativ zum Quellordner
path: Path
path: Path | None # nur bei lokalen Quellen
state: FileState
@@ -100,11 +101,31 @@ def scan(
yield from walk(root, "")
# Datei ist geöffnet, aber gerade nicht lesbar (z. B. exklusiv vom Schreiber
# gesperrt): gilt als „noch nicht fertig“ – gleicht nie einem echten Zustand.
BUSY = FileState(-1, -1)
def current_state(path: Path) -> FileState | None:
"""Aktuelle Größe und mtime – **frisch vom Server**.
``stat()`` beantworten CIFS- und NFS-Clients aus ihrem Attribut-Cache; im
Praxistest lieferte CIFS so sekundenlang eine veraltete Größe, während die
Datei noch wuchs. Erst ``open()`` erzwingt frische Attribute (NFS:
Close-to-open-Konsistenz, SMB: Antwort auf CREATE). Darum öffnen + ``fstat``.
"""
try:
st = os.stat(path, follow_symlinks=False)
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except FileNotFoundError:
return None
except OSError as exc:
if exc.errno in (errno.ELOOP, errno.ENOTDIR):
return None # Symlink oder kein Pfad mehr
return BUSY
try:
st = os.fstat(fd)
finally:
os.close(fd)
if not stat.S_ISREG(st.st_mode):
return None
return FileState(st.st_size, st.st_mtime_ns)
+311
View File
@@ -0,0 +1,311 @@
"""Quellen: eingehängtes Verzeichnis (SMB/NFS) oder SFTP.
Die Engine greift nur über diese Schnittstelle auf die Quelle zu – scannen,
frischen Zustand messen, lesen, löschen, in die Quarantäne verschieben. So gelten
für SFTP-Quellen dieselben Garantien wie für eingehängte Freigaben.
Adressierung: ``root`` ist die Wurzel der Anbindung, ``subdir`` der Quell-
Unterordner der Regel. Kandidaten tragen Pfade relativ zu ``subdir`` (so landen sie
im Ziel und in der Buchführung); die Quarantäne liegt relativ zu ``root``.
SFTP-Besonderheit: Änderungszeiten kommen nur sekundengenau. Die Stabilitäts-
prüfung stützt sich dort vor allem auf die Größe – mit einem Fenster ab 2 s
unproblematisch.
"""
from __future__ import annotations
import contextlib
import errno
import os
import posixpath
import socket
import stat
from abc import ABC, abstractmethod
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import IO, Any
import paramiko
from netmover.mounting.sftp import SftpSession
from netmover.mover.fsops import rename_noreplace
from netmover.mover.scan import (
IGNORED_DIRS,
Candidate,
FileState,
Matcher,
current_state,
is_ignored_name,
scan,
)
_NET_ERRORS = (paramiko.SSHException, EOFError, socket.timeout, ConnectionError)
class SourceMissing(Exception):
"""Quellordner existiert nicht (Lauf abbrechen, nichts anfassen)."""
@dataclass(frozen=True)
class SourceStat:
size: int
mtime_ns: int
atime_ns: int
mode: int # Rechte-Bits
def _join(*parts: str) -> str:
return posixpath.join(*[p for p in parts if p]) if any(parts) else ""
@contextlib.contextmanager
def network_errors() -> Iterator[None]:
"""Verbindungsabbrüche bei SFTP als ``ENOTCONN`` melden – die Engine bricht den
Lauf dann ab (Anbindung gestört), statt jede Datei einzeln scheitern zu lassen."""
try:
yield
except _NET_ERRORS as exc:
raise OSError(errno.ENOTCONN, f"SFTP-Verbindung unterbrochen: {exc}") from exc
class Source(ABC):
kind: str
def __init__(self, subdir: str = "") -> None:
self.subdir = subdir.strip("/")
def full(self, rel: str) -> str:
"""Pfad relativ zur Wurzel der Anbindung."""
return _join(self.subdir, rel)
@abstractmethod
def describe(self, rel: str = "") -> str: ...
@abstractmethod
def check_root(self) -> None:
"""Wirft :class:`SourceMissing`, wenn der Quellordner fehlt."""
@abstractmethod
def scan(
self, matcher: Matcher, recursive: bool, exclude: frozenset[str] = frozenset()
) -> list[Candidate]:
"""``exclude``: Verzeichnisse relativ zur Wurzel der Anbindung."""
@abstractmethod
def state(self, rel: str) -> FileState | None:
"""Frischer Zustand; ``None`` = verschwunden, ``BUSY`` = gerade gesperrt."""
@abstractmethod
def stat(self, rel: str) -> SourceStat: ...
@abstractmethod
def open(self, rel: str) -> contextlib.AbstractContextManager[IO[bytes]]: ...
@abstractmethod
def delete(self, rel: str) -> None: ...
@abstractmethod
def exists_root_rel(self, root_rel: str) -> bool: ...
@abstractmethod
def describe_root_rel(self, root_rel: str) -> str: ...
@abstractmethod
def move_to(self, rel: str, root_rel_target: str) -> None:
"""Innerhalb der Anbindung verschieben, ohne zu überschreiben
(``FileExistsError``, wenn das Ziel existiert)."""
def local_path(self, rel: str) -> Path | None:
"""Lokaler Pfad, falls vorhanden (für den Schnellweg ohne Kopie)."""
return None
def close(self) -> None: # noqa: B027 – absichtlich optional
pass
class LocalSource(Source):
"""Eingehängte Freigabe (oder lokales Verzeichnis)."""
kind = "local"
def __init__(self, root: Path, subdir: str = "") -> None:
super().__init__(subdir)
self.root = root
self.base = root / self.subdir if self.subdir else root
# zusätzlich auszulassende Verzeichnisse, z. B. ein Ziel innerhalb der Quelle
self.exclude_paths: set[Path] = set()
def _p(self, rel: str) -> Path:
return self.base / rel
def describe(self, rel: str = "") -> str:
return str(self._p(rel)) if rel else str(self.base)
def check_root(self) -> None:
if not self.base.is_dir():
raise SourceMissing(f"Quellordner {self.base} existiert nicht")
def scan(
self, matcher: Matcher, recursive: bool, exclude: frozenset[str] = frozenset()
) -> list[Candidate]:
paths = {(self.root / e).resolve() for e in exclude} | set(self.exclude_paths)
return list(scan(self.base, matcher, recursive, frozenset(paths)))
def state(self, rel: str) -> FileState | None:
return current_state(self._p(rel))
def stat(self, rel: str) -> SourceStat:
st = os.stat(self._p(rel))
return SourceStat(st.st_size, st.st_mtime_ns, st.st_atime_ns, stat.S_IMODE(st.st_mode))
def open(self, rel: str) -> contextlib.AbstractContextManager[IO[bytes]]:
return self._p(rel).open("rb")
def delete(self, rel: str) -> None:
os.unlink(self._p(rel))
def exists_root_rel(self, root_rel: str) -> bool:
return os.path.lexists(self.root / root_rel)
def describe_root_rel(self, root_rel: str) -> str:
return str(self.root / root_rel)
def move_to(self, rel: str, root_rel_target: str) -> None:
target = self.root / root_rel_target
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
if rename_noreplace(self._p(rel), target):
return
if os.path.lexists(target):
raise FileExistsError(errno.EEXIST, "exists", str(target))
os.rename(self._p(rel), target)
def local_path(self, rel: str) -> Path:
return self._p(rel)
class SftpSource(Source):
"""Quelle auf einem SFTP-Server (z. B. ein Server, der nur per SSH erreichbar ist)."""
kind = "sftp"
def __init__(self, session: SftpSession, base: str, subdir: str = "", label: str = ""):
super().__init__(subdir)
self.session = session
self.client = session.client
self.root = base.rstrip("/") or "/"
self.label = label or "sftp"
def _p(self, rel: str) -> str:
return posixpath.join(self.root, self.full(rel)) if self.full(rel) else self.root
def describe(self, rel: str = "") -> str:
return f"{self.label}:{self._p(rel)}"
def close(self) -> None:
self.session.close()
def check_root(self) -> None:
with network_errors():
try:
attrs = self.client.stat(self._p(""))
except FileNotFoundError:
raise SourceMissing(f"Quellordner {self.describe()} existiert nicht") from None
if attrs.st_mode is None or not stat.S_ISDIR(attrs.st_mode):
raise SourceMissing(f"{self.describe()} ist kein Verzeichnis")
def scan(
self, matcher: Matcher, recursive: bool, exclude: frozenset[str] = frozenset()
) -> list[Candidate]:
excluded = {e.strip("/") for e in exclude}
found: list[Candidate] = []
def walk(rel_dir: str) -> None:
with network_errors():
entries = sorted(
self.client.listdir_attr(self._p(rel_dir)), key=lambda a: a.filename
)
for a in entries:
name = a.filename
if name.startswith(".") or a.st_mode is None:
continue
rel = _join(rel_dir, name)
if stat.S_ISDIR(a.st_mode):
if recursive and name not in IGNORED_DIRS and self.full(rel) not in excluded:
walk(rel)
continue
if not stat.S_ISREG(a.st_mode) or is_ignored_name(name):
continue # Symlinks, Geräte … und unfertige Übertragungen
if matcher(rel):
found.append(Candidate(rel, None, _state(a)))
walk("")
return found
def state(self, rel: str) -> FileState | None:
with network_errors():
try:
a = self.client.lstat(self._p(rel))
except FileNotFoundError:
return None
if a.st_mode is None or not stat.S_ISREG(a.st_mode):
return None
return _state(a)
def stat(self, rel: str) -> SourceStat:
with network_errors():
a = self.client.lstat(self._p(rel))
mtime = int(a.st_mtime or 0) * 1_000_000_000
atime = int(a.st_atime or a.st_mtime or 0) * 1_000_000_000
return SourceStat(int(a.st_size or 0), mtime, atime, stat.S_IMODE(a.st_mode or 0o644))
@contextlib.contextmanager
def _reader(self, rel: str) -> Iterator[IO[bytes]]:
with network_errors():
size = self.client.lstat(self._p(rel)).st_size
fh: Any = self.client.open(self._p(rel), "rb")
try:
fh.prefetch(size) # Größe mitgeben: spart ein fstat und liest vorab
yield fh
finally:
with contextlib.suppress(*_NET_ERRORS, OSError):
fh.close()
def open(self, rel: str) -> contextlib.AbstractContextManager[IO[bytes]]:
return self._reader(rel)
def delete(self, rel: str) -> None:
with network_errors():
self.client.remove(self._p(rel))
def exists_root_rel(self, root_rel: str) -> bool:
with network_errors():
try:
self.client.lstat(posixpath.join(self.root, root_rel))
except FileNotFoundError:
return False
return True
def describe_root_rel(self, root_rel: str) -> str:
return f"{self.label}:{posixpath.join(self.root, root_rel)}"
def move_to(self, rel: str, root_rel_target: str) -> None:
target = posixpath.join(self.root, root_rel_target)
with network_errors():
current = self.root
for part in posixpath.dirname(root_rel_target).split("/"):
if not part:
continue
current = posixpath.join(current, part)
try:
self.client.stat(current)
except FileNotFoundError:
self.client.mkdir(current, mode=0o755)
if self.exists_root_rel(root_rel_target):
raise FileExistsError(errno.EEXIST, "exists", target)
self.client.rename(self._p(rel), target) # SFTPv3-rename überschreibt nicht
def _state(a: paramiko.SFTPAttributes) -> FileState:
return FileState(int(a.st_size or 0), int(a.st_mtime or 0) * 1_000_000_000)
+172
View File
@@ -0,0 +1,172 @@
"""Benachrichtigung per generischem Webhook.
Der Body ist eine Vorlage mit ``$platzhaltern`` (``string.Template``):
``$event``, ``$title``, ``$message``, ``$rule``, ``$time``, ``$host``.
Ist der Content-Type JSON, werden die Werte JSON-maskiert eingesetzt – so
funktionieren auch eigene JSON-Formate (z. B. Gotify ``{"title": "$title", …}``).
Für ntfy reicht ``text/plain`` mit ``$message`` und der Topic-URL.
Versand im Hintergrund-Thread, damit ein langsamer Empfänger nie einen Lauf bremst.
"""
from __future__ import annotations
import json
import socket
import threading
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from string import Template
import httpx
from sqlalchemy.orm import Session, sessionmaker
from netmover.crypto import SecretBox
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
log = get_logger(__name__)
@dataclass(frozen=True)
class Notification:
event: str # z. B. "rule.failed", "rule.recovered", "mount.error"
title: str
message: str
rule: str = ""
def render(template: str, content_type: str, n: Notification, now: datetime | None = None) -> str:
values = {
"event": n.event,
"title": n.title,
"message": n.message,
"rule": n.rule,
"time": (now or datetime.now(UTC)).isoformat(timespec="seconds"),
"host": socket.gethostname(),
}
if "json" in content_type.lower():
values = {k: json.dumps(v, ensure_ascii=False)[1:-1] for k, v in values.items()}
return Template(template).safe_substitute(values)
Sender = Callable[[str, str, str], None] # (url, content_type, body)
def http_sender(url: str, content_type: str, body: str) -> None:
resp = httpx.post(
url, content=body.encode(), headers={"Content-Type": content_type}, timeout=10
)
resp.raise_for_status()
Mailer = Callable[[AppSettings, "str | None", str, str], None] # (cfg, passwort, betreff, text)
class Notifier:
"""Verteilt Benachrichtigungen auf die eingerichteten Kanäle (Webhook, E-Mail)."""
def __init__(
self,
session_factory: sessionmaker[Session],
*,
sender: Sender = http_sender,
mailer: Mailer | None = None,
secrets: SecretBox | None = None,
background: bool = True,
) -> None:
self.session_factory = session_factory
self._sender = sender
self._mailer: Mailer = mailer or smtp_send
self._secrets = secrets
self._background = background
def settings(self) -> AppSettings:
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:
cfg = settings or self.settings()
if cfg.notify_mode is NotifyMode.OFF:
return
jobs: list[tuple[str, Callable[[], None]]] = []
if cfg.webhook_url:
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:
for channel, job in jobs:
try:
job()
event(log, "notify.sent", kind=n.event, rule=n.rule, channel=channel)
except Exception as exc: # Benachrichtigung darf nie den Dienst stören
# Weder URL noch Zugangsdaten protokollieren.
event(
log,
"notify.failed",
level=30,
kind=n.event,
channel=channel,
error=type(exc).__name__,
)
if self._background:
threading.Thread(target=deliver, name="nm-notify", daemon=True).start()
else:
deliver()
def rule_result(self, rule: str, failed: bool, consecutive: int, message: str) -> None:
"""Entscheidet nach Modus, ob ein Regelergebnis gemeldet wird."""
cfg = self.settings()
if cfg.notify_mode is NotifyMode.OFF:
return
threshold = 1 if cfg.notify_mode is NotifyMode.EVERY else max(1, cfg.notify_after)
if failed:
if cfg.notify_mode is NotifyMode.EVERY or consecutive == threshold:
suffix = f" ({consecutive}× in Folge)" if consecutive > 1 else ""
self.send(
Notification(
"rule.failed",
f"NetMover: Regel „{rule}“ fehlgeschlagen{suffix}",
message,
rule,
),
cfg,
)
elif cfg.notify_recovery and consecutive >= threshold:
self.send(
Notification(
"rule.recovered",
f"NetMover: Regel „{rule}“ läuft wieder",
f"Nach {consecutive} fehlgeschlagenen Läufen wieder erfolgreich.",
rule,
),
cfg,
)
def mount_event(self, kind: str, mount: str, message: str) -> None:
"""Passt zur ``Notifier``-Signatur des :class:`MountManager`."""
title = (
f"NetMover: Anbindung „{mount}“ wieder verfügbar"
if kind == "mount.recovered"
else f"NetMover: Anbindung „{mount}“ gestört"
)
self.send(Notification(kind, title, message))
+213
View File
@@ -0,0 +1,213 @@
"""Läufe ausführen und protokollieren.
Pro Lauf eine Zeile in ``runs`` (auch für übersprungene Termine, Fallstrick 8),
die Einzeldateien in ``run_items``. Danach die Regelzähler:
* ``last_run_at`` – jeder Lauf außer übersprungenen,
* ``last_success_at`` – Status ``ok`` oder ``partial`` (das Werkzeug hat gearbeitet),
* ``consecutive_failures`` – ``error`` und ``partial`` zählen hoch, ``ok`` setzt zurück.
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 datetime import datetime, timedelta
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, sessionmaker
from netmover.db import session_scope
from netmover.logging_setup import event, get_logger
from netmover.models import FileFailure, Rule, Run, RunItem, RunStatus, RunTrigger, utcnow
from netmover.mover.engine import RunResult
from netmover.mover.runner import RuleRunner
from netmover.notify import Notifier
from netmover.settings_store import load_settings
log = get_logger(__name__)
MAX_ITEMS_PER_RUN = 2000
SUCCESS = frozenset({RunStatus.OK, RunStatus.PARTIAL})
FAILURE = frozenset({RunStatus.ERROR, RunStatus.PARTIAL})
class RunService:
def __init__(
self,
session_factory: sessionmaker[Session],
runner: RuleRunner,
notifier: Notifier | None = None,
) -> None:
self.session_factory = session_factory
self.runner = runner
self.notifier = notifier
def execute(
self,
rule_id: int,
trigger: RunTrigger = RunTrigger.SCHEDULE,
dry_run: bool | None = None,
) -> int | None:
"""Führt die Regel aus, protokolliert den Lauf und liefert die Run-ID
(None, wenn die Regel inzwischen gelöscht wurde)."""
if trigger is RunTrigger.SCHEDULE and load_settings(self.session_factory).maintenance:
return None
started = utcnow()
result = self.runner.run(rule_id, dry_run=dry_run)
finished = utcnow()
count_it = not (trigger is RunTrigger.MANUAL and dry_run)
return self.record(rule_id, trigger, result, started, finished, update_counters=count_it)
def record(
self,
rule_id: int,
trigger: RunTrigger,
result: RunResult,
started: datetime,
finished: datetime,
*,
update_counters: bool = True,
) -> int | None:
notify: tuple[str, bool, int, str] | None = None
with session_scope(self.session_factory) as s:
rule = s.get(Rule, rule_id)
if rule is None:
return None
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
if len(items) > MAX_ITEMS_PER_RUN:
extra = len(items) - MAX_ITEMS_PER_RUN
items = items[:MAX_ITEMS_PER_RUN]
note = f"{extra} weitere Dateien nicht einzeln protokolliert"
message = f"{message}; {note}" if message else note
run = Run(
rule_id=rule_id,
started_at=started,
finished_at=finished,
status=result.status,
trigger=trigger,
dry_run=result.dry_run,
seen=result.seen,
moved=result.moved,
skipped=result.skipped,
failed=result.failed,
message=message,
)
s.add(run)
s.flush()
s.add_all(
RunItem(
run_id=run.id,
src=i.src,
dst=i.dst,
status=i.status,
size=i.size,
message=i.message,
)
for i in items
)
if update_counters and result.status is not RunStatus.SKIPPED:
previous = rule.consecutive_failures
rule.last_run_at = finished
if result.status in SUCCESS:
rule.last_success_at = finished
if result.status in FAILURE:
rule.consecutive_failures += 1
notify = (rule.name, True, rule.consecutive_failures, self._summary(result))
else:
rule.consecutive_failures = 0
if previous:
notify = (rule.name, False, previous, "")
run_id = run.id
rule_name = rule.name
level = 20 if result.status in (RunStatus.OK, RunStatus.SKIPPED) else 30
if result.status is RunStatus.OK and not result.items:
level = 10 # leere Läufe alle 30 s nur auf DEBUG
event(
log,
"run.finished",
level=level,
rule=rule_name,
run=run_id,
status=result.status.value,
trigger=trigger.value,
dry_run=result.dry_run,
seen=result.seen,
moved=result.moved,
skipped=result.skipped,
failed=result.failed,
took_ms=int((finished - started).total_seconds() * 1000),
message=result.message or "",
)
if notify is not None and self.notifier is not None:
self.notifier.rule_result(*notify)
return run_id
@staticmethod
def _summary(result: RunResult) -> str:
if result.status is RunStatus.ERROR:
return result.message or "Lauf fehlgeschlagen"
failed = [i for i in result.items if i.status.value == "failed"][:5]
lines = [f"{result.failed} Datei(en) fehlgeschlagen:"]
lines += [f"• {i.src}: {i.message}" for i in failed]
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]:
"""Protokolle älter als die Aufbewahrungsfrist löschen (Items per CASCADE),
dazu Fehlerzähler, die seitdem nicht mehr angefasst wurden."""
days = load_settings(session_factory).retention_days
cutoff = (now or utcnow()) - timedelta(days=days)
with session_scope(session_factory) as s:
old_ids = list(s.scalars(select(Run.id).where(Run.started_at < cutoff)))
for i in range(0, len(old_ids), 500):
chunk = old_ids[i : i + 500]
s.execute(delete(RunItem).where(RunItem.run_id.in_(chunk)))
s.execute(delete(Run).where(Run.id.in_(chunk)))
failures = s.execute(delete(FileFailure).where(FileFailure.last_attempt_at < cutoff))
removed_failures = getattr(failures, "rowcount", 0) or 0
stats = {"runs": len(old_ids), "file_failures": removed_failures}
event(log, "cleanup.done", retention_days=days, **stats)
return stats
+171
View File
@@ -0,0 +1,171 @@
"""Zeitsteuerung mit APScheduler (``AsyncIOScheduler``).
* Ein Job pro aktiver Regel im jeweiligen Intervall. Erster Start mit kleinem
zufälligem Versatz, dazu Jitter pro Termin – damit nicht alle Regeln
gleichzeitig starten (Fallstrick 8).
* Die Läufe selbst sind blockierend und laufen in einem eigenen Thread-Pool,
nie im Event-Loop. Überlappung regelt der Lock im :class:`RuleRunner`, der den
übersprungenen Termin auch protokolliert – deshalb ``max_instances`` > 1.
* Herzschlag-Job für ``/healthz`` und stündliches Aufräumen der Protokolle.
"""
from __future__ import annotations
import asyncio
import random
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime, timedelta
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.db import session_scope
from netmover.logging_setup import event, get_logger
from netmover.models import Rule, RunTrigger, utcnow
from netmover.runs import RunService, cleanup
log = get_logger(__name__)
MAX_START_OFFSET = 10.0
HEARTBEAT_SECONDS = 30
CLEANUP_SECONDS = 3600
RETRY_SECONDS = 60
PUSH_SECONDS = 60
def _job_id(rule_id: int) -> str:
return f"rule:{rule_id}"
class RuleScheduler:
def __init__(
self,
session_factory: sessionmaker[Session],
service: RunService,
*,
retry_mounts: Callable[[], object] | None = None,
push_health: Callable[[], object] | None = None,
rng: random.Random | None = None,
workers: int = 8,
) -> None:
self.session_factory = session_factory
self.service = service
self._retry_mounts = retry_mounts
self._push_health = push_health
self._rng = rng or random.Random() # noqa: S311 – nur Startversatz
self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="nm-run")
self.scheduler = AsyncIOScheduler(
timezone=UTC,
job_defaults={"coalesce": True, "max_instances": 3},
)
self._intervals: dict[int, int] = {}
self.last_heartbeat: datetime | None = None
# -- Lebenszyklus ---------------------------------------------------------------
def start(self) -> None:
self.scheduler.start()
self.heartbeat()
self.scheduler.add_job(
self.heartbeat, IntervalTrigger(seconds=HEARTBEAT_SECONDS), id="heartbeat"
)
self.scheduler.add_job(
self._cleanup,
IntervalTrigger(seconds=CLEANUP_SECONDS, start_date=utcnow() + timedelta(minutes=1)),
id="cleanup",
)
if self._retry_mounts is not None:
self.scheduler.add_job(
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()
event(log, "scheduler.started", rules=len(self._intervals))
def shutdown(self) -> None:
if self.scheduler.running:
self.scheduler.shutdown(wait=False)
# Laufende Worker nicht abwarten: abgebrochene Übertragungen hinterlassen
# höchstens .part-Dateien, die Quelle bleibt unangetastet.
self._executor.shutdown(wait=False, cancel_futures=True)
event(log, "scheduler.stopped")
@property
def running(self) -> bool:
return bool(self.scheduler.running)
def heartbeat(self) -> None:
self.last_heartbeat = utcnow()
# -- Regeln -------------------------------------------------------------------------
def sync(self) -> None:
"""Jobs an die Regeln in der Datenbank angleichen (nach jeder Änderung)."""
with session_scope(self.session_factory) as s:
wanted = {
rid: interval
for rid, interval in s.execute(
select(Rule.id, Rule.interval_seconds).where(Rule.enabled.is_(True))
).all()
}
for rid in list(self._intervals):
if rid not in wanted:
if self.scheduler.get_job(_job_id(rid)):
self.scheduler.remove_job(_job_id(rid))
del self._intervals[rid]
for rid, interval in wanted.items():
if self._intervals.get(rid) == interval and self.scheduler.get_job(_job_id(rid)):
continue
offset = self._rng.uniform(0, min(float(interval), MAX_START_OFFSET))
trigger = IntervalTrigger(
seconds=interval,
start_date=utcnow() + timedelta(seconds=offset),
jitter=max(1, min(5, interval // 10)),
)
self.scheduler.add_job(
self._run_rule,
trigger,
args=[rid],
id=_job_id(rid),
name=f"Regel {rid}",
replace_existing=True,
misfire_grace_time=interval,
)
self._intervals[rid] = interval
def next_run(self, rule_id: int) -> datetime | None:
job = self.scheduler.get_job(_job_id(rule_id))
return job.next_run_time if job is not None else None
async def _run_rule(self, rule_id: int) -> None:
loop = asyncio.get_running_loop()
await loop.run_in_executor(
self._executor, self.service.execute, rule_id, RunTrigger.SCHEDULE
)
async def run_now(self, rule_id: int, dry_run: bool | None = None) -> int | None:
"""„Jetzt ausführen“ / „Probelauf“ aus der Oberfläche."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
self._executor, self.service.execute, rule_id, RunTrigger.MANUAL, dry_run
)
async def _retry(self) -> None:
if self._retry_mounts is not None:
loop = asyncio.get_running_loop()
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:
loop = asyncio.get_running_loop()
await loop.run_in_executor(self._executor, cleanup, self.session_factory)
+97
View File
@@ -0,0 +1,97 @@
"""Laufzeit-Einstellungen in der Tabelle ``settings`` (in der Oberfläche änderbar)."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, fields
from enum import StrEnum
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.db import session_scope
from netmover.models import Setting
class NotifyMode(StrEnum):
OFF = "off"
EVERY = "every" # bei jedem fehlgeschlagenen Lauf
AFTER_N = "after_n" # erst nach N fehlgeschlagenen Läufen in Folge
DEFAULT_TEMPLATE = (
'{"event": "$event", "title": "$title", "message": "$message", '
'"rule": "$rule", "time": "$time", "host": "$host"}'
)
@dataclass
class AppSettings:
retention_days: int = 30
webhook_url: str = ""
notify_mode: NotifyMode = NotifyMode.OFF
notify_after: int = 3
notify_recovery: bool = True
webhook_template: str = DEFAULT_TEMPLATE
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 = ""
# Anmeldung: Netze ohne Passwort (trusted_networks), Netze mit Passwort
# (allowed_networks, leer = alle übrigen); alle anderen Netze werden abgewiesen.
# auth_mode "off" = ohne Anmeldung von überall (aus 0.5.0, weiterhin gültig).
auth_mode: str = "always"
trusted_networks: str = ""
allowed_networks: str = ""
_PREFIX = "app."
def load_settings(factory: sessionmaker[Session]) -> AppSettings:
with session_scope(factory) as s:
rows = {
r.key[len(_PREFIX) :]: r.value
for r in s.scalars(select(Setting).where(Setting.key.like(f"{_PREFIX}%")))
}
result = AppSettings()
for f in fields(AppSettings):
if f.name in rows:
raw = json.loads(rows[f.name])
setattr(result, f.name, NotifyMode(raw) if f.name == "notify_mode" else raw)
return result
def save_settings(factory: sessionmaker[Session], settings: AppSettings) -> None:
with session_scope(factory) as s:
for key, value in asdict(settings).items():
row = s.get(Setting, _PREFIX + key)
encoded = json.dumps(value.value if isinstance(value, StrEnum) else value)
if row is None:
s.add(Setting(key=_PREFIX + key, value=encoded))
else:
row.value = encoded
def get_value(factory: sessionmaker[Session], key: str) -> str | None:
with session_scope(factory) as s:
row = s.get(Setting, key)
return row.value if row else None
def set_value(factory: sessionmaker[Session], key: str, value: str) -> None:
with session_scope(factory) as s:
row = s.get(Setting, key)
if row is None:
s.add(Setting(key=key, value=value))
else:
row.value = value
+205
View File
@@ -0,0 +1,205 @@
"""Export/Import der Konfiguration als JSON – ohne Zugangsdaten.
Ausgenommen sind Passwörter, private Schlüssel, Benutzernamen und die
Webhook-URL (enthält meist ein Token). Nach einem Import müssen Zugangsdaten
neu eingegeben werden. Anbindungen und Regeln werden über ihren Namen
zugeordnet; vorhandene Namen werden übersprungen, nie überschrieben.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from netmover import __version__
from netmover.models import (
ArchiveKind,
ArchiveLayout,
CollisionPolicy,
Mount,
MountState,
MountType,
PatternType,
Rule,
RuleAction,
)
from netmover.mounting.manager import MountManager
from netmover.settings_store import AppSettings, NotifyMode
from netmover.validation import (
ValidationError,
parse_extra_options,
safe_relpath,
slugify,
validate_server,
validate_share,
)
FORMAT_VERSION = 1
MOUNT_FIELDS = (
"name",
"type",
"server",
"share",
"port",
"host_key",
"uid",
"gid",
"file_mode",
"dir_mode",
"extra_options",
"auto_mount",
"enabled",
)
RULE_FIELDS = (
"name",
"enabled",
"src_subpath",
"dst_path",
"pattern",
"pattern_type",
"recursive",
"interval_seconds",
"stability_seconds",
"min_age_seconds",
"max_size_mb",
"op_timeout_seconds",
"action",
"on_collision",
"set_owner",
"set_mode",
"dry_run",
"quarantine_path",
"quarantine_after",
"archive_kind",
"archive_path",
"archive_layout",
)
SETTING_FIELDS = (
"retention_days",
"notify_mode",
"notify_after",
"notify_recovery",
"webhook_template",
"webhook_content_type",
)
def _plain(value: Any) -> Any:
return value.value if hasattr(value, "value") else value
def export_config(s: Session, settings: AppSettings) -> dict[str, Any]:
mounts = s.scalars(select(Mount).order_by(Mount.name)).all()
names = {m.id: m.name for m in mounts}
return {
"format": FORMAT_VERSION,
"netmover": __version__,
"mounts": [{f: _plain(getattr(m, f)) for f in MOUNT_FIELDS} for m in mounts],
"rules": [
{
**{f: _plain(getattr(r, f)) for f in RULE_FIELDS},
"src_mount": names[r.src_mount_id],
"dst_mount": names.get(r.dst_mount_id) if r.dst_mount_id else None,
"archive_mount": names.get(r.archive_mount_id) if r.archive_mount_id else None,
}
for r in s.scalars(select(Rule).order_by(Rule.name))
],
"settings": {f: _plain(getattr(settings, f)) for f in SETTING_FIELDS},
}
@dataclass
class ImportReport:
created: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
settings: dict[str, Any] = field(default_factory=dict)
def import_config(s: Session, mounts: MountManager, data: dict[str, Any]) -> ImportReport:
report = ImportReport()
if not isinstance(data, dict) or data.get("format") != FORMAT_VERSION:
report.errors.append("Unbekanntes Format – erwartet wird ein NetMover-Export (format 1).")
return report
for raw in data.get("mounts") or []:
name = str(raw.get("name", ""))
if s.scalar(select(Mount.id).where(Mount.name == name)) is not None:
report.skipped.append(f"Anbindung „{name}“ (existiert)")
continue
try:
mtype = MountType(raw["type"])
slug = slugify(name)
if s.scalar(select(Mount.id).where(Mount.slug == slug)) is not None:
raise ValidationError("name", "Slug bereits vergeben")
m = Mount(
name=name,
slug=slug,
type=mtype,
server=validate_server(str(raw["server"])),
share=validate_share(mtype, str(raw["share"])),
port=raw.get("port"),
host_key=raw.get("host_key"),
uid=raw.get("uid"),
gid=raw.get("gid"),
file_mode=raw.get("file_mode"),
dir_mode=raw.get("dir_mode"),
extra_options=str(raw.get("extra_options") or ""),
# ohne Zugangsdaten nicht automatisch einhängen
auto_mount=bool(raw.get("auto_mount")) and mtype is not MountType.CIFS,
enabled=bool(raw.get("enabled", True)),
state=MountState.UNMOUNTED,
mountpoint=str(mounts.mountpoint_for(slug)) if mtype.is_kernel_mount else None,
)
parse_extra_options(mtype, m.extra_options)
s.add(m)
s.flush()
report.created.append(f"Anbindung „{name}“")
except (KeyError, ValueError, ValidationError) as exc:
report.errors.append(f"Anbindung „{name}“: {exc}")
ids = {name: mid for mid, name in s.execute(select(Mount.id, Mount.name)).all()}
for raw in data.get("rules") or []:
name = str(raw.get("name", ""))
if s.scalar(select(Rule.id).where(Rule.name == name)) is not None:
report.skipped.append(f"Regel „{name}“ (existiert)")
continue
try:
src = ids[raw["src_mount"]]
dst = ids[raw["dst_mount"]] if raw.get("dst_mount") else None
arch = ids[raw["archive_mount"]] if raw.get("archive_mount") else None
r = Rule(src_mount_id=src, dst_mount_id=dst, archive_mount_id=arch)
for f in RULE_FIELDS:
if f in raw:
setattr(r, f, raw[f])
r.pattern_type = PatternType(r.pattern_type)
r.action = RuleAction(r.action)
r.on_collision = CollisionPolicy(r.on_collision)
r.archive_kind = ArchiveKind(r.archive_kind or "none")
r.archive_layout = ArchiveLayout(r.archive_layout or "flat")
r.src_subpath = safe_relpath("src_subpath", r.src_subpath or "")
if int(r.interval_seconds) < 10:
raise ValueError("Intervall < 10 s")
r.enabled = False # erst nach Eingabe der Zugangsdaten aktivieren
s.add(r)
s.flush()
report.created.append(f"Regel „{name}“ (deaktiviert)")
except KeyError as exc:
report.errors.append(f"Regel „{name}“: Anbindung {exc} fehlt")
except (ValueError, ValidationError) as exc:
report.errors.append(f"Regel „{name}“: {exc}")
if isinstance(data.get("settings"), dict):
report.settings = {f: data["settings"][f] for f in SETTING_FIELDS if f in data["settings"]}
return report
def apply_imported_settings(current: AppSettings, values: dict[str, Any]) -> AppSettings:
"""Nach dem Commit des Imports aufrufen (eigene Transaktion)."""
for key, value in values.items():
setattr(current, key, NotifyMode(value) if key == "notify_mode" else value)
return current
+1
View File
@@ -0,0 +1 @@
"""Weboberfläche: FastAPI + Jinja2 + HTMX, serverseitig gerendert."""
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
"""Anmeldung, Einrichtung beim ersten Start, CSRF und Sperre bei Fehlversuchen.
* Ein einziges Administratorkonto; Passwort-Hash (Argon2) in ``settings``.
* Erster Start: Ohne Passwort führt jede Seite zur Einrichtung. Dafür wird ein
einmaliger Einrichtungscode ins Log geschrieben – so kann nicht irgendwer im
LAN, der die Seite zuerst öffnet, das Passwort festlegen.
* Session-Cookie ``HttpOnly``, ``SameSite=Lax`` (Starlette-SessionMiddleware,
signiert). CSRF-Token pro Session, Pflicht bei jedem POST – als Formularfeld
``csrf_token`` oder Header ``X-CSRF-Token`` (HTMX).
"""
from __future__ import annotations
import hmac
import ipaddress
import re
import secrets
import threading
import time
from collections import defaultdict, deque
from pathlib import Path
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from fastapi import HTTPException, Request
from sqlalchemy.orm import Session, sessionmaker
from netmover.settings_store import get_value, set_value
PASSWORD_KEY = "auth.admin_password_hash" # noqa: S105 – Schlüsselname
MIN_PASSWORD_LENGTH = 10
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_hasher = PasswordHasher()
AUTH_ALWAYS, AUTH_TRUSTED, AUTH_OFF = "always", "trusted", "off"
AUTH_MODES = (AUTH_ALWAYS, AUTH_TRUSTED, AUTH_OFF)
def parse_networks(raw: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
"""„10.0.10.0/24, 192.168.1.5“ → Netze; ``ValueError`` mit verständlicher Meldung."""
nets = []
for part in re.split(r"[,;\s]+", raw.strip()):
if not part:
continue
try:
nets.append(ipaddress.ip_network(part, strict=False))
except ValueError:
raise ValueError(f"„{part}“ ist kein gültiges Netz (z. B. 10.0.10.0/24)") from None
return nets
ACCESS_NOPW, ACCESS_PW, ACCESS_DENY = "nopw", "pw", "deny"
def _address(host: str | None) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
if not host:
return None
try:
return ipaddress.ip_address(host)
except ValueError:
return None
def _networks_or_empty(raw: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
try:
return parse_networks(raw)
except ValueError:
return [] # gespeicherte Werte sind beim Speichern geprüft
def access_decision(host: str | None, mode: str, trusted: str, allowed: str) -> str:
"""Wie darf diese Adresse auf die Oberfläche zugreifen?
1. aus ``trusted`` (oder Modus „off“): **ohne Passwort**,
2. ``allowed`` leer oder Adresse darin: **mit Passwort**,
3. sonst: **abgewiesen**.
Hinter einem Reverse Proxy ist die Client-Adresse nur dann echt, wenn
``NETMOVER_FORWARDED_ALLOW_IPS`` den Proxy kennt – sonst sähe jede Anfrage aus,
als käme sie vom Proxy selbst."""
if mode == AUTH_OFF:
return ACCESS_NOPW
addr = _address(host)
if addr is not None and any(addr in n for n in _networks_or_empty(trusted)):
return ACCESS_NOPW
allowed_nets = _networks_or_empty(allowed)
if allowed_nets and (addr is None or not any(addr in n for n in allowed_nets)):
return ACCESS_DENY
return ACCESS_PW
def client_host(request: Request) -> str | None:
return request.client.host if request.client else None
def login_bypass(request: Request, mode: str, trusted: str, allowed: str = "") -> str | None:
"""Grund, warum keine Anmeldung nötig ist (``"off"``/``"trusted"``), sonst ``None``."""
if access_decision(client_host(request), mode, trusted, allowed) != ACCESS_NOPW:
return None
return AUTH_OFF if mode == AUTH_OFF or _covers_everything(trusted) else AUTH_TRUSTED
def _covers_everything(raw: str) -> bool:
return any(n.prefixlen == 0 for n in _networks_or_empty(raw))
def includes_loopback(raw: str) -> bool:
try:
nets = parse_networks(raw)
except ValueError:
return False
return any(
ipaddress.ip_address("127.0.0.1") in n or ipaddress.ip_address("::1") in n for n in nets
)
class LoginRequired(Exception):
def __init__(self, setup: bool = False) -> None:
self.setup = setup
def load_or_create_session_key(path: Path) -> str:
import os
path.parent.mkdir(parents=True, exist_ok=True)
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
return path.read_text().strip()
key = secrets.token_urlsafe(48)
with os.fdopen(fd, "w") as fh:
fh.write(key + "\n")
return key
def password_is_set(factory: sessionmaker[Session]) -> bool:
return bool(get_value(factory, PASSWORD_KEY))
def set_password(factory: sessionmaker[Session], password: str) -> None:
set_value(factory, PASSWORD_KEY, _hasher.hash(password))
def verify_password(factory: sessionmaker[Session], password: str) -> bool:
stored = get_value(factory, PASSWORD_KEY)
if not stored:
return False
try:
_hasher.verify(stored, password)
except (VerificationError, InvalidHashError):
return False
if _hasher.check_needs_rehash(stored):
set_value(factory, PASSWORD_KEY, _hasher.hash(password))
return True
def password_problem(password: str, confirm: str) -> str | None:
if len(password) < MIN_PASSWORD_LENGTH:
return f"Mindestens {MIN_PASSWORD_LENGTH} Zeichen."
if password != confirm:
return "Die Passwörter stimmen nicht überein."
return None
# -- Session / CSRF -------------------------------------------------------------------
def csrf_token(request: Request) -> str:
token = request.session.get("csrf")
if not isinstance(token, str):
token = secrets.token_urlsafe(32)
request.session["csrf"] = token
return token
def login_session(request: Request) -> None:
"""Neue Session nach erfolgreicher Anmeldung (Schutz vor Session-Fixation)."""
request.session.clear()
request.session["user"] = "admin"
request.session["csrf"] = secrets.token_urlsafe(32)
request.session["since"] = int(time.time())
def is_logged_in(request: Request) -> bool:
return request.session.get("user") == "admin"
async def check_csrf(request: Request) -> None:
if request.method in SAFE_METHODS:
return
expected = request.session.get("csrf")
sent = request.headers.get("x-csrf-token")
if sent is None:
form = await request.form()
value = form.get("csrf_token")
sent = value if isinstance(value, str) else None
if not expected or not sent or not hmac.compare_digest(str(expected), sent):
raise HTTPException(status_code=403, detail="CSRF-Token fehlt oder ist ungültig.")
# -- Sperre bei Fehlversuchen --------------------------------------------------------------
class LoginThrottle:
"""Nach ``limit`` Fehlversuchen innerhalb von ``window`` Sekunden wird die
Client-IP für ``lockout`` Sekunden gesperrt."""
def __init__(self, limit: int = 5, window: float = 900, lockout: float = 300) -> None:
self.limit = limit
self.window = window
self.lockout = lockout
self._fails: dict[str, deque[float]] = defaultdict(deque)
self._locked: dict[str, float] = {}
self._lock = threading.Lock()
def locked_for(self, client: str) -> float:
with self._lock:
until = self._locked.get(client, 0)
return max(0.0, until - time.monotonic())
def failure(self, client: str) -> None:
now = time.monotonic()
with self._lock:
q = self._fails[client]
q.append(now)
while q and now - q[0] > self.window:
q.popleft()
if len(q) >= self.limit:
self._locked[client] = now + self.lockout
q.clear()
def success(self, client: str) -> None:
with self._lock:
self._fails.pop(client, None)
self._locked.pop(client, None)
class SetupCode:
"""Einmaliger Code für die Ersteinrichtung (steht im Log)."""
def __init__(self) -> None:
self.code = "-".join(secrets.token_hex(2) for _ in range(3))
def matches(self, value: str) -> bool:
return hmac.compare_digest(self.code, value.strip().lower())
+178
View File
@@ -0,0 +1,178 @@
/* NetMover – handgeschrieben, ohne Build-Schritt. Hell/Dunkel nach Systemeinstellung. */
:root {
--bg: #f6f7f9; --card: #fff; --fg: #1d2330; --muted: #5d6676; --line: #dfe3ea;
--accent: #2f6fde; --accent-fg: #fff; --ok: #1e8e3e; --warn: #b26a00; --err: #c62828;
--ok-bg: #e6f4ea; --warn-bg: #fff4e0; --err-bg: #fdecea; --info-bg: #e8f0fe;
--radius: 8px; --gap: 12px;
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #12151b; --card: #1b2028; --fg: #e6e9ef; --muted: #9aa3b2; --line: #2c333e;
--accent: #6b9bff; --accent-fg: #0b1020; --ok: #57c27a; --warn: #f0a63a; --err: #ff6b6b;
--ok-bg: #16301f; --warn-bg: #36280f; --err-bg: #3a1717; --info-bg: #182540;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); }
a { color: var(--accent); }
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; }
/* --- Layout: Kopfzeile + Seitenleiste (Desktop), aufklappbares Menü (Handy) --- */
:root { --sidebar: 228px; --topbar: 52px; }
.topbar { position: sticky; top: 0; z-index: 20; height: var(--topbar); display: flex; align-items: center;
gap: 12px; padding: 0 16px; background: var(--card); border-bottom: 1px solid var(--line); }
.topbar .brand { font-weight: 700; font-size: 1.05rem; text-decoration: none; color: var(--fg); }
.topbar .spacer { flex: 1; }
.health { display: inline-flex; align-items: center; gap: 7px; font-size: .85em; font-weight: 600;
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; }
h2 { font-size: 1.1rem; margin: 24px 0 8px; }
.muted { color: var(--muted); }
.small { font-size: .85em; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; }
.grid { display: grid; gap: var(--gap); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.spread { justify-content: space-between; }
.stack > * + * { margin-top: 8px; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 99px; font-size: .8em; font-weight: 600;
background: var(--bg); border: 1px solid var(--line); white-space: nowrap; }
.badge.ok { background: var(--ok-bg); color: var(--ok); border-color: transparent; }
.badge.warn { background: var(--warn-bg); color: var(--warn); border-color: transparent; }
.badge.err { background: var(--err-bg); color: var(--err); border-color: transparent; }
.msg { padding: 8px 12px; border-radius: var(--radius); margin: 0 0 10px; }
.msg.ok { background: var(--ok-bg); } .msg.error { background: var(--err-bg); }
.msg.info { background: var(--info-bg); } .msg.warn { background: var(--warn-bg); }
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line);
border-radius: var(--radius); overflow: hidden; }
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { font-size: .8em; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); background: var(--bg); }
tr:last-child td { border-bottom: 0; }
tr.warn td { background: var(--warn-bg); }
tr.err td { background: var(--err-bg); }
.table-wrap { overflow-x: auto; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
button, .btn { font: inherit; padding: 6px 12px; border-radius: var(--radius); border: 1px solid var(--line);
background: var(--card); color: var(--fg); cursor: pointer; text-decoration: none; display: inline-block; }
button.primary, .btn.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
button.danger { color: var(--err); }
button.small, .btn.small { padding: 3px 9px; font-size: .85em; }
button:disabled { opacity: .5; }
form.inline { display: inline; margin: 0; }
form.form { max-width: 760px; }
fieldset { border: 1px solid var(--line); border-radius: var(--radius); margin: 0 0 14px; padding: 10px 14px 14px; background: var(--card); }
legend { font-weight: 600; padding: 0 6px; }
.fields { display: grid; gap: 10px 14px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
.field { display: flex; flex-direction: column; gap: 3px; }
.field.wide { grid-column: 1 / -1; }
.field label { font-size: .85em; font-weight: 600; }
.field .hint { font-size: .8em; color: var(--muted); }
.field .error { font-size: .85em; color: var(--err); }
input[type=text], input[type=password], input[type=number], input[type=url], select, textarea {
font: inherit; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); width: 100%; }
textarea { min-height: 70px; }
.check { flex-direction: row; align-items: center; gap: 6px; }
.has-error input, .has-error select, .has-error textarea { border-color: var(--err); }
.steps { list-style: none; padding: 0; margin: 0; }
.steps li { padding: 3px 0; }
.steps .ico { display: inline-block; width: 1.4em; font-weight: 700; }
.steps .ok { color: var(--ok); } .steps .fail { color: var(--err); } .steps .skip { color: var(--muted); }
details > summary { cursor: pointer; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline; }
.login { max-width: 380px; margin: 10vh auto; }
[hidden] { display: none !important; }
@media (max-width: 640px) {
main { padding: 12px 12px 32px; }
th, td { padding: 6px; }
table.responsive thead { display: none; }
table.responsive tr { display: block; border-bottom: 1px solid var(--line); padding: 6px 0; }
table.responsive td { display: flex; justify-content: space-between; gap: 10px; border: 0; padding: 3px 8px; }
table.responsive td::before { content: attr(data-label); color: var(--muted); font-size: .85em; }
table.responsive td.num { text-align: left; }
}
.text-err { color: var(--err); } .text-warn { color: var(--warn); } .text-ok { color: var(--ok); }
pre.text-err { margin-top: 6px; }
ul.tight { margin: 4px 0 0; padding-left: 20px; }
.foot { text-align: center; padding: 16px; }
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; }
+67
View File
@@ -0,0 +1,67 @@
// NetMover – kleine Helfer. Keine Inline-Skripte (CSP: script-src 'self').
(function () {
"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 csrf = meta ? meta.getAttribute("content") : "";
// CSRF-Token für alle HTMX-Anfragen
document.addEventListener("htmx:configRequest", function (e) {
e.detail.headers["X-CSRF-Token"] = csrf;
});
// Rückfrage vor gefährlichen Aktionen: <form data-confirm="…">
document.addEventListener("submit", function (e) {
var msg = e.target.getAttribute && e.target.getAttribute("data-confirm");
if (msg && !window.confirm(msg)) { e.preventDefault(); }
}, true);
// Felder je nach Auswahl ein-/ausblenden: data-show-for="select-name:wert1 wert2"
function refresh(form) {
// data-show-for="feld:wert1 wert2" – mehrere Bedingungen mit „|“ (ODER)
form.querySelectorAll("[data-show-for]").forEach(function (el) {
el.hidden = !el.getAttribute("data-show-for").split("|").some(function (cond) {
var spec = cond.split(":");
var ctl = form.querySelector('[name="' + spec[0] + '"]:checked') ||
form.querySelector('select[name="' + spec[0] + '"]');
return spec[1].split(" ").indexOf(ctl ? ctl.value : "") !== -1;
});
});
}
document.querySelectorAll("form[data-dynamic]").forEach(function (form) {
form.addEventListener("change", function () { refresh(form); });
refresh(form);
});
// Öffentlichen Schlüssel kopieren
document.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest("[data-copy]");
if (!btn) return;
var src = document.getElementById(btn.getAttribute("data-copy"));
if (src && navigator.clipboard) {
navigator.clipboard.writeText(src.textContent.trim()).then(function () {
btn.textContent = "kopiert ✓";
});
}
});
})();
File diff suppressed because one or more lines are too long
+68
View File
@@ -0,0 +1,68 @@
{% from "icons.html" import icon %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf }}">
<meta name="htmx-config" content='{"includeIndicatorStyles": false, "allowEval": false}'>
<title>{% block title %}NetMover{% endblock %} · NetMover</title>
<link rel="stylesheet" href="/static/app.css?v={{ version }}">
<script src="/static/htmx.min.js?v=2.0.4" defer></script>
<script src="/static/app.js?v={{ version }}" defer></script>
</head>
<body class="{{ 'with-nav' if logged_in else 'no-nav' }}">
{% if logged_in %}
{% 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>
{% if nav %}
<a class="health {{ 'ok' if nav.healthy else 'bad' }}" href="/" title="Gesundheitszustand">
<span class="dot"></span>{{ 'Alles in Ordnung' if nav.healthy else (nav.problems ~ (' Problem' if nav.problems == 1 else ' Probleme')) }}</a>
{% if auth_bypass %}<a class="health warn" href="/settings#anmeldung" title="Ohne Passwort bedienbar">{{ 'ohne Anmeldung' if auth_bypass == 'off' else 'ohne Anmeldung (vertrautes Netz)' }}</a>{% endif %}
{% if nav.maintenance %}<a class="health warn" href="/" title="Geplante Läufe sind pausiert"><span class="dot"></span>Wartungsmodus</a>{% endif %}
{% endif %}
<span class="spacer"></span>
{% if help_anchor %}<a class="help-link" href="/help#{{ help_anchor }}">{{ icon('help') }}<span>Hilfe zu dieser Seite</span></a>{% endif %}
</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', '/credentials', 'Zugangsdaten', 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">
{% if session_login %}
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button class="nav-button">{{ icon('logout') }}<span>Abmelden</span></button>
</form>
{% endif %}
<div class="version">NetMover {{ version }}</div>
</div>
</nav>
{% endif %}
<main>
{% for kind, text in flashes %}<p class="msg {{ kind }}">{{ text }}</p>{% endfor %}
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% from "macros.html" import csrf_field %}
{% block title %}Zugangsdaten{% endblock %}
{% block content %}
<h1>Zugangsdaten</h1>
<p class="muted small">Einmal hinterlegen, in beliebig vielen Anbindungen auswählen. Passwörter und private Schlüssel
liegen verschlüsselt in der Datenbank und werden nie angezeigt.</p>
<h2>Passwörter <a class="legend-help" href="/help#zugangsdaten" title="Hilfe">?</a></h2>
<p class="muted small">Für SMB-Freigaben und SFTP-Server mit Passwort. Ein neues Passwort gilt sofort für alle Anbindungen, die es nutzen.</p>
{% if not passwords %}<p class="muted">Noch keine Passwörter gespeichert.</p>{% endif %}
<div class="stack">
{% for p in passwords %}
<div class="card" id="cred-{{ p.id }}">
<div class="row spread">
<div><strong>{{ p.name }}</strong> <span class="badge">{% if p.domain %}{{ p.domain }}\{% endif %}{{ p.username }}</span>
{% if p.used_by %}<span class="badge ok">benutzt von {{ p.used_by|join(', ') }}</span>{% else %}<span class="badge">unbenutzt</span>{% endif %}</div>
<div class="row">
<details class="rename"><summary class="btn small">Ändern</summary>
<form class="stack" method="post" action="/credentials/{{ p.id }}/password">{{ csrf_field(csrf) }}
<input type="text" name="username" value="{{ p.username }}" aria-label="Benutzer" required>
<input type="text" name="domain" value="{{ p.domain or '' }}" placeholder="Domäne (optional)" aria-label="Domäne">
<input type="password" name="password" placeholder="neues Passwort (leer = unverändert)" aria-label="Passwort" autocomplete="new-password">
<button class="small primary">Speichern</button></form></details>
<details class="rename"><summary class="btn small">Umbenennen</summary>
<form class="row" method="post" action="/credentials/{{ p.id }}/rename">{{ csrf_field(csrf) }}
<input type="text" name="name" value="{{ p.name }}" aria-label="Neuer Name" required maxlength="100">
<button class="small primary">Speichern</button></form></details>
<form class="inline" method="post" action="/credentials/{{ p.id }}/delete" data-confirm="„{{ p.name }}“ löschen?">{{ csrf_field(csrf) }}
<button class="small danger"{% if p.used_by %} disabled title="Wird noch benutzt"{% endif %}>Löschen</button></form>
</div>
</div>
<div class="small muted">angelegt {{ p.created_at|localdt }}</div>
</div>
{% endfor %}
</div>
<form class="form card stack" method="post" action="/credentials/passwords">{{ csrf_field(csrf) }}
<div class="fields">
<div class="field"><label for="p-name">Name</label><input id="p-name" type="text" name="name" placeholder="z. B. NAS-Scanner" required maxlength="100"></div>
<div class="field"><label for="p-user">Benutzer</label><input id="p-user" type="text" name="username" required autocomplete="off"></div>
<div class="field"><label for="p-pass">Passwort</label><input id="p-pass" type="password" name="password" required autocomplete="new-password"></div>
<div class="field"><label for="p-dom">Domäne (optional)</label><input id="p-dom" type="text" name="domain"></div>
</div>
<button class="primary">Passwort speichern</button>
</form>
<h2>SSH-Schlüssel</h2>
<p class="muted small">Für SFTP-Anbindungen. Ein Schlüssel kann von mehreren Anbindungen benutzt werden; den Benutzernamen legt jede Anbindung selbst fest.</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="/credentials/{{ 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="/credentials/{{ 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>
<h3>Neuen Schlüssel erzeugen</h3>
<form class="form card row" method="post" action="/credentials/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>
<h3>Vorhandenen Schlüssel importieren</h3>
<form class="form card stack" method="post" action="/credentials/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 %}
+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 %}
+51
View File
@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% from "macros.html" import run_badge %}
{% block title %}Protokoll{% endblock %}
{% block content %}
<h1>Protokoll</h1>
<form class="row card" method="get">
<select name="rule"><option value="">alle Regeln</option>
{% for id, name in rules %}<option value="{{ id }}" {{ 'selected' if id == rule_id }}>{{ name }}</option>{% endfor %}</select>
<select name="status"><option value="">jeder Status</option>
{% for st in [RunStatus.OK, RunStatus.PARTIAL, RunStatus.ERROR, RunStatus.SKIPPED] %}
<option value="{{ st.value }}" {{ 'selected' if st.value == status }}>{{ RUN_LABELS[st] }}</option>{% endfor %}</select>
<label class="row small"><input type="checkbox" name="all" value="1" {{ 'checked' if not active_only }}> auch leere Läufe</label>
<button>Filtern</button>
<span class="muted small">{{ total }} Läufe</span>
</form>
<div class="table-wrap"><table class="responsive">
<thead><tr><th>Zeitpunkt</th><th>Regel</th><th>Status</th><th class="num">Dauer</th><th class="num">gesehen</th><th class="num">übertragen</th><th class="num">übersprungen</th><th class="num">Fehler</th></tr></thead>
<tbody>
{% for run, rule_name in rows %}
<tr class="{{ 'err' if run.status == RunStatus.ERROR }}">
<td data-label="Zeitpunkt">{{ run.started_at|localdt }}{% if run.dry_run %} <span class="badge">Probe</span>{% endif %}{% if run.trigger.value == 'manual' %} <span class="badge">manuell</span>{% endif %}</td>
<td data-label="Regel">{{ rule_name }}</td>
<td data-label="Status">{{ run_badge(run.status) }}</td>
<td data-label="Dauer" class="num">{{ duration(run.started_at, run.finished_at) }}</td>
<td data-label="gesehen" class="num">{{ run.seen }}</td>
<td data-label="übertragen" class="num">{{ run.moved }}</td>
<td data-label="übersprungen" class="num">{{ run.skipped }}</td>
<td data-label="Fehler" class="num">{{ run.failed }}</td>
</tr>
{% if run.message or run.moved or run.skipped or run.failed %}
<tr><td colspan="8">
{% if run.message %}<div class="small {{ 'text-err' if run.status == RunStatus.ERROR }}">{{ run.message }}</div>{% endif %}
{% if run.moved or run.skipped or run.failed %}
<details hx-get="/log/{{ run.id }}/items" hx-trigger="toggle once" hx-target="find .items" hx-swap="innerHTML">
<summary class="small">Einzeldateien</summary><div class="items muted small">lädt …</div></details>
{% endif %}
</td></tr>
{% endif %}
{% else %}
<tr><td colspan="8" class="muted">Keine Läufe.</td></tr>
{% endfor %}
</tbody></table></div>
{% if pages > 1 %}
<div class="row">
{% set base = '?rule=' ~ (rule_id or '') ~ '&status=' ~ status ~ ('&all=1' if not active_only else '') %}
{% if page > 1 %}<a class="btn small" href="{{ base }}&page={{ page - 1 }}">← neuer</a>{% endif %}
<span class="small muted">Seite {{ page }} / {{ pages }}</span>
{% if page < pages %}<a class="btn small" href="{{ base }}&page={{ page + 1 }}">älter →</a>{% endif %}
</div>
{% endif %}
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Anmelden{% endblock %}
{% block content %}
<div class="login card">
<h1>NetMover</h1>
{% if error %}<p class="msg error">{{ error }}</p>{% endif %}
<form method="post" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<div class="field">
<label for="pw">Passwort</label>
<input id="pw" type="password" name="password" autocomplete="current-password" autofocus required>
</div>
<button class="primary">Anmelden</button>
</form>
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% macro field(name, label, value='', type='text', errors={}, hint='', wide=false, placeholder='', attrs='') -%}
<div class="field{{ ' wide' if wide }}{{ ' has-error' if errors.get(name) }}">
<label for="f-{{ name }}">{{ label }}</label>
<input id="f-{{ name }}" type="{{ type }}" name="{{ name }}" value="{{ '' if value is none else value }}"
placeholder="{{ placeholder }}" {{ attrs|safe }}>
{% if errors.get(name) %}<span class="error">{{ errors[name] }}</span>{% endif %}
{% if hint %}<span class="hint">{{ hint }}</span>{% endif %}
</div>
{%- endmacro %}
{% macro select(name, label, options, current, errors={}, hint='') -%}
<div class="field{{ ' has-error' if errors.get(name) }}">
<label for="f-{{ name }}">{{ label }}</label>
<select id="f-{{ name }}" name="{{ name }}">
{% for value, text in options %}
<option value="{{ value }}" {{ 'selected' if value|string == current|string }}>{{ text }}</option>
{% endfor %}
</select>
{% if errors.get(name) %}<span class="error">{{ errors[name] }}</span>{% endif %}
{% if hint %}<span class="hint">{{ hint }}</span>{% endif %}
</div>
{%- endmacro %}
{% macro check(name, label, checked, hint='', wide=false) -%}
<div class="field check{{ ' wide' if wide }}">
<input id="f-{{ name }}" type="checkbox" name="{{ name }}" {{ 'checked' if checked }}>
<label for="f-{{ name }}">{{ label }}</label>
{% if hint %}<span class="hint">{{ hint }}</span>{% endif %}
</div>
{%- endmacro %}
{% macro csrf_field(csrf) %}<input type="hidden" name="csrf_token" value="{{ csrf }}">{% endmacro %}
{% macro state_badge(m) -%}
{% set labels = SFTP_STATE_LABELS if m.type == MountType.SFTP else STATE_LABELS %}
{% set cls = {'mounted': 'ok', 'degraded': 'warn', 'error': 'err'}.get(m.state.value, '') %}
<span class="badge {{ cls }}">{{ labels[m.state] }}</span>
{%- endmacro %}
{% macro run_badge(status) -%}
{% set cls = {'ok': 'ok', 'partial': 'warn', 'error': 'err'}.get(status.value, '') %}
<span class="badge {{ cls }}">{{ RUN_LABELS[status] }}</span>
{%- endmacro %}
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% from "macros.html" import field, select, check %}
{% block title %}{{ 'Anbindung bearbeiten' if mount else 'Neue Anbindung' }}{% endblock %}
{% block content %}
<h1>{{ 'Anbindung „' ~ mount.name ~ '“ bearbeiten' if mount else 'Neue Anbindung' }}</h1>
{% if errors.get('_') %}<p class="msg error">{{ errors['_'] }}</p>{% endif %}
<form class="form" method="post" data-dynamic>
<input type="hidden" name="csrf_token" value="{{ csrf }}">
{% if mount %}<input type="hidden" name="mount_id" value="{{ mount.id }}">{% endif %}
<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 '')) }}
{{ select('type', 'Typ', [('cifs', 'SMB/CIFS'), ('nfs4', 'NFS 4'), ('nfs3', 'NFS 3'), ('sftp', 'SFTP')], d.type.value, errors=errors) }}
{{ 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') }}">
<label for="f-share">Freigabe / Pfad</label>
<input id="f-share" type="text" name="share" value="{{ d.share }}" required>
{% if errors.get('share') %}<span class="error">{{ errors['share'] }}</span>{% endif %}
<span class="hint" data-show-for="type:cifs">Freigabename, z. B. <code>scans</code></span>
<span class="hint" data-show-for="type:nfs3 nfs4">Exportpfad, z. B. <code>/volume1/scans</code></span>
<span class="hint" data-show-for="type:sftp">Absoluter Basisordner, z. B. <code>/opt/paperless/consume</code></span>
</div>
<div data-show-for="type:sftp">{{ field('port', 'SSH-Port', d.port or '', type='number', errors=errors, placeholder='22') }}</div>
</div></fieldset>
<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'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div>
<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='Verwalten unter „Zugangsdaten“.') }}
</div></div>
<div data-show-for="type:cifs|auth:password">
{{ select('cred_id', 'Zugangsdaten', password_options, d.cred_id, errors=errors, hint='Gespeicherte Passwörter verwalten unter „Zugangsdaten“.') }}
</div>
<div data-show-for="auth:ssh_key|cred_id:own">
{{ field('username', 'Benutzername', d.username, errors=errors, attrs='autocomplete="off"') }}
</div>
<div data-show-for="cred_id:own"><div data-show-for="type:cifs">{{ field('domain', 'Domäne (optional)', d.domain, errors=errors) }}</div></div>
<div data-show-for="cred_id:own"><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>
<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 %}
<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>
{% 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 %}
</fieldset>
<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('gid', 'gid', d.gid, type='number', errors=errors) }}
{{ field('file_mode', 'file_mode', d.file_mode, errors=errors, placeholder='0644') }}
{{ field('dir_mode', 'dir_mode', d.dir_mode, errors=errors, placeholder='0755') }}
</div></fieldset>
<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.') }}
{{ check('auto_mount', 'Beim Start automatisch einhängen', d.auto_mount) }}
</div></fieldset>
{{ check('enabled', 'Aktiv', d.enabled) }}
<div class="row">
<button class="primary">Speichern</button>
<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>
<a class="btn" href="/mounts">Abbrechen</a>
</div>
<div id="form-check"></div>
</form>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% from "macros.html" import state_badge, csrf_field %}
{% block title %}Anbindungen{% endblock %}
{% block content %}
<div class="row spread"><h1>Anbindungen</h1><a class="btn primary" href="/mounts/new">+ Neue Anbindung</a></div>
{% if not mounts %}<p class="muted">Noch keine Anbindungen.</p>{% endif %}
<div class="stack">
{% for m in mounts %}
<div class="card" id="mount-{{ m.id }}">
<div class="row spread">
<div><strong>{{ m.name }}</strong> {{ state_badge(m) }}
{% if not m.enabled %}<span class="badge">deaktiviert</span>{% endif %}
{% if m.auto_mount %}<span class="badge">auto</span>{% endif %}</div>
<div class="row">
<button class="small" hx-post="/mounts/{{ m.id }}/test" hx-target="#check-{{ m.id }}" hx-swap="innerHTML">
Testen <span class="htmx-indicator">…</span></button>
{% if m.type != MountType.SFTP %}
{% if m.state == MountState.MOUNTED or m.state == MountState.DEGRADED %}
<form class="inline" method="post" action="/mounts/{{ m.id }}/unmount" data-confirm="„{{ m.name }}“ aushängen?">{{ csrf_field(csrf) }}<button class="small">Aushängen</button></form>
{% else %}
<form class="inline" method="post" action="/mounts/{{ m.id }}/mount">{{ csrf_field(csrf) }}<button class="small">Einhängen</button></form>
{% endif %}
{% endif %}
<a class="btn small" href="/mounts/{{ m.id }}/edit">Bearbeiten</a>
<form class="inline" method="post" action="/mounts/{{ m.id }}/delete" data-confirm="„{{ m.name }}“ wirklich löschen?">{{ csrf_field(csrf) }}<button class="small danger">Löschen</button></form>
</div>
</div>
<div class="muted small mono">{{ m.type.value }} · {% if m.type == MountType.CIFS %}//{{ m.server }}/{{ m.share }}{% elif m.type == MountType.SFTP %}{{ m.server }}:{{ m.port or 22 }}{{ m.share }}{% else %}{{ m.server }}:{{ m.share }}{% endif %}
{% if m.mountpoint %} → {{ m.mountpoint }}{% endif %}</div>
{% if m.last_error %}<pre class="small text-err">{{ m.last_error }}</pre>{% endif %}
{% if key_names.get(m.id) %}<div class="small">Anmeldung mit gespeicherten Zugangsdaten <a href="/credentials">„{{ key_names[m.id] }}“</a></div>{% endif %}
{% if m.type == MountType.SFTP %}
<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>
{% else %}<span class="text-warn">noch nicht bestätigt – bitte „Testen“</span>{% endif %}</div>
{% if pubkeys.get(m.id) %}
<details class="small"><summary>Öffentlicher Schlüssel für authorized_keys</summary>
<pre class="mono" id="pub-{{ m.id }}">{{ pubkeys[m.id] }}</pre>
<button class="small" type="button" data-copy="pub-{{ m.id }}">kopieren</button></details>
{% endif %}
{% endif %}
<div id="check-{{ m.id }}">
{% if reports.get(m.id) %}<details class="small"><summary class="muted">Letzter Test {{ m.last_test_at|ago }}</summary>
{% with report = reports[m.id], errors = {} %}{% include "partials/check_result.html" %}{% endwith %}</details>{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,20 @@
{% set step_names = {'mount': 'Einhängen', 'read': 'Lesen', 'write': 'Schreiben', 'connect': 'Verbinden'} %}
<div class="card stack">
{% if errors %}
<p class="msg error">Eingaben unvollständig:</p>
<ul class="tight">{% for k, v in errors.items() %}<li><strong>{{ k }}</strong>: {{ v }}</li>{% endfor %}</ul>
{% elif report %}
<ul class="steps">
{% for st in report.steps %}
<li><span class="ico {{ 'ok' if st.ok else 'skip' if st.ok is none else 'fail' }}">{{ '✓' if st.ok else '–' if st.ok is none else '✗' }}</span>
<strong>{{ step_names.get(st.name, st.name) }}</strong>{% if st.message %}: <span class="{{ 'text-err' if st.ok == false }}">{{ st.message }}</span>{% endif %}</li>
{% endfor %}
</ul>
<div class="small">Geeignet als:
{% for role in report.roles %}<span class="badge ok">{{ role.value }}</span> {% else %}<span class="badge err">nichts</span>{% endfor %}</div>
{% if report.owner %}<div class="small muted">Testdatei gehörte {{ report.owner }}{% if report.root_squash %} (Root-Squash){% endif %}</div>{% endif %}
{% if report.free_bytes is not none %}<div class="small muted">{{ report.free_bytes|bytes }} frei von {{ report.total_bytes|bytes }}</div>{% endif %}
{% if report.host_key_fingerprint %}<div class="small">Hostschlüssel: <span class="mono">{{ report.host_key_fingerprint }}</span></div>{% endif %}
{% for h in report.hints %}<p class="msg info small">{{ h }}</p>{% endfor %}
{% endif %}
</div>
@@ -0,0 +1,18 @@
{% from "macros.html" import run_badge %}
<div class="card stack">
{% if not run %}<p class="msg error">Probelauf nicht möglich (Regel gelöscht?).</p>
{% else %}
<div class="row"><strong>Probelauf</strong> {{ run_badge(run.status) }}
<span class="muted small">{{ run.seen }} gefunden · {{ run.moved }} würden übertragen · {{ run.skipped }} übersprungen</span></div>
{% if run.message %}<p class="msg {{ 'error' if run.status == RunStatus.ERROR else 'info' }} small">{{ run.message }}</p>{% endif %}
{% if items %}
<div class="table-wrap"><table class="responsive small">
<thead><tr><th>Datei</th><th>Ziel</th><th>Ergebnis</th></tr></thead>
<tbody>{% for i in items %}
<tr><td data-label="Datei" class="mono">{{ i.src }}</td><td data-label="Ziel" class="mono">{{ i.dst or '–' }}</td>
<td data-label="Ergebnis">{{ i.message or i.status.value }}</td></tr>
{% endfor %}</tbody></table></div>
{% elif run.status != RunStatus.ERROR %}<p class="muted small">Keine passenden Dateien.</p>{% endif %}
<p class="muted small">Es wurde nichts verändert.</p>
{% endif %}
</div>
@@ -0,0 +1,11 @@
{% set cls = {'moved': 'ok', 'copied': 'ok', 'planned': 'ok', 'failed': 'err', 'quarantined': 'warn'} %}
{% set labels = {'moved': 'verschoben', 'copied': 'kopiert', 'skipped': 'übersprungen', 'failed': 'Fehler', 'quarantined': 'Quarantäne', 'planned': 'geplant'} %}
<table class="responsive small">
<thead><tr><th>Datei</th><th>Ziel</th><th class="num">Größe</th><th>Ergebnis</th></tr></thead>
<tbody>{% for i in items %}
<tr><td data-label="Datei" class="mono">{{ i.src }}</td><td data-label="Ziel" class="mono">{{ i.dst or '–' }}</td>
<td data-label="Größe" class="num">{{ i.size|bytes }}</td>
<td data-label="Ergebnis"><span class="badge {{ cls.get(i.status.value, '') }}">{{ labels[i.status.value] }}</span>
{% if i.message %}<span class="{{ 'text-err' if i.status.value == 'failed' }}">{{ i.message }}</span>{% endif %}</td></tr>
{% else %}<tr><td colspan="4" class="muted">Keine Einzeldateien protokolliert.</td></tr>{% endfor %}
</tbody></table>
@@ -0,0 +1,56 @@
{% from "macros.html" import state_badge, run_badge %}
{% if not health.ok %}
<div class="msg error"><strong>Achtung:</strong>
<ul class="tight">{% for p in health.problems %}<li>{{ p }}</li>{% endfor %}</ul></div>
{% else %}
<p class="msg ok">Alles in Ordnung.</p>
{% endif %}
<h2>Anbindungen</h2>
{% if not mounts %}<p class="muted">Noch keine Anbindungen – <a href="/mounts/new">jetzt anlegen</a>.</p>{% endif %}
<div class="grid">
{% for v in mounts %}{% set m = v.m %}
<div class="card">
<div class="row spread"><strong><a href="/mounts#mount-{{ m.id }}">{{ m.name }}</a></strong>{{ state_badge(m) }}</div>
<div class="muted small mono">{{ m.type.value }} · {{ m.server }}{{ ':' if m.type != MountType.CIFS else '/' }}{{ m.share }}</div>
{% if m.state == MountState.MOUNTED and m.last_mounted_at and m.type != MountType.SFTP %}
<div class="small">eingehängt seit {{ m.last_mounted_at|localdt }}</div>
{% elif m.state in (MountState.DEGRADED, MountState.ERROR) %}
<div class="small">{{ 'gestört' if m.state == MountState.DEGRADED else 'Fehler' }} seit {{ m.state_since|localdt }} ({{ m.state_since|ago }})</div>
{% endif %}
{% if v.usage %}{% set used = (v.usage[0] - v.usage[1]) %}{% set pct = (100 * used / v.usage[0]) if v.usage[0] else 0 %}
<meter class="fill" min="0" max="100" low="85" high="95" optimum="0" value="{{ pct|round(1) }}">{{ pct|round(0)|int }} %</meter>
<div class="small muted">{{ v.usage[1]|bytes }} frei von {{ v.usage[0]|bytes }} ({{ pct|round(0)|int }} % belegt)</div>
{% endif %}
{% if m.last_error %}<pre class="small text-err">{{ m.last_error }}</pre>{% endif %}
</div>
{% endfor %}
</div>
<h2>Regeln</h2>
{% if not rules %}<p class="muted">Noch keine Regeln – <a href="/rules/new">jetzt anlegen</a>.</p>{% else %}
<div class="table-wrap"><table class="responsive">
<thead><tr><th>Regel</th><th>Letztes Ergebnis</th><th>Letzter Erfolg</th><th>Nächster Lauf</th><th></th></tr></thead>
<tbody>
{% for v in rules %}{% set r = v.r %}
<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 %}
<div class="muted small mono">{{ v.target }}</div></td>
<td data-label="Letztes Ergebnis">
{% 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) }}
<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 %}
{% 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 %}
{% 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><form class="inline" method="post" action="/rules/{{ r.id }}/run">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button class="small" {{ 'disabled' if v.running_since }}>Jetzt ausführen</button></form></td>
</tr>
{% endfor %}
</tbody></table></div>
{% endif %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% from "macros.html" import field, select, check %}
{% block title %}{{ 'Regel bearbeiten' if rule_id else 'Neue Regel' }}{% endblock %}
{% block content %}
<h1>{{ 'Regel bearbeiten' if rule_id else 'Neue Regel' }}</h1>
{% if errors.get('_') %}<p class="msg error">{{ errors['_'] }}</p>{% endif %}
{% if not src_mounts %}<p class="msg info">Zuerst eine Anbindung als Quelle <a href="/mounts/new">anlegen</a>.</p>{% endif %}
<form class="form" method="post" data-dynamic>
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<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('interval_seconds', 'Intervall (Sekunden)', d.interval_seconds, type='number', errors=errors, attrs='min="10" required', hint='mindestens 10') }}
{{ check('enabled', 'Aktiv', d.enabled) }}
{{ check('dry_run', 'Nur Probelauf (nichts verändern)', d.dry_run) }}
</div></fieldset>
<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) }}
{{ 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) }}
{{ field('pattern', 'Filter', d.pattern, errors=errors, hint='Glob: *.pdf;*.jpg (Dateiname, ohne Groß/klein). Regex: gegen relativen Pfad.') }}
{{ check('recursive', 'Unterordner einbeziehen (Struktur bleibt erhalten)', d.recursive) }}
</div></fieldset>
<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) }}
<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.') }}
{{ select('action', 'Aktion', [('move', 'verschieben'), ('copy', 'kopieren (Quelle bleibt)')], d.action.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>
<fieldset><legend>Archiv <a class="legend-help" href="/help#archiv" title="Hilfe">?</a></legend><div class="fields">
{{ select('archive_kind', 'Archiv', [('none', 'kein Archiv'), ('mount', 'Anbindung'), ('local', 'Lokaler Pfad')], d.archive_kind.value, errors=errors, hint='Zusätzliche Kopie. Die Quelle wird erst gelöscht, wenn Ziel UND Archiv sie haben.') }}
<div data-show-for="archive_kind:mount">{{ select('archive_mount_id', 'Archiv-Anbindung', dst_options, d.archive_mount_id or '', errors=errors) }}</div>
<div data-show-for="archive_kind:mount local">{{ field('archive_path', 'Archivordner', d.archive_path, errors=errors, hint='Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren.') }}</div>
<div data-show-for="archive_kind:mount local">{{ select('archive_layout', 'Ablage', [('flat', 'wie in der Quelle'), ('date', 'nach Datum (Jahr/Monat)')], d.archive_layout.value, errors=errors, hint='Im Archiv wird nie überschrieben – gleiche Namen werden umbenannt.') }}</div>
</div></fieldset>
<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('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('op_timeout_seconds', 'Zeitlimit ohne Fortschritt (s)', d.op_timeout_seconds, type='number', errors=errors, attrs='min="10"') }}
</div></fieldset>
<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_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_after', 'nach Fehlversuchen', d.quarantine_after, type='number', errors=errors, attrs='min="1"') }}
</div></fieldset>
<div class="row"><button class="primary">Speichern</button><a class="btn" href="/rules">Abbrechen</a></div>
</form>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% from "macros.html" import run_badge, csrf_field %}
{% block title %}Regeln{% endblock %}
{% block content %}
<div class="row spread"><h1>Regeln</h1><a class="btn primary" href="/rules/new">+ Neue Regel</a></div>
{% if not rules %}<p class="muted">Noch keine Regeln.</p>{% endif %}
<div class="stack">
{% for v in rules %}{% set r = v.r %}
<div class="card" id="rule-{{ r.id }}">
<div class="row spread">
<div><strong>{{ r.name }}</strong>
{% if r.enabled %}<span class="badge ok">aktiv</span>{% else %}<span class="badge">aus</span>{% endif %}
{% if r.dry_run %}<span class="badge warn">nur Probelauf</span>{% endif %}
<span class="badge">{{ 'kopieren' if r.action == RuleAction.COPY else 'verschieben' }}</span>
<span class="badge">alle {{ r.interval_seconds }} s</span></div>
<div class="row">
<form class="inline" method="post" action="/rules/{{ r.id }}/run">{{ csrf_field(csrf) }}<button class="small" {{ 'disabled' if v.running_since }}>Jetzt ausführen</button></form>
<button class="small" hx-post="/rules/{{ r.id }}/dryrun" hx-target="#dry-{{ r.id }}" hx-swap="innerHTML">Probelauf <span class="htmx-indicator">…</span></button>
<form class="inline" method="post" action="/rules/{{ r.id }}/toggle">{{ csrf_field(csrf) }}<button class="small">{{ 'Deaktivieren' if r.enabled else 'Aktivieren' }}</button></form>
<a class="btn small" href="/rules/{{ r.id }}/edit">Bearbeiten</a>
<form class="inline" method="post" action="/rules/{{ r.id }}/delete" data-confirm="Regel „{{ r.name }}“ und ihr Protokoll löschen?">{{ csrf_field(csrf) }}<button class="small danger">Löschen</button></form>
</div>
</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 %}
· 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>
<div id="dry-{{ r.id }}"></div>
</div>
{% endfor %}
</div>
{% endblock %}
+98
View File
@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% from "macros.html" import field, select, check, csrf_field %}
{% block title %}Einstellungen{% endblock %}
{% block content %}
<h1>Einstellungen</h1>
<form class="form" method="post" action="/settings" data-dynamic>
{{ csrf_field(csrf) }}
<fieldset><legend>Protokoll</legend><div class="fields">
{{ field('retention_days', 'Aufbewahrung (Tage)', cfg.retention_days, type='number', errors=errors, attrs='min="1" max="3650"') }}
</div></fieldset>
<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, 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>
{{ 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) }}
<div class="field wide"><label for="f-tpl">Vorlage</label>
<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>
</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><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>
</form>
<h2>Passwort ändern</h2>
<form class="form card stack" method="post" action="/settings/password">
{{ csrf_field(csrf) }}
<div class="fields">
<div class="field"><label for="cur">Aktuelles Passwort</label><input id="cur" type="password" name="current" autocomplete="current-password" required></div>
<div class="field"><label for="np">Neues Passwort</label><input id="np" type="password" name="password" autocomplete="new-password" minlength="10" required></div>
<div class="field"><label for="np2">Wiederholen</label><input id="np2" type="password" name="confirm" autocomplete="new-password" minlength="10" required></div>
</div>
<button>Passwort ändern</button>
</form>
<h2 id="anmeldung">Anmeldung <a class="legend-help" href="/help#anmeldung" title="Hilfe">?</a></h2>
<form class="form card stack" method="post" action="/settings/auth">
{{ csrf_field(csrf) }}
<p class="small muted">Deine Adresse gerade: <code>{{ client_ip }}</code></p>
<div class="fields">
<div class="field wide"><label for="f-nets">Ohne Passwort aus diesen Netzen</label>
<input id="f-nets" type="text" name="trusted_networks" value="{{ cfg.trusted_networks }}" placeholder="z. B. 10.0.10.0/24, 10.1.5.0/24">
<span class="hint">Mit Komma getrennt; einzelne Adressen gehen auch. Leer = niemand ohne Passwort. <code>0.0.0.0/0, ::/0</code> = Anmeldung ganz aus.</span></div>
<div class="field wide"><label for="f-allowed">Mit Passwort aus diesen Netzen</label>
<input id="f-allowed" type="text" name="allowed_networks" value="{{ cfg.allowed_networks }}" placeholder="leer = alle übrigen Netze">
<span class="hint">Leer = von überall sonst mit Passwort. Ausgefüllt = <strong>alle anderen Netze werden abgewiesen</strong> (auch die Anmeldeseite). <code>/healthz</code> bleibt für die Überwachung erreichbar.</span></div>
<div class="field"><label for="f-cur-auth">Aktuelles Passwort (zur Bestätigung)</label>
<input id="f-cur-auth" type="password" name="current" autocomplete="current-password" required></div>
</div>
<p class="msg warn small">NetMover läuft als root und kennt die Zugangsdaten deiner Freigaben. Wer die Oberfläche ohne Passwort erreicht,
kann alles ändern – Netze „ohne Passwort“ daher so eng wie möglich wählen. Hinter einem Reverse Proxy <code>NETMOVER_FORWARDED_ALLOW_IPS</code> setzen,
sonst sieht jede Anfrage aus, als käme sie vom Proxy. Eine Einstellung, mit der du dich selbst aussperren würdest, wird nicht gespeichert.</p>
{% if loopback_trusted %}<p class="msg warn small">„Ohne Passwort“ enthält 127.0.0.1/::1. Läuft ein Reverse Proxy auf diesem Server, gilt dann <strong>jede</strong> Anfrage über den Proxy als vertrauenswürdig.</p>{% endif %}
<div class="row"><button class="primary">Anmeldung speichern</button></div>
</form>
<h2>Export / Import</h2>
<div class="card stack">
<p class="small muted">Export enthält Anbindungen, Regeln und Einstellungen – <strong>ohne</strong> Zugangsdaten und Webhook-URL.
Beim Import werden vorhandene Namen übersprungen; importierte Regeln sind zunächst deaktiviert.</p>
<div class="row"><a class="btn" href="/settings/export">Konfiguration exportieren</a></div>
<form class="row" method="post" action="/settings/import" enctype="multipart/form-data">
{{ csrf_field(csrf) }}<input type="file" name="file" accept="application/json,.json" required><button>Importieren</button>
</form>
</div>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Einrichtung{% endblock %}
{% block content %}
<div class="login card">
<h1>Ersteinrichtung</h1>
<p class="muted">Bitte ein Administratorpasswort festlegen (mindestens 10 Zeichen).
Den Einrichtungscode schreibt NetMover beim Start ins Log:
<code>journalctl -u netmover | grep setup</code></p>
{% if error %}<p class="msg error">{{ error }}</p>{% endif %}
<form method="post" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<div class="field"><label for="code">Einrichtungscode</label>
<input id="code" type="text" name="code" autocomplete="off" placeholder="xxxx-xxxx-xxxx" required></div>
<div class="field"><label for="pw">Passwort</label>
<input id="pw" type="password" name="password" autocomplete="new-password" minlength="10" required></div>
<div class="field"><label for="pw2">Passwort wiederholen</label>
<input id="pw2" type="password" name="confirm" autocomplete="new-password" minlength="10" required></div>
<button class="primary">Passwort setzen</button>
</form>
</div>
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Status{% endblock %}
{% block content %}
<div class="row spread"><h1>Status</h1>
<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">
{% include "partials/status_body.html" %}
</div>
{% endblock %}
+42
View File
@@ -5,13 +5,18 @@ from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session, sessionmaker
from netmover.app_state import NetMover
from netmover.config import Settings
from netmover.crypto import SecretBox
from netmover.db import make_engine, make_session_factory, session_scope, upgrade
from netmover.models import Credential, CredentialKind, Mount, MountType
from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.manager import MountManager
from netmover.web.app import create_app
from tests.webutil import do_setup
@pytest.fixture
@@ -124,3 +129,40 @@ def make_mount(
return m.id
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
+5 -1
View File
@@ -48,7 +48,11 @@ class _Server(paramiko.ServerInterface):
class _Handle(SFTPHandle):
pass
def stat(self) -> Any: # fstat wie bei OpenSSH
try:
return SFTPAttributes.from_stat(os.fstat(self.readfile.fileno()))
except OSError as e:
return SFTPServer.convert_errno(e.errno)
class _RootedSFTP(SFTPServerInterface):
+351
View File
@@ -0,0 +1,351 @@
"""0.7.0: Archiv – Kopie ans Ziel UND ins Archiv, Quelle erst danach löschen."""
from __future__ import annotations
import os
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.db import session_scope
from netmover.models import (
ArchivedFile,
ArchiveKind,
CollisionPolicy,
ItemStatus,
Rule,
RuleAction,
RunStatus,
)
from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.manager import MountManager
from netmover.mover.destinations import LocalDestination, TransferError
from netmover.mover.engine import Engine, RuleSnapshot, RunResult
from netmover.mover.ledger import Ledger
from netmover.mover.runner import RuleRunner
from netmover.timeouts import CancelToken
from tests.conftest import MountFactory
@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="r", src_mount_id=mid, dst_path="/x")
s.add(r)
s.flush()
return r.id
FIXED = datetime(2026, 10, 12, 9, 30).timestamp()
@pytest.fixture
def engine(session_factory: sessionmaker[Session]) -> Engine:
return Engine(Ledger(session_factory), sleep=lambda _: None, clock=lambda: FIXED)
@pytest.fixture
def dirs(tmp_path: Path) -> tuple[Path, Path, Path]:
src, dst, arch = tmp_path / "src", tmp_path / "dst", tmp_path / "archiv"
for d in (src, dst, arch):
d.mkdir()
return src, dst, arch
@pytest.fixture(autouse=True)
def cross_device(monkeypatch: pytest.MonkeyPatch) -> None:
"""tmp_path liegt auf einem Gerät – realistischer Weg über Kopie erzwingen."""
monkeypatch.setattr(LocalDestination, "move_same_device", lambda *a, **k: None)
def snap(rule_id: int, **kw: object) -> RuleSnapshot:
return RuleSnapshot(id=rule_id, name="r", stability_seconds=0, **kw) # type: ignore[arg-type]
def run(
engine: Engine, rule: RuleSnapshot, src: Path, dst: Path, arch: Path | None, **kw: object
) -> RunResult:
archive = LocalDestination(arch) if arch is not None else None
return engine.execute(rule, src, LocalDestination(dst), CancelToken(), archive=archive, **kw) # type: ignore[arg-type]
def write(p: Path, data: bytes = b"inhalt") -> Path:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(data)
return p
def names(d: Path) -> list[str]:
return sorted(str(p.relative_to(d)) for p in d.rglob("*") if p.is_file())
def test_move_delivers_to_target_and_archive_then_deletes_source(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
data = os.urandom(200_000)
write(src / "scan001.pdf", data)
result = run(engine, snap(rule_id), src, dst, arch)
assert result.status is RunStatus.OK
item = result.items[0]
assert item.status is ItemStatus.MOVED and item.message and "archiviert:" in item.message
assert (dst / "scan001.pdf").read_bytes() == data == (arch / "scan001.pdf").read_bytes()
assert not (src / "scan001.pdf").exists()
assert names(dst) == ["scan001.pdf"] and names(arch) == ["scan001.pdf"] # keine .part-Reste
def test_date_layout(engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]) -> None:
src, dst, arch = dirs
write(src / "max" / "a.pdf")
run(engine, snap(rule_id, recursive=True, archive_layout="date"), src, dst, arch)
assert names(arch) == ["2026/10/max/a.pdf"] and names(dst) == ["max/a.pdf"]
def test_archive_failure_delivers_nothing_and_keeps_source(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
src, dst, arch = dirs
write(src / "a.pdf")
real = LocalDestination._link_noreplace
def fail_in_archive(self: LocalDestination, tmp: str, final: str) -> None:
if self.root == arch:
raise TransferError("Archiv voll")
real(self, tmp, final)
monkeypatch.setattr(LocalDestination, "_link_noreplace", fail_in_archive)
result = run(engine, snap(rule_id), src, dst, arch)
item = result.items[0]
assert item.status is ItemStatus.FAILED and item.message == "Archiv: Archiv voll (Versuch 1)"
assert (src / "a.pdf").exists() and names(dst) == [] and names(arch) == []
def test_target_failure_after_archive_never_archives_twice(
engine: Engine,
rule_id: int,
dirs: tuple[Path, Path, Path],
monkeypatch: pytest.MonkeyPatch,
session_factory: sessionmaker[Session],
) -> None:
src, dst, arch = dirs
write(src / "a.pdf")
real = LocalDestination._link_noreplace
def fail_in_target(self: LocalDestination, tmp: str, final: str) -> None:
if self.root == dst:
raise TransferError("Ziel gesperrt")
real(self, tmp, final)
monkeypatch.setattr(LocalDestination, "_link_noreplace", fail_in_target)
first = run(engine, snap(rule_id), src, dst, arch)
assert first.items[0].status is ItemStatus.FAILED
assert (src / "a.pdf").exists() and names(arch) == ["a.pdf"] and names(dst) == []
with session_factory() as s:
assert len(s.scalars(select(ArchivedFile)).all()) == 1
monkeypatch.setattr(LocalDestination, "_link_noreplace", real) # Ziel wieder frei
second = run(engine, snap(rule_id), src, dst, arch)
assert second.items[0].status is ItemStatus.MOVED
assert names(arch) == ["a.pdf"] # keine zweite Archivkopie (a-1.pdf)
assert names(dst) == ["a.pdf"] and not (src / "a.pdf").exists()
def test_archive_never_overwrites(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
write(arch / "scan.pdf", b"alt im Archiv")
write(src / "scan.pdf", b"neu")
run(engine, snap(rule_id, on_collision=CollisionPolicy.OVERWRITE), src, dst, arch)
assert (arch / "scan.pdf").read_bytes() == b"alt im Archiv"
assert (arch / "scan-1.pdf").read_bytes() == b"neu"
def test_no_space_in_archive_writes_nothing(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
src, dst, arch = dirs
write(src / "a.pdf", b"x" * 1000)
real = LocalDestination.free_bytes
monkeypatch.setattr(
LocalDestination, "free_bytes", lambda self: 10 if self.root == arch else real(self)
)
result = run(engine, snap(rule_id), src, dst, arch)
assert "zu wenig Platz im Archiv" in (result.items[0].message or "")
assert names(dst) == [] and names(arch) == [] and (src / "a.pdf").exists()
def test_source_changed_during_archive_copy(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
src, dst, arch = dirs
f = write(src / "a.pdf", b"original")
real = LocalDestination._apply_meta
def change(self: LocalDestination, rel: str, meta: object) -> list[str]:
if self.root == arch:
f.write_bytes(b"geaendert!!")
return real(self, rel, meta) # type: ignore[arg-type]
monkeypatch.setattr(LocalDestination, "_apply_meta", change)
result = run(engine, snap(rule_id), src, dst, arch)
assert result.items[0].status is ItemStatus.SKIPPED
assert names(dst) == [] and names(arch) == [] and f.exists()
def test_copy_mode_with_archive(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
f = write(src / "a.pdf", b"v1")
rule = snap(rule_id, action=RuleAction.COPY)
for _ in range(3):
run(engine, rule, src, dst, arch)
assert f.exists() and names(dst) == ["a.pdf"] and names(arch) == ["a.pdf"]
f.write_bytes(b"version 2")
run(engine, rule, src, dst, arch)
assert names(dst) == ["a-1.pdf", "a.pdf"] and names(arch) == ["a-1.pdf", "a.pdf"]
def test_dry_run_shows_archive_and_writes_nothing(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
write(src / "a.pdf")
result = run(engine, snap(rule_id, archive_layout="date"), src, dst, arch, dry_run=True)
assert result.items[0].status is ItemStatus.PLANNED
assert f"Archiv: {arch / '2026/10/a.pdf'}" in (result.items[0].message or "")
assert names(dst) == [] and names(arch) == [] and (src / "a.pdf").exists()
def test_without_archive_unchanged(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
write(src / "a.pdf")
result = run(engine, snap(rule_id), src, dst, None)
assert result.items[0].message is None and names(arch) == []
# -- Regellauf ----------------------------------------------------------------------------------
@pytest.fixture
def setup(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
tmp_path: Path,
) -> Iterator[tuple[int, Path, Path, Path]]:
scans = fake_backend.export("nas", "scans")
arch_remote = fake_backend.export("nas", "archiv")
src_id = make_mount("scans", share="scans")
arch_id = make_mount("archiv", share="archiv")
manager.mount(src_id)
manager.mount(arch_id)
consume = tmp_path / "consume"
consume.mkdir()
with session_scope(session_factory) as s:
r = Rule(
name="Scans → Paperless + Archiv",
src_mount_id=src_id,
dst_path=str(consume),
stability_seconds=0,
archive_kind=ArchiveKind.MOUNT,
archive_mount_id=arch_id,
archive_path="scans",
)
s.add(r)
s.flush()
rid = r.id
yield rid, scans, consume, arch_remote
def test_runner_with_archive_mount(
setup: tuple[int, Path, Path, Path],
manager: MountManager,
session_factory: sessionmaker[Session],
) -> None:
rid, scans, consume, arch = setup
write(scans / "doc.pdf", b"%PDF")
result = RuleRunner(session_factory, manager, sleep=lambda _: None).run(rid)
assert result.status is RunStatus.OK, result.message
assert (
(consume / "doc.pdf").read_bytes() == b"%PDF" == (arch / "scans" / "doc.pdf").read_bytes()
)
assert not (scans / "doc.pdf").exists()
def test_unavailable_archive_makes_rule_wait(
setup: tuple[int, Path, Path, Path],
manager: MountManager,
fake_backend: FakeMountBackend,
session_factory: sessionmaker[Session],
) -> None:
rid, scans, consume, _arch = setup
write(scans / "doc.pdf")
fake_backend.vanish(manager.mountpoint_for("archiv"))
result = RuleRunner(session_factory, manager, sleep=lambda _: None).run(rid)
assert result.unavailable and result.status is RunStatus.ERROR
assert (scans / "doc.pdf").exists() and list(consume.iterdir()) == []
def test_mount_used_as_archive_cannot_be_deleted(
setup: tuple[int, Path, Path, Path],
session_factory: sessionmaker[Session],
manager: MountManager,
) -> None:
from netmover.admin import AdminError, delete_mount
from netmover.models import Mount
with session_scope(session_factory) as s:
arch_id = s.scalar(select(Mount.id).where(Mount.name == "archiv"))
assert arch_id is not None
with pytest.raises(AdminError, match="Wird noch von Regeln benutzt"):
delete_mount(s, manager, arch_id)
def test_rule_form_archive_validation(
session_factory: sessionmaker[Session], make_mount: MountFactory
) -> None:
from netmover.admin import parse_rule_form, save_rule
mid = make_mount()
base = {
"name": "x",
"src_mount_id": str(mid),
"dst_kind": "local",
"dst_path": "/srv/consume",
"interval_seconds": "30",
"stability_seconds": "5",
"op_timeout_seconds": "120",
}
with session_scope(session_factory) as s:
_, err = parse_rule_form(
s, {**base, "archive_kind": "local", "archive_path": "/srv/consume"}
)
assert "derselbe Ordner" in err["archive_path"]
_, err = parse_rule_form(s, {**base, "archive_kind": "mount", "archive_path": "a"})
assert "archive_mount_id" in err
_, err = parse_rule_form(s, {**base, "archive_kind": "local", "archive_path": "relativ"})
assert "absoluten Pfad" in err["archive_path"]
d, err = parse_rule_form(
s,
{
**base,
"archive_kind": "mount",
"archive_mount_id": str(mid),
"archive_path": "archiv/scans",
"archive_layout": "date",
},
)
assert err == {}
r, _ = save_rule(s, d)
assert r is not None and r.archive_kind is ArchiveKind.MOUNT
assert r.archive_path == "archiv/scans" and r.archive_layout.value == "date"
+167
View File
@@ -0,0 +1,167 @@
"""Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen."""
from __future__ import annotations
from typing import Any
import pytest
from fastapi.testclient import TestClient
from netmover.settings_store import load_settings, save_settings
from netmover.web.auth import (
ACCESS_DENY,
ACCESS_NOPW,
ACCESS_PW,
access_decision,
includes_loopback,
parse_networks,
)
from tests.webutil import PASSWORD, csrf_of
def _other(client: TestClient, host: str) -> TestClient:
"""Weiterer Client für dieselbe App (ohne eigenen Lebenszyklus) mit fester Adresse."""
return TestClient(client.app, follow_redirects=False, client=(host, 51000))
def _save(c: TestClient, trusted: str = "", allowed: str = "", current: str = PASSWORD) -> Any:
token = (
csrf_of(c.get("/settings").text)
if "X-Test-CSRF" not in c.headers
else c.headers["X-Test-CSRF"]
)
return c.post(
"/settings/auth",
data={
"csrf_token": token,
"trusted_networks": trusted,
"allowed_networks": allowed,
"current": current,
},
)
def _set(state: Any, **kw: str) -> None:
cfg = load_settings(state.session_factory)
for k, v in kw.items():
setattr(cfg, k, v)
save_settings(state.session_factory, cfg)
# -- Entscheidung ------------------------------------------------------------------------------
@pytest.mark.parametrize(
("host", "trusted", "allowed", "expected"),
[
("10.0.10.50", "", "", ACCESS_PW), # Standard: überall mit Passwort
("10.0.10.50", "10.0.10.0/24", "", ACCESS_NOPW),
("10.1.5.9", "10.0.10.0/24", "", ACCESS_PW), # übrige Netze mit Passwort
("10.1.5.9", "10.0.10.0/24", "10.1.5.0/24", ACCESS_PW),
("192.168.5.5", "10.0.10.0/24", "10.1.5.0/24", ACCESS_DENY),
("10.0.10.50", "10.0.10.0/24", "10.1.5.0/24", ACCESS_NOPW), # ohne PW schlägt Liste
("2001:db8::5", "0.0.0.0/0, ::/0", "", ACCESS_NOPW), # Anmeldung ganz aus
("testclient", "", "10.0.0.0/8", ACCESS_DENY), # keine IP-Adresse
],
)
def test_access_decision(host: str, trusted: str, allowed: str, expected: str) -> None:
assert access_decision(host, "always", trusted, allowed) == expected
def test_legacy_off_mode_still_works() -> None:
assert access_decision("192.168.5.5", "off", "", "10.1.5.0/24") == ACCESS_NOPW
# -- Oberfläche --------------------------------------------------------------------------------
def test_default_requires_login(client: TestClient) -> None:
assert _other(client, "10.0.10.50").get("/").status_code == 303
def test_change_needs_current_password(client: TestClient, state: Any) -> None:
_save(client, trusted="10.0.10.0/24", current="falsch")
assert load_settings(state.session_factory).trusted_networks == ""
assert "Aktuelles Passwort falsch" in client.get("/settings").text
def test_without_password_from_trusted_network(client: TestClient, state: Any) -> None:
_save(client, trusted="10.0.10.0/24")
lan = _other(client, "10.0.10.50")
r = lan.get("/")
assert r.status_code == 200 and "ohne Anmeldung (vertrautes Netz)" in r.text
assert "Abmelden" not in r.text and lan.get("/login").headers["location"] == "/"
assert lan.post("/maintenance").status_code == 403 # CSRF gilt weiterhin
assert (
lan.post("/maintenance", data={"csrf_token": csrf_of(lan.get("/").text)}).status_code == 303
)
assert _other(client, "10.1.5.9").get("/").status_code == 303 # übrige mit Passwort
def test_other_networks_are_rejected_when_password_list_is_set(
client: TestClient, state: Any
) -> None:
_save(client, trusted="0.0.0.0/0") # damit der Test-Client (keine IP) weiterkommt …
_set(state, trusted_networks="10.0.10.0/24", allowed_networks="10.1.5.0/24") # … dann scharf
assert _other(client, "10.0.10.50").get("/").status_code == 200
assert _other(client, "10.1.5.9").get("/").headers["location"].startswith("/login")
blocked = _other(client, "192.168.5.5")
for url in ("/", "/login", "/setup", "/mounts"):
assert blocked.get(url).status_code == 403, url
assert blocked.get("/healthz").status_code == 200 # Überwachung bleibt erreichbar
assert blocked.get("/static/app.css").status_code == 200
def test_cannot_lock_yourself_out(client: TestClient, state: Any) -> None:
lan = _other(client, "10.0.10.50")
_save(client, trusted="10.0.10.0/24")
# aus 10.0.10.50 eine Liste speichern, in der das eigene Netz fehlt
r = _save(lan, trusted="", allowed="10.1.5.0/24")
assert r.status_code == 303
assert "ausgesperrt" in lan.get("/settings").text
cfg = load_settings(state.session_factory)
assert cfg.trusted_networks == "10.0.10.0/24" and cfg.allowed_networks == ""
# eigenes Netz bei „mit Passwort“ – erlaubt
_save(lan, trusted="10.0.10.0/24", allowed="10.1.5.0/24")
assert load_settings(state.session_factory).allowed_networks == "10.1.5.0/24"
def test_everything_without_password(client: TestClient) -> None:
_save(client, trusted="0.0.0.0/0, ::/0")
r = _other(client, "192.168.77.1").get("/rules")
assert r.status_code == 200 and ">ohne Anmeldung<" in r.text
def test_setup_still_required_without_password(raw_client: TestClient, state: Any) -> None:
_set(state, trusted_networks="0.0.0.0/0")
assert raw_client.get("/").headers["location"] == "/setup"
def test_validation_and_loopback_hint(client: TestClient, state: Any) -> None:
_save(client, trusted="10.0.10.0/24, quatsch")
assert "kein gültiges Netz" in client.get("/settings").text
assert load_settings(state.session_factory).trusted_networks == ""
_save(client, trusted="127.0.0.1")
assert "jede</strong> Anfrage über den Proxy" in client.get("/settings").text
def test_network_parsing() -> None:
nets = parse_networks("10.0.10.0/24; 192.168.1.20 fe80::/10")
assert [str(n) for n in nets] == ["10.0.10.0/24", "192.168.1.20/32", "fe80::/10"]
assert includes_loopback("10.0.0.0/8, 127.0.0.0/8") and not includes_loopback("10.0.0.0/8")
with pytest.raises(ValueError):
parse_networks("10.0.0.0/33")
def test_reset_password_turns_login_back_on(
state: Any, settings: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
from netmover import cli
from netmover.web.auth import set_password
set_password(state.session_factory, PASSWORD)
_set(state, auth_mode="off", trusted_networks="0.0.0.0/0", allowed_networks="10.1.5.0/24")
monkeypatch.setattr(cli, "get_settings", lambda: settings)
assert cli.main(["reset-password"]) == 0
cfg = load_settings(state.session_factory)
assert cfg.auth_mode == "always" and cfg.trusted_networks == "" and cfg.allowed_networks == ""
+383
View File
@@ -0,0 +1,383 @@
"""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.credentials import (
delete_key,
fingerprint,
generate_key,
import_key,
list_keys,
rename_key,
)
from netmover.crypto import SecretBox, generate_ssh_keypair
from netmover.db import session_scope
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="existieren 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
# -- Gespeicherte Passwörter (0.5.0) ----------------------------------------------------------
def _cifs(**kw: object) -> MountInput:
data: dict[str, object] = {
"name": "nas",
"type": MountType.CIFS,
"server": "10.0.10.6",
"share": "scans",
}
data.update(kw)
return MountInput(**data) # type: ignore[arg-type]
def test_stored_password_shared_by_mounts(
session: Session, secretbox: SecretBox, manager: MountManager
) -> None:
from netmover.credentials import create_password, list_passwords, update_password
cred = create_password(session, secretbox, "NAS-Scanner", "scanner", "Pa$$,wort", "WG")
a, err = save_mount(session, secretbox, manager, _cifs(cred_id=str(cred.id)))
assert not err and a is not None
b, err = save_mount(
session, secretbox, manager, _cifs(name="archiv", share="archiv", cred_id=str(cred.id))
)
assert not err and b is not None
assert a.credential is cred and b.credential is cred and a.username is None
assert list_passwords(session)[0].used_by == ["archiv", "nas"]
# ein neues Passwort gilt für beide
update_password(session, secretbox, cred.id, "scanner", "neu!", "WG")
from netmover.admin import credential_for_test, mount_input_from_model
for m in (a, b):
data = mount_input_from_model(m)
assert data.cred_id == str(cred.id) and data.username == "scanner"
c = credential_for_test(data, m, secretbox, session)
assert c is not None and c.secret == "neu!" and c.domain == "WG"
# Anbindung löschen lässt die gespeicherten Zugangsdaten stehen
delete_mount(session, manager, a.id)
from netmover.credentials import delete_credential
with pytest.raises(AdminError, match="noch benutzt"):
delete_credential(session, cred.id)
delete_mount(session, manager, b.id)
assert delete_credential(session, cred.id) == "NAS-Scanner"
def test_switch_from_own_to_stored_password_cleans_up(
session: Session, secretbox: SecretBox, manager: MountManager
) -> None:
from netmover.credentials import create_password
m, _ = save_mount(session, secretbox, manager, _cifs(username="alt", password="pw"))
assert m is not None and m.credential is not None
own_id = m.credential.id
cred = create_password(session, secretbox, "geteilt", "scanner", "x")
m, err = save_mount(session, secretbox, manager, _cifs(cred_id=str(cred.id)), m.id)
assert not err and m is not None and m.credential is cred
assert session.get(Credential, own_id) is None
# zurück auf eigene Angaben: gespeicherter Eintrag bleibt unverändert
m, err = save_mount(session, secretbox, manager, _cifs(username="eigen", password="pw2"), m.id)
assert not err and m is not None and not m.credential.managed # type: ignore[union-attr]
assert cred.username == "scanner" and secretbox.decrypt(cred.secret_enc) == "x"
def test_stored_password_validation(session: Session, secretbox: SecretBox) -> None:
from netmover.credentials import create_password, update_password
with pytest.raises(AdminError, match="Passwort fehlt"):
create_password(session, secretbox, "a", "u", "")
with pytest.raises(AdminError):
create_password(session, secretbox, "a", "u", "zeile\numbruch")
cred = create_password(session, secretbox, "a", "u", "pw")
update_password(session, secretbox, cred.id, "u2", "") # leer = unverändert
assert cred.username == "u2" and secretbox.decrypt(cred.secret_enc) == "pw"
def test_stored_password_mount_actually_mounts(
session_factory: sessionmaker[Session],
secretbox: SecretBox,
manager: MountManager,
fake_backend: object,
) -> None:
from netmover.credentials import create_password
fake_backend.export("10.0.10.6", "scans") # type: ignore[attr-defined]
with session_scope(session_factory) as s:
cred = create_password(s, secretbox, "NAS", "scanner", "Pa$$wort", "WG")
m, _ = save_mount(s, secretbox, manager, _cifs(cred_id=str(cred.id)))
assert m is not None
mid = m.id
manager.mount(mid)
spec = fake_backend.calls[-1].spec # type: ignore[attr-defined]
assert spec.credentials.username == "scanner" and spec.credentials.password == "Pa$$wort"
assert spec.credentials.domain == "WG"
+108 -7
View File
@@ -240,6 +240,9 @@ def test_source_not_deletable_keeps_target_and_prevents_duplicate(
) -> None:
if path == "copy":
monkeypatch.setattr(LocalDestination, "move_same_device", lambda *a, **k: None)
else:
# Dateisystem ohne renameat2 → link + unlink, unlink scheitert
monkeypatch.setattr("netmover.mover.destinations.rename_noreplace", lambda *a: False)
write(src / "scan.pdf")
real_unlink = os.unlink
@@ -248,7 +251,7 @@ def test_source_not_deletable_keeps_target_and_prevents_duplicate(
raise PermissionError(errno.EACCES, "Permission denied", str(path))
real_unlink(path) # type: ignore[arg-type]
monkeypatch.setattr("netmover.mover.engine.os.unlink", deny)
monkeypatch.setattr("netmover.mover.sources.os.unlink", deny)
first = run(engine, snap(rule_id), src, dst)
assert first.items[0].status is ItemStatus.FAILED
assert "nicht löschbar" in (first.items[0].message or "")
@@ -637,22 +640,24 @@ def test_local_destination_must_exist(
def test_fsync_before_final_rename(
engine: Engine, rule_id: int, src: Path, dst: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from netmover.mover import fsops
events: list[str] = []
real_fsync, real_link = os.fsync, os.link
real_fsync, real_rename = os.fsync, fsops.rename_noreplace
def fsync(fd: int) -> None:
events.append("fsync")
real_fsync(fd)
def link(a: object, b: object, *args: object, **kw: object) -> None:
events.append(f"link:{Path(str(b)).name}")
real_link(a, b) # type: ignore[arg-type]
def rename(a: object, b: object) -> bool:
events.append(f"rename:{Path(str(b)).name}")
return real_rename(a, b) # type: ignore[arg-type]
monkeypatch.setattr("netmover.mover.destinations.os.fsync", fsync)
monkeypatch.setattr("netmover.mover.destinations.os.link", link)
monkeypatch.setattr("netmover.mover.destinations.rename_noreplace", rename)
write(src / "a.pdf")
run(engine, snap(rule_id), src, dst)
assert events.index("fsync") < events.index("link:a.pdf")
assert events.index("fsync") < events.index("rename:a.pdf")
@pytest.mark.usefixtures("cross_device")
@@ -678,3 +683,99 @@ def test_name_taken_between_check_and_link_is_never_overwritten(
assert result.items[0].dst == str(dst / "scan-1.pdf")
assert (dst / "scan.pdf").read_text() == "fremd"
assert (dst / "scan-1.pdf").read_text() == "neu"
# -- renameat2(RENAME_NOREPLACE) ----------------------------------------------------------
def test_rename_noreplace(tmp_path: Path) -> None:
from netmover.mover.fsops import rename_noreplace
a, b = write(tmp_path / "a", "neu"), write(tmp_path / "b", "alt")
ino = a.stat().st_ino
if not rename_noreplace(a, tmp_path / "c"):
pytest.skip("Dateisystem des Testrechners kann kein renameat2")
assert (tmp_path / "c").stat().st_ino == ino and not a.exists() # echtes rename
with pytest.raises(FileExistsError):
rename_noreplace(tmp_path / "c", b)
assert b.read_text() == "alt" and (tmp_path / "c").exists()
@pytest.mark.usefixtures("cross_device")
@pytest.mark.parametrize("supported", [True, False])
def test_final_step_is_a_real_rename_when_possible(
engine: Engine,
rule_id: int,
src: Path,
dst: Path,
monkeypatch: pytest.MonkeyPatch,
supported: bool,
) -> None:
"""Watcher wie Paperless sehen ein rename (IN_MOVED_TO); ohne Unterstützung
bleibt es beim link-Rückfallweg – überschrieben wird in keinem Fall."""
links: list[object] = []
real_link = os.link
def spy_link(a: object, b: object, *args: object, **kw: object) -> None:
links.append(b)
real_link(a, b) # type: ignore[arg-type]
monkeypatch.setattr("netmover.mover.destinations.os.link", spy_link)
if not supported:
monkeypatch.setattr("netmover.mover.destinations.rename_noreplace", lambda *a: False)
write(dst / "a.pdf", "alt")
write(src / "a.pdf", "neu")
result = run(engine, snap(rule_id), src, dst)
assert result.items[0].dst == str(dst / "a-1.pdf")
assert (dst / "a.pdf").read_text() == "alt" and (dst / "a-1.pdf").read_text() == "neu"
assert bool(links) is not supported
assert [p.name for p in dst.iterdir() if p.name.endswith(".part")] == []
# -- Praxisbefund: veralteter Attribut-Cache (CIFS/NFS) -------------------------------------
def test_stability_uses_fresh_attributes_not_stat_cache(
engine: Engine,
rule_id: int,
src: Path,
dst: Path,
hooks: list[Callable[[], None]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Auf dem Testhost lieferte CIFS per stat() sekundenlang eine veraltete Größe,
während die Datei wuchs. Simuliert: os.stat liefert für die Datei immer den
alten Stand (Cache), nur öffnen + fstat sieht die Wahrheit."""
growing = write(src / "langsam.pdf", "a" * 10)
stale = os.stat(growing)
real_stat = os.stat
def cached_stat(path: object, *a: object, **kw: object) -> os.stat_result:
if str(path) == str(growing):
return stale
return real_stat(path, *a, **kw) # type: ignore[arg-type]
monkeypatch.setattr(os, "stat", cached_stat)
hooks.append(lambda: growing.write_text("a" * 20))
result = run(engine, snap(rule_id), src, dst)
assert statuses(result) == {"langsam.pdf": ItemStatus.SKIPPED}
assert "wird noch geschrieben" in (result.items[0].message or "")
assert growing.exists() and not (dst / "langsam.pdf").exists()
def test_locked_file_counts_as_in_use(
engine: Engine, rule_id: int, src: Path, dst: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
f = write(src / "gesperrt.pdf")
real_open = os.open
def locked(path: object, flags: int, *a: object, **kw: object) -> int:
if str(path) == str(f):
raise PermissionError(errno.EACCES, "Permission denied", str(path))
return real_open(path, flags, *a, **kw) # type: ignore[arg-type]
monkeypatch.setattr("netmover.mover.scan.os.open", locked)
result = run(engine, snap(rule_id), src, dst)
assert statuses(result) == {"gesperrt.pdf": ItemStatus.SKIPPED}
assert result.items[0].message == "Datei ist gerade in Benutzung"
assert f.exists()
+41
View File
@@ -110,8 +110,49 @@ def test_is_mounted_reads_mountinfo(tmp_path: Path) -> None:
),
("nfs4", "mount.nfs4: access denied by server while mounting nas:/x", "NFS-Berechtigungen"),
("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"),
],
)
def test_explain(fstype: str, msg: str, expect: str) -> None:
assert expect in explain_mount_error(fstype, msg)
def _alive(pid: int) -> bool:
try:
with open(f"/proc/{pid}/stat") as fh:
return fh.read().split(")")[-1].split()[0] != "Z"
except (FileNotFoundError, ProcessLookupError): # verschwindet beim Lesen → ESRCH
return False
def test_timeout_kills_whole_process_group(tmp_path: Path) -> None:
"""mount startet Helfer (mount.nfs) – die dürfen nach dem Zeitlimit nicht weiterlaufen."""
from netmover.mounting.linux import run_process_group
pidfile = tmp_path / "helper.pid"
script = f"sleep 30 & echo $! > {pidfile}; wait"
with pytest.raises(subprocess.TimeoutExpired):
run_process_group(["sh", "-c", script], capture_output=True, text=True, timeout=0.5)
helper = int(pidfile.read_text())
for _ in range(20):
if not _alive(helper):
break
import time
time.sleep(0.05)
assert not _alive(helper)
def test_process_group_runner_returns_output() -> None:
from netmover.mounting.linux import run_process_group
r = run_process_group(
["sh", "-c", "echo out; echo err >&2; exit 3"], capture_output=True, text=True, timeout=5
)
assert (r.returncode, r.stdout, r.stderr) == (3, "out\n", "err\n")
+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
+156
View File
@@ -367,3 +367,159 @@ def test_usage(
manager.mount(mid)
usage = manager.usage(mid)
assert usage is not None and usage[0] >= usage[1] > 0
# -- Praxisbefund: NAS-Neustart dauert länger als die Wiederherstellung ----------------------
class Clock:
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
@pytest.fixture
def clock() -> Clock:
return Clock()
@pytest.fixture
def retry_manager(
session_factory: sessionmaker[Session],
fake_backend: FakeMountBackend,
secretbox: object,
mount_base: Path,
notifications: list[tuple[str, str, str]],
clock: Clock,
) -> MountManager:
return MountManager(
session_factory,
fake_backend,
secretbox,
mount_base, # type: ignore[arg-type]
probe_timeout=0.5,
recover_backoff=(0, 0, 0),
sleep=lambda _: None,
notifier=lambda *a: notifications.append(a),
retry_interval=60,
clock=clock,
)
def test_failed_recovery_retries_every_interval_until_nas_is_back(
retry_manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
notifications: list[tuple[str, str, str]],
clock: Clock,
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount()
retry_manager.mount(mid)
mp = retry_manager.mountpoint_for("scans")
fake_backend.inject_fault(mp, errno.ESTALE)
fake_backend.fail_next_mount(times=3) # NAS startet neu
with pytest.raises(MountUnavailable, match="alle 60 s"):
retry_manager.check_alive(mid)
assert not fake_backend.is_mounted(mp)
mounts_before = sum(1 for c in fake_backend.calls if c.action == "mount")
clock.now += 30 # zu früh: kein Versuch, Lauf abgebrochen
with pytest.raises(MountUnavailable, match="in 30 s"):
retry_manager.check_alive(mid)
assert sum(1 for c in fake_backend.calls if c.action == "mount") == mounts_before
clock.now += 30 # NAS noch nicht zurück
fake_backend.fail_next_mount()
with pytest.raises(MountUnavailable, match="weiterhin nicht verfügbar"):
retry_manager.check_alive(mid)
clock.now += 60 # NAS wieder da
assert retry_manager.check_alive(mid) == mp
assert fake_backend.is_mounted(mp)
assert _state(session_factory, mid).state is MountState.MOUNTED
assert notifications[-1][0] == "mount.recovered"
def test_no_retry_without_auto_mount(
retry_manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
clock: Clock,
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount(auto_mount=False)
retry_manager.mount(mid)
fake_backend.inject_fault(retry_manager.mountpoint_for("scans"), errno.ESTALE)
fake_backend.fail_next_mount(times=3)
with pytest.raises(MountUnavailable):
retry_manager.check_alive(mid)
clock.now += 600
with pytest.raises(MountUnavailable, match="nicht eingehängt"):
retry_manager.check_alive(mid)
def test_externally_unmounted_is_still_never_remounted(
retry_manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
clock: Clock,
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount()
retry_manager.mount(mid)
fake_backend.vanish(retry_manager.mountpoint_for("scans"))
clock.now += 600
with pytest.raises(MountUnavailable, match="nicht eingehängt"):
retry_manager.check_alive(mid)
assert [c.action for c in fake_backend.calls] == ["mount"]
def test_hint_text_is_not_redacted() -> None:
from netmover.mounting.manager import _redact_output
msg = "mount error(13): Permission denied falsch\n→ Benutzername/Passwort falsch oder …"
out = _redact_output(msg, ["falsch"])
assert out.startswith("mount error(13): Permission denied ••••••")
assert out.endswith("→ Benutzername/Passwort falsch oder …")
def test_restore_failure_at_boot_is_retried_by_background_job(
retry_manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
notifications: list[tuple[str, str, str]],
clock: Clock,
) -> None:
"""Stromausfall: die VM bootet schneller als das NAS."""
fake_backend.export("nas", "scans")
mid = make_mount() # keine Regel benutzt die Anbindung
fake_backend.fail_next_mount()
assert [r.ok for r in retry_manager.restore_all()] == [False]
assert retry_manager.retry_pending() == [] # noch nicht fällig
clock.now += 60
assert retry_manager.retry_pending() == ["scans"]
assert fake_backend.is_mounted(retry_manager.mountpoint_for("scans"))
assert _state(session_factory, mid).state is MountState.MOUNTED
assert notifications[-1][0] == "mount.recovered"
clock.now += 60
assert retry_manager.retry_pending() == [] # erledigt
def test_manual_unmount_stops_retrying(
retry_manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
clock: Clock,
) -> None:
mid = make_mount() # nicht exportiert → Wiederherstellung scheitert
retry_manager.restore_all()
retry_manager.unmount(mid)
fake_backend.export("nas", "scans")
clock.now += 120
assert retry_manager.retry_pending() == []
assert not fake_backend.mounted
+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
+395
View File
@@ -0,0 +1,395 @@
"""Protokollierung, Regelzähler, Benachrichtigung, Aufräumen, Health (Fallstrick 9)."""
from __future__ import annotations
import json
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 (
FileFailure,
ItemStatus,
Mount,
MountState,
Rule,
Run,
RunItem,
RunStatus,
RunTrigger,
utcnow,
)
from netmover.mover.engine import ItemResult, RunResult
from netmover.mover.runner import RuleRunner
from netmover.notify import Notification, Notifier, render
from netmover.runs import RunService, cleanup
from netmover.settings_store import AppSettings, NotifyMode, load_settings, save_settings
from tests.conftest import MountFactory
class FakeRunner:
def __init__(self) -> None:
self.results: list[RunResult] = []
def run(self, rule_id: int, *, dry_run: bool | None = None) -> RunResult:
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
@pytest.fixture
def sent() -> list[tuple[str, str, str]]:
return []
@pytest.fixture
def notifier(session_factory: sessionmaker[Session], sent: list[tuple[str, str, str]]) -> Notifier:
return Notifier(session_factory, sender=lambda *a: sent.append(a), background=False)
@pytest.fixture
def fake_runner() -> FakeRunner:
return FakeRunner()
@pytest.fixture
def service(
session_factory: sessionmaker[Session], fake_runner: FakeRunner, notifier: Notifier
) -> RunService:
return RunService(session_factory, fake_runner, notifier) # type: ignore[arg-type]
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 _enable_webhook(sf: sessionmaker[Session], mode: NotifyMode, after: int = 3) -> None:
save_settings(
sf, AppSettings(webhook_url="http://hook.invalid/x", notify_mode=mode, notify_after=after)
)
def ok() -> RunResult:
return RunResult(
RunStatus.OK, seen=1, moved=1, items=[ItemResult("a.pdf", ItemStatus.MOVED, "/x/a.pdf", 3)]
)
def err() -> RunResult:
return RunResult(RunStatus.ERROR, message="„scans“ ist nicht eingehängt")
def partial() -> RunResult:
return RunResult(
RunStatus.PARTIAL,
seen=2,
moved=1,
failed=1,
items=[ItemResult("b.pdf", ItemStatus.FAILED, None, 1, "kaputt (Versuch 1)")],
)
def skipped() -> RunResult:
return RunResult(RunStatus.SKIPPED, message="läuft noch")
def test_run_and_items_are_recorded(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
) -> None:
fake_runner.results.append(ok())
run_id = service.execute(rule_id)
with session_factory() as s:
run = s.get(Run, run_id)
assert run is not None
assert (run.status, run.seen, run.moved, run.trigger) == (
RunStatus.OK,
1,
1,
RunTrigger.SCHEDULE,
)
assert run.finished_at is not None and run.finished_at >= run.started_at
items = s.scalars(select(RunItem)).all()
assert [(i.src, i.dst, i.status) for i in items] == [
("a.pdf", "/x/a.pdf", ItemStatus.MOVED)
]
r = _rule(session_factory, rule_id)
assert r.last_success_at is not None and r.consecutive_failures == 0
def test_counters(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
) -> None:
fake_runner.results += [err(), partial(), skipped()]
service.execute(rule_id)
r = _rule(session_factory, rule_id)
assert r.consecutive_failures == 1 and r.last_success_at is None and r.last_run_at is not None
service.execute(rule_id)
r = _rule(session_factory, rule_id)
assert r.consecutive_failures == 2 and r.last_success_at is not None # partial: Werkzeug lebt
last_run = r.last_run_at
service.execute(rule_id) # übersprungen: protokolliert, Zähler unverändert
r = _rule(session_factory, rule_id)
assert r.consecutive_failures == 2 and r.last_run_at == last_run
with session_factory() as s:
assert [x.status for x in s.scalars(select(Run).order_by(Run.id))] == [
RunStatus.ERROR,
RunStatus.PARTIAL,
RunStatus.SKIPPED,
]
def test_manual_dry_run_does_not_touch_counters(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
) -> None:
fake_runner.results.append(RunResult(RunStatus.ERROR, dry_run=True, message="x"))
service.execute(rule_id, RunTrigger.MANUAL, dry_run=True)
r = _rule(session_factory, rule_id)
assert r.consecutive_failures == 0 and r.last_run_at is None
def test_deleted_rule_is_not_recorded(service: RunService, fake_runner: FakeRunner) -> None:
fake_runner.results.append(err())
assert service.execute(4711) is None
def test_items_are_capped(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("netmover.runs.MAX_ITEMS_PER_RUN", 3)
fake_runner.results.append(
RunResult(RunStatus.OK, items=[ItemResult(f"{i}.pdf", ItemStatus.MOVED) for i in range(5)])
)
run_id = service.execute(rule_id)
with session_factory() as s:
assert len(s.scalars(select(RunItem)).all()) == 3
run = s.get(Run, run_id)
assert run is not None and "2 weitere" in (run.message or "")
# -- Benachrichtigung -------------------------------------------------------------------------
def test_notify_every_failure(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
sent: list[tuple[str, str, str]],
) -> None:
_enable_webhook(session_factory, NotifyMode.EVERY)
fake_runner.results += [err(), err(), ok()]
for _ in range(3):
service.execute(rule_id)
bodies = [json.loads(b) for _, _, b in sent]
assert [b["event"] for b in bodies] == ["rule.failed", "rule.failed", "rule.recovered"]
assert bodies[0]["message"] == "„scans“ ist nicht eingehängt"
assert "2× in Folge" in bodies[1]["title"]
def test_notify_after_n_only_once_at_threshold(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
sent: list[tuple[str, str, str]],
) -> None:
_enable_webhook(session_factory, NotifyMode.AFTER_N, after=3)
fake_runner.results += [err(), err(), err(), err(), partial(), ok()]
events = []
for _ in range(6):
service.execute(rule_id)
events.append(len(sent))
assert events == [0, 0, 1, 1, 1, 2]
assert json.loads(sent[-1][2])["event"] == "rule.recovered"
def test_no_recovery_message_below_threshold(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
sent: list[tuple[str, str, str]],
) -> None:
_enable_webhook(session_factory, NotifyMode.AFTER_N, after=3)
fake_runner.results += [err(), ok()]
service.execute(rule_id)
service.execute(rule_id)
assert sent == []
def test_notify_off_by_default(
service: RunService, fake_runner: FakeRunner, rule_id: int, sent: list[tuple[str, str, str]]
) -> None:
fake_runner.results.append(err())
service.execute(rule_id)
assert sent == []
def test_render_json_escapes_values() -> None:
n = Notification("rule.failed", 'Titel mit "Anführung"', "Zeile 1\nZeile 2 \\ $x", "r")
body = render('{"t": "$title", "m": "$message", "x": "$unbekannt"}', "application/json", n)
data = json.loads(body)
assert data == {"t": 'Titel mit "Anführung"', "m": "Zeile 1\nZeile 2 \\ $x", "x": "$unbekannt"}
assert render("$message", "text/plain", n) == "Zeile 1\nZeile 2 \\ $x"
def test_sender_failure_is_swallowed(session_factory: sessionmaker[Session]) -> None:
def broken(*a: object) -> None:
raise ConnectionError("weg")
_enable_webhook(session_factory, NotifyMode.EVERY)
Notifier(session_factory, sender=broken, background=False).send(Notification("x", "t", "m"))
def test_settings_roundtrip(session_factory: sessionmaker[Session]) -> None:
assert load_settings(session_factory) == AppSettings()
save_settings(session_factory, AppSettings(retention_days=7, notify_mode=NotifyMode.EVERY))
loaded = load_settings(session_factory)
assert loaded.retention_days == 7 and loaded.notify_mode is NotifyMode.EVERY
# -- Aufräumen ---------------------------------------------------------------------------------
def test_cleanup_removes_old_runs_with_items(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
) -> None:
fake_runner.results += [ok(), ok()]
old = service.execute(rule_id)
new = service.execute(rule_id)
with session_scope(session_factory) as s:
run = s.get(Run, old)
assert run is not None
run.started_at = utcnow() - timedelta(days=31)
s.add(
FileFailure(
rule_id=rule_id,
src_relpath="x",
size=1,
mtime_ns=1,
attempts=1,
last_attempt_at=utcnow() - timedelta(days=40),
)
)
stats = cleanup(session_factory)
assert stats == {"runs": 1, "file_failures": 1}
with session_factory() as s:
assert [r.id for r in s.scalars(select(Run))] == [new]
assert len(s.scalars(select(RunItem)).all()) == 1
# -- Health (Fallstrick 9) -------------------------------------------------------------------
def _health(sf: sessionmaker[Session], started_ago: float = 3600, **kw: object): # type: ignore[no-untyped-def]
now = utcnow()
args: dict[str, object] = {
"scheduler_running": True,
"last_heartbeat": now,
"service_started": now - timedelta(seconds=started_ago),
"now": now,
}
args.update(kw)
return check_health(sf, **args) # type: ignore[arg-type]
def _age_rule(sf: sessionmaker[Session], rid: int, seconds: float | None) -> None:
with session_scope(sf) as s:
r = s.get(Rule, rid)
assert r is not None
r.created_at = utcnow() - timedelta(days=1)
r.last_success_at = None if seconds is None else utcnow() - timedelta(seconds=seconds)
def test_health_ok(session_factory: sessionmaker[Session], rule_id: int) -> None:
_age_rule(session_factory, rule_id, 60) # 2× Intervall
h = _health(session_factory)
assert h.ok, h.problems
def test_health_stale_rule_after_three_intervals(
session_factory: sessionmaker[Session], rule_id: int
) -> None:
_age_rule(session_factory, rule_id, 91)
h = _health(session_factory)
assert not h.ok and h.stale_rules == {rule_id}
assert "kein erfolgreicher Lauf" in h.problems[0]
def test_health_never_succeeded_but_service_just_started(
session_factory: sessionmaker[Session], rule_id: int
) -> None:
_age_rule(session_factory, rule_id, None)
assert _health(session_factory, started_ago=30).ok
assert not _health(session_factory, started_ago=120).ok
def test_health_disabled_rule_is_ignored(
session_factory: sessionmaker[Session], rule_id: int
) -> None:
_age_rule(session_factory, rule_id, None)
with session_scope(session_factory) as s:
r = s.get(Rule, rule_id)
assert r is not None
r.enabled = False
assert _health(session_factory).ok
def test_health_mount_error_only_for_enabled_mounts(
session_factory: sessionmaker[Session], make_mount: MountFactory
) -> None:
mid = make_mount("b", share="b", enabled=False)
with session_scope(session_factory) as s:
m = s.get(Mount, mid)
assert m is not None
m.state = MountState.ERROR
assert _health(session_factory).ok
with session_scope(session_factory) as s:
m = s.get(Mount, mid)
assert m is not None
m.enabled = True
h = _health(session_factory)
assert not h.ok and "Fehlerzustand" in h.problems[0]
def test_health_scheduler(session_factory: sessionmaker[Session]) -> None:
assert not _health(session_factory, scheduler_running=False).ok
assert not _health(session_factory, last_heartbeat=utcnow() - timedelta(minutes=5)).ok
def test_runner_type_is_compatible(session_factory: sessionmaker[Session], tmp_path: Path) -> None:
# FakeRunner oben muss dieselbe Schnittstelle bieten wie RuleRunner.run
assert RuleRunner.run.__code__.co_kwonlyargcount == 1
+177
View File
@@ -0,0 +1,177 @@
"""Scheduler: Jobs je Regel, Versatz, Neuabgleich, manuelle Läufe, Dienststart."""
from __future__ import annotations
import asyncio
import random
from collections.abc import Callable, Coroutine
from datetime import timedelta
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.app_state import NetMover
from netmover.config import Settings
from netmover.db import session_scope
from netmover.models import Mount, MountState, MountType, Rule, Run, RunStatus, RunTrigger, utcnow
from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.manager import MountManager
from netmover.mover.runner import RuleRunner
from netmover.runs import RunService
from netmover.scheduler import MAX_START_OFFSET, RuleScheduler
from tests.conftest import MountFactory
def arun(fn: Callable[[], Coroutine[Any, Any, None]]) -> None:
asyncio.run(fn())
@pytest.fixture
def rules(
make_mount: MountFactory,
session_factory: sessionmaker[Session],
fake_backend: FakeMountBackend,
manager: MountManager,
) -> list[int]:
fake_backend.export("nas", "scans")
mid = make_mount()
manager.mount(mid)
ids = []
with session_scope(session_factory) as s:
for name, interval, enabled in [("a", 30, True), ("b", 60, True), ("c", 30, False)]:
r = Rule(
name=name,
src_mount_id=mid,
dst_path=str(fake_backend.remote_root),
interval_seconds=interval,
enabled=enabled,
stability_seconds=0,
)
s.add(r)
s.flush()
ids.append(r.id)
return ids
@pytest.fixture
def scheduler(session_factory: sessionmaker[Session], manager: MountManager) -> RuleScheduler:
runner = RuleRunner(session_factory, manager, sleep=lambda _: None)
return RuleScheduler(session_factory, RunService(session_factory, runner), rng=random.Random(1)) # noqa: S311
def test_jobs_follow_rules(
scheduler: RuleScheduler, rules: list[int], session_factory: sessionmaker[Session]
) -> None:
a, b, c = rules
async def main() -> None:
scheduler.start()
try:
assert scheduler.next_run(a) is not None
assert scheduler.next_run(b) is not None
assert scheduler.next_run(c) is None
# erster Termin mit kleinem Versatz
for rid in (a, b):
nxt = scheduler.next_run(rid)
assert nxt is not None
# Versatz ≤ 10 s plus Jitter ≤ 5 s
assert nxt - utcnow() <= timedelta(seconds=MAX_START_OFFSET + 5 + 1)
with session_scope(session_factory) as s:
ra, rc = s.get(Rule, a), s.get(Rule, c)
assert ra is not None and rc is not None
ra.enabled = False
rc.enabled = True
rb = s.get(Rule, b)
assert rb is not None
rb.interval_seconds = 120
scheduler.sync()
assert scheduler.next_run(a) is None
assert scheduler.next_run(c) is not None
job = scheduler.scheduler.get_job(f"rule:{b}")
assert job is not None and job.trigger.interval == timedelta(seconds=120)
finally:
scheduler.shutdown()
arun(main)
def test_run_now_records_manual_run(
scheduler: RuleScheduler, rules: list[int], session_factory: sessionmaker[Session]
) -> None:
a = rules[0]
async def main() -> None:
scheduler.start()
try:
run_id = await scheduler.run_now(a, dry_run=True)
finally:
scheduler.shutdown()
with session_factory() as s:
run = s.get(Run, run_id)
assert run is not None
assert run.trigger is RunTrigger.MANUAL and run.dry_run and run.status is RunStatus.OK
arun(main)
def test_scheduled_job_runs_in_thread_pool(
scheduler: RuleScheduler, rules: list[int], session_factory: sessionmaker[Session]
) -> None:
a = rules[0]
async def main() -> None:
scheduler.start()
try:
await scheduler._run_rule(a)
assert scheduler.running and scheduler.last_heartbeat is not None
finally:
scheduler.shutdown()
with session_factory() as s:
runs = s.scalars(select(Run)).all()
assert [(r.rule_id, r.trigger) for r in runs] == [(a, RunTrigger.SCHEDULE)]
arun(main)
def test_app_startup_restores_mounts_before_scheduler(tmp_path: Path) -> None:
settings = Settings(
data_dir=tmp_path / "lib",
run_dir=tmp_path / "run",
mount_base=tmp_path / "mnt",
mount_backend="fake",
)
backend = FakeMountBackend(tmp_path / "fake")
backend.export("nas", "scans")
app = NetMover(settings, backend=backend)
with session_scope(app.session_factory) as s:
s.add(
Mount(
name="scans",
slug="scans",
type=MountType.CIFS,
server="nas",
share="scans",
mountpoint=str((tmp_path / "mnt" / "scans").resolve()),
)
)
assert (tmp_path / "lib" / "secret.key").stat().st_mode & 0o777 == 0o600
async def main() -> None:
app.start()
try:
assert [r.ok for r in app.restore_results] == [True]
assert app.scheduler.running
assert app.health().ok
finally:
app.stop()
arun(main)
# Beim Beenden NICHT aushängen
assert backend.is_mounted((tmp_path / "mnt").resolve() / "scans")
with app.session_factory() as s:
m = s.scalars(select(Mount)).one()
assert m.state is MountState.MOUNTED
+2
View File
@@ -147,6 +147,8 @@ def test_manager_pins_host_key_and_checks_alive(
with session_factory() as s:
mm = s.get(Mount, mid)
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
with session_factory() as s:
+398
View File
@@ -0,0 +1,398 @@
"""0.5.0: SFTP als Quelle – dieselben Garantien wie bei eingehängten Freigaben."""
from __future__ import annotations
import errno
import hashlib
import os
from collections.abc import Callable, Iterator
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.crypto import SecretBox
from netmover.db import session_scope
from netmover.models import (
CopiedFile,
Credential,
CredentialKind,
ItemStatus,
Mount,
MountType,
PatternType,
Rule,
RuleAction,
RunStatus,
)
from netmover.mounting.manager import MountManager
from netmover.mounting.sftp import SftpConfig, connect
from netmover.mover.destinations import LocalDestination, SftpDestination
from netmover.mover.engine import Engine, RuleSnapshot, RunResult
from netmover.mover.ledger import Ledger
from netmover.mover.runner import RuleRunner
from netmover.mover.scan import Matcher
from netmover.mover.sources import SftpSource
from netmover.timeouts import CancelToken
from tests.conftest import MountFactory
from tests.sftp_stub import StubSFTPServer
@pytest.fixture
def server(tmp_path: Path) -> Iterator[StubSFTPServer]:
root = tmp_path / "sftp-root"
for d in ("eingang", "ziel", "consume"):
(root / d).mkdir(parents=True)
srv = StubSFTPServer(root).start()
yield srv
srv.stop()
def _cfg(server: StubSFTPServer) -> SftpConfig:
return SftpConfig(
"127.0.0.1",
server.port,
server.username,
"/",
private_key=server.client_private_key,
host_key=server.host_key_string,
)
@pytest.fixture
def source(server: StubSFTPServer) -> Iterator[SftpSource]:
s = SftpSource(connect(_cfg(server)), "/", "eingang", label="nas2")
yield s
s.close()
@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="r", src_mount_id=mid, dst_path="/x")
s.add(r)
s.flush()
return r.id
@pytest.fixture
def hooks() -> list[Callable[[], None]]:
return []
@pytest.fixture
def engine(session_factory: sessionmaker[Session], hooks: list[Callable[[], None]]) -> Engine:
def sleep(_: float) -> None:
for h in hooks:
h()
return Engine(Ledger(session_factory), sleep=sleep)
def snap(rule_id: int, **kw: object) -> RuleSnapshot:
return RuleSnapshot(id=rule_id, name="r", stability_seconds=2, **kw) # type: ignore[arg-type]
def write(path: Path, data: bytes = b"inhalt") -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
def run(
engine: Engine, rule: RuleSnapshot, src: SftpSource, dest: object, **kw: object
) -> RunResult:
return engine.execute(rule, src, dest, CancelToken(), **kw) # type: ignore[arg-type]
def sha(p: Path) -> str:
return hashlib.sha256(p.read_bytes()).hexdigest()
# -- Scan ----------------------------------------------------------------------------------
def test_scan_filters_like_local_sources(source: SftpSource, server: StubSFTPServer) -> None:
root = server.root / "eingang"
for name in ("a.pdf", "b.PDF", "notiz.txt", ".versteckt.pdf", "halb.pdf.part", "x.tmp"):
write(root / name)
write(root / "max" / "c.pdf")
write(root / "@eaDir" / "a.pdf" / "thumb.pdf")
write(root / "quarantaene" / "d.pdf")
m = Matcher("*.pdf", PatternType.GLOB)
assert [c.relpath for c in source.scan(m, recursive=False)] == ["a.pdf", "b.PDF"]
rec = [
c.relpath
for c in source.scan(m, recursive=True, exclude=frozenset({"eingang/quarantaene"}))
]
assert rec == ["a.pdf", "b.PDF", "max/c.pdf"]
c = source.scan(m, recursive=False)[0]
assert c.path is None and c.state.size == len(b"inhalt")
def test_missing_source_dir_aborts(
engine: Engine, rule_id: int, server: StubSFTPServer, tmp_path: Path
) -> None:
src = SftpSource(connect(_cfg(server)), "/", "gibtsnicht")
try:
result = run(
engine, snap(rule_id), src, LocalDestination(tmp_path / "dst", create_root=True)
)
finally:
src.close()
assert result.status is RunStatus.ERROR and "existiert nicht" in (result.message or "")
# -- Verschieben / Kopieren ----------------------------------------------------------------
def test_move_sftp_to_local(
engine: Engine, rule_id: int, source: SftpSource, server: StubSFTPServer, tmp_path: Path
) -> None:
f = write(server.root / "eingang" / "scan001.pdf", os.urandom(300_000))
digest = sha(f)
dst = tmp_path / "dst"
dst.mkdir()
write(dst / "scan001.pdf", b"alt")
result = run(engine, snap(rule_id), source, LocalDestination(dst))
assert result.status is RunStatus.OK, result.message
assert [(i.src, i.status) for i in result.items] == [("scan001.pdf", ItemStatus.MOVED)]
assert sha(dst / "scan001-1.pdf") == digest and (dst / "scan001.pdf").read_bytes() == b"alt"
assert not f.exists()
assert sorted(p.name for p in dst.iterdir()) == ["scan001-1.pdf", "scan001.pdf"]
def test_move_sftp_to_sftp(
engine: Engine, rule_id: int, source: SftpSource, server: StubSFTPServer
) -> None:
f = write(server.root / "eingang" / "a.pdf", os.urandom(50_000))
digest = sha(f)
dest = SftpDestination(connect(_cfg(server)), "/ziel", label="nas3")
try:
result = run(engine, snap(rule_id), source, dest)
finally:
dest.close()
assert result.items[0].status is ItemStatus.MOVED and result.items[0].dst == "nas3:/ziel/a.pdf"
assert sha(server.root / "ziel" / "a.pdf") == digest and not f.exists()
assert list((server.root / "ziel").glob(".netmover-*")) == []
def test_copy_mode_remembers_versions(
engine: Engine,
rule_id: int,
source: SftpSource,
server: StubSFTPServer,
tmp_path: Path,
session_factory: sessionmaker[Session],
) -> None:
f = write(server.root / "eingang" / "a.pdf", b"v1")
dst = tmp_path / "dst"
dst.mkdir()
rule = snap(rule_id, action=RuleAction.COPY)
for _ in range(3):
run(engine, rule, source, LocalDestination(dst))
assert sorted(p.name for p in dst.iterdir()) == ["a.pdf"] and f.exists()
f.write_bytes(b"version 2") # andere Größe → neue Version
run(engine, rule, source, LocalDestination(dst))
assert sorted(p.name for p in dst.iterdir()) == ["a-1.pdf", "a.pdf"]
with session_factory() as s:
assert len(s.scalars(select(CopiedFile)).all()) == 2
def test_mtime_is_carried_over(
engine: Engine, rule_id: int, source: SftpSource, server: StubSFTPServer, tmp_path: Path
) -> None:
f = write(server.root / "eingang" / "a.pdf")
os.utime(f, (1_600_000_000, 1_600_000_123))
dst = tmp_path / "dst"
dst.mkdir()
run(engine, snap(rule_id, action=RuleAction.COPY), source, LocalDestination(dst))
assert int((dst / "a.pdf").stat().st_mtime) == 1_600_000_123
# -- Fallstricke ---------------------------------------------------------------------------
def test_growing_file_is_not_touched(
engine: Engine,
rule_id: int,
source: SftpSource,
server: StubSFTPServer,
tmp_path: Path,
hooks: list[Callable[[], None]],
) -> None:
growing = write(server.root / "eingang" / "wachsend.pdf", b"x" * 10)
hooks.append(lambda: growing.write_bytes(b"x" * 20))
dst = tmp_path / "dst"
dst.mkdir()
result = run(engine, snap(rule_id), source, LocalDestination(dst))
assert result.items[0].status is ItemStatus.SKIPPED and growing.exists()
assert list(dst.iterdir()) == []
def test_source_changed_during_transfer_keeps_source(
engine: Engine,
rule_id: int,
source: SftpSource,
server: StubSFTPServer,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
f = write(server.root / "eingang" / "a.pdf", b"original")
real = LocalDestination._apply_meta
def change(self: LocalDestination, rel: str, meta: object) -> list[str]:
f.write_bytes(b"geaendert!!")
return real(self, rel, meta) # type: ignore[arg-type]
monkeypatch.setattr(LocalDestination, "_apply_meta", change)
dst = tmp_path / "dst"
dst.mkdir()
result = run(engine, snap(rule_id), source, LocalDestination(dst))
assert result.items[0].status is ItemStatus.SKIPPED and f.exists()
assert list(dst.iterdir()) == []
def test_source_not_deletable_prevents_duplicates(
engine: Engine,
rule_id: int,
source: SftpSource,
server: StubSFTPServer,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
write(server.root / "eingang" / "a.pdf")
def deny(*a: object, **kw: object) -> None:
raise PermissionError(errno.EACCES, "Permission denied")
monkeypatch.setattr(source.client, "remove", deny)
dst = tmp_path / "dst"
dst.mkdir()
first = run(engine, snap(rule_id), source, LocalDestination(dst))
assert first.items[0].status is ItemStatus.FAILED and "nicht löschbar" in (
first.items[0].message or ""
)
second = run(engine, snap(rule_id), source, LocalDestination(dst))
assert second.items == [] and sorted(p.name for p in dst.iterdir()) == ["a.pdf"]
def test_quarantine_on_sftp_source(
engine: Engine,
rule_id: int,
source: SftpSource,
server: StubSFTPServer,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from netmover.mover.destinations import TransferError
def broken(*a: object, **kw: object) -> None:
raise TransferError("kaputt")
monkeypatch.setattr(LocalDestination, "_link_noreplace", broken)
write(server.root / "eingang" / "defekt.pdf")
dst = tmp_path / "dst"
dst.mkdir()
rule = snap(rule_id, quarantine_after=2)
for _ in range(2):
run(engine, rule, source, LocalDestination(dst), quarantine_rel="eingang/_q")
r = run(engine, rule, source, LocalDestination(dst), quarantine_rel="eingang/_q")
assert r.items[0].status is ItemStatus.QUARANTINED
assert r.items[0].dst == "nas2:/eingang/_q/defekt.pdf"
assert (server.root / "eingang" / "_q" / "defekt.pdf").exists()
def test_connection_loss_aborts_run(
engine: Engine,
rule_id: int,
source: SftpSource,
server: StubSFTPServer,
tmp_path: Path,
hooks: list[Callable[[], None]],
) -> None:
write(server.root / "eingang" / "a.pdf")
write(server.root / "eingang" / "b.pdf")
hooks.append(lambda: source.session.transport.close()) # Verbindung bricht weg
dst = tmp_path / "dst"
dst.mkdir()
result = run(engine, snap(rule_id), source, LocalDestination(dst))
assert result.status is RunStatus.ERROR
assert (server.root / "eingang" / "a.pdf").exists() and list(dst.iterdir()) == []
# -- Regellauf mit SFTP-Quelle ------------------------------------------------------------
def test_runner_with_sftp_source(
server: StubSFTPServer,
manager: MountManager,
session_factory: sessionmaker[Session],
secretbox: SecretBox,
tmp_path: Path,
) -> None:
write(server.root / "eingang" / "scan.pdf", b"%PDF")
consume = tmp_path / "consume"
consume.mkdir()
with session_scope(session_factory) as s:
c = Credential(
name="k",
kind=CredentialKind.SSH_KEY,
username=server.username,
secret_enc=secretbox.encrypt(server.client_private_key),
)
s.add(c)
s.flush()
m = Mount(
name="Scanner-Server",
slug="scanner",
type=MountType.SFTP,
server="127.0.0.1",
share="/",
port=server.port,
cred_id=c.id,
host_key=server.host_key_string,
)
s.add(m)
s.flush()
r = Rule(
name="SFTP → lokal",
src_mount_id=m.id,
src_subpath="eingang",
dst_path=str(consume),
stability_seconds=2,
)
s.add(r)
s.flush()
rid = r.id
result = RuleRunner(session_factory, manager, sleep=lambda _: None).run(rid)
assert result.status is RunStatus.OK, result.message
assert result.items[0].dst == str(consume / "scan.pdf")
assert (consume / "scan.pdf").read_bytes() == b"%PDF"
assert not (server.root / "eingang" / "scan.pdf").exists()
def test_rule_form_accepts_sftp_source_with_min_stability(
session_factory: sessionmaker[Session],
) -> None:
from netmover.admin import parse_rule_form
with session_scope(session_factory) as s:
m = Mount(name="sftp", slug="sftp", type=MountType.SFTP, server="10.0.0.1", share="/in")
s.add(m)
s.flush()
form = {
"name": "x",
"src_mount_id": str(m.id),
"dst_kind": "local",
"dst_path": "/tmp",
"interval_seconds": "30",
"stability_seconds": "1",
"op_timeout_seconds": "120",
}
_, errors = parse_rule_form(s, form)
assert "src_mount_id" not in errors and "mindestens 2 s" in errors["stability_seconds"]
_, errors = parse_rule_form(s, {**form, "stability_seconds": "3"})
assert errors == {}
+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
+623
View File
@@ -0,0 +1,623 @@
"""Weboberfläche: Einrichtung, Anmeldung, CSRF, Seiten und Aktionen (FakeMountBackend)."""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from netmover.app_state import NetMover
from netmover.config import Settings
from netmover.db import session_scope
from netmover.models import Credential, Mount, MountState, Rule, Run, RunStatus
from netmover.mounting.fake import FakeMountBackend
from netmover.web.app import create_app
from tests.webutil import PASSWORD, csrf_of, do_setup, post
# -- Einrichtung und Anmeldung --------------------------------------------------------------
def test_first_start_forces_setup(raw_client: TestClient) -> None:
for url in ("/", "/mounts", "/rules", "/log", "/settings", "/login"):
r = raw_client.get(url)
assert r.status_code == 303 and r.headers["location"] == "/setup", url
def test_setup_requires_code_and_strong_password(raw_client: TestClient) -> None:
token = csrf_of(raw_client.get("/setup").text)
code = raw_client.app.state.setup_code.code # type: ignore[attr-defined]
bad = [
{"code": "falsch", "password": PASSWORD, "confirm": PASSWORD},
{"code": code, "password": "kurz", "confirm": "kurz"},
{"code": code, "password": PASSWORD, "confirm": PASSWORD + "x"},
]
for data in bad:
r = raw_client.post("/setup", data={"csrf_token": token, **data})
assert r.status_code == 400
assert raw_client.get("/").status_code == 303
def test_setup_only_once(client: TestClient) -> None:
assert client.get("/setup").headers["location"] == "/login"
def test_login_logout_and_cookie_flags(raw_client: TestClient) -> None:
do_setup(raw_client)
token = csrf_of(raw_client.get("/").text)
raw_client.post("/logout", data={"csrf_token": token})
assert raw_client.get("/").status_code == 303
page = raw_client.get("/login")
r = raw_client.post("/login", data={"csrf_token": csrf_of(page.text), "password": "falsch"})
assert r.status_code == 401
r = raw_client.post("/login", data={"csrf_token": csrf_of(page.text), "password": PASSWORD})
assert r.status_code == 303
cookie = r.headers["set-cookie"].lower()
assert "httponly" in cookie and "samesite=lax" in cookie
assert raw_client.get("/").status_code == 200
def test_login_lockout(raw_client: TestClient) -> None:
do_setup(raw_client)
raw_client.cookies.clear()
token = csrf_of(raw_client.get("/login").text)
codes = [
raw_client.post("/login", data={"csrf_token": token, "password": "x"}).status_code
for _ in range(6)
]
assert codes[:5] == [401] * 5 and codes[5] == 429
def test_csrf_required_for_post(client: TestClient) -> None:
assert client.post("/rules/1/toggle").status_code == 403
assert client.post("/rules/1/toggle", data={"csrf_token": "falsch"}).status_code == 403
assert post(client, "/rules/1/toggle").status_code == 303
r = client.post("/rules/1/toggle", headers={"X-CSRF-Token": client.headers["X-Test-CSRF"]})
assert r.status_code == 303
def test_htmx_request_without_login_gets_hx_redirect(raw_client: TestClient) -> None:
do_setup(raw_client)
raw_client.cookies.clear()
r = raw_client.get("/partials/status", headers={"HX-Request": "true"})
assert r.status_code == 204 and r.headers["HX-Redirect"].startswith("/login")
def test_security_headers(client: TestClient) -> None:
r = client.get("/")
assert "script-src 'self'" in r.headers["content-security-policy"]
assert r.headers["x-frame-options"] == "DENY"
assert r.headers["cache-control"] == "no-store"
def test_no_inline_scripts_or_styles(client: TestClient) -> None:
for url in (
"/",
"/mounts",
"/mounts/new",
"/keys",
"/rules",
"/rules/new",
"/log",
"/settings",
):
html = client.get(url).text
assert "style=" not in html, url
assert not re.search(r"<script(?![^>]*\bsrc=)", html), url
def test_healthz(client: TestClient, state: NetMover) -> None:
r = client.get("/healthz")
assert r.status_code == 200 and r.json()["status"] == "ok"
with session_scope(state.session_factory) as s:
s.add(
Mount(
name="kaputt",
slug="kaputt",
type="cifs",
server="nas",
share="x",
mountpoint="/mnt/netmover/kaputt",
state=MountState.ERROR,
last_error="weg",
)
)
r = client.get("/healthz")
assert r.status_code == 503 and "kaputt" in r.json()["problems"][0]
# -- Anbindungen ------------------------------------------------------------------------------
MOUNT_FORM = {
"name": "Synology Scans",
"type": "cifs",
"server": "nas",
"share": "scans",
"username": "scanner",
"password": "Pa$$w0rd,mit=Zeichen",
"uid": "1000",
"gid": "1000",
"file_mode": "0644",
"dir_mode": "0755",
"extra_options": "vers=3.0",
"auto_mount": "on",
"enabled": "on",
}
def _mount_id(state: NetMover, name: str) -> int:
with state.session_factory() as s:
mid = s.scalar(select(Mount.id).where(Mount.name == name))
assert mid is not None
return mid
def test_create_mount_test_mount_unmount_delete(
client: TestClient, state: NetMover, backend: FakeMountBackend
) -> None:
backend.export("nas", "scans")
r = post(client, "/mounts/new", MOUNT_FORM)
assert r.status_code == 303, r.text
mid = _mount_id(state, "Synology Scans")
with state.session_factory() as s:
m = s.get(Mount, mid)
assert m is not None and m.slug == "synology-scans" and m.credential is not None
assert "Pa$$" not in m.credential.secret_enc
assert state.secrets.decrypt(m.credential.secret_enc) == MOUNT_FORM["password"]
# Passwort taucht nirgends auf
for url in ("/mounts", f"/mounts/{mid}/edit"):
assert MOUNT_FORM["password"] not in client.get(url).text
r = post(client, f"/mounts/{mid}/test")
assert r.status_code == 200 and "Quelle mit Löschen" in r.text and "✓" in r.text
assert post(client, f"/mounts/{mid}/mount").status_code == 303
assert backend.is_mounted(state.mounts.mountpoint_for("synology-scans"))
spec = [c for c in backend.calls if c.action == "mount"][-1].spec
assert spec is not None and spec.credentials is not None
assert spec.credentials.password == MOUNT_FORM["password"]
assert "Fehler" not in client.get("/").text
assert post(client, f"/mounts/{mid}/unmount").status_code == 303
assert not backend.mounted
assert post(client, f"/mounts/{mid}/delete").status_code == 303
with state.session_factory() as s:
assert s.scalars(select(Mount)).all() == []
assert s.scalars(select(Credential)).all() == []
def test_empty_password_keeps_secret(client: TestClient, state: NetMover) -> None:
post(client, "/mounts/new", MOUNT_FORM)
mid = _mount_id(state, "Synology Scans")
r = post(client, f"/mounts/{mid}/edit", {**MOUNT_FORM, "password": "", "username": "neu"})
assert r.status_code == 303
with state.session_factory() as s:
m = s.get(Mount, mid)
assert m is not None and m.credential is not None
assert m.credential.username == "neu"
assert state.secrets.decrypt(m.credential.secret_enc) == MOUNT_FORM["password"]
def test_mount_form_validation_shows_errors(client: TestClient, state: NetMover) -> None:
bad = {**MOUNT_FORM, "server": "nas;reboot", "extra_options": "password=x"}
r = post(client, "/mounts/new", bad)
assert r.status_code == 400
assert "Ungültiger Servername" in r.text and "gesperrt" in r.text
with state.session_factory() as s:
assert s.scalars(select(Mount)).all() == []
def test_connection_change_requires_unmount(
client: TestClient, state: NetMover, backend: FakeMountBackend
) -> None:
backend.export("nas", "scans")
post(client, "/mounts/new", MOUNT_FORM)
mid = _mount_id(state, "Synology Scans")
post(client, f"/mounts/{mid}/mount")
r = post(client, f"/mounts/{mid}/edit", {**MOUNT_FORM, "share": "anders", "password": ""})
assert r.status_code == 400 and "ausgehängt" in r.text
# Nicht-Verbindungsfelder gehen trotzdem
r = post(client, f"/mounts/{mid}/edit", {**MOUNT_FORM, "password": "", "auto_mount": ""})
assert r.status_code == 303
def test_form_test_with_unsaved_data(client: TestClient, backend: FakeMountBackend) -> None:
backend.export("nas", "scans")
r = post(client, "/mounts/test", MOUNT_FORM)
assert r.status_code == 200 and "Einhängen" in r.text and "✓" in r.text
r = post(client, "/mounts/test", {**MOUNT_FORM, "share": "fehlt"})
assert ("✗" in r.text and "existiert auf dem Server nicht" not in r.text) or "✗" in r.text
def test_sftp_mount_generates_key(client: TestClient, state: NetMover) -> None:
r = post(
client,
"/mounts/new",
{
"name": "paperless",
"type": "sftp",
"server": "10.0.0.5",
"share": "/opt/consume",
"port": "22",
"auth": "ssh_key",
"username": "paperless",
"enabled": "on",
},
)
assert r.status_code == 303, r.text
page = client.get("/mounts").text
assert "ssh-ed25519 " in page and "BEGIN OPENSSH PRIVATE KEY" not in page
with state.session_factory() as s:
m = s.scalars(select(Mount)).one()
assert m.mountpoint is None and m.auto_mount is False
def test_delete_mount_used_by_rule_is_refused(client: TestClient, state: NetMover) -> None:
post(client, "/mounts/new", MOUNT_FORM)
mid = _mount_id(state, "Synology Scans")
with session_scope(state.session_factory) as s:
s.add(Rule(name="r", src_mount_id=mid, dst_path="/tmp"))
post(client, f"/mounts/{mid}/delete")
assert "Wird noch von Regeln benutzt" in client.get("/mounts").text
assert _mount_id(state, "Synology Scans") == mid
# -- Regeln -----------------------------------------------------------------------------------
@pytest.fixture
def mounted(
client: TestClient, state: NetMover, backend: FakeMountBackend, tmp_path: Path
) -> tuple[int, Path, Path]:
remote = backend.export("nas", "scans")
post(client, "/mounts/new", MOUNT_FORM)
mid = _mount_id(state, "Synology Scans")
post(client, f"/mounts/{mid}/mount")
consume = tmp_path / "consume"
consume.mkdir()
return mid, remote, consume
def rule_form(mid: int, consume: Path, **kw: str) -> dict[str, str]:
data = {
"name": "Scans → Paperless",
"enabled": "on",
"src_mount_id": str(mid),
"src_subpath": "",
"dst_kind": "local",
"dst_path": str(consume),
"pattern": "*.pdf",
"pattern_type": "glob",
"interval_seconds": "30",
"stability_seconds": "0",
"min_age_seconds": "0",
"op_timeout_seconds": "120",
"action": "move",
"on_collision": "rename",
"quarantine_after": "3",
}
data.update(kw)
return data
def test_rule_validation(client: TestClient, mounted: tuple[int, Path, Path]) -> None:
mid, _, consume = mounted
r = post(
client,
"/rules/new",
rule_form(
mid,
consume,
interval_seconds="5",
pattern_type="regex",
pattern="([",
set_owner="root",
dst_path="relativ",
),
)
assert r.status_code == 400
for msg in ("mindestens 10", "regulärer Ausdruck", "uid:gid", "absoluten Pfad"):
assert msg in r.text
def test_rule_create_dryrun_run(
client: TestClient, state: NetMover, mounted: tuple[int, Path, Path]
) -> None:
mid, remote, consume = mounted
(remote / "scan001.pdf").write_text("pdf")
(remote / "notiz.txt").write_text("txt")
r = post(client, "/rules/new", rule_form(mid, consume))
assert r.status_code == 303, r.text
with state.session_factory() as s:
rid = s.scalars(select(Rule.id)).one()
assert state.scheduler.next_run(rid) is not None
r = post(client, f"/rules/{rid}/dryrun")
assert r.status_code == 200
assert "scan001.pdf" in r.text and str(consume / "scan001.pdf") in r.text
assert "notiz.txt" not in r.text and "nichts verändert" in r.text
assert (remote / "scan001.pdf").exists()
result = state.runs.execute(rid) # „Jetzt ausführen“ synchron für den Test
assert result is not None
assert (consume / "scan001.pdf").exists() and not (remote / "scan001.pdf").exists()
log = client.get("/log").text
assert "Scans → Paperless" in log
with state.session_factory() as s:
run = s.scalars(select(Run).where(Run.dry_run.is_(False))).one()
assert run.status is RunStatus.OK
items = client.get(f"/log/{run.id}/items").text
assert "scan001.pdf" in items and "verschoben" in items
def test_rule_run_now_button(
client: TestClient, state: NetMover, mounted: tuple[int, Path, Path]
) -> None:
mid, _, consume = mounted
post(client, "/rules/new", rule_form(mid, consume))
with state.session_factory() as s:
rid = s.scalars(select(Rule.id)).one()
r = post(client, f"/rules/{rid}/run")
assert r.status_code == 303
assert "Lauf gestartet" in client.get("/").text
def test_rule_toggle_and_delete_sync_scheduler(
client: TestClient, state: NetMover, mounted: tuple[int, Path, Path]
) -> None:
mid, _, consume = mounted
post(client, "/rules/new", rule_form(mid, consume))
with state.session_factory() as s:
rid = s.scalars(select(Rule.id)).one()
post(client, f"/rules/{rid}/toggle")
assert state.scheduler.next_run(rid) is None
post(client, f"/rules/{rid}/toggle")
assert state.scheduler.next_run(rid) is not None
post(client, f"/rules/{rid}/delete")
assert state.scheduler.next_run(rid) is None
def test_status_page_shows_stale_warning(
client: TestClient, state: NetMover, mounted: tuple[int, Path, Path]
) -> None:
from datetime import timedelta
from netmover.models import utcnow
mid, _, consume = mounted
post(client, "/rules/new", rule_form(mid, consume))
with session_scope(state.session_factory) as s:
r = s.scalars(select(Rule)).one()
r.created_at = utcnow() - timedelta(hours=1)
r.last_success_at = utcnow() - timedelta(minutes=10)
state.started_at = utcnow() - timedelta(hours=1)
page = client.get("/").text
assert "länger als 3 × Intervall" in page and 'class="warn"' in page
assert client.get("/healthz").status_code == 503
# -- Einstellungen -------------------------------------------------------------------------------
def test_settings_webhook_url_is_masked(client: TestClient, state: NetMover) -> None:
from netmover.settings_store import load_settings
url = "https://ntfy.example/geheimes-topic-token"
r = post(
client,
"/settings",
{
"retention_days": "14",
"notify_mode": "every",
"notify_after": "3",
"webhook_url": url,
"webhook_content_type": "text/plain",
"webhook_template": "$message",
},
)
assert r.status_code == 303
page = client.get("/settings").text
assert "geheimes-topic-token" not in page and "ntfy.example" in page
# Maske unverändert zurückschicken → URL bleibt
masked = re.search(r'name="webhook_url" value="([^"]*)"', page)
assert masked
post(
client,
"/settings",
{
"retention_days": "14",
"notify_mode": "every",
"notify_after": "3",
"webhook_url": masked.group(1),
"webhook_content_type": "text/plain",
"webhook_template": "$message",
},
)
cfg = load_settings(state.session_factory)
assert cfg.webhook_url == url and cfg.retention_days == 14
def test_change_password(client: TestClient) -> None:
r = post(
client,
"/settings/password",
{"current": "falsch", "password": "neues-passwort-1", "confirm": "neues-passwort-1"},
)
assert "falsch" in client.get("/settings").text
r = post(
client,
"/settings/password",
{"current": PASSWORD, "password": "neues-passwort-1", "confirm": "neues-passwort-1"},
)
assert r.status_code == 303
def test_export_excludes_secrets_and_import_roundtrip(
client: TestClient,
state: NetMover,
mounted: tuple[int, Path, Path],
tmp_path: Path,
) -> None:
mid, _, consume = mounted
post(client, "/rules/new", rule_form(mid, consume))
r = client.get("/settings/export")
assert r.status_code == 200 and "attachment" in r.headers["content-disposition"]
text = r.text
assert MOUNT_FORM["password"] not in text and "scanner" not in text and "secret_enc" not in text
data = json.loads(text)
assert data["mounts"][0]["name"] == "Synology Scans"
assert data["rules"][0]["src_mount"] == "Synology Scans"
# in eine frische Instanz importieren
settings2 = Settings(
data_dir=tmp_path / "lib2",
run_dir=tmp_path / "run2",
mount_base=tmp_path / "mnt2",
mount_backend="fake",
)
state2 = NetMover(settings2, backend=FakeMountBackend(tmp_path / "fake2"))
with TestClient(create_app(settings2, state2), follow_redirects=False) as c2:
c2.headers["X-Test-CSRF"] = do_setup(c2)
r = c2.post(
"/settings/import",
data={"csrf_token": c2.headers["X-Test-CSRF"]},
files={"file": ("export.json", text, "application/json")},
)
assert r.status_code == 303 and r.headers["location"] == "/settings", r.headers
page = c2.get("/settings").text
assert "Importiert" in page and "neu eingeben" in page
with state2.session_factory() as s:
m = s.scalars(select(Mount)).one()
assert m.credential is None and m.auto_mount is False
rule = s.scalars(select(Rule)).one()
assert rule.enabled is False and rule.src_mount_id == m.id
# zweiter Import überspringt vorhandene Namen
c2.post(
"/settings/import",
data={"csrf_token": c2.headers["X-Test-CSRF"]},
files={"file": ("export.json", text, "application/json")},
)
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, "/credentials/keys/generate", {"name": "paperless-lxc"})
assert r.status_code == 303
private, public = generate_ssh_keypair()
r = post(
client,
"/credentials/keys/import",
{"name": "alt", "private_key": private, "passphrase": ""},
)
assert r.status_code == 303
page = client.get("/credentials").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 "gespeicherten Zugangsdaten" 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"/credentials/{key_id}/delete")
assert "noch benutzt" in client.get("/credentials").text
post(client, f"/mounts/{_mount_id(state, 'paperless')}/delete")
post(client, f"/credentials/{key_id}/delete")
page = client.get("/credentials").text
assert "gelöscht" in page and ">alt<" not in page
def test_key_import_error_is_shown(client: TestClient) -> None:
post(
client,
"/credentials/keys/import",
{"name": "x", "private_key": "kein schlüssel", "passphrase": ""},
)
assert "Kein lesbarer privater Schlüssel" in client.get("/credentials").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, "/credentials/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])
def test_credentials_page_passwords(
client: TestClient, state: NetMover, backend: FakeMountBackend
) -> None:
r = post(
client,
"/credentials/passwords",
{"name": "NAS-Scanner", "username": "scanner", "password": "Geheim,123", "domain": ""},
)
assert r.status_code == 303
page = client.get("/credentials").text
assert "NAS-Scanner" in page and "scanner" in page and "Geheim,123" not in page
with state.session_factory() as s:
cid = s.scalar(select(Credential.id).where(Credential.name == "NAS-Scanner"))
form = client.get("/mounts/new").text
assert f'<option value="{cid}"' in form and "eigene Angaben für diese Anbindung" in form
backend.export("nas", "scans")
r = post(
client, "/mounts/new", {**MOUNT_FORM, "username": "", "password": "", "cred_id": str(cid)}
)
assert r.status_code == 303, r.text
assert "gespeicherten Zugangsdaten" in client.get("/mounts").text
mid = _mount_id(state, "Synology Scans")
assert "Quelle mit Löschen" in post(client, f"/mounts/{mid}/test").text
post(
client,
f"/credentials/{cid}/password",
{"username": "scanner2", "domain": "", "password": ""},
)
assert "gilt ab sofort für alle" in client.get("/credentials").text
assert client.get("/keys").headers["location"] == "/credentials"
+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)