205 lines
9 KiB
Python
205 lines
9 KiB
Python
|
|
#!/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())
|