From 9f36f08db05f3c8ff1314596f97bc80abdbda0c8 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sat, 22 Aug 2026 16:31:35 -0400 Subject: [PATCH] underlay : la frontiere entre les deux mondes, et l'instrument qui la mesure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demande de l'exploitant : « il faut vraiment faire une distinction entre l'underlay et les tenants. Definis bien la frontiere entre les deux mondes. L'underlay et son tenant doivent avoir chacun sa voute. » LA DOCTRINE (docs/frontiere-physique-virtuel.md) : qui possede quoi, ou ca vit, qui l'administre. Et la regle qui la rend operante — UN TENANT NE DETIENT JAMAIS UN SECRET DU MONDE PHYSIQUE. Aujourd'hui chaque tenant porte le jeton d'API du cluster ; patient 0 a du le recopier pour exister. C'est la faute des neuf copies, appliquee aux secrets : une valeur qui vit a N endroits diverge, et on ne peut plus en revoquer une sans les autres. L'INSTRUMENT (`make underlay-plan`) confronte le fichier au reel : l'API du cluster pour les adresses REELLEMENT portees, une sonde TCP pour ce qui repond. N'ecrit rien. CE QU'IL A TROUVE, des le premier passage : management 10.0.0.0/24, stockage 10.0.1.0/24, ceph 10.0.2-3.0/24 -> PERSONNE transit 10.0.4.0/24, vxlan 10.0.5.0/24 -> occupes (3 noeuds) portes par les noeuds et declares NULLE PART : 192.168.11.x (la vraie gestion), 10.11.5-7.x, 192.168.50.x, 10.1.110.254 La frontiere avait deja migre vers 10.17.0.1 ; les hyperviseurs, non. Le fichier decrivait le monde d'avant — et c'est pour cela que la regle d'admin de patient 0 atterrissait sur `wan`, ou elle n'aurait jamais laisse passer personne. DEUX PRECAUTIONS ECRITES DANS L'INSTRUMENT, apprises en l'ecrivant : - l'AUTORITE DEPEND DU ROLE. L'API de Proxmox connait ses hyperviseurs, et eux seuls. Declarer un commutateur « porte par personne » parce que le cluster l'ignore, c'est accuser le monde de ce que l'instrument ne voit pas. - « pas joignable d'ici » n'est pas « absent ». Les reseaux de CHEMIN (transit, VXLAN, stockage) ne sont jamais joignables de l'exterieur, par construction (D-78). Le premier jet les declarait morts. Le devis a donc corrige DEUX FOIS sa propre facon de mesurer avant de rendre un verdict. RESTE, dans l'ordre : ecrire dans underlay.yml ce qui EST ; separer les voutes ; et alors seulement appliquer la frontiere. make verifier 41/41. Co-Authored-By: Claude Opus 5 --- Makefile | 3 + docs/frontiere-physique-virtuel.md | 112 ++++++++++++++++ scripts/devis_underlay.py | 204 +++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 docs/frontiere-physique-virtuel.md create mode 100644 scripts/devis_underlay.py diff --git a/Makefile b/Makefile index a4fcc20..9755709 100644 --- a/Makefile +++ b/Makefile @@ -559,6 +559,9 @@ devis-opnsense-verifier: ## Verifie le devis de la frontiere nord/sud (aucune ec underlay: ## Underlay (fabric physique cluster-global : mgmt/iSCSI/Ceph) : affiche + valide (P23) python3 scripts/underlay.py +underlay-plan: ## Confronte l'underlay DECLARE au reel (API du cluster + sonde TCP) — n'ecrit rien + python3 scripts/devis_underlay.py + flux-verifier: ## Verifie que le registre des flux correspond aux meta/flux.yml des roles python3 scripts/resoudre_flux.py verifier diff --git a/docs/frontiere-physique-virtuel.md b/docs/frontiere-physique-virtuel.md new file mode 100644 index 0000000..ca6fe5a --- /dev/null +++ b/docs/frontiere-physique-virtuel.md @@ -0,0 +1,112 @@ +# La frontière entre les deux mondes — physique et virtuel + +> **Pour qui :** l'**exploitant** et le **mainteneur**. Ce document tranche une question qui +> revenait à chaque décision : *qui possède quoi, et où ça vit ?* + +Set-OPS manipule deux mondes qui se ressemblent et n'obéissent pas aux mêmes règles. Les +confondre a coûté plusieurs soirées ; les séparer proprement rend la plupart des questions +suivantes évidentes. + +--- + +## Les deux mondes + +| | **Monde PHYSIQUE** — l'underlay | **Monde VIRTUEL** — le tenant | +|---|---|---| +| Ce que c'est | commutateurs, hyperviseurs, frontière, stockage, transport VXLAN | VM, applications, données | +| Possédé par | l'**hébergeur** | l'**organisation** | +| Adressage | bande basse du site : `10..0-15.x` | zones : `10..16+.x` | +| Décrit dans | `underlay.yml`, `proxmox-hebergeur.yml` | `plan/` | +| Monté par | le symlink `underlay.yml` | le symlink `instance` | +| Change quand | on touche au **matériel** | on touche au **service** | +| Ses secrets | jeton d'API Proxmox, clé d'API de la frontière, accès aux commutateurs | LDAP, forge, SSO, bases | +| Sa voûte | **la sienne**, chez l'hébergeur | `group_vars/all/vault.yml` | + +**Les deux symlinks sont indépendants** (D-80) : `instance` dit *quel tenant*, +`underlay.yml` dit *sur quelle fabric*. Un tenant se déplace d'une fabric à l'autre sans +qu'on touche à son plan — c'est ce qui rend la portabilité possible. + +--- + +## La règle qui rend la frontière opérante + +> **Un tenant ne détient jamais un secret du monde physique.** + +Aujourd'hui, chaque tenant porte dans sa voûte le jeton d'API du cluster. Patient 0 a dû le +recopier pour exister. C'est exactement la faute des **neuf copies** de la résolution +d'instance, appliquée aux secrets : une valeur qui vit à N endroits finit par diverger, et +on ne peut plus révoquer l'une sans révoquer les autres. + +Conséquence : **l'underlay a sa propre voûte**, chez l'hébergeur, à côté d'`underlay.yml`. +Les opérations qui parlent au matériel — cloner une VM, poser une zone SDN, écrire sur la +frontière — l'y lisent. Les tenants n'y ont pas accès et n'en ont pas besoin. + +--- + +## Qui administre quoi, et depuis où + +L'administration du monde physique se fait **depuis le tenant de l'hébergeur** : c'est lui +qui porte les outils, les clés et les traces. Le plan de gestion du site +(`10..0.0/24`) est la seule source autorisée à ouvrir SSH sur la flotte et à +franchir la frontière. + +Ce n'est pas un détail de commodité. Une machine qui administre depuis l'extérieur des deux +mondes — un portable sur le réseau de la maison — n'est ni sauvegardée, ni reconstructible, +ni prouvée. Le jour où elle disparaît, l'écosystème est intact et personne ne peut plus y +entrer. + +--- + +## Ce que la confusion a coûté + +**Une règle qui ne peut jamais correspondre.** `devis_opnsense` dérive l'interface d'une +règle de l'**attachement réel** de sa source (D-61), et cet attachement se lit dans +`underlay.yml`. Un plan d'administration absent du fichier est classé « distant », et sa +règle atterrit sur `wan`. Le 22 août, on s'apprêtait à poser 89 objets sur la frontière +avec une règle d'admin qui n'aurait jamais laissé passer personne. + +**Un fichier qui décrit un monde disparu.** Mesuré le même jour : + +``` +management 10.0.0.0/24 déclaré → PERSONNE +stockage 10.0.1.0/24 déclaré → PERSONNE +ceph 10.0.2-3.0/24 déclaré → PERSONNE +transit 10.0.4.0/24 déclaré → occupé (3 nœuds) +vxlan 10.0.5.0/24 déclaré → occupé (3 nœuds) + +et, portés par les nœuds sans être déclarés nulle part : +192.168.11.x 10.11.5-7.x 192.168.50.x 10.1.110.254 +``` + +La frontière avait déjà migré vers `10.17.0.1` ; les hyperviseurs, non. Le fichier était +resté au monde d'avant. + +--- + +## L'instrument + +``` +make underlay-plan # confronte le fichier au réel, n'écrit rien +``` + +Il interroge l'API du cluster pour les adresses **réellement portées**, et sonde en TCP ce +qui répond. Deux précautions y sont inscrites, toutes deux apprises en l'écrivant : + +- **l'autorité dépend du rôle.** L'API de Proxmox connaît ses hyperviseurs, et eux seuls. + Déclarer un commutateur « porté par personne » parce que le cluster l'ignore, c'est + accuser le monde de ce que l'instrument ne voit pas ; +- **« pas joignable d'ici » n'est pas « absent ».** Les réseaux de *chemin* (transit, + transport VXLAN, stockage) ne sont **jamais** joignables depuis l'extérieur, par + construction (D-78). Le premier jet du devis les déclarait morts. + +--- + +## Ce qui reste à faire, dans l'ordre + +- [ ] **Reconnaître** : `make underlay-plan`, puis écrire dans `underlay.yml` ce qui est — + pas ce qui était prévu. Chaque réseau porté et non déclaré est un réseau que le + moteur ne sait pas classer. +- [ ] **Séparer les voûtes** : créer celle de l'underlay, y déplacer le jeton Proxmox et la + clé de la frontière, les retirer des voûtes de tenants. +- [ ] **Alors seulement**, appliquer la frontière — ses règles dépendent des deux points + ci-dessus. diff --git a/scripts/devis_underlay.py b/scripts/devis_underlay.py new file mode 100644 index 0000000..4e72a49 --- /dev/null +++ b/scripts/devis_underlay.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Devis de l'UNDERLAY — ce que le fichier declare, confronte a ce qui existe. + +`underlay.yml` decrit le monde PHYSIQUE d'un site : commutateurs, hyperviseurs, +frontiere, reseaux de transport. Rien ne verifiait qu'il decrive encore quelque chose. + +POURQUOI CE DEVIS EXISTE (mesure du 2026-08-22). Le fichier de Chezlepro declarait un +plan de gestion `10.0.0.0/24` avec les hyperviseurs en `10.0.0.41/43/47`. Mesure : ni +`10.0.0.1`, ni `10.0.0.41` ne repondent, et les hyperviseurs vivent en `192.168.11.x`. La +frontiere, elle, avait deja migre vers `10.17.0.1`. Le fichier decrivait un monde disparu. + +CE QUE CA COUTAIT, et ce n'etait pas theorique : `devis_opnsense` derive l'interface d'une +regle de l'ATTACHEMENT REEL de sa source, et cet attachement se lit ici. Un plan +d'administration absent du fichier est classe « distant », et sa regle atterrit sur `wan` +ou elle ne peut JAMAIS correspondre. On aurait pose 89 objets sur la frontiere pour se +voir refuser quand meme. + +CE QU'IL INTERROGE : l'API du cluster pour les adresses REELLES des noeuds, et une sonde +TCP pour ce qui repond. N'ECRIT RIEN — ni sur le cluster, ni dans le fichier. + + python3 scripts/devis_underlay.py # tableau lisible + python3 scripts/devis_underlay.py --verifier # code de sortie (0 = le fichier dit vrai) +""" +from __future__ import annotations + +import argparse +import ipaddress +import socket +import sys +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RACINE / "scripts")) + +import underlay as underlay_mod # noqa: E402 +from proxmox_api import Cluster # noqa: E402 + +PORTS_SONDE = (22, 8006, 443, 80) + + +def repond(adresse: str, ports: tuple[int, ...] = PORTS_SONDE, delai: float = 1.5) -> int | None: + """Le premier port qui accepte une connexion, ou None. Sonde TCP, jamais ICMP. + + Un ping refuse ne prouve rien — beaucoup d'equipements le filtrent par defaut. Une + poignee TCP acceptee prouve qu'il y a quelqu'un. + """ + for port in ports: + try: + with socket.create_connection((adresse, port), timeout=delai): + return port + except OSError: + continue + return None + + +def adresses_reelles(api: Cluster, noeuds: list[str]) -> dict[str, list[str]]: + """Les adresses que PORTENT reellement les noeuds, vues par l'API du cluster.""" + trouve: dict[str, list[str]] = {} + for n in noeuds: + rep = api(f"/nodes/{n}/network") + if not isinstance(rep, list): + continue + adresses = [] + for i in rep: + cidr = i.get("cidr") or i.get("address") + if cidr and not str(cidr).startswith("127."): + adresses.append(str(cidr)) + trouve[n] = sorted(adresses) + return trouve + + +def constater() -> tuple[list[dict], list[str]]: + """Confronte chaque reseau et chaque hote declares a ce qui repond.""" + u = underlay_mod.charger() + if u is None: + raise SystemExit("Aucun underlay monte (underlay.yml absent) — rien a confronter.") + + lignes: list[dict] = [] + notes: list[str] = [] + + # 2. Les noeuds du cluster : ou sont-ils VRAIMENT ? (l'AUTORITE) + try: + api, _ = Cluster.depuis_hebergeur() + rep = api("/nodes") + noeuds = sorted(n.get("node") for n in rep) if isinstance(rep, list) else [] + reelles = adresses_reelles(api, noeuds) + except Exception as e: # noqa: BLE001 — l'API est un tiers ; on rend la cause lisible + notes.append(f"Cluster injoignable ({str(e)[:80]}) : adresses reelles non relevees.") + reelles = {} + + portees = {a.split("/")[0] for ads in reelles.values() for a in ads} + + # 1. Les hotes declares : le CLUSTER porte-t-il cette adresse ? + # + # L'autorite est l'API, pas ma sonde TCP. Le premier jet de ce devis declarait « MUET » + # tout ce qu'il ne joignait pas — et disait donc faux de `10.0.4.41`, une adresse bien + # reelle sur un reseau de CHEMIN, que rien ne joint jamais de l'exterieur par + # construction (D-78). Un instrument qui confond « absent » et « pas joignable d'ici » + # accuse le monde de ce qu'il ne sait pas voir. La sonde TCP reste, en second rang : + # elle dit « joignable d'ici », ce qui est une autre question, utile pour le plan + # d'administration. + for h in underlay_mod.hotes(u): + ip = str(h.get("ip") or "") + if not ip: + continue + # L'AUTORITE DEPEND DU ROLE, et c'est la deuxieme fois que ce devis l'apprend : + # l'API de Proxmox connait ses hyperviseurs, et EUX SEULS. Un commutateur ou une + # frontiere n'y figurent pas — les declarer « portes par personne » parce que le + # cluster les ignore serait accuser le monde de ce que l'instrument ne voit pas. + role = str(h.get("role") or "") + port = repond(ip) + if role == "hyperviseur": + if not reelles: + verdict = "cluster injoignable — non verifie" + elif ip in portees: + verdict = f"portee par le cluster{f', joignable (tcp/{port})' if port else ''}" + else: + verdict = "PORTEE PAR AUCUN NOEUD" + else: + verdict = (f"repond (tcp/{port})" if port + else "non joignable d'ici (equipement hors cluster)") + lignes.append({"objet": f"{h.get('nom','?')} ({h.get('reseau','?')})", + "declare": ip, "verdict": verdict}) + + declarees = {str(h.get("ip")) for h in underlay_mod.hotes(u) if h.get("ip")} + for noeud, adresses in sorted(reelles.items()): + nues = [a.split("/")[0] for a in adresses] + connues = [a for a in nues if a in declarees] + lignes.append({"objet": f"{noeud} — adresses reelles", + "declare": ", ".join(a for a in nues if not a.startswith("169.254"))[:60], + "verdict": "declaree" if connues else "AUCUNE N'EST DECLAREE"}) + + # 3. Les reseaux declares : au moins une adresse reelle y tombe-t-elle ? + toutes_reelles = [a.split("/")[0] for ads in reelles.values() for a in ads] + for r in underlay_mod.reseaux(u): + try: + reseau = ipaddress.ip_network(str(r.get("sous_reseau")), strict=False) + except ValueError: + continue + dedans = [a for a in toutes_reelles + if ipaddress.ip_address(a) in reseau] if toutes_reelles else [] + if not toutes_reelles: + verdict = "cluster injoignable — non verifie" + elif dedans: + verdict = f"occupe ({len(dedans)} adresse(s) reelle(s))" + elif repond(str(r.get("passerelle") or "")) is not None: + verdict = "passerelle repond, aucun noeud dessus" + else: + verdict = "PERSONNE" + lignes.append({"objet": f"reseau {r.get('nom')}", + "declare": str(r.get("sous_reseau")), "verdict": verdict}) + + # 4. L'inverse, et c'est celui qu'on oublie : ce que les noeuds PORTENT et que + # l'underlay ne declare nulle part. Un reseau non declare est un reseau que le + # moteur ne sait pas classer — donc dont les regles atterrissent au mauvais endroit. + reseaux_declares = [] + for r in underlay_mod.reseaux(u): + try: + reseaux_declares.append(ipaddress.ip_network(str(r.get("sous_reseau")), strict=False)) + except ValueError: + continue + inconnues = sorted({a for a in toutes_reelles + if not a.startswith(("127.", "169.254.")) + and not any(ipaddress.ip_address(a) in r for r in reseaux_declares)}) + for a in inconnues: + lignes.append({"objet": "porte par le cluster", "declare": a, + "verdict": "NON DECLARE dans underlay.yml"}) + return lignes, notes + + +def afficher(lignes: list[dict], notes: list[str]) -> bool: + print(f"Devis de l'underlay — source : {underlay_mod.chemin()}\n") + print(f" {'OBJET':<34} {'DECLARE':<40} VERDICT") + faux = 0 + for l in lignes: + alerte = l["verdict"].isupper() or l["verdict"] in ("MUET", "PERSONNE") + faux += 1 if alerte else 0 + print(f" {l['objet']:<34} {l['declare']:<40} {l['verdict']}") + for n in notes: + print(f"\n /!\\ {n}") + print("\n" + (f"ECART : {faux} ligne(s) que le fichier declare et que rien n'occupe. " + f"L'underlay decrit un monde qui n'est plus la." + if faux else + "CONFORME : tout ce que l'underlay declare existe et repond.")) + return faux == 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--verifier", action="store_true", help="code de sortie seulement") + a = ap.parse_args() + lignes, notes = constater() + if a.verifier: + faux = [l for l in lignes if l["verdict"].isupper() + or l["verdict"] in ("MUET", "PERSONNE")] + for l in faux: + print(f"underlay : {l['objet']} declare « {l['declare']} » — {l['verdict']}", + file=sys.stderr) + return 1 if faux else 0 + return 0 if afficher(lignes, notes) else 1 + + +if __name__ == "__main__": + sys.exit(main())