diff --git a/CHANGELOG.md b/CHANGELOG.md index 530b599..c22d803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`. ## [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 ### Hinzugefügt @@ -213,7 +234,8 @@ Erste Version. - Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt. -[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.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.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 diff --git a/DEVLOG.md b/DEVLOG.md index c655fdb..0b36104 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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-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) | -| **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) **Erledigt** diff --git a/ROADMAP.md b/ROADMAP.md index c01340c..fc25e98 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,13 +6,6 @@ stehen mit Version im [CHANGELOG](CHANGELOG.md); Details zu Entscheidungen im 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 | Status | Thema | Warum | @@ -35,6 +28,7 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht | 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.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 | diff --git a/compose.yml b/compose.yml index bbf4b16..12ffedb 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: netmover: build: . - image: netmover:0.4.0 + image: netmover:0.5.0 container_name: netmover restart: unless-stopped cap_add: diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 13b1806..5336a82 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -8,21 +8,21 @@ Diese Anleitung erklärt die Bedienung. Installation, Updates und technische Det stehen im [README](../README.md). **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) · -[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} | 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` | -| **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“ | | **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ü -aufgebaut: **Anbindungen → (Schlüssel) → Regeln → Status und Protokoll beobachten.** +aufgebaut: **(Zugangsdaten) → Anbindungen → Regeln → Status und Protokoll beobachten.** ![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` | | **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. | -| **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. | | **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. | @@ -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 [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 -man für mehrere Anbindungen verwenden; den Benutzernamen legt jede Anbindung fest. +Benutzer und Passwörter für Freigaben sowie SSH-Schlüssel einmal hinterlegen und in +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: @@ -119,17 +133,15 @@ man für mehrere Anbindungen verwenden; den Benutzernamen legt jede Anbindung fe **authorized_keys-Zeile** kopieren. Sie beginnt mit `restrict,command="/usr/lib/openssh/sftp-server"` – damit darf der Schlüssel **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). 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 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 -verschlüsselt in NetMovers Datenbank. +angeben: Sie wird nur zum Entschlüsseln benutzt. -Private Schlüssel zeigt NetMover nie an. Ein Schlüssel lässt sich nur löschen, wenn -ihn keine Anbindung mehr nutzt. +Gespeicherte Zugangsdaten lassen sich nur löschen, wenn sie keine Anbindung mehr nutzt. ## 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 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} *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} 1. Anbindung **NAS** (SMB, Freigabe `Scans`), testen, einhängen. -2. Schlüssel **paperless-lxc** erzeugen, Zeile in `authorized_keys` im Paperless-LXC - eintragen. +2. Unter *Zugangsdaten* den Schlüssel **paperless-lxc** erzeugen, Zeile in + `authorized_keys` im Paperless-LXC eintragen. 3. Anbindung **Paperless** (SFTP, Pfad `/opt/paperless_data/consume`, Schlüssel wählen, Benutzer = der Paperless-Benutzer), testen. 4. Regel **Scans → Paperless**: Quelle NAS, Filter `*.pdf`, Ziel Paperless, diff --git a/docs/img/anbindung.png b/docs/img/anbindung.png index 506a8d4..bdeb690 100644 Binary files a/docs/img/anbindung.png and b/docs/img/anbindung.png differ diff --git a/docs/img/regel.png b/docs/img/regel.png index 2572df5..bc7ff98 100644 Binary files a/docs/img/regel.png and b/docs/img/regel.png differ diff --git a/docs/img/schluessel.png b/docs/img/schluessel.png deleted file mode 100644 index 0270753..0000000 Binary files a/docs/img/schluessel.png and /dev/null differ diff --git a/docs/img/status.png b/docs/img/status.png index 3755365..92f66fd 100644 Binary files a/docs/img/status.png and b/docs/img/status.png differ diff --git a/docs/img/zugangsdaten.png b/docs/img/zugangsdaten.png new file mode 100644 index 0000000..13b226e Binary files /dev/null and b/docs/img/zugangsdaten.png differ diff --git a/src/netmover/__init__.py b/src/netmover/__init__.py index b5b33d7..570a308 100644 --- a/src/netmover/__init__.py +++ b/src/netmover/__init__.py @@ -1,3 +1,3 @@ """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/src/netmover/cli.py b/src/netmover/cli.py index b7fe7de..4319076 100644 --- a/src/netmover/cli.py +++ b/src/netmover/cli.py @@ -64,7 +64,11 @@ def main(argv: Sequence[str] | None = None) -> int: row = s.get(Setting, PASSWORD_KEY) if row is not None: 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() print("Passwort gelöscht. Dienst neu starten; der Einrichtungscode steht im Log.") return 0 diff --git a/src/netmover/settings_store.py b/src/netmover/settings_store.py index a096015..4ddde1b 100644 --- a/src/netmover/settings_store.py +++ b/src/netmover/settings_store.py @@ -46,6 +46,9 @@ class AppSettings: smtp_password_enc: str = "" smtp_from: str = "" smtp_to: str = "" + # Anmeldung: "always" | "trusted" (ohne Anmeldung aus trusted_networks) | "off" + auth_mode: str = "always" + trusted_networks: str = "" _PREFIX = "app." diff --git a/src/netmover/web/app.py b/src/netmover/web/app.py index b7d88ac..0193037 100644 --- a/src/netmover/web/app.py +++ b/src/netmover/web/app.py @@ -180,7 +180,7 @@ def flash(request: Request, message: str, kind: str = "ok") -> None: HELP_ANCHORS = { "status": "status", "mounts": "anbindungen", - "keys": "schluessel", + "keys": "zugangsdaten", "rules": "regeln", "log": "protokoll", "settings": "einstellungen", @@ -200,7 +200,7 @@ class NavState: def _nav_state(request: Request) -> NavState | None: - if not auth.is_logged_in(request): + if not _authorized(request): return None state = nm(request) 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), "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", ""), **ctx, }, @@ -263,13 +265,22 @@ def _form_dict(form: Any) -> dict[str, str]: async def guard(request: Request) -> None: 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): 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() 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)]) public = APIRouter() @@ -349,8 +360,12 @@ async def setup_submit(request: Request) -> Response: @public.get("/login", response_class=HTMLResponse) 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") + cfg = load_settings(state.session_factory) + if auth.login_bypass(request, cfg.auth_mode, cfg.trusted_networks): + return redirect("/") return render(request, "login.html") @@ -1061,6 +1076,8 @@ async def settings_page(request: Request) -> Response: cfg=cfg, errors={}, 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") +@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") async def settings_export(request: Request) -> Response: state = nm(request) diff --git a/src/netmover/web/auth.py b/src/netmover/web/auth.py index 68f219b..26e35e5 100644 --- a/src/netmover/web/auth.py +++ b/src/netmover/web/auth.py @@ -12,6 +12,8 @@ from __future__ import annotations import hmac +import ipaddress +import re import secrets import threading import time @@ -32,6 +34,54 @@ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) _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): def __init__(self, setup: bool = False) -> None: self.setup = setup diff --git a/src/netmover/web/templates/base.html b/src/netmover/web/templates/base.html index 5de82bd..bcd582e 100644 --- a/src/netmover/web/templates/base.html +++ b/src/netmover/web/templates/base.html @@ -23,6 +23,7 @@ {% if nav %} {{ 'Alles in Ordnung' if nav.healthy else (nav.problems ~ (' Problem' if nav.problems == 1 else ' Probleme')) }} + {% if auth_bypass %}{{ 'ohne Anmeldung' if auth_bypass == 'off' else 'ohne Anmeldung (vertrautes Netz)' }}{% endif %} {% if nav.maintenance %}Wartungsmodus{% endif %} {% endif %} @@ -49,10 +50,12 @@ {{ item('help', '/help', 'Hilfe') }} diff --git a/src/netmover/web/templates/settings.html b/src/netmover/web/templates/settings.html index f7a52a7..a1d0003 100644 --- a/src/netmover/web/templates/settings.html +++ b/src/netmover/web/templates/settings.html @@ -65,6 +65,24 @@ +

Anmeldung ?

+
+ {{ csrf_field(csrf) }} +
+ {{ select('auth_mode', 'Anmeldung erforderlich', [('always', 'immer (empfohlen)'), ('trusted', 'nicht aus vertrauten Netzen'), ('off', 'nie')], cfg.auth_mode) }} +
+ + Mit Komma getrennt. Deine Adresse gerade: {{ client_ip }}. Von allen anderen Adressen wird weiter nach dem Passwort gefragt.
+
+
+
+

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 NETMOVER_FORWARDED_ALLOW_IPS setzen, sonst sieht jede Anfrage aus, als käme sie vom Proxy.

+ {% if loopback_trusted %}

Die Liste enthält 127.0.0.1/::1. Läuft ein Reverse Proxy auf diesem Server, gilt dann jede Anfrage über den Proxy als vertrauenswürdig.

{% endif %} +
+
+

Export / Import

Export enthält Anbindungen, Regeln und Einstellungen – ohne Zugangsdaten und Webhook-URL. diff --git a/tests/test_auth_modes.py b/tests/test_auth_modes.py new file mode 100644 index 0000000..20b1ef6 --- /dev/null +++ b/tests/test_auth_modes.py @@ -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 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"