"""Netzwerk-Speicherziele (NFS/SMB) direkt aus Rippy heraus einhängen. Commander-Anforderung 23.07.: Data-Mounts über das UI, universell für jede Installation. Der api-Container läuft dafür mit CAP_SYS_ADMIN und einem rshared-Bind auf /app/media — ein Mount hier propagiert über den Host in alle anderen Container (Worker sieht das Ziel sofort). Zugangsdaten liegen in Postgres (Klartext — bewusster Heimnetz-Kompromiss, im README dokumentiert; SMB-Credentials wandern NIE in die Kommandozeile, sondern über eine temporäre credentials-Datei). """ import os import posixpath import re import subprocess import tempfile import db MEDIA_ROOT = "/app/media" NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$") def _mountpoint(name: str) -> str: """Mountpunkt eines Speicherziels — IMMER ein Container-Pfad. posixpath statt os.path (Befund 26.07.2026): Innerhalb des api-Containers ist das dasselbe, aber `pfad_map_vorschlag()` liefert diesen Pfad an einen WINDOWS-Worker aus. Mit os.path.join entstand beim Test unter Windows `/app/media\\rippy`, und `pfad_lokal()` fand dann kein Präfix — das Mapping wäre still wirkungslos geblieben. Dieselbe Falle wie bei `_zielbasis()` im Worker (v3.14), diesmal vom Test gefunden statt live. """ return posixpath.join(MEDIA_ROOT, name) def validiere_name(name: str) -> bool: return bool(NAME_MUSTER.match(name)) def ist_gemountet(name: str) -> bool: """Ist unter /app/media/ etwas gemountet? Ein TOTER CIFS-Mount (NAS weg) lässt os.path.ismount mit OSError fliegen — das zählt weiter als 'gemountet' (nur eben kaputt), damit die Reparatur greift.""" try: return os.path.ismount(_mountpoint(name)) except OSError: return True def ist_erreichbar(name: str) -> bool: """Kann auf das Ziel WIRKLICH zugegriffen werden? Ein toter CIFS-Mount (Host weg, stale) ist zwar 'gemountet', aber jeder Zugriff scheitert. Der Zugriff wird HART auf 3 s begrenzt (`timeout 3 ls`) — ein direktes os.listdir blockierte sonst bis zum CIFS-Timeout (~10 s, Befund 24.07.: die Speicherziele-Seite lud dadurch träge). """ ziel = _mountpoint(name) try: ergebnis = subprocess.run( ["timeout", "3", "ls", ziel], capture_output=True, timeout=5 ) return ergebnis.returncode == 0 except (OSError, subprocess.TimeoutExpired): return False def schreibtest(pfad: str) -> bool: """Berechtigungs-Prüfung: können wir im Ziel wirklich schreiben? Commander-Anforderung 23.07.: beim Hinzufügen muss geprüft werden, ob die Rechte stimmen — ein nur-lesbares Ziel fällt sonst erst beim Rip auf. """ testdatei = os.path.join(pfad, ".rippy-schreibtest") try: with open(testdatei, "w") as f: f.write("ok") os.unlink(testdatei) return True except OSError: return False def _stale_mounts_loesen(ziel: str) -> int: """Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount. Warum: mounten() fällt bei einem TOTEN Mount (os.path.ismount wirft OSError) auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.: über viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff (ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das Re-Mounten idempotent. `umount -l` (lazy) hängt nicht an einem toten CIFS. Rueckgabe: Zahl der geloesten Schichten. """ geloest = 0 for _ in range(20): # harte Obergrenze gegen Endlosschleife try: ergebnis = subprocess.run( ["umount", "-l", ziel], capture_output=True, timeout=10 ) except (OSError, subprocess.TimeoutExpired): break if ergebnis.returncode != 0: break # nichts (mehr) gemountet geloest += 1 return geloest def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str: """Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext. Befund 24.07.: „session setup failed: NT_STATUS_ACCESS_DENIED" hieß in Wahrheit nur „Windows/NAS erlauben keine Gast-Abfrage" — ohne Übersetzung rät niemand, dass oben Benutzer/Passwort fehlen. """ if "NT_STATUS_ACCESS_DENIED" in fehler: if not mit_credentials: return ( "Zugriff verweigert — dieser Rechner erlaubt keine Gast-Abfrage. " "Benutzername + Passwort eintragen (Windows: ein Konto mit " "Zugriff auf die Freigabe), dann erneut auflisten." ) return ( "Zugriff verweigert — Benutzername/Passwort stimmen nicht oder das " "Konto darf die Freigaben nicht auflisten." ) if "NT_STATUS_LOGON_FAILURE" in fehler: return "Anmeldung fehlgeschlagen — Benutzername oder Passwort falsch." if ( "NT_STATUS_HOST_UNREACHABLE" in fehler or "NT_STATUS_IO_TIMEOUT" in fehler or "NT_STATUS_UNSUCCESSFUL" in fehler or "NT_STATUS_CONNECTION_REFUSED" in fehler or "Connection to" in fehler ): return ( "Rechner nicht erreichbar — Name/IP prüfen; läuft dort ein " "SMB-Dienst (Port 445, Datei- und Druckerfreigabe aktiv)?" ) return fehler[:200] def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> list: """Listet die SMB-Freigaben eines Rechners (smbclient -L) — damit man seinen PC/NAS per Klick wählt statt //host/share zu raten.""" cmd = ["smbclient", "-L", f"//{host}", "-g"] if username: cmd += ["-U", f"{username}%{passwort or ''}"] else: cmd += ["-N"] try: ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=20) except subprocess.TimeoutExpired: raise RuntimeError( f"Freigaben-Abfrage fehlgeschlagen: {host} antwortet nicht " "(Timeout nach 20 s) — Name/IP und Netzwerk prüfen." ) freigaben = [] for zeile in (ergebnis.stdout or "").splitlines(): # -g-Format: Disk|Freigabename|Kommentar teile = zeile.split("|") if len(teile) >= 2 and teile[0] == "Disk" and not teile[1].endswith("$"): freigaben.append(teile[1]) if not freigaben and ergebnis.returncode != 0: fehler = (ergebnis.stderr or ergebnis.stdout or "").strip() raise RuntimeError( "Freigaben-Abfrage fehlgeschlagen: " + uebersetze_smb_fehler(fehler, bool(username)) ) return freigaben def unc_aus_quelle(typ: str, quelle: str) -> str: """`//host/freigabe` → `\\\\host\\freigabe` (pure Funktion, testbar). Nur für CIFS/SMB. NFS gibt "" zurück: Windows kann NFS zwar einhängen (optionale Funktion „Client für NFS"), die Schreibweise ist aber eine andere und der Export-Pfad lässt sich nicht zuverlässig übersetzen — raten verstößt gegen AGENTS Regel D. """ if typ != "cifs" or not quelle: return "" rest = quelle.strip() if not rest.startswith("//"): return "" return "\\\\" + rest[2:].replace("/", "\\") def pfad_map_vorschlag(eintraege: list) -> list: """Welcher Container-Pfad entspricht welcher Windows-Freigabe? Das ist der fehlende Anschluss für `RIPPY_PATH_MAP` (Befund 26.07.2026): Ein externer Worker bekommt von Rippy Pfade wie `/app/media/rippy/` — das sind Pfade INNERHALB des Containers. Er sieht sie nur, wenn sie auf eine Netzwerk-Freigabe übersetzt werden, und dieses Mapping wurde bis dahin von niemandem gesetzt. Externes Encoden konnte also nie laufen. Geraten werden muss dafür nichts: Rippy hat die Freigabe selbst eingehängt und kennt ihre Quelle (`//192.168.178.62/rippy`). Der Mountpunkt ist `/app/media/`, und beides zusammen IST das Mapping. """ vorschlaege = [] for eintrag in eintraege or []: name = (eintrag.get("name") or "").strip() if not name: continue typ = (eintrag.get("typ") or "").strip() unc = unc_aus_quelle(typ, eintrag.get("quelle") or "") vorschlaege.append({ "name": name, "typ": typ, "quelle": eintrag.get("quelle") or "", "container": _mountpoint(name), "unc": unc, "gemountet": ist_gemountet(name), }) return vorschlaege def pfad_map_zeile(vorschlaege: list) -> str: """Die Vorschläge als fertiger RIPPY_PATH_MAP-Wert (pure Funktion). Format wie `worker/tasks.pfad_lokal()` es liest: Paare `container=ziel`, getrennt durch `;`. Einträge ohne übersetzbaren UNC-Pfad (NFS) fallen weg — ein halbes Mapping wäre schlimmer als keines, weil `pfad_lokal` beim ersten passenden Präfix aufhört. """ paare = [f"{v['container']}={v['unc']}" for v in vorschlaege or [] if v.get("unc")] return ";".join(paare) def mounten(name: str, typ: str, quelle: str, optionen: str = "", username: str = "", passwort: str = "") -> bool: """Hängt ein NFS/CIFS-Ziel unter /app/media/ ein. Rückgabe: True wenn das Ziel SCHREIBBAR ist. Wirft RuntimeError bei Mount-Fehlern. """ ziel = _mountpoint(name) # exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir # täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant. try: os.makedirs(ziel, exist_ok=True) except FileExistsError: pass # ⚠️ ERREICHBARKEIT VOR ismount — nicht umgekehrt (Befund 26.07.2026). # # Vorher stand hier `if os.path.ismount(ziel): return schreibtest(ziel)`. # Das hatte zwei Folgen, beide am selben Nachmittag gemessen: # # 1. Nach `docker compose up -d --build` war die CIFS-Freigabe TOT (4 von 4 # Zugriffen liefen in 10 s Timeout, /storage-mounts meldete # `reachable: false`) — und blieb es. Der Mountpunkt existierte im neuen # Container weiter (Bind-Mount vom Host), `ismount` sagte also „ist schon # da" und das Wiederherstellen brach genau hier ab. Die Reparatur per # POST /storage-mounts/rippy/repair stellte sie in einer Sekunde her — # beim Start hätte dasselbe passieren müssen. # 2. `schreibtest()` öffnet eine Datei OHNE Zeitgrenze. Auf einem toten CIFS # blockiert das im Kernel (Thread-Zustand D), und `alle_remounten()` # hängt beim API-Start dauerhaft in einem Hintergrund-Thread. # # `ist_erreichbar()` hat eine harte Grenze (`timeout 3 ls`). Antwortet die # Freigabe, sind ismount und schreibtest danach gefahrlos. Antwortet sie # nicht, fällt es unten durch: lösen, frisch mounten. Das ist genau der Weg, # den `reparieren()` geht — nur eben automatisch. if ist_erreichbar(name): try: if os.path.ismount(ziel): return schreibtest(ziel) except OSError: pass # unten erst lösen, dann frisch mounten (kein Stapeln) # Idempotent: etwaige (auch gestapelte/tote) Alt-Mounts erst lösen, damit der # folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten). _stale_mounts_loesen(ziel) creds_datei = None try: if typ == "nfs": opts = optionen or "vers=4,soft,timeo=100" cmd = ["mount", "-t", "nfs", "-o", opts, quelle, ziel] elif typ == "cifs": teile = [optionen] if optionen else [] if username: # Credentials über Datei statt Kommandozeile (ps-sichtbar!) creds = tempfile.NamedTemporaryFile( "w", delete=False, prefix="cifs-", suffix=".cred" ) creds.write(f"username={username}\npassword={passwort or ''}\n") creds.close() os.chmod(creds.name, 0o600) creds_datei = creds.name teile.append(f"credentials={creds.name}") else: teile.append("guest") teile.append("iocharset=utf8") cmd = ["mount", "-t", "cifs", "-o", ",".join(teile), quelle, ziel] else: raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)") ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if ergebnis.returncode != 0: fehler = (ergebnis.stderr or ergebnis.stdout or "").strip() hinweis = "" if "error(13)" in fehler or "Permission denied" in fehler: hinweis = ( " — Zugriff verweigert: Benutzer/Passwort prüfen; ohne " "Angaben versucht Rippy einen Gast-Zugriff, den Windows/" "NAS meist ablehnen." ) raise RuntimeError(f"mount schlug fehl: {fehler[:300]}{hinweis}") return schreibtest(ziel) finally: if creds_datei: try: os.unlink(creds_datei) except OSError: pass def _lazy_umount(ziel: str) -> None: """`umount -l`: hängt auch einen TOTEN/beschäftigten Mount ab (detach now, cleanup later). Ohne das ließ sich ein Mount zu einem weggefallenen NAS gar nicht mehr entfernen (Befund 24.07.).""" subprocess.run(["umount", "-l", ziel], capture_output=True, text=True, timeout=30) def aushaengen(name: str) -> None: ziel = _mountpoint(name) try: noch_mount = os.path.ismount(ziel) except OSError: noch_mount = True # stale if noch_mount: ergebnis = subprocess.run( ["umount", ziel], capture_output=True, text=True, timeout=30 ) if ergebnis.returncode != 0: # Toter/beschäftigter Mount → lazy detach (klappt immer) _lazy_umount(ziel) try: os.rmdir(ziel) except OSError: pass # nicht leer oder weg — egal def reparieren(name: str, typ: str, quelle: str, optionen: str = "", username: str = "", passwort: str = "") -> bool: """Toten/veralteten Mount frisch neu verbinden: lazy abhängen, neu mounten. Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) — das normale mounten() würde am „ist schon Mountpoint" hängenbleiben. """ ziel = _mountpoint(name) try: if os.path.ismount(ziel): _lazy_umount(ziel) except OSError: _lazy_umount(ziel) return mounten(name, typ, quelle, optionen, username, passwort) def alle_remounten() -> list: """Beim API-Start: alle gespeicherten Mounts wiederherstellen.""" meldungen = [] for eintrag in db.list_mounts(): try: mounten( eintrag["name"], eintrag["typ"], eintrag["quelle"], eintrag.get("optionen") or "", eintrag.get("username") or "", eintrag.get("passwort") or "", ) meldungen.append(f"{eintrag['name']}: eingehängt") except Exception as e: meldungen.append(f"{eintrag['name']}: FEHLER — {e}") return meldungen