[ADD] qemu: an SSH configuration tool, with recursive ProxyJump

~/.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 <alias> », 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 « name<TAB>ip » 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) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-01 07:24:15 -04:00
parent 7d96b56fb4
commit b8bfb0f8e0
2 changed files with 256 additions and 2 deletions

View file

@ -1193,6 +1193,11 @@ class TODO:
) )
}, },
{"prompt_description": t("Statistics (installs, durations, VMs)")}, {"prompt_description": t("Statistics (installs, durations, VMs)")},
{
"prompt_description": t(
"SSH configuration (~/.ssh/config, ProxyJump)"
)
},
{"section": t("Catalog")}, {"section": t("Catalog")},
{"prompt_description": t("List available images and specs")}, {"prompt_description": t("List available images and specs")},
] ]
@ -1231,6 +1236,8 @@ class TODO:
elif status == "12": elif status == "12":
self._qemu_stats() self._qemu_stats()
elif status == "13": elif status == "13":
self._qemu_ssh_config_menu()
elif status == "14":
self._qemu_list_images() self._qemu_list_images()
else: else:
cmd_no_found = True cmd_no_found = True
@ -1247,6 +1254,186 @@ class TODO:
if cmd_no_found: if cmd_no_found:
print(t("Command not 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 « nom<TAB>ip » 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 <alias> », 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): def _qemu_stats(self):
"""Statistiques d'utilisation de QEMU, et remise à zéro. """Statistiques d'utilisation de QEMU, et remise à zéro.
@ -2534,8 +2721,12 @@ class TODO:
print(f"{t('Will execute:')} {cmd}") print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False) self.execute.exec_command_live(cmd, source_erplibre=False)
def _write_ssh_config_entry(self, host, user, ip): def _write_ssh_config_entry(self, host, user, ip, proxy_jump=None):
"""Écrit/remplace un bloc « Host <host> » dans ~/.ssh/config.""" """Écrit/remplace un bloc « Host <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") cfg = os.path.expanduser("~/.ssh/config")
os.makedirs(os.path.dirname(cfg), exist_ok=True) os.makedirs(os.path.dirname(cfg), exist_ok=True)
existing = "" existing = ""
@ -2559,6 +2750,8 @@ class TODO:
f" StrictHostKeyChecking no\n" f" StrictHostKeyChecking no\n"
f" UserKnownHostsFile /dev/null\n" f" UserKnownHostsFile /dev/null\n"
) )
if proxy_jump:
block += f" ProxyJump {proxy_jump}\n"
content = (existing + "\n\n" + block) if existing else block content = (existing + "\n\n" + block) if existing else block
with open(cfg, "w", encoding="utf-8") as fh: with open(cfg, "w", encoding="utf-8") as fh:
fh.write(content) fh.write(content)

View file

@ -1399,6 +1399,67 @@ TRANSLATIONS = {
"fr": "Choix (numéro, vide = garder) :", "fr": "Choix (numéro, vide = garder) :",
"en": "Choice (number, blank = keep):", "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": { "Odoo migration interface": {
"fr": "🚚 Interface de la migration Odoo", "fr": "🚚 Interface de la migration Odoo",
"en": "🚚 Odoo migration interface", "en": "🚚 Odoo migration interface",