[FIX] qemu ssh: take the login from ~/.ssh/config, not a hardcoded erplibre

Adopting a host from ~/.ssh/config only to write « User erplibre » under its
guests defeats the point: those hosts are not necessarily ERPLibre VMs, and a
guest follows its parent's convention because the parent created it.

The declared User is now read and propagated — to the guest entries, to the
summary line, and to the qemu+ssh URI handed to virt-manager. Only when
nothing is declared does QEMU_VM_USER apply, the cloud-init account of VMs
deployed from here, now a named constant instead of a literal repeated at
each call site.

Resolution follows OpenSSH: the FIRST User among the matching blocks wins,
wildcards included. That is the opposite of what one expects, so it was
checked against ssh -G rather than the manual: with « Host * / User global »
placed first, ssh really does report global even for a host that declares its
own — which is why the manual tells you to put « Host * » last. The
implementation matches on both layouts.

« user@host » is also accepted when typing a raw address, and otherwise the
account is asked with QEMU_VM_USER as default.

ssh-copy-id needed no change: it is given the alias, whose block now carries
the right User.

Verified on a config with a per-host User, a host without one and a trailing
« Host * »: the three resolutions correct, the guest written with mathben and
not erplibre, and virt-manager offered ('hyperviseur', 'mathben').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-02 07:55:56 -04:00
parent d6ec01d289
commit e8f7e8384a
2 changed files with 105 additions and 15 deletions

View file

@ -1288,6 +1288,10 @@ class TODO:
if cmd_no_found: if cmd_no_found:
print(t("Command not found !")) print(t("Command not found !"))
# Compte créé par cloud-init dans les VM déployées ici. Sert de défaut
# quand ~/.ssh/config ne déclare aucun `User` pour l'hôte adopté.
QEMU_VM_USER = "erplibre"
# Profondeur d'exploration par défaut : hôte -> VM -> VM imbriquée. Le # Profondeur d'exploration par défaut : hôte -> VM -> VM imbriquée. Le
# profil « ERPLibre Déploiement (+ QEMU + dev) » installe QEMU DANS la VM, # profil « ERPLibre Déploiement (+ QEMU + dev) » installe QEMU DANS la VM,
# donc un parc à deux niveaux est le cas courant. # donc un parc à deux niveaux est le cas courant.
@ -1346,26 +1350,48 @@ class TODO:
hosts if not raw else self._parse_index_selection(raw, hosts) hosts if not raw else self._parse_index_selection(raw, hosts)
) )
# Déjà dans ~/.ssh/config : leur adresse y est, rien à réécrire. # Déjà dans ~/.ssh/config : leur adresse y est, rien à réécrire.
return [{"alias": name, "ip": None} for name in chosen or hosts] # Le `User` déclaré est repris tel quel : ces hôtes ne sont pas
# forcément des VM ERPLibre, et leurs invitées suivent la même
# convention que leur parent.
return [
{
"alias": name,
"ip": None,
"user": self._ssh_config_user(name),
}
for name in chosen or hosts
]
if answer == "3": if answer == "3":
target = input(f"{t('Host or IP:')} ").strip() target = input(f"{t('Host or IP:')} ").strip()
if not target: if not target:
return [] return []
if target in self._ssh_config_hosts(): if target in self._ssh_config_hosts():
return [{"alias": target, "ip": None}] return [
{
"alias": target,
"ip": None,
"user": self._ssh_config_user(target),
}
]
# « utilisateur@hôte » est accepté : c'est la forme qu'on tape
# naturellement, et elle évite une question de plus.
user, _, address = target.rpartition("@")
# Une IP brute n'est pas un alias : on lui en donne un, sinon ni # Une IP brute n'est pas un alias : on lui en donne un, sinon ni
# le ProxyJump des enfants ni virt-manager n'auraient de nom. # le ProxyJump des enfants ni virt-manager n'auraient de nom.
default_alias = "qemu-" + target.replace(".", "-").replace( default_alias = "qemu-" + address.replace(".", "-")
"@", "-"
)
alias = ( alias = (
input( input(
f"{t('Name for ~/.ssh/config')} ({default_alias}): " f"{t('Name for ~/.ssh/config')} ({default_alias}): "
).strip() ).strip()
or default_alias or default_alias
) )
return [{"alias": alias, "ip": target}] if not user:
user = (
input(f"{t('User')} ({self.QEMU_VM_USER}): ").strip()
or self.QEMU_VM_USER
)
return [{"alias": alias, "ip": address, "user": user}]
names = self._qemu_pick_domains() names = self._qemu_pick_domains()
if not names: if not names:
@ -1613,12 +1639,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. `hosts` contient les noms CHAÎNÉS, de sorte pour une VM imbriquée. `hosts` = [(alias_chaîné, compte)], de sorte
que l'imbrication se lise aussi dans l'interface graphique.""" que l'imbrication se lise aussi dans l'interface graphique et que le
compte soit celui de ~/.ssh/config, pas un défaut supposé."""
if self._virt_manager_uris() is None: if self._virt_manager_uris() is None:
return return
labels = { labels = {
f"qemu+ssh://erplibre@{alias}/system": alias for alias in hosts f"qemu+ssh://{user or self.QEMU_VM_USER}@{alias}/system": alias
for alias, user in hosts
} }
uris = ["qemu:///system"] + list(labels) uris = ["qemu:///system"] + list(labels)
print(f"\n🖥 {t('virt-manager detected')}") print(f"\n🖥 {t('virt-manager detected')}")
@ -1703,15 +1731,26 @@ class TODO:
entries = [] # un enregistrement par machine écrite entries = [] # un enregistrement par machine écrite
taken = set() # tous les noms d'hôte déjà attribués taken = set() # tous les noms d'hôte déjà attribués
chain_of = {} # alias -> nom chaîné « parent+enfant » chain_of = {} # alias -> nom chaîné « parent+enfant »
user_of = {} # alias -> compte de connexion
hosts_libvirt = [] # machines qui font tourner QEMU hosts_libvirt = [] # machines qui font tourner QEMU
frontier = [] frontier = []
for root in roots: for root in roots:
alias, ip = root["alias"], root.get("ip") alias, ip = root["alias"], root.get("ip")
# Le compte vient de ~/.ssh/config quand il y est déclaré : un
# hôte adopté n'est pas forcément une VM ERPLibre.
user_of[alias] = root.get("user") or self.QEMU_VM_USER
if ip: if ip:
self._write_ssh_config_entry( self._write_ssh_config_entry(
alias, "erplibre", ip, identity_file=identity alias, user_of[alias], ip, identity_file=identity
)
entries.append(
{
"names": [alias],
"ip": ip,
"parent": None,
"user": user_of[alias],
}
) )
entries.append({"names": [alias], "ip": ip, "parent": None})
taken.add(alias) taken.add(alias)
chain_of[alias] = alias chain_of[alias] = alias
frontier.append(alias) frontier.append(alias)
@ -1756,15 +1795,23 @@ class TODO:
chain = f"{chain_of[parent]}+{child}" chain = f"{chain_of[parent]}+{child}"
if chain in taken: if chain in taken:
continue # déjà vu (cycle) continue # déjà vu (cycle)
# L'invitée hérite du compte de son parent : elle a été
# créée par lui, avec la même convention.
user_of[chain] = user_of[parent]
self._write_ssh_config_entry( self._write_ssh_config_entry(
chain, chain,
"erplibre", user_of[chain],
ip, ip,
proxy_jump=parent, proxy_jump=parent,
identity_file=identity, identity_file=identity,
) )
entries.append( entries.append(
{"names": [chain], "ip": ip, "parent": parent} {
"names": [chain],
"ip": ip,
"parent": parent,
"user": user_of[chain],
}
) )
taken.add(chain) taken.add(chain)
chain_of[chain] = chain chain_of[chain] = chain
@ -1774,14 +1821,20 @@ class TODO:
print(f"\n── {t('SSH hosts written')} ──") print(f"\n── {t('SSH hosts written')} ──")
for item in entries: for item in entries:
via = f" ({t('via')} {item['parent']})" if item["parent"] else "" via = f" ({t('via')} {item['parent']})" if item["parent"] else ""
print(f" ssh {' '.join(item['names']):<44} {item['ip']}{via}") print(
f" ssh {' '.join(item['names']):<40}"
f" {item['user']}@{item['ip']}{via}"
)
# Les machines qui hébergent QEMU 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.
# On y présente le nom CHAÎNÉ, pour que l'imbrication se lise aussi # 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. # dans l'interface graphique et pas seulement dans ~/.ssh/config.
self._virt_manager_offer( self._virt_manager_offer(
[chain_of.get(alias, alias) for alias in hosts_libvirt] [
(chain_of.get(alias, alias), user_of.get(alias, ""))
for alias in hosts_libvirt
]
) )
def _qemu_stats(self): def _qemu_stats(self):
@ -5409,6 +5462,39 @@ class TODO:
pass pass
return names return names
@staticmethod
def _ssh_config_user(host):
"""`User` déclaré pour cet hôte dans ~/.ssh/config, ou "".
On suit la règle d'OpenSSH : le PREMIER `User` rencontré parmi les
blocs qui correspondent l'emporte, motifs (`Host *`) compris. Sans
déclaration on renvoie "" il n'y a alors rien à copier, et
l'appelant garde son défaut plutôt que d'inventer le nom de session
locale."""
import fnmatch
path = os.path.expanduser("~/.ssh/config")
matching = False
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
stripped = line.strip()
if re.match(r"^Host[ \t]+", stripped):
matching = any(
fnmatch.fnmatch(host, pattern)
for pattern in stripped.split()[1:]
)
continue
if matching:
found = re.match(
r"^User[ \t]+(\S+)", stripped, re.IGNORECASE
)
if found:
return found.group(1)
except OSError:
pass
return ""
@staticmethod @staticmethod
def _port_is_free(port): def _port_is_free(port):
"""Vrai si rien n'écoute sur ce port en local.""" """Vrai si rien n'écoute sur ce port en local."""

View file

@ -1541,6 +1541,10 @@ TRANSLATIONS = {
"fr": "Hôte ou IP :", "fr": "Hôte ou IP :",
"en": "Host or IP:", "en": "Host or IP:",
}, },
"User": {
"fr": "Utilisateur",
"en": "User",
},
"Name for ~/.ssh/config": { "Name for ~/.ssh/config": {
"fr": "Nom pour ~/.ssh/config", "fr": "Nom pour ~/.ssh/config",
"en": "Name for ~/.ssh/config", "en": "Name for ~/.ssh/config",