Compare commits

...

11 commits

Author SHA1 Message Date
35f3881218 recette : un depot occupe n est pas une sauvegarde cassee
Some checks are pending
verifier / verifier (push) Waiting to run
make valider rendait ECHEC sur data-sql-01, et la meme commande rejouee a la main
restaurait 5 fichiers. restic verrouille son depot pendant qu il ecrit : une recette
qui croise la fenetre de sauvegarde lit un echec de RESTAURATION la ou il n y a
qu une attente. Verdict juste sur l instant, faux sur le fond, et DEPENDANT DE
L HEURE a laquelle on la lance.

ON REESSAIE, MAIS ON NE MASQUE PAS. Trois tentatives espacees couvrent un verrou
d ecriture ; au-dela, l echec est REEL et la recette rend la RAISON que restic a
donnee, au lieu d un -1 muet qui obligeait a se connecter pour comprendre.

CONTROLE VERIFIE SUR LA MACHINE : cle de dechiffrement retiree ->
« ECHEC apres 3 tentatives — restic dit : Fatal: Resolving password failed ».
La recette attrape toujours une vraie panne, et elle en donne la cause.

DEUX FAUTES A MOI EN CHEMIN, CORRIGEES.

`[ "$f" -lt 0 ] && printf ...` : la DERNIERE commande d un script decide de son code
de sortie, et un test faux rend 1. La tache echouait donc exactement sur les
machines ou la restauration avait REUSSI — trois hotes verts declares en echec.

Puis la ligne `raison=` n etait emise qu en cas d echec : le gabarit cherchait un
champ absent, rendait None, et explosait sur les DIX machines saines. Elle est
desormais TOUJOURS emise, vide en cas de succes. Un champ toujours present coute un
octet et supprime un cas.

make valider : rc=0. make verifier : vert. make prouver : 55 OK, 0 echec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-31 20:41:39 -04:00
0dd57ba995 artefacts : deux filets — un cache mort ne doit plus rendre un tenant irreparable
CE CACHE EST UN MAILLON DONT DEPEND SA PROPRE RECONSTRUCTION. Toute la flotte y
prend ses paquets. S il pointe vers un amont mort, apt echoue sur les quinze
machines, donc le socle echoue, donc le deploiement n atteint jamais la couche qui
poserait le bon amont. LE CORRECTIF SE RETROUVE DANS LA COUCHE QUE LA PANNE EMPECHE
D ATTEINDRE, et il faut une main pour en sortir.

C est arrive le 2026-08-31 : l amont pointait sur le cache de patient 0, eteint la
veille pour liberer de la RAM. apt rendait « 503 Connection timeout » EN CITANT
L ADRESSE DU CACHE LOCAL, jamais celle de l amont manquant — la panne accusait le
maillon visible.

DEUX FILETS, SYMETRIQUES.

serveur_artefacts sonde l amont AVANT d ecrire, et refuse s il est muet : mieux
vaut un cache qui garde sa configuration precedente qu un cache qu on vient de
rendre inutilisable pour toute la flotte.

client_artefacts sonde la source AVANT de detourner apt vers elle. Ce fichier
ECRASE le plancher d amorcage pose par le socle — le poser sur un cache mort prive
la machine du cache du SITE, qui lui fonctionnait. En refusant, le plancher reste :
la flotte est degradee mais debout, et REPARABLE PAR UN DEPLOIEMENT.

`ignore_errors` ET NON `failed_when: false` — la difference est toute la garde.
failed_when: false REECRIT le verdict : la tache n est plus jamais failed, donc
`is not failed` est toujours vrai, donc l assertion ne peut pas tirer. Ecrite ainsi,
ma premiere version a declare « joignable » un amont mesure MUET la seconde d avant.
Une garde qui ne peut pas echouer ne garde rien.

Deux controles verifies sur la machine : amont eteint -> refuse ; amont reel -> pose.

make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-31 18:31:21 -04:00
e2bc235132 audit + timers : deux services en echec sur les quinze machines, muets depuis toujours
`make prouver` disait 15/15 a failed=0. Les machines, elles, portaient chacune trois
unites systemd en echec. Le rapport d Ansible n est pas l etat d une machine.

AUDITD : ONZE REGLES ARMEES, PERSONNE POUR COLLECTER.

Deux fichiers de regles identiques cohabitaient — 99-chezlepro.rules et
99-setops.rules, vestige du renommage du role. augenrules CONCATENE rules.d/ :

    Error sending add rule data request (Rule exists)
    There was an error in line 16 of /etc/audit/audit.rules

audit-rules echoue, et auditd ne demarre pas — c est sa dependance. Resultat :
auditctl -l affiche onze regles, ce qui donne toutes les apparences d un audit qui
fonctionne, et rien ne les enregistre.

Meme mue que ssh_baseline, meme registre : auditd_fichiers_perimes. On n y ajoute
que des noms qu on a REELLEMENT deposes un jour.

Et `failed_when: false` cachait la panne : quinze machines a failed=0 avec auditd
mort sur les quinze. Un service de securite qui ne demarre pas doit se VOIR.

TIMERS APT-DAILY : UN ETAT D ECHEC RESIDUEL.

Masquer le service pendant que son timer tourne lui fait perdre sa cible ; systemd
le note et le GARDE (Unit to trigger vanished). `state: stopped` n efface pas un
etat failed — seul reset-failed le fait.

Ce n est pas cosmetique : une supervision qui compte les unites en echec compte ces
deux-la pour toujours, et la vraie panne s y noiera. Meme defaut que le journal de
la frontiere noye sous 982 000 entrees.

DIAGNOSTIC FAUX, CORRIGE : j avais lu « masked enabled » dans list-unit-files comme
un etat contradictoire, et construit une reparation pour le defaire. Ces colonnes
sont ETAT puis PRESET — masque avec un prereglage constructeur active est normal.
La reparation a ete annulee avant d etre livree.

make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-31 16:16:13 -04:00
fde0903ed8 flux + postfix : un port symbolique dans le pare-feu, un rechargement qui n applique rien
Le deploiement depuis ops-01 passe de 3 plays a 21 : douze machines sur quinze
deployees completement. Deux defauts restaient.

UN PORT SYMBOLIQUE ECRIT TEL QUEL.

    /etc/nftables.conf:36: Could not resolve service: Servname not supported
        ip saddr { ... } udp dport derive accept   # serveur_powerdns

`port: derive` dit que le port depend du deploiement. Le devis de la frontiere le
resout depuis le 2026-08-25 ; le generateur nftables ecrivait le mot, et nft
refusait TOUT le fichier. Il resout desormais par le plan et n emet rien quand il
ne peut pas, en le DISANT — un flux tu en silence est une porte qu on croit
ouverte. Verifie que ca ne ferme rien : infra-dns-01 garde son 53 par
serveur_resolveur, et l omission de PowerDNS est juste puisqu il ecoute en loopback
derriere lui.

LA VALIDATION NE POSE PAS LA MEME QUESTION QUE L EMISSION. Ma premiere garde
refusait tout port non numerique et a fait echouer P09, qui valide les roles HORS
instance — la ou derive est legitime. PORTS_SYMBOLIQUES nomme le vocabulaire : le
mot passe a la validation, jamais dans un fichier, et un mot inconnu reste refuse
des deux cotes.

RECHARGER N APPLIQUE PAS UN CHANGEMENT D ECOUTE, deuxieme fois.

    warning: to change inet_protocols, stop and start Postfix
    fatal: :::submission: Address family for hostname not supported

main.cf porte inet_protocols, que Postfix refuse de changer a chaud. Le master garde
all, tente d ouvrir :::submission en IPv6 et meurt — APRES avoir accepte une
configuration valide. postfix check ne dit rien parce que la configuration EST
valide : c est la transition qui ne l est pas. Meme famille que nginx. main.cf
notifie desormais le redemarrage.

Diagnostic corrige en chemin : j ai d abord accuse postfix@-.service, dont
l assertion echouait — c est moi qui l avais declenche par un demarrage manuel,
postfix.service le declare en Conflicts.

make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-30 13:00:54 -04:00
a5c9a88f00 resolveur du site : le troisieme service prete pendant la jeunesse d un tenant
Apres les paquets (serveur_cache_site) et le genome (serveur_forge_site), les NOMS.
Meme patron : un installateur, un marqueur.

CE QUE CA CORRIGE, mesure sur infra-pki-01, premiere machine a porter l autorite
de Chezlepro :

    DNS               BLOQUE      un tenant resout chez lui, sa requete ne sort pas
    443 sortant       OK          par adresse
    apt via le cache  OK          le mandataire resout a sa place

apt s en sortait ; TOUT LE RESTE ETAIT AVEUGLE AUX NOMS. Versionner la cle de
signature Smallstep a fait passer une tache et l echec s est deplace d un cran : le
depot lui-meme devait etre joint par son nom.

POURQUOI PAS L UNBOUND DE LA FRONTIERE : il tourne sur le boitier sans etre un
service gere, et le plan du site l avait deja ecrit une fois — un processus n est
pas un service. site-dns-01 est deploye par le moteur et verifie par le harnais.

OU VIT LA DERIVATION : dans site_inventaire.py, qui connait le plan du site ET le
registre des tenants. Le role 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.

PIEGE DE FILTRE, ATTRAPE EN LE BRANCHANT : _supernets_voisins() EXCLUT l instance
montee (personne n est son propre voisin). Le site sert TOUS ceux qu il heberge — y
compris celui qu on deploie. Reutilise tel quel, il privait de resolution le seul
tenant en cours de montage. On reutilise donc la DECOUVERTE partagee sans son
filtre : deux recensements divergent, deux filtres non.

Le marqueur verifie deux choses : que le resolveur ECOUTE, et que sa liste
d autorisation ADMET reellement les tenants. Sans la seconde, la panne chez le
locataire ressemblerait a un DNS mort alors que c est une ACL.

make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-30 12:37:33 -04:00
fc76a4e252 step-ca : la cle de signature versionnee — plus aucun appel sortant pour l obtenir
Le deploiement de Chezlepro depuis son propre runner a franchi le socle et le
durcissement sur les quinze machines, et s est arrete sur une seule :

    infra-pki-01 : Echec temporaire dans la resolution du nom
    url: https://packages.smallstep.com/keys/apt/repo-signing-key.gpg

UN TENANT N A PAS DE DNS SORTANT, ET C EST VOULU. Il resout chez lui, sa requete ne
traverse jamais la frontiere. apt s en sort par le mandataire du cache, qui resout a
sa place ; get_url n a pas de mandataire. Mesure sur la machine : DNS bloque, 443
sortant OK par adresse, apt via le cache OK.

Les cinq reprises du role ne pouvaient rien : elles etaient ecrites pour un serveur
INTERMITTENT (2026-08-23), pas pour une resolution fermee. Une reprise ne repare que
ce qui est passager.

UNE DETTE NOMMEE, APPELEE. client_artefacts le disait : les depots tiers en HTTPS
vont en direct, la seconde voie est propre et RESTE A FAIRE. Elle a ete appelee le
jour ou un ecosysteme s est reconstruit derriere une frontiere qui fait son travail.

La cle est versionnee, avec son empreinte et sa procedure de rafraichissement. Meme
idiome que les collections et les roues : le depot porte, la cible n ouvre rien.
C est ce qui rend une lignee reproductible SANS INTERNET.

Cout ecrit : une rotation amont n est plus recuperee toute seule, mais elle ne passe
pas inapercue — apt refuse alors le depot, bruyamment.

TROUVE EN LA RECUPERANT : l URL publique REDIRIGE vers pkgs.infra.smallstep.com.
Sans suivre les redirections on obtient un fichier VIDE, et curl -f sans -L sort en
succes. La procedure le dit, parce que ce piege ne se voit qu au deploiement suivant.

Le correctif du pare-feu a tenu : plus une seule suspension, quinze machines a
ok=66 et 0 unreachable.

make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-30 12:13:39 -04:00
6f4833e65c nftables : armer le pare-feu coupait la main qui l arme
Une session SSH ouverte AVANT le demarrage de nftables n a aucune entree
conntrack. Des que le ruleset s installe, ses paquets arrivent en invalid et
tombent sur la regle ct state invalid drop : la session meurt. Les connexions
NEUVES passent sans probleme. Le pare-feu est correct ; c est la continuite qui ne
l est pas.

CE N EST PAS UN VERROUILLAGE, ET C EST CE QUI LE REND DANGEREUX. Rien n echoue :
Ansible attend une reponse qui ne viendra jamais, sans delai. Un deploiement est
reste suspendu CINQUANTE-HUIT MINUTES sur six machines pendant que les quinze
repondaient parfaitement a qui se connectait a neuf.

async + poll 0 : on lance et on ne guette pas une reponse qui ne peut pas revenir.
reset_connection jette la session morte, wait_for_connection en ouvre une neuve,
suivie par conntrack des sa poignee.

Le delai est une ATTENTE, pas un verdict : si l hote ne revient pas, c est un vrai
verrouillage — la garde nftables_admin_ssh a manque sa cible — et il faut le dire
fort plutot que de laisser un playbook pendre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-30 10:00:18 -04:00
bb63865a37 cles : le terrain etait inoccupable — la cle du tenant nait avec ses machines
Le deploiement lance depuis ops-01 s est arrete au premier geste, sur les quinze
machines a la fois : Permission denied (publickey). Les VM neuves n acceptaient que
la cle de l exploitant. Celle du runner EST declaree au plan, mais c est le SOCLE
qui la depose — et le socle doit etre applique par quelqu un qui peut deja entrer.
Boucle fermee : le tenant recevait un terrain qu il ne pouvait pas occuper.

DEUX CLES, DEUX PORTEES :

    la cle du SITE     -> sur le SEUL runner du tenant     l insemination
    la cle du TENANT   -> sur TOUTES ses machines          il va les configurer

POSER UNE CLE AU CLONAGE N EST PAS ENTRER CHEZ LE TENANT. C est un parametre de
creation, au meme titre que l adresse ou le disque : le site ecrit les conditions
de NAISSANCE, il n ouvre aucune session. Le site n obtient aucun acces sur ces
machines ; seul le runner du tenant en obtient un.

Forme tranchee par l exploitant : le site renseigne le seul runner, qui se charge
de toute sa flotte. Le plancher /etc/hosts suit le meme chemin — ops-01 a le sien
depuis son insemination et resout ses quinze voisines par leur nom.

LA REVOCATION EST HONOREE A LA NAISSANCE : une entree a etat absent n est pas
reposee. Sans cette lecture, une cle retiree de la flotte serait ressuscitee sur
chaque VM creee ensuite — panne lente, silencieuse, invisible au plan.

P55 garde les deux moities : la cle du site ne nait que sur un porteur de
serveur_ops_tenant, et aucune machine ne reste sans celle de son tenant. Une
frontiere tenue a une seule couche n est pas tenue. Deux controles negatifs.

make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-30 08:54:17 -04:00
f2f6cccd3e clonage : l hyperviseur juge le demarrage, plus le chronometre du module
Quinze VM creees trois a la fois. L une a depasse le delai du module PENDANT QUE
PROXMOX GENERAIT ENCORE SON ISO CLOUD-INIT :

    Reached timeout while waiting for starting VM.
    Last line in task before timeout: generating cloud-init ISO

Elle a demarre juste apres. flotte-creer a rendu « au moins une VM n a pas ete
creee » alors que les quinze tournaient. CE FAUX ECHEC ARRETE UNE RECONSTRUCTION :
reconstruire enchaine flotte-creer puis deployer-tout.

DEUX CORRECTIONS, LA SECONDE EST LA VRAIE. Delai plus genereux, parce que la
generation d ISO sur stockage partage se met en file quand trois clonages tombent
ensemble. Mais un delai reste un pari : son expiration ne conclut plus rien.
L HYPERVISEUR JUGE, en repondant ce qu il fait tourner — meme principe que P52, ou
l agent invite juge la materialisation a la place d une reponse SSH.

Logique verifiee sur quatre cas : seul running passe ; VM arretee, introuvable et
reponse malformee echouent. Une reponse vide ne passe pas en silence. Rejoue sur
une VM deja en marche : idempotent.

CE QUE LA CREATION DE CETTE NUIT N A PAS PROUVE : les quinze VM ont ete
materialisees DEPUIS LE POSTE, pas depuis le runner du SITE. Le chemin existait —
make inseminer, ecrit la veille — et il n a pas servi. Le poste detient
legitimement la voute du site, donc ce n est pas une faute de pouvoir ; c est une
demonstration qui manque.

make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-30 08:28:13 -04:00
5c59f19530 voutes : une cle absente n est pas une faute — elle est souvent la bonne reponse
Sur le runner d un tenant, la cle du SITE DOIT manquer : il porte la carte de la
fabric et ne doit jamais pouvoir l ouvrir. Ma premiere version sortait en erreur
des qu une cle manquait — elle presentait une SEPARATION REUSSIE comme un defaut,
et a fait echouer une chaine parfaitement saine.

Seule l absence de la cle de l INSTANCE MONTEE empeche la machine de travailler.
C est elle, et elle seule, qui decide du code de sortie ; le reste s affiche.

Constate sur ops-01 le jour de son armement :

    instance    OPS-Chezlepro     cle presente
    hebergeur   SITE-Chezlepro    sans cle

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 18:41:21 -04:00
732aff9c70 sdn : le puits avalait le plan d administration — une route, et le chemin existe
CE QUE LA MESURE A ETABLI, saut par saut. Le poste de l exploitant atteint la
frontiere, qui LAISSE PASSER (rule 31/0 match, pass out vlan040). Asgard RECOIT le
paquet sur vlan40. La VM ne le voit jamais. Et le noyau dit pourquoi :

    ip route get 10.17.19.41 from 10.0.31.11 iif vlan40  ->  dev vrf_t17
    ip route get 10.17.19.41 from 10.17.0.17 iif vlan40  ->  Invalid cross-device link

LA SOURCE EST LE DISCRIMINANT, pas l interface. Le chemin de RETOUR, vu du VRF :

    vers 10.0.31.11  ->  via 10.0.4.1 dev vlan40
    vers 10.17.0.17  ->  Invalid argument          <- le puits

LE PUITS A SES RAISONS et on n y touche pas : sans lui, une adresse non attribuee
du supernet sort par le defaut, revient par la frontiere dans la table PRINCIPALE
et repart vers le reseau de gestion (mesure du 2026-08-09). Le defaut n est pas
qu il existe, c est qu il est TROP LARGE : il couvre la bande basse ou D-77 place
justement l underlay d un site. Deux regles justes separement, contradictoires
ensemble.

LA CORRECTION EST DERIVEE, PAS ECRITE : pour chaque tenant, les reseaux
d administration qu il DECLARE (nftables_admin_ssh) et qui tombent dans son propre
supernet recoivent une route vers la frontiere, plus specifique que le puits. Ceux
qui vivent dehors n en ont pas besoin — la route par defaut les joint deja.

    vrf_t17   ip route 10.17.0.0/24    ...     l admin de Chezlepro
    vrf_t23   (rien)                           le sien est hors de son supernet
    vrf_t29   ip route 10.29.19.41/32  ...     l admin de patient 0 : son ops-01

CE QUE CA REPARE AU-DELA DE L ACCES : les regles administration -> tenant de la
frontiere etaient VRAIES et INAPPLICABLES a la fois. Elles correspondaient, elles
laissaient passer, et le paquet mourait un saut plus loin. Un devis vert sur un
chemin qui ne pouvait pas aboutir.

make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 17:46:40 -04:00
34 changed files with 1576 additions and 77 deletions

View file

@ -1,5 +1,381 @@
# CHANGELOG — Set-OPS
## 2026-08-31 — La recette dependait de l'heure : un depot occupe n'est pas une sauvegarde cassee
**55 preuves. `make valider` : rc=0.** La reconstruction de Chezlepro est validee de bout
en bout — 16 cibles Prometheus UP, 6 vhosts HTTPS, un courriel **reellement remis** (envoi
→ LMTP → Maildir), et six depots restic **restaures et verifies**, dont l'annuaire dont on
prouve qu'il se rejoue (`slapadd -u`).
Un seul verdict rouge au premier passage : `data-sql-01`, la base. Et la meme commande,
rejouee a la main, restaurait ses cinq fichiers.
**`restic` verrouille son depot pendant qu'il ecrit.** Une recette qui croise la fenetre de
sauvegarde lit donc un echec de RESTAURATION la ou il n'y a qu'une attente — *verdict juste
sur l'instant, faux sur le fond, et dependant de l'heure a laquelle on l'a lancee.*
**On reessaie, mais on ne masque pas.** Trois tentatives espacees couvrent un verrou
d'ecriture ; au-dela l'echec est reel, et la recette rend desormais **la raison que restic
a donnee** au lieu d'un `-1` muet. Controle sur la machine — cle de dechiffrement retiree :
```
ECHEC — la RESTAURATION a échoué après 3 tentatives (snapshots=0)
— restic dit : Fatal: Resolving password failed
```
### Deux fautes a moi, en chemin
`[ "$f" -lt 0 ] && printf ...` : **la derniere commande d'un script decide de son code de
sortie**, et un test faux rend 1. La tache echouait donc exactement sur les machines ou la
restauration avait REUSSI — trois hotes verts declares en echec.
Puis la ligne `raison=` n'etait emise qu'en cas d'echec : le gabarit cherchait un champ
absent, rendait `None`, et explosait sur les **dix machines saines**. Elle est desormais
toujours emise, vide en cas de succes. *Un champ toujours present coute un octet et
supprime un cas.*
## 2026-08-31 — Deux filets : un cache mort ne rend plus un tenant irreparable
**55 preuves.** Le cache d'un tenant est **un maillon dont depend sa propre
reconstruction**. Toute la flotte y prend ses paquets ; s'il pointe vers un amont mort,
`apt` echoue sur les quinze machines, donc le socle echoue, donc le deploiement n'atteint
jamais la couche qui poserait le bon amont.
*Le correctif se retrouve dans la couche que la panne empeche d'atteindre* — et il faut
une main pour en sortir. C'est exactement ce qui s'est passe : l'amont pointait sur le
cache de patient 0, eteint la veille pour liberer de la RAM.
**`apt` rendait `503 Connection timeout` en citant l'adresse du cache LOCAL**, jamais
celle de l'amont manquant. La panne accusait le maillon visible.
### Deux filets, symetriques
`serveur_artefacts` sonde l'amont **avant** d'ecrire, et refuse s'il est muet : mieux vaut
un cache qui garde sa configuration precedente qu'un cache qu'on vient de rendre
inutilisable pour toute la flotte.
`client_artefacts` sonde la source **avant** de detourner `apt` vers elle. Ce fichier
ecrase le plancher d'amorcage pose par le socle — le poser sur un cache mort prive la
machine du cache du SITE, qui lui fonctionnait. En refusant, le plancher reste : la flotte
est degradee mais debout, et **reparable par un deploiement**.
### `ignore_errors` et non `failed_when: false` — la difference est toute la garde
`failed_when: false` **reecrit le verdict** : la tache n'est plus jamais `failed`, donc
`is not failed` est toujours vrai, donc l'assertion qui suit ne peut pas tirer. Ecrite
ainsi, ma premiere version a declare « joignable » un amont mesure **muet la seconde
d'avant**.
*Une garde qui ne peut pas echouer ne garde rien.* Deux controles verifies sur la machine :
amont eteint → refuse ; amont reel → pose.
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
## 2026-08-31 — Trois unites en echec sur chaque machine, et personne ne les voyait
**55 preuves.** Le deploiement rendait `15/15, failed=0`. Les machines portaient chacune
**trois unites systemd en echec**. *Le rapport d'Ansible n'est pas l'etat d'une machine.*
### auditd : onze regles armees, personne pour collecter
Deux fichiers de regles identiques cohabitaient — `99-chezlepro.rules` et
`99-setops.rules`, vestige du renommage du role. `augenrules` **concatene** tout
`rules.d/`, et le noyau refuse la seconde occurrence :
```
Error sending add rule data request (Rule exists)
There was an error in line 16 of /etc/audit/audit.rules
```
`audit-rules` echoue, et `auditd` ne demarre pas — c'est sa dependance. **`auditctl -l`
affichait pourtant onze regles**, ce qui donne toutes les apparences d'un audit qui
fonctionne. L'audit etait arme dans le noyau et personne ne l'enregistrait.
Meme mue que `ssh_baseline`, meme registre — `auditd_fichiers_perimes`. Et
`failed_when: false` cachait le reste : *un service de securite qui ne demarre pas doit
se voir.*
### Les timers apt-daily : un etat d'echec residuel
Masquer le service pendant que son timer tourne lui fait perdre sa cible ; systemd le note
et le **garde**. `state: stopped` n'efface pas un etat `failed` — seul `reset-failed` le
fait.
Ce n'est pas cosmetique. Une supervision qui compte les unites en echec compte ces deux-la
**pour toujours**, et la vraie panne s'y noiera. Meme defaut que le journal de la frontiere
noye sous 982 000 entrees : *ce qui ment le plus n'est pas ce qui se tait, c'est ce qui crie
sans raison.*
### Un diagnostic faux, annule avant d'etre livre
J'avais lu `masked enabled` dans `list-unit-files` comme un etat contradictoire, conclu que
masquer empechait de desactiver, et bati une reparation pour le defaire. **Ces colonnes
sont ETAT puis PRESET** : masque, avec un prereglage constructeur active, est parfaitement
normal. Mesure sans ambiguite ensuite : `is-enabled=masked`, `is-active=failed`. Le
masquage etait juste ; seule la trace restait.
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
## 2026-08-30 — Vingt-et-un plays : le resolveur ouvre la chaine, deux defauts tombent
**55 preuves.** Le deploiement depuis `ops-01` passe de **3 plays a 21**. Douze machines
sur quinze sont deployees completement (`ok=91` a `137`, `failed=0`). Le resolveur du site
a debloque tout ce qui suivait.
Trois echecs restants, deux causes — et les deux etaient de vrais defauts du moteur.
### Un port symbolique ecrit tel quel dans le pare-feu
```
/etc/nftables.conf:36: Error: Could not resolve service: Servname not supported
ip saddr { ... } udp dport derive accept # serveur_powerdns
```
`port: derive` dit *« ce port depend du deploiement »* — Forgejo ecoute 3000 derriere un
edge et 443 quand il sert son propre TLS ; PowerDNS 53 seul sur son hote et 5300 en
loopback derriere le resolveur. **Seul le plan sait lequel.** Le devis de la frontiere le
resout depuis le 2026-08-25 ; le generateur nftables, lui, ecrivait le mot.
Il resout desormais par le plan, et **n'emet rien** quand il ne peut pas — en le DISANT :
*un flux tu en silence est une porte qu'on croit ouverte.* Verifie que ca ne ferme rien :
`infra-dns-01` garde son 53, ouvert par `serveur_resolveur` ; l'omission de PowerDNS est
juste, puisqu'il ecoute en loopback derriere lui.
**Et la validation ne pose pas la meme question que l'emission.** Ma premiere garde
refusait tout port non numerique — elle a fait echouer P09, qui valide les roles **hors
instance**, la ou `derive` est parfaitement legitime. `PORTS_SYMBOLIQUES` nomme le
vocabulaire : le mot passe a la validation, jamais dans un fichier, et un mot inconnu reste
refuse des deux cotes. *Confondre les deux questions coute des deux cotes : un fichier
casse d'un cote, un role correct declare fautif de l'autre.*
### Recharger n'applique pas un changement d'ecoute — deuxieme fois
```
warning: ignoring inet_protocols parameter value change
warning: to change inet_protocols, stop and start Postfix
fatal: :::submission: Address family for hostname not supported
```
`main.cf` porte `inet_protocols`, que Postfix refuse de changer a chaud. Le `master` garde
`all`, tente d'ouvrir `:::submission` en IPv6, et meurt — **apres avoir accepte une
configuration valide**. `postfix check` ne dit rien, parce que la configuration EST valide :
c'est la TRANSITION qui ne l'est pas.
Meme famille que `nginx : restart si changement d'ecoute`. Ce qui vit dans le processus
maitre — protocoles, adresses, ports — exige qu'il reparte. `main.cf` notifie desormais le
redemarrage.
*Diagnostic corrige en chemin : j'ai d'abord accuse `postfix@-.service`, dont l'assertion
echouait sur `/etc/postfix-/main.cf`. C'etait MOI qui l'avais declenche en tentant un
demarrage manuel — `postfix.service` le declare en `Conflicts`. Le vrai journal etait
ailleurs.*
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
## 2026-08-30 — La cle de signature est versionnee : une lignee qui se reproduit sans Internet
**55 preuves.** Le deploiement de Chezlepro depuis son propre runner a franchi le socle et
le durcissement sur les quinze machines, et s'est arrete sur une seule :
```
infra-pki-01 : Request failed: <urlopen error [Errno -3]
Echec temporaire dans la resolution du nom>
url: https://packages.smallstep.com/keys/apt/repo-signing-key.gpg
```
**Un tenant n'a pas de DNS sortant, et c'est voulu.** Il resout chez lui, sa requete ne
traverse jamais la frontiere. `apt` s'en sort par le mandataire du cache — qui resout a sa
place ; `get_url` n'a pas de mandataire. Mesure sur la machine :
```
resolv.conf 9.9.9.9, 149.112.112.112
DNS BLOQUE
443 sortant OK (par adresse)
apt via le cache OK
```
Les cinq reprises du role ne pouvaient rien : elles etaient ecrites pour un serveur
**intermittent** (mesure du 2026-08-23), pas pour une resolution fermee. *Une reprise ne
repare que ce qui est passager.*
### Une dette nommee, appelee
C'etait ecrit dans `client_artefacts` : *« les depots tiers en HTTPS continuent d'aller en
direct […] la seconde voie est propre et **reste a faire** — et le dire vaut mieux que le
laisser croire »*. Elle a ete appelee le jour ou un ecosysteme s'est reconstruit derriere
une frontiere qui fait son travail.
**La cle est desormais versionnee**, avec son empreinte et sa procedure de rafraichissement.
Meme idiome que les collections Ansible et les roues Python : le depot porte, la cible
n'ouvre rien. C'est ce qui rend une lignee reproductible **sans Internet**.
*Ce que ca coute, et qui est ecrit : une rotation amont n'est plus recuperee toute seule.
Elle ne passe pas inapercue pour autant — `apt` refuse alors le depot, bruyamment. Mieux
vaut un refus lisible qu'un telechargement qui reussit depuis n'importe ou.*
**Trouve en la recuperant** : l'URL publique **redirige** vers `pkgs.infra.smallstep.com`.
Sans suivre les redirections, on obtient un fichier VIDE que rien ne signale — un `curl -f`
sans `-L` rend zero octet et sort en succes. La procedure de mise a jour le dit, parce que
ce piege-la ne se voit qu'au deploiement suivant.
### Le correctif du pare-feu a tenu
Plus une seule suspension : les quinze machines ont franchi le durcissement (`ok=66`
chacune, `0 unreachable`) et repris la main apres s'etre armees. C'est la vraie nouvelle
de ce passage — le defaut d'hier soir ne se reproduit plus.
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
## 2026-08-30 — Le terrain etait inoccupable : la cle du tenant nait avec ses machines
**55 preuves.** Le deploiement lance depuis `ops-01` s'est arrete au premier geste, sur les
quinze machines a la fois :
```
ops-01 -> toutes : Permission denied (publickey)
```
Les VM neuves n'acceptaient qu'une cle : celle de l'exploitant, posee par cloud-init. La
cle du runner **est** declaree au plan (`ssh_baseline_cles_admin`) — mais c'est le SOCLE
qui la depose, et le socle doit etre applique par quelqu'un qui peut deja entrer. *Boucle
fermee : le tenant recevait un terrain qu'il ne pouvait pas occuper.*
### Deux cles, deux portees, et la difference est toute l'architecture
```
la cle du SITE -> sur le SEUL runner du tenant l'insemination
la cle du TENANT -> sur TOUTES ses machines il va les configurer
```
**Poser une cle au clonage n'est pas entrer chez le tenant.** C'est un parametre de
creation, au meme titre que l'adresse ou le disque : le site ecrit les conditions de
NAISSANCE, il n'ouvre aucune session. La distinction n'est pas rhetorique — le site
n'obtient aucun acces sur ces machines, seul le runner du tenant en obtient un.
*C'est la forme que l'exploitant a tranchee : le site renseigne le seul runner, qui se
charge ensuite de toute sa flotte. Le plancher `/etc/hosts` suit le meme chemin — `ops-01`
a le sien depuis son insemination, il resout ses quinze voisines par leur nom (verifie :
`obs-01.chezlepro.internal:22` ouvert), et c'est lui qui posera le leur en appliquant le
socle. Le SITE n'a jamais a toucher une machine de tenant.*
### La revocation est honoree a la naissance
Une entree passee a `etat: absent` n'est pas reposee sur les VM neuves. Sans cette lecture,
une cle retiree de toute la flotte serait **ressuscitee sur chaque machine creee ensuite** —
une panne lente, silencieuse, et invisible au plan.
### P55 — le pouvoir se lit dans les cles, pas dans les intentions
Une VM recoit ses cles a la naissance, et personne ne relit un `authorized_keys` pose il y
a six mois. Si celle du site partait sur toute une flotte, l'hebergeur y gagnerait un acces
que rien ne declare. P55 verifie les deux moities : la cle du site ne nait que sur un
porteur de `serveur_ops_tenant`, et aucune machine ne reste sans celle de son tenant.
*C'est le pendant exact du flux d'insemination — une frontiere tenue a une seule couche
n'est pas tenue.* Deux controles negatifs verifies.
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
## 2026-08-30 — Le demarrage declarait en panne ce qui fonctionnait
**54 preuves.** Quinze VM de Chezlepro creees trois a la fois. L'une d'elles a depasse le
delai du module **pendant que Proxmox generait encore son ISO cloud-init** :
```
fatal: Reached timeout while waiting for starting VM.
Last line in task before timeout: 'generating cloud-init ISO'
```
Elle a demarre juste apres. Le module avait renonce, pas l'hyperviseur — et
`flotte-creer` a rendu *« au moins une VM n'a pas ete creee »* alors que **les quinze
tournaient**.
**Ce faux echec arrete une reconstruction.** `make reconstruire` enchaine `flotte-creer`
puis `deployer-tout` : la sequence se serait arretee la, sur une flotte complete et saine.
### Un delai reste un pari — on n'en fait plus un verdict
Deux corrections, et la seconde est la vraie. Le delai devient genereux, parce que la
generation d'ISO sur un stockage partage se met en file quand trois clonages tombent
ensemble. Mais son expiration ne conclut plus rien : **c'est l'hyperviseur qui juge**, en
repondant ce qu'il fait tourner.
C'est exactement le principe de P52, applique un cran plus tot — la, l'agent invite jugeait
la materialisation a la place d'une reponse SSH ; ici, l'API juge le demarrage a la place
d'un chronometre.
*Logique verifiee sur quatre cas : seul `running` passe ; VM arretee, VM introuvable et
reponse malformee echouent toutes. Une reponse vide ne passe pas en silence.* Rejoue sur
une VM deja en marche : idempotent.
### Ce que la creation de cette nuit n'a PAS prouve
Les quinze VM ont ete materialisees **depuis le poste**, pas depuis le runner du SITE. Le
chemin existait — `make inseminer` etait ecrit la veille — et il n'a pas servi. Rien de
cette nuit ne demontre donc que le runner du site sait materialiser une flotte : il a cree
une VM l'avant-veille, pas celles-ci.
*Ce n'est pas une faute de pouvoir : le poste detient legitimement la voute du site. C'est
une demonstration qui manque, et il faut le dire plutot que de laisser croire le contraire.*
make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.
## 2026-08-28 — Le puits avalait le plan d'administration
**54 preuves.** Le poste de l'exploitant ne joignait aucune machine de tenant. La chaîne,
mesurée saut par saut :
```
1. le poste 10.17.0.17 -> 10.17.19.41:22 route et source correctes
2. la frontiere rule 31/0(match): pass out vlan040 ELLE LAISSE PASSER
3. asgard vlan40 In ... 10.17.19.41.22 IL RECOIT
4. la VM (rien) IL N'ARRIVE JAMAIS
```
Et le noyau dit pourquoi — **la source est le discriminant, pas l'interface** :
```
ip route get 10.17.19.41 from 10.0.31.11 iif vlan40 -> dev vrf_t17
ip route get 10.17.19.41 from 10.17.0.17 iif vlan40 -> Invalid cross-device link
```
Parce que le **retour** est impossible : depuis le VRF du tenant, `10.17.0.17` tombe dans
`blackhole 10.17.0.0/16`.
### Le puits a ses raisons — c'est sa largeur qui était fausse
Sans lui, une adresse non attribuée du supernet sort par le défaut, revient par la
frontière **dans la table principale** et repart vers le réseau de gestion (mesuré le
2026-08-09). On n'y touche pas.
Mais il couvre la **bande basse**, là où D-77 place justement l'underlay d'un site. *Deux
règles justes séparément, contradictoires ensemble.*
### La correction est dérivée, pas écrite
Pour chaque tenant, les réseaux d'administration **qu'il déclare** (`nftables_admin_ssh`)
et qui tombent dans son propre supernet reçoivent une route vers la frontière, plus
spécifique que le puits. Ceux qui vivent dehors n'en ont pas besoin.
```
vrf_t17 ip route 10.17.0.0/24 ... l'admin de Chezlepro
vrf_t23 (rien) le sien est hors de son supernet
vrf_t29 ip route 10.29.19.41/32 ... l'admin de patient 0 : son propre ops-01
```
### Ce que ça répare au-delà de l'accès
Les règles `administration → tenant` de la frontière étaient **vraies et inapplicables à
la fois**. Elles correspondaient, elles laissaient passer, et le paquet mourait un saut
plus loin. *Un devis vert sur un chemin qui ne pouvait pas aboutir* — exactement le
« périmètre vide » que ce dépôt traque partout ailleurs.
*Trois instruments m'ont menti avant d'y arriver : une capture qui ne tournait pas, un
`ping` vers un ICMP que rien n'autorise, et un test TCP depuis la frontière dont la source
n'est pas dans l'IPSet de la VM. La mesure qui a tranché est `ip route get ... iif`,
comparée entre une source qui marche et une qui échoue.*
make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.
## 2026-08-28 — `make inseminer` : le geste sort de mes mains et entre dans le depot
**54 preuves.** L'insemination a d'abord ete conduite A LA MAIN depuis le runner du site —

View file

@ -0,0 +1,91 @@
# Preuve de conformite — Set-OPS — 2026-08-30
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (55 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 39 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 37 rôles, 97 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 15 hotes, 36 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 11 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 51 groupe(s), 81 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 15 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 31 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 20, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 53 scripts expliques et atteignables, 106 cibles make documentees, 63 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 41 exigence(s) de role, toutes satisfaites (130 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (38 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (27 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 38 role(s) serveur/client tous nommes, 39 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 49 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 66 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 84 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (115 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 131 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 15 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 15. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-08-30._

View file

@ -0,0 +1,91 @@
# Preuve de conformite — Set-OPS — 2026-08-31
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (55 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 39 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 37 rôles, 97 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 15 hotes, 36 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 11 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 51 groupe(s), 81 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 15 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 31 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 20, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 53 scripts expliques et atteignables, 106 cibles make documentees, 63 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 41 exigence(s) de role, toutes satisfaites (130 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (38 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (28 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 38 role(s) serveur/client tous nommes, 39 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 49 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 66 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 84 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (115 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 131 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 15 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 15. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-08-31._

View file

@ -23,10 +23,10 @@ README de rôles). Cette page comble ces deux trous.
| Ce qu'on compte | Combien | Comment on le mesure |
|---|---|---|
| rôles | 62 | `roles/*/` |
| README de rôles | 62 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| rôles | 63 | `roles/*/` |
| README de rôles | 63 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 38 | `docs/*.md` |
| pièces d'audit | 29 | `docs/audit/*` |
| pièces d'audit | 31 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 78 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |

View file

@ -56,6 +56,7 @@ Ce que la reconstruction couvre, par capacité :
| Resolution | `serveur_resolveur`, `client_resolveur` | UN resolveur recursif par tenant (Unbound), qui recurse depuis la racine et delegue la zone souveraine a PowerDNS. Remplace les N demons locaux d'avant le 2026-08-24 |
| Cache du site | `serveur_cache_site` | designe LE cache que les ecosystemes voisins prennent comme amont : Debian telecharge une fois pour toute la fabric, et le cache ne voit que des requetes agregees. Reserve a l'ecosysteme de l'hebergeur |
| Forge du genome du site | `serveur_forge_site` | designe LA forge dont les ecosystemes de ce site se reproduisent (D-81), et l'ouvre a eux. Marqueur : `serveur_forgejo` installe. |
| Resolveur du site | `serveur_resolveur_site` | designe LE resolveur que les ecosystemes de ce site interrogent tant qu'ils n'ont pas le leur. Marqueur : `serveur_resolveur` installe. |
| Agents de flotte | `client_metrique`, `client_journal`, `client_smtp` | collecte et relais sur toute la flotte |
*Rôles retirés (2026-07-04, supersédés ou hors conception)* : `serveur_sendmail`

View file

@ -62,6 +62,8 @@ couches:
# La forge du genome, meme nature : elle vit dans l'underlay de
# l'hebergeur et un tenant la CONSOMME sans jamais la deployer.
- serveur_forge_site
# Le resolveur du site, meme nature : prete aux locataires pendant leur jeunesse.
- serveur_resolveur_site
- nom: apps
raison: "Les applications métier, qui consomment les services (base, SSO, courriel, edge)."
groupes:

View file

@ -94,6 +94,8 @@
| `serveur_resolveur` | ingress | 53 | udp | flotte | clair | Toute la flotte du tenant résout ici — et nulle part ailleurs. |
| `serveur_resolveur` | ingress | 53 | tcp | flotte | clair | Réponses longues et bascule TCP, obligatoires en DNS. |
| `serveur_resolveur` | egress | 53 | udp | externe | clair | Récursion depuis les serveurs racine. Aucun transitaire : l'écosystème ne confie ses questions à personne. |
| `serveur_resolveur_site` | ingress | 53 | udp | voisins_site | clair | Les écosystèmes de ce site résolvent ici tant qu'ils n'ont pas leur propre résolveur. `client_resolveur` les bascule chez eux dès qu'il existe ; retirer cet emprunt est alors une émancipation. |
| `serveur_resolveur_site` | ingress | 53 | tcp | voisins_site | clair | Réponses longues et bascule TCP, obligatoires en DNS. |
| `serveur_rspamd` | ingress | 11332 | tcp | localhost | clair | Protocole milter consommé par Postfix co-localisé (analyse + signature DKIM). Local uniquement. |
| `serveur_rspamd` | ingress | 11334 | tcp | localhost | clair | Interface de contrôle rspamd (statistiques, apprentissage) en local. |
| `serveur_step_ca` | ingress | 8443 | tcp | flotte | tls-requis | ACME + API step-ca : chaque nœud (client_pki) émet/renouvelle ses certificats et récupère la racine. |
@ -104,7 +106,7 @@
## Synthèse chiffrement
- **clair** : 35 flux
- **clair** : 37 flux
- **n-a** : 3 flux
- **ssh** : 7 flux
- **starttls** : 6 flux

View file

@ -0,0 +1,15 @@
---
- name: Appliquer le groupe serveur_resolveur_site
hosts: serveur_resolveur_site
become: true
gather_facts: true
pre_tasks:
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- serveur_resolveur_site

View file

@ -606,6 +606,25 @@
searchdomains: "{{ proxmox_clone_domaines_recherche | default(omit, true) }}"
agent: "enabled=1"
# LE DEMARRAGE DECLARAIT EN PANNE CE QUI FONCTIONNAIT (2026-08-29).
#
# Quinze VM creees trois a la fois : l'une d'elles a depasse le delai du module
# PENDANT QUE PROXMOX GENERAIT ENCORE SON ISO CLOUD-INIT.
#
# fatal: Reached timeout while waiting for starting VM.
# Last line in task before timeout: 'generating cloud-init ISO'
#
# Elle a demarre juste apres. Le module avait abandonne, pas l'hyperviseur — et
# `flotte-creer` a rendu « au moins une VM n'a pas ete creee » alors que les quinze
# tournaient. CE FAUX ECHEC ARRETE UNE RECONSTRUCTION : `reconstruire` enchaine
# `flotte-creer` puis `deployer-tout`, et se serait arrete la.
#
# DEUX CORRECTIONS, ET LA SECONDE EST LA VRAIE. Un delai plus genereux (le defaut du
# module est court, et la generation d'ISO sur un stockage partage se met en file
# quand trois clonages tombent ensemble). Mais un delai reste un pari : on ne fait
# donc plus de son expiration un verdict. C'EST L'HYPERVISEUR QUI JUGE, en repondant
# ce qu'il fait tourner — meme principe que P52, ou l'agent invite juge la
# materialisation a la place d'une reponse SSH.
- name: Demarrer le clone
community.general.proxmox_kvm:
api_host: "{{ proxmox_api_host_effectif }}"
@ -618,6 +637,48 @@
vmid: "{{ proxmox_clone_vmid | int }}"
name: "{{ proxmox_clone_nom }}"
state: started
timeout: "{{ proxmox_clone_delai_demarrage | default(180) | int }}"
register: proxmox_clone_demarrage
failed_when: false
when: proxmox_clone_demarrer | default(true) | bool
# L'HYPERVISEUR EST LE JUGE. On lui demande ce qu'il fait tourner, on ne deduit rien
# du sort de la tache precedente. Les tentatives couvrent le cas ou il finissait
# encore son travail au moment ou le module a renonce.
- name: Confirmer aupres de l'hyperviseur que la VM tourne
community.general.proxmox_vm_info:
api_host: "{{ proxmox_api_host_effectif }}"
api_port: "{{ proxmox_api_port_effectif | int if proxmox_api_port_effectif | length > 0 else omit }}"
api_user: "{{ proxmox_api_user_effectif }}"
api_token_id: "{{ proxmox_api_token_id_effectif }}"
api_token_secret: "{{ proxmox_api_token_secret_effectif }}"
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
node: "{{ proxmox_clone_noeud }}"
vmid: "{{ proxmox_clone_vmid | int }}"
type: qemu
register: proxmox_clone_etat
until: (proxmox_clone_etat.proxmox_vms | default([])
| map(attribute='status') | first | default('')) == 'running'
retries: 12
delay: 10
changed_when: false
when: proxmox_clone_demarrer | default(true) | bool
- name: Refuser si la VM ne tourne toujours pas
ansible.builtin.assert:
that:
- (proxmox_clone_etat.proxmox_vms | default([])
| map(attribute='status') | first | default('')) == 'running'
fail_msg: >-
{{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) ne tourne pas apres
deux minutes d'attente. Ce n'est plus un delai trop court : l'hyperviseur lui-meme
la rapporte
« {{ proxmox_clone_etat.proxmox_vms | default([]) | map(attribute='status')
| first | default('introuvable') }} ».
success_msg: >-
{{ proxmox_clone_nom }} tourne
{{ '(le module avait renonce avant l hyperviseur)'
if proxmox_clone_demarrage.failed | default(false) else '' }}
when: proxmox_clone_demarrer | default(true) | bool
- name: Afficher le resume du clone

View file

@ -44,6 +44,8 @@
import_playbook: groupes/serveur_cache_site.yml
- name: Couche services — groupe serveur_forge_site
import_playbook: groupes/serveur_forge_site.yml
- name: Couche services — groupe serveur_resolveur_site
import_playbook: groupes/serveur_resolveur_site.yml
- name: Couche services — groupe serveur_resolveur
import_playbook: groupes/serveur_resolveur.yml
- name: Couche services — groupe serveur_dovecot

View file

@ -137,11 +137,40 @@
n=$(restic snapshots 2>/dev/null | grep -cE '^[0-9a-f]{8} ' || echo 0)
if [ "$n" -lt 1 ]; then echo "sansobjet=0 snapshots=0 restored_files=-1 ldif_rejouable=-1"; exit 0; fi
tmp=$(mktemp -d)
if restic restore latest --target "$tmp" >/dev/null 2>&1; then
f=$(find "$tmp" -type f | wc -l)
else
f=-1
fi
# LE DEPOT PEUT ETRE OCCUPE, ET CE N'EST PAS UNE SAUVEGARDE CASSEE (2026-08-31).
#
# `restic` verrouille son depot pendant qu'il ecrit. Une recette qui croise la
# fenetre de sauvegarde lit donc un echec de RESTAURATION la ou il n'y a qu'une
# attente — verdict juste sur l'instant, faux sur le fond, et dependant de
# l'heure a laquelle on l'a lancee. Constate sur `data-sql-01`, dont la
# sauvegarde de 02h38 tenait encore le depot : `ECHEC` au premier passage,
# `OK — 5 fichiers` en rejouant la meme commande a la main.
#
# ON REESSAIE, MAIS ON NE MASQUE PAS. Trois tentatives espacees couvrent
# largement un verrou d'ecriture ; au-dela, l'echec est REEL et on rend la
# RAISON que restic a donnee, au lieu d'un `-1` muet qui laisse chercher.
f=-1
err=""
for essai in 1 2 3; do
if err=$(restic restore latest --target "$tmp" 2>&1 >/dev/null); then
f=$(find "$tmp" -type f | wc -l)
break
fi
rm -rf "$tmp"; tmp=$(mktemp -d)
[ "$essai" -lt 3 ] && sleep 20
done
# La raison voyage sur une ligne a part : le verdict la cite, et l'exploitant
# n'a pas a se connecter pour savoir ce que restic a dit.
#
# `if` ET NON `[ ... ] && ...` : la DERNIERE commande d'un script decide de son
# code de sortie, et un test faux rend 1. Ecrit en `&&`, cette ligne faisait
# echouer la tache exactement sur les machines ou la restauration avait REUSSI
# — trois hotes verts declares en echec (2026-08-31).
# TOUJOURS EMISE, vide en cas de succes. Une ligne parfois absente oblige son
# lecteur a gerer l'absence, et c'est la que le gabarit s'est casse : la
# recherche rendait `None` et le verdict explosait sur les DIX machines saines
# (2026-08-31). Un champ toujours present coute un octet et supprime un cas.
printf 'raison=%s\n' "$(printf '%s' "$err" | tr '\n' ' ' | cut -c1-160)"
# L'annuaire est la seule charge dont on peut prouver ICI qu'elle est REJOUABLE,
# sans rien écrire : `slapadd -u` est un essai à blanc. Éprouvé le 2026-08-12,
# avec contrôle négatif (un LDIF corrompu sort en 1).
@ -163,6 +192,8 @@
restaures: "{{ recette_restore.stdout | regex_search('restored_files=(-?\\d+)', '\\1') | first | int }}"
snaps: "{{ recette_restore.stdout | regex_search('snapshots=(\\d+)', '\\1') | first | int }}"
rejouable: "{{ ((recette_restore.stdout | regex_search('ldif_rejouable=(-?\\d+)', '\\1')) or [-1]) | first | int }}"
# Ce que restic a dit, quand il a refuse. Vide sinon — la ligne est toujours la.
raison: "{{ ((recette_restore.stdout | regex_search('raison=(.*)', '\\1')) or ['']) | first | trim }}"
# « L'instantané n'emporte aucun fichier » n'est PAS un échec de restauration : la
# restauration a parfaitement fonctionné, il n'y avait rien à rendre. La machine ne
# peut pas distinguer « les données ont disparu » de « il n'y en a pas encore ».
@ -182,5 +213,6 @@
'OK — ' ~ snaps ~ ' snapshot(s), restauration vérifiée (' ~ restaures ~ ' fichiers)'
~ ((rejouable | int == 1) | ternary(', annuaire REJOUABLE (slapadd -u).', '.')))) }}
fail_msg: >-
ÉCHEC — la RESTAURATION elle-même a échoué (snapshots={{ snaps }}){{
ÉCHEC — la RESTAURATION elle-même a échoué après 3 tentatives
(snapshots={{ snaps }}){{ raison | ternary(' — restic dit : ' ~ raison, '') }}{{
(rejouable | int == 0) | ternary(', et l annuaire restauré N EST PAS rejouable', '') }}.

View file

@ -1,2 +1,25 @@
---
auditd_enabled: true
# LES FICHIERS QU'UNE NOMENCLATURE ABANDONNEE A LAISSES DERRIERE ELLE.
#
# Ce role a porte le nom `chezlepro` avant de porter celui du moteur. Le renommage a
# change le fichier DEPOSE sans retirer le precedent — meme mue que `ssh_baseline`, meme
# registre, et une consequence PIRE ici.
#
# `augenrules` CONCATENE tout `rules.d/`. Deux fichiers portant la meme regle la
# presentent donc deux fois au noyau, qui refuse la seconde :
#
# Error sending add rule data request (Rule exists)
# There was an error in line 16 of /etc/audit/audit.rules
#
# `audit-rules.service` echoue, et `auditd.service` ne demarre pas — c'est sa dependance.
# Resultat mesure le 2026-08-30 sur les quinze machines de Chezlepro : ONZE REGLES
# ARMEES DANS LE NOYAU, ET PERSONNE POUR COLLECTER. `auditctl -l` les affiche, ce qui
# donne toutes les apparences d'un audit qui fonctionne.
#
# On n'ajoute a cette liste que des noms qu'on a REELLEMENT deposes un jour : retirer un
# fichier qu'on n'a jamais ecrit serait s'arroger le droit de supprimer la configuration
# de quelqu'un d'autre.
auditd_fichiers_perimes:
- 99-chezlepro.rules

View file

@ -6,6 +6,18 @@
- audispd-plugins
state: present
# AVANT DE POSER LES NOTRES, RETIRER CELLES D'UNE NOMENCLATURE ABANDONNEE.
#
# `augenrules` concatene tout `rules.d/` : un doublon fait echouer le chargement ENTIER,
# et `auditd` ne demarre pas faute de sa dependance. Voir defaults/main.yml.
- name: Retirer les regles auditd d'une nomenclature abandonnee
ansible.builtin.file:
path: "/etc/audit/rules.d/{{ item }}"
state: absent
loop: "{{ auditd_fichiers_perimes }}"
notify: Restart auditd
when: auditd_enabled | bool
- name: Déployer les règles auditd Set-OPS
ansible.builtin.template:
src: 99-setops.rules.j2
@ -16,10 +28,30 @@
notify: Restart auditd
when: auditd_enabled | bool
# `failed_when: false` A CACHE LA PANNE PENDANT TOUT UN DEPLOIEMENT (2026-08-30).
#
# Quinze machines rapportaient `failed=0` alors qu'`auditd` etait mort sur les quinze. Un
# service de securite qui ne demarre pas doit se VOIR : c'est precisement le genre de
# panne que personne ne va chercher, puisque rien ne la signale et que `auditctl -l`
# affiche des regles.
#
# On tolere encore l'echec sur les machines ou l'audit n'est pas demande — mais quand il
# l'est, on le dit.
- name: Activer auditd
ansible.builtin.systemd:
name: auditd
enabled: true
state: started
when: auditd_enabled | bool
failed_when: false
register: auditd_demarrage
- name: Refuser si l'audit est demande mais ne tourne pas
ansible.builtin.assert:
that:
- auditd_demarrage is not failed
fail_msg: >-
`auditd_enabled` est vrai mais le service n'a pas demarre. Cause la plus frequente :
un doublon dans `/etc/audit/rules.d/` fait echouer `audit-rules.service`, dont
`auditd` depend — verifier `augenrules --check` et `auditd_fichiers_perimes`.
Les regles peuvent etre ARMEES dans le noyau sans que personne ne collecte.
when: auditd_enabled | bool

View file

@ -8,6 +8,53 @@
Déclarer le service au plan, ou forcer client_artefacts_actif=false.
when: client_artefacts_actif | bool
# --- LE FILET, COTE CLIENT : NE PAS VISER UN CACHE QUI NE SERT PAS ------------
#
# Symetrique de la garde de `serveur_artefacts` (mesure du 2026-08-31). Celle-la protege
# le CACHE d'un amont mort ; celle-ci protege LA FLOTTE d'un cache mort.
#
# Ce fichier ECRASE le plancher d'amorcage pose par le socle — il trie apres. Le poser
# alors que le cache du tenant ne repond pas prive donc la machine du cache du SITE, qui
# lui fonctionnait : on remplace une source qui marche par une qui ne marche pas.
#
# Quinze machines ont ainsi perdu `apt` d'un coup, et le deploiement ne pouvait plus
# atteindre la couche qui aurait repare le cache. En laissant le plancher en place, elles
# restent servies par le site — dégradé, mais debout, et reparable par un deploiement.
- name: La source de l'écosystème répond-elle vraiment ?
ansible.builtin.wait_for:
host: "{{ client_artefacts_hote }}.{{ domaine_interne }}"
port: "{{ client_artefacts_port }}"
timeout: 10
register: client_artefacts_sonde
changed_when: false
# `ignore_errors` ET NON `failed_when: false` — LA DIFFERENCE EST TOUTE LA GARDE.
#
# `failed_when: false` REECRIT le verdict : la tache n'est plus jamais `failed`, donc
# `is not failed` est toujours vrai, donc l'assertion qui suit ne peut PAS tirer. Ecrite
# ainsi, elle a declare « joignable » un amont mesure MUET la seconde d'avant
# (2026-08-31). Une garde qui ne peut pas echouer ne garde rien.
#
# `ignore_errors` laisse le verdict intact et se contente de ne pas arreter le play :
# c'est ce qu'il faut pour SONDER puis DECIDER.
ignore_errors: true
when:
- client_artefacts_actif | bool
- not ansible_check_mode
- name: Refuser de detourner apt vers une source muette
ansible.builtin.assert:
that:
- client_artefacts_sonde is not failed
fail_msg: >-
{{ client_artefacts_hote }}:{{ client_artefacts_port }} ne repond pas. Diriger `apt`
vers elle retirerait a cette machine le cache d'amorcage du SITE, qui fonctionne —
et la flotte perdrait `apt` sans qu'un deploiement puisse la reparer.
Deployer `serveur_artefacts` sur {{ client_artefacts_hote }} d'abord.
success_msg: "Source d'artefacts {{ client_artefacts_hote }} joignable."
when:
- client_artefacts_actif | bool
- not ansible_check_mode
- name: Diriger apt vers la source de l'écosystème
ansible.builtin.copy:
dest: "{{ client_artefacts_fichier }}"

View file

@ -21,6 +21,32 @@
when: common_packages_desarmer_maj_auto | bool
failed_when: false # une unite absente n'est pas une faute : elle est desarmee
# EFFACER LA TRACE QUE LE DESARMEMENT LAISSE (mesure du 2026-08-30).
#
# Masquer le service pendant que son timer TOURNE lui fait perdre sa cible. systemd le
# note, et le garde :
#
# apt-daily.timer: Unit to trigger vanished.
# apt-daily.timer: Failed with result 'resources'.
#
# `state: stopped` n'efface pas un etat `failed` — seul `reset-failed` le fait. Sans lui,
# CHAQUE machine porte deux unites en echec permanent. Constate sur les quinze de
# Chezlepro : `systemctl list-units --state=failed` en rend deux partout.
#
# CE N'EST PAS COSMETIQUE. Une supervision qui compte les unites en echec compte ces
# deux-la pour toujours — et le jour ou une VRAIE panne s'ajoute, elle se noie dans un
# bruit qu'on a appris a ignorer. C'est le meme defaut que le journal de la frontiere
# noye sous 982 000 entrees : ce qui ment le plus n'est pas ce qui se tait, c'est ce qui
# crie sans raison.
- name: Effacer l'etat d'echec laisse par le desarmement
ansible.builtin.command:
argv: ["systemctl", "reset-failed", "{{ item }}"]
loop: "{{ common_packages_unites_maj_auto }}"
register: common_packages_reset
changed_when: false
failed_when: false # rien a effacer n'est pas une faute
when: common_packages_desarmer_maj_auto | bool
- name: Mettre à jour le cache APT
ansible.builtin.apt:
update_cache: true

View file

@ -36,11 +36,41 @@
- nftables_baseline_enabled | bool
- not nftables_baseline_ruleset_stat.stat.exists
- name: Activer nftables si demandé
# ARMER LE PARE-FEU COUPE LA MAIN QUI L'ARME (mesure du 2026-08-30).
#
# Une session SSH ouverte AVANT le demarrage de nftables n'a aucune entree conntrack.
# Des que le ruleset s'installe, ses paquets arrivent en `invalid` et tombent sur
# `ct state invalid drop` — la session meurt. Les connexions NEUVES passent sans
# probleme (SYN -> NEW -> suivi) : le pare-feu est correct, c'est la continuite qui
# ne l'est pas.
#
# CE N'EST PAS UN VERROUILLAGE, ET C'EST CE QUI LE REND DANGEREUX. Rien n'echoue :
# Ansible attend une reponse qui ne viendra jamais, sans delai. Le 2026-08-30, un
# deploiement est reste suspendu CINQUANTE-HUIT MINUTES sur six machines, pendant que
# les quinze repondaient parfaitement a qui se connectait a neuf.
#
# `async` + `poll: 0` : on lance et on ne guette pas la reponse — elle ne peut pas
# revenir. `reset_connection` jette la session morte, `wait_for_connection` en ouvre
# une neuve, qui elle sera suivie par conntrack des sa poignee.
- name: Activer nftables si demandé (sans attendre la reponse)
ansible.builtin.systemd:
name: nftables
enabled: true
state: started
async: 60
poll: 0
when: nftables_baseline_enabled | bool
- name: Jeter la session que le pare-feu vient de couper
ansible.builtin.meta: reset_connection
# LE DELAI EST UNE ATTENTE, PAS UN VERDICT : si l'hote ne revient pas, c'est un vrai
# verrouillage — la garde `nftables_admin_ssh` a manque sa cible — et il faut le dire
# fort plutot que de laisser un playbook pendre.
- name: Reprendre la main sur l'hote qu'on vient d'armer
ansible.builtin.wait_for_connection:
delay: 3
timeout: 90
when: nftables_baseline_enabled | bool
- name: Désactiver nftables si demandé

View file

@ -36,6 +36,60 @@
# dernier lu gagne. Le préfixe `zzz-` place donc notre configuration après celle du
# paquet Debian et celle de debconf, sans avoir à les éditer — on ajoute, on ne remplace
# pas. Une valeur posée ici l'emporte, et `apt purge` rend la machine à son état d'origine.
# --- LE FILET : NE PAS POSER UN AMONT INJOIGNABLE ----------------------------
#
# CE CACHE EST UN MAILLON DONT DEPEND SA PROPRE RECONSTRUCTION (mesure du 2026-08-31).
#
# Toute la flotte du tenant prend ses paquets ici. Si ce cache pointe vers un amont mort,
# `apt` echoue sur les QUINZE machines — donc le socle echoue, donc le deploiement
# n'atteint jamais la couche qui poserait le bon amont. Le correctif se retrouve dans la
# couche que la panne empeche d'atteindre, et il faut une main pour en sortir.
#
# C'est exactement ce qui est arrive : l'amont pointait sur le cache de patient 0, eteint
# la veille pour liberer de la RAM. `apt` rendait `503 Connection timeout` EN CITANT
# L'ADRESSE DU CACHE LOCAL, jamais celle de l'amont manquant — la panne accusait le
# maillon visible.
#
# On mesure donc AVANT d'ecrire. Un amont injoignable est refuse : mieux vaut un cache
# qui garde sa configuration precedente et un message clair, qu'un cache qu'on vient de
# rendre inutilisable pour toute la flotte.
- name: L'amont declare repond-il ?
ansible.builtin.wait_for:
host: "{{ serveur_artefacts_amont | urlsplit('hostname') }}"
port: "{{ serveur_artefacts_amont | urlsplit('port') | default(3142, true) | int }}"
timeout: 10
register: serveur_artefacts_amont_sonde
changed_when: false
# `ignore_errors` ET NON `failed_when: false` — LA DIFFERENCE EST TOUTE LA GARDE.
#
# `failed_when: false` REECRIT le verdict : la tache n'est plus jamais `failed`, donc
# `is not failed` est toujours vrai, donc l'assertion qui suit ne peut PAS tirer. Ecrite
# ainsi, elle a declare « joignable » un amont mesure MUET la seconde d'avant
# (2026-08-31). Une garde qui ne peut pas echouer ne garde rien.
#
# `ignore_errors` laisse le verdict intact et se contente de ne pas arreter le play :
# c'est ce qu'il faut pour SONDER puis DECIDER.
ignore_errors: true
when:
- serveur_artefacts_amont | length > 0
- not ansible_check_mode
- name: Refuser un amont injoignable plutot que de rendre ce cache inutilisable
ansible.builtin.assert:
that:
- serveur_artefacts_amont_sonde is not failed
fail_msg: >-
L'amont declare ({{ serveur_artefacts_amont }}) ne repond pas. Le poser rendrait ce
cache — et donc `apt` sur TOUTE la flotte de cet ecosysteme — inutilisable, et le
deploiement ne pourrait plus atteindre la couche qui le corrigerait.
Verifier que la machine d'amont tourne, ou vider `serveur_artefacts_amont` pour
aller directement chez Debian (c'est une emancipation : voir
docs/filiation-emancipation.md).
success_msg: "Amont {{ serveur_artefacts_amont }} joignable."
when:
- serveur_artefacts_amont | length > 0
- not ansible_check_mode
- name: Déployer la configuration Set-OPS
ansible.builtin.template:
src: zzz-setops.conf.j2

View file

@ -149,6 +149,27 @@
no_log: true # contient le mot de passe de bind LDAP
notify: Recharger postfix
# REDEMARRER, PAS RECHARGER — ET POSTFIX LE DIT LUI-MEME (2026-08-30).
#
# `main.cf` porte `inet_protocols`, et ce parametre-la ne se change PAS a chaud. Sur une
# machine nee en `all` et passee en `ipv4`, le rechargement rend :
#
# warning: ignoring inet_protocols parameter value change
# warning: to change inet_protocols, stop and start Postfix
# fatal: :::submission: Address family for hostname not supported
#
# Le `master` garde donc son ancienne valeur, tente d'ouvrir `:::submission` en IPv6, et
# meurt. Le service tombe en `failed` APRES avoir accepte une configuration valide —
# `postfix check` ne dit rien, parce que la configuration EST valide ; c'est la
# transition qui ne l'est pas.
#
# MEME FAMILLE QUE `nginx-restart-si-changement-d-ecoute` : un rechargement ne reapplique
# pas un changement d'ECOUTE. Ce qui vit dans le processus maitre — protocoles, adresses,
# ports — exige qu'il reparte.
#
# On redemarre donc sur tout changement de `main.cf`. C'est plus large que le seul
# `inet_protocols`, et c'est voulu : distinguer ligne par ligne ce qui se recharge de ce
# qui se redemarre demanderait de tenir a jour une liste que Postfix ne publie pas.
- name: Déployer main.cf
ansible.builtin.template:
src: main.cf.j2
@ -156,7 +177,7 @@
owner: root
group: root
mode: "0644"
notify: Recharger postfix
notify: Redémarrer postfix
- name: Activer la soumission :587 (clients authentifiés, SASL Dovecot distant)
ansible.builtin.blockinfile:

View file

@ -0,0 +1,57 @@
# serveur_resolveur_site
**Le marqueur du résolveur du site.** Il dit que *ce* résolveur est celui que les
écosystèmes de ce site interrogent tant qu'ils n'ont pas le leur, et déclare le flux qui
le permet. Il n'installe rien.
## Le troisième service prêté
C'est le même patron que `serveur_artefacts` + `serveur_cache_site`, et que
`serveur_forgejo` + `serveur_forge_site` : **un installateur, un marqueur**.
| ce que le site prête | pendant la jeunesse du tenant |
|---|---|
| les paquets | `serveur_cache_site` |
| le génome | `serveur_forge_site` |
| **les noms** | **ce rôle** |
## Ce qu'il a corrigé
Mesuré le 2026-08-30 sur `infra-pki-01`, première machine à porter l'autorité de
certification de Chezlepro :
```
DNS BLOQUÉ un tenant résout chez lui, sa requête ne sort pas
443 sortant OK par adresse
apt via le cache OK le mandataire résout à sa place
```
`apt` s'en sortait ; **tout le reste était aveugle aux noms** — un `get_url`, un dépôt
tiers en HTTPS, un `git clone`. Versionner la clé de signature Smallstep a fait passer une
tâche, et l'échec s'est déplacé d'un cran : le dépôt lui-même devait être joint par son
nom.
## Pourquoi pas l'Unbound de la frontière
Il tourne sur le boîtier **sans être un service géré**, et le plan du site l'avait déjà
écrit une fois : *« un processus n'est pas un service »*. `site-dns-01` est déployé par le
moteur, vérifié par le harnais, et porte ses flux.
## Où vit la dérivation
La liste d'autorisation est calculée par `scripts/site_inventaire.py`, qui connaît le plan
du site **et** le registre des tenants. Le rôle, lui, tourne sur une machine qui n'a
aucune raison de lire la carte de l'hébergeur — *la dérivation appartient à qui détient
les deux sources*.
Attention au filtre : `resoudre_flux._supernets_voisins()` **exclut** l'instance montée
(personne n'est son propre voisin). Le site sert **tous** ceux qu'il héberge, y compris
celui qu'on déploie — sans quoi le seul tenant en cours de montage serait précisément
celui qu'on prive de résolution.
## Ce que ce rôle ne fait pas
Il n'installe rien, n'ouvre aucun port, ne sauvegarde rien. Il attribue une responsabilité
— et vérifie deux choses : que le résolveur **écoute**, et que sa liste d'autorisation
**admet réellement** les tenants du site. Sans la seconde, la panne chez le locataire
ressemblerait à un DNS mort alors que c'est une ACL.

View file

@ -0,0 +1,11 @@
---
# LE RÉSOLVEUR DU SITE — le marqueur, pas l'installateur.
#
# `serveur_resolveur` pose Unbound et son ACL ; ce rôle dit que CE résolveur est celui que
# les écosystèmes du site interrogent pendant leur jeunesse, et déclare le flux qui le
# permet.
#
# La liste d'autorisation se dérive dans `scripts/site_inventaire.py`, qui connaît le plan
# du site ET le registre des tenants. On ne la redéclare pas ici : une seconde source
# finirait par diverger de la première, et la divergence se lirait « tout va bien ».
serveur_resolveur_site_port: "{{ serveur_resolveur_port | default(53) }}"

View file

@ -0,0 +1,13 @@
---
# Position de ce role dans la directive d'authentification (D-38..D-41).
# Voir docs/authentification.md. Gardee par la preuve P29.
authentification:
portee: sans-auth-humaine
mecanisme: aucun
formulaire_local: sans-objet
secours: "Acces SSH a l'hote"
raison: >-
Un resolveur ne sert que des machines. Ce qui le protege n'est pas une
authentification mais sa LISTE D'AUTORISATION : seuls les supernets des tenants de ce
site l'interrogent, et la frontiere le fait respecter. Un resolveur ouvert au monde
serait un relais d'amplification — c'est ce risque-la que l'ACL couvre, pas un secret.

View file

@ -0,0 +1,7 @@
---
# Empreinte ressources — ce role n'installe rien. Il attribue une responsabilite a un
# resolveur qui existe deja, et la rend lisible dans le plan.
setops_empreinte:
coeurs: 0
memoire_mo: 0
disque_go: 0

View file

@ -0,0 +1,43 @@
---
# Flux réseau du RÉSOLVEUR DU SITE. Voir docs/flux-conception.md.
#
# TROISIÈME SERVICE QUE LE SITE PRÊTE À SES LOCATAIRES, après les paquets
# (`serveur_cache_site`) et le génome (`serveur_forge_site`). Même patron, même raison :
# un écosystème neuf n'a ni cache, ni forge, ni résolveur — et c'est lui qui doit les
# construire.
#
# CE QUE ÇA A COÛTÉ DE NE PAS L'AVOIR (2026-08-30). `infra-pki-01`, première machine à
# porter l'autorité de Chezlepro, n'a pas pu récupérer une clé de signature :
#
# Echec temporaire dans la resolution du nom
#
# `apt` s'en sortait par le mandataire du cache, qui résout à sa place. Tout ce qui ne
# passe pas par apt — un `get_url`, un `git clone`, une sonde — n'avait aucun moyen de
# traduire un nom. Le tenant sortait en 443 par adresse et restait aveugle aux noms.
#
# POURQUOI PAS L'UNBOUND DE LA FRONTIÈRE. Il tourne sur le boîtier sans être un service
# géré, et le plan du site l'a déjà écrit une fois : *un processus n'est pas un service*.
# Celui-ci est déployé par le moteur, vérifié par le harnais, et porte ses flux.
#
# `voisins_site` désigne les autres tenants que CE SITE héberge. Ni `flotte` (mon
# écosystème), ni `externe` — un résolveur ouvert au monde est un relais d'amplification.
flux:
- sens: ingress
port: 53
protocole: udp
pair: voisins_site
chiffrement: clair
raison: >-
Les écosystèmes de ce site résolvent ici tant qu'ils n'ont pas leur propre
résolveur. `client_resolveur` les bascule chez eux dès qu'il existe ; retirer cet
emprunt est alors une émancipation.
# CE RÔLE N'OUVRE AUCUNE ÉCOUTE — il emprunte celle de `serveur_resolveur`, sur le
# même hôte. Sans ce mot, P33 y voit deux rôles qui se disputent le port 53.
partage: true
- sens: ingress
port: 53
protocole: tcp
pair: voisins_site
chiffrement: clair
raison: "Réponses longues et bascule TCP, obligatoires en DNS."
partage: true

View file

@ -0,0 +1,15 @@
---
# CE RÔLE EN EXIGE UN AUTRE, ET C'EST À ANSIBLE DE LE SAVOIR.
#
# `serveur_resolveur_site` n'installe rien : il MARQUE un résolveur comme celui que les
# écosystèmes du site interrogent pendant leur jeunesse, et vérifie qu'il écoute vraiment.
# Pour cela il lit les variables de `serveur_resolveur`.
#
# Les valeurs par défaut d'un rôle ne sont en portée que DANS LE PLAY QUI L'INCLUT. Une
# dépendance de rôle règle les deux choses d'un coup : Ansible applique l'installateur
# AVANT le marqueur, dans le MÊME play, donc ses variables sont en portée — et l'ordre
# cesse de dépendre de la façon dont on lance le déploiement.
#
# Même raisonnement, mot pour mot, que `serveur_cache_site` et `serveur_forge_site`.
dependencies:
- role: serveur_resolveur

View file

@ -0,0 +1,51 @@
---
# CE RÔLE MARQUE UN RÉSOLVEUR ; IL N'EN INSTALLE PAS.
#
# Il présuppose `serveur_resolveur` sur le même hôte et lit ses variables. Le message
# qu'on obtiendrait sans lui — « variable non définie » — envoie chercher une faute de
# frappe alors que la cause est une déclaration incomplète, un cran au-dessus.
- name: Le rôle qui installe le résolveur est-il bien là ?
ansible.builtin.assert:
that:
- serveur_resolveur_reseaux_autorises is defined
- serveur_resolveur_reseaux_autorises | length > 0
fail_msg: >-
`serveur_resolveur_site` marque un résolveur existant, il n'en installe aucun.
Cet hôte doit AUSSI porter `serveur_resolveur`. Ajouter les deux rôles à sa
déclaration, dans cet ordre.
# UN MARQUEUR QUI NE VÉRIFIE PAS CE QU'IL DÉSIGNE NE MARQUE RIEN (D-68).
#
# La liste d'autorisation vient de l'inventaire du site. Si elle n'a pas reçu les
# supernets des tenants, ce rôle attribuerait une responsabilité que le service refuse
# d'assumer — et la panne, chez le locataire, ressemblerait à un DNS mort alors que c'est
# une ACL. C'est exactement ce qui est arrivé au site lui-même quand il a été découpé en
# zones (voir `site_inventaire.py`).
- name: Quels supernets de tenants ce résolveur doit-il servir ?
ansible.builtin.set_fact:
serveur_resolveur_site_attendus: >-
{{ serveur_resolveur_site_supernets_tenants | default([]) }}
- name: Exiger que les tenants du site figurent dans la liste d'autorisation
ansible.builtin.assert:
that:
- serveur_resolveur_site_attendus
| difference(serveur_resolveur_reseaux_autorises) | length == 0
fail_msg: >-
Ce résolveur est déclaré résolveur DU SITE, mais sa liste d'autorisation n'admet pas
{{ serveur_resolveur_site_attendus
| difference(serveur_resolveur_reseaux_autorises) | join(', ') }}.
Les locataires seraient refusés, et leur panne ressemblerait à un DNS mort.
success_msg: >-
{{ serveur_resolveur_site_attendus | length }} supernet(s) de tenant admis.
when: serveur_resolveur_site_attendus | length > 0
# ÉCRIRE, PUIS RELIRE. La seule prise de ce rôle sur le réel est de vérifier que le
# service qu'il désigne existe vraiment. Sans ça, il donnerait la résolution du site à un
# port que personne n'écoute — et un écosystème neuf attendrait sans savoir pourquoi.
- name: Le résolveur écoute-t-il vraiment ?
ansible.builtin.wait_for:
host: "127.0.0.1"
port: "{{ serveur_resolveur_site_port }}"
timeout: 30
when: not ansible_check_mode

View file

@ -0,0 +1,51 @@
# La clé de signature du dépôt Smallstep — versionnée, et pourquoi
```
pub rsa2048 2021-05-04 [SC]
35BA A0B3 3E9E B396 F59C A838 C0BA 5CE6 DC63 15A3
uid Artifact Registry Repository Signer <artifact-registry-repository-signer@google.com>
```
## Pourquoi elle est ici et non téléchargée
**Un tenant n'a pas de DNS sortant, et c'est voulu** — il résout chez lui, sa requête ne
traverse jamais la frontière. `apt` s'en sort par le mandataire du cache, qui résout à sa
place ; `get_url` n'a pas de mandataire.
Mesuré le 2026-08-30 sur `infra-pki-01`, première machine à porter l'autorité de
certification de Chezlepro :
```
Request failed: <urlopen error [Errno -3] Échec temporaire dans la résolution du nom>
url: https://packages.smallstep.com/keys/apt/repo-signing-key.gpg
```
La dette était déjà nommée dans `roles/client_artefacts/defaults/main.yml` : *« les dépôts
tiers en HTTPS continuent d'aller en direct […] la seconde voie est propre et reste à
faire »*. Elle a été appelée le jour où un écosystème s'est reconstruit derrière une
frontière qui fait son travail.
**Ce que ça achète : une lignée qui se reproduit sans Internet.** C'est le même principe
que les collections Ansible et les roues Python — le contrôleur récupère une fois, le
dépôt porte, la cible n'ouvre rien.
## Ce que ça coûte, et qu'il faut savoir
Cette clé **expirera ou tournera** un jour. Le dépôt apt échouera alors bruyamment, à la
vérification de signature — un échec clair, pas un silence. C'est le bon sens de la panne :
mieux vaut un refus lisible qu'un téléchargement qui réussit depuis n'importe où.
## La remettre à jour
Depuis une machine qui a le DNS (le poste de l'exploitant), **et en suivant les
redirections** — l'URL publique renvoie vers `pkgs.infra.smallstep.com`, et sans `-L` on
récupère un fichier VIDE que rien ne signale :
```sh
curl -fsSL https://packages.smallstep.com/keys/apt/repo-signing-key.gpg \
-o roles/serveur_step_ca/files/smallstep-repo-signing-key.asc
gpg --show-keys --with-fingerprint roles/serveur_step_ca/files/smallstep-repo-signing-key.asc
```
Comparer l'empreinte à celle du haut de ce fichier avant de committer. Une clé qu'on
remplace sans regarder son empreinte n'est plus une clé, c'est un fichier.

View file

@ -0,0 +1,19 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
xsBNBGCRt7MBCADkYJHHQQoL6tKrW/LbmfR9ljz7ib2aWno4JO3VKQvLwjyUMPpq
/SXXMOnx8jXwgWizpPxQYDRJ0SQXS9ULJ1hXRL/OgMnZAYvYDeV2jBnKsAIEdiG/
e1qm8P4W9qpWJc+hNq7FOT13RzGWRx57SdLWSXo0KeY38r9lvjjOmT/cuOcmjwlD
T9XYf/RSO+yJ/AsyMdAr+ZbDeQUd9HYJiPdI04lGaGM02MjDMnx+monc+y54t+Z+
ry1WtQdzoQt9dHlIPlV1tR+xV5DHHsejCZxu9TWzzSlL5wfBBeEz7R/OIzivGJpW
QdJzd+2QDXSRg9q2XYWP5ZVtSgjVVJjNlb6ZABEBAAHNVEFydGlmYWN0IFJlZ2lz
dHJ5IFJlcG9zaXRvcnkgU2lnbmVyIDxhcnRpZmFjdC1yZWdpc3RyeS1yZXBvc2l0
b3J5LXNpZ25lckBnb29nbGUuY29tPsLAjgQTAQoAOBYhBDW6oLM+nrOW9ZyoOMC6
XObcYxWjBQJgkbezAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEMC6XObc
YxWj+igIAMFh6DrAYMeq9sbZ1ZG6oAMrinUheGQbEqe76nIDQNsZnhDwZ2wWqgVC
7DgOMqlhQmOmzm7M6Nzmq2dvPwq3xC2OeI9fQyzjT72deBTzLP7PJok9PJFOMdLf
ILSsUnmMsheQt4DUO0jYAX2KUuWOIXXJaZ319QyoRNBPYa5qz7qXS7wHLOY89IDq
fHt6Aud8ER5zhyOyhytcYMeaGC1g1IKWmgewnhEq02FantMJGlmmFi2eA0EPD02G
C3742QGqRxLwjWsm5/TpyuU24EYKRGCRm7QdVIo3ugFSetKrn0byOxWGBvtu4fH8
XWvZkRT+u+yzH1s5yFYBqc2JTrrJvRU=
=QnvN
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -16,47 +16,42 @@
group: root
mode: "0755"
# Recuperee UNE FOIS. Sans garde, chaque deploiement recontactait le serveur du
# fournisseur : cinq cles x quatorze hotes = soixante-dix allers-retours externes pour
# des cles deja installees, et autant d'occasions qu'un tiers lent fasse tomber le
# deploiement. Arbitrage rendu le 2026-08-09 : une plateforme souveraine ne depend pas
# de six serveurs etrangers pour redeployer ce qu'elle possede deja.
# LA GARDE « RECUPEREE UNE FOIS » A DISPARU AVEC CE QU'ELLE GARDAIT (2026-08-30).
#
# CONSEQUENCE ASSUMEE : une rotation de cle amont n'est plus recuperee toute seule. Elle
# ne passe pas inapercue pour autant — `apt` refuse alors le depot, bruyamment. Pour
# forcer le rafraichissement : supprimer le fichier et rejouer le role.
- name: Cette ressource est-elle deja recuperee ? (Telecharger la cle de signature Sm)
ansible.builtin.stat:
path: "{{ serveur_step_ca_depot_cle_fichier }}"
register: telecharger_la_cle_de_signature_smallste_present
# Elle evitait de recontacter le fournisseur a chaque passage — soixante-dix allers-retours
# externes pour des cles deja installees (arbitrage du 2026-08-09). La cle etant desormais
# VERSIONNEE, il n'y a plus d'aller-retour a eviter : `copy` ne sort pas, et son
# idempotence vient de la comparaison d'empreintes, pas d'un `stat` qu'on ecrit soi-meme.
#
# Le meme arbitrage est donc honore plus completement : la plateforme ne depend plus du
# tout de serveurs etrangers pour redeployer ce qu'elle possede deja.
- name: Telecharger la cle de signature Smallstep
# UN SERVEUR TIERS QUI REPOND UNE FOIS SUR DEUX NE DOIT PAS ARRETER UN DEPLOIEMENT DE
# QUARANTE MINUTES (mesure du 2026-08-23, premier deploiement de patient 0).
#
# `packages.smallstep.com` est intermittent : la meme URL pend au premier essai et rend
# 200 en 0,48 s au second. Le defaut de `get_url` est un delai de 10 secondes et AUCUNE
# reprise — le socle echouait donc sur la premiere machine, avant meme d'avoir pose le
# resolveur des autres.
#
# Mesure faite avant d'accuser : DNS resolvait, la poignee TLS aboutissait
# (`Verify return code: 0`), un transfert de 138 Ko depuis deb.debian.org passait en
# 0,09 s, et le chemin acceptait 1450 octets et refusait 1500 — exactement l'attendu en
# overlay. Le reseau n'y etait pour rien.
#
# Meme idiome que les roles Keycloak et Forgejo devant leurs propres tiers.
when: not telecharger_la_cle_de_signature_smallste_present.stat.exists
ansible.builtin.get_url:
url: "{{ serveur_step_ca_depot_cle_url }}"
# LA CLE DE SIGNATURE EST VERSIONNEE, PLUS TELECHARGEE (2026-08-30).
#
# Elle etait tiree de `packages.smallstep.com` par son NOM. Un tenant n'a pas de DNS
# sortant — il resout chez lui, sa requete ne traverse jamais la frontiere — et
# `get_url` n'a pas de mandataire, la ou `apt` s'en sort par le cache qui resout a sa
# place. Mesure sur `infra-pki-01`, premiere machine a porter l'autorite de Chezlepro :
#
# Request failed: <urlopen error [Errno -3] Echec temporaire dans la resolution du nom>
#
# Les cinq reprises de l'ancienne version ne pouvaient rien : elles etaient ecrites pour
# un serveur INTERMITTENT (mesure du 2026-08-23), pas pour une resolution fermee. Une
# reprise ne repare que ce qui est passager.
#
# MEME IDIOME QUE LES COLLECTIONS ET LES ROUES : le depot porte, la cible n'ouvre rien.
# C'est ce qui rend une lignee reproductible sans Internet. Empreinte, raison et
# procedure de mise a jour dans `files/README-cle-smallstep.md`.
#
# `serveur_step_ca_depot_cle_url` reste declaree : elle documente la provenance et sert
# a rafraichir le fichier depuis un poste qui a le DNS. Elle n'est plus lue au deploiement.
- name: Poser la cle de signature Smallstep (versionnee, aucun appel sortant)
ansible.builtin.copy:
src: smallstep-repo-signing-key.asc
dest: "{{ serveur_step_ca_depot_cle_fichier }}"
owner: root
group: root
mode: "0644"
timeout: 30
register: serveur_step_ca_cle
retries: 5
delay: 6
until: serveur_step_ca_cle is succeeded
- name: Ajouter le depot apt Smallstep
ansible.builtin.template:

View file

@ -52,6 +52,7 @@ Usage :
from __future__ import annotations
import argparse
import ipaddress
import json
import os
import sys
@ -118,6 +119,36 @@ def sdn_hebergeur() -> dict:
return data.get("proxmox_sdn") or {}
def _admin_dans_le_supernet(nom_instance: str, supernet: str) -> list[str]:
"""Les reseaux d'administration de ce tenant qui tombent DANS son propre supernet.
CEUX-LA SEULEMENT ONT BESOIN D'UNE ROUTE, et c'est tout le point. Un plan
d'administration pose hors du supernet (10.0.0.0/24 du site, une adresse de VPN) est
joint par la route par defaut du VRF : il n'a jamais rencontre le puits. Celui qui vit
DANS le supernet, lui, tombe dedans.
D-77 place justement l'underlay d'un site dans la BANDE BASSE de son propre supernet —
`10.<index>.0-15.x`. Chez un hebergeur qui est aussi son propre tenant, le plan
d'administration est donc a l'interieur du /16 que le puits couvre. Les deux regles
sont justes separement et se contredisent ensemble ; cette fonction nomme
l'intersection.
"""
try:
from devis_reseau import admin_de
reseau_super = ipaddress.ip_network(supernet, strict=False)
dedans = []
for cidr in admin_de(nom_instance):
try:
n = ipaddress.ip_network(str(cidr).strip(), strict=False)
except ValueError:
continue
if n.subnet_of(reseau_super) and n != reseau_super:
dedans.append(str(n))
return sorted(set(dedans))
except Exception:
return []
def construire(tenants: list[tuple[str, str, dict]]) -> dict:
sdn = sdn_hebergeur()
u = underlay_mod.charger()
@ -140,6 +171,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"tenant": nom_instance,
"index": index,
"supernet": supernet_de(index),
"admin_interne": _admin_dans_le_supernet(nom_instance, supernet_de(index)),
"zone": zone_de(index),
"vrf_vxlan": index,
"mtu": mtu,
@ -200,7 +232,29 @@ def strophe_frr(devis: dict) -> str:
# n'importe quelle adresse inexistante du supernet — ce qui a longtemps
# passe pour de l'anti-usurpation de la frontiere, alors que c'etait une
# route manquante ici.
f" ip route {b['supernet']} blackhole",
]
# LE PUITS AVALAIT LE PLAN D'ADMINISTRATION (mesure du 2026-08-28).
#
# Il couvre tout le supernet du tenant. Les VNets s'en sortent : leurs /24 sont
# connectes, donc plus specifiques. Mais D-77 place l'underlay d'un site dans la
# BANDE BASSE de ce meme supernet — et rien ne l'y rendait plus specifique.
#
# Consequence, invisible parce qu'elle porte sur le RETOUR : un paquet venu du plan
# d'administration arrive bien sur l'hyperviseur, et le noyau refuse de l'acheminer
# parce que sa source est injoignable depuis le VRF.
#
# ip route get 10.17.19.41 from 10.0.31.11 iif vlan40 -> dev vrf_t17
# ip route get 10.17.19.41 from 10.17.0.17 iif vlan40 -> Invalid cross-device link
#
# La frontiere, elle, laissait passer : ses regles `administration -> tenant`
# etaient donc VRAIES et INAPPLICABLES a la fois — un devis vert sur un chemin
# qui ne pouvait pas aboutir.
#
# On ne touche pas au puits, qui a ses raisons (voir plus haut). On rend l'admin
# plus specifique que lui, vers la frontiere — la ou ce trafic doit aller.
for _adm in b.get("admin_interne") or []:
lignes.append(f" ip route {_adm} {devis['frontiere']} nexthop-vrf default")
lignes += [f" ip route {b['supernet']} blackhole",
"exit-vrf"]
return "\n".join(lignes) + "\n"

View file

@ -271,7 +271,7 @@ def parametres_proxmox_hote(data: dict, host: str,
# La cle vient du PLAN DU SITE, pas du disque local : on materialise depuis le runner
# comme depuis le poste de l'exploitant, et un `lookup` local rendrait deux valeurs
# differentes selon qui agit — la VM nee du poste n'accepterait pas le runner.
lignes.append(f"SETOPS_CLES_AMORCAGE='{_cle_amorcage(data, host)}'")
lignes.append(f"SETOPS_CLES_AMORCAGE='{_cle_amorcage(data, host, inventaire)}'")
return lignes
@ -279,27 +279,93 @@ def parametres_proxmox_hote(data: dict, host: str,
GROUPE_RUNNER_TENANT = "serveur_ops_tenant"
def _cle_amorcage(data: dict, host: str) -> str:
"""La cle publique du runner du SITE, si cet hote est le runner de son tenant.
def _cle_amorcage(data: dict, host: str, inventaire: Path | None = None) -> str:
"""Les cles publiques a poser sur cet hote A SA NAISSANCE. Une par ligne.
Rend '' partout ailleurs — et aussi quand le depot de l'hebergeur n'est pas monte
ou ne declare pas de runner : un ecosysteme se materialise alors comme avant, avec
la seule cle de l'exploitant. Degrader, jamais deviner.
DEUX CLES, DEUX PORTEES, ET LA DIFFERENCE EST TOUTE L'ARCHITECTURE :
la cle du SITE -> sur le SEUL runner du tenant. C'est l'insemination : le site
amorce une machine, et une seule, puis s'arrete.
la cle du TENANT -> sur TOUTES les machines de ce tenant. C'est son runner qui
les configurera, et il ne peut pas le faire sans y entrer.
POSER UNE CLE AU CLONAGE N'EST PAS ENTRER CHEZ LE TENANT. C'est un parametre de
creation, au meme titre que l'adresse ou le disque : le site ecrit les conditions de
NAISSANCE de la machine, il n'ouvre aucune session dedans. La distinction n'est pas
rhetorique — le site n'obtient aucun acces sur ces machines, seul le runner du tenant
en obtient un.
SANS ELLE, LE TERRAIN EST INOCCUPABLE (mesure du 2026-08-30). Quinze VM creees, et le
runner du tenant se voyait refuser l'entree sur les quinze :
ops-01 -> toutes : Permission denied (publickey)
Sa cle EST declaree au plan (`ssh_baseline_cles_admin`), mais c'est le SOCLE qui la
depose — et le socle doit etre applique par quelqu'un qui peut deja entrer. Boucle
fermee : le tenant recevait un terrain qu'il ne pouvait pas occuper.
Rend '' pour ce qui n'est pas trouve, jamais une valeur devinee : un ecosysteme dont
le plan ne declare rien se materialise comme avant, avec la seule cle de l'exploitant.
"""
cles: list[str] = []
# 1. LA CLE DU SITE — le runner du tenant, et lui seul.
if GROUPE_RUNNER_TENANT in groupes_hote(data, host):
try:
import underlay as underlay_mod
c = str((underlay_mod.lire_plan_site("10-intrants.yml") or {})
.get("runner_cle_publique") or "").strip()
if c:
cles.append(c)
except Exception:
pass
# 2. LA CLE DU TENANT — toutes ses machines, y compris son runner (il se joint
# lui-meme comme les autres, et l'exclure creerait un cas particulier de plus).
#
# ELLE VIENT DU PLAN DU TENANT, pas d'un fichier lu sur une machine : materialiser
# depuis le poste et depuis le runner du site doit produire la meme VM.
for entree in _cles_admin_du_plan(inventaire):
if entree not in cles:
cles.append(entree)
for c in cles:
if "'" in c:
raise ValueError(
f"Une cle publique declaree contient une apostrophe ({host}) : "
"la valeur passe par une commande shell, elle ne peut pas en porter."
)
return "\n".join(cles)
def _cles_admin_du_plan(inventaire: Path | None) -> list[str]:
"""Les cles `ssh_baseline_cles_admin` de l'instance, celles a l'etat `present`.
ON HONORE L'ETAT. Une entree passee a `absent` est une REVOCATION : la reposer a la
naissance ressusciterait sur chaque VM neuve une cle qu'on vient de retirer de toute
la flotte — la panne serait lente, silencieuse, et parfaitement invisible au plan.
"""
if GROUPE_RUNNER_TENANT not in groupes_hote(data, host):
return ""
try:
import underlay as underlay_mod
cle = str((underlay_mod.lire_plan_site("10-intrants.yml") or {})
.get("runner_cle_publique") or "").strip()
depuis = Path(inventaire).parent if inventaire else None
gv = (depuis or _instance_dir()) / "group_vars" / "all"
for fichier in sorted(gv.glob("*.yml")):
if "vault" in fichier.name:
continue
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
if isinstance(data, dict) and data.get("ssh_baseline_cles_admin"):
return [str(e.get("cle", "")).strip()
for e in data["ssh_baseline_cles_admin"]
if isinstance(e, dict)
and str(e.get("etat", "present")) == "present"
and str(e.get("cle", "")).strip()]
except Exception:
return ""
if "'" in cle:
raise ValueError(
f"`runner_cle_publique` du site contient une apostrophe ({host}) : "
"la valeur passe par une commande shell, elle ne peut pas en porter."
)
return cle
pass
return []
def _instance_dir() -> Path:
from inventory_rules import instance_courante
return Path(instance_courante())
def _rang_des_couches() -> dict[str, int]:

View file

@ -1396,6 +1396,74 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]:
f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.")
def preuve_cle_du_site_bornee_au_runner() -> tuple[bool, str]:
"""La cle du SITE ne nait que sur le runner d'un tenant. Celle du tenant, partout chez lui.
LE POUVOIR SE LIT DANS LES CLES, PAS DANS LES INTENTIONS. Une VM recoit ses cles a la
NAISSANCE, par cloud-init — et personne ne relit un `authorized_keys` pose il y a six
mois. Si la cle du site se mettait a partir sur toutes les machines d'un locataire,
l'hebergeur obtiendrait un acces SSH a sa flotte entiere, en silence, et rien dans le
plan ne le dirait.
C'est le pendant exact du flux d'insemination : celui-ci vise le groupe
`serveur_ops_tenant`, cette preuve verifie que la CLE suit le meme critere. Une
frontiere tenue a une seule couche n'est pas tenue.
L'AUTRE MOITIE COMPTE AUSSI : sans la cle du TENANT sur ses machines, son runner recoit
un terrain qu'il ne peut pas occuper — mesure du 2026-08-30, quinze VM et quinze
« Permission denied ».
"""
inv = RACINE / INV
if not inv.is_file():
return True, "Aucun inventaire monte : rien a verifier."
sys.path.insert(0, str(RACINE / "scripts"))
import inventory_host as ih
data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {}
actifs = sorted(ih._hotes_du_groupe(data, "hotes_actifs")
if hasattr(ih, "_hotes_du_groupe")
else (data.get("all", {}).get("children", {})
.get("hotes_actifs", {}).get("hosts", {}) or {}))
if not actifs:
return True, "Aucun hote actif : rien a verifier."
cle_site = ""
try:
import underlay as underlay_mod
cle_site = str((underlay_mod.lire_plan_site("10-intrants.yml") or {})
.get("runner_cle_publique") or "").strip()
except Exception:
pass
runners = set(ih.groupes_hote(data, h) and h for h in actifs
if ih.GROUPE_RUNNER_TENANT in ih.groupes_hote(data, h))
fautes: list[str] = []
porteurs_site: list[str] = []
sans_cle_tenant: list[str] = []
for h in actifs:
recues = [c for c in ih._cle_amorcage(data, h, inv).split("\n") if c.strip()]
if cle_site and cle_site in recues:
porteurs_site.append(h)
if h not in runners:
fautes.append(f"{h} recoit la cle du SITE sans porter "
f"`{ih.GROUPE_RUNNER_TENANT}` — l'hebergeur y gagnerait "
f"un acces qu'aucun flux ne declare")
if not [c for c in recues if c != cle_site]:
sans_cle_tenant.append(h)
# Sans cle du tenant, le runner ne peut pas occuper le terrain qu'on lui prepare.
# On ne l'exige que si l'instance en declare une : un plan qui n'en nomme aucune
# se materialise comme avant, avec la seule cle de l'exploitant.
if ih._cles_admin_du_plan(inv) and sans_cle_tenant:
fautes.append(f"{len(sans_cle_tenant)} hote(s) ne recevraient AUCUNE cle de "
f"leur tenant : son runner ne pourrait pas les configurer "
f"({', '.join(sans_cle_tenant[:3])})")
if fautes:
return False, "Les cles de naissance debordent :\n - " + "\n - ".join(fautes)
return True, (f"{len(actifs)} hote(s) : la cle du SITE ne nait que sur "
f"{len(porteurs_site)} runner(s) de tenant, celle du tenant sur "
f"{len(actifs) - len(sans_cle_tenant)}.")
def preuve_insemination_sans_secret() -> tuple[bool, str]:
"""Ce que le SITE pose chez un tenant ne demande AUCUN secret de ce tenant.
@ -2145,6 +2213,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_interne_parle_bordure_se_tait},
{"id": "P54", "titre": "L'insemination ne reclame aucun secret du tenant",
"refs": [], "func": preuve_insemination_sans_secret},
{"id": "P55", "titre": "La cle du SITE ne nait que sur le runner d'un tenant",
"refs": [], "func": preuve_cle_du_site_bornee_au_runner},
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
"func": preuve_devis_frontiere_du_site},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],

View file

@ -307,7 +307,20 @@ CODES_ICMP = {
}
def _selecteur_nft(protocole: str, port) -> str:
# LES PORTS QUI NE SONT PAS DES NOMBRES, ET QUI SONT LEGITIMES.
#
# `derive` dit « ce port depend du deploiement » : Forgejo ecoute 3000 derriere un edge et
# 443 quand il sert son propre TLS, PowerDNS 53 seul sur son hote et 5300 en loopback
# derriere le resolveur. Seul le PLAN sait lequel — le role, lui, ne peut que le nommer.
#
# Ce mot est donc VALIDE quand on verifie un role, et INTERDIT quand on emet un fichier :
# ce sont deux questions differentes, et les confondre coute des deux cotes. Ecrit tel
# quel dans un ruleset, `nft` refuse TOUT le fichier ; refuse a la validation, un role
# parfaitement correct devient une faute.
PORTS_SYMBOLIQUES = {"derive"}
def _selecteur_nft(protocole: str, port, resolu: bool = True) -> str:
"""Selecteur nftables pour un flux : `tcp dport 22`, ou l'equivalent ICMP.
Un `port` non numerique sur de l'ICMP designe un code, jamais un port. Un code
@ -323,14 +336,59 @@ def _selecteur_nft(protocole: str, port) -> str:
f"Flux ICMP avec `port: {port}` : l'ICMP n'a pas de port. Nommer un code "
f"connu ({', '.join(sorted(CODES_ICMP))}) ou etendre CODES_ICMP."
)
# UN PORT SYMBOLIQUE NE DOIT JAMAIS ATTEINDRE LE FICHIER (2026-08-30).
#
# `port: derive` dit « ce port depend du deploiement ». Il etait ecrit TEL QUEL dans
# le ruleset, et `nft` le refusait a l'application — donc trois deploiements plus
# tard, sur la machine, avec un message qui parle de `ai_socktype` :
#
# /etc/nftables.conf:36: Error: Could not resolve service: Servname not supported
# ip saddr { ... } udp dport derive accept # serveur_powerdns
#
# L'appelant le resout depuis le plan AVANT d'arriver ici. S'il n'y arrive pas, il
# n'emet pas de regle — comme le devis OPNsense le fait depuis le 2026-08-25. Cette
# garde est le dernier filet : elle transforme un fichier casse en erreur de
# GENERATION, lisible la ou on peut encore la corriger.
mot = str(port).strip()
if not mot.isdigit():
if mot in PORTS_SYMBOLIQUES and not resolu:
# Validation d'un role, hors instance : le mot est declare, le plan le
# resoudra. On rend un selecteur de forme, jamais ecrit dans un fichier.
return f"{p} dport <{mot}>"
raise ErreurFlux(
f"Flux {p} avec `port: {port}` : port non numerique"
+ (" et non resolu par le plan" if mot in PORTS_SYMBOLIQUES
else f" et inconnu (attendus : un nombre, ou {', '.join(sorted(PORTS_SYMBOLIQUES))})")
+ ". Le declarer au plan (`applications.yml`, cle `port`) ou ne pas emettre "
"la regle : ecrit tel quel, `nft` refuse tout le fichier."
)
return f"{p} dport {port}"
def _ports_du_plan() -> dict[str, str]:
"""Port declare au plan pour chaque groupe — ce que `port: derive` vaut ici.
MEME SOURCE QUE LE DEVIS DE LA FRONTIERE (`devis_opnsense._ports_du_plan_site`), pour
le plan du TENANT au lieu de celui du site. Un service dit `derive` quand son port
depend du deploiement : Forgejo ecoute 3000 derriere un edge et 443 quand il sert son
propre TLS. Seul le plan sait lequel.
"""
try:
from inventory_rules import plan_de
f = Path(plan_de()) / "applications.yml"
apps = (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("applications") or {}
return {str(a["groupe"]): str(a["port"]) for a in apps.values()
if a.get("groupe") and a.get("port")}
except Exception:
return {}
def generer_nftables(flux_par_role: dict[str, list[dict]]) -> list[Path]:
data = yaml.safe_load(_inventaire().read_text(encoding="utf-8")) or {}
actifs_noms = set(_hotes_du_groupe(data, "hotes_actifs"))
ip_par_hote = _ip_par_hote(data)
admin_ssh = _sources_admin_ssh()
ports_plan = _ports_du_plan()
regles_admin = [
f" ip saddr {{ {cidr} }} tcp dport 22 accept # administration (garde anti-lockout, intrant nftables_admin_ssh)"
for cidr in admin_ssh
@ -347,6 +405,25 @@ def generer_nftables(flux_par_role: dict[str, list[dict]]) -> list[Path]:
if fl.get("sens") != "ingress":
continue
ports = fl["port"] if isinstance(fl["port"], list) else [fl["port"]]
# `derive` se resout par le plan ; sans lui on n'emet RIEN plutot qu'une
# regle que `nft` refusera — et l'on DIT ce qu'on ne pose pas. Un flux
# tu en silence est une porte qu'on croit ouverte.
if str(fl.get("protocole", "")).lower() in ("tcp", "udp"):
reels = []
for pt in ports:
if str(pt).isdigit():
reels.append(pt)
continue
connu = ports_plan.get(g)
if connu:
reels.append(connu)
else:
print(f"note : {g} declare un port `{pt}` que le plan ne "
f"resout pas — aucune regle emise pour {hote}.",
file=sys.stderr)
if not reels:
continue
ports = reels
srcs = _resoudre_sources(data, fl.get("pair"), actifs_noms, ip_par_hote)
saddr = "" if not srcs else "ip saddr { " + ", ".join(srcs) + " } "
local = "localhost" in (fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")])
@ -450,7 +527,10 @@ def main(argv: list[str]) -> int:
ports = fl["port"] if isinstance(fl["port"], list) else [fl["port"]]
for port in ports:
try:
_selecteur_nft(fl.get("protocole", "tcp"), port)
# `resolu=False` : on verifie un ROLE, pas un fichier. Un
# port symbolique y est legitime — c'est le plan qui le
# resoudra, et `generer_nftables` refuse s'il n'y arrive pas.
_selecteur_nft(fl.get("protocole", "tcp"), port, resolu=False)
except ErreurFlux as e:
raise ErreurFlux(f"{role} : {e}") from e
print(f"Flux coherents : {r} rôles, {t} flux, schéma + matrice OK.")

View file

@ -92,6 +92,36 @@ def inventaire() -> dict:
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 groupes d'une machine du site, tels que `plan/applications.yml` les declare.
def _groupes_de(nom_hote: str) -> set:
return {str(a.get("groupe")) for a in applications.values()
if str(a.get("hote")) == nom_hote and a.get("groupe")}
par_reseau = {r.get("nom"): r for r in U.reseaux(u)}
# Les sous-reseaux ou vivent REELLEMENT des machines du site : l'etendue de
# l'ecosysteme, telle que le plan la dessine.
@ -221,7 +251,24 @@ def inventaire() -> dict:
#
# Un resolveur ouvert au monde est un relais d'amplification ; celui-ci est
# ouvert a son ecosysteme, et a rien d'autre.
"serveur_resolveur_reseaux_autorises": _zones_du_site,
# 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),
# Ce que le marqueur verifiera : la liste ci-dessus les admet-elle vraiment ?
"serveur_resolveur_site_supernets_tenants": _supernets_tenants,
# 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.

View file

@ -112,19 +112,33 @@ def identites() -> str:
def etat() -> int:
"""Rapport lisible. Le CODE DE SORTIE ne juge que ce que CETTE machine doit pouvoir.
UNE CLE ABSENTE N'EST PAS UNE FAUTE — c'est souvent la bonne reponse (2026-08-28).
Sur le runner d'un tenant, la cle du SITE doit manquer : il porte la carte de la
fabric et ne doit jamais pouvoir l'ouvrir. Ma premiere version sortait en erreur des
qu'une cle manquait, ce qui faisait echouer une chaine parfaitement saine et
presentait une SEPARATION REUSSIE comme un defaut.
Seule l'absence de la cle de l'INSTANCE MONTEE empeche la machine de travailler :
c'est elle, et elle seule, qui decide du code de sortie. Le reste s'affiche.
"""
v = voutes()
if not v:
print("Aucune voute a nommer : ni instance montee, ni underlay.")
return 0
manquantes = 0
bloquant = False
for role, nom, chemin in v:
present = chemin.is_file()
manquantes += 0 if present else 1
print(f"{role:<11} {nom:<20} {'cle presente' if present else 'CLE ABSENTE '} {chemin}")
if manquantes:
print(f"\n{manquantes} cle(s) absente(s). En creer une :",
"umask 077; openssl rand -base64 48 | tr -d '\\n' > <chemin>", sep="\n ")
return 1 if manquantes else 0
if role == "instance" and not present:
bloquant = True
etiquette = "cle presente" if present else (
"CLE ABSENTE " if role == "instance" else "sans cle ")
print(f"{role:<11} {nom:<20} {etiquette} {chemin}")
if bloquant:
print("\nLa cle de l'instance montee manque — cette machine ne peut rien "
"configurer.\n umask 077; openssl rand -base64 48 | tr -d '\\n' > <chemin>")
return 1 if bloquant else 0
def main(argv: list[str] | None = None) -> int: