durcissement : le site recoit enfin serveur_durci
Some checks are pending
verifier / verifier (push) Waiting to run

Les six machines du SITE — racine de l AC, forge, cache, resolveur, depot de
sauvegarde, runner — ne recevaient que serveur_debian. Ni auditd, ni fail2ban,
ni apparmor, ni sysctl, ni pare-feu. Le commentaire au-dessus de GROUPE_SOCLE
affirmait pourtant le contraire, ce qui rendait l ecart invisible.

Le pare-feu du site n avait aucune des trois pieces qui le rendent applicable :
- aucune regle generee (resoudre_flux lit un hosts.yml ; le site a un inventaire
  dynamique) -> commande nftables-site, branchee sur make flux
- le chemin du jeu de regles sortait du depot (inventory_dir vaut scripts/ pour
  un inventaire dynamique) -> host_var explicite
- aucun nftables_admin_ssh : l exploitant administre PAR REBOND, la connexion
  arrive avec l adresse de la patte de frontiere DANS LA ZONE VISEE. Le
  generateur refuse desormais de produire des regles sans cet intrant.

Defaut attrape en LISANT le jeu de regles avant de l appliquer : la regle DNS de
site-dns-01 omettait 10.17.0.0/16. _supernets_voisins retire l instance montee —
juste chez un tenant, faux du cote du site, ou ca retire le locataire qu on
pilote. L appliquer aurait prive de DNS les quinze machines reconstruites la
veille.

MaxStartups du depot passe en host_var : dans le meta du role, il ne portait que
dans son play, et le passage de serveur_durci le remettait au defaut sans rien
dire. Un reglage qui depend de l ordre des plays revient en arriere.

Verifie depuis un locataire a travers le nouveau pare-feu : cache, forge, depot
(2 snapshots) et resolveur repondent ; l AC du site refuse, et c est correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
This commit is contained in:
Daniel Allaire 2026-09-02 10:31:21 -04:00
parent 0854b2a93c
commit 53c5f41a1a
5 changed files with 245 additions and 36 deletions

View file

@ -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.**

View file

@ -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

View file

@ -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"

View file

@ -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.<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]:
"""`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)

View file

@ -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)