Compare commits
4
Commits
2770c2e204
...
e1a917b974
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1a917b974 | ||
|
|
c8ee950514 | ||
|
|
dd9177859e | ||
|
|
00a0537085 |
@@ -0,0 +1,8 @@
|
|||||||
|
.venv
|
||||||
|
.git
|
||||||
|
data
|
||||||
|
**/__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
tests
|
||||||
@@ -8,6 +8,10 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] – 2026-10-10
|
||||||
|
|
||||||
|
Erste Version.
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
- Projektgerüst (Python ≥ 3.12, FastAPI-Stack, src-Layout, ruff, mypy strict, pytest).
|
- Projektgerüst (Python ≥ 3.12, FastAPI-Stack, src-Layout, ruff, mypy strict, pytest).
|
||||||
- Datenmodell mit SQLAlchemy 2.x: `credentials`, `mounts`, `rules`, `runs`,
|
- Datenmodell mit SQLAlchemy 2.x: `credentials`, `mounts`, `rules`, `runs`,
|
||||||
@@ -67,6 +71,13 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
|
|||||||
auch für Anbindungen, deren Wiederherstellung scheitert.
|
auch für Anbindungen, deren Wiederherstellung scheitert.
|
||||||
- Dienststart: Migration → Schlüssel → Anbindungen wiederherstellen → Scheduler;
|
- Dienststart: Migration → Schlüssel → Anbindungen wiederherstellen → Scheduler;
|
||||||
beim Beenden werden Mounts nicht ausgehängt.
|
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):
|
- Weboberfläche (FastAPI, Jinja2, HTMX ohne Build-Schritt, handyfähig, Hell/Dunkel):
|
||||||
Status, Anbindungen, Regeln, Protokoll, Einstellungen.
|
Status, Anbindungen, Regeln, Protokoll, Einstellungen.
|
||||||
- Ersteinrichtung mit Pflicht-Passwort (≥ 10 Zeichen) und einmaligem
|
- Ersteinrichtung mit Pflicht-Passwort (≥ 10 Zeichen) und einmaligem
|
||||||
@@ -87,3 +98,6 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
|
|||||||
Session stand (Session erkennt nur Neuzuweisungen als Änderung).
|
Session stand (Session erkennt nur Neuzuweisungen als Änderung).
|
||||||
- Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den
|
- Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den
|
||||||
Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt.
|
Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt.
|
||||||
|
|
||||||
|
[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.0...HEAD
|
||||||
|
[0.1.0]: https://git.wilde-server.de/florian.wilde/netmover/releases/tag/v0.1.0
|
||||||
|
|||||||
@@ -12,10 +12,42 @@ Zeiten in Ortszeit (Europe/Berlin).
|
|||||||
| 2026-10-10 | 18:53 | 19:07 | 0:14 | 3 | Mover-Logik |
|
| 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:10 | 19:17 | 0:07 | 4 | Scheduler, Protokoll, Benachrichtigung |
|
||||||
| 2026-10-10 | 19:18 | 19:34 | 0:16 | 5 | Weboberfläche und Anmeldung |
|
| 2026-10-10 | 19: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 |
|
||||||
|
| **Summe** | | | **1:06** | | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-10-10 · 19:40–19:50 · Schritt 6: Deployment, README, Release 0.1.0
|
||||||
|
|
||||||
|
**Erledigt**
|
||||||
|
- `deploy/netmover.service`, `deploy/netmover.env`, `deploy/install.sh`,
|
||||||
|
`Dockerfile` (python:3.12-slim-trixie, mehrstufig), `compose.yml`, `.dockerignore`.
|
||||||
|
- README vollständig; `LICENSE` (MIT, wie seit Schritt 1 in `pyproject.toml`).
|
||||||
|
- Version 0.1.0, Changelog-Abschnitt, Tag `v0.1.0`.
|
||||||
|
|
||||||
|
**Geprüft**
|
||||||
|
- Wheel enthält Templates, statische Dateien und Migrationen.
|
||||||
|
- Docker-Image gebaut; Container startet, `/healthz` = 200, `mount.cifs` 7.4 und
|
||||||
|
`mount.nfs` vorhanden. **Propagation getestet:** tmpfs im Container unter
|
||||||
|
`/mnt/netmover` eingehängt → mit `rshared` auf dem Host sichtbar, ohne nicht.
|
||||||
|
- `systemd-analyze verify` der Unit ohne Beanstandung. Ein echter Start der Unit
|
||||||
|
als root (insb. `MountFlags=shared` mit `ProtectSystem`) war hier nicht möglich →
|
||||||
|
beim ersten Einsatz mit `findmnt -R /mnt/netmover` prüfen.
|
||||||
|
|
||||||
|
**Entscheidungen**
|
||||||
|
- Paperless-ngx-Consumer geprüft: nutzt `watchfiles` (`Change.added`) plus
|
||||||
|
5-Minuten-Rescan – erkennt also auch per `link()` erzeugte Dateien. Für Watcher,
|
||||||
|
die nur `IN_MOVED_TO`/`IN_CLOSE_WRITE` kennen, trotzdem auf
|
||||||
|
`renameat2(RENAME_NOREPLACE)` umgestellt (entspricht der Vorgabe `os.replace`,
|
||||||
|
ohne die Garantie „nie überschreiben“ aufzugeben).
|
||||||
|
- Härtung der Unit bewusst zurückhaltend (keine `CapabilityBoundingSet`, kein
|
||||||
|
`ProtectKernelModules`): ungetestete Einschränkungen könnten Mounten brechen
|
||||||
|
(z. B. NFS mit privilegiertem Quellport, Modul-Autoload für cifs).
|
||||||
|
- SFTP-Schlüssel im README per `restrict,command="…/sftp-server"` auf SFTP beschränkt.
|
||||||
|
|
||||||
|
**Offen für später**
|
||||||
|
- Erster Praxistest auf der Ziel-VM mit echtem Synology-NAS und Paperless-LXC.
|
||||||
|
|
||||||
## 2026-10-10 · 19:18–19:34 · Schritt 5: Weboberfläche und Anmeldung
|
## 2026-10-10 · 19:18–19:34 · Schritt 5: Weboberfläche und Anmeldung
|
||||||
|
|
||||||
**Erledigt**
|
**Erledigt**
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# NetMover im Container (Alternative zum systemd-Dienst, siehe README).
|
||||||
|
# Braucht zur Laufzeit CAP_SYS_ADMIN und ein Mount-Verzeichnis mit
|
||||||
|
# Propagation „rshared“ – sonst sieht der Host die Mounts nicht.
|
||||||
|
|
||||||
|
FROM python:3.12-slim-trixie AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN pip wheel --no-cache-dir --wheel-dir /wheels .
|
||||||
|
|
||||||
|
FROM python:3.12-slim-trixie
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends cifs-utils nfs-common \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=build /wheels /wheels
|
||||||
|
RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels
|
||||||
|
|
||||||
|
ENV NETMOVER_HOST=0.0.0.0 \
|
||||||
|
NETMOVER_PORT=8080 \
|
||||||
|
NETMOVER_DATA_DIR=/var/lib/netmover \
|
||||||
|
NETMOVER_RUN_DIR=/run/netmover \
|
||||||
|
NETMOVER_MOUNT_BASE=/mnt/netmover \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
VOLUME ["/var/lib/netmover"]
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||||
|
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=4).status == 200 else 1)"
|
||||||
|
CMD ["netmover", "serve"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Florian Wilde
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,9 +1,364 @@
|
|||||||
# NetMover
|
# NetMover
|
||||||
|
|
||||||
Selbst gehosteter Dienst mit Web-Oberfläche, der SMB/CIFS- und NFS-Freigaben
|
Selbst gehosteter Dienst mit Weboberfläche, der **SMB/CIFS- und NFS-Freigaben
|
||||||
verwaltet und Dateien zeitgesteuert zwischen ihnen verschiebt.
|
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
|
||||||
|
cd netmover
|
||||||
|
sudo ./deploy/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript installiert `python3-venv`, `cifs-utils` und `nfs-common` und richtet
|
||||||
|
NetMover in `/opt/netmover/venv` ein. Danach installiert es
|
||||||
|
[`deploy/netmover.service`](deploy/netmover.service) und startet den Dienst.
|
||||||
|
Ein Update ist derselbe Aufruf nach einem `git pull`.
|
||||||
|
|
||||||
|
Was wo liegt:
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `/var/lib/netmover/netmover.db` | Datenbank (SQLite) |
|
||||||
|
| `/var/lib/netmover/secret.key` | Schlüssel für die Zugangsdaten (0600) – **mitsichern!** |
|
||||||
|
| `/etc/netmover/netmover.env` | Einstellungen (Port, Logs, …) |
|
||||||
|
| `/mnt/netmover/<name>` | Mountpoints – nur NetMover legt hier etwas an |
|
||||||
|
| `/run/netmover` | kurzlebige Credentials-Dateien für `mount.cifs` |
|
||||||
|
|
||||||
|
**Warum als root?** Ein- und Aushängen braucht `CAP_SYS_ADMIN`. Außerdem soll
|
||||||
|
NetMover Dateien auf beliebigen Freigaben lesen, löschen und mit den richtigen
|
||||||
|
Eigentümern anlegen können. Die Unit härtet den Dienst so weit, wie es sich mit
|
||||||
|
Mounten verträgt: `ProtectSystem=full`, `PrivateTmp`, `NoNewPrivileges`, Zustand nur
|
||||||
|
für root lesbar. Weil diese Optionen einen eigenen Mount-Namespace erzeugen, steht
|
||||||
|
`MountFlags=shared` in der Unit. Nur so sieht das System die Mounts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
findmnt -R /mnt/netmover # sollte die eingehängten Freigaben zeigen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beim Beenden bleiben die Mounts eingehängt**, weil andere Programme sie benutzen
|
||||||
|
könnten. Beim Start werden alle Anbindungen mit „automatisch einhängen“
|
||||||
|
wiederhergestellt. Erst danach startet der Scheduler.
|
||||||
|
|
||||||
|
## Erster Start
|
||||||
|
|
||||||
|
NetMover lauscht standardmäßig nur auf `127.0.0.1:8080`. Vom Arbeitsplatz aus
|
||||||
|
erreichst du die Oberfläche per SSH-Tunnel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -L 8080:127.0.0.1:8080 root@netmover-vm
|
||||||
|
# dann im Browser: http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Aufruf musst du ein Administratorpasswort setzen (mindestens 10 Zeichen).
|
||||||
|
Dafür brauchst du den **Einrichtungscode** aus dem Log. Damit kann nicht irgendwer
|
||||||
|
im Netz, der die Seite zuerst öffnet, das Passwort setzen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u netmover | grep setup.required
|
||||||
|
# … event=setup.required code=4f2a-91c0-7d3e …
|
||||||
|
```
|
||||||
|
|
||||||
|
Passwort vergessen? `sudo /opt/netmover/venv/bin/netmover reset-password`, dann
|
||||||
|
`systemctl restart netmover` und die Einrichtung neu durchlaufen.
|
||||||
|
|
||||||
|
## Beispiel: Synology → Paperless
|
||||||
|
|
||||||
|
Ziel: Scans landen auf dem NAS in `scans`. NetMover schiebt PDFs alle 30 Sekunden
|
||||||
|
in den Consume-Ordner von Paperless, das in einem LXC per SSH erreichbar ist.
|
||||||
|
|
||||||
|
### 1. Synology vorbereiten
|
||||||
|
|
||||||
|
1. **Systemsteuerung → Benutzer** → Benutzer `netmover` anlegen, Lese-/Schreibrecht
|
||||||
|
auf den gemeinsamen Ordner `scans`.
|
||||||
|
2. **Systemsteuerung → Dateidienste → SMB**: aktiviert. Unter *Erweitert* als
|
||||||
|
minimales Protokoll SMB2 oder höher.
|
||||||
|
|
||||||
|
### 2. Anbindung „Synology Scans“ anlegen
|
||||||
|
|
||||||
|
*Anbindungen → Neue Anbindung*:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Name | `Synology Scans` |
|
||||||
|
| Typ | SMB/CIFS |
|
||||||
|
| Server | `192.168.1.10` (IP des NAS) |
|
||||||
|
| Freigabe | `scans` |
|
||||||
|
| Benutzername / Passwort | `netmover` / … |
|
||||||
|
| Zusatzoptionen | `vers=3.0` |
|
||||||
|
| Beim Start automatisch einhängen | ✓ |
|
||||||
|
|
||||||
|
Klicke auf **Testen**. Alle drei Schritte (Einhängen, Lesen, Schreiben) sollten ✓
|
||||||
|
zeigen, und die Rolle sollte „Quelle mit Löschen“ lauten. Danach speichern und
|
||||||
|
**Einhängen**.
|
||||||
|
|
||||||
|
### 3. Paperless-LXC vorbereiten (SFTP-Ziel)
|
||||||
|
|
||||||
|
Lege in NetMover die *Anbindung → Neue Anbindung* an:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Name | `Paperless` |
|
||||||
|
| Typ | SFTP (nur Ziel) |
|
||||||
|
| Server / SSH-Port | IP des LXC / `22` |
|
||||||
|
| Freigabe / Pfad | `/opt/paperless_data/consume` |
|
||||||
|
| Anmeldeart | SSH-Schlüssel (NetMover erzeugt ihn) |
|
||||||
|
| Benutzername | `paperless` (der Benutzer, unter dem Paperless läuft) |
|
||||||
|
|
||||||
|
Nach dem Speichern zeigt NetMover den **öffentlichen Schlüssel**. Trage ihn im LXC
|
||||||
|
ein, und zwar so, dass der Schlüssel **nur SFTP** darf:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# im Paperless-LXC als root
|
||||||
|
install -d -m 700 -o paperless -g paperless ~paperless/.ssh
|
||||||
|
echo 'restrict,command="/usr/lib/openssh/sftp-server" ssh-ed25519 AAAA… netmover@paperless' \
|
||||||
|
>> ~paperless/.ssh/authorized_keys
|
||||||
|
chown paperless:paperless ~paperless/.ssh/authorized_keys
|
||||||
|
chmod 600 ~paperless/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann in NetMover bei der Anbindung **Testen**. Prüfe den angezeigten Fingerabdruck
|
||||||
|
des Hostschlüssels. Ermitteln kannst du ihn im LXC mit
|
||||||
|
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`. NetMover hält ihn beim ersten
|
||||||
|
Test fest und verweigert später jede Verbindung, wenn er sich ändert.
|
||||||
|
|
||||||
|
> Weil die Dateien als Benutzer `paperless` ankommen, darf Paperless sie nach dem
|
||||||
|
> Einlesen auch löschen. Paperless-ngx (aktuelle Versionen mit `watchfiles`)
|
||||||
|
> bemerkt neue Dateien sofort. Ältere Versionen, die nur auf
|
||||||
|
> `IN_CLOSE_WRITE`/`IN_MOVED_TO` lauschen, siehe [Fehlersuche](#fehlersuche).
|
||||||
|
|
||||||
|
### 4. Regel anlegen
|
||||||
|
|
||||||
|
*Regeln → Neue Regel*:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Name | `Scans → Paperless` |
|
||||||
|
| Intervall | `30` |
|
||||||
|
| Quelle | `Synology Scans`, Unterordner leer |
|
||||||
|
| Filter | Glob `*.pdf` |
|
||||||
|
| Ziel | Anbindung `Paperless`, Zielordner leer |
|
||||||
|
| Aktion | verschieben |
|
||||||
|
| Wenn Datei existiert | umbenennen |
|
||||||
|
| Stabilitätsfenster | `5` |
|
||||||
|
| Quarantäneordner | `_quarantaene` (optional) |
|
||||||
|
|
||||||
|
Klicke auf **Probelauf**. Die Tabelle zeigt, welche Datei wohin ginge, ohne etwas
|
||||||
|
zu verändern. Danach aktivieren und auf der Statusseite verfolgen.
|
||||||
|
|
||||||
|
**Getrennt nach Unterordnern?** Scannt z. B. jede Person in einen eigenen
|
||||||
|
Unterordner (`scans/max`, `scans/erika`), legst du pro Unterordner eine Regel mit
|
||||||
|
eigenem Quell-Unterordner und eigenem Ziel an.
|
||||||
|
|
||||||
|
**Paperless lokal statt per SSH?** Läuft Paperless auf demselben Host, wähle als Ziel
|
||||||
|
„Lokaler Pfad“ (z. B. `/opt/paperless_data/consume`). Der Ordner muss existieren und
|
||||||
|
darf nicht unter `/mnt/netmover` liegen. Mit „Eigentümer setzen“ (z. B. `1000:1000`)
|
||||||
|
gehören die Dateien danach dem Paperless-Benutzer.
|
||||||
|
|
||||||
|
### NFS statt SMB
|
||||||
|
|
||||||
|
Am NAS: *Dateidienste → NFS* aktivieren. Dann unter *Gemeinsamer Ordner →
|
||||||
|
Bearbeiten → NFS-Berechtigungen* eine Regel mit der IP des NetMover-Hosts anlegen.
|
||||||
|
In NetMover Typ „NFS 4“, Freigabe `/volume1/scans`.
|
||||||
|
|
||||||
|
**Root-Squash:** NetMover läuft als root. Synology bildet root standardmäßig auf
|
||||||
|
`nobody` ab, dann scheitert das Schreiben oder `chown`. Der **Testen**-Knopf erkennt
|
||||||
|
das und sagt es dir. Abhilfe in der NFS-Regel: *Squash → „Keine Zuordnung“* (No
|
||||||
|
mapping) oder „Alle Benutzer zu admin zuordnen“.
|
||||||
|
|
||||||
|
## Alternative: Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /mnt/netmover
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs | grep setup.required
|
||||||
|
```
|
||||||
|
|
||||||
|
[`compose.yml`](compose.yml) gibt dem Container die nötigen Rechte:
|
||||||
|
`cap_add: [SYS_ADMIN, DAC_READ_SEARCH]` und `security_opt: [apparmor:unconfined]`.
|
||||||
|
|
||||||
|
> **Wichtig:** `/mnt/netmover` muss als `bind` mit **`propagation: rshared`**
|
||||||
|
> eingebunden sein. Ohne das bleiben die Mounts im Container gefangen: NetMover
|
||||||
|
> selbst sieht sie, aber weder der Host noch andere Container (z. B. Paperless).
|
||||||
|
> Getestet: Mit `rshared` erscheint ein im Container eingehängtes Dateisystem
|
||||||
|
> sofort unter `/mnt/netmover` auf dem Host, ohne `rshared` nicht.
|
||||||
|
|
||||||
|
Lokale Ziele musst du zusätzlich als Volume einbinden. Für NFSv3 im Container die
|
||||||
|
Option `nolock` setzen oder besser NFSv4 nehmen; NFSv3-Locking braucht
|
||||||
|
`rpc.statd`, und den gibt es im Container nicht.
|
||||||
|
|
||||||
|
**Unterschied zum systemd-Dienst:** Der Dienst direkt auf dem Host braucht keine
|
||||||
|
Propagation-Tricks, keine AppArmor-Ausnahme und keine zusätzlichen Volumes. Docker
|
||||||
|
kapselt die Software, hat aber trotz Container `CAP_SYS_ADMIN` und ist damit kaum
|
||||||
|
weniger mächtig als root auf dem Host.
|
||||||
|
|
||||||
|
## Reverse Proxy mit TLS
|
||||||
|
|
||||||
|
NetMover gehört **nicht ins Internet**. Es hat root-Rechte, hängt beliebige
|
||||||
|
Freigaben ein und speichert Zugangsdaten zu deinem NAS. Ein Fehler oder ein
|
||||||
|
schwaches Passwort wäre ein direkter Weg in dein Netz. Für den Zugriff im LAN gehört
|
||||||
|
ein Reverse Proxy mit TLS davor; für unterwegs nimm VPN (z. B. WireGuard).
|
||||||
|
|
||||||
|
Beispiel Caddy auf demselben Host:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
netmover.lan {
|
||||||
|
tls internal
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel nginx:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name netmover.lan;
|
||||||
|
ssl_certificate /etc/ssl/netmover.crt;
|
||||||
|
ssl_certificate_key /etc/ssl/netmover.key;
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300s; # Mount-Tests können dauern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann in `/etc/netmover/netmover.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NETMOVER_COOKIE_SECURE=true # Session-Cookie nur noch per HTTPS
|
||||||
|
NETMOVER_FORWARDED_ALLOW_IPS=127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Umgebungsvariablen (in `/etc/netmover/netmover.env`); alles Übrige stellst du in
|
||||||
|
der Oberfläche unter *Einstellungen* ein:
|
||||||
|
|
||||||
|
| Variable | Standard | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `NETMOVER_HOST` | `127.0.0.1` | Adresse, auf der NetMover lauscht |
|
||||||
|
| `NETMOVER_PORT` | `8080` | Port |
|
||||||
|
| `NETMOVER_DATA_DIR` | `/var/lib/netmover` | Datenbank und Schlüssel |
|
||||||
|
| `NETMOVER_MOUNT_BASE` | `/mnt/netmover` | Basisverzeichnis der Mountpoints |
|
||||||
|
| `NETMOVER_RUN_DIR` | `/run/netmover` | kurzlebige Credentials-Dateien |
|
||||||
|
| `NETMOVER_COOKIE_SECURE` | `false` | Cookie nur per HTTPS (hinter TLS-Proxy: `true`) |
|
||||||
|
| `NETMOVER_FORWARDED_ALLOW_IPS` | `127.0.0.1` | Proxy-IPs, deren `X-Forwarded-*` gilt |
|
||||||
|
| `NETMOVER_SESSION_HOURS` | `12` | Gültigkeit der Anmeldung |
|
||||||
|
| `NETMOVER_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||||
|
| `NETMOVER_LOG_FORMAT` | `logfmt` | `logfmt` oder `json` (ein Ereignis pro Zeile) |
|
||||||
|
|
||||||
|
In der Oberfläche unter *Einstellungen* stellst du ein:
|
||||||
|
- Aufbewahrung der Protokolle (Standard 30 Tage).
|
||||||
|
- Webhook-Benachrichtigung: „bei jedem Fehler“ oder „nach N Fehlern in Folge“,
|
||||||
|
jeweils mit Entwarnung.
|
||||||
|
- Export und Import der Konfiguration, **ohne** Zugangsdaten und Webhook-URL.
|
||||||
|
|
||||||
|
Webhook-Vorlagen (Platzhalter `$event $title $message $rule $time $host`):
|
||||||
|
|
||||||
|
| Dienst | URL | Content-Type | Vorlage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ntfy | `https://ntfy.sh/<topic>` | `text/plain` | `$title: $message` |
|
||||||
|
| Gotify | `https://gotify.lan/message?token=…` | `application/json` | `{"title": "$title", "message": "$message", "priority": 5}` |
|
||||||
|
| eigener Empfänger | beliebig | `application/json` | Standardvorlage |
|
||||||
|
|
||||||
|
`GET /healthz` liefert `200` oder `503` mit der Liste der Probleme (ohne Anmeldung),
|
||||||
|
z. B. für Uptime Kuma.
|
||||||
|
|
||||||
|
## Fehlersuche
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u netmover -f # Log live
|
||||||
|
journalctl -u netmover | grep level=error
|
||||||
|
findmnt -R /mnt/netmover # was ist wirklich eingehängt?
|
||||||
|
```
|
||||||
|
|
||||||
|
| Meldung / Symptom | Ursache und Abhilfe |
|
||||||
|
|---|---|
|
||||||
|
| `mount error(13): Permission denied` (CIFS) | Benutzer oder Passwort falsch, oder keine Rechte auf die Freigabe. |
|
||||||
|
| `mount error(2): No such file or directory` | Freigabename falsch geschrieben (Groß/klein!). |
|
||||||
|
| `mount error(112): Host is down` | NAS nicht erreichbar, oder SMB1 erwartet: `vers=3.0` bzw. `vers=2.1` probieren. |
|
||||||
|
| `… helper program` / `unknown filesystem type` | `apt install cifs-utils nfs-common` |
|
||||||
|
| `Operation not permitted` / `only root can do that` | Kein `CAP_SYS_ADMIN`: unprivilegierter LXC oder Docker ohne `cap_add`. |
|
||||||
|
| `access denied by server` (NFS) | NFS-Regel am NAS fehlt für die IP dieses Hosts (oder falsches Subnetz in der Regel). |
|
||||||
|
| Test: „Root-Squash“ | Siehe [NFS statt SMB](#nfs-statt-smb); `chown` wird bis dahin übersprungen. |
|
||||||
|
| „… ist nicht leer“ beim Einhängen | Im Mountpoint liegen Dateien, die geschrieben wurden, als nichts eingehängt war. Inhalt prüfen und sichern, Verzeichnis leeren, erneut einhängen. |
|
||||||
|
| „nicht eingehängt – Lauf abgebrochen“ | Die Freigabe ist weg (z. B. von Hand ausgehängt). Auf der Anbindungsseite **Einhängen**. NetMover hängt verschwundene Mounts absichtlich nicht selbst neu ein. |
|
||||||
|
| „gestört (ESTALE …)“, danach wieder OK | NAS-Neustart; NetMover hat neu eingehängt. Bleibt der Fehler: Netzwerk/NAS prüfen. |
|
||||||
|
| „Keine Reaktion seit 120 s“ | Ein Dateizugriff hängt (meist NFS mit `hard`). Die Regel bleibt blockiert („läuft seit …“), bis der Zugriff zurückkehrt; die Anbindung wird vor dem nächsten Lauf geprüft. |
|
||||||
|
| „Hostschlüssel … hat sich geändert“ (SFTP) | Wurde der Zielserver neu aufgesetzt? Dann bei der Anbindung „Hostschlüssel zurücksetzen“ und neu testen. Sonst **nicht** verbinden. |
|
||||||
|
| SFTP: „Anmeldung abgelehnt“ | Öffentlicher Schlüssel fehlt in `authorized_keys`, oder Rechte von `~/.ssh` stimmen nicht (700/600, Eigentümer). |
|
||||||
|
| Paperless bemerkt neue Dateien nicht | Ältere Paperless-Versionen: `PAPERLESS_CONSUMER_POLLING=10` setzen. |
|
||||||
|
| Regel gelb „länger als 3 × Intervall“ | Seit dem Dreifachen des Intervalls kein erfolgreicher Lauf; Protokoll der Regel ansehen. |
|
||||||
|
| Einrichtungscode verloren | `systemctl restart netmover` erzeugt einen neuen. |
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- **Nicht ins Internet stellen** (siehe [Reverse Proxy](#reverse-proxy-mit-tls)).
|
||||||
|
- Zugangsdaten liegen nur verschlüsselt in der Datenbank (Fernet, Schlüssel
|
||||||
|
`secret.key`, 0600). Die Oberfläche zeigt sie nie an; in der Prozessliste stehen
|
||||||
|
sie ebenfalls nie, weil `mount.cifs` eine kurzlebige Credentials-Datei bekommt.
|
||||||
|
Ausgaben von `mount` werden geschwärzt, bevor sie protokolliert werden.
|
||||||
|
- **Sichere `secret.key` zusammen mit der Datenbank.** Ohne den Schlüssel sind die
|
||||||
|
gespeicherten Passwörter verloren.
|
||||||
|
- Anmeldung:
|
||||||
|
- Ein Administratorkonto mit Argon2-Passworthash.
|
||||||
|
- Sperre nach 5 Fehlversuchen.
|
||||||
|
- Session-Cookie `HttpOnly`, `SameSite=Lax`.
|
||||||
|
- CSRF-Token bei jedem POST.
|
||||||
|
- Strikte Content-Security-Policy ohne Inline-Skripte.
|
||||||
|
- Eingaben, die in `mount` landen, werden streng geprüft. Zusatzoptionen
|
||||||
|
sind nur aus einer Allowlist erlaubt, `credentials`, `username`, `password` und
|
||||||
|
`pass` sind gesperrt, `mount` wird nie über eine Shell aufgerufen.
|
||||||
|
- Mountpoints entstehen ausschließlich unter `/mnt/netmover`; NetMover ändert
|
||||||
|
`/etc/fstab` nie.
|
||||||
|
- SFTP-Ziele: Schlüssel per `restrict,command="/usr/lib/openssh/sftp-server"` auf
|
||||||
|
SFTP beschränken; der Hostschlüssel wird festgehalten.
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
@@ -12,10 +367,26 @@ python3 -m venv .venv # oder: --without-pip + get-pip.py
|
|||||||
.venv/bin/pip install -e '.[dev]'
|
.venv/bin/pip install -e '.[dev]'
|
||||||
.venv/bin/pytest
|
.venv/bin/pytest
|
||||||
.venv/bin/ruff check . && .venv/bin/ruff format --check . && .venv/bin/mypy
|
.venv/bin/ruff check . && .venv/bin/ruff format --check . && .venv/bin/mypy
|
||||||
NETMOVER_DATA_DIR=$PWD/data .venv/bin/netmover migrate
|
|
||||||
|
# Oberfläche ohne root und ohne NAS ausprobieren (simulierte Mounts):
|
||||||
|
NETMOVER_DATA_DIR=/tmp/nm NETMOVER_RUN_DIR=/tmp/nm/run NETMOVER_MOUNT_BASE=/tmp/nm/mnt \
|
||||||
|
NETMOVER_MOUNT_BACKEND=fake .venv/bin/netmover serve
|
||||||
|
# Freigaben simulieren: mkdir -p /tmp/nm/fake/remote/<server>/<freigabe>
|
||||||
```
|
```
|
||||||
|
|
||||||
Neue Migration nach Modelländerung:
|
Aufbau:
|
||||||
|
|
||||||
|
| Modul | Aufgabe |
|
||||||
|
|---|---|
|
||||||
|
| `mounting/` | `MountBackend` (Linux/Fake), Mount-Verwaltung, Test, Wiederherstellung, SFTP |
|
||||||
|
| `mover/` | Scan, Stabilität, atomares Schreiben, Kollisionen, Buchführung, Regellauf |
|
||||||
|
| `runs.py`, `scheduler.py`, `health.py`, `notify.py` | Protokoll, Zeitsteuerung, Gesundheit, Webhook |
|
||||||
|
| `admin.py`, `transfer.py`, `web/` | Formulare, Export/Import, Oberfläche |
|
||||||
|
|
||||||
|
Die gesamte Mover-Logik ist mit dem `FakeMountBackend` ohne root testbar. SFTP
|
||||||
|
wird gegen einen echten SFTP-Server im Testprozess getestet.
|
||||||
|
|
||||||
|
Neue Migration nach einer Modelländerung:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NETMOVER_DATA_DIR=$PWD/data .venv/bin/alembic revision --autogenerate -m "…"
|
NETMOVER_DATA_DIR=$PWD/data .venv/bin/alembic revision --autogenerate -m "…"
|
||||||
@@ -23,11 +394,19 @@ NETMOVER_DATA_DIR=$PWD/data .venv/bin/alembic revision --autogenerate -m "…"
|
|||||||
|
|
||||||
Veröffentlichte Migrationen (ab v0.1.0) werden nie mehr geändert, nur ergänzt.
|
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/)
|
- Commits nach [Conventional Commits](https://www.conventionalcommits.org/de/)
|
||||||
(`feat:`, `fix:`, `docs:`, `test:`, `chore:` …).
|
(`feat:`, `fix:`, `docs:`, `test:`, `chore:` …).
|
||||||
- Jede nutzerrelevante Änderung bekommt einen Eintrag unter `[Unreleased]` in
|
- Jede nutzerrelevante Änderung kommt unter `[Unreleased]` in
|
||||||
[CHANGELOG.md](CHANGELOG.md). Beim Release wird der Abschnitt versioniert,
|
[CHANGELOG.md](CHANGELOG.md). Bei einem Release:
|
||||||
die Version in `src/netmover/__init__.py` angehoben und ein Tag `vX.Y.Z` gesetzt.
|
1. Den Abschnitt `[Unreleased]` mit Versionsnummer versehen.
|
||||||
- Arbeitszeit und Entscheidungen: [DEVLOG.md](DEVLOG.md).
|
2. Die Version in `src/netmover/__init__.py` anheben.
|
||||||
|
3. Einen Tag `vX.Y.Z` setzen.
|
||||||
|
- Arbeitszeit und Entscheidungen stehen in [DEVLOG.md](DEVLOG.md).
|
||||||
|
- htmx 2.0.4 liegt unter `src/netmover/web/static/htmx.min.js`
|
||||||
|
(SHA-256 `e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447`).
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# NetMover per Docker Compose. Vorher auf dem Host:
|
||||||
|
# mkdir -p /mnt/netmover
|
||||||
|
# Die Weboberfläche ist nur auf dem Host selbst erreichbar (127.0.0.1:8080).
|
||||||
|
services:
|
||||||
|
netmover:
|
||||||
|
build: .
|
||||||
|
image: netmover:0.1.0
|
||||||
|
container_name: netmover
|
||||||
|
restart: unless-stopped
|
||||||
|
cap_add:
|
||||||
|
- SYS_ADMIN # mount/umount
|
||||||
|
- DAC_READ_SEARCH # mount.cifs liest die Credentials-Datei
|
||||||
|
security_opt:
|
||||||
|
- apparmor:unconfined # Dockers AppArmor-Profil verbietet mount
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
|
environment:
|
||||||
|
NETMOVER_LOG_LEVEL: INFO
|
||||||
|
# Hinter einem Reverse Proxy mit HTTPS:
|
||||||
|
# NETMOVER_COOKIE_SECURE: "true"
|
||||||
|
# NETMOVER_FORWARDED_ALLOW_IPS: "172.16.0.0/12"
|
||||||
|
volumes:
|
||||||
|
- netmover-data:/var/lib/netmover
|
||||||
|
# WICHTIG: rshared – sonst bleiben die Mounts im Container gefangen und
|
||||||
|
# weder der Host noch andere Container sehen sie.
|
||||||
|
- type: bind
|
||||||
|
source: /mnt/netmover
|
||||||
|
target: /mnt/netmover
|
||||||
|
bind:
|
||||||
|
propagation: rshared
|
||||||
|
# Lokale Ziele (z. B. Paperless-Consume-Ordner) zusätzlich einbinden:
|
||||||
|
# - /opt/paperless/consume:/opt/paperless/consume
|
||||||
|
tmpfs:
|
||||||
|
- /run/netmover:mode=0700
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
netmover-data:
|
||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# NetMover auf Debian 13 installieren bzw. aktualisieren (als root ausführen).
|
||||||
|
#
|
||||||
|
# ./deploy/install.sh # aus diesem Verzeichnis installieren
|
||||||
|
# ./deploy/install.sh 'git+https://git.wilde-server.de/florian.wilde/netmover.git@v0.1.0'
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SOURCE="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||||
|
PREFIX=/opt/netmover
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "Bitte als root ausführen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Pakete"
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq python3 python3-venv cifs-utils nfs-common >/dev/null
|
||||||
|
|
||||||
|
echo "==> Virtuelle Umgebung in $PREFIX/venv"
|
||||||
|
install -d -m 0755 "$PREFIX"
|
||||||
|
[ -x "$PREFIX/venv/bin/python" ] || python3 -m venv "$PREFIX/venv"
|
||||||
|
"$PREFIX/venv/bin/pip" install -q --upgrade pip
|
||||||
|
"$PREFIX/venv/bin/pip" install -q --upgrade "$SOURCE"
|
||||||
|
|
||||||
|
echo "==> Konfiguration"
|
||||||
|
install -d -m 0755 /etc/netmover
|
||||||
|
if [ ! -f /etc/netmover/netmover.env ]; then
|
||||||
|
install -m 0640 "$(dirname "$0")/netmover.env" /etc/netmover/netmover.env
|
||||||
|
fi
|
||||||
|
install -d -m 0755 /mnt/netmover
|
||||||
|
|
||||||
|
echo "==> systemd"
|
||||||
|
install -m 0644 "$(dirname "$0")/netmover.service" /etc/systemd/system/netmover.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable netmover >/dev/null 2>&1
|
||||||
|
systemctl restart netmover
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
systemctl --no-pager --lines=0 status netmover || true
|
||||||
|
echo
|
||||||
|
if journalctl -u netmover -n 200 --no-pager | grep -q "setup.required"; then
|
||||||
|
echo "Ersteinrichtung: Einrichtungscode aus dem Log:"
|
||||||
|
journalctl -u netmover -n 200 --no-pager | grep "setup.required" | tail -1
|
||||||
|
fi
|
||||||
|
echo "Fertig. Weboberfläche: http://127.0.0.1:8080 (README: SSH-Tunnel / Reverse Proxy)"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Umgebungsvariablen für NetMover (/etc/netmover/netmover.env).
|
||||||
|
# Nach Änderungen: systemctl restart netmover
|
||||||
|
|
||||||
|
# Nur lokal erreichbar – für Zugriff aus dem LAN einen Reverse Proxy mit TLS
|
||||||
|
# davorsetzen (README, „Reverse Proxy“) oder per SSH-Tunnel arbeiten.
|
||||||
|
NETMOVER_HOST=127.0.0.1
|
||||||
|
NETMOVER_PORT=8080
|
||||||
|
|
||||||
|
# Hinter einem Reverse Proxy mit HTTPS:
|
||||||
|
# NETMOVER_COOKIE_SECURE=true
|
||||||
|
# NETMOVER_FORWARDED_ALLOW_IPS=127.0.0.1
|
||||||
|
|
||||||
|
# NETMOVER_MOUNT_BASE=/mnt/netmover
|
||||||
|
# NETMOVER_SESSION_HOURS=12
|
||||||
|
NETMOVER_LOG_LEVEL=INFO
|
||||||
|
# logfmt (gut lesbar im Journal) oder json
|
||||||
|
NETMOVER_LOG_FORMAT=logfmt
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# NetMover als systemd-Dienst direkt auf dem Host (empfohlen).
|
||||||
|
# Installation: siehe README, Abschnitt „Installation“.
|
||||||
|
[Unit]
|
||||||
|
Description=NetMover – NFS/SMB-Mounts und zeitgesteuertes Verschieben
|
||||||
|
Documentation=https://git.wilde-server.de/florian.wilde/netmover
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target remote-fs-pre.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
# root ist nötig: mount/umount brauchen CAP_SYS_ADMIN, und Dateien auf beliebigen
|
||||||
|
# Freigaben sollen mit den richtigen Eigentümern geschrieben werden (siehe README).
|
||||||
|
User=root
|
||||||
|
EnvironmentFile=-/etc/netmover/netmover.env
|
||||||
|
ExecStart=/opt/netmover/venv/bin/netmover serve
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# Beim Stoppen werden Mounts bewusst NICHT ausgehängt.
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=20
|
||||||
|
|
||||||
|
# /var/lib/netmover (Datenbank, Schlüssel) und /run/netmover (kurzlebige
|
||||||
|
# Credentials-Dateien für mount.cifs) – beide nur für root lesbar.
|
||||||
|
StateDirectory=netmover
|
||||||
|
StateDirectoryMode=0700
|
||||||
|
RuntimeDirectory=netmover
|
||||||
|
RuntimeDirectoryMode=0700
|
||||||
|
|
||||||
|
# Härtung, soweit mit Mounten vereinbar.
|
||||||
|
# Die Optionen unten erzeugen einen eigenen Mount-Namespace für den Dienst.
|
||||||
|
# MountFlags=shared sorgt dafür, dass Mounts unter /mnt/netmover trotzdem im
|
||||||
|
# System sichtbar sind (prüfen mit: findmnt -R /mnt/netmover).
|
||||||
|
MountFlags=shared
|
||||||
|
# /usr, /boot und /etc schreibgeschützt; /mnt, /opt, /srv, /var bleiben schreibbar
|
||||||
|
# (lokale Ziele wie /opt/paperless/consume funktionieren also).
|
||||||
|
ProtectSystem=full
|
||||||
|
PrivateTmp=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
UMask=0022
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
|
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
|
||||||
|
|
||||||
__version__ = "0.1.0.dev0"
|
__version__ = "0.1.0"
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ verlieren. Ablauf für jede Datei:
|
|||||||
Größe prüfen,
|
Größe prüfen,
|
||||||
2. Metadaten setzen (mtime, Modus, Eigentümer – Fehler sind nur Hinweise),
|
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,
|
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,
|
SFTP-``rename``), außer „überschreiben“ ist ausdrücklich eingestellt,
|
||||||
5. erst danach löscht der Aufrufer die Quelle.
|
5. erst danach löscht der Aufrufer die Quelle.
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ from typing import Literal
|
|||||||
|
|
||||||
from netmover.models import CollisionPolicy
|
from netmover.models import CollisionPolicy
|
||||||
from netmover.mounting.sftp import SftpSession
|
from netmover.mounting.sftp import SftpSession
|
||||||
|
from netmover.mover.fsops import rename_noreplace
|
||||||
from netmover.mover.naming import collision_candidates
|
from netmover.mover.naming import collision_candidates
|
||||||
from netmover.timeouts import CancelToken
|
from netmover.timeouts import CancelToken
|
||||||
|
|
||||||
@@ -283,6 +285,9 @@ class LocalDestination(Destination):
|
|||||||
|
|
||||||
def _link_noreplace(self, tmp: str, final: str) -> None:
|
def _link_noreplace(self, tmp: str, final: str) -> None:
|
||||||
src, dst = self._p(tmp), self._p(final)
|
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:
|
try:
|
||||||
os.link(src, dst)
|
os.link(src, dst)
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
@@ -340,6 +345,9 @@ class LocalDestination(Destination):
|
|||||||
raise CollisionSkip
|
raise CollisionSkip
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
if rename_noreplace(src, self._p(cand)):
|
||||||
|
final = cand
|
||||||
|
break
|
||||||
os.link(src, self._p(cand))
|
os.link(src, self._p(cand))
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
if policy is CollisionPolicy.SKIP:
|
if policy is CollisionPolicy.SKIP:
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Atomares Umbenennen ohne Überschreiben.
|
||||||
|
|
||||||
|
``renameat2(..., RENAME_NOREPLACE)`` ist ein echtes ``rename`` (Watcher sehen
|
||||||
|
``IN_MOVED_TO`` wie bei ``os.replace``), scheitert aber mit ``EEXIST``, statt
|
||||||
|
eine vorhandene Datei zu ersetzen. ext4, xfs, btrfs und tmpfs können das; NFS
|
||||||
|
und CIFS meist nicht – dann liefert :func:`rename_noreplace` ``False`` und der
|
||||||
|
Aufrufer weicht auf ``link()`` + ``unlink()`` aus.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import errno
|
||||||
|
import os
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import cache
|
||||||
|
|
||||||
|
AT_FDCWD = -100
|
||||||
|
RENAME_NOREPLACE = 1
|
||||||
|
_UNSUPPORTED = frozenset({errno.EINVAL, errno.ENOSYS, errno.ENOTSUP, errno.EOPNOTSUPP, errno.EPERM})
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _renameat2() -> Callable[..., int] | None:
|
||||||
|
name = ctypes.util.find_library("c")
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
libc = ctypes.CDLL(name, use_errno=True)
|
||||||
|
fn = getattr(libc, "renameat2", None)
|
||||||
|
if fn is None:
|
||||||
|
return None
|
||||||
|
fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
||||||
|
fn.restype = ctypes.c_int
|
||||||
|
renameat2: Callable[..., int] = fn
|
||||||
|
return renameat2
|
||||||
|
|
||||||
|
|
||||||
|
def rename_noreplace(src: str | os.PathLike[str], dst: str | os.PathLike[str]) -> bool:
|
||||||
|
"""True = umbenannt. False = vom Dateisystem nicht unterstützt (nichts passiert).
|
||||||
|
``FileExistsError``, wenn ``dst`` existiert; andere Fehler als ``OSError``."""
|
||||||
|
fn = _renameat2()
|
||||||
|
if fn is None:
|
||||||
|
return False
|
||||||
|
res = fn(AT_FDCWD, os.fsencode(src), AT_FDCWD, os.fsencode(dst), RENAME_NOREPLACE)
|
||||||
|
if res == 0:
|
||||||
|
return True
|
||||||
|
err = ctypes.get_errno()
|
||||||
|
if err == errno.EEXIST:
|
||||||
|
raise FileExistsError(err, os.strerror(err), os.fsdecode(dst))
|
||||||
|
if err in _UNSUPPORTED:
|
||||||
|
return False
|
||||||
|
raise OSError(err, os.strerror(err), os.fsdecode(src))
|
||||||
+58
-6
@@ -240,6 +240,9 @@ def test_source_not_deletable_keeps_target_and_prevents_duplicate(
|
|||||||
) -> None:
|
) -> None:
|
||||||
if path == "copy":
|
if path == "copy":
|
||||||
monkeypatch.setattr(LocalDestination, "move_same_device", lambda *a, **k: None)
|
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")
|
write(src / "scan.pdf")
|
||||||
real_unlink = os.unlink
|
real_unlink = os.unlink
|
||||||
|
|
||||||
@@ -637,22 +640,24 @@ def test_local_destination_must_exist(
|
|||||||
def test_fsync_before_final_rename(
|
def test_fsync_before_final_rename(
|
||||||
engine: Engine, rule_id: int, src: Path, dst: Path, monkeypatch: pytest.MonkeyPatch
|
engine: Engine, rule_id: int, src: Path, dst: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
from netmover.mover import fsops
|
||||||
|
|
||||||
events: list[str] = []
|
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:
|
def fsync(fd: int) -> None:
|
||||||
events.append("fsync")
|
events.append("fsync")
|
||||||
real_fsync(fd)
|
real_fsync(fd)
|
||||||
|
|
||||||
def link(a: object, b: object, *args: object, **kw: object) -> None:
|
def rename(a: object, b: object) -> bool:
|
||||||
events.append(f"link:{Path(str(b)).name}")
|
events.append(f"rename:{Path(str(b)).name}")
|
||||||
real_link(a, b) # type: ignore[arg-type]
|
return real_rename(a, b) # type: ignore[arg-type]
|
||||||
|
|
||||||
monkeypatch.setattr("netmover.mover.destinations.os.fsync", fsync)
|
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")
|
write(src / "a.pdf")
|
||||||
run(engine, snap(rule_id), src, dst)
|
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")
|
@pytest.mark.usefixtures("cross_device")
|
||||||
@@ -678,3 +683,50 @@ def test_name_taken_between_check_and_link_is_never_overwritten(
|
|||||||
assert result.items[0].dst == str(dst / "scan-1.pdf")
|
assert result.items[0].dst == str(dst / "scan-1.pdf")
|
||||||
assert (dst / "scan.pdf").read_text() == "fremd"
|
assert (dst / "scan.pdf").read_text() == "fremd"
|
||||||
assert (dst / "scan-1.pdf").read_text() == "neu"
|
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")] == []
|
||||||
|
|||||||
Reference in New Issue
Block a user