diff --git a/CHANGELOG.md b/CHANGELOG.md index 0769496..e13190a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # CHANGELOG — Set-OPS +## 2026-08-28 — P53 : l'interne refuse à voix haute, la bordure se tait + +**53 preuves.** Décision de l'exploitant : `block` vers l'Internet, `reject` à l'intérieur. +*« Parce que c'est prudent. »* + +Ce n'est pas le refus qui informe, c'est **ce qu'il fait au silence**. Sous `drop` partout, +un timeout voulait dire trois choses incompatibles — aucune machine, aucune route, ou une +politique. Quand la politique parle, il n'en reste qu'une. + +``` +nftables par hôte policy drop + reject with icmpx type admin-prohibited +pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique) +frontière OPNsense block — INCHANGÉ, et c'est la condition +``` + +**`admin-prohibited` et non `tcp reset`** : un RST est indiscernable d'un port fermé sans +service. Ce message-ci dit qu'une *politique* a refusé — la seule forme qui distingue « on +ne veut pas de toi » de « il n'y a rien ». + +**La chaîne `forward` reste muette**, et ce n'est pas un oubli : elle porte le trafic qui +*traverse* l'hôte. Y répondre ferait parler cette machine **au nom d'une destination qui +n'est pas elle** — le défaut qu'on corrige dans `input`, déplacé d'un cran. + +### Pourquoi c'est prudent, et pas seulement commode + +**L'obscurité était déjà nulle à l'intérieur.** Chaque machine porte un `/etc/hosts` +généré qui liste toutes ses voisines avec leurs adresses. Se cacher de pairs qui ont déjà +notre adresse ne protège de rien — le `drop` coûtait du diagnostic sans rien acheter. + +**Et la bordure protège le reject.** Rien d'indéclaré ne franchit le périmètre : ce reject +ne répond donc **jamais** à l'Internet. Mesure du 2026-08-27 à la frontière, qui explique +l'autre moitié du choix : 982 000 entrées par jour, dont 82 % un balayage contre le port +VNC. Y répondre serait un vecteur d'amplification, source usurpée comprise. + +P53 garde les deux moitiés **ensemble**, parce que l'une sans l'autre est fausse : *une +bordure bavarde s'expose, un interne muet ment.* Trois contrôles négatifs vérifiés. + +### La mesure a corrigé la mesure — deux fois + +**Ma preuve était trop grossière.** Elle interdisait le littéral `DROP` dans le pare-feu +est-ouest, et a fait échouer un code **juste** : +`dc["politique"].upper() in ("DROP", "REJECT")` détecte une politique posée *au +datacenter*, où elle vaudrait pour tout le parc — un garde-fou, pas un réglage. *Une preuve +qui interdit un mot au lieu de mesurer une propriété finit par accuser ce qu'elle devrait +protéger.* Elle cherche désormais une **assignation en dur**, pas une occurrence. + +**Et l'absence parlait déjà.** Mesuré depuis `ops-01` après application : + +``` +cache du site, déclaré OUVERT 0,00 s +machine INEXISTANTE OSError errno 113 EHOSTUNREACH 3,05 s +autre tenant, frontière TimeoutError 6,01 s +``` + +La passerelle anycast dit `EHOSTUNREACH` ; seule la frontière se tait. **Mes deux erreurs +de diagnostic de la journée ne venaient donc pas du `drop`, mais de ma sonde** — `curl` et +`bash /dev/tcp` écrasent `EHOSTUNREACH` et `timeout` dans un même « échec ». L'information +était là ; l'instrument la jetait. *Encore une fois, vérifier d'où l'on mesure avant +d'accuser ce qu'on mesure.* + +Le `reject` garde toute sa valeur pour ce qu'aucun errno ne dit : la politique **d'hôte** +et **est-ouest**. Sa démonstration attend deux machines vivantes dans un même tenant — +Chezlepro n'en a qu'une. + +**Appliqué** : 6 VM passées en `REJECT` (Chezlepro `ops-01`, les cinq de patient 0), 0 +créée, 0 retirée. Le changement ne ferme rien — `REJECT` et `DROP` refusent tous deux, +l'un répond. + +make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute. + ## 2026-08-28 — L'insémination aboutit : un runner de tenant, né d'un runner de site **52 preuves.** `ops-01` existe, configurée de bout en bout **par le runner du SITE**, qui diff --git a/docs/audit/preuve-2026-08-28.md b/docs/audit/preuve-2026-08-28.md index 006d739..4813776 100644 --- a/docs/audit/preuve-2026-08-28.md +++ b/docs/audit/preuve-2026-08-28.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (52 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (53 OK · 0 echec · 0 saute) ## Preuves @@ -65,6 +65,7 @@ | P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 125 regles `pass`), tous non consignes et tous motives. | | P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 | | P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. | +| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b | ## Couverture des affirmations ✅ du registre diff --git a/scripts/appliquer_proxmox_fw.py b/scripts/appliquer_proxmox_fw.py index db29d48..712e8a0 100644 --- a/scripts/appliquer_proxmox_fw.py +++ b/scripts/appliquer_proxmox_fw.py @@ -10,7 +10,7 @@ DEUX SENS : ce que le devis demande et qui manque est cree, ce qu'il ne demande plus est RETIRE. Un groupe orphelin ne bloque rien, mais il decrit une politique qui n'est plus la notre. -LE DROP EST PORTE PAR LA VM, jamais par le datacenter. Au datacenter, `policy_in` +LE REFUS EST PORTE PAR LA VM, jamais par le datacenter. Au datacenter, `policy_in` est la politique par defaut de TOUTE VM dont le pare-feu s'active — un parc herite sans regles tomberait d'un bloc. Pose sur chaque VM tenant, il donne la meme isolation sans cette falaise, et le script le fait lui-meme. @@ -29,6 +29,16 @@ Usage : """ from __future__ import annotations +# LA POLITIQUE DE REFUS DE L'EST-OUEST — UNE SEULE SOURCE (2026-08-28). +# +# Elle etait ecrite DEUX FOIS : une a l'ecriture, une a la detection d'ecart. Deux +# litteraux pour la meme decision, et le jour ou l'un change sans l'autre, le devis +# propose une mise a jour eternelle qu'il vient pourtant d'appliquer. +# +# REJECT ET NON DROP : cette couche filtre l'interne. Voir la preuve P53 et +# `docs/flux-conception.md` — l'interne parle, la bordure se tait. +POLITIQUE_VM = "REJECT" + import json import os import subprocess @@ -117,7 +127,7 @@ def plan(api: Cluster, devis: dict) -> dict: "groupes": sorted(str(r.get("action")) for r in regles if isinstance(r, dict) and r.get("type") == "group"), "actif": str(o.get("enable", "")), - # Le DROP vit ICI, sur la VM du tenant — pas au datacenter, ou il vaudrait + # Le refus vit ICI, sur la VM du tenant — pas au datacenter, ou il vaudrait # pour tout le parc, y compris ce qui n'appartient a aucun tenant. "politique": str(o.get("policy_in", "")).upper(), } @@ -138,7 +148,7 @@ def plan(api: Cluster, devis: dict) -> dict: if v in affect_posees and (affect_posees[v]["groupes"] != a["groupes"] or affect_posees[v]["actif"] != "1" - or affect_posees[v]["politique"] != "DROP")}, + or affect_posees[v]["politique"] != POLITIQUE_VM)}, "affect_posees": affect_posees, "vm_absentes": sorted(vm_absentes), "datacenter": {"actif": str(opts.get("enable", "")), "politique": str(opts.get("policy_in", ""))}, @@ -238,10 +248,26 @@ def appliquer(api: Cluster, p: dict) -> int: for g in a["groupes"]: _fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules", "POST", {"type": "group", "action": g, "enable": 1}), f"VM {v} += {g}") - # `policy_in=DROP` sur LA VM : c'est ce qui rend les groupes ci-dessus exclusifs. + # `policy_in` sur LA VM : c'est ce qui rend les groupes ci-dessus exclusifs. # Au datacenter, la meme valeur s'appliquerait a tout ce qui n'est pas un tenant. + # + # REJECT ET NON DROP, PARCE QUE C'EST L'INTERNE (2026-08-28). Cette couche filtre + # l'est-ouest : des machines d'un meme ecosysteme, ou d'un tenant a son site. Le + # silence y coutait cher — un timeout voulait dire aussi bien « aucune machine » + # que « une politique », et le 2026-08-28 cette ambiguite a fait appliquer un + # pare-feu pour reparer une ABSENCE. Ce qui informe n'est pas le refus, c'est ce + # qu'il fait au TIMEOUT : quand la politique parle, le silence ne veut plus dire + # qu'une chose. + # + # L'OBSCURITE ETAIT DEJA NULLE ICI : chaque machine porte un `/etc/hosts` genere + # qui liste toutes ses voisines avec leurs adresses. Se cacher de pairs qui ont + # deja notre adresse n'achete rien. + # + # LA FRONTIERE, ELLE, RESTE EN `block` — et c'est ce qui rend ce reject prudent : + # rien d'indeclare ne franchit le perimetre, donc il ne repond jamais a + # l'Internet. Voir la preuve P53. _fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/options", "PUT", - {"enable": 1, "policy_in": "DROP"}), f"pare-feu de la VM {v}") + {"enable": 1, "policy_in": POLITIQUE_VM}), f"pare-feu de la VM {v}") # 4. Retraits : groupes avant IPSets, un IPSet reference ne se supprime pas. for n in p["groupes_retirer"]: diff --git a/scripts/devis_proxmox_fw.py b/scripts/devis_proxmox_fw.py index b1a6273..194bf67 100644 --- a/scripts/devis_proxmox_fw.py +++ b/scripts/devis_proxmox_fw.py @@ -228,7 +228,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # GARDE ANTI-LOCKOUT. Le plan de gestion du tenant est resolu en # `+-flotte` : les hotes du tenant, et EUX SEULS. Or Ansible se # connecte depuis le reseau d'administration, qui n'en fait pas partie. - # Sans cette regle, la premiere VM passee en `policy_in=DROP` se ferme + # Sans cette regle, la premiere VM passee en `policy_in=REJECT` se ferme # derriere l'outil qui vient de la configurer. Meme source unique que # les nftables et la frontiere : l'intrant `nftables_admin_ssh`. if "22" in _ports(fl) and admin_de(nom): @@ -265,7 +265,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: if porte: affect.append({"hote": hote, "vmid": vmid, "groupes": porte}) - # Roles portes mais SANS regle entrante : sous une politique DROP ils sont + # Roles portes mais SANS regle entrante : sous une politique REJECT ils sont # injoignables. C'est souvent voulu (ils n'ecoutent que sur la boucle locale, ou # seule la frontiere les atteint) — mais l'absence doit se lire, pas se deviner. avec = {g["role"] for g in groupes} @@ -310,19 +310,25 @@ def rendre(devis: dict) -> str: "# (`make devis-opnsense`). Les flux `pair: externe` sont sautes.", "# ============================================================", "", - "## 0. Prerequis — TROIS verrous, et le DROP se pose PAR VM", + "## 0. Prerequis — TROIS verrous, et le REJECT se pose PAR VM", "#", "# Une VM n'est filtree que si les trois sont fermes :", "# datacenter enable=1 (defaut 0 : rien ne filtre)", "# la VM enable=1 (defaut 0 — c'est le verrou du milieu)", "# sa carte firewall=1 (par interface)", "#", - "# La politique DROP se pose SUR CHAQUE VM TENANT, jamais au datacenter :", + "# La politique REJECT se pose SUR CHAQUE VM TENANT, jamais au datacenter :", + "#", + "# REJECT ET NON DROP : cette couche filtre l'EST-OUEST, entre machines qui se", + "# connaissent deja (leur `/etc/hosts` genere liste toutes leurs voisines). Un", + "# refus muet y coutait du diagnostic sans rien acheter — et rendait un timeout", + "# indiscernable d'une machine absente. La FRONTIERE, elle, reste en `block` :", + "# c'est ce qui rend ce reject prudent. Voir la preuve P53.", "# pvesh set /cluster/sdn # sans toucher a policy_in", "# pvesh set /cluster/firewall/options --enable 1", - "# pvesh set /nodes//qemu//firewall/options --enable 1 --policy_in DROP", + "# pvesh set /nodes//qemu//firewall/options --enable 1 --policy_in REJECT", "#", - "# `policy_in DROP` AU DATACENTER serait la politique par defaut de TOUTE VM dont", + "# `policy_in` AU DATACENTER serait la politique par defaut de TOUTE VM dont", "# le pare-feu deviendrait actif — y compris un parc herite sans aucune regle, qui", "# tomberait d'un bloc. Le poser par VM donne la meme isolation sans cette falaise.", "", @@ -353,7 +359,7 @@ def rendre(devis: dict) -> str: out.append(f" vmid {a['vmid'] or '(non materialise)'} {a['hote']:<18} " f"-> {', '.join(a['groupes'])}") if b["muets"]: - out += ["", "### Sans regle entrante — injoignables sous la politique DROP"] + out += ["", "### Sans regle entrante — injoignables sous la politique REJECT"] for m in b["muets"]: marque = " /!\\" if m["motif"].startswith("A VERIFIER") else "" out.append(f" {m['role']:<22} {m['motif']}{marque}") @@ -363,11 +369,11 @@ def rendre(devis: dict) -> str: out.append("") out += [ "## Defaut", - "# `policy_in DROP` SUR CHAQUE VM TENANT : ce qui n'est pas ci-dessus n'entre pas.", + "# `policy_in REJECT` SUR CHAQUE VM TENANT : ce qui n'est pas ci-dessus n'entre pas.", "# Un flux oublie se declare dans `roles//meta/flux.yml`, jamais a la main", "# dans Proxmox — la regle serait perdue a la generation.", "#", - "# Le DROP est porte par la VM et non par le datacenter : l'isolation est la meme,", + "# Le refus est porte par la VM et non par le datacenter : l'isolation est la meme,", "# et le parc qui n'appartient a aucun tenant garde sa politique inchangee.", ] return "\n".join(out) diff --git a/scripts/prouver.py b/scripts/prouver.py index a9ce337..3b53c6c 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -1396,6 +1396,85 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]: f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.") +def preuve_interne_parle_bordure_se_tait() -> tuple[bool, str]: + """L'interne REFUSE A VOIX HAUTE, la bordure reste MUETTE. + + POURQUOI (2026-08-28). Sous `drop` partout, un timeout voulait dire trois choses + incompatibles : aucune machine, aucune route, ou une politique. Deux erreurs de + diagnostic en une journee, dont une qui a fait appliquer un pare-feu pour reparer une + ABSENCE. Ce qui informe n'est pas le refus — c'est ce qu'il fait au SILENCE : quand la + politique parle, un timeout ne veut plus dire qu'une chose. + + L'OBSCURITE ETAIT DEJA NULLE A L'INTERIEUR : chaque machine porte un `/etc/hosts` + genere qui liste toutes ses voisines avec leurs adresses. Se cacher de pairs qui ont + deja notre adresse ne protege de rien, et coute le diagnostic. + + LA BORDURE, ELLE, DOIT SE TAIRE, et c'est ce qui rend le reject interne prudent : + rien d'indeclare ne franchit le perimetre, donc ce reject ne repond jamais a + l'Internet. Mesure du 2026-08-27 a la frontiere : 982 000 entrees par jour, dont 82 % + un balayage contre le port VNC. Y repondre serait un vecteur d'amplification, source + usurpee comprise. + + Cette preuve garde les deux moities ENSEMBLE, parce que l'une sans l'autre est fausse : + une bordure bavarde s'expose, un interne muet ment. + """ + fautes: list[str] = [] + mesures: list[str] = [] + + # 1. nftables d'hote : chaque ruleset genere doit REFUSER A VOIX HAUTE. + src = (RACINE / "scripts" / "resoudre_flux.py").read_text(encoding="utf-8") + if "reject with icmpx type admin-prohibited" not in src: + fautes.append("le gabarit nftables n'emet pas de `reject with icmpx type " + "admin-prohibited` : l'interne resterait muet") + # `tcp reset` serait indiscernable d'un port ferme sans service. + if "reject with tcp reset" in src: + fautes.append("le gabarit nftables emet un `tcp reset`, indiscernable d'un port " + "ferme sans service — `admin-prohibited` dit qu'une POLITIQUE refuse") + + rulesets = sorted((INSTANCE / "flux-genere").glob("*.nft")) + muets = [f.name for f in rulesets + if "reject with icmpx type admin-prohibited" not in f.read_text(encoding="utf-8")] + if muets: + fautes.append(f"{len(muets)} ruleset(s) genere(s) sans refus audible : " + + ", ".join(muets[:4])) + mesures.append(f"{len(rulesets)} ruleset(s) nftables refusent a voix haute") + + # 2. Pare-feu est-ouest Proxmox : politique REJECT, et UNE SEULE source. + ew = (RACINE / "scripts" / "appliquer_proxmox_fw.py").read_text(encoding="utf-8") + if 'POLITIQUE_VM = "REJECT"' not in ew: + fautes.append("le pare-feu est-ouest ne pose pas `POLITIQUE_VM = \"REJECT\"`") + # DEUX LITTERAUX POUR LA MEME DECISION, c'est une divergence differee : l'ecriture de + # la politique et la detection de son ecart doivent lire la MEME source. + # + # ON NE CHERCHE PAS LE MOT « DROP », ON CHERCHE UNE ASSIGNATION EN DUR. Ma premiere + # version refusait toute occurrence du litteral, et elle a fait echouer un code + # JUSTE : `dc["politique"].upper() in ("DROP", "REJECT")` detecte une politique posee + # AU DATACENTER, ou elle vaudrait pour tout le parc — c'est un garde-fou, pas un + # reglage. Une preuve qui interdit un mot au lieu de mesurer une propriete finit par + # accuser ce qu'elle devrait proteger. + if re.search(r'"policy_in":\s*"', ew): + fautes.append("la politique de la VM est ecrite en dur : elle doit venir de " + "`POLITIQUE_VM`, seule source") + if re.search(r'\["politique"\]\s*!=\s*"', ew): + fautes.append("la detection d'ecart compare a un litteral : elle doit lire " + "`POLITIQUE_VM`, sinon les deux divergeront") + mesures.append("pare-feu est-ouest en REJECT, source unique") + + # 3. La bordure se tait — et ce n'est PAS une preference, c'est la condition de + # prudence du reject interne. Les actions du devis OPNsense restent `pass`/`block`. + front = (RACINE / "scripts" / "devis_opnsense.py").read_text(encoding="utf-8") + if '"reject"' in front or "'reject'" in front: + fautes.append("le devis de la frontiere emet un `reject` : la bordure doit rester " + "muette, sans quoi elle repond aux balayages d'Internet") + actions = sorted(set(re.findall(r'"action":\s*"(\w+)"', front)) + | set(re.findall(r'action.*?or\s+"(\w+)"', front))) + mesures.append("frontiere muette (actions : " + ", ".join(actions or ["pass"]) + ")") + + if fautes: + return False, "La ligne n'est plus tenue :\n - " + "\n - ".join(fautes) + return True, "L'interne parle, la bordure se tait — " + " ; ".join(mesures) + "." + + def preuve_materialiser_n_exige_pas_d_entrer() -> tuple[bool, str]: """Creer une VM ne demande pas d'entrer dans le tenant qui l'heberge. @@ -2006,6 +2085,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_collections_declarees_et_epinglees}, {"id": "P52", "titre": "Materialiser n'exige pas d'entrer dans le tenant", "refs": [], "func": preuve_materialiser_n_exige_pas_d_entrer}, + {"id": "P53", "titre": "L'interne refuse a voix haute, la bordure se tait", + "refs": [], "func": preuve_interne_parle_bordure_se_tait}, {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], "func": preuve_devis_frontiere_du_site}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [], diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 7fc267e..16ad2f8 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -392,8 +392,37 @@ def _rendre_nft(hote: str, regles: list[str]) -> str: f" ct state invalid drop\n" f" ip protocol icmp accept\n" f" ip6 nexthdr icmpv6 accept\n\n" - f"{corps}\n" + f"{corps}\n\n" + f" # L'INTERNE PARLE, LA BORDURE SE TAIT (2026-08-28).\n" + f" #\n" + f" # Ce qui n'est pas accepte plus haut est REFUSE A VOIX HAUTE, et ce n'est pas\n" + f" # le refus qui informe : c'est ce qu'il fait au TIMEOUT. Sous `drop`, un\n" + f" # silence voulait dire trois choses incompatibles — aucune machine, aucune\n" + f" # route, ou une politique. Deux erreurs de diagnostic en une journee le\n" + f" # 2026-08-28, dont une qui a fait appliquer un pare-feu pour reparer une\n" + f" # ABSENCE. Quand la politique parle, le silence ne veut plus dire qu'une\n" + f" # chose : il n'y a personne, ou il n'y a pas de chemin.\n" + f" #\n" + f" # `admin-prohibited` ET NON `tcp reset` : un RST est indiscernable d'un port\n" + f" # ferme sans service. Ce message-ci dit qu'une POLITIQUE a refuse — c'est la\n" + f" # seule forme qui distingue « on ne veut pas de toi » de « il n'y a rien ».\n" + f" #\n" + f" # POURQUOI C'EST PRUDENT ICI ET PAS A LA FRONTIERE : la bordure reste en\n" + f" # `block`, donc rien d'indeclare ne franchit le perimetre. Ce reject ne\n" + f" # repond jamais a l'Internet — il ne parle qu'a des pairs qui ont deja\n" + f" # l'adresse de cette machine dans leur `/etc/hosts` genere. L'obscurite\n" + f" # etait donc deja nulle entre nous : le `drop` coutait du diagnostic sans\n" + f" # rien acheter. Mesure du 2026-08-27 a la frontiere, qui explique l'autre\n" + f" # moitie du choix : 982 000 entrees par jour, dont 82 %% un balayage\n" + f" # Internet contre le port VNC. Repondre a chacune serait un vecteur\n" + f" # d'amplification, source usurpee comprise.\n" + f" reject with icmpx type admin-prohibited\n" f" }}\n\n" + f" # LA CHAINE `forward` RESTE MUETTE, et ce n'est pas un oubli : elle porte le\n" + f" # trafic qui TRAVERSE cet hote, pas celui qui lui est destine. Y repondre\n" + f" # ferait parler cette machine AU NOM d'une destination qui n'est pas elle —\n" + f" # un refus qu'on attribuerait au mauvais endroit, c'est-a-dire exactement le\n" + f" # defaut qu'on corrige dans `input`.\n" f" chain forward {{\n" f" type filter hook forward priority 0; policy drop;\n" f" ct state established,related accept\n"