From 75498384c95bbddb715284e95a0d6d657ee8e83f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 03:40:22 -0400 Subject: [PATCH] [ADD] qemu: build the tunnel to the remote desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "Remote desktop tunnel" entry under SSH configuration. It lists the targets, resolves them, and composes the full command -- nothing to fill in. todo.py runs on the libvirt HOST; the tunnel starts from the workstation. It cannot open it, but it alone knows the VM's private IP, its port and the address through which it was reached. That last one comes from SSH_CONNECTION, whose third field is exactly the server address used -- far safer than a "hostname" that may resolve to nothing outside. When ~/.ssh/config already carries the entry, the tunnel takes it: "ssh -N -L 5902:localhost:5901 ". ProxyJump makes the route and "localhost" means the VM's OWN loopback, so the tunnel survives an IP change. That config is also the right source of targets, not the local libvirt: a graphical VM is often nested, and virsh would only ever list the orchestrator. Two details: the hypervisor console needs VNC bound to the loopback, as "listen=none" opens no socket at all; and two menu entries had no icon. --- FR --- Une entrée « Tunnel bureau distant » sous Configuration SSH. Elle liste les cibles, les résout, et compose la commande complète — rien à remplir. todo.py tourne sur l'HÔTE libvirt ; le tunnel, lui, part du poste de travail. Il ne peut donc pas l'ouvrir, mais il est le seul à connaître l'IP privée de la VM, son port et l'adresse par laquelle on l'a joint. Cette dernière vient de SSH_CONNECTION, dont le troisième champ est exactement l'adresse serveur utilisée — bien plus sûr qu'un « hostname » qui peut ne rien résoudre depuis l'extérieur. Quand ~/.ssh/config porte déjà l'entrée, le tunnel l'emprunte : « ssh -N -L 5902:localhost:5901 ». Le ProxyJump fait la route et « localhost » désigne le bouclage DE LA VM, si bien que le tunnel survit à un changement d'IP. Ce fichier est aussi la bonne source de cibles, pas le libvirt local : une VM graphique est souvent imbriquée, et virsh ne listerait jamais que l'orchestrateur. Deux détails : la console de l'hyperviseur exige un VNC sur la boucle locale, « listen=none » n'ouvrant aucun socket ; et deux entrées de menu n'avaient pas d'icône. Assisted-by: Claude Opus 5 --- script/qemu/deploy_qemu.py | 17 +++- script/todo/todo.py | 160 +++++++++++++++++++++++++++++++++++++ script/todo/todo_i18n.py | 8 +- 3 files changed, 180 insertions(+), 5 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 66e596d..4f483de 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1787,7 +1787,22 @@ def virt_install( "\n Le bureau sera accessible à distance, par le réseau." ) else: - graphics = "spice,listen=none" + # VNC sur la boucle locale, PAS « spice,listen=none ». + # + # « listen=none » est le défaut de virt-install et il n'expose + # rien : QEMU crée l'affichage mais n'ouvre AUCUN socket TCP, seul + # le canal libvirt y mène. Un virt-manager tournant sur la machine + # même y accède ; rien d'autre. Or l'hôte QEMU est lui-même une VM + # ici — la console était donc inatteignable par construction, et + # aucun « ssh -L » ne pouvait y remédier : il n'y avait pas de port + # où aboutir. + # + # 127.0.0.1 n'expose rien au réseau non plus : le port n'est + # joignable que depuis l'hôte, donc à travers un tunnel SSH. VNC + # plutôt que SPICE parce qu'il tient en UN port — un seul « -L » + # suffit, avec n'importe quel client. Pour revenir au comportement + # d'avant : --graphics spice,listen=none + graphics = "vnc,listen=127.0.0.1" video = ["--video", "virtio"] cmd = [ "virt-install", diff --git a/script/todo/todo.py b/script/todo/todo.py index 50f0202..e1fe8d3 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -11,6 +11,7 @@ import inspect import json import logging import os +import socket import re import shlex import shutil @@ -1282,6 +1283,11 @@ class TODO: "SSH configuration (~/.ssh/config, ProxyJump)" ) }, + { + "prompt_description": t( + "Remote desktop tunnel (VNC/RDP through SSH)" + ) + }, {"section": t("Catalog")}, {"prompt_description": t("List available images and specs")}, ] @@ -1322,6 +1328,8 @@ class TODO: elif status == "13": self._qemu_ssh_config_menu() elif status == "14": + self._qemu_tunnel_menu() + elif status == "15": self._qemu_list_images() else: cmd_no_found = True @@ -1473,6 +1481,158 @@ class TODO: roots.append({"alias": name, "ip": ip}) return roots + # Ports du bureau distant, par gestionnaire de paquets de la VM. Ils + # viennent de _QEMU_DESKTOP_REMOTE, seule source : xrdp sur 3389 partout, + # sauf Arch qui reçoit TigerVNC sur 5901. + @classmethod + def _qemu_desktop_port(cls, distro): + if distro == "arch": + return cls._QEMU_DESKTOP_REMOTE["pacman"]["port"], "VNC" + return cls._QEMU_DESKTOP_REMOTE["apt"]["port"], "RDP" + + @staticmethod + def _qemu_self_address(): + """Adresse par laquelle l'utilisateur a JOINT cet hôte. + + SSH_CONNECTION porte « ip_client port_client ip_serveur port_serveur » : + le troisième champ est exactement l'adresse à remettre dans la commande + de tunnel, bien mieux qu'un « hostname » qui peut ne rien résoudre + depuis le poste de travail. Hors session SSH, on retombe sur le nom + d'hôte, en le signalant.""" + conn = os.environ.get("SSH_CONNECTION", "").split() + if len(conn) >= 3: + return conn[2], True + return socket.gethostname(), False + + def _qemu_tunnel_menu(self): + """Commande de tunnel SSH vers le bureau distant d'une machine. + + La source des cibles est ~/.ssh/config, PAS le libvirt local. La VM + graphique est souvent imbriquee : un orchestrateur QEMU tourne dans une + VM, et la machine a bureau vit DANS cet orchestrateur. Le « virsh » du + poste ne voit alors que l'orchestrateur, et proposer sa liste menait + droit a la mauvaise machine — vecu. + + ~/.ssh/config, lui, connait les deux, ProxyJump compris : c'est la + seule vue qui traverse les niveaux. Les domaines libvirt LOCAUX sont + ajoutes en complement quand ils ne s'y trouvent pas deja. + """ + print(f"\n🖥 {t('Remote desktop tunnel')}") + hosts = list(self._ssh_config_hosts()) + targets = [(h, "ssh_config") for h in hosts] + # Complement local, sans sudo tant qu'on n'en a pas besoin : la + # plupart des cibles utiles sont deja dans ssh_config. + if not targets: + for name in self._qemu_list_domains(): + targets.append((name, "virsh")) + if not targets: + print(f" {t('No host in ~/.ssh/config and no local VM.')}") + return + for i, (name, src) in enumerate(targets, 1): + mark = "" if src == "ssh_config" else f" ({t('local VM')})" + print(f" [{i}] {name}{mark}") + answer = input(f"{t('Which VM?')} [1]: ").strip() or "1" + if not answer.isdigit() or not (1 <= int(answer) <= len(targets)): + print(t("Cancelled.")) + return + name, src = targets[int(answer) - 1] + + # Le port ne se devine pas pour un hote de ssh_config : on ne connait + # ni sa distribution ni son bureau. On propose, l'utilisateur tranche. + print(f"\n {t('Remote desktop kind:')}") + print(f" [1] RDP 3389 (xrdp) *") + print(f" [2] VNC 5901 (TigerVNC, Arch)") + print(f" [3] {t('Hypervisor console (QEMU screen, no guest server)')}") + kind_answer = input(f"{t('Choice')} [1]: ").strip() or "1" + if kind_answer == "3": + self._qemu_console_tunnel() + return + port, kind = (5901, "VNC") if kind_answer == "2" else (3389, "RDP") + local = port + 1 + + print(f"\n {t('Run this on YOUR workstation:')}") + if src == "ssh_config": + # « localhost » est resolu par le DERNIER saut, donc par la machine + # elle-meme : le ProxyJump de ssh_config traverse les niveaux. + print(f"\n ssh -N -L {local}:localhost:{port} {name}\n") + print(f" {t('(through the ProxyJump already in ~/.ssh/config)')}") + else: + ip = self._qemu_resolve_ips([name]).get(name) + if not ip: + print(f" {t('No IP for this VM; is it running?')}") + return + host, from_ssh = self._qemu_self_address() + user = os.environ.get("USER", "user") + if not from_ssh: + print( + f" ⚠ {t('Not in an SSH session: check the host address.')}" + ) + print(f"\n ssh -N -L {local}:{ip}:{port} {user}@{host}\n") + print(f" ⚠ {t('No ~/.ssh/config entry; see SSH configuration.')}") + print( + f" {t('then point your client at')} localhost:{local} ({kind})" + ) + print(f" {t('The tunnel stays open as long as that ssh runs.')}") + + def _qemu_console_tunnel(self): + """Tunnel vers l'ÉCRAN QEMU d'une VM, pas vers un serveur de l'invité. + + Les deux autres choix du menu supposent un service DANS l'invité — + xrdp, TigerVNC — donc une session de bureau déjà ouverte et un mot de + passe posé. La console de l'hyperviseur, elle, existe dès l'amorçage et + ne demande rien à l'invité : c'est ce que montre virt-manager. + + Le port n'est pas devinable : libvirt l'attribue au démarrage. On le + lit donc, et l'absence de port est un diagnostic à part entière — avec + « listen=none » QEMU n'ouvre AUCUN socket, et aucun tunnel n'y peut + rien tant que le domaine n'est pas redéfini. + """ + names = self._qemu_list_domains() + if not names: + print(f" {t('No local VM.')}") + return + for i, name in enumerate(names, 1): + print(f" [{i}] {name}") + raw = input(f"{t('Which VM?')} [1]: ").strip() or "1" + if not raw.isdigit() or not (1 <= int(raw) <= len(names)): + print(t("Cancelled.")) + return + name = names[int(raw) - 1] + try: + res = subprocess.run( + ["sudo", "virsh", "vncdisplay", name], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + res = None + # « 127.0.0.1:0 » désigne le port 5900, « :1 » le 5901, etc. + port = None + if res and res.returncode == 0: + disp = res.stdout.strip().rsplit(":", 1) + if len(disp) == 2 and disp[1].isdigit(): + port = 5900 + int(disp[1]) + if not port: + print(f"\n ⚠ {t('This VM exposes no VNC port.')}") + print(f" {t('Its display is likely spice with listen=none:')}") + print(f" sudo virsh dumpxml {name} | grep -A2 '") + print(f" sudo virsh start {name}") + print(f"\n {t('New VMs get this by default; see deploy_qemu.')}") + return + host, from_ssh = self._qemu_self_address() + user = os.environ.get("USER", "user") + if not from_ssh: + print(f" ⚠ {t('Not in an SSH session: check the host address.')}") + print(f"\n {t('Run this on YOUR workstation:')}") + print(f"\n ssh -N -L {port}:127.0.0.1:{port} {user}@{host}\n") + print(f" {t('then point your VNC client at')} localhost:{port}") + print(f" {t('The tunnel stays open as long as that ssh runs.')}") + def _qemu_ssh_config_menu(self): """Écrit les entrées ~/.ssh/config du parc QEMU. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index a716ed4..775f041 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3060,8 +3060,8 @@ TRANSLATIONS = { "en": "select all", }, "Delete VM(s)": { - "fr": "🗑 Effacer une ou plusieurs VM", - "en": "🗑 Delete VM(s)", + "fr": "🗑 Effacer une ou plusieurs VM", + "en": "🗑 Delete VM(s)", }, "No VM found.": { "fr": "Aucune VM trouvée.", @@ -3688,8 +3688,8 @@ TRANSLATIONS = { "en": "Verify SHA256 after download? (y/N, default: no): ", }, "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)": { - "fr": "QEMU - Exemple dry-run (demo-vm, Ubuntu 24.04)", - "en": "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)", + "fr": "🧰 QEMU - Exemple dry-run (demo-vm, Ubuntu 24.04)", + "en": "🧰 QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)", }, # QEMU - statistics screen "Statistics (installs, durations, VMs)": {