"""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 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: return os.path.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() faellt bei einem TOTEN Mount (os.path.ismount wirft OSError) auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.: ueber 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) haengt 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 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 try: if os.path.ismount(ziel): return schreibtest(ziel) except OSError: pass # toter Mount → 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