plan/acces.yml chez le locataire (cles publiques), reseau/port/instance derives de l'index. La garde du devis refuse qu'un tunnel de locataire vise autre chose que son supernet : sans elle, un ecosysteme s'ouvrirait un acces chez un voisin depuis son propre plan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
896 lines
45 KiB
Python
896 lines
45 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.
|
|
# `dns_public_site` et `primaires_dns_locataires` — LA REPLICATION DU DNS PUBLIC (2026-09-16).
|
|
#
|
|
# Le locataire ecrit sa zone publique ; le serveur DNS public du SITE la tire, signee TSIG.
|
|
# Les deux mots sont les deux bouts du meme flux, calques sur `runner_site` :
|
|
#
|
|
# `dns_public_site` la SOURCE vue du locataire : le serveur public de son site,
|
|
# derive du plan du site — jamais une adresse recopiee ;
|
|
# `primaires_dns_locataires` la DESTINATION vue du site : les autoritatifs des locataires.
|
|
#
|
|
# POURQUOI PAS LE NOM DU ROLE. `serveur_powerdns` existe AUSSI au site (`site-dns-01`) : une
|
|
# sortie declaree « vers serveur_powerdns » aurait vise le PowerDNS du site lui-meme, et
|
|
# jamais ceux des locataires. Le mot ne peut designer que les bonnes machines.
|
|
MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive", "admin",
|
|
"voisins_site", "fabric", "frontiere", "runner_site",
|
|
"dns_public_site", "primaires_dns_locataires"}
|
|
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"]
|
|
recus = [str(s) for s in sources] if isinstance(sources, list) else [str(sources)]
|
|
return sorted(set(recus) | set(_tunnel_admin_locataire()))
|
|
return sorted(set(_tunnel_admin_locataire()))
|
|
|
|
|
|
def _tunnel_admin_locataire() -> list[str]:
|
|
"""Le tunnel d'administration QUE CE LOCATAIRE declare — derive, jamais recopie.
|
|
|
|
Le site donne `nftables_admin_ssh` par contrat : son plan d'administration et son propre
|
|
tunnel. Le locataire, lui, decide de SES acces (`plan/acces.yml`) ; le reseau de son
|
|
tunnel se derive de son index. L'ecrire une seconde fois dans les intrants ferait diverger
|
|
ce que la frontiere route et ce que ses machines acceptent.
|
|
|
|
AUCUN PAIR, AUCUNE SOURCE : declarer un reseau que personne n'emprunte ouvrirait le SSH
|
|
de toutes ses machines a un tunnel qui n'existe pas.
|
|
"""
|
|
from inventory_rules import charger_acces, charger_nomenclature, reseau_vpn_locataire
|
|
base = INSTANCE
|
|
try:
|
|
pairs = charger_acces(base / "plan" / "acces.yml").get("acces_admin_vpn") or {}
|
|
index = charger_nomenclature(base / "plan" / "nomenclature.yml").get("index")
|
|
except (OSError, ValueError):
|
|
return []
|
|
actifs = [n for n, c in pairs.items()
|
|
if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]
|
|
return [reseau_vpn_locataire(index)] if (actifs and index is not None) else []
|
|
|
|
|
|
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 _adresses_fabric() -> list[str]:
|
|
"""Les adresses du materiel de l'hebergeur : hyperviseurs et frontiere.
|
|
|
|
MEME SOURCE QUE L'ALIAS `SETOPS_FABRIC` du devis de la frontiere — la carte de
|
|
l'hebergeur. Ecrire ces adresses ailleurs les ferait mentir le jour ou un noeud
|
|
change de patte, ce qui est deja arrive : la frontiere a porte `.1` puis `.17`, et
|
|
les hyperviseurs ont continue de renvoyer vers une adresse que plus personne ne
|
|
portait, « sans qu'aucun journal ne le dise ».
|
|
|
|
Rend [] quand aucun underlay n'est monte : degrader, jamais deviner.
|
|
"""
|
|
try:
|
|
import underlay as _u
|
|
carte = _u.charger()
|
|
if not carte:
|
|
return []
|
|
return sorted({str(h["ip"]) for h in _u.hotes(carte)
|
|
if h.get("role") in ("hyperviseur", "frontiere") and h.get("ip")})
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _adresses_frontiere() -> list[str]:
|
|
"""Les pattes de la frontiere ACTIVE — l'autorite de temps de l'ecosysteme.
|
|
|
|
PLUS ETROIT QUE `fabric`, ET C'EST LE POINT. `_adresses_fabric()` rend les
|
|
hyperviseurs ET la frontiere : s'en servir pour l'heure ouvrirait le 123 vers des
|
|
machines qui ne le servent pas. Une paire dit CE QU'EST le pair, pas ou il se trouve.
|
|
|
|
`etat: reserve` est exclu : `bifrost-2` est declaree et ne porte aucune adresse. La
|
|
compter rendrait une regle vers une machine qui n'existe pas encore — et le jour ou
|
|
elle existera, elle heritera de la regle sans que personne ne l'ait decide.
|
|
|
|
Rend [] quand aucun underlay n'est monte : degrader, jamais deviner.
|
|
"""
|
|
try:
|
|
import underlay as _u
|
|
carte = _u.charger()
|
|
if not carte:
|
|
return []
|
|
return sorted({str(h["ip"]) for h in _u.hotes(carte)
|
|
if h.get("role") == "frontiere" and h.get("ip")
|
|
and str(h.get("etat", "actif")) != "reserve"})
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
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", "dns_public_site",
|
|
"primaires_dns_locataires"):
|
|
# 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 == "frontiere":
|
|
# LA FRONTIERE EST UN PAIR A ELLE SEULE (2026-09-11). Comme `fabric`, elle
|
|
# vit dans la carte de l'hebergeur et non dans un groupe d'hotes — mais plus
|
|
# etroite : `fabric` couvre aussi les hyperviseurs, qui ne servent pas
|
|
# l'heure. Resolue plus bas, jamais laissee tomber dans le cas general : une
|
|
# source vide produirait une regle sans `saddr`.
|
|
continue
|
|
if p == "fabric":
|
|
# LA FABRIC N'EST PAS UN GROUPE D'HOTES, ELLE EST DANS LA CARTE (2026-09-10).
|
|
#
|
|
# `fabric` figurait dans `MOTS_PAIR` — donc accepte comme mot valide — sans
|
|
# aucune branche pour le resoudre. Il tombait dans le cas general, ne trouvait
|
|
# aucun hote, et une source VIDE produit une regle SANS `saddr` :
|
|
#
|
|
# tcp dport 3100 accept # ouvert a tout le monde
|
|
#
|
|
# Un mot reconnu mais non resolu est pire qu'un mot inconnu : celui-ci serait
|
|
# refuse a la validation, celui-la produit une porte grande ouverte qui a
|
|
# l'air d'un flux precis.
|
|
#
|
|
# Les adresses viennent de l'underlay, comme celles du devis de la frontiere :
|
|
# hyperviseurs et frontiere, le materiel qu'aucun agent n'habite.
|
|
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 "fabric" in demandes:
|
|
srcs |= set(_adresses_fabric())
|
|
if "frontiere" in demandes:
|
|
srcs |= set(_adresses_frontiere())
|
|
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())
|
|
if "dns_public_site" in demandes:
|
|
srcs |= set(_adresses_dns_public_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 _adresses_dns_public_site() -> list[str]:
|
|
"""L'adresse du serveur DNS public du SITE — la seule source autorisee a tirer une zone.
|
|
|
|
Meme raison que `_adresses_runner_site` : derivee du plan du site, qui est le seul a
|
|
savoir ou vit ce serveur. Une adresse recopiee survivrait a un deplacement et laisserait
|
|
le primaire du locataire avec une regle qui n'autorise personne.
|
|
|
|
CE N'EST PAS LA GARDE DU TRANSFERT. PowerDNS ne donne la zone qu'a une requete signee
|
|
TSIG : cette regle limite qui peut FRAPPER, la cle decide qui peut LIRE. Rend [] sans
|
|
depot d'hebergeur monte.
|
|
"""
|
|
try:
|
|
import underlay as underlay_mod
|
|
return underlay_mod.adresses_site_portant("serveur_dns_public")
|
|
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",
|
|
# `echo-request` est un TYPE, pas un code de destination-unreachable — d'ou l'absence
|
|
# de `icmp code` ici. La supervision s'en sert pour `hostalive` : sans ce flux,
|
|
# l'Icinga du site tenait 6 de ses 7 machines pour mortes, et Icinga SUPPRIME les
|
|
# notifications des services d'un hote DOWN. Une supervision qui croit tout mort
|
|
# n'alerte plus de rien (2026-09-09).
|
|
"echo-request": "icmp type echo-request",
|
|
}
|
|
|
|
|
|
# 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 _ports_du_plan_site() -> dict[str, str]:
|
|
"""Port declare par le plan du SITE pour chaque groupe — l'autre moitie de `derive`.
|
|
|
|
LES DEUX PLANS NE DISENT PAS LA MEME CHOSE, ET C'EST TOUT L'INTERET (2026-09-10).
|
|
|
|
`derive` veut dire « ce port depend du deploiement ». Forgejo en est l'exemple meme :
|
|
chez un tenant il ecoute 3000 DERRIERE un edge qui termine le TLS ; au site il n'a pas
|
|
d'edge et sert son propre TLS sur 443. Le plan du site le dit noir sur blanc :
|
|
|
|
forgejo:
|
|
# 443, ET NON 3000. [...] garder 3000 aurait grave `:3000` dans ROOT_URL
|
|
|
|
`generer_nftables(site=True)` interrogeait pourtant le plan du TENANT. La regle d'hote
|
|
de la forge du site ouvrait donc 3000 — un port que personne n'ecoute — et laissait 443
|
|
ferme a toute la flotte du site.
|
|
|
|
CE QUE CA A COUTE : le runner ne pouvait plus cloner le genome. Mesure du 2026-09-10,
|
|
depuis les sept machines, `10.0.33.11:443` etait injoignable de partout SAUF depuis la
|
|
forge elle-meme — y compris depuis une machine du MEME sous-reseau, ce qui excluait la
|
|
frontiere et designait le pare-feu de l'hote. Le tenant, lui, restait juste : son
|
|
Forgejo ecoute bien 3000. Un seul registre pour deux verites opposees.
|
|
"""
|
|
try:
|
|
import underlay as _u
|
|
apps = (_u.lire_plan_site("applications.yml") 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"]))
|
|
# LE TUNNEL D'ADMINISTRATION, DERIVE (2026-09-17). Le declarer une seconde fois dans
|
|
# `nftables_admin_ssh` serait une liste qui en suit une autre : le jour ou le reseau du
|
|
# tunnel change, la moitie des machines refuserait l'exploitant sans rien dire.
|
|
_vpn = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("acces_admin_vpn") or {}
|
|
if _vpn.get("reseau"):
|
|
srcs.append(str(_vpn["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_site() if site else _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) + " } "
|
|
_pairs_fl = fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")]
|
|
local = "localhost" in _pairs_fl
|
|
# UNE SOURCE VIDE N'EST PAS « TOUT LE MONDE » (mesure du 2026-09-10).
|
|
#
|
|
# Une paire qui NOMME un ensemble — un role, `flotte`, `fabric`, `edge` —
|
|
# et qui ne resout rien decrivait jusqu'ici une regle SANS `saddr`, donc
|
|
# ouverte a l'Internet. Le silence de la resolution devenait une porte.
|
|
#
|
|
# CE QUE CA A COUTE, trouve en lisant les fichiers generes : sur le
|
|
# `mon-01` d'un tenant,
|
|
#
|
|
# tcp dport 5665 accept # serveur_icinga
|
|
#
|
|
# L'API de supervision ouverte a tous, parce que `pair: serveur_backup` ne
|
|
# resout rien — cet ecosysteme depose son etat chez le site et n'a pas de
|
|
# depot a lui. La regle avait l'air d'un flux precis.
|
|
#
|
|
# `expositions` et `externe`, EUX, veulent bien dire « tout le monde » :
|
|
# ce sont des services publies, et leur ouverture est une intention, pas un
|
|
# accident. On ne les touche pas.
|
|
#
|
|
# Ailleurs : pas de source, pas de regle — et on le DIT, parce qu'un flux
|
|
# tu en silence est une porte qu'on croit ouverte (ou fermee).
|
|
_public = {"expositions", "externe"} & set(_pairs_fl)
|
|
if not srcs and not local and not _public:
|
|
print(f"note : {g} declare un flux entrant depuis "
|
|
f"`{'/'.join(str(x) for x in _pairs_fl)}` qui ne resout aucune "
|
|
f"source — aucune regle emise pour {hote} (une source vide "
|
|
f"ouvrirait le port a tous).", file=sys.stderr)
|
|
continue
|
|
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)
|
|
# UN HOTE RETIRE DU PLAN LAISSAIT SON PARE-FEU DERRIERE LUI (2026-09-13).
|
|
#
|
|
# Cette boucle ECRIVAIT sans jamais RETIRER. En retirant `backup-01` du plan d'un
|
|
# locataire, son `backup-01.nft` est reste sur le disque — un ruleset complet pour une
|
|
# machine qui n'existe plus, impossible a distinguer des autres au premier coup d'oeil.
|
|
#
|
|
# CE QUE CA COUTE : `flux-genere/` cesse d'etre une IMAGE du plan pour devenir un
|
|
# cumul de tous les plans successifs. Quiconque lit le dossier pour savoir ce que
|
|
# l'ecosysteme expose lit alors un etat qui n'a jamais existe.
|
|
#
|
|
# C'est le meme patron que les neuf resolutions d'instance et que les listes qui se
|
|
# suivent : un dossier GENERE doit etre le reflet exact de sa source, donc la
|
|
# generation doit aussi SUPPRIMER. La garde est posee en meme temps que le remede —
|
|
# **P53** voyait deja l'orphelin, puisqu'un ruleset perime n'a pas de refus audible.
|
|
for orphelin in sorted(sortie_dir.glob("*.nft")):
|
|
if orphelin not in ecrits:
|
|
orphelin.unlink()
|
|
print(f"note : {orphelin.name} retire — cet hote n'est plus au plan.",
|
|
file=sys.stderr)
|
|
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:]))
|