feat: Anmeldung abschaltbar (vertraute Netze oder ganz); Doku 0.5.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-11 01:12:28 +02:00
co-authored by Claude Opus 5.5
parent f5ea5840d4
commit 579a2c164f
18 changed files with 357 additions and 34 deletions
+23 -1
View File
@@ -8,6 +8,27 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
## [Unreleased] ## [Unreleased]
## [0.5.0] – 2026-10-11
### Hinzugefügt
- **SFTP als Quelle** mit denselben Garantien wie bei SMB/NFS: Stabilitätsprüfung,
atomares Schreiben, Löschen der Quelle erst nach vollständiger Übertragung,
Kopier-Buchführung, Quarantäne. Verbindungsabbrüche brechen den Lauf als gestörte
Anbindung ab. Bei SFTP-Quellen mindestens 2 s Stabilitätsfenster (Zeiten nur
sekundengenau).
- **Gespeicherte Zugangsdaten** für SMB und SFTP-Passwörter (Benutzer, Passwort,
Domäne); in Anbindungen auswählbar, ein neues Passwort gilt sofort für alle.
Die Seite „Schlüssel“ heißt jetzt **„Zugangsdaten“** (Passwörter + SSH-Schlüssel);
`/keys` leitet weiter.
- **Anmeldung abschaltbar** (*Einstellungen → Anmeldung*, Bestätigung mit Passwort):
immer / nicht aus vertrauenswürdigen Netzen / nie (z. B. hinter Reverse Proxy mit
eigener Anmeldung). Deutlicher Hinweis in der Kopfzeile; CSRF-Schutz bleibt;
`netmover reset-password` schaltet die Anmeldung wieder ein.
### Geändert
- Mover intern auf eine Quell-Schnittstelle umgestellt (`LocalSource`, `SftpSource`);
bestehendes Verhalten unverändert (alle bisherigen Tests gelten weiter).
## [0.4.0] – 2026-10-11 ## [0.4.0] – 2026-10-11
### Hinzugefügt ### Hinzugefügt
@@ -213,7 +234,8 @@ Erste Version.
- 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.4.0...HEAD [Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.5.0...HEAD
[0.5.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.4.0...v0.5.0
[0.4.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.3.0...v0.4.0 [0.4.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.3.0...v0.4.0
[0.3.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.1...v0.3.0 [0.3.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.1...v0.3.0
[0.2.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.0...v0.2.1 [0.2.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.0...v0.2.1
+28 -1
View File
@@ -18,10 +18,37 @@ Zeiten in Ortszeit (Europe/Berlin).
| 2026-10-10 | 23:31 | 23:58 | 0:27 | – | Schlüsselverwaltung, Update-Skript, 0.2.x | | 2026-10-10 | 23:31 | 23:58 | 0:27 | – | Schlüsselverwaltung, Update-Skript, 0.2.x |
| 2026-10-11 | 00:13 | 00:32 | 0:19 | – | Menü, Anleitung, Roadmap, Uptime Kuma (0.3.0) | | 2026-10-11 | 00:13 | 00:32 | 0:19 | – | Menü, Anleitung, Roadmap, Uptime Kuma (0.3.0) |
| 2026-10-11 | 00:31 | 00:50 | 0:19 | – | Pausieren, Wartungsmodus, E-Mail (0.4.0) | | 2026-10-11 | 00:31 | 00:50 | 0:19 | – | Pausieren, Wartungsmodus, E-Mail (0.4.0) |
| **Summe** | | | **2:55** | | | | 2026-10-11 | 00:54 | 01:16 | 0:22 | – | SFTP-Quelle, Zugangsdaten, Anmeldung (0.5.0) |
| **Summe** | | | **3:17** | | |
--- ---
## 2026-10-11 · 00:54–01:16 · SFTP-Quelle, Zugangsdaten, Anmeldung abschaltbar (0.5.0)
**Erledigt**
- `mover/sources.py`: `Source` mit `LocalSource`/`SftpSource`; Engine greift nur noch
darüber auf die Quelle zu (Scan, frischer Zustand, Lesen, Löschen, Quarantäne).
Ziele bekommen statt `Path` ein „Readable“ (Adapter) – Destinations fast unverändert.
- `credentials.py` (vorher `keys.py`): gespeicherte Passwörter; Seite „Zugangsdaten“.
- Anmeldung: Modus immer / vertraute Netze / nie, zentral im `guard`.
- 26 neue Tests (325 gesamt); alle bisherigen Mover-Tests bestanden den Umbau
unverändert (nur ein Monkeypatch-Ziel verschoben).
**Entscheidungen**
- SFTP-Quelle: Stabilität über Größe + sekundengenaue mtime, Mindestfenster 2 s;
Verbindungsabbruch → `ENOTCONN` → Lauf abgebrochen (Anbindung gestört).
- Gespeicherte Passwörter tragen den Benutzer selbst (anders als Schlüssel, deren
Benutzer zur Anbindung gehört).
- „Passworteingabe deaktivieren“ als Login-Modus umgesetzt, mit Bestätigung durch
das Passwort, Warnhinweis und Notausgang über `reset-password`. Ein Passwort bleibt
Pflicht (Ersteinrichtung), sonst ließe sich die Anmeldung nie wieder einschalten.
**Stolpersteine**
- paramiko `prefetch()` braucht `fstat`; der Test-Server konnte es nicht – jetzt
kann er es, und die Quelle übergibt die bekannte Größe (spart einen Umlauf).
- Zwei TestClients mit eigenem Lebenszyklus starteten den Scheduler doppelt →
zweiter Client ohne Lebenszyklus auf derselben App.
## 2026-10-11 · 00:31–00:50 · Pausieren, Wartungsmodus, E-Mail (0.4.0) ## 2026-10-11 · 00:31–00:50 · Pausieren, Wartungsmodus, E-Mail (0.4.0)
**Erledigt** **Erledigt**
+1 -7
View File
@@ -6,13 +6,6 @@ stehen mit Version im [CHANGELOG](CHANGELOG.md); Details zu Entscheidungen im
Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht entschieden) Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht entschieden)
## In Arbeit (0.5.0)
| Status | Thema | Warum |
|---|---|---|
| 🔨 | **SFTP als Quelle**: Scan, Stabilitätsprüfung und Löschen der Quelle über SFTP, mit denselben Garantien wie bei SMB/NFS | Quellen, die nur per SSH erreichbar sind |
| 🔨 | **Gespeicherte Zugangsdaten** für SMB und SFTP-Passwörter, zusammen mit den SSH-Schlüsseln auf einer Seite „Zugangsdaten“ | Mehrere Freigaben mit demselben NAS-Benutzer an einer Stelle pflegen |
## Geplant ## Geplant
| Status | Thema | Warum | | Status | Thema | Warum |
@@ -35,6 +28,7 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht
| Version | Thema | | Version | Thema |
|---|---| |---|---|
| 0.5.0 | SFTP als Quelle; gespeicherte Zugangsdaten für SMB/SFTP-Passwörter (Seite „Zugangsdaten“); Anmeldung abschaltbar (vertrauenswürdige Netze / ganz) |
| 0.4.0 | Automatisch pausieren/fortsetzen bei nicht erreichbaren Anbindungen; Wartungsmodus; E-Mail-Benachrichtigung; eine Meldung pro ausgefallener Anbindung | | 0.4.0 | Automatisch pausieren/fortsetzen bei nicht erreichbaren Anbindungen; Wartungsmodus; E-Mail-Benachrichtigung; eine Meldung pro ausgefallener Anbindung |
| 0.3.0 | Neues Menü (gruppierte Seitenleiste, Statusampel, Handy-Menü); Anleitung im Repo und als Hilfe-Seite; Roadmap; Uptime Kuma (Einzel-Endpunkte, Push-Monitor) | | 0.3.0 | Neues Menü (gruppierte Seitenleiste, Statusampel, Handy-Menü); Anleitung im Repo und als Hilfe-Seite; Roadmap; Uptime Kuma (Einzel-Endpunkte, Push-Monitor) |
| 0.2.1 | Migrationen in einer Transaktion und ohne Fremdschlüsselfehler auf befüllten Datenbanken | | 0.2.1 | Migrationen in einer Transaktion und ohne Fremdschlüsselfehler auf befüllten Datenbanken |
+1 -1
View File
@@ -4,7 +4,7 @@
services: services:
netmover: netmover:
build: . build: .
image: netmover:0.4.0 image: netmover:0.5.0
container_name: netmover container_name: netmover
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
+51 -17
View File
@@ -8,21 +8,21 @@ Diese Anleitung erklärt die Bedienung. Installation, Updates und technische Det
stehen im [README](../README.md). stehen im [README](../README.md).
**Inhalt:** [Begriffe](#begriffe) · [Erste Anmeldung](#erste-anmeldung) · **Inhalt:** [Begriffe](#begriffe) · [Erste Anmeldung](#erste-anmeldung) ·
[Anbindungen](#anbindungen) · [Schlüssel](#schluessel) · [Regeln](#regeln) · [Zugangsdaten](#zugangsdaten) · [Anbindungen](#anbindungen) · [Regeln](#regeln) ·
[Probelauf](#probelauf) · [Status](#status) · [Wartungsmodus](#wartungsmodus) · [Protokoll](#protokoll) · [Probelauf](#probelauf) · [Status](#status) · [Wartungsmodus](#wartungsmodus) · [Protokoll](#protokoll) ·
[Einstellungen](#einstellungen) · [Uptime Kuma](#uptime-kuma) · [Beispiele](#beispiele) · [Häufige Fragen](#fragen) [Einstellungen](#einstellungen) · [Anmeldung](#anmeldung) · [Uptime Kuma](#uptime-kuma) · [Beispiele](#beispiele) · [Häufige Fragen](#fragen)
## Die vier Begriffe {#begriffe} ## Die vier Begriffe {#begriffe}
| Begriff | Was es ist | Beispiel | | Begriff | Was es ist | Beispiel |
|---|---|---| |---|---|---|
| **Anbindung** | Ein Ort, an den NetMover herankommt: eine SMB-Freigabe, ein NFS-Export oder ein Server per SFTP | „NAS“ = `//10.0.10.6/Scans` | | **Anbindung** | Ein Ort, an den NetMover herankommt: eine SMB-Freigabe, ein NFS-Export oder ein Server per SFTP | „NAS“ = `//10.0.10.6/Scans` |
| **Schlüssel** | Ein gespeicherter SSH-Schlüssel für SFTP-Anbindungen | „paperless-lxc“ | | **Zugangsdaten** | Gespeicherter Benutzer mit Passwort oder SSH-Schlüssel, in mehreren Anbindungen nutzbar | „NAS-Scanner“, „paperless-lxc“ |
| **Regel** | „Alle N Sekunden aus Anbindung A nach Anbindung B“ – mit Filter, Sicherheitsprüfungen und Verhalten bei gleichen Namen | „Scans → Paperless“ | | **Regel** | „Alle N Sekunden aus Anbindung A nach Anbindung B“ – mit Filter, Sicherheitsprüfungen und Verhalten bei gleichen Namen | „Scans → Paperless“ |
| **Lauf** | Eine Ausführung einer Regel; steht mit allen Dateien im Protokoll | 23:41:07, 3 Dateien verschoben | | **Lauf** | Eine Ausführung einer Regel; steht mit allen Dateien im Protokoll | 23:41:07, 3 Dateien verschoben |
Die Reihenfolge beim Einrichten ist immer dieselbe – und so ist auch das Menü Die Reihenfolge beim Einrichten ist immer dieselbe – und so ist auch das Menü
aufgebaut: **Anbindungen → (Schlüssel) → Regeln → Status und Protokoll beobachten.** aufgebaut: **(Zugangsdaten) → Anbindungen → Regeln → Status und Protokoll beobachten.**
![Statusseite](img/status.png) ![Statusseite](img/status.png)
@@ -66,7 +66,7 @@ Stabilitätsfenster dort mindestens 2 Sekunden betragen.
| **Server** | IP-Adresse oder Hostname, z. B. `10.0.10.6` | | **Server** | IP-Adresse oder Hostname, z. B. `10.0.10.6` |
| **Freigabe / Pfad** | SMB: Freigabename, optional mit Unterordner (`Scans` oder `Scans/privat`). NFS: Exportpfad (`/volume1/scans`). SFTP: absoluter Ordner auf dem Server (`/opt/paperless_data/consume`). | | **Freigabe / Pfad** | SMB: Freigabename, optional mit Unterordner (`Scans` oder `Scans/privat`). NFS: Exportpfad (`/volume1/scans`). SFTP: absoluter Ordner auf dem Server (`/opt/paperless_data/consume`). |
| **Benutzername / Passwort** | Bei SMB leer lassen für Gastzugang. Beim Bearbeiten heißt ein leeres Passwortfeld „unverändert“ – Passwörter werden nie angezeigt. | | **Benutzername / Passwort** | Bei SMB leer lassen für Gastzugang. Beim Bearbeiten heißt ein leeres Passwortfeld „unverändert“ – Passwörter werden nie angezeigt. |
| **Anmeldeart, Schlüssel** (SFTP) | SSH-Schlüssel (gespeichert oder nur für diese Anbindung) oder Passwort, siehe [Schlüssel](#schluessel). | | **Zugangsdaten / Anmeldeart** | Gespeicherte Zugangsdaten auswählen oder eigene Angaben nur für diese Anbindung; bei SFTP zusätzlich SSH-Schlüssel oder Passwort, siehe [Zugangsdaten](#zugangsdaten). |
| **uid, gid, file_mode, dir_mode** (SMB) | Wem die Dateien im eingehängten Ordner gehören. Meist leer lassen. | | **uid, gid, file_mode, dir_mode** (SMB) | Wem die Dateien im eingehängten Ordner gehören. Meist leer lassen. |
| **Zusatzoptionen** | Für Sonderfälle, z. B. `vers=3.0` oder `nohandlecache`. Nur erlaubte Optionen; Zugangsdaten setzt NetMover selbst. | | **Zusatzoptionen** | Für Sonderfälle, z. B. `vers=3.0` oder `nohandlecache`. Nur erlaubte Optionen; Zugangsdaten setzt NetMover selbst. |
| **Beim Start automatisch einhängen** | Nach einem Neustart des Servers wieder einhängen. Fällt das NAS aus, versucht NetMover es außerdem jede Minute erneut. | | **Beim Start automatisch einhängen** | Nach einem Neustart des Servers wieder einhängen. Fällt das NAS aus, versucht NetMover es außerdem jede Minute erneut. |
@@ -104,14 +104,28 @@ Es wird nie in ein leeres Verzeichnis geschrieben, nur weil die Freigabe fehlt.
Solche Regeln **warten** automatisch, bis die Anbindung zurück ist (siehe Solche Regeln **warten** automatisch, bis die Anbindung zurück ist (siehe
[Status](#status)), und laufen dann von selbst weiter. [Status](#status)), und laufen dann von selbst weiter.
## Schlüssel {#schluessel} ## Zugangsdaten {#zugangsdaten}
*Verbindungen → Schlüssel* *Verbindungen → Zugangsdaten*
Für SFTP-Ziele ist ein SSH-Schlüssel sicherer als ein Passwort. Einen Schlüssel kann Benutzer und Passwörter für Freigaben sowie SSH-Schlüssel einmal hinterlegen und in
man für mehrere Anbindungen verwenden; den Benutzernamen legt jede Anbindung fest. beliebig vielen Anbindungen auswählen. Passwörter und private Schlüssel liegen
verschlüsselt in der Datenbank und werden nie angezeigt.
![Schlüssel](img/schluessel.png) ![Zugangsdaten](img/zugangsdaten.png)
### Passwörter {#zugangsdaten-passwoerter}
Für SMB-Freigaben und SFTP-Server mit Passwort: Name, Benutzer, Passwort und
optional Domäne. In der Anbindung unter *Zugangsdaten* auswählen statt „eigene
Angaben“. **Ändern** setzt Benutzer, Domäne oder ein neues Passwort – das gilt sofort
für alle Anbindungen, die den Eintrag nutzen (praktisch, wenn das NAS-Passwort
wechselt).
### SSH-Schlüssel {#schluessel}
Für SFTP ist ein Schlüssel sicherer als ein Passwort. Einen Schlüssel kann man für
mehrere Anbindungen verwenden; den Benutzernamen legt jede Anbindung fest.
**Neuen Schlüssel erzeugen** – Namen eingeben, *Ed25519-Schlüssel erzeugen*. Dann: **Neuen Schlüssel erzeugen** – Namen eingeben, *Ed25519-Schlüssel erzeugen*. Dann:
@@ -119,17 +133,15 @@ man für mehrere Anbindungen verwenden; den Benutzernamen legt jede Anbindung fe
**authorized_keys-Zeile** kopieren. Sie beginnt mit **authorized_keys-Zeile** kopieren. Sie beginnt mit
`restrict,command="/usr/lib/openssh/sftp-server"` – damit darf der Schlüssel `restrict,command="/usr/lib/openssh/sftp-server"` – damit darf der Schlüssel
**nur SFTP**, keine Shell. **nur SFTP**, keine Shell.
2. Auf dem Zielserver an die Datei `~/.ssh/authorized_keys` des Benutzers anhängen 2. Auf dem Server an die Datei `~/.ssh/authorized_keys` des Benutzers anhängen
(Ordner `~/.ssh` mit Rechten `700`, Datei `600`, Eigentümer der Benutzer). (Ordner `~/.ssh` mit Rechten `700`, Datei `600`, Eigentümer der Benutzer).
3. In der Anbindung *Anmeldeart: SSH-Schlüssel* und den Schlüssel wählen, **Testen**. 3. In der Anbindung *Anmeldeart: SSH-Schlüssel* und den Schlüssel wählen, **Testen**.
**Vorhandenen Schlüssel importieren** – privaten Schlüssel einfügen (OpenSSH- oder **Vorhandenen Schlüssel importieren** – privaten Schlüssel einfügen (OpenSSH- oder
PEM-Format; Ed25519, ECDSA oder RSA ab 2048 Bit). Hat er eine Passphrase, diese PEM-Format; Ed25519, ECDSA oder RSA ab 2048 Bit). Hat er eine Passphrase, diese
angeben: Sie wird nur zum Entschlüsseln benutzt, der Schlüssel liegt danach angeben: Sie wird nur zum Entschlüsseln benutzt.
verschlüsselt in NetMovers Datenbank.
Private Schlüssel zeigt NetMover nie an. Ein Schlüssel lässt sich nur löschen, wenn Gespeicherte Zugangsdaten lassen sich nur löschen, wenn sie keine Anbindung mehr nutzt.
ihn keine Anbindung mehr nutzt.
## Regeln {#regeln} ## Regeln {#regeln}
@@ -319,6 +331,28 @@ Als Problem gilt: Scheduler steht, eine aktive Anbindung ist im Fehlerzustand, e
aktive Regel hatte länger als das Dreifache ihres Intervalls keinen Erfolg oder ist aktive Regel hatte länger als das Dreifache ihres Intervalls keinen Erfolg oder ist
dreimal hintereinander gescheitert. dreimal hintereinander gescheitert.
### Anmeldung {#anmeldung}
Standardmäßig fragt NetMover immer nach dem Passwort. Unter *Einstellungen →
Anmeldung* lässt sich das ändern (Bestätigung mit dem aktuellen Passwort):
| Einstellung | Wirkung |
|---|---|
| **immer** (empfohlen) | Anmeldung bei jedem Zugriff |
| **nicht aus vertrauenswürdigen Netzen** | ohne Passwort aus den eingetragenen Netzen, z. B. `10.0.10.0/24`; von überall sonst mit Passwort |
| **nie** | keine Anmeldung – nur hinter einem Reverse Proxy mit eigener Anmeldung (z. B. Authelia, Authentik) |
Solange ohne Anmeldung gearbeitet wird, steht oben gelb „ohne Anmeldung“. Der Schutz
gegen untergeschobene Formulare bleibt immer aktiv.
> NetMover läuft als root und kennt die Zugangsdaten deiner Freigaben. Wer die
> Oberfläche ohne Anmeldung erreicht, kann alles ändern. Läuft ein Reverse Proxy auf
> demselben Server, kommen alle Anfragen von `127.0.0.1` – dann `127.0.0.1` nicht als
> vertrauenswürdig eintragen und `NETMOVER_FORWARDED_ALLOW_IPS` setzen.
Zurück zur Anmeldung: dieselbe Einstellung auf „immer“, oder auf dem Server
`/opt/netmover/venv/bin/netmover reset-password` (setzt auch die Anmeldung zurück).
### Export und Import {#export-import} ### Export und Import {#export-import}
*Konfiguration exportieren* lädt Anbindungen, Regeln und Einstellungen als JSON *Konfiguration exportieren* lädt Anbindungen, Regeln und Einstellungen als JSON
@@ -331,8 +365,8 @@ deaktiviert, Zugangsdaten müssen neu eingegeben werden.
### Scanner → NAS → Paperless {#beispiel-paperless} ### Scanner → NAS → Paperless {#beispiel-paperless}
1. Anbindung **NAS** (SMB, Freigabe `Scans`), testen, einhängen. 1. Anbindung **NAS** (SMB, Freigabe `Scans`), testen, einhängen.
2. Schlüssel **paperless-lxc** erzeugen, Zeile in `authorized_keys` im Paperless-LXC 2. Unter *Zugangsdaten* den Schlüssel **paperless-lxc** erzeugen, Zeile in
eintragen. `authorized_keys` im Paperless-LXC eintragen.
3. Anbindung **Paperless** (SFTP, Pfad `/opt/paperless_data/consume`, Schlüssel 3. Anbindung **Paperless** (SFTP, Pfad `/opt/paperless_data/consume`, Schlüssel
wählen, Benutzer = der Paperless-Benutzer), testen. wählen, Benutzer = der Paperless-Benutzer), testen.
4. Regel **Scans → Paperless**: Quelle NAS, Filter `*.pdf`, Ziel Paperless, 4. Regel **Scans → Paperless**: Quelle NAS, Filter `*.pdf`, Ziel Paperless,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+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.4.0" __version__ = "0.5.0"
+5 -1
View File
@@ -64,7 +64,11 @@ def main(argv: Sequence[str] | None = None) -> int:
row = s.get(Setting, PASSWORD_KEY) row = s.get(Setting, PASSWORD_KEY)
if row is not None: if row is not None:
s.delete(row) s.delete(row)
s.commit() # Anmeldung wieder einschalten (falls abgeschaltet)
mode = s.get(Setting, "app.auth_mode")
if mode is not None:
mode.value = '"always"'
s.commit()
engine.dispose() engine.dispose()
print("Passwort gelöscht. Dienst neu starten; der Einrichtungscode steht im Log.") print("Passwort gelöscht. Dienst neu starten; der Einrichtungscode steht im Log.")
return 0 return 0
+3
View File
@@ -46,6 +46,9 @@ class AppSettings:
smtp_password_enc: str = "" smtp_password_enc: str = ""
smtp_from: str = "" smtp_from: str = ""
smtp_to: str = "" smtp_to: str = ""
# Anmeldung: "always" | "trusted" (ohne Anmeldung aus trusted_networks) | "off"
auth_mode: str = "always"
trusted_networks: str = ""
_PREFIX = "app." _PREFIX = "app."
+59 -5
View File
@@ -180,7 +180,7 @@ def flash(request: Request, message: str, kind: str = "ok") -> None:
HELP_ANCHORS = { HELP_ANCHORS = {
"status": "status", "status": "status",
"mounts": "anbindungen", "mounts": "anbindungen",
"keys": "schluessel", "keys": "zugangsdaten",
"rules": "regeln", "rules": "regeln",
"log": "protokoll", "log": "protokoll",
"settings": "einstellungen", "settings": "einstellungen",
@@ -200,7 +200,7 @@ class NavState:
def _nav_state(request: Request) -> NavState | None: def _nav_state(request: Request) -> NavState | None:
if not auth.is_logged_in(request): if not _authorized(request):
return None return None
state = nm(request) state = nm(request)
health = state.health() health = state.health()
@@ -237,7 +237,9 @@ def render(request: Request, name: str, status_code: int = 200, **ctx: Any) -> H
{ {
"csrf": auth.csrf_token(request), "csrf": auth.csrf_token(request),
"flashes": flashes, "flashes": flashes,
"logged_in": auth.is_logged_in(request), "logged_in": _authorized(request),
"session_login": auth.is_logged_in(request),
"auth_bypass": getattr(request.state, "auth_bypass", None),
"active": ctx.pop("active", ""), "active": ctx.pop("active", ""),
**ctx, **ctx,
}, },
@@ -263,13 +265,22 @@ def _form_dict(form: Any) -> dict[str, str]:
async def guard(request: Request) -> None: async def guard(request: Request) -> None:
state = nm(request) state = nm(request)
# Ein Passwort ist immer Pflicht – sonst ließe sich die Anmeldung nie wieder einschalten.
if not auth.password_is_set(state.session_factory): if not auth.password_is_set(state.session_factory):
raise auth.LoginRequired(setup=True) raise auth.LoginRequired(setup=True)
if not auth.is_logged_in(request): cfg = load_settings(state.session_factory)
bypass = auth.login_bypass(request, cfg.auth_mode, cfg.trusted_networks)
if bypass:
request.state.auth_bypass = bypass
elif not auth.is_logged_in(request):
raise auth.LoginRequired() raise auth.LoginRequired()
await auth.check_csrf(request) await auth.check_csrf(request)
def _authorized(request: Request) -> bool:
return auth.is_logged_in(request) or bool(getattr(request.state, "auth_bypass", None))
protected = APIRouter(dependencies=[Depends(guard)]) protected = APIRouter(dependencies=[Depends(guard)])
public = APIRouter() public = APIRouter()
@@ -349,8 +360,12 @@ async def setup_submit(request: Request) -> Response:
@public.get("/login", response_class=HTMLResponse) @public.get("/login", response_class=HTMLResponse)
async def login_form(request: Request) -> Response: async def login_form(request: Request) -> Response:
if not auth.password_is_set(nm(request).session_factory): state = nm(request)
if not auth.password_is_set(state.session_factory):
return redirect("/setup") return redirect("/setup")
cfg = load_settings(state.session_factory)
if auth.login_bypass(request, cfg.auth_mode, cfg.trusted_networks):
return redirect("/")
return render(request, "login.html") return render(request, "login.html")
@@ -1061,6 +1076,8 @@ async def settings_page(request: Request) -> Response:
cfg=cfg, cfg=cfg,
errors={}, errors={},
monitor_urls=_monitor_urls(request), monitor_urls=_monitor_urls(request),
client_ip=request.client.host if request.client else "",
loopback_trusted=auth.includes_loopback(cfg.trusted_networks),
) )
@@ -1216,6 +1233,43 @@ async def settings_password(request: Request) -> Response:
return redirect("/settings") return redirect("/settings")
@protected.post("/settings/auth", response_class=HTMLResponse)
async def settings_auth(request: Request) -> Response:
"""Anmeldung abschalten bzw. auf vertrauenswürdige Netze beschränken.
Nur mit dem aktuellen Passwort."""
state = nm(request)
form = _form_dict(await request.form())
if not await run_in_threadpool(
auth.verify_password, state.session_factory, form.get("current", "")
):
flash(request, "Aktuelles Passwort falsch – Anmeldung unverändert.", "error")
return redirect("/settings#anmeldung")
mode = form.get("auth_mode", auth.AUTH_ALWAYS)
networks = (form.get("trusted_networks") or "").strip()
if mode not in auth.AUTH_MODES:
flash(request, "Ungültige Auswahl.", "error")
return redirect("/settings#anmeldung")
try:
nets = auth.parse_networks(networks)
except ValueError as exc:
flash(request, str(exc), "error")
return redirect("/settings#anmeldung")
if mode == auth.AUTH_TRUSTED and not nets:
flash(request, "Bitte mindestens ein vertrauenswürdiges Netz angeben.", "error")
return redirect("/settings#anmeldung")
cfg = load_settings(state.session_factory)
cfg.auth_mode, cfg.trusted_networks = mode, networks
save_settings(state.session_factory, cfg)
event(log, "auth.mode_changed", level=30, mode=mode, networks=networks)
msgs = {
auth.AUTH_ALWAYS: "Anmeldung ist wieder immer erforderlich.",
auth.AUTH_TRUSTED: f"Ohne Anmeldung aus: {networks}. Von überall sonst mit Passwort.",
auth.AUTH_OFF: "Anmeldung abgeschaltet – NetMover ist ohne Passwort bedienbar.",
}
flash(request, msgs[mode], "info" if mode == auth.AUTH_ALWAYS else "warn")
return redirect("/settings#anmeldung")
@protected.get("/settings/export") @protected.get("/settings/export")
async def settings_export(request: Request) -> Response: async def settings_export(request: Request) -> Response:
state = nm(request) state = nm(request)
+50
View File
@@ -12,6 +12,8 @@
from __future__ import annotations from __future__ import annotations
import hmac import hmac
import ipaddress
import re
import secrets import secrets
import threading import threading
import time import time
@@ -32,6 +34,54 @@ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_hasher = PasswordHasher() _hasher = PasswordHasher()
AUTH_ALWAYS, AUTH_TRUSTED, AUTH_OFF = "always", "trusted", "off"
AUTH_MODES = (AUTH_ALWAYS, AUTH_TRUSTED, AUTH_OFF)
def parse_networks(raw: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
"""„10.0.10.0/24, 192.168.1.5“ → Netze; ``ValueError`` mit verständlicher Meldung."""
nets = []
for part in re.split(r"[,;\s]+", raw.strip()):
if not part:
continue
try:
nets.append(ipaddress.ip_network(part, strict=False))
except ValueError:
raise ValueError(f"„{part}“ ist kein gültiges Netz (z. B. 10.0.10.0/24)") from None
return nets
def login_bypass(request: Request, mode: str, trusted: str) -> str | None:
"""Grund, warum keine Anmeldung nötig ist (``"off"``/``"trusted"``), sonst ``None``.
Hinter einem Reverse Proxy ist die Client-Adresse nur dann echt, wenn
``NETMOVER_FORWARDED_ALLOW_IPS`` den Proxy kennt – sonst sähe jede Anfrage aus,
als käme sie vom Proxy selbst."""
if mode == AUTH_OFF:
return AUTH_OFF
if mode != AUTH_TRUSTED or request.client is None:
return None
try:
addr = ipaddress.ip_address(request.client.host)
except ValueError:
return None
try:
nets = parse_networks(trusted)
except ValueError:
return None
return AUTH_TRUSTED if any(addr in n for n in nets) else None
def includes_loopback(raw: str) -> bool:
try:
nets = parse_networks(raw)
except ValueError:
return False
return any(
ipaddress.ip_address("127.0.0.1") in n or ipaddress.ip_address("::1") in n for n in nets
)
class LoginRequired(Exception): class LoginRequired(Exception):
def __init__(self, setup: bool = False) -> None: def __init__(self, setup: bool = False) -> None:
self.setup = setup self.setup = setup
+3
View File
@@ -23,6 +23,7 @@
{% if nav %} {% if nav %}
<a class="health {{ 'ok' if nav.healthy else 'bad' }}" href="/" title="Gesundheitszustand"> <a class="health {{ 'ok' if nav.healthy else 'bad' }}" href="/" title="Gesundheitszustand">
<span class="dot"></span>{{ 'Alles in Ordnung' if nav.healthy else (nav.problems ~ (' Problem' if nav.problems == 1 else ' Probleme')) }}</a> <span class="dot"></span>{{ 'Alles in Ordnung' if nav.healthy else (nav.problems ~ (' Problem' if nav.problems == 1 else ' Probleme')) }}</a>
{% if auth_bypass %}<a class="health warn" href="/settings#anmeldung" title="Ohne Passwort bedienbar">{{ 'ohne Anmeldung' if auth_bypass == 'off' else 'ohne Anmeldung (vertrautes Netz)' }}</a>{% endif %}
{% if nav.maintenance %}<a class="health warn" href="/" title="Geplante Läufe sind pausiert"><span class="dot"></span>Wartungsmodus</a>{% endif %} {% if nav.maintenance %}<a class="health warn" href="/" title="Geplante Läufe sind pausiert"><span class="dot"></span>Wartungsmodus</a>{% endif %}
{% endif %} {% endif %}
<span class="spacer"></span> <span class="spacer"></span>
@@ -49,10 +50,12 @@
{{ item('help', '/help', 'Hilfe') }} {{ item('help', '/help', 'Hilfe') }}
</div> </div>
<div class="nav-foot"> <div class="nav-foot">
{% if session_login %}
<form method="post" action="/logout"> <form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf }}"> <input type="hidden" name="csrf_token" value="{{ csrf }}">
<button class="nav-button">{{ icon('logout') }}<span>Abmelden</span></button> <button class="nav-button">{{ icon('logout') }}<span>Abmelden</span></button>
</form> </form>
{% endif %}
<div class="version">NetMover {{ version }}</div> <div class="version">NetMover {{ version }}</div>
</div> </div>
</nav> </nav>
+18
View File
@@ -65,6 +65,24 @@
<button>Passwort ändern</button> <button>Passwort ändern</button>
</form> </form>
<h2 id="anmeldung">Anmeldung <a class="legend-help" href="/help#anmeldung" title="Hilfe">?</a></h2>
<form class="form card stack" method="post" action="/settings/auth" data-dynamic>
{{ csrf_field(csrf) }}
<div class="fields">
{{ select('auth_mode', 'Anmeldung erforderlich', [('always', 'immer (empfohlen)'), ('trusted', 'nicht aus vertrauten Netzen'), ('off', 'nie')], cfg.auth_mode) }}
<div class="field wide" data-show-for="auth_mode:trusted"><label for="f-nets">Vertrauenswürdige Netze</label>
<input id="f-nets" type="text" name="trusted_networks" value="{{ cfg.trusted_networks }}" placeholder="10.0.10.0/24, 192.168.1.20">
<span class="hint">Mit Komma getrennt. Deine Adresse gerade: <code>{{ client_ip }}</code>. Von allen anderen Adressen wird weiter nach dem Passwort gefragt.</span></div>
<div class="field"><label for="f-cur-auth">Aktuelles Passwort (zur Bestätigung)</label>
<input id="f-cur-auth" type="password" name="current" autocomplete="current-password" required></div>
</div>
<p class="msg warn small" data-show-for="auth_mode:off|auth_mode:trusted">NetMover läuft als root und kennt die Zugangsdaten deiner Freigaben.
Ohne Anmeldung kann jeder, der die Oberfläche erreicht, Anbindungen und Regeln ändern. „Nie“ nur hinter einem Reverse Proxy mit eigener Anmeldung (z. B. Authelia) verwenden.
Hinter einem Proxy außerdem <code>NETMOVER_FORWARDED_ALLOW_IPS</code> setzen, sonst sieht jede Anfrage aus, als käme sie vom Proxy.</p>
{% if loopback_trusted %}<p class="msg warn small">Die Liste enthält 127.0.0.1/::1. Läuft ein Reverse Proxy auf diesem Server, gilt dann <strong>jede</strong> Anfrage über den Proxy als vertrauenswürdig.</p>{% endif %}
<div class="row"><button class="primary">Anmeldung speichern</button></div>
</form>
<h2>Export / Import</h2> <h2>Export / Import</h2>
<div class="card stack"> <div class="card stack">
<p class="small muted">Export enthält Anbindungen, Regeln und Einstellungen – <strong>ohne</strong> Zugangsdaten und Webhook-URL. <p class="small muted">Export enthält Anbindungen, Regeln und Einstellungen – <strong>ohne</strong> Zugangsdaten und Webhook-URL.
+114
View File
@@ -0,0 +1,114 @@
"""0.5.0: Anmeldung abschaltbar – immer / nicht aus vertrauenswürdigen Netzen / nie."""
from __future__ import annotations
from typing import Any
import pytest
from fastapi.testclient import TestClient
from netmover.settings_store import load_settings
from netmover.web.auth import includes_loopback, parse_networks
from tests.webutil import PASSWORD, csrf_of, post
def _set_mode(client: TestClient, mode: str, nets: str = "", current: str = PASSWORD) -> Any:
return post(
client, "/settings/auth", {"auth_mode": mode, "trusted_networks": nets, "current": current}
)
def _other(client: TestClient, host: str) -> TestClient:
"""Weiterer Client für dieselbe App (ohne eigenen Lebenszyklus) mit fester Adresse."""
return TestClient(client.app, follow_redirects=False, client=(host, 51000))
@pytest.fixture
def lan_client(client: TestClient) -> TestClient:
return _other(client, "10.0.10.50")
def test_default_requires_login(client: TestClient, lan_client: TestClient) -> None:
assert lan_client.get("/").status_code == 303
def test_mode_change_needs_current_password(client: TestClient, state: Any) -> None:
_set_mode(client, "off", current="falsch")
assert load_settings(state.session_factory).auth_mode == "always"
assert "Aktuelles Passwort falsch" in client.get("/settings").text
def test_trusted_networks(
client: TestClient, lan_client: TestClient, settings: Any, state: Any
) -> None:
_set_mode(client, "trusted", "10.0.10.0/24")
r = lan_client.get("/")
assert r.status_code == 200 and "ohne Anmeldung (vertrautes Netz)" in r.text
assert "Abmelden" not in r.text
assert lan_client.get("/login").headers["location"] == "/"
# CSRF gilt weiterhin
assert lan_client.post("/maintenance").status_code == 403
token = csrf_of(lan_client.get("/").text)
assert lan_client.post("/maintenance", data={"csrf_token": token}).status_code == 303
# andere Netze brauchen weiter das Passwort
assert _other(client, "192.168.5.5").get("/").status_code == 303
def test_off_and_back_on(client: TestClient, lan_client: TestClient) -> None:
_set_mode(client, "off")
r = lan_client.get("/rules")
assert r.status_code == 200 and "ohne Anmeldung" in r.text
token = csrf_of(r.text)
lan_client.post(
"/settings/auth",
data={
"csrf_token": token,
"auth_mode": "always",
"trusted_networks": "",
"current": PASSWORD,
},
)
assert lan_client.get("/").status_code == 303
def test_setup_still_required_without_password(raw_client: TestClient, state: Any) -> None:
from netmover.settings_store import save_settings
cfg = load_settings(state.session_factory)
cfg.auth_mode = "off"
save_settings(state.session_factory, cfg)
assert raw_client.get("/").headers["location"] == "/setup"
def test_validation(client: TestClient, state: Any) -> None:
_set_mode(client, "trusted", "")
assert "mindestens ein" in client.get("/settings").text
_set_mode(client, "trusted", "10.0.10.0/24, quatsch")
assert "kein gültiges Netz" in client.get("/settings").text
assert load_settings(state.session_factory).auth_mode == "always"
_set_mode(client, "trusted", "127.0.0.1")
assert "jede</strong> Anfrage über den Proxy" in client.get("/settings").text
def test_network_parsing() -> None:
nets = parse_networks("10.0.10.0/24; 192.168.1.20 fe80::/10")
assert [str(n) for n in nets] == ["10.0.10.0/24", "192.168.1.20/32", "fe80::/10"]
assert includes_loopback("10.0.0.0/8, 127.0.0.0/8") and not includes_loopback("10.0.0.0/8")
with pytest.raises(ValueError):
parse_networks("10.0.0.0/33")
def test_reset_password_turns_login_back_on(
state: Any, settings: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
from netmover import cli
from netmover.settings_store import save_settings
from netmover.web.auth import set_password
set_password(state.session_factory, PASSWORD)
cfg = load_settings(state.session_factory)
cfg.auth_mode = "off"
save_settings(state.session_factory, cfg)
monkeypatch.setattr(cli, "get_settings", lambda: settings)
assert cli.main(["reset-password"]) == 0
assert load_settings(state.session_factory).auth_mode == "always"