underlay : la frontiere entre les deux mondes, et l'instrument qui la mesure
Some checks are pending
verifier / verifier (push) Waiting to run

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-22 16:31:35 -04:00
parent 5be3fbd5e1
commit 9f36f08db0
3 changed files with 319 additions and 0 deletions

View file

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

View file

@ -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.<index>.0-15.x` | zones : `10.<index>.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.<index>.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.

204
scripts/devis_underlay.py Normal file
View file

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