#!/usr/bin/env python3 """Resolveur des flux reseau Set-OPS : meta/flux.yml -> registre d'audit + apercus nftables. Agrege les `roles//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 /flux-genere/.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 est_groupe_operationnel RACINE = Path(__file__).resolve().parents[1] ROLES = RACINE / "roles" REGISTRE = RACINE / "docs" / "registre-flux.md" INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) SENS = {"ingress", "egress"} PROTO = {"tcp", "udp"} CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n-a"} MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive"} 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 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), f.get("port") or 0)): 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 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} return sorted({ip_par_hote[h] for h in noms if h in ip_par_hote}) 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}{fl['protocole']} dport {port} accept # {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"# APERCU inspectable — NON active. L'activation reste un geste dedie, teste par noeud.\n\n" f"# Remplace UNIQUEMENT la table setops (pas de `flush ruleset` : préserve les\n" f"# tables de Docker — DNAT/forward des conteneurs, ex. Collabora).\n" f"table inet setops_flux {{}}\n" f"delete table inet setops_flux\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" f" }}\n\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) 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:]))