Compare commits
11 commits
90ea474745
...
35f3881218
| Author | SHA1 | Date | |
|---|---|---|---|
| 35f3881218 | |||
| 0dd57ba995 | |||
| e2bc235132 | |||
| fde0903ed8 | |||
| a5c9a88f00 | |||
| fc76a4e252 | |||
| 6f4833e65c | |||
| bb63865a37 | |||
| f2f6cccd3e | |||
| 5c59f19530 | |||
| 732aff9c70 |
34 changed files with 1576 additions and 77 deletions
376
CHANGELOG.md
376
CHANGELOG.md
|
|
@ -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 —
|
||||
|
|
|
|||
91
docs/audit/preuve-2026-08-30.md
Normal file
91
docs/audit/preuve-2026-08-30.md
Normal 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 | [0m[0m |
|
||||
| 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._
|
||||
91
docs/audit/preuve-2026-08-31.md
Normal file
91
docs/audit/preuve-2026-08-31.md
Normal 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 | [0m[0m |
|
||||
| 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._
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
playbooks/groupes/serveur_resolveur_site.yml
Normal file
15
playbooks/groupes/serveur_resolveur_site.yml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', '') }}.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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é
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
57
roles/serveur_resolveur_site/README.md
Normal file
57
roles/serveur_resolveur_site/README.md
Normal 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.
|
||||
11
roles/serveur_resolveur_site/defaults/main.yml
Normal file
11
roles/serveur_resolveur_site/defaults/main.yml
Normal 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) }}"
|
||||
13
roles/serveur_resolveur_site/meta/authentification.yml
Normal file
13
roles/serveur_resolveur_site/meta/authentification.yml
Normal 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.
|
||||
7
roles/serveur_resolveur_site/meta/empreinte.yml
Normal file
7
roles/serveur_resolveur_site/meta/empreinte.yml
Normal 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
|
||||
43
roles/serveur_resolveur_site/meta/flux.yml
Normal file
43
roles/serveur_resolveur_site/meta/flux.yml
Normal 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
|
||||
15
roles/serveur_resolveur_site/meta/main.yml
Normal file
15
roles/serveur_resolveur_site/meta/main.yml
Normal 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
|
||||
51
roles/serveur_resolveur_site/tasks/main.yml
Normal file
51
roles/serveur_resolveur_site/tasks/main.yml
Normal 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
|
||||
51
roles/serveur_step_ca/files/README-cle-smallstep.md
Normal file
51
roles/serveur_step_ca/files/README-cle-smallstep.md
Normal 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.
|
||||
19
roles/serveur_step_ca/files/smallstep-repo-signing-key.asc
Normal file
19
roles/serveur_step_ca/files/smallstep-repo-signing-key.asc
Normal 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-----
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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": [],
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue