contexte : etape 2, la frontiere premier temps, les identites (P87)

La face reseau porte ses zones ; verifier_frontiere confronte supernet,
administration, tunnel, alias de groupe, routes et traduction sortante a
la face reseau et a la fiche du site. Aucun ecart ; cinq alterations vues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-04 23:28:06 -04:00
parent 3d01208a68
commit 78c2c25761
8 changed files with 165 additions and 6 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) ## Écrire, puis relire (D-68)
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**. `--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 86 preuves de `make prouver` : elles lisent le dépôt, sans le C'est aussi ce que font les 87 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 moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
dépôt annonce.** dépôt annonce.**

View file

@ -1,5 +1,33 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-10-04 (83) — Étape 2 : la frontière, premier temps (les identités)
**Le chemin** (`docs/conception-contextes.md` §6), étape 2, dernier consommateur : la
frontière. Découpée en trois temps avec l'exploitant : les **identités** (ici), les **entrées
publiques** (redirections, règles depuis Internet), les **sorties et l'administration**.
**Fait** (`scripts/contexte.py`) :
- la face réseau porte ses **zones** : un sous-réseau par catégorie de sa nomenclature, dérivé
de son index par `sous_reseau_de`, comme la frontière et le SDN le dérivent ;
- `verifier_frontiere(site, locataire)` confronte les identités par lesquelles le devis de la
frontière désigne le locataire à ce que sa face réseau et la fiche du site portent : le
supernet (ses zones), l'administration (que la frontière range par interface, gestion et
VPN), le tunnel, **chaque alias de groupe** (8 chez chaque locataire : les groupes qui ont
des flux de bordure), une route par zone, et la traduction sortante par l'adresse publique
que le site attribue ;
- les notes que le devis imprime (« flux `externe` sans `port_public` », « sortie vers un
groupe absent de ce site ») sont retenues pendant la vérification : utiles à qui lit le
devis, du bruit dans une preuve. Elles serviront au temps suivant.
**Éprouvé** :
- **P87**, « La frontière désigne chaque locataire par les identités qu'il publie » : aucun
écart chez les deux locataires.
- `test_contexte.py`, 73 contrôles : une identité altérée de **cinq façons** (zones,
administration déclarée, tunnel, membres d'un groupe, adresse publique attribuée) est vue à
la frontière. La vérification compare bien les 8 alias de groupe de Technolibre, contrôlé à
part avant d'écrire la preuve.
- `make verifier` conforme, **87/87**. Les trois documents comptent 87 preuves.
## 2026-10-04 (82) — Étape 2 : les flux de chaque machine, et une information que le locataire jetait ## 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. **Le chemin** (`docs/conception-contextes.md` §6), étape 2, sens locataire → site : les flux.

View file

@ -178,7 +178,8 @@ croisée, plus aucun recalcul.
(P85) et les flux de chaque machine (P86) existent, et disent exactement ce que les lectures (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 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 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. les publie désormais (`sources_declarees`). La frontière, en trois temps : les identités
(P87) sont faites ; restent les entrées publiques, puis les sorties et l'administration.
Méthodes du contrat : `site.fiche_pour(locataire)`, `locataire.face_reseau()`. Une classe 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. 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 ## Le trou qu'il comble
`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 `scripts/prouver.py` porte 87 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 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 cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
propriétaire, que rien n'est codé en dur. 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 : 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 (86 preuves, zéro réseau) make prouver le dépôt est-il cohérent avec lui-même (87 preuves, zéro réseau)
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80) 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 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ù make expositions-plan chaque service publié répond-il, et depuis où

View file

@ -434,11 +434,18 @@ class Locataire(Ecosysteme):
self.dossier_inventaire() / "group_vars" / "serveur_backup.yml")} self.dossier_inventaire() / "group_vars" / "serveur_backup.yml")}
sources.update({f"flux-genere/{h}.connectivite.json": sources.update({f"flux-genere/{h}.connectivite.json":
_empreinte(self.depot / "flux-genere" / f"{h}.connectivite.json") for h in flux}) _empreinte(self.depot / "flux-genere" / f"{h}.connectivite.json") for h in flux})
# Ses zones : un sous-reseau par categorie de sa nomenclature, derive de son index
# (`sous_reseau_de`, comme la frontiere et le SDN le derivent).
from inventory_rules import sous_reseau_de
cats = self.nomenclature().get("categories") or {}
zones = ([sous_reseau_de(self.index, int(z)) for z in sorted(cats, key=int)]
if self.index is not None else [])
return { return {
"locataire": self.nom, "locataire": self.nom,
"hebergeur": self.nom_hebergeur(), "hebergeur": self.nom_hebergeur(),
"sources": sources, "sources": sources,
"index": self.index, "index": self.index,
"zones": zones,
"machines": machines, "machines": machines,
"groupes": groupes, "groupes": groupes,
"administration": {"intrant": intrant, "tunnel": tunnel_admin_de(self.depot), "administration": {"intrant": intrant, "tunnel": tunnel_admin_de(self.depot),
@ -714,6 +721,72 @@ def verifier_flux(site: "Site", locataire: "Locataire", face: dict | None = None
return ecarts return ecarts
def verifier_frontiere(site: "Site", locataire: "Locataire", face: dict | None = None,
fiche: dict | None = None) -> list[str]:
"""La frontiere, premier temps : les IDENTITES par lesquelles elle designe ce locataire.
Ses alias (supernet, administration par interface, tunnel, un alias par groupe qui a des
flux de bordure), ses routes, et sa traduction sortante. Chacun doit dire ce que la face
reseau du locataire (zones, groupes, administration) et la fiche du site (adresse publique
attribuee) portent deja. Les entrees publiques et les sorties sont les temps suivants.
"""
import re
import devis_opnsense
import devis_reseau
f = face if face is not None else locataire.face_reseau()
fi = fiche if fiche is not None else site.fiche_pour(locataire)
with _monter(site):
tenants = devis_reseau.decouvrir_du_site()
etq = next((p + str(n["index"]) for nom, p, n in tenants if nom == locataire.nom), None)
# Le devis imprime ses notes (flux sans `port_public`, sorties vers un absent) : utiles a
# qui le lit, du bruit dans une preuve. On les retient ici.
import io
with contextlib.redirect_stderr(io.StringIO()):
devis = devis_opnsense.construire(tenants) if etq else {}
if not etq:
return ["la frontiere ne decouvre pas ce locataire"]
ecarts: list[str] = []
alias = devis.get("alias") or {}
ips = {h: m["ip"] for h, m in f["machines"].items()}
# Le supernet : ses zones.
sup = (alias.get(f"SETOPS_TENANT_{etq}") or {}).get("contenu")
if sorted(sup or []) != sorted(f["zones"]):
ecarts.append(f"SETOPS_TENANT_{etq} : la frontiere porte {sup}, la face {f['zones']}")
# L'administration : l'intrant, que la frontiere range par interface, et le tunnel.
adm = sorted({c for k, v in alias.items() if re.fullmatch(rf"SETOPS_ADMIN_{etq}_(?!TUNNEL)\w+", k)
for c in v.get("contenu") or []})
if adm != sorted(f["administration"]["intrant"]):
ecarts.append(f"administration : la frontiere range {adm}, la face {sorted(f['administration']['intrant'])}")
tun = sorted((alias.get(f"SETOPS_ADMIN_{etq}_TUNNEL") or {}).get("contenu") or [])
if tun != sorted(f["administration"]["tunnel"]):
ecarts.append(f"tunnel : la frontiere porte {tun}, la face {sorted(f['administration']['tunnel'])}")
# Un alias par groupe : ses membres sont ceux du groupe dans la face.
for k, v in sorted(alias.items()):
m = re.search(r"portant (\S+) \(instance (\S+)\)", str(v.get("description", "")))
if not m or m.group(2) != locataire.nom:
continue
role = m.group(1)
attendu = sorted({ips[h] for h in f["groupes"].get(role, [])})
if role not in f["groupes"]:
ecarts.append(f"{k} : groupe {role} inconnu de la face")
elif sorted(v.get("contenu") or []) != attendu:
ecarts.append(f"{k} : la frontiere porte {sorted(v.get('contenu') or [])}, la face {attendu}")
# Les routes : une par zone.
routes = sorted(r["reseau"] for r in devis.get("routes") or [] if r.get("tenant") == locataire.nom)
if routes != sorted(f["zones"]):
ecarts.append(f"routes : la frontiere route {routes}, la face a pour zones {sorted(f['zones'])}")
# La traduction sortante : sa flotte sort par l'adresse que le site lui attribue.
nat = [x for x in devis.get("nat") or [] if x.get("tenant") == locataire.nom]
cibles = sorted(str(x.get("cible")) for x in nat)
if cibles != [str(fi["attribue"]["ip_publique"])]:
ecarts.append(f"traduction sortante : la frontiere fait sortir par {cibles}, "
f"la fiche attribue {fi['attribue']['ip_publique']}")
if any(x.get("source") != f"SETOPS_TENANT_{etq}" for x in nat):
ecarts.append("traduction sortante : la source n'est pas le supernet du locataire")
return ecarts
def couples_hebergeur() -> list[tuple["Site", "Locataire"]]: def couples_hebergeur() -> list[tuple["Site", "Locataire"]]:
"""Chaque (site, locataire) dont ce site est l'hebergeur ACTIF — les seuls qui ont une fiche """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.""" a verifier : un site de reprise ne depose rien tant que la bascule n'a pas eu lieu."""
@ -812,7 +885,8 @@ def main(argv: list[str] | None = None) -> int:
for site, loc in couples: for site, loc in couples:
face = loc.face_reseau() face = loc.face_reseau()
for quoi, e in (("faits", verifier_face(site, loc, face)), for quoi, e in (("faits", verifier_face(site, loc, face)),
("flux ", verifier_flux(site, loc, face))): ("flux ", verifier_flux(site, loc, face)),
("frontiere (identites)", verifier_frontiere(site, loc, face))):
total += len(e) total += len(e)
print(f"{'OK ' if not e else 'ECART'} {loc.nom} -> {site.nom} ({quoi})" print(f"{'OK ' if not e else 'ECART'} {loc.nom} -> {site.nom} ({quoi})"
+ "".join(f"\n - {x}" for x in e)) + "".join(f"\n - {x}" for x in e))

View file

@ -4740,6 +4740,29 @@ def preuve_flux_concordent() -> tuple[bool, str]:
f"Proxmox admettent les memes sources.") f"Proxmox admettent les memes sources.")
def preuve_frontiere_identites() -> tuple[bool, str]:
"""Les alias, les routes et la traduction sortante par lesquels la frontiere designe chaque
locataire disent-ils ce que sa face reseau et la fiche du site portent ?
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). La frontiere est le dernier
consommateur du site qui lit l'interieur des locataires. Premier temps de sa bascule : les
identites — supernet (ses zones), administration par interface, tunnel, un alias par groupe
qui a des flux de bordure, une route par zone, la sortie par l'adresse publique attribuee.
Les entrees publiques et les sorties suivront.
"""
sys.path.insert(0, str(RACINE / "scripts"))
import contexte as _ctx
couples = _ctx.couples_hebergeur()
if not couples:
return True, "Aucun site frere : aucune frontiere a confronter."
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_frontiere(s, l)]
if ecarts:
return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que le locataire publie : "
+ " | ".join(ecarts[:4]))
return True, (f"{len(couples)} locataire(s) : la frontiere les designe par les identites qu'ils "
f"publient (supernet, administration, tunnel, groupes, routes, sortie).")
PREUVES: list[dict] = [ PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]}, "cmds": [["ansible-lint", "-q"]]},
@ -4932,6 +4955,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_face_reseau}, "refs": [], "func": preuve_face_reseau},
{"id": "P86", "titre": "Machine par machine, le locataire et Proxmox admettent les memes entrees", {"id": "P86", "titre": "Machine par machine, le locataire et Proxmox admettent les memes entrees",
"refs": [], "func": preuve_flux_concordent}, "refs": [], "func": preuve_flux_concordent},
{"id": "P87", "titre": "La frontiere designe chaque locataire par les identites qu'il publie",
"refs": [], "func": preuve_frontiere_identites},
] ]

View file

@ -13,7 +13,9 @@ site lui attribue, et nomme un hebergeur qui existe ; la fiche de chaque heberge
exactement ce que son locataire porte, et une fiche alteree (sept facons) est vue en ecart ; la face 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 reseau de chaque locataire dit ce que les consommateurs du site en tirent, et une face alteree
(huit facons) est vue en ecart ; machine par machine, le pare-feu du locataire et celui de (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. Proxmox admettent les memes entrees, et des flux alteres (quatre facons) sont vus en ecart ;
la frontiere designe chaque locataire par les memes identites, et une identite alteree (cinq
facons) se voit.
""" """
from __future__ import annotations from __future__ import annotations
@ -282,6 +284,34 @@ def flux() -> None:
verifier(bool(C.verifier_flux(site, loc, f)), f"des flux alteres ({nom}) sont vus en ecart") verifier(bool(C.verifier_flux(site, loc, f)), f"des flux alteres ({nom}) sont vus en ecart")
def frontiere() -> None:
"""La frontiere designe chaque locataire par les memes identites que sa face reseau et la
fiche du site — et chaque identite alteree se voit."""
import copy
couples = C.couples_hebergeur()
if not couples:
print("SAUTE aucun site frere : aucune frontiere a confronter")
return
for site, loc in couples:
e = C.verifier_frontiere(site, loc)
verifier(not e, f"frontiere {loc.nom} : alias, routes et traduction sortante concordent"
+ (f" ({e[0]})" if e else ""))
site, loc = couples[0]
face, fiche = loc.face_reseau(), site.fiche_pour(loc)
groupe = next(g for g in ("serveur_postfix", "serveur_dovecot", "serveur_nginx") if face["groupes"].get(g))
alterations = {
"zones": lambda f, fi: f.__setitem__("zones", f["zones"][:-1]),
"administration declaree": lambda f, fi: f["administration"].__setitem__("intrant", ["192.0.2.0/24"]),
"tunnel": lambda f, fi: f["administration"].__setitem__("tunnel", ["192.0.2.128/25"]),
f"membres du groupe {groupe}": lambda f, fi: f["groupes"].__setitem__(groupe, []),
"adresse publique attribuee": lambda f, fi: fi["attribue"].__setitem__("ip_publique", "192.0.2.60"),
}
for nom, alterer in alterations.items():
f, fi = copy.deepcopy(face), copy.deepcopy(fiche)
alterer(f, fi)
verifier(bool(C.verifier_frontiere(site, loc, f, fi)), f"une identite alteree ({nom}) est vue a la frontiere")
def main() -> int: def main() -> int:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp) d = Path(tmp)
@ -293,6 +323,7 @@ def main() -> int:
fiches() fiches()
faces() faces()
flux() flux()
frontiere()
if ECHECS: if ECHECS:
print(f"\n{len(ECHECS)} echec(s).") print(f"\n{len(ECHECS)} echec(s).")
return 1 return 1