Compare commits
7
Commits
f008c216be
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1a917b974 | ||
|
|
c8ee950514 | ||
|
|
dd9177859e | ||
|
|
00a0537085 | ||
|
|
2770c2e204 | ||
|
|
2b0b039aeb | ||
|
|
e2587551b0 |
@@ -0,0 +1,8 @@
|
||||
.venv
|
||||
.git
|
||||
data
|
||||
**/__pycache__
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
tests
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
@@ -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:
|
||||
Executable
+46
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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,3 +1,3 @@
|
||||
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
|
||||
|
||||
__version__ = "0.1.0.dev0"
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Weboberfläche: FastAPI + Jinja2 + HTMX, serverseitig gerendert."""
|
||||
@@ -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"]
|
||||
@@ -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())
|
||||
@@ -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; }
|
||||
@@ -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 ✓";
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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")] == []
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user