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:
parent
a546b03c3a
commit
91355bcdef
9 changed files with 126 additions and 24 deletions
30
CHANGELOG.md
30
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.
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue