From e88a80cb253103bd8c660b2a8c4d5af288e8d198 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 3 Aug 2026 12:52:00 +0200 Subject: [PATCH] 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 so viele USB-Weiterleitungen --iso Abbild als CD einlegen spice 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) --- pvesnap/recovery.py | 124 +++++++++++++++++++++++++++++++++++++++ pvesnap/recovery_ui.py | 65 +++++++++++++++++++- tools/get-guest-tools.sh | 102 ++++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 1 deletion(-) create mode 100755 tools/get-guest-tools.sh diff --git a/pvesnap/recovery.py b/pvesnap/recovery.py index 94fa9e7..c60d7db 100644 --- a/pvesnap/recovery.py +++ b/pvesnap/recovery.py @@ -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: diff --git a/pvesnap/recovery_ui.py b/pvesnap/recovery_ui.py index 44afb28..e89dd96 100644 --- a/pvesnap/recovery_ui.py +++ b/pvesnap/recovery_ui.py @@ -27,7 +27,8 @@ from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_MARK, C_OK, C_SEL, C_WARN keybar, message, prompt, put, read_key) from .proxmox import Proxmox, ProxmoxError, pvesh_available from .recovery import (RecoveryError, Spec, create, - eject_transfer, insert_transfer, transfer_state, + eject_transfer, insert_transfer, spice_file, + transfer_state, destroy, find_orphans, flatten, flatten_cost, flatten_hint, human_bytes, linked_volumes, list_instances, plan, remove_orphans, start) @@ -165,6 +166,13 @@ def _fields(guest): if guest.type == "qemu": rows.append(("resume", "Arbeitsspeicher", "choice", RESUME_CHOICES)) rows.append(("transfers", "Transfer-Laufwerke", "transfers", ())) + if guest.type == "qemu": + rows.append(("spice", "SPICE-Anzeige", "choice", + ((False, "nein - nur noVNC im Browser"), + (True, "ja - zusaetzlich remote-viewer")))) + rows.append(("usb", "USB-Weiterleitung", "choice", + ((0, "keine"), (1, "1 Anschluss"), (2, "2 Anschluesse"), + (4, "4 Anschluesse")))) rows += [ ("newid", "Neue VMID", "text", ()), ("node", "Node", "text", ()), @@ -214,6 +222,10 @@ def _options(win, guest, spec): "sie standen - ohne Crash-Recovery.", "transfers": "Vorbereitete Austauschmedien. Anlegen und befuellen " "in der Uebersicht mit v .", + "spice": "Zweiter Zugang ueber remote-viewer. Achtung: vertraegt " + "sich nicht mit geladenem Arbeitsspeicher.", + "usb": "Ein USB-Geraet vom eigenen Rechner in den Gast reichen - " + "etwa einen Dongle. Laeuft ueber SPICE, nicht ueber das Netz.", "newid": "VMID der neuen Maschine.", "node": "Auf welchem Node sie laufen soll.", "start": "Sofort hochfahren.", @@ -321,6 +333,14 @@ def summary_lines(plan_): % (human_bytes(plan_.vmstate_bytes) or "?") if plan_.resume else "vorhanden, wird aber nicht geladen"), curses.color_pair(C_OK) if plan_.resume else 0)) + if plan_.spice: + lines.append(("Anzeige: noVNC und SPICE (vga: qxl)", + curses.color_pair(C_OK))) + if plan_.usb: + lines.append(("USB: %d Weiterleitung(en) ueber SPICE" % plan_.usb, + curses.color_pair(C_OK))) + if plan_.iso: + lines.append(("CD: %s" % plan_.iso, 0)) for volume in plan_.transfers: lines.append(("Transfer: %-14s %8s im Gast als %s" % (volume.name, human_bytes(volume.size), volume.label), @@ -449,6 +469,9 @@ def _details(stdscr, proxmox, instance): if instance.type == "lxc": lines.append((" oder auf dem Host: pct enter %d" % instance.vmid, curses.color_pair(C_MARK))) + else: + lines.append((" SPICE (remote-viewer): pvesnap-recovery spice %d" + % instance.vmid, curses.color_pair(C_MARK))) if instance.resumed: lines.append(("", 0)) @@ -713,6 +736,9 @@ def _spec_from_args(args, mode): spec.net = args.net or "" spec.resume = args.resume spec.transfers = list(args.transfer or []) + spec.spice = bool(getattr(args, "spice", False)) + spec.usb = int(getattr(args, "usb", 0) or 0) + spec.iso = getattr(args, "iso", "") or "" spec.memory = args.memory or 0 spec.cores = args.cores or 0 spec.name = args.name or "" @@ -877,6 +903,28 @@ def cmd_cleanup(proxmox, args): return 1 if failed else 0 +def cmd_spice(proxmox, args): + instance = _find_instance(proxmox, args.vmid) + text = spice_file(proxmox, instance) + if args.output: + with open(args.output, "w", encoding="utf-8") as handle: + handle.write(text) + os.chmod(args.output, 0o600) + print(args.output) + print("\n Auf dem eigenen Rechner oeffnen:", file=sys.stderr) + print(" scp root@%s:%s . && remote-viewer %s" + % (instance.node, args.output, os.path.basename(args.output)), + file=sys.stderr) + else: + sys.stdout.write(text) + print("\n Umleiten und mit remote-viewer oeffnen:", file=sys.stderr) + print(" pvesnap-recovery spice %d -o vm.vv" % instance.vmid, + file=sys.stderr) + print(" Das Kennwort darin ist ein Einmal-Kennwort und laeuft bald ab.", + file=sys.stderr) + return 0 + + def cmd_console(proxmox, args): instance = _find_instance(proxmox, args.vmid) print(instance.console_url()) @@ -911,6 +959,14 @@ def _add_create_options(parser): help="Arbeitsspeicher des Snapshots laden") parser.add_argument("--no-resume", dest="resume", action="store_false", help="kalt starten, auch wenn der Snapshot RAM enthaelt") + parser.add_argument("--spice", action="store_true", + help="SPICE-Anzeige einschalten (vga: qxl) - vertraegt " + "sich nicht mit geladenem Arbeitsspeicher") + parser.add_argument("--usb", type=int, default=0, metavar="ANZAHL", + help="so viele USB-Weiterleitungen ueber SPICE, z.B. " + "fuer einen Dongle (geht auch mit RAM-Zustand)") + parser.add_argument("--iso", metavar="VOLID", + help="Abbild als CD einlegen, z.B. local:iso/virtio-win.iso") parser.add_argument("--transfer", action="append", metavar="NAME", default=[], help="vorbereitetes Transfer-Laufwerk anhaengen " "(mehrfach moeglich)") @@ -978,6 +1034,12 @@ def build_parser(): sub = subs.add_parser(name, help=helptext) sub.add_argument("vmid", type=int) + spice = subs.add_parser("spice", + help="SPICE-Verbindungsdatei fuer remote-viewer") + spice.add_argument("vmid", type=int) + spice.add_argument("-o", "--output", metavar="DATEI", + help="hineinschreiben statt auf die Ausgabe") + flatten_ = subs.add_parser( "flatten", help="vom Quell-Snapshot loesen - fuer den Dauerbetrieb") flatten_.add_argument("vmid", type=int) @@ -1020,6 +1082,7 @@ def main(argv=None): "flatten": lambda: cmd_flatten(proxmox, args), "cleanup": lambda: cmd_cleanup(proxmox, args), "console": lambda: cmd_console(proxmox, args), + "spice": lambda: cmd_spice(proxmox, args), "start": lambda: cmd_start(proxmox, args), "stop": lambda: cmd_stop(proxmox, args), } diff --git a/tools/get-guest-tools.sh b/tools/get-guest-tools.sh new file mode 100755 index 0000000..29d56ad --- /dev/null +++ b/tools/get-guest-tools.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# --------------------------------------------------------------------- +# Guest-Tools fuer wiederhergestellte Maschinen holen +# +# Die USB-Weiterleitung ueber SPICE braucht im Gast NICHTS - ein +# weitergereichter Dongle taucht dort als gewoehnliches USB-Geraet auf. +# Die Tools sind nur fuer den Komfort: Zwischenablage, automatische +# Aufloesung, sauberer Mauszeiger. +# +# Aufruf: ./get-guest-tools.sh [--storage ] [--list] +# +# --storage wohin (Vorgabe: erster Storage mit iso-Inhalt) +# --list nur zeigen, was da ist +# -h, --help diese Hilfe +# +# Danach beim Anlegen einlegen: +# pvesnap-recovery live 802 --spice --usb 2 --iso :iso/ +# --------------------------------------------------------------------- +set -euo pipefail + +STORAGE="" +LIST=0 + +# virtio-win enthaelt fuer Windows alles auf einmal: den qxl-Grafiktreiber, +# den SPICE-Agenten und die virtio-Treiber. Fuer Linux gibt es nichts zu +# holen - dort genuegt das Paket der Distribution. +WINDOWS_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" +WINDOWS_NAME="virtio-win.iso" + +while [ $# -gt 0 ]; do + case "$1" in + --storage) STORAGE="${2:-}"; shift ;; + --storage=*) STORAGE="${1#*=}" ;; + --list) LIST=1 ;; + -h|--help) sed -n '2,19p' "$0" | sed 's/^#[[:space:]]\{0,1\}//'; exit 0 ;; + *) echo "Unbekannte Option: $1" >&2; exit 1 ;; + esac + shift +done + +info() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m==>\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m==>\033[0m %s\n' "$*" >&2; exit 1; } + +command -v pvesm >/dev/null 2>&1 || fail "pvesm nicht gefunden - das gehoert auf einen Proxmox-Host." + +# Storage bestimmen: der erste, der iso-Inhalt aufnimmt +if [ -z "$STORAGE" ]; then + STORAGE="$(pvesm status --content iso 2>/dev/null | awk 'NR>1 && $3=="active" {print $1; exit}')" + [ -n "$STORAGE" ] || fail "Kein Storage mit iso-Inhalt gefunden. + In der Proxmox-Oberflaeche unter Datacenter -> Storage bei einem Storage + den Inhalt 'ISO image' zulassen, oder --storage angeben." +fi + +DIR="$(pvesm path "${STORAGE}:iso/x" 2>/dev/null | xargs dirname 2>/dev/null || true)" +[ -n "$DIR" ] || fail "Ablageort von '${STORAGE}' nicht ermittelbar." + +if [ "$LIST" -eq 1 ]; then + info "Vorhandene Abbilder auf '${STORAGE}' (${DIR}):" + pvesm list "$STORAGE" --content iso 2>/dev/null | tail -n +2 | sed 's/^/ /' \ + || echo " (keine)" + exit 0 +fi + +info "Ablage: ${DIR}" +mkdir -p "$DIR" + +# --- Windows --------------------------------------------------------- +TARGET="${DIR}/${WINDOWS_NAME}" +if [ -f "$TARGET" ]; then + info "Schon da: ${STORAGE}:iso/${WINDOWS_NAME} ($(du -h "$TARGET" | cut -f1))" +else + info "Hole virtio-win (Windows: qxl-Treiber, SPICE-Agent, virtio-Treiber)" + command -v curl >/dev/null 2>&1 || fail "curl wird benoetigt." + if curl -fL --progress-bar -o "${TARGET}.teil" "$WINDOWS_URL"; then + mv "${TARGET}.teil" "$TARGET" + info "Fertig: ${STORAGE}:iso/${WINDOWS_NAME} ($(du -h "$TARGET" | cut -f1))" + else + rm -f "${TARGET}.teil" + fail "Herunterladen fehlgeschlagen. Hat der Host Internetzugang? + Sonst von Hand holen und nach ${DIR} legen: + ${WINDOWS_URL}" + fi +fi + +cat < --spice --usb 2 --iso ${STORAGE}:iso/${WINDOWS_NAME} + +Im Gast danach: + + Windows Von der CD 'virtio-win-guest-tools.exe' ausfuehren. + Linux Nichts von der CD - das Paket der Distribution nehmen: + apt install spice-vdagent (Debian/Ubuntu) + dnf install spice-vdagent (Fedora/RHEL) + +Fuer einen weitergereichten USB-Dongle braucht es davon nichts. Der taucht im +Gast als gewoehnliches USB-Geraet auf, sobald du ihn im remote-viewer unter +"USB-Geraeteauswahl" anhakst. +EOF