1 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5.5 cf7a3bcc88 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>
2026-10-11 08:46:43 +02:00
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]
## [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
+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: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**
+1
View File
@@ -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) |
+1 -1
View File
@@ -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:
+21 -13
View File
@@ -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}
+1 -1
View File
@@ -1,3 +1,3 @@
"""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)
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.")
+4 -1
View File
@@ -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."
+68 -24
View File
@@ -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(
"<!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")
async def security_headers(
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
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:
+12 -9
View File
@@ -66,20 +66,23 @@
</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>
<form class="form card stack" method="post" action="/settings/auth">
{{ csrf_field(csrf) }}
<p class="small muted">Deine Adresse gerade: <code>{{ client_ip }}</code></p>
<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 wide"><label for="f-nets">Ohne Passwort aus diesen Netzen</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">
<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>
<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>
<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 %}
<p class="msg warn small">NetMover läuft als root und kennt die Zugangsdaten deiner Freigaben. Wer die Oberfläche ohne Passwort erreicht,
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,
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">„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>
</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
@@ -7,15 +7,16 @@ 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}
)
from netmover.settings_store import load_settings, save_settings
from netmover.web.auth import (
ACCESS_DENY,
ACCESS_NOPW,
ACCESS_PW,
access_decision,
includes_loopback,
parse_networks,
)
from tests.webutil import PASSWORD, csrf_of
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))
@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(
def _save(c: TestClient, trusted: str = "", allowed: str = "", current: str = PASSWORD) -> Any:
token = (
csrf_of(c.get("/settings").text)
if "X-Test-CSRF" not in c.headers
else c.headers["X-Test-CSRF"]
)
return c.post(
"/settings/auth",
data={
"csrf_token": token,
"auth_mode": "always",
"trusted_networks": "",
"current": PASSWORD,
"trusted_networks": trusted,
"allowed_networks": allowed,
"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:
from netmover.settings_store import save_settings
cfg = load_settings(state.session_factory)
cfg.auth_mode = "off"
save_settings(state.session_factory, cfg)
_set(state, trusted_networks="0.0.0.0/0")
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")
def test_validation_and_loopback_hint(client: TestClient, state: Any) -> None:
_save(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 load_settings(state.session_factory).trusted_networks == ""
_save(client, trusted="127.0.0.1")
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
) -> 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)
_set(state, auth_mode="off", trusted_networks="0.0.0.0/0", allowed_networks="10.1.5.0/24")
monkeypatch.setattr(cli, "get_settings", lambda: settings)
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 == ""