[IMP] qemu ssh: generate and deploy the key, register virt-manager

Writing ~/.ssh/config only helps if the key is accepted at the other end.
Both SSH options now offer to create an ed25519 key when none exists — the
same choice deploy_qemu.ensure_ssh_key makes, so created and adopted VMs share
one key — and to push it with ssh-copy-id.

Hosts that already accept the key are skipped, tested with
PasswordAuthentication=no: without it ssh would fall back to the password and
every host would look like it already had the key. ssh-copy-id runs on the
real terminal rather than through captured output, otherwise its password
prompt would be invisible.

In the recursive walk the key is deployed BEFORE probing each level, not at
the end. The probe uses BatchMode, so an un-keyed machine answers nothing and
the level below it stays invisible — deploying afterwards would find only the
first level.

virt-manager, when installed, gets the machines that actually run libvirt
added to its connection list, so their nested VMs can be driven from the local
GUI. The URI uses the SSH ALIAS rather than the raw IP: qemu+ssh goes through
the ssh binary, hence ~/.ssh/config, so the alias already carries both the
address and the ProxyJump — a bare IP could not reach a nested VM at all.

Connections live in GSettings, not a file. The list is READ first and written
back merged, so nothing already configured is lost, and a failed read (no
schema, no virt-manager) simply means the whole feature stays silent — no
prompt, no write. virt-manager rewrites its settings when it exits, so a
warning says to restart it.

Verified: key generated with 0600 on the private half and reused on the second
call; ssh-copy-id issued only for hosts that need it; the recursive walk
deploying level by level before each probe; GSettings merge keeping existing
URIs and skipping the write when nothing is missing; « @as [] », a populated
list and a missing schema all parsed correctly.

Caveat: virt-manager is not installed on this machine, so the absent path was
exercised for real and the write path only against a stubbed gsettings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-01 07:40:00 -04:00
parent b8bfb0f8e0
commit b197cc01c5
2 changed files with 259 additions and 4 deletions

View file

@ -2,6 +2,7 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import ast
import configparser
import datetime
import getpass
@ -1318,19 +1319,207 @@ class TODO:
names = self._qemu_pick_domains()
if not names:
return
deploy_key = self._is_yes_default_yes(
input(t("Create the SSH key if missing and deploy it? (Y/n): "))
)
ip_map = self._qemu_resolve_ips(names, timeout=60)
written = 0
written = []
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
written.append(name)
print(
f"\n{written} {self._plural(t('entry'), written)}"
f" {t('written')} ({len(names) - written} {t('skipped')})"
f"\n{len(written)} {self._plural(t('entry'), len(written))}"
f" {t('written')} ({len(names) - len(written)} {t('skipped')})"
)
if deploy_key:
self._ssh_deploy_keys(written)
def _ssh_ensure_key(self):
"""Chemin de la clé PUBLIQUE, générée si aucune n'existe.
Sans clé, ssh-copy-id n'a rien à déployer. On en crée une ed25519 sans
passphrase le même choix que `deploy_qemu.ensure_ssh_key`, pour que
les VM créées et celles adoptées ici partagent la même clé."""
existing = self._qemu_default_ssh_key()
if existing:
return existing
path = os.path.expanduser("~/.ssh/id_ed25519")
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
print(f"🔑 {t('Generating an ed25519 SSH key')}: {path}")
try:
res = subprocess.run(
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", path],
capture_output=True,
text=True,
timeout=60,
)
except (OSError, subprocess.SubprocessError) as exc:
print(f"{t('Cannot generate the key')}: {exc}")
return ""
if res.returncode != 0:
print(f"{t('Cannot generate the key')}: {res.stderr.strip()}")
return ""
return f"{path}.pub"
@staticmethod
def _ssh_key_accepted(alias):
"""Vrai si la connexion par CLÉ passe déjà (aucun mot de passe).
`PasswordAuthentication=no` est le point clé : sans lui, ssh
basculerait sur le mot de passe et on croirait la clé installée."""
try:
res = subprocess.run(
[
"ssh",
"-o",
"BatchMode=yes",
"-o",
"PasswordAuthentication=no",
"-o",
"ConnectTimeout=10",
alias,
"true",
],
capture_output=True,
timeout=45,
)
return res.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def _ssh_deploy_keys(self, aliases):
"""Déploie la clé publique sur les hôtes qui ne l'ont pas encore.
ssh-copy-id passe par ssh, donc par ~/.ssh/config : le ProxyJump d'une
VM imbriquée s'applique tout seul. Le mot de passe est demandé
directement dans le terminal (pas de capture de la sortie), sinon
l'invite serait invisible."""
if not aliases:
return
pub = self._ssh_ensure_key()
if not pub:
return
print(
f"\n🔑 {t('Deploying the key on')} {len(aliases)} "
f"{self._plural(t('host'), len(aliases))} ({pub})"
)
n_ok = n_skip = n_fail = 0
for alias in aliases:
if self._ssh_key_accepted(alias):
print(f" · {alias}: {t('key already accepted')}")
n_skip += 1
continue
print(f" ⤴ ssh-copy-id {alias}")
try:
res = subprocess.run(
["ssh-copy-id", "-i", pub, alias], timeout=180
)
ok = res.returncode == 0
except (OSError, subprocess.SubprocessError) as exc:
print(f"{exc}")
ok = False
if ok:
n_ok += 1
else:
n_fail += 1
print(
f" {n_ok} {t('deployed')} · {n_skip} {t('already there')} · "
f"{n_fail} {t('failed')}"
)
# Connexions de virt-manager : stockées dans GSettings, pas dans un
# fichier. Le schéma est le même depuis des années (virt-manager 5.1
# inclus) ; on LIT d'abord, et on n'écrit que si la lecture a marché.
_VIRT_MANAGER_SCHEMA = "org.virt-manager.virt-manager.connections"
def _virt_manager_uris(self):
"""URI déjà connues de virt-manager, ou None s'il n'est pas là."""
if not shutil.which("virt-manager") or not shutil.which("gsettings"):
return None
try:
res = subprocess.run(
["gsettings", "get", self._VIRT_MANAGER_SCHEMA, "uris"],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
return None
if res.returncode != 0:
return None
# GSettings rend du littéral Python : ['a', 'b'] ou @as [].
raw = res.stdout.strip()
if raw.startswith("@as "):
raw = raw[4:].strip()
try:
value = ast.literal_eval(raw)
except (ValueError, SyntaxError):
return None
return [str(item) for item in value] if isinstance(value, list) else []
def _virt_manager_add(self, uris):
"""Ajoute les URI manquantes à virt-manager. Renvoie le nb ajouté."""
current = self._virt_manager_uris()
if current is None:
return 0
missing = [uri for uri in uris if uri not in current]
if not missing:
print(f" · {t('virt-manager: every connection already there')}")
return 0
merged = current + missing
literal = "[" + ", ".join(f"'{uri}'" for uri in merged) + "]"
try:
res = subprocess.run(
[
"gsettings",
"set",
self._VIRT_MANAGER_SCHEMA,
"uris",
literal,
],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError) as exc:
print(f" ⚠ virt-manager: {exc}")
return 0
if res.returncode != 0:
print(f" ⚠ virt-manager: {res.stderr.strip()}")
return 0
for uri in missing:
print(f" ✅ virt-manager: {uri}")
return len(missing)
def _virt_manager_offer(self, hosts):
"""Propose d'ajouter à virt-manager les machines qui font tourner
libvirt, pour piloter leurs VM depuis l'interface graphique locale.
On passe par l'ALIAS SSH et non par l'IP : le transport qemu+ssh
utilise le binaire ssh, donc ~/.ssh/config l'alias porte déjà
l'adresse ET le ProxyJump, ce qu'une IP brute ne saurait pas faire
pour une VM imbriquée."""
if self._virt_manager_uris() is None:
return
uris = ["qemu:///system"] + [
f"qemu+ssh://erplibre@{alias}/system" for alias in hosts
]
print(f"\n🖥 {t('virt-manager detected')}")
for uri in uris:
print(f" {uri}")
if not self._is_yes_default_yes(
input(t("Add the missing connections to virt-manager? (Y/n): "))
):
return
added = self._virt_manager_add(uris)
if added:
# virt-manager réécrit ses réglages en quittant : s'il tourne, il
# écraserait ce qu'on vient d'ajouter.
print(f"{t('Restart virt-manager if it is open.')}")
def _qemu_ssh_probe_remote(self, alias):
"""VM libvirt vues DEPUIS `alias` : [(nom, ip)].
@ -1380,10 +1569,14 @@ class TODO:
max_depth = max(1, int(raw)) if raw else self._QEMU_SSH_DEPTH
except ValueError:
max_depth = self._QEMU_SSH_DEPTH
deploy_key = self._is_yes_default_yes(
input(t("Create the SSH key if missing and deploy it? (Y/n): "))
)
# Niveau 0 : les VM locales, jointes directement.
ip_map = self._qemu_resolve_ips(roots, timeout=60)
aliases = {} # alias -> (nom, ip, parent|None)
hosts_libvirt = [] # machines qui font tourner libvirt
frontier = []
for name in roots:
ip = ip_map.get(name)
@ -1397,6 +1590,11 @@ class TODO:
for depth in range(1, max_depth + 1):
if not frontier:
break
# La clé est déployée AVANT de sonder : la sonde utilise
# BatchMode, donc sans clé acceptée elle échouerait et le niveau
# suivant resterait invisible.
if deploy_key:
self._ssh_deploy_keys(frontier)
print(
f"\n🔎 {t('Level')} {depth}"
f"{len(frontier)} {t('machines to probe')}"
@ -1407,6 +1605,7 @@ class TODO:
if found is None:
print(f"{parent}: {t('unreachable or no libvirt')}")
continue
hosts_libvirt.append(parent)
if not found:
print(f" · {parent}: {t('no nested VM')}")
continue
@ -1434,6 +1633,10 @@ class TODO:
via = f" ({t('via')} {parent})" if parent else ""
print(f" ssh {alias:<34} {ip}{via}")
# Les machines qui hébergent des VM sont celles qui valent d'être
# ajoutées à virt-manager : c'est de là qu'on pilote leurs invitées.
self._virt_manager_offer(hosts_libvirt)
def _qemu_stats(self):
"""Statistiques d'utilisation de QEMU, et remise à zéro.

View file

@ -1452,6 +1452,58 @@ TRANSLATIONS = {
"fr": "aucune VM imbriquée",
"en": "no nested VM",
},
"Create the SSH key if missing and deploy it? (Y/n): ": {
"fr": "Créer la clé SSH si absente et la déployer ? "
"(O/n, défaut : oui) : ",
"en": "Create the SSH key if missing and deploy it? "
"(Y/n, default: yes): ",
},
"Generating an ed25519 SSH key": {
"fr": "Génération d'une clé SSH ed25519",
"en": "Generating an ed25519 SSH key",
},
"Cannot generate the key": {
"fr": "Impossible de générer la clé",
"en": "Cannot generate the key",
},
"Deploying the key on": {
"fr": "Déploiement de la clé sur",
"en": "Deploying the key on",
},
"host": {
"fr": "hôte",
"en": "host",
},
"key already accepted": {
"fr": "clé déjà acceptée",
"en": "key already accepted",
},
"deployed": {
"fr": "déployée(s)",
"en": "deployed",
},
"already there": {
"fr": "déjà en place",
"en": "already there",
},
"virt-manager detected": {
"fr": "virt-manager détecté",
"en": "virt-manager detected",
},
"virt-manager: every connection already there": {
"fr": "virt-manager : toutes les connexions sont déjà là",
"en": "virt-manager: every connection already there",
},
"Add the missing connections to virt-manager? (Y/n): ": {
"fr": "Ajouter les connexions manquantes à virt-manager ? "
"(O/n, défaut : oui) : ",
"en": "Add the missing connections to virt-manager? "
"(Y/n, default: yes): ",
},
"Restart virt-manager if it is open.": {
"fr": "Redémarrer virt-manager s'il est ouvert.",
"en": "Restart virt-manager if it is open.",
},
"SSH hosts written": {
"fr": "Hôtes SSH écrits",
"en": "SSH hosts written",