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)
`--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
dépôt annonce.**

View file

@ -1,5 +1,37 @@
# 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
**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
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
(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
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 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
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 (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 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

@ -445,7 +445,8 @@ class Locataire(Ecosysteme):
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 [])
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 (
self.plan / "nomenclature.yml", self.inventaire(), self.plan / "acces.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
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,
fiche: dict | None = None) -> list[str]:
"""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)),
("frontiere (identites)", verifier_frontiere(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)
print(f"{'OK ' if not e else 'ECART'} {loc.nom} -> {site.nom} ({quoi})"
+ "".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.")
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] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]},
@ -5006,6 +5028,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_frontiere_entrees_publiques},
{"id": "P89", "titre": "Ce que l'administration atteint chez un locataire, la frontiere le tient de lui",
"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()}
sorties: dict[str, dict[tuple, 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):
groupes = [g for g, membres in _enfants(data).items()
if hote in (membres.get("hosts") or {}) and est_groupe_operationnel(g)]
regles: list[str] = list(regles_admin)
for g in sorted(groupes):
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":
continue
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"])),
"entrees": entrees.get(hote, []),
"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")
ecrits.append(chemin)
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
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
facons) se voit.
facons) se voit ; ce qui sort vers l'Internet concorde, et une sortie alteree se voit.
"""
from __future__ import annotations
@ -395,6 +395,36 @@ def administration() -> None:
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:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
@ -409,6 +439,7 @@ def main() -> int:
frontiere()
entrees_publiques()
administration()
sorties()
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1