Compare commits
18
Commits
2770c2e204
..
v0.2.0
@@ -0,0 +1,8 @@
|
||||
.venv
|
||||
.git
|
||||
data
|
||||
**/__pycache__
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
tests
|
||||
@@ -8,6 +8,60 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] – 2026-10-11
|
||||
|
||||
### Hinzugefügt
|
||||
- **Schlüsselverwaltung** (Seite „Schlüssel“): SSH-Schlüssel erzeugen (Ed25519) oder
|
||||
importieren (Ed25519, ECDSA, RSA ≥ 2048 Bit; OpenSSH/PEM, auch mit Passphrase),
|
||||
Fingerabdruck und `authorized_keys`-Zeile mit SFTP-Beschränkung zum Kopieren,
|
||||
Umbenennen, Löschen nur wenn unbenutzt. Private Schlüssel werden nie angezeigt.
|
||||
- SFTP-Anbindungen können einen gespeicherten Schlüssel auswählen; der Benutzername
|
||||
gehört zur Anbindung (Migration 0002: `credentials.managed`, `mounts.username`).
|
||||
- **`netmover-update`**: Update aus dem Git-Repo mit Sicherung von Datenbank,
|
||||
Schlüsseln und Einstellungen, Changelog-Auszug, Neustart und Healthcheck;
|
||||
Versionswahl, Warnung beim Zurückstufen.
|
||||
- Installation als Git-Klon unter `/opt/netmover/src`; `install.sh` installiert
|
||||
`git` und das Update-Skript.
|
||||
|
||||
### Geändert
|
||||
- Klartext-Hinweis bei `mount error(95)` (keine gemeinsame SMB-Version) mit
|
||||
Synology-Einstellung; aufgefallen bei der Anbindung des echten NAS.
|
||||
- SFTP-Anbindungen gelten nach erfolgreichem Test als „erreichbar“ (vorher bis
|
||||
zum ersten Regellauf „nicht geprüft“).
|
||||
|
||||
## [0.1.1] – 2026-10-10
|
||||
|
||||
Korrekturen aus dem ersten Praxistest auf einer Debian-13-VM mit echten
|
||||
SMB-, NFS- und SFTP-Servern.
|
||||
|
||||
### Behoben
|
||||
- **systemd-Unit:** `ProtectSystem`, `PrivateTmp` und `ProtectControlGroups`
|
||||
gaben dem Dienst einen eigenen Mount-Namespace. Die Mounts waren für das System
|
||||
und andere Programme unsichtbar und verschwanden bei jedem Neustart des Dienstes
|
||||
(`MountFlags=shared` half nicht). Jetzt nur noch Härtungen ohne Namespace.
|
||||
- **Stabilitätsprüfung:** CIFS lieferte per `stat()` sekundenlang eine veraltete
|
||||
Größe einer noch wachsenden Datei. Gemessen wird jetzt per `open()` + `fstat()`,
|
||||
das erzwingt frische Attribute. Gesperrte Dateien gelten als „in Benutzung“.
|
||||
(Die zweite Sicherung, die Byte-Zählung beim Kopieren, hatte den Fall bereits
|
||||
abgefangen; es kam keine unvollständige Datei an.)
|
||||
- **Ausfall länger als die Wiederherstellung (z. B. NAS-Neustart, Stromausfall):**
|
||||
Von NetMover selbst ausgehängte Anbindungen mit „automatisch einhängen“ werden
|
||||
jetzt jede Minute erneut eingehängt, auch wenn das Einhängen schon beim
|
||||
Dienststart scheiterte. Entwarnung per Webhook. Von außen ausgehängte Anbindungen
|
||||
bleiben weiterhin unangetastet.
|
||||
- Bei einer Zeitüberschreitung von `mount` wird die ganze Prozessgruppe beendet, damit
|
||||
`mount.nfs` nicht später doch noch einhängt.
|
||||
- Hinweistexte zu `mount`-Fehlern werden nicht mehr mitgeschwärzt.
|
||||
- `install.sh` ohne `debconf`-Warnungen, wenn kein Terminal angeschlossen ist.
|
||||
|
||||
### Dokumentation
|
||||
- Fehlersuche: verzögert sichtbare Dateien auf CIFS → `nohandlecache`.
|
||||
- README erklärt, warum die Unit keine Namespace-Härtung nutzt.
|
||||
|
||||
## [0.1.0] – 2026-10-10
|
||||
|
||||
Erste Version.
|
||||
|
||||
### Hinzugefügt
|
||||
- Projektgerüst (Python ≥ 3.12, FastAPI-Stack, src-Layout, ruff, mypy strict, pytest).
|
||||
- Datenmodell mit SQLAlchemy 2.x: `credentials`, `mounts`, `rules`, `runs`,
|
||||
@@ -67,6 +121,13 @@ 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
|
||||
@@ -87,3 +148,8 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
|
||||
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.2.0...HEAD
|
||||
[0.2.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.1...v0.2.0
|
||||
[0.1.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://git.wilde-server.de/florian.wilde/netmover/releases/tag/v0.1.0
|
||||
|
||||
@@ -12,10 +12,87 @@ Zeiten in Ortszeit (Europe/Berlin).
|
||||
| 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 |
|
||||
| 2026-10-10 | 19:18 | 19:34 | 0:16 | 5 | Weboberfläche und Anmeldung |
|
||||
| **Summe** | | | **0:56** | | |
|
||||
| 2026-10-10 | 19:40 | 19:50 | 0:10 | 6 | Deployment, README, Release 0.1.0 |
|
||||
| 2026-10-10 | 21:36 | 22:05 | 0:29 | – | Praxistest auf VM, Release 0.1.1 |
|
||||
| **Summe** | | | **1:35** | | |
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-10 · 21:36–22:05 · Praxistest auf der Debian-13-VM, Release 0.1.1
|
||||
|
||||
**Umgebung**
|
||||
- KVM-VM `srv-rz-netmover` (10.0.10.223), Debian 13, Python 3.13, 2 GB RAM.
|
||||
- Installation per `deploy/install.sh` aus einem `git archive` (VM ohne Gitea-Zugang).
|
||||
- Vorübergehende Testserver auf der VM: Samba (nur `lo`), NFS-Exporte für
|
||||
`127.0.0.1` (mit/ohne Root-Squash), SFTP-Benutzer `paperless` mit Schlüssel
|
||||
`restrict,command="/usr/lib/openssh/sftp-server"`.
|
||||
- Auf Wunsch `NETMOVER_HOST=0.0.0.0`, damit die Oberfläche im LAN erreichbar ist.
|
||||
|
||||
**Bestanden**
|
||||
- Ersteinrichtung mit Code aus dem Log, Cookie-Flags.
|
||||
- CIFS (Passwort mit Komma/Ausrufezeichen, Credentials-Datei danach weg), NFS 4,
|
||||
NFS 3, Root-Squash-Erkennung mit Synology-Hinweis, SFTP mit TOFU (Fingerabdruck
|
||||
= `ssh-keygen -lf`), echtes OpenSSH liefert `statvfs`.
|
||||
- Probelauf, Kollision `scan001-1.pdf`, `.txt`/`.part` ignoriert, keine `.part`-Reste,
|
||||
Eigentümer `paperless` im SFTP-Ziel.
|
||||
- Langsam wachsende Datei (20 × 50 KB/s): erst nach Ende verschoben, Prüfsumme gleich.
|
||||
- Kopiermodus rekursiv mit Eigentümer/Modus; zweiter Lauf „2 bereits übertragen“.
|
||||
- Samba gestoppt: Lebenszeichen 15 s → gestört → 3 Versuche → Fehler, `/healthz` 503,
|
||||
nichts angefasst. NFS-Server gestoppt (hängender `hard`-Mount): Oberfläche und
|
||||
andere Regel liefen weiter, Rückkehr automatisch.
|
||||
- Mounts überstehen Stopp/Start des Dienstes (gleiche Mount-IDs).
|
||||
- Neustart der VM: NFS sofort, SMB per Neuversuch nach ~1 min, Regel läuft weiter.
|
||||
|
||||
**Gefunden und behoben (v0.1.1)**
|
||||
1. **systemd-Unit mit eigenem Mount-Namespace** (ProtectSystem/PrivateTmp/…):
|
||||
Mounts nur im Dienst sichtbar, `shared,slave`. Härtung ohne Namespace.
|
||||
2. **CIFS-Attribut-Cache**: `stat()` meldete 3 s lang eine veraltete Größe einer
|
||||
wachsenden Datei; die Byte-Zählung beim Kopieren fing das ab. Jetzt
|
||||
`open()` + `fstat()` für beide Messungen.
|
||||
3. **Nach drei gescheiterten Versuchen endgültig aufgegeben** – zu kurz für einen
|
||||
NAS-Neustart, und beim Booten vor dem NAS kam die Freigabe nie zurück.
|
||||
Jetzt minütlicher Neuversuch (nur für von NetMover selbst ausgehängte
|
||||
Anbindungen mit `auto_mount`) plus Scheduler-Job.
|
||||
4. `mount`-Zeitlimit beendete nur `mount`, nicht `mount.nfs` → später doch
|
||||
eingehängt. Jetzt ganze Prozessgruppe.
|
||||
5. Hinweistext wurde mitgeschwärzt; `debconf`-Warnungen in `install.sh`.
|
||||
|
||||
**Nicht nachstellbar**
|
||||
- Einmalig 46 s Verzögerung direkt nach dem Booten (CIFS-Verzeichnis-Cache bei
|
||||
lokal statt per SMB angelegter Datei). Messungen danach 9–11 s, mit und ohne
|
||||
`nohandlecache`. Als Hinweis in die Fehlersuche aufgenommen.
|
||||
|
||||
## 2026-10-10 · 19:40–19:50 · Schritt 6: Deployment, README, Release 0.1.0
|
||||
|
||||
**Erledigt**
|
||||
- `deploy/netmover.service`, `deploy/netmover.env`, `deploy/install.sh`,
|
||||
`Dockerfile` (python:3.12-slim-trixie, mehrstufig), `compose.yml`, `.dockerignore`.
|
||||
- README vollständig; `LICENSE` (MIT, wie seit Schritt 1 in `pyproject.toml`).
|
||||
- Version 0.1.0, Changelog-Abschnitt, Tag `v0.1.0`.
|
||||
|
||||
**Geprüft**
|
||||
- Wheel enthält Templates, statische Dateien und Migrationen.
|
||||
- Docker-Image gebaut; Container startet, `/healthz` = 200, `mount.cifs` 7.4 und
|
||||
`mount.nfs` vorhanden. **Propagation getestet:** tmpfs im Container unter
|
||||
`/mnt/netmover` eingehängt → mit `rshared` auf dem Host sichtbar, ohne nicht.
|
||||
- `systemd-analyze verify` der Unit ohne Beanstandung. Ein echter Start der Unit
|
||||
als root (insb. `MountFlags=shared` mit `ProtectSystem`) war hier nicht möglich →
|
||||
beim ersten Einsatz mit `findmnt -R /mnt/netmover` prüfen.
|
||||
|
||||
**Entscheidungen**
|
||||
- Paperless-ngx-Consumer geprüft: nutzt `watchfiles` (`Change.added`) plus
|
||||
5-Minuten-Rescan – erkennt also auch per `link()` erzeugte Dateien. Für Watcher,
|
||||
die nur `IN_MOVED_TO`/`IN_CLOSE_WRITE` kennen, trotzdem auf
|
||||
`renameat2(RENAME_NOREPLACE)` umgestellt (entspricht der Vorgabe `os.replace`,
|
||||
ohne die Garantie „nie überschreiben“ aufzugeben).
|
||||
- Härtung der Unit bewusst zurückhaltend (keine `CapabilityBoundingSet`, kein
|
||||
`ProtectKernelModules`): ungetestete Einschränkungen könnten Mounten brechen
|
||||
(z. B. NFS mit privilegiertem Quellport, Modul-Autoload für cifs).
|
||||
- SFTP-Schlüssel im README per `restrict,command="…/sftp-server"` auf SFTP beschränkt.
|
||||
|
||||
**Offen für später**
|
||||
- Erster Praxistest auf der Ziel-VM mit echtem Synology-NAS und Paperless-LXC.
|
||||
|
||||
## 2026-10-10 · 19:18–19:34 · Schritt 5: Weboberfläche und Anmeldung
|
||||
|
||||
**Erledigt**
|
||||
|
||||
+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,400 @@
|
||||
# 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 5 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 /opt/netmover/src
|
||||
cd /opt/netmover/src && git checkout "$(git tag -l 'v*' --sort=-v:refname | head -n 1)"
|
||||
./deploy/install.sh
|
||||
```
|
||||
|
||||
Das Skript installiert `python3-venv`, `cifs-utils`, `nfs-common` und `git`. Es
|
||||
richtet NetMover in `/opt/netmover/venv` ein, installiert
|
||||
[`deploy/netmover.service`](deploy/netmover.service) und das Update-Skript
|
||||
`netmover-update` und startet den Dienst.
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
netmover-update # auf die neueste Version
|
||||
netmover-update --list # verfügbare Versionen
|
||||
netmover-update v0.2.0 # bestimmte Version
|
||||
```
|
||||
|
||||
`netmover-update` geht so vor:
|
||||
1. Neue Versions-Tags aus Gitea holen und die Änderungen laut Changelog anzeigen.
|
||||
2. **Datenbank, `secret.key` und Einstellungen nach `/var/backups/netmover/` sichern**
|
||||
(die letzten 10 bleiben).
|
||||
3. Die Version auschecken, installieren und den Dienst neu starten. Neue
|
||||
Datenbank-Migrationen laufen dabei automatisch.
|
||||
4. `/healthz` prüfen.
|
||||
|
||||
Die Mounts bleiben die ganze Zeit eingehängt. Geht etwas schief, nennt das Skript
|
||||
die Befehle, um die Sicherung zurückzuspielen und zur vorherigen Version zu wechseln.
|
||||
**Zurückstufen** auf eine ältere Version geht nur mit der Datenbank-Sicherung von
|
||||
vor dem Update, wenn das Update eine Migration enthielt; das Skript fragt deshalb nach.
|
||||
|
||||
Was wo liegt:
|
||||
|
||||
| Pfad | Inhalt |
|
||||
|---|---|
|
||||
| `/var/lib/netmover/netmover.db` | Datenbank (SQLite) |
|
||||
| `/var/lib/netmover/secret.key` | Schlüssel für die Zugangsdaten (0600) – **mitsichern!** |
|
||||
| `/etc/netmover/netmover.env` | Einstellungen (Port, Logs, …) |
|
||||
| `/mnt/netmover/<name>` | Mountpoints – nur NetMover legt hier etwas an |
|
||||
| `/run/netmover` | kurzlebige Credentials-Dateien für `mount.cifs` |
|
||||
|
||||
**Warum als root?** Ein- und Aushängen braucht `CAP_SYS_ADMIN`. Außerdem soll
|
||||
NetMover Dateien auf beliebigen Freigaben lesen, löschen und mit den richtigen
|
||||
Eigentümern anlegen können.
|
||||
|
||||
**Warum so wenig systemd-Härtung?** Optionen wie `ProtectSystem=`, `PrivateTmp=`
|
||||
oder `ProtectHome=` geben dem Dienst einen eigenen Mount-Namespace. Die Mounts
|
||||
landeten dann *nur dort*: Das System und Paperless sähen sie nicht, und jeder
|
||||
Neustart des Dienstes würde sie verwerfen. `MountFlags=shared` hilft dagegen nicht.
|
||||
Auf der Test-VM nachgewiesen. Die Unit nutzt deshalb nur Härtungen ohne eigenen
|
||||
Namespace (`NoNewPrivileges`, `RestrictSUIDSGID`, `LockPersonality`,
|
||||
`RestrictRealtime`); Datenbank und Schlüssel sind nur für root lesbar. Prüfen:
|
||||
|
||||
```bash
|
||||
findmnt -l | grep /mnt/netmover # die eingehängten Freigaben müssen hier stehen
|
||||
```
|
||||
|
||||
**Beim Beenden bleiben die Mounts eingehängt**, weil andere Programme sie benutzen
|
||||
könnten. Beim Start werden alle Anbindungen mit „automatisch einhängen“
|
||||
wiederhergestellt. Erst danach startet der Scheduler.
|
||||
|
||||
## Erster Start
|
||||
|
||||
NetMover lauscht standardmäßig nur auf `127.0.0.1:8080`. Vom Arbeitsplatz aus
|
||||
erreichst du die Oberfläche per SSH-Tunnel:
|
||||
|
||||
```bash
|
||||
ssh -L 8080:127.0.0.1:8080 root@netmover-vm
|
||||
# dann im Browser: http://localhost:8080
|
||||
```
|
||||
|
||||
Beim ersten Aufruf musst du ein Administratorpasswort setzen (mindestens 10 Zeichen).
|
||||
Dafür brauchst du den **Einrichtungscode** aus dem Log. Damit kann nicht irgendwer
|
||||
im Netz, der die Seite zuerst öffnet, das Passwort setzen:
|
||||
|
||||
```bash
|
||||
journalctl -u netmover | grep setup.required
|
||||
# … event=setup.required code=4f2a-91c0-7d3e …
|
||||
```
|
||||
|
||||
Passwort vergessen? `sudo /opt/netmover/venv/bin/netmover reset-password`, dann
|
||||
`systemctl restart netmover` und die Einrichtung neu durchlaufen.
|
||||
|
||||
## Beispiel: Synology → Paperless
|
||||
|
||||
Ziel: Scans landen auf dem NAS in `scans`. NetMover schiebt PDFs alle 30 Sekunden
|
||||
in den Consume-Ordner von Paperless, das in einem LXC per SSH erreichbar ist.
|
||||
|
||||
### 1. Synology vorbereiten
|
||||
|
||||
1. **Systemsteuerung → Benutzer** → Benutzer `netmover` anlegen, Lese-/Schreibrecht
|
||||
auf den gemeinsamen Ordner `scans`.
|
||||
2. **Systemsteuerung → Dateidienste → SMB**: aktiviert. Unter *Erweitert* als
|
||||
minimales Protokoll SMB2 oder höher.
|
||||
|
||||
### 2. Anbindung „Synology Scans“ anlegen
|
||||
|
||||
*Anbindungen → Neue Anbindung*:
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Name | `Synology Scans` |
|
||||
| Typ | SMB/CIFS |
|
||||
| Server | `192.168.1.10` (IP des NAS) |
|
||||
| Freigabe | `scans` |
|
||||
| Benutzername / Passwort | `netmover` / … |
|
||||
| Zusatzoptionen | `vers=3.0` |
|
||||
| Beim Start automatisch einhängen | ✓ |
|
||||
|
||||
Klicke auf **Testen**. Alle drei Schritte (Einhängen, Lesen, Schreiben) sollten ✓
|
||||
zeigen, und die Rolle sollte „Quelle mit Löschen“ lauten. Danach speichern und
|
||||
**Einhängen**.
|
||||
|
||||
### 3. Paperless-LXC vorbereiten (SFTP-Ziel)
|
||||
|
||||
Lege in NetMover die *Anbindung → Neue Anbindung* an:
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Name | `Paperless` |
|
||||
| Typ | SFTP (nur Ziel) |
|
||||
| Server / SSH-Port | IP des LXC / `22` |
|
||||
| Freigabe / Pfad | `/opt/paperless_data/consume` |
|
||||
| Anmeldeart | SSH-Schlüssel |
|
||||
| Schlüssel | einen gespeicherten Schlüssel wählen (siehe unten) oder „neuer Schlüssel nur für diese Anbindung“ |
|
||||
| Benutzername | `paperless` (der Benutzer, unter dem Paperless läuft) |
|
||||
|
||||
**Schlüsselverwaltung:** Unter *Schlüssel* erzeugst du Ed25519-Schlüssel oder
|
||||
importierst vorhandene (Ed25519, ECDSA, RSA ≥ 2048 Bit; OpenSSH- oder PEM-Format,
|
||||
auch mit Passphrase). Ein Schlüssel kann von mehreren Anbindungen genutzt werden; den
|
||||
Benutzernamen legt jede Anbindung selbst fest. Private Schlüssel liegen verschlüsselt
|
||||
in der Datenbank und werden nie angezeigt. Die Seite zeigt den Fingerabdruck und eine
|
||||
fertige `authorized_keys`-Zeile zum Kopieren.
|
||||
|
||||
Trage den öffentlichen Schlüssel im LXC so ein, dass er **nur SFTP** darf:
|
||||
|
||||
```bash
|
||||
# im Paperless-LXC als root
|
||||
install -d -m 700 -o paperless -g paperless ~paperless/.ssh
|
||||
echo 'restrict,command="/usr/lib/openssh/sftp-server" ssh-ed25519 AAAA… netmover@paperless' \
|
||||
>> ~paperless/.ssh/authorized_keys
|
||||
chown paperless:paperless ~paperless/.ssh/authorized_keys
|
||||
chmod 600 ~paperless/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
Dann in NetMover bei der Anbindung **Testen**. Prüfe den angezeigten Fingerabdruck
|
||||
des Hostschlüssels. Ermitteln kannst du ihn im LXC mit
|
||||
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`. NetMover hält ihn beim ersten
|
||||
Test fest und verweigert später jede Verbindung, wenn er sich ändert.
|
||||
|
||||
> Weil die Dateien als Benutzer `paperless` ankommen, darf Paperless sie nach dem
|
||||
> Einlesen auch löschen. Paperless-ngx (aktuelle Versionen mit `watchfiles`)
|
||||
> bemerkt neue Dateien sofort. Ältere Versionen, die nur auf
|
||||
> `IN_CLOSE_WRITE`/`IN_MOVED_TO` lauschen, siehe [Fehlersuche](#fehlersuche).
|
||||
|
||||
### 4. Regel anlegen
|
||||
|
||||
*Regeln → Neue Regel*:
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Name | `Scans → Paperless` |
|
||||
| Intervall | `30` |
|
||||
| Quelle | `Synology Scans`, Unterordner leer |
|
||||
| Filter | Glob `*.pdf` |
|
||||
| Ziel | Anbindung `Paperless`, Zielordner leer |
|
||||
| Aktion | verschieben |
|
||||
| Wenn Datei existiert | umbenennen |
|
||||
| Stabilitätsfenster | `5` |
|
||||
| Quarantäneordner | `_quarantaene` (optional) |
|
||||
|
||||
Klicke auf **Probelauf**. Die Tabelle zeigt, welche Datei wohin ginge, ohne etwas
|
||||
zu verändern. Danach aktivieren und auf der Statusseite verfolgen.
|
||||
|
||||
**Getrennt nach Unterordnern?** Scannt z. B. jede Person in einen eigenen
|
||||
Unterordner (`scans/max`, `scans/erika`), legst du pro Unterordner eine Regel mit
|
||||
eigenem Quell-Unterordner und eigenem Ziel an.
|
||||
|
||||
**Paperless lokal statt per SSH?** Läuft Paperless auf demselben Host, wähle als Ziel
|
||||
„Lokaler Pfad“ (z. B. `/opt/paperless_data/consume`). Der Ordner muss existieren und
|
||||
darf nicht unter `/mnt/netmover` liegen. Mit „Eigentümer setzen“ (z. B. `1000:1000`)
|
||||
gehören die Dateien danach dem Paperless-Benutzer.
|
||||
|
||||
### NFS statt SMB
|
||||
|
||||
Am NAS: *Dateidienste → NFS* aktivieren. Dann unter *Gemeinsamer Ordner →
|
||||
Bearbeiten → NFS-Berechtigungen* eine Regel mit der IP des NetMover-Hosts anlegen.
|
||||
In NetMover Typ „NFS 4“, Freigabe `/volume1/scans`.
|
||||
|
||||
**Root-Squash:** NetMover läuft als root. Synology bildet root standardmäßig auf
|
||||
`nobody` ab, dann scheitert das Schreiben oder `chown`. Der **Testen**-Knopf erkennt
|
||||
das und sagt es dir. Abhilfe in der NFS-Regel: *Squash → „Keine Zuordnung“* (No
|
||||
mapping) oder „Alle Benutzer zu admin zuordnen“.
|
||||
|
||||
## Alternative: Docker
|
||||
|
||||
```bash
|
||||
mkdir -p /mnt/netmover
|
||||
docker compose up -d --build
|
||||
docker compose logs | grep setup.required
|
||||
```
|
||||
|
||||
[`compose.yml`](compose.yml) gibt dem Container die nötigen Rechte:
|
||||
`cap_add: [SYS_ADMIN, DAC_READ_SEARCH]` und `security_opt: [apparmor:unconfined]`.
|
||||
|
||||
> **Wichtig:** `/mnt/netmover` muss als `bind` mit **`propagation: rshared`**
|
||||
> eingebunden sein. Ohne das bleiben die Mounts im Container gefangen: NetMover
|
||||
> selbst sieht sie, aber weder der Host noch andere Container (z. B. Paperless).
|
||||
> Getestet: Mit `rshared` erscheint ein im Container eingehängtes Dateisystem
|
||||
> sofort unter `/mnt/netmover` auf dem Host, ohne `rshared` nicht.
|
||||
|
||||
Lokale Ziele musst du zusätzlich als Volume einbinden. Für NFSv3 im Container die
|
||||
Option `nolock` setzen oder besser NFSv4 nehmen; NFSv3-Locking braucht
|
||||
`rpc.statd`, und den gibt es im Container nicht.
|
||||
|
||||
**Unterschied zum systemd-Dienst:** Der Dienst direkt auf dem Host braucht keine
|
||||
Propagation-Tricks, keine AppArmor-Ausnahme und keine zusätzlichen Volumes. Docker
|
||||
kapselt die Software, hat aber trotz Container `CAP_SYS_ADMIN` und ist damit kaum
|
||||
weniger mächtig als root auf dem Host.
|
||||
|
||||
## Reverse Proxy mit TLS
|
||||
|
||||
NetMover gehört **nicht ins Internet**. Es hat root-Rechte, hängt beliebige
|
||||
Freigaben ein und speichert Zugangsdaten zu deinem NAS. Ein Fehler oder ein
|
||||
schwaches Passwort wäre ein direkter Weg in dein Netz. Für den Zugriff im LAN gehört
|
||||
ein Reverse Proxy mit TLS davor; für unterwegs nimm VPN (z. B. WireGuard).
|
||||
|
||||
Beispiel Caddy auf demselben Host:
|
||||
|
||||
```caddyfile
|
||||
netmover.lan {
|
||||
tls internal
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
Beispiel nginx:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name netmover.lan;
|
||||
ssl_certificate /etc/ssl/netmover.crt;
|
||||
ssl_certificate_key /etc/ssl/netmover.key;
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s; # Mount-Tests können dauern
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dann in `/etc/netmover/netmover.env`:
|
||||
|
||||
```bash
|
||||
NETMOVER_COOKIE_SECURE=true # Session-Cookie nur noch per HTTPS
|
||||
NETMOVER_FORWARDED_ALLOW_IPS=127.0.0.1
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Umgebungsvariablen (in `/etc/netmover/netmover.env`); alles Übrige stellst du in
|
||||
der Oberfläche unter *Einstellungen* ein:
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---|---|
|
||||
| `NETMOVER_HOST` | `127.0.0.1` | Adresse, auf der NetMover lauscht |
|
||||
| `NETMOVER_PORT` | `8080` | Port |
|
||||
| `NETMOVER_DATA_DIR` | `/var/lib/netmover` | Datenbank und Schlüssel |
|
||||
| `NETMOVER_MOUNT_BASE` | `/mnt/netmover` | Basisverzeichnis der Mountpoints |
|
||||
| `NETMOVER_RUN_DIR` | `/run/netmover` | kurzlebige Credentials-Dateien |
|
||||
| `NETMOVER_COOKIE_SECURE` | `false` | Cookie nur per HTTPS (hinter TLS-Proxy: `true`) |
|
||||
| `NETMOVER_FORWARDED_ALLOW_IPS` | `127.0.0.1` | Proxy-IPs, deren `X-Forwarded-*` gilt |
|
||||
| `NETMOVER_SESSION_HOURS` | `12` | Gültigkeit der Anmeldung |
|
||||
| `NETMOVER_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
| `NETMOVER_LOG_FORMAT` | `logfmt` | `logfmt` oder `json` (ein Ereignis pro Zeile) |
|
||||
|
||||
In der Oberfläche unter *Einstellungen* stellst du ein:
|
||||
- Aufbewahrung der Protokolle (Standard 30 Tage).
|
||||
- Webhook-Benachrichtigung: „bei jedem Fehler“ oder „nach N Fehlern in Folge“,
|
||||
jeweils mit Entwarnung.
|
||||
- Export und Import der Konfiguration, **ohne** Zugangsdaten und Webhook-URL.
|
||||
|
||||
Webhook-Vorlagen (Platzhalter `$event $title $message $rule $time $host`):
|
||||
|
||||
| Dienst | URL | Content-Type | Vorlage |
|
||||
|---|---|---|---|
|
||||
| ntfy | `https://ntfy.sh/<topic>` | `text/plain` | `$title: $message` |
|
||||
| Gotify | `https://gotify.lan/message?token=…` | `application/json` | `{"title": "$title", "message": "$message", "priority": 5}` |
|
||||
| eigener Empfänger | beliebig | `application/json` | Standardvorlage |
|
||||
|
||||
`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(95): Operation not supported` | Keine gemeinsame SMB-Version (im Kernel-Log: „Dialect not supported by server“). Am NAS das maximale SMB-Protokoll auf SMB3 stellen oder Zusatzoption `vers=2.0`. |
|
||||
| `mount error(112): Host is down` | NAS nicht erreichbar, oder SMB1 erwartet: `vers=3.0` bzw. `vers=2.1` probieren. |
|
||||
| `… helper program` / `unknown filesystem type` | `apt install cifs-utils nfs-common` |
|
||||
| `Operation not permitted` / `only root can do that` | Kein `CAP_SYS_ADMIN`: unprivilegierter LXC oder Docker ohne `cap_add`. |
|
||||
| `access denied by server` (NFS) | NFS-Regel am NAS fehlt für die IP dieses Hosts (oder falsches Subnetz in der Regel). |
|
||||
| Test: „Root-Squash“ | Siehe [NFS statt SMB](#nfs-statt-smb); `chown` wird bis dahin übersprungen. |
|
||||
| „… ist nicht leer“ beim Einhängen | Im Mountpoint liegen Dateien, die geschrieben wurden, als nichts eingehängt war. Inhalt prüfen und sichern, Verzeichnis leeren, erneut einhängen. |
|
||||
| „nicht eingehängt – Lauf abgebrochen“ | Die Freigabe ist weg (z. B. von Hand ausgehängt). Auf der Anbindungsseite **Einhängen**. NetMover hängt verschwundene Mounts absichtlich nicht selbst neu ein. |
|
||||
| „gestört (ESTALE …)“, danach wieder OK | NAS-Neustart; NetMover hat neu eingehängt. Bleibt der Fehler: Netzwerk/NAS prüfen. |
|
||||
| „weiterhin nicht verfügbar – neuer Versuch in 60 s“ | Das NAS ist (noch) weg, z. B. während eines DSM-Updates. Bei Anbindungen mit „automatisch einhängen“ versucht NetMover es jede Minute erneut und meldet per Webhook, wenn es wieder klappt. |
|
||||
| „Keine Reaktion seit 120 s“ | Ein Dateizugriff hängt (meist NFS mit `hard`). Die Regel bleibt blockiert („läuft seit …“), bis der Zugriff zurückkehrt; die Anbindung wird vor dem nächsten Lauf geprüft. |
|
||||
| „Hostschlüssel … hat sich geändert“ (SFTP) | Wurde der Zielserver neu aufgesetzt? Dann bei der Anbindung „Hostschlüssel zurücksetzen“ und neu testen. Sonst **nicht** verbinden. |
|
||||
| SFTP: „Anmeldung abgelehnt“ | Öffentlicher Schlüssel fehlt in `authorized_keys`, oder Rechte von `~/.ssh` stimmen nicht (700/600, Eigentümer). |
|
||||
| Paperless bemerkt neue Dateien nicht | Ältere Paperless-Versionen: `PAPERLESS_CONSUMER_POLLING=10` setzen. |
|
||||
| Neue Dateien erscheinen erst nach bis zu 30 s | Der CIFS-Client speichert Verzeichnislisten zwischen. Gelangen Dateien nicht per SMB aufs NAS (z. B. per FTP vom Scanner), erfährt er nichts davon. Abhilfe: Zusatzoption `nohandlecache`. |
|
||||
| Regel gelb „länger als 3 × Intervall“ | Seit dem Dreifachen des Intervalls kein erfolgreicher Lauf; Protokoll der Regel ansehen. |
|
||||
| Einrichtungscode verloren | `systemctl restart netmover` erzeugt einen neuen. |
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- **Nicht ins Internet stellen** (siehe [Reverse Proxy](#reverse-proxy-mit-tls)).
|
||||
- Zugangsdaten liegen nur verschlüsselt in der Datenbank (Fernet, Schlüssel
|
||||
`secret.key`, 0600). Die Oberfläche zeigt sie nie an; in der Prozessliste stehen
|
||||
sie ebenfalls nie, weil `mount.cifs` eine kurzlebige Credentials-Datei bekommt.
|
||||
Ausgaben von `mount` werden geschwärzt, bevor sie protokolliert werden.
|
||||
- **Sichere `secret.key` zusammen mit der Datenbank.** Ohne den Schlüssel sind die
|
||||
gespeicherten Passwörter verloren.
|
||||
- Anmeldung:
|
||||
- Ein Administratorkonto mit Argon2-Passworthash.
|
||||
- Sperre nach 5 Fehlversuchen.
|
||||
- Session-Cookie `HttpOnly`, `SameSite=Lax`.
|
||||
- CSRF-Token bei jedem POST.
|
||||
- Strikte Content-Security-Policy ohne Inline-Skripte.
|
||||
- Eingaben, die in `mount` landen, werden streng geprüft. Zusatzoptionen
|
||||
sind nur aus einer Allowlist erlaubt, `credentials`, `username`, `password` und
|
||||
`pass` sind gesperrt, `mount` wird nie über eine Shell aufgerufen.
|
||||
- Mountpoints entstehen ausschließlich unter `/mnt/netmover`; NetMover ändert
|
||||
`/etc/fstab` nie.
|
||||
- SFTP-Ziele: Schlüssel per `restrict,command="/usr/lib/openssh/sftp-server"` auf
|
||||
SFTP beschränken; der Hostschlüssel wird festgehalten.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
@@ -12,10 +403,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`, `keys.py`, `transfer.py`, `web/` | Formulare, Schlüsselverwaltung, 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 +430,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.2.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
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/sh
|
||||
# NetMover auf Debian 13 installieren bzw. aktualisieren (als root ausführen).
|
||||
#
|
||||
# git clone https://git.wilde-server.de/florian.wilde/netmover.git /opt/netmover/src
|
||||
# /opt/netmover/src/deploy/install.sh
|
||||
#
|
||||
# Spätere Updates: netmover-update (siehe deploy/update.sh).
|
||||
set -eu
|
||||
|
||||
SOURCE="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
PREFIX=/opt/netmover
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Bitte als root ausführen." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Pakete"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq python3 python3-venv cifs-utils nfs-common git >/dev/null
|
||||
|
||||
echo "==> Virtuelle Umgebung in $PREFIX/venv"
|
||||
install -d -m 0755 "$PREFIX"
|
||||
[ -x "$PREFIX/venv/bin/python" ] || python3 -m venv "$PREFIX/venv"
|
||||
"$PREFIX/venv/bin/pip" install -q --upgrade pip
|
||||
"$PREFIX/venv/bin/pip" install -q --upgrade "$SOURCE"
|
||||
|
||||
echo "==> Konfiguration"
|
||||
install -d -m 0755 /etc/netmover
|
||||
if [ ! -f /etc/netmover/netmover.env ]; then
|
||||
install -m 0640 "$(dirname "$0")/netmover.env" /etc/netmover/netmover.env
|
||||
fi
|
||||
install -d -m 0755 /mnt/netmover
|
||||
|
||||
echo "==> Update-Skript"
|
||||
install -m 0755 "$(dirname "$0")/update.sh" /usr/local/sbin/netmover-update
|
||||
|
||||
echo "==> systemd"
|
||||
install -m 0644 "$(dirname "$0")/netmover.service" /etc/systemd/system/netmover.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable netmover >/dev/null 2>&1
|
||||
systemctl restart netmover
|
||||
|
||||
sleep 2
|
||||
systemctl --no-pager --lines=0 status netmover || true
|
||||
echo
|
||||
if journalctl -u netmover -n 200 --no-pager | grep -q "setup.required"; then
|
||||
echo "Ersteinrichtung: Einrichtungscode aus dem Log:"
|
||||
journalctl -u netmover -n 200 --no-pager | grep "setup.required" | tail -1
|
||||
fi
|
||||
echo "Fertig. Weboberfläche: http://127.0.0.1:8080 (README: SSH-Tunnel / Reverse Proxy)"
|
||||
@@ -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,44 @@
|
||||
# NetMover als systemd-Dienst direkt auf dem Host (empfohlen).
|
||||
# Installation: siehe README, Abschnitt „Installation“.
|
||||
[Unit]
|
||||
Description=NetMover – NFS/SMB-Mounts und zeitgesteuertes Verschieben
|
||||
Documentation=https://git.wilde-server.de/florian.wilde/netmover
|
||||
Wants=network-online.target
|
||||
After=network-online.target remote-fs-pre.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
# root ist nötig: mount/umount brauchen CAP_SYS_ADMIN, und Dateien auf beliebigen
|
||||
# Freigaben sollen mit den richtigen Eigentümern geschrieben werden (siehe README).
|
||||
User=root
|
||||
EnvironmentFile=-/etc/netmover/netmover.env
|
||||
ExecStart=/opt/netmover/venv/bin/netmover serve
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# Beim Stoppen werden Mounts bewusst NICHT ausgehängt.
|
||||
KillMode=mixed
|
||||
TimeoutStopSec=20
|
||||
|
||||
# /var/lib/netmover (Datenbank, Schlüssel) und /run/netmover (kurzlebige
|
||||
# Credentials-Dateien für mount.cifs) – beide nur für root lesbar.
|
||||
StateDirectory=netmover
|
||||
StateDirectoryMode=0700
|
||||
RuntimeDirectory=netmover
|
||||
RuntimeDirectoryMode=0700
|
||||
|
||||
# Härtung, soweit mit Mounten vereinbar.
|
||||
# WICHTIG: Keine Optionen, die einen eigenen Mount-Namespace erzeugen
|
||||
# (ProtectSystem=, ProtectHome=, PrivateTmp=, ProtectControlGroups=,
|
||||
# ReadOnlyPaths=, BindPaths= …). Sonst landen die Mounts im Namespace des
|
||||
# Dienstes: das System und andere Programme (Paperless!) sähen sie nicht, und
|
||||
# jeder Neustart des Dienstes würde sie verwerfen. MountFlags=shared hilft dagegen
|
||||
# nicht – systemd setzt den Namespace als „slave“ auf, Mounts fließen nur hinein.
|
||||
# Auf dem Testsystem nachgewiesen; prüfen mit: findmnt -l | grep /mnt/netmover
|
||||
NoNewPrivileges=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
UMask=0022
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
#!/bin/sh
|
||||
# NetMover aktualisieren (als root). Wird von install.sh als
|
||||
# /usr/local/sbin/netmover-update installiert.
|
||||
#
|
||||
# netmover-update # auf die neueste Version (neuester Tag vX.Y.Z)
|
||||
# netmover-update v0.2.0 # auf eine bestimmte Version (auch zurück)
|
||||
# netmover-update --list # verfügbare Versionen anzeigen
|
||||
#
|
||||
# Ablauf: Tags holen → Datenbank und Schlüssel sichern → Version auschecken →
|
||||
# install.sh (installiert, migriert beim Start, startet den Dienst neu) →
|
||||
# Gesundheit prüfen. Die Mounts bleiben dabei eingehängt.
|
||||
set -eu
|
||||
|
||||
SRC="${NETMOVER_SRC:-/opt/netmover/src}"
|
||||
VENV=/opt/netmover/venv
|
||||
ENV_FILE=/etc/netmover/netmover.env
|
||||
BACKUP_ROOT=/var/backups/netmover
|
||||
KEEP_BACKUPS=10
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Bitte als root ausführen." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$SRC/.git" ]; then
|
||||
echo "Kein Git-Klon unter $SRC. Einmalig einrichten:" >&2
|
||||
echo " git clone https://git.wilde-server.de/florian.wilde/netmover.git $SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Einstellungen des Dienstes (Port, Datenverzeichnis)
|
||||
NETMOVER_PORT=8080
|
||||
NETMOVER_DATA_DIR=/var/lib/netmover
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
|
||||
|
||||
cd "$SRC"
|
||||
git fetch --tags --quiet origin
|
||||
|
||||
if [ "${1:-}" = "--list" ]; then
|
||||
git tag -l 'v*' --sort=-v:refname
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TARGET="${1:-$(git tag -l 'v*' --sort=-v:refname | head -n 1)}"
|
||||
if ! git rev-parse -q --verify "refs/tags/$TARGET" >/dev/null; then
|
||||
echo "Version $TARGET gibt es nicht. Verfügbar:" >&2
|
||||
git tag -l 'v*' --sort=-v:refname | head -n 10 >&2
|
||||
exit 1
|
||||
fi
|
||||
CURRENT="v$("$VENV/bin/netmover" version 2>/dev/null || echo "?")"
|
||||
echo "Installiert: $CURRENT"
|
||||
echo "Ziel: $TARGET"
|
||||
if [ "$CURRENT" = "$TARGET" ] && [ "${FORCE:-0}" != "1" ]; then
|
||||
echo "Bereits auf dem gewünschten Stand (FORCE=1 erzwingt eine Neuinstallation)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
NEWEST="$(printf '%s\n%s\n' "$CURRENT" "$TARGET" | sort -V | tail -n 1)"
|
||||
if [ "$NEWEST" = "$CURRENT" ] && [ "$CURRENT" != "$TARGET" ]; then
|
||||
echo
|
||||
echo "!! Zurückstufen auf eine ältere Version: Wurde die Datenbank inzwischen migriert,"
|
||||
echo " startet die alte Version nicht. Dann vorher die Sicherung von *vor* dem Update"
|
||||
echo " zurückspielen (siehe $BACKUP_ROOT, Anleitung im README)."
|
||||
printf " Trotzdem fortfahren? [j/N] "
|
||||
read -r answer
|
||||
[ "$answer" = "j" ] || [ "$answer" = "J" ] || exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "==> Änderungen laut Changelog"
|
||||
git show "$TARGET:CHANGELOG.md" | awk -v cur="${CURRENT#v}" '
|
||||
/^## \[/ { if (index($0, "[" cur "]")) exit; show = ($0 !~ /Unreleased/) }
|
||||
show' | head -n 60
|
||||
|
||||
echo
|
||||
echo "==> Sicherung"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP="$BACKUP_ROOT/$STAMP-$CURRENT"
|
||||
install -d -m 0700 "$BACKUP_ROOT" "$BACKUP"
|
||||
# Konsistente Kopie auch bei laufendem Dienst (SQLite-Online-Backup)
|
||||
"$VENV/bin/python" - "$NETMOVER_DATA_DIR/netmover.db" "$BACKUP/netmover.db" <<'EOF'
|
||||
import sqlite3, sys
|
||||
src, dst = sqlite3.connect(sys.argv[1]), sqlite3.connect(sys.argv[2])
|
||||
with dst:
|
||||
src.backup(dst)
|
||||
src.close(); dst.close()
|
||||
EOF
|
||||
for f in secret.key session.key; do
|
||||
[ -f "$NETMOVER_DATA_DIR/$f" ] && cp -p "$NETMOVER_DATA_DIR/$f" "$BACKUP/"
|
||||
done
|
||||
[ -f "$ENV_FILE" ] && cp -p "$ENV_FILE" "$BACKUP/"
|
||||
chmod 0600 "$BACKUP"/*
|
||||
echo " $BACKUP"
|
||||
# alte Sicherungen aufräumen
|
||||
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -print | sort \
|
||||
| head -n "-$KEEP_BACKUPS" | xargs -r rm -rf --
|
||||
|
||||
echo
|
||||
echo "==> Version $TARGET auschecken und installieren"
|
||||
git -c advice.detachedHead=false checkout --quiet "$TARGET"
|
||||
"$SRC/deploy/install.sh" "$SRC" >/tmp/netmover-update.log 2>&1 || {
|
||||
echo "install.sh ist fehlgeschlagen – Ausgabe: /tmp/netmover-update.log" >&2
|
||||
tail -n 20 /tmp/netmover-update.log >&2
|
||||
FAILED=1
|
||||
}
|
||||
|
||||
echo
|
||||
echo "==> Prüfung"
|
||||
OK=0
|
||||
if [ "${FAILED:-0}" != "1" ]; then
|
||||
i=0
|
||||
while [ $i -lt 30 ]; do
|
||||
if "$VENV/bin/python" - "$NETMOVER_PORT" <<'EOF'
|
||||
import json, sys, urllib.error, urllib.request
|
||||
url = f"http://127.0.0.1:{sys.argv[1]}/healthz"
|
||||
try:
|
||||
body = urllib.request.urlopen(url, timeout=3).read()
|
||||
except urllib.error.HTTPError as exc: # 503 = läuft, meldet aber Probleme
|
||||
body = exc.read()
|
||||
except OSError:
|
||||
sys.exit(1)
|
||||
data = json.loads(body)
|
||||
print(f" Gesundheit: {data['status']}")
|
||||
for p in data.get("problems", []):
|
||||
print(f" – {p}")
|
||||
EOF
|
||||
then
|
||||
OK=1
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$OK" = "1" ] && systemctl is-active --quiet netmover; then
|
||||
echo " Dienst läuft: $("$VENV/bin/netmover" version)"
|
||||
echo
|
||||
echo "Fertig. Sicherung: $BACKUP"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "!! Der Dienst läuft nach dem Update nicht richtig." >&2
|
||||
echo " Log: journalctl -u netmover -n 50" >&2
|
||||
echo " Zurück auf $CURRENT (Datenbank aus der Sicherung, weil sie migriert sein kann):" >&2
|
||||
echo " systemctl stop netmover" >&2
|
||||
echo " cp $BACKUP/netmover.db $NETMOVER_DATA_DIR/netmover.db" >&2
|
||||
echo " rm -f $NETMOVER_DATA_DIR/netmover.db-wal $NETMOVER_DATA_DIR/netmover.db-shm" >&2
|
||||
echo " netmover-update $CURRENT" >&2
|
||||
exit 1
|
||||
@@ -33,6 +33,7 @@ dev = [
|
||||
"ruff>=0.6",
|
||||
"mypy>=1.11",
|
||||
"types-paramiko",
|
||||
"shellcheck-py",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
|
||||
|
||||
__version__ = "0.1.0.dev0"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
+54
-15
@@ -84,6 +84,9 @@ def _int(
|
||||
# -- Anbindungen ------------------------------------------------------------------------
|
||||
|
||||
|
||||
NEW_KEY = "new"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MountInput:
|
||||
name: str = ""
|
||||
@@ -95,6 +98,9 @@ class MountInput:
|
||||
password: str = "" # leer = unverändert
|
||||
domain: str = ""
|
||||
auth: CredentialKind = CredentialKind.PASSWORD # nur sftp: Schlüssel oder Passwort
|
||||
# nur sftp + Schlüssel: "new" = eigener Schlüssel nur für diese Anbindung,
|
||||
# sonst ID eines Schlüssels aus der Schlüsselverwaltung
|
||||
key_id: str = NEW_KEY
|
||||
uid: int | None = None
|
||||
gid: int | None = None
|
||||
file_mode: str | None = None
|
||||
@@ -126,9 +132,10 @@ def mount_input_from_model(m: Mount) -> MountInput:
|
||||
server=m.server,
|
||||
share=m.share,
|
||||
port=m.port,
|
||||
username=c.username if c else "",
|
||||
username=m.username or (c.username if c else ""),
|
||||
domain=(c.domain or "") if c else "",
|
||||
auth=c.kind if c else CredentialKind.PASSWORD,
|
||||
key_id=str(c.id) if c is not None and c.managed else NEW_KEY,
|
||||
uid=m.uid,
|
||||
gid=m.gid,
|
||||
file_mode=m.file_mode,
|
||||
@@ -151,7 +158,11 @@ def parse_mount_form(form: Mapping[str, str]) -> tuple[MountInput, Errors]:
|
||||
extra_options=(form.get("extra_options") or "").strip(),
|
||||
auto_mount=_bool(form, "auto_mount"),
|
||||
enabled=_bool(form, "enabled"),
|
||||
key_id=(form.get("key_id") or NEW_KEY).strip(),
|
||||
)
|
||||
if data.key_id != NEW_KEY and not data.key_id.isdigit():
|
||||
errors["key_id"] = "Ungültige Schlüsselauswahl."
|
||||
data.key_id = NEW_KEY
|
||||
try:
|
||||
data.type = MountType(form.get("type", "cifs"))
|
||||
except ValueError:
|
||||
@@ -203,17 +214,27 @@ def parse_mount_form(form: Mapping[str, str]) -> tuple[MountInput, Errors]:
|
||||
errors["username"] = "SFTP braucht einen Benutzernamen."
|
||||
if data.type is not MountType.SFTP:
|
||||
data.auth = CredentialKind.PASSWORD
|
||||
if data.auth is not CredentialKind.SSH_KEY:
|
||||
data.key_id = NEW_KEY
|
||||
return data, errors
|
||||
|
||||
|
||||
def credential_for_test(
|
||||
data: MountInput, existing: Mount | None, secrets: SecretBox
|
||||
data: MountInput, existing: Mount | None, secrets: SecretBox, s: Session | None = None
|
||||
) -> CredentialData | None:
|
||||
"""Zugangsdaten für „Testen“ aus dem Formular – leeres Passwort = gespeichertes."""
|
||||
"""Zugangsdaten für „Testen“ aus dem Formular – leeres Passwort = gespeichertes,
|
||||
gewählter Schlüssel aus der Schlüsselverwaltung."""
|
||||
if data.type in (MountType.NFS3, MountType.NFS4) or not data.username:
|
||||
return None
|
||||
if data.auth is CredentialKind.SSH_KEY and data.key_id != NEW_KEY and s is not None:
|
||||
key = s.get(Credential, int(data.key_id))
|
||||
if key is None or not key.managed:
|
||||
return None
|
||||
return CredentialData(data.auth, data.username, secrets.decrypt(key.secret_enc))
|
||||
secret = data.password
|
||||
stored = existing.credential if existing is not None else None
|
||||
if stored is not None and stored.managed:
|
||||
stored = None # eigener Schlüssel gewählt, gespeicherter ist nicht gemeint
|
||||
if not secret and stored is not None and stored.kind is data.auth:
|
||||
secret = secrets.decrypt(stored.secret_enc)
|
||||
if not secret:
|
||||
@@ -296,20 +317,32 @@ def save_mount(
|
||||
if not data.type.is_kernel_mount:
|
||||
m.auto_mount = False
|
||||
|
||||
# Zugangsdaten: jede Anbindung pflegt ihre eigenen.
|
||||
cred = m.credential
|
||||
# Zugangsdaten: entweder ein gespeicherter Schlüssel aus der Schlüsselverwaltung
|
||||
# (geteilt, wird hier nie verändert) oder eigene Zugangsdaten nur dieser Anbindung.
|
||||
current = m.credential
|
||||
own = current if current is not None and not current.managed else None
|
||||
needs_cred = data.type is MountType.SFTP or (
|
||||
data.type is MountType.CIFS and bool(data.username)
|
||||
)
|
||||
use_managed = (
|
||||
data.type is MountType.SFTP
|
||||
and data.auth is CredentialKind.SSH_KEY
|
||||
and data.key_id != NEW_KEY
|
||||
)
|
||||
if not needs_cred:
|
||||
if cred is not None:
|
||||
m.credential = None
|
||||
s.flush()
|
||||
if not cred.mounts:
|
||||
s.delete(cred)
|
||||
m.credential = None
|
||||
m.username = None
|
||||
elif use_managed:
|
||||
key = s.get(Credential, int(data.key_id))
|
||||
if key is None or not key.managed or key.kind is not CredentialKind.SSH_KEY:
|
||||
s.rollback()
|
||||
return None, {"key_id": "Schlüssel existiert nicht."}
|
||||
m.credential = key
|
||||
m.username = data.username
|
||||
else:
|
||||
kind_changed = cred is not None and cred.kind is not data.auth
|
||||
if cred is None or kind_changed:
|
||||
m.username = None
|
||||
cred = own
|
||||
if cred is None or cred.kind is not data.auth:
|
||||
if data.auth is CredentialKind.SSH_KEY:
|
||||
private, public = generate_ssh_keypair(f"netmover@{m.slug}")
|
||||
secret, pub = private, public
|
||||
@@ -320,9 +353,8 @@ def save_mount(
|
||||
return None, errors
|
||||
secret, pub = data.password, None
|
||||
if cred is None:
|
||||
cred = Credential(name=f"mount:{m.slug}")
|
||||
cred = Credential(name=f"mount:{m.slug}", managed=False)
|
||||
s.add(cred)
|
||||
m.credential = cred
|
||||
cred.kind = data.auth
|
||||
cred.secret_enc = secrets.encrypt(secret)
|
||||
cred.public_key = pub
|
||||
@@ -330,6 +362,12 @@ def save_mount(
|
||||
cred.secret_enc = secrets.encrypt(data.password)
|
||||
cred.username = data.username
|
||||
cred.domain = data.domain or None
|
||||
m.credential = cred
|
||||
# Eigene Zugangsdaten, die nicht mehr gebraucht werden, entfernen.
|
||||
if own is not None and m.credential is not own:
|
||||
s.flush()
|
||||
if not own.mounts:
|
||||
s.delete(own)
|
||||
try:
|
||||
s.flush()
|
||||
except IntegrityError:
|
||||
@@ -353,7 +391,8 @@ def delete_mount(s: Session, mounts: MountManager, mount_id: int) -> None:
|
||||
cred = m.credential
|
||||
s.delete(m)
|
||||
s.flush()
|
||||
if cred is not None and not cred.mounts:
|
||||
# Eigene Zugangsdaten gehen mit; Schlüssel aus der Schlüsselverwaltung bleiben.
|
||||
if cred is not None and not cred.managed and not cred.mounts:
|
||||
s.delete(cred)
|
||||
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@ class NetMover:
|
||||
)
|
||||
self.runner = RuleRunner(self.session_factory, self.mounts)
|
||||
self.runs = RunService(self.session_factory, self.runner, self.notifier)
|
||||
self.scheduler = RuleScheduler(self.session_factory, self.runs)
|
||||
self.scheduler = RuleScheduler(
|
||||
self.session_factory, self.runs, retry_mounts=self.mounts.retry_pending
|
||||
)
|
||||
self.started_at: datetime = utcnow()
|
||||
self.restore_results: list[RestoreResult] = []
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Schlüsselverwaltung: gespeicherte SSH-Schlüssel für SFTP-Anbindungen.
|
||||
|
||||
* Erzeugen (Ed25519) oder Importieren (Ed25519, ECDSA, RSA ≥ 2048 Bit; OpenSSH-
|
||||
oder PEM-Format, optional mit Passphrase). Eine Passphrase wird beim Import
|
||||
einmal benutzt; gespeichert wird der Schlüssel ohne Passphrase, aber mit
|
||||
Fernet verschlüsselt – wie alle Geheimnisse.
|
||||
* Der private Schlüssel verlässt NetMover nie: Die Oberfläche zeigt nur den
|
||||
öffentlichen Schlüssel und den Fingerabdruck.
|
||||
* Ein Schlüssel kann von mehreren Anbindungen genutzt werden; den Benutzernamen
|
||||
legt jede Anbindung selbst fest. Löschen geht nur, solange ihn keine nutzt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from netmover.admin import AdminError
|
||||
from netmover.crypto import SecretBox
|
||||
from netmover.models import Credential, CredentialKind, Mount
|
||||
|
||||
MAX_KEY_BYTES = 16 * 1024
|
||||
MIN_RSA_BITS = 2048
|
||||
SFTP_ONLY_PREFIX = 'restrict,command="/usr/lib/openssh/sftp-server" '
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyInfo:
|
||||
id: int
|
||||
name: str
|
||||
key_type: str
|
||||
fingerprint: str
|
||||
public_key: str
|
||||
created_at: datetime
|
||||
used_by: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def authorized_keys_line(self) -> str:
|
||||
"""Zeile für ~/.ssh/authorized_keys, auf SFTP beschränkt."""
|
||||
return SFTP_ONLY_PREFIX + self.public_key
|
||||
|
||||
|
||||
def fingerprint(public_key: str) -> str:
|
||||
"""SHA256-Fingerabdruck wie ``ssh-keygen -lf``."""
|
||||
blob = base64.b64decode(public_key.split()[1])
|
||||
return "SHA256:" + base64.b64encode(hashlib.sha256(blob).digest()).decode().rstrip("=")
|
||||
|
||||
|
||||
def _validate_name(s: Session, name: str, exclude_id: int | None = None) -> str:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise AdminError("Name fehlt.")
|
||||
if len(name) > 100:
|
||||
raise AdminError("Name höchstens 100 Zeichen.")
|
||||
if name.startswith("mount:"):
|
||||
raise AdminError("Namen mit „mount:“ sind für NetMover reserviert.")
|
||||
dup = s.scalar(select(Credential.id).where(Credential.name == name))
|
||||
if dup is not None and dup != exclude_id:
|
||||
raise AdminError(f"Ein Schlüssel „{name}“ existiert bereits.")
|
||||
return name
|
||||
|
||||
|
||||
def _comment(name: str) -> str:
|
||||
return "netmover:" + "".join(c if c.isalnum() or c in "-_." else "-" for c in name)
|
||||
|
||||
|
||||
PrivateKey = ed25519.Ed25519PrivateKey | ec.EllipticCurvePrivateKey | rsa.RSAPrivateKey
|
||||
|
||||
|
||||
def _store(s: Session, secrets: SecretBox, name: str, key: PrivateKey) -> Credential:
|
||||
private = key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.OpenSSH,
|
||||
serialization.NoEncryption(),
|
||||
).decode()
|
||||
public = (
|
||||
key.public_key()
|
||||
.public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH)
|
||||
.decode()
|
||||
)
|
||||
cred = Credential(
|
||||
name=name,
|
||||
kind=CredentialKind.SSH_KEY,
|
||||
username="",
|
||||
secret_enc=secrets.encrypt(private),
|
||||
public_key=f"{public} {_comment(name)}",
|
||||
managed=True,
|
||||
)
|
||||
s.add(cred)
|
||||
s.flush()
|
||||
return cred
|
||||
|
||||
|
||||
def generate_key(s: Session, secrets: SecretBox, name: str) -> Credential:
|
||||
return _store(s, secrets, _validate_name(s, name), ed25519.Ed25519PrivateKey.generate())
|
||||
|
||||
|
||||
def import_key(
|
||||
s: Session, secrets: SecretBox, name: str, private_key: str, passphrase: str = ""
|
||||
) -> Credential:
|
||||
name = _validate_name(s, name)
|
||||
data = private_key.strip().encode()
|
||||
if not data:
|
||||
raise AdminError("Privaten Schlüssel einfügen.")
|
||||
if len(data) > MAX_KEY_BYTES:
|
||||
raise AdminError("Schlüssel ist zu groß.")
|
||||
if b"PUBLIC KEY" in data or data.startswith((b"ssh-", b"ecdsa-")):
|
||||
raise AdminError("Das ist ein öffentlicher Schlüssel – benötigt wird der private.")
|
||||
password = passphrase.encode() if passphrase else None
|
||||
loaders = (
|
||||
[serialization.load_ssh_private_key]
|
||||
if b"OPENSSH PRIVATE KEY" in data
|
||||
else [serialization.load_pem_private_key]
|
||||
)
|
||||
try:
|
||||
key = loaders[0](data, password)
|
||||
except TypeError:
|
||||
raise AdminError(
|
||||
"Der Schlüssel ist mit einer Passphrase geschützt – bitte Passphrase angeben."
|
||||
if password is None
|
||||
else "Der Schlüssel hat keine Passphrase – Feld bitte leer lassen."
|
||||
) from None
|
||||
except ValueError as exc:
|
||||
msg = str(exc).lower()
|
||||
if password is not None or "password" in msg or "decrypt" in msg:
|
||||
# Bei OpenSSH-Schlüsseln meldet cryptography eine falsche Passphrase
|
||||
# als „Corrupt data“ – beides lässt sich nicht unterscheiden.
|
||||
raise AdminError("Passphrase falsch (oder Schlüssel beschädigt).") from None
|
||||
raise AdminError(
|
||||
"Kein lesbarer privater Schlüssel (erwartet OpenSSH- oder PEM-Format)."
|
||||
) from None
|
||||
except Exception: # unbekannte Formate/Algorithmen sauber melden
|
||||
raise AdminError("Schlüsselformat wird nicht unterstützt.") from None
|
||||
if not isinstance(
|
||||
key, ed25519.Ed25519PrivateKey | ec.EllipticCurvePrivateKey | rsa.RSAPrivateKey
|
||||
):
|
||||
raise AdminError("Unterstützt werden Ed25519, ECDSA und RSA.")
|
||||
if isinstance(key, rsa.RSAPrivateKey) and key.key_size < MIN_RSA_BITS:
|
||||
raise AdminError(f"RSA-Schlüssel mit {key.key_size} Bit ist zu schwach (mind. 2048).")
|
||||
return _store(s, secrets, name, key)
|
||||
|
||||
|
||||
def _users(s: Session, key_id: int) -> list[str]:
|
||||
# Direkt abfragen statt key.mounts: die Beziehung ist nach dem Löschen von
|
||||
# Anbindungen in derselben Session veraltet (passive_deletes).
|
||||
return sorted(s.scalars(select(Mount.name).where(Mount.cred_id == key_id)))
|
||||
|
||||
|
||||
def list_keys(s: Session) -> list[KeyInfo]:
|
||||
keys = s.scalars(
|
||||
select(Credential).where(Credential.managed.is_(True)).order_by(Credential.name)
|
||||
).all()
|
||||
result = []
|
||||
for k in keys:
|
||||
pub = k.public_key or ""
|
||||
result.append(
|
||||
KeyInfo(
|
||||
id=k.id,
|
||||
name=k.name,
|
||||
key_type=pub.split()[0] if pub else "?",
|
||||
fingerprint=fingerprint(pub) if pub else "?",
|
||||
public_key=pub,
|
||||
created_at=k.created_at,
|
||||
used_by=_users(s, k.id),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def managed_key(s: Session, key_id: int) -> Credential:
|
||||
key = s.get(Credential, key_id)
|
||||
if key is None or not key.managed or key.kind is not CredentialKind.SSH_KEY:
|
||||
raise AdminError("Schlüssel existiert nicht.")
|
||||
return key
|
||||
|
||||
|
||||
def rename_key(s: Session, key_id: int, name: str) -> None:
|
||||
key = managed_key(s, key_id)
|
||||
key.name = _validate_name(s, name, exclude_id=key_id)
|
||||
|
||||
|
||||
def delete_key(s: Session, key_id: int) -> str:
|
||||
key = managed_key(s, key_id)
|
||||
users = _users(s, key_id)
|
||||
if users:
|
||||
raise AdminError(f"Schlüssel „{key.name}“ wird noch benutzt von: {', '.join(users)}")
|
||||
name = key.name
|
||||
s.delete(key)
|
||||
s.flush()
|
||||
return name
|
||||
@@ -0,0 +1,34 @@
|
||||
"""key management
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-10-10
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: str | None = "0001"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("credentials", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("managed", sa.Boolean(), server_default=sa.text("0"), nullable=False)
|
||||
)
|
||||
|
||||
with op.batch_alter_table("mounts", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("username", sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("mounts", schema=None) as batch_op:
|
||||
batch_op.drop_column("username")
|
||||
|
||||
with op.batch_alter_table("credentials", schema=None) as batch_op:
|
||||
batch_op.drop_column("managed")
|
||||
@@ -7,6 +7,9 @@ Abweichungen bzw. Ergänzungen gegenüber dem Mindestumfang aus der Spezifikatio
|
||||
(beim ersten Test festgehaltener Hostschlüssel, danach verbindlich).
|
||||
* ``credentials.kind`` (``password`` | ``ssh_key``) und ``credentials.public_key``.
|
||||
* ``credentials.domain`` – Windows-/AD-Freigaben brauchen oft eine Domäne.
|
||||
* ``credentials.managed`` + ``mounts.username`` (Migration 0002) –
|
||||
Schlüsselverwaltung: gespeicherte SSH-Schlüssel, die mehrere Anbindungen mit
|
||||
jeweils eigenem Benutzernamen nutzen.
|
||||
* ``mounts.state_since`` – für „hängt seit …“ / „gestört seit …“ auf der Statusseite.
|
||||
* ``mounts.write_ok`` / ``mounts.root_squash`` / ``mounts.last_test_*`` – Ergebnis
|
||||
des letzten Mount-Tests; ``root_squash`` entscheidet, ob ``chown`` auf NFS-Zielen
|
||||
@@ -38,6 +41,7 @@ from sqlalchemy import (
|
||||
TypeDecorator,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy import false as sa_false
|
||||
from sqlalchemy.engine import Dialect
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
@@ -166,6 +170,9 @@ class Credential(Base):
|
||||
domain: Mapped[str | None] = mapped_column(String(255))
|
||||
secret_enc: Mapped[str] = mapped_column(Text) # Passwort bzw. privater Schlüssel (Fernet)
|
||||
public_key: Mapped[str | None] = mapped_column(Text) # nur ssh_key, OpenSSH-Format
|
||||
# True: in der Schlüsselverwaltung gepflegt, von mehreren Anbindungen nutzbar.
|
||||
# False: gehört genau einer Anbindung und wird mit ihr gelöscht.
|
||||
managed: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa_false())
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
|
||||
|
||||
# passive_deletes="all": das ORM soll cred_id nicht still auf NULL setzen,
|
||||
@@ -183,6 +190,9 @@ class Mount(Base):
|
||||
server: Mapped[str] = mapped_column(String(255))
|
||||
share: Mapped[str] = mapped_column(String(1024))
|
||||
port: Mapped[int | None] = mapped_column(Integer) # nur sftp
|
||||
# Benutzer für die Anmeldung; überschreibt credentials.username. Nötig, weil ein
|
||||
# gespeicherter SSH-Schlüssel für verschiedene Server/Benutzer gelten kann.
|
||||
username: Mapped[str | None] = mapped_column(String(255))
|
||||
host_key: Mapped[str | None] = mapped_column(Text) # nur sftp, "<typ> <base64>"
|
||||
mountpoint: Mapped[str | None] = mapped_column(String(1024), unique=True) # NULL bei sftp
|
||||
cred_id: Mapped[int | None] = mapped_column(ForeignKey("credentials.id", ondelete="RESTRICT"))
|
||||
|
||||
@@ -8,7 +8,9 @@ Optionszeile – die stünde sonst in der Prozessliste.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import uuid
|
||||
from collections.abc import Callable, Sequence
|
||||
@@ -24,6 +26,28 @@ log = get_logger(__name__)
|
||||
|
||||
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
|
||||
|
||||
|
||||
def run_process_group(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
"""Wie ``subprocess.run``, aber in eigener Prozessgruppe: Bei Zeitüberschreitung
|
||||
wird die ganze Gruppe beendet. ``mount`` startet Hilfsprogramme wie
|
||||
``mount.nfs``; würde nur ``mount`` beendet, liefe ``mount.nfs`` weiter und
|
||||
hängte womöglich Minuten später doch noch ein (auf der Test-VM beobachtet)."""
|
||||
timeout = kwargs.pop("timeout", None)
|
||||
kwargs.pop("check", None)
|
||||
capture = kwargs.pop("capture_output", False)
|
||||
if capture:
|
||||
kwargs["stdout"] = kwargs["stderr"] = subprocess.PIPE
|
||||
with subprocess.Popen(argv, start_new_session=True, **kwargs) as proc:
|
||||
try:
|
||||
out, err = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.communicate()
|
||||
raise
|
||||
return subprocess.CompletedProcess(argv, proc.returncode, out, err)
|
||||
|
||||
|
||||
_ENV = {"LC_ALL": "C", "PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}
|
||||
|
||||
|
||||
@@ -34,6 +58,13 @@ def explain_mount_error(fstype: str, message: str) -> str:
|
||||
if "helper program" in low or "unknown filesystem type" in low:
|
||||
pkg = "cifs-utils" if fstype == "cifs" else "nfs-common"
|
||||
hint = f"Das Paket „{pkg}“ fehlt: apt install {pkg}"
|
||||
elif fstype == "cifs" and ("error(95)" in low or "operation not supported" in low):
|
||||
hint = (
|
||||
"Server und Client finden keine gemeinsame SMB-Version. Am besten am NAS "
|
||||
"SMB2/SMB3 erlauben (Synology: Systemsteuerung → Dateidienste → SMB → "
|
||||
"Erweiterte Einstellungen → Maximales SMB-Protokoll: SMB3). Alternativ "
|
||||
"Zusatzoption vers=2.0 – vers=1.0 nur im Notfall, SMB1 ist unsicher."
|
||||
)
|
||||
elif "permission denied" in low and fstype == "cifs":
|
||||
hint = "Benutzername/Passwort falsch oder der Benutzer hat keine Rechte auf die Freigabe."
|
||||
elif "permission denied" in low or "access denied by server" in low:
|
||||
@@ -60,7 +91,7 @@ class LinuxMountBackend(MountBackend):
|
||||
self,
|
||||
run_dir: Path,
|
||||
mountinfo_path: Path = MOUNTINFO,
|
||||
runner: Runner = subprocess.run,
|
||||
runner: Runner = run_process_group,
|
||||
) -> None:
|
||||
self.run_dir = run_dir
|
||||
self.mountinfo_path = mountinfo_path
|
||||
|
||||
@@ -15,7 +15,9 @@ Fallstricke:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import errno
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
@@ -104,6 +106,13 @@ class RestoreResult:
|
||||
message: str
|
||||
|
||||
|
||||
def _redact_output(message: str, secrets: Sequence[str]) -> str:
|
||||
"""Schwärzt nur die Ausgabe von ``mount``, nicht den angehängten Hinweistext
|
||||
(sonst würde z. B. das Passwort „falsch“ auch im Hinweis geschwärzt)."""
|
||||
output, sep, hint = message.partition("\n→ ")
|
||||
return redact(output, secrets) + sep + hint
|
||||
|
||||
|
||||
def build_spec(cfg: MountConfig, mountpoint: Path, cred: CredentialData | None) -> MountSpec:
|
||||
"""Baut den ``mount``-Aufruf. Validiert erneut (Verteidigung in der Tiefe) –
|
||||
auch Daten aus der Datenbank laufen hier noch einmal durch die Prüfung."""
|
||||
@@ -166,6 +175,8 @@ class MountManager:
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
euid: Callable[[], int] = os.geteuid,
|
||||
notifier: Notifier | None = None,
|
||||
retry_interval: float = 60,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
self.backend = backend
|
||||
@@ -177,6 +188,12 @@ class MountManager:
|
||||
self._sleep = sleep
|
||||
self._euid = euid
|
||||
self._notifier = notifier
|
||||
self.retry_interval = retry_interval
|
||||
self._clock = clock
|
||||
# Mounts, die NetMover nach gescheiterter Wiederherstellung selbst ausgehängt
|
||||
# hat: nächster erlaubter Versuch (monotone Zeit). Nur diese werden
|
||||
# automatisch neu eingehängt – von außen ausgehängte nie (Fallstrick 2).
|
||||
self._retry_at: dict[int, float] = {}
|
||||
self._locks: dict[int, threading.RLock] = {}
|
||||
self._locks_guard = threading.Lock()
|
||||
|
||||
@@ -211,7 +228,7 @@ class MountManager:
|
||||
if m.credential is not None:
|
||||
c = m.credential
|
||||
cred = CredentialData(
|
||||
c.kind, c.username, self.secrets.decrypt(c.secret_enc), c.domain
|
||||
c.kind, m.username or c.username, self.secrets.decrypt(c.secret_enc), c.domain
|
||||
)
|
||||
s.expunge(m)
|
||||
return m, cred
|
||||
@@ -267,6 +284,7 @@ class MountManager:
|
||||
|
||||
def mount(self, mount_id: int) -> None:
|
||||
with self._lock(mount_id):
|
||||
self._retry_at.pop(mount_id, None)
|
||||
m, cred = self._load(mount_id)
|
||||
if not m.type.is_kernel_mount:
|
||||
raise MountError("SFTP-Anbindungen werden nicht eingehängt, nur getestet")
|
||||
@@ -292,6 +310,7 @@ class MountManager:
|
||||
|
||||
def unmount(self, mount_id: int) -> None:
|
||||
with self._lock(mount_id):
|
||||
self._retry_at.pop(mount_id, None)
|
||||
m, _ = self._load(mount_id)
|
||||
if not m.type.is_kernel_mount:
|
||||
self._set_state(mount_id, MountState.UNMOUNTED)
|
||||
@@ -325,6 +344,8 @@ class MountManager:
|
||||
self.mount(mount_id)
|
||||
results.append(RestoreResult(name, True, "eingehängt"))
|
||||
except (MountError, ValidationError, LookupError) as exc:
|
||||
# z. B. Stromausfall: die VM bootet schneller als das NAS
|
||||
self._retry_at[mount_id] = self._clock() + self.retry_interval
|
||||
results.append(RestoreResult(name, False, str(exc)))
|
||||
self._notify("mount.restore_failed", name, str(exc))
|
||||
for r in results:
|
||||
@@ -391,7 +412,7 @@ class MountManager:
|
||||
self.backend.mount(build_spec(cfg, temp, cred), timeout=self.mount_timeout)
|
||||
except (MountError, ValidationError) as exc:
|
||||
self._remove_mountpoint(temp)
|
||||
report.step("mount", False, redact(str(exc), secrets))
|
||||
report.step("mount", False, _redact_output(str(exc), secrets))
|
||||
report.step("read", None)
|
||||
report.step("write", None)
|
||||
return report
|
||||
@@ -486,6 +507,11 @@ class MountManager:
|
||||
if cfg.type is MountType.SFTP and m.host_key is None and report.can_read:
|
||||
m.host_key = report.host_key
|
||||
event(log, "sftp.host_key_pinned", mount=m.name, fp=report.host_key_fingerprint)
|
||||
# SFTP hat keinen Mount-Zustand – ein erfolgreicher Test heißt „erreichbar“.
|
||||
if cfg.type is MountType.SFTP and report.ok and m.state is not MountState.MOUNTED:
|
||||
m.state = MountState.MOUNTED
|
||||
m.state_since = utcnow()
|
||||
m.last_error = None
|
||||
|
||||
# -- Lebenszeichen und Wiederherstellung --------------------------------------
|
||||
|
||||
@@ -501,6 +527,9 @@ class MountManager:
|
||||
return None
|
||||
mp = self.mountpoint_for(m.slug)
|
||||
if not self.backend.is_mounted(mp):
|
||||
if mount_id in self._retry_at:
|
||||
self._retry_mount(mount_id, m, cred, mp)
|
||||
return mp
|
||||
msg = f"„{m.name}“ ist nicht eingehängt – Lauf abgebrochen, nichts angefasst."
|
||||
self._set_state(mount_id, MountState.ERROR, msg)
|
||||
raise MountUnavailable(msg)
|
||||
@@ -568,10 +597,74 @@ class MountManager:
|
||||
f"„{m.name}“ gestört ({reason}); Neu-Einhängen nach "
|
||||
f"{len(self.recover_backoff)} Versuchen gescheitert: {last}"
|
||||
)
|
||||
if m.auto_mount:
|
||||
self._retry_at[mount_id] = self._clock() + self.retry_interval
|
||||
msg += f"\n→ Neuer Versuch automatisch alle {self.retry_interval:g} s."
|
||||
self._set_state(mount_id, MountState.ERROR, msg)
|
||||
self._notify("mount.error", m.name, msg)
|
||||
raise MountUnavailable(msg)
|
||||
|
||||
def retry_pending(self) -> list[str]:
|
||||
"""Fällige Neuversuche ausführen (Scheduler-Job, jede Minute) – auch für
|
||||
Anbindungen, die gerade keine Regel benutzt. Liefert die zurückgeholten Namen."""
|
||||
recovered: list[str] = []
|
||||
for mount_id in list(self._retry_at):
|
||||
if self._retry_at.get(mount_id, float("inf")) > self._clock():
|
||||
continue
|
||||
with self._lock(mount_id):
|
||||
if mount_id not in self._retry_at:
|
||||
continue
|
||||
try:
|
||||
m, cred = self._load(mount_id)
|
||||
except LookupError:
|
||||
self._retry_at.pop(mount_id, None)
|
||||
continue
|
||||
if not (m.enabled and m.auto_mount and m.type.is_kernel_mount):
|
||||
self._retry_at.pop(mount_id, None)
|
||||
continue
|
||||
mp = self.mountpoint_for(m.slug)
|
||||
if self.backend.is_mounted(mp):
|
||||
self._retry_at.pop(mount_id, None)
|
||||
continue
|
||||
try:
|
||||
self._retry_mount(mount_id, m, cred, mp)
|
||||
recovered.append(m.name)
|
||||
except MountUnavailable:
|
||||
pass
|
||||
return recovered
|
||||
|
||||
def _retry_mount(self, mount_id: int, m: Mount, cred: CredentialData | None, mp: Path) -> None:
|
||||
"""Ein Versuch, einen von NetMover selbst ausgehängten Mount zurückzuholen –
|
||||
höchstens einmal pro ``retry_interval``, damit kein Lauf alle 10 s hängt."""
|
||||
wait = self._retry_at[mount_id] - self._clock()
|
||||
if wait > 0:
|
||||
raise MountUnavailable(
|
||||
f"„{m.name}“ nicht verfügbar – nächster Versuch, neu einzuhängen, "
|
||||
f"in {math.ceil(wait)} s. Lauf abgebrochen, nichts angefasst."
|
||||
)
|
||||
try:
|
||||
self._prepare_mountpoint(mp)
|
||||
self.backend.mount(
|
||||
build_spec(MountConfig.from_model(m), mp, cred), timeout=self.mount_timeout
|
||||
)
|
||||
self._probe(m.name, mp)
|
||||
except (MountError, OSError, OperationTimeout, ValidationError) as exc:
|
||||
if self.backend.is_mounted(mp):
|
||||
with contextlib.suppress(MountError):
|
||||
self.backend.unmount(mp, lazy=True, timeout=self.mount_timeout)
|
||||
self._retry_at[mount_id] = self._clock() + self.retry_interval
|
||||
msg = (
|
||||
f"„{m.name}“ weiterhin nicht verfügbar: {exc}\n"
|
||||
f"→ Neuer Versuch in {self.retry_interval:g} s."
|
||||
)
|
||||
self._set_state(mount_id, MountState.ERROR, msg)
|
||||
event(log, "mount.retry_failed", level=30, mount=m.name, error=str(exc))
|
||||
raise MountUnavailable(msg) from None
|
||||
del self._retry_at[mount_id]
|
||||
self._set_state(mount_id, MountState.MOUNTED, mounted=True)
|
||||
event(log, "mount.recovered", mount=m.name, attempt="retry")
|
||||
self._notify("mount.recovered", m.name, f"„{m.name}“ ist wieder eingehängt.")
|
||||
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -13,6 +13,7 @@ Reihenfolge pro Lauf:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import errno
|
||||
import os
|
||||
import stat
|
||||
@@ -39,7 +40,7 @@ from netmover.mover.destinations import (
|
||||
)
|
||||
from netmover.mover.ledger import FailureInfo, Ledger
|
||||
from netmover.mover.naming import collision_candidates
|
||||
from netmover.mover.scan import Candidate, FileState, Matcher, current_state, scan
|
||||
from netmover.mover.scan import BUSY, Candidate, FileState, Matcher, current_state, scan
|
||||
from netmover.timeouts import Cancelled, CancelToken
|
||||
|
||||
log = get_logger(__name__)
|
||||
@@ -222,26 +223,32 @@ class Engine:
|
||||
else:
|
||||
pending.append(c)
|
||||
|
||||
# Stabilität (Fallstrick 1): zweite Messung nach dem Stabilitätsfenster.
|
||||
# Stabilität (Fallstrick 1): zwei frische Messungen (öffnen + fstat, nicht
|
||||
# der Verzeichnis-Cache) im Abstand des Stabilitätsfensters.
|
||||
first: dict[str, FileState | None] = {c.relpath: current_state(c.path) for c in pending}
|
||||
if pending and rule.stability_seconds > 0:
|
||||
self._sleep(rule.stability_seconds)
|
||||
token.check()
|
||||
stable: list[Candidate] = []
|
||||
for c in pending:
|
||||
st = current_state(c.path)
|
||||
if st is None:
|
||||
before, st = first[c.relpath], current_state(c.path)
|
||||
if before is None or st is None:
|
||||
continue # inzwischen verschwunden – nichts zu tun
|
||||
if st != c.state:
|
||||
if st != before or st == BUSY:
|
||||
result.add(
|
||||
ItemResult(
|
||||
c.relpath,
|
||||
ItemStatus.SKIPPED,
|
||||
size=st.size,
|
||||
message="wird noch geschrieben (Größe/Zeit geändert)",
|
||||
size=max(st.size, 0),
|
||||
message="wird noch geschrieben (Größe/Zeit geändert)"
|
||||
if st != BUSY
|
||||
else "Datei ist gerade in Benutzung",
|
||||
)
|
||||
)
|
||||
continue
|
||||
stable.append(c)
|
||||
# ab hier gilt der frisch gemessene Zustand (für Prüfung vor dem Umbenennen
|
||||
# und für die Kopier-Buchführung)
|
||||
stable.append(dataclasses.replace(c, state=st))
|
||||
|
||||
failures = self.ledger.failures(rule.id)
|
||||
for c in stable:
|
||||
|
||||
@@ -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))
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
@@ -100,11 +101,31 @@ def scan(
|
||||
yield from walk(root, "")
|
||||
|
||||
|
||||
# Datei ist geöffnet, aber gerade nicht lesbar (z. B. exklusiv vom Schreiber
|
||||
# gesperrt): gilt als „noch nicht fertig“ – gleicht nie einem echten Zustand.
|
||||
BUSY = FileState(-1, -1)
|
||||
|
||||
|
||||
def current_state(path: Path) -> FileState | None:
|
||||
"""Aktuelle Größe und mtime – **frisch vom Server**.
|
||||
|
||||
``stat()`` beantworten CIFS- und NFS-Clients aus ihrem Attribut-Cache; im
|
||||
Praxistest lieferte CIFS so sekundenlang eine veraltete Größe, während die
|
||||
Datei noch wuchs. Erst ``open()`` erzwingt frische Attribute (NFS:
|
||||
Close-to-open-Konsistenz, SMB: Antwort auf CREATE). Darum öffnen + ``fstat``.
|
||||
"""
|
||||
try:
|
||||
st = os.stat(path, follow_symlinks=False)
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.ELOOP, errno.ENOTDIR):
|
||||
return None # Symlink oder kein Pfad mehr
|
||||
return BUSY
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
return None
|
||||
return FileState(st.st_size, st.st_mtime_ns)
|
||||
|
||||
@@ -125,4 +125,9 @@ class Notifier:
|
||||
|
||||
def mount_event(self, kind: str, mount: str, message: str) -> None:
|
||||
"""Passt zur ``Notifier``-Signatur des :class:`MountManager`."""
|
||||
self.send(Notification(kind, f"NetMover: Anbindung „{mount}“ gestört", message))
|
||||
title = (
|
||||
f"NetMover: Anbindung „{mount}“ wieder verfügbar"
|
||||
if kind == "mount.recovered"
|
||||
else f"NetMover: Anbindung „{mount}“ gestört"
|
||||
)
|
||||
self.send(Notification(kind, title, message))
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -31,6 +32,7 @@ log = get_logger(__name__)
|
||||
MAX_START_OFFSET = 10.0
|
||||
HEARTBEAT_SECONDS = 30
|
||||
CLEANUP_SECONDS = 3600
|
||||
RETRY_SECONDS = 60
|
||||
|
||||
|
||||
def _job_id(rule_id: int) -> str:
|
||||
@@ -43,11 +45,13 @@ class RuleScheduler:
|
||||
session_factory: sessionmaker[Session],
|
||||
service: RunService,
|
||||
*,
|
||||
retry_mounts: Callable[[], object] | None = None,
|
||||
rng: random.Random | None = None,
|
||||
workers: int = 8,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
self.service = service
|
||||
self._retry_mounts = retry_mounts
|
||||
self._rng = rng or random.Random() # noqa: S311 – nur Startversatz
|
||||
self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="nm-run")
|
||||
self.scheduler = AsyncIOScheduler(
|
||||
@@ -70,6 +74,10 @@ class RuleScheduler:
|
||||
IntervalTrigger(seconds=CLEANUP_SECONDS, start_date=utcnow() + timedelta(minutes=1)),
|
||||
id="cleanup",
|
||||
)
|
||||
if self._retry_mounts is not None:
|
||||
self.scheduler.add_job(
|
||||
self._retry, IntervalTrigger(seconds=RETRY_SECONDS), id="mount-retry"
|
||||
)
|
||||
self.sync()
|
||||
event(log, "scheduler.started", rules=len(self._intervals))
|
||||
|
||||
@@ -141,6 +149,11 @@ class RuleScheduler:
|
||||
self._executor, self.service.execute, rule_id, RunTrigger.MANUAL, dry_run
|
||||
)
|
||||
|
||||
async def _retry(self) -> None:
|
||||
if self._retry_mounts is not None:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(self._executor, self._retry_mounts)
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(self._executor, cleanup, self.session_factory)
|
||||
|
||||
+102
-6
@@ -45,6 +45,7 @@ from netmover.admin import (
|
||||
from netmover.app_state import NetMover
|
||||
from netmover.config import Settings, get_settings
|
||||
from netmover.db import session_scope
|
||||
from netmover.keys import delete_key, generate_key, import_key, list_keys, rename_key
|
||||
from netmover.logging_setup import event, get_logger
|
||||
from netmover.models import (
|
||||
CollisionPolicy,
|
||||
@@ -392,10 +393,21 @@ async def mounts_page(request: Request) -> Response:
|
||||
pubkeys = {
|
||||
m.id: m.credential.public_key
|
||||
for m in mounts
|
||||
if m.credential is not None and m.credential.public_key
|
||||
if m.credential is not None and m.credential.public_key and not m.credential.managed
|
||||
}
|
||||
key_names = {
|
||||
m.id: m.credential.name
|
||||
for m in mounts
|
||||
if m.credential is not None and m.credential.managed
|
||||
}
|
||||
return render(
|
||||
request, "mounts.html", active="mounts", mounts=mounts, reports=reports, pubkeys=pubkeys
|
||||
request,
|
||||
"mounts.html",
|
||||
active="mounts",
|
||||
mounts=mounts,
|
||||
reports=reports,
|
||||
pubkeys=pubkeys,
|
||||
key_names=key_names,
|
||||
)
|
||||
|
||||
|
||||
@@ -409,6 +421,15 @@ def _report_data(raw: str | None) -> dict[str, Any]:
|
||||
return data
|
||||
|
||||
|
||||
def _key_options(request: Request) -> list[tuple[str, str]]:
|
||||
with nm(request).session_factory() as s:
|
||||
keys = list_keys(s)
|
||||
return [("new", "neuer Schlüssel nur für diese Anbindung")] + [
|
||||
(str(k.id), f"{k.name} ({k.key_type.removeprefix('ssh-')}, {k.fingerprint[7:19]}…)")
|
||||
for k in keys
|
||||
]
|
||||
|
||||
|
||||
def _mount_form(
|
||||
request: Request,
|
||||
data: MountInput,
|
||||
@@ -416,7 +437,11 @@ def _mount_form(
|
||||
mount: Mount | None = None,
|
||||
status_code: int = 200,
|
||||
) -> Response:
|
||||
pub = mount.credential.public_key if mount is not None and mount.credential else None
|
||||
pub = (
|
||||
mount.credential.public_key
|
||||
if mount is not None and mount.credential and not mount.credential.managed
|
||||
else None
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"mount_form.html",
|
||||
@@ -426,7 +451,10 @@ def _mount_form(
|
||||
errors=errors,
|
||||
mount=mount,
|
||||
public_key=pub,
|
||||
has_secret=bool(mount is not None and mount.credential is not None),
|
||||
has_secret=bool(
|
||||
mount is not None and mount.credential is not None and not mount.credential.managed
|
||||
),
|
||||
key_options=_key_options(request),
|
||||
)
|
||||
|
||||
|
||||
@@ -487,7 +515,7 @@ async def mount_test_form(request: Request) -> Response:
|
||||
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)
|
||||
cred = credential_for_test(data, existing, state.secrets, s)
|
||||
cfg = data.config()
|
||||
if data.type is MountType.SFTP and existing is not None:
|
||||
cfg = dataclasses.replace(cfg, host_key=existing.host_key)
|
||||
@@ -503,7 +531,7 @@ async def mount_test(request: Request, mount_id: int) -> Response:
|
||||
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)
|
||||
cred = credential_for_test(data, m, state.secrets, s)
|
||||
from netmover.mounting.manager import MountConfig
|
||||
|
||||
cfg = MountConfig.from_model(m)
|
||||
@@ -563,6 +591,74 @@ async def mount_reset_hostkey(request: Request, mount_id: int) -> Response:
|
||||
return redirect(f"/mounts#mount-{mount_id}")
|
||||
|
||||
|
||||
# -- Schlüsselverwaltung ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@protected.get("/keys", response_class=HTMLResponse)
|
||||
async def keys_page(request: Request) -> Response:
|
||||
with nm(request).session_factory() as s:
|
||||
return render(request, "keys.html", active="keys", keys=list_keys(s))
|
||||
|
||||
|
||||
async def _key_action(request: Request, work: Callable[[Any], str]) -> Response:
|
||||
state = nm(request)
|
||||
try:
|
||||
with session_scope(state.session_factory) as s:
|
||||
flash(request, work(s))
|
||||
except AdminError as exc:
|
||||
flash(request, str(exc), "error")
|
||||
return redirect("/keys")
|
||||
|
||||
|
||||
@protected.post("/keys/generate")
|
||||
async def key_generate(request: Request) -> Response:
|
||||
form = _form_dict(await request.form())
|
||||
secrets = nm(request).secrets
|
||||
|
||||
def work(s: Any) -> str:
|
||||
key = generate_key(s, secrets, form.get("name", ""))
|
||||
return f"Schlüssel „{key.name}“ erzeugt."
|
||||
|
||||
return await _key_action(request, work)
|
||||
|
||||
|
||||
@protected.post("/keys/import")
|
||||
async def key_import(request: Request) -> Response:
|
||||
form = _form_dict(await request.form())
|
||||
secrets = nm(request).secrets
|
||||
|
||||
def work(s: Any) -> str:
|
||||
key = import_key(
|
||||
s,
|
||||
secrets,
|
||||
form.get("name", ""),
|
||||
form.get("private_key", ""),
|
||||
form.get("passphrase", ""),
|
||||
)
|
||||
return f"Schlüssel „{key.name}“ importiert."
|
||||
|
||||
return await _key_action(request, work)
|
||||
|
||||
|
||||
@protected.post("/keys/{key_id}/rename")
|
||||
async def key_rename(request: Request, key_id: int) -> Response:
|
||||
form = _form_dict(await request.form())
|
||||
|
||||
def work(s: Any) -> str:
|
||||
rename_key(s, key_id, form.get("name", ""))
|
||||
return "Schlüssel umbenannt."
|
||||
|
||||
return await _key_action(request, work)
|
||||
|
||||
|
||||
@protected.post("/keys/{key_id}/delete")
|
||||
async def key_delete(request: Request, key_id: int) -> Response:
|
||||
def work(s: Any) -> str:
|
||||
return f"Schlüssel „{delete_key(s, key_id)}“ gelöscht."
|
||||
|
||||
return await _key_action(request, work)
|
||||
|
||||
|
||||
# -- Regeln -----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<nav class="main">
|
||||
<a href="/" class="{{ 'active' if active == 'status' }}">Status</a>
|
||||
<a href="/mounts" class="{{ 'active' if active == 'mounts' }}">Anbindungen</a>
|
||||
<a href="/keys" class="{{ 'active' if active == 'keys' }}">Schlüssel</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>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros.html" import csrf_field %}
|
||||
{% block title %}Schlüssel{% endblock %}
|
||||
{% block content %}
|
||||
<h1>SSH-Schlüssel</h1>
|
||||
<p class="muted small">Gespeicherte Schlüssel für SFTP-Anbindungen. Ein Schlüssel kann von mehreren
|
||||
Anbindungen benutzt werden; den Benutzernamen legt jede Anbindung selbst fest. Private Schlüssel
|
||||
liegen verschlüsselt in der Datenbank und werden nie angezeigt.</p>
|
||||
|
||||
{% if not keys %}<p class="muted">Noch keine Schlüssel gespeichert.</p>{% endif %}
|
||||
<div class="stack">
|
||||
{% for k in keys %}
|
||||
<div class="card" id="key-{{ k.id }}">
|
||||
<div class="row spread">
|
||||
<div><strong>{{ k.name }}</strong> <span class="badge">{{ k.key_type.removeprefix('ssh-') }}</span>
|
||||
{% if k.used_by %}<span class="badge ok">benutzt von {{ k.used_by|join(', ') }}</span>{% else %}<span class="badge">unbenutzt</span>{% endif %}</div>
|
||||
<div class="row">
|
||||
<form class="inline row" method="post" action="/keys/{{ k.id }}/rename">{{ csrf_field(csrf) }}
|
||||
<input type="text" name="name" value="{{ k.name }}" aria-label="Neuer Name" required maxlength="100">
|
||||
<button class="small">Umbenennen</button></form>
|
||||
<form class="inline" method="post" action="/keys/{{ k.id }}/delete" data-confirm="Schlüssel „{{ k.name }}“ löschen?">{{ csrf_field(csrf) }}
|
||||
<button class="small danger" {{ 'disabled' if k.used_by }}>Löschen</button></form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small muted">Fingerabdruck <span class="mono">{{ k.fingerprint }}</span> · angelegt {{ k.created_at|localdt }}</div>
|
||||
<details class="small"><summary>Öffentlicher Schlüssel / authorized_keys</summary>
|
||||
<div class="field"><label>Zeile für <code>~/.ssh/authorized_keys</code> (nur SFTP erlaubt, empfohlen)</label>
|
||||
<pre class="mono card" id="auth-{{ k.id }}">{{ k.authorized_keys_line }}</pre>
|
||||
<button class="small" type="button" data-copy="auth-{{ k.id }}">kopieren</button></div>
|
||||
<div class="field"><label>Öffentlicher Schlüssel</label>
|
||||
<pre class="mono card" id="pub-{{ k.id }}">{{ k.public_key }}</pre>
|
||||
<button class="small" type="button" data-copy="pub-{{ k.id }}">kopieren</button></div>
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h2>Neuen Schlüssel erzeugen</h2>
|
||||
<form class="form card row" method="post" action="/keys/generate">{{ csrf_field(csrf) }}
|
||||
<input type="text" name="name" placeholder="Name, z. B. paperless-lxc" aria-label="Name" required maxlength="100">
|
||||
<button class="primary">Ed25519-Schlüssel erzeugen</button>
|
||||
</form>
|
||||
|
||||
<h2>Vorhandenen Schlüssel importieren</h2>
|
||||
<form class="form card stack" method="post" action="/keys/import">{{ csrf_field(csrf) }}
|
||||
<div class="fields">
|
||||
<div class="field"><label for="k-name">Name</label><input id="k-name" type="text" name="name" required maxlength="100"></div>
|
||||
<div class="field"><label for="k-pass">Passphrase (falls vorhanden)</label><input id="k-pass" type="password" name="passphrase" autocomplete="off"></div>
|
||||
<div class="field wide"><label for="k-priv">Privater Schlüssel</label>
|
||||
<textarea id="k-priv" name="private_key" class="mono" rows="8" autocomplete="off" spellcheck="false"
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- … -----END OPENSSH PRIVATE KEY-----" required></textarea>
|
||||
<span class="hint">Ed25519, ECDSA oder RSA (mind. 2048 Bit), OpenSSH- oder PEM-Format. Eine Passphrase wird nur zum Entschlüsseln benutzt.</span></div>
|
||||
</div>
|
||||
<button>Importieren</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -23,7 +23,10 @@
|
||||
</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>
|
||||
<div data-show-for="type:sftp">{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div>
|
||||
<div data-show-for="type:sftp"><div data-show-for="auth:ssh_key">
|
||||
{{ select('key_id', 'Schlüssel', key_options, d.key_id, errors=errors, hint='Gespeicherte Schlüssel verwalten unter „Schlüssel“.') }}
|
||||
</div></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">
|
||||
@@ -33,7 +36,7 @@
|
||||
<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>
|
||||
{% if public_key %}<div class="field wide"><label>Öffentlicher Schlüssel dieser Anbindung (in ~/.ssh/authorized_keys des Zielbenutzers eintragen)</label>
|
||||
<pre class="mono card" id="pubkey">{{ public_key }}</pre><button class="small" type="button" data-copy="pubkey">kopieren</button></div>{% endif %}
|
||||
</fieldset>
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
<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 key_names.get(m.id) %}
|
||||
<div class="small">Anmeldung mit gespeichertem Schlüssel <a href="/keys">„{{ key_names[m.id] }}“</a></div>
|
||||
{% endif %}
|
||||
{% if pubkeys.get(m.id) %}
|
||||
<details class="small"><summary>Öffentlicher Schlüssel für authorized_keys</summary>
|
||||
<pre class="mono" id="pub-{{ m.id }}">{{ pubkeys[m.id] }}</pre>
|
||||
|
||||
+107
-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,99 @@ def test_name_taken_between_check_and_link_is_never_overwritten(
|
||||
assert result.items[0].dst == str(dst / "scan-1.pdf")
|
||||
assert (dst / "scan.pdf").read_text() == "fremd"
|
||||
assert (dst / "scan-1.pdf").read_text() == "neu"
|
||||
|
||||
|
||||
# -- renameat2(RENAME_NOREPLACE) ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_rename_noreplace(tmp_path: Path) -> None:
|
||||
from netmover.mover.fsops import rename_noreplace
|
||||
|
||||
a, b = write(tmp_path / "a", "neu"), write(tmp_path / "b", "alt")
|
||||
ino = a.stat().st_ino
|
||||
if not rename_noreplace(a, tmp_path / "c"):
|
||||
pytest.skip("Dateisystem des Testrechners kann kein renameat2")
|
||||
assert (tmp_path / "c").stat().st_ino == ino and not a.exists() # echtes rename
|
||||
with pytest.raises(FileExistsError):
|
||||
rename_noreplace(tmp_path / "c", b)
|
||||
assert b.read_text() == "alt" and (tmp_path / "c").exists()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("cross_device")
|
||||
@pytest.mark.parametrize("supported", [True, False])
|
||||
def test_final_step_is_a_real_rename_when_possible(
|
||||
engine: Engine,
|
||||
rule_id: int,
|
||||
src: Path,
|
||||
dst: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
supported: bool,
|
||||
) -> None:
|
||||
"""Watcher wie Paperless sehen ein rename (IN_MOVED_TO); ohne Unterstützung
|
||||
bleibt es beim link-Rückfallweg – überschrieben wird in keinem Fall."""
|
||||
links: list[object] = []
|
||||
real_link = os.link
|
||||
|
||||
def spy_link(a: object, b: object, *args: object, **kw: object) -> None:
|
||||
links.append(b)
|
||||
real_link(a, b) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr("netmover.mover.destinations.os.link", spy_link)
|
||||
if not supported:
|
||||
monkeypatch.setattr("netmover.mover.destinations.rename_noreplace", lambda *a: False)
|
||||
write(dst / "a.pdf", "alt")
|
||||
write(src / "a.pdf", "neu")
|
||||
result = run(engine, snap(rule_id), src, dst)
|
||||
assert result.items[0].dst == str(dst / "a-1.pdf")
|
||||
assert (dst / "a.pdf").read_text() == "alt" and (dst / "a-1.pdf").read_text() == "neu"
|
||||
assert bool(links) is not supported
|
||||
assert [p.name for p in dst.iterdir() if p.name.endswith(".part")] == []
|
||||
|
||||
|
||||
# -- Praxisbefund: veralteter Attribut-Cache (CIFS/NFS) -------------------------------------
|
||||
|
||||
|
||||
def test_stability_uses_fresh_attributes_not_stat_cache(
|
||||
engine: Engine,
|
||||
rule_id: int,
|
||||
src: Path,
|
||||
dst: Path,
|
||||
hooks: list[Callable[[], None]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Auf dem Testhost lieferte CIFS per stat() sekundenlang eine veraltete Größe,
|
||||
während die Datei wuchs. Simuliert: os.stat liefert für die Datei immer den
|
||||
alten Stand (Cache), nur öffnen + fstat sieht die Wahrheit."""
|
||||
growing = write(src / "langsam.pdf", "a" * 10)
|
||||
stale = os.stat(growing)
|
||||
real_stat = os.stat
|
||||
|
||||
def cached_stat(path: object, *a: object, **kw: object) -> os.stat_result:
|
||||
if str(path) == str(growing):
|
||||
return stale
|
||||
return real_stat(path, *a, **kw) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr(os, "stat", cached_stat)
|
||||
hooks.append(lambda: growing.write_text("a" * 20))
|
||||
result = run(engine, snap(rule_id), src, dst)
|
||||
assert statuses(result) == {"langsam.pdf": ItemStatus.SKIPPED}
|
||||
assert "wird noch geschrieben" in (result.items[0].message or "")
|
||||
assert growing.exists() and not (dst / "langsam.pdf").exists()
|
||||
|
||||
|
||||
def test_locked_file_counts_as_in_use(
|
||||
engine: Engine, rule_id: int, src: Path, dst: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
f = write(src / "gesperrt.pdf")
|
||||
real_open = os.open
|
||||
|
||||
def locked(path: object, flags: int, *a: object, **kw: object) -> int:
|
||||
if str(path) == str(f):
|
||||
raise PermissionError(errno.EACCES, "Permission denied", str(path))
|
||||
return real_open(path, flags, *a, **kw) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr("netmover.mover.scan.os.open", locked)
|
||||
result = run(engine, snap(rule_id), src, dst)
|
||||
assert statuses(result) == {"gesperrt.pdf": ItemStatus.SKIPPED}
|
||||
assert result.items[0].message == "Datei ist gerade in Benutzung"
|
||||
assert f.exists()
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Schlüsselverwaltung: erzeugen, importieren, auswählen, schützen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from netmover.admin import AdminError, MountInput, delete_mount, save_mount
|
||||
from netmover.crypto import SecretBox, generate_ssh_keypair
|
||||
from netmover.db import session_scope
|
||||
from netmover.keys import delete_key, fingerprint, generate_key, import_key, list_keys, rename_key
|
||||
from netmover.models import Credential, CredentialKind, Mount, MountType
|
||||
from netmover.mounting.manager import MountConfig, MountManager
|
||||
from tests.sftp_stub import StubSFTPServer
|
||||
|
||||
|
||||
def _sftp_input(**kw: object) -> MountInput:
|
||||
data: dict[str, object] = {
|
||||
"name": "paperless",
|
||||
"type": MountType.SFTP,
|
||||
"server": "127.0.0.1",
|
||||
"share": "/consume",
|
||||
"port": 22,
|
||||
"username": "paperless",
|
||||
"auth": CredentialKind.SSH_KEY,
|
||||
}
|
||||
data.update(kw)
|
||||
return MountInput(**data) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# -- Modul --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_generate_key(session: Session, secretbox: SecretBox) -> None:
|
||||
key = generate_key(session, secretbox, "paperless-lxc")
|
||||
assert key.managed and key.kind is CredentialKind.SSH_KEY
|
||||
assert key.public_key and key.public_key.startswith("ssh-ed25519 ")
|
||||
assert key.public_key.endswith(" netmover:paperless-lxc")
|
||||
assert "PRIVATE" not in key.public_key
|
||||
assert "OPENSSH PRIVATE KEY" in secretbox.decrypt(key.secret_enc)
|
||||
info = list_keys(session)[0]
|
||||
assert info.fingerprint.startswith("SHA256:") and info.used_by == []
|
||||
assert info.authorized_keys_line.startswith(
|
||||
'restrict,command="/usr/lib/openssh/sftp-server" ssh-ed25519 '
|
||||
)
|
||||
|
||||
|
||||
def test_fingerprint_matches_ssh_keygen(tmp_path: Path) -> None:
|
||||
_, pub = generate_ssh_keypair("x")
|
||||
f = tmp_path / "k.pub"
|
||||
f.write_text(pub + "\n")
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["ssh-keygen", "-lf", str(f)], # noqa: S607 – Abgleich mit OpenSSH
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout
|
||||
except FileNotFoundError:
|
||||
pytest.skip("ssh-keygen nicht installiert")
|
||||
assert fingerprint(pub) == out.split()[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind", ["ed25519-openssh-pass", "rsa-pem", "ecdsa-pem-pass", "rsa-openssh"]
|
||||
)
|
||||
def test_import_formats(session: Session, secretbox: SecretBox, kind: str) -> None:
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
if kind.startswith("ed25519"):
|
||||
key = ed25519.Ed25519PrivateKey.generate()
|
||||
elif kind.startswith("rsa"):
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048) # type: ignore[assignment]
|
||||
else:
|
||||
key = ec.generate_private_key(ec.SECP256R1()) # type: ignore[assignment]
|
||||
enc = (
|
||||
serialization.BestAvailableEncryption(b"geheim")
|
||||
if kind.endswith("pass")
|
||||
else serialization.NoEncryption()
|
||||
)
|
||||
fmt = (
|
||||
serialization.PrivateFormat.OpenSSH
|
||||
if "openssh" in kind
|
||||
else serialization.PrivateFormat.PKCS8
|
||||
)
|
||||
pem = key.private_bytes(serialization.Encoding.PEM, fmt, enc).decode()
|
||||
cred = import_key(session, secretbox, kind, pem, "geheim" if kind.endswith("pass") else "")
|
||||
expected_pub = (
|
||||
key.public_key()
|
||||
.public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH)
|
||||
.decode()
|
||||
)
|
||||
assert cred.public_key is not None and cred.public_key.startswith(expected_pub)
|
||||
stored = secretbox.decrypt(cred.secret_enc)
|
||||
assert "OPENSSH PRIVATE KEY" in stored
|
||||
# ohne Passphrase gespeichert → von paramiko direkt nutzbar
|
||||
from netmover.mounting.sftp import load_private_key
|
||||
|
||||
assert load_private_key(stored).get_base64() == expected_pub.split()[1]
|
||||
|
||||
|
||||
def test_import_errors(session: Session, secretbox: SecretBox) -> None:
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
key = ed25519.Ed25519PrivateKey.generate()
|
||||
protected = key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.OpenSSH,
|
||||
serialization.BestAvailableEncryption(b"richtig"),
|
||||
).decode()
|
||||
with pytest.raises(AdminError, match="Passphrase angeben"):
|
||||
import_key(session, secretbox, "a", protected, "")
|
||||
with pytest.raises(AdminError, match="Passphrase falsch"):
|
||||
import_key(session, secretbox, "a", protected, "falsch")
|
||||
_, pub = generate_ssh_keypair()
|
||||
with pytest.raises(AdminError, match="öffentlicher Schlüssel"):
|
||||
import_key(session, secretbox, "a", pub)
|
||||
with pytest.raises(AdminError, match="Kein lesbarer"):
|
||||
import_key(
|
||||
session, secretbox, "a", "-----BEGIN PRIVATE KEY-----\nMüll\n-----END PRIVATE KEY-----"
|
||||
)
|
||||
weak = (
|
||||
rsa.generate_private_key(public_exponent=65537, key_size=1024) # noqa: S505 – absichtlich schwach
|
||||
.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
.decode()
|
||||
)
|
||||
with pytest.raises(AdminError, match="zu schwach"):
|
||||
import_key(session, secretbox, "a", weak)
|
||||
assert list_keys(session) == []
|
||||
|
||||
|
||||
def test_names(session: Session, secretbox: SecretBox) -> None:
|
||||
generate_key(session, secretbox, "eins")
|
||||
with pytest.raises(AdminError, match="existiert bereits"):
|
||||
generate_key(session, secretbox, "eins")
|
||||
with pytest.raises(AdminError, match="reserviert"):
|
||||
generate_key(session, secretbox, "mount:x")
|
||||
zwei = generate_key(session, secretbox, "zwei")
|
||||
rename_key(session, zwei.id, "drei")
|
||||
assert [k.name for k in list_keys(session)] == ["drei", "eins"]
|
||||
with pytest.raises(AdminError):
|
||||
rename_key(session, zwei.id, "eins")
|
||||
|
||||
|
||||
# -- Zusammenspiel mit Anbindungen ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_mount_uses_shared_key_with_own_username(
|
||||
session: Session, secretbox: SecretBox, manager: MountManager
|
||||
) -> None:
|
||||
key = generate_key(session, secretbox, "lxc")
|
||||
a, err = save_mount(session, secretbox, manager, _sftp_input(key_id=str(key.id)))
|
||||
assert not err and a is not None
|
||||
b, err = save_mount(
|
||||
session,
|
||||
secretbox,
|
||||
manager,
|
||||
_sftp_input(name="anderer", server="10.0.0.9", username="root", key_id=str(key.id)),
|
||||
)
|
||||
assert not err and b is not None
|
||||
assert a.credential is key and b.credential is key
|
||||
assert (a.username, b.username) == ("paperless", "root")
|
||||
assert key.username == "" # der Schlüssel selbst bleibt unverändert
|
||||
assert list_keys(session)[0].used_by == ["anderer", "paperless"]
|
||||
|
||||
# Schlüssel in Benutzung → nicht löschbar
|
||||
with pytest.raises(AdminError, match="noch benutzt"):
|
||||
delete_key(session, key.id)
|
||||
# Anbindung löschen lässt den gespeicherten Schlüssel stehen
|
||||
delete_mount(session, manager, a.id)
|
||||
delete_mount(session, manager, b.id)
|
||||
assert session.get(Credential, key.id) is not None
|
||||
delete_key(session, key.id)
|
||||
assert session.get(Credential, key.id) is None
|
||||
|
||||
|
||||
def test_switching_between_own_and_shared_key(
|
||||
session: Session, secretbox: SecretBox, manager: MountManager
|
||||
) -> None:
|
||||
m, _ = save_mount(session, secretbox, manager, _sftp_input()) # eigener Schlüssel
|
||||
assert m is not None and m.credential is not None and not m.credential.managed
|
||||
own_id = m.credential.id
|
||||
key = generate_key(session, secretbox, "geteilt")
|
||||
m, _ = save_mount(session, secretbox, manager, _sftp_input(key_id=str(key.id)), m.id)
|
||||
assert m is not None and m.credential is key
|
||||
assert session.get(Credential, own_id) is None # eigener Schlüssel aufgeräumt
|
||||
# Wechsel auf Passwort darf den gespeicherten Schlüssel nicht verändern
|
||||
secret_before = key.secret_enc
|
||||
m, _ = save_mount(
|
||||
session,
|
||||
secretbox,
|
||||
manager,
|
||||
_sftp_input(auth=CredentialKind.PASSWORD, password="pw123"),
|
||||
m.id,
|
||||
)
|
||||
assert m is not None and m.credential is not None and m.credential is not key
|
||||
assert not m.credential.managed and m.credential.username == "paperless"
|
||||
assert m.username is None
|
||||
assert key.secret_enc == secret_before and key.kind is CredentialKind.SSH_KEY
|
||||
|
||||
|
||||
def test_unknown_or_foreign_key_id_is_rejected(
|
||||
session: Session, secretbox: SecretBox, manager: MountManager
|
||||
) -> None:
|
||||
own, _ = save_mount(session, secretbox, manager, _sftp_input(name="a"))
|
||||
assert own is not None and own.credential is not None
|
||||
for bad in ("4711", str(own.credential.id)): # nicht vorhanden / kein gespeicherter Schlüssel
|
||||
m, err = save_mount(session, secretbox, manager, _sftp_input(name="b", key_id=bad))
|
||||
assert m is None and "key_id" in err
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(tmp_path: Path) -> Iterator[StubSFTPServer]:
|
||||
root = tmp_path / "sftp-root"
|
||||
(root / "consume").mkdir(parents=True)
|
||||
srv = StubSFTPServer(root, username="paperless").start()
|
||||
yield srv
|
||||
srv.stop()
|
||||
|
||||
|
||||
def test_manager_connects_with_shared_key_and_mount_username(
|
||||
server: StubSFTPServer,
|
||||
session_factory: sessionmaker[Session],
|
||||
secretbox: SecretBox,
|
||||
manager: MountManager,
|
||||
) -> None:
|
||||
with session_scope(session_factory) as s:
|
||||
key = import_key(s, secretbox, "lxc", server.client_private_key)
|
||||
m, err = save_mount(
|
||||
s, secretbox, manager, _sftp_input(port=server.port, key_id=str(key.id))
|
||||
)
|
||||
assert not err and m is not None
|
||||
mid = m.id
|
||||
with session_factory() as s:
|
||||
mm = s.get(Mount, mid)
|
||||
assert mm is not None
|
||||
cfg = MountConfig.from_model(mm)
|
||||
from netmover.admin import credential_for_test, mount_input_from_model
|
||||
|
||||
with session_factory() as s:
|
||||
mm = s.get(Mount, mid)
|
||||
assert mm is not None
|
||||
data = mount_input_from_model(mm)
|
||||
assert data.key_id == str(mm.cred_id) and data.username == "paperless"
|
||||
cred = credential_for_test(data, mm, secretbox, s)
|
||||
assert cred is not None and cred.username == "paperless"
|
||||
assert manager.check(cfg, cred, mount_id=mid).ok
|
||||
assert manager.check_alive(mid) is None # Regellauf-Weg: Benutzer der Anbindung
|
||||
|
||||
# Gegenprobe: falscher Benutzer an der Anbindung → Anmeldung abgelehnt
|
||||
with session_scope(session_factory) as s:
|
||||
mm = s.get(Mount, mid)
|
||||
assert mm is not None
|
||||
mm.username = "jemand"
|
||||
from netmover.mounting.manager import MountUnavailable
|
||||
|
||||
with pytest.raises(MountUnavailable, match="Anmeldung abgelehnt"):
|
||||
manager.check_alive(mid)
|
||||
|
||||
|
||||
def test_list_keys_ignores_per_mount_credentials(
|
||||
session: Session, secretbox: SecretBox, manager: MountManager
|
||||
) -> None:
|
||||
save_mount(session, secretbox, manager, _sftp_input())
|
||||
assert list_keys(session) == []
|
||||
assert session.scalars(select(Credential)).one().managed is False
|
||||
@@ -110,8 +110,49 @@ def test_is_mounted_reads_mountinfo(tmp_path: Path) -> None:
|
||||
),
|
||||
("nfs4", "mount.nfs4: access denied by server while mounting nas:/x", "NFS-Berechtigungen"),
|
||||
("cifs", "mount error(112): Host is down", "nicht erreichbar"),
|
||||
# Praxisfall: Synology mit „Maximales SMB-Protokoll: SMB2“
|
||||
(
|
||||
"cifs",
|
||||
"mount error(95): Operation not supported\nRefer to the mount.cifs(8) manual page",
|
||||
"vers=2.0",
|
||||
),
|
||||
("cifs", "mount: only root can do that", "CAP_SYS_ADMIN"),
|
||||
],
|
||||
)
|
||||
def test_explain(fstype: str, msg: str, expect: str) -> None:
|
||||
assert expect in explain_mount_error(fstype, msg)
|
||||
|
||||
|
||||
def _alive(pid: int) -> bool:
|
||||
try:
|
||||
with open(f"/proc/{pid}/stat") as fh:
|
||||
return fh.read().split(")")[-1].split()[0] != "Z"
|
||||
except (FileNotFoundError, ProcessLookupError): # verschwindet beim Lesen → ESRCH
|
||||
return False
|
||||
|
||||
|
||||
def test_timeout_kills_whole_process_group(tmp_path: Path) -> None:
|
||||
"""mount startet Helfer (mount.nfs) – die dürfen nach dem Zeitlimit nicht weiterlaufen."""
|
||||
from netmover.mounting.linux import run_process_group
|
||||
|
||||
pidfile = tmp_path / "helper.pid"
|
||||
script = f"sleep 30 & echo $! > {pidfile}; wait"
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
run_process_group(["sh", "-c", script], capture_output=True, text=True, timeout=0.5)
|
||||
helper = int(pidfile.read_text())
|
||||
for _ in range(20):
|
||||
if not _alive(helper):
|
||||
break
|
||||
import time
|
||||
|
||||
time.sleep(0.05)
|
||||
assert not _alive(helper)
|
||||
|
||||
|
||||
def test_process_group_runner_returns_output() -> None:
|
||||
from netmover.mounting.linux import run_process_group
|
||||
|
||||
r = run_process_group(
|
||||
["sh", "-c", "echo out; echo err >&2; exit 3"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
assert (r.returncode, r.stdout, r.stderr) == (3, "out\n", "err\n")
|
||||
|
||||
@@ -367,3 +367,159 @@ def test_usage(
|
||||
manager.mount(mid)
|
||||
usage = manager.usage(mid)
|
||||
assert usage is not None and usage[0] >= usage[1] > 0
|
||||
|
||||
|
||||
# -- Praxisbefund: NAS-Neustart dauert länger als die Wiederherstellung ----------------------
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 1000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock() -> Clock:
|
||||
return Clock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def retry_manager(
|
||||
session_factory: sessionmaker[Session],
|
||||
fake_backend: FakeMountBackend,
|
||||
secretbox: object,
|
||||
mount_base: Path,
|
||||
notifications: list[tuple[str, str, str]],
|
||||
clock: Clock,
|
||||
) -> MountManager:
|
||||
return MountManager(
|
||||
session_factory,
|
||||
fake_backend,
|
||||
secretbox,
|
||||
mount_base, # type: ignore[arg-type]
|
||||
probe_timeout=0.5,
|
||||
recover_backoff=(0, 0, 0),
|
||||
sleep=lambda _: None,
|
||||
notifier=lambda *a: notifications.append(a),
|
||||
retry_interval=60,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
|
||||
def test_failed_recovery_retries_every_interval_until_nas_is_back(
|
||||
retry_manager: MountManager,
|
||||
fake_backend: FakeMountBackend,
|
||||
make_mount: MountFactory,
|
||||
session_factory: sessionmaker[Session],
|
||||
notifications: list[tuple[str, str, str]],
|
||||
clock: Clock,
|
||||
) -> None:
|
||||
fake_backend.export("nas", "scans")
|
||||
mid = make_mount()
|
||||
retry_manager.mount(mid)
|
||||
mp = retry_manager.mountpoint_for("scans")
|
||||
fake_backend.inject_fault(mp, errno.ESTALE)
|
||||
fake_backend.fail_next_mount(times=3) # NAS startet neu
|
||||
with pytest.raises(MountUnavailable, match="alle 60 s"):
|
||||
retry_manager.check_alive(mid)
|
||||
assert not fake_backend.is_mounted(mp)
|
||||
|
||||
mounts_before = sum(1 for c in fake_backend.calls if c.action == "mount")
|
||||
clock.now += 30 # zu früh: kein Versuch, Lauf abgebrochen
|
||||
with pytest.raises(MountUnavailable, match="in 30 s"):
|
||||
retry_manager.check_alive(mid)
|
||||
assert sum(1 for c in fake_backend.calls if c.action == "mount") == mounts_before
|
||||
|
||||
clock.now += 30 # NAS noch nicht zurück
|
||||
fake_backend.fail_next_mount()
|
||||
with pytest.raises(MountUnavailable, match="weiterhin nicht verfügbar"):
|
||||
retry_manager.check_alive(mid)
|
||||
|
||||
clock.now += 60 # NAS wieder da
|
||||
assert retry_manager.check_alive(mid) == mp
|
||||
assert fake_backend.is_mounted(mp)
|
||||
assert _state(session_factory, mid).state is MountState.MOUNTED
|
||||
assert notifications[-1][0] == "mount.recovered"
|
||||
|
||||
|
||||
def test_no_retry_without_auto_mount(
|
||||
retry_manager: MountManager,
|
||||
fake_backend: FakeMountBackend,
|
||||
make_mount: MountFactory,
|
||||
clock: Clock,
|
||||
) -> None:
|
||||
fake_backend.export("nas", "scans")
|
||||
mid = make_mount(auto_mount=False)
|
||||
retry_manager.mount(mid)
|
||||
fake_backend.inject_fault(retry_manager.mountpoint_for("scans"), errno.ESTALE)
|
||||
fake_backend.fail_next_mount(times=3)
|
||||
with pytest.raises(MountUnavailable):
|
||||
retry_manager.check_alive(mid)
|
||||
clock.now += 600
|
||||
with pytest.raises(MountUnavailable, match="nicht eingehängt"):
|
||||
retry_manager.check_alive(mid)
|
||||
|
||||
|
||||
def test_externally_unmounted_is_still_never_remounted(
|
||||
retry_manager: MountManager,
|
||||
fake_backend: FakeMountBackend,
|
||||
make_mount: MountFactory,
|
||||
clock: Clock,
|
||||
) -> None:
|
||||
fake_backend.export("nas", "scans")
|
||||
mid = make_mount()
|
||||
retry_manager.mount(mid)
|
||||
fake_backend.vanish(retry_manager.mountpoint_for("scans"))
|
||||
clock.now += 600
|
||||
with pytest.raises(MountUnavailable, match="nicht eingehängt"):
|
||||
retry_manager.check_alive(mid)
|
||||
assert [c.action for c in fake_backend.calls] == ["mount"]
|
||||
|
||||
|
||||
def test_hint_text_is_not_redacted() -> None:
|
||||
from netmover.mounting.manager import _redact_output
|
||||
|
||||
msg = "mount error(13): Permission denied falsch\n→ Benutzername/Passwort falsch oder …"
|
||||
out = _redact_output(msg, ["falsch"])
|
||||
assert out.startswith("mount error(13): Permission denied ••••••")
|
||||
assert out.endswith("→ Benutzername/Passwort falsch oder …")
|
||||
|
||||
|
||||
def test_restore_failure_at_boot_is_retried_by_background_job(
|
||||
retry_manager: MountManager,
|
||||
fake_backend: FakeMountBackend,
|
||||
make_mount: MountFactory,
|
||||
session_factory: sessionmaker[Session],
|
||||
notifications: list[tuple[str, str, str]],
|
||||
clock: Clock,
|
||||
) -> None:
|
||||
"""Stromausfall: die VM bootet schneller als das NAS."""
|
||||
fake_backend.export("nas", "scans")
|
||||
mid = make_mount() # keine Regel benutzt die Anbindung
|
||||
fake_backend.fail_next_mount()
|
||||
assert [r.ok for r in retry_manager.restore_all()] == [False]
|
||||
assert retry_manager.retry_pending() == [] # noch nicht fällig
|
||||
clock.now += 60
|
||||
assert retry_manager.retry_pending() == ["scans"]
|
||||
assert fake_backend.is_mounted(retry_manager.mountpoint_for("scans"))
|
||||
assert _state(session_factory, mid).state is MountState.MOUNTED
|
||||
assert notifications[-1][0] == "mount.recovered"
|
||||
clock.now += 60
|
||||
assert retry_manager.retry_pending() == [] # erledigt
|
||||
|
||||
|
||||
def test_manual_unmount_stops_retrying(
|
||||
retry_manager: MountManager,
|
||||
fake_backend: FakeMountBackend,
|
||||
make_mount: MountFactory,
|
||||
clock: Clock,
|
||||
) -> None:
|
||||
mid = make_mount() # nicht exportiert → Wiederherstellung scheitert
|
||||
retry_manager.restore_all()
|
||||
retry_manager.unmount(mid)
|
||||
fake_backend.export("nas", "scans")
|
||||
clock.now += 120
|
||||
assert retry_manager.retry_pending() == []
|
||||
assert not fake_backend.mounted
|
||||
|
||||
@@ -147,6 +147,8 @@ def test_manager_pins_host_key_and_checks_alive(
|
||||
with session_factory() as s:
|
||||
mm = s.get(Mount, mid)
|
||||
assert mm is not None and mm.host_key == server.host_key_string
|
||||
# Praxisbefund: nach erfolgreichem Test „erreichbar“ statt „nicht geprüft“
|
||||
assert mm.state is MountState.MOUNTED
|
||||
|
||||
assert manager.check_alive(mid) is None
|
||||
with session_factory() as s:
|
||||
|
||||
+67
-1
@@ -154,7 +154,16 @@ def test_security_headers(client: TestClient) -> None:
|
||||
|
||||
|
||||
def test_no_inline_scripts_or_styles(client: TestClient) -> None:
|
||||
for url in ("/", "/mounts", "/mounts/new", "/rules", "/rules/new", "/log", "/settings"):
|
||||
for url in (
|
||||
"/",
|
||||
"/mounts",
|
||||
"/mounts/new",
|
||||
"/keys",
|
||||
"/rules",
|
||||
"/rules/new",
|
||||
"/log",
|
||||
"/settings",
|
||||
):
|
||||
html = client.get(url).text
|
||||
assert "style=" not in html, url
|
||||
assert not re.search(r"<script(?![^>]*\bsrc=)", html), url
|
||||
@@ -555,3 +564,60 @@ def test_export_excludes_secrets_and_import_roundtrip(
|
||||
files={"file": ("export.json", text, "application/json")},
|
||||
)
|
||||
assert "Übersprungen" in c2.get("/settings").text
|
||||
|
||||
|
||||
# -- Schlüsselverwaltung -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_keys_page_generate_import_select_delete(client: TestClient, state: NetMover) -> None:
|
||||
from netmover.crypto import generate_ssh_keypair
|
||||
|
||||
r = post(client, "/keys/generate", {"name": "paperless-lxc"})
|
||||
assert r.status_code == 303
|
||||
private, public = generate_ssh_keypair()
|
||||
r = post(client, "/keys/import", {"name": "alt", "private_key": private, "passphrase": ""})
|
||||
assert r.status_code == 303
|
||||
page = client.get("/keys").text
|
||||
assert "paperless-lxc" in page and "alt" in page
|
||||
assert public.split()[1] in page # öffentlicher Teil sichtbar
|
||||
for line in private.splitlines()[1:-1]: # keine Zeile des privaten Schlüssels
|
||||
assert line not in page
|
||||
assert "restrict,command="/usr/lib/openssh/sftp-server"" in page
|
||||
|
||||
with state.session_factory() as s:
|
||||
key_id = s.scalar(select(Credential.id).where(Credential.name == "alt"))
|
||||
form = client.get("/mounts/new").text
|
||||
assert f'value="{key_id}"' in form and "neuer Schlüssel nur für diese Anbindung" in form
|
||||
|
||||
r = post(
|
||||
client,
|
||||
"/mounts/new",
|
||||
{
|
||||
"name": "paperless",
|
||||
"type": "sftp",
|
||||
"server": "10.0.0.5",
|
||||
"share": "/consume",
|
||||
"port": "22",
|
||||
"auth": "ssh_key",
|
||||
"key_id": str(key_id),
|
||||
"username": "root",
|
||||
"enabled": "on",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 303, r.text
|
||||
mounts = client.get("/mounts").text
|
||||
assert "gespeichertem Schlüssel" in mounts and "„alt“" in mounts
|
||||
edit = client.get(f"/mounts/{_mount_id(state, 'paperless')}/edit").text
|
||||
assert f'<option value="{key_id}" selected>' in edit and 'value="root"' in edit
|
||||
|
||||
post(client, f"/keys/{key_id}/delete")
|
||||
assert "noch benutzt" in client.get("/keys").text
|
||||
post(client, f"/mounts/{_mount_id(state, 'paperless')}/delete")
|
||||
post(client, f"/keys/{key_id}/delete")
|
||||
page = client.get("/keys").text
|
||||
assert "gelöscht" in page and ">alt<" not in page
|
||||
|
||||
|
||||
def test_key_import_error_is_shown(client: TestClient) -> None:
|
||||
post(client, "/keys/import", {"name": "x", "private_key": "kein schlüssel", "passphrase": ""})
|
||||
assert "Kein lesbarer privater Schlüssel" in client.get("/keys").text
|
||||
|
||||
Reference in New Issue
Block a user