[IMP] qemu ssh: ask about the key when SSH refuses, not before
« Créer la clé SSH si absente et la déployer ? » was asked before anything had
been attempted — so before knowing whether a key was needed at all. On a fleet
already reachable it was pure noise, and answering no left every later failure
reported as a flat « injoignable ».
The question now appears where the problem does:
🔒 hote: SSH refused the identity.
Permission denied (publickey).
Aucune clé SSH dans ~/.ssh.
En créer une et la déployer ? (O/n)
and the host is probed again straight after, so the walk carries on into its
guests instead of stopping.
That required telling a refused identity from an unreachable host, which the
probe could not do: both returned None. It now reports which — auth or net —
by matching ssh's own wording (permission denied, too many authentication
failures, no such identity, host key verification failed). An unreachable host
never triggers the key question, because a key would not help it, and the real
ssh message is printed either way rather than a generic label.
A key created mid-walk is picked up by the entries written afterwards, so
their IdentityFile names it.
Verified against ssh's actual messages: the eight classified correctly,
including « Identity file not accessible » which is a warning about a missing
file, not a refusal. Then the flow: a fleet that answers straight away is
never asked about keys at all, a refusal asks and — once accepted — creates,
deploys, re-probes and configures the guest, declining reports « accès
refusé » without deploying, and an unreachable host says so without mentioning
identity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5d6a262489
commit
9ee54f00cd
2 changed files with 116 additions and 28 deletions
|
|
@ -1427,10 +1427,9 @@ class TODO:
|
||||||
max_depth = max(1, int(raw)) if raw else self._QEMU_SSH_DEPTH
|
max_depth = max(1, int(raw)) if raw else self._QEMU_SSH_DEPTH
|
||||||
except ValueError:
|
except ValueError:
|
||||||
max_depth = self._QEMU_SSH_DEPTH
|
max_depth = self._QEMU_SSH_DEPTH
|
||||||
deploy_key = self._is_yes_default_yes(
|
# Aucune question sur la clé ici : tant que rien n'a échoué, elle
|
||||||
input(t("Create the SSH key if missing and deploy it? (Y/n): "))
|
# serait prématurée. Elle est posée à la première identité refusée.
|
||||||
)
|
self._qemu_ssh_walk(roots, max_depth)
|
||||||
self._qemu_ssh_walk(roots, max_depth, deploy_key)
|
|
||||||
|
|
||||||
def _qemu_pick_domains(self):
|
def _qemu_pick_domains(self):
|
||||||
"""Fait choisir des VM parmi celles définies. Vide = toutes."""
|
"""Fait choisir des VM parmi celles définies. Vide = toutes."""
|
||||||
|
|
@ -1671,9 +1670,57 @@ class TODO:
|
||||||
note = t("Restart virt-manager to see the new connections")
|
note = t("Restart virt-manager to see the new connections")
|
||||||
print(f" ℹ️ {note} ({t('the names apply live')})")
|
print(f" ℹ️ {note} ({t('the names apply live')})")
|
||||||
|
|
||||||
|
# Signatures d'un refus d'AUTHENTIFICATION dans la sortie de ssh, par
|
||||||
|
# opposition à un hôte éteint ou introuvable. C'est la distinction qui
|
||||||
|
# décide s'il vaut la peine de parler de clé SSH.
|
||||||
|
_SSH_AUTH_ERRORS = (
|
||||||
|
"permission denied",
|
||||||
|
"too many authentication failures",
|
||||||
|
"no such identity",
|
||||||
|
"host key verification failed",
|
||||||
|
"publickey",
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _ssh_error_kind(cls, stderr):
|
||||||
|
"""« auth » si ssh a refusé l'identité, « net » sinon.
|
||||||
|
|
||||||
|
Un hôte éteint et une clé absente produisent tous deux « injoignable »
|
||||||
|
alors qu'ils n'appellent pas du tout la même réponse."""
|
||||||
|
text = (stderr or "").lower()
|
||||||
|
if any(marker in text for marker in cls._SSH_AUTH_ERRORS):
|
||||||
|
return "auth"
|
||||||
|
return "net"
|
||||||
|
|
||||||
|
def _qemu_ssh_retry_with_key(self, alias, message):
|
||||||
|
"""ssh a refusé l'identité : proposer la clé, puis resonder une fois.
|
||||||
|
|
||||||
|
Posée ICI et pas au début : tant que rien n'échoue, la question est
|
||||||
|
prématurée — et si l'accès passe déjà par une clé d'agent ou un autre
|
||||||
|
mécanisme, elle n'aurait jamais lieu d'être."""
|
||||||
|
print(f"\n 🔒 {alias}: {t('SSH refused the identity.')}")
|
||||||
|
print(f" {message}")
|
||||||
|
pub = self._qemu_default_ssh_key()
|
||||||
|
if pub:
|
||||||
|
print(f" {t('Existing key:')} {pub}")
|
||||||
|
question = t("Deploy it on this host (ssh-copy-id)? (Y/n): ")
|
||||||
|
else:
|
||||||
|
print(f" {t('No SSH key in ~/.ssh.')}")
|
||||||
|
question = t("Create one and deploy it? (Y/n): ")
|
||||||
|
if not self._is_yes_default_yes(input(f" {question}")):
|
||||||
|
return "auth", message
|
||||||
|
if not self._ssh_ensure_key():
|
||||||
|
return "auth", message
|
||||||
|
self._ssh_deploy_keys([alias])
|
||||||
|
return self._qemu_ssh_probe_remote(alias)
|
||||||
|
|
||||||
def _qemu_ssh_probe_remote(self, alias):
|
def _qemu_ssh_probe_remote(self, alias):
|
||||||
"""Sonde `alias` : (libvirt_présent, [(nom, ip)]), ou None si
|
"""Sonde `alias`. Renvoie (statut, données) :
|
||||||
injoignable.
|
|
||||||
|
("ok", [(nom, ip)]) libvirt répond, voici ses VM
|
||||||
|
("nolibvirt", []) joignable, mais pas de QEMU
|
||||||
|
("auth", message) ssh a refusé l'identité
|
||||||
|
("net", message) injoignable (éteint, DNS, port fermé…)
|
||||||
|
|
||||||
Passe par « ssh <alias> », donc par le bloc ~/.ssh/config qu'on vient
|
Passe par « ssh <alias> », donc par le bloc ~/.ssh/config qu'on vient
|
||||||
d'écrire : le ProxyJump du parent s'applique tout seul et la même
|
d'écrire : le ProxyJump du parent s'applique tout seul et la même
|
||||||
|
|
@ -1695,10 +1742,14 @@ class TODO:
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
cmd, capture_output=True, text=True, timeout=90
|
cmd, capture_output=True, text=True, timeout=90
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.SubprocessError):
|
except subprocess.TimeoutExpired:
|
||||||
return None
|
return "net", t("timed out")
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
return "net", str(exc)
|
||||||
if res.returncode != 0:
|
if res.returncode != 0:
|
||||||
return None
|
detail = (res.stderr or "").strip().splitlines()
|
||||||
|
message = detail[-1] if detail else f"exit {res.returncode}"
|
||||||
|
return self._ssh_error_kind(res.stderr), message
|
||||||
has_libvirt = False
|
has_libvirt = False
|
||||||
found = []
|
found = []
|
||||||
for line in res.stdout.splitlines():
|
for line in res.stdout.splitlines():
|
||||||
|
|
@ -1709,9 +1760,9 @@ class TODO:
|
||||||
has_libvirt = parts[1].strip() == "yes"
|
has_libvirt = parts[1].strip() == "yes"
|
||||||
continue
|
continue
|
||||||
found.append((parts[0], parts[1].strip()))
|
found.append((parts[0], parts[1].strip()))
|
||||||
return has_libvirt, found
|
return ("ok" if has_libvirt else "nolibvirt"), found
|
||||||
|
|
||||||
def _qemu_ssh_walk(self, roots, max_depth, deploy_key):
|
def _qemu_ssh_walk(self, roots, max_depth):
|
||||||
"""Descend le parc depuis `roots` et écrit un ProxyJump par niveau.
|
"""Descend le parc depuis `roots` et écrit un ProxyJump par niveau.
|
||||||
|
|
||||||
Une VM du profil « Déploiement » héberge elle-même des VM : celles-ci
|
Une VM du profil « Déploiement » héberge elle-même des VM : celles-ci
|
||||||
|
|
@ -1722,14 +1773,10 @@ class TODO:
|
||||||
Une racine sans IP est un hôte DÉJÀ décrit dans ~/.ssh/config : son
|
Une racine sans IP est un hôte DÉJÀ décrit dans ~/.ssh/config : son
|
||||||
adresse y est, on ne la réécrit pas, on part simplement de lui.
|
adresse y est, on ne la réécrit pas, on part simplement de lui.
|
||||||
"""
|
"""
|
||||||
# La clé est fixée AVANT d'écrire quoi que ce soit : c'est elle qui
|
# Clé existante s'il y en a une : elle va dans IdentityFile. Si une
|
||||||
# va dans IdentityFile à chaque niveau, et celle qu'on déploiera.
|
# clé est créée plus tard, en réaction à un refus, les entrées
|
||||||
pub = (
|
# suivantes la reprendront.
|
||||||
self._ssh_ensure_key()
|
identity = self._ssh_private_key(self._qemu_default_ssh_key())
|
||||||
if deploy_key
|
|
||||||
else self._qemu_default_ssh_key()
|
|
||||||
)
|
|
||||||
identity = self._ssh_private_key(pub)
|
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -1763,22 +1810,34 @@ class TODO:
|
||||||
for depth in range(1, max_depth):
|
for depth in range(1, max_depth):
|
||||||
if not frontier:
|
if not frontier:
|
||||||
break
|
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(
|
print(
|
||||||
f"\n🔎 {t('Level')} {depth + 1} — "
|
f"\n🔎 {t('Level')} {depth + 1} — "
|
||||||
f"{len(frontier)} {t('machines to probe')}"
|
f"{len(frontier)} {t('machines to probe')}"
|
||||||
)
|
)
|
||||||
next_frontier = []
|
next_frontier = []
|
||||||
for parent in frontier:
|
for parent in frontier:
|
||||||
probed = self._qemu_ssh_probe_remote(parent)
|
status, found = self._qemu_ssh_probe_remote(parent)
|
||||||
if probed is None:
|
if status == "auth":
|
||||||
print(f" ⏭ {parent}: {t('unreachable')}")
|
# C'EST ici qu'une clé manquante se manifeste, pas avant :
|
||||||
|
# on ne parle d'identité qu'une fois l'identité refusée.
|
||||||
|
status, found = self._qemu_ssh_retry_with_key(
|
||||||
|
parent, found
|
||||||
|
)
|
||||||
|
# Une clé a pu naître de cet échange : les entrées
|
||||||
|
# écrites ensuite doivent la nommer.
|
||||||
|
identity = (
|
||||||
|
self._ssh_private_key(self._qemu_default_ssh_key())
|
||||||
|
or identity
|
||||||
|
)
|
||||||
|
if status in ("auth", "net"):
|
||||||
|
label = (
|
||||||
|
t("access refused")
|
||||||
|
if status == "auth"
|
||||||
|
else t("unreachable")
|
||||||
|
)
|
||||||
|
print(f" ⏭ {parent}: {label} — {found}")
|
||||||
continue
|
continue
|
||||||
has_libvirt, found = probed
|
has_libvirt = status == "ok"
|
||||||
if not has_libvirt:
|
if not has_libvirt:
|
||||||
print(f" · {parent}: {t('no QEMU/libvirt here')}")
|
print(f" · {parent}: {t('no QEMU/libvirt here')}")
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -1582,6 +1582,35 @@ TRANSLATIONS = {
|
||||||
"fr": "machines à sonder",
|
"fr": "machines à sonder",
|
||||||
"en": "machines to probe",
|
"en": "machines to probe",
|
||||||
},
|
},
|
||||||
|
"SSH refused the identity.": {
|
||||||
|
"fr": "SSH a refusé l'identité.",
|
||||||
|
"en": "SSH refused the identity.",
|
||||||
|
},
|
||||||
|
"Existing key:": {
|
||||||
|
"fr": "Clé existante :",
|
||||||
|
"en": "Existing key:",
|
||||||
|
},
|
||||||
|
"Deploy it on this host (ssh-copy-id)? (Y/n): ": {
|
||||||
|
"fr": "La déployer sur cet hôte (ssh-copy-id) ? "
|
||||||
|
"(O/n, défaut : oui) : ",
|
||||||
|
"en": "Deploy it on this host (ssh-copy-id)? (Y/n, default: yes): ",
|
||||||
|
},
|
||||||
|
"No SSH key in ~/.ssh.": {
|
||||||
|
"fr": "Aucune clé SSH dans ~/.ssh.",
|
||||||
|
"en": "No SSH key in ~/.ssh.",
|
||||||
|
},
|
||||||
|
"Create one and deploy it? (Y/n): ": {
|
||||||
|
"fr": "En créer une et la déployer ? (O/n, défaut : oui) : ",
|
||||||
|
"en": "Create one and deploy it? (Y/n, default: yes): ",
|
||||||
|
},
|
||||||
|
"access refused": {
|
||||||
|
"fr": "accès refusé",
|
||||||
|
"en": "access refused",
|
||||||
|
},
|
||||||
|
"timed out": {
|
||||||
|
"fr": "délai dépassé",
|
||||||
|
"en": "timed out",
|
||||||
|
},
|
||||||
"unreachable": {
|
"unreachable": {
|
||||||
"fr": "injoignable",
|
"fr": "injoignable",
|
||||||
"en": "unreachable",
|
"en": "unreachable",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue