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>
This commit is contained in:
duffyduck
2026-07-31 22:12:18 +02:00
co-authored by Claude Opus 5
parent 6279a20d4a
commit 2b895e6f4f
4 changed files with 428 additions and 98 deletions
+58 -74
View File
@@ -5,8 +5,8 @@ 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, exfat, "Datenbank-Dumps"
/var/lib/pvesnap/transfer/werkzeuge.img 2G, ext4, "Skripte und Tools"
/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
@@ -49,48 +49,29 @@ CONF_ROOT = "/etc/pve/nodes"
NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$")
# Was sich womit anlegen laesst. Die Partitionstabelle macht in jedem Fall
# parted, das Dateisystem der jeweilige Formatierer.
FILESYSTEMS = {
"ext4": {
"mkfs": ["mkfs.ext4", "-q", "-m", "0", "-L"],
"fuer": "nur Linux, behaelt Rechte und Besitzer",
"paket": "e2fsprogs",
},
"xfs": {
"mkfs": ["mkfs.xfs", "-q", "-L"],
"fuer": "nur Linux",
"paket": "xfsprogs",
},
"exfat": {
"mkfs": ["mkfs.exfat", "-L"],
"fuer": "Windows und Linux, keine Groessengrenze je Datei",
"paket": "exfatprogs",
"hinweis": "apt install exfatprogs (281 KB, entfernt nichts)",
},
"vfat": {
"mkfs": ["mkfs.vfat", "-n"],
"fuer": "ueberall lesbar, aber nur Dateien bis 4 GB",
"paket": "dosfstools",
},
}
# 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
# Wie lang die Datentraegerbezeichnung sein darf. exFAT und FAT lassen nur
# 11 Zeichen zu - laenger, und mkfs bricht ab.
LABEL_MAX = {"vfat": 11, "exfat": 11, "xfs": 12, "ext4": 16}
# exFAT als Vorgabe: Windows und Linux koennen beide damit umgehen, und anders
# als FAT32 gibt es keine 4-GB-Grenze je Datei. Fehlt der Formatierer, faellt
# die Wahl auf ext4 zurueck.
DEFAULT_SIZE = "20G"
def default_filesystem():
return "exfat" if have("mkfs.exfat") else "ext4"
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))
DEFAULT_FS = "exfat"
README = """Transfer-Laufwerk von pvesnap
Dieses Laufwerk gehoert zu keiner Maschine. Es dient nur dazu, Dateien
@@ -153,15 +134,6 @@ def have(command):
return which(command) is not None
def available_filesystems():
"""[(name, verfuegbar, beschreibung)] - was sich hier anlegen laesst."""
result = []
for name in ("exfat", "ext4", "xfs", "vfat"):
spec = FILESYSTEMS[name]
result.append((name, have(spec["mkfs"][0]), spec))
return result
def human_bytes(count):
value = float(count or 0)
for unit in ("B", "K", "M", "G", "T"):
@@ -193,7 +165,7 @@ class Volume:
name: str
image: str = ""
size: int = 0
fs: str = "ext4"
fs: str = FILESYSTEM
label: str = ""
note: str = ""
created: int = 0
@@ -206,16 +178,20 @@ class Volume:
@property
def state(self):
if self.mountpoint:
return "host"
# 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": "an VM %d angehaengt" % self.attached_to,
"guest": "in Benutzung von %d" % self.attached_to,
"frei": "frei"}[self.state]
@property
@@ -317,7 +293,11 @@ def _guest_usage():
steht der Eintrag auch dann, wenn die Maschine gerade nicht laeuft.
"""
usage = {}
device = re.compile(r"(/dev/loop\d+)")
# 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:
@@ -355,6 +335,7 @@ def load():
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
@@ -362,7 +343,7 @@ def load():
if candidate in mounts:
volume.mountpoint = mounts[candidate]
break
volume.attached_to = usage.get(volume.loop, 0)
volume.attached_to = volume.attached_to or usage.get(volume.loop, 0)
volumes.append(volume)
return volumes
@@ -379,8 +360,7 @@ def get(name):
# Anlegen
# ---------------------------------------------------------------------------
def create(name, size=DEFAULT_SIZE, fs=None, note="", partitioned=True,
progress=None):
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)
@@ -394,22 +374,16 @@ def create(name, size=DEFAULT_SIZE, fs=None, note="", partitioned=True,
if any(v.name == name for v in _index_load()):
raise TransferError("Ein Transfer-Laufwerk namens %r gibt es schon." % name)
fs = fs or default_filesystem()
if fs not in FILESYSTEMS:
raise TransferError("Unbekanntes Dateisystem %r (%s)"
% (fs, ", ".join(sorted(FILESYSTEMS))))
spec = FILESYSTEMS[fs]
if not have(spec["mkfs"][0]):
raise TransferError("%s fehlt - %s kann hier nicht angelegt werden.\n%s"
% (spec["mkfs"][0], fs,
spec.get("hinweis") or "apt install %s" % spec["paket"]))
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, fs)
label = _label_for(name)
image = os.path.join(TRANSFER_DIR, "%s.img" % name)
os.makedirs(TRANSFER_DIR, exist_ok=True)
@@ -420,7 +394,8 @@ def create(name, size=DEFAULT_SIZE, fs=None, note="", partitioned=True,
except OSError as exc:
raise TransferError("%s nicht anlegbar: %s" % (image, exc))
volume = Volume(name=name, image=image, size=size_bytes, fs=fs, label=label,
volume = Volume(name=name, image=image, size=size_bytes, fs=FILESYSTEM,
label=label,
note=note, created=int(time.time()), partitioned=partitioned)
try:
if partitioned:
@@ -433,9 +408,9 @@ def create(name, size=DEFAULT_SIZE, fs=None, note="", partitioned=True,
"mkpart", "primary", "1MiB", "100%",
"set", "1", "msftdata", "on"])
with _loop(image, partitioned) as device:
step("Formatieren mit %s, Bezeichnung %s" % (fs, label))
run(spec["mkfs"] + [label, device], timeout=1800)
_place_readme(device, fs, label)
step("Formatieren mit %s, Bezeichnung %s" % (FILESYSTEM, label))
run(MKFS + [label, device], timeout=1800)
_place_readme(device, label)
except Exception:
try:
os.unlink(image)
@@ -495,21 +470,21 @@ def partition_of(device):
return device
def _label_for(name, fs):
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.get(fs, 11)] or "PVESNAP"
return clean[:LABEL_MAX] or "PVESNAP"
def _place_readme(device, fs, label):
def _place_readme(device, label):
mountpoint = os.path.join(MOUNT_ROOT, ".neu-%d" % os.getpid())
try:
os.makedirs(mountpoint, exist_ok=True)
run(["mount", device, mountpoint], timeout=120)
run(["mount", "-o", "umask=0000", device, mountpoint], timeout=120)
except (TransferError, OSError) as exc:
log.debug("Anleitung nicht ablegbar: %s", exc)
return
@@ -558,7 +533,12 @@ def host_mount(volume):
mountpoint = os.path.join(MOUNT_ROOT, volume.name)
os.makedirs(mountpoint, exist_ok=True)
try:
run(["mount", target, mountpoint], timeout=120)
# 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
@@ -569,6 +549,10 @@ def host_mount(volume):
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: