[IMP] qemu ssh: show the nesting chain in virt-manager too

A nested QEMU host appeared in virt-manager under its bare name, so nothing
said which machine it lived inside. It now carries the same « parent+child »
chain the ssh aliases use, at two levels of defence:

- the connection URI uses the CHAINED alias, so even a virt-manager that
  ignores custom labels shows the nesting in the host part;
- pretty-name is set explicitly, which is the label virt-manager actually
  displays.

pretty-name lives in a RELOCATABLE GSettings schema,
org.virt-manager.virt-manager.connection, one path per connection. The path is
built with no escaping at all: virt-manager just deletes every « / » from the
URI and uses the rest verbatim (virtManager/config.py, _make_perconn_key), so
qemu+ssh://erplibre@a+b/system becomes conns/qemu+ssh:erplibre@a+bsystem/.

That was verified for real rather than assumed: virt-manager is not installed
here, so its gschema was fetched from the distribution package, compiled into
a temporary schema dir, and the set/get/reset round trip run against it with a
genuinely chained URI — colons, @ and + all survive the dconf path. The key
was reset afterwards; nothing was left in dconf.

Making the URI carry the chain needs the chained name to resolve, so a nested
host is now written as « Host <short> <parent+child> » — ONE block, two names,
since ssh accepts several patterns on a Host line. The short name stays for
typing and is still only kept when free; the chain is always there. Checked
with ssh -G: both names yield the same HostName and ProxyJump, and a third
level chains as a+b+c. libvirt was checked too — it hands the URI host to ssh
untouched, + included.

Replacing a block by name meant a regex that could not see a Host line
carrying several names, which would have left the same name defined twice —
ssh honours the first, so an update would silently not apply. Removal now
parses the file into blocks and drops any whose name list intersects the new
one. Verified on a config with a global directive, a Match block and a
multi-name Host: only the targeted block goes, rewriting twice is idempotent,
and an unknown name leaves the file byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-02 02:18:58 -04:00
parent e687b874d6
commit cd8c32b5b2

View file

@ -1455,6 +1455,36 @@ class TODO:
# fichier. Le schéma est le même depuis des années (virt-manager 5.1 # 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é. # inclus) ; on LIT d'abord, et on n'écrit que si la lecture a marché.
_VIRT_MANAGER_SCHEMA = "org.virt-manager.virt-manager.connections" _VIRT_MANAGER_SCHEMA = "org.virt-manager.virt-manager.connections"
# Schéma RELOCATABLE d'UNE connexion : il porte son nom affiché.
_VIRT_MANAGER_CONN_SCHEMA = "org.virt-manager.virt-manager.connection"
@staticmethod
def _virt_manager_conn_path(uri):
"""Chemin dconf des réglages d'une connexion.
virt-manager ne fait aucun échappement : il retire simplement TOUS
les « / » de l'URI et s'en sert comme segment unique
(virtManager/config.py, _make_perconn_key). Le « :», le « @» et le
« + » restent donc tels quels."""
return f"/org/virt-manager/virt-manager/conns/{uri.replace('/', '')}/"
def _virt_manager_set_label(self, uri, label):
"""Fixe le nom affiché d'une connexion. Sans lui, virt-manager
fabrique un libellé à partir de l'URI, où l'imbrication se lit mal."""
target = (
f"{self._VIRT_MANAGER_CONN_SCHEMA}:"
f"{self._virt_manager_conn_path(uri)}"
)
try:
res = subprocess.run(
["gsettings", "set", target, "pretty-name", label],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
return False
return res.returncode == 0
def _virt_manager_uris(self): def _virt_manager_uris(self):
"""URI déjà connues de virt-manager, ou None s'il n'est pas là.""" """URI déjà connues de virt-manager, ou None s'il n'est pas là."""
@ -1522,12 +1552,14 @@ class TODO:
On passe par l'ALIAS SSH et non par l'IP : le transport qemu+ssh 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à 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 l'adresse ET le ProxyJump, ce qu'une IP brute ne saurait pas faire
pour une VM imbriquée.""" pour une VM imbriquée. `hosts` contient les noms CHAÎNÉS, de sorte
que l'imbrication se lise aussi dans l'interface graphique."""
if self._virt_manager_uris() is None: if self._virt_manager_uris() is None:
return return
uris = ["qemu:///system"] + [ labels = {
f"qemu+ssh://erplibre@{alias}/system" for alias in hosts f"qemu+ssh://erplibre@{alias}/system": alias for alias in hosts
] }
uris = ["qemu:///system"] + list(labels)
print(f"\n🖥 {t('virt-manager detected')}") print(f"\n🖥 {t('virt-manager detected')}")
for uri in uris: for uri in uris:
print(f" {uri}") print(f" {uri}")
@ -1535,6 +1567,10 @@ class TODO:
input(t("Add the missing connections to virt-manager? (Y/n): ")) input(t("Add the missing connections to virt-manager? (Y/n): "))
): ):
return return
# Le nom affiché est posé AVANT l'ajout à la liste : virt-manager le
# lit au moment d'afficher la connexion, pas à l'inscription.
for uri, label in labels.items():
self._virt_manager_set_label(uri, label)
added = self._virt_manager_add(uris) added = self._virt_manager_add(uris)
if added: if added:
# virt-manager réécrit ses réglages en quittant : s'il tourne, il # virt-manager réécrit ses réglages en quittant : s'il tourne, il
@ -1613,8 +1649,10 @@ class TODO:
# Niveau 0 : les VM locales, jointes directement. # Niveau 0 : les VM locales, jointes directement.
ip_map = self._qemu_resolve_ips(roots, timeout=60) ip_map = self._qemu_resolve_ips(roots, timeout=60)
aliases = {} # alias -> (nom, ip, parent|None) entries = [] # un enregistrement par machine écrite
hosts_libvirt = [] # machines qui font tourner libvirt taken = set() # tous les noms d'hôte déjà attribués
chain_of = {} # alias -> nom chaîné « parent+enfant »
hosts_libvirt = [] # machines qui font tourner QEMU
frontier = [] frontier = []
for name in roots: for name in roots:
ip = ip_map.get(name) ip = ip_map.get(name)
@ -1624,7 +1662,11 @@ class TODO:
self._write_ssh_config_entry( self._write_ssh_config_entry(
name, "erplibre", ip, identity_file=identity name, "erplibre", ip, identity_file=identity
) )
aliases[name] = (name, ip, None) entries.append(
{"names": [name], "ip": ip, "parent": None, "chain": name}
)
taken.add(name)
chain_of[name] = name
frontier.append(name) frontier.append(name)
for depth in range(1, max_depth + 1): for depth in range(1, max_depth + 1):
@ -1659,33 +1701,47 @@ class TODO:
if not ip: if not ip:
print(f"{parent} {child}: {t('no IP')}") print(f"{parent} {child}: {t('no IP')}")
continue continue
# Le nom court est plus agréable à taper ; on ne le # Le nom CHAÎNÉ dit où vit la VM ; le nom court est plus
# préfixe du parent qu'en cas de collision, pour ne # agréable à taper. UN SEUL bloc porte les deux — ssh
# jamais écraser l'entrée d'une autre machine. # accepte plusieurs noms sur la ligne Host — et le court
alias = child # n'est retenu que s'il est libre, pour ne jamais masquer
if alias in aliases: # une autre machine.
alias = f"{parent}+{child}" chain = f"{chain_of[parent]}+{child}"
if alias in aliases: if chain in taken:
continue # déjà vu (cycle) continue # déjà vu (cycle)
names = [chain] if child in taken else [child, chain]
self._write_ssh_config_entry( self._write_ssh_config_entry(
alias, names,
"erplibre", "erplibre",
ip, ip,
proxy_jump=parent, proxy_jump=parent,
identity_file=identity, identity_file=identity,
) )
aliases[alias] = (child, ip, parent) entries.append(
next_frontier.append(alias) {
"names": names,
"ip": ip,
"parent": parent,
"chain": chain,
}
)
taken.update(names)
chain_of[names[0]] = chain
next_frontier.append(names[0])
frontier = next_frontier frontier = next_frontier
print(f"\n── {t('SSH hosts written')} ──") print(f"\n── {t('SSH hosts written')} ──")
for alias, (name, ip, parent) in aliases.items(): for item in entries:
via = f" ({t('via')} {parent})" if parent else "" via = f" ({t('via')} {item['parent']})" if item["parent"] else ""
print(f" ssh {alias:<34} {ip}{via}") print(f" ssh {' '.join(item['names']):<44} {item['ip']}{via}")
# Les machines qui hébergent des VM sont celles qui valent d'être # Les machines qui hébergent QEMU sont celles qui valent d'être
# ajoutées à virt-manager : c'est de là qu'on pilote leurs invitées. # ajoutées à virt-manager : c'est de là qu'on pilote leurs invitées.
self._virt_manager_offer(hosts_libvirt) # On y présente le nom CHAÎNÉ, pour que l'imbrication se lise aussi
# dans l'interface graphique et pas seulement dans ~/.ssh/config.
self._virt_manager_offer(
[chain_of.get(alias, alias) for alias in hosts_libvirt]
)
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.
@ -2984,11 +3040,50 @@ class TODO:
priv = pub_path[:-4] if pub_path.endswith(".pub") else pub_path priv = pub_path[:-4] if pub_path.endswith(".pub") else pub_path
return priv if os.path.exists(os.path.expanduser(priv)) else "" return priv if os.path.exists(os.path.expanduser(priv)) else ""
@staticmethod
def _ssh_config_drop_hosts(content, names):
"""Retire les blocs « Host … » qui déclarent l'un de `names`.
On découpe en blocs plutôt que de substituer par expression
régulière : une ligne Host peut porter PLUSIEURS noms, et il faut
alors retirer le bloc entier dès qu'un seul de ses noms est repris —
sinon le même nom se retrouverait défini deux fois, et ssh
appliquerait la première définition rencontrée."""
drop = set(names)
out, block, block_names = [], [], set()
def flush():
if block and not (block_names & drop):
out.extend(block)
for line in content.splitlines(keepends=True):
if re.match(r"^[ \t]*Host[ \t]+", line):
flush()
block = [line]
block_names = set(line.split()[1:])
elif block:
# Une ligne non indentée et non vide clôt le bloc (Match,
# directive globale…) : elle n'appartient à personne.
if line.strip() and not line[:1].isspace():
flush()
block, block_names = [], set()
out.append(line)
else:
block.append(line)
else:
out.append(line)
flush()
return "".join(out)
def _write_ssh_config_entry( def _write_ssh_config_entry(
self, host, user, ip, proxy_jump=None, identity_file=None self, host, user, ip, proxy_jump=None, identity_file=None
): ):
"""Écrit/remplace un bloc « Host <host> » dans ~/.ssh/config. """Écrit/remplace un bloc « Host <host> » dans ~/.ssh/config.
`host` peut être une liste de noms : ils partagent alors un seul bloc.
Sert aux VM imbriquées, joignables par leur nom court ET par leur nom
chaîné « parent+enfant », qui montre elles vivent.
`proxy_jump` : alias du rebond pour une VM imbriquée, dont l'IP n'est `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 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.
@ -2996,23 +3091,16 @@ class TODO:
`identity_file` : clé PRIVÉE à présenter. Sans elle, ssh propose `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 toutes les identités de l'agent et un parc un peu fourni déclenche
« Too many authentication failures » avant d'arriver à la bonne.""" « Too many authentication failures » avant d'arriver à la bonne."""
names = [host] if isinstance(host, str) else list(host)
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 = ""
if os.path.exists(cfg): if os.path.exists(cfg):
with open(cfg, encoding="utf-8") as fh: with open(cfg, encoding="utf-8") as fh:
existing = fh.read() existing = fh.read()
# Retire un ancien bloc du même Host (ses lignes indentées). Pas de existing = self._ssh_config_drop_hosts(existing, names).rstrip("\n")
# drapeau DOTALL : « . » ne doit PAS traverser les sauts de ligne,
# sinon .* engloutirait tout jusqu'à la fin du fichier et effacerait
# les entrées suivantes.
pattern = re.compile(
rf"(?m)^[ \t]*Host[ \t]+{re.escape(host)}[ \t]*\n"
r"(?:[ \t]+[^\n]*\n?)*"
)
existing = pattern.sub("", existing).rstrip("\n")
block = ( block = (
f"Host {host}\n" f"Host {' '.join(names)}\n"
f" HostName {ip}\n" f" HostName {ip}\n"
f" User {user}\n" f" User {user}\n"
# IP DHCP réutilisées entre VM -> on évite l'erreur de clé d'hôte. # IP DHCP réutilisées entre VM -> on évite l'erreur de clé d'hôte.
@ -3033,7 +3121,7 @@ class TODO:
with open(cfg, "w", encoding="utf-8") as fh: with open(cfg, "w", encoding="utf-8") as fh:
fh.write(content) fh.write(content)
os.chmod(cfg, 0o600) os.chmod(cfg, 0o600)
print(f"{t('Added to ~/.ssh/config:')} ssh {host}") print(f"{t('Added to ~/.ssh/config:')} ssh {names[0]}")
def _qemu_list_domains(self): def _qemu_list_domains(self):
"""Noms des VM libvirt définies (via virsh).""" """Noms des VM libvirt définies (via virsh)."""