feat: Anmeldung je Netz – ohne Passwort, mit Passwort oder abgewiesen

Zwei Netzlisten statt Modusauswahl; fremde Netze werden abgewiesen, wenn
„mit Passwort“ ausgefüllt ist; /healthz bleibt erreichbar; Schutz vor
Aussperren; reset-password hebt Sperren auf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-11 08:46:43 +02:00
co-authored by Claude Opus 5.5
parent 579a2c164f
commit 2652406ced
12 changed files with 315 additions and 135 deletions
+14 -1
View File
@@ -8,6 +8,18 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
## [Unreleased] ## [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 ## [0.5.0] – 2026-10-11
### Hinzugefügt ### Hinzugefügt
@@ -234,7 +246,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.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.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
+17 -1
View File
@@ -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: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) |
| 2026-10-11 | 00:54 | 01:16 | 0:22 | – | SFTP-Quelle, Zugangsdaten, Anmeldung (0.5.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) ## 2026-10-11 · 00:54–01:16 · SFTP-Quelle, Zugangsdaten, Anmeldung abschaltbar (0.5.0)
**Erledigt** **Erledigt**
+1
View File
@@ -28,6 +28,7 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht
| Version | Thema | | 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.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) |
+1 -1
View File
@@ -4,7 +4,7 @@
services: services:
netmover: netmover:
build: . build: .
image: netmover:0.5.0 image: netmover:0.6.0
container_name: netmover container_name: netmover
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
+21 -13
View File
@@ -333,25 +333,33 @@ dreimal hintereinander gescheitert.
### Anmeldung {#anmeldung} ### Anmeldung {#anmeldung}
Standardmäßig fragt NetMover immer nach dem Passwort. Unter *Einstellungen → Unter *Einstellungen → Anmeldung* legst du fest, **aus welchen Netzen** es mit und
Anmeldung* lässt sich das ändern (Bestätigung mit dem aktuellen Passwort): ohne Passwort geht (Bestätigung mit dem aktuellen Passwort):
| Einstellung | Wirkung | | Feld | Wirkung |
|---|---| |---|---|
| **immer** (empfohlen) | Anmeldung bei jedem Zugriff | | **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. |
| **nicht aus vertrauenswürdigen Netzen** | ohne Passwort aus den eingetragenen Netzen, z. B. `10.0.10.0/24`; von überall sonst mit Passwort | | **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). |
| **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 Beispiel: Ohne Passwort aus dem Heimnetz `10.0.10.0/24`, mit Passwort aus dem
gegen untergeschobene Formulare bleibt immer aktiv. 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 > 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 > Oberfläche ohne Passwort erreicht, kann alles ändern – Netze „ohne Passwort“ daher
> demselben Server, kommen alle Anfragen von `127.0.0.1` – dann `127.0.0.1` nicht als > so eng wie möglich wählen. Läuft ein Reverse Proxy auf demselben Server, kommen
> vertrauenswürdig eintragen und `NETMOVER_FORWARDED_ALLOW_IPS` setzen. > 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 Ausgesperrt oder Passwort vergessen? Auf dem Server
`/opt/netmover/venv/bin/netmover reset-password` (setzt auch die Anmeldung zurück). `/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} ### Export und Import {#export-import}
+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.5.0" __version__ = "0.6.0"
+9 -4
View File
@@ -64,10 +64,15 @@ 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)
# Anmeldung wieder einschalten (falls abgeschaltet) # Anmeldung wieder überall einschalten und Netz-Sperren aufheben
mode = s.get(Setting, "app.auth_mode") for key, value in (
if mode is not None: ("app.auth_mode", '"always"'),
mode.value = '"always"' ("app.trusted_networks", '""'),
("app.allowed_networks", '""'),
):
setting = s.get(Setting, key)
if setting is not None:
setting.value = value
s.commit() 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.")
+4 -1
View File
@@ -46,9 +46,12 @@ 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" # 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" auth_mode: str = "always"
trusted_networks: str = "" trusted_networks: str = ""
allowed_networks: str = ""
_PREFIX = "app." _PREFIX = "app."
+68 -24
View File
@@ -269,7 +269,7 @@ async def guard(request: Request) -> None:
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)
cfg = load_settings(state.session_factory) 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: if bypass:
request.state.auth_bypass = bypass request.state.auth_bypass = bypass
elif not auth.is_logged_in(request): 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): if not auth.password_is_set(state.session_factory):
return redirect("/setup") return redirect("/setup")
cfg = load_settings(state.session_factory) 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 redirect("/")
return render(request, "login.html") return render(request, "login.html")
@@ -1235,39 +1235,54 @@ async def settings_password(request: Request) -> Response:
@protected.post("/settings/auth", response_class=HTMLResponse) @protected.post("/settings/auth", response_class=HTMLResponse)
async def settings_auth(request: Request) -> Response: async def settings_auth(request: Request) -> Response:
"""Anmeldung abschalten bzw. auf vertrauenswürdige Netze beschränken. """Netze ohne / mit Passwort festlegen. Nur mit dem aktuellen Passwort und nie so,
Nur mit dem aktuellen Passwort.""" dass man sich mit der eigenen Adresse aussperrt."""
state = nm(request) state = nm(request)
form = _form_dict(await request.form()) form = _form_dict(await request.form())
back = redirect("/settings#anmeldung")
if not await run_in_threadpool( if not await run_in_threadpool(
auth.verify_password, state.session_factory, form.get("current", "") auth.verify_password, state.session_factory, form.get("current", "")
): ):
flash(request, "Aktuelles Passwort falsch – Anmeldung unverändert.", "error") flash(request, "Aktuelles Passwort falsch – Anmeldung unverändert.", "error")
return redirect("/settings#anmeldung") return back
mode = form.get("auth_mode", auth.AUTH_ALWAYS) trusted = (form.get("trusted_networks") or "").strip()
networks = (form.get("trusted_networks") or "").strip() allowed = (form.get("allowed_networks") or "").strip()
if mode not in auth.AUTH_MODES:
flash(request, "Ungültige Auswahl.", "error")
return redirect("/settings#anmeldung")
try: try:
nets = auth.parse_networks(networks) auth.parse_networks(trusted)
auth.parse_networks(allowed)
except ValueError as exc: except ValueError as exc:
flash(request, str(exc), "error") flash(request, str(exc), "error")
return redirect("/settings#anmeldung") return back
if mode == auth.AUTH_TRUSTED and not nets: me = auth.client_host(request)
flash(request, "Bitte mindestens ein vertrauenswürdiges Netz angeben.", "error") if auth.access_decision(me, auth.AUTH_ALWAYS, trusted, allowed) == auth.ACCESS_DENY:
return redirect("/settings#anmeldung") 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 = 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) save_settings(state.session_factory, cfg)
event(log, "auth.mode_changed", level=30, mode=mode, networks=networks) event(
msgs = { log,
auth.AUTH_ALWAYS: "Anmeldung ist wieder immer erforderlich.", "auth.access_changed",
auth.AUTH_TRUSTED: f"Ohne Anmeldung aus: {networks}. Von überall sonst mit Passwort.", level=30,
auth.AUTH_OFF: "Anmeldung abgeschaltet – NetMover ist ohne Passwort bedienbar.", without_password=trusted,
} with_password=allowed or "alle übrigen",
flash(request, msgs[mode], "info" if mode == auth.AUTH_ALWAYS else "warn") )
return redirect("/settings#anmeldung") 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") @protected.get("/settings/export")
@@ -1371,6 +1386,35 @@ def create_app(settings: Settings | None = None, state: NetMover | None = None)
openapi_url=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(
"<!doctype html><meta charset=utf-8><title>NetMover</title>"
"<p>Zugriff aus diesem Netz ist nicht erlaubt.</p>",
status_code=403,
)
return await call_next(request)
@app.middleware("http") @app.middleware("http")
async def security_headers( async def security_headers(
request: Request, call_next: Callable[[Request], Awaitable[Response]] request: Request, call_next: Callable[[Request], Awaitable[Response]]
+47 -13
View File
@@ -51,25 +51,59 @@ def parse_networks(raw: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Netwo
return nets return nets
def login_bypass(request: Request, mode: str, trusted: str) -> str | None: ACCESS_NOPW, ACCESS_PW, ACCESS_DENY = "nopw", "pw", "deny"
"""Grund, warum keine Anmeldung nötig ist (``"off"``/``"trusted"``), sonst ``None``.
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 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, ``NETMOVER_FORWARDED_ALLOW_IPS`` den Proxy kennt – sonst sähe jede Anfrage aus,
als käme sie vom Proxy selbst.""" als käme sie vom Proxy selbst."""
if mode == AUTH_OFF: if mode == AUTH_OFF:
return AUTH_OFF return ACCESS_NOPW
if mode != AUTH_TRUSTED or request.client is None: 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 return None
try: return AUTH_OFF if mode == AUTH_OFF or _covers_everything(trusted) else AUTH_TRUSTED
addr = ipaddress.ip_address(request.client.host)
except ValueError:
return None def _covers_everything(raw: str) -> bool:
try: return any(n.prefixlen == 0 for n in _networks_or_empty(raw))
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: def includes_loopback(raw: str) -> bool:
+12 -9
View File
@@ -66,20 +66,23 @@
</form> </form>
<h2 id="anmeldung">Anmeldung <a class="legend-help" href="/help#anmeldung" title="Hilfe">?</a></h2> <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> <form class="form card stack" method="post" action="/settings/auth">
{{ csrf_field(csrf) }} {{ csrf_field(csrf) }}
<p class="small muted">Deine Adresse gerade: <code>{{ client_ip }}</code></p>
<div class="fields"> <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"><label for="f-nets">Ohne Passwort aus diesen Netzen</label>
<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="z. B. 10.0.10.0/24, 10.1.5.0/24">
<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; einzelne Adressen gehen auch. Leer = niemand ohne Passwort. <code>0.0.0.0/0, ::/0</code> = Anmeldung ganz aus.</span></div>
<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 wide"><label for="f-allowed">Mit Passwort aus diesen Netzen</label>
<input id="f-allowed" type="text" name="allowed_networks" value="{{ cfg.allowed_networks }}" placeholder="leer = alle übrigen Netze">
<span class="hint">Leer = von überall sonst mit Passwort. Ausgefüllt = <strong>alle anderen Netze werden abgewiesen</strong> (auch die Anmeldeseite). <code>/healthz</code> bleibt für die Überwachung erreichbar.</span></div>
<div class="field"><label for="f-cur-auth">Aktuelles Passwort (zur Bestätigung)</label> <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> <input id="f-cur-auth" type="password" name="current" autocomplete="current-password" required></div>
</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. <p class="msg warn small">NetMover läuft als root und kennt die Zugangsdaten deiner Freigaben. Wer die Oberfläche ohne Passwort erreicht,
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. kann alles ändern – Netze „ohne Passwort“ daher so eng wie möglich wählen. Hinter einem Reverse Proxy <code>NETMOVER_FORWARDED_ALLOW_IPS</code> setzen,
Hinter einem Proxy außerdem <code>NETMOVER_FORWARDED_ALLOW_IPS</code> setzen, sonst sieht jede Anfrage aus, als käme sie vom Proxy.</p> sonst sieht jede Anfrage aus, als käme sie vom Proxy. Eine Einstellung, mit der du dich selbst aussperren würdest, wird nicht gespeichert.</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 %} {% if loopback_trusted %}<p class="msg warn small">„Ohne Passwort“ 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> <div class="row"><button class="primary">Anmeldung speichern</button></div>
</form> </form>
+120 -67
View File
@@ -1,4 +1,4 @@
"""0.5.0: Anmeldung abschaltbar – immer / nicht aus vertrauenswürdigen Netzen / nie.""" """Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen."""
from __future__ import annotations from __future__ import annotations
@@ -7,15 +7,16 @@ from typing import Any
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from netmover.settings_store import load_settings from netmover.settings_store import load_settings, save_settings
from netmover.web.auth import includes_loopback, parse_networks from netmover.web.auth import (
from tests.webutil import PASSWORD, csrf_of, post ACCESS_DENY,
ACCESS_NOPW,
ACCESS_PW,
def _set_mode(client: TestClient, mode: str, nets: str = "", current: str = PASSWORD) -> Any: access_decision,
return post( includes_loopback,
client, "/settings/auth", {"auth_mode": mode, "trusted_networks": nets, "current": current} parse_networks,
) )
from tests.webutil import PASSWORD, csrf_of
def _other(client: TestClient, host: str) -> TestClient: def _other(client: TestClient, host: str) -> TestClient:
@@ -23,70 +24,124 @@ def _other(client: TestClient, host: str) -> TestClient:
return TestClient(client.app, follow_redirects=False, client=(host, 51000)) return TestClient(client.app, follow_redirects=False, client=(host, 51000))
@pytest.fixture def _save(c: TestClient, trusted: str = "", allowed: str = "", current: str = PASSWORD) -> Any:
def lan_client(client: TestClient) -> TestClient: token = (
return _other(client, "10.0.10.50") csrf_of(c.get("/settings").text)
if "X-Test-CSRF" not in c.headers
else c.headers["X-Test-CSRF"]
def test_default_requires_login(client: TestClient, lan_client: TestClient) -> None: )
assert lan_client.get("/").status_code == 303 return c.post(
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", "/settings/auth",
data={ data={
"csrf_token": token, "csrf_token": token,
"auth_mode": "always", "trusted_networks": trusted,
"trusted_networks": "", "allowed_networks": allowed,
"current": PASSWORD, "current": current,
}, },
) )
assert lan_client.get("/").status_code == 303
def _set(state: Any, **kw: str) -> None:
cfg = load_settings(state.session_factory)
for k, v in kw.items():
setattr(cfg, k, v)
save_settings(state.session_factory, cfg)
# -- Entscheidung ------------------------------------------------------------------------------
@pytest.mark.parametrize(
("host", "trusted", "allowed", "expected"),
[
("10.0.10.50", "", "", ACCESS_PW), # Standard: überall mit Passwort
("10.0.10.50", "10.0.10.0/24", "", ACCESS_NOPW),
("10.1.5.9", "10.0.10.0/24", "", ACCESS_PW), # übrige Netze mit Passwort
("10.1.5.9", "10.0.10.0/24", "10.1.5.0/24", ACCESS_PW),
("192.168.5.5", "10.0.10.0/24", "10.1.5.0/24", ACCESS_DENY),
("10.0.10.50", "10.0.10.0/24", "10.1.5.0/24", ACCESS_NOPW), # ohne PW schlägt Liste
("2001:db8::5", "0.0.0.0/0, ::/0", "", ACCESS_NOPW), # Anmeldung ganz aus
("testclient", "", "10.0.0.0/8", ACCESS_DENY), # keine IP-Adresse
],
)
def test_access_decision(host: str, trusted: str, allowed: str, expected: str) -> None:
assert access_decision(host, "always", trusted, allowed) == expected
def test_legacy_off_mode_still_works() -> None:
assert access_decision("192.168.5.5", "off", "", "10.1.5.0/24") == ACCESS_NOPW
# -- Oberfläche --------------------------------------------------------------------------------
def test_default_requires_login(client: TestClient) -> None:
assert _other(client, "10.0.10.50").get("/").status_code == 303
def test_change_needs_current_password(client: TestClient, state: Any) -> None:
_save(client, trusted="10.0.10.0/24", current="falsch")
assert load_settings(state.session_factory).trusted_networks == ""
assert "Aktuelles Passwort falsch" in client.get("/settings").text
def test_without_password_from_trusted_network(client: TestClient, state: Any) -> None:
_save(client, trusted="10.0.10.0/24")
lan = _other(client, "10.0.10.50")
r = lan.get("/")
assert r.status_code == 200 and "ohne Anmeldung (vertrautes Netz)" in r.text
assert "Abmelden" not in r.text and lan.get("/login").headers["location"] == "/"
assert lan.post("/maintenance").status_code == 403 # CSRF gilt weiterhin
assert (
lan.post("/maintenance", data={"csrf_token": csrf_of(lan.get("/").text)}).status_code == 303
)
assert _other(client, "10.1.5.9").get("/").status_code == 303 # übrige mit Passwort
def test_other_networks_are_rejected_when_password_list_is_set(
client: TestClient, state: Any
) -> None:
_save(client, trusted="0.0.0.0/0") # damit der Test-Client (keine IP) weiterkommt …
_set(state, trusted_networks="10.0.10.0/24", allowed_networks="10.1.5.0/24") # … dann scharf
assert _other(client, "10.0.10.50").get("/").status_code == 200
assert _other(client, "10.1.5.9").get("/").headers["location"].startswith("/login")
blocked = _other(client, "192.168.5.5")
for url in ("/", "/login", "/setup", "/mounts"):
assert blocked.get(url).status_code == 403, url
assert blocked.get("/healthz").status_code == 200 # Überwachung bleibt erreichbar
assert blocked.get("/static/app.css").status_code == 200
def test_cannot_lock_yourself_out(client: TestClient, state: Any) -> None:
lan = _other(client, "10.0.10.50")
_save(client, trusted="10.0.10.0/24")
# aus 10.0.10.50 eine Liste speichern, in der das eigene Netz fehlt
r = _save(lan, trusted="", allowed="10.1.5.0/24")
assert r.status_code == 303
assert "ausgesperrt" in lan.get("/settings").text
cfg = load_settings(state.session_factory)
assert cfg.trusted_networks == "10.0.10.0/24" and cfg.allowed_networks == ""
# eigenes Netz bei „mit Passwort“ – erlaubt
_save(lan, trusted="10.0.10.0/24", allowed="10.1.5.0/24")
assert load_settings(state.session_factory).allowed_networks == "10.1.5.0/24"
def test_everything_without_password(client: TestClient) -> None:
_save(client, trusted="0.0.0.0/0, ::/0")
r = _other(client, "192.168.77.1").get("/rules")
assert r.status_code == 200 and ">ohne Anmeldung<" in r.text
def test_setup_still_required_without_password(raw_client: TestClient, state: Any) -> None: def test_setup_still_required_without_password(raw_client: TestClient, state: Any) -> None:
from netmover.settings_store import save_settings _set(state, trusted_networks="0.0.0.0/0")
cfg = load_settings(state.session_factory)
cfg.auth_mode = "off"
save_settings(state.session_factory, cfg)
assert raw_client.get("/").headers["location"] == "/setup" assert raw_client.get("/").headers["location"] == "/setup"
def test_validation(client: TestClient, state: Any) -> None: def test_validation_and_loopback_hint(client: TestClient, state: Any) -> None:
_set_mode(client, "trusted", "") _save(client, trusted="10.0.10.0/24, quatsch")
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 "kein gültiges Netz" in client.get("/settings").text
assert load_settings(state.session_factory).auth_mode == "always" assert load_settings(state.session_factory).trusted_networks == ""
_set_mode(client, "trusted", "127.0.0.1") _save(client, trusted="127.0.0.1")
assert "jede</strong> Anfrage über den Proxy" in client.get("/settings").text assert "jede</strong> Anfrage über den Proxy" in client.get("/settings").text
@@ -102,13 +157,11 @@ def test_reset_password_turns_login_back_on(
state: Any, settings: Any, monkeypatch: pytest.MonkeyPatch state: Any, settings: Any, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
from netmover import cli from netmover import cli
from netmover.settings_store import save_settings
from netmover.web.auth import set_password from netmover.web.auth import set_password
set_password(state.session_factory, PASSWORD) set_password(state.session_factory, PASSWORD)
cfg = load_settings(state.session_factory) _set(state, auth_mode="off", trusted_networks="0.0.0.0/0", allowed_networks="10.1.5.0/24")
cfg.auth_mode = "off"
save_settings(state.session_factory, cfg)
monkeypatch.setattr(cli, "get_settings", lambda: settings) monkeypatch.setattr(cli, "get_settings", lambda: settings)
assert cli.main(["reset-password"]) == 0 assert cli.main(["reset-password"]) == 0
assert load_settings(state.session_factory).auth_mode == "always" cfg = load_settings(state.session_factory)
assert cfg.auth_mode == "always" and cfg.trusted_networks == "" and cfg.allowed_networks == ""