feat: Anmeldung abschaltbar (vertraute Netze oder ganz); Doku 0.5.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -8,6 +8,27 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] – 2026-10-11
|
||||
|
||||
### Hinzugefügt
|
||||
- **SFTP als Quelle** mit denselben Garantien wie bei SMB/NFS: Stabilitätsprüfung,
|
||||
atomares Schreiben, Löschen der Quelle erst nach vollständiger Übertragung,
|
||||
Kopier-Buchführung, Quarantäne. Verbindungsabbrüche brechen den Lauf als gestörte
|
||||
Anbindung ab. Bei SFTP-Quellen mindestens 2 s Stabilitätsfenster (Zeiten nur
|
||||
sekundengenau).
|
||||
- **Gespeicherte Zugangsdaten** für SMB und SFTP-Passwörter (Benutzer, Passwort,
|
||||
Domäne); in Anbindungen auswählbar, ein neues Passwort gilt sofort für alle.
|
||||
Die Seite „Schlüssel“ heißt jetzt **„Zugangsdaten“** (Passwörter + SSH-Schlüssel);
|
||||
`/keys` leitet weiter.
|
||||
- **Anmeldung abschaltbar** (*Einstellungen → Anmeldung*, Bestätigung mit Passwort):
|
||||
immer / nicht aus vertrauenswürdigen Netzen / nie (z. B. hinter Reverse Proxy mit
|
||||
eigener Anmeldung). Deutlicher Hinweis in der Kopfzeile; CSRF-Schutz bleibt;
|
||||
`netmover reset-password` schaltet die Anmeldung wieder ein.
|
||||
|
||||
### Geändert
|
||||
- Mover intern auf eine Quell-Schnittstelle umgestellt (`LocalSource`, `SftpSource`);
|
||||
bestehendes Verhalten unverändert (alle bisherigen Tests gelten weiter).
|
||||
|
||||
## [0.4.0] – 2026-10-11
|
||||
|
||||
### Hinzugefügt
|
||||
@@ -213,7 +234,8 @@ Erste Version.
|
||||
- Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den
|
||||
Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt.
|
||||
|
||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.4.0...HEAD
|
||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.5.0...HEAD
|
||||
[0.5.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.1...v0.3.0
|
||||
[0.2.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.0...v0.2.1
|
||||
|
||||
@@ -18,10 +18,37 @@ Zeiten in Ortszeit (Europe/Berlin).
|
||||
| 2026-10-10 | 23:31 | 23:58 | 0:27 | – | Schlüsselverwaltung, Update-Skript, 0.2.x |
|
||||
| 2026-10-11 | 00:13 | 00:32 | 0:19 | – | Menü, Anleitung, Roadmap, Uptime Kuma (0.3.0) |
|
||||
| 2026-10-11 | 00:31 | 00:50 | 0:19 | – | Pausieren, Wartungsmodus, E-Mail (0.4.0) |
|
||||
| **Summe** | | | **2:55** | | |
|
||||
| 2026-10-11 | 00:54 | 01:16 | 0:22 | – | SFTP-Quelle, Zugangsdaten, Anmeldung (0.5.0) |
|
||||
| **Summe** | | | **3:17** | | |
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-11 · 00:54–01:16 · SFTP-Quelle, Zugangsdaten, Anmeldung abschaltbar (0.5.0)
|
||||
|
||||
**Erledigt**
|
||||
- `mover/sources.py`: `Source` mit `LocalSource`/`SftpSource`; Engine greift nur noch
|
||||
darüber auf die Quelle zu (Scan, frischer Zustand, Lesen, Löschen, Quarantäne).
|
||||
Ziele bekommen statt `Path` ein „Readable“ (Adapter) – Destinations fast unverändert.
|
||||
- `credentials.py` (vorher `keys.py`): gespeicherte Passwörter; Seite „Zugangsdaten“.
|
||||
- Anmeldung: Modus immer / vertraute Netze / nie, zentral im `guard`.
|
||||
- 26 neue Tests (325 gesamt); alle bisherigen Mover-Tests bestanden den Umbau
|
||||
unverändert (nur ein Monkeypatch-Ziel verschoben).
|
||||
|
||||
**Entscheidungen**
|
||||
- SFTP-Quelle: Stabilität über Größe + sekundengenaue mtime, Mindestfenster 2 s;
|
||||
Verbindungsabbruch → `ENOTCONN` → Lauf abgebrochen (Anbindung gestört).
|
||||
- Gespeicherte Passwörter tragen den Benutzer selbst (anders als Schlüssel, deren
|
||||
Benutzer zur Anbindung gehört).
|
||||
- „Passworteingabe deaktivieren“ als Login-Modus umgesetzt, mit Bestätigung durch
|
||||
das Passwort, Warnhinweis und Notausgang über `reset-password`. Ein Passwort bleibt
|
||||
Pflicht (Ersteinrichtung), sonst ließe sich die Anmeldung nie wieder einschalten.
|
||||
|
||||
**Stolpersteine**
|
||||
- paramiko `prefetch()` braucht `fstat`; der Test-Server konnte es nicht – jetzt
|
||||
kann er es, und die Quelle übergibt die bekannte Größe (spart einen Umlauf).
|
||||
- Zwei TestClients mit eigenem Lebenszyklus starteten den Scheduler doppelt →
|
||||
zweiter Client ohne Lebenszyklus auf derselben App.
|
||||
|
||||
## 2026-10-11 · 00:31–00:50 · Pausieren, Wartungsmodus, E-Mail (0.4.0)
|
||||
|
||||
**Erledigt**
|
||||
|
||||
@@ -6,13 +6,6 @@ stehen mit Version im [CHANGELOG](CHANGELOG.md); Details zu Entscheidungen im
|
||||
|
||||
Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht entschieden)
|
||||
|
||||
## In Arbeit (0.5.0)
|
||||
|
||||
| Status | Thema | Warum |
|
||||
|---|---|---|
|
||||
| 🔨 | **SFTP als Quelle**: Scan, Stabilitätsprüfung und Löschen der Quelle über SFTP, mit denselben Garantien wie bei SMB/NFS | Quellen, die nur per SSH erreichbar sind |
|
||||
| 🔨 | **Gespeicherte Zugangsdaten** für SMB und SFTP-Passwörter, zusammen mit den SSH-Schlüsseln auf einer Seite „Zugangsdaten“ | Mehrere Freigaben mit demselben NAS-Benutzer an einer Stelle pflegen |
|
||||
|
||||
## Geplant
|
||||
|
||||
| Status | Thema | Warum |
|
||||
@@ -35,6 +28,7 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht
|
||||
|
||||
| Version | Thema |
|
||||
|---|---|
|
||||
| 0.5.0 | SFTP als Quelle; gespeicherte Zugangsdaten für SMB/SFTP-Passwörter (Seite „Zugangsdaten“); Anmeldung abschaltbar (vertrauenswürdige Netze / ganz) |
|
||||
| 0.4.0 | Automatisch pausieren/fortsetzen bei nicht erreichbaren Anbindungen; Wartungsmodus; E-Mail-Benachrichtigung; eine Meldung pro ausgefallener Anbindung |
|
||||
| 0.3.0 | Neues Menü (gruppierte Seitenleiste, Statusampel, Handy-Menü); Anleitung im Repo und als Hilfe-Seite; Roadmap; Uptime Kuma (Einzel-Endpunkte, Push-Monitor) |
|
||||
| 0.2.1 | Migrationen in einer Transaktion und ohne Fremdschlüsselfehler auf befüllten Datenbanken |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
services:
|
||||
netmover:
|
||||
build: .
|
||||
image: netmover:0.4.0
|
||||
image: netmover:0.5.0
|
||||
container_name: netmover
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
||||
@@ -8,21 +8,21 @@ Diese Anleitung erklärt die Bedienung. Installation, Updates und technische Det
|
||||
stehen im [README](../README.md).
|
||||
|
||||
**Inhalt:** [Begriffe](#begriffe) · [Erste Anmeldung](#erste-anmeldung) ·
|
||||
[Anbindungen](#anbindungen) · [Schlüssel](#schluessel) · [Regeln](#regeln) ·
|
||||
[Zugangsdaten](#zugangsdaten) · [Anbindungen](#anbindungen) · [Regeln](#regeln) ·
|
||||
[Probelauf](#probelauf) · [Status](#status) · [Wartungsmodus](#wartungsmodus) · [Protokoll](#protokoll) ·
|
||||
[Einstellungen](#einstellungen) · [Uptime Kuma](#uptime-kuma) · [Beispiele](#beispiele) · [Häufige Fragen](#fragen)
|
||||
[Einstellungen](#einstellungen) · [Anmeldung](#anmeldung) · [Uptime Kuma](#uptime-kuma) · [Beispiele](#beispiele) · [Häufige Fragen](#fragen)
|
||||
|
||||
## Die vier Begriffe {#begriffe}
|
||||
|
||||
| Begriff | Was es ist | Beispiel |
|
||||
|---|---|---|
|
||||
| **Anbindung** | Ein Ort, an den NetMover herankommt: eine SMB-Freigabe, ein NFS-Export oder ein Server per SFTP | „NAS“ = `//10.0.10.6/Scans` |
|
||||
| **Schlüssel** | Ein gespeicherter SSH-Schlüssel für SFTP-Anbindungen | „paperless-lxc“ |
|
||||
| **Zugangsdaten** | Gespeicherter Benutzer mit Passwort oder SSH-Schlüssel, in mehreren Anbindungen nutzbar | „NAS-Scanner“, „paperless-lxc“ |
|
||||
| **Regel** | „Alle N Sekunden aus Anbindung A nach Anbindung B“ – mit Filter, Sicherheitsprüfungen und Verhalten bei gleichen Namen | „Scans → Paperless“ |
|
||||
| **Lauf** | Eine Ausführung einer Regel; steht mit allen Dateien im Protokoll | 23:41:07, 3 Dateien verschoben |
|
||||
|
||||
Die Reihenfolge beim Einrichten ist immer dieselbe – und so ist auch das Menü
|
||||
aufgebaut: **Anbindungen → (Schlüssel) → Regeln → Status und Protokoll beobachten.**
|
||||
aufgebaut: **(Zugangsdaten) → Anbindungen → Regeln → Status und Protokoll beobachten.**
|
||||
|
||||

|
||||
|
||||
@@ -66,7 +66,7 @@ Stabilitätsfenster dort mindestens 2 Sekunden betragen.
|
||||
| **Server** | IP-Adresse oder Hostname, z. B. `10.0.10.6` |
|
||||
| **Freigabe / Pfad** | SMB: Freigabename, optional mit Unterordner (`Scans` oder `Scans/privat`). NFS: Exportpfad (`/volume1/scans`). SFTP: absoluter Ordner auf dem Server (`/opt/paperless_data/consume`). |
|
||||
| **Benutzername / Passwort** | Bei SMB leer lassen für Gastzugang. Beim Bearbeiten heißt ein leeres Passwortfeld „unverändert“ – Passwörter werden nie angezeigt. |
|
||||
| **Anmeldeart, Schlüssel** (SFTP) | SSH-Schlüssel (gespeichert oder nur für diese Anbindung) oder Passwort, siehe [Schlüssel](#schluessel). |
|
||||
| **Zugangsdaten / Anmeldeart** | Gespeicherte Zugangsdaten auswählen oder eigene Angaben nur für diese Anbindung; bei SFTP zusätzlich SSH-Schlüssel oder Passwort, siehe [Zugangsdaten](#zugangsdaten). |
|
||||
| **uid, gid, file_mode, dir_mode** (SMB) | Wem die Dateien im eingehängten Ordner gehören. Meist leer lassen. |
|
||||
| **Zusatzoptionen** | Für Sonderfälle, z. B. `vers=3.0` oder `nohandlecache`. Nur erlaubte Optionen; Zugangsdaten setzt NetMover selbst. |
|
||||
| **Beim Start automatisch einhängen** | Nach einem Neustart des Servers wieder einhängen. Fällt das NAS aus, versucht NetMover es außerdem jede Minute erneut. |
|
||||
@@ -104,14 +104,28 @@ Es wird nie in ein leeres Verzeichnis geschrieben, nur weil die Freigabe fehlt.
|
||||
Solche Regeln **warten** automatisch, bis die Anbindung zurück ist (siehe
|
||||
[Status](#status)), und laufen dann von selbst weiter.
|
||||
|
||||
## Schlüssel {#schluessel}
|
||||
## Zugangsdaten {#zugangsdaten}
|
||||
|
||||
*Verbindungen → Schlüssel*
|
||||
*Verbindungen → Zugangsdaten*
|
||||
|
||||
Für SFTP-Ziele ist ein SSH-Schlüssel sicherer als ein Passwort. Einen Schlüssel kann
|
||||
man für mehrere Anbindungen verwenden; den Benutzernamen legt jede Anbindung fest.
|
||||
Benutzer und Passwörter für Freigaben sowie SSH-Schlüssel einmal hinterlegen und in
|
||||
beliebig vielen Anbindungen auswählen. Passwörter und private Schlüssel liegen
|
||||
verschlüsselt in der Datenbank und werden nie angezeigt.
|
||||
|
||||

|
||||

|
||||
|
||||
### Passwörter {#zugangsdaten-passwoerter}
|
||||
|
||||
Für SMB-Freigaben und SFTP-Server mit Passwort: Name, Benutzer, Passwort und
|
||||
optional Domäne. In der Anbindung unter *Zugangsdaten* auswählen statt „eigene
|
||||
Angaben“. **Ändern** setzt Benutzer, Domäne oder ein neues Passwort – das gilt sofort
|
||||
für alle Anbindungen, die den Eintrag nutzen (praktisch, wenn das NAS-Passwort
|
||||
wechselt).
|
||||
|
||||
### SSH-Schlüssel {#schluessel}
|
||||
|
||||
Für SFTP ist ein Schlüssel sicherer als ein Passwort. Einen Schlüssel kann man für
|
||||
mehrere Anbindungen verwenden; den Benutzernamen legt jede Anbindung fest.
|
||||
|
||||
**Neuen Schlüssel erzeugen** – Namen eingeben, *Ed25519-Schlüssel erzeugen*. Dann:
|
||||
|
||||
@@ -119,17 +133,15 @@ man für mehrere Anbindungen verwenden; den Benutzernamen legt jede Anbindung fe
|
||||
**authorized_keys-Zeile** kopieren. Sie beginnt mit
|
||||
`restrict,command="/usr/lib/openssh/sftp-server"` – damit darf der Schlüssel
|
||||
**nur SFTP**, keine Shell.
|
||||
2. Auf dem Zielserver an die Datei `~/.ssh/authorized_keys` des Benutzers anhängen
|
||||
2. Auf dem Server an die Datei `~/.ssh/authorized_keys` des Benutzers anhängen
|
||||
(Ordner `~/.ssh` mit Rechten `700`, Datei `600`, Eigentümer der Benutzer).
|
||||
3. In der Anbindung *Anmeldeart: SSH-Schlüssel* und den Schlüssel wählen, **Testen**.
|
||||
|
||||
**Vorhandenen Schlüssel importieren** – privaten Schlüssel einfügen (OpenSSH- oder
|
||||
PEM-Format; Ed25519, ECDSA oder RSA ab 2048 Bit). Hat er eine Passphrase, diese
|
||||
angeben: Sie wird nur zum Entschlüsseln benutzt, der Schlüssel liegt danach
|
||||
verschlüsselt in NetMovers Datenbank.
|
||||
angeben: Sie wird nur zum Entschlüsseln benutzt.
|
||||
|
||||
Private Schlüssel zeigt NetMover nie an. Ein Schlüssel lässt sich nur löschen, wenn
|
||||
ihn keine Anbindung mehr nutzt.
|
||||
Gespeicherte Zugangsdaten lassen sich nur löschen, wenn sie keine Anbindung mehr nutzt.
|
||||
|
||||
## Regeln {#regeln}
|
||||
|
||||
@@ -319,6 +331,28 @@ Als Problem gilt: Scheduler steht, eine aktive Anbindung ist im Fehlerzustand, e
|
||||
aktive Regel hatte länger als das Dreifache ihres Intervalls keinen Erfolg oder ist
|
||||
dreimal hintereinander gescheitert.
|
||||
|
||||
### Anmeldung {#anmeldung}
|
||||
|
||||
Standardmäßig fragt NetMover immer nach dem Passwort. Unter *Einstellungen →
|
||||
Anmeldung* lässt sich das ändern (Bestätigung mit dem aktuellen Passwort):
|
||||
|
||||
| Einstellung | Wirkung |
|
||||
|---|---|
|
||||
| **immer** (empfohlen) | Anmeldung bei jedem Zugriff |
|
||||
| **nicht aus vertrauenswürdigen Netzen** | ohne Passwort aus den eingetragenen Netzen, z. B. `10.0.10.0/24`; von überall sonst mit Passwort |
|
||||
| **nie** | keine Anmeldung – nur hinter einem Reverse Proxy mit eigener Anmeldung (z. B. Authelia, Authentik) |
|
||||
|
||||
Solange ohne Anmeldung gearbeitet wird, steht oben gelb „ohne Anmeldung“. Der Schutz
|
||||
gegen untergeschobene Formulare bleibt immer aktiv.
|
||||
|
||||
> NetMover läuft als root und kennt die Zugangsdaten deiner Freigaben. Wer die
|
||||
> Oberfläche ohne Anmeldung erreicht, kann alles ändern. Läuft ein Reverse Proxy auf
|
||||
> demselben Server, kommen alle Anfragen von `127.0.0.1` – dann `127.0.0.1` nicht als
|
||||
> vertrauenswürdig eintragen und `NETMOVER_FORWARDED_ALLOW_IPS` setzen.
|
||||
|
||||
Zurück zur Anmeldung: dieselbe Einstellung auf „immer“, oder auf dem Server
|
||||
`/opt/netmover/venv/bin/netmover reset-password` (setzt auch die Anmeldung zurück).
|
||||
|
||||
### Export und Import {#export-import}
|
||||
|
||||
*Konfiguration exportieren* lädt Anbindungen, Regeln und Einstellungen als JSON
|
||||
@@ -331,8 +365,8 @@ deaktiviert, Zugangsdaten müssen neu eingegeben werden.
|
||||
### Scanner → NAS → Paperless {#beispiel-paperless}
|
||||
|
||||
1. Anbindung **NAS** (SMB, Freigabe `Scans`), testen, einhängen.
|
||||
2. Schlüssel **paperless-lxc** erzeugen, Zeile in `authorized_keys` im Paperless-LXC
|
||||
eintragen.
|
||||
2. Unter *Zugangsdaten* den Schlüssel **paperless-lxc** erzeugen, Zeile in
|
||||
`authorized_keys` im Paperless-LXC eintragen.
|
||||
3. Anbindung **Paperless** (SFTP, Pfad `/opt/paperless_data/consume`, Schlüssel
|
||||
wählen, Benutzer = der Paperless-Benutzer), testen.
|
||||
4. Regel **Scans → Paperless**: Quelle NAS, Filter `*.pdf`, Ziel Paperless,
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -1,3 +1,3 @@
|
||||
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
|
||||
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.5.0"
|
||||
|
||||
@@ -64,6 +64,10 @@ 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"'
|
||||
s.commit()
|
||||
engine.dispose()
|
||||
print("Passwort gelöscht. Dienst neu starten; der Einrichtungscode steht im Log.")
|
||||
|
||||
@@ -46,6 +46,9 @@ class AppSettings:
|
||||
smtp_password_enc: str = ""
|
||||
smtp_from: str = ""
|
||||
smtp_to: str = ""
|
||||
# Anmeldung: "always" | "trusted" (ohne Anmeldung aus trusted_networks) | "off"
|
||||
auth_mode: str = "always"
|
||||
trusted_networks: str = ""
|
||||
|
||||
|
||||
_PREFIX = "app."
|
||||
|
||||
@@ -180,7 +180,7 @@ def flash(request: Request, message: str, kind: str = "ok") -> None:
|
||||
HELP_ANCHORS = {
|
||||
"status": "status",
|
||||
"mounts": "anbindungen",
|
||||
"keys": "schluessel",
|
||||
"keys": "zugangsdaten",
|
||||
"rules": "regeln",
|
||||
"log": "protokoll",
|
||||
"settings": "einstellungen",
|
||||
@@ -200,7 +200,7 @@ class NavState:
|
||||
|
||||
|
||||
def _nav_state(request: Request) -> NavState | None:
|
||||
if not auth.is_logged_in(request):
|
||||
if not _authorized(request):
|
||||
return None
|
||||
state = nm(request)
|
||||
health = state.health()
|
||||
@@ -237,7 +237,9 @@ def render(request: Request, name: str, status_code: int = 200, **ctx: Any) -> H
|
||||
{
|
||||
"csrf": auth.csrf_token(request),
|
||||
"flashes": flashes,
|
||||
"logged_in": auth.is_logged_in(request),
|
||||
"logged_in": _authorized(request),
|
||||
"session_login": auth.is_logged_in(request),
|
||||
"auth_bypass": getattr(request.state, "auth_bypass", None),
|
||||
"active": ctx.pop("active", ""),
|
||||
**ctx,
|
||||
},
|
||||
@@ -263,13 +265,22 @@ def _form_dict(form: Any) -> dict[str, str]:
|
||||
|
||||
async def guard(request: Request) -> None:
|
||||
state = nm(request)
|
||||
# Ein Passwort ist immer Pflicht – sonst ließe sich die Anmeldung nie wieder einschalten.
|
||||
if not auth.password_is_set(state.session_factory):
|
||||
raise auth.LoginRequired(setup=True)
|
||||
if not auth.is_logged_in(request):
|
||||
cfg = load_settings(state.session_factory)
|
||||
bypass = auth.login_bypass(request, cfg.auth_mode, cfg.trusted_networks)
|
||||
if bypass:
|
||||
request.state.auth_bypass = bypass
|
||||
elif not auth.is_logged_in(request):
|
||||
raise auth.LoginRequired()
|
||||
await auth.check_csrf(request)
|
||||
|
||||
|
||||
def _authorized(request: Request) -> bool:
|
||||
return auth.is_logged_in(request) or bool(getattr(request.state, "auth_bypass", None))
|
||||
|
||||
|
||||
protected = APIRouter(dependencies=[Depends(guard)])
|
||||
public = APIRouter()
|
||||
|
||||
@@ -349,8 +360,12 @@ async def setup_submit(request: Request) -> Response:
|
||||
|
||||
@public.get("/login", response_class=HTMLResponse)
|
||||
async def login_form(request: Request) -> Response:
|
||||
if not auth.password_is_set(nm(request).session_factory):
|
||||
state = nm(request)
|
||||
if not auth.password_is_set(state.session_factory):
|
||||
return redirect("/setup")
|
||||
cfg = load_settings(state.session_factory)
|
||||
if auth.login_bypass(request, cfg.auth_mode, cfg.trusted_networks):
|
||||
return redirect("/")
|
||||
return render(request, "login.html")
|
||||
|
||||
|
||||
@@ -1061,6 +1076,8 @@ async def settings_page(request: Request) -> Response:
|
||||
cfg=cfg,
|
||||
errors={},
|
||||
monitor_urls=_monitor_urls(request),
|
||||
client_ip=request.client.host if request.client else "",
|
||||
loopback_trusted=auth.includes_loopback(cfg.trusted_networks),
|
||||
)
|
||||
|
||||
|
||||
@@ -1216,6 +1233,43 @@ async def settings_password(request: Request) -> Response:
|
||||
return redirect("/settings")
|
||||
|
||||
|
||||
@protected.post("/settings/auth", response_class=HTMLResponse)
|
||||
async def settings_auth(request: Request) -> Response:
|
||||
"""Anmeldung abschalten bzw. auf vertrauenswürdige Netze beschränken.
|
||||
Nur mit dem aktuellen Passwort."""
|
||||
state = nm(request)
|
||||
form = _form_dict(await request.form())
|
||||
if not await run_in_threadpool(
|
||||
auth.verify_password, state.session_factory, form.get("current", "")
|
||||
):
|
||||
flash(request, "Aktuelles Passwort falsch – Anmeldung unverändert.", "error")
|
||||
return redirect("/settings#anmeldung")
|
||||
mode = form.get("auth_mode", auth.AUTH_ALWAYS)
|
||||
networks = (form.get("trusted_networks") or "").strip()
|
||||
if mode not in auth.AUTH_MODES:
|
||||
flash(request, "Ungültige Auswahl.", "error")
|
||||
return redirect("/settings#anmeldung")
|
||||
try:
|
||||
nets = auth.parse_networks(networks)
|
||||
except ValueError as exc:
|
||||
flash(request, str(exc), "error")
|
||||
return redirect("/settings#anmeldung")
|
||||
if mode == auth.AUTH_TRUSTED and not nets:
|
||||
flash(request, "Bitte mindestens ein vertrauenswürdiges Netz angeben.", "error")
|
||||
return redirect("/settings#anmeldung")
|
||||
cfg = load_settings(state.session_factory)
|
||||
cfg.auth_mode, cfg.trusted_networks = mode, networks
|
||||
save_settings(state.session_factory, cfg)
|
||||
event(log, "auth.mode_changed", level=30, mode=mode, networks=networks)
|
||||
msgs = {
|
||||
auth.AUTH_ALWAYS: "Anmeldung ist wieder immer erforderlich.",
|
||||
auth.AUTH_TRUSTED: f"Ohne Anmeldung aus: {networks}. Von überall sonst mit Passwort.",
|
||||
auth.AUTH_OFF: "Anmeldung abgeschaltet – NetMover ist ohne Passwort bedienbar.",
|
||||
}
|
||||
flash(request, msgs[mode], "info" if mode == auth.AUTH_ALWAYS else "warn")
|
||||
return redirect("/settings#anmeldung")
|
||||
|
||||
|
||||
@protected.get("/settings/export")
|
||||
async def settings_export(request: Request) -> Response:
|
||||
state = nm(request)
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import ipaddress
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
@@ -32,6 +34,54 @@ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
AUTH_ALWAYS, AUTH_TRUSTED, AUTH_OFF = "always", "trusted", "off"
|
||||
AUTH_MODES = (AUTH_ALWAYS, AUTH_TRUSTED, AUTH_OFF)
|
||||
|
||||
|
||||
def parse_networks(raw: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
|
||||
"""„10.0.10.0/24, 192.168.1.5“ → Netze; ``ValueError`` mit verständlicher Meldung."""
|
||||
nets = []
|
||||
for part in re.split(r"[,;\s]+", raw.strip()):
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
nets.append(ipaddress.ip_network(part, strict=False))
|
||||
except ValueError:
|
||||
raise ValueError(f"„{part}“ ist kein gültiges Netz (z. B. 10.0.10.0/24)") from None
|
||||
return nets
|
||||
|
||||
|
||||
def login_bypass(request: Request, mode: str, trusted: str) -> str | None:
|
||||
"""Grund, warum keine Anmeldung nötig ist (``"off"``/``"trusted"``), sonst ``None``.
|
||||
|
||||
Hinter einem Reverse Proxy ist die Client-Adresse nur dann echt, wenn
|
||||
``NETMOVER_FORWARDED_ALLOW_IPS`` den Proxy kennt – sonst sähe jede Anfrage aus,
|
||||
als käme sie vom Proxy selbst."""
|
||||
if mode == AUTH_OFF:
|
||||
return AUTH_OFF
|
||||
if mode != AUTH_TRUSTED or request.client is None:
|
||||
return None
|
||||
try:
|
||||
addr = ipaddress.ip_address(request.client.host)
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
nets = parse_networks(trusted)
|
||||
except ValueError:
|
||||
return None
|
||||
return AUTH_TRUSTED if any(addr in n for n in nets) else None
|
||||
|
||||
|
||||
def includes_loopback(raw: str) -> bool:
|
||||
try:
|
||||
nets = parse_networks(raw)
|
||||
except ValueError:
|
||||
return False
|
||||
return any(
|
||||
ipaddress.ip_address("127.0.0.1") in n or ipaddress.ip_address("::1") in n for n in nets
|
||||
)
|
||||
|
||||
|
||||
class LoginRequired(Exception):
|
||||
def __init__(self, setup: bool = False) -> None:
|
||||
self.setup = setup
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
{% if nav %}
|
||||
<a class="health {{ 'ok' if nav.healthy else 'bad' }}" href="/" title="Gesundheitszustand">
|
||||
<span class="dot"></span>{{ 'Alles in Ordnung' if nav.healthy else (nav.problems ~ (' Problem' if nav.problems == 1 else ' Probleme')) }}</a>
|
||||
{% if auth_bypass %}<a class="health warn" href="/settings#anmeldung" title="Ohne Passwort bedienbar">{{ 'ohne Anmeldung' if auth_bypass == 'off' else 'ohne Anmeldung (vertrautes Netz)' }}</a>{% endif %}
|
||||
{% if nav.maintenance %}<a class="health warn" href="/" title="Geplante Läufe sind pausiert"><span class="dot"></span>Wartungsmodus</a>{% endif %}
|
||||
{% endif %}
|
||||
<span class="spacer"></span>
|
||||
@@ -49,10 +50,12 @@
|
||||
{{ item('help', '/help', 'Hilfe') }}
|
||||
</div>
|
||||
<div class="nav-foot">
|
||||
{% if session_login %}
|
||||
<form method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||
<button class="nav-button">{{ icon('logout') }}<span>Abmelden</span></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="version">NetMover {{ version }}</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -65,6 +65,24 @@
|
||||
<button>Passwort ändern</button>
|
||||
</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>
|
||||
{{ csrf_field(csrf) }}
|
||||
<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"><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 %}
|
||||
<div class="row"><button class="primary">Anmeldung speichern</button></div>
|
||||
</form>
|
||||
|
||||
<h2>Export / Import</h2>
|
||||
<div class="card stack">
|
||||
<p class="small muted">Export enthält Anbindungen, Regeln und Einstellungen – <strong>ohne</strong> Zugangsdaten und Webhook-URL.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""0.5.0: Anmeldung abschaltbar – immer / nicht aus vertrauenswürdigen Netzen / nie."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from netmover.settings_store import load_settings
|
||||
from netmover.web.auth import includes_loopback, parse_networks
|
||||
from tests.webutil import PASSWORD, csrf_of, post
|
||||
|
||||
|
||||
def _set_mode(client: TestClient, mode: str, nets: str = "", current: str = PASSWORD) -> Any:
|
||||
return post(
|
||||
client, "/settings/auth", {"auth_mode": mode, "trusted_networks": nets, "current": current}
|
||||
)
|
||||
|
||||
|
||||
def _other(client: TestClient, host: str) -> TestClient:
|
||||
"""Weiterer Client für dieselbe App (ohne eigenen Lebenszyklus) mit fester Adresse."""
|
||||
return TestClient(client.app, follow_redirects=False, client=(host, 51000))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lan_client(client: TestClient) -> TestClient:
|
||||
return _other(client, "10.0.10.50")
|
||||
|
||||
|
||||
def test_default_requires_login(client: TestClient, lan_client: TestClient) -> None:
|
||||
assert lan_client.get("/").status_code == 303
|
||||
|
||||
|
||||
def test_mode_change_needs_current_password(client: TestClient, state: Any) -> None:
|
||||
_set_mode(client, "off", current="falsch")
|
||||
assert load_settings(state.session_factory).auth_mode == "always"
|
||||
assert "Aktuelles Passwort falsch" in client.get("/settings").text
|
||||
|
||||
|
||||
def test_trusted_networks(
|
||||
client: TestClient, lan_client: TestClient, settings: Any, state: Any
|
||||
) -> None:
|
||||
_set_mode(client, "trusted", "10.0.10.0/24")
|
||||
r = lan_client.get("/")
|
||||
assert r.status_code == 200 and "ohne Anmeldung (vertrautes Netz)" in r.text
|
||||
assert "Abmelden" not in r.text
|
||||
assert lan_client.get("/login").headers["location"] == "/"
|
||||
# CSRF gilt weiterhin
|
||||
assert lan_client.post("/maintenance").status_code == 403
|
||||
token = csrf_of(lan_client.get("/").text)
|
||||
assert lan_client.post("/maintenance", data={"csrf_token": token}).status_code == 303
|
||||
# andere Netze brauchen weiter das Passwort
|
||||
assert _other(client, "192.168.5.5").get("/").status_code == 303
|
||||
|
||||
|
||||
def test_off_and_back_on(client: TestClient, lan_client: TestClient) -> None:
|
||||
_set_mode(client, "off")
|
||||
r = lan_client.get("/rules")
|
||||
assert r.status_code == 200 and "ohne Anmeldung" in r.text
|
||||
token = csrf_of(r.text)
|
||||
lan_client.post(
|
||||
"/settings/auth",
|
||||
data={
|
||||
"csrf_token": token,
|
||||
"auth_mode": "always",
|
||||
"trusted_networks": "",
|
||||
"current": PASSWORD,
|
||||
},
|
||||
)
|
||||
assert lan_client.get("/").status_code == 303
|
||||
|
||||
|
||||
def test_setup_still_required_without_password(raw_client: TestClient, state: Any) -> None:
|
||||
from netmover.settings_store import save_settings
|
||||
|
||||
cfg = load_settings(state.session_factory)
|
||||
cfg.auth_mode = "off"
|
||||
save_settings(state.session_factory, cfg)
|
||||
assert raw_client.get("/").headers["location"] == "/setup"
|
||||
|
||||
|
||||
def test_validation(client: TestClient, state: Any) -> None:
|
||||
_set_mode(client, "trusted", "")
|
||||
assert "mindestens ein" in client.get("/settings").text
|
||||
_set_mode(client, "trusted", "10.0.10.0/24, quatsch")
|
||||
assert "kein gültiges Netz" in client.get("/settings").text
|
||||
assert load_settings(state.session_factory).auth_mode == "always"
|
||||
_set_mode(client, "trusted", "127.0.0.1")
|
||||
assert "jede</strong> Anfrage über den Proxy" in client.get("/settings").text
|
||||
|
||||
|
||||
def test_network_parsing() -> None:
|
||||
nets = parse_networks("10.0.10.0/24; 192.168.1.20 fe80::/10")
|
||||
assert [str(n) for n in nets] == ["10.0.10.0/24", "192.168.1.20/32", "fe80::/10"]
|
||||
assert includes_loopback("10.0.0.0/8, 127.0.0.0/8") and not includes_loopback("10.0.0.0/8")
|
||||
with pytest.raises(ValueError):
|
||||
parse_networks("10.0.0.0/33")
|
||||
|
||||
|
||||
def test_reset_password_turns_login_back_on(
|
||||
state: Any, settings: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from netmover import cli
|
||||
from netmover.settings_store import save_settings
|
||||
from netmover.web.auth import set_password
|
||||
|
||||
set_password(state.session_factory, PASSWORD)
|
||||
cfg = load_settings(state.session_factory)
|
||||
cfg.auth_mode = "off"
|
||||
save_settings(state.session_factory, cfg)
|
||||
monkeypatch.setattr(cli, "get_settings", lambda: settings)
|
||||
assert cli.main(["reset-password"]) == 0
|
||||
assert load_settings(state.session_factory).auth_mode == "always"
|
||||