Reconstruction complète prouvée (14 VM + sauvegardes + AC supprimées, puis `make myDay` rebâtit tout ; `make valider` entièrement vert). Bugs corrigés : - client_pki : empreinte du root CA dérivée dynamiquement de l'autorité (au lieu d'une valeur figée en Vault) — une AC régénérée a une empreinte neuve. - serveur_keycloak : assignation de rôle tolérante aux utilisateurs absents (annuaire vide sur un from-zero). - serveur_prometheus : ne scrute que les hôtes ACTIFS (client_metrique ∩ hotes_actifs). - nftables résolu : compatible Docker — remplace la seule table setops_flux (pas de flush ruleset, préserve les tables Docker) + forward autorise docker0/established. Sans ça, forward policy drop coupait Collabora (conteneur). Ajoute playbooks/proxmox/supprimer_vm_debian.yml (suppression par VMID, garde-fous). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
287 lines
12 KiB
Python
287 lines
12 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 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:]))
|