From b8bfb0f8e04893c858bd62f9a037e8a062ce7976 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 1 Aug 2026 07:24:15 -0400 Subject: [PATCH] [ADD] qemu: an SSH configuration tool, with recursive ProxyJump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~/.ssh/config entries were only ever written while deploying a VM. A fleet that already exists — or one whose DHCP leases have moved — had no way to refresh them. Entry [13] of the QEMU menu does it on demand: [1] update ~/.ssh/config for the local VMs [2] add the nested VMs through ProxyJump, recursively The second one matters because of the « ERPLibre Deployment (+ QEMU + dev) » profile: a VM built that way hosts VMs of its own, on its own private network. Those are not reachable from this host at all — only from their parent. So each level is written with a ProxyJump to the level above, and OpenSSH chains the hops on its own. « ssh erplibre-fedora-42 » then works from here even though the address only means something two machines away. The recursion probes over « ssh », i.e. through the block just written, so the parent's own ProxyJump applies automatically and one probe works identically at any depth. One SSH connection per MACHINE, not per VM: a single snippet returns every « nameip » pair, falling back to the guest agent when the dnsmasq lease is missing. Passwordless sudo is a given here — the cloud-init config grants it (deploy_qemu.py:1175). A nested VM keeps its short name, which is what one wants to type, and is only prefixed with its parent on collision — so discovering a machine that already exists elsewhere never overwrites the other one's entry. Depth defaults to 2 (host, VM, nested VM) and already-seen aliases are skipped, which is what stops a cycle: a child that reports its own grandparent. Verified on a simulated two-level fleet including a deliberate cycle: the ProxyJump chain is correct at each level, the colliding name is prefixed, the parent block is not overwritten, and only one probe per machine is issued. The remote snippet itself was run for real on this host — valid POSIX sh, two VMs with their addresses. A VM without an IP is skipped rather than written with an empty HostName. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/todo/todo.py | 197 ++++++++++++++++++++++++++++++++++++++- script/todo/todo_i18n.py | 61 ++++++++++++ 2 files changed, 256 insertions(+), 2 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 7c00436..ddb7024 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1193,6 +1193,11 @@ class TODO: ) }, {"prompt_description": t("Statistics (installs, durations, VMs)")}, + { + "prompt_description": t( + "SSH configuration (~/.ssh/config, ProxyJump)" + ) + }, {"section": t("Catalog")}, {"prompt_description": t("List available images and specs")}, ] @@ -1231,6 +1236,8 @@ class TODO: elif status == "12": self._qemu_stats() elif status == "13": + self._qemu_ssh_config_menu() + elif status == "14": self._qemu_list_images() else: cmd_no_found = True @@ -1247,6 +1254,186 @@ class TODO: if cmd_no_found: print(t("Command not found !")) + # Profondeur d'exploration par défaut : hôte -> VM -> VM imbriquée. Le + # profil « ERPLibre Déploiement (+ QEMU + dev) » installe QEMU DANS la VM, + # donc un parc à deux niveaux est le cas courant. + _QEMU_SSH_DEPTH = 2 + # Sonde exécutée SUR une machine : un couple « nomip » par VM + # libvirt. Une seule connexion SSH par niveau plutôt qu'une par VM. Le + # bail dnsmasq peut manquer, d'où le repli sur l'agent invité. + _QEMU_SSH_PROBE = ( + "for n in $(sudo virsh list --all --name 2>/dev/null); do " + 'ip=$(sudo virsh domifaddr "$n" --source lease 2>/dev/null ' + "| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' | head -1); " + 'if [ -z "$ip" ]; then ' + 'ip=$(sudo virsh domifaddr "$n" --source agent 2>/dev/null ' + "| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' " + "| grep -v '^127\\.' | head -1); fi; " + 'printf "%s\\t%s\\n" "$n" "$ip"; done' + ) + + def _qemu_ssh_config_menu(self): + """Écrit les entrées ~/.ssh/config du parc QEMU.""" + print(f"🔑 {t('SSH configuration for QEMU VMs')}") + choices = [ + {"prompt_description": t("Update ~/.ssh/config for local VMs")}, + { + "prompt_description": t( + "Add nested VMs through ProxyJump (recursive)" + ) + }, + ] + help_info = self.fill_help_info(choices) + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._qemu_ssh_config_local() + elif status == "2": + self._qemu_ssh_config_nested() + else: + print(t("Command not found !")) + + def _qemu_pick_domains(self): + """Fait choisir des VM parmi celles définies. Vide = toutes.""" + names = self._qemu_list_domains() + if not names: + print(t("No VM found.")) + return [] + for i, name in enumerate(names, 1): + print(f" [{i}] {name}") + raw = input( + t("Which VMs? (numbers, comma-separated; blank = all): ") + ).strip() + if not raw: + return names + chosen = self._parse_index_selection(raw, names) + return chosen or names + + def _qemu_ssh_config_local(self): + """Une entrée ~/.ssh/config par VM locale, comme le fait déjà le + déploiement — mais sur un parc déjà en place.""" + names = self._qemu_pick_domains() + if not names: + return + ip_map = self._qemu_resolve_ips(names, timeout=60) + written = 0 + for name in names: + ip = ip_map.get(name) + if not ip: + print(f" ⏭ {name}: {t('no IP')}") + continue + self._write_ssh_config_entry(name, "erplibre", ip) + written += 1 + print( + f"\n✅ {written} {self._plural(t('entry'), written)}" + f" {t('written')} ({len(names) - written} {t('skipped')})" + ) + + def _qemu_ssh_probe_remote(self, alias): + """VM libvirt vues DEPUIS `alias` : [(nom, ip)]. + + Passe par « ssh », donc par le bloc ~/.ssh/config qu'on vient + d'écrire : le ProxyJump du parent s'applique tout seul et la même + sonde marche à n'importe quelle profondeur.""" + cmd = [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + alias, + self._QEMU_SSH_PROBE, + ] + try: + res = subprocess.run( + cmd, capture_output=True, text=True, timeout=90 + ) + except (OSError, subprocess.SubprocessError): + return None + if res.returncode != 0: + return None + found = [] + for line in res.stdout.splitlines(): + parts = line.strip().split("\t") + if len(parts) == 2 and parts[0]: + found.append((parts[0], parts[1].strip())) + return found + + def _qemu_ssh_config_nested(self): + """Descend le parc en profondeur et écrit un ProxyJump par niveau. + + Une VM du profil « Déploiement » héberge elle-même des VM : celles-ci + n'ont pas d'IP joignable depuis l'hôte, seulement depuis leur parent. + ProxyJump enchaîne les sauts, et la chaîne se construit d'elle-même + puisque le parent est déjà dans ~/.ssh/config quand on écrit l'enfant. + """ + roots = self._qemu_pick_domains() + if not roots: + return + raw = input( + f"{t('Depth (default:')} {self._QEMU_SSH_DEPTH}): " + ).strip() + try: + max_depth = max(1, int(raw)) if raw else self._QEMU_SSH_DEPTH + except ValueError: + max_depth = self._QEMU_SSH_DEPTH + + # Niveau 0 : les VM locales, jointes directement. + ip_map = self._qemu_resolve_ips(roots, timeout=60) + aliases = {} # alias -> (nom, ip, parent|None) + frontier = [] + for name in roots: + ip = ip_map.get(name) + if not ip: + print(f" ⏭ {name}: {t('no IP')}") + continue + self._write_ssh_config_entry(name, "erplibre", ip) + aliases[name] = (name, ip, None) + frontier.append(name) + + for depth in range(1, max_depth + 1): + if not frontier: + break + print( + f"\n🔎 {t('Level')} {depth} — " + f"{len(frontier)} {t('machines to probe')}" + ) + next_frontier = [] + for parent in frontier: + found = self._qemu_ssh_probe_remote(parent) + if found is None: + print(f" ⏭ {parent}: {t('unreachable or no libvirt')}") + continue + if not found: + print(f" · {parent}: {t('no nested VM')}") + continue + for child, ip in found: + if not ip: + print(f" ⏭ {parent} › {child}: {t('no IP')}") + continue + # Le nom court est plus agréable à taper ; on ne le + # préfixe du parent qu'en cas de collision, pour ne + # jamais écraser l'entrée d'une autre machine. + alias = child + if alias in aliases: + alias = f"{parent}+{child}" + if alias in aliases: + continue # déjà vu (cycle) + self._write_ssh_config_entry( + alias, "erplibre", ip, proxy_jump=parent + ) + aliases[alias] = (child, ip, parent) + next_frontier.append(alias) + frontier = next_frontier + + print(f"\n── {t('SSH hosts written')} ──") + for alias, (name, ip, parent) in aliases.items(): + via = f" ({t('via')} {parent})" if parent else "" + print(f" ssh {alias:<34} {ip}{via}") + def _qemu_stats(self): """Statistiques d'utilisation de QEMU, et remise à zéro. @@ -2534,8 +2721,12 @@ class TODO: print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) - def _write_ssh_config_entry(self, host, user, ip): - """Écrit/remplace un bloc « Host » dans ~/.ssh/config.""" + def _write_ssh_config_entry(self, host, user, ip, proxy_jump=None): + """Écrit/remplace un bloc « Host » dans ~/.ssh/config. + + `proxy_jump` : alias du rebond pour une VM imbriquée, dont l'IP n'est + joignable que depuis son hôte. OpenSSH enchaîne les ProxyJump tout + seul dès que le parent a lui-même le sien.""" cfg = os.path.expanduser("~/.ssh/config") os.makedirs(os.path.dirname(cfg), exist_ok=True) existing = "" @@ -2559,6 +2750,8 @@ class TODO: f" StrictHostKeyChecking no\n" f" UserKnownHostsFile /dev/null\n" ) + if proxy_jump: + block += f" ProxyJump {proxy_jump}\n" content = (existing + "\n\n" + block) if existing else block with open(cfg, "w", encoding="utf-8") as fh: fh.write(content) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index ec612b4..b7fc71f 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1399,6 +1399,67 @@ TRANSLATIONS = { "fr": "Choix (numéro, vide = garder) :", "en": "Choice (number, blank = keep):", }, + "SSH configuration (~/.ssh/config, ProxyJump)": { + "fr": "🔑 Configuration SSH (~/.ssh/config, ProxyJump)", + "en": "🔑 SSH configuration (~/.ssh/config, ProxyJump)", + }, + "SSH configuration for QEMU VMs": { + "fr": "Configuration SSH des VM QEMU", + "en": "SSH configuration for QEMU VMs", + }, + "Update ~/.ssh/config for local VMs": { + "fr": "📝 Mettre à jour ~/.ssh/config pour les VM locales", + "en": "📝 Update ~/.ssh/config for local VMs", + }, + "Add nested VMs through ProxyJump (recursive)": { + "fr": "🪜 Ajouter les VM imbriquées via ProxyJump (récursif)", + "en": "🪜 Add nested VMs through ProxyJump (recursive)", + }, + "Which VMs? (numbers, comma-separated; blank = all): ": { + "fr": "Quelles VM ? (numéros séparés par des virgules ; " + "vide = toutes) : ", + "en": "Which VMs? (numbers, comma-separated; blank = all): ", + }, + "Depth (default:": { + "fr": "Profondeur (défaut :", + "en": "Depth (default:", + }, + "entry": { + "fr": "entrée", + "en": "entry", + }, + "written": { + "fr": "écrite(s)", + "en": "written", + }, + "skipped": { + "fr": "ignorée(s)", + "en": "skipped", + }, + "Level": { + "fr": "Niveau", + "en": "Level", + }, + "machines to probe": { + "fr": "machines à sonder", + "en": "machines to probe", + }, + "unreachable or no libvirt": { + "fr": "injoignable ou sans libvirt", + "en": "unreachable or no libvirt", + }, + "no nested VM": { + "fr": "aucune VM imbriquée", + "en": "no nested VM", + }, + "SSH hosts written": { + "fr": "Hôtes SSH écrits", + "en": "SSH hosts written", + }, + "via": { + "fr": "via", + "en": "via", + }, "Odoo migration interface": { "fr": "🚚 Interface de la migration Odoo", "en": "🚚 Odoo migration interface",