SPICE-Anzeige und USB-Weiterleitung fuer Wiederherstellungen

Der Anlass: ein Software-Schutzmodul am USB-Port. Normalerweise haengt so ein
Dongle an einem USB-Server im Netz - eine abgeschottete Wiederherstellung hat
aber keins. Ueber SPICE kommt das Geraet vom Rechner des Bedieners, nicht ueber
das Gastnetz. Die Maschine bleibt also ohne Netzwerk und hat trotzdem ihren
Dongle.

  --spice        SPICE-Anzeige (vga: qxl), zusaetzlich zu noVNC
  --usb <n>      so viele USB-Weiterleitungen
  --iso <volid>  Abbild als CD einlegen
  spice <vmid>   Verbindungsdatei fuer remote-viewer (.vv)

Zwei Dinge zeigte erst der Test, beide haetten die Sache still unbrauchbar
gemacht:

  vga: qxl zerstoert den geladenen Arbeitsspeicher. qxl hat 64 MB
  Grafikspeicher, die Vorgabe 16 - beim Laden kommt "Size mismatch:
  vga.vram", und die Maschine bleibt angehalten stehen. Wieder mit TASK OK.
  Wird jetzt abgelehnt, mit den drei Auswegen in der Meldung. Der dauerhafte
  ist, am Original 'vga: qxl' zu setzen - dann tragen alle kuenftigen
  Snapshots es mit und beides geht gleichzeitig.

  USB ohne SPICE ist wirkungslos. QEMU legt die usbredir-Geraete zwar an, aber
  ohne SPICE-Server gibt es keinen Kanal dafuer - spiceproxy sagt schlicht "no
  spice port". --usb schaltet die Anzeige deshalb mit ein.

Ausserdem korrigiert: in der .vv-Datei darf das Zertifikat nicht in echte
Zeilenumbrueche uebersetzt werden. virt-viewer erwartet die Zeichenfolge \n als
zwei Zeichen in einer einzigen Zeile; sonst steht nur die erste hinter "ca=".

tools/get-guest-tools.sh holt virtio-win auf einen ISO-Storage. Fuer den
Dongle wird davon nichts gebraucht - die Tools sind nur fuer Zwischenablage,
Aufloesung und Mauszeiger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-08-03 12:52:00 +02:00
co-authored by Claude Opus 5
parent a1076c6641
commit e88a80cb25
3 changed files with 290 additions and 1 deletions
+124
View File
@@ -444,6 +444,9 @@ class Spec:
net: str = "" # leer = Vorgabe des Modus; none|down|on
resume: object = None # None = RAM-Zustand nehmen, wenn vorhanden
transfers: list = field(default_factory=list) # Namen vorhandener Laufwerke
spice: bool = False # SPICE-Anzeige (vga: qxl)
usb: int = 0 # so viele USB-Weiterleitungen fuer SPICE
iso: str = "" # Abbild als CD einlegen (volid)
memory: int = 0 # 0 = wie im Snapshot
cores: int = 0
name: str = ""
@@ -470,6 +473,9 @@ class Plan:
vmstate_bytes: int = 0
resume: bool = False
transfers: list = field(default_factory=list) # [transfer.Volume]
spice: bool = False
usb: int = 0
iso: str = ""
warnings: list = field(default_factory=list)
# Was nicht nur unschoen, sondern gefaehrlich ist. Hierfuer genuegt ein
# beilaeufiges "ja" nicht - siehe cmd_create() und die Oberflaeche.
@@ -740,6 +746,54 @@ def plan(proxmox, guest, snapname, spec):
% (name, volume.state_text))
result.transfers.append(volume)
# -- SPICE, USB-Weiterleitung, CD --------------------------------------
result.usb = max(0, int(spec.usb or 0))
result.spice = bool(spec.spice)
result.iso = spec.iso or ""
if result.usb > 14:
raise RecoveryError("Hoechstens 14 USB-Weiterleitungen.")
# USB-Weiterleitung laeuft ueber den SPICE-Kanal. Ohne SPICE legt QEMU die
# Geraete zwar an, aber es gibt keine Verbindung, die sie transportiert -
# "no spice port". Also gehoert beides zusammen.
if result.usb and not result.spice:
result.spice = True
result.notes.append(
"SPICE-Anzeige wird mit eingeschaltet: die USB-Weiterleitung laeuft "
"ueber den SPICE-Kanal, ohne den waeren die Anschluesse wirkungslos.")
original_vga = str(config.get("vga") or "")
if result.spice and result.resume and not original_vga.startswith("qxl"):
raise RecoveryError(
"SPICE und geladener Arbeitsspeicher gehen bei dieser Maschine "
"nicht zusammen.\n"
"SPICE verlangt 'vga: qxl' (64 MB Grafikspeicher), im Snapshot "
"steckt die Vorgabe mit 16 MB. Der gespeicherte Zustand passt dann "
"nicht mehr und die Maschine bleibt beim Laden haengen "
"('Size mismatch: vga.vram').\n"
"\n"
"Moeglichkeiten:\n"
" --no-resume kalt starten, dafuer mit SPICE und USB\n"
" ohne --spice warmer RAM-Zustand, dafuer nur noVNC\n"
"\n"
"Dauerhaft loesen laesst sich das nur am Original: steht dort "
"'vga: qxl', tragen alle kuenftigen Snapshots das mit, und beides "
"geht gleichzeitig.")
if result.spice:
result.notes.append(
"Zugang auf zwei Wegen: noVNC im Browser und SPICE ueber "
"'pvesnap-recovery spice %d' (remote-viewer)." % result.newid)
if result.usb:
result.notes.append(
"Im remote-viewer unter 'USB-Geraeteauswahl' das Geraet anhaken - "
"es kommt vom eigenen Rechner, nicht ueber das Netz des Gastes. "
"Guest-Tools braucht es dafuer nicht.")
if result.iso:
result.notes.append("Eingelegt wird %s - im Gast als CD-Laufwerk."
% result.iso)
# -- Hinweise ----------------------------------------------------------
if result.dropped and not spec.keep_binds:
result.notes.append("Nicht uebernommen werden Einbindungen von Host-"
@@ -874,6 +928,20 @@ def create(proxmox, plan_, progress=None):
attached.append({"name": volume.name, "key": slot, "drive": value,
"kind": "disk", "pending": bool(plan_.resume)})
# -- SPICE, USB-Weiterleitung, CD ----------------------------------
if plan_.spice:
step("Schalte SPICE-Anzeige ein (vga: qxl)")
new_config["vga"] = "qxl"
for _ in range(plan_.usb):
slot = _free_usb_slot(new_config)
new_config[slot] = "spice"
if plan_.usb:
step("%d USB-Weiterleitung(en) fuer SPICE eingerichtet" % plan_.usb)
if plan_.iso:
slot = _free_cdrom_slot(new_config)
new_config[slot] = "%s,media=cdrom" % plan_.iso
step("Lege %s als CD ein (%s)" % (plan_.iso, slot))
# -- restliche Anpassungen -----------------------------------------
if spec.memory:
new_config["memory"] = spec.memory
@@ -1101,6 +1169,41 @@ def insert_transfer(proxmox, instance, name, progress=None):
return True
# Die Reihenfolge ist die einer .vv-Datei fuer remote-viewer; "ca" muss ganz
# zum Schluss, weil das Zertifikat mehrzeilig ist.
_VV_KEYS = ("type", "host", "port", "tls-port", "password", "proxy",
"host-subject", "title", "toggle-fullscreen", "release-cursor",
"secure-attention", "delete-this-file", "ca")
def spice_file(proxmox, instance):
"""Verbindungsbeschreibung fuer remote-viewer (.vv) als Text.
Proxmox liefert die Angaben als JSON; daraus wird hier die Datei gebaut,
die virt-viewer erwartet. Sie enthaelt ein Einmal-Kennwort und laeuft nach
kurzer Zeit ab - also frisch erzeugen, wenn sie nicht mehr zieht.
"""
data = proxmox._json(["create", "/nodes/%s/%s/%d/spiceproxy"
% (instance.node, instance.type, instance.vmid)])
if not isinstance(data, dict) or not data.get("host"):
raise RecoveryError(
"%s liefert keine SPICE-Verbindung. Laeuft die Maschine, und ist "
"die SPICE-Anzeige eingeschaltet (vga: qxl)?" % instance.label)
lines = ["[virt-viewer]"]
for key in _VV_KEYS:
if key in data and data[key] not in (None, ""):
# Bewusst ohne Umwandlung: das Zertifikat enthaelt die Zeichenfolge
# \n als zwei Zeichen, und genau so erwartet virt-viewer sie. Wuerde
# man daraus echte Zeilenumbrueche machen, stuende nur die erste
# Zeile hinter "ca=" und der Rest waere kaputt.
lines.append("%s=%s" % (key, data[key]))
for key in sorted(data):
if key not in _VV_KEYS and data[key] not in (None, ""):
lines.append("%s=%s" % (key, data[key]))
return "\n".join(lines) + "\n"
def _default_name(guest, plan_):
base = re.sub(r"[^A-Za-z0-9-]", "-", guest.name or "vm%d" % guest.vmid)[:40]
return ("%s-wdh" % base).strip("-")
@@ -1132,6 +1235,27 @@ def _free_disk_slot(config):
raise RecoveryError("Kein freier Platz fuer ein Austauschlaufwerk.")
def _free_usb_slot(config):
for index in range(0, 14):
key = "usb%d" % index
if key not in config:
return key
raise RecoveryError("Alle 14 USB-Anschluesse sind belegt.")
def _free_cdrom_slot(config):
"""Ein CD-Laufwerk - moeglichst das leere, das schon da ist."""
for key in sorted(config):
if QEMU_DISK_KEY.match(key) and "media=cdrom" in str(config[key]) \
and str(config[key]).split(",")[0] in ("none", "cdrom"):
return key
for index in range(0, 4):
key = "ide%d" % index
if key not in config:
return key
raise RecoveryError("Kein Platz fuer ein CD-Laufwerk frei.")
def _free_mp_slot(config):
for index in range(0, 256):
if "mp%d" % index not in config: