frontiere : etanche — CONFORME sur 56 lignes, dans les deux sens

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-09 21:33:04 -04:00
parent a546b03c3a
commit 91355bcdef
9 changed files with 126 additions and 24 deletions

View file

@ -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.

View file

@ -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. |

View file

@ -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

View file

@ -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

View file

@ -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`

View file

@ -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

View file

@ -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

View file

@ -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)]

View file

@ -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