diff --git a/AGENTS.md b/AGENTS.md index a569f24..20a9679 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 88 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 89 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.** diff --git a/CHANGELOG.md b/CHANGELOG.md index 2877559..a0a63da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,69 @@ # CHANGELOG — Set-OPS +## 2026-10-05 (87) — Une règle qui disait qu'un locataire pouvait créer des VM + +**La question de l'exploitant** : « un flux est permis entre une machine d'un tenant et l'API +Proxmox ? » Déclaré : oui. `roles/serveur_ops/meta/flux.yml` portait une sortie 8006 vers +`externe`, « API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant », et la +frontière en tirait une règle pour le runner de chaque locataire. + +**Mesuré** (2026-10-05) : **non**. Depuis trois machines de deux locataires, vers les neuf +adresses des trois hyperviseurs et vers leur passerelle de zone, avec la forge du site pour +témoin (200) : +- `192.168.11.4x:8006` et `10.0.4.4x:8006`, et leur 22 : `ECONNREFUSED`. La frontière refuse ; + son journal porte les tentatives en `block`, par le refus audible du lien de transit. La + règle déclarée visait « tout sauf les plages privées », et les hyperviseurs y sont : elle + ne laissait passer **aucun** paquet ; +- la passerelle de zone (`10.23.19.1`, l'hyperviseur lui-même dans le VRF du locataire) : + `ECONNREFUSED`, sans passer par la frontière ; c'est l'hyperviseur qui refuse. Cause probable, + **non vérifiée** (poste hors site, sans accès SSH aux hyperviseurs) : pveproxy écoute dans le + VRF par défaut et `net.ipv4.tcp_l3mdev_accept` vaut 0. + +**Fait** : la sortie est retirée de `serveur_ops`, avec la mesure en trace. Une règle morte qui +affirme un pouvoir qu'aucun locataire ne doit avoir. Matérialiser reste au **site** +(`serveur_ops_site` → `SETOPS_FABRIC`, 8006, inchangé). Registre des flux régénéré ; flux des +locataires inchangés. + +**Simulé** (`make frontiere-plan`) : 5 retraits — la règle 8006 des deux locataires et celle de +la console du site (`SETOPS_SITE_SERVEUR_OPS`, morte pour la même raison), et les deux alias +`SERVEUR_OPS` des locataires, devenus orphelins. Rien d'autre sur 335 règles et 12 routes. + +**Reste ouvert** : le chemin par la passerelle de zone contourne la frontière. Il est fermé +aujourd'hui ; la garde qui le surveillera est à décider avec l'exploitant. + +**Validation** : `make verifier` conforme, 89/89. + +## 2026-10-05 (86) — Étape 2 : la frontière, troisième temps (l'administration) + +**Le chemin** (`docs/conception-contextes.md` §6), étape 2, frontière, troisième temps, +première moitié : ce que l'administration atteint chez un locataire. Les sorties suivent. + +**Fait** (`scripts/contexte.py`) : +- la face réseau dit, pour chaque entrée ouverte à tous, si son flux l'ouvre **aussi au + poste** (`poste`, vrai par défaut). Elle le lit dans le catalogue des flux du moteur, que le + runner du locataire possède aussi : aucun changement chez le locataire. Une seule entrée est + fermée au poste, le 25 de Postfix (un flux de serveur à serveur) ; +- `verifier_administration(site, locataire)` confronte, pour **chaque classe + d'administration** que la frontière range par interface (gestion, VPN), ce qu'elle atteint + machine par machine avec ce que la face lui ouvre : le SSH, les entrées qui nomment + l'administration parmi leurs sources, les entrées publiques ouvertes au poste. Le **tunnel** + du locataire doit exister si la face en a un, et viser toute sa flotte ; ses ports (22 et + 443) sont une politique du site, écrite dans le devis, pas une donnée du locataire. + +**Relevé, non modifié** : le devis justifie `poste: false` sur le 25 par « deux couches qui ne +déclarent pas la même politique » (mesure du 2026-08-09 : la bordure autorisait, nftables +refusait). Depuis le 2026-09-29, nftables accepte le 25 de partout (un port public ouvert à +tous) : l'administration y accède donc par la machine, pas par la bordure. La bordure est la +plus stricte, sans danger ; mais les deux couches divergent de nouveau, dans l'autre sens. + +**Éprouvé** : +- **P89**, « Ce que l'administration atteint chez un locataire, la frontière le tient de lui » : + aucun écart chez les deux locataires. +- `test_contexte.py`, 86 contrôles : une administration altérée de **quatre façons** (le 25 + ouvert au poste, les sources d'administration retirées d'une machine, un SSH d'administration + retiré, le tunnel retiré) est vue à la frontière. +- `make verifier` conforme, **89/89**. Les trois documents comptent 89 preuves. + ## 2026-10-05 (85) — Un passage entre locataires que rien ne justifiait **Trouvé en confrontant la frontière à la face réseau** (sous-étape 3 de la frontière, diff --git a/docs/conception-contextes.md b/docs/conception-contextes.md index c307728..a185ce3 100644 --- a/docs/conception-contextes.md +++ b/docs/conception-contextes.md @@ -179,7 +179,7 @@ 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) et les entrées publiques (P88) sont faites ; restent les sorties et l'administration. +(P87), les entrées publiques (P88) et l'administration (P89) sont faites ; restent les sorties. 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. diff --git a/docs/devis-services.md b/docs/devis-services.md index 1b9e5ad..3bc6564 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -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 88 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`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 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. diff --git a/docs/registre-flux.md b/docs/registre-flux.md index 532dd4d..a3f7b35 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -86,7 +86,6 @@ | `serveur_ops` | ingress | 8090 | tcp | edge, admin | clair | Console d'exploitation servie par l'edge (TLS terminé à l'edge), et joignable depuis le plan d'administration là où il n'y a pas d'edge. Le GUI lui-même reste sur la boucle locale : c'est nginx qui authentifie devant. | | `serveur_ops` | egress | 22 | tcp | flotte | ssh | Piloter la flotte — c'est la raison d'être du poste. | | `serveur_ops` | egress | 443 | tcp | edge | tls-requis | Cloner et resynchroniser le génome depuis la forge de l'écosystème. | -| `serveur_ops` | egress | 8006 | tcp | externe | tls-requis | API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant. | | `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. | | `serveur_ops_site` | egress | 22 | tcp | serveur_ops_tenant | ssh | Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne transporte aucun secret : la voute est remise par un humain. | | `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. | @@ -135,4 +134,4 @@ - **starttls** : 6 flux - **tls** : 10 flux - **tls-cible** : 2 flux -- **tls-requis** : 35 flux +- **tls-requis** : 34 flux diff --git a/docs/responsabilites-locataire-hebergeur.md b/docs/responsabilites-locataire-hebergeur.md index fcd0dd6..f045d69 100644 --- a/docs/responsabilites-locataire-hebergeur.md +++ b/docs/responsabilites-locataire-hebergeur.md @@ -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 (88 preuves, zéro réseau) +make prouver le dépôt est-il cohérent avec lui-même (89 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ù diff --git a/roles/serveur_ops/meta/flux.yml b/roles/serveur_ops/meta/flux.yml index 7e647b9..6e74dd6 100644 --- a/roles/serveur_ops/meta/flux.yml +++ b/roles/serveur_ops/meta/flux.yml @@ -41,15 +41,17 @@ flux: pair: flotte chiffrement: ssh raison: "Piloter la flotte — c'est la raison d'être du poste." - # L'HYPERVISEUR N'EST PAS DANS L'ÉCOSYSTÈME : il appartient au monde physique, de - # l'autre côté de la frontière. C'est donc un pair `externe`, et ce flux est le seul - # par lequel un écosystème peut en engendrer un autre. - - sens: egress - port: 8006 - protocole: tcp - pair: externe - chiffrement: tls-requis - raison: "API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant." + # UN LOCATAIRE NE PARLE PAS A L'HYPERVISEUR — ET CE N'EST PLUS DECLARE ICI (2026-10-05). + # + # Ce fichier portait une sortie 8006 vers `externe` : « API de l'hyperviseur : créer et + # cloner les VM d'un écosystème descendant ». Mesure du 2026-10-05, depuis trois machines de + # deux locataires : AUCUN paquet ne passait. La frontiere rendait cette sortie vers « tout + # sauf les plages privees », et les hyperviseurs sont en plages privees ; elle refusait donc + # (journal : `block`, refus audible du lien de transit). La regle etait morte, et sa lecture + # mentait : elle disait qu'un locataire pouvait creer des VM. + # + # Materialiser est un pouvoir du SITE (`serveur_ops_site`, sa voute porte l'API Proxmox). + # Une emancipation qui en aurait besoin se declarera le jour ou elle existera. # LE GENOME SE CLONE A LA FORGE DU SITE — ET CE N'EST PLUS DECLARE ICI (2026-10-05). # # Ce fichier portait une sortie 443 vers `voisins_site`, ecrite quand la forge du genome diff --git a/scripts/contexte.py b/scripts/contexte.py index 1b10510..3077753 100644 --- a/scripts/contexte.py +++ b/scripts/contexte.py @@ -30,6 +30,7 @@ import contextlib import hashlib import json import os +import re import sys from pathlib import Path @@ -423,12 +424,28 @@ class Locataire(Ecosysteme): # LES FLUX DE CHAQUE MACHINE, tels que le locataire les a deja resolus pour ses propres # pare-feux (`make flux` -> `flux-genere/.connectivite.json`) : chaque entree # admise (protocole, port, sources), plus ses reseaux d'administration. + # + # UNE ENTREE OUVERTE A TOUS L'EST-ELLE AUSSI AU POSTE ? Le flux le dit (`poste`, vrai par + # defaut ; faux pour le 25 de Postfix, un flux de serveur a serveur). La frontiere en + # tire ses regles d'administration ; la face le lit dans le meme catalogue du moteur. + from devis_opnsense import _ports as ports_du_flux + from resoudre_flux import _pairs, charger_flux + catalogue = charger_flux() + + def ouvert_au_poste(e: dict) -> bool: + semblables = [fl for fl in catalogue.get(str(e.get("role")), []) + if fl.get("sens") == "ingress" and "externe" in _pairs(fl) + and str(fl.get("protocole", "tcp")).lower() == str(e.get("proto")) + and str(e.get("port")) in ports_du_flux(fl)] + return all(fl.get("poste", True) for fl in semblables) 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 []} + 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 []} 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", @@ -817,6 +834,66 @@ def verifier_entrees_publiques(site: "Site", locataire: "Locataire", face: dict return ecarts +def verifier_administration(site: "Site", locataire: "Locataire", face: dict | None = None) -> list[str]: + """La frontiere, troisieme temps (premiere moitie) : ce que l'ADMINISTRATION atteint. + + Par la gestion et par le VPN du site, chaque classe d'administration doit atteindre, machine + par machine, exactement ce que la face reseau lui ouvre : le SSH, les entrees qui nomment + l'administration parmi leurs sources, et les entrees ouvertes a tous que leur flux ouvre + aussi au poste. Le tunnel du locataire, lui, atteint toute sa flotte sur les ports que la + POLITIQUE DU SITE lui accorde (22 et 443, ecrits dans le devis) : on verifie sa presence et + sa destination, pas ses ports, qui ne sont pas une donnee du locataire. + """ + 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 []} + + # Ce que la face ouvre a l'administration. + intrant = set(f["administration"]["intrant"]) + attendu: set[tuple] = set() + for h, fl in (f.get("flux") or {}).items(): + ip = f["machines"][h]["ip"] + for e in fl.get("entrees") or []: + sources = set(e.get("sources") or []) + if (sources & intrant) or ("0.0.0.0/0" in sources and e.get("poste", True)): + attendu.add((ip, str(e.get("proto")), _ICMP.get(str(e.get("port")), str(e.get("port"))))) + if fl.get("administration"): + attendu.add((ip, "tcp", "22")) + + ecarts: list[str] = [] + classes = sorted(k for k in alias if re.fullmatch(rf"SETOPS_ADMIN_{etq}_(?!TUNNEL)\w+", k)) + for classe in classes: + vu = {(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") == "in" and r.get("source") == classe + for ip in adresses(str(r.get("destination"))) for p in r.get("ports") or []} + for x in sorted(attendu - vu): + ecarts.append(f"{classe} : la face ouvre {x[0]} {x[1]}/{x[2]} a l'administration, la frontiere non") + for x in sorted(vu - attendu): + ecarts.append(f"{classe} : la frontiere ouvre {x[0]} {x[1]}/{x[2]}, la face ne l'ouvre pas a l'administration") + + tunnel = [r for r in devis.get("regles") or [] + if r.get("tenant") == locataire.nom and r.get("source") == f"SETOPS_ADMIN_{etq}_TUNNEL"] + if bool(tunnel) != bool(f["administration"]["tunnel"]): + ecarts.append(f"tunnel : {len(tunnel)} regle(s) a la frontiere, la face " + f"{'en a un' if f['administration']['tunnel'] else 'n en a aucun'}") + for r in tunnel: + if adresses(str(r.get("destination"))) != set(ips): + ecarts.append(f"tunnel : la regle {r.get('ports')} ne vise pas toute la flotte du 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. @@ -974,7 +1051,8 @@ def main(argv: list[str] | None = None) -> int: for quoi, e in (("faits", verifier_face(site, loc, face)), ("flux ", verifier_flux(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))): total += len(e) print(f"{'OK ' if not e else 'ECART'} {loc.nom} -> {site.nom} ({quoi})" + "".join(f"\n - {x}" for x in e)) diff --git a/scripts/prouver.py b/scripts/prouver.py index 52ff479..2e0a589 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -4786,6 +4786,28 @@ def preuve_frontiere_entrees_publiques() -> tuple[bool, str]: f"ce que leur face reseau ouvre a tous.") +def preuve_frontiere_administration() -> tuple[bool, str]: + """Par la gestion, le VPN et le tunnel, l'administration atteint-elle chez chaque locataire + exactement ce que sa face reseau lui ouvre ? + + POURQUOI (2026-10-05, `docs/conception-contextes.md` §2.4). Troisieme temps de la frontiere, + premiere moitie. Le SSH, les entrees qui nomment l'administration parmi leurs sources, et + les entrees publiques que leur flux ouvre au poste (`poste`, faux pour le 25 de Postfix). + Le tunnel atteint toute la flotte, sur les ports que la politique du site lui accorde. + """ + sys.path.insert(0, str(RACINE / "scripts")) + import contexte as _ctx + couples = _ctx.couples_hebergeur() + if not couples: + return True, "Aucun site frere : aucune administration a confronter." + ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_administration(s, l)] + if ecarts: + return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que la face ouvre a " + f"l'administration : " + " | ".join(ecarts[:4])) + return True, (f"{len(couples)} locataire(s) : par la gestion, le VPN et le tunnel, " + f"l'administration atteint exactement ce que leur face reseau lui ouvre.") + + PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, @@ -4982,6 +5004,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_frontiere_identites}, {"id": "P88", "titre": "Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui", "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}, ] diff --git a/scripts/tests/test_contexte.py b/scripts/tests/test_contexte.py index 387fba5..0f7040e 100644 --- a/scripts/tests/test_contexte.py +++ b/scripts/tests/test_contexte.py @@ -16,7 +16,8 @@ reseau de chaque locataire dit ce que les consommateurs du site en tirent, et un 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 ; ce qui entre depuis l'Internet concorde, et une entree alteree (quatre facons) -se voit. +se voit ; ce que l'administration atteint concorde, et une administration alteree (quatre +facons) se voit. """ from __future__ import annotations @@ -351,6 +352,49 @@ def entrees_publiques() -> None: f"une entree publique alteree ({nom}) est vue a la frontiere") +def administration() -> None: + """Ce que l'administration atteint par la gestion, le VPN et le tunnel : la frontiere dit ce + que la face ouvre — et chaque alteration se voit.""" + import copy + couples = C.couples_hebergeur() + if not couples: + print("SAUTE aucun site frere : aucune administration a confronter") + return + for site, loc in couples: + e = C.verifier_administration(site, loc) + verifier(not e, f"administration {loc.nom} : gestion, VPN et tunnel concordent" + + (f" ({e[0]})" if e else "")) + site, loc = couples[0] + face = loc.face_reseau() + ferme = [(h, e) for h, f in face["flux"].items() for e in f["entrees"] if e.get("poste") is False] + verifier(bool(ferme), f"la face porte les entrees publiques fermees au poste ({[(h, e['port']) for h, e in ferme]})") + hote_adm = next(h for h, f in face["flux"].items() + if any(set(e.get("sources") or []) & set(face["administration"]["intrant"]) + and "0.0.0.0/0" not in (e.get("sources") or []) for e in f["entrees"])) + + def ouvrir_au_poste(f): + h, e0 = ferme[0] + for e in f["flux"][h]["entrees"]: + if e.get("port") == e0.get("port"): + e["poste"] = True + + def retirer_sources_admin(f): + for e in f["flux"][hote_adm]["entrees"]: + e["sources"] = [s for s in e.get("sources") or [] if s not in face["administration"]["intrant"]] + + alterations = { + "une entree fermee au poste, ouverte": ouvrir_au_poste, + f"sources d'administration retirees sur {hote_adm}": retirer_sources_admin, + "SSH d'administration retire": lambda f: f["flux"][hote_adm].__setitem__("administration", []), + "tunnel retire": lambda f: f["administration"].__setitem__("tunnel", []), + } + for nom, alterer in alterations.items(): + f = copy.deepcopy(face) + alterer(f) + verifier(bool(C.verifier_administration(site, loc, f)), + f"une administration alteree ({nom}) est vue a la frontiere") + + def main() -> int: with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) @@ -364,6 +408,7 @@ def main() -> int: flux() frontiere() entrees_publiques() + administration() if ECHECS: print(f"\n{len(ECHECS)} echec(s).") return 1