Set-OPS-Public/scripts/resoudre_flux.py

478 lines
23 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Resolveur des flux reseau Set-OPS : meta/flux.yml -> registre d'audit + apercus nftables.
Agrege les `roles/<role>/meta/flux.yml`, resout les `pair`, et produit :
- docs/registre-flux.md : la MATRICE D'AUDIT (niveau moteur, role->role : port,
sens, chiffrement, raison). Deterministe, sans donnee d'instance.
- des APERCUS nftables par hote (IP resolues, `ip saddr` = moindre privilege,
policy drop) sous <instance>/flux-genere/<hote>.nft. Fichiers GENERES et
inspectables — NON actives (l'activation reste un geste dedie, teste par noeud).
NON destructif : n'active aucun pare-feu. Voir docs/flux-conception.md.
Usage :
python3 scripts/resoudre_flux.py registre # (re)genere docs/registre-flux.md
python3 scripts/resoudre_flux.py nftables # apercus par hote (requiert l'inventaire)
python3 scripts/resoudre_flux.py verifier # valide le schema de tous les flux.yml
"""
from __future__ import annotations
import glob
import os
import sys
from pathlib import Path
import yaml
resolution d'instance : une seule, partagee — au lieu de neuf copies Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? » Neuf modules portaient chacun leur reponse. - 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ; - 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et _frontiere_absente lisaient le symlink au lieu de la variable ; - 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ; - 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme. Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier. LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(), dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve — c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut. Vingt-huit modules y sont branches. CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure : 17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose. P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees : instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de la resolution. make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
from inventory_rules import instance_courante, est_groupe_operationnel
RACINE = Path(__file__).resolve().parents[1]
ROLES = RACINE / "roles"
REGISTRE = RACINE / "docs" / "registre-flux.md"
resolution d'instance : une seule, partagee — au lieu de neuf copies Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? » Neuf modules portaient chacun leur reponse. - 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ; - 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et _frontiere_absente lisaient le symlink au lieu de la variable ; - 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ; - 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme. Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier. LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(), dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve — c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut. Vingt-huit modules y sont branches. CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure : 17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose. P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees : instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de la resolution. make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
INSTANCE = instance_courante()
SENS = {"ingress", "egress"}
# `icmp` n'a pas de port : le champ `port` porte alors le TYPE (ex. `frag-needed`).
# Sans lui, la decouverte de MTU de chemin ne peut pas etre DECLAREE — et une bordure
# en default-deny la casse en silence des que l'overlay descend sous 1500.
PROTO = {"tcp", "udp", "icmp"}
CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n-a"}
# `admin` : les RESEAUX d'administration (intrant `nftables_admin_ssh`), pas des
# hotes. C'est le troisieme chemin d'arrivee, distinct de `flotte` (le tenant) et
# d'`externe` (Internet) — l'exploitant n'est ni l'un ni l'autre.
# `voisins_site` : les autres tenants FEDERES de la meme fabric. Ni `flotte` (mon
# ecosysteme), ni `externe` (l'Internet) — un quatrieme chemin d'arrivee, celui du
# VOISINAGE.
#
# POURQUOI CE MOT EXISTE (2026-08-24). Le cache d'artefacts se CHAINE : chaque ecosysteme
# garde le sien, et celui-ci prend le cache de l'hebergeur comme amont. Debian n'est alors
# telecharge qu'une fois pour tout le site. Sans ce mot, la seule facon de declarer ce flux
# aurait ete `ingress` + `externe` — qui signifie « depuis l'Internet » et aurait PUBLIE le
# cache au monde entier.
#
# Il rend des CIDR (les supernets des voisins), pas des adresses d'hotes : il est donc
# traite a part, comme `admin`.
# `fabric` — LE MATERIEL DE L'HEBERGEUR : hyperviseurs, frontiere, commutateurs.
#
# Il a fallu ce mot le 2026-08-25. Le runner de SITE declarait son API Proxmox en
# `externe`, et « externe » se rend par « tout sauf les espaces prives ». Or les
# hyperviseurs SONT en RFC 1918 : la regle sortante les aurait exclus, tout en ayant
# l'air d'ouvrir le flux. Un flux qui a l'air ouvert et qui ne l'est pas est pire qu'un
# flux ferme — il ne se cherche pas.
#
# Ce n'est pas non plus `flotte` (les machines d'un ecosysteme) ni `voisins_site` (les
# tenants d'a cote) : c'est le SOCLE sur lequel les uns et les autres reposent. Le seul
# a s'y adresser est le runner du site, et c'est tout son objet — materialiser.
MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive", "admin",
insemination : declarer le lien, l'emettre d'un seul cote, et un test rouge L'insemination avait un nom depuis ce matin ; elle n'avait pas de flux. Deux declarations, aux deux bouts, et rien d'autre : serveur_ops_site egress 22/tcp -> serveur_ops_tenant serveur_ops_tenant ingress 22/tcp <- runner_site partage: true ETROIT PAR CONSTRUCTION : il vise le GROUPE `serveur_ops_tenant`, qu'un ecosysteme ne pose que sur une machine. Au socle, il aurait ouvert le SSH du site vers toute la flotte du tenant. L'en-tete disait « ce role n'entre JAMAIS chez un tenant ». Frontiere intenable : `creer-vm` exige `_instance-requise`, et le runner du site avait deja du basculer son symlink `instance` sur OPS-Chezlepro pour materialiser ses VM. Declarer ne cree pas ce pouvoir — ca rend limitable un pouvoir qui s'exercait sans borne. Ce qui reste interdit n'est pas une regle mais un FAIT : il n'a pas la voute du tenant. LA REGLE EST EMISE D'UN SEUL COTE, et pas celui qu'on croit. Le paquet penetre le pare-feu par la patte du SITE, pas par le transit : la regle appartient au cote site du devis. L'emettre aussi depuis l'`ingress` du tenant aurait produit une seconde regle sur la mauvaise interface — jamais evaluee, indiscernable d'une regle utile. La declaration du tenant pose sa regle nftables, et elle seule : ip saddr { 10.0.31.11 } tcp dport 22 accept L'adresse DERIVE du plan du site. Ecrite a la main, elle aurait survecu au prochain deplacement du runner sans bruit — le site a deja deplace ses machines le 08-25. Plan de la frontiere : 2 objets a creer, 0 a retirer, 126 inchanges. RIEN D'APPLIQUE. P41 APPLIQUEE AU PLAN DU SITE : `resoudre_flux` en avait besoin a son tour ; les trois lecteurs demenagent dans `underlay` et `devis_opnsense` delegue. DEUX GARDES ONT TRAVAILLE : P33 a refuse `ingress 22` sur un hote portant deja le sshd du socle (reponse : `partage: true`, comme `serveur_backup`), et le devis a refuse d'emettre vers un alias vide. UN TEST ROUGE DEPUIS TROIS JOURS. `test_adressage_derive` construisait un site avec un `index` — or un SITE n'en a pas depuis add94f2 (08-25), remplace par `bande_basse_de`. Invisible parce que le geste quotidien est `make prouver`, qui ne joue pas les tests. Remis sur le contrat actuel, avec sa contrepartie : sans `bande_basse_de`, aucun chevauchement n'est tolere. make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 13:09:28 -04:00
"voisins_site", "fabric", "runner_site"}
GROUPE_EDGE = "serveur_nginx"
class ErreurFlux(Exception):
pass
def charger_flux() -> dict[str, list[dict]]:
"""{role: [flux, ...]} pour chaque role portant un meta/flux.yml."""
resultat: dict[str, list[dict]] = {}
for chemin in sorted(glob.glob(str(ROLES / "*" / "meta" / "flux.yml"))):
role = Path(chemin).parents[1].name
data = yaml.safe_load(Path(chemin).read_text(encoding="utf-8")) or {}
flux = data.get("flux")
if not isinstance(flux, list):
raise ErreurFlux(f"{chemin} : cle 'flux' (liste) attendue.")
resultat[role] = flux
return resultat
def _pairs(flux: dict) -> list:
pair = flux.get("pair")
return pair if isinstance(pair, list) else [pair]
def valider(flux_par_role: dict[str, list[dict]]) -> tuple[int, int]:
"""Valide le schema + la coherence de matrice. Leve ErreurFlux sinon."""
roles_existants = {p.name for p in ROLES.iterdir() if p.is_dir()}
erreurs: list[str] = []
ingress: dict[str, set] = {}
egress: list[tuple[str, str, object]] = []
total = 0
for role, flux in flux_par_role.items():
for i, fl in enumerate(flux):
total += 1
ref = f"{role}/meta/flux.yml[{i}]"
for cle in ("sens", "port", "protocole", "pair", "chiffrement", "raison"):
if cle not in fl:
erreurs.append(f"{ref} : cle manquante {cle}")
if fl.get("sens") not in SENS:
erreurs.append(f"{ref} : sens invalide {fl.get('sens')!r}")
if fl.get("protocole") not in PROTO:
erreurs.append(f"{ref} : protocole invalide {fl.get('protocole')!r}")
if fl.get("chiffrement") not in CHIFFREMENT:
erreurs.append(f"{ref} : chiffrement invalide {fl.get('chiffrement')!r}")
for p in _pairs(fl):
if p not in MOTS_PAIR and p not in roles_existants and not str(p).startswith(("serveur_", "client_")):
erreurs.append(f"{ref} : pair inconnu {p!r}")
if fl.get("sens") == "ingress":
ingress.setdefault(role, set()).add(fl.get("port"))
elif fl.get("sens") == "egress":
for p in _pairs(fl):
if str(p).startswith(("serveur_", "client_")):
egress.append((role, p, fl.get("port")))
for role, pair, port in egress:
if port not in ingress.get(pair, set()):
erreurs.append(f"matrice : {role} -> {pair}:{port} sans ingress correspondant sur {pair}")
if erreurs:
raise ErreurFlux("\n - ".join(["Flux incoherents :"] + erreurs))
return len(flux_par_role), total
# --- Registre d'audit (niveau moteur, sans instance) ---------------------------
def _cle_port(port) -> tuple:
"""Cle de tri stable pour un port, qu'il soit numerique ou symbolique.
Tous les ports ne sont pas des nombres : `derive` (serveur_nginx) et
`frag-needed` (ICMP, serveur_debian) en sont. Comparer directement
laisserait `int < str` lever une TypeError des que deux ports de types
differents se retrouvent dans le meme sens pour un meme role.
"""
if isinstance(port, list):
port = port[0] if port else 0
try:
return (0, int(port), "")
except (TypeError, ValueError):
return (1, 0, str(port))
def generer_registre(flux_par_role: dict[str, list[dict]]) -> str:
lignes = [
"# Registre des flux réseau — matrice d'audit (GÉNÉRÉ)",
"",
"> Généré par `scripts/resoudre_flux.py` (`make flux`) depuis les `roles/*/meta/flux.yml`.",
"> **Ne pas éditer à la main.** Matrice source→destination pour l'audit de sécurité et le",
"> label de certification. `ingress` = le rôle écoute ; `egress` = le rôle se connecte.",
"",
"| Rôle (propriétaire) | Sens | Port | Proto | Pair | Chiffrement | Raison |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
rangs = {s: i for i, s in enumerate(("ingress", "egress"))}
for role in sorted(flux_par_role):
for fl in sorted(flux_par_role[role], key=lambda f: (rangs.get(f.get("sens"), 9), _cle_port(f.get("port")))):
pair = ", ".join(str(p) for p in _pairs(fl))
port = fl.get("port")
port = ", ".join(str(x) for x in port) if isinstance(port, list) else str(port)
lignes.append(
f"| `{role}` | {fl.get('sens')} | {port} | {fl.get('protocole')} "
f"| {pair} | {fl.get('chiffrement')} | {fl.get('raison', '')} |"
)
# Synthese chiffrement
compte: dict[str, int] = {}
for flux in flux_par_role.values():
for fl in flux:
compte[fl.get("chiffrement")] = compte.get(fl.get("chiffrement"), 0) + 1
lignes += ["", "## Synthèse chiffrement", ""]
for chi in sorted(compte):
lignes.append(f"- **{chi}** : {compte[chi]} flux")
return "\n".join(lignes) + "\n"
# --- Apercus nftables par hote (requiert l'inventaire) -------------------------
def _inventaire() -> Path:
for nom in ("principal", "production"):
p = INSTANCE / "inventories" / nom / "hosts.yml"
if p.exists():
return p
raise ErreurFlux(f"Inventaire introuvable sous {INSTANCE}/inventories/(principal|production).")
def _enfants(data: dict) -> dict:
return data.get("all", {}).get("children", {})
def _hotes_du_groupe(data: dict, groupe: str) -> dict:
return _enfants(data).get(groupe, {}).get("hosts", {}) or {}
def _sources_admin_ssh() -> list[str]:
"""CIDR d'administration SSH toujours autorises (intrant nftables_admin_ssh).
Garde anti-lockout : le contexte d'ou l'on administre (controleur/VPN) doit
rester joignable en SSH quelle que soit la resolution des flux.
"""
dossier = _inventaire().parent / "group_vars" / "all"
for fichier in sorted(dossier.glob("*.yml")):
if "vault" in fichier.name:
continue
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
if isinstance(data, dict) and data.get("nftables_admin_ssh"):
sources = data["nftables_admin_ssh"]
return [str(s) for s in sources] if isinstance(sources, list) else [str(sources)]
return []
def _ip_par_hote(data: dict) -> dict[str, str]:
"""{hote: ansible_host} — l'IP vit dans hotes_actifs ; l'appartenance ailleurs est null."""
table: dict[str, str] = {}
for membres in _enfants(data).values():
for hote, v in (membres.get("hosts") or {}).items():
if isinstance(v, dict) and v.get("ansible_host"):
table[hote] = v["ansible_host"]
return table
def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict[str, str]) -> list[str]:
"""pair -> IP sources concretes (pour une regle ingress). [] = pas de regle inter-noeud."""
noms: set[str] = set()
for p in (pair if isinstance(pair, list) else [pair]):
if p in ("localhost", "externe", "expositions", "derive"):
continue # lo (auto), frontiere OPNsense, ou hors perimetre noeud
insemination : declarer le lien, l'emettre d'un seul cote, et un test rouge L'insemination avait un nom depuis ce matin ; elle n'avait pas de flux. Deux declarations, aux deux bouts, et rien d'autre : serveur_ops_site egress 22/tcp -> serveur_ops_tenant serveur_ops_tenant ingress 22/tcp <- runner_site partage: true ETROIT PAR CONSTRUCTION : il vise le GROUPE `serveur_ops_tenant`, qu'un ecosysteme ne pose que sur une machine. Au socle, il aurait ouvert le SSH du site vers toute la flotte du tenant. L'en-tete disait « ce role n'entre JAMAIS chez un tenant ». Frontiere intenable : `creer-vm` exige `_instance-requise`, et le runner du site avait deja du basculer son symlink `instance` sur OPS-Chezlepro pour materialiser ses VM. Declarer ne cree pas ce pouvoir — ca rend limitable un pouvoir qui s'exercait sans borne. Ce qui reste interdit n'est pas une regle mais un FAIT : il n'a pas la voute du tenant. LA REGLE EST EMISE D'UN SEUL COTE, et pas celui qu'on croit. Le paquet penetre le pare-feu par la patte du SITE, pas par le transit : la regle appartient au cote site du devis. L'emettre aussi depuis l'`ingress` du tenant aurait produit une seconde regle sur la mauvaise interface — jamais evaluee, indiscernable d'une regle utile. La declaration du tenant pose sa regle nftables, et elle seule : ip saddr { 10.0.31.11 } tcp dport 22 accept L'adresse DERIVE du plan du site. Ecrite a la main, elle aurait survecu au prochain deplacement du runner sans bruit — le site a deja deplace ses machines le 08-25. Plan de la frontiere : 2 objets a creer, 0 a retirer, 126 inchanges. RIEN D'APPLIQUE. P41 APPLIQUEE AU PLAN DU SITE : `resoudre_flux` en avait besoin a son tour ; les trois lecteurs demenagent dans `underlay` et `devis_opnsense` delegue. DEUX GARDES ONT TRAVAILLE : P33 a refuse `ingress 22` sur un hote portant deja le sshd du socle (reponse : `partage: true`, comme `serveur_backup`), et le devis a refuse d'emettre vers un alias vide. UN TEST ROUGE DEPUIS TROIS JOURS. `test_adressage_derive` construisait un site avec un `index` — or un SITE n'en a pas depuis add94f2 (08-25), remplace par `bande_basse_de`. Invisible parce que le geste quotidien est `make prouver`, qui ne joue pas les tests. Remis sur le contrat actuel, avec sa contrepartie : sans `bande_basse_de`, aucun chevauchement n'est tolere. make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 13:09:28 -04:00
if p in ("admin", "voisins_site", "runner_site"):
# Aucun des trois n'est un groupe d'hotes DE CET ECOSYSTEME : ce sont des
# sources qui lui sont EXTERIEURES. `admin` vient de l'intrant
# `nftables_admin_ssh` ; `voisins_site` des supernets des autres tenants
# federes ; `runner_site` du plan du SITE. Traites a part, plus bas.
continue
if p == "flotte":
noms |= actifs_noms
else:
groupe = GROUPE_EDGE if p == "edge" else p
noms |= {h for h in _hotes_du_groupe(data, groupe) if h in actifs_noms}
srcs = {ip_par_hote[h] for h in noms if h in ip_par_hote}
demandes = pair if isinstance(pair, list) else [pair]
if "admin" in demandes:
srcs |= set(_sources_admin_ssh())
if "voisins_site" in demandes:
srcs |= set(_supernets_voisins())
insemination : declarer le lien, l'emettre d'un seul cote, et un test rouge L'insemination avait un nom depuis ce matin ; elle n'avait pas de flux. Deux declarations, aux deux bouts, et rien d'autre : serveur_ops_site egress 22/tcp -> serveur_ops_tenant serveur_ops_tenant ingress 22/tcp <- runner_site partage: true ETROIT PAR CONSTRUCTION : il vise le GROUPE `serveur_ops_tenant`, qu'un ecosysteme ne pose que sur une machine. Au socle, il aurait ouvert le SSH du site vers toute la flotte du tenant. L'en-tete disait « ce role n'entre JAMAIS chez un tenant ». Frontiere intenable : `creer-vm` exige `_instance-requise`, et le runner du site avait deja du basculer son symlink `instance` sur OPS-Chezlepro pour materialiser ses VM. Declarer ne cree pas ce pouvoir — ca rend limitable un pouvoir qui s'exercait sans borne. Ce qui reste interdit n'est pas une regle mais un FAIT : il n'a pas la voute du tenant. LA REGLE EST EMISE D'UN SEUL COTE, et pas celui qu'on croit. Le paquet penetre le pare-feu par la patte du SITE, pas par le transit : la regle appartient au cote site du devis. L'emettre aussi depuis l'`ingress` du tenant aurait produit une seconde regle sur la mauvaise interface — jamais evaluee, indiscernable d'une regle utile. La declaration du tenant pose sa regle nftables, et elle seule : ip saddr { 10.0.31.11 } tcp dport 22 accept L'adresse DERIVE du plan du site. Ecrite a la main, elle aurait survecu au prochain deplacement du runner sans bruit — le site a deja deplace ses machines le 08-25. Plan de la frontiere : 2 objets a creer, 0 a retirer, 126 inchanges. RIEN D'APPLIQUE. P41 APPLIQUEE AU PLAN DU SITE : `resoudre_flux` en avait besoin a son tour ; les trois lecteurs demenagent dans `underlay` et `devis_opnsense` delegue. DEUX GARDES ONT TRAVAILLE : P33 a refuse `ingress 22` sur un hote portant deja le sshd du socle (reponse : `partage: true`, comme `serveur_backup`), et le devis a refuse d'emettre vers un alias vide. UN TEST ROUGE DEPUIS TROIS JOURS. `test_adressage_derive` construisait un site avec un `index` — or un SITE n'en a pas depuis add94f2 (08-25), remplace par `bande_basse_de`. Invisible parce que le geste quotidien est `make prouver`, qui ne joue pas les tests. Remis sur le contrat actuel, avec sa contrepartie : sans `bande_basse_de`, aucun chevauchement n'est tolere. make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 13:09:28 -04:00
if "runner_site" in demandes:
srcs |= set(_adresses_runner_site())
return sorted(srcs)
insemination : declarer le lien, l'emettre d'un seul cote, et un test rouge L'insemination avait un nom depuis ce matin ; elle n'avait pas de flux. Deux declarations, aux deux bouts, et rien d'autre : serveur_ops_site egress 22/tcp -> serveur_ops_tenant serveur_ops_tenant ingress 22/tcp <- runner_site partage: true ETROIT PAR CONSTRUCTION : il vise le GROUPE `serveur_ops_tenant`, qu'un ecosysteme ne pose que sur une machine. Au socle, il aurait ouvert le SSH du site vers toute la flotte du tenant. L'en-tete disait « ce role n'entre JAMAIS chez un tenant ». Frontiere intenable : `creer-vm` exige `_instance-requise`, et le runner du site avait deja du basculer son symlink `instance` sur OPS-Chezlepro pour materialiser ses VM. Declarer ne cree pas ce pouvoir — ca rend limitable un pouvoir qui s'exercait sans borne. Ce qui reste interdit n'est pas une regle mais un FAIT : il n'a pas la voute du tenant. LA REGLE EST EMISE D'UN SEUL COTE, et pas celui qu'on croit. Le paquet penetre le pare-feu par la patte du SITE, pas par le transit : la regle appartient au cote site du devis. L'emettre aussi depuis l'`ingress` du tenant aurait produit une seconde regle sur la mauvaise interface — jamais evaluee, indiscernable d'une regle utile. La declaration du tenant pose sa regle nftables, et elle seule : ip saddr { 10.0.31.11 } tcp dport 22 accept L'adresse DERIVE du plan du site. Ecrite a la main, elle aurait survecu au prochain deplacement du runner sans bruit — le site a deja deplace ses machines le 08-25. Plan de la frontiere : 2 objets a creer, 0 a retirer, 126 inchanges. RIEN D'APPLIQUE. P41 APPLIQUEE AU PLAN DU SITE : `resoudre_flux` en avait besoin a son tour ; les trois lecteurs demenagent dans `underlay` et `devis_opnsense` delegue. DEUX GARDES ONT TRAVAILLE : P33 a refuse `ingress 22` sur un hote portant deja le sshd du socle (reponse : `partage: true`, comme `serveur_backup`), et le devis a refuse d'emettre vers un alias vide. UN TEST ROUGE DEPUIS TROIS JOURS. `test_adressage_derive` construisait un site avec un `index` — or un SITE n'en a pas depuis add94f2 (08-25), remplace par `bande_basse_de`. Invisible parce que le geste quotidien est `make prouver`, qui ne joue pas les tests. Remis sur le contrat actuel, avec sa contrepartie : sans `bande_basse_de`, aucun chevauchement n'est tolere. make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 13:09:28 -04:00
def _adresses_runner_site() -> list[str]:
"""L'adresse du runner du SITE — la source du flux d'insemination.
DERIVEE DU PLAN DU SITE, jamais ecrite ici. Le site est le seul a savoir ou vit son
runner, et il l'a deja deplace une fois (decoupage en zones du 2026-08-25). Une
adresse recopiee ici aurait survecu a ce deplacement en silence, et le tenant se
serait retrouve avec une regle qui n'autorise personne — la panne la plus muette de
cette couche.
Rend [] quand le depot de l'hebergeur n'est pas monte : un tenant clone sans lui
degrade proprement, il ne casse pas.
"""
try:
import underlay as underlay_mod
return underlay_mod.adresses_site_portant("serveur_ops_site")
except Exception:
return []
def _supernets_voisins() -> list[str]:
"""Les supernets des AUTRES tenants que CE SITE heberge.
ON REUTILISE `devis_reseau.decouvrir_du_site()`, on n'en ecrit pas une deuxieme
version. C'est la lecon de P41 appliquee ici : deux recensements de tenants finiraient
par diverger, et la divergence se lirait « tout va bien ».
Elle porte aussi la bonne semantique. « Voisin » veut dire *sur ma fabric*, pas
*quelque part dans la federation* : un tenant pose sur un autre site n'est joignable
par aucune regle de cette frontiere-ci, et lui ouvrir un flux serait un chemin qui ne
correspondra jamais a un paquet.
Elle porte enfin la regle du `federe` ABSENT = federe (devis_reseau, ligne 568). Ma
premiere version lisait l'absence comme « non federe » et excluait Chezlepro et
Technolibre en silence — leur nomenclature est anterieure a cette cle.
"""
try:
import devis_reseau
from inventory_rules import supernet_de
tenants = devis_reseau.decouvrir_du_site()
except Exception:
# Sans underlay designe (ex. `make flux` hors site), il n'y a pas de voisinage a
# resoudre. Rendre [] plutot qu'echouer : l'absence de voisins n'est pas une faute.
return []
moi = INSTANCE.resolve().name
return sorted({supernet_de(int(n["index"])) for nom, _p, n in tenants if nom != moi})
# Codes ICMP de destination inatteignable qu'un flux peut nommer dans `port`. L'ICMP n'a
# PAS de port : il a un type et un code. Emettre `icmp dport frag-needed` produit un jeu
# que `nft` refuse — et le service ne demarre plus du tout, donc l'hote perd sa barriere
# au lieu d'en gagner une. Constate le 2026-08-06 sur les 14 hotes a la fois.
CODES_ICMP = {
"frag-needed": "icmp type destination-unreachable icmp code frag-needed",
}
def _selecteur_nft(protocole: str, port) -> str:
"""Selecteur nftables pour un flux : `tcp dport 22`, ou l'equivalent ICMP.
Un `port` non numerique sur de l'ICMP designe un code, jamais un port. Un code
inconnu est refuse ici plutot que de produire un fichier que `nft` rejettera —
l'erreur se lit au moment de la generation, pas trois deploiements plus tard.
"""
p = str(protocole).lower()
if p in ("icmp", "icmpv6"):
cle = str(port).strip()
if cle in CODES_ICMP:
return CODES_ICMP[cle]
raise ErreurFlux(
f"Flux ICMP avec `port: {port}` : l'ICMP n'a pas de port. Nommer un code "
f"connu ({', '.join(sorted(CODES_ICMP))}) ou etendre CODES_ICMP."
)
return f"{p} dport {port}"
def generer_nftables(flux_par_role: dict[str, list[dict]]) -> list[Path]:
data = yaml.safe_load(_inventaire().read_text(encoding="utf-8")) or {}
actifs_noms = set(_hotes_du_groupe(data, "hotes_actifs"))
ip_par_hote = _ip_par_hote(data)
admin_ssh = _sources_admin_ssh()
regles_admin = [
f" ip saddr {{ {cidr} }} tcp dport 22 accept # administration (garde anti-lockout, intrant nftables_admin_ssh)"
for cidr in admin_ssh
]
sortie_dir = _inventaire().parent.parent.parent / "flux-genere"
sortie_dir.mkdir(parents=True, exist_ok=True)
ecrits: list[Path] = []
for hote in sorted(actifs_noms):
groupes = [g for g, membres in _enfants(data).items()
if hote in (membres.get("hosts") or {}) and est_groupe_operationnel(g)]
regles: list[str] = list(regles_admin)
for g in sorted(groupes):
for fl in flux_par_role.get(g, []):
if fl.get("sens") != "ingress":
continue
ports = fl["port"] if isinstance(fl["port"], list) else [fl["port"]]
srcs = _resoudre_sources(data, fl.get("pair"), actifs_noms, ip_par_hote)
saddr = "" if not srcs else "ip saddr { " + ", ".join(srcs) + " } "
local = "localhost" in (fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")])
for port in ports:
if local and not srcs:
continue # lo deja accepte
regles.append(f" {saddr}{_selecteur_nft(fl['protocole'], port)} accept"
f" # {g}: {fl.get('raison','')}")
contenu = _rendre_nft(hote, regles)
chemin = sortie_dir / f"{hote}.nft"
chemin.write_text(contenu, encoding="utf-8")
ecrits.append(chemin)
return ecrits
def _rendre_nft(hote: str, regles: list[str]) -> str:
corps = "\n".join(regles) if regles else " # (aucun flux ingress inter-noeud)"
return (
f"#!/usr/sbin/nft -f\n"
f"# GENERE par scripts/resoudre_flux.py depuis les meta/flux.yml — hote {hote}.\n"
portabilite : le repli nftables survivait a la bascule et annulait tout Le socle pose l'un de DEUX fichiers dans /etc/nftables.conf : le ruleset derive (table setops_flux) s'il existe, sinon un gabarit de repli plat (table setops_filter) qui n'ouvre que le 22. Ce sont des ALTERNATIVES, jamais des couches. Mais le rechargement est `nft -f`, qui AJOUTE sans purger, et le fichier derive retirait setops_flux — jamais setops_filter. Un hote passe du repli au derive gardait donc DEUX chaines input sur le meme hook, toutes deux en policy drop. Le paquet traverse les deux : seule l'intersection de leurs accept passait. Symptome parfaitement trompeur : AC debout, 8443 en ecoute, regle posee et acceptante, ICMP entre les deux VM a 0,15 ms — et step ca bootstrap qui expire. Il a fallu lire le ruleset entier pour voir la seconde table. Le fichier derive retire desormais les DEUX tables (le repli portait deja flush ruleset). Pas de flush ruleset cote derive : choix du depot pour ne pas detruire de tables etrangeres, respecte. Ce qui l'avait rendu possible : `make reconstruire` ne generait JAMAIS les flux. `make flux` en est maintenant la premiere etape. Et `no_log` a masque la cause trois fois dans la journee — dont au terme d'un deploiement de 157 taches. Les taches concernees extraient desormais le verdict a part (ce qui a echoue, quel code, quel message) sans toucher aux en-tetes. Une garde qui protege un secret ne doit pas emporter le diagnostic. Verifie : TLS OK depuis infra-dns-01 vers l'AC, une seule table nftables, 13/14 hotes deployes sans echec, prouver.py 34 OK, ansible-lint production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:24:41 -04:00
f"# Deploye par le role `nftables_baseline` en tant que /etc/nftables.conf.\n\n"
f"# Remplace UNIQUEMENT les tables setops (pas de `flush ruleset` : préserve les\n"
f"# tables etrangeres — DNAT/forward de conteneurs, par exemple).\n"
f"#\n"
f"# `setops_filter` est le gabarit de REPLI, pose quand aucun ruleset resolu\n"
f"# n'existe encore. Les deux fichiers sont des ALTERNATIVES, jamais des couches —\n"
f"# mais `nft -f` AJOUTE sans purger, et le repli survivait donc a la bascule.\n"
f"# Deux chaines `input` sur le meme hook, toutes deux en `policy drop` : le\n"
f"# paquet traverse les DEUX, et seule l'intersection de leurs `accept` passait.\n"
f"# Mesure du 2026-08-10 sur Technolibre : l'AC etait debout, sa regle 8443 posee\n"
f"# et acceptante, l'ICMP passait en 0,15 ms — et le TCP expirait, arrete par le\n"
f"# `policy drop` du repli. Chaque fichier retire donc desormais la table de\n"
f"# l'autre, et la bascule converge dans les deux sens.\n"
f"table inet setops_flux {{}}\n"
portabilite : le repli nftables survivait a la bascule et annulait tout Le socle pose l'un de DEUX fichiers dans /etc/nftables.conf : le ruleset derive (table setops_flux) s'il existe, sinon un gabarit de repli plat (table setops_filter) qui n'ouvre que le 22. Ce sont des ALTERNATIVES, jamais des couches. Mais le rechargement est `nft -f`, qui AJOUTE sans purger, et le fichier derive retirait setops_flux — jamais setops_filter. Un hote passe du repli au derive gardait donc DEUX chaines input sur le meme hook, toutes deux en policy drop. Le paquet traverse les deux : seule l'intersection de leurs accept passait. Symptome parfaitement trompeur : AC debout, 8443 en ecoute, regle posee et acceptante, ICMP entre les deux VM a 0,15 ms — et step ca bootstrap qui expire. Il a fallu lire le ruleset entier pour voir la seconde table. Le fichier derive retire desormais les DEUX tables (le repli portait deja flush ruleset). Pas de flush ruleset cote derive : choix du depot pour ne pas detruire de tables etrangeres, respecte. Ce qui l'avait rendu possible : `make reconstruire` ne generait JAMAIS les flux. `make flux` en est maintenant la premiere etape. Et `no_log` a masque la cause trois fois dans la journee — dont au terme d'un deploiement de 157 taches. Les taches concernees extraient desormais le verdict a part (ce qui a echoue, quel code, quel message) sans toucher aux en-tetes. Une garde qui protege un secret ne doit pas emporter le diagnostic. Verifie : TLS OK depuis infra-dns-01 vers l'AC, une seule table nftables, 13/14 hotes deployes sans echec, prouver.py 34 OK, ansible-lint production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:24:41 -04:00
f"delete table inet setops_flux\n"
f"table inet setops_filter {{}}\n"
f"delete table inet setops_filter\n\n"
f"table inet setops_flux {{\n"
f" chain input {{\n"
f" type filter hook input priority 0; policy drop;\n\n"
f" iif \"lo\" accept\n"
f" ct state established,related accept\n"
f" ct state invalid drop\n"
f" ip protocol icmp accept\n"
f" ip6 nexthdr icmpv6 accept\n\n"
flux : l interne refuse a voix haute, la bordure se tait (P53) Decision de l exploitant : block vers l Internet, reject a l interieur, 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 aucune machine, aucune route, ou une politique. Quand la politique parle, il n en reste qu une. nftables par hote policy drop + reject with icmpx type admin-prohibited pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique) frontiere OPNsense block — INCHANGE, et c est la condition admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote, et y repondre ferait parler cette machine au nom d une destination qui n est pas elle. POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject — rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet. 982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC. LA MESURE A CORRIGE LA MESURE, DEUX FOIS. Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle devrait proteger. Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante, timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux dans un meme echec. Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme. Trois controles negatifs verifies. make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
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"
flux : l interne refuse a voix haute, la bordure se tait (P53) Decision de l exploitant : block vers l Internet, reject a l interieur, 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 aucune machine, aucune route, ou une politique. Quand la politique parle, il n en reste qu une. nftables par hote policy drop + reject with icmpx type admin-prohibited pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique) frontiere OPNsense block — INCHANGE, et c est la condition admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote, et y repondre ferait parler cette machine au nom d une destination qui n est pas elle. POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject — rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet. 982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC. LA MESURE A CORRIGE LA MESURE, DEUX FOIS. Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle devrait proteger. Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante, timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux dans un meme echec. Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme. Trois controles negatifs verifies. make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
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"
f" iifname \"docker0\" accept\n"
f" oifname \"docker0\" accept\n"
f" }}\n"
f" chain output {{ type filter hook output priority 0; policy accept; }}\n"
f"}}\n"
)
def main(argv: list[str]) -> int:
commande = argv[0] if argv else "registre"
try:
flux = charger_flux()
if commande == "verifier":
r, t = valider(flux)
# Chaque flux doit produire un selecteur nftables VALIDE. Le verifier ici, et
# pas seulement a la generation, ferme le cas ou un role declare un flux que
# personne ne porte encore : le devis passerait, et la panne arriverait le
# jour ou un hote prend ce role. `nft -c` local demanderait des privileges
# (netlink) ; construire le selecteur ne coute rien et attrape la meme faute.
for role, liste in sorted(flux.items()):
for fl in liste:
ports = fl["port"] if isinstance(fl["port"], list) else [fl["port"]]
for port in ports:
try:
_selecteur_nft(fl.get("protocole", "tcp"), port)
except ErreurFlux as e:
raise ErreurFlux(f"{role} : {e}") from e
print(f"Flux coherents : {r} rôles, {t} flux, schéma + matrice OK.")
elif commande == "registre":
valider(flux)
REGISTRE.write_text(generer_registre(flux), encoding="utf-8")
print(f"{REGISTRE.relative_to(RACINE)} (re)généré depuis les meta/flux.yml.")
elif commande == "nftables":
valider(flux)
ecrits = generer_nftables(flux)
print(f"{len(ecrits)} aperçu(s) nftables générés (NON activés) :")
for p in ecrits:
print(f" {p}")
else:
print(f"Commande inconnue : {commande} (registre | nftables | verifier)", file=sys.stderr)
return 2
except ErreurFlux as e:
print(f"ERREUR flux :\n{e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))