From 91355bcdef419ab4899c8367a98f24e7f70476be Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 9 Aug 2026 21:33:04 -0400 Subject: [PATCH] =?UTF-8?q?frontiere=20:=20etanche=20=E2=80=94=20CONFORME?= =?UTF-8?q?=20sur=2056=20lignes,=20dans=20les=20deux=20sens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'exploitant a retire la derniere regle heritee. `make frontiere-mesurer` rend CONFORME (code 0) : tout ce qui est declare est livre, tout le reste est refuse — y compris collab-01:9980, le seul qui livrait vraiment un HTTP 200 depuis le poste. Et mon instrument avait tort, pas la frontiere. Il comptait 38 ecarts en concluant depuis le client : « connexion etablie => la bordure a relaye ». Faux, verifie A LA DESTINATION : pendant que le poste tenait une connexion « etablie » vers idm-01:389, idm-01 n'en voyait aucune ; collab-01 n'en voyait aucune sur 9980. La frontiere repond a la poignee TCP sans relayer. Le devis raisonne desormais sur la LIVRAISON seule, et le controle ne rend le releve NUL que s'il LIVRE des donnees — qu'il ressorte AMBIGU est attendu ici et le rapport le dit a chaque execution. Cette relaxation rend aussi le sens sortant mesurable : il etait declare NUL en permanence. Nouveau mot-cle `poste: false` dans meta/flux.yml : un service publie n'est pas forcement fait pour un poste de travail. Le 25 entrant de Postfix est un flux serveur a serveur ; la frontiere l'etendait au VLAN d'administration ou le nftables de l'hote le refusait. Deux regles retirees. Le mot-cle vit avec le role qui sait ce que son port veut dire ; le generateur ne connait toujours aucun numero de port. Verifie : frontiere-plan sans ecart (41 regles, 12 routes), flotte 14/14, frontiere-mesurer CONFORME, prouver.py 0. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 30 ++++++++++++++++++++++++ docs/audit/preuve-2026-08-09.md | 2 +- docs/devis-services.md | 20 ++++++++++++---- docs/flux-conception.md | 22 ++++++++++++++++++ docs/frontiere-opnsense.md | 11 ++++++++- roles/serveur_postfix/meta/flux.yml | 1 + scripts/devis_frontiere.py | 36 ++++++++++++++++++++--------- scripts/devis_opnsense.py | 18 ++++++++++++--- scripts/sonde_tcp.py | 10 +++++--- 9 files changed, 126 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddaa8ab..91354db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # CHANGELOG — Set-OPS +## 2026-08-09 — La frontière est étanche : 56 lignes conformes, dans les deux sens + +L'exploitant a retiré la dernière règle héritée, celle qu'il avait lui-même étiquetée +`PAS SUPPOSÉ -> ACTION REQUISE`. **`make frontiere-mesurer` : CONFORME, code 0.** Tout ce +qui est déclaré est livré, tout le reste est refusé — y compris `collab-01:9980`, le seul +qui livrait vraiment un `HTTP/1.1 200 OK` depuis le poste. + +**Et mon instrument avait tort, pas la frontière.** Il comptait 38 écarts. Il concluait +depuis le client : *connexion établie ⇒ la bordure a relayé*. Faux, et vérifié **à la +destination** — pendant que le poste tenait une connexion « établie » vers `idm-01:389`, +`idm-01` n'en voyait aucune ; `collab-01` n'en voyait aucune sur 9980. La frontière répond +elle-même à la poignée TCP, pour toute destination qu'elle route, sans jamais relayer. + +Le devis raisonne désormais sur la **livraison** seule : un port est conforme s'il livre +quand il doit livrer et ne livre rien quand il ne doit pas. Ce que fait la poignée TCP ne +regarde personne. Le contrôle, en conséquence, ne rend le relevé NUL que s'il **livre** des +données — qu'il ressorte AMBIGU est attendu ici, et le rapport le dit en toutes lettres à +chaque exécution. Cette relaxation rend aussi le sens sortant mesurable : il était déclaré +NUL en permanence. + +**Un flux publié n'est pas forcément fait pour un poste de travail.** Nouveau mot-clé +`poste: false` dans `meta/flux.yml` : le `25` entrant de Postfix est un flux serveur à +serveur (les MX distants). La frontière l'étendait au VLAN d'administration, où le +`nftables` de l'hôte le refusait — deux couches qui ne déclarent pas la même politique, et +une politique qu'on ne peut plus lire. Deux règles retirées. Le mot-clé vit avec le rôle, +qui sait ce que son port veut dire ; le générateur ne connaît toujours aucun numéro de port. + +Vérifié : `frontiere-plan` sans écart (41 règles, 12 routes), flotte 14/14, +`frontiere-mesurer` CONFORME, `prouver.py` 0. + ## 2026-08-09 — Un sixième devis : « ce qui n'est pas déclaré est-il refusé ? » `devis_expositions.py` pose la question positive — chaque exposition déclarée répond-elle. diff --git a/docs/audit/preuve-2026-08-09.md b/docs/audit/preuve-2026-08-09.md index 50a627d..8700f6b 100644 --- a/docs/audit/preuve-2026-08-09.md +++ b/docs/audit/preuve-2026-08-09.md @@ -36,7 +36,7 @@ | P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. | | P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (20 sections). | | P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 6 reseau(x), aucune collision avec la plage tenant. | -| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 41 regles, 12 routes, admin=10.0.0.0/24,192.168.254.2/32,192.168.255.2/32. | +| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 39 regles, 12 routes, admin=10.0.0.0/24,192.168.254.2/32,192.168.255.2/32. | | P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 38 groupe(s), 64 regle(s). | | P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 4 integration(s) universelle(s) : aucune lacune, aucune recopie (1 exemption(s) derivee(s) du service rendu). | | P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 0 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. | diff --git a/docs/devis-services.md b/docs/devis-services.md index e0e3364..af49e65 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -80,16 +80,26 @@ frontière. `scripts/sonde_tcp.py` porte deux principes, tirés de trois faux diagnostics : -- **Un contrôle avant tout verdict** — une adresse où personne n'écoute. Si elle répond, - le relevé est déclaré **NUL**. Mieux vaut pas de mesure qu'une mesure fausse. +- **Un contrôle avant tout verdict** — une adresse où personne n'écoute. S'il **livre** des + données, le relevé est déclaré **NUL**. Mieux vaut pas de mesure qu'une mesure fausse. - **Établir n'est pas livrer.** La sonde fait parler le service : bannière, sinon requête HTTP minimale, sinon poignée TLS. Sans réponse, le verdict est **AMBIGU**, jamais « ouvert » — LDAP et PostgreSQL attendent un message qu'on ne fabrique pas ici, et un synproxy se comporte exactement pareil. -Le verdict sépare deux natures d'écart : *refusé attendu, connexion établie* = la bordure a -relayé, c'est le trou ; *livré attendu, rien livré* = la bordure autorise et l'hôte refuse -— rien ne fuit, mais les deux couches ne déclarent pas la même politique. +**Seule la livraison compte, et c'est l'enseignement de la semaine.** Sur ce réseau, la +frontière répond elle-même à la poignée TCP pour toute destination qu'elle route, sans +jamais relayer : le contrôle ressort AMBIGU à chaque exécution, et le rapport le dit. Vérifié +**à la destination** le 2026-08-09 — pendant que le poste tenait une connexion « établie » +vers `idm-01:389`, `idm-01` n'en voyait aucune. Un port est donc conforme s'il livre quand il +doit livrer et ne livre rien quand il ne doit pas ; ce que fait la poignée ne regarde +personne. + +Reste un écart de nature différente, et le devis le nomme comme tel : *livré attendu, rien +livré* = la bordure autorise et l'hôte refuse. Rien ne fuit, mais les deux couches ne +déclarent pas la même politique — et une politique qu'on ne peut plus lire est une politique +qu'on ne tient plus. C'est ce qui a fait naître le mot-clé `poste: false` dans +`meta/flux.yml` (voir [`frontiere-opnsense.md`](frontiere-opnsense.md)). ## Le devis des expositions diff --git a/docs/flux-conception.md b/docs/flux-conception.md index 4597d87..4922528 100644 --- a/docs/flux-conception.md +++ b/docs/flux-conception.md @@ -57,6 +57,28 @@ non activé* tant que le registre n'est pas complet et validé. 3. **remplir** tous les rôles (large transcription du travail zéro-confiance déjà fait) ; 4. **générer** + registre d'audit ; puis **activer** nftables nœud par nœud. +## `poste: false` — un service publié qui ne s'adresse pas à un humain + +Ajouté le 2026-08-09. Ne concerne que les flux `ingress` dont le pair inclut `externe`, +c'est-à-dire les services publiés. + +La frontière étend ces services au **VLAN d'administration** : le poste de l'exploitant y +est, et c'est de là qu'il ouvre ses consoles web ou son client de courriel. Mais tous les +services publiés ne s'adressent pas à un humain — le `25` entrant de Postfix est un flux +*serveur à serveur*, les MX distants. + +| Situation | Exemple | `poste` | +|---|---|---| +| un poste de travail s'y connecte | `serveur_nginx` 443, `serveur_dovecot` 993 | absent (défaut `true`) | +| flux serveur à serveur uniquement | `serveur_postfix` 25 entrant | `false` | + +Sans ce mot-clé, la frontière autorisait `admin → 25` que le `nftables` de l'hôte refusait : +deux couches déclarant deux politiques différentes. Mesuré par `make frontiere-mesurer`, qui +distingue précisément ce cas d'une vraie fuite. + +Le mot-clé vit dans `meta/flux.yml`, avec le rôle **qui sait ce que son port veut dire**. Le +générateur de la frontière, lui, ne connaît aucun numéro de port. + ## `partage:` — décrire une écoute plutôt que l'ouvrir Ajouté le 2026-08-09 avec **P33**, qui refuse que deux rôles co-localisés revendiquent le diff --git a/docs/frontiere-opnsense.md b/docs/frontiere-opnsense.md index 6237b72..e66b7d5 100644 --- a/docs/frontiere-opnsense.md +++ b/docs/frontiere-opnsense.md @@ -108,7 +108,16 @@ défaut-deny de tout effet. Cette règle héritée n'a **aucune API** : sa désa geste d'interface, qui appartient à l'exploitant. Set-OPS déclare donc explicitement ces flux sur l'interface de gestion, afin que la -désactiver ne coupe personne de ses propres consoles web. +désactiver ne coupe personne de ses propres consoles web. *(Fait le 2026-08-09 : la règle +d'usine et une seconde règle héritée ont été retirées. `make frontiere-mesurer` rend +CONFORME sur 56 lignes.)* + +**Sauf quand le flux déclare `poste: false`.** Tous les services publiés ne s'adressent pas +à un humain : le `25` entrant de Postfix est un flux *serveur à serveur* — les MX distants. +L'étendre au VLAN d'administration faisait dire à la frontière ce qu'aucune autre couche +n'honorait : la bordure autorisait, le `nftables` de l'hôte refusait. Le mot-clé vit dans +`meta/flux.yml`, avec le rôle qui sait ce que son port veut dire ; le générateur, lui, ne +connaît toujours aucun numéro de port. Deux omissions sont **annoncées** plutôt que tues : un tenant sans inventaire généré (aucune règle) et un tenant dont `nftables_admin_ssh` est vide (règle SSH omise — l'ouvrir à `any` diff --git a/roles/serveur_postfix/meta/flux.yml b/roles/serveur_postfix/meta/flux.yml index 302f656..837d5ee 100644 --- a/roles/serveur_postfix/meta/flux.yml +++ b/roles/serveur_postfix/meta/flux.yml @@ -6,6 +6,7 @@ flux: protocole: tcp pair: [externe, client_smtp] chiffrement: starttls + poste: false raison: "SMTP entrant : courrier externe (MX) et notifications internes (client_smtp)." - sens: ingress port: 587 diff --git a/scripts/devis_frontiere.py b/scripts/devis_frontiere.py index e68ea14..54a1aad 100644 --- a/scripts/devis_frontiere.py +++ b/scripts/devis_frontiere.py @@ -123,20 +123,33 @@ def analyser(releve: dict) -> tuple[list[str], list[str]]: continue if not bloc.get("valide", False): ecarts.append( - f"Releve {vantage} NUL : le controle {CONTROLE[0]} a repondu — quelque " - "chose sur le chemin repond a la place des destinations. Aucun verdict " - "de ce point de vue n'a de sens tant que ce n'est pas corrige." + f"Releve {vantage} NUL : le controle {CONTROLE[0]} a LIVRE des donnees — " + "quelque chose parle a la place des destinations. Aucun verdict de ce " + "point de vue n'a de sens tant que ce n'est pas corrige." ) continue lignes.append(f" --- {vantage} ---") + ctrl = str((bloc.get("controle") or {}).get("verdict")) + if ctrl == "AMBIGU": + lignes.append( + f" (la poignee TCP est repondue par la frontiere — le controle " + f"{CONTROLE[0]} « s'etablit » aussi. Seule la LIVRAISON compte ici.)") for r in bloc.get("resultats") or []: etiq = str(r.get("etiquette") or "") attendu = etiq.rsplit("attendu=", 1)[-1] if "attendu=" in etiq else "?" obtenu = str(r.get("verdict")) - # AMBIGU vaut « la connexion s'etablit sans rien livrer » : pour un port qui - # doit etre REFUSE, c'est un ecart — la frontiere a relaye. Pour un port qui - # doit etre LIVRE, c'est aussi un ecart : on n'a pas la preuve qu'il sert. - conforme = (attendu == obtenu) + # SEULE LA LIVRAISON COMPTE, et c'est tout l'enseignement de la semaine. + # + # `AMBIGU` vaut « la connexion s'etablit sans rien livrer ». Longtemps compte + # comme un trou — « la bordure a relaye » — c'etait faux : verifie A LA + # DESTINATION le 2026-08-09, ni `idm-01` ni `collab-01` ne voyaient la moindre + # connexion pendant que le poste en tenait une « etablie ». La frontiere + # repond a la poignee sans jamais relayer. Le controle le confirme a chaque + # execution : lui aussi ressort AMBIGU, et personne n'ecoute derriere. + # + # Un port est donc conforme s'il livre quand il doit livrer, et ne livre pas + # quand il ne doit pas. Ce que fait la poignee TCP ne regarde personne. + conforme = (obtenu == "LIVRE") == (attendu == "LIVRE") lignes.append(f" {'ok ' if conforme else 'ECART'} {etiq[:52]:<52} " f"{obtenu:<7} {str(r.get('preuve'))[:40]}") if conforme: @@ -146,11 +159,12 @@ def analyser(releve: dict) -> tuple[list[str], list[str]]: # - LIVRE attendu, rien livre -> la bordure autorise, l'HOTE refuse. # Rien ne fuit ; ce sont les deux couches qui ne disent pas la meme chose. if attendu == "LIVRE": - ecarts.append(f"{etiq} — la frontiere l'autorise mais l'hote le refuse : " - "les deux couches ne declarent pas la meme politique") + ecarts.append(f"{etiq} — rien n'est livre : la frontiere l'autorise mais " + "l'hote le refuse. Les deux couches ne declarent pas la " + "meme politique.") else: - ecarts.append(f"{etiq} — obtenu {obtenu} : la frontiere a relaye " - f"({r.get('preuve')})") + ecarts.append(f"{etiq} — DES DONNEES SONT LIVREES alors que la politique " + f"l'interdit ({r.get('preuve')})") return ecarts, lignes diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index d02f408..e8909bf 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -410,8 +410,20 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: if admin_par_if[etiquette]["wan"]: portees.append((f"SETOPS_ADMIN_{etiquette}_WAN", if_wan)) elif entrant: - # Un service publie est joint DEPUIS INTERNET par le WAN, mais aussi - # depuis le VLAN d'administration — le poste de l'exploitant y est. + # Un service publie est joint DEPUIS INTERNET par le WAN, et le plus + # souvent aussi depuis le VLAN d'administration — le poste de + # l'exploitant y est. + # + # Sauf quand le flux declare `poste: false`. Tous les services publies ne + # s'adressent pas a un humain : le `25` entrant de Postfix est un flux + # SERVEUR A SERVEUR (les MX distants). Le declarer joignable depuis le + # poste faisait dire a la frontiere ce qu'aucune autre couche n'honorait — + # mesure du 2026-08-09 : la bordure l'autorisait, le nftables de l'hote le + # refusait. Deux couches qui ne declarent pas la meme politique, c'est + # une politique qu'on ne peut plus lire. + # + # Le mot-cle vit dans `meta/flux.yml`, avec le role qui sait ce que son + # port veut dire. Le generateur, lui, ne connait aucun numero de port. # Ce second chemin fonctionnait jusqu'ici par la regle d'usine # `Default allow LAN to any`, qui autorise TOUT depuis le LAN : notre # defaut-deny n'y avait donc aucun effet. Mesure du 2026-08-09 : depuis @@ -422,7 +434,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # cette desactivation possible SANS couper l'exploitant de ses propres # services. Sans elle, fermer le LAN fermerait aussi ses consoles web. portees = [("any", if_wan)] - if admin_par_if[etiquette]["gestion"]: + if admin_par_if[etiquette]["gestion"] and fl.get("poste", True): portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion)) else: portees = [("any", if_transit)] diff --git a/scripts/sonde_tcp.py b/scripts/sonde_tcp.py index 361f34e..11db912 100644 --- a/scripts/sonde_tcp.py +++ b/scripts/sonde_tcp.py @@ -103,9 +103,13 @@ def main() -> int: ctrl_hote, ctrl_port = entree["controle"] controle = _sonder(str(ctrl_hote), int(ctrl_port)) - # Le controle DOIT etre bloque. S'il ne l'est pas, quelque chose sur le chemin repond - # a la place des destinations : plus aucun verdict de ce releve n'a de sens. - if controle["verdict"] != "BLOQUE": + # Le controle ne doit rien LIVRER. Qu'il ressorte AMBIGU est tolerable et meme + # attendu ici : la frontiere repond elle-meme a la poignee TCP sans jamais relayer + # (verifie a la destination le 2026-08-09 — `idm-01` et `collab-01` ne voyaient + # aucune connexion pendant que le poste en tenait une « etablie »). Ce que le controle + # garantit, c'est que la LIVRAISON n'est pas simulee. S'il livre des donnees, quelque + # chose parle a la place des destinations et plus aucun verdict n'a de sens. + if controle["verdict"] == "LIVRE": json.dump({"valide": False, "controle": controle, "resultats": []}, sys.stdout) return 0