Files
proxmox-snapshot-service/pvesnap/transfer.py
T
duffyduckandClaude Opus 5 2b895e6f4f Transfer-Laufwerke: nur noch exFAT, Oberflaeche und Anbindung
Ein einziges Dateisystem, mit Absicht: exFAT koennen Windows und Linux beide
von Haus aus, es kennt keine Besitzrechte auf dem Datentraeger und hat anders
als FAT32 keine 4-GB-Grenze je Datei. Die Auswahl faellt damit weg - eine
Entscheidung weniger, wenn es schnell gehen muss.

Neu in der Oberflaeche:

  Taste v          Uebersicht der Laufwerke mit Zustand
  Enter darauf     haengt am Host ein und startet den Commander - links das
                   Laufwerk, rechts der eigene Rechner
  n / u / l        anlegen, aushaengen, loeschen
  Optionsmaske     Mehrfachauswahl statt Groessenangabe
  --transfer NAME  dasselbe auf der Kommandozeile, mehrfach moeglich

Am laufenden System durchgespielt: Laufwerk angelegt, vom Host ein Skript
hineingelegt, Wiederherstellung damit gestartet, im Gast gelesen, ausgefuehrt
und geschrieben, Wiederherstellung verworfen - das Laufwerk lebte samt
Ergebnis weiter.

Zwei Rechtefallen dabei gefunden und geschlossen:

  Der Gast konnte lesen, aber nicht schreiben. exFAT hat keine Besitzrechte
  auf der Platte, die entstehen beim Einhaengen - mit der Vorgabe gehoert
  alles root, und der root eines unprivilegierten Containers ist auf dem Host
  uid 100000. Wird jetzt mit umask=0000 eingehaengt.

  Ein durchgereichtes Laufwerk galt als "am Host eingehaengt", weil beim
  Container genau das der Fall ist. Wer das sah, haette es aushaengen und dem
  laufenden Gast den Boden wegziehen koennen. Die Belegung wird jetzt auch aus
  Container-Konfigurationen gelesen, der Gast hat Vorrang in der Anzeige, und
  Aushaengen wird abgelehnt, solange jemand darauf arbeitet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:12:18 +02:00

629 lines
22 KiB
Python

"""Transfer-Laufwerke - benannte Austauschmedien zwischen Host und Gast.
Ein wiederhergestellter Snapshot laeuft ohne Netzwerk. Damit trotzdem etwas
hinein- und wieder herauskommt, gibt es Transfer-Laufwerke: schlichte
Abbilddateien, die auf dem Host ganz normal eingehaengt werden koennen und sich
einem Gast als zusaetzliche Platte anhaengen lassen.
/var/lib/pvesnap/transfer/dumps.img 20G "Datenbank-Dumps"
/var/lib/pvesnap/transfer/werkzeuge.img 2G "Skripte und Tools"
Sie gehoeren zu keinem Gast und ueberleben jede Wiederherstellung. Man legt sie
einmal an, befuellt sie in Ruhe, und waehlt sie beim naechsten Notfall nur noch
aus einer Liste aus.
Warum eine Datei und kein Proxmox-Volume?
* Ein Proxmox-Volume wuerde beim Entfernen der Maschine mitgeloescht - und
zwar auch dann, wenn jemand sie in der Weboberflaeche wegwirft. Die
muehsam vorbereitete Werkzeugsammlung waere weg.
* Bei Pfaden ist das ausgeschlossen: PVE::QemuServer::destroy_vm ueberspringt
sie ausdruecklich ("return if $volid =~ m|^/|").
Angehaengt wird ueber ein Loop-Geraet, weil Proxmox als Pfad nur /dev/...
akzeptiert - eine Abbilddatei direkt einzutragen lehnt es ab ("unable to
associate path to any storage").
Damit sich Host und Gast nicht gegenseitig das Dateisystem zerlegen, darf ein
Laufwerk immer nur an einer Stelle in Benutzung sein. Darueber wacht diese
Schicht.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import time
from dataclasses import dataclass, field
log = logging.getLogger("pvesnap.transfer")
TRANSFER_DIR = "/var/lib/pvesnap/transfer"
INDEX = os.path.join(TRANSFER_DIR, "index.json")
MOUNT_ROOT = "/run/pvesnap/transfer"
CONF_ROOT = "/etc/pve/nodes"
NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$")
# Ein einziges Dateisystem, mit Absicht. exFAT koennen Windows und Linux beide
# von Haus aus, es kennt keine Besitzrechte auf dem Datentraeger (damit kann
# das Rechteproblem gar nicht erst auftreten, das ext4 hier hatte) und anders
# als FAT32 gibt es keine 4-GB-Grenze je Datei. Eine Auswahl waere eine
# Entscheidung mehr im Notfall - und genau die soll wegfallen.
FILESYSTEM = "exfat"
MKFS = ["mkfs.exfat", "-L"]
PACKAGE = "exfatprogs"
LABEL_MAX = 11 # mehr laesst exFAT nicht zu
DEFAULT_SIZE = "20G"
def formatter_missing():
"""Meldung, falls sich hier nichts anlegen laesst - sonst ''."""
if have(MKFS[0]):
return ""
return ("%s fehlt - ohne das lassen sich keine Transfer-Laufwerke anlegen.\n"
" apt install %s (klein, entfernt nichts)\n"
"Oder ./install.sh erneut aufrufen, das erledigt es mit."
% (MKFS[0], PACKAGE))
README = """Transfer-Laufwerk von pvesnap
Dieses Laufwerk gehoert zu keiner Maschine. Es dient nur dazu, Dateien
zwischen dem Proxmox-Host und einem wiederhergestellten Snapshot hin und her
zu schaufeln - der hat ja kein Netzwerk.
Im Gast : Datentraeger mit der Bezeichnung %s einhaengen
Auf dem Host: pvesnap-recovery -> Taste v -> Enter
Alles, was hier liegt, bleibt erhalten - auch wenn die Wiederherstellung
laengst verworfen ist.
"""
class TransferError(Exception):
"""Ein Transfer-Laufwerk liess sich nicht anlegen oder benutzen."""
# ---------------------------------------------------------------------------
# Kommandos
# ---------------------------------------------------------------------------
def run(args, timeout=300, check=True):
args = [str(a) for a in args]
args[0] = which(args[0]) or args[0]
log.debug("exec: %s", " ".join(args))
try:
proc = subprocess.run(args, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=timeout)
except FileNotFoundError:
raise TransferError("Kommando nicht gefunden: %s" % args[0])
except subprocess.TimeoutExpired:
raise TransferError("Zeitueberschreitung bei: %s" % " ".join(args))
stdout = proc.stdout.decode("utf-8", "replace").strip()
stderr = proc.stderr.decode("utf-8", "replace").strip()
if check and proc.returncode != 0:
detail = (stderr or stdout or "Fehler").splitlines()
raise TransferError("%s: %s" % (args[0], detail[-1] if detail else "Fehler"))
return stdout
# Die Formatierer liegen in sbin. Wer pvesnap aus einer Umgebung mit magerem
# PATH aufruft - etwa als systemd-Dienst - findet sie sonst nicht, obwohl sie
# da sind.
_SBIN = ("/usr/local/sbin", "/usr/sbin", "/sbin")
def which(command):
found = shutil.which(command)
if found:
return found
for directory in _SBIN:
candidate = os.path.join(directory, command)
if os.access(candidate, os.X_OK):
return candidate
return None
def have(command):
return which(command) is not None
def human_bytes(count):
value = float(count or 0)
for unit in ("B", "K", "M", "G", "T"):
if value < 1024 or unit == "T":
return "%d%s" % (value, unit) if unit == "B" else "%.1f%s" % (value, unit)
value /= 1024
return "%.1fT" % value
_SIZE = re.compile(r"^\s*(\d+(?:[.,]\d+)?)\s*([KMGT])?i?B?\s*$", re.I)
_FACTOR = {"K": 1024, "M": 1024 ** 2, "G": 1024 ** 3, "T": 1024 ** 4}
def parse_size(text, default_unit="G"):
match = _SIZE.match(str(text or ""))
if not match:
raise TransferError("Groessenangabe nicht verstanden: %r (erwartet z.B. 20G)"
% text)
value = float(match.group(1).replace(",", "."))
return max(1024 ** 2, int(value * _FACTOR[(match.group(2) or default_unit).upper()]))
# ---------------------------------------------------------------------------
# Datenmodell
# ---------------------------------------------------------------------------
@dataclass
class Volume:
name: str
image: str = ""
size: int = 0
fs: str = FILESYSTEM
label: str = ""
note: str = ""
created: int = 0
partitioned: bool = True
# zur Laufzeit ermittelt, nicht gespeichert
loop: str = ""
mountpoint: str = ""
attached_to: int = 0
@property
def state(self):
# Beim Container haelt der Host den Mountpunkt und reicht ihn per
# Bind-Mount durch - das ist dasselbe Dateisystem an zwei Stellen, kein
# zweites Einhaengen. Der Gast hat trotzdem Vorrang in der Anzeige,
# sonst haengt jemand aus, was gerade benutzt wird.
if self.attached_to:
return "guest"
if self.mountpoint:
return "host"
return "frei"
@property
def state_text(self):
return {"host": "am Host eingehaengt",
"guest": "in Benutzung von %d" % self.attached_to,
"frei": "frei"}[self.state]
@property
def free(self):
return self.state == "frei"
@property
def used_bytes(self):
try:
return os.stat(self.image).st_blocks * 512
except OSError:
return 0
def to_dict(self):
return {k: getattr(self, k) for k in
("name", "image", "size", "fs", "label", "note", "created",
"partitioned")}
@classmethod
def from_dict(cls, data):
known = {k: data.get(k) for k in
("name", "image", "size", "fs", "label", "note", "created",
"partitioned") if data.get(k) is not None}
return cls(**known)
# ---------------------------------------------------------------------------
# Verzeichnis und Verzeichnisdatei
# ---------------------------------------------------------------------------
def _index_load():
try:
with open(INDEX, "r", encoding="utf-8") as handle:
data = json.load(handle)
return [Volume.from_dict(e) for e in data] if isinstance(data, list) else []
except (OSError, ValueError, TypeError):
return []
def _index_save(volumes):
try:
os.makedirs(TRANSFER_DIR, exist_ok=True)
tmp = INDEX + ".tmp"
with open(tmp, "w", encoding="utf-8") as handle:
json.dump([v.to_dict() for v in volumes], handle, indent=1)
os.replace(tmp, INDEX)
except OSError as exc:
raise TransferError("Verzeichnisdatei %s nicht schreibbar: %s" % (INDEX, exc))
def _index_put(volume):
volumes = [v for v in _index_load() if v.name != volume.name]
volumes.append(volume)
_index_save(sorted(volumes, key=lambda v: v.name))
def _index_drop(name):
_index_save([v for v in _index_load() if v.name != name])
# ---------------------------------------------------------------------------
# Zustand aus der Wirklichkeit lesen
# ---------------------------------------------------------------------------
def _loop_devices():
"""{Abbildpfad: Loop-Geraet}"""
try:
data = json.loads(run(["losetup", "--json", "--list"], check=False) or "{}")
except ValueError:
return {}
result = {}
for entry in data.get("loopdevices") or []:
backing = entry.get("back-file") or ""
# geloeschte Abbilder haengt losetup ein " (deleted)" an
backing = backing.split(" (deleted)")[0]
if backing:
result[os.path.realpath(backing)] = entry.get("name") or ""
return result
def _mounts():
"""{Geraet: Mountpunkt}"""
result = {}
try:
with open("/proc/mounts", "r", encoding="utf-8") as handle:
for line in handle:
parts = line.split()
if len(parts) >= 2:
result[parts[0]] = parts[1].replace("\\040", " ")
except OSError:
pass
return result
def _guest_usage():
"""{Loop-Geraet: VMID} - wer haelt gerade welches Geraet?
Gelesen aus den Gast-Konfigurationen, weil das die Wahrheit ist: dort
steht der Eintrag auch dann, wenn die Maschine gerade nicht laeuft.
"""
usage = {}
# Bei virtuellen Maschinen steht das Loop-Geraet in der Konfiguration, bei
# Containern der durchgereichte Mountpunkt. Beides muss erkannt werden -
# sonst gilt ein Laufwerk als frei, obwohl ein Container darauf arbeitet.
device = re.compile(r"(/dev/loop\d+|%s/[a-z0-9_-]+)"
% re.escape(MOUNT_ROOT))
try:
nodes = os.listdir(CONF_ROOT)
except OSError:
return usage
for node in nodes:
for sub in ("qemu-server", "lxc"):
directory = os.path.join(CONF_ROOT, node, sub)
try:
names = os.listdir(directory)
except OSError:
continue
for name in names:
if not (name.endswith(".conf") and name[:-5].isdigit()):
continue
try:
with open(os.path.join(directory, name), "r",
encoding="utf-8", errors="replace") as handle:
text = handle.read()
except OSError:
continue
for found in device.findall(text):
usage[found] = int(name[:-5])
return usage
def load():
"""Alle Transfer-Laufwerke mit ihrem tatsaechlichen Zustand."""
loops = _loop_devices()
mounts = _mounts()
usage = _guest_usage()
volumes = []
for volume in _index_load():
if not os.path.exists(volume.image):
volume.note = (volume.note + " [Abbilddatei fehlt]").strip()
volumes.append(volume)
continue
volume.attached_to = usage.get(os.path.join(MOUNT_ROOT, volume.name), 0)
volume.loop = loops.get(os.path.realpath(volume.image), "")
if volume.loop:
# Bei partitionierten Abbildern haengt das Dateisystem auf ...p1
for candidate in (volume.loop + "p1", volume.loop):
if candidate in mounts:
volume.mountpoint = mounts[candidate]
break
volume.attached_to = volume.attached_to or usage.get(volume.loop, 0)
volumes.append(volume)
return volumes
def get(name):
for volume in load():
if volume.name == name:
return volume
raise TransferError("Kein Transfer-Laufwerk mit dem Namen %r. "
"Vorhandene zeigt: pvesnap-recovery transfer list" % name)
# ---------------------------------------------------------------------------
# Anlegen
# ---------------------------------------------------------------------------
def create(name, size=DEFAULT_SIZE, note="", partitioned=True, progress=None):
"""Ein neues Transfer-Laufwerk anlegen und formatieren."""
def step(text):
log.info("%s", text)
if progress:
progress(text)
name = str(name or "").strip().lower()
if not NAME_PATTERN.match(name):
raise TransferError("Ungueltiger Name %r - erlaubt sind Kleinbuchstaben, "
"Ziffern, Bindestrich und Unterstrich (max. 31)." % name)
if any(v.name == name for v in _index_load()):
raise TransferError("Ein Transfer-Laufwerk namens %r gibt es schon." % name)
fehlt = formatter_missing()
if fehlt:
raise TransferError(fehlt)
if partitioned and not have("parted"):
raise TransferError("'parted' fehlt (apt install parted) - ohne "
"Partitionstabelle zeigt Windows den Datentraeger nur "
"als 'nicht initialisiert'.")
size_bytes = parse_size(size)
label = _label_for(name)
image = os.path.join(TRANSFER_DIR, "%s.img" % name)
os.makedirs(TRANSFER_DIR, exist_ok=True)
step("Lege %s an (%s, duenn belegt)" % (image, human_bytes(size_bytes)))
try:
with open(image, "wb") as handle:
handle.truncate(size_bytes)
except OSError as exc:
raise TransferError("%s nicht anlegbar: %s" % (image, exc))
volume = Volume(name=name, image=image, size=size_bytes, fs=FILESYSTEM,
label=label,
note=note, created=int(time.time()), partitioned=partitioned)
try:
if partitioned:
# Die Typ-Kennung "msftdata" (Microsoft basic data) ist der
# entscheidende Teil: nur darauf vergibt Windows von selbst einen
# Laufwerksbuchstaben. Ohne sie vergibt parted "Linux filesystem",
# und Windows zeigt die Partition zwar an, haengt sie aber nicht ein.
step("Partitionstabelle anlegen (GPT, eine Partition, msftdata)")
run(["parted", "-s", image, "mklabel", "gpt",
"mkpart", "primary", "1MiB", "100%",
"set", "1", "msftdata", "on"])
with _loop(image, partitioned) as device:
step("Formatieren mit %s, Bezeichnung %s" % (FILESYSTEM, label))
run(MKFS + [label, device], timeout=1800)
_place_readme(device, label)
except Exception:
try:
os.unlink(image)
except OSError:
pass
raise
_index_put(volume)
step("Fertig: %s" % name)
return volume
class _loop:
"""Ein Abbild voruebergehend als Blockgeraet - bei Bedarf die Partition."""
def __init__(self, image, partitioned=True):
self.image = image
self.partitioned = partitioned
self.device = ""
def __enter__(self):
self.device = attach_loop(self.image, self.partitioned)
return partition_of(self.device) if self.partitioned else self.device
def __exit__(self, *_exc):
detach_loop(self.device)
return False
def attach_loop(image, partitioned=True):
"""Abbild an ein freies Loop-Geraet haengen (oder das vorhandene liefern)."""
existing = _loop_devices().get(os.path.realpath(image))
if existing:
return existing
args = ["losetup", "--find", "--show"]
if partitioned:
args.insert(1, "-P") # Partitionen als /dev/loopNp1 anbieten
device = run(args + [image], timeout=120)
if not device.startswith("/dev/"):
raise TransferError("losetup lieferte keinen Geraetenamen: %s" % device)
run(["udevadm", "settle", "--timeout=10"], check=False)
return device
def detach_loop(device):
if device:
run(["losetup", "-d", device], check=False, timeout=60)
def partition_of(device):
"""/dev/loop0 -> /dev/loop0p1, falls es die Partition gibt."""
for _ in range(30):
candidate = device + "p1"
if os.path.exists(candidate):
return candidate
time.sleep(0.1)
return device
def _label_for(name):
"""Datentraegerbezeichnung - so, dass man sie im Gast wiederfindet.
Kurz gehalten, weil exFAT und FAT nur 11 Zeichen zulassen. Im Gast reicht
damit: mount /dev/disk/by-label/<NAME> /mnt
"""
clean = re.sub(r"[^A-Z0-9_-]", "", name.upper())
return clean[:LABEL_MAX] or "PVESNAP"
def _place_readme(device, label):
mountpoint = os.path.join(MOUNT_ROOT, ".neu-%d" % os.getpid())
try:
os.makedirs(mountpoint, exist_ok=True)
run(["mount", "-o", "umask=0000", device, mountpoint], timeout=120)
except (TransferError, OSError) as exc:
log.debug("Anleitung nicht ablegbar: %s", exc)
return
try:
# Sonst gehoert das Wurzelverzeichnis root - und im Gast sitzt davor
# ein gewoehnlicher Benutzer, der nichts hineinschreiben kann. Das
# sieht dann aus wie ein schreibgeschuetzter Datentraeger, ist aber
# eine Rechtefrage. Bei exFAT und FAT laeuft chmod ins Leere, weil sie
# ueberhaupt keine Besitzrechte kennen - dort ist es ohnehin egal.
try:
os.chmod(mountpoint, 0o777)
except OSError:
pass
with open(os.path.join(mountpoint, "LIESMICH.txt"), "w",
encoding="utf-8") as handle:
handle.write(README % label)
except OSError as exc:
log.debug("LIESMICH.txt nicht schreibbar: %s", exc)
finally:
run(["umount", mountpoint], check=False, timeout=120)
try:
os.rmdir(mountpoint)
except OSError:
pass
# ---------------------------------------------------------------------------
# Am Host benutzen
# ---------------------------------------------------------------------------
def host_mount(volume):
"""Auf dem Host einhaengen; liefert den Pfad."""
volume = get(volume.name if isinstance(volume, Volume) else volume)
if volume.mountpoint:
return volume.mountpoint
if volume.attached_to:
raise TransferError(
"%r haengt gerade an VM %d. Erst dort abhaengen - Host und Gast "
"duerfen nicht gleichzeitig darauf schreiben, das zerlegt das "
"Dateisystem." % (volume.name, volume.attached_to))
if not os.path.exists(volume.image):
raise TransferError("Abbilddatei fehlt: %s" % volume.image)
device = attach_loop(volume.image, volume.partitioned)
target = partition_of(device) if volume.partitioned else device
mountpoint = os.path.join(MOUNT_ROOT, volume.name)
os.makedirs(mountpoint, exist_ok=True)
try:
# umask=0000: exFAT kennt keine Besitzrechte auf dem Datentraeger, die
# entstehen erst beim Einhaengen. Mit der Vorgabe gehoert alles root -
# und ein unprivilegierter Container, dessen root auf dem Host die uid
# 100000 hat, darf dann nichts hineinschreiben. Fuer ein Austauschmedium
# ist das keine sinnvolle Grenze.
run(["mount", "-o", "umask=0000", target, mountpoint], timeout=120)
except TransferError:
detach_loop(device)
raise
log.info("Transfer-Laufwerk %r eingehaengt unter %s", volume.name, mountpoint)
return mountpoint
def host_umount(volume):
"""Wieder aushaengen und das Loop-Geraet freigeben."""
volume = get(volume.name if isinstance(volume, Volume) else volume)
if volume.attached_to:
raise TransferError(
"%r wird gerade von %d benutzt - aushaengen wuerde dem laufenden "
"Gast den Boden wegziehen." % (volume.name, volume.attached_to))
if volume.mountpoint:
run(["umount", volume.mountpoint], check=False, timeout=120)
try:
os.rmdir(volume.mountpoint)
except OSError:
pass
if volume.loop and not volume.attached_to:
detach_loop(volume.loop)
return True
# ---------------------------------------------------------------------------
# An einen Gast haengen
# ---------------------------------------------------------------------------
def drive_string(volume):
"""Der Wert, der in die Gast-Konfiguration geschrieben wird.
Proxmox nimmt als Pfad nur /dev/... - eine Abbilddatei lehnt es ab. Und
weil es ein Pfad ist, wird er beim Entfernen der Maschine nie freigegeben.
"""
volume = get(volume.name if isinstance(volume, Volume) else volume)
if volume.mountpoint:
raise TransferError(
"%r ist gerade auf dem Host eingehaengt (%s). Erst dort aushaengen."
% (volume.name, volume.mountpoint))
if volume.attached_to:
raise TransferError("%r haengt bereits an VM %d."
% (volume.name, volume.attached_to))
device = attach_loop(volume.image, volume.partitioned)
return "%s,backup=0" % device
def release(volume):
"""Nach dem Verwerfen einer Maschine das Loop-Geraet wieder freigeben."""
try:
volume = get(volume.name if isinstance(volume, Volume) else volume)
except TransferError:
return False
if volume.mountpoint or volume.attached_to:
return False
if volume.loop:
detach_loop(volume.loop)
return True
def remove(name, force=False):
"""Ein Transfer-Laufwerk endgueltig loeschen."""
volume = get(name)
if not volume.free and not force:
raise TransferError("%r ist gerade in Benutzung (%s)."
% (name, volume.state_text))
if volume.mountpoint:
run(["umount", volume.mountpoint], check=False, timeout=120)
if volume.loop:
detach_loop(volume.loop)
try:
if os.path.exists(volume.image):
os.unlink(volume.image)
except OSError as exc:
raise TransferError("%s nicht loeschbar: %s" % (volume.image, exc))
_index_drop(name)
return True
def cleanup_loops():
"""Loop-Geraete freigeben, die niemand mehr braucht."""
freed = []
for volume in load():
if volume.loop and not volume.mountpoint and not volume.attached_to:
detach_loop(volume.loop)
freed.append(volume.name)
return freed