underlay : la frontiere entre les deux mondes, et l'instrument qui la mesure
Some checks are pending
verifier / verifier (push) Waiting to run
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:
parent
5be3fbd5e1
commit
9f36f08db0
3 changed files with 319 additions and 0 deletions
3
Makefile
3
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
|
||||
|
||||
|
|
|
|||
112
docs/frontiere-physique-virtuel.md
Normal file
112
docs/frontiere-physique-virtuel.md
Normal 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
204
scripts/devis_underlay.py
Normal 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())
|
||||
Loading…
Reference in a new issue