Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf7a3bcc88 |
+14
-1
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+20
-12
@@ -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
|
||||
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,3 +1,3 @@
|
||||
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "0.6.0"
|
||||
|
||||
+9
-4
@@ -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.")
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+119
-66
@@ -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 == ""
|
||||
|
||||
Reference in New Issue
Block a user