diff --git a/CHANGELOG.md b/CHANGELOG.md index a0af142..c199d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## 2026-08-06 — le chemin nord-sud devient dérivable +### Un applicateur pour le pare-feu est-ouest — et un refus assumé + +`scripts/appliquer_proxmox_fw.py` réconcilie les trois couches du devis : 26 IPSets, +36 groupes de sécurité, et les affectations aux VM. Créé, mis à jour, retiré — même contrat +que les deux autres. + +**Ce qu'il ne fera jamais : activer le pare-feu du datacenter.** Ce réglage +(`enable=1` + `policy_in=DROP`) vaut pour **toutes** les VM du cluster, y compris les 37 +machines héritées qui n'ont aucune règle. Le basculer couperait le parc d'un coup. L'écart est +signalé à chaque exécution, en toutes lettres ; la décision reste humaine. + +C'est la première fois qu'un applicateur de ce dépôt **refuse par conception** de faire une +partie de son devis. Le refus vaut mieux qu'une option qu'on finirait par cocher sans y penser. + +**Les 28 VM du devis n'existent pas encore.** Elles sont listées comme différées, pas comme +erreurs : leur affectation se posera au prochain passage, une fois clonées. + +**Les objets posés sont inertes**, précisément parce que le prérequis n'est pas rempli. C'est +ce qui rend l'application sûre aujourd'hui : la politique est en place et vérifiable, sans rien +filtrer tant qu'on ne l'a pas décidé. + +**`scripts/proxmox_api.py`** extrait ce que les deux applicateurs Proxmox partagent — surtout +la recomposition du jeton `utilisateur@royaume!nom`, dont la voûte ne porte que le nom. La +dupliquer, c'était préparer un 401 muet le jour où l'un des deux morceaux changerait. + +Une fausse alerte en passant : les noms tronqués à 18 caractères semblaient collisionner entre +IPSets et groupes. Ce sont deux espaces de noms distincts chez Proxmox, et les règles +référencent un IPSet par un `+`. Aucune collision — et le devis portait déjà sa garde. + + ### Un applicateur pour le SDN, et pour la sortie des VRF `scripts/appliquer_sdn.py` — deuxième applicateur du dépôt, même contrat que celui de la diff --git a/Makefile b/Makefile index d1f76dd..6f5891b 100644 --- a/Makefile +++ b/Makefile @@ -348,6 +348,18 @@ devis-reseau: ansible-runtime ## Devis switch (VLANs/SVIs/ACLs) du reseau conver devis-opnsense: ansible-runtime ## Devis OPNsense (frontiere nord/sud), derive du registre des flux (pair: externe) python3 scripts/devis_opnsense.py $(if $(JSON),--json,) +.PHONY: proxmox-fw-plan proxmox-fw-appliquer +proxmox-fw-plan: ansible-runtime ## Ecart entre le pare-feu est-ouest Proxmox et son devis (aucune ecriture) + python3 scripts/appliquer_proxmox_fw.py + +proxmox-fw-appliquer: ansible-runtime ## Reconcilie le pare-feu est-ouest : IPSets, groupes, affectations. CONFIRMER=true + @if [[ "$(CONFIRMER)" != "true" ]]; then \ + printf '%s\n' "Refus: cette cible ECRIT sur le pare-feu du cluster (IPSets, groupes, VM)" \ + "et RETIRE ce qui est perime. Elle n'active JAMAIS le pare-feu du datacenter." \ + "Relire d'abord 'make proxmox-fw-plan', puis: make proxmox-fw-appliquer CONFIRMER=true"; \ + exit 2; fi + CONFIRMER=true python3 scripts/appliquer_proxmox_fw.py + .PHONY: sdn-plan sdn-appliquer sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture) python3 scripts/appliquer_sdn.py diff --git a/scripts/appliquer_proxmox_fw.py b/scripts/appliquer_proxmox_fw.py new file mode 100644 index 0000000..47820e3 --- /dev/null +++ b/scripts/appliquer_proxmox_fw.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Reconcilie le pare-feu Proxmox (est-ouest intra-tenant) avec son devis. + +Trois couches, toutes au niveau du CLUSTER sauf la derniere : + - IPSets : qui porte quel role + - Groupes : les flux entrants d'un role + - Affectations : quels groupes sur quelle VM + +DEUX SENS : ce que le devis demande et qui manque est cree, ce qu'il ne demande +plus est RETIRE. Un groupe orphelin ne bloque rien, mais il decrit une politique +qui n'est plus la notre. + +CE QU'IL NE FAIT JAMAIS : activer le pare-feu du datacenter, ni poser sa politique +d'entree a DROP. Ce reglage vaut pour TOUTES les VM du cluster, y compris celles +qui n'ont aucune regle — le basculer couperait le parc herite d'un coup. L'ecart +est signale, la decision reste humaine. + +PERIMETRE STRICT : seuls les objets prefixes par l'etiquette d'un tenant du devis +(`t11-`, `t17-`) sont touches. Le reste du cluster n'existe pas pour ce script. + +NON DESTRUCTIF PAR DEFAUT : sans `CONFIRMER=true`, aucune ecriture (regle 4). + +Usage : + python3 scripts/appliquer_proxmox_fw.py # plan seul + CONFIRMER=true python3 scripts/appliquer_proxmox_fw.py # applique +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE / "scripts")) + +from proxmox_api import Cluster # noqa: E402 + + +def _prefixe(bloc: dict) -> str: + """`t11-` — deduit du premier IPSet, qui les porte tous. Jamais saisi.""" + for nom in bloc.get("ipsets") or {}: + return nom.split("-")[0] + "-" + for g in bloc.get("groupes") or []: + return str(g["nom"]).split("-")[0] + "-" + return "" + + +def _regle_cle(r: dict) -> tuple: + """Identite d'une regle de groupe, insensible a la position et au commentaire.""" + return (str(r.get("type") or r.get("sens") or "").lower(), + str(r.get("action") or "").upper(), + str(r.get("source") or ""), + str(r.get("proto") or ""), + str(r.get("dport") or "")) + + +def plan(api: Cluster, devis: dict) -> dict: + prefixes = tuple(_prefixe(b) for b in devis["blocs"] if _prefixe(b)) + + ipsets_voulus, groupes_voulus, affect_voulues = {}, {}, {} + for b in devis["blocs"]: + for nom, d in (b.get("ipsets") or {}).items(): + ipsets_voulus[nom] = {"membres": sorted(set(d["membres"])), "role": d.get("role", "")} + for g in b.get("groupes") or []: + groupes_voulus[g["nom"]] = g + for a in b.get("affectations") or []: + affect_voulues[int(a["vmid"])] = {"hote": a["hote"], "groupes": sorted(a["groupes"])} + + # --- etat du cluster, restreint a nos prefixes ----------------------------- + ipsets_poses = {} + for s in (api("/cluster/firewall/ipset") or []): + if isinstance(s, dict) and str(s.get("name", "")).startswith(prefixes): + membres = [str(m.get("cidr")) for m in (api(f"/cluster/firewall/ipset/{s['name']}") or []) + if isinstance(m, dict)] + ipsets_poses[s["name"]] = sorted(set(membres)) + + groupes_poses = {} + for g in (api("/cluster/firewall/groups") or []): + if isinstance(g, dict) and str(g.get("group", "")).startswith(prefixes): + regles = [r for r in (api(f"/cluster/firewall/groups/{g['group']}") or []) + if isinstance(r, dict)] + groupes_poses[g["group"]] = regles + + vm_du_cluster = {int(r["vmid"]): r["node"] for r in (api("/cluster/resources?type=vm") or []) + if isinstance(r, dict) and r.get("vmid")} + + affect_posees, vm_absentes = {}, [] + for vmid in affect_voulues: + noeud = vm_du_cluster.get(vmid) + if not noeud: + vm_absentes.append(vmid) + continue + regles = api(f"/nodes/{noeud}/qemu/{vmid}/firewall/rules") or [] + affect_posees[vmid] = { + "noeud": noeud, + "groupes": sorted(str(r.get("action")) for r in regles + if isinstance(r, dict) and r.get("type") == "group"), + "actif": str((api(f"/nodes/{noeud}/qemu/{vmid}/firewall/options") or {}).get("enable", "")), + } + + opts = api("/cluster/firewall/options") or {} + return { + "ipsets_creer": {n: v for n, v in ipsets_voulus.items() if n not in ipsets_poses}, + "ipsets_majer": {n: (ipsets_poses[n], v["membres"]) for n, v in ipsets_voulus.items() + if n in ipsets_poses and ipsets_poses[n] != v["membres"]}, + "ipsets_retirer": sorted(n for n in ipsets_poses if n not in ipsets_voulus), + "groupes_creer": {n: g for n, g in groupes_voulus.items() if n not in groupes_poses}, + "groupes_majer": {n: g for n, g in groupes_voulus.items() + if n in groupes_poses + and {_regle_cle(r) for r in g["regles"]} + != {_regle_cle(r) for r in groupes_poses[n]}}, + "groupes_retirer": sorted(n for n in groupes_poses if n not in groupes_voulus), + "affect_majer": {v: a for v, a in affect_voulues.items() + if v in affect_posees + and (affect_posees[v]["groupes"] != a["groupes"] + or affect_posees[v]["actif"] != "1")}, + "affect_posees": affect_posees, + "vm_absentes": sorted(vm_absentes), + "datacenter": {"actif": str(opts.get("enable", "")), "politique": str(opts.get("policy_in", ""))}, + "_voulus": {"ipsets": ipsets_voulus, "groupes": groupes_voulus, "affect": affect_voulues}, + } + + +def afficher(p: dict) -> bool: + for n in sorted(p["ipsets_creer"]): + print(f" + IPSet {n:<22} {len(p['ipsets_creer'][n]['membres'])} membre(s)") + for n, (avant, apres) in sorted(p["ipsets_majer"].items()): + print(f" ~ IPSet {n:<22} {len(avant)} -> {len(apres)} membre(s)") + for n in p["ipsets_retirer"]: + print(f" - IPSet PERIME {n}") + for n, g in sorted(p["groupes_creer"].items()): + print(f" + groupe {n:<22} {len(g['regles'])} regle(s) # {g.get('role','')}") + for n in sorted(p["groupes_majer"]): + print(f" ~ groupe {n:<22} regles differentes") + for n in p["groupes_retirer"]: + print(f" - groupe PERIME {n}") + for v, a in sorted(p["affect_majer"].items()): + print(f" ~ VM {v:<12} {a['hote']:<16} -> {', '.join(a['groupes'])}") + if p["vm_absentes"]: + print(f"\n {len(p['vm_absentes'])} VM du devis absente(s) du cluster — affectation " + f"differee, elle se posera au clonage :") + print(f" {', '.join(str(v) for v in p['vm_absentes'][:8])}" + + (" …" if len(p["vm_absentes"]) > 8 else "")) + dc = p["datacenter"] + if dc["actif"] != "1" or dc["politique"].upper() != "DROP": + print(f"\n /!\\ PREREQUIS NON REMPLI — pare-feu du datacenter : actif={dc['actif'] or '0'}, " + f"politique d'entree={dc['politique'] or '(defaut: ACCEPT)'}.") + print(" Sans `enable=1` ET `policy_in=DROP`, les regles ci-dessus s'ajoutent a un") + print(" tout-permis et n'interdisent RIEN.") + print(" Ce script ne le bascule PAS : le reglage vaut pour TOUTES les VM du") + print(" cluster, y compris le parc herite qui n'a aucune regle. A decider a la main.") + creer = len(p["ipsets_creer"]) + len(p["groupes_creer"]) + majer = len(p["ipsets_majer"]) + len(p["groupes_majer"]) + len(p["affect_majer"]) + retirer = len(p["ipsets_retirer"]) + len(p["groupes_retirer"]) + print(f"\n a creer : {creer} | a mettre a jour : {majer} | a retirer : {retirer}") + return bool(creer or majer or retirer) + + +def appliquer(api: Cluster, p: dict) -> int: + echecs = 0 + + def _fait(rep, quoi): + nonlocal echecs + if (e := api.rate(rep)): + echecs += 1 + print(f" ! ECHEC {quoi} : {e[:150]}") + return False + return True + + # 1. IPSets AVANT les groupes : une regle qui reference un IPSet absent est refusee. + for n, v in sorted(p["ipsets_creer"].items()): + if _fait(api("/cluster/firewall/ipset", "POST", + {"name": n, "comment": v["role"][:100]}), f"ipset {n}"): + for m in v["membres"]: + _fait(api(f"/cluster/firewall/ipset/{n}", "POST", {"cidr": m}), f"{n} += {m}") + for n, (avant, apres) in sorted(p["ipsets_majer"].items()): + for m in sorted(set(apres) - set(avant)): + _fait(api(f"/cluster/firewall/ipset/{n}", "POST", {"cidr": m}), f"{n} += {m}") + for m in sorted(set(avant) - set(apres)): + _fait(api(f"/cluster/firewall/ipset/{n}/{m}", "DELETE"), f"{n} -= {m}") + + # 2. Groupes. Une mise a jour REMPLACE les regles : elles sont a nous, et les + # comparer une a une pour un edit partiel couterait plus que de les reposer. + for n in sorted(p["groupes_majer"]): + for r in reversed(api(f"/cluster/firewall/groups/{n}") or []): + _fait(api(f"/cluster/firewall/groups/{n}/{r['pos']}", "DELETE"), f"purge {n}") + for n, g in sorted({**p["groupes_creer"], **p["groupes_majer"]}.items()): + if n in p["groupes_creer"]: + _fait(api("/cluster/firewall/groups", "POST", + {"group": n, "comment": str(g.get("role", ""))[:100]}), f"groupe {n}") + for r in g["regles"]: + corps = {"type": str(r.get("sens", "in")).lower(), "action": r["action"], + "enable": 1, "comment": str(r.get("via") or g.get("role", ""))[:100]} + for cle, champ in (("source", "source"), ("proto", "proto"), ("dport", "dport")): + if r.get(cle): + corps[champ] = r[cle] + _fait(api(f"/cluster/firewall/groups/{n}", "POST", corps), f"regle de {n}") + + # 3. Affectations aux VM, et activation de leur pare-feu — sans quoi les groupes + # seraient poses sur une machine qui ne filtre pas. + for v, a in sorted(p["affect_majer"].items()): + noeud = p["affect_posees"][v]["noeud"] + for r in reversed(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules") or []): + if isinstance(r, dict) and r.get("type") == "group": + _fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules/{r['pos']}", "DELETE"), + f"purge VM {v}") + for g in a["groupes"]: + _fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules", "POST", + {"type": "group", "action": g, "enable": 1}), f"VM {v} += {g}") + _fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/options", "PUT", {"enable": 1}), + f"pare-feu de la VM {v}") + + # 4. Retraits : groupes avant IPSets, un IPSet reference ne se supprime pas. + for n in p["groupes_retirer"]: + for r in reversed(api(f"/cluster/firewall/groups/{n}") or []): + _fait(api(f"/cluster/firewall/groups/{n}/{r['pos']}", "DELETE"), f"purge {n}") + _fait(api(f"/cluster/firewall/groups/{n}", "DELETE"), f"retrait groupe {n}") + for n in p["ipsets_retirer"]: + _fait(api(f"/cluster/firewall/ipset/{n}", "DELETE", {"force": 1}), f"retrait ipset {n}") + + if echecs: + print(f"\n {echecs} echec(s) — la reconciliation est INCOMPLETE.") + return 1 + print("\n Pare-feu est-ouest reconcilie.") + return 0 + + +def main() -> int: + api, base = Cluster.depuis_hebergeur() + r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_proxmox_fw.py"), "--json"], + cwd=RACINE, capture_output=True, text=True, + env={**os.environ, "SETOPS_INSTANCE": str(base)}) + if r.returncode != 0: + raise SystemExit("Le devis ne se genere pas :\n" + r.stderr.strip()[:400]) + devis = json.loads(r.stdout) + + print(f"Cluster {api.hote} — {len(devis['blocs'])} tenant(s) au devis\n") + p = plan(api, devis) + quelque_chose = afficher(p) + if not quelque_chose: + print("\n Le cluster dit deja ce que le devis dit. Rien a faire.") + return 0 + if os.environ.get("CONFIRMER") != "true": + print("\n PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.") + return 0 + print() + return appliquer(api, p) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/appliquer_sdn.py b/scripts/appliquer_sdn.py index c7fb8b8..96143a2 100644 --- a/scripts/appliquer_sdn.py +++ b/scripts/appliquer_sdn.py @@ -39,63 +39,15 @@ RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE / "scripts")) import devis_sdn as devis_mod # noqa: E402 +from proxmox_api import Cluster, ssh as _ssh # noqa: E402 CHEMIN_FRR = "/etc/frr/frr.conf.local" -def _hebergeur() -> Path: - lien = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml")) - if not lien.exists(): - raise SystemExit("Aucun underlay ne designe d'hebergeur : pas de cluster a piloter.") - return lien.resolve().parent -def _voute(base: Path) -> dict: - for nom in ("principal", "production", "lab"): - p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml" - if p.is_file(): - r = subprocess.run(["ansible-vault", "view", str(p)], capture_output=True, text=True) - if r.returncode != 0: - raise SystemExit("Voute illisible : renseigner ANSIBLE_VAULT_PASSWORD_FILE.\n" - + r.stderr.strip()[:300]) - return yaml.safe_load(r.stdout) or {} - raise SystemExit(f"Aucune voute sous {base}/inventories/*/group_vars/all/.") -class Cluster: - """Le minimum d'API Proxmox pour reconcilier le SDN.""" - - def __init__(self, hote: str, port, user: str, tid: str, secret: str): - self.base = f"https://{hote}:{port}/api2/json" - # Le jeton complet est `utilisateur@royaume!nom` : la voute ne porte que le NOM, - # l'utilisateur vit dans proxmox-hebergeur.yml. Les recomposer ici evite de - # stocker deux fois la meme identite — et le 401 muet quand elles divergent. - self.tok = f"PVEAPIToken={user}!{str(tid).split('!')[-1]}={secret}" - self.ctx = ssl._create_unverified_context() - - def __call__(self, chemin: str, methode: str = "GET", corps: dict | None = None): - data = urllib.parse.urlencode(corps).encode() if corps else None - req = urllib.request.Request(self.base + chemin, data=data, method=methode, - headers={"Authorization": self.tok}) - try: - with urllib.request.urlopen(req, context=self.ctx, timeout=45) as rep: - return json.loads(rep.read()).get("data") - except urllib.error.HTTPError as e: - return {"_erreur": f"{e.code} {e.read().decode()[:200]}"} - except OSError as e: - return {"_erreur": str(e)[:200]} - - -def _ssh(hote: str, commande: str, entree: str | None = None) -> tuple[int, str]: - """SSH vers un noeud de sortie. La cle vient de l'agent, ou de SETOPS_SSH_KEY.""" - cmd = ["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10"] - cle = os.environ.get("SETOPS_SSH_KEY") - if cle: - cmd += ["-o", "IdentitiesOnly=yes", "-i", os.path.expanduser(cle)] - cmd += [f"{os.environ.get('SETOPS_SSH_USER', 'ansible')}@{hote}", commande] - r = subprocess.run(cmd, input=entree, capture_output=True, text=True, timeout=90) - return r.returncode, (r.stdout or "") + (r.stderr or "") def _vm_par_vnet(api: Cluster) -> dict[str, list[str]]: @@ -299,12 +251,7 @@ def appliquer(api: Cluster, p: dict, devis: dict) -> int: def main() -> int: - base = _hebergeur() - heb = yaml.safe_load((base / "proxmox-hebergeur.yml").read_text(encoding="utf-8")) or {} - v = _voute(base) - api = Cluster(heb.get("proxmox_api_host"), heb.get("proxmox_api_port", 8006), - heb.get("proxmox_api_user"), v["proxmox_api_token_id"], - v["proxmox_api_token_secret"]) + api, base = Cluster.depuis_hebergeur() r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_sdn.py"), "--json"], cwd=RACINE, capture_output=True, text=True, @@ -313,7 +260,7 @@ def main() -> int: raise SystemExit("Le devis ne se genere pas :\n" + r.stderr.strip()[:400]) devis = json.loads(r.stdout) - print(f"Cluster {heb.get('proxmox_api_host')} — {len(devis['zones'])} zone(s) au devis\n") + print(f"Cluster {api.hote} — {len(devis['zones'])} zone(s) au devis\n") p = plan(api, devis) if not afficher(p): print("\n Le cluster dit deja ce que le devis dit. Rien a faire.") diff --git a/scripts/proxmox_api.py b/scripts/proxmox_api.py new file mode 100644 index 0000000..b4f68bc --- /dev/null +++ b/scripts/proxmox_api.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Acces au cluster Proxmox de l'hebergeur — partage par les applicateurs. + +Extrait pour n'avoir QU'UN endroit ou vit la recomposition du jeton : la voute ne +porte que le NOM du jeton, l'utilisateur vit dans `proxmox-hebergeur.yml`. Les +recomposer a deux endroits, c'est se preparer un 401 muet le jour ou l'un des deux +change. +""" +from __future__ import annotations + +import json +import os +import ssl +import subprocess +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parent.parent + + +def hebergeur() -> Path: + """Depot de l'hebergeur, designe par le symlink `underlay.yml` (D-17).""" + lien = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml")) + if not lien.exists(): + raise SystemExit("Aucun underlay ne designe d'hebergeur : pas de cluster a piloter.") + return lien.resolve().parent + + +def voute(base: Path) -> dict: + """Secrets, lus par ansible-vault. Rien n'est journalise ni ecrit sur disque.""" + for nom in ("principal", "production", "lab"): + p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml" + if p.is_file(): + r = subprocess.run(["ansible-vault", "view", str(p)], capture_output=True, text=True) + if r.returncode != 0: + raise SystemExit("Voute illisible : renseigner ANSIBLE_VAULT_PASSWORD_FILE.\n" + + r.stderr.strip()[:300]) + return yaml.safe_load(r.stdout) or {} + raise SystemExit(f"Aucune voute sous {base}/inventories/*/group_vars/all/.") + + +class Cluster: + """Le minimum d'API Proxmox. Retourne `{'_erreur': ...}` plutot que de lever.""" + + def __init__(self, hote: str, port, user: str, tid: str, secret: str): + self.base = f"https://{hote}:{port}/api2/json" + self.hote = hote + self.tok = f"PVEAPIToken={user}!{str(tid).split('!')[-1]}={secret}" + self.ctx = ssl._create_unverified_context() + + @classmethod + def depuis_hebergeur(cls) -> tuple["Cluster", Path]: + base = hebergeur() + heb = yaml.safe_load((base / "proxmox-hebergeur.yml").read_text(encoding="utf-8")) or {} + v = voute(base) + return cls(heb.get("proxmox_api_host"), heb.get("proxmox_api_port", 8006), + heb.get("proxmox_api_user"), v["proxmox_api_token_id"], + v["proxmox_api_token_secret"]), base + + def __call__(self, chemin: str, methode: str = "GET", corps: dict | None = None): + data = urllib.parse.urlencode(corps, doseq=True).encode() if corps else None + req = urllib.request.Request(self.base + chemin, data=data, method=methode, + headers={"Authorization": self.tok}) + try: + with urllib.request.urlopen(req, context=self.ctx, timeout=45) as rep: + return json.loads(rep.read()).get("data") + except urllib.error.HTTPError as e: + return {"_erreur": f"{e.code} {e.read().decode()[:200]}"} + except OSError as e: + return {"_erreur": str(e)[:200]} + + def rate(self, rep) -> str | None: + return rep["_erreur"] if isinstance(rep, dict) and rep.get("_erreur") else None + + +def ssh(hote: str, commande: str, entree: str | None = None) -> tuple[int, str]: + """SSH vers un noeud. La cle vient de l'agent, ou de SETOPS_SSH_KEY.""" + cmd = ["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=10"] + cle = os.environ.get("SETOPS_SSH_KEY") + if cle: + cmd += ["-o", "IdentitiesOnly=yes", "-i", os.path.expanduser(cle)] + cmd += [f"{os.environ.get('SETOPS_SSH_USER', 'ansible')}@{hote}", commande] + r = subprocess.run(cmd, input=entree, capture_output=True, text=True, timeout=90) + return r.returncode, (r.stdout or "") + (r.stderr or "")