Set-OPS-Public/scripts/tests/test_parite_tls_site.py
Daniel Allaire f3bb9caee3 chiffrement : metriques et journaux du site en TLS, et la frontiere certifiee par sa propre autorite
Metriques et journaux (CHANGELOG 142). Le site pose les quatre interrupteurs TLS
que chaque locataire pose dans ses group_vars : node_exporter et Loki servaient
en HTTP sur les neuf VM, le certificat etait la, inutilise. test_parite_tls_site.py
compare desormais le site aux locataires. Les hyperviseurs poussent leurs
journaux en HTTPS verifie, avec la racine du site comme simple fichier.
L'exportateur PostgreSQL sert en HTTPS partout, sur l'interrupteur de PostgreSQL
lui-meme, que Prometheus lit pour choisir son schema.

La frontiere (CHANGELOG 143). Une autorite subordonnee signee par la racine du
site, bornee (pathlen 0, noms sous le domaine et adresses du site, contrainte
critique), cle nee sur la boite (scripts/frontiere_pki.py). Syslog vers Loki en
TLS mutuel (seule la frontiere peut ecrire), exportateur en HTTPS, interface web
sur le certificat de services ; les clients d'API verifient la frontiere contre
la racine du site. La lecture du magasin passe par config.xml : l'API de
recherche renvoie les cles privees.

Deploye et mesure au site, chez les deux locataires et sur la frontiere ; 95 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:25:28 -04:00

107 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Ce qu'un locataire chiffre par ses group_vars, le site le chiffre aussi.
POURQUOI (2026-10-12). Trois fois le meme ecart : un interrupteur TLS que chaque locataire
pose dans ses `group_vars`, et que l'inventaire du site — dynamique, sans group_vars — ne
posait nulle part. PostgreSQL servait le certificat auto-signe de Debian (2026-09-12),
l'edge aussi (meme jour), et node_exporter et Loki servaient en HTTP (2026-10-12) alors
que le registre des flux les dit `tls`. Chaque fois, le certificat etait la, inutilise.
Le test lit les `group_vars/<groupe>.yml` des locataires montes a cote du depot, retient
chaque cle qui contient `tls` et vaut vrai, et exige la meme valeur dans les hostvars des
machines du site qui portent ce groupe.
"""
from __future__ import annotations
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(RACINE / "scripts"))
import site_inventaire as si # noqa: E402
# CE QUI RESTE A FAIRE, NOMME — pas une tolerance muette. Chaque entree dit pourquoi le
# site differe encore, et disparait le jour ou l'ecart se ferme.
RESTE = {
("client_metrique", "client_metrique_tls_actif", "hyperviseurs"):
"node_exporter des hyperviseurs : il leur faudrait un certificat a eux "
"(pas de client_pki sur la fabric) — collecte encore en HTTP, chantier suivant.",
}
# CE QUE LE SITE OBTIENT AUTREMENT, avec la CONDITION qui le prouve dans ses hostvars.
# Pas une dispense : si la condition tombe, l'ecart redevient un echec.
AUTREMENT = {
("serveur_icinga", "serveur_icinga_db_tls"): (
lambda v: v.get("serveur_postgresql_tls_force") is True,
"derive par le role de `serveur_postgresql_tls_force` (resoudre_base), vrai au site"),
("serveur_postfix", "serveur_postfix_lmtp_tls_verify"): (
lambda v: v.get("serveur_postfix_mode") == "relais",
"sans objet : le relais du site ne remet rien par LMTP (`serveur_postfix_mode: relais`)"),
}
def interrupteurs_des_locataires() -> dict[tuple[str, str], list[str]]:
"""{(groupe, cle): [locataires]} pour chaque cle *tls* vraie d'un group_vars."""
vus: dict[tuple[str, str], list[str]] = {}
for gv in sorted(RACINE.parent.glob("OPS-*/inventories/principal/group_vars/*.yml")):
locataire = gv.parents[3].name
if locataire.endswith("-lab"):
continue
try:
donnees = yaml.safe_load(gv.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
continue
if not isinstance(donnees, dict):
continue
for cle, val in donnees.items():
if "tls" in str(cle) and val is True:
vus.setdefault((gv.stem, str(cle)), []).append(locataire)
return vus
def main() -> int:
attendus = interrupteurs_des_locataires()
if not attendus:
print("OK : aucun locataire monte a cote du depot — rien a comparer.")
return 0
try:
inv = si.inventaire()
except SystemExit as e: # plan du site refuse ou absent
print(f"OK : aucun site monte ({e}) — rien a comparer.")
return 0
except Exception as e: # noqa: BLE001
print(f"OK : aucun site monte ({type(e).__name__}) — rien a comparer.")
return 0
hv = inv["_meta"]["hostvars"]
hyperviseurs = set((inv.get("hyperviseurs") or {}).get("hosts") or [])
echecs, restes = [], []
for (groupe, cle), qui in sorted(attendus.items()):
for hote in (inv.get(groupe) or {}).get("hosts") or []:
if hv.get(hote, {}).get(cle) is True:
continue
autre = AUTREMENT.get((groupe, cle))
if autre and autre[0](hv.get(hote, {})):
continue
classe = "hyperviseurs" if hote in hyperviseurs else "vm"
raison = RESTE.get((groupe, cle, classe))
if raison:
restes.append(f"{hote} : {cle} — {raison}")
else:
echecs.append(f"{hote} ({groupe}) : `{cle}` vaut {hv.get(hote, {}).get(cle)!r} au site, "
f"true chez {', '.join(qui)}")
for r in sorted(set(restes)):
print(f"RESTE : {r}")
for e in echecs:
print(f"ECHEC : {e}")
if not echecs:
print(f"OK : les {len(attendus)} interrupteurs TLS des locataires valent aussi au site.")
return 1 if echecs else 0
if __name__ == "__main__":
raise SystemExit(main())