sonder : rapporter ce qui distingue, au lieu d un echec
Some checks are pending
verifier / verifier (push) Waiting to run

Trois faux diagnostics en une journee, tous dus a l instrument et aucun au
composant. curl et bash /dev/tcp ecrasent quatre causes incompatibles dans le meme
mot : ouvert, une POLITIQUE qui refuse, une machine ABSENTE, et la frontiere muette.

LE MEME CODE DIT DEUX CHOSES SELON LE DELAI, et c est la distinction qui a coute le
plus cher : EHOSTUNREACH immediat = pas de route ; le meme apres trois secondes =
il n y a pas de machine, c est l ARP qui renonce. Les confondre a fait appliquer un
pare-feu pour reparer un vide.  est pure, donc gardee par un test qui
exige que les deux ne se lisent jamais pareil.

LA GARDE ECHOUAIT DU COTE SILENCIEUX. L outil dit par quelle SOURCE le paquet part
et refuse de conclure sur une passerelle anycast — le piege qui m a fait declarer
muet un REJECT qui emettait bien ses RST. 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 : precisement la ou le piege se produit. Une garde qui echoue
doit crier, pas se taire.

ET « NON CONCLUANT » EST UN RESULTAT : sur une source anycast et un silence, l outil
ne tranche pas, il dit quoi faire — compter les paquets du cote qui refuse, ou
sonder depuis une VM.

TCP seulement, et c est dit : UDP n a pas de poignee.

Valide contre le reel depuis ops-01 : trois cas sur quatre, le quatrieme attendant
deux machines vivantes dans un meme tenant.

make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
This commit is contained in:
Daniel Allaire 2026-08-28 17:02:20 -04:00
parent c918a50257
commit 0595bf03c1
5 changed files with 368 additions and 2 deletions

View file

@ -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.

View file

@ -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=<hote|ip> [PORTS="22 443"]
@if [[ -z "$(CIBLE)" ]]; then \
printf '%s\n' 'Refus: relancer avec CIBLE=<hote du plan, ou adresse>.'; \
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)

View file

@ -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. |

205
scripts/sonder.py Executable file
View file

@ -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())

View file

@ -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())