diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e944c..708e69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # CHANGELOG — Set-OPS +## 2026-08-28 — `make sonder` : rapporter ce qui distingue, au lieu d'« échec » + +**53 preuves.** Trois faux diagnostics en une journée, **tous dus à l'instrument, aucun au +composant**. `curl`, `bash /dev/tcp` et un `ping` nu écrasent quatre causes incompatibles +dans le même mot. L'information était là chaque fois ; l'outil la jetait. + +``` +ouvert, ~0 s le flux est déclaré et le service écoute +refusé (RST) une POLITIQUE interne refuse — immédiat +EHOSTUNREACH, ~3 s LA MACHINE N'EXISTE PAS (l'ARP a abandonné) +silence jusqu'au délai la FRONTIÈRE, muette par conception +``` + +Validé contre le réel depuis `ops-01` — trois des quatre cas, le quatrième attendant deux +machines vivantes dans un même tenant. + +### Le même code d'erreur dit deux choses selon le délai + +C'est la distinction qui a coûté le plus cher : `EHOSTUNREACH` **immédiat** veut dire « pas +de route » ; le **même code après trois secondes** veut dire « il n'y a pas de machine » — +c'est l'ARP qui renonce. Les confondre a fait appliquer un pare-feu pour réparer un vide. +`interpreter` est une fonction pure, donc gardée par un test qui exige que les deux ne se +lisent **jamais** pareil. + +### La garde qui échouait du côté silencieux + +L'outil dit par quelle **source** le paquet part, et refuse de conclure quand c'est une +passerelle anycast — le piège qui m'a fait déclarer muet un `REJECT` qui émettait bien ses +RST. + +Ma première version rendait « pas anycast » quand `inventory_rules` était introuvable. +C'est-à-dire **exactement sur un hyperviseur où l'on a copié le seul fichier pour +diagnostiquer** — là où le piège se produit. Constaté à l'essai : l'avertissement s'est tu +sur `10.17.21.1`, qui *est* une passerelle anycast. *Une garde qui échoue doit crier, pas +se taire* : sans le moteur, elle retombe désormais sur une heuristique franchement +étiquetée `PASSERELLE PROBABLE (module absent)`. + +### Et « non concluant » est un résultat + +Quand la source est anycast et que la mesure est un silence, l'outil ne tranche pas — il +dit quoi faire à la place : *compter les paquets du côté qui refuse, ou sonder depuis une +VM*. C'est ce compteur qui a prouvé, lui, que le refus était instantané. + +*TCP seulement, et c'est dit : UDP n'a pas de poignée, un silence y confond « ouvert » et +« filtré », et prétendre trancher serait le défaut qu'on corrige.* + +make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute. + ## 2026-08-28 — P53 : l'interne refuse à voix haute, la bordure se tait **53 preuves.** Décision de l'exploitant : `block` vers l'Internet, `reject` à l'intérieur. diff --git a/Makefile b/Makefile index 2fe6874..3d3c833 100644 --- a/Makefile +++ b/Makefile @@ -261,6 +261,19 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_adressage_derive.py python3 scripts/tests/test_gui_intrants.py python3 scripts/tests/test_devis_placement.py + python3 scripts/tests/test_sonder.py + +# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une +# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine +# n'existe pas », « une politique refuse » et « la frontiere se tait » dans le meme mot. +.PHONY: sonder +sonder: ## Sonde une cible et DIT ce qui distingue — CIBLE= [PORTS="22 443"] + @if [[ -z "$(CIBLE)" ]]; then \ + printf '%s\n' 'Refus: relancer avec CIBLE=.'; \ + printf '%s\n' 'Ex. make sonder CIBLE=infra-dns-01 PORTS="53 22"'; \ + exit 2; \ + fi + @python3 scripts/sonder.py $(CIBLE) $(or $(PORTS),22) $(if $(DELAI),--delai $(DELAI)) .PHONY: verifier verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe ## Rejoue les preuves SANS reecrire le rapport (verification rapide) diff --git a/docs/audit/preuve-2026-08-28.md b/docs/audit/preuve-2026-08-28.md index 4813776..48b44ae 100644 --- a/docs/audit/preuve-2026-08-28.md +++ b/docs/audit/preuve-2026-08-28.md @@ -43,7 +43,7 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 30 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 19, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. | -| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 52 scripts expliques et atteignables, 104 cibles make documentees, 62 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 53 scripts expliques et atteignables, 105 cibles make documentees, 62 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 41 exigence(s) de role, toutes satisfaites (130 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (38 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (26 genere(s) exempte(s)). | @@ -53,7 +53,7 @@ | P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 37 role(s) serveur/client tous nommes, 38 groupe(s) cite(s) en table existent tous. | | P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. | | P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. | -| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 48 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | +| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 49 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | | P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product | | P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 60 regle(s) du site. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. | diff --git a/scripts/sonder.py b/scripts/sonder.py new file mode 100755 index 0000000..117ae79 --- /dev/null +++ b/scripts/sonder.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Sonder une cible, et RAPPORTER CE QUI DISTINGUE — pas un « echec ». + +POURQUOI CET OUTIL EXISTE (2026-08-28). Trois faux diagnostics en une journee, tous dus +a l'instrument et aucun au composant : + + 1. « BLOQUE » vers le resolveur, le cache et la PKI d'un tenant, lu comme un pare-feu. + C'etait une ABSENCE — une seule VM existait. Un pare-feu a ete applique pour reparer + un vide. + 2. « 3,05 s » presente comme le signal d'un refus. C'etait l'ARP qui abandonne, pas un + REJECT — lequel repond en microsecondes. + 3. Un REJECT declare muet. Il emettait bien quatre RST (compteur 17 -> 21) ; la sonde + partait d'une passerelle ANYCAST, donc la reponse revenait ailleurs. + +`curl`, `bash /dev/tcp` et un `ping` nu ecrasent quatre causes incompatibles dans le meme +mot. L'information etait la a chaque fois — l'outil la jetait. + +CE QU'IL MESURE, ET CE QU'IL REFUSE DE CONCLURE : + + ouvert, ~0 s le flux est declare et le service ecoute + refuse (RST/ECONNREFUSED) une POLITIQUE interne refuse — reject, immediat + EHOSTUNREACH, ~3 s LA MACHINE N'EXISTE PAS (l'ARP a abandonne) + silence jusqu'au delai la FRONTIERE, muette par conception (block) + +Et deux gardes sur l'instrument lui-meme : + + - il dit par quelle SOURCE le paquet part (`ip route get`), parce qu'une passerelle + anycast rend la mesure NON CONCLUANTE : la reponse revient au noeud qui porte + l'adresse, pas a l'emetteur ; + - il ne conclut pas quand il ne peut pas. « Non concluant » est un resultat. + +TCP SEULEMENT. UDP n'a pas de poignee : un silence y confond « ouvert » et « filtre », +et pretendre trancher serait exactement le defaut qu'on corrige. + +Usage : + python3 scripts/sonder.py 10.17.19.41 22 443 + python3 scripts/sonder.py infra-dns-01 53 # nom resolu par l'inventaire +""" +from __future__ import annotations + +import argparse +import errno as _errno +import socket +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +# Seuil au-dela duquel un echec « injoignable » porte la signature de l'ARP qui abandonne +# (trois retransmissions). En-dessous, c'est un refus immediat de la couche 3. +SEUIL_ARP = 1.5 + +VERDICTS = { + "ouvert": "le flux est declare et le service ecoute", + "refuse": "une POLITIQUE interne refuse (reject) — immediat", + "absent": "LA MACHINE N'EXISTE PAS : l'ARP a abandonne", + "injoignable": "pas de route vers cette destination", + "silence": "refus MUET — la frontiere (block), ou un drop en chemin", +} + + +def interpreter(issue: str, err: int | None, duree: float) -> tuple[str, str]: + """(cle, phrase) — la lecture d'une sonde. Fonction PURE, donc testable. + + ELLE NE DEVINE PAS. `EHOSTUNREACH` rapide n'est pas la meme chose que le meme code + apres trois secondes : le premier est un refus de la couche 3, le second la signature + de l'ARP qui renonce. Confondre les deux, c'est refaire l'erreur du 2026-08-28. + """ + if issue == "ouvert": + return "ouvert", VERDICTS["ouvert"] + if err == _errno.ECONNREFUSED: + return "refuse", VERDICTS["refuse"] + if err == _errno.EHOSTUNREACH: + cle = "absent" if duree >= SEUIL_ARP else "injoignable" + return cle, VERDICTS[cle] + if err in (_errno.ENETUNREACH,): + return "injoignable", VERDICTS["injoignable"] + return "silence", VERDICTS["silence"] + + +def sonder(cible: str, port: int, delai: float) -> dict: + debut = time.time() + s = socket.socket() + s.settimeout(delai) + try: + s.connect((cible, port)) + issue, err = "ouvert", None + except OSError as e: + issue, err = "echec", getattr(e, "errno", None) + finally: + s.close() + duree = time.time() - debut + cle, phrase = interpreter(issue, err, duree) + return {"port": port, "cle": cle, "phrase": phrase, "duree": duree, + "errno": err, "nom_errno": _errno.errorcode.get(err, "") if err else ""} + + +def source_vers(cible: str) -> tuple[str, str]: + """(adresse source, note) telle que le noyau la choisira. ('', '') si indisponible. + + La note vaut '', 'anycast' (derive du plan) ou 'anycast?' (heuristique, module absent). + """ + try: + sortie = subprocess.run(["ip", "route", "get", cible], capture_output=True, + text=True, timeout=5).stdout + except (OSError, subprocess.SubprocessError): + return "", "" + jetons = sortie.split() + if "src" not in jetons: + return "", "" + src = jetons[jetons.index("src") + 1] + return src, _est_passerelle(src) + + +def _est_passerelle(adresse: str) -> str: + """'anycast', 'anycast?' ou '' — cette adresse est-elle une passerelle de zone ? + + DERIVE, jamais compare a une liste : les passerelles sont le premier hote de chaque + /24 de zone (`passerelle_de`). Une liste ecrite ici vieillirait au premier tenant + ajoute, et la garde se tairait exactement quand elle devrait parler. + + ET ELLE NE TOMBE PAS DU COTE SILENCIEUX. Ma premiere version rendait « pas anycast » + quand `inventory_rules` etait introuvable — ce qui arrive des qu'on copie ce seul + fichier sur un hyperviseur pour diagnostiquer, c'est-a-dire exactement la ou le piege + se produit. Constate le 2026-08-28 : l'avertissement s'est taise sur `10.17.21.1`, + qui EST une passerelle anycast. Une garde qui echoue doit crier, pas se taire ; sans + le module, on retombe donc sur une heuristique franchement etiquetee. + """ + morceaux = adresse.split(".") + if len(morceaux) != 4 or morceaux[3] != "1" or morceaux[0] != "10": + return "" + try: + index, troisieme = int(morceaux[1]), int(morceaux[2]) + except ValueError: + return "" + try: + from inventory_rules import passerelle_de + except Exception: + # Sans le moteur, on ne PROUVE pas — mais un `10.x.y.1` est un premier hote, + # donc une passerelle dans toute nomenclature de ce depot. On le dit au + # conditionnel plutot que de laisser croire que la mesure est concluante. + return "anycast?" + return "anycast" if any(passerelle_de(index, cat) == adresse + for cat in range(0, 12)) else "" + + +def _adresse_de(nom: str) -> str: + """Le nom tel que l'inventaire le connait, sinon le nom tel quel.""" + try: + import yaml + from inventory_rules import inventaire_de + inv = inventaire_de() + if inv and Path(inv).is_file(): + data = yaml.safe_load(Path(inv).read_text(encoding="utf-8")) or {} + pile = [data.get("all", {})] + while pile: + n = pile.pop() + for h, v in (n.get("hosts") or {}).items(): + if h == nom and isinstance(v, dict) and v.get("ansible_host"): + return str(v["ansible_host"]) + pile.extend((n.get("children") or {}).values()) + except Exception: + pass + return nom + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("cible", help="adresse, ou nom d'hote connu de l'inventaire") + ap.add_argument("ports", nargs="*", type=int, default=[22], help="ports TCP (defaut 22)") + ap.add_argument("--delai", type=float, default=6.0, help="patience, en secondes") + args = ap.parse_args(argv) + + adresse = _adresse_de(args.cible) + etiquette = args.cible if adresse == args.cible else f"{args.cible} ({adresse})" + print(f"Sonde de {etiquette} — TCP, patience {args.delai:g} s") + + src, note = source_vers(adresse) + if src: + avertissement = "" + if note: + certain = note == "anycast" + avertissement = ( + f" /!\\ PASSERELLE {'ANYCAST' if certain else 'PROBABLE (module absent)'}" + " — mesure NON CONCLUANTE en cas de silence :\n" + " la reponse revient au noeud qui porte cette adresse, pas ici.") + print(f" source choisie par le noyau : {src}{avertissement}") + print() + + resultats = [sonder(adresse, p, args.delai) for p in args.ports] + for r in resultats: + code = f" [{r['nom_errno']}]" if r["nom_errno"] else "" + print(f" {r['port']:<7} {r['cle']:<12} {r['duree']:6.3f} s{code}") + print(f" {r['phrase']}") + if note and any(r["cle"] == "silence" for r in resultats): + print("\n Un silence mesure depuis une passerelle anycast ne prouve RIEN :" + "\n compter les paquets du cote qui refuse, ou sonder depuis une VM.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_sonder.py b/scripts/tests/test_sonder.py new file mode 100644 index 0000000..9227c3b --- /dev/null +++ b/scripts/tests/test_sonder.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""La sonde distingue-t-elle ce qu'un « echec » confond ? + +CE QUE CES TESTS GARDENT, ET POURQUOI. Le 2026-08-28, trois faux diagnostics ont ete +poses en une journee — tous par l'instrument, aucun par le composant. Le pire des trois +n'etait pas une confusion entre « ouvert » et « ferme », mais entre DEUX FORMES DU MEME +CODE : `EHOSTUNREACH` immediat (pas de route) et `EHOSTUNREACH` apres trois secondes +(l'ARP qui renonce, donc AUCUNE MACHINE). Le second a fait appliquer un pare-feu pour +reparer un vide. + +`interpreter` est une fonction PURE : elle se joue sans reseau, donc elle se garde ici. +""" +import errno +import sys +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(RACINE / "scripts")) + +import sonder # noqa: E402 + + +def test_ouvert_est_ouvert() -> None: + assert sonder.interpreter("ouvert", None, 0.001)[0] == "ouvert" + + +def test_refus_immediat_est_une_politique() -> None: + """Un RST est le signal d'un reject : une POLITIQUE, pas une absence.""" + cle, phrase = sonder.interpreter("echec", errno.ECONNREFUSED, 0.002) + assert cle == "refuse" + assert "POLITIQUE" in phrase + + +def test_le_meme_code_dit_deux_choses_selon_le_delai() -> None: + """LE TEST QUI COMPTE. `EHOSTUNREACH` lent = machine absente ; rapide = pas de route. + + Les confondre est exactement l'erreur du 2026-08-28. + """ + lent, _ = sonder.interpreter("echec", errno.EHOSTUNREACH, 3.05) + vif, _ = sonder.interpreter("echec", errno.EHOSTUNREACH, 0.01) + assert lent == "absent", "un EHOSTUNREACH apres l'ARP dit qu'il n'y a PAS de machine" + assert vif == "injoignable", "le meme code, immediat, dit qu'il n'y a pas de route" + assert lent != vif, "les deux ne doivent JAMAIS se lire pareil" + + +def test_le_silence_reste_le_silence() -> None: + """Sans code, on ne conclut pas au-dela de « quelque chose se tait ».""" + cle, phrase = sonder.interpreter("echec", None, 6.0) + assert cle == "silence" + assert "frontiere" in phrase + + +def test_une_passerelle_de_zone_est_signalee_anycast() -> None: + """La garde qui m'a manque : `10.17.21.1` EST une passerelle de zone.""" + assert sonder._est_passerelle("10.17.21.1") == "anycast" + assert sonder._est_passerelle("10.17.19.41") == "" + + +def test_sans_le_moteur_la_garde_crie_au_lieu_de_se_taire() -> None: + """Une garde qui echoue doit crier. Ma premiere version rendait « pas anycast » + quand `inventory_rules` manquait — c'est-a-dire sur un hyperviseur ou l'on a copie + le seul fichier, donc precisement la ou le piege se produit.""" + vrai_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__ + + def sans_moteur(nom, *a, **k): + if nom == "inventory_rules": + raise ImportError("simule") + return vrai_import(nom, *a, **k) + + cible = __builtins__ if isinstance(__builtins__, dict) else __builtins__.__dict__ + cible["__import__"] = sans_moteur + try: + assert sonder._est_passerelle("10.17.21.1") == "anycast?", \ + "sans le moteur, la garde doit avertir au conditionnel, pas se taire" + assert sonder._est_passerelle("10.17.19.41") == "" + finally: + cible["__import__"] = vrai_import + + +def main() -> int: + tests = [ + test_ouvert_est_ouvert, + test_refus_immediat_est_une_politique, + test_le_meme_code_dit_deux_choses_selon_le_delai, + test_le_silence_reste_le_silence, + test_une_passerelle_de_zone_est_signalee_anycast, + test_sans_le_moteur_la_garde_crie_au_lieu_de_se_taire, + ] + definis = {n for n, v in globals().items() if n.startswith("test_") and callable(v)} + oublies = definis - {t.__name__ for t in tests} + assert not oublies, f"test(s) definis mais jamais joues : {sorted(oublies)}" + for t in tests: + t() + print(f"ok {t.__name__}") + print(f"{len(tests)} tests passes.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())