Set-OPS-Public/scripts/resoudre_flux.py
Daniel Allaire 4e12c3a802
Some checks are pending
verifier / verifier (push) Waiting to run
supervision du site, et la panne qui dormait dans un mot
Le site a son temoin : site-mon-01 (VLAN 36) porte PostgreSQL, Icinga et un
relais. Le depot lui rapporte, et son premier verdict fut un vrai defaut —
site-mon-01 n avait jamais depose son propre etat. Les trois sont au vert.

serveur_backup_verification_locale repasse a true sur le depot : le drapeau ne
dit plus  on renonce  mais  verifie ce que tu peux ouvrir . La liste des noeuds
attendus derive de l inventaire ou tourne le role, donc du site seul.

serveur_postfix gagne un mode relais : un site n heberge aucune boite, il
expedie. Directement par sa frontiere — emprunter le MTA d un locataire ferait
dependre l hebergeur d un ecosysteme qu il peut outvivre.

LA PANNE DE FOND : le modele de routes OPNsense lit enabled ; on lui envoyait
disabled=0, un champ ignore. enabled restait a son defaut, ETEINT. Chaque route
creee par Set-OPS depuis l origine l etait desactivee — invisible, parce qu une
route eteinte EXISTE dans le modele et compte  posee . Le rechargement lance
pour activer la nouvelle patte a fait reprendre au noyau sa table depuis le
modele : 14 routes sur 15 disparues, 11 machines de Chezlepro injoignables le
lendemain de sa reconstruction, et le devis toujours vert.

Trois corrections empilees : la cause (enabled), la cecite (le plan lit la table
du NOYAU et signale absent ou eteint), l inaction (la reconfiguration ne se
declenchait que sur un changement du modele).

Aussi : un role du site peut enfin en appeler un autre a travers la frontiere —
le devis traitait tout pair nomme comme  l exterieur . Et nftables_admin_ssh se
DERIVE de la carte : recopie a la main, il retardait d une zone, et m a
verrouille dehors de la machine que je venais de creer.

Reste ouvert : le destinataire des alertes est encore root@localhost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 17:34:01 -04:00

673 lines
33 KiB
Python

#!/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
from inventory_rules import instance_courante, est_groupe_operationnel
RACINE = Path(__file__).resolve().parents[1]
ROLES = RACINE / "roles"
REGISTRE = RACINE / "docs" / "registre-flux.md"
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",
"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],
site: bool = False) -> 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
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_site() if site else _sources_admin_ssh())
if "voisins_site" in demandes:
# DU COTE DU SITE, « VOISIN » N'EXCLUT PERSONNE (mesure du 2026-09-02).
#
# `_supernets_voisins()` retire l'instance montee : c'est juste chez un TENANT,
# ou l'on declare des flux vers ses voisins et ou nul n'est son propre voisin.
# Applique au SITE, ce filtre retire le locataire qu'on pilote au moment ou l'on
# genere — c'est-a-dire, presque toujours, celui a qui l'on tient le plus.
#
# CE QUE CA AURAIT COUTE : le jeu de regles produit pour `site-dns-01` autorisait
# 10.23.0.0/16 et 10.29.0.0/16 sur le port 53, et PAS 10.17.0.0/16. Chezlepro
# resout encore sur le resolveur du site : l'appliquer aurait prive de DNS les
# quinze machines qu'on venait de reconstruire. Le defaut a ete vu en LISANT le
# fichier avant de le poser, pas en le posant.
#
# C'est le meme piege que `site_inventaire` documente pour les ACL du resolveur,
# et il s'est retendu ici parce que la regle est portee par la fonction, pas par
# le lieu qui l'appelle. On prend donc la decouverte SANS son filtre : le site
# sert tous ceux qu'il heberge, y compris l'instance montee.
if site:
try:
import devis_reseau
from inventory_rules import supernet_de
srcs |= {supernet_de(int(n["index"]))
for _nom, _p, n in devis_reseau.decouvrir_du_site()
if n.get("index") is not None}
except Exception:
pass
else:
srcs |= set(_supernets_voisins())
if "runner_site" in demandes:
srcs |= set(_adresses_runner_site())
return sorted(srcs)
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",
}
# LES PORTS QUI NE SONT PAS DES NOMBRES, ET QUI SONT LEGITIMES.
#
# `derive` dit « ce port depend du deploiement » : Forgejo ecoute 3000 derriere un edge et
# 443 quand il sert son propre TLS, PowerDNS 53 seul sur son hote et 5300 en loopback
# derriere le resolveur. Seul le PLAN sait lequel — le role, lui, ne peut que le nommer.
#
# Ce mot est donc VALIDE quand on verifie un role, et INTERDIT quand on emet un fichier :
# ce sont deux questions differentes, et les confondre coute des deux cotes. Ecrit tel
# quel dans un ruleset, `nft` refuse TOUT le fichier ; refuse a la validation, un role
# parfaitement correct devient une faute.
PORTS_SYMBOLIQUES = {"derive"}
def _selecteur_nft(protocole: str, port, resolu: bool = True) -> 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."
)
# UN PORT SYMBOLIQUE NE DOIT JAMAIS ATTEINDRE LE FICHIER (2026-08-30).
#
# `port: derive` dit « ce port depend du deploiement ». Il etait ecrit TEL QUEL dans
# le ruleset, et `nft` le refusait a l'application — donc trois deploiements plus
# tard, sur la machine, avec un message qui parle de `ai_socktype` :
#
# /etc/nftables.conf:36: Error: Could not resolve service: Servname not supported
# ip saddr { ... } udp dport derive accept # serveur_powerdns
#
# L'appelant le resout depuis le plan AVANT d'arriver ici. S'il n'y arrive pas, il
# n'emet pas de regle — comme le devis OPNsense le fait depuis le 2026-08-25. Cette
# garde est le dernier filet : elle transforme un fichier casse en erreur de
# GENERATION, lisible la ou on peut encore la corriger.
mot = str(port).strip()
if not mot.isdigit():
if mot in PORTS_SYMBOLIQUES and not resolu:
# Validation d'un role, hors instance : le mot est declare, le plan le
# resoudra. On rend un selecteur de forme, jamais ecrit dans un fichier.
return f"{p} dport <{mot}>"
raise ErreurFlux(
f"Flux {p} avec `port: {port}` : port non numerique"
+ (" et non resolu par le plan" if mot in PORTS_SYMBOLIQUES
else f" et inconnu (attendus : un nombre, ou {', '.join(sorted(PORTS_SYMBOLIQUES))})")
+ ". Le declarer au plan (`applications.yml`, cle `port`) ou ne pas emettre "
"la regle : ecrit tel quel, `nft` refuse tout le fichier."
)
return f"{p} dport {port}"
def _ports_du_plan() -> dict[str, str]:
"""Port declare au plan pour chaque groupe — ce que `port: derive` vaut ici.
MEME SOURCE QUE LE DEVIS DE LA FRONTIERE (`devis_opnsense._ports_du_plan_site`), pour
le plan du TENANT au lieu de celui du site. Un service dit `derive` quand son port
depend du deploiement : Forgejo ecoute 3000 derriere un edge et 443 quand il sert son
propre TLS. Seul le plan sait lequel.
"""
try:
from inventory_rules import plan_de
f = Path(plan_de()) / "applications.yml"
apps = (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("applications") or {}
return {str(a["groupe"]): str(a["port"]) for a in apps.values()
if a.get("groupe") and a.get("port")}
except Exception:
return {}
def _inventaire_site() -> dict:
"""L'inventaire du SITE, rendu dans la forme qu'attendent les fonctions ci-dessus.
Un site n'a pas de `hosts.yml` : son inventaire est un SCRIPT (`site_inventaire.py`),
parce que sa declaration EST deja sa forme finale — rien n'y est derive d'un index.
C'est voulu, et documente la-bas.
La consequence, elle, ne l'etait pas : tout ce qui lit l'inventaire en YAML ignorait
purement et simplement les machines du site. `generer_nftables` n'ecrivait donc
aucune regle pour elles — et comme le pare-feu n'y etait pas non plus applique,
personne ne pouvait s'en apercevoir. Deux absences qui se cachaient l'une l'autre.
On traduit ici la forme dynamique (`{groupe: {hosts: [...]}}` + `_meta.hostvars`)
vers la forme statique (`all.children.<groupe>.hosts.<hote>`), plutot que de dupliquer
la logique de generation. Une seule facon de produire des regles pour tout le monde.
"""
import site_inventaire
src = site_inventaire.inventaire()
hostvars = (src.get("_meta") or {}).get("hostvars") or {}
enfants: dict = {}
for groupe, contenu in src.items():
if groupe == "_meta" or not isinstance(contenu, dict):
continue
enfants[groupe] = {"hosts": {h: hostvars.get(h) for h in (contenu.get("hosts") or [])}}
return {"all": {"children": enfants}}
def _sources_admin_ssh_site() -> list[str]:
"""Par ou l'administration entre sur le SITE — DERIVE de la carte, plus recopie.
L'exploitant administre PAR REBOND : la connexion vers une machine du site est
ouverte par la frontiere, et arrive avec l'adresse de SA PATTE DANS LA ZONE DE LA
MACHINE VISEE — pas avec celle du poste. Il faut donc la passerelle de chaque zone.
POURQUOI DERIVE (paye le 2026-09-02, une heure apres avoir ecrit le contraire).
Cette liste etait un intrant, ecrit a la main, avec ce commentaire dans le plan :
« oublier une seule de ces adresses rend une zone entiere inadministrable ». Une zone
a ete ajoutee le meme jour ; la liste ne l'a pas suivie ; `site-mon-01` s'est
retrouvee injoignable des l'application de son pare-feu, et il a fallu la rouvrir par
l'agent invite de l'hyperviseur.
Une liste recopiee retarde toujours d'une zone sur la carte. Celle-ci LIT la carte :
ajouter un reseau au site suffit, il n'y a plus rien a penser a mettre a jour.
L'intrant reste lu et AJOUTE a la derivation — un site peut avoir une voie
d'administration que la carte ne decrit pas (un VPN, un poste sur un lien hors
fabric). On elargit, on ne remplace pas.
"""
import underlay as underlay_mod
srcs: list[str] = []
u = underlay_mod.charger() or {}
for r in (u.get("reseaux") or []):
# Les zones du SITE seulement : les reseaux de fabric (management, ceph, iscsi)
# ne portent pas de machines du site et n'ont pas a ouvrir SSH chez elles.
if str(r.get("nom", "")).startswith("site-") and r.get("passerelle"):
srcs.append(f"{r['passerelle']}/32")
# La zone de pilotage en entier : le runner du site y vit, c'est de la qu'il
# deploie. Une passerelle seule ne suffirait pas a le laisser entrer.
if str(r.get("nom", "")) == "site-pilotage" and r.get("sous_reseau"):
srcs.append(str(r["sous_reseau"]))
src = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("nftables_admin_ssh")
if src:
srcs += [str(s) for s in src] if isinstance(src, list) else [str(src)]
return sorted(set(srcs))
def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -> list[Path]:
if site:
data = _inventaire_site()
admin_ssh = _sources_admin_ssh_site()
if not admin_ssh:
raise ErreurFlux(
"Le plan du site ne declare pas `nftables_admin_ssh`. Sans lui, les regles "
"generees refuseraient SSH sur TOUTES les machines du site — et le seul "
"moyen de les reparer passe par SSH. Declarer l'intrant avant de generer.")
else:
data = yaml.safe_load(_inventaire().read_text(encoding="utf-8")) or {}
admin_ssh = _sources_admin_ssh()
actifs_noms = set(_hotes_du_groupe(data, "hotes_actifs"))
ip_par_hote = _ip_par_hote(data)
ports_plan = _ports_du_plan()
regles_admin = [
f" ip saddr {{ {cidr} }} tcp dport 22 accept # administration (garde anti-lockout, intrant nftables_admin_ssh)"
for cidr in admin_ssh
]
if site:
# A cote du plan du site, comme sa carte et sa voute : un artefact du site vit
# chez le site, pas dans l'instance montee au moment ou on l'a genere.
import underlay as underlay_mod
sortie_dir = (underlay_mod.chemin().resolve().parent / "flux-genere")
else:
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"]]
# `derive` se resout par le plan ; sans lui on n'emet RIEN plutot qu'une
# regle que `nft` refusera — et l'on DIT ce qu'on ne pose pas. Un flux
# tu en silence est une porte qu'on croit ouverte.
if str(fl.get("protocole", "")).lower() in ("tcp", "udp"):
reels = []
for pt in ports:
if str(pt).isdigit():
reels.append(pt)
continue
connu = ports_plan.get(g)
if connu:
reels.append(connu)
else:
print(f"note : {g} declare un port `{pt}` que le plan ne "
f"resout pas — aucune regle emise pour {hote}.",
file=sys.stderr)
if not reels:
continue
ports = reels
srcs = _resoudre_sources(data, fl.get("pair"), actifs_noms, ip_par_hote, site=site)
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"
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"
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"
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"
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:
# `resolu=False` : on verifie un ROLE, pas un fichier. Un
# port symbolique y est legitime — c'est le plan qui le
# resoudra, et `generer_nftables` refuse s'il n'y arrive pas.
_selecteur_nft(fl.get("protocole", "tcp"), port, resolu=False)
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}")
elif commande == "nftables-site":
valider(flux)
ecrits = generer_nftables(flux, site=True)
print(f"{len(ecrits)} aperçu(s) nftables du SITE générés (NON activés) :")
for p in ecrits:
print(f" {p}")
else:
print(f"Commande inconnue : {commande} "
"(registre | nftables | nftables-site | 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:]))