2026-07-22 21:32:42 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Confronte le GABARIT de voute d'une instance aux secrets que son plan exige.
|
|
|
|
|
|
|
|
|
|
Le gabarit `group_vars/all/vault.yml.example` est la seule liste que l'operateur
|
|
|
|
|
voit avant de creer sa voute. S'il est incomplet, le deploiement casse au milieu,
|
|
|
|
|
sur une variable indefinie ; s'il est bavard, l'operateur genere des secrets morts.
|
|
|
|
|
|
|
|
|
|
Les secrets EXIGES sont recenses a trois endroits, sans rien deviner :
|
|
|
|
|
1. `plan/bases-donnees.yml` -> le champ `secret` de chaque base ;
|
|
|
|
|
2. les roles des groupes actifs du plan -> toute reference `vault_*` ;
|
|
|
|
|
3. les `group_vars` de l'instance -> toute reference `vault_*`.
|
|
|
|
|
|
|
|
|
|
La voute REELLE (vault.yml) n'est jamais lue ni dechiffree : on ne compare que
|
|
|
|
|
des NOMS, et seulement ceux du gabarit en clair.
|
|
|
|
|
|
|
|
|
|
Usage :
|
|
|
|
|
python3 scripts/voute.py lister
|
|
|
|
|
python3 scripts/voute.py verifier # rc=2 si le gabarit est incomplet
|
|
|
|
|
python3 scripts/voute.py verifier --strict # rc=2 aussi si des cles sont inutiles
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
SDN : make devis-sdn — zone, VNets et sous-réseaux dérivés du seed (D-43/44, P30)
Ajouter un tenant implique 1 zone EVPN + 6 VNets + 6 sous-réseaux sur le cluster.
Aucun générateur ne les produisait : dernière lacune dans un dépôt où tout dérive.
26 objets pour les deux tenants, VNI = 1000 + index×10 + zone, sous-réseau et
passerelle par les mêmes fonctions que l'inventaire.
Nommage, en deux temps. D'abord VRF0017 / v1174, alignés sur ce que le cluster
portait — réflexe inverse du bon : cette convention venait d'une création à la
main et ne disait pas de quel tenant il s'agissait. Forme retenue : t<index> pour
la zone, t<index><zone abrégée> pour le VNet (t17, t17serv). C'est le préfixe que
le pare-feu Proxmox utilisait déjà (t17-cli-metrique), donc un seul schéma dans
tout le dépôt. Abréviation = 4 premières lettres du libellé, accents retirés.
Pas de tiret entre index et zone, contrairement aux IPSets : t245-serv ferait 9
caractères, t245serv en fait 8 — la forme reste uniforme jusqu'au dernier index.
Contrainte cadrante : zones ET VNets sont limités à 8 caractères par Proxmox.
sdn-evpn.md annonçait chez17-services-infra (21) : il aurait été refusé à
l'application. P30 refuse tout dépassement et toute collision de nom, de VNI ou
de sous-réseau. Éprouvé aux bornes et par sabotage.
Vérification la plus forte : avant renommage, la dérivation reproduisait à
l'identique les deux zones créées à la main — nom, VNI de VRF, MTU, contrôleur.
voute.py saisir : le pendant de la génération. On génère un secret dont le dépôt
est la source, on saisit celui dont un tiers est la source — inventer une clé
d'API OPNsense donnerait une valeur refusée à la première requête, avec P18 au
vert sur une voûte inutilisable. Sans écho, double confirmation, rien sur la
ligne de commande.
Reste ouvert : aucun nœud de sortie déclaré. Le devis émet un marqueur, pas une
valeur plausible. Deux points à trancher — le nœud de sortie route selon sa
propre table (défaut actuel : 192.168.11.254, pas la frontière), et l'entrée
n'est pas redondante puisqu'elle dépend d'une route statique d'OPNsense vers un
seul nœud.
D-45 : l'affinité de VM attend Proxmox 9 (cluster en 8.4.19), tenue à la main.
30 preuves OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:06:42 -04:00
|
|
|
import getpass
|
2026-07-22 21:32:42 -04:00
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
resolution d'instance : une seule, partagee — au lieu de neuf copies
Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.
- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
_frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.
Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un
moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas
l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier.
LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.
CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.
P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.
make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
|
|
|
from inventory_rules import (
|
|
|
|
|
instance_courante,charger_applications, charger_bases_donnees, charger_serveurs,
|
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur
Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants.
1. Intégrations universelles (D-33/D-34, P26)
Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à
quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être
oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01
n'étaient ni supervisés, ni journalisés, ni certifiés.
Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le
plan ne garde que les vrais choix et refuse la recopie. Les exemptions se
dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle
pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace.
Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la
voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations
universelles auraient cessé d'être exigés et P18 serait passé au vert sur une
voûte incomplète.
Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41
lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas
client_pki sur infra-pki-01.
2. Vue Intégrations : la matrice
La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas
été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x
intégrations : colonnes de politique en lecture seule, facultatives cochables sur
place, ligne de couverture n/N qui rend le motif visible sans le juger.
3. Propriété des intrants (D-35/D-36, P27)
Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière.
Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de
stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont
dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive —
l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et
ses défauts de placement.
Le panneau nomme désormais le propriétaire de chaque section : éditer une section
« hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas.
26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
|
|
|
integrations_universelles)
|
2026-07-22 21:32:42 -04:00
|
|
|
|
|
|
|
|
RACINE = Path(__file__).resolve().parents[1]
|
resolution d'instance : une seule, partagee — au lieu de neuf copies
Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.
- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
_frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.
Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un
moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas
l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier.
LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.
CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.
P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.
make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
|
|
|
INSTANCE = instance_courante()
|
2026-07-22 21:32:42 -04:00
|
|
|
ROLES = RACINE / "roles"
|
|
|
|
|
MOTIF_VAULT = re.compile(r"\b(vault_[a-z0-9_]+)")
|
|
|
|
|
|
voutes : le monde physique a la sienne, les tenants n'en portent plus les cles
Demande de l'exploitant : « l'underlay et son tenant doivent avoir chacun sa voute ».
CE QUI ETAIT FAUX. Le jeton d'API du cluster et la cle d'API de la frontiere vivaient dans
la voute de CHAQUE tenant. Patient 0 a du les recopier pour exister. Consequence : on ne
pouvait plus revoquer l'acces d'un locataire sans le revoquer pour tous — la faute des
neuf copies, appliquee aux secrets.
CE QUI EST POSE :
- `underlay.vault.yml`, chez l'hebergeur, a cote d'underlay.yml. Quatre secrets deplaces
(jeton Proxmox, cle et secret d'API OPNsense), retires des deux voutes de tenants.
- `proxmox_api.voute()` lit l'underlay APRES le tenant, donc l'hebergeur fait foi ; un site
non encore migre continue de fonctionner sur son ancienne voute.
- `appliquer_opnsense._voute()` n'a plus sa propre lecture : elle appelle celle du cluster.
La reecrire aurait fait une dixieme copie le jour ou l'on refermait les neuf autres.
- Le playbook de clonage charge la voute de l'underlay APRES celle du tenant, par la meme
derivation que proxmox-hebergeur.yml : le symlink designe deja l'hebergeur.
- `voute.py` sait que ces secrets ne sont plus attendus chez un tenant (P18).
EPROUVE SUR LE REEL, apres retrait des cles chez les deux tenants : l'API du cluster
repond, le devis de placement est conforme pour les deux ecosystemes, le devis de
frontiere se genere (86 objets), le SDN est convergent.
UNE PRECAUTION APPRISE EN CHEMIN : le premier essai a ecrit la voute EN CLAIR avant de la
chiffrer, et le chiffrement a echoue — il a fallu detruire le fichier. La sequence est
desormais l'inverse : chiffrer dans un dossier de travail, ne deposer que le resultat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:13:48 -04:00
|
|
|
# Secrets du MONDE PHYSIQUE : ils n'ont pas le prefixe `vault_`, et depuis le 2026-08-22
|
|
|
|
|
# ils ne vivent PLUS dans la voute d'un tenant mais dans celle de l'underlay, chez
|
|
|
|
|
# l'hebergeur (docs/frontiere-physique-virtuel.md). Un tenant qui les porte encore est un
|
|
|
|
|
# reste de migration, pas une exigence — d'ou leur retrait des attentes du gabarit.
|
|
|
|
|
SECRETS_MONDE_PHYSIQUE = {"proxmox_api_token_id", "proxmox_api_token_secret",
|
|
|
|
|
"vault_opnsense_api_key", "vault_opnsense_api_secret"}
|
|
|
|
|
SECRETS_HORS_MOTIF: set[str] = set()
|
2026-07-22 21:32:42 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _inventaire_dir() -> Path | None:
|
|
|
|
|
for nom in ("principal", "production", "lab"):
|
|
|
|
|
d = INSTANCE / "inventories" / nom
|
|
|
|
|
if d.is_dir():
|
|
|
|
|
return d
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def gabarit() -> Path | None:
|
|
|
|
|
d = _inventaire_dir()
|
|
|
|
|
if not d:
|
|
|
|
|
return None
|
|
|
|
|
p = d / "group_vars" / "all" / "vault.yml.example"
|
|
|
|
|
return p if p.is_file() else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _refs_vault(chemin: Path) -> set[str]:
|
collabora : en natif, la derniere exception conteneurisee tombe
Le role lancait collabora/code dans Docker -- seule exception de la flotte au
principe « logiciel libre en natif ». Desormais : paquet coolwsd du depot amont,
systemd, derriere l'edge nginx. community.docker est retiree de requirements :
plus aucun role n'a besoin de Docker.
Eprouve avant d'ecrire, sur une Debian 13.6 reelle et sans rien installer :
apt-get install --simulate coolwsd resout jusqu'a « Conf coolwsd (26.04.3.1-1) »,
libgcc1 est fourni par libgcc-s1, et le depot CODE-deb est PLAT.
La configuration passe par un fragment systemd (--o:) : le coolwsd.xml livre,
439 lignes commentees, reste intact.
Trois outils du harnais ont pese. voute.py lisait les COMMENTAIRES : documenter
le nom d'une clef suffisait a l'exiger -- corrige, controle negatif fait. Et
verifier_intrants avait raison : une garde ecrite en deux morceaux promettait un
secret pour une console fermee ; reecrite en implication, elle dit le vrai
contrat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:23:35 -04:00
|
|
|
"""Les `vault_*` qu'un fichier UTILISE — pas ceux dont il PARLE.
|
|
|
|
|
|
|
|
|
|
ON IGNORE LES COMMENTAIRES (2026-08-24). Le scanner lisait le fichier entier, si bien
|
|
|
|
|
qu'expliquer en commentaire « pour ouvrir cette option, fournir vault_x » SUFFISAIT a
|
|
|
|
|
rendre `vault_x` obligatoire dans le gabarit et dans la voute reelle.
|
|
|
|
|
|
|
|
|
|
Constate sur `serveur_collabora` : sa console d'administration est FERMEE par defaut,
|
|
|
|
|
et le role se contentait de documenter comment l'ouvrir. Trois phrases de commentaire
|
|
|
|
|
imposaient donc a tout ecosysteme installant Collabora d'inventer, chiffrer et faire
|
|
|
|
|
tourner un secret pour une porte que personne n'ouvre.
|
|
|
|
|
|
|
|
|
|
Documenter le nom d'une clef ne doit pas l'exiger. Une clef reellement utilisee
|
|
|
|
|
apparait dans du code — c'est ce qu'on lit ici.
|
|
|
|
|
"""
|
2026-07-22 21:32:42 -04:00
|
|
|
try:
|
collabora : en natif, la derniere exception conteneurisee tombe
Le role lancait collabora/code dans Docker -- seule exception de la flotte au
principe « logiciel libre en natif ». Desormais : paquet coolwsd du depot amont,
systemd, derriere l'edge nginx. community.docker est retiree de requirements :
plus aucun role n'a besoin de Docker.
Eprouve avant d'ecrire, sur une Debian 13.6 reelle et sans rien installer :
apt-get install --simulate coolwsd resout jusqu'a « Conf coolwsd (26.04.3.1-1) »,
libgcc1 est fourni par libgcc-s1, et le depot CODE-deb est PLAT.
La configuration passe par un fragment systemd (--o:) : le coolwsd.xml livre,
439 lignes commentees, reste intact.
Trois outils du harnais ont pese. voute.py lisait les COMMENTAIRES : documenter
le nom d'une clef suffisait a l'exiger -- corrige, controle negatif fait. Et
verifier_intrants avait raison : une garde ecrite en deux morceaux promettait un
secret pour une console fermee ; reecrite en implication, elle dit le vrai
contrat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:23:35 -04:00
|
|
|
texte = chemin.read_text(encoding="utf-8", errors="ignore")
|
2026-07-22 21:32:42 -04:00
|
|
|
except OSError:
|
|
|
|
|
return set()
|
collabora : en natif, la derniere exception conteneurisee tombe
Le role lancait collabora/code dans Docker -- seule exception de la flotte au
principe « logiciel libre en natif ». Desormais : paquet coolwsd du depot amont,
systemd, derriere l'edge nginx. community.docker est retiree de requirements :
plus aucun role n'a besoin de Docker.
Eprouve avant d'ecrire, sur une Debian 13.6 reelle et sans rien installer :
apt-get install --simulate coolwsd resout jusqu'a « Conf coolwsd (26.04.3.1-1) »,
libgcc1 est fourni par libgcc-s1, et le depot CODE-deb est PLAT.
La configuration passe par un fragment systemd (--o:) : le coolwsd.xml livre,
439 lignes commentees, reste intact.
Trois outils du harnais ont pese. voute.py lisait les COMMENTAIRES : documenter
le nom d'une clef suffisait a l'exiger -- corrige, controle negatif fait. Et
verifier_intrants avait raison : une garde ecrite en deux morceaux promettait un
secret pour une console fermee ; reecrite en implication, elle dit le vrai
contrat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:23:35 -04:00
|
|
|
# Suffisant pour YAML, Jinja (`{# … #}` compris) et Python : ce qui suit un `#` hors
|
|
|
|
|
# guillemets est de la prose. On ne cherche pas a analyser finement — un `#` dans une
|
|
|
|
|
# chaine rendrait au pire une reference ignoree, et une reference reellement utilisee
|
|
|
|
|
# apparait ailleurs dans du code.
|
|
|
|
|
utile = "\n".join(ligne.split("#", 1)[0] for ligne in texte.splitlines())
|
|
|
|
|
return set(MOTIF_VAULT.findall(utile))
|
2026-07-22 21:32:42 -04:00
|
|
|
|
|
|
|
|
|
2026-09-16 22:09:21 -04:00
|
|
|
def _cles_dnssec() -> list[str]:
|
|
|
|
|
"""Une cle par zone `primaire-cache` signee du plan. Meme nom que `dnssec.py` et le role."""
|
|
|
|
|
import re as _re
|
|
|
|
|
try:
|
|
|
|
|
doms = (yaml.safe_load((INSTANCE / "plan" / "domaines.yml").read_text(encoding="utf-8"))
|
|
|
|
|
or {}).get("domaines_publics") or {}
|
|
|
|
|
except OSError:
|
|
|
|
|
return []
|
|
|
|
|
return sorted("vault_dnssec_" + _re.sub(r"[^a-z0-9]", "_", str(z).lower().rstrip("."))
|
|
|
|
|
for z, c in doms.items()
|
|
|
|
|
if isinstance(c, dict) and c.get("autorite") == "primaire-cache"
|
|
|
|
|
and c.get("dnssec") is True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
FAMILLES_DERIVEES = {"vault_dnssec_": _cles_dnssec}
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 21:32:42 -04:00
|
|
|
def groupes_du_plan() -> set[str]:
|
|
|
|
|
apps = charger_applications(INSTANCE / "plan/applications.yml")
|
|
|
|
|
srv = charger_serveurs(INSTANCE / "plan/serveurs.yml")
|
|
|
|
|
groupes = {a.get("groupe") for a in (apps.get("applications") or {}).values() if a.get("groupe")}
|
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur
Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants.
1. Intégrations universelles (D-33/D-34, P26)
Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à
quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être
oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01
n'étaient ni supervisés, ni journalisés, ni certifiés.
Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le
plan ne garde que les vrais choix et refuse la recopie. Les exemptions se
dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle
pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace.
Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la
voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations
universelles auraient cessé d'être exigés et P18 serait passé au vert sur une
voûte incomplète.
Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41
lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas
client_pki sur infra-pki-01.
2. Vue Intégrations : la matrice
La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas
été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x
intégrations : colonnes de politique en lecture seule, facultatives cochables sur
place, ligne de couverture n/N qui rend le motif visible sans le juger.
3. Propriété des intrants (D-35/D-36, P27)
Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière.
Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de
stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont
dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive —
l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et
ses défauts de placement.
Le panneau nomme désormais le propriétaire de chaque section : éditer une section
« hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas.
26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
|
|
|
serveurs = (srv.get("serveurs") or {})
|
|
|
|
|
for s in serveurs.values():
|
2026-07-22 21:32:42 -04:00
|
|
|
groupes.update(s.get("integrations") or [])
|
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur
Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants.
1. Intégrations universelles (D-33/D-34, P26)
Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à
quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être
oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01
n'étaient ni supervisés, ni journalisés, ni certifiés.
Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le
plan ne garde que les vrais choix et refuse la recopie. Les exemptions se
dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle
pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace.
Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la
voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations
universelles auraient cessé d'être exigés et P18 serait passé au vert sur une
voûte incomplète.
Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41
lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas
client_pki sur infra-pki-01.
2. Vue Intégrations : la matrice
La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas
été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x
intégrations : colonnes de politique en lecture seule, facultatives cochables sur
place, ligne de couverture n/N qui rend le motif visible sans le juger.
3. Propriété des intrants (D-35/D-36, P27)
Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière.
Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de
stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont
dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive —
l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et
ses défauts de placement.
Le panneau nomme désormais le propriétaire de chaque section : éditer une section
« hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas.
26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
|
|
|
# Les integrations universelles ne figurent plus dans le plan (D-33) : sans
|
|
|
|
|
# cette ligne, leurs secrets cesseraient d'etre exiges — et P18 passerait au
|
|
|
|
|
# vert sur une voute incomplete. L'exemption `sauf_role` ne retire le role qu'a
|
|
|
|
|
# UN hote : le groupe existe des qu'il reste un serveur, donc pas de filtrage.
|
|
|
|
|
if serveurs:
|
|
|
|
|
groupes.update(integrations_universelles())
|
2026-07-22 21:32:42 -04:00
|
|
|
return groupes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def secrets_exiges() -> dict[str, set[str]]:
|
|
|
|
|
"""{secret: {origines}} — d'ou vient chaque exigence, pour un message utile."""
|
|
|
|
|
exiges: dict[str, set[str]] = {}
|
|
|
|
|
|
|
|
|
|
def noter(nom: str, origine: str) -> None:
|
|
|
|
|
exiges.setdefault(nom, set()).add(origine)
|
|
|
|
|
|
|
|
|
|
bases = charger_bases_donnees(INSTANCE / "plan/bases-donnees.yml")
|
|
|
|
|
for cle, base in (bases.get("bases_donnees") or {}).items():
|
|
|
|
|
if base.get("secret"):
|
|
|
|
|
noter(str(base["secret"]), f"base '{cle}'")
|
|
|
|
|
|
|
|
|
|
for groupe in sorted(groupes_du_plan()):
|
|
|
|
|
dossier = ROLES / groupe
|
|
|
|
|
if not dossier.is_dir():
|
|
|
|
|
continue
|
|
|
|
|
for f in list(dossier.rglob("*.yml")) + list(dossier.rglob("*.j2")):
|
|
|
|
|
for nom in _refs_vault(f):
|
|
|
|
|
noter(nom, f"role {groupe}")
|
|
|
|
|
|
2026-08-07 13:42:27 -04:00
|
|
|
# Roles appliques PAR UN PLAYBOOK sans etre eux-memes un groupe (ex.
|
|
|
|
|
# `amorcage_acces`, porte par le playbook de l'annuaire). Sans ce passage,
|
|
|
|
|
# leurs secrets echappaient au recensement — ce que D-20 interdit : « la
|
|
|
|
|
# liste des secrets se recense, elle ne s'ecrit pas ». Constate le 2026-08-07.
|
|
|
|
|
for pb in sorted((RACINE / "playbooks" / "groupes").glob("*.yml")):
|
|
|
|
|
try:
|
|
|
|
|
docs = yaml.safe_load(pb.read_text(encoding="utf-8")) or []
|
|
|
|
|
except yaml.YAMLError:
|
|
|
|
|
continue
|
|
|
|
|
for jeu in docs if isinstance(docs, list) else [docs]:
|
|
|
|
|
for r in (jeu or {}).get("roles") or []:
|
|
|
|
|
nom_role = r if isinstance(r, str) else (r or {}).get("role", "")
|
|
|
|
|
dossier = ROLES / str(nom_role)
|
|
|
|
|
if not dossier.is_dir():
|
|
|
|
|
continue
|
|
|
|
|
for f in list(dossier.rglob("*.yml")) + list(dossier.rglob("*.j2")):
|
|
|
|
|
for nom in _refs_vault(f):
|
|
|
|
|
noter(nom, f"role {nom_role} (playbook {pb.name})")
|
|
|
|
|
|
2026-09-16 22:09:21 -04:00
|
|
|
# LES FAMILLES DE SECRETS DONT LE NOM SE DERIVE DU PLAN (2026-09-16). Un role qui compose
|
|
|
|
|
# `'vault_dnssec_' ~ zone` laisse ici un PREFIXE : le lire comme un nom exigeait une clef
|
|
|
|
|
# `vault_dnssec_` que personne ne peut fournir. Le taire aurait ete pire — la voute d'un
|
|
|
|
|
# locataire aurait pu manquer la cle d'une zone signee sans qu'aucune preuve le dise.
|
|
|
|
|
# Chaque famille connue est donc DEPLIEE depuis le plan ; une famille inconnue reste
|
|
|
|
|
# exigee telle quelle, et l'echec qui en resulte la nomme.
|
|
|
|
|
for prefixe, derive in FAMILLES_DERIVEES.items():
|
|
|
|
|
origines = exiges.pop(prefixe, None)
|
|
|
|
|
if origines is None:
|
|
|
|
|
continue
|
|
|
|
|
for nom in derive():
|
|
|
|
|
for origine in origines:
|
|
|
|
|
noter(nom, f"{origine}, derive du plan")
|
|
|
|
|
|
2026-07-22 21:32:42 -04:00
|
|
|
d = _inventaire_dir()
|
|
|
|
|
if d and (d / "group_vars").is_dir():
|
|
|
|
|
for f in (d / "group_vars").rglob("*.yml"):
|
|
|
|
|
if f.name.endswith(".example"):
|
|
|
|
|
continue
|
|
|
|
|
for nom in _refs_vault(f):
|
|
|
|
|
noter(nom, f"group_vars/{f.name}")
|
|
|
|
|
|
|
|
|
|
return exiges
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cles_gabarit(path: Path) -> set[str]:
|
|
|
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
|
|
|
return set(data) if isinstance(data, dict) else set()
|
|
|
|
|
|
|
|
|
|
|
proxmox : pools créés, jeton normalisé, reliquat de voûte supprimé
Reconnaissance en lecture seule de l'API du cluster, avec le jeton de la voûte.
Trois valeurs devinées étaient fausses, et deux défauts bloquants sont apparus.
Corrigé d'après le cluster
- stockages : truenas-dbsql manquait ; le catalogue ne garde que ceux qui
portent `images` (PBS, cephFS, local et truenas iSCSI n'accueillent pas de
disque de VM) ;
- ponts : vmbr0 avait été omis, et l'uniformité sur les trois nœuds n'avait pas
été vérifiée — un pont partiel empêche la VM de démarrer sur certains nœuds.
Pools
Chezlepro-17 et Technolibre-11 créés, dérivés comme le reste. Les pools
Env.Tenant antérieurs (Prod.Chezlepro, Lab.KBR...) sont l'ancien monde : on n'y
touche pas et on n'y verse pas la flotte générée. Diff réel : 2 pools ajoutés,
0 retiré, 1 VM sur 38 déplacée — infra-pki-01, qui n'avait aucun pool.
Défaut bloquant : make creer-vm aurait échoué en 401
proxmoxer recompose `utilisateur!nom` à partir d'api_user et d'api_token_id. La
voûte stocke la forme complète, que les playbooks passaient telle quelle, d'où
un 401 muet — alors que le même jeton fonctionne en curl. Mesuré des deux côtés
avec un module en lecture seule : forme complète = 401, forme courte = OK.
Normalisation par split('!') | last, qui accepte les deux écritures.
Reliquat proxmox.vault.yml supprimé
Toléré « en compatibilité », il restait le seul porteur du jeton chez
Technolibre — et comme *.vault.yml est gitignoré, ce jeton ne voyageait avec
aucun dépôt : une voûte unique (D-19) qui ne l'était pas. Migration faite en
mémoire, avec relecture et aller-retour de chiffrement vérifiés avant écriture ;
fichier supprimé, listes de chargement des playbooks nettoyées, validé par un
appel API réel ne chargeant que all/vault.yml.
La garde qui manquait
voute.py verifier ne comparait que le gabarit — il disait « complet » pendant
qu'un secret vivait ailleurs. Il contrôle maintenant aussi la voûte réelle quand
ANSIBLE_VAULT_PASSWORD_FILE la rend déchiffrable : noms de clés seulement,
jamais de valeur, et vérification sautée sans mot de passe.
Elle a trouvé un second trou dès son premier passage : la voûte réelle de
Technolibre n'a ni vault_nextcloud_admin ni vault_nextcloud_oidc, que le plan
exige. Non corrigé — générer ces secrets est une décision, et celui d'OIDC doit
correspondre à ce que Keycloak connaîtra.
27 preuves OK. --syntax-check et ansible-lint (production) sur les playbooks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:52:44 -04:00
|
|
|
def cles_voute_reelle() -> set[str] | None:
|
|
|
|
|
"""Noms des cles de la VRAIE voute, si elle est dechiffrable ici. Sinon None.
|
|
|
|
|
|
|
|
|
|
Le gabarit dit ce qu'il FAUDRAIT ; il ne dit pas ce qui EST. Un tenant a vecu
|
|
|
|
|
avec son jeton d'hyperviseur dans `proxmox.vault.yml` — un fichier gitignore,
|
|
|
|
|
hors de la voute unique — pendant que le gabarit affichait « complet ». La
|
|
|
|
|
verification du gabarit ne pouvait pas le voir.
|
|
|
|
|
|
|
|
|
|
On ne lit QUE les noms de cles ; aucune valeur n'est retournee ni affichee.
|
|
|
|
|
None (et non un ensemble vide) quand la voute est absente ou le mot de passe
|
|
|
|
|
indisponible : c'est une verification qui se SAUTE, jamais une qui echoue —
|
|
|
|
|
le harnais doit rester utilisable sans acces aux secrets.
|
|
|
|
|
"""
|
|
|
|
|
chemin = None
|
|
|
|
|
for nom in ("principal", "production", "lab"):
|
|
|
|
|
p = INSTANCE / "inventories" / nom / "group_vars" / "all" / "vault.yml"
|
|
|
|
|
if p.is_file():
|
|
|
|
|
chemin = p
|
|
|
|
|
break
|
|
|
|
|
if chemin is None:
|
|
|
|
|
return None
|
|
|
|
|
brut = chemin.read_bytes()
|
|
|
|
|
if not brut.startswith(b"$ANSIBLE_VAULT"):
|
|
|
|
|
data = yaml.safe_load(brut.decode("utf-8", "ignore")) or {}
|
|
|
|
|
return set(data) if isinstance(data, dict) else set()
|
|
|
|
|
mdp_fichier = os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE")
|
|
|
|
|
if not mdp_fichier or not Path(os.path.expanduser(mdp_fichier)).is_file():
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
from ansible.parsing.vault import VaultLib, VaultSecret
|
|
|
|
|
mdp = Path(os.path.expanduser(mdp_fichier)).read_bytes().strip()
|
|
|
|
|
clair = VaultLib([("default", VaultSecret(mdp))]).decrypt(brut)
|
|
|
|
|
data = yaml.safe_load(clair.decode("utf-8")) or {}
|
|
|
|
|
return set(data) if isinstance(data, dict) else set()
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
SDN : make devis-sdn — zone, VNets et sous-réseaux dérivés du seed (D-43/44, P30)
Ajouter un tenant implique 1 zone EVPN + 6 VNets + 6 sous-réseaux sur le cluster.
Aucun générateur ne les produisait : dernière lacune dans un dépôt où tout dérive.
26 objets pour les deux tenants, VNI = 1000 + index×10 + zone, sous-réseau et
passerelle par les mêmes fonctions que l'inventaire.
Nommage, en deux temps. D'abord VRF0017 / v1174, alignés sur ce que le cluster
portait — réflexe inverse du bon : cette convention venait d'une création à la
main et ne disait pas de quel tenant il s'agissait. Forme retenue : t<index> pour
la zone, t<index><zone abrégée> pour le VNet (t17, t17serv). C'est le préfixe que
le pare-feu Proxmox utilisait déjà (t17-cli-metrique), donc un seul schéma dans
tout le dépôt. Abréviation = 4 premières lettres du libellé, accents retirés.
Pas de tiret entre index et zone, contrairement aux IPSets : t245-serv ferait 9
caractères, t245serv en fait 8 — la forme reste uniforme jusqu'au dernier index.
Contrainte cadrante : zones ET VNets sont limités à 8 caractères par Proxmox.
sdn-evpn.md annonçait chez17-services-infra (21) : il aurait été refusé à
l'application. P30 refuse tout dépassement et toute collision de nom, de VNI ou
de sous-réseau. Éprouvé aux bornes et par sabotage.
Vérification la plus forte : avant renommage, la dérivation reproduisait à
l'identique les deux zones créées à la main — nom, VNI de VRF, MTU, contrôleur.
voute.py saisir : le pendant de la génération. On génère un secret dont le dépôt
est la source, on saisit celui dont un tiers est la source — inventer une clé
d'API OPNsense donnerait une valeur refusée à la première requête, avec P18 au
vert sur une voûte inutilisable. Sans écho, double confirmation, rien sur la
ligne de commande.
Reste ouvert : aucun nœud de sortie déclaré. Le devis émet un marqueur, pas une
valeur plausible. Deux points à trancher — le nœud de sortie route selon sa
propre table (défaut actuel : 192.168.11.254, pas la frontière), et l'entrée
n'est pas redondante puisqu'elle dépend d'une route statique d'OPNsense vers un
seul nœud.
D-45 : l'affinité de VM attend Proxmox 9 (cluster en 8.4.19), tenue à la main.
30 preuves OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:06:42 -04:00
|
|
|
def _voute_reelle() -> Path | None:
|
|
|
|
|
for nom in ("principal", "production", "lab"):
|
|
|
|
|
p = INSTANCE / "inventories" / nom / "group_vars" / "all" / "vault.yml"
|
|
|
|
|
if p.is_file():
|
|
|
|
|
return p
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def saisir(noms: list[str], remplacer: bool = False) -> int:
|
|
|
|
|
"""Ecrit dans la voute des secrets EMIS PAR UN TIERS, saisis sans echo.
|
|
|
|
|
|
|
|
|
|
Pendant de la generation, et sa contrepartie exacte : on GENERE un secret dont
|
|
|
|
|
le depot est la source (Ansible configure les deux cotes depuis la meme
|
|
|
|
|
variable) ; on SAISIT un secret dont un tiers est la source. Inventer une cle
|
|
|
|
|
d'API OPNsense donnerait une valeur syntaxiquement correcte et refusee a la
|
|
|
|
|
premiere requete — pire qu'une absence, puisque la garde passerait au vert.
|
|
|
|
|
|
|
|
|
|
Rien n'est affiche, rien n'est ecrit en clair sur disque, rien ne passe par la
|
|
|
|
|
ligne de commande (donc ni par l'historique du shell, ni par la liste des
|
|
|
|
|
processus). Le resultat est reparse et redechiffre avant d'etre pose.
|
|
|
|
|
"""
|
|
|
|
|
from ansible.parsing.vault import VaultLib, VaultSecret
|
|
|
|
|
|
|
|
|
|
cible = _voute_reelle()
|
|
|
|
|
if cible is None:
|
|
|
|
|
print("erreur: aucune voute group_vars/all/vault.yml dans cette instance.",
|
|
|
|
|
file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
fichier_mdp = os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE")
|
|
|
|
|
if not fichier_mdp or not Path(os.path.expanduser(fichier_mdp)).is_file():
|
|
|
|
|
print("erreur: ANSIBLE_VAULT_PASSWORD_FILE absent ou introuvable.", file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
mdp = Path(os.path.expanduser(fichier_mdp)).read_bytes().strip()
|
|
|
|
|
vault = VaultLib([("default", VaultSecret(mdp))])
|
|
|
|
|
|
|
|
|
|
brut = cible.read_bytes()
|
|
|
|
|
texte = (vault.decrypt(brut) if brut.startswith(b"$ANSIBLE_VAULT") else brut).decode("utf-8")
|
|
|
|
|
data = yaml.safe_load(texte) or {}
|
|
|
|
|
|
|
|
|
|
a_poser: dict[str, str] = {}
|
|
|
|
|
for nom in noms:
|
|
|
|
|
if str(data.get(nom, "")).strip() and not remplacer:
|
|
|
|
|
print(f" {nom} : deja renseigne, ignore (--remplacer pour ecraser).")
|
|
|
|
|
continue
|
|
|
|
|
while True:
|
|
|
|
|
v = getpass.getpass(f" {nom} : ")
|
|
|
|
|
if not v.strip():
|
|
|
|
|
print(" valeur vide refusee.")
|
|
|
|
|
continue
|
|
|
|
|
if getpass.getpass(f" {nom} (confirmer) : ") != v:
|
|
|
|
|
print(" les deux saisies different, on recommence.")
|
|
|
|
|
continue
|
|
|
|
|
break
|
|
|
|
|
a_poser[nom] = v
|
|
|
|
|
if not a_poser:
|
|
|
|
|
print("Rien a ecrire.")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
nouveau = (texte.rstrip("\n") + "\n\n"
|
|
|
|
|
+ "# --- Saisis a la main (secrets emis par un tiers) ---\n"
|
|
|
|
|
+ yaml.safe_dump(a_poser, default_flow_style=False,
|
|
|
|
|
allow_unicode=True, sort_keys=True))
|
|
|
|
|
attendu = {**data, **a_poser}
|
|
|
|
|
if yaml.safe_load(nouveau) != attendu:
|
|
|
|
|
print("erreur: le YAML fusionne ne reproduit pas l'union attendue.", file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
chiffre = vault.encrypt(nouveau.encode("utf-8"))
|
|
|
|
|
if not chiffre.endswith(b"\n"):
|
|
|
|
|
chiffre += b"\n"
|
|
|
|
|
if yaml.safe_load(vault.decrypt(chiffre).decode()) != attendu:
|
|
|
|
|
print("erreur: aller-retour de chiffrement infidele, rien n'a ete ecrit.", file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
cible.write_bytes(chiffre)
|
|
|
|
|
print(f"Ecrit et verifie : {len(a_poser)} secret(s) -> {cible} "
|
|
|
|
|
f"({len(attendu)} cle(s) au total).")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 21:32:42 -04:00
|
|
|
def main() -> int:
|
|
|
|
|
parser = argparse.ArgumentParser(description="Couverture du gabarit de voute Set-OPS.")
|
|
|
|
|
sub = parser.add_subparsers(dest="commande", required=True)
|
|
|
|
|
sub.add_parser("lister", help="Liste les secrets exiges et leur origine.")
|
|
|
|
|
pv = sub.add_parser("verifier", help="Le gabarit couvre-t-il les secrets exiges ?")
|
|
|
|
|
pv.add_argument("--strict", action="store_true",
|
|
|
|
|
help="Echouer aussi si le gabarit porte des cles inutiles.")
|
SDN : make devis-sdn — zone, VNets et sous-réseaux dérivés du seed (D-43/44, P30)
Ajouter un tenant implique 1 zone EVPN + 6 VNets + 6 sous-réseaux sur le cluster.
Aucun générateur ne les produisait : dernière lacune dans un dépôt où tout dérive.
26 objets pour les deux tenants, VNI = 1000 + index×10 + zone, sous-réseau et
passerelle par les mêmes fonctions que l'inventaire.
Nommage, en deux temps. D'abord VRF0017 / v1174, alignés sur ce que le cluster
portait — réflexe inverse du bon : cette convention venait d'une création à la
main et ne disait pas de quel tenant il s'agissait. Forme retenue : t<index> pour
la zone, t<index><zone abrégée> pour le VNet (t17, t17serv). C'est le préfixe que
le pare-feu Proxmox utilisait déjà (t17-cli-metrique), donc un seul schéma dans
tout le dépôt. Abréviation = 4 premières lettres du libellé, accents retirés.
Pas de tiret entre index et zone, contrairement aux IPSets : t245-serv ferait 9
caractères, t245serv en fait 8 — la forme reste uniforme jusqu'au dernier index.
Contrainte cadrante : zones ET VNets sont limités à 8 caractères par Proxmox.
sdn-evpn.md annonçait chez17-services-infra (21) : il aurait été refusé à
l'application. P30 refuse tout dépassement et toute collision de nom, de VNI ou
de sous-réseau. Éprouvé aux bornes et par sabotage.
Vérification la plus forte : avant renommage, la dérivation reproduisait à
l'identique les deux zones créées à la main — nom, VNI de VRF, MTU, contrôleur.
voute.py saisir : le pendant de la génération. On génère un secret dont le dépôt
est la source, on saisit celui dont un tiers est la source — inventer une clé
d'API OPNsense donnerait une valeur refusée à la première requête, avec P18 au
vert sur une voûte inutilisable. Sans écho, double confirmation, rien sur la
ligne de commande.
Reste ouvert : aucun nœud de sortie déclaré. Le devis émet un marqueur, pas une
valeur plausible. Deux points à trancher — le nœud de sortie route selon sa
propre table (défaut actuel : 192.168.11.254, pas la frontière), et l'entrée
n'est pas redondante puisqu'elle dépend d'une route statique d'OPNsense vers un
seul nœud.
D-45 : l'affinité de VM attend Proxmox 9 (cluster en 8.4.19), tenue à la main.
30 preuves OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:06:42 -04:00
|
|
|
ps = sub.add_parser("saisir", help="Saisir sans echo des secrets emis par un tiers.")
|
|
|
|
|
ps.add_argument("noms", nargs="+", help="Noms des cles a saisir (ex. vault_opnsense_api_key).")
|
|
|
|
|
ps.add_argument("--remplacer", action="store_true",
|
|
|
|
|
help="Ecraser une cle deja renseignee.")
|
2026-07-22 21:32:42 -04:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
SDN : make devis-sdn — zone, VNets et sous-réseaux dérivés du seed (D-43/44, P30)
Ajouter un tenant implique 1 zone EVPN + 6 VNets + 6 sous-réseaux sur le cluster.
Aucun générateur ne les produisait : dernière lacune dans un dépôt où tout dérive.
26 objets pour les deux tenants, VNI = 1000 + index×10 + zone, sous-réseau et
passerelle par les mêmes fonctions que l'inventaire.
Nommage, en deux temps. D'abord VRF0017 / v1174, alignés sur ce que le cluster
portait — réflexe inverse du bon : cette convention venait d'une création à la
main et ne disait pas de quel tenant il s'agissait. Forme retenue : t<index> pour
la zone, t<index><zone abrégée> pour le VNet (t17, t17serv). C'est le préfixe que
le pare-feu Proxmox utilisait déjà (t17-cli-metrique), donc un seul schéma dans
tout le dépôt. Abréviation = 4 premières lettres du libellé, accents retirés.
Pas de tiret entre index et zone, contrairement aux IPSets : t245-serv ferait 9
caractères, t245serv en fait 8 — la forme reste uniforme jusqu'au dernier index.
Contrainte cadrante : zones ET VNets sont limités à 8 caractères par Proxmox.
sdn-evpn.md annonçait chez17-services-infra (21) : il aurait été refusé à
l'application. P30 refuse tout dépassement et toute collision de nom, de VNI ou
de sous-réseau. Éprouvé aux bornes et par sabotage.
Vérification la plus forte : avant renommage, la dérivation reproduisait à
l'identique les deux zones créées à la main — nom, VNI de VRF, MTU, contrôleur.
voute.py saisir : le pendant de la génération. On génère un secret dont le dépôt
est la source, on saisit celui dont un tiers est la source — inventer une clé
d'API OPNsense donnerait une valeur refusée à la première requête, avec P18 au
vert sur une voûte inutilisable. Sans écho, double confirmation, rien sur la
ligne de commande.
Reste ouvert : aucun nœud de sortie déclaré. Le devis émet un marqueur, pas une
valeur plausible. Deux points à trancher — le nœud de sortie route selon sa
propre table (défaut actuel : 192.168.11.254, pas la frontière), et l'entrée
n'est pas redondante puisqu'elle dépend d'une route statique d'OPNsense vers un
seul nœud.
D-45 : l'affinité de VM attend Proxmox 9 (cluster en 8.4.19), tenue à la main.
30 preuves OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:06:42 -04:00
|
|
|
if args.commande == "saisir":
|
|
|
|
|
return saisir(args.noms, args.remplacer)
|
|
|
|
|
|
2026-07-22 21:32:42 -04:00
|
|
|
g = gabarit()
|
|
|
|
|
if not g:
|
|
|
|
|
print("Aucun gabarit vault.yml.example : rien a verifier.")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
exiges = secrets_exiges()
|
|
|
|
|
presentes = cles_gabarit(g)
|
|
|
|
|
|
|
|
|
|
if args.commande == "lister":
|
|
|
|
|
for nom in sorted(exiges):
|
|
|
|
|
etat = "au gabarit" if nom in presentes else "ABSENT du gabarit"
|
|
|
|
|
print(f"{nom:<34} {etat:<18} <- {', '.join(sorted(exiges[nom]))}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
manquants = sorted(set(exiges) - presentes)
|
voutes : le monde physique a la sienne, les tenants n'en portent plus les cles
Demande de l'exploitant : « l'underlay et son tenant doivent avoir chacun sa voute ».
CE QUI ETAIT FAUX. Le jeton d'API du cluster et la cle d'API de la frontiere vivaient dans
la voute de CHAQUE tenant. Patient 0 a du les recopier pour exister. Consequence : on ne
pouvait plus revoquer l'acces d'un locataire sans le revoquer pour tous — la faute des
neuf copies, appliquee aux secrets.
CE QUI EST POSE :
- `underlay.vault.yml`, chez l'hebergeur, a cote d'underlay.yml. Quatre secrets deplaces
(jeton Proxmox, cle et secret d'API OPNsense), retires des deux voutes de tenants.
- `proxmox_api.voute()` lit l'underlay APRES le tenant, donc l'hebergeur fait foi ; un site
non encore migre continue de fonctionner sur son ancienne voute.
- `appliquer_opnsense._voute()` n'a plus sa propre lecture : elle appelle celle du cluster.
La reecrire aurait fait une dixieme copie le jour ou l'on refermait les neuf autres.
- Le playbook de clonage charge la voute de l'underlay APRES celle du tenant, par la meme
derivation que proxmox-hebergeur.yml : le symlink designe deja l'hebergeur.
- `voute.py` sait que ces secrets ne sont plus attendus chez un tenant (P18).
EPROUVE SUR LE REEL, apres retrait des cles chez les deux tenants : l'API du cluster
repond, le devis de placement est conforme pour les deux ecosystemes, le devis de
frontiere se genere (86 objets), le SDN est convergent.
UNE PRECAUTION APPRISE EN CHEMIN : le premier essai a ecrit la voute EN CLAIR avant de la
chiffrer, et le chiffrement a echoue — il a fallu detruire le fichier. La sequence est
desormais l'inverse : chiffrer dans un dossier de travail, ne deposer que le resultat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:13:48 -04:00
|
|
|
inutiles = sorted(presentes - set(exiges) - SECRETS_HORS_MOTIF
|
|
|
|
|
- SECRETS_MONDE_PHYSIQUE)
|
2026-07-22 21:32:42 -04:00
|
|
|
|
|
|
|
|
if manquants:
|
|
|
|
|
print(f"erreur: {len(manquants)} secret(s) exige(s) par le plan mais ABSENT(s) "
|
|
|
|
|
f"du gabarit {g.relative_to(RACINE) if g.is_relative_to(RACINE) else g} :",
|
|
|
|
|
file=sys.stderr)
|
|
|
|
|
for nom in manquants:
|
|
|
|
|
print(f" - {nom} <- {', '.join(sorted(exiges[nom]))}", file=sys.stderr)
|
|
|
|
|
if inutiles:
|
|
|
|
|
flux = sys.stderr if args.strict else sys.stdout
|
|
|
|
|
print(f"{'erreur' if args.strict else 'note'}: {len(inutiles)} cle(s) du gabarit "
|
|
|
|
|
f"que le plan n'exige plus : {', '.join(inutiles)}", file=flux)
|
|
|
|
|
|
proxmox : pools créés, jeton normalisé, reliquat de voûte supprimé
Reconnaissance en lecture seule de l'API du cluster, avec le jeton de la voûte.
Trois valeurs devinées étaient fausses, et deux défauts bloquants sont apparus.
Corrigé d'après le cluster
- stockages : truenas-dbsql manquait ; le catalogue ne garde que ceux qui
portent `images` (PBS, cephFS, local et truenas iSCSI n'accueillent pas de
disque de VM) ;
- ponts : vmbr0 avait été omis, et l'uniformité sur les trois nœuds n'avait pas
été vérifiée — un pont partiel empêche la VM de démarrer sur certains nœuds.
Pools
Chezlepro-17 et Technolibre-11 créés, dérivés comme le reste. Les pools
Env.Tenant antérieurs (Prod.Chezlepro, Lab.KBR...) sont l'ancien monde : on n'y
touche pas et on n'y verse pas la flotte générée. Diff réel : 2 pools ajoutés,
0 retiré, 1 VM sur 38 déplacée — infra-pki-01, qui n'avait aucun pool.
Défaut bloquant : make creer-vm aurait échoué en 401
proxmoxer recompose `utilisateur!nom` à partir d'api_user et d'api_token_id. La
voûte stocke la forme complète, que les playbooks passaient telle quelle, d'où
un 401 muet — alors que le même jeton fonctionne en curl. Mesuré des deux côtés
avec un module en lecture seule : forme complète = 401, forme courte = OK.
Normalisation par split('!') | last, qui accepte les deux écritures.
Reliquat proxmox.vault.yml supprimé
Toléré « en compatibilité », il restait le seul porteur du jeton chez
Technolibre — et comme *.vault.yml est gitignoré, ce jeton ne voyageait avec
aucun dépôt : une voûte unique (D-19) qui ne l'était pas. Migration faite en
mémoire, avec relecture et aller-retour de chiffrement vérifiés avant écriture ;
fichier supprimé, listes de chargement des playbooks nettoyées, validé par un
appel API réel ne chargeant que all/vault.yml.
La garde qui manquait
voute.py verifier ne comparait que le gabarit — il disait « complet » pendant
qu'un secret vivait ailleurs. Il contrôle maintenant aussi la voûte réelle quand
ANSIBLE_VAULT_PASSWORD_FILE la rend déchiffrable : noms de clés seulement,
jamais de valeur, et vérification sautée sans mot de passe.
Elle a trouvé un second trou dès son premier passage : la voûte réelle de
Technolibre n'a ni vault_nextcloud_admin ni vault_nextcloud_oidc, que le plan
exige. Non corrigé — générer ces secrets est une décision, et celui d'OIDC doit
correspondre à ce que Keycloak connaîtra.
27 preuves OK. --syntax-check et ansible-lint (production) sur les playbooks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:52:44 -04:00
|
|
|
# Deuxieme volet : la voute REELLE, quand elle est lisible ici. Le gabarit dit
|
|
|
|
|
# ce qu'il faudrait ; lui seul ne prouve rien sur ce qui est reellement en place.
|
|
|
|
|
reelles = cles_voute_reelle()
|
|
|
|
|
absentes_reelles: list[str] = []
|
|
|
|
|
if reelles is not None:
|
voutes : le monde physique a la sienne, les tenants n'en portent plus les cles
Demande de l'exploitant : « l'underlay et son tenant doivent avoir chacun sa voute ».
CE QUI ETAIT FAUX. Le jeton d'API du cluster et la cle d'API de la frontiere vivaient dans
la voute de CHAQUE tenant. Patient 0 a du les recopier pour exister. Consequence : on ne
pouvait plus revoquer l'acces d'un locataire sans le revoquer pour tous — la faute des
neuf copies, appliquee aux secrets.
CE QUI EST POSE :
- `underlay.vault.yml`, chez l'hebergeur, a cote d'underlay.yml. Quatre secrets deplaces
(jeton Proxmox, cle et secret d'API OPNsense), retires des deux voutes de tenants.
- `proxmox_api.voute()` lit l'underlay APRES le tenant, donc l'hebergeur fait foi ; un site
non encore migre continue de fonctionner sur son ancienne voute.
- `appliquer_opnsense._voute()` n'a plus sa propre lecture : elle appelle celle du cluster.
La reecrire aurait fait une dixieme copie le jour ou l'on refermait les neuf autres.
- Le playbook de clonage charge la voute de l'underlay APRES celle du tenant, par la meme
derivation que proxmox-hebergeur.yml : le symlink designe deja l'hebergeur.
- `voute.py` sait que ces secrets ne sont plus attendus chez un tenant (P18).
EPROUVE SUR LE REEL, apres retrait des cles chez les deux tenants : l'API du cluster
repond, le devis de placement est conforme pour les deux ecosystemes, le devis de
frontiere se genere (86 objets), le SDN est convergent.
UNE PRECAUTION APPRISE EN CHEMIN : le premier essai a ecrit la voute EN CLAIR avant de la
chiffrer, et le chiffrement a echoue — il a fallu detruire le fichier. La sequence est
desormais l'inverse : chiffrer dans un dossier de travail, ne deposer que le resultat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:13:48 -04:00
|
|
|
attendues = (set(exiges) | SECRETS_HORS_MOTIF) - SECRETS_MONDE_PHYSIQUE
|
proxmox : pools créés, jeton normalisé, reliquat de voûte supprimé
Reconnaissance en lecture seule de l'API du cluster, avec le jeton de la voûte.
Trois valeurs devinées étaient fausses, et deux défauts bloquants sont apparus.
Corrigé d'après le cluster
- stockages : truenas-dbsql manquait ; le catalogue ne garde que ceux qui
portent `images` (PBS, cephFS, local et truenas iSCSI n'accueillent pas de
disque de VM) ;
- ponts : vmbr0 avait été omis, et l'uniformité sur les trois nœuds n'avait pas
été vérifiée — un pont partiel empêche la VM de démarrer sur certains nœuds.
Pools
Chezlepro-17 et Technolibre-11 créés, dérivés comme le reste. Les pools
Env.Tenant antérieurs (Prod.Chezlepro, Lab.KBR...) sont l'ancien monde : on n'y
touche pas et on n'y verse pas la flotte générée. Diff réel : 2 pools ajoutés,
0 retiré, 1 VM sur 38 déplacée — infra-pki-01, qui n'avait aucun pool.
Défaut bloquant : make creer-vm aurait échoué en 401
proxmoxer recompose `utilisateur!nom` à partir d'api_user et d'api_token_id. La
voûte stocke la forme complète, que les playbooks passaient telle quelle, d'où
un 401 muet — alors que le même jeton fonctionne en curl. Mesuré des deux côtés
avec un module en lecture seule : forme complète = 401, forme courte = OK.
Normalisation par split('!') | last, qui accepte les deux écritures.
Reliquat proxmox.vault.yml supprimé
Toléré « en compatibilité », il restait le seul porteur du jeton chez
Technolibre — et comme *.vault.yml est gitignoré, ce jeton ne voyageait avec
aucun dépôt : une voûte unique (D-19) qui ne l'était pas. Migration faite en
mémoire, avec relecture et aller-retour de chiffrement vérifiés avant écriture ;
fichier supprimé, listes de chargement des playbooks nettoyées, validé par un
appel API réel ne chargeant que all/vault.yml.
La garde qui manquait
voute.py verifier ne comparait que le gabarit — il disait « complet » pendant
qu'un secret vivait ailleurs. Il contrôle maintenant aussi la voûte réelle quand
ANSIBLE_VAULT_PASSWORD_FILE la rend déchiffrable : noms de clés seulement,
jamais de valeur, et vérification sautée sans mot de passe.
Elle a trouvé un second trou dès son premier passage : la voûte réelle de
Technolibre n'a ni vault_nextcloud_admin ni vault_nextcloud_oidc, que le plan
exige. Non corrigé — générer ces secrets est une décision, et celui d'OIDC doit
correspondre à ce que Keycloak connaîtra.
27 preuves OK. --syntax-check et ansible-lint (production) sur les playbooks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:52:44 -04:00
|
|
|
absentes_reelles = sorted(attendues - reelles)
|
|
|
|
|
if absentes_reelles:
|
|
|
|
|
print(f"erreur: {len(absentes_reelles)} secret(s) absent(s) de la VOUTE REELLE "
|
|
|
|
|
f"(group_vars/all/vault.yml) : {', '.join(absentes_reelles)}", file=sys.stderr)
|
|
|
|
|
print(" Un secret servi depuis un autre fichier (proxmox.vault.yml, "
|
|
|
|
|
"proxmox.local.yml) n'est pas dans la voute unique — cf. D-19.", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
if manquants or absentes_reelles or (inutiles and args.strict):
|
2026-07-22 21:32:42 -04:00
|
|
|
return 2
|
proxmox : pools créés, jeton normalisé, reliquat de voûte supprimé
Reconnaissance en lecture seule de l'API du cluster, avec le jeton de la voûte.
Trois valeurs devinées étaient fausses, et deux défauts bloquants sont apparus.
Corrigé d'après le cluster
- stockages : truenas-dbsql manquait ; le catalogue ne garde que ceux qui
portent `images` (PBS, cephFS, local et truenas iSCSI n'accueillent pas de
disque de VM) ;
- ponts : vmbr0 avait été omis, et l'uniformité sur les trois nœuds n'avait pas
été vérifiée — un pont partiel empêche la VM de démarrer sur certains nœuds.
Pools
Chezlepro-17 et Technolibre-11 créés, dérivés comme le reste. Les pools
Env.Tenant antérieurs (Prod.Chezlepro, Lab.KBR...) sont l'ancien monde : on n'y
touche pas et on n'y verse pas la flotte générée. Diff réel : 2 pools ajoutés,
0 retiré, 1 VM sur 38 déplacée — infra-pki-01, qui n'avait aucun pool.
Défaut bloquant : make creer-vm aurait échoué en 401
proxmoxer recompose `utilisateur!nom` à partir d'api_user et d'api_token_id. La
voûte stocke la forme complète, que les playbooks passaient telle quelle, d'où
un 401 muet — alors que le même jeton fonctionne en curl. Mesuré des deux côtés
avec un module en lecture seule : forme complète = 401, forme courte = OK.
Normalisation par split('!') | last, qui accepte les deux écritures.
Reliquat proxmox.vault.yml supprimé
Toléré « en compatibilité », il restait le seul porteur du jeton chez
Technolibre — et comme *.vault.yml est gitignoré, ce jeton ne voyageait avec
aucun dépôt : une voûte unique (D-19) qui ne l'était pas. Migration faite en
mémoire, avec relecture et aller-retour de chiffrement vérifiés avant écriture ;
fichier supprimé, listes de chargement des playbooks nettoyées, validé par un
appel API réel ne chargeant que all/vault.yml.
La garde qui manquait
voute.py verifier ne comparait que le gabarit — il disait « complet » pendant
qu'un secret vivait ailleurs. Il contrôle maintenant aussi la voûte réelle quand
ANSIBLE_VAULT_PASSWORD_FILE la rend déchiffrable : noms de clés seulement,
jamais de valeur, et vérification sautée sans mot de passe.
Elle a trouvé un second trou dès son premier passage : la voûte réelle de
Technolibre n'a ni vault_nextcloud_admin ni vault_nextcloud_oidc, que le plan
exige. Non corrigé — générer ces secrets est une décision, et celui d'OIDC doit
correspondre à ce que Keycloak connaîtra.
27 preuves OK. --syntax-check et ansible-lint (production) sur les playbooks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:52:44 -04:00
|
|
|
if reelles is None:
|
|
|
|
|
print(f"Gabarit de voute complet : {len(exiges)} secret(s) exige(s), tous presents. "
|
|
|
|
|
f"(Voute reelle non lisible ici : verification sautee.)")
|
|
|
|
|
else:
|
|
|
|
|
print(f"Gabarit de voute complet : {len(exiges)} secret(s) exige(s), tous presents. "
|
|
|
|
|
f"Voute reelle : {len(reelles)} cle(s), aucun manque.")
|
2026-07-22 21:32:42 -04:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|