From e687b874d672e67b287159e48da0b12fe7c66b5b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 2 Aug 2026 02:03:05 -0400 Subject: [PATCH] [FIX] qemu ssh: only offer virt-manager the hosts that run QEMU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every reachable machine ended up in virt-manager, not just the ones hosting VMs. The probe ran « for n in $(sudo virsh list --all --name 2>/dev/null) »: with no virsh, the command substitution is empty, the loop body never runs and the snippet exits 0 with no output — indistinguishable from « QEMU is here, it just has no VM ». Both read as a libvirt host. The probe now states it outright, « LIBVIRTyes|no » as its first line, so the two cases separate: a machine WITHOUT QEMU is skipped, a machine with QEMU and no VM is still offered — that is where one would create some. Verified against the real snippet: this host answers « yes » plus its two VMs, and with virsh out of PATH it answers « no ». Then on a simulated fleet, only vm-avec-qemu and vm-qemu-sans-vm are proposed; the plain dev VM and the unreachable one are not. Also: the ~/.ssh/config blocks were missing IdentityFile. Every entry now names the private key it needs — the one cloud-init injected, or the one just deployed — with IdentitiesOnly yes beside it. Without that flag IdentityFile ADDS to the agent's identities instead of replacing them, and a slightly full agent hits « Too many authentication failures » before reaching the right key. The .pub suffix is stripped: IdentityFile wants the private half. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/todo/todo.py | 117 ++++++++++++++++++++++++++++++++------- script/todo/todo_i18n.py | 16 ++++-- 2 files changed, 108 insertions(+), 25 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index ed225a6..2d7cea8 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1259,11 +1259,21 @@ class TODO: # 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é. + # Sonde exécutée SUR une machine. Première ligne « LIBVIRTyes|no », + # puis un couple « nomip » par VM. 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é. + # + # La première ligne est indispensable : sans virsh, la boucle ne tourne + # simplement pas et la sonde sortirait VIDE avec un code 0 — impossible + # alors de distinguer « pas de QEMU ici » de « QEMU présent, aucune VM ». _QEMU_SSH_PROBE = ( - "for n in $(sudo virsh list --all --name 2>/dev/null); do " + "if ! command -v virsh >/dev/null 2>&1; then " + "printf 'LIBVIRT\\tno\\n'; exit 0; fi; " + "vms=$(sudo virsh list --all --name 2>/dev/null) || " + "{ printf 'LIBVIRT\\tno\\n'; exit 0; }; " + "printf 'LIBVIRT\\tyes\\n'; " + "for n in $vms; 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 ' @@ -1322,6 +1332,14 @@ class TODO: deploy_key = self._is_yes_default_yes( input(t("Create the SSH key if missing and deploy it? (Y/n): ")) ) + # La clé est fixée AVANT d'écrire : c'est elle qui va dans + # IdentityFile, et c'est la même qu'on déploiera ensuite. + pub = ( + self._ssh_ensure_key() + if deploy_key + else self._qemu_default_ssh_key() + ) + identity = self._ssh_private_key(pub) ip_map = self._qemu_resolve_ips(names, timeout=60) written = [] for name in names: @@ -1329,7 +1347,9 @@ class TODO: if not ip: print(f" ⏭ {name}: {t('no IP')}") continue - self._write_ssh_config_entry(name, "erplibre", ip) + self._write_ssh_config_entry( + name, "erplibre", ip, identity_file=identity + ) written.append(name) print( f"\n✅ {len(written)} {self._plural(t('entry'), len(written))}" @@ -1522,11 +1542,16 @@ class TODO: print(f" ⚠ {t('Restart virt-manager if it is open.')}") def _qemu_ssh_probe_remote(self, alias): - """VM libvirt vues DEPUIS `alias` : [(nom, ip)]. + """Sonde `alias` : (libvirt_présent, [(nom, ip)]), ou None si + injoignable. 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.""" + sonde marche à n'importe quelle profondeur. + + « libvirt présent » et « a des VM » sont deux choses distinctes : une + machine avec QEMU mais sans VM mérite quand même sa connexion + virt-manager, une machine sans QEMU n'en veut aucune.""" cmd = [ "ssh", "-o", @@ -1544,12 +1569,17 @@ class TODO: return None if res.returncode != 0: return None + has_libvirt = False 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 + if len(parts) != 2 or not parts[0]: + continue + if parts[0] == "LIBVIRT": + has_libvirt = parts[1].strip() == "yes" + continue + found.append((parts[0], parts[1].strip())) + return has_libvirt, found def _qemu_ssh_config_nested(self): """Descend le parc en profondeur et écrit un ProxyJump par niveau. @@ -1572,6 +1602,14 @@ class TODO: deploy_key = self._is_yes_default_yes( input(t("Create the SSH key if missing and deploy it? (Y/n): ")) ) + # La clé est fixée AVANT d'écrire quoi que ce soit : c'est elle qui + # va dans IdentityFile à chaque niveau, et celle qu'on déploiera. + pub = ( + self._ssh_ensure_key() + if deploy_key + else self._qemu_default_ssh_key() + ) + identity = self._ssh_private_key(pub) # Niveau 0 : les VM locales, jointes directement. ip_map = self._qemu_resolve_ips(roots, timeout=60) @@ -1583,7 +1621,9 @@ class TODO: if not ip: print(f" ⏭ {name}: {t('no IP')}") continue - self._write_ssh_config_entry(name, "erplibre", ip) + self._write_ssh_config_entry( + name, "erplibre", ip, identity_file=identity + ) aliases[name] = (name, ip, None) frontier.append(name) @@ -1601,13 +1641,19 @@ class TODO: ) 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')}") + probed = self._qemu_ssh_probe_remote(parent) + if probed is None: + print(f" ⏭ {parent}: {t('unreachable')}") continue + has_libvirt, found = probed + if not has_libvirt: + print(f" · {parent}: {t('no QEMU/libvirt here')}") + continue + # Une machine avec QEMU vaut sa connexion virt-manager, même + # sans VM : c'est là qu'on pourra en créer. hosts_libvirt.append(parent) if not found: - print(f" · {parent}: {t('no nested VM')}") + print(f" · {parent}: {t('QEMU present, no VM')}") continue for child, ip in found: if not ip: @@ -1622,7 +1668,11 @@ class TODO: if alias in aliases: continue # déjà vu (cycle) self._write_ssh_config_entry( - alias, "erplibre", ip, proxy_jump=parent + alias, + "erplibre", + ip, + proxy_jump=parent, + identity_file=identity, ) aliases[alias] = (child, ip, parent) next_frontier.append(alias) @@ -2924,12 +2974,28 @@ 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, proxy_jump=None): + @staticmethod + def _ssh_private_key(pub_path): + """Clé PRIVÉE correspondant à une clé publique, ou '' si introuvable. + C'est elle que réclame IdentityFile ; donner le « .pub » ferait + échouer l'authentification.""" + if not pub_path: + return "" + priv = pub_path[:-4] if pub_path.endswith(".pub") else pub_path + return priv if os.path.exists(os.path.expanduser(priv)) else "" + + def _write_ssh_config_entry( + self, host, user, ip, proxy_jump=None, identity_file=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.""" + seul dès que le parent a lui-même le sien. + + `identity_file` : clé PRIVÉE à présenter. Sans elle, ssh propose + toutes les identités de l'agent et un parc un peu fourni déclenche + « Too many authentication failures » avant d'arriver à la bonne.""" cfg = os.path.expanduser("~/.ssh/config") os.makedirs(os.path.dirname(cfg), exist_ok=True) existing = "" @@ -2953,6 +3019,14 @@ class TODO: f" StrictHostKeyChecking no\n" f" UserKnownHostsFile /dev/null\n" ) + if identity_file: + # IdentitiesOnly : sans lui, IdentityFile s'AJOUTE aux clés de + # l'agent au lieu de les remplacer, et le serveur coupe après + # 5 essais infructueux. + block += ( + f" IdentityFile {identity_file}\n" + f" IdentitiesOnly yes\n" + ) if proxy_jump: block += f" ProxyJump {proxy_jump}\n" content = (existing + "\n\n" + block) if existing else block @@ -5057,10 +5131,15 @@ class TODO: ip_map = self._qemu_resolve_ips(deployed, labels) if add_ssh_config: + # La clé injectée par cloud-init est celle de la spec : c'est + # elle que doit présenter ssh, pas la première venue de l'agent. + identity = self._ssh_private_key(ssh_key) for name in deployed: ip = ip_map.get(name) if ip: - self._write_ssh_config_entry(name, "erplibre", ip) + self._write_ssh_config_entry( + name, "erplibre", ip, identity_file=identity + ) # 7) Installation ERPLibre (clone + make) si demandée. if install: diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index db4e5e2..effa2e4 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1444,13 +1444,17 @@ TRANSLATIONS = { "fr": "machines à sonder", "en": "machines to probe", }, - "unreachable or no libvirt": { - "fr": "injoignable ou sans libvirt", - "en": "unreachable or no libvirt", + "unreachable": { + "fr": "injoignable", + "en": "unreachable", }, - "no nested VM": { - "fr": "aucune VM imbriquée", - "en": "no nested VM", + "no QEMU/libvirt here": { + "fr": "pas de QEMU/libvirt ici", + "en": "no QEMU/libvirt here", + }, + "QEMU present, no VM": { + "fr": "QEMU présent, aucune VM", + "en": "QEMU present, no VM", }, "Create the SSH key if missing and deploy it? (Y/n): ": { "fr": "Créer la clé SSH si absente et la déployer ? "