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:
co-authored by
Claude Opus 5.5
parent
689ddf0d94
commit
e5ed8c4d88
@@ -0,0 +1 @@
|
||||
"""Mount-Schicht: Backends (Linux/Fake), Verwaltung, Tests, SFTP-Anbindung."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user