Repository navigation
Expand file tree
/
Copy pathssh_deploy.py
More file actions
451 lines (377 loc) · 14.2 KB
/
Copy pathssh_deploy.py
File metadata and controls
451 lines (377 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
#!/usr/bin/env python3
"""
ssh_deploy.py
Pour chaque hyperviseur libvirt (lu depuis dconf virt-manager) :
1. Génère les clés RSA-4096 et Ed25519 sur l'hyperviseur si absentes
2. Pour chaque VM running avec une IP connue :
a. Tente une connexion SSH par clé depuis l'hyperviseur → VM
b. Si échec : tente ssh-copy-id avec mot de passe (mémorisé par IP/user)
c. Si ssh-copy-id OK : reteste la connexion par clé
d. Si tout échoue : passe à la suivante
Dépendances : pip install paramiko
"""
import getpass
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from urllib.parse import urlparse
from loguru import logger as log
try:
import paramiko
except ImportError:
print("Dépendance manquante : pip install paramiko")
sys.exit(1)
def _configure_logging() -> None:
"""Sortie console de l'outil autonome (appelée par main() uniquement).
Outil lancé hors de l'application (``python3 tools/...``) : il configure
son propre puits loguru au démarrage, jamais à l'import (issue #170).
"""
log.debug("_configure_logging() called | remplacement du puits par défaut")
log.remove()
log.add(sys.stderr, level="INFO", format="{time:HH:mm:ss} [{level}] {message}")
log.debug("_configure_logging() returning | stderr niveau INFO")
@dataclass
class VMTarget:
"""Cible VM pour déploiement de clé SSH."""
name: str
ip: str
user: str = "root"
ssh_key_ok: bool = False
ssh_copy_id_ok: bool = False
error: str | None = None
@dataclass
class Hypervisor:
"""Hyperviseur et résultats de déploiement de ses VMs."""
uri: str
host: str
user: str
port: int
transport: str
vm_targets: list[VMTarget] = field(default_factory=list)
def get_libvirt_uris():
"""Lit les URIs libvirt depuis dconf.
Returns:
list[str]: URIs trouvées, ou liste vide.
"""
try:
result = subprocess.run(
["dconf", "read", "/org/virt-manager/virt-manager/connections/uris"],
capture_output=True,
text=True,
check=True,
)
raw = result.stdout.strip()
if not raw or raw == "@as []":
return []
return re.findall(r"'([^']+)'", raw)
except Exception as e:
log.error(f"dconf error: {e}")
return []
def parse_libvirt_uri(uri):
"""Convertit une URI libvirt en objet Hypervisor.
Args:
uri (str): URI libvirt source.
Returns:
Hypervisor: Hyperviseur parsé.
"""
parsed = urlparse(uri)
scheme = parsed.scheme
transport = scheme.split("+")[1] if "+" in scheme else ("tcp" if parsed.hostname else "local")
return Hypervisor(
uri=uri,
host=parsed.hostname or "localhost",
user=parsed.username or "",
port=parsed.port or 22,
transport=transport,
)
def ssh_connect(host, user, port=22, password=None, timeout=10):
"""Établit une connexion SSH vers un hôte.
Args:
host (str): Adresse de l'hôte.
user (str): Utilisateur SSH.
port (int, optional): Port SSH. Defaults to 22.
password (str | None, optional): Mot de passe SSH. Defaults to None.
timeout (int, optional): Timeout de connexion en secondes. Defaults to 10.
Returns:
paramiko.SSHClient | None: Client connecté ou None.
"""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
kwargs = dict(hostname=host, port=port, timeout=timeout, look_for_keys=True, allow_agent=True)
if user:
kwargs["username"] = user
if password:
kwargs.update(password=password, look_for_keys=False, allow_agent=False)
try:
client.connect(**kwargs)
return client
except Exception as e:
log.debug(f"Connexion échouée {host}: {e}")
return None
def ssh_run(client, cmd, timeout=30):
"""Exécute une commande SSH et retourne code, stdout, stderr.
Args:
client (paramiko.SSHClient): Client SSH connecté.
cmd (str): Commande shell.
timeout (int, optional): Timeout d'exécution. Defaults to 30.
Returns:
tuple[int, str, str]: Code de retour, stdout, stderr.
"""
_, stdout, stderr = client.exec_command(cmd, timeout=timeout)
rc = stdout.channel.recv_exit_status()
return (
rc,
stdout.read().decode(errors="replace").strip(),
stderr.read().decode(errors="replace").strip(),
)
KEY_TYPES = [
(
"rsa",
"/root/.ssh/id_rsa",
"ssh-keygen -t rsa -b 4096 -N '' -f /root/.ssh/id_rsa",
),
(
"ed25519",
"/root/.ssh/id_ed25519",
"ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519",
),
]
def ensure_ssh_keys(client, hv_host):
"""Vérifie/crée les clés SSH de l'hyperviseur et retourne les clés publiques.
Args:
client (paramiko.SSHClient): Client SSH hyperviseur.
hv_host (str): Nom/IP de l'hyperviseur (log).
Returns:
list[str]: Contenus des clés publiques disponibles.
"""
ssh_run(client, "mkdir -p /root/.ssh && chmod 700 /root/.ssh")
pubkeys = []
for ktype, privpath, keygen_cmd in KEY_TYPES:
_, out, _ = ssh_run(client, f"test -f {privpath} && echo EXISTS || echo MISSING")
if "MISSING" in out:
log.info(f" [{hv_host}] Génération clé {ktype}…")
rc, _, err = ssh_run(client, keygen_cmd)
if rc != 0:
log.error(f" Échec {ktype}: {err}")
continue
log.info(f" [{hv_host}] Clé {ktype} générée.")
else:
log.info(f" [{hv_host}] Clé {ktype} déjà présente.")
_, pub, _ = ssh_run(client, f"cat {privpath}.pub 2>/dev/null")
if pub:
pubkeys.append(pub)
return pubkeys
def load_inventory(path="inventory.json"):
"""Charge l'inventaire JSON généré en amont.
Args:
path (str, optional): Chemin de l'inventaire JSON. Defaults to "inventory.json".
Returns:
list[dict]: Contenu d'inventaire.
"""
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError:
log.error(f"{path} introuvable. Lancez d'abord libvirt_inventory.py")
sys.exit(1)
def get_vm_targets(inventory, hv_host, default_user="root"):
"""Extrait les VMs running avec IP pour un hyperviseur donné.
Args:
inventory (list[dict]): Inventaire complet.
hv_host (str): Hôte hyperviseur à filtrer.
default_user (str, optional): Utilisateur SSH VM. Defaults to "root".
Returns:
list[VMTarget]: Cibles VM prêtes au déploiement.
"""
targets = []
for hv in inventory:
if hv["host"] != hv_host:
continue
for vm in hv["vms"]:
if vm["state"] != "running":
continue
for iface in vm["interfaces"]:
ip = iface.get("ip")
if ip:
targets.append(VMTarget(name=vm["name"], ip=ip, user=default_user))
break
return targets
_password_cache: dict[tuple[str, str], str] = {}
def _subnet_key(ip, user):
return (user, ".".join(ip.split(".")[:3]))
def get_password(ip, user):
"""Récupère/cache le mot de passe SSH pour une cible.
Args:
ip (str): IP de la VM cible.
user (str): Utilisateur SSH.
Returns:
str | None: Mot de passe saisi ou None si ignoré.
"""
exact = (user, ip)
if exact in _password_cache:
return _password_cache[exact]
sk = _subnet_key(ip, user)
if sk in _password_cache:
log.info(f" Mot de passe réutilisé (réseau {sk[1]}.0/24)")
_password_cache[exact] = _password_cache[sk]
return _password_cache[exact]
print(f"\n Mot de passe SSH requis pour {user}@{ip}")
pwd = getpass.getpass(" Entrez le mot de passe (Entrée pour ignorer) : ")
if not pwd:
return None
_password_cache[exact] = pwd
_password_cache[sk] = pwd
return pwd
def test_ssh_key_from_hypervisor(hv_client, vm):
"""Teste une connexion SSH par clé de l'hyperviseur vers la VM.
Args:
hv_client (paramiko.SSHClient): Client SSH hyperviseur.
vm (VMTarget): VM cible.
Returns:
bool: True si la connexion par clé fonctionne.
"""
cmd = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 {vm.user}@{vm.ip} echo OK 2>/dev/null"
rc, out, _ = ssh_run(hv_client, cmd, timeout=15)
return rc == 0 and "OK" in out
def _shell_quote(s):
return "'" + s.replace("'", "'\\''") + "'"
def copy_id_from_hypervisor(hv_client, vm, password):
"""Déploie la clé publique hyperviseur vers une VM via ssh-copy-id.
Args:
hv_client (paramiko.SSHClient): Client SSH hyperviseur.
vm (VMTarget): VM cible.
password (str): Mot de passe SSH de la VM.
Returns:
bool: True si déploiement réussi ou clé déjà présente.
"""
rc_sp, sshpass_path, _ = ssh_run(hv_client, "which sshpass 2>/dev/null")
if rc_sp == 0 and sshpass_path.strip():
cmd = f"sshpass -p {_shell_quote(password)} ssh-copy-id -o StrictHostKeyChecking=no -o ConnectTimeout=5 {vm.user}@{vm.ip} 2>&1"
else:
rc_ex, _, _ = ssh_run(hv_client, "which expect 2>/dev/null")
if rc_ex != 0:
log.warning(" Ni sshpass ni expect disponible. Installez : apt install sshpass")
return False
escaped = password.replace('"', '\\"')
cmd = f'expect -c "spawn ssh-copy-id -o StrictHostKeyChecking=no {vm.user}@{vm.ip}; expect {{*password* {{ send \\"{escaped}\\r\\"; exp_continue }} eof}}" 2>&1'
rc, out, _ = ssh_run(hv_client, cmd, timeout=30)
return rc == 0 or "already" in out.lower()
def process_hypervisor(hv, inventory, default_vm_user="root"):
"""Exécute le workflow complet de déploiement de clés pour un hyperviseur.
Args:
hv (Hypervisor): Hyperviseur cible.
inventory (list[dict]): Inventaire JSON des VMs.
default_vm_user (str, optional): Utilisateur SSH par défaut pour les VMs.
Defaults to "root".
Returns:
None: Met à jour `hv.vm_targets` en place.
"""
print(f"\n{'═' * 60}\n Hyperviseur : {hv.host}\n{'═' * 60}")
hv_client = ssh_connect(hv.host, hv.user, hv.port)
if not hv_client:
log.error(f" Impossible de se connecter à {hv.host}")
return
try:
print(f"\n[1/2] Génération des clés SSH sur {hv.host}…")
pubkeys = ensure_ssh_keys(hv_client, hv.host)
if not pubkeys:
log.error(" Aucune clé disponible, abandon.")
return
targets = get_vm_targets(inventory, hv.host, default_vm_user)
if not targets:
log.warning(f" Aucune VM running avec IP pour {hv.host}")
return
print(f"\n[2/2] Déploiement vers {len(targets)} VM(s)…")
results = []
for vm in targets:
print(f"\n ▶ {vm.name} ({vm.user}@{vm.ip})")
if test_ssh_key_from_hypervisor(hv_client, vm):
vm.ssh_key_ok = True
print(" ✅ Connexion par clé déjà OK")
results.append(vm)
continue
password = get_password(vm.ip, vm.user)
if not password:
vm.error = "Mot de passe non fourni"
print(" ⏭ Ignoré")
results.append(vm)
continue
if copy_id_from_hypervisor(hv_client, vm, password):
vm.ssh_copy_id_ok = True
if test_ssh_key_from_hypervisor(hv_client, vm):
vm.ssh_key_ok = True
print(" ✅ Clé déployée et connexion validée")
else:
vm.error = "ssh-copy-id OK mais re-test échoué"
print(" ⚠️ ssh-copy-id OK mais re-test échoué")
else:
vm.error = "ssh-copy-id échoué"
print(" ❌ ssh-copy-id échoué, suivante")
results.append(vm)
hv.vm_targets = results
ok = [v for v in results if v.ssh_key_ok]
ko = [v for v in results if not v.ssh_key_ok]
print(f"\n Résumé {hv.host} : {len(ok)}/{len(results)} VM(s) OK")
for v in ko:
print(f" ✗ {v.name} ({v.ip}) — {v.error or 'inconnu'}")
finally:
hv_client.close()
def export_report(hypervisors, path="ssh_deploy_report.json"):
"""Exporte un rapport JSON de déploiement SSH.
Args:
hypervisors (list[Hypervisor]): Hyperviseurs traités.
path (str, optional): Fichier de sortie. Defaults to "ssh_deploy_report.json".
Returns:
None: Écrit le rapport sur disque.
"""
data = [
{
"host": hv.host,
"uri": hv.uri,
"vms": [
{
"name": v.name,
"ip": v.ip,
"user": v.user,
"ssh_key_ok": v.ssh_key_ok,
"ssh_copy_id_attempted": v.ssh_copy_id_ok,
"error": v.error,
}
for v in hv.vm_targets
],
}
for hv in hypervisors
]
with open(path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
log.info(f"Rapport → {path}")
def main():
"""Point d'entrée CLI du déploiement SSH inter-VM.
Returns:
None: Exécute le flux complet de traitement.
"""
_configure_logging()
import argparse
p = argparse.ArgumentParser()
p.add_argument("--inventory", default="inventory.json")
p.add_argument("--vm-user", default="root")
p.add_argument("--uris", nargs="*")
args = p.parse_args()
inventory = load_inventory(args.inventory)
uris = args.uris or get_libvirt_uris()
if not uris:
log.error("Aucune URI trouvée.")
sys.exit(1)
hypervisors = []
for uri in uris:
hv = parse_libvirt_uri(uri)
if not hv or hv.transport == "local":
continue
process_hypervisor(hv, inventory, args.vm_user)
hypervisors.append(hv)
export_report(hypervisors)
print("\n✅ Rapport → ssh_deploy_report.json")
if __name__ == "__main__":
main()