Au site, l'inventaire est dynamique : le chemin de la liste pointait hors du depot (meme surcharge que le ruleset). /etc/setops a deja ses gardiens : la liste, qui n'a rien de secret, va dans /usr/local/lib/setops. L'activation du minuteur ne s'execute plus en --check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1013 lines
57 KiB
Python
Executable file
1013 lines
57 KiB
Python
Executable file
#!/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
|
|
import materiel as _materiel # noqa: E402
|
|
from inventory_rules import expositions_des_applications # 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 _controleur_est_une_machine_du_site() -> bool:
|
|
"""Le controleur EST-IL l'une des machines que le PLAN du site declare ?
|
|
|
|
C'EST LA TROISIEME FORMULATION, ET LES DEUX PREMIERES ETAIENT FAUSSES :
|
|
|
|
1. « suis-je dans un RESEAU du site ? » — le poste de l'exploitant porte
|
|
`10.37.0.17`, donc il est dans le reseau `management`, et il aurait perdu son
|
|
rebond alors qu'il en a besoin : ses paquets arrivent sur une autre patte de la
|
|
frontiere, avec d'autres regles. Etre DANS un reseau ne dit rien de ce qu'on
|
|
atteint.
|
|
|
|
2. « mon adresse est-elle dans `underlay.hotes` ? » — `hotes` ne contient que les
|
|
EQUIPEMENTS (switches, hyperviseurs, frontiere). Les MACHINES du site vivent dans
|
|
son PLAN, et le module le dit lui-meme a `machines()`. La comparaison portait donc
|
|
toujours sur une liste vide, et rendait toujours False, sans rien signaler.
|
|
|
|
LA BONNE QUESTION SE POSE PAR LE NOM. Le runner s'appelle `site-ops-01`, et c'est une
|
|
cle du plan du site. Aucune derivation d'adresse, aucune liste a tenir a jour.
|
|
|
|
En cas de doute — nom illisible, plan muet — on rend False : poser le rebond est le
|
|
comportement d'avant, donc le seul qu'on sache sur. Degrader, jamais deviner.
|
|
"""
|
|
import socket
|
|
# `U`, PAS `underlay_mod` — ET LE `except` AVALAIT LA FAUTE (2026-09-14).
|
|
#
|
|
# La premiere version appelait `underlay_mod`, un nom qui n'existe pas dans ce
|
|
# fichier : le module y est importe `as U`. Python levait donc un `AttributeError`
|
|
# a CHAQUE appel — et le `except Exception: return False` le rendait muet.
|
|
#
|
|
# RESULTAT : la fonction repondait toujours « je ne suis pas une machine du site », le
|
|
# rebond etait toujours pose, et rien ne le disait. Une garde qui se tait ne garde
|
|
# rien ; c'est exactement le defaut que ce depot traque ailleurs, ecrit ici.
|
|
#
|
|
# LE `except` NE COUVRE PLUS QUE CE QU'IL DOIT : un plan illisible ou absent, pas une
|
|
# faute de programmation. Une erreur d'attribut ou de nom doit remonter.
|
|
court = socket.gethostname().split(".")[0]
|
|
try:
|
|
serveurs = (U.lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}
|
|
except (OSError, ValueError) as e:
|
|
print(f"note : plan du site illisible ({e.__class__.__name__}) — rebond conserve.",
|
|
file=sys.stderr)
|
|
return False
|
|
return court in serveurs
|
|
|
|
|
|
|
|
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()
|
|
|
|
# LES RELATIONS DU DNS PUBLIC DU SITE (2026-09-16).
|
|
#
|
|
# Pour chaque locataire du site qui declare une zone `autorite: primaire-cache` : son
|
|
# autoritatif, et les zones que le serveur public du site doit en tirer. Meme decouverte
|
|
# que les sauvegardes — le site sert les locataires qu'il heberge, et cesse de les servir
|
|
# quand ils partent.
|
|
#
|
|
# L'ADRESSE DU PRIMAIRE SE DERIVE DE LA NOMENCLATURE DU LOCATAIRE, jamais d'une liste.
|
|
# Une table d'adresses ecrite a la main a deja fait publier huit adresses fausses le meme
|
|
# jour, dans un document remis a un client.
|
|
#
|
|
# SEULES LES ZONES `primaire-cache`. Les zones `.internal` vivent aussi dans
|
|
# `domaines_publics` (`autorite: auto-heberge`) : trier sur le nom du registre les
|
|
# aurait publiees au monde.
|
|
def _relations_dns_public() -> list:
|
|
out = []
|
|
try:
|
|
import devis_reseau
|
|
from inventory_rules import deriver_nomenclature
|
|
racine_instances = devis_reseau.DOSSIER_INSTANCES
|
|
decouverts = devis_reseau.decouvrir_du_site()
|
|
except Exception:
|
|
return out
|
|
for nom_depot, _prefixe, _n in decouverts:
|
|
plan = racine_instances / nom_depot / "plan"
|
|
try:
|
|
doms = (yaml.safe_load((plan / "domaines.yml").read_text(encoding="utf-8"))
|
|
or {}).get("domaines_publics") or {}
|
|
apps = (yaml.safe_load((plan / "applications.yml").read_text(encoding="utf-8"))
|
|
or {}).get("applications") or {}
|
|
srvs = (yaml.safe_load((plan / "serveurs.yml").read_text(encoding="utf-8"))
|
|
or {}).get("serveurs") or {}
|
|
nomencl = yaml.safe_load((plan / "nomenclature.yml").read_text(encoding="utf-8")) or {}
|
|
except OSError:
|
|
continue
|
|
zones = sorted(z for z, c in doms.items()
|
|
if isinstance(c, dict) and str(c.get("autorite")) == "primaire-cache")
|
|
if not zones:
|
|
continue
|
|
hote = next((str(a.get("hote")) for a in apps.values()
|
|
if str(a.get("groupe")) == "serveur_powerdns"), "")
|
|
s = srvs.get(hote) or {}
|
|
d = deriver_nomenclature(s.get("fonction"), s.get("sequence", 1), nomencl) if s else None
|
|
if not d:
|
|
continue # un primaire sans adresse derivable : pas de relation, pas de trou
|
|
colocalise = any(str(a.get("groupe")) == "serveur_resolveur"
|
|
and str(a.get("hote")) == hote for a in apps.values())
|
|
slug = nom_depot.lower().removeprefix("ops-")
|
|
out.append({"ecosysteme": nom_depot,
|
|
"cle_tsig": f"tsig-{slug}",
|
|
"secret_var": f"vault_tsig_dns_public_{slug.replace('-', '_')}",
|
|
"primaire": d["adresse_ip"],
|
|
"port": 5300 if colocalise else 53,
|
|
"zones": zones,
|
|
# LES ZONES QUE LE LOCATAIRE SIGNE. Le site ne detient aucune cle ; il
|
|
# doit pourtant savoir qu'une zone signee lui arrive NUE, ou avec des
|
|
# signatures proches d'expirer — c'est lui que le monde interroge.
|
|
"signees": sorted(z for z in zones if doms[z].get("dnssec") is True)})
|
|
return out
|
|
|
|
_relations_dns = _relations_dns_public()
|
|
|
|
# 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)}
|
|
# L'AUTORITE DE TEMPS, COTE SITE (2026-09-11).
|
|
#
|
|
# Ici la frontiere est la passerelle DIRECTE de chaque zone — pas besoin de passer par
|
|
# le lien de transit comme un tenant. On prend donc la patte de la frontiere posee sur
|
|
# LA ZONE DE LA MACHINE : le chemin le plus court, et celui qu'aucune regle
|
|
# supplementaire n'a besoin d'ouvrir.
|
|
_temps_par_reseau = {str(_h.get("reseau")): str(_h.get("ip"))
|
|
for _h in U.hotes(u)
|
|
if _h.get("role") == "frontiere" and _h.get("ip")
|
|
and str(_h.get("etat", "actif")) != "reserve"}
|
|
# 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"), "")
|
|
|
|
# LE DEPOT DES BINAIRES DIRECTS — meme derivation que le resolveur, pour la meme
|
|
# raison : l'adresse se lit la ou elle est vraie, dans le plan qui porte le service.
|
|
#
|
|
# Forgejo, Keycloak, Nextcloud et oauth2-proxy ne vivent dans aucun depot apt : le
|
|
# controleur les tire puis les pousse par SSH. Le cache du site les tient desormais
|
|
# (`serveur_artefacts`, LocalDirs), et c'est ce qui permet a un runner de monter un
|
|
# ecosysteme entier sans sortir. Mesure du 2026-09-12 : sans lui, 570 Mo par runner.
|
|
_cache_site = next((str(s.get("ip")) for nom, s in serveurs.items()
|
|
if any(a.get("hote") == nom and a.get("groupe") == "serveur_artefacts"
|
|
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
|
|
if _cache_site:
|
|
communes["setops_depot_binaires"] = f"http://{_cache_site}:3142/setops-binaires"
|
|
# L'AMORCAGE APT DES MACHINES DU SITE LUI-MEME (2026-09-14).
|
|
#
|
|
# `dns_amorcage` etait derive juste au-dessus ; son jumeau ne l'etait pas. Le
|
|
# socle n'ecrit `00-setops-amorcage-artefacts` que `when artefacts_amorcage is
|
|
# defined` — jamais, donc, pour une machine du site. Le fichier du GABARIT
|
|
# survivait alors intact.
|
|
#
|
|
# CE QUE CA A COUTE, mesure le 2026-09-14 sur `site-edge-01`, premiere machine
|
|
# du site creee depuis le renumerotage :
|
|
#
|
|
# Failed to update apt cache after 5 retries
|
|
# Acquire::http::Proxy "http://10.0.33.21:3142" <- adresse d'AVANT
|
|
#
|
|
# Le gabarit porte cette ligne depuis sa fabrication, le 2026-09-01. Les machines
|
|
# nees AVANT le renumerotage n'ont rien vu : l'adresse etait juste, puis
|
|
# `client_artefacts` a pose un fichier qui trie APRES et l'a emporte. Une machine
|
|
# NEUVE, elle, doit faire son premier `apt update` avec le seul fichier du
|
|
# gabarit — et il ment.
|
|
#
|
|
# LE SITE FOURNIT DEJA CETTE VALEUR A SES LOCATAIRES (`site_intrants.py`). Il ne
|
|
# se la donnait pas a lui-meme. C'est encore « le site revele ce que le tenant
|
|
# cache », a l'envers : ce que l'hebergeur pretait sans l'appliquer chez lui.
|
|
communes["artefacts_amorcage"] = f"{_cache_site}:3142"
|
|
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"])
|
|
# LA CLE AUSSI EST UNE PROPRIETE DU CONTROLEUR, PAS DU SITE (2026-09-14).
|
|
#
|
|
# `cle_ssh` vaut `~/.ssh/id_ed25519_ansible_chezlepro` : le chemin sur le POSTE de
|
|
# l'exploitant. Impose au runner du site, il designe un fichier qui n'existe pas chez
|
|
# lui — et Ansible echoue sans dire que le chemin vient d'ailleurs.
|
|
#
|
|
# Le runner a SA propre paire, celle que le plan du site autorise desormais sur ses
|
|
# machines (`cles_admin`). Lui laisser son defaut, c'est le laisser etre lui-meme.
|
|
#
|
|
# MEME CONDITION QUE LE REBOND, ET CE N'EST PAS UN HASARD : les deux decrivent COMMENT
|
|
# ON ARRIVE, pas ce vers quoi on va. Ce qui depend de l'endroit d'ou l'on part n'a pas
|
|
# sa place dans la description d'un site.
|
|
if intrants.get("cle_ssh") and not _controleur_est_une_machine_du_site():
|
|
communes["ansible_ssh_private_key_file"] = intrants["cle_ssh"]
|
|
# LE SITE N'AVAIT AUCUN MOYEN D'AUTORISER UNE CLE D'ADMINISTRATION (2026-09-14).
|
|
#
|
|
# Un locataire declare `ssh_baseline_cles_admin` dans ses group_vars, et `ssh_baseline`
|
|
# les pose sur toute sa flotte. Cote site, rien ne transmettait cette liste : ses
|
|
# machines n'autorisaient donc que la cle posee par cloud-init a leur naissance.
|
|
#
|
|
# CE QUE CA COUTAIT, ET C'EST STRUCTUREL : le runner du SITE ne pouvait entrer sur
|
|
# AUCUNE machine du site. Il sait inseminer un locataire — c'est prouve — et il ne
|
|
# savait pas configurer l'hebergeur qui le porte. Tout le site avait ete deploye
|
|
# depuis le poste de l'exploitant, et rien ne disait que c'etait la seule voie.
|
|
if intrants.get("cles_admin"):
|
|
communes["ssh_baseline_cles_admin"] = intrants["cles_admin"]
|
|
# LE REBOND NE SERT QU'A CELUI QUI EST DEHORS (2026-09-14).
|
|
#
|
|
# Il etait pose sans condition. Depuis le poste de l'exploitant c'est juste : aucune
|
|
# route directe ne mene aux zones du site. Depuis le runner du site — qui vit DANS une
|
|
# de ces zones — c'est un detour qui casse : il doit s'authentifier aupres de la
|
|
# frontiere, ou sa cle n'est pas autorisee, et OpenSSH rend alors
|
|
#
|
|
# Host key verification failed.
|
|
# Connection closed by UNKNOWN port 65535
|
|
#
|
|
# un message qui accuse les cles d'HOTE alors que l'echec est une authentification, et
|
|
# sur le SAUTEUR, pas sur la cible. Une heure de fausse piste.
|
|
#
|
|
# ON NE DEMANDE PAS UN INTRANT DE PLUS : le controleur sait ou il est. S'il porte deja
|
|
# une adresse dans un reseau du site, il n'a personne a sauter.
|
|
if intrants.get("rebond") and not _controleur_est_une_machine_du_site():
|
|
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`, et
|
|
# c'est de la que `client_pki` tire les SAN du certificat.
|
|
#
|
|
# ON APPELLE LA FONCTION PARTAGEE, ON NE LA REECRIT PLUS (2026-09-14).
|
|
#
|
|
# Cette boucle disait : « le site n'a pas d'edge, donc chaque service se sert
|
|
# lui-meme, donc le groupe est le sien ». C'etait vrai — jusqu'au jour ou le site a
|
|
# eu un edge. La meme regle vivait alors a DEUX endroits : ici, et dans
|
|
# `expositions_des_applications`, qui lit le registre des domaines. Seule la seconde
|
|
# a appris ; celle-ci est restee sur l'ancienne verite.
|
|
#
|
|
# CE QUE CA A COUTE. `client_pki` retient un FQDN si son `edge` est un GROUPE DE
|
|
# CET HOTE. L'edge appartient a `serveur_nginx` ; cette boucle rendait
|
|
# `serveur_grafana`, `serveur_icingaweb2`... Aucun nom ne correspondait, et le
|
|
# certificat de `site-edge-01` ne portait QUE celui de la machine — du TLS correct
|
|
# sur trois noms que personne ne peut appeler.
|
|
#
|
|
# Deux ecritures d'une meme regle finissent par diverger. Celle-ci n'existe plus.
|
|
expositions = expositions_des_applications(
|
|
{"applications": applications},
|
|
{"domaines_publics": (U.lire_plan_site("domaines.yml") or {}).get("domaines_publics") or {}},
|
|
)
|
|
|
|
# --- 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))
|
|
|
|
# LES ADRESSES DE LA FABRIC, pour que Prometheus puisse la TIRER (2026-09-10).
|
|
#
|
|
# Le job principal de Prometheus vaut `client_metrique ∩ hotes_actifs`, et les
|
|
# hyperviseurs sont volontairement HORS de `hotes_actifs` : ce groupe sert aux
|
|
# operations de flotte (`deployer-tout`), et un hyperviseur n'est pas une VM de la
|
|
# flotte. L'intersection les excluait donc, alors meme qu'ils portent node_exporter.
|
|
#
|
|
# `serveur_prometheus_cibles_supplementaires` est le point d'extension prevu par le
|
|
# role. On y met la fabric sous son PROPRE job : elle n'est pas la flotte, et un
|
|
# tableau qui les melange ferait croire a un parc homogene.
|
|
_fabric_nommes = {
|
|
f"{h['ip']}:9100": str(h.get("nom")) for h in U.hotes(u)
|
|
if str(h.get("role")) == "hyperviseur" and str(h.get("ip", "")).startswith("192.168.11.")}
|
|
_fabric_metriques = sorted(_fabric_nommes)
|
|
|
|
# LA FRONTIERE, QUAND SON EXPORTATEUR EST DECLARE (2026-09-17). L'adresse est celle de sa
|
|
# patte sur la zone declaree, lue dans l'underlay ; le nom est celui du boitier.
|
|
_frontiere_metriques: dict[str, str] = {}
|
|
try:
|
|
_carte_o = U.chemin().resolve().parent / "opnsense.yml" if U.chemin() else None
|
|
_expo_f = ((yaml.safe_load(_carte_o.read_text(encoding="utf-8")) or {})
|
|
.get("opnsense_exportateur_metriques") or {}) if _carte_o and _carte_o.is_file() else {}
|
|
except (OSError, yaml.YAMLError):
|
|
_expo_f = {}
|
|
if _expo_f:
|
|
for _h in U.hotes(u):
|
|
if (str(_h.get("role")) == "frontiere" and str(_h.get("etat") or "actif") == "actif"
|
|
and str(_h.get("reseau")) == str(_expo_f.get("patte")) and _h.get("ip")):
|
|
_frontiere_metriques[f"{_h['ip']}:{_expo_f.get('port', 9100)}"] = str(_h.get("nom"))
|
|
|
|
# CE QUE LA SUPERVISION IRA VOIR : chaque patte de la frontiere, nommee par la zone
|
|
# qu'elle sert. Les commutateurs restent dehors — D-48 les tient hors flotte, et rien
|
|
# ne les rend interrogeables sans leur ouvrir un acces qu'on ne veut pas leur ouvrir.
|
|
_fabric_surveillee = []
|
|
for _h in U.hotes(u):
|
|
if str(_h.get("role")) != "frontiere":
|
|
continue
|
|
# UN NOM RESERVE N'EST PAS UN EQUIPEMENT. `bifrost-2` est declare pour que le lien
|
|
# de transit soit documente et que le nom soit pris — le boitier n'existe pas.
|
|
# Le surveiller donnerait deux CRITICAL permanents, et une alarme toujours rouge
|
|
# est une alarme qu'on cesse de lire.
|
|
if str(_h.get("etat") or "actif") != "actif":
|
|
continue
|
|
_ip = str(_h.get("ip") or "")
|
|
_res = str(_h.get("reseau") or "")
|
|
if not _ip:
|
|
continue
|
|
_fabric_surveillee.append({
|
|
"nom": f"frontiere-{_ip.replace('.', '-')}",
|
|
"adresse": _ip,
|
|
"role": f"frontiere — patte {_res or _ip}",
|
|
})
|
|
_fabric_surveillee.sort(key=lambda x: x["adresse"])
|
|
|
|
# Le nom de la frontiere, tel que la carte le donne — pour etiqueter ses journaux.
|
|
_nom_frontiere = next((str(h.get("nom")) for h in U.hotes(u)
|
|
if h.get("role") == "frontiere"
|
|
and str(h.get("etat") or "actif") == "actif"), "frontiere")
|
|
|
|
# LE NOM PUBLIC D'UN SERVICE VIENT DU PLAN, PAS DU ROLE — AUSSI AU SITE (2026-09-14).
|
|
#
|
|
# `instancier` derive `<groupe>_hostname` de l'exposition declaree depuis le
|
|
# 2026-09-10, et P67 le garde. Les deux ne connaissent que l'instance MONTEE, donc un
|
|
# locataire. L'inventaire du site, lui, n'en derivait AUCUN : ses cinq groupes exposes
|
|
# tombaient tous sur la devinette de leur role.
|
|
#
|
|
# Quatre tombaient juste par convention — `forge`, `pki`, `dns`, `sauvegarde`. Le
|
|
# cinquieme non : le plan expose `observatoire.genese.internal`, et `serveur_grafana`
|
|
# devine `grafana.{{ domaine_interne }}`. Grafana fabriquait donc son `root_url`, ses
|
|
# liens d'alerte et son URL de retour OIDC avec un nom que rien ne sert.
|
|
#
|
|
# C'EST LE MEME DEFAUT QU'IL Y A QUATRE JOURS, de l'autre cote de la cloture. La garde
|
|
# avait ete posee la ou il s'etait montre, et pas la ou il pouvait se montrer aussi.
|
|
#
|
|
# UNE SEULE EXPOSITION, sinon rien : deux noms ne designent aucun canonique, et le
|
|
# role garde alors la main. `setdefault` cote hote : une valeur ecrite a la main dans
|
|
# les variables d'une machine gagne encore.
|
|
# PAR HOTE *ET* PAR GROUPE (2026-09-15) — un role peut servir deux applications sur
|
|
# deux machines, et la table indexee par groupe seul donnait le DERNIER nom a tous.
|
|
# Voir `instancier.py`, ou le meme defaut a fait croire a la passerelle de la vigie
|
|
# qu'elle etait la console.
|
|
_hostname_par_hote_groupe: dict = {}
|
|
for _app in applications.values():
|
|
_exp = [str(f).strip() for f in (_app.get("expose") or []) if str(f).strip()]
|
|
_g, _h = str(_app.get("groupe") or ""), str(_app.get("hote") or "")
|
|
if _g and _h and len(_exp) == 1:
|
|
_hostname_par_hote_groupe[(_h, _g)] = _exp[0]
|
|
|
|
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"),
|
|
# La frontiere, autorite de temps — sa patte SUR LA ZONE DE CETTE MACHINE.
|
|
"chrony_serveurs": ([_temps_par_reseau[str(srv.get("reseau"))]]
|
|
if str(srv.get("reseau")) in _temps_par_reseau else []),
|
|
# 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 ""),
|
|
# La liste que la sonde `connectivite` teste, ecrite a cote du ruleset et pour
|
|
# la meme raison (2026-09-30) : au site, le defaut pointait hors du depot, et la
|
|
# sonde aurait tourne sans liste.
|
|
"nftables_baseline_connectivite_genere": str(
|
|
(U.chemin().resolve().parent / "flux-genere" / f"{nom}.connectivite.json")
|
|
if U.chemin() else ""),
|
|
# Ce que le marqueur verifiera : la liste ci-dessus les admet-elle vraiment ?
|
|
"serveur_resolveur_site_supernets_tenants": _supernets_tenants,
|
|
# Les zones que le serveur DNS public du site doit tirer, et de qui. Vide
|
|
# partout ailleurs : aucune autre machine du site ne sert Internet.
|
|
"serveur_dns_public_relations": (
|
|
_relations_dns if "serveur_dns_public" in _groupes_de(nom) else []),
|
|
# `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 GRAFANA DU SITE N'A PAS DE SSO — pas par oubli, par D-87.
|
|
#
|
|
# Le SSO d'un tenant supposerait que le site emprunte l'identite d'un
|
|
# ecosysteme qu'il doit pouvoir survivre ; un Keycloak DU site ferait
|
|
# remonter l'identite dans le site, exactement ce que D-87 refuse. Reste
|
|
# le compte local, que le site atteint par `sudo` sur l'hote — et il faut
|
|
# alors REOUVRIR le formulaire, que le mode SSO ferme.
|
|
#
|
|
# Les deux lignes vont ensemble : eteindre l'une sans allumer l'autre
|
|
# laisse un Grafana sans aucune porte. Le role refuse desormais cette
|
|
# combinaison, plutot que de la deployer.
|
|
# LE POSTGRESQL DU SITE ETAIT HORS DE LA DOCTRINE (2026-09-12).
|
|
#
|
|
# `serveur_postgresql_tls_actif` vaut `false` par defaut, et le tenant le
|
|
# met a `true` dans ses group_vars. Le SITE ne le mettait nulle part : son
|
|
# PostgreSQL servait le certificat AUTO-SIGNE du paquet Debian
|
|
# (`ssl-cert-snakeoil.pem`), pas celui de son AC.
|
|
#
|
|
# PERSONNE NE L'AVAIT VU parce qu'aucun client du site n'exigeait la
|
|
# verification. Le premier a l'exiger — l'import du schema Icinga DB, qui
|
|
# se connecte par le FQDN — a echoue sur `certificate verify failed`, et
|
|
# c'est ainsi que l'ecart s'est montre. Le defaut n'a pas casse la
|
|
# construction : la construction a revele le defaut.
|
|
#
|
|
# Ce n'est pas un blocage d'amorcage, c'est un trou de zero-confiance
|
|
# est-ouest : le site chiffrait sans que personne ne verifie a qui.
|
|
# ET LES CLIENTS DOIVENT ETRE NOMMES. Le tenant declare
|
|
# `serveur_postgresql_reseaux_autorises: [supernet]` ; le site ne declarait
|
|
# rien, donc `pg_hba` n'autorisait que le local :
|
|
#
|
|
# aucune entree dans pg_hba.conf pour l hote 10.37.36.11,
|
|
# utilisateur icingadb, chiffrement SSL
|
|
#
|
|
# Les zones du site, et elles seules — pas un `0.0.0.0/0` qui rendrait le
|
|
# verrou `hostssl` decoratif.
|
|
"serveur_postgresql_reseaux_autorises": _zones_du_site,
|
|
# `tls_force` pose `hostssl` : toute connexion NON chiffree est refusee.
|
|
# On ne l'active qu'apres avoir confirme que le certificat servi vient bien
|
|
# de l'AC — c'est fait, mesure a 17:31 ce jour.
|
|
"serveur_postgresql_tls_force": True,
|
|
"serveur_postgresql_tls_actif": True,
|
|
"serveur_grafana_oidc_actif": False,
|
|
"serveur_grafana_connexion_locale": True,
|
|
# LA CONSOLE DE SUPERVISION GERE SES COMPTES ELLE-MEME, AU SITE.
|
|
#
|
|
# Meme raison que pour Grafana juste au-dessus : un SITE n'a ni annuaire ni
|
|
# Keycloak — ce sont des services d'ECOSYSTEME, et l'hebergeur n'en est pas
|
|
# un. Les deux autres modes d'Icinga Web 2 (`ldap`, `external`) supposent
|
|
# l'un ou l'autre, et n'ont de sens que chez un locataire.
|
|
#
|
|
# `db` EST LE MODE NATIF : sa propre base, sa propre page de connexion, et la
|
|
# gestion des comptes DANS l'interface. La premiere ecriture posait un
|
|
# `auth_basic` nginx devant — un vestibule devant une application qui sait
|
|
# deja s'authentifier (corrige le 2026-09-15).
|
|
"serveur_icingaweb2_auth": "db",
|
|
# LA CONSOLE D'EXPLOITATION DU SITE, ALLUMEE (2026-09-15).
|
|
#
|
|
# `locale` ET PAS `oidc`, ET LA DIFFERENCE AVEC LA VIGIE EST TOUT LE SUJET.
|
|
# Icinga Web 2 sait s'authentifier : on lui a RETIRE son vestibule. Le GUI de
|
|
# Set-OPS, lui, n'a AUCUNE authentification — `GET /` sert la page a qui la
|
|
# demande, avec son jeton dedans. Le vestibule est donc sa seule serrure, et
|
|
# non une couche de trop.
|
|
#
|
|
# Un site n'a ni annuaire ni Keycloak : `locale` est le repli, et il est
|
|
# declare comme tel dans le role. Chez un locataire, ce sera `oidc`.
|
|
"serveur_ops_gui_actif": True,
|
|
"serveur_ops_gui_auth": "locale",
|
|
"serveur_ops_gui_admin_motdepasse": "{{ vault_setops_gui_admin | default('') }}",
|
|
# L'EDGE SERT LE CERTIFICAT DE L'AC INTERNE, PAS LE BOUCHON DE DEBIAN.
|
|
#
|
|
# `serveur_nginx` a pour DEFAUT `ssl-cert-snakeoil.pem`, avec un commentaire
|
|
# qui date d'avant la PKI : « a remplacer par des certificats de l'AC interne
|
|
# plus tard ». Chez un locataire, `group_vars/serveur_nginx.yml` le remplace.
|
|
# L'inventaire du site est DYNAMIQUE et n'a pas de group_vars : l'edge est
|
|
# donc ne en servant du snakeoil.
|
|
#
|
|
# C'EST LE PLUS GRAVE DES REPLIS DE LA JOURNEE. Les autres rendaient un
|
|
# service muet ou une regle inerte — celui-la rend du TLS qui RESSEMBLE a du
|
|
# TLS. Le cadenas s'affiche, la connexion est chiffree, et rien n'est prouve :
|
|
# le certificat servi n'est signe par personne et ne porte aucun des noms.
|
|
#
|
|
# Le chemin est celui que `client_pki` ecrit, et le meme que celui des
|
|
# locataires — une seule convention, pas deux.
|
|
"serveur_nginx_certificat":
|
|
"/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.crt",
|
|
"serveur_nginx_cle":
|
|
"/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key",
|
|
# 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,
|
|
# LE COLLECTEUR ECOUTE AUSSI CE QUI NE PEUT PAS POUSSER. La frontiere n'a pas
|
|
# d'agent : elle emet du syslog, et Alloy le traduit vers Loki. Active sur la
|
|
# machine qui PORTE Loki — un second recepteur ailleurs serait une seconde
|
|
# configuration a garder en phase.
|
|
**({"client_journal_syslog_ecoute": f"{srv.get('ip')}:1514",
|
|
"client_journal_syslog_emetteur": _nom_frontiere,
|
|
# La sonde interroge la MEME etiquette que celle posee sur l'ecouteur :
|
|
# une seule derivation, deux consommateurs.
|
|
"serveur_loki_sonde_frontiere_etiquette": _nom_frontiere}
|
|
if "serveur_loki" in _groupes_de(nom) else {}),
|
|
# LA FABRIC SURVEILLEE PAR CONTROLE ACTIF (2026-09-10).
|
|
#
|
|
# Un pare-feu n'accueille aucun agent : Icinga doit aller VOIR. Une entree par
|
|
# PATTE de la frontiere, parce que chaque zone depend de son interface a elle —
|
|
# une interface eteinte coupe une zone pendant que les autres vont bien.
|
|
**({"serveur_icinga_fabric": _fabric_surveillee}
|
|
if (_fabric_surveillee and "serveur_icinga" in _groupes_de(nom)) else {}),
|
|
# LE MATERIEL : un catalogue, deux lecteurs (tableau Grafana, verdict Icinga).
|
|
# Voir `scripts/materiel.py` — les seuils vivent la, une fois.
|
|
**({"setops_materiel": _materiel.catalogue(),
|
|
"setops_materiel_hotes": [
|
|
{"nom": _n, "adresse": _c.rsplit(":", 1)[0], "job": "fabric",
|
|
"role": "hyperviseur"}
|
|
for _c, _n in sorted(_fabric_nommes.items(), key=lambda x: x[1])]
|
|
+ [{"nom": _n, "adresse": _c.rsplit(":", 1)[0], "job": "frontiere",
|
|
"role": "frontiere"}
|
|
for _c, _n in sorted(_frontiere_metriques.items(), key=lambda x: x[1])]}
|
|
if (_fabric_nommes and ({"serveur_grafana", "serveur_icinga"}
|
|
& set(_groupes_de(nom)))) else {}),
|
|
# La fabric, en job separe — voir `_fabric_metriques` plus haut.
|
|
**({"serveur_prometheus_cibles_supplementaires":
|
|
# `hotes` : chaque serie porte `hote="asgard"`. Sans lui, un tableau du
|
|
# materiel legende « 192.168.11.43:9100 » — et c'est a l'exploitant de se
|
|
# souvenir quelle machine chauffe.
|
|
[{"job": "fabric", "cibles": _fabric_metriques, "hotes": _fabric_nommes}]
|
|
+ ([{"job": "frontiere", "cibles": sorted(_frontiere_metriques),
|
|
"hotes": _frontiere_metriques}] if _frontiere_metriques else [])}
|
|
if (_fabric_metriques and "serveur_prometheus" in _groupes_de(nom)) else {}),
|
|
**communes,
|
|
# En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le
|
|
# plan declare pour toutes.
|
|
**(srv.get("variables") or {}),
|
|
}
|
|
# Nom public de chaque service porte par cet hote, tel que le plan l'expose.
|
|
for _groupe_expose in sorted(_groupes_de(nom)):
|
|
_fqdn = _hostname_par_hote_groupe.get((nom, _groupe_expose))
|
|
if _fqdn:
|
|
hostvars[nom].setdefault(f"{_groupe_expose}_hostname", _fqdn)
|
|
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)
|
|
|
|
# --- LES HYPERVISEURS ENTRENT, MAIS SEULEMENT POUR ETRE VUS (2026-09-10) ----------
|
|
#
|
|
# L'Icinga du site ne connaissait que ses sept VM. Mesure : depuis `site-mon-01`, les
|
|
# trois hyperviseurs ET sa propre passerelle rendaient 100 % de perte au ping. La
|
|
# machine qui PORTE les VM etait invisible de la supervision qui les surveille.
|
|
#
|
|
# D-48 le permet, et le dit : « les hyperviseurs sont gerables par Ansible ; hors
|
|
# flotte ne vaut que pour les commutateurs et la frontiere ». Ils peuvent donc porter
|
|
# les integrations d'observation, ce qui vaut infiniment mieux qu'un ping : charge,
|
|
# disque, unites systemd en echec, certificat.
|
|
#
|
|
# ILS N'ENTRENT PAS DANS LE SOCLE, ET C'EST LE POINT DELICAT. Un hyperviseur Proxmox
|
|
# n'est pas une VM de la flotte : lui appliquer `serveur_durci` reecrirait son
|
|
# pare-feu, son SSH et ses sysctl — sur la machine qui tient tout le reste. Ils sont
|
|
# donc places dans leur PROPRE groupe, hors de `GROUPE_SOCLE` et hors de
|
|
# `GROUPE_FLOTTE` : `make site-appliquer GROUPE=serveur_durci` ne les touchera jamais,
|
|
# parce qu'ils n'y sont pas.
|
|
#
|
|
# Ce que ca coute a dire : un groupe qui recoit des roles doit etre nomme a chaque
|
|
# fois. C'est le prix d'un perimetre explicite, et il est moins cher qu'un
|
|
# durcissement applique par megarde a un hyperviseur.
|
|
# L'adresse du temoin, derivee comme tout le reste : l'hote qui porte `serveur_icinga`.
|
|
_ip_icinga = ""
|
|
for _n_app, _a_app in applications.items():
|
|
if str(_a_app.get("groupe")) == "serveur_icinga":
|
|
_s = serveurs.get(_a_app.get("hote")) or {}
|
|
_ip_icinga = str(_s.get("ip") or "")
|
|
break
|
|
|
|
_ip_loki = ""
|
|
for _n_app, _a_app in applications.items():
|
|
if str(_a_app.get("groupe")) == "serveur_loki":
|
|
_ip_loki = str((serveurs.get(_a_app.get("hote")) or {}).get("ip") or "")
|
|
break
|
|
|
|
_hyperviseurs = {}
|
|
for _h in U.hotes(u):
|
|
if str(_h.get("role")) != "hyperviseur":
|
|
continue
|
|
_nom, _ip = str(_h.get("nom") or ""), str(_h.get("ip") or "")
|
|
if not _nom or not _ip:
|
|
continue
|
|
# UNE SEULE ADRESSE PAR HYPERVISEUR : l'underlay en declare trois par machine
|
|
# (administration, transport, fabric). On retient celle du plan d'ADMINISTRATION,
|
|
# la seule que le poste et le runner routent — les autres ne sont pas joignables
|
|
# d'ou l'on parle.
|
|
if _nom in _hyperviseurs:
|
|
continue
|
|
if not _ip.startswith("192.168.11."):
|
|
continue
|
|
_hyperviseurs[_nom] = _ip
|
|
|
|
for _nom, _ip in sorted(_hyperviseurs.items()):
|
|
hostvars[_nom] = {
|
|
"ansible_host": _ip,
|
|
"ansible_user": UTILISATEUR_DEFAUT,
|
|
"setops_site": True,
|
|
"setops_plan_dir": str(plan_dir() or ""),
|
|
# PAS DE PARE-FEU DERIVE : aucun `nftables_baseline_ruleset_genere`. Un
|
|
# hyperviseur porte son propre filtrage, pose par Proxmox et par l'exploitant.
|
|
#
|
|
# LE TEMOIN SE JOINT PAR SON ADRESSE, PAS PAR SON NOM. Le porteur de sante vise
|
|
# `<hote>.<domaine>`, ce qui suppose le plancher `/etc/hosts` du socle — que ces
|
|
# machines n'ont pas, et ne doivent pas avoir : Proxmox s'en sert pour l'identite
|
|
# de noeud du cluster. Mesure : `curl: (6) Could not resolve host` sur les trois.
|
|
**({"client_sante_icinga_url": f"https://{_ip_icinga}:5665"}
|
|
if _ip_icinga else {}),
|
|
# LES JOURNAUX, EUX, PARTENT D'ICI — et par une ADRESSE, meme raison que
|
|
# ci-dessus : pas de plancher `/etc/hosts` sur un hyperviseur.
|
|
**({"client_journal_loki_url":
|
|
f"http://{_ip_loki}:3100/loki/api/v1/push"} if _ip_loki else {}),
|
|
**communes,
|
|
}
|
|
groupes.setdefault("hyperviseurs", []).append(_nom)
|
|
# ON TIRE, ON NE POUSSE PAS — ET C'EST LA ROUTE GELEE QUI LE DECIDE (2026-09-10).
|
|
#
|
|
# Toute la supervision de Set-OPS est PASSIVE : chaque noeud mesure et pousse. Ici
|
|
# c'est impossible, et la mesure le dit sans ambiguite :
|
|
#
|
|
# asgard -> 10.0.36.11 via 192.168.11.254 (le routeur du site)
|
|
# route par defaut via 192.168.11.254 GELEE (D-57)
|
|
#
|
|
# Un hyperviseur envoie vers un routeur qui ne connait pas les reseaux du site.
|
|
# Le porteur de sante expirait donc en 20 s. Le remede evident — router 10.0.0.0/8
|
|
# par la frontiere — touche la route par defaut d'un hyperviseur EN SERVICE, ce que
|
|
# D-57 interdit : « risquer de perdre l'hyperviseur ET le chemin pour le reparer ».
|
|
#
|
|
# LE SENS INVERSE, LUI, EST OUVRABLE : la frontiere route deja `serveur_ops_site`
|
|
# vers `SETOPS_FABRIC`. Prometheus TIRE donc les metriques, et rien ne pousse.
|
|
#
|
|
# CE QU'ON NE PERD PAS EN ECHANGE. `node_exporter` expose 1005 unites systemd avec
|
|
# leur etat — exactement ce que la sonde `sante` mesurait, plus la charge, le
|
|
# disque et l'horloge. On perd le mecanisme du `ttl` (le SILENCE qui alerte) ;
|
|
# Prometheus le remplace par sa propre cible `down`, que la sonde `collecte`
|
|
# rapporte deja.
|
|
groupes.setdefault("client_metrique", []).append(_nom)
|
|
# LES JOURNAUX SE POUSSENT, EUX. La route specifique vers les zones du site — celle
|
|
# qui manquait pour 35 et 36 — suffit a un push : il part de la source et n'attend
|
|
# qu'un accuse. Un scrape, lui, exige que la reponse revienne par le meme chemin,
|
|
# ce que la route par defaut gelee empeche. D'ou metriques TIREES, journaux POUSSES.
|
|
groupes.setdefault("client_journal", []).append(_nom)
|
|
|
|
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())
|