[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:
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
# profil « ERPLibre Déploiement (+ QEMU + dev) » installe QEMU DANS la VM,
# 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)
)
# 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":
target = input(f"{t('Host or IP:')} ").strip()
if not target:
return []
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
# le ProxyJump des enfants ni virt-manager n'auraient de nom.
default_alias = "qemu-" + target.replace(".", "-").replace(
"@", "-"
)
default_alias = "qemu-" + address.replace(".", "-")
alias = (
input(
f"{t('Name for ~/.ssh/config')} ({default_alias}): "
).strip()
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()
if not names:
@ -1613,12 +1639,14 @@ class TODO:
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. `hosts` contient les noms CHAÎNÉS, de sorte
que l'imbrication se lise aussi dans l'interface graphique."""
pour une VM imbriquée. `hosts` = [(alias_chaîné, compte)], de sorte
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:
return
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)
print(f"\n🖥 {t('virt-manager detected')}")
@ -1703,15 +1731,26 @@ class TODO:
entries = [] # un enregistrement par machine écrite
taken = set() # tous les noms d'hôte déjà attribués
chain_of = {} # alias -> nom chaîné « parent+enfant »
user_of = {} # alias -> compte de connexion
hosts_libvirt = [] # machines qui font tourner QEMU
frontier = []
for root in roots:
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:
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)
chain_of[alias] = alias
frontier.append(alias)
@ -1756,15 +1795,23 @@ class TODO:
chain = f"{chain_of[parent]}+{child}"
if chain in taken:
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(
chain,
"erplibre",
user_of[chain],
ip,
proxy_jump=parent,
identity_file=identity,
)
entries.append(
{"names": [chain], "ip": ip, "parent": parent}
{
"names": [chain],
"ip": ip,
"parent": parent,
"user": user_of[chain],
}
)
taken.add(chain)
chain_of[chain] = chain
@ -1774,14 +1821,20 @@ class TODO:
print(f"\n── {t('SSH hosts written')} ──")
for item in entries:
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
# 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
# dans l'interface graphique et pas seulement dans ~/.ssh/config.
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):
@ -5409,6 +5462,39 @@ class TODO:
pass
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
def _port_is_free(port):
"""Vrai si rien n'écoute sur ce port en local."""

View file

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