diff --git a/CHANGELOG.md b/CHANGELOG.md index c22d803..ebab0bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`. ## [Unreleased] +## [0.6.0] – 2026-10-11 + +### Geändert +- **Anmeldung je Netz konfigurierbar** (*Einstellungen → Anmeldung*): „Ohne Passwort + aus diesen Netzen“ und „Mit Passwort aus diesen Netzen“ (leer = alle übrigen). + Ist die zweite Liste ausgefüllt, werden alle anderen Netze abgewiesen (403, auch die + Anmeldeseite); `/healthz` und statische Dateien bleiben erreichbar. Ersetzt die + Auswahl „immer / vertraute Netze / nie“ aus 0.5.0 (`0.0.0.0/0, ::/0` = ganz aus; + gespeicherter Modus „nie“ gilt weiter). +- Schutz vor Aussperren: Eine Einstellung, mit der die eigene Adresse keinen Zugang + mehr hätte, wird nicht gespeichert. `netmover reset-password` hebt auch Netz-Sperren auf. + ## [0.5.0] – 2026-10-11 ### Hinzugefügt @@ -234,7 +246,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.5.0...HEAD +[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.6.0...HEAD +[0.6.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.5.0...v0.6.0 [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 diff --git a/DEVLOG.md b/DEVLOG.md index 0b36104..e061a1f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -19,10 +19,26 @@ Zeiten in Ortszeit (Europe/Berlin). | 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:54 | 01:16 | 0:22 | – | SFTP-Quelle, Zugangsdaten, Anmeldung (0.5.0) | -| **Summe** | | | **3:17** | | | +| 2026-10-11 | 08:43 | 08:55 | 0:12 | – | Anmeldung je Netz (0.6.0) | +| **Summe** | | | **3:29** | | | --- +## 2026-10-11 · 08:43–08:55 · Anmeldung je Netz (0.6.0) + +**Anlass** – Wunsch: Anmeldung abschalten, und zwar konfigurierbar, aus welchen Netzen +mit und ohne Passwort. Zugriffe kamen aus 10.0.10.0/24 und 10.1.5.0/24 (Access-Log). + +**Erledigt** +- `auth.access_decision()` als einzige Entscheidung (ohne PW / mit PW / abgewiesen), + benutzt von Middleware (403 für fremde Netze), Guard und Anmeldeseite. +- Schutz vor Aussperren beim Speichern; `reset-password` hebt Sperren auf. +- 19 Tests für die Anmeldung (336 gesamt). + +**Entscheidungen** +- `/healthz` und `/static` nie sperren – Uptime Kuma kann in einem anderen Netz stehen. +- Nicht-IP-Clients (z. B. Unix-Socket) gelten als „in keinem Netz“. + ## 2026-10-11 · 00:54–01:16 · SFTP-Quelle, Zugangsdaten, Anmeldung abschaltbar (0.5.0) **Erledigt** diff --git a/ROADMAP.md b/ROADMAP.md index fc25e98..947a912 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,6 +28,7 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht | Version | Thema | |---|---| +| 0.6.0 | Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen, Schutz vor Aussperren | | 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) | diff --git a/compose.yml b/compose.yml index 12ffedb..4b8ac39 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: netmover: build: . - image: netmover:0.5.0 + image: netmover:0.6.0 container_name: netmover restart: unless-stopped cap_add: diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 5336a82..55fc328 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -333,25 +333,33 @@ 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): +Unter *Einstellungen → Anmeldung* legst du fest, **aus welchen Netzen** es mit und +ohne Passwort geht (Bestätigung mit dem aktuellen Passwort): -| Einstellung | Wirkung | +| Feld | 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) | +| **Ohne Passwort aus diesen Netzen** | z. B. `10.0.10.0/24, 10.1.5.0/24` – von dort keine Anmeldung. Leer = niemand. `0.0.0.0/0, ::/0` = Anmeldung ganz aus. | +| **Mit Passwort aus diesen Netzen** | Leer = **alle übrigen** Netze mit Passwort. Ausgefüllt = nur von dort mit Passwort, **alle anderen Netze werden abgewiesen** (auch die Anmeldeseite). | -Solange ohne Anmeldung gearbeitet wird, steht oben gelb „ohne Anmeldung“. Der Schutz -gegen untergeschobene Formulare bleibt immer aktiv. +Beispiel: Ohne Passwort aus dem Heimnetz `10.0.10.0/24`, mit Passwort aus dem +VPN-Netz `10.8.0.0/24`, alles andere gesperrt. + +- Einzelne Adressen gehen auch (`192.168.1.20`). +- Die Seite zeigt deine aktuelle Adresse. Eine Einstellung, mit der du dich selbst + aussperren würdest, wird nicht gespeichert. +- `/healthz` bleibt für die Überwachung von überall erreichbar. +- Solange du ohne Passwort arbeitest, 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. +> Oberfläche ohne Passwort erreicht, kann alles ändern – Netze „ohne Passwort“ daher +> so eng wie möglich wählen. Läuft ein Reverse Proxy auf demselben Server, kommen +> alle Anfragen von `127.0.0.1`: dann `127.0.0.1` nicht 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). +Ausgesperrt oder Passwort vergessen? Auf dem Server +`/opt/netmover/venv/bin/netmover reset-password` – setzt das Passwort zurück, schaltet +die Anmeldung überall wieder ein und hebt Netz-Sperren auf. ### Export und Import {#export-import} diff --git a/src/netmover/__init__.py b/src/netmover/__init__.py index 570a308..7ff7d1e 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.5.0" +__version__ = "0.6.0" diff --git a/src/netmover/cli.py b/src/netmover/cli.py index 4319076..d7ca8f1 100644 --- a/src/netmover/cli.py +++ b/src/netmover/cli.py @@ -64,10 +64,15 @@ def main(argv: Sequence[str] | None = None) -> int: row = s.get(Setting, PASSWORD_KEY) if row is not None: s.delete(row) - # Anmeldung wieder einschalten (falls abgeschaltet) - mode = s.get(Setting, "app.auth_mode") - if mode is not None: - mode.value = '"always"' + # Anmeldung wieder überall einschalten und Netz-Sperren aufheben + for key, value in ( + ("app.auth_mode", '"always"'), + ("app.trusted_networks", '""'), + ("app.allowed_networks", '""'), + ): + setting = s.get(Setting, key) + if setting is not None: + setting.value = value s.commit() engine.dispose() print("Passwort gelöscht. Dienst neu starten; der Einrichtungscode steht im Log.") diff --git a/src/netmover/settings_store.py b/src/netmover/settings_store.py index 4ddde1b..4d3ec08 100644 --- a/src/netmover/settings_store.py +++ b/src/netmover/settings_store.py @@ -46,9 +46,12 @@ class AppSettings: smtp_password_enc: str = "" smtp_from: str = "" smtp_to: str = "" - # Anmeldung: "always" | "trusted" (ohne Anmeldung aus trusted_networks) | "off" + # Anmeldung: Netze ohne Passwort (trusted_networks), Netze mit Passwort + # (allowed_networks, leer = alle übrigen); alle anderen Netze werden abgewiesen. + # auth_mode "off" = ohne Anmeldung von überall (aus 0.5.0, weiterhin gültig). auth_mode: str = "always" trusted_networks: str = "" + allowed_networks: str = "" _PREFIX = "app." diff --git a/src/netmover/web/app.py b/src/netmover/web/app.py index 0193037..2881995 100644 --- a/src/netmover/web/app.py +++ b/src/netmover/web/app.py @@ -269,7 +269,7 @@ async def guard(request: Request) -> None: if not auth.password_is_set(state.session_factory): raise auth.LoginRequired(setup=True) cfg = load_settings(state.session_factory) - bypass = auth.login_bypass(request, cfg.auth_mode, cfg.trusted_networks) + bypass = auth.login_bypass(request, cfg.auth_mode, cfg.trusted_networks, cfg.allowed_networks) if bypass: request.state.auth_bypass = bypass elif not auth.is_logged_in(request): @@ -364,7 +364,7 @@ async def login_form(request: Request) -> Response: 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): + if auth.login_bypass(request, cfg.auth_mode, cfg.trusted_networks, cfg.allowed_networks): return redirect("/") return render(request, "login.html") @@ -1235,39 +1235,54 @@ async def settings_password(request: Request) -> Response: @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.""" + """Netze ohne / mit Passwort festlegen. Nur mit dem aktuellen Passwort und nie so, + dass man sich mit der eigenen Adresse aussperrt.""" state = nm(request) form = _form_dict(await request.form()) + back = redirect("/settings#anmeldung") 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") + return back + trusted = (form.get("trusted_networks") or "").strip() + allowed = (form.get("allowed_networks") or "").strip() try: - nets = auth.parse_networks(networks) + auth.parse_networks(trusted) + auth.parse_networks(allowed) 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") + return back + me = auth.client_host(request) + if auth.access_decision(me, auth.AUTH_ALWAYS, trusted, allowed) == auth.ACCESS_DENY: + flash( + request, + f"Nicht gespeichert: Damit wärst du selbst ausgesperrt (deine Adresse {me}). " + "Trage dein Netz bei „mit“ oder „ohne Passwort“ ein.", + "error", + ) + return back cfg = load_settings(state.session_factory) - cfg.auth_mode, cfg.trusted_networks = mode, networks + cfg.trusted_networks, cfg.allowed_networks = trusted, allowed + cfg.auth_mode = auth.AUTH_TRUSTED if trusted else auth.AUTH_ALWAYS 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") + event( + log, + "auth.access_changed", + level=30, + without_password=trusted, + with_password=allowed or "alle übrigen", + ) + parts = [f"ohne Passwort aus: {trusted}" if trusted else "überall mit Passwort"] + parts.append( + f"mit Passwort nur aus: {allowed} – alle anderen Netze abgewiesen" + if allowed + else "alle übrigen Netze mit Passwort" + ) + flash( + request, "Anmeldung gespeichert: " + "; ".join(parts) + ".", "warn" if trusted else "info" + ) + return back @protected.get("/settings/export") @@ -1371,6 +1386,35 @@ def create_app(settings: Settings | None = None, state: NetMover | None = None) openapi_url=None, ) + @app.middleware("http") + async def network_access( + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + path = request.url.path + if not (path.startswith("/healthz") or path.startswith("/static/")): + app_state = getattr(request.app.state, "nm", None) + if app_state is not None: + cfg = load_settings(app_state.session_factory) + decision = auth.access_decision( + auth.client_host(request), + cfg.auth_mode, + cfg.trusted_networks, + cfg.allowed_networks, + ) + if decision == auth.ACCESS_DENY: + event( + log, + "auth.denied_network", + level=30, + client=auth.client_host(request) or "?", + ) + return HTMLResponse( + "
Zugriff aus diesem Netz ist nicht erlaubt.
", + status_code=403, + ) + return await call_next(request) + @app.middleware("http") async def security_headers( request: Request, call_next: Callable[[Request], Awaitable[Response]] diff --git a/src/netmover/web/auth.py b/src/netmover/web/auth.py index 26e35e5..f4849c2 100644 --- a/src/netmover/web/auth.py +++ b/src/netmover/web/auth.py @@ -51,25 +51,59 @@ def parse_networks(raw: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Netwo return nets -def login_bypass(request: Request, mode: str, trusted: str) -> str | None: - """Grund, warum keine Anmeldung nötig ist (``"off"``/``"trusted"``), sonst ``None``. +ACCESS_NOPW, ACCESS_PW, ACCESS_DENY = "nopw", "pw", "deny" + + +def _address(host: str | None) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: + if not host: + return None + try: + return ipaddress.ip_address(host) + except ValueError: + return None + + +def _networks_or_empty(raw: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]: + try: + return parse_networks(raw) + except ValueError: + return [] # gespeicherte Werte sind beim Speichern geprüft + + +def access_decision(host: str | None, mode: str, trusted: str, allowed: str) -> str: + """Wie darf diese Adresse auf die Oberfläche zugreifen? + + 1. aus ``trusted`` (oder Modus „off“): **ohne Passwort**, + 2. ``allowed`` leer oder Adresse darin: **mit Passwort**, + 3. sonst: **abgewiesen**. 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 ACCESS_NOPW + addr = _address(host) + if addr is not None and any(addr in n for n in _networks_or_empty(trusted)): + return ACCESS_NOPW + allowed_nets = _networks_or_empty(allowed) + if allowed_nets and (addr is None or not any(addr in n for n in allowed_nets)): + return ACCESS_DENY + return ACCESS_PW + + +def client_host(request: Request) -> str | None: + return request.client.host if request.client else None + + +def login_bypass(request: Request, mode: str, trusted: str, allowed: str = "") -> str | None: + """Grund, warum keine Anmeldung nötig ist (``"off"``/``"trusted"``), sonst ``None``.""" + if access_decision(client_host(request), mode, trusted, allowed) != ACCESS_NOPW: 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 + return AUTH_OFF if mode == AUTH_OFF or _covers_everything(trusted) else AUTH_TRUSTED + + +def _covers_everything(raw: str) -> bool: + return any(n.prefixlen == 0 for n in _networks_or_empty(raw)) def includes_loopback(raw: str) -> bool: diff --git a/src/netmover/web/templates/settings.html b/src/netmover/web/templates/settings.html index a1d0003..f0cfc5f 100644 --- a/src/netmover/web/templates/settings.html +++ b/src/netmover/web/templates/settings.html @@ -66,20 +66,23 @@