#!/usr/bin/env python3 """Inventaire dynamique des machines de l'HEBERGEUR, lu dans `underlay.yml`. ansible-playbook -i scripts/site_inventaire.py playbooks/groupes/serveur_ops_site.yml POURQUOI DYNAMIQUE, ET PAS UN `hosts.yml` GENERE. La regle du depot est qu'un inventaire de tenant est un ARTEFACT GENERE, jamais edite a la main : on edite le plan, puis `make instancier`. Cette regle existe parce qu'un tenant DERIVE tout de son index — le fichier intermediaire est le prix a payer pour rendre la derivation inspectable. Un site ne derive de rien. Sa declaration EST deja sa forme finale : `underlay.yml` dit l'adresse, le nœud, le pont, les services. Materialiser un second fichier qui repete la meme chose ne rendrait rien plus inspectable — ca creerait seulement un artefact de plus a garder en phase, et une occasion de plus de le laisser deriver de sa source. LES GROUPES SONT LES SERVICES. Une machine qui declare `services: [serveur_ops_site]` atterrit dans le groupe `serveur_ops_site`, et `playbooks/groupes/serveur_ops_site.yml` la trouve sans qu'on ait rien a cabler. C'est la meme convention que pour les tenants : ce qui se partage entre un site et un tenant, ce sont les ROLES, pas la forme du plan. """ from __future__ import annotations import argparse import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import yaml # noqa: E402 import underlay as U # noqa: E402 from inventory_rules import integrations_universelles # noqa: E402 # L'inventaire des tenants passe par ce compte ; le site n'a aucune raison d'en differer. UTILISATEUR_DEFAUT = "ansible" # LE SOCLE S'APPLIQUE AUSSI AU SITE. Une machine de l'hebergeur n'est pas d'une autre # 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. # # 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. # # `hosts_statiques` construit sa liste depuis `hotes_actifs` + `hotes_planifies` — des # groupes qu'un inventaire de tenant fabrique, et que celui du site ne fabriquait pas. Le # role tournait donc sans erreur et ecrivait un plancher VIDE : `localhost`, et rien # d'autre. Un succes qui n'a rien fait, la pire forme d'echec. # # Le site n'a pas d'hotes « planifies » — une machine y est declaree ou elle n'existe pas. # Elles vont donc toutes dans `hotes_actifs`. GROUPE_FLOTTE = "hotes_actifs" def plan_dir() -> Path | None: """Le `plan/` du SITE, a cote de son `underlay.yml` — derive du symlink, jamais redit. UN SITE A BIEN UN PLAN (2026-08-25). Ce qui le distingue d'un tenant n'est pas l'absence de plan mais l'absence de DERIVATION : un tenant tire son adressage de son `index`, un site le declare, parce qu'il EST le terrain. Il ne passe donc pas par `instancier` — mais il a bien des machines, des services et des intrants a declarer. Faute d'avoir fait cette distinction, ce plan a d'abord ete reconstruit morceau par morceau dans `underlay.yml`, sans etre nomme : chaque manque est arrive par surprise au lieu d'etre previsible. `underlay.yml` decrit ce qui est MESURE (la fabric) ; le plan decrit ce qui est VOULU. """ c = U.chemin() return (c.resolve().parent / "plan") if c else None def _charger(nom: str) -> dict: d = plan_dir() f = (d / nom) if d else None if not f or not f.is_file(): return {} return yaml.safe_load(f.read_text(encoding="utf-8")) or {} def inventaire() -> dict: u = U.charger() if u is None: # Aucun underlay monte : un inventaire VIDE, pas une erreur. Ansible doit pouvoir # etre lance sans site sans que ca ressemble a une panne. return {"_meta": {"hostvars": {}}} intrants = _charger("10-intrants.yml") serveurs = (_charger("serveurs.yml") or {}).get("serveurs") or {} applications = (_charger("applications.yml") or {}).get("applications") or {} if not serveurs: return {"_meta": {"hostvars": {}}} # LES SUPERNETS DES TENANTS QUE CE SITE HEBERGE. # # LA MEME SOURCE QUE LES FLUX, UN FILTRE DIFFERENT — et la difference compte. # # `resoudre_flux._supernets_voisins()` EXCLUT l'instance montee : il sert a declarer # les flux d'un tenant vers ses VOISINS, et personne n'est son propre voisin. Le site, # lui, sert TOUS ceux qu'il heberge — y compris celui qu'on pilote au moment ou l'on # genere cet inventaire. Reutiliser la fonction telle quelle aurait prive de # resolution le seul tenant qu'on est en train de deployer, et la panne serait apparue # chez lui seul (constate en la branchant, le 2026-08-30 : Chezlepro manquait). # # On reutilise donc la DECOUVERTE partagee (`decouvrir_du_site`, lecon de P41) sans # son filtre : deux recensements de tenants finiraient par diverger, deux filtres non. # # Rend [] si la carte de l'hebergeur n'est pas montee : le site se decrit alors comme # avant, sans preter son resolveur. Degrader, jamais deviner. try: import devis_reseau from inventory_rules import supernet_de _supernets_tenants = sorted({supernet_de(int(n["index"])) for _nom, _p, n in devis_reseau.decouvrir_du_site() if n.get("index") is not None}) except Exception: _supernets_tenants = [] # LES LOCATAIRES DU DEPOT DE SAUVEGARDE — UN COMPTE UNIX CHACUN. # # Le depot du site recoit l'etat de PLUSIEURS ecosystemes. `serveur_backup`, concu # pour le depot d'un tenant, n'a qu'un compte et qu'une cle : la ou il n'y a qu'un # locataire, ca suffit. Ici, ca ne suffit plus — une seule cle partagee donnerait a # chaque tenant la lecture des instantanes de tous les autres. Une sauvegarde qui # expose ce qu'elle protege ne protege rien. # # D'ou un compte par locataire, chacun avec SON home en 0700 et SA cle. L'isolation # est alors celle du noyau, pas une convention de nommage de repertoires. # # La cle PUBLIQUE d'un tenant n'est pas un secret : elle vit deja en clair dans son # `group_vars/serveur_backup.yml`. C'est la MOITIE PRIVEE qui reste chez lui, dans sa # voute, que le site ne detient pas et n'a pas a detenir. Le site heberge des # octets chiffres par restic cote client : il ne peut pas les lire non plus. # # Meme decouverte que ci-dessus (`decouvrir_du_site`) : le site sert les tenants # qu'il heberge, et cesse de les servir quand ils s'emancipent. def _locataires_sauvegarde() -> list: out = [] try: import devis_reseau racine_instances = devis_reseau.DOSSIER_INSTANCES decouverts = devis_reseau.decouvrir_du_site() except Exception: return out for nom_depot, prefixe, _n in decouverts: gv = (racine_instances / nom_depot / "inventories" / "principal" / "group_vars" / "serveur_backup.yml") try: cle = str((yaml.safe_load(gv.read_text(encoding="utf-8")) or {}) .get("serveur_backup_pubkey") or "").strip() except OSError: cle = "" # Un locataire sans cle publique declaree n'est pas une erreur du site : # c'est un tenant qui n'a pas encore de sauvegarde. On ne lui ouvre pas de # compte, et surtout on n'en ouvre pas un SANS cle — il accepterait alors # tout ce que le compte `restic` accepte deja. if cle: out.append({"nom": nom_depot, "compte": "restic-" + prefixe.lower(), "pubkey": cle}) return out _locataires_backup = _locataires_sauvegarde() # OU LE SITE DEPOSE SON PROPRE ETAT. # # Le site a de l'etat a lui, et pas des moindres : la racine de son autorite de # certification, et la forge qui porte le genome. Tout le reste est reconstructible # par le code — le cache se re-remplit, la zone DNS se regenere depuis le plan, les # depots du runner vivent dans git. # # IL DEPOSE SUR SON PROPRE DEPOT, avec SA propre identite (`backup_pubkey` du plan, # moitie privee dans `underlay.vault.yml`). Celle des locataires ne lui sert a rien # et ne doit surtout pas lui servir : chaque deposant a son compte, le sien est # `restic` — celui que `serveur_backup` cree, avec `/srv/restic/site` pour home. # # LE NOM DU SERVICE, PAS L'ADRESSE. `client_backup_repo` grave cette valeur dans le # chemin de CHAQUE instantane : une adresse rendrait le depot indeplacable. On prend # donc le `expose` declare par l'application qui porte `serveur_backup_site` — # la meme source que celle qu'un locataire utilise. _cible_backup = next( (str(f) for app in applications.values() if str(app.get("groupe") or "") == "serveur_backup_site" for f in (app.get("expose") or [])), "") # Les groupes d'une machine du site, tels que `plan/applications.yml` les declare. def _groupes_de(nom_hote: str) -> set: return {str(a.get("groupe")) for a in applications.values() if str(a.get("hote")) == nom_hote and a.get("groupe")} par_reseau = {r.get("nom"): r for r in U.reseaux(u)} # Les sous-reseaux ou vivent REELLEMENT des machines du site : l'etendue de # l'ecosysteme, telle que le plan la dessine. _zones_du_site = sorted({str((par_reseau.get(s.get("reseau")) or {}).get("sous_reseau")) for s in serveurs.values() if str(s.get("etat", "actif")) == "actif" and (par_reseau.get(s.get("reseau")) or {}).get("sous_reseau")}) # LE RESOLVEUR D'AMORCAGE SE DERIVE, IL NE S'ECRIT PAS (2026-08-25). # # Il a ete ecrit en dur deux fois, et il a eu tort les deux fois : d'abord la frontiere # (`10.0.3.1`) alors que le site avait son DNS, puis `10.0.3.51` — juste jusqu'a ce que # le decoupage en zones deplace la machine en `10.0.34.11`. # # Une adresse ecrite a la main est une copie ; une copie se perime. Celle-ci se lit # desormais la ou elle est vraie : l'hote qui porte `serveur_resolveur` dans ce plan. # Le plan peut encore la surcharger — pour l'amorcage d'un site tout neuf, ou le # resolveur n'existe pas encore — mais ce n'est plus le cas normal. _resolveur = next((str(s.get("ip")) for nom, s in serveurs.items() if any(a.get("hote") == nom and a.get("groupe") == "serveur_resolveur" for a in applications.values()) and str(s.get("etat", "actif")) == "actif"), "") # Ce que TOUTE machine du site recoit, des intrants du plan. communes: dict = {} if _resolveur: communes["dns_amorcage"] = _resolveur for cle in ("domaine_interne", "organisation", "dns_amorcage"): if intrants.get(cle): communes[cle] = str(intrants[cle]) # L'identite de sauvegarde DU SITE (moitie publique). Le depot du site l'autorise # pour ses propres machines ; les locataires ont chacun la leur, ailleurs. if intrants.get("backup_pubkey"): communes["serveur_backup_pubkey"] = str(intrants["backup_pubkey"]) if intrants.get("cle_ssh"): communes["ansible_ssh_private_key_file"] = intrants["cle_ssh"] if intrants.get("rebond"): communes["ansible_ssh_common_args"] = ( f"-o ProxyJump={intrants['rebond']} -o StrictHostKeyChecking=no") # Les integrations universelles, moins celles que le site ne peut pas honorer. exemptees = dict(intrants.get("integrations_exemptes") or {}) universelles = [r for r in integrations_universelles() if r not in exemptees] # LES EXPOSITIONS : le nom du SERVICE, pas celui de la machine. Un nom de service # survit au demenagement du service ; un nom de machine, non. # # `edge` nomme le GROUPE qui sert ce FQDN — c'est ce qu'attend `hosts_statiques`. # Chez un tenant c'est l'edge nginx, qui termine le TLS pour tout le monde. Le site # n'a pas d'edge : chaque service se sert lui-meme, donc le groupe est le sien. La # forme reste la meme, ce qui la remplit change. expositions: list[dict] = [] for nom_app, app in applications.items(): for fqdn in (app.get("expose") or []): expositions.append({"fqdn": str(fqdn), "edge": str(app.get("groupe") or ""), "application": nom_app}) # --- LES GARDES DU PLAN, LA OU LE PLAN EST LU ---------------------------- # # Elles vivaient dans `underlay.valider()` tant que les machines habitaient la carte. # Le plan les emporte avec lui : une garde loin de ce qu'elle garde finit par garder # autre chose. Elles refusent ici, avant que quoi que ce soit ne soit clone. erreurs: list[str] = [] noeuds = {h.get("nom") for h in U.hotes(u) if h.get("role") == "hyperviseur"} vus_ip: dict[str, str] = {str(h.get("ip")): str(h.get("nom")) for h in U.hotes(u)} vus_vmid: dict[int, str] = {} for nom, srv in serveurs.items(): if str(srv.get("etat", "actif")) != "actif": continue r = par_reseau.get(srv.get("reseau")) if not r: erreurs.append(f"serveur '{nom}': reseau '{srv.get('reseau')}' inconnu de " f"`underlay.yml` — la fabric est la source de ces valeurs") continue if not str(r.get("pont", "") or "").strip(): erreurs.append(f"serveur '{nom}': le reseau '{r.get('nom')}' ne declare aucun " f"`pont:` — aucun pont d'hyperviseur ne le porte, une VM y " f"naitrait sourde") if srv.get("noeud") not in noeuds: erreurs.append(f"serveur '{nom}': noeud '{srv.get('noeud')}' n'est pas un " f"hyperviseur declare dans `underlay.hotes`") ip = str(srv.get("ip") or "") if ip in vus_ip: erreurs.append(f"serveur '{nom}': IP {ip} deja prise par '{vus_ip[ip]}'") vus_ip[ip] = nom vmid = srv.get("vmid") if not isinstance(vmid, int): erreurs.append(f"serveur '{nom}': `vmid` absent ou non entier") elif vmid in vus_vmid: erreurs.append(f"serveur '{nom}': vmid {vmid} en double avec " f"'{vus_vmid[vmid]}'") else: vus_vmid[vmid] = nom for nom_app, app in applications.items(): if app.get("hote") not in serveurs: erreurs.append(f"application '{nom_app}': hote '{app.get('hote')}' n'est pas " f"declare dans `plan/serveurs.yml`") sans_service = [n for n in serveurs if not any(a.get("hote") == n for a in applications.values())] if sans_service: erreurs.append("serveur(s) sans aucune application : " + ", ".join(sorted(sans_service)) + " — une machine du site existe POUR un role") if erreurs: raise SystemExit("plan du site refuse :\n - " + "\n - ".join(erreurs)) hostvars: dict[str, dict] = {} groupes: dict[str, list[str]] = {} for nom, srv in serveurs.items(): if str(srv.get("etat", "actif")) != "actif": continue r = par_reseau.get(srv.get("reseau")) or {} hostvars[nom] = { "ansible_host": str(srv["ip"]), "ansible_user": srv.get("utilisateur", UTILISATEUR_DEFAUT), "site_reseau": srv.get("reseau"), "site_pont": r.get("pont"), "site_noeud": srv.get("noeud"), "proxmox_vmid": srv.get("vmid"), # L'espace d'adressage de cet ecosysteme. Un tenant le derive de son index ; # le site le tient du reseau ou vivent ses machines. Meme sens, autre source. # `serveur_resolveur` s'en sert pour savoir QUI a le droit de l'interroger. "setops_supernet": str(r.get("sous_reseau") or ""), # QUI A LE DROIT D'INTERROGER LE RESOLVEUR — TOUTES LES ZONES DU SITE. # # `serveur_resolveur` derive sa liste d'autorisation de `setops_supernet`, qui # vaut le sous-reseau de la machine. C'etait juste tant que le site etait un # `/24` plat. Decoupe en zones, le resolveur n'autorisait plus que la SIENNE : # les trois autres se faisaient refuser, et la panne ressemble a un DNS mort # alors que c'est une ACL. # # Un resolveur ouvert au monde est un relais d'amplification ; celui-ci est # ouvert a son ecosysteme, et a rien d'autre. # LE SITE PRETE SON RESOLVEUR PENDANT LA JEUNESSE DE SES LOCATAIRES # (2026-08-30). L'hote qui porte `serveur_resolveur_site` admet EN PLUS les # supernets des tenants que ce site heberge. # # POURQUOI ICI ET PAS DANS LE ROLE : l'inventaire connait le plan du site ET # le registre des tenants ; le role, lui, tourne sur une machine qui n'a # aucune raison de lire la carte de l'hebergeur. La derivation appartient a # qui detient les deux sources. # # CE QUE CA A COUTE DE NE PAS L'AVOIR : `infra-pki-01`, premiere machine a # porter l'autorite de Chezlepro, ne pouvait resoudre aucun nom. `apt` s'en # sortait par le mandataire du cache, qui resout a sa place — tout le reste # (`get_url`, un depot tiers en HTTPS, un `git clone`) restait aveugle. "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. # A qui la supervision parle. Vide, le role le SIGNALE au deploiement. "serveur_icinga_destinataire": str(intrants.get("supervision_courriel") or ""), # LE POSTFIX DU SITE EXPEDIE, IL NE RECOIT PAS. # # `serveur_postfix` est d'abord le serveur de courriel d'un ecosysteme : il # resout ses destinataires dans un annuaire LDAP et remet a Dovecot. Le site # n'a ni l'un ni l'autre, et n'heberge aucune boite — il lui faut seulement # de quoi faire SORTIR une alerte, en son propre nom. "serveur_postfix_mode": "relais", # Le depot du site, pour les machines du site qui y deposent. Vide s'il n'y # en a pas — `client_backup` refuse alors, plutot que de viser un nom qui # ne repond pas : mieux vaut un deploiement qui s'arrete qu'une sauvegarde # qu'on croit faite. **({"client_backup_cible": _cible_backup, "client_backup_utilisateur_distant": "restic"} if _cible_backup else {}), # Les ecosystemes qui deposent leur etat ici, un compte Unix chacun. "serveur_backup_site_locataires": ( _locataires_backup if "serveur_backup_site" in _groupes_de(nom) else []), # Un role partage peut avoir besoin de savoir de quel cote il tourne. "setops_site": True, # Le plan du site, pour les roles qui lisent des registres. "setops_plan_dir": str(plan_dir() or ""), "hosts_statiques_expositions": expositions, **communes, # En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le # plan declare pour toutes. **(srv.get("variables") or {}), } 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) for integ in (srv.get("integrations") or []): groupes.setdefault(str(integ), []).append(nom) # LES GROUPES VIENNENT DU REGISTRE DES APPLICATIONS, comme chez un tenant. for app in applications.values(): hote, groupe = app.get("hote"), app.get("groupe") if hote in hostvars and groupe: groupes.setdefault(str(groupe), []).append(hote) out: dict = {g: {"hosts": sorted(set(h))} for g, h in groupes.items()} out["site"] = {"hosts": sorted(hostvars)} out["_meta"] = {"hostvars": hostvars} return out def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--list", action="store_true") ap.add_argument("--host") a = ap.parse_args() if a.host: print(json.dumps(inventaire().get("_meta", {}).get("hostvars", {}).get(a.host, {}))) else: print(json.dumps(inventaire(), indent=2, sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main())