7 Commits
Author SHA1 Message Date
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
42 changed files with 3953 additions and 25 deletions
+8
View File
@@ -0,0 +1,8 @@
.venv
.git
data
**/__pycache__
.pytest_cache
.mypy_cache
.ruff_cache
tests
+30
View File
@@ -8,6 +8,10 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
## [Unreleased]
## [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`,
@@ -67,7 +71,33 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
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.1.0...HEAD
[0.1.0]: https://git.wilde-server.de/florian.wilde/netmover/releases/tag/v0.1.0
+67 -1
View File
@@ -11,10 +11,76 @@ Zeiten in Ortszeit (Europe/Berlin).
| 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 |
| 2026-10-10 | 19:10 | 19:17 | 0:07 | 4 | Scheduler, Protokoll, Benachrichtigung |
| **Summe** | | | **0:40** | | |
| 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 |
| **Summe** | | | **1:06** | | |
---
## 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**
+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.
+389 -10
View File
@@ -1,9 +1,364 @@
# 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, lokal oder (nur als Ziel) per SFTP angebunden.
> Status: in Entwicklung (Schritt 4 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.
- [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
cd netmover
sudo ./deploy/install.sh
```
Das Skript installiert `python3-venv`, `cifs-utils` und `nfs-common` und richtet
NetMover in `/opt/netmover/venv` ein. Danach installiert es
[`deploy/netmover.service`](deploy/netmover.service) und startet den Dienst.
Ein Update ist derselbe Aufruf nach einem `git pull`.
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. Die Unit härtet den Dienst so weit, wie es sich mit
Mounten verträgt: `ProtectSystem=full`, `PrivateTmp`, `NoNewPrivileges`, Zustand nur
für root lesbar. Weil diese Optionen einen eigenen Mount-Namespace erzeugen, steht
`MountFlags=shared` in der Unit. Nur so sieht das System die Mounts:
```bash
findmnt -R /mnt/netmover # sollte die eingehängten Freigaben zeigen
```
**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 (nur Ziel) |
| Server / SSH-Port | IP des LXC / `22` |
| Freigabe / Pfad | `/opt/paperless_data/consume` |
| Anmeldeart | SSH-Schlüssel (NetMover erzeugt ihn) |
| Benutzername | `paperless` (der Benutzer, unter dem Paperless läuft) |
Nach dem Speichern zeigt NetMover den **öffentlichen Schlüssel**. Trage ihn im LXC
ein, und zwar so, dass der Schlüssel **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 |
`GET /healthz` liefert `200` oder `503` mit der Liste der Probleme (ohne Anmeldung),
z. B. für 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(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. |
| „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. |
| 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 +367,26 @@ 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`, `transfer.py`, `web/` | Formulare, Export/Import, Oberfläche |
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 +394,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
+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.1.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:
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# NetMover auf Debian 13 installieren bzw. aktualisieren (als root ausführen).
#
# ./deploy/install.sh # aus diesem Verzeichnis installieren
# ./deploy/install.sh 'git+https://git.wilde-server.de/florian.wilde/netmover.git@v0.1.0'
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"
apt-get update -qq
apt-get install -y -qq python3 python3-venv cifs-utils nfs-common >/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 "==> 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
+45
View File
@@ -0,0 +1,45 @@
# 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.
# Die Optionen unten erzeugen einen eigenen Mount-Namespace für den Dienst.
# MountFlags=shared sorgt dafür, dass Mounts unter /mnt/netmover trotzdem im
# System sichtbar sind (prüfen mit: findmnt -R /mnt/netmover).
MountFlags=shared
# /usr, /boot und /etc schreibgeschützt; /mnt, /opt, /srv, /var bleiben schreibbar
# (lokale Ziele wie /opt/paperless/consume funktionieren also).
ProtectSystem=full
PrivateTmp=yes
NoNewPrivileges=yes
ProtectControlGroups=yes
LockPersonality=yes
RestrictRealtime=yes
UMask=0022
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -1,3 +1,3 @@
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
__version__ = "0.1.0.dev0"
__version__ = "0.1.0"
+543
View File
@@ -0,0 +1,543 @@
"""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 (
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 ------------------------------------------------------------------------
@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
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=c.username if c else "",
domain=(c.domain or "") if c else "",
auth=c.kind if c else CredentialKind.PASSWORD,
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"),
)
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.type is MountType.SFTP and not data.username:
errors["username"] = "SFTP braucht einen Benutzernamen."
if data.type is not MountType.SFTP:
data.auth = CredentialKind.PASSWORD
return data, errors
def credential_for_test(
data: MountInput, existing: Mount | None, secrets: SecretBox
) -> CredentialData | None:
"""Zugangsdaten für „Testen“ aus dem Formular – leeres Passwort = gespeichertes."""
if data.type in (MountType.NFS3, MountType.NFS4) or not data.username:
return None
secret = data.password
stored = existing.credential if existing is not None else None
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: jede Anbindung pflegt ihre eigenen.
cred = m.credential
needs_cred = data.type is MountType.SFTP or (
data.type is MountType.CIFS and bool(data.username)
)
if not needs_cred:
if cred is not None:
m.credential = None
s.flush()
if not cred.mounts:
s.delete(cred)
else:
kind_changed = cred is not None and cred.kind is not data.auth
if cred is None or kind_changed:
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}")
s.add(cred)
m.credential = 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
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))
).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()
if cred is not None 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
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,
)
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."
elif not src.type.is_kernel_mount:
errors["src_mount_id"] = "SFTP-Anbindungen sind nur als Ziel möglich."
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."
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)
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",
):
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, {}
+37 -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,36 @@ 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)
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"
+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:
+4 -2
View File
@@ -572,7 +572,7 @@ class MountManager:
self._notify("mount.error", m.name, msg)
raise MountUnavailable(msg)
def usage(self, mount_id: int) -> tuple[int, int] | None:
def usage(self, mount_id: int, timeout: float | None = None) -> tuple[int, int] | None:
"""(gesamt, frei) in Bytes – nur bei eingehängten Kernel-Mounts."""
m, _ = self._load(mount_id)
if not m.type.is_kernel_mount:
@@ -581,7 +581,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
+9 -1
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.
@@ -33,6 +34,7 @@ from typing import Literal
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.timeouts import CancelToken
@@ -283,6 +285,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 +345,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:
+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))
+196
View File
@@ -0,0 +1,196 @@
"""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 (
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",
)
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,
}
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
r = Rule(src_mount_id=src, dst_mount_id=dst)
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.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."""
+957
View File
@@ -0,0 +1,957 @@
"""FastAPI-Anwendung: Middleware, Anmeldung, Seiten.
Seiten werden serverseitig gerendert (Jinja2). HTMX nur dort, wo es echten
Nutzen bringt: Testergebnisse, Probelauf-Tabelle, aufklappbare Protokolle und
das automatische Aktualisieren der Statusseite. Alle Aktionen funktionieren
auch als normale Formulare (Post/Redirect/Get).
"""
from __future__ import annotations
import asyncio
import dataclasses
import json
import math
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from importlib.resources import files
from typing import Any
from fastapi import APIRouter, Depends, FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import func, or_, select
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import UploadFile
from starlette.middleware.sessions import SessionMiddleware
from netmover import __version__
from netmover.admin import (
AdminError,
MountInput,
RuleInput,
credential_for_test,
delete_mount,
mount_input_from_model,
parse_mount_form,
parse_rule_form,
rule_input_from_model,
save_mount,
save_rule,
)
from netmover.app_state import NetMover
from netmover.config import Settings, get_settings
from netmover.db import session_scope
from netmover.logging_setup import event, get_logger
from netmover.models import (
CollisionPolicy,
CredentialKind,
Mount,
MountState,
MountType,
PatternType,
Rule,
RuleAction,
Run,
RunItem,
RunStatus,
utcnow,
)
from netmover.mounting.backend import MountError
from netmover.mounting.report import CheckReport
from netmover.notify import Notification
from netmover.settings_store import AppSettings, NotifyMode, load_settings, save_settings
from netmover.transfer import apply_imported_settings, export_config, import_config
from netmover.web import auth
log = get_logger(__name__)
PAGE_SIZE = 50
STATE_LABELS = {
MountState.MOUNTED: "eingehängt",
MountState.UNMOUNTED: "ausgehängt",
MountState.DEGRADED: "gestört",
MountState.ERROR: "Fehler",
}
SFTP_STATE_LABELS = {
MountState.MOUNTED: "erreichbar",
MountState.UNMOUNTED: "nicht geprüft",
MountState.DEGRADED: "gestört",
MountState.ERROR: "nicht erreichbar",
}
RUN_LABELS = {
RunStatus.OK: "OK",
RunStatus.PARTIAL: "teilweise",
RunStatus.ERROR: "Fehler",
RunStatus.SKIPPED: "übersprungen",
RunStatus.RUNNING: "läuft",
}
# -- Jinja-Filter -----------------------------------------------------------------------
def _ago(value: datetime | None, now: datetime | None = None) -> str:
if value is None:
return "–"
seconds = ((now or datetime.now(UTC)) - value).total_seconds()
future = seconds < 0
seconds = abs(seconds)
if seconds < 60:
text = f"{int(seconds)} s"
elif seconds < 3600:
text = f"{int(seconds // 60)} min"
elif seconds < 86400:
text = f"{seconds / 3600:.1f} h".replace(".", ",")
else:
text = f"{int(seconds // 86400)} Tg."
return f"in {text}" if future else f"vor {text}"
def _localdt(value: datetime | None) -> str:
return value.astimezone().strftime("%d.%m.%Y %H:%M:%S") if value else "–"
def _bytes(value: int | None) -> str:
if value is None:
return "–"
units = ["B", "KB", "MB", "GB", "TB", "PB"]
n = float(value)
i = 0
while n >= 1024 and i < len(units) - 1:
n /= 1024
i += 1
return f"{n:.1f} {units[i]}".replace(".", ",") if i else f"{int(n)} B"
def _duration(start: datetime | None, end: datetime | None) -> str:
if start is None or end is None:
return "–"
s = (end - start).total_seconds()
return f"{s:.1f} s".replace(".", ",") if s < 60 else f"{int(s // 60)} min {int(s % 60)} s"
TEMPLATES = Jinja2Templates(directory=str(files("netmover.web") / "templates"))
TEMPLATES.env.filters.update(ago=_ago, localdt=_localdt, bytes=_bytes)
TEMPLATES.env.globals.update(
version=__version__,
duration=_duration,
STATE_LABELS=STATE_LABELS,
SFTP_STATE_LABELS=SFTP_STATE_LABELS,
RUN_LABELS=RUN_LABELS,
MountType=MountType,
MountState=MountState,
RunStatus=RunStatus,
PatternType=PatternType,
RuleAction=RuleAction,
CollisionPolicy=CollisionPolicy,
CredentialKind=CredentialKind,
NotifyMode=NotifyMode,
)
def flash(request: Request, message: str, kind: str = "ok") -> None:
# Neu zuweisen statt anhängen: die Session erkennt nur Zuweisungen als Änderung.
request.session["flash"] = [*request.session.get("flash", []), [kind, message]]
def render(request: Request, name: str, status_code: int = 200, **ctx: Any) -> HTMLResponse:
flashes = request.session.pop("flash", []) if not name.startswith("partials/") else []
return TEMPLATES.TemplateResponse(
request,
name,
{
"csrf": auth.csrf_token(request),
"flashes": flashes,
"logged_in": auth.is_logged_in(request),
"active": ctx.pop("active", ""),
**ctx,
},
status_code=status_code,
)
def redirect(url: str) -> RedirectResponse:
return RedirectResponse(url, status_code=303)
def nm(request: Request) -> NetMover:
app_state: NetMover = request.app.state.nm
return app_state
def _form_dict(form: Any) -> dict[str, str]:
return {k: v for k, v in form.items() if isinstance(v, str)}
# -- Schutz für alle geschützten Routen ---------------------------------------------------
async def guard(request: Request) -> None:
state = nm(request)
if not auth.password_is_set(state.session_factory):
raise auth.LoginRequired(setup=True)
if not auth.is_logged_in(request):
raise auth.LoginRequired()
await auth.check_csrf(request)
protected = APIRouter(dependencies=[Depends(guard)])
public = APIRouter()
# -- Öffentlich: Health, Einrichtung, Anmeldung --------------------------------------------
@public.get("/healthz")
async def healthz(request: Request) -> JSONResponse:
health = await run_in_threadpool(nm(request).health)
body = {"status": "ok" if health.ok else "error", "problems": health.problems}
return JSONResponse(body, status_code=200 if health.ok else 503)
@public.get("/setup", response_class=HTMLResponse)
async def setup_form(request: Request) -> Response:
if auth.password_is_set(nm(request).session_factory):
return redirect("/login")
return render(request, "setup.html")
@public.post("/setup", response_class=HTMLResponse)
async def setup_submit(request: Request) -> Response:
await auth.check_csrf(request)
state = nm(request)
if auth.password_is_set(state.session_factory):
return redirect("/login")
form = await request.form()
password = str(form.get("password") or "")
problem = auth.password_problem(password, str(form.get("confirm") or ""))
if not request.app.state.setup_code.matches(str(form.get("code") or "")):
problem = (
"Einrichtungscode stimmt nicht – er steht im Log des Dienstes (journalctl -u netmover)."
)
if problem:
return render(request, "setup.html", status_code=400, error=problem)
await run_in_threadpool(auth.set_password, state.session_factory, password)
auth.login_session(request)
event(log, "auth.setup_done")
flash(request, "Passwort gesetzt. Willkommen bei NetMover!")
return redirect("/")
@public.get("/login", response_class=HTMLResponse)
async def login_form(request: Request) -> Response:
if not auth.password_is_set(nm(request).session_factory):
return redirect("/setup")
return render(request, "login.html")
@public.post("/login", response_class=HTMLResponse)
async def login_submit(request: Request) -> Response:
await auth.check_csrf(request)
throttle: auth.LoginThrottle = request.app.state.throttle
client = request.client.host if request.client else "?"
wait = throttle.locked_for(client)
if wait:
return render(
request,
"login.html",
status_code=429,
error=f"Zu viele Fehlversuche – bitte {math.ceil(wait / 60)} min warten.",
)
form = await request.form()
ok = await run_in_threadpool(
auth.verify_password, nm(request).session_factory, str(form.get("password") or "")
)
if not ok:
throttle.failure(client)
event(log, "auth.login_failed", level=30, client=client)
return render(request, "login.html", status_code=401, error="Passwort falsch.")
throttle.success(client)
auth.login_session(request)
event(log, "auth.login", client=client)
target = request.query_params.get("next", "/")
return redirect(target if target.startswith("/") and not target.startswith("//") else "/")
@protected.post("/logout")
async def logout(request: Request) -> Response:
request.session.clear()
return redirect("/login")
# -- Status -----------------------------------------------------------------------------------
@dataclass
class MountView:
m: Mount
usage: tuple[int, int] | None
@dataclass
class RuleView:
r: Rule
last_run: Run | None
next_run: datetime | None
running_since: datetime | None
stale: bool
target: str
def _rule_target(r: Rule) -> str:
src = f"{r.src_mount.name}:/{r.src_subpath}" if r.src_subpath else f"{r.src_mount.name}:/"
if r.dst_mount is None:
dst = r.dst_path
else:
dst = f"{r.dst_mount.name}:/{r.dst_path}" if r.dst_path else f"{r.dst_mount.name}:/"
return f"{src} → {dst}"
async def _status_context(request: Request) -> dict[str, Any]:
state = nm(request)
health = await run_in_threadpool(state.health)
with state.session_factory() as s:
mounts = s.scalars(select(Mount).order_by(Mount.name)).all()
rules = s.scalars(select(Rule).order_by(Rule.name)).all()
last_runs = {
run.rule_id: run
for run in s.scalars(
select(Run).where(
Run.id.in_(
select(func.max(Run.id))
.where(Run.status != RunStatus.SKIPPED)
.group_by(Run.rule_id)
)
)
)
}
rule_views = [
RuleView(
r,
last_runs.get(r.id),
state.scheduler.next_run(r.id),
state.runner.running_since(r.id),
r.id in health.stale_rules,
_rule_target(r),
)
for r in rules
]
for m in mounts:
s.expunge(m)
async def usage(m: Mount) -> tuple[int, int] | None:
if m.state is not MountState.MOUNTED or not m.type.is_kernel_mount:
return None
return await run_in_threadpool(state.mounts.usage, m.id, 2.0)
usages = await asyncio.gather(*(usage(m) for m in mounts))
return {
"health": health,
"mounts": [MountView(m, u) for m, u in zip(mounts, usages, strict=True)],
"rules": rule_views,
"now": utcnow(),
}
@protected.get("/", response_class=HTMLResponse)
async def status_page(request: Request) -> Response:
return render(request, "status.html", active="status", **await _status_context(request))
@protected.get("/partials/status", response_class=HTMLResponse)
async def status_partial(request: Request) -> Response:
return render(request, "partials/status_body.html", **await _status_context(request))
@protected.post("/rules/{rule_id}/run")
async def rule_run_now(request: Request, rule_id: int) -> Response:
state = nm(request)
task = asyncio.create_task(state.scheduler.run_now(rule_id))
request.app.state.tasks.add(task)
task.add_done_callback(request.app.state.tasks.discard)
flash(request, "Lauf gestartet – das Ergebnis erscheint gleich im Protokoll.")
return redirect(request.headers.get("referer") or "/")
# -- Anbindungen ----------------------------------------------------------------------------
@protected.get("/mounts", response_class=HTMLResponse)
async def mounts_page(request: Request) -> Response:
with nm(request).session_factory() as s:
mounts = s.scalars(select(Mount).order_by(Mount.name)).all()
reports = {
m.id: CheckReport(**_report_data(m.last_test_result))
for m in mounts
if m.last_test_result
}
pubkeys = {
m.id: m.credential.public_key
for m in mounts
if m.credential is not None and m.credential.public_key
}
return render(
request, "mounts.html", active="mounts", mounts=mounts, reports=reports, pubkeys=pubkeys
)
def _report_data(raw: str | None) -> dict[str, Any]:
from netmover.mounting.report import CheckStep
data: dict[str, Any] = json.loads(raw or "{}")
data.pop("roles", None)
data.pop("ok", None)
data["steps"] = [CheckStep(**st) for st in data.get("steps", [])]
return data
def _mount_form(
request: Request,
data: MountInput,
errors: dict[str, str],
mount: Mount | None = None,
status_code: int = 200,
) -> Response:
pub = mount.credential.public_key if mount is not None and mount.credential else None
return render(
request,
"mount_form.html",
status_code=status_code,
active="mounts",
d=data,
errors=errors,
mount=mount,
public_key=pub,
has_secret=bool(mount is not None and mount.credential is not None),
)
@protected.get("/mounts/new", response_class=HTMLResponse)
async def mount_new(request: Request) -> Response:
return _mount_form(request, MountInput(), {})
@protected.get("/mounts/{mount_id}/edit", response_class=HTMLResponse)
async def mount_edit(request: Request, mount_id: int) -> Response:
with nm(request).session_factory() as s:
m = s.get(Mount, mount_id)
if m is None:
return redirect("/mounts")
return _mount_form(request, mount_input_from_model(m), {}, m)
@protected.post("/mounts/new", response_class=HTMLResponse)
@protected.post("/mounts/{mount_id}/edit", response_class=HTMLResponse)
async def mount_save(request: Request, mount_id: int | None = None) -> Response:
state = nm(request)
data, errors = parse_mount_form(_form_dict(await request.form()))
with session_scope(state.session_factory) as s:
existing = s.get(Mount, mount_id) if mount_id is not None else None
if not errors:
try:
m, errors = save_mount(s, state.secrets, state.mounts, data, mount_id)
except AdminError as exc:
errors = {"_": str(exc)}
m = None
else:
m = None
if errors or m is None:
s.rollback()
existing = s.get(Mount, mount_id) if mount_id is not None else None
return _mount_form(request, data, errors, existing, status_code=400)
saved_id, name = m.id, m.name
is_sftp_key = m.type is MountType.SFTP and data.auth is CredentialKind.SSH_KEY
flash(request, f"Anbindung „{name}“ gespeichert.")
if is_sftp_key:
flash(
request,
"Öffentlichen Schlüssel beim Zielserver in authorized_keys eintragen, dann „Testen“.",
"info",
)
state.scheduler.sync()
return redirect(f"/mounts#mount-{saved_id}")
@protected.post("/mounts/test", response_class=HTMLResponse)
async def mount_test_form(request: Request) -> Response:
"""„Testen“ im Formular – mit den (noch nicht gespeicherten) Eingaben."""
state = nm(request)
form = _form_dict(await request.form())
data, errors = parse_mount_form(form)
if errors:
return render(request, "partials/check_result.html", errors=errors, report=None)
existing_id = int(form["mount_id"]) if form.get("mount_id", "").isdigit() else None
with state.session_factory() as s:
existing = s.get(Mount, existing_id) if existing_id else None
cred = credential_for_test(data, existing, state.secrets)
cfg = data.config()
if data.type is MountType.SFTP and existing is not None:
cfg = dataclasses.replace(cfg, host_key=existing.host_key)
report = await run_in_threadpool(state.mounts.check, cfg, cred, None)
return render(request, "partials/check_result.html", report=report, errors={})
@protected.post("/mounts/{mount_id}/test", response_class=HTMLResponse)
async def mount_test(request: Request, mount_id: int) -> Response:
state = nm(request)
with state.session_factory() as s:
m = s.get(Mount, mount_id)
if m is None:
return HTMLResponse("Anbindung existiert nicht.", status_code=404)
data = mount_input_from_model(m)
cred = credential_for_test(data, m, state.secrets)
from netmover.mounting.manager import MountConfig
cfg = MountConfig.from_model(m)
report = await run_in_threadpool(state.mounts.check, cfg, cred, mount_id)
return render(request, "partials/check_result.html", report=report, errors={})
async def _mount_action(
request: Request, mount_id: int, fn: Callable[[int], None], ok: str
) -> Response:
try:
await run_in_threadpool(fn, mount_id)
flash(request, ok)
except (MountError, LookupError) as exc:
flash(request, str(exc), "error")
return redirect(f"/mounts#mount-{mount_id}")
@protected.post("/mounts/{mount_id}/mount")
async def mount_mount(request: Request, mount_id: int) -> Response:
return await _mount_action(request, mount_id, nm(request).mounts.mount, "Eingehängt.")
@protected.post("/mounts/{mount_id}/unmount")
async def mount_unmount(request: Request, mount_id: int) -> Response:
return await _mount_action(request, mount_id, nm(request).mounts.unmount, "Ausgehängt.")
@protected.post("/mounts/{mount_id}/delete")
async def mount_delete(request: Request, mount_id: int) -> Response:
state = nm(request)
def work() -> None:
with session_scope(state.session_factory) as s:
delete_mount(s, state.mounts, mount_id)
try:
await run_in_threadpool(work)
flash(request, "Anbindung gelöscht.")
except (AdminError, MountError) as exc:
flash(request, str(exc), "error")
return redirect("/mounts")
@protected.post("/mounts/{mount_id}/reset-hostkey")
async def mount_reset_hostkey(request: Request, mount_id: int) -> Response:
with session_scope(nm(request).session_factory) as s:
m = s.get(Mount, mount_id)
if m is not None:
m.host_key = None
m.state = MountState.UNMOUNTED
flash(
request,
"Hostschlüssel zurückgesetzt – beim nächsten „Testen“ wird er neu festgehalten.",
"info",
)
return redirect(f"/mounts#mount-{mount_id}")
# -- Regeln -----------------------------------------------------------------------------------
def _rule_form(
request: Request,
d: RuleInput,
errors: dict[str, str],
rule_id: int | None,
status_code: int = 200,
) -> Response:
with nm(request).session_factory() as s:
mounts = s.scalars(select(Mount).order_by(Mount.name)).all()
for m in mounts:
s.expunge(m)
return render(
request,
"rule_form.html",
status_code=status_code,
active="rules",
d=d,
errors=errors,
rule_id=rule_id,
src_mounts=[m for m in mounts if m.type.is_kernel_mount],
src_options=[("", "– wählen –")]
+ [(m.id, m.name) for m in mounts if m.type.is_kernel_mount],
dst_options=[("", "– wählen –")] + [(m.id, f"{m.name} ({m.type.value})") for m in mounts],
)
@protected.get("/rules", response_class=HTMLResponse)
async def rules_page(request: Request) -> Response:
ctx = await _status_context(request)
return render(request, "rules.html", active="rules", rules=ctx["rules"], now=ctx["now"])
@protected.get("/rules/new", response_class=HTMLResponse)
async def rule_new(request: Request) -> Response:
return _rule_form(request, RuleInput(), {}, None)
@protected.get("/rules/{rule_id}/edit", response_class=HTMLResponse)
async def rule_edit(request: Request, rule_id: int) -> Response:
with nm(request).session_factory() as s:
r = s.get(Rule, rule_id)
if r is None:
return redirect("/rules")
d = rule_input_from_model(r)
return _rule_form(request, d, {}, rule_id)
@protected.post("/rules/new", response_class=HTMLResponse)
@protected.post("/rules/{rule_id}/edit", response_class=HTMLResponse)
async def rule_save(request: Request, rule_id: int | None = None) -> Response:
state = nm(request)
form = _form_dict(await request.form())
with session_scope(state.session_factory) as s:
d, errors = parse_rule_form(s, form)
r = None
if not errors:
try:
r, errors = save_rule(s, d, rule_id)
except AdminError as exc:
errors = {"_": str(exc)}
if errors or r is None:
s.rollback()
return _rule_form(request, d, errors, rule_id, status_code=400)
name = r.name
state.scheduler.sync()
flash(request, f"Regel „{name}“ gespeichert.")
return redirect("/rules")
@protected.post("/rules/{rule_id}/toggle")
async def rule_toggle(request: Request, rule_id: int) -> Response:
state = nm(request)
with session_scope(state.session_factory) as s:
r = s.get(Rule, rule_id)
if r is not None:
r.enabled = not r.enabled
flash(request, f"Regel „{r.name}“ {'aktiviert' if r.enabled else 'deaktiviert'}.")
state.scheduler.sync()
return redirect("/rules")
@protected.post("/rules/{rule_id}/delete")
async def rule_delete(request: Request, rule_id: int) -> Response:
state = nm(request)
with session_scope(state.session_factory) as s:
r = s.get(Rule, rule_id)
if r is not None:
s.delete(r)
flash(request, f"Regel „{r.name}“ gelöscht.")
state.scheduler.sync()
return redirect("/rules")
@protected.post("/rules/{rule_id}/dryrun", response_class=HTMLResponse)
async def rule_dryrun(request: Request, rule_id: int) -> Response:
state = nm(request)
run_id = await state.scheduler.run_now(rule_id, dry_run=True)
with state.session_factory() as s:
run = s.get(Run, run_id) if run_id else None
items = s.scalars(select(RunItem).where(RunItem.run_id == run_id)).all() if run_id else []
return render(request, "partials/dryrun.html", run=run, items=items)
# -- Protokoll --------------------------------------------------------------------------------
@protected.get("/log", response_class=HTMLResponse)
async def log_page(request: Request) -> Response:
q = request.query_params
rule_id = int(q["rule"]) if q.get("rule", "").isdigit() else None
status = q.get("status") or ""
active_only = q.get("all") != "1"
page = max(1, int(q["page"])) if q.get("page", "").isdigit() else 1
with nm(request).session_factory() as s:
stmt = select(Run, Rule.name).join(Rule, Rule.id == Run.rule_id)
if rule_id:
stmt = stmt.where(Run.rule_id == rule_id)
if status in {st.value for st in RunStatus}:
stmt = stmt.where(Run.status == RunStatus(status))
if active_only:
stmt = stmt.where(
or_(Run.seen > 0, Run.status != RunStatus.OK, Run.message.is_not(None))
)
total = s.scalar(select(func.count()).select_from(stmt.subquery())) or 0
rows = s.execute(
stmt.order_by(Run.started_at.desc(), Run.id.desc())
.offset((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE)
).all()
rules = s.execute(select(Rule.id, Rule.name).order_by(Rule.name)).all()
return render(
request,
"log.html",
active="log",
rows=rows,
rules=rules,
rule_id=rule_id,
status=status,
active_only=active_only,
page=page,
pages=max(1, math.ceil(total / PAGE_SIZE)),
total=total,
)
@protected.get("/log/{run_id}/items", response_class=HTMLResponse)
async def log_items(request: Request, run_id: int) -> Response:
with nm(request).session_factory() as s:
items = s.scalars(
select(RunItem).where(RunItem.run_id == run_id).order_by(RunItem.id)
).all()
run = s.get(Run, run_id)
return render(request, "partials/run_items.html", items=items, run=run)
# -- Einstellungen ------------------------------------------------------------------------------
@protected.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request) -> Response:
cfg = load_settings(nm(request).session_factory)
return render(request, "settings.html", active="settings", cfg=cfg, errors={})
@protected.post("/settings", response_class=HTMLResponse)
async def settings_save(request: Request) -> Response:
state = nm(request)
form = _form_dict(await request.form())
cfg = load_settings(state.session_factory)
errors: dict[str, str] = {}
try:
cfg.retention_days = int(form.get("retention_days") or 30)
if not 1 <= cfg.retention_days <= 3650:
errors["retention_days"] = "Zwischen 1 und 3650 Tagen."
cfg.notify_after = int(form.get("notify_after") or 3)
if cfg.notify_after < 1:
errors["notify_after"] = "Mindestens 1."
except ValueError:
errors["retention_days"] = "Bitte ganze Zahlen angeben."
try:
cfg.notify_mode = NotifyMode(form.get("notify_mode") or "off")
except ValueError:
errors["notify_mode"] = "Ungültig."
url = (form.get("webhook_url") or "").strip()
if url != auth_mask(cfg.webhook_url): # Maske unverändert = URL behalten
if url and not url.startswith(("http://", "https://")):
errors["webhook_url"] = "URL muss mit http:// oder https:// beginnen."
cfg.webhook_url = url
cfg.notify_recovery = form.get("notify_recovery") == "on"
cfg.webhook_template = form.get("webhook_template") or cfg.webhook_template
cfg.webhook_content_type = (form.get("webhook_content_type") or "application/json").strip()
if errors:
return render(
request, "settings.html", status_code=400, active="settings", cfg=cfg, errors=errors
)
save_settings(state.session_factory, cfg)
flash(request, "Einstellungen gespeichert.")
return redirect("/settings")
def auth_mask(url: str) -> str:
"""Webhook-URL nur gekürzt anzeigen – sie enthält oft ein Token."""
if not url:
return ""
head, _, _rest = url.partition("://")
host = _rest.split("/", 1)[0]
return f"{head}://{host}/••••••"
TEMPLATES.env.globals["mask_url"] = auth_mask
@protected.post("/settings/webhook-test", response_class=HTMLResponse)
async def settings_webhook_test(request: Request) -> Response:
state = nm(request)
cfg = load_settings(state.session_factory)
if not cfg.webhook_url:
return HTMLResponse('<p class="msg error">Keine Webhook-URL gespeichert.</p>')
from netmover.notify import http_sender
from netmover.notify import render as render_body
body = render_body(
cfg.webhook_template,
cfg.webhook_content_type,
Notification("test", "NetMover: Testnachricht", "Webhook funktioniert."),
)
try:
await run_in_threadpool(http_sender, cfg.webhook_url, cfg.webhook_content_type, body)
except Exception as exc:
return HTMLResponse(f'<p class="msg error">Fehlgeschlagen: {type(exc).__name__}</p>')
return HTMLResponse('<p class="msg ok">Testnachricht gesendet.</p>')
@protected.post("/settings/password", response_class=HTMLResponse)
async def settings_password(request: Request) -> Response:
state = nm(request)
form = _form_dict(await request.form())
if not await run_in_threadpool(
auth.verify_password, state.session_factory, form.get("current", "")
):
flash(request, "Aktuelles Passwort falsch.", "error")
return redirect("/settings")
problem = auth.password_problem(form.get("password", ""), form.get("confirm", ""))
if problem:
flash(request, problem, "error")
return redirect("/settings")
await run_in_threadpool(auth.set_password, state.session_factory, form["password"])
auth.login_session(request)
flash(request, "Passwort geändert.")
return redirect("/settings")
@protected.get("/settings/export")
async def settings_export(request: Request) -> Response:
state = nm(request)
with state.session_factory() as s:
data = export_config(s, load_settings(state.session_factory))
stamp = datetime.now().strftime("%Y%m%d-%H%M")
return Response(
json.dumps(data, ensure_ascii=False, indent=2),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="netmover-{stamp}.json"'},
)
@protected.post("/settings/import")
async def settings_import(request: Request) -> Response:
state = nm(request)
form = await request.form()
upload = form.get("file")
if not isinstance(upload, UploadFile):
flash(request, "Keine Datei ausgewählt.", "error")
return redirect("/settings")
raw = await upload.read(2 * 1024 * 1024 + 1)
if len(raw) > 2 * 1024 * 1024:
flash(request, "Datei zu groß.", "error")
return redirect("/settings")
try:
data = json.loads(raw)
except ValueError:
flash(request, "Keine gültige JSON-Datei.", "error")
return redirect("/settings")
with session_scope(state.session_factory) as s:
report = import_config(s, state.mounts, data)
if report.settings:
save_settings(
state.session_factory,
apply_imported_settings(load_settings(state.session_factory), report.settings),
)
state.scheduler.sync()
if report.created:
flash(request, "Importiert: " + ", ".join(report.created))
if report.skipped:
flash(request, "Übersprungen: " + ", ".join(report.skipped), "info")
for err in report.errors:
flash(request, err, "error")
if report.created:
flash(
request,
"Zugangsdaten bitte bei den Anbindungen neu eingeben; importierte Regeln "
"sind deaktiviert.",
"info",
)
return redirect("/settings")
# -- Anwendung ----------------------------------------------------------------------------------
SECURITY_HEADERS = {
"Content-Security-Policy": (
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
"frame-ancestors 'none'; form-action 'self'; base-uri 'none'"
),
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
}
def create_app(settings: Settings | None = None, state: NetMover | None = None) -> FastAPI:
settings = settings or get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app_state = state or NetMover(settings)
app.state.nm = app_state
app.state.setup_code = auth.SetupCode()
app.state.throttle = auth.LoginThrottle()
app.state.tasks = set()
if not auth.password_is_set(app_state.session_factory):
event(
log,
"setup.required",
level=30,
code=app.state.setup_code.code,
hint="Diesen Code bei der Ersteinrichtung im Browser eingeben",
)
app_state.start()
try:
yield
finally:
app_state.stop()
app = FastAPI(
title="NetMover",
version=__version__,
lifespan=lifespan,
docs_url=None,
redoc_url=None,
openapi_url=None,
)
@app.middleware("http")
async def security_headers(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
response = await call_next(request)
for k, v in SECURITY_HEADERS.items():
response.headers.setdefault(k, v)
if request.url.path not in ("/healthz",) and not request.url.path.startswith("/static/"):
response.headers.setdefault("Cache-Control", "no-store")
return response
app.add_middleware(
SessionMiddleware,
secret_key=auth.load_or_create_session_key(settings.session_key_path),
session_cookie="netmover_session",
max_age=settings.session_hours * 3600,
same_site="lax",
https_only=settings.cookie_secure,
)
@app.exception_handler(auth.LoginRequired)
async def login_required(request: Request, exc: auth.LoginRequired) -> Response:
target = "/setup" if exc.setup else f"/login?next={request.url.path}"
if request.headers.get("hx-request"):
return Response(status_code=204, headers={"HX-Redirect": target})
return redirect(target)
app.mount(
"/static", StaticFiles(directory=str(files("netmover.web") / "static")), name="static"
)
app.include_router(public)
app.include_router(protected)
return app
__all__ = ["AppSettings", "create_app"]
+163
View File
@@ -0,0 +1,163 @@
"""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 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()
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())
+111
View File
@@ -0,0 +1,111 @@
/* 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; }
header.top { background: var(--card); border-bottom: 1px solid var(--line); }
header.top .inner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center;
gap: var(--gap); padding: 8px 16px; flex-wrap: wrap; }
header.top .brand { font-weight: 700; text-decoration: none; color: var(--fg); margin-right: 8px; }
nav.main { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; }
nav.main a { padding: 6px 10px; border-radius: var(--radius); text-decoration: none; color: var(--fg); }
nav.main a.active, nav.main a:hover { background: var(--bg); }
main { max-width: 1200px; margin: 0 auto; padding: 16px; }
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: 10px; }
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; }
+45
View File
@@ -0,0 +1,45 @@
// NetMover – kleine Helfer. Keine Inline-Skripte (CSP: script-src 'self').
(function () {
"use strict";
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) {
form.querySelectorAll("[data-show-for]").forEach(function (el) {
var spec = el.getAttribute("data-show-for").split(":");
var ctl = form.querySelector('[name="' + spec[0] + '"]:checked') ||
form.querySelector('select[name="' + spec[0] + '"]');
var value = ctl ? ctl.value : "";
var show = spec[1].split(" ").indexOf(value) !== -1;
el.hidden = !show;
});
}
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
+36
View File
@@ -0,0 +1,36 @@
<!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>
{% if logged_in %}
<header class="top"><div class="inner">
<a class="brand" href="/">NetMover</a>
<nav class="main">
<a href="/" class="{{ 'active' if active == 'status' }}">Status</a>
<a href="/mounts" class="{{ 'active' if active == 'mounts' }}">Anbindungen</a>
<a href="/rules" class="{{ 'active' if active == 'rules' }}">Regeln</a>
<a href="/log" class="{{ 'active' if active == 'log' }}">Protokoll</a>
<a href="/settings" class="{{ 'active' if active == 'settings' }}">Einstellungen</a>
</nav>
<form class="inline" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button class="small">Abmelden</button>
</form>
</div></header>
{% endif %}
<main>
{% for kind, text in flashes %}<p class="msg {{ kind }}">{{ text }}</p>{% endfor %}
{% block content %}{% endblock %}
</main>
<footer class="foot muted small">NetMover {{ version }}</footer>
</body>
</html>
+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='') -%}
<div class="field check">
<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,61 @@
{% 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</legend><div class="fields">
{{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"', hint=('Verzeichnis: ' ~ mount.mountpoint if mount and mount.mountpoint else 'Bestimmt das Verzeichnis unter /mnt/netmover')) }}
{{ select('type', 'Typ', [('cifs', 'SMB/CIFS'), ('nfs4', 'NFS 4'), ('nfs3', 'NFS 3'), ('sftp', 'SFTP (nur Ziel)')], d.type.value, errors=errors) }}
{{ field('server', 'Server', d.server, errors=errors, placeholder='192.168.1.10 oder nas.lan', attrs='required') }}
<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</legend><div class="fields">
<div data-show-for="type:sftp">{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel (NetMover erzeugt ihn)'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div>
{{ field('username', 'Benutzername', d.username, errors=errors, attrs='autocomplete="off"', hint='Bei CIFS leer lassen für Gastzugang') }}
<div data-show-for="type:cifs">{{ field('domain', 'Domäne (optional)', d.domain, errors=errors) }}</div>
<div class="field{{ ' has-error' if errors.get('password') }}" id="pw-field">
<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 }}Bei SFTP mit Schlüssel leer lassen.</span>
</div>
</div>
{% if public_key %}<div class="field wide"><label>Öffentlicher Schlüssel (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)</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</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" 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 %}
+48
View File
@@ -0,0 +1,48 @@
{% 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 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,54 @@
{% 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 }}">
<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 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 %}<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 %}
+49
View File
@@ -0,0 +1,49 @@
{% 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 SMB- oder NFS-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</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</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</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>Sicherheit und Grenzen</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</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 %}
+32
View File
@@ -0,0 +1,32 @@
{% 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>
<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 %}
+46
View File
@@ -0,0 +1,46 @@
{% 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>Benachrichtigung (Webhook)</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) }}
<div data-show-for="notify_mode:after_n">{{ field('notify_after', 'N', cfg.notify_after, type='number', errors=errors, attrs='min="1"') }}</div>
{{ field('webhook_url', 'Webhook-URL', mask_url(cfg.webhook_url), errors=errors, wide=true, placeholder='https://ntfy.sh/mein-topic', hint='Wird nur gekürzt angezeigt; unverändert lassen = behalten, leeren = entfernen.') }}
{{ field('webhook_content_type', 'Content-Type', cfg.webhook_content_type, errors=errors) }}
{{ check('notify_recovery', 'Entwarnung senden, wenn es wieder läuft', cfg.notify_recovery) }}
<div class="field wide"><label for="f-tpl">Vorlage</label>
<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>
<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>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 %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Status{% endblock %}
{% block content %}
<div class="row spread"><h1>Status</h1>
<span class="muted small">aktualisiert sich alle 15 s</span></div>
<div id="status-body" hx-get="/partials/status" hx-trigger="every 15s" hx-swap="innerHTML">
{% include "partials/status_body.html" %}
</div>
{% endblock %}
+58 -6
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
@@ -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,50 @@ 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")] == []
+557
View File
@@ -0,0 +1,557 @@
"""Weboberfläche: Einrichtung, Anmeldung, CSRF, Seiten und Aktionen (FakeMountBackend)."""
from __future__ import annotations
import json
import re
from collections.abc import Iterator
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
PASSWORD = "sehr-geheim-123"
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
return Settings(
data_dir=tmp_path / "lib",
run_dir=tmp_path / "run",
mount_base=tmp_path / "mnt",
mount_backend="fake",
)
@pytest.fixture
def backend(tmp_path: Path) -> Iterator[FakeMountBackend]:
b = FakeMountBackend(tmp_path / "fake")
yield b
b.release_hangs()
@pytest.fixture
def state(settings: Settings, backend: FakeMountBackend) -> NetMover:
return NetMover(settings, backend=backend)
@pytest.fixture
def raw_client(settings: Settings, state: NetMover) -> Iterator[TestClient]:
with TestClient(create_app(settings, state), follow_redirects=False) as c:
yield c
def csrf_of(html: str) -> str:
m = re.search(r'name="csrf-token" content="([^"]+)"', html)
assert m, "kein CSRF-Token in der Seite"
return m.group(1)
def do_setup(c: TestClient) -> str:
page = c.get("/setup")
token = csrf_of(page.text)
code = c.app.state.setup_code.code # type: ignore[attr-defined]
r = c.post(
"/setup",
data={"csrf_token": token, "code": code, "password": PASSWORD, "confirm": PASSWORD},
)
assert r.status_code == 303, r.text
return csrf_of(c.get("/").text)
@pytest.fixture
def client(raw_client: TestClient) -> TestClient:
raw_client.headers["X-Test-CSRF"] = do_setup(raw_client)
return raw_client
def post(c: TestClient, url: str, data: dict[str, str] | None = None, **kw: object): # type: ignore[no-untyped-def]
payload = {"csrf_token": c.headers["X-Test-CSRF"], **(data or {})}
return c.post(url, data=payload, **kw) # type: ignore[arg-type]
# -- Einrichtung und Anmeldung --------------------------------------------------------------
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", "/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