feat: Mount-Schicht mit Linux-/Fake-Backend, Test und Wiederherstellung

MountBackend-Interface mit LinuxMountBackend (Argumentliste, kurzlebige
Credentials-Datei) und FakeMountBackend; MountManager für Einhängen,
Aushängen, Start-Wiederherstellung, Drei-Schritt-Test mit Root-Squash-
Erkennung, Lebenszeichen und umount -l/Neu-Einhängen; SFTP-Anbindung mit
festgehaltenem Hostschlüssel. Tests inkl. echtem SFTP-Server im Prozess.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-10 18:46:50 +02:00
co-authored by Claude Opus 5.5
parent 689ddf0d94
commit e5ed8c4d88
18 changed files with 2478 additions and 9 deletions
+1
View File
@@ -0,0 +1 @@
"""Mount-Schicht: Backends (Linux/Fake), Verwaltung, Tests, SFTP-Anbindung."""
+53
View File
@@ -0,0 +1,53 @@
"""Interface der Mount-Schicht.
Alles, was den Kernel betrifft (einhängen, aushängen, „ist eingehängt?“, Lebens-
zeichen), läuft über :class:`MountBackend`. Dadurch ist die gesamte Mover-Logik
mit :class:`~netmover.mounting.fake.FakeMountBackend` ohne root testbar.
"""
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
@dataclass(frozen=True)
class CifsCredentials:
username: str
password: str
domain: str | None = None
def __repr__(self) -> str: # nie das Passwort in Logs/Tracebacks
return f"CifsCredentials(username={self.username!r}, password='***')"
@dataclass(frozen=True)
class MountSpec:
fstype: str # Kernel-Dateisystemtyp: "cifs", "nfs", "nfs4"
source: str # "//server/share" bzw. "server:/export"
mountpoint: Path
options: tuple[str, ...] = ()
credentials: CifsCredentials | None = field(default=None, repr=True)
class MountError(RuntimeError):
"""``mount``/``umount`` gescheitert. Die Meldung ist bereits geschwärzt."""
class MountBackend(ABC):
@abstractmethod
def mount(self, spec: MountSpec, timeout: float = 60) -> None: ...
@abstractmethod
def unmount(self, mountpoint: Path, lazy: bool = False, timeout: float = 60) -> None: ...
@abstractmethod
def is_mounted(self, mountpoint: Path) -> bool: ...
def probe(self, path: Path) -> None:
"""Lebenszeichen: ``stat`` + ``listdir``. Kann bei hängenden Mounts blockieren
– Aufrufer verwenden :func:`netmover.timeouts.run_with_timeout`."""
os.stat(path)
os.listdir(path)
+126
View File
@@ -0,0 +1,126 @@
"""Test-Implementierung: simuliert Mounts auf einem temporären Verzeichnis.
* „Entfernte“ Freigaben sind Verzeichnisse unter ``<root>/remote/<server>/<share>``
(anlegen mit :meth:`FakeMountBackend.export`).
* Einhängen ersetzt das leere Mountpoint-Verzeichnis durch einen Symlink auf die
Freigabe; Aushängen stellt das leere Verzeichnis wieder her. Ein ausgehängter
Mountpoint ist also – genau wie in echt – ein ganz normales leeres Verzeichnis.
* :meth:`inject_fault` lässt das Lebenszeichen eines Mounts mit ``ESTALE``,
``EIO``, ``ENOTCONN`` scheitern oder hängen (Fallstrick 13).
"""
from __future__ import annotations
import errno
import os
import threading
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from netmover.mounting.backend import MountBackend, MountError, MountSpec
Fault = int | Literal["hang"]
@dataclass
class FakeCall:
action: str
mountpoint: Path
spec: MountSpec | None = None
lazy: bool = False
class FakeMountBackend(MountBackend):
def __init__(self, root: Path) -> None:
self.remote_root = root / "remote"
self.remote_root.mkdir(parents=True, exist_ok=True)
self.mounted: dict[Path, Path] = {}
self.faults: dict[Path, Fault] = {}
self.mount_failures: deque[str] = deque()
self.heal_on_remount = True
self.busy: set[Path] = set()
self.calls: list[FakeCall] = []
self._release = threading.Event()
# -- Steuerung für Tests -----------------------------------------------------
def export(self, server: str, share: str) -> Path:
path = self.remote_root / server / share.strip("/")
path.mkdir(parents=True, exist_ok=True)
return path
def fail_next_mount(
self, message: str = "mount error(112): Host is down", times: int = 1
) -> None:
self.mount_failures.extend([message] * times)
def inject_fault(self, mountpoint: Path, fault: Fault) -> None:
self.faults[Path(mountpoint)] = fault
def clear_fault(self, mountpoint: Path) -> None:
self.faults.pop(Path(mountpoint), None)
def vanish(self, mountpoint: Path) -> None:
"""Mount verschwindet von außen (z. B. ``umount`` durch Dritte)."""
mp = Path(mountpoint)
if mp in self.mounted:
mp.unlink()
mp.mkdir()
del self.mounted[mp]
def release_hangs(self) -> None:
self._release.set()
def _remote_for(self, spec: MountSpec) -> Path:
if spec.source.startswith("//"):
server, _, share = spec.source[2:].partition("/")
else:
server, _, share = spec.source.partition(":")
return self.remote_root / server / share.strip("/")
# -- Interface -----------------------------------------------------------------
def mount(self, spec: MountSpec, timeout: float = 60) -> None:
mp = Path(spec.mountpoint)
self.calls.append(FakeCall("mount", mp, spec=spec))
if self.mount_failures:
raise MountError(self.mount_failures.popleft())
if mp in self.mounted:
raise MountError(f"mount: {mp}: already mounted")
remote = self._remote_for(spec)
if not remote.is_dir():
raise MountError("mount error(2): No such file or directory")
if not mp.is_dir() or mp.is_symlink():
raise MountError(f"mount: {mp}: mount point does not exist")
mp.rmdir() # scheitert, wenn nicht leer – wie ein echter Mount, der verdeckt
mp.symlink_to(remote, target_is_directory=True)
self.mounted[mp] = remote
if self.heal_on_remount:
self.faults.pop(mp, None)
def unmount(self, mountpoint: Path, lazy: bool = False, timeout: float = 60) -> None:
mp = Path(mountpoint)
self.calls.append(FakeCall("unmount", mp, lazy=lazy))
if mp not in self.mounted:
raise MountError(f"umount: {mp}: not mounted.")
if mp in self.busy and not lazy:
raise MountError(f"umount: {mp}: target is busy.")
mp.unlink()
mp.mkdir()
del self.mounted[mp]
self.busy.discard(mp)
def is_mounted(self, mountpoint: Path) -> bool:
return Path(mountpoint) in self.mounted
def probe(self, path: Path) -> None:
p = Path(path)
for mp, fault in self.faults.items():
if p == mp or mp in p.parents:
if fault == "hang":
self._release.wait()
raise OSError(errno.EIO, os.strerror(errno.EIO), str(p))
raise OSError(fault, os.strerror(fault), str(p))
super().probe(p)
+132
View File
@@ -0,0 +1,132 @@
"""Echte Implementierung: ruft ``mount``/``umount`` auf.
Fallstrick 5: ``subprocess`` nur mit Argumentliste, nie ``shell=True``.
Fallstrick 7: Zugangsdaten für ``mount.cifs`` nur über eine kurzlebige
Credentials-Datei (0600) unter ``/run/netmover``, nie als ``password=`` in der
Optionszeile – die stünde sonst in der Prozessliste.
"""
from __future__ import annotations
import os
import subprocess
import uuid
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Any
from netmover.crypto import redact
from netmover.logging_setup import event, get_logger
from netmover.mounting.backend import CifsCredentials, MountBackend, MountError, MountSpec
from netmover.mounting.mountinfo import MOUNTINFO, find_mount, read_mountinfo
log = get_logger(__name__)
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
_ENV = {"LC_ALL": "C", "PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}
def explain_mount_error(fstype: str, message: str) -> str:
"""Hängt an typische ``mount``-Meldungen einen Hinweis im Klartext an."""
low = message.lower()
hint = ""
if "helper program" in low or "unknown filesystem type" in low:
pkg = "cifs-utils" if fstype == "cifs" else "nfs-common"
hint = f"Das Paket „{pkg}“ fehlt: apt install {pkg}"
elif "permission denied" in low and fstype == "cifs":
hint = "Benutzername/Passwort falsch oder der Benutzer hat keine Rechte auf die Freigabe."
elif "permission denied" in low or "access denied by server" in low:
hint = (
"Der NFS-Server erlaubt diesem Host den Zugriff nicht. Synology: Gemeinsamer "
"Ordner → Bearbeiten → NFS-Berechtigungen → Regel mit der IP dieses Hosts anlegen."
)
elif "operation not permitted" in low or "only root can" in low:
hint = (
"Keine Berechtigung zum Einhängen. NetMover muss als root mit CAP_SYS_ADMIN laufen "
"(in unprivilegierten LXC-Containern ist Einhängen nicht möglich)."
)
elif "no such file or directory" in low or "mount error(2)" in low:
hint = "Freigabe bzw. Exportpfad existiert auf dem Server nicht – Schreibweise prüfen."
elif "host is down" in low or "no route to host" in low or "timed out" in low:
hint = "Server nicht erreichbar – Adresse, Netzwerk und Firewall prüfen."
elif "connection refused" in low:
hint = "Server lehnt die Verbindung ab – ist SMB bzw. NFS am NAS aktiviert?"
return f"{message}\n→ {hint}" if hint else message
class LinuxMountBackend(MountBackend):
def __init__(
self,
run_dir: Path,
mountinfo_path: Path = MOUNTINFO,
runner: Runner = subprocess.run,
) -> None:
self.run_dir = run_dir
self.mountinfo_path = mountinfo_path
self._runner = runner
# -- Hilfen ------------------------------------------------------------------
def _write_credentials(self, creds: CifsCredentials) -> Path:
self.run_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
path = self.run_dir / f"cred-{uuid.uuid4().hex}"
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(f"username={creds.username}\n")
fh.write(f"password={creds.password}\n")
if creds.domain:
fh.write(f"domain={creds.domain}\n")
return path
def _run(self, argv: Sequence[str], timeout: float, secrets: Sequence[str]) -> None:
kwargs: dict[str, Any] = {
"capture_output": True,
"text": True,
"timeout": timeout,
"env": _ENV,
"check": False,
"stdin": subprocess.DEVNULL,
}
try:
proc = self._runner(list(argv), **kwargs)
except subprocess.TimeoutExpired:
raise MountError(f"{argv[0]}: keine Antwort nach {timeout:g} s") from None
except FileNotFoundError:
raise MountError(f"{argv[0]}: Programm nicht gefunden") from None
if proc.returncode != 0:
output = (proc.stderr or "").strip() or (proc.stdout or "").strip()
output = redact(output or f"Exit-Code {proc.returncode}", secrets)
raise MountError(output)
# -- Interface -----------------------------------------------------------------
def mount(self, spec: MountSpec, timeout: float = 60) -> None:
options = list(spec.options)
secrets: list[str] = []
cred_file: Path | None = None
try:
if spec.credentials is not None:
cred_file = self._write_credentials(spec.credentials)
options.append(f"credentials={cred_file}")
secrets.append(spec.credentials.password)
argv = ["mount", "-t", spec.fstype]
if options:
argv += ["-o", ",".join(options)]
argv += [spec.source, str(spec.mountpoint)]
try:
self._run(argv, timeout, secrets)
except MountError as exc:
raise MountError(explain_mount_error(spec.fstype, str(exc))) from None
finally:
if cred_file is not None:
cred_file.unlink(missing_ok=True)
event(log, "mount.mounted", source=spec.source, mountpoint=str(spec.mountpoint))
def unmount(self, mountpoint: Path, lazy: bool = False, timeout: float = 60) -> None:
argv = ["umount"] + (["-l"] if lazy else []) + [str(mountpoint)]
self._run(argv, timeout, [])
event(log, "mount.unmounted", mountpoint=str(mountpoint), lazy=lazy)
def is_mounted(self, mountpoint: Path) -> bool:
return find_mount(mountpoint, read_mountinfo(self.mountinfo_path)) is not None
+587
View File
@@ -0,0 +1,587 @@
"""Verwaltung der Anbindungen: einhängen, aushängen, testen, wiederherstellen.
Verbindet Datenbank, Geheimnisse und :class:`MountBackend`. Alle Methoden sind
synchron und blockierend; der Web-Teil ruft sie über ``asyncio.to_thread`` auf,
die Regel-Worker direkt.
Fallstricke:
* 2 – :meth:`MountManager.check_alive` prüft „eingehängt“ über das Backend
(``/proc/self/mountinfo``) und bricht ab, statt in ein leeres Verzeichnis zu schreiben.
* 6 – Mountpoints entstehen nur als ``<mount_base>/<slug>``; vor dem Einhängen
existiert das Verzeichnis und ist leer, nach dem Aushängen wird es entfernt.
* 13 – Lebenszeichen mit Zeitlimit; bei ESTALE/EIO/ENOTCONN/Zeitüberschreitung
``umount -l`` und neu einhängen, höchstens N Versuche mit wachsendem Abstand.
"""
from __future__ import annotations
import errno
import os
import threading
import time
import uuid
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.crypto import SecretBox, redact
from netmover.db import session_scope
from netmover.logging_setup import event, get_logger
from netmover.models import CredentialKind, Mount, MountState, MountType, utcnow
from netmover.mounting.backend import CifsCredentials, MountBackend, MountError, MountSpec
from netmover.mounting.report import CheckReport, assess_write
from netmover.mounting.sftp import SftpConfig, SftpError, check_connection, connect
from netmover.timeouts import OperationTimeout, run_with_timeout
from netmover.validation import (
ValidationError,
parse_extra_options,
slugify,
validate_server,
validate_share,
)
log = get_logger(__name__)
# Fehler, bei denen ein Neu-Einhängen hilft (Fallstrick 13).
RECOVERABLE_ERRNOS = frozenset({errno.ESTALE, errno.EIO, errno.ENOTCONN, errno.EHOSTDOWN})
Notifier = Callable[[str, str, str], None] # (ereignis, anbindung, meldung)
class MountUnavailable(RuntimeError):
"""Anbindung für einen Lauf nicht nutzbar – Lauf abbrechen, nichts anfassen."""
@dataclass(frozen=True)
class CredentialData:
kind: CredentialKind
username: str
secret: str
domain: str | None = None
def __repr__(self) -> str:
return f"CredentialData(kind={self.kind}, username={self.username!r}, secret='***')"
@dataclass(frozen=True)
class MountConfig:
name: str
type: MountType
server: str
share: str
port: int | None = None
host_key: str | None = None
uid: int | None = None
gid: int | None = None
file_mode: str | None = None
dir_mode: str | None = None
extra_options: str = ""
@classmethod
def from_model(cls, m: Mount) -> MountConfig:
return cls(
name=m.name,
type=m.type,
server=m.server,
share=m.share,
port=m.port,
host_key=m.host_key,
uid=m.uid,
gid=m.gid,
file_mode=m.file_mode,
dir_mode=m.dir_mode,
extra_options=m.extra_options,
)
@dataclass(frozen=True)
class RestoreResult:
name: str
ok: bool
message: str
def build_spec(cfg: MountConfig, mountpoint: Path, cred: CredentialData | None) -> MountSpec:
"""Baut den ``mount``-Aufruf. Validiert erneut (Verteidigung in der Tiefe) –
auch Daten aus der Datenbank laufen hier noch einmal durch die Prüfung."""
if not cfg.type.is_kernel_mount:
raise MountError("SFTP-Anbindungen werden nicht eingehängt")
server = validate_server(cfg.server)
share = validate_share(cfg.type, cfg.share)
options = [o.render() for o in parse_extra_options(cfg.type, cfg.extra_options)]
credentials: CifsCredentials | None = None
if cfg.type is MountType.CIFS:
fstype, source = "cifs", f"//{server}/{share}"
for key, value in (
("uid", cfg.uid),
("gid", cfg.gid),
("file_mode", cfg.file_mode),
("dir_mode", cfg.dir_mode),
):
if value is not None:
options.append(f"{key}={value}")
if cred is None:
options.append("guest")
else:
if cred.kind is not CredentialKind.PASSWORD:
raise MountError("CIFS braucht Zugangsdaten mit Passwort, keinen SSH-Schlüssel")
credentials = CifsCredentials(cred.username, cred.secret, cred.domain)
elif cfg.type is MountType.NFS4:
fstype, source = "nfs4", f"{server}:{share}"
else:
fstype, source = "nfs", f"{server}:{share}"
options.insert(0, "vers=3")
return MountSpec(fstype, source, mountpoint, tuple(options), credentials)
def sftp_config(cfg: MountConfig, cred: CredentialData | None) -> SftpConfig:
if cred is None:
raise SftpError("SFTP braucht Zugangsdaten (SSH-Schlüssel oder Passwort)")
return SftpConfig(
host=validate_server(cfg.server),
port=cfg.port or 22,
username=cred.username,
base_path=validate_share(MountType.SFTP, cfg.share),
password=cred.secret if cred.kind is CredentialKind.PASSWORD else None,
private_key=cred.secret if cred.kind is CredentialKind.SSH_KEY else None,
host_key=cfg.host_key,
)
class MountManager:
def __init__(
self,
session_factory: sessionmaker[Session],
backend: MountBackend,
secrets: SecretBox,
mount_base: Path,
*,
mount_timeout: float = 60,
probe_timeout: float = 15,
recover_backoff: Sequence[float] = (0, 5, 15),
sleep: Callable[[float], None] = time.sleep,
euid: Callable[[], int] = os.geteuid,
notifier: Notifier | None = None,
) -> None:
self.session_factory = session_factory
self.backend = backend
self.secrets = secrets
self.mount_base = mount_base
self.mount_timeout = mount_timeout
self.probe_timeout = probe_timeout
self.recover_backoff = tuple(recover_backoff)
self._sleep = sleep
self._euid = euid
self._notifier = notifier
self._locks: dict[int, threading.RLock] = {}
self._locks_guard = threading.Lock()
# -- Hilfen --------------------------------------------------------------------
def _lock(self, mount_id: int) -> threading.RLock:
with self._locks_guard:
return self._locks.setdefault(mount_id, threading.RLock())
def _notify(self, kind: str, name: str, message: str) -> None:
if self._notifier is not None:
try:
self._notifier(kind, name, message)
except Exception:
log.exception("notifier.failed")
def mountpoint_for(self, slug: str) -> Path:
if slugify(slug) != slug:
raise ValidationError("slug", f"Ungültiger Slug: {slug!r}")
base = self.mount_base.resolve()
mp = base / slug
if mp.parent != base:
raise ValidationError("slug", "Mountpoint außerhalb des Basisverzeichnisses")
return mp
def _load(self, mount_id: int) -> tuple[Mount, CredentialData | None]:
with session_scope(self.session_factory) as s:
m = s.get(Mount, mount_id)
if m is None:
raise LookupError(f"Anbindung {mount_id} existiert nicht")
cred: CredentialData | None = None
if m.credential is not None:
c = m.credential
cred = CredentialData(
c.kind, c.username, self.secrets.decrypt(c.secret_enc), c.domain
)
s.expunge(m)
return m, cred
def _set_state(
self,
mount_id: int,
state: MountState,
error: str | None = None,
*,
mounted: bool = False,
) -> None:
with session_scope(self.session_factory) as s:
m = s.get(Mount, mount_id)
if m is None:
return
if m.state is not state:
m.state_since = utcnow()
event(log, "mount.state", mount=m.name, old=m.state.value, new=state.value)
m.state = state
m.last_error = error
if mounted:
m.last_mounted_at = utcnow()
def _prepare_mountpoint(self, mp: Path) -> bool:
"""Legt den Mountpoint an bzw. prüft, dass er leer ist. True = neu angelegt."""
self.mount_base.mkdir(mode=0o755, parents=True, exist_ok=True)
if mp.is_symlink() or (mp.exists() and not mp.is_dir()):
raise MountError(f"{mp} ist kein Verzeichnis – bitte von Hand prüfen")
if mp.is_dir():
if any(mp.iterdir()):
raise MountError(
f"{mp} ist nicht leer. Vermutlich wurde dort geschrieben, während nichts "
"eingehängt war. Inhalt prüfen und sichern, dann Verzeichnis leeren."
)
return False
mp.mkdir(mode=0o755)
return True
@staticmethod
def _remove_mountpoint(mp: Path) -> None:
try:
mp.rmdir()
except FileNotFoundError:
pass
except OSError as exc:
event(log, "mount.rmdir_failed", level=30, mountpoint=str(mp), error=str(exc))
def _probe(self, name: str, mp: Path) -> None:
run_with_timeout(lambda: self.backend.probe(mp), self.probe_timeout, f"probe:{name}")
# -- Einhängen / Aushängen -----------------------------------------------------
def mount(self, mount_id: int) -> None:
with self._lock(mount_id):
m, cred = self._load(mount_id)
if not m.type.is_kernel_mount:
raise MountError("SFTP-Anbindungen werden nicht eingehängt, nur getestet")
mp = self.mountpoint_for(m.slug)
if self.backend.is_mounted(mp):
self._set_state(mount_id, MountState.MOUNTED)
return
try:
spec = build_spec(MountConfig.from_model(m), mp, cred)
created = self._prepare_mountpoint(mp)
except (MountError, ValidationError) as exc:
self._set_state(mount_id, MountState.ERROR, str(exc))
raise MountError(str(exc)) from None
try:
self.backend.mount(spec, timeout=self.mount_timeout)
except MountError as exc:
if created:
self._remove_mountpoint(mp)
self._set_state(mount_id, MountState.ERROR, str(exc))
event(log, "mount.failed", level=40, mount=m.name, error=str(exc))
raise
self._set_state(mount_id, MountState.MOUNTED, mounted=True)
def unmount(self, mount_id: int) -> None:
with self._lock(mount_id):
m, _ = self._load(mount_id)
if not m.type.is_kernel_mount:
self._set_state(mount_id, MountState.UNMOUNTED)
return
mp = self.mountpoint_for(m.slug)
if self.backend.is_mounted(mp):
try:
self.backend.unmount(mp, timeout=self.mount_timeout)
except MountError as exc:
msg = str(exc)
if "busy" in msg.lower():
msg += "\n→ Ein Prozess benutzt den Mount noch (z. B. ein Regellauf)."
raise MountError(msg) from None
if not self.backend.is_mounted(mp):
self._remove_mountpoint(mp)
self._set_state(mount_id, MountState.UNMOUNTED)
def restore_all(self) -> list[RestoreResult]:
"""Beim Dienststart: alle Anbindungen mit ``auto_mount`` wiederherstellen."""
with session_scope(self.session_factory) as s:
rows = s.execute(
select(Mount.id, Mount.name, Mount.type).where(
Mount.auto_mount.is_(True), Mount.enabled.is_(True)
)
).all()
results: list[RestoreResult] = []
for mount_id, name, mtype in rows:
if not MountType(mtype).is_kernel_mount:
continue
try:
self.mount(mount_id)
results.append(RestoreResult(name, True, "eingehängt"))
except (MountError, ValidationError, LookupError) as exc:
results.append(RestoreResult(name, False, str(exc)))
self._notify("mount.restore_failed", name, str(exc))
for r in results:
event(
log,
"mount.restore",
level=20 if r.ok else 40,
mount=r.name,
ok=r.ok,
message=r.message,
)
return results
# -- Test -------------------------------------------------------------------------
def check(
self,
cfg: MountConfig,
cred: CredentialData | None,
mount_id: int | None = None,
) -> CheckReport:
"""Testet eine (ggf. noch nicht gespeicherte) Anbindung in drei Schritten."""
if cfg.type is MountType.SFTP:
report = self._check_sftp(cfg, cred)
else:
report = self._check_kernel(cfg, cred, mount_id)
if mount_id is not None:
self._store_check(mount_id, report, cfg)
return report
def _check_sftp(self, cfg: MountConfig, cred: CredentialData | None) -> CheckReport:
try:
scfg = sftp_config(cfg, cred)
except (SftpError, ValidationError) as exc:
report = CheckReport()
report.step("connect", False, str(exc))
return report
return run_with_timeout(
lambda: check_connection(scfg, timeout=self.probe_timeout),
self.probe_timeout * 4,
f"check:{cfg.name}",
)
def _check_kernel(
self, cfg: MountConfig, cred: CredentialData | None, mount_id: int | None
) -> CheckReport:
report = CheckReport()
secrets = [cred.secret] if cred else []
target: Path | None = None
temp: Path | None = None
if mount_id is not None:
m, _ = self._load(mount_id)
mp = self.mountpoint_for(m.slug)
if self.backend.is_mounted(mp):
target = mp
report.step("mount", True, "bereits eingehängt")
if target is None:
self.mount_base.mkdir(mode=0o755, parents=True, exist_ok=True)
temp = self.mount_base / f".test-{uuid.uuid4().hex[:12]}"
temp.mkdir(mode=0o700)
try:
self.backend.mount(build_spec(cfg, temp, cred), timeout=self.mount_timeout)
except (MountError, ValidationError) as exc:
self._remove_mountpoint(temp)
report.step("mount", False, redact(str(exc), secrets))
report.step("read", None)
report.step("write", None)
return report
target = temp
report.step("mount", True, "Einhängen erfolgreich")
try:
self._check_read_write(cfg, target, report)
finally:
if temp is not None:
try:
self.backend.unmount(temp, timeout=self.mount_timeout)
except MountError:
try:
self.backend.unmount(temp, lazy=True, timeout=self.mount_timeout)
except MountError as exc:
event(log, "check.unmount_failed", level=40, path=str(temp), error=str(exc))
if not self.backend.is_mounted(temp):
self._remove_mountpoint(temp)
return report
def _check_read_write(self, cfg: MountConfig, target: Path, report: CheckReport) -> None:
name = cfg.name
try:
count = run_with_timeout(
lambda: len(os.listdir(target)), self.probe_timeout, f"read:{name}"
)
report.can_read = True
report.step("read", True, f"{count} Einträge")
except (OSError, OperationTimeout) as exc:
report.step("read", False, str(exc))
report.step("write", None)
return
def write_probe() -> tuple[OSError | None, int | None, int | None, OSError | None]:
# Punkt am Anfang + .tmp: ein parallel laufender Mover fasst sie nie an.
path = target / f".netmover-test-{uuid.uuid4().hex}.tmp"
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
try:
os.write(fd, b"netmover")
os.fsync(fd)
finally:
os.close(fd)
except OSError as exc:
return exc, None, None, None
st = os.stat(path)
try:
path.unlink()
except OSError as exc:
return None, st.st_uid, st.st_gid, exc
return None, st.st_uid, st.st_gid, None
try:
werr, uid, gid, derr = run_with_timeout(
write_probe, self.probe_timeout, f"write:{name}"
)
except OperationTimeout as exc:
report.step("write", False, str(exc))
return
result = assess_write(cfg.type, self._euid(), werr, uid, derr)
report.can_write = result.write_ok
report.can_delete = result.delete_ok
report.root_squash = result.root_squash
report.hints.extend(result.hints)
if uid is not None:
report.owner = f"{uid}:{gid}"
report.step("write", result.write_ok, result.message)
if cfg.type is MountType.CIFS and result.write_ok:
report.hints.append(
"CIFS: Eigentümer und Rechte bestimmen die Mount-Optionen uid/gid/file_mode – "
"chown/chmod nach dem Verschieben werden übersprungen."
)
try:
st = run_with_timeout(lambda: os.statvfs(target), self.probe_timeout, f"statvfs:{name}")
report.total_bytes = st.f_blocks * st.f_frsize
report.free_bytes = st.f_bavail * st.f_frsize
except (OSError, OperationTimeout):
pass
def _store_check(self, mount_id: int, report: CheckReport, cfg: MountConfig) -> None:
with session_scope(self.session_factory) as s:
m = s.get(Mount, mount_id)
if m is None:
return
m.last_test_at = utcnow()
m.last_test_result = report.to_json()
m.write_ok = report.can_write
if report.root_squash is not None:
m.root_squash = report.root_squash
# SFTP: Hostschlüssel beim ersten erfolgreichen Test festhalten (TOFU).
if cfg.type is MountType.SFTP and m.host_key is None and report.can_read:
m.host_key = report.host_key
event(log, "sftp.host_key_pinned", mount=m.name, fp=report.host_key_fingerprint)
# -- Lebenszeichen und Wiederherstellung --------------------------------------
def check_alive(self, mount_id: int) -> Path | None:
"""Vor jedem Lauf. Liefert den Mountpoint (None bei SFTP) oder wirft
:class:`MountUnavailable`. Stellt gestörte Mounts selbst wieder her."""
with self._lock(mount_id):
m, cred = self._load(mount_id)
if not m.enabled:
raise MountUnavailable(f"Anbindung „{m.name}“ ist deaktiviert")
if m.type is MountType.SFTP:
self._check_alive_sftp(mount_id, m, cred)
return None
mp = self.mountpoint_for(m.slug)
if not self.backend.is_mounted(mp):
msg = f"„{m.name}“ ist nicht eingehängt – Lauf abgebrochen, nichts angefasst."
self._set_state(mount_id, MountState.ERROR, msg)
raise MountUnavailable(msg)
try:
self._probe(m.name, mp)
except OperationTimeout as exc:
self._recover(mount_id, m, cred, f"hängt: {exc}")
except OSError as exc:
if exc.errno not in RECOVERABLE_ERRNOS:
msg = f"„{m.name}“: {exc.strerror} ({exc.filename})"
self._set_state(mount_id, MountState.ERROR, msg)
raise MountUnavailable(msg) from None
self._recover(
mount_id, m, cred, f"{errno.errorcode.get(exc.errno or 0, '?')}: {exc.strerror}"
)
else:
if m.state is not MountState.MOUNTED:
self._set_state(mount_id, MountState.MOUNTED)
return mp
def _check_alive_sftp(self, mount_id: int, m: Mount, cred: CredentialData | None) -> None:
try:
scfg = sftp_config(MountConfig.from_model(m), cred)
def probe() -> None:
with connect(scfg, timeout=self.probe_timeout) as sess:
sess.client.stat(scfg.base_path)
run_with_timeout(probe, self.probe_timeout * 2, f"probe:{m.name}")
except (SftpError, ValidationError, OSError, OperationTimeout) as exc:
msg = f"„{m.name}“ nicht erreichbar: {exc}"
self._set_state(mount_id, MountState.ERROR, msg)
if m.state is not MountState.ERROR:
self._notify("mount.error", m.name, msg)
raise MountUnavailable(msg) from None
if m.state is not MountState.MOUNTED:
self._set_state(mount_id, MountState.MOUNTED)
def _recover(self, mount_id: int, m: Mount, cred: CredentialData | None, reason: str) -> None:
mp = self.mountpoint_for(m.slug)
self._set_state(mount_id, MountState.DEGRADED, f"gestört ({reason}) – wird neu eingehängt")
event(log, "mount.degraded", level=30, mount=m.name, reason=reason)
last: str = reason
for attempt, delay in enumerate(self.recover_backoff, start=1):
if delay:
self._sleep(delay)
try:
if self.backend.is_mounted(mp):
self.backend.unmount(mp, lazy=True, timeout=self.mount_timeout)
self._prepare_mountpoint(mp)
self.backend.mount(
build_spec(MountConfig.from_model(m), mp, cred), timeout=self.mount_timeout
)
self._probe(m.name, mp)
except (MountError, OSError, OperationTimeout, ValidationError) as exc:
last = str(exc)
event(
log, "mount.recover_failed", level=30, mount=m.name, attempt=attempt, error=last
)
continue
self._set_state(mount_id, MountState.MOUNTED, mounted=True)
event(log, "mount.recovered", mount=m.name, attempt=attempt)
return
msg = (
f"„{m.name}“ gestört ({reason}); Neu-Einhängen nach "
f"{len(self.recover_backoff)} Versuchen gescheitert: {last}"
)
self._set_state(mount_id, MountState.ERROR, msg)
self._notify("mount.error", m.name, msg)
raise MountUnavailable(msg)
def usage(self, mount_id: int) -> tuple[int, int] | None:
"""(gesamt, frei) in Bytes – nur bei eingehängten Kernel-Mounts."""
m, _ = self._load(mount_id)
if not m.type.is_kernel_mount:
return None
mp = self.mountpoint_for(m.slug)
if not self.backend.is_mounted(mp):
return None
try:
st = run_with_timeout(lambda: os.statvfs(mp), self.probe_timeout, f"statvfs:{m.name}")
except (OSError, OperationTimeout):
return None
return st.f_blocks * st.f_frsize, st.f_bavail * st.f_frsize
+68
View File
@@ -0,0 +1,68 @@
"""``/proc/self/mountinfo`` lesen (Fallstrick 2).
Ob ein Verzeichnis eingehängt ist, entscheidet ausschließlich der Kernel – nie
das Vorhandensein von Dateien. Ein leerer Mountpoint sieht sonst genauso aus wie
eine leere Freigabe.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from pathlib import Path
MOUNTINFO = Path("/proc/self/mountinfo")
_OCTAL_RE = re.compile(r"\\([0-7]{3})")
def _unescape(field: str) -> str:
# Der Kernel kodiert Leerzeichen, Tab, Zeilenumbruch und Backslash oktal.
return _OCTAL_RE.sub(lambda m: chr(int(m.group(1), 8)), field)
@dataclass(frozen=True)
class MountEntry:
mountpoint: str
fstype: str
source: str
options: str
def parse_mountinfo(text: str) -> list[MountEntry]:
entries: list[MountEntry] = []
for line in text.splitlines():
if not line.strip():
continue
left, sep, right = line.partition(" - ")
if not sep:
continue
lf = left.split(" ")
rf = right.split(" ")
if len(lf) < 6 or len(rf) < 3:
continue
entries.append(
MountEntry(
mountpoint=_unescape(lf[4]),
fstype=rf[0],
source=_unescape(rf[1]),
options=rf[2],
)
)
return entries
def read_mountinfo(path: Path = MOUNTINFO) -> list[MountEntry]:
return parse_mountinfo(path.read_text(encoding="utf-8", errors="surrogateescape"))
def find_mount(mountpoint: Path, entries: list[MountEntry]) -> MountEntry | None:
"""Liefert den obersten Eintrag für genau diesen Mountpoint (bei Übereinander-
Mounts gewinnt der zuletzt eingehängte)."""
target = os.path.normpath(str(mountpoint))
found: MountEntry | None = None
for entry in entries:
if entry.mountpoint == target:
found = entry
return found
+134
View File
@@ -0,0 +1,134 @@
"""Ergebnis eines Mount-Tests: drei getrennte Schritte plus abgeleitete Rollen.
Fallstrick 12: Der Schreibtest erkennt Root-Squash auf NFS und nennt Ursache
und Abhilfe im Klartext.
"""
from __future__ import annotations
import errno
import json
from dataclasses import asdict, dataclass, field
from enum import StrEnum
from netmover.models import MountType
class Role(StrEnum):
SOURCE_COPY = "nur Quelle, kopieren"
SOURCE_MOVE = "Quelle mit Löschen"
TARGET = "Ziel"
SYNOLOGY_SQUASH_HINT = (
"Synology: Systemsteuerung → Gemeinsamer Ordner → Ordner wählen → Bearbeiten → "
"NFS-Berechtigungen → Regel bearbeiten → Squash: „Keine Zuordnung“ "
"(No mapping) oder „Alle Benutzer zu admin zuordnen“."
)
@dataclass
class CheckStep:
name: str # "mount" | "read" | "write" (bei sftp: "connect")
ok: bool | None # None = nicht ausgeführt
message: str = ""
@dataclass
class CheckReport:
steps: list[CheckStep] = field(default_factory=list)
can_read: bool = False
can_write: bool = False
can_delete: bool = False
root_squash: bool | None = None
owner: str | None = None
total_bytes: int | None = None
free_bytes: int | None = None
hints: list[str] = field(default_factory=list)
host_key: str | None = None # sftp: festgestellter Hostschlüssel
host_key_fingerprint: str | None = None
@property
def ok(self) -> bool:
return all(s.ok is not False for s in self.steps) and self.can_read
@property
def roles(self) -> list[Role]:
roles: list[Role] = []
if self.can_read:
roles.append(Role.SOURCE_MOVE if self.can_delete else Role.SOURCE_COPY)
if self.can_write:
roles.append(Role.TARGET)
return roles
def step(self, name: str, ok: bool | None, message: str = "") -> CheckStep:
s = CheckStep(name, ok, message)
self.steps.append(s)
return s
def to_json(self) -> str:
data = asdict(self)
data["roles"] = [r.value for r in self.roles]
data["ok"] = self.ok
return json.dumps(data, ensure_ascii=False)
@dataclass
class WriteAssessment:
write_ok: bool
delete_ok: bool
root_squash: bool | None
message: str
hints: list[str]
def assess_write(
mount_type: MountType,
euid: int,
write_error: OSError | None,
created_uid: int | None,
delete_error: OSError | None,
) -> WriteAssessment:
"""Reine Auswertung des Schreibtests – getrennt von den Dateizugriffen, damit
sich Root-Squash ohne root und ohne NAS testen lässt."""
is_nfs = mount_type in (MountType.NFS3, MountType.NFS4)
hints: list[str] = []
if write_error is not None:
denied = write_error.errno in (errno.EACCES, errno.EPERM)
if is_nfs and euid == 0 and denied:
hints.append(
"Schreiben als root wurde abgelehnt – vermutlich Root-Squash: Der NFS-Server "
"bildet root auf „nobody“ ab, und „nobody“ darf hier nicht schreiben. "
+ SYNOLOGY_SQUASH_HINT
)
return WriteAssessment(False, False, True, "Schreiben verweigert (Root-Squash?)", hints)
if write_error.errno == errno.EROFS:
return WriteAssessment(False, False, None, "Freigabe ist schreibgeschützt", hints)
if denied:
hints.append("Keine Schreibrechte – als Quelle nur zum Kopieren geeignet.")
return WriteAssessment(
False, False, None, f"Schreiben fehlgeschlagen: {write_error.strerror}", hints
)
root_squash: bool | None = None
message = "Datei angelegt und gelöscht"
if is_nfs and euid == 0 and created_uid is not None:
root_squash = created_uid != 0
if root_squash:
message = f"Schreiben möglich, Datei gehört aber uid {created_uid} (Root-Squash)"
hints.append(
"Root-Squash aktiv: Dateien gehören nicht root, „chown“ nach dem Verschieben "
"ist nicht möglich und wird übersprungen. Wer Eigentümer setzen will: "
+ SYNOLOGY_SQUASH_HINT
)
if delete_error is not None:
return WriteAssessment(
True,
False,
root_squash,
f"Angelegt, aber Löschen fehlgeschlagen: {delete_error.strerror}",
[*hints, "Testdatei konnte nicht gelöscht werden – bitte von Hand entfernen."],
)
return WriteAssessment(True, True, root_squash, message, hints)
+260
View File
@@ -0,0 +1,260 @@
"""SFTP-Anbindung für Ziele, die nur per SSH erreichbar sind (z. B. ein LXC).
Kein Kernel-Mount – daher kein Hängen im Kernel, aber auch kein ``ismount``.
Der Hostschlüssel wird beim ersten Test festgehalten (Trust on first use) und ist
danach verbindlich; eine Abweichung bricht jede Verbindung ab.
"""
from __future__ import annotations
import base64
import contextlib
import hashlib
import io
import posixpath
import socket
import stat
import uuid
from collections.abc import Iterator
from dataclasses import dataclass
from types import TracebackType
import paramiko
from paramiko.sftp import CMD_EXTENDED, CMD_EXTENDED_REPLY
from netmover.crypto import redact
from netmover.mounting.report import CheckReport
class SftpError(RuntimeError):
pass
class HostKeyMismatch(SftpError):
pass
class HostKeyUnknown(SftpError):
pass
@dataclass(frozen=True)
class SftpConfig:
host: str
port: int
username: str
base_path: str
password: str | None = None
private_key: str | None = None
host_key: str | None = None # "<typ> <base64>"
def __repr__(self) -> str:
return (
f"SftpConfig(host={self.host!r}, port={self.port}, username={self.username!r}, "
f"base_path={self.base_path!r})"
)
@property
def secrets(self) -> list[str | None]:
return [self.password, self.private_key]
def fingerprint(key: paramiko.PKey) -> str:
digest = hashlib.sha256(key.asbytes()).digest()
return "SHA256:" + base64.b64encode(digest).decode().rstrip("=")
def host_key_string(key: paramiko.PKey) -> str:
return f"{key.get_name()} {key.get_base64()}"
def load_private_key(pem: str) -> paramiko.PKey:
errors: list[str] = []
key_classes: list[type[paramiko.PKey]] = [
paramiko.Ed25519Key,
paramiko.ECDSAKey,
paramiko.RSAKey,
]
for cls in key_classes:
try:
return cls.from_private_key(io.StringIO(pem))
except (paramiko.SSHException, ValueError) as exc:
errors.append(f"{cls.__name__}: {exc}")
raise SftpError("Privater Schlüssel nicht lesbar (Ed25519, ECDSA oder RSA ohne Passphrase)")
class SftpSession:
def __init__(self, transport: paramiko.Transport, client: paramiko.SFTPClient) -> None:
self.transport = transport
self.client = client
key = transport.get_remote_server_key()
self.host_key = host_key_string(key)
self.fingerprint = fingerprint(key)
def close(self) -> None:
with contextlib.suppress(Exception):
self.client.close()
with contextlib.suppress(Exception):
self.transport.close()
def __enter__(self) -> SftpSession:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
self.close()
# -- Erweiterungen von OpenSSH (paramiko bietet sie nicht direkt an) -----------
def statvfs(self, path: str) -> tuple[int, int] | None:
"""(gesamt, frei für Nutzer) in Bytes über ``statvfs@openssh.com``, sonst None."""
try:
t, msg = self.client._request( # type: ignore[attr-defined]
CMD_EXTENDED, "statvfs@openssh.com", path
)
except (OSError, paramiko.SSHException):
return None
if t != CMD_EXTENDED_REPLY or msg is None:
return None
_bsize, frsize, blocks, _bfree, bavail = (msg.get_int64() for _ in range(5))
return blocks * frsize, bavail * frsize
def fsync(self, handle: paramiko.SFTPFile) -> bool:
"""``fsync@openssh.com`` auf eine offene Datei; False, wenn nicht unterstützt."""
try:
self.client._request( # type: ignore[attr-defined]
CMD_EXTENDED, "fsync@openssh.com", handle.handle
)
except (OSError, paramiko.SSHException):
return False
return True
def connect(
cfg: SftpConfig, timeout: float = 20, allow_unknown_host_key: bool = False
) -> SftpSession:
try:
sock = socket.create_connection((cfg.host, cfg.port), timeout=timeout)
except OSError as exc:
raise SftpError(f"Verbindung zu {cfg.host}:{cfg.port} fehlgeschlagen: {exc}") from None
transport = paramiko.Transport(sock)
transport.banner_timeout = timeout
transport.auth_timeout = timeout
try:
transport.start_client(timeout=timeout)
server_key = transport.get_remote_server_key()
seen = host_key_string(server_key)
if cfg.host_key is None:
if not allow_unknown_host_key:
raise HostKeyUnknown(
"Hostschlüssel noch nicht bestätigt – bitte die Anbindung zuerst testen."
)
elif cfg.host_key.split()[:2] != seen.split()[:2]:
raise HostKeyMismatch(
f"Hostschlüssel von {cfg.host} hat sich geändert "
f"(jetzt {fingerprint(server_key)}). "
"Wurde der Server neu aufgesetzt? Dann Hostschlüssel in der Anbindung "
"zurücksetzen und neu testen – sonst nicht verbinden!"
)
if cfg.private_key:
transport.auth_publickey(cfg.username, load_private_key(cfg.private_key))
elif cfg.password is not None:
transport.auth_password(cfg.username, cfg.password)
else:
raise SftpError("Keine Zugangsdaten hinterlegt")
client = paramiko.SFTPClient.from_transport(transport)
if client is None:
raise SftpError("SFTP-Subsystem nicht verfügbar")
client.get_channel().settimeout(timeout) # type: ignore[union-attr]
return SftpSession(transport, client)
except paramiko.AuthenticationException:
transport.close()
raise SftpError(
"Anmeldung abgelehnt – Benutzername prüfen und den öffentlichen Schlüssel in "
"~/.ssh/authorized_keys des Zielbenutzers eintragen."
) from None
except (paramiko.SSHException, OSError, EOFError) as exc:
transport.close()
raise SftpError(redact(f"SSH-Fehler: {exc}", cfg.secrets)) from None
except SftpError:
transport.close()
raise
@contextlib.contextmanager
def session(cfg: SftpConfig, timeout: float = 20) -> Iterator[SftpSession]:
s = connect(cfg, timeout=timeout)
try:
yield s
finally:
s.close()
def check_connection(cfg: SftpConfig, timeout: float = 20) -> CheckReport:
"""Verbinden, Basisordner lesen, Testdatei schreiben/umbenennen/löschen."""
report = CheckReport()
try:
sess = connect(cfg, timeout=timeout, allow_unknown_host_key=True)
except SftpError as exc:
report.step("connect", False, str(exc))
report.step("read", None)
report.step("write", None)
return report
with sess:
report.host_key = sess.host_key
report.host_key_fingerprint = sess.fingerprint
new = " (neu – bitte Fingerabdruck prüfen)" if cfg.host_key is None else ""
report.step("connect", True, f"Hostschlüssel {sess.fingerprint}{new}")
try:
attrs = sess.client.stat(cfg.base_path)
if attrs.st_mode is None or not stat.S_ISDIR(attrs.st_mode):
raise SftpError(f"{cfg.base_path} ist kein Verzeichnis")
count = len(sess.client.listdir(cfg.base_path))
report.can_read = True
report.step("read", True, f"{count} Einträge")
except (OSError, SftpError) as exc:
report.step("read", False, f"{cfg.base_path}: {exc}")
report.step("write", None)
return report
token = uuid.uuid4().hex
part = posixpath.join(cfg.base_path, f".netmover-test-{token}.part")
final = posixpath.join(cfg.base_path, f".netmover-test-{token}.tmp")
try:
with sess.client.open(part, "wb") as fh:
fh.write(b"netmover")
fh.flush()
synced = sess.fsync(fh)
sess.client.posix_rename(part, final)
st = sess.client.stat(final)
report.owner = f"{st.st_uid}:{st.st_gid}"
report.can_write = True
except OSError as exc:
report.step("write", False, f"Schreiben fehlgeschlagen: {exc}")
with contextlib.suppress(OSError):
sess.client.remove(part)
return report
try:
sess.client.remove(final)
report.can_delete = True
report.step(
"write",
True,
"Datei angelegt, atomar umbenannt und gelöscht"
+ ("" if synced else " (Server unterstützt kein fsync – Hinweis)"),
)
except OSError as exc:
report.step("write", True, f"Angelegt, aber Löschen fehlgeschlagen: {exc}")
usage = sess.statvfs(cfg.base_path)
if usage:
report.total_bytes, report.free_bytes = usage
else:
report.hints.append(
"Server meldet keinen freien Speicher (statvfs) – Platzprüfung wird übersprungen."
)
return report