contexte : etape 2 terminee, les sorties (P90)

resoudre_flux publie les sorties vers l'Internet de chaque machine ;
verifier_sorties les confronte aux regles de sortie de la frontiere.
Le contrat site <-> locataire est entierement publie et prouve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-05 11:21:37 -04:00
parent 92a6fb0ead
commit d6591cb271
9 changed files with 157 additions and 7 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 89 preuves de `make prouver` : elles lisent le dépôt, sans le C'est aussi ce que font les 90 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,37 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-10-05 (89) — Étape 2 terminée : les sorties, et le contrat entier tient
**Le chemin** (`docs/conception-contextes.md` §6), étape 2, frontière, dernier temps : ce qui
sort de chez un locataire vers l'Internet. Avec lui, **l'étape 2 est complète** : chaque chose
que le site lit chez un locataire est publiée par le locataire, et une preuve vérifie que la
publication dit exactement ce que le site en tire.
**Fait** :
- `resoudre_flux.py` relève, avec les mêmes conditions que les entrées (groupe opérationnel,
`flux_applicable`), les **sorties vers l'extérieur** de chaque machine, et les publie dans
son `*.connectivite.json` (`sorties_externes` : protocole, port, rôle). Ses machines
acceptent tout en sortie : il ne les relevait pas, la frontière les filtre ;
- flux régénérés chez les deux locataires : les 13 fichiers de connectivité de chacun gagnent
le champ, **aucun `.nft` ne bouge**. La sonde `connectivite` l'ignore ;
- la face réseau reprend ces sorties ; `verifier_sorties(site, locataire)` les confronte,
machine par machine, aux règles de sortie de la frontière vers « tout sauf l'interne », et
signale toute sortie vers une autre destination.
**Éprouvé** :
- **P90**, « Ce qui sort d'un locataire vers l'Internet, la frontière le tient de lui » : aucun
écart. Comparés chez Technolibre : 43 sorties publiées (machine × port), 7 règles de sortie
(le système en 80, 443 et ICMP ; Postfix en 25 ; PowerDNS et le résolveur en 53). Les deux
sorties fausses de `serveur_ops` étaient déjà retirées ((85), (87)).
- `test_contexte.py`, 91 contrôles : une sortie retirée ou ajoutée est vue à la frontière.
- `make verifier` conforme, **90/90**. Les trois documents comptent 90 preuves.
**Le bilan de l'étape 2** : sept preuves (P84 à P90) ; trois choses que le locataire
**jetait** ou **ne relevait pas** et publie désormais (les clients nommés d'un port public,
le drapeau `poste`, ses sorties) ; deux sorties fausses retirées de la frontière, dont un
passage entre locataires ; une garde sur la passerelle de zone. Prochaine étape (3) : les
consommateurs du site lisent les fiches au lieu des fichiers internes, à résultat identique.
## 2026-10-05 (88) — La passerelle n'est pas une porte : une garde sur l'API Proxmox ## 2026-10-05 (88) — La passerelle n'est pas une porte : une garde sur l'API Proxmox
**Le reste ouvert de (87)** : chez un locataire en SDN, la passerelle de chaque zone est une **Le reste ouvert de (87)** : chez un locataire en SDN, la passerelle de chaque zone est une

View file

@ -179,7 +179,8 @@ croisée, plus aucun recalcul.
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`). La frontière, en trois temps : les identités les publie désormais (`sources_declarees`). La frontière, en trois temps : les identités
(P87), les entrées publiques (P88) et l'administration (P89) sont faites ; restent les sorties. (P87), les entrées publiques (P88), l'administration (P89) et les sorties (P90) sont faites.
**L'étape 2 est terminée** (2026-10-05).
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 89 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le `scripts/prouver.py` porte 90 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 (89 preuves, zéro réseau) make prouver le dépôt est-il cohérent avec lui-même (90 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

@ -445,7 +445,8 @@ class Locataire(Ecosysteme):
d = json.loads(c.read_text(encoding="utf-8")) d = json.loads(c.read_text(encoding="utf-8"))
entrees = [dict(e, poste=ouvert_au_poste(e)) if "0.0.0.0/0" in (e.get("sources") or []) entrees = [dict(e, poste=ouvert_au_poste(e)) if "0.0.0.0/0" in (e.get("sources") or [])
else dict(e) for e in d.get("entrees") or []] else dict(e) for e in d.get("entrees") or []]
flux[h] = {"entrees": entrees, "administration": d.get("admin_ssh") or []} flux[h] = {"entrees": entrees, "administration": d.get("admin_ssh") or [],
"sorties_externes": d.get("sorties_externes") or []}
sources = {str(Path(*f.relative_to(self.depot).parts)): _empreinte(f) for f in ( 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 / "nomenclature.yml", self.inventaire(), self.plan / "acces.yml",
self.plan / "domaines.yml", self.plan / "applications.yml", self.plan / "domaines.yml", self.plan / "applications.yml",
@ -894,6 +895,52 @@ def verifier_administration(site: "Site", locataire: "Locataire", face: dict | N
return ecarts return ecarts
def verifier_sorties(site: "Site", locataire: "Locataire", face: dict | None = None) -> list[str]:
"""La frontiere, troisieme temps (seconde moitie) : ce qui SORT de chez le locataire vers
l'Internet.
Machine par machine, les regles de sortie vers « tout sauf l'interne » (`!SETOPS_INTERNES`)
doivent dire ce que la face reseau publie dans `sorties_externes` : les memes (machine,
protocole, port). Le locataire relevait ses entrees pour ses propres pare-feux ; ses sorties,
que ses machines acceptent toutes, il ne les relevait pas — il les publie depuis le
2026-10-05, pour la frontiere.
"""
import ipaddress
f = face if face is not None else locataire.face_reseau()
etq, devis = _devis_frontiere(site, locataire)
if not etq:
return ["la frontiere ne decouvre pas ce locataire"]
alias = devis.get("alias") or {}
ips = sorted({m["ip"] for m in f["machines"].values()})
def adresses(nom: str) -> set[str]:
a = alias.get(nom) or {}
if a.get("type") == "network":
reseaux = [ipaddress.ip_network(str(c), strict=False) for c in a.get("contenu") or []]
return {ip for ip in ips if any(ipaddress.ip_address(ip) in r for r in reseaux)}
return {str(c) for c in a.get("contenu") or []}
publiees = {(f["machines"][h]["ip"], str(s.get("proto")), _ICMP.get(str(s.get("port")), str(s.get("port"))))
for h, fl in (f.get("flux") or {}).items() for s in fl.get("sorties_externes") or []}
vues = {(ip, str(r.get("protocole")), _ICMP.get(str(p), str(p)))
for r in devis.get("regles") or []
if r.get("tenant") == locataire.nom and r.get("sens") == "out"
and str(r.get("destination")) == "!SETOPS_INTERNES"
for ip in adresses(str(r.get("source"))) for p in r.get("ports") or []}
ecarts: list[str] = []
for x in sorted(publiees - vues):
ecarts.append(f"{x[0]} {x[1]}/{x[2]} : le locataire sort vers l'Internet, la frontiere ne le laisse pas")
for x in sorted(vues - publiees):
ecarts.append(f"{x[0]} {x[1]}/{x[2]} : la frontiere laisse sortir, le locataire ne le publie pas")
autres = [r for r in devis.get("regles") or []
if r.get("tenant") == locataire.nom and r.get("sens") == "out"
and str(r.get("destination")) != "!SETOPS_INTERNES"]
for r in autres:
ecarts.append(f"sortie vers {r.get('destination')} ({r.get('protocole')}/{','.join(r.get('ports') or [])}, "
f"{r.get('role')}) : ni l'Internet, ni publiee par le locataire")
return ecarts
def verifier_frontiere(site: "Site", locataire: "Locataire", face: dict | None = None, def verifier_frontiere(site: "Site", locataire: "Locataire", face: dict | None = None,
fiche: dict | None = None) -> list[str]: fiche: dict | None = None) -> list[str]:
"""La frontiere, premier temps : les IDENTITES par lesquelles elle designe ce locataire. """La frontiere, premier temps : les IDENTITES par lesquelles elle designe ce locataire.
@ -1052,7 +1099,8 @@ def main(argv: list[str] | None = None) -> int:
("flux ", verifier_flux(site, loc, face)), ("flux ", verifier_flux(site, loc, face)),
("frontiere (identites)", verifier_frontiere(site, loc, face)), ("frontiere (identites)", verifier_frontiere(site, loc, face)),
("frontiere (entrees publiques)", verifier_entrees_publiques(site, loc, face)), ("frontiere (entrees publiques)", verifier_entrees_publiques(site, loc, face)),
("frontiere (administration)", verifier_administration(site, loc, face))): ("frontiere (administration)", verifier_administration(site, loc, face)),
("frontiere (sorties)", verifier_sorties(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

@ -4808,6 +4808,28 @@ def preuve_frontiere_administration() -> tuple[bool, str]:
f"l'administration atteint exactement ce que leur face reseau lui ouvre.") f"l'administration atteint exactement ce que leur face reseau lui ouvre.")
def preuve_frontiere_sorties() -> tuple[bool, str]:
"""Les regles de sortie d'un locataire vers l'Internet disent-elles ce que sa face reseau
publie (`sorties_externes`) ?
POURQUOI (2026-10-05, `docs/conception-contextes.md` §2.4). Dernier temps de la frontiere.
Le locataire ne relevait pas ses sorties (ses machines les acceptent toutes) ; il les publie
desormais, et la frontiere doit les laisser passer, elles et rien d'autre. En chemin, deux
sorties de `serveur_ops` se sont revelees fausses et ont ete retirees (85, 87).
"""
sys.path.insert(0, str(RACINE / "scripts"))
import contexte as _ctx
couples = _ctx.couples_hebergeur()
if not couples:
return True, "Aucun site frere : aucune sortie a confronter."
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_sorties(s, l)]
if ecarts:
return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que le locataire publie comme "
f"sorties : " + " | ".join(ecarts[:4]))
return True, (f"{len(couples)} locataire(s) : la frontiere laisse sortir exactement ce que leur "
f"face reseau publie.")
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"]]},
@ -5006,6 +5028,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_frontiere_entrees_publiques}, "refs": [], "func": preuve_frontiere_entrees_publiques},
{"id": "P89", "titre": "Ce que l'administration atteint chez un locataire, la frontiere le tient de lui", {"id": "P89", "titre": "Ce que l'administration atteint chez un locataire, la frontiere le tient de lui",
"refs": [], "func": preuve_frontiere_administration}, "refs": [], "func": preuve_frontiere_administration},
{"id": "P90", "titre": "Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui",
"refs": [], "func": preuve_frontiere_sorties},
] ]

View file

@ -785,12 +785,24 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -
hote_par_ip = {ip: h for h, ip in ip_par_hote.items()} hote_par_ip = {ip: h for h, ip in ip_par_hote.items()}
sorties: dict[str, dict[tuple, dict]] = {} sorties: dict[str, dict[tuple, dict]] = {}
entrees: dict[str, list[dict]] = {} entrees: dict[str, list[dict]] = {}
# CE QUE CHAQUE VM DEMANDE A L'EXTERIEUR (2026-10-05). Ses machines acceptent tout en
# sortie : nftables n'en a pas besoin, et ce generateur ne les relevait donc pas. Mais la
# FRONTIERE, elle, filtre ces sorties, et la face reseau du locataire doit les lui dire
# (docs/conception-contextes.md). Relevees ICI, avec les memes conditions que les entrees.
sorties_externes: dict[str, set[tuple]] = {}
for hote in sorted(actifs_noms): for hote in sorted(actifs_noms):
groupes = [g for g, membres in _enfants(data).items() groupes = [g for g, membres in _enfants(data).items()
if hote in (membres.get("hosts") or {}) and est_groupe_operationnel(g)] if hote in (membres.get("hosts") or {}) and est_groupe_operationnel(g)]
regles: list[str] = list(regles_admin) regles: list[str] = list(regles_admin)
for g in sorted(groupes): for g in sorted(groupes):
for fl in flux_par_role.get(g, []): for fl in flux_par_role.get(g, []):
if fl.get("sens") == "egress" and "externe" in _pairs(fl):
if flux_applicable(fl, g, dossier_ecosysteme,
(_enfants(data).get(g, {}).get("hosts") or {}).get(hote)):
for pt in (fl["port"] if isinstance(fl["port"], list) else [fl["port"]]):
sorties_externes.setdefault(hote, set()).add(
(str(fl.get("protocole", "tcp")).lower(), str(pt), g))
continue
if fl.get("sens") != "ingress": if fl.get("sens") != "ingress":
continue continue
if not flux_applicable(fl, g, dossier_ecosysteme, if not flux_applicable(fl, g, dossier_ecosysteme,
@ -914,6 +926,8 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -
key=lambda x: (x["vers"], x["proto"], x["port"])), key=lambda x: (x["vers"], x["proto"], x["port"])),
"entrees": entrees.get(hote, []), "entrees": entrees.get(hote, []),
"admin_ssh": list(admin_ssh), "admin_ssh": list(admin_ssh),
"sorties_externes": [{"proto": pr, "port": po, "role": ro}
for pr, po, ro in sorted(sorties_externes.get(hote, set()))],
}, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") }, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
ecrits.append(chemin) ecrits.append(chemin)
for orphelin in sorted(list(sortie_dir.glob("*.nft")) + list(sortie_dir.glob("*.connectivite.json"))): for orphelin in sorted(list(sortie_dir.glob("*.nft")) + list(sortie_dir.glob("*.connectivite.json"))):

View file

@ -17,7 +17,7 @@ Proxmox admettent les memes entrees, et des flux alteres (quatre facons) sont vu
la frontiere designe chaque locataire par les memes identites, et une identite alteree (cinq la frontiere designe chaque locataire par les memes identites, et une identite alteree (cinq
facons) se voit ; ce qui entre depuis l'Internet concorde, et une entree alteree (quatre facons) facons) se voit ; ce qui entre depuis l'Internet concorde, et une entree alteree (quatre facons)
se voit ; ce que l'administration atteint concorde, et une administration alteree (quatre se voit ; ce que l'administration atteint concorde, et une administration alteree (quatre
facons) se voit. facons) se voit ; ce qui sort vers l'Internet concorde, et une sortie alteree se voit.
""" """
from __future__ import annotations from __future__ import annotations
@ -395,6 +395,36 @@ def administration() -> None:
f"une administration alteree ({nom}) est vue a la frontiere") f"une administration alteree ({nom}) est vue a la frontiere")
def sorties() -> None:
"""Ce qui sort de chez chaque locataire vers l'Internet : la frontiere dit ce que la face
publie — et chaque alteration se voit."""
import copy
couples = C.couples_hebergeur()
if not couples:
print("SAUTE aucun site frere : aucune sortie a confronter")
return
for site, loc in couples:
e = C.verifier_sorties(site, loc)
verifier(not e, f"sorties {loc.nom} : la frontiere laisse sortir exactement ce que la face publie"
+ (f" ({e[0]})" if e else ""))
site, loc = couples[0]
face = loc.face_reseau()
hote = next(h for h, f in face["flux"].items() if f.get("sorties_externes"))
verifier(sum(len(f.get("sorties_externes") or []) for f in face["flux"].values()) > 0,
"la face publie les sorties vers l'Internet de chaque machine")
def retirer(f):
f["flux"][hote]["sorties_externes"] = f["flux"][hote]["sorties_externes"][1:]
def ajouter(f):
f["flux"][hote]["sorties_externes"].append({"proto": "tcp", "port": "6667", "role": "x"})
for nom, alterer in (("une sortie retiree", retirer), ("une sortie ajoutee", ajouter)):
f = copy.deepcopy(face)
alterer(f)
verifier(bool(C.verifier_sorties(site, loc, f)), f"une sortie 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)
@ -409,6 +439,7 @@ def main() -> int:
frontiere() frontiere()
entrees_publiques() entrees_publiques()
administration() administration()
sorties()
if ECHECS: if ECHECS:
print(f"\n{len(ECHECS)} echec(s).") print(f"\n{len(ECHECS)} echec(s).")
return 1 return 1