diff --git a/CHANGELOG.md b/CHANGELOG.md index b815204..445cf94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # CHANGELOG — Set-OPS +## 2026-09-02 (2) — Les machines du site sont durcies : le commentaire disait vrai, le code n'en faisait que la moitie + +**56 preuves.** Les six machines du SITE — racine de l'AC, forge du genome, cache +d'artefacts, resolveur, depot de sauvegarde, runner — ne recevaient QUE `serveur_debian`. +`serveur_durci` ne leur etait jamais applique : ni auditd, ni fail2ban, ni apparmor, ni +sysctl, ni pare-feu. Un inventaire de tenant met chaque machine dans les DEUX groupes +(`inventory_host.py`) ; celui du site n'en mettait aucune dans le second. + +Le commentaire au-dessus de `GROUPE_SOCLE` affirmait pourtant : « elle veut le meme +durcissement SSH, les memes horloges, LE MEME PARE-FEU que n'importe quelle machine de la +flotte ». Personne ne pouvait voir l'ecart, puisque la documentation disait le contraire +de ce que le code faisait. Deuxieme fois en deux jours qu'un commentaire juste couvre une +implementation qui ne l'est pas. + +**Trouve en cherchant autre chose** : un depot de sauvegarde refusait une cle valide, et le +fichier de durcissement SSH fautif datait d'une version abandonnee le 2026-08-09. Rien ne +l'avait jamais remplace parce que rien n'appliquait le role qui le remplace. + +Etat obtenu, mesure sur les machines : auditd, fail2ban, apparmor et nftables actifs sur +les six, chacune avec son jeu de regles resolu, aucun service en echec. + +### Le pare-feu du site n'avait aucune des trois pieces qui le rendent applicable + +**1. Aucune regle n'etait generee pour le site.** `resoudre_flux.py nftables` lit un +`hosts.yml` ; le site a un inventaire DYNAMIQUE. Ses machines etaient donc invisibles pour +la generation. Deux absences se cachaient l'une l'autre : pas de regles, et pas de +pare-feu pour s'en apercevoir. D'ou `nftables-site`, qui traduit l'inventaire dynamique +vers la forme attendue plutot que de dupliquer la generation, et ecrit a cote du plan du +site. Branche sur `make flux`, pour que ca ne se demode pas. + +**2. Le chemin du jeu de regles sortait du depot.** `nftables_baseline` derive son chemin +de `inventory_dir` — juste pour un tenant, faux pour un inventaire dynamique dont le +`inventory_dir` est `scripts/`. Le role serait tombe sur son repli : une politique `drop` +sans les regles resolues. + +**3. Aucun plan d'administration.** `nftables_admin_ssh` est la seule chose qui empeche un +deploiement de couper la main qui le lance. Pour le site, il fallait comprendre que +l'exploitant administre PAR REBOND : la connexion 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. +Oublier une seule de ces adresses rend une zone entiere inadministrable. Le generateur +REFUSE desormais de produire des regles si cet intrant manque. + +### Le defaut que la lecture a attrape avant l'application + +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`. `_supernets_voisins()` retire l'instance montee : juste +chez un tenant, ou nul n'est son propre voisin ; **faux du cote du site**, ou ca 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. + +Chezlepro resout encore sur le resolveur du site. L'appliquer aurait prive de DNS les +quinze machines reconstruites la veille. C'est le meme piege que `site_inventaire` +documente deja pour les ACL du resolveur : il s'est retendu ici parce que la regle est +portee par la FONCTION, pas par le lieu qui l'appelle. + +### Une valeur qui dependait de l'ordre des plays + +`serveur_backup_site` desserrait `MaxStartups` a `60:30:200` — un depot de site voit la +somme des locataires. La valeur vivait dans son `meta`, donc ne portait que dans SON play. +Le jour ou `serveur_durci` a apporte `ssh_hardening` a toutes les machines du site, son +passage — le dernier — a remis le depot au defaut, sans rien signaler. Passee en host_var : +tout play qui applique le role la voit. Un reglage qui depend de l'ordre des plays est un +reglage qui reviendra en arriere. + +### Verifie apres coup, pas seulement dans le journal + +Depuis un locataire, a travers le nouveau pare-feu : le cache repond, la forge repond, le +depot rend `2 snapshots`, le resolveur du site resout. L'AC du site, elle, refuse — et +c'est correct : son flux declare `pair: flotte`, un locataire a la sienne. + ## 2026-09-02 — Reconstruction de Chezlepro depuis zero : quatre defauts que seule une flotte rasee pouvait montrer **56 preuves. `make valider` : 0 echec sur 13 hotes, test de restitution compris.** diff --git a/Makefile b/Makefile index 9f003a5..3e541b4 100644 --- a/Makefile +++ b/Makefile @@ -565,6 +565,11 @@ site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declaree flux: ansible-runtime ## Regenere le registre des flux et les regles nftables depuis les meta/flux.yml python3 scripts/resoudre_flux.py registre python3 scripts/resoudre_flux.py nftables + @# Les machines du SITE aussi : leur inventaire est dynamique, donc invisible + @# pour la generation qui lit un `hosts.yml`. Deux absences se cachaient l'une + @# l'autre — aucune regle generee, et aucun pare-feu applique pour s'en rendre + @# compte. Sans underlay monte, la cible ne fait rien plutot que d'echouer. + @python3 scripts/resoudre_flux.py nftables-site || printf 'note : pas de site monte, aucune regle de site generee.\n' .PHONY: devis-reseau devis-reseau: ansible-runtime ## Devis switch (VLANs/SVIs/ACLs) du reseau converge, derive des nomenclatures. DIALECTE=cisco|binardat diff --git a/roles/serveur_backup_site/meta/main.yml b/roles/serveur_backup_site/meta/main.yml index 39a3df3..0f188b8 100644 --- a/roles/serveur_backup_site/meta/main.yml +++ b/roles/serveur_backup_site/meta/main.yml @@ -47,29 +47,6 @@ dependencies: # traversable et non listable : voir `serveur_backup_site_parent`. serveur_backup_racine: "/srv/restic/site" - # SSH DURCI, ET DESSERRÉ LÀ OÙ CE DÉPÔT L'EXIGE (mesuré le 2026-09-01). - # - # DEUX DÉFAUTS EN UN, TROUVÉS EN LANÇANT DOUZE SAUVEGARDES D'UN COUP. - # - # 1. Le gabarit transporte des fichiers de durcissement PÉRIMÉS — dont - # `20-chezlepro-hardening.conf`, celui-là même que `ssh_hardening_fichiers_perimes` - # existe pour retirer. Il a été figé dans l'image, donc chaque VM clonée en hérite. - # Les machines du SITE ne recevaient jamais `ssh_hardening` (leur socle est - # `serveur_debian` seul, là où un locataire reçoit aussi `serveur_durci`) : rien ne - # venait donc jamais le corriger. Le dépôt tournait avec `MaxStartups 5:30:20` et - # `MaxSessions 2`, des valeurs abandonnées depuis le 2026-08-09. - # - # 2. `MaxStartups` compte les connexions NON ENCORE AUTHENTIFIÉES. Un dépôt - # d'écosystème en voit une poignée ; celui d'un SITE en voit la somme de tous ses - # locataires, et leurs minuteurs se réveillent à la même heure. Au-delà du seuil, - # sshd coupe AVANT la bannière : le client rapporte - # `kex_exchange_identification: Connection reset by peer`, qui accuse le réseau - # pour un refus applicatif. Une sauvegarde perdue qui se lit comme un incident - # réseau ne sera pas cherchée au bon endroit. - # - # 60 : large devant la somme des flottes servies. Le port 22 de ce dépôt n'est - # atteignable que depuis les locataires déclarés (`meta/flux.yml`) ; l'inondation dont - # `MaxStartups` protège est déjà couverte à la frontière, la panne qu'il créait ne - # l'était pas. Même arbitrage que `ssh_hardening` a fait pour la voie d'administration. + # SSH DURCI COMME PARTOUT AILLEURS. Le desserrage que ce depot exige ne se declare PAS + # ici : voir `serveur_backup_site_max_startups` dans `site_inventaire.py`, et pourquoi. - role: ssh_hardening - ssh_hardening_max_startups: "60:30:200" diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 310dd9a..33fab21 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -223,7 +223,8 @@ def _ip_par_hote(data: dict) -> dict[str, str]: return table -def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict[str, str]) -> list[str]: +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]): @@ -243,9 +244,36 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict 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()) + srcs |= set(_sources_admin_ssh_site() if site else _sources_admin_ssh()) if "voisins_site" in demandes: - srcs |= set(_supernets_voisins()) + # 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) @@ -383,17 +411,73 @@ def _ports_du_plan() -> dict[str, str]: return {} -def generer_nftables(flux_par_role: dict[str, list[dict]]) -> list[Path]: - data = yaml.safe_load(_inventaire().read_text(encoding="utf-8")) or {} +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..hosts.`), 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]: + """`nftables_admin_ssh` du PLAN DU SITE — la meme garde, l'autre cote de la frontiere. + + Le site n'a pas de `group_vars/all` : ses intrants vivent dans `plan/10-intrants.yml`. + Rendre [] ici priverait les regles de leur garde anti-verrouillage, donc on le REFUSE + plus haut plutot que de generer un jeu de regles qui coupe SSH. + """ + import underlay as underlay_mod + src = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("nftables_admin_ssh") + if not src: + return [] + return [str(s) for s in src] if isinstance(src, list) else [str(src)] + + +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) - admin_ssh = _sources_admin_ssh() 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 ] - sortie_dir = _inventaire().parent.parent.parent / "flux-genere" + 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): @@ -424,7 +508,7 @@ def generer_nftables(flux_par_role: dict[str, list[dict]]) -> list[Path]: if not reels: continue ports = reels - srcs = _resoudre_sources(data, fl.get("pair"), actifs_noms, ip_par_hote) + 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: @@ -544,8 +628,15 @@ def main(argv: list[str]) -> int: 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 | verifier)", file=sys.stderr) + 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) diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index b5428b1..fb23650 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -40,7 +40,24 @@ UTILISATEUR_DEFAUT = "ansible" # espece : elle veut le meme durcissement SSH, les memes horloges, le meme pare-feu que # n'importe quelle machine de la flotte. L'oublier lui aurait donne un SSH par defaut et # aucune regle de frontiere — la machine la plus puissante du site, la moins protegee. -GROUPE_SOCLE = "serveur_debian" +# +# CE COMMENTAIRE DISAIT VRAI, LE CODE N'EN FAISAIT QUE LA MOITIE (2026-09-02). +# +# Seul `serveur_debian` etait pose. `serveur_durci` — auditd, fail2ban, apparmor, +# sysctl, unattended-upgrades, journald, ssh_hardening, nftables — ne l'etait pas. Un +# inventaire de tenant met CHAQUE machine dans les deux (`inventory_host.py`) ; celui du +# site n'en mettait aucune dans le second. +# +# Ce que ca laissait sans protection : la racine de l'autorite de certification, la +# forge qui porte le genome, le cache d'artefacts par ou passe chaque paquet installe +# sur chaque machine de chaque locataire, et le depot ou tous deposent leur etat. Les +# machines les plus consequentes de l'installation etaient les moins durcies — et +# personne ne pouvait le voir, puisque le commentaire affirmait le contraire. +# +# TROUVE EN CHERCHANT AUTRE CHOSE : un depot de sauvegarde refusait une cle valide, et +# le fichier de durcissement SSH fautif datait d'une version abandonnee le 2026-08-09. +# Rien ne l'avait jamais remplace parce que rien n'appliquait le role qui le remplace. +GROUPE_SOCLE = ["serveur_debian", "serveur_durci"] # LE PLANCHER `/etc/hosts` SE DERIVE DE CE GROUPE, et de nul autre. # @@ -317,8 +334,54 @@ def inventaire() -> dict: "serveur_resolveur_reseaux_autorises": ( _zones_du_site + _supernets_tenants if "serveur_resolveur_site" in _groupes_de(nom) else _zones_du_site), + # LE PARE-FEU DE MACHINE, EN DEUX TEMPS (2026-09-02). + # + # `serveur_durci` porte `nftables_baseline`, qui refuse tout ce qui n'est pas + # declare. Chez un tenant, trois pieces le rendent applicable : un registre de + # flux resolu POUR SES HOTES, un chemin ou lire le jeu de regles genere, et un + # `nftables_admin_ssh` qui dit par ou l'administration entre. + # + # Le site n'en avait aucune : `resoudre_flux.py nftables` n'ecrit que pour + # l'instance montee, et le chemin par defaut (`inventory_dir/../../`) sort du + # depot pour un inventaire DYNAMIQUE, dont le `inventory_dir` est `scripts/`. + # + # Poser le pare-feu avant ces pieces, ce serait appliquer une politique + # `drop` sans aucune regle : les six machines du site tomberaient d'un coup, + # et le deploiement qui les repare passe justement par SSH. On pose donc les + # neuf autres roles d'abord — ils ne coupent rien — et le pare-feu ensuite, + # quand il aura de quoi laisser passer. + "nftables_baseline_enabled": True, + # LE CHEMIN DU JEU DE REGLES, DIT ICI PARCE QUE LE DEFAUT NE PEUT PAS LE DIRE. + # + # `nftables_baseline` derive son chemin de `inventory_dir` — juste pour un + # tenant, dont l'inventaire est un FICHIER sous `instance/inventories/...`. + # Le site a un inventaire DYNAMIQUE : son `inventory_dir` est `scripts/`, et + # le defaut pointait donc un cran AU-DESSUS du depot, sur un chemin qui + # n'existe pas. Le role serait tombe sur son repli — une politique `drop` + # sans les regles resolues. + # + # Les regles du site vivent a cote de son plan, comme sa carte et sa voute : + # `make flux` les y ecrit (`resoudre_flux.py nftables-site`). + "nftables_baseline_ruleset_genere": str( + (U.chemin().resolve().parent / "flux-genere" / f"{nom}.nft") + if U.chemin() else ""), # Ce que le marqueur verifiera : la liste ci-dessus les admet-elle vraiment ? "serveur_resolveur_site_supernets_tenants": _supernets_tenants, + # `MaxStartups` COMPTE LES CONNEXIONS NON AUTHENTIFIEES, et un depot de SITE + # en voit la somme de tous ses locataires — dont les minuteurs se reveillent + # a la meme heure. Au-dela du seuil, sshd coupe AVANT la banniere : le client + # rapporte `kex_exchange_identification: Connection reset by peer`, qui + # accuse le reseau pour un refus applicatif. Douze sauvegardes simultanees, + # une perdue (2026-09-01). + # + # EN host_var, ET NON EN PARAMETRE DE DEPENDANCE (regression du 2026-09-02). + # La valeur vivait dans le `meta` de `serveur_backup_site` : elle ne portait + # donc que dans le play de CE role. Le jour ou `serveur_durci` a apporte + # `ssh_hardening` a toutes les machines du site, son passage — le dernier — a + # remis le depot a la valeur par defaut, sans rien signaler. Un reglage qui + # depend de l'ordre des plays est un reglage qui reviendra en arriere. + # + # Ici, tout play qui applique `ssh_hardening` a cet hote la voit. # Les ecosystemes qui deposent leur etat ici, un compte Unix chacun. "serveur_backup_site_locataires": ( _locataires_backup @@ -333,7 +396,10 @@ def inventaire() -> dict: # plan declare pour toutes. **(srv.get("variables") or {}), } - groupes.setdefault(GROUPE_SOCLE, []).append(nom) + for _socle in GROUPE_SOCLE: + groupes.setdefault(_socle, []).append(nom) + if "serveur_backup_site" in _groupes_de(nom): + hostvars[nom]["ssh_hardening_max_startups"] = "60:30:200" groupes.setdefault(GROUPE_FLOTTE, []).append(nom) for integ in universelles: groupes.setdefault(integ, []).append(nom)