[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:
parent
d6ec01d289
commit
e8f7e8384a
2 changed files with 105 additions and 15 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue