contexte : etape 2, les flux de chaque machine (P86)

La face reseau porte les flux resolus par le locataire ; verifier_flux
les confronte au devis Proxmox, port par port. resoudre_flux publie les
sources_declarees d un port aussi public, que le locataire jetait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-04 23:10:12 -04:00
parent 077c6c91b4
commit 3d01208a68
9 changed files with 249 additions and 12 deletions

View file

@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente
## Écrire, puis relire (D-68)
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
C'est aussi ce que font les 85 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 86 preuves de `make prouver` : elles lisent le dépôt, sans le
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
dépôt annonce.**

View file

@ -1,5 +1,44 @@
# CHANGELOG — Set-OPS
## 2026-10-04 (82) — Étape 2 : les flux de chaque machine, et une information que le locataire jetait
**Le chemin** (`docs/conception-contextes.md` §6), étape 2, sens locataire → site : les flux.
La face réseau porte désormais les flux de **chaque machine**, ceux que consomme le pare-feu
Proxmox. Ceux de la **frontière** (interfaces, alias, traduction d'adresses, flux entre site
et locataires) restent à faire.
**Fait** (`scripts/contexte.py`) :
- la face réseau reprend, pour chaque machine, ce que le locataire a **déjà résolu** pour ses
propres pare-feux (`flux-genere/<hôte>.connectivite.json`) : chaque entrée admise et ses
réseaux d'administration ;
- `verifier_flux(site, locataire)` compare, machine par machine et port par port, les sources
admises par **nftables** (le locataire) et par **Proxmox** (le devis du site). Les deux
écrivent la même politique autrement : règle par groupe contre sources réunies, ensembles
nommés contre adresses en clair, « Internet » (`0.0.0.0/0` contre « tout sauf RFC 1918 »,
doublé d'une règle « administration (poste) »), l'administration à part, l'ICMP nommé
différemment. Ces différences sont neutralisées, et décrites dans le code.
**Ce que la première mesure a trouvé** : 3 écarts par locataire, tous sur `edge-mta-01`
(25, 465, 587). Ce n'était pas une divergence de politique, mais une **information perdue** :
quand un port est à la fois public et ouvert à des clients internes (`[externe, client_smtp]`),
`resoudre_flux.py` écrivait `0.0.0.0/0` et **jetait** la liste des clients nommés. Juste pour
l'hôte, qui accepte de partout ; insuffisant pour Proxmox, dont l'Internet exclut les plages
privées et qui doit admettre ces clients nommément. Telle quelle, la face réseau n'aurait pas
suffi au site.
**Corrigé à la source** (décision de l'exploitant) : `resoudre_flux.py` publie, à côté de
`0.0.0.0/0`, les `sources_declarees`. Flux régénérés chez les deux locataires : seul
`edge-mta-01.connectivite.json` change, **aucun `.nft` ne bouge**. La sonde `connectivite`,
qui lit ces fichiers sur les machines, ignore le champ nouveau ; le fichier sera recopié au
prochain passage de `nftables_baseline`, sans effet.
**Éprouvé** :
- **P86**, « Machine par machine, le locataire et Proxmox admettent les mêmes entrées » :
aucun écart chez les deux locataires.
- `test_contexte.py`, 66 contrôles : des flux altérés de **quatre façons** (une source retirée,
un port ajouté, les sources déclarées effacées, l'administration retirée) sont vus en écart.
- `make verifier` conforme, **86/86**. Les trois documents comptent 86 preuves.
## 2026-10-04 (81) — Étape 2, seconde moitié (les faits) : la face réseau du locataire
**Le chemin** (`docs/conception-contextes.md` §6), étape 2, sens locataire → site. La face

View file

@ -174,6 +174,12 @@ croisée, plus aucun recalcul.
- **Chaque fiche porte l'empreinte de sa source**, et une preuve de chaque côté vérifie que la
fiche reçue correspond à ce que l'autre a publié.
**Où en est l'étape 2 (2026-10-04).** La fiche du site (P84), les faits de la face réseau
(P85) et les flux de chaque machine (P86) existent, et disent exactement ce que les lectures
croisées produisent. La première mesure des flux a trouvé une information que le locataire
jetait : les clients nommés d'un port aussi public, que Proxmox doit admettre nommément. Il
les publie désormais (`sources_declarees`). Reste la frontière.
Méthodes du contrat : `site.fiche_pour(locataire)`, `locataire.face_reseau()`. Une classe
n'ouvre jamais les fichiers de l'autre ; une preuve vérifiera la règle.

View file

@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
## Le trou qu'il comble
`scripts/prouver.py` porte 85 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 86 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
propriétaire, que rien n'est codé en dur.

View file

@ -99,7 +99,7 @@ Nommer un point ouvert vaut mieux qu'une ligne rassurante sans mécanisme derri
Aucune de ces responsabilités n'est laissée à la parole :
```
make prouver le dépôt est-il cohérent avec lui-même (85 preuves, zéro réseau)
make prouver le dépôt est-il cohérent avec lui-même (86 preuves, zéro réseau)
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
make certificats-plan ce que le disque porte contre ce que la mémoire sert
make expositions-plan chaque service publié répond-il, et depuis où

View file

@ -373,7 +373,8 @@ class Locataire(Ecosysteme):
re-derivait, sequence 1 par defaut), sa cle de sauvegarde de SON dossier d'inventaire
(le site ecrivait `principal` en dur).
LES FLUX RESOLUS sont la seconde moitie de l'etape 2 ; ils ne sont pas encore ici.
LES FLUX de chaque machine viennent de ce que le locataire a deja resolu pour ses
propres pare-feux (`flux-genere/*.connectivite.json`). Ceux de la frontiere suivront.
"""
from inventory_rules import charger_acces, est_groupe_operationnel, tunnel_admin_de
from resoudre_flux import _enfants, _hotes_du_groupe, _ip_par_hote
@ -418,10 +419,21 @@ class Locataire(Ecosysteme):
"port": 5300 if colocalise else 53} if zones else {})
cle_sauvegarde = str(_yaml(self.dossier_inventaire() / "group_vars" / "serveur_backup.yml")
.get("serveur_backup_pubkey") or "").strip()
# LES FLUX DE CHAQUE MACHINE, tels que le locataire les a deja resolus pour ses propres
# pare-feux (`make flux` -> `flux-genere/<hote>.connectivite.json`) : chaque entree
# admise (protocole, port, sources), plus ses reseaux d'administration.
flux = {}
for h in sorted(ips):
c = self.depot / "flux-genere" / f"{h}.connectivite.json"
if c.is_file():
d = json.loads(c.read_text(encoding="utf-8"))
flux[h] = {"entrees": d.get("entrees") or [], "administration": d.get("admin_ssh") or []}
sources = {str(Path(*f.relative_to(self.depot).parts)): _empreinte(f) for f in (
self.plan / "nomenclature.yml", self.inventaire(), self.plan / "acces.yml",
self.plan / "domaines.yml", self.plan / "applications.yml",
self.dossier_inventaire() / "group_vars" / "serveur_backup.yml")}
sources.update({f"flux-genere/{h}.connectivite.json":
_empreinte(self.depot / "flux-genere" / f"{h}.connectivite.json") for h in flux})
return {
"locataire": self.nom,
"hebergeur": self.nom_hebergeur(),
@ -434,6 +446,7 @@ class Locataire(Ecosysteme):
for n, c in sorted(pairs.items()) if isinstance(c, dict)}},
"dns_public": dns_public,
"sauvegarde": {"cle_publique": cle_sauvegarde},
"flux": flux,
}
def decrire(self) -> dict:
@ -603,6 +616,104 @@ def verifier_face(site: "Site", locataire: "Locataire", face: dict | None = None
return ecarts
# LES DEUX PARE-FEUX ECRIVENT LA MEME POLITIQUE AUTREMENT. On les ramene a une forme commune :
# pour chaque machine, pour chaque (protocole, port), l'ENSEMBLE des sources admises, chacune
# etant une adresse, `EXTERNE` ou `ADMIN`. Les differences d'ecriture, toutes neutralisees ici :
# - Proxmox ecrit une regle par groupe source ; le locataire reunit les sources d'un port ;
# - Proxmox nomme un ensemble (`+t23-srv-nginx`) ou ecrit les adresses en clair, separees
# par des virgules (la fabric vers Loki, le DNS public du site) ;
# - « Internet » : `0.0.0.0/0` chez le locataire, l'ensemble « tout sauf RFC 1918 » chez
# Proxmox, double d'une regle « administration (poste) » sur le meme port ;
# - l'administration : une liste a part (`admin_ssh`) et des entrees ordinaires chez le
# locataire, l'ensemble « reseaux d'administration » chez Proxmox ;
# - l'ICMP : `frag-needed` d'un cote, `fragmentation-needed` de l'autre.
# Un port ouvert a tous ET a des clients nommes porte ces derniers dans `sources_declarees`
# (depuis le 2026-10-04) : sans eux, Proxmox ne saurait pas qui admettre en plus d'Internet.
EXTERNE, ADMIN = "EXTERNE", "ADMIN"
_ICMP = {"frag-needed": "fragmentation-needed"}
def _entrees_locataire(face: dict) -> dict[str, dict]:
out: dict[str, dict] = {}
for h, f in (face.get("flux") or {}).items():
admin = set(f.get("administration") or [])
par_port = out.setdefault(h, {})
for e in f.get("entrees") or []:
cle = (str(e.get("proto")), _ICMP.get(str(e.get("port")), str(e.get("port"))))
atomes = par_port.setdefault(cle, set())
# `sources_declarees` : les clients nommes d'un port aussi ouvert a tous (`0.0.0.0/0`).
for s in list(e.get("sources") or []) + list(e.get("sources_declarees") or []):
atomes.add(EXTERNE if s == "0.0.0.0/0" else ADMIN if s in admin else str(s))
if admin:
par_port.setdefault(("tcp", "22"), set()).add(ADMIN)
return out
def _entrees_proxmox(bloc: dict) -> dict[str, dict]:
ens = bloc.get("ipsets") or {}
regles = {g["nom"]: g.get("regles") or [] for g in bloc.get("groupes") or []}
out: dict[str, dict] = {}
for a in bloc.get("affectations") or []:
par_port = out.setdefault(a["hote"], {})
for g in a.get("groupes") or []:
for r in regles.get(g, []):
if r.get("action") != "ACCEPT" or r.get("sens") != "IN":
continue
if str(r.get("via", "")) == "administration (poste)":
continue # le double d'une regle `internet` sur le meme port
source = str(r.get("source", ""))
if source.startswith("+"):
e = ens.get(source[1:]) or {}
role = str(e.get("role", ""))
atomes = ({EXTERNE} if role.startswith("Internet") else
{ADMIN} if role.startswith("reseaux d'admin") else
{str(m) for m in e.get("membres") or []})
else:
atomes = {s.strip() for s in source.split(",") if s.strip()}
for p in str(r.get("dport") or r.get("icmp_type")).split(","):
cle = (str(r.get("proto")), _ICMP.get(p.strip(), p.strip()))
par_port.setdefault(cle, set()).update(atomes)
return out
def verifier_flux(site: "Site", locataire: "Locataire", face: dict | None = None) -> list[str]:
"""Machine par machine : le pare-feu du locataire (nftables, resolu par lui) et celui de
Proxmox (devise par le site) admettent-ils les memes entrees ?
C'est le constat 2 de la conception : les flux etaient calcules DEUX FOIS, chacun avec sa
version du moteur. Vide = les deux pare-feux disent la meme chose, et le site pourra, a
l'etape 3, deviser Proxmox depuis la face reseau sans rien recalculer.
"""
import devis_proxmox_fw
import devis_reseau
f = face if face is not None else locataire.face_reseau()
with _monter(site):
bloc = next((b for b in devis_proxmox_fw.construire(devis_reseau.decouvrir_du_site())["blocs"]
if b.get("tenant") == locataire.nom), None)
if bloc is None:
return ["le devis Proxmox n'a aucun bloc pour ce locataire"]
loc, pve = _entrees_locataire(f), _entrees_proxmox(bloc)
ecarts: list[str] = []
for h in sorted(set(loc) | set(pve)):
if h not in loc:
ecarts.append(f"{h} : filtre par Proxmox, sans flux publie par le locataire")
continue
if h not in pve:
ecarts.append(f"{h} : flux publies par le locataire, aucune affectation Proxmox")
continue
for cle in sorted(set(loc[h]) | set(pve[h])):
l, p = loc[h].get(cle, set()), pve[h].get(cle, set())
if l == p:
continue
port = f"{cle[0]}/{cle[1]}"
if l - p:
ecarts.append(f"{h} {port} : le locataire admet {sorted(l - p)}, Proxmox non")
if p - l:
ecarts.append(f"{h} {port} : Proxmox admet {sorted(p - l)}, le locataire non")
return ecarts
def couples_hebergeur() -> list[tuple["Site", "Locataire"]]:
"""Chaque (site, locataire) dont ce site est l'hebergeur ACTIF — les seuls qui ont une fiche
a verifier : un site de reprise ne depose rien tant que la bascule n'a pas eu lieu."""
@ -699,10 +810,12 @@ def main(argv: list[str] | None = None) -> int:
return 0
total = 0
for site, loc in couples:
e = verifier_face(site, loc)
total += len(e)
print(f"{'OK ' if not e else 'ECART'} {loc.nom} -> {site.nom}"
+ "".join(f"\n - {x}" for x in e))
face = loc.face_reseau()
for quoi, e in (("faits", verifier_face(site, loc, face)),
("flux ", verifier_flux(site, loc, face))):
total += len(e)
print(f"{'OK ' if not e else 'ECART'} {loc.nom} -> {site.nom} ({quoi})"
+ "".join(f"\n - {x}" for x in e))
return 1 if total else 0
if args.fiche:
loc = Locataire.charger(args.fiche)

View file

@ -4715,6 +4715,31 @@ def preuve_face_reseau() -> tuple[bool, str]:
f"(decouverte, devis Proxmox, frontiere, DNS public, sauvegarde).")
def preuve_flux_concordent() -> tuple[bool, str]:
"""Le pare-feu de chaque machine (nftables, resolu par le locataire) et celui de Proxmox
(devise par le site) admettent-ils les memes entrees ?
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4, constat 2). Les flux etaient
calcules DEUX FOIS, par le locataire et par le site, chacun avec sa version du moteur. La
face reseau publie ceux du locataire (`flux-genere/*.connectivite.json`). Machine par
machine et port par port, les sources admises doivent etre les memes, une fois neutralisees
les differences d'ecriture (decrites dans `contexte.py`). Premiere mesure : 3 ecarts par
locataire, tous sur le courriel — le locataire jetait les clients internes d'un port aussi
public ; il les publie desormais (`sources_declarees`).
"""
sys.path.insert(0, str(RACINE / "scripts"))
import contexte as _ctx
couples = _ctx.couples_hebergeur()
if not couples:
return True, "Aucun site frere : aucun flux a confronter."
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_flux(s, l)]
if ecarts:
return False, (f"{len(ecarts)} ecart(s) entre le pare-feu des machines et celui de Proxmox : "
+ " | ".join(ecarts[:4]))
return True, (f"{len(couples)} locataire(s) : machine par machine et port par port, nftables et "
f"Proxmox admettent les memes sources.")
PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]},
@ -4905,6 +4930,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_fiche_du_site},
{"id": "P85", "titre": "La face reseau du locataire dit exactement ce que le site en tire",
"refs": [], "func": preuve_face_reseau},
{"id": "P86", "titre": "Machine par machine, le locataire et Proxmox admettent les memes entrees",
"refs": [], "func": preuve_flux_concordent},
]

View file

@ -867,9 +867,16 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -
continue # lo deja accepte
regles.append(f" {saddr}{_selecteur_nft(fl['protocole'], port)} accept"
f" # {g}: {fl.get('raison','')}")
entrees.setdefault(hote, []).append(
{"proto": proto, "port": str(port), "role": g,
"sources": list(srcs) if saddr else ["0.0.0.0/0"]})
entree = {"proto": proto, "port": str(port), "role": g,
"sources": list(srcs) if saddr else ["0.0.0.0/0"]}
# « TOUT LE MONDE » NE DOIT PAS EFFACER QUI ETAIT NOMME (2026-10-04). L'hote
# accepte de partout, et c'est juste pour lui ; mais le pare-feu de Proxmox,
# dont l'Internet exclut les plages privees, doit admettre les clients
# internes NOMMEMENT (le 25 de Postfix : `client_smtp`). Les jeter ici
# privait la face reseau du locataire de ce que le site doit en lire.
if not saddr and srcs:
entree["sources_declarees"] = sorted(str(s) for s in srcs)
entrees.setdefault(hote, []).append(entree)
# Testable depuis la source : TCP, et l'ICMP « echo » (ping). L'UDP ne se
# « connecte » pas ; les autres types ICMP ne se provoquent pas.
if proto == "tcp" or (proto == "icmp" and str(port) == "echo-request"):

View file

@ -12,7 +12,8 @@ Puis, si les vrais depots sont la : chaque locataire declare par un site porte l
site lui attribue, et nomme un hebergeur qui existe ; la fiche de chaque hebergeur dit
exactement ce que son locataire porte, et une fiche alteree (sept facons) est vue en ecart ; la face
reseau de chaque locataire dit ce que les consommateurs du site en tirent, et une face alteree
(huit facons) est vue en ecart.
(huit facons) est vue en ecart ; machine par machine, le pare-feu du locataire et celui de
Proxmox admettent les memes entrees, et des flux alteres (quatre facons) sont vus en ecart.
"""
from __future__ import annotations
@ -238,6 +239,49 @@ def faces() -> None:
verifier(bool(C.verifier_face(site, loc, f)), f"une face reseau alteree ({nom}) est vue en ecart")
def flux() -> None:
"""Machine par machine, le pare-feu du locataire et celui de Proxmox admettent les memes
entrees — et une face dont les flux sont alteres se voit."""
import copy
couples = C.couples_hebergeur()
if not couples:
print("SAUTE aucun site frere : aucun flux a confronter")
return
for site, loc in couples:
e = C.verifier_flux(site, loc)
verifier(not e, f"flux {loc.nom} : le locataire et Proxmox admettent les memes entrees"
+ (f" ({e[0]})" if e else ""))
site, loc = couples[0]
juste = loc.face_reseau()
mta = next(h for h, f in juste["flux"].items()
if any(x.get("sources_declarees") for x in f["entrees"]))
autre = next(h for h, f in juste["flux"].items()
if any(len(x.get("sources") or []) > 1 for x in f["entrees"]))
def retirer_une_source(f):
e = next(x for x in f["flux"][autre]["entrees"] if len(x.get("sources") or []) > 1)
e["sources"] = e["sources"][1:]
def ajouter_un_port(f):
f["flux"][autre]["entrees"].append({"proto": "tcp", "port": "6666", "role": "x",
"sources": ["192.0.2.66"]})
def effacer_declarees(f):
for x in f["flux"][mta]["entrees"]:
x.pop("sources_declarees", None)
def retirer_admin(f):
f["flux"][autre]["administration"] = []
for nom, alterer in (("une source retiree", retirer_une_source),
("un port ajoute", ajouter_un_port),
(f"sources declarees effacees sur {mta}", effacer_declarees),
("administration retiree", retirer_admin)):
f = copy.deepcopy(juste)
alterer(f)
verifier(bool(C.verifier_flux(site, loc, f)), f"des flux alteres ({nom}) sont vus en ecart")
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
@ -248,6 +292,7 @@ def main() -> int:
monter_restaure()
fiches()
faces()
flux()
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1