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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:49:03 +02:00
14 changed files with 760 additions and 19 deletions
+8
View File
@@ -0,0 +1,8 @@
.venv
.git
data
**/__pycache__
.pytest_cache
.mypy_cache
.ruff_cache
tests
+14
View File
@@ -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
+33 -1
View File
@@ -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
View File
@@ -0,0 +1,29 @@
# NetMover im Container (Alternative zum systemd-Dienst, siehe README).
# Braucht zur Laufzeit CAP_SYS_ADMIN und ein Mount-Verzeichnis mit
# Propagation „rshared“ – sonst sieht der Host die Mounts nicht.
FROM python:3.12-slim-trixie AS build
WORKDIR /src
COPY pyproject.toml README.md ./
COPY src ./src
RUN pip wheel --no-cache-dir --wheel-dir /wheels .
FROM python:3.12-slim-trixie
RUN apt-get update \
&& apt-get install -y --no-install-recommends cifs-utils nfs-common \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /wheels /wheels
RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels
ENV NETMOVER_HOST=0.0.0.0 \
NETMOVER_PORT=8080 \
NETMOVER_DATA_DIR=/var/lib/netmover \
NETMOVER_RUN_DIR=/run/netmover \
NETMOVER_MOUNT_BASE=/mnt/netmover \
PYTHONUNBUFFERED=1
VOLUME ["/var/lib/netmover"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=4).status == 200 else 1)"
CMD ["netmover", "serve"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Florian Wilde
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+389 -10
View File
@@ -1,9 +1,364 @@
# NetMover # 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
View File
@@ -0,0 +1,37 @@
# NetMover per Docker Compose. Vorher auf dem Host:
# mkdir -p /mnt/netmover
# Die Weboberfläche ist nur auf dem Host selbst erreichbar (127.0.0.1:8080).
services:
netmover:
build: .
image: netmover:0.1.0
container_name: netmover
restart: unless-stopped
cap_add:
- SYS_ADMIN # mount/umount
- DAC_READ_SEARCH # mount.cifs liest die Credentials-Datei
security_opt:
- apparmor:unconfined # Dockers AppArmor-Profil verbietet mount
ports:
- "127.0.0.1:8080:8080"
environment:
NETMOVER_LOG_LEVEL: INFO
# Hinter einem Reverse Proxy mit HTTPS:
# NETMOVER_COOKIE_SECURE: "true"
# NETMOVER_FORWARDED_ALLOW_IPS: "172.16.0.0/12"
volumes:
- netmover-data:/var/lib/netmover
# WICHTIG: rshared – sonst bleiben die Mounts im Container gefangen und
# weder der Host noch andere Container sehen sie.
- type: bind
source: /mnt/netmover
target: /mnt/netmover
bind:
propagation: rshared
# Lokale Ziele (z. B. Paperless-Consume-Ordner) zusätzlich einbinden:
# - /opt/paperless/consume:/opt/paperless/consume
tmpfs:
- /run/netmover:mode=0700
volumes:
netmover-data:
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# NetMover auf Debian 13 installieren bzw. aktualisieren (als root ausführen).
#
# ./deploy/install.sh # aus diesem Verzeichnis installieren
# ./deploy/install.sh 'git+https://git.wilde-server.de/florian.wilde/netmover.git@v0.1.0'
set -eu
SOURCE="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
PREFIX=/opt/netmover
if [ "$(id -u)" -ne 0 ]; then
echo "Bitte als root ausführen." >&2
exit 1
fi
echo "==> Pakete"
apt-get update -qq
apt-get install -y -qq python3 python3-venv cifs-utils nfs-common >/dev/null
echo "==> Virtuelle Umgebung in $PREFIX/venv"
install -d -m 0755 "$PREFIX"
[ -x "$PREFIX/venv/bin/python" ] || python3 -m venv "$PREFIX/venv"
"$PREFIX/venv/bin/pip" install -q --upgrade pip
"$PREFIX/venv/bin/pip" install -q --upgrade "$SOURCE"
echo "==> Konfiguration"
install -d -m 0755 /etc/netmover
if [ ! -f /etc/netmover/netmover.env ]; then
install -m 0640 "$(dirname "$0")/netmover.env" /etc/netmover/netmover.env
fi
install -d -m 0755 /mnt/netmover
echo "==> systemd"
install -m 0644 "$(dirname "$0")/netmover.service" /etc/systemd/system/netmover.service
systemctl daemon-reload
systemctl enable netmover >/dev/null 2>&1
systemctl restart netmover
sleep 2
systemctl --no-pager --lines=0 status netmover || true
echo
if journalctl -u netmover -n 200 --no-pager | grep -q "setup.required"; then
echo "Ersteinrichtung: Einrichtungscode aus dem Log:"
journalctl -u netmover -n 200 --no-pager | grep "setup.required" | tail -1
fi
echo "Fertig. Weboberfläche: http://127.0.0.1:8080 (README: SSH-Tunnel / Reverse Proxy)"
+17
View File
@@ -0,0 +1,17 @@
# Umgebungsvariablen für NetMover (/etc/netmover/netmover.env).
# Nach Änderungen: systemctl restart netmover
# Nur lokal erreichbar – für Zugriff aus dem LAN einen Reverse Proxy mit TLS
# davorsetzen (README, „Reverse Proxy“) oder per SSH-Tunnel arbeiten.
NETMOVER_HOST=127.0.0.1
NETMOVER_PORT=8080
# Hinter einem Reverse Proxy mit HTTPS:
# NETMOVER_COOKIE_SECURE=true
# NETMOVER_FORWARDED_ALLOW_IPS=127.0.0.1
# NETMOVER_MOUNT_BASE=/mnt/netmover
# NETMOVER_SESSION_HOURS=12
NETMOVER_LOG_LEVEL=INFO
# logfmt (gut lesbar im Journal) oder json
NETMOVER_LOG_FORMAT=logfmt
+45
View File
@@ -0,0 +1,45 @@
# NetMover als systemd-Dienst direkt auf dem Host (empfohlen).
# Installation: siehe README, Abschnitt „Installation“.
[Unit]
Description=NetMover – NFS/SMB-Mounts und zeitgesteuertes Verschieben
Documentation=https://git.wilde-server.de/florian.wilde/netmover
Wants=network-online.target
After=network-online.target remote-fs-pre.target
[Service]
Type=exec
# root ist nötig: mount/umount brauchen CAP_SYS_ADMIN, und Dateien auf beliebigen
# Freigaben sollen mit den richtigen Eigentümern geschrieben werden (siehe README).
User=root
EnvironmentFile=-/etc/netmover/netmover.env
ExecStart=/opt/netmover/venv/bin/netmover serve
Restart=on-failure
RestartSec=5
# Beim Stoppen werden Mounts bewusst NICHT ausgehängt.
KillMode=mixed
TimeoutStopSec=20
# /var/lib/netmover (Datenbank, Schlüssel) und /run/netmover (kurzlebige
# Credentials-Dateien für mount.cifs) – beide nur für root lesbar.
StateDirectory=netmover
StateDirectoryMode=0700
RuntimeDirectory=netmover
RuntimeDirectoryMode=0700
# Härtung, soweit mit Mounten vereinbar.
# Die Optionen unten erzeugen einen eigenen Mount-Namespace für den Dienst.
# MountFlags=shared sorgt dafür, dass Mounts unter /mnt/netmover trotzdem im
# System sichtbar sind (prüfen mit: findmnt -R /mnt/netmover).
MountFlags=shared
# /usr, /boot und /etc schreibgeschützt; /mnt, /opt, /srv, /var bleiben schreibbar
# (lokale Ziele wie /opt/paperless/consume funktionieren also).
ProtectSystem=full
PrivateTmp=yes
NoNewPrivileges=yes
ProtectControlGroups=yes
LockPersonality=yes
RestrictRealtime=yes
UMask=0022
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -1,3 +1,3 @@
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
__version__ = "0.1.0.dev0" __version__ = "0.1.0"
+9 -1
View File
@@ -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:
+53
View File
@@ -0,0 +1,53 @@
"""Atomares Umbenennen ohne Überschreiben.
``renameat2(..., RENAME_NOREPLACE)`` ist ein echtes ``rename`` (Watcher sehen
``IN_MOVED_TO`` wie bei ``os.replace``), scheitert aber mit ``EEXIST``, statt
eine vorhandene Datei zu ersetzen. ext4, xfs, btrfs und tmpfs können das; NFS
und CIFS meist nicht – dann liefert :func:`rename_noreplace` ``False`` und der
Aufrufer weicht auf ``link()`` + ``unlink()`` aus.
"""
from __future__ import annotations
import ctypes
import ctypes.util
import errno
import os
from collections.abc import Callable
from functools import cache
AT_FDCWD = -100
RENAME_NOREPLACE = 1
_UNSUPPORTED = frozenset({errno.EINVAL, errno.ENOSYS, errno.ENOTSUP, errno.EOPNOTSUPP, errno.EPERM})
@cache
def _renameat2() -> Callable[..., int] | None:
name = ctypes.util.find_library("c")
if name is None:
return None
libc = ctypes.CDLL(name, use_errno=True)
fn = getattr(libc, "renameat2", None)
if fn is None:
return None
fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
fn.restype = ctypes.c_int
renameat2: Callable[..., int] = fn
return renameat2
def rename_noreplace(src: str | os.PathLike[str], dst: str | os.PathLike[str]) -> bool:
"""True = umbenannt. False = vom Dateisystem nicht unterstützt (nichts passiert).
``FileExistsError``, wenn ``dst`` existiert; andere Fehler als ``OSError``."""
fn = _renameat2()
if fn is None:
return False
res = fn(AT_FDCWD, os.fsencode(src), AT_FDCWD, os.fsencode(dst), RENAME_NOREPLACE)
if res == 0:
return True
err = ctypes.get_errno()
if err == errno.EEXIST:
raise FileExistsError(err, os.strerror(err), os.fsdecode(dst))
if err in _UNSUPPORTED:
return False
raise OSError(err, os.strerror(err), os.fsdecode(src))
+58 -6
View File
@@ -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")] == []