Compare commits

...

33 commits

Author SHA1 Message Date
ac48b7650b proxmox : le pare-feu ne s'arme que dans le SDN — et 45e preuve
Some checks are pending
verifier / verifier (push) Waiting to run
Le decoupage du site en quatre zones a revele un defaut qui dormait dans le
moteur. `firewall=1` sur l'interface d'une VM fait passer tout le trafic ponte
par conntrack. Sur un VNet SDN c'est sans consequence : en EVPN le routage
inter-VNet se fait dans le VRF, SUR LE NOEUD, et le flux ne quitte jamais
l'hyperviseur. Sur un pont classique route par une frontiere externe, deux VM du
MEME noeud dans deux VLAN differents ne se parlent qu'en EPINGLE : la trame sort
par le lien physique, la frontiere la route, elle revient sur le meme pont. La
meme table conntrack voit alors les deux moities de la connexion, classe le
retour INVALID, et PVEFW-FORWARD le jette.

La mesure a tranche :

  ops(asgard)  -> pki(asgard)     0/8    dns(gandalf) -> cache(gandalf)  0/8
  ops(asgard)  -> forge(vishnu)   6/8    dns(gandalf) -> pki(asgard)     6/8
  ops(asgard)  -> cache(gandalf)  8/8    dns(gandalf) -> forge(vishnu)   8/8

Toutes les paires intra-noeud echouent, toutes les paires inter-noeuds passent.
Douze tentatives faisaient monter le compteur `ctstate INVALID` de +112 sur
asgard et +116 sur gandalf ; `firewall=0` pose, il ne bouge plus — 0 sur les
deux, pour le meme trafic.

Le defaut se deguise en panne reseau : la poignee TCP ABOUTIT, et ce sont les
paquets de DONNEES qui disparaissent. L'AC le disait dans son propre journal
(`TLS handshake error ... i/o timeout` : elle accepte et attend un ClientHello
qui n'arrive jamais). Ecartes un par un, par la mesure : regles identiques champ
par champ, alias corrects, assignation des interfaces confirmee, ARP et routes
saines, IPS desactive, shaper vide, NAT source limite a `wan`, MTU a 1500 de
bout en bout — et la taille sans effet, 100 octets se perdant comme 1460.

L'INTENTION DECLAREE NE SUFFIT PAS : un tenant declare
`proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme
valeur PAR DEFAUT, chaque hote la remplacant par son VNet derive. L'hote qui
retombe sur `vmbr1` naitrait arme sur un pont classique. `cloner_vm_debian.yml`
croise donc l'intention avec le pont REELLEMENT utilise, et le dit quand il
desarme — un desarmement muet serait le meme piege, en silence.

P45 EVALUE l'expression du playbook sur quatre cas plutot que d'en lire le
texte, dont un qui DOIT rendre vrai : sans lui, une expression constamment
fausse passerait la preuve sans rien garantir. Controle negatif verifie — le
drapeau remis a plat fait echouer la preuve.

Ce commit emporte aussi le desarmement d'`unattended-upgrades` dans
`common_packages`, jusqu'ici applique sur les machines mais pas versionne : le
verrou dpkg tenu 48 minutes par une vague de maj automatiques a coute deux
deploiements.

Le site a revele ce defaut parce qu'il a ete le premier a porter plusieurs
zones. Ce n'est pas une particularite du site : un tenant derive les siennes du
meme principe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:50:53 -04:00
1a7c042e5b site : quatre zones d'autorite, et l'ordre inscrit dans les integrations
Le site n'est plus un /24 plat. Une zone par nature d'autorite — pilotage, autorite,
genome, service — chacune son VLAN et sa patte sur la frontiere. L'inversion corrigee,
mesuree : les cinq VM du site n'avaient AUCUN filtrage est-ouest, contre policy_in=DROP
sur une machine de tenant. La plus autoritaire etait la moins protegee.

Filtrage nord-sud par choix de l'exploitant : un seul point de police, un seul devis.
90 -> 117 regles. Chaque flux `flotte` produit une regle par zone SOURCE, destination
nommee — ce qui etait gratuit devient police.

L'ORDRE FAIT PARTIE DE L'INTEGRATION. client_pki tournait en parallele sur tous les hotes ;
sur celui qui porte l'autorite il recharge step-ca, et les quatre autres echouaient dans
cette fenetre sur `TLS handshake timeout` — un message qui accuse le reseau. Chaque
integration declare desormais sa dependance dans meta/integration.yml, les playbooks en
sont le miroir genere, et P44 refuse l'ecart (4 controles negatifs). `serveur: ~` est une
reponse valable : client_metrique pose un exportateur qu'on vient LIRE.

Autres defauts du meme soir :
  - le plancher /etc/hosts venait APRES le premier apt, qui vise le cache par son NOM :
    boucle fermee des que les adresses changent. Il ne s'installe pas, il rend installable.
  - dns_amorcage ecrit en dur a eu tort deux fois ; il se derive de serveur_resolveur.
  - l'ACL du resolveur derivait d'un seul sous-reseau : trois zones refusees sur quatre.

ET UNE ERREUR A MOI : j'ai diagnostique un trou noir de MTU et declare 1450. Faux — pas de
VXLAN sur ce chemin, tout est a 1500 de bout en bout. Ma mesure etait reelle, mon
interpretation non : je venais de debrancher la carte a chaud, et chaque `ip link set mtu`
reconfigurait l'interface. C'est la reconfiguration qui debloquait, pas la valeur.

44 preuves vertes, ansible-lint profil production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:31:07 -04:00
158c3b314a preuve : P43 — la frontiere voit-elle les machines du site ?
Couvre ce qui a failli coûter 36 objets ce soir : le devis de la frontiere avait cesse de
voir le site et proposait de retirer tous ses alias et toutes ses regles. Harnais vert,
lint vert — seule la lecture manuelle du plan avant application l'a attrape.

La premiere version etait inutile, et c'est instructif : elle lisait le plan par
`devis_opnsense._machines_du_plan_site()`, la fonction meme dont la panne etait a
detecter. Eprouvee sur la regression reelle, elle SE TAISAIT — les deux voyaient le vide,
et elle concluait « rien a prouver ». Une preuve qui partage la source de ce qu'elle
verifie ne verifie rien.

La version retenue lit plan/serveurs.yml et plan/applications.yml DIRECTEMENT et confronte
au devis produit. Eprouvee sur la regression reelle : elle refuse et nomme la cause.

43 preuves vertes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:11:39 -04:00
ea67b75a15 dns : le site resout chez lui, et le socle cesse de le defaire
Mise au point de l'exploitant : la frontiere n'a pas de service DNS actif et gere. Un
Unbound y tourne — il repondait, ce qui m'a induit en erreur — mais un processus n'est pas
un service. La delegation de zone que j'y avais posee est retiree.

Et le site a son propre DNS : `dns_amorcage` pointait encore sur la frontiere, valeur d'un
moment ou site-dns-01 n'existait pas. Elle vaut desormais 10.0.3.51.

Deux defauts revelés au passage :

  - devis_opnsense lisait encore underlay.machines(), vide depuis le deplacement du plan.
    Il proposait de RETIRER 36 objets — tous les alias et regles du site. Aucune preuve ne
    couvre ce devis : c'est le plan avant application qui l'a attrape.
  - le socle defaisait la bascule de client_resolveur a chaque passage. Sa garde ne
    protegeait que l'hote du resolveur (127.0.0.1). Invisible chez un tenant, ou
    dns_amorcage vaut l'adresse du resolveur : la coincidence masquait le defaut. Ca ne
    s'est vu qu'en retirant la regle de pare-feu — les cinq machines ont perdu la
    resolution d'un coup.

L'amorcage ne s'applique plus que si rien de sense n'est en place. Verifie : socle
`changed=0`, les cinq machines restent sur 10.0.3.51.

42 preuves vertes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:57:48 -04:00
b60946f19f forgejo : verifier l'etat du compte, ne pas faire confiance au drapeau
Toute l'API rendait 403 — ni 401, ni message de droits : jeton valide, identite reconnue,
requete rejetee. La cause etait `must-change-password` sur le compte d'administration.

Le role passait DEJA `--must-change-password=false`, corrige le 2026-08-23 sur la premiere
forge de patient 0. Forgejo 16 a ignore l'option : le compte de la forge du SITE est ne
avec le drapeau pose malgre elle. La correction d'il y a deux jours ne protegeait plus
rien, et rien ne le disait.

Le role ne demande donc plus a la creation de bien se comporter : il CONSTATE l'etat voulu
et le retablit, par une tache idempotente sans effet quand la creation a tenu parole.

Le genome vit desormais sur la forge du site : six depots, 606 commits, chaque tete
confrontee entre le poste et la forge — identique. L'ancienne forge reste comme second
remote : « une famille, pas un maitre ».

42 preuves vertes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:33:10 -04:00
43666cff6e site : un SITE a bien un plan — et huit defauts que lui seul pouvait reveler
J'ai repete toute la journee qu'un SITE n'est pas un plan. C'etait faux : j'en
reconstruisais un morceau par morceau dans underlay.yml sans le nommer. Ce qui est vrai,
c'est qu'un site ne DERIVE pas — il declare ses adresses parce qu'il EST le terrain — et
ne passe donc pas par `instancier`. Mais ne pas deriver n'est pas ne pas avoir de plan.

SITE-Chezlepro/plan/{10-intrants,serveurs,applications}.yml ; underlay.yml ne garde que
la fabric. Ce qui est MESURE d'un cote, ce qui est VOULU de l'autre. Les groupes viennent
du registre des applications, les gardes ont suivi le plan (4 controles negatifs).

Huit defauts, tous invisibles chez un tenant parce qu'il a toujours tout :
  - client_pki codait `infra-pki-01` EN DUR — vrai par coincidence de nomenclature ;
  - aucun moyen pour un service non-root de lire la cle (Forgejo tourne en `git`) ;
  - le certificat, PUBLIC par nature, restait en 0600 ;
  - l'unite Forgejo n'avait pas d'ExecReload : un cert renouvele aurait ete servi perime ;
  - le role ne savait pas servir TLS lui-meme (il y avait toujours un edge) ;
  - le cert ne couvrait pas le nom de SERVICE, faute d'`expose:` ;
  - les registres se chargeaient en tout-ou-rien : sans domaines.yml, plancher vide ;
  - le flux declarait 3000 en dur.

Le message accusait presque toujours autre chose : le DNS quand c'etait un nom faux, une
permission de fichier quand c'etait un port privilegie, rien du tout quand le plancher
s'ecrivait vide.

ROOT_URL est gravee dans les URL de clonage : la forge sert desormais sur 443, avec
CAP_NET_BIND_SERVICE, et son URL n'a plus de port.

Verifie depuis le reseau : Verify return code 0, https://forge.genese.internal/ -> 200.

42 preuves vertes, flux coherents (34 roles, 92 flux).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:34 -04:00
add94f2cbd underlay : un SITE n'a pas d'index — le vestige est retire
`index: 17` ne disait pas « voici mon adressage » : un site ne derive rien, ses machines
vivent sur un reseau de fabric hors de toute derivation. Il disait UNE seule chose — quel
supernet de tenant est le mien — pour autoriser un reseau du site a en occuper la bande
basse (D-77). Un reste de la coincidence hebergeur<->tenant chez Chezlepro, qui est les
deux a la fois ; ailleurs il aurait fallu inventer un index a un hebergeur qui n'heberge
pas son propre ecosysteme.

L'exception se declare desormais sur le RESEAU qui en a besoin, et elle NOMME le tenant
dont elle occupe la bande basse (`bande_basse_de: OPS-Chezlepro`). Plus vrai : un seul
reseau est concerne. Plus verifiable : le nom se resout dans le registre `tenants:`, donc
une faute de frappe est refusee au lieu de passer.

Quatre controles negatifs, tous refuses — tenant inconnu, exception absente, debordement
sur la bande des zones, et le mauvais tenant nomme.

42 preuves vertes, les quatre devis du panneau OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:11:20 -04:00
467b05cbc0 site : un ecosysteme complet — PKI, DNS, forge, cache, runner
Le site portait trois services sur les sept du modele `origine`. Sa forge servait LE
GENOME EN CLAIR et aucune de ses machines n'avait de certificat — donc aucun chiffrement
est-ouest, ce que la doctrine zero-confiance interdit. site-pki-01 (step-ca) et
site-dns-01 (PowerDNS + resolveur colocalises) rejoignent les trois autres.

Quatre defauts que le site a fait tomber, chacun invisible chez un tenant :

  - DNS bloque par notre propre default-deny. Un tenant a son resolveur DANS son reseau
    et ne traverse jamais la frontiere ; le site interroge la sienne. La regle est derivee
    de `site.dns_amorcage`, destination declaree, jamais `any`.
  - serveur_cache_site n'installe rien : il marque un cache et lit les variables de
    serveur_artefacts. Les defauts d'un role ne sont en portee que dans le play qui
    l'inclut — une dependance de role regle l'ordre ET la portee.
  - resoudre_idp partait meme avec OIDC desactive, et exigeait un plan. La resolution
    suit desormais l'usage.
  - le plancher /etc/hosts etait VIDE : `hotes_actifs` n'existait pas dans l'inventaire
    du site. Un role qui reussit en n'ecrivant rien est la pire forme d'echec.

Une machine du site peut desormais se configurer (`variables:`), appliquee en dernier :
ce qu'une machine declare d'elle-meme prime sur ce que le site declare pour toutes.

Verifie et non suppose : systemd disait `active` mais rien n'ecoutait sur 443 — step-ca
sert sur 8443. La zone souveraine resout et la recursion marche, mesurees sur la machine.

42 preuves vertes, ansible-lint profil production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:00:17 -04:00
a530d5f70e federation : c'est le SITE qui determine l'index d'un tenant
SITE et OPS sont deux classes distinctes. Le site decide de la fabric et du reseau et
PRODUIT les intrants ; le tenant les consomme et n'a d'intelligence que sur ses
applications, leur configuration et leurs integrations. Une valeur reseau qu'un OPS decide
est une valeur mal placee.

L'index est l'intrant reseau par excellence — supernet, sous-reseaux de zone, VLAN, VMID,
noms de VNet en descendent. Il etait declare DEUX FOIS : dans la nomenclature du tenant et
dans l'underlay du site. Et le site ne declarait meme pas qui il heberge : la cle
`tenants:` existait dans le code, jamais dans la carte — la decouverte se faisait par
balayage des dossiers freres.

`tenants:` devient un registre d'allocation (nom -> index). Le site alloue, le tenant
recoit, la nomenclature n'est plus que la copie verifiable d'une decision prise ailleurs.
Quatre gardes neuves, chacune eprouvee par un controle negatif, sous P23.

Un tenant qui s'emancipe recoit un index NEUF de son nouveau site : l'index n'est pas une
propriete du tenant, c'est une place sur une fabric.

42 preuves vertes, quatre devis du panneau OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:42:14 -04:00
fcde343ef0 devis reseau : un segment non etiquete n'appartient a aucune fabric de switch
`segment_physique: true` retire la cle `vlan`. devis_reseau.py ecrivait `r['vlan']` en une
vingtaine d'endroits : KeyError, /api/devis-reseau en 500, et LA VUE RESEAU DU PANNEAU
RESTAIT VIDE — une panne a deux couches de sa cause.

Filtre a la source plutot que colmatage : devis_reseau definit son propre
`reseaux_de_fabric` qui ecarte les reseaux sans etiquette, et les dix appels y passent.
Colmater les vingt occurrences aurait laisse la vingt-et-unieme.

Le bloc de gestion d'un switch echappait au filtre (il lit son reseau directement). Sans
etiquette, aucune `interface VlanN` n'existe : l'adresse va sur l'interface de gestion
native du boitier. Le devis le DIT au lieu d'inventer une syntaxe.

Expose au passage que bifrost-3 et bifrost-4 declarent leur gestion sur un segment qui ne
les traverse pas, a des adresses qui n'ont jamais repondu.

Les quatre devis du panneau repassent. 42 preuves vertes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 09:51:57 -04:00
a6713ffdcd doctrine : patient 0 n'est pas un tenant — corriger la raison, garder le geste
Le commit dc92f01 justifiait le retrait des roles de site de patient 0 par « patient 0
est un tenant comme les autres, c'est meme tout ce qu'il prouve ». C'est FAUX.

Un tenant ordinaire existe POUR SES GENS : Chezlepro heberge de l'identite, du courriel,
de la collaboration. Patient 0 n'heberge que LA LIGNEE. Il est l'ecosysteme d'origine et
le detenteur du genome, et il le reste.

Ce qui le quitte, ce sont deux responsabilites de SITE qu'il tenait faute d'un SITE
capable de les porter — a l'epoque un site n'etait qu'un fichier de carte, sans machines.
Le SITE existe desormais comme objet a part entiere : le geste etait donc juste, seule sa
raison ne l'etait pas.

Le texte est corrige plutot qu'efface, dans le CHANGELOG comme dans les fichiers : une
doctrine juste appuyee sur une raison fausse finit toujours par se retourner.

42 preuves vertes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 09:34:02 -04:00
0b6c036f8c ssh : retirer les drop-ins d'une nomenclature abandonnee
Mesure sur forge-01 (par l'agent invite, sans dependre du reseau) : quatre drop-ins SSH
coexistent la ou il devrait y en avoir deux. Les roles se sont appeles `chezlepro` avant
de porter le nom du moteur ; le renommage a change le fichier DEPOSE sans retirer le
precedent.

Et les paires divergent : MaxSessions 2 contre 10, MaxStartups 5:30:20 contre 10:30:60.
sshd retient la PREMIERE valeur rencontree pour chaque mot-cle et lit les drop-ins dans
l'ordre lexical — c'est donc l'ANCIEN fichier qui gagne. Une configuration qu'on croit
avoir remplacee reste aux commandes, en silence.

ssh_baseline et ssh_hardening retirent chacun le fichier de la nomenclature qu'ils ont
abandonnee, AVANT de deposer le leur, et notifient la meme validation. La liste est
declarative et ne contient que des noms reellement deposes un jour : supprimer un fichier
qu'on n'a jamais ecrit serait effacer la configuration d'un autre.

PAS ENCORE EPROUVE : les seules machines portant les anciens fichiers sont celles de
patient 0, hors d'atteinte depuis le poste. Ecrit, linte, reste a le voir agir.

42 preuves vertes, ansible-lint profil production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 03:40:24 -04:00
6715f0d5c5 clonage : quatre defauts que la premiere VM placee hors du noeud du gabarit a reveles
Toutes les VM naissaient sur le noeud du gabarit puis migraient. site-cache-01 est la
premiere a etre placee ailleurs, et elle a fait tomber quatre defauts enchaines :

1. l'URL de clonage visait le noeud de DESTINATION, alors que l'API veut celui qui DETIENT
   le gabarit ; la destination se dit par `target`. D'ou un 500 sur tout clonage
   inter-noeuds. Le noeud du gabarit se decouvre dans l'inventaire du cluster.
2. ce 500 etait avale par failed_when: false + no_log: true. Une assertion le releve
   desormais, message de l'API compris.
3. l'attente interrogeait nodes/<destination>/tasks/<UPID> ; la tache vit sur le noeud du
   gabarit. 60 tentatives x 10 s pour un clonage termine en 87 s, puis la suite qui reprend
   sans un mot. Le noeud se lit dans l'UPID lui-meme.
4. la garde d'apres-attente retombait sur `exitstatus | default('OK')` : une attente qui
   n'avait rien observe passait pour un succes. Elle exige d'avoir VU la tache s'arreter.

Et une taille de disque porte toujours son unite : `disque: 40` etait lu comme un
retrecissement, erreur toleree a raison — la machine naissait donc avec les 16 Go du
gabarit au lieu de 40, sans que rien ne le dise.

Le site declare enfin ce qu'il materialise (materialisation: gabarit, stockage,
clone_complet) au lieu de l'heriter du tenant actif.

Chrono : clonage reel 59 s et 87 s pour 3,3 Gio alloues.

42 preuves vertes, ansible-lint profil production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 02:44:21 -04:00
dc92f01fe4 frontiere : le devis voit le site — fabric, alias et regles
Un mot manquait au vocabulaire des flux. Le runner de SITE declarait son API Proxmox en
`externe`, qui se rend par !SETOPS_INTERNES — or les hyperviseurs SONT en RFC 1918 : la
regle les aurait exclus tout en ayant l'air d'ouvrir le flux. D'ou `fabric`.

Deux flux manquaient :
  - serveur_cache_site n'avait aucun egress : la chaine de caches se terminait sur un
    cache vide, et ca ne se serait vu qu'au premier apt update d'un ecosysteme neuf ;
  - serveur_ops_site n'avait que l'API : le shell des noeuds et l'API de la frontiere
    sont deux autres pouvoirs, declares a part.

Le devis ignorait les machines du site — dans aucun plan de tenant, donc invisibles a sa
boucle, zero regle rendue SANS RIEN SIGNALER. Il emet desormais SETOPS_SITE,
SETOPS_FABRIC, SETOPS_ADMIN_SITE et un alias par role, sur opt2 (arrivee reelle du
trafic) et lan pour le SSH, plus le NAT sortant du site.

Le socle s'applique au site : site_inventaire.py range ses machines dans serveur_debian.

Patient 0 ne declare plus serveur_ops_site ni serveur_cache_site : il est un tenant
comme les autres, c'est meme tout ce qu'il prouve. Inventaire regenere.

42 preuves vertes. Devis : 26 regles a creer, 5 a retirer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 00:49:04 -04:00
bf35b4f528 site : les machines de l'hebergeur sur leur propre reseau de fabric
VLAN 30 / 10.0.3.0/24, pont vmbr3 (bond3, sur les trois noeuds), passerelle 10.0.3.1
portee par la frontiere (OPT2). site-ops-01 en .11, site-cache-01 en .21.

Le repli intermediaire etait grappe-controle (192.168.11.0/24) : porte par un vrai pont,
mais de l'herite — des adresses sans avenir, derriere une passerelle qui n'est meme pas
la frontiere. Le VLAN 30 leve les deux : adressage de fabric coherent avec le transit
(vlan 40 -> 10.0.4.0/24), et sortie PAR LA FRONTIERE, donc policee.

42 preuves vertes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 00:32:55 -04:00
6e126dd8a9 site : le chemin qui materialise les machines de l'hebergeur
- site_machines.py : traduit une declaration d'underlay en SETOPS_*, que cloner-vm
  consomme deja. Le clone reste le seul chemin eprouve.
- site_inventaire.py : inventaire DYNAMIQUE. Un site ne derivant de rien, sa declaration
  est deja sa forme finale — un hosts.yml genere ne rendrait rien plus inspectable.
  Les groupes sont les services : playbooks/groupes/<role>.yml trouve ses hotes seul.
- cibles site-decrire / site-inventaire / site-creer (CONFIRMER=true) / site-appliquer,
  toutes independantes d'une instance montee : le site existe avant tout tenant.
- playbook de groupe manquant pour serveur_cache_site, et son classement en couche.

Le premier garde-fou de site-appliquer refusait un GROUPE vide : il ne pouvait jamais
se declencher (GROUPE a un defaut global). Le vrai risque, observe en le testant : un
playbook de tenant contre l'inventaire du site ne matche aucun hote et sort avec 0 — un
succes qui n'a rien fait. Refus desormais de tout groupe absent de cet inventaire.

42 preuves vertes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:11:32 -04:00
93dde5b4f2 site : un SITE n'est pas un plan — ses machines vivent dans l'underlay
La branche « adressage declare » du generateur de tenants est retiree. Un tenant se
derive de son index ; un site n'a pas d'index et ne derive de rien. Les faire passer par
la meme moulinette donnait une nomenclature de site vide de sens, et un site exclu des
devis par ABSENCE d'index plutot que par nature.

Les machines de l'hebergeur se declarent desormais dans underlay.yml, a cote des switches
et des hyperviseurs qui les portent. Six gardes neuves, chacune eprouvee par un controle
negatif.

Mesures qui ont corrige la carte :
  - 10.17.0.0/24 n'a pas d'etiquette VLAN (segment physique sur igb0 de la frontiere) ;
    le VLAN 10 de vmbr1 est l'ancien plan 10.0.0.0/24, vide.
  - aucun pont d'hyperviseur ne porte ce segment : trois sondes muettes, temoin positif
    reussi. Une VM y naitrait sourde — le validateur le refuse.
  - les machines du site vont donc sur grappe-controle (vmbr0), seul plan de l'hebergeur
    porte par un pont reel, avec passerelle et sortie.

Role d'hote neuf : passerelle_amont — un routeur reel que nous n'administrons pas.

42 preuves vertes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:02:05 -04:00
9960cfd68e instancier : accepter un adressage declare, pour les plans sans index
Un SITE decrit les machines de l'hebergeur : pas d'index, pas de cohabitation
avec les tenants, il vit dans le reseau d'administration. Rien ne peut donc
deriver d'un seed inexistant.

L'explicite (`ip`, `vmid`, `vlan`) gagne sur le derive, et les deux chemins se
rejoignent sur un seul jeu de hostvars. La garde reste entiere : une machine
sans adresse -- ni declaree ni derivable -- est toujours refusee.

Revele en preparant le plan du site : l'underlay ne dit PAS quel pont Proxmox
porte quel reseau. Les tenants ne s'en apercevaient pas, leur pont etant un VNet
derive de leur index. Un SITE n'en a pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:35:52 -04:00
85ef6e107e voute : demander le mot de passe une fois, pas quinze
flotte-creer appelait `make creer-vm` une fois par hote, et chaque appel
ajoutait --ask-vault-pass. Quinze machines = quinze invites, quatre en
parallele, avec la sortie redirigee vers des journaux : l'exploitant est harcele
par des invites qu'il ne voit meme pas.

L'idiome existait DEJA dans deployer-tout, et je ne l'avais pas cherche : ma
premiere correction inventait une seconde facon de manipuler un secret. Deux
resolutions d'une meme question finissent par diverger -- la lecon de P41,
appliquee a un mot de passe.

Les deux cibles partagent maintenant un bloc unique, VAULT_UNE_FOIS.

Et une garde que ma premiere version n'avait pas : `-t 0`. Sans elle, un appel
non interactif restait bloque sur une invite que personne ne lit -- constate en
verifiant ma propre correction, qui a pendu deux minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:14:08 -04:00
9c173f52ba forge du genome : mutualisee par site, avec une confiance limitee a une url
Une forge sert a deux choses que le meme logiciel confondait : porter le GENOME
-- meme contenu pour tous les tenants d'un site -- et heberger le TRAVAIL de ses
gens, qui est un service du tenant. La premiere se mutualise, la seconde non.

Lire le genome chez un voisin suppose de faire confiance a SON autorite. On ne
pose PAS cette racine dans le magasin systeme : `git config
http.<url>.sslCAInfo` limite la confiance a cette seule forge. Une porte, pas un
trousseau.

L'adresse est une IP : `forge.genese.internal` ne resout pas depuis un autre
tenant, et le certificat de l'edge porte l'IP dans ses SAN.

Effet de bord recherche : le genome devient disponible AVANT que la forge du
tenant soit debout. Un ecosysteme neuf n'attend plus sa propre forge pour se
remplir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:55:44 -04:00
da211bd81b modeles : extraire le denominateur commun de patient 0
Patient 0 conflait trois roles : le denominateur commun, l'ecosysteme de
l'hebergeur de SITE-Chezlepro, et le detenteur du genome. Seul le premier est
generique -- il devient le modele `origine`.

Un modele n'a ni index reel, ni voute, ni parente, ni machines. Patient 0 avait
les quatre : c'est ce qui prouvait la confusion.

`origine` ne porte PAS serveur_ops_site ni serveur_cache_site : ce sont les
roles de l'hebergeur, et un client qui les recevrait aurait un pouvoir sur ses
voisins.

Trouve au passage : les six modeles prives portaient encore setops_plan_dir en
dur sur `instance/plan`. Corrige chez les instances il y a deux jours, il avait
survecu ici -- un deploiement par SETOPS_INSTANCE y aurait lu le plan d'une
autre instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:17:07 -04:00
bf8e27ef85 pare-feu est-ouest : applique, et le port derive enfin compris des deux cotes
verifier_ports traitait depuis toujours un port non numerique comme « pas une
ecoute fixe ». Le generateur est-ouest l'envoyait tel quel a l'API Proxmox :
« invalid port 'derive' », six regles refusees. Une meme notion, comprise d'un
cote et pas de l'autre.

Sauter est la bonne reponse : depuis que le resolveur est la seule porte,
PowerDNS n'ecoute que sur 127.0.0.1:5300 -- aucune regle est-ouest n'a d'objet
pour lui. Les trois groupes t*-srv-powerdns sont retires.

Mais un flux qu'on n'applique pas doit SE VOIR : le devis recense et affiche les
ports sautes avec leur raison. Sans cette note, sauter proprement serait devenu
un trou silencieux.

Les deux devis sont clos. Flotte verifiee : DNS, Internet, apt, cache joignable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:58:54 -04:00
a1997774be frontiere : appliquee, et la contrainte de nommage d'OPNsense consignee
Devis clos : 0 a creer, 0 a retirer, 65 inchange + 15 routes. Flotte verifiee
apres coup -- DNS interne, Internet et apt sans erreur sur les cinq hotes.

OPNsense refuse un alias de 32 caracteres ou plus. La contrainte n'etait ecrite
nulle part et se manifestait a l'APPLICATION, pas au devis. nom_alias abrege
desormais (SERVEUR_ -> SRV_, comme le pare-feu est-ouest) et REFUSE bruyamment
si le nom deborde encore : un devis qui promet un objet que la cible rejettera
n'est pas un devis. Le role devient serveur_cache_site.

« RIEN N'EST APPLIQUE » signifiait « pas encore recharge », pas « rien ecrit » :
les objets etaient dans la config, le pare-feu en marche les ignorait. J'avais
lu le message a l'envers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:51:29 -04:00
fa7e656844 artefacts : le chainage des caches, et la regle qui appartient au SITE
Une regle inter-tenant n'appartient ni a l'emetteur ni au recepteur : c'est le
site qui autorise un flux entre deux de ses tenants, et son runner qui prepare
le terrain.

Deux silences fermes dans le generateur de frontiere. flux_frontiere() ne
retenait que les flux `externe` -- or deux tenants vivent sur des VLAN routes
par la frontiere, leur trafic la traverse. Et un egress vers un voisin recevait
!SETOPS_INTERNES en destination : le port ouvert vers l'INTERNET. La destination
est desormais nommee.

Deux roles parce que meta/flux.yml est statique : un role unique aurait declare
l'ingress pour TOUS les caches -- maillage complet, visible au devis.
serveur_artefacts_site porte l'ingress, serveur_artefacts l'egress vers l'amont.

Debian est desormais telecharge une fois pour toute la fabric. Le cache du site
ne voit que des requetes agregees, jamais quelle machine installe quoi.

Rien n'est applique : le devis se lit avant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:40:33 -04:00
c391d1ed30 frontiere : rendre les flux inter-tenants, sans encore les declarer
Le mot `voisins_site` etait accepte par la validation mais AUCUN generateur ne
le rendait : zero regle au devis, et rien ne le signalait. Deux causes, toutes
deux fermees.

flux_frontiere() ne retenait que les flux `externe`. Or deux tenants de la meme
fabric vivent sur des VLAN distincts, routes par la frontiere : leur trafic la
traverse, donc elle doit le porter.

Et le rendu manquait : une regle inter-tenant s'attache au meme lien de transit
que le reste -- les tenants s'y distinguent par leur ALIAS SOURCE, pas par une
interface. Ma mise en garde precedente reposait sur un modele faux.

LA REGLE APPARTIENT AU SITE, pas a l'un des deux tenants : c'est son runner qui
prepare le terrain, aucun ecosysteme n'ouvre de porte chez un autre.

Les declarations de chainage restent RETIREES : le devis obtenu ouvrait plus que
voulu -- maillage complet entre tous les caches, et un egress 3142 vers
l'Internet au lieu du seul voisin. Deux raffinements a faire avant de declarer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:32:35 -04:00
6547eb2fac flux : le mot voisins_site, et l'aveu de ce qui manque pour s'en servir
Le registre ne savait pas dire « les autres tenants de ma fabric ». ingress +
externe signifie DEPUIS L'INTERNET : declarer ainsi un cache partage l'aurait
publie au monde.

voisins_site rend les supernets des tenants que CE SITE heberge, en reutilisant
devis_reseau.decouvrir_du_site() plutot qu'en ecrivant un second recensement.
Ma premiere version lisait un `federe` absent comme « non federe » et excluait
Chezlepro et Technolibre en silence -- la decouverte canonique dit l'inverse.

LE CHAINAGE DES CACHES N'EST PAS LIVRE. Aucun generateur ne rend ce mot : zero
regle 3142 au devis de frontiere. Les declarations ont donc ete RETIREES plutot
que laissees a moitie -- un flux declare que personne n'applique est le piege
que ce depot traque.

La difficulte est structurelle : les regles OPNsense s'evaluent sur l'interface
d'ARRIVEE, donc une regle inter-tenant doit etre posee sur l'interface du
VOISIN. Le generateur construit tenant par tenant, sur les interfaces de ce
tenant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:15:19 -04:00
12d938cdb0 artefacts : le cache existe dans chaque ecosysteme, plus seulement chez patient 0
Un seul cache existait -- celui de patient 0. Les trois autres instances et les
six modeles allaient chercher leurs paquets chez Debian, machine par machine.

Place sur la forge quand il y en a une (« la forge est la source », du code ET
des binaires), sinon sur l'hote des services d'infrastructure.

Voir docs/filiation-emancipation.md : le cache est MUTUALISABLE. Un ecosysteme
au premier age peut aussi bien pointer sur celui de son hote plutot que d'en
heberger un.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:02:54 -04:00
db2d6f6bf0 resolveur : un par tenant, et non plus un par machine
Mesure avant de decider : ~21 Mo de RSS par VM pour 28 a 189 requetes servies.
Le gain de cache est negligeable ; ce qu'on recupere, c'est un demon au lieu de
cinq et un endroit a regarder au lieu de cinq.

Pas sur la frontiere : un resolveur de SITE devrait connaitre la zone interne de
chaque tenant, et un tenant dont le resolveur vit chez l'hebergeur ne peut plus
s'emanciper avec. La recursion est generique, la zone interne ne l'est pas.

L'autoritatif se replie sur 127.0.0.1:5300 -- derive de la colocalisation, pas
declare a la main. Son port devient `derive` dans meta/flux.yml : les deux lient
53 mais sur des adresses differentes.

Deux pieges. Unbound refuse d'interroger une loopback par defaut : sans lever
do-not-query-localhost, toute la zone rendait SERVFAIL. Et le plancher
/etc/hosts MASQUAIT la panne -- getent repondait, dig disait SERVFAIL. La tache
de validation du role avait raison contre moi.

client_unbound n'installant plus Unbound, son nom mentait : client_resolveur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:59:34 -04:00
70e57076e8 collabora : en natif, la derniere exception conteneurisee tombe
Le role lancait collabora/code dans Docker -- seule exception de la flotte au
principe « logiciel libre en natif ». Desormais : paquet coolwsd du depot amont,
systemd, derriere l'edge nginx. community.docker est retiree de requirements :
plus aucun role n'a besoin de Docker.

Eprouve avant d'ecrire, sur une Debian 13.6 reelle et sans rien installer :
apt-get install --simulate coolwsd resout jusqu'a « Conf coolwsd (26.04.3.1-1) »,
libgcc1 est fourni par libgcc-s1, et le depot CODE-deb est PLAT.

La configuration passe par un fragment systemd (--o:) : le coolwsd.xml livre,
439 lignes commentees, reste intact.

Trois outils du harnais ont pese. voute.py lisait les COMMENTAIRES : documenter
le nom d'une clef suffisait a l'exiger -- corrige, controle negatif fait. Et
verifier_intrants avait raison : une garde ecrite en deux morceaux promettait un
secret pour une console fermee ; reecrite en implication, elle dit le vrai
contrat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:23:35 -04:00
1363ebff59 acces : le runner peut enfin entrer, et trois silences fermes
ssh_baseline gere les cles d'administration declarees au plan, avec un `etat`
par entree : revoquer devient un changement de plan, pas une visite sur chaque
machine. Pas d'exclusive -- il effacerait la cle de cloud-init et fermerait la
flotte a tout le monde.

cloud-init reecrivait /etc/hosts a chaque demarrage et effacait le plancher de
resolution. Constate sur infra-dns-01 apres un redemarrage : six entrees
perdues, revelees deux jours plus tard par un apt update qui ne resolvait plus.

requirements.yml ne declarait pas ansible.posix ni community.docker, pourtant
utilisees. Ca marchait chez le mainteneur, pas sur un runner. Et le cache des
collections suivait l'existence du fichier au lieu de son contenu.

Enfin : deux `when` sur une meme tache, c'est un seul -- le dernier. Le
check-mode avait disparu en silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:50:37 -04:00
2b55761fba runner : separer le pouvoir de configurer de celui de materialiser
La ligne de partage est celle des voutes. serveur_ops calcule et configure --
voute du tenant, SSH chez lui. serveur_ops_site materialise -- voute du SITE,
API de l'hyperviseur, jamais de SSH chez un tenant.

Un runner par tenant qui materialiserait mettrait la voute du SITE en N
exemplaires. Un runner unique qui ferait tout traverserait le default-deny
inter-tenant et rendrait l'emancipation impossible.

Le role depose la voute CHIFFREE et relit l'en-tete apres avoir ecrit : sans
`decrypt: false`, Ansible dechiffre la source quand il detient le mot de passe
-- constate le jour meme, 776 octets en clair au lieu de 3465. Controle negatif
fait, la garde mord.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:30:03 -04:00
f248bb084f filiation : nommer les trois ages d'un ecosysteme
Un ecosysteme nait fils, peut rester mutualise par choix, et s'emancipe quand il
le decide. Le moteur avait rencontre ce motif trois fois sans le nommer :
client_artefacts_actif, serveur_ops_forge_externe, client_backup_cible.

serveur_ops_forge_externe etait citee par le registre des dependances ET par le
README, definie nulle part : l'exemption ne pouvait jamais s'appliquer. Elle
existe maintenant, avec un amont obligatoire.

Consequence pour les modeles : quatre n'ont pas de forge, ce ne sont pas des
lacunes mais des ecosystemes au premier age. Ils le declarent.

Reste a faire : l'instrument qui PROUVE qu'une emancipation a coupe le lien.
Sans lui, on croirait s'etre emancipe en restant dependant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 14:10:10 -04:00
e6c5f9f539 instancier : refuser une machine dont la fonction n'est pas declaree
deriver_nomenclature(...) or {} avalait l'echec : une fonction absente rendait
un dictionnaire vide, et la machine entrait dans l'inventaire avec
ansible_host: None. La generation se declarait reussie ; la panne serait
apparue au deploiement, sous une forme incomprehensible.

Revele en portant serveur_ops dans les modeles : presence-web range son socle
en zone 1 et n'a pas de categorie 4. Le defaut n'est pas apparu en ecrivant le
role ni en le deployant chez patient 0 -- il a fallu le porter ailleurs.

serveur_ops ne nomme plus patient 0 dans ses defauts : un ecosysteme distrait
aurait clone le genome d'un autre, en silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 11:42:32 -04:00
107 changed files with 5074 additions and 429 deletions

File diff suppressed because it is too large Load diff

127
Makefile
View file

@ -368,18 +368,7 @@ deployer: _instance-requise ## Deploie un hote, couche par couche, dans l'ordre
printf '%s\n' 'Refus: aucun playbook applicable pour HOTE=$(HOTE).'; \
exit 2; \
fi; \
vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \
if [[ -t 0 ]]; then \
read -r -s -p 'Mot de passe du vault Ansible: ' mdp; echo; \
vf="$$(mktemp)"; printf '%s' "$$mdp" > "$$vf"; chmod 600 "$$vf"; \
export ANSIBLE_VAULT_PASSWORD_FILE="$$vf"; \
trap 'rm -f "$$vf"' EXIT; \
else \
printf '%s\n' 'Refus: vault chiffre detecte mais aucun mot de passe (entree non interactive). Fournir ANSIBLE_VAULT_PASSWORD_FILE ou le champ vault de la GUI.'; \
exit 2; \
fi; \
fi; \
$(VAULT_UNE_FOIS) \
$(MAKE) _verifier-acces-hote LIMITE="$(HOTE)"; \
$(MAKE) _verifier-privileges-hote LIMITE="$(HOTE)"; \
for playbook in $$playbooks; do \
@ -651,6 +640,35 @@ deployer-tout: _instance-requise ## Deploie TOUTE la flotte dans l'ordre des cou
# Un echec n'est pas avale : le code de retour de chaque hote est relu, et la cible sort
# en erreur si l'un d'eux a echoue — sinon `_attendre-flotte` partirait sur une flotte
# incomplete.
# --- LE MOT DE PASSE DE LA VOUTE, DEMANDE UNE SEULE FOIS ----------------------
#
# Deux cibles longues en ont besoin : `deployer-tout` (une invocation ansible) et
# `flotte-creer` (une invocation PAR MACHINE). La seconde redemandait le mot de passe a
# chaque VM — quinze fois sur la reconstruction de Chezlepro, quatre en parallele, avec
# la sortie redirigee vers des journaux : l'exploitant est harcele par des invites qu'il
# ne voit meme pas.
#
# UN SEUL BLOC POUR LES DEUX. Deux facons de manipuler un secret finiraient par diverger,
# et c'est le genre de divergence qu'on ne remarque qu'apres.
#
# `-t 0` : ne demander que si un humain est au clavier. Un appel non interactif refuse
# clairement plutot que de pendre sur une invite que personne ne lit.
define VAULT_UNE_FOIS
vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \
if [[ -t 0 ]]; then \
read -r -s -p 'Mot de passe du vault Ansible (demande une seule fois): ' mdp; echo; \
vf="$$(mktemp)"; printf '%s' "$$mdp" > "$$vf"; chmod 600 "$$vf"; \
export ANSIBLE_VAULT_PASSWORD_FILE="$$vf"; \
trap 'rm -f "$$vf"' EXIT; \
else \
printf '%s\n' 'Refus: vault chiffre detecte mais aucun mot de passe (entree non interactive). Fournir ANSIBLE_VAULT_PASSWORD_FILE ou le champ vault de la GUI.'; \
exit 2; \
fi; \
fi;
endef
flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le plan — PARALLELE=n
@set -e; \
if [[ "$(CONFIRMER)" != "true" ]]; then \
@ -662,6 +680,7 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
if [[ -z "$$hotes" ]]; then printf '%s\n' 'Refus: aucun hote actif dans le plan.'; exit 2; fi; \
par="$(PARALLELE)"; par="$${par:-4}"; \
tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \
$(VAULT_UNE_FOIS) \
printf 'Creation de %s VM, %s a la fois.\n' "$$(echo $$hotes | wc -w)" "$$par"; \
n=0; \
for h in $$hotes; do \
@ -681,6 +700,90 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
if (( echec )); then printf '\nAu moins une VM n a pas ete creee — rien ne continue.\n'; exit 2; fi; \
printf '\nToutes les VM actives sont creees.\n'
# --- LES MACHINES DU SITE -----------------------------------------------------
#
# UN SITE N'EST PAS UN PLAN. Un tenant se derive de son `index` ; un site n'en a pas et
# ne derive de rien — il EST le terrain sur lequel les tenants derivent. Ses machines se
# declarent dans `underlay.yml`, a cote des switches et des hyperviseurs qui les portent.
#
# D'ou des cibles distinctes de `flotte-*` : elles ne demandent AUCUNE instance montee.
# Le site existe avant tout tenant, et doit pouvoir naitre quand aucun n'est encore la.
#
# Le clone lui-meme reste `cloner-vm` — le seul chemin eprouve, et il n'y en aura pas
# deux. `site_machines.py` ne fait que traduire la declaration en ses parametres.
.PHONY: site-decrire
site-decrire: ## Les machines de l'hebergeur declarees dans l'underlay
@python3 scripts/site_machines.py decrire
.PHONY: site-inventaire
site-inventaire: ## L'inventaire dynamique du site, tel qu'Ansible le voit
@ansible-inventory -i scripts/site_inventaire.py --graph
.PHONY: site-appliquer
site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops_site|...>
@# `GROUPE` a un defaut global (GROUPE_DEBIAN) : tester s'il est VIDE ne protege de
@# rien, ce garde-fou-la ne pourrait jamais se declencher. Le vrai risque, observe :
@# lancer un playbook de TENANT contre l'inventaire du SITE. Ansible n'y trouve aucun
@# hote, n'a rien a faire, et sort avec 0 — un succes qui n'a rien fait. On refuse donc
@# tout groupe qui n'existe pas DANS CET INVENTAIRE-CI.
@#
@# LA VOUTE DU SITE est derivee du symlink `underlay.yml`, jamais redeclaree. Les roles
@# partages exigent leurs secrets (Forgejo : secret_key, internal_token, admin_password).
@# Un tenant les tient de `group_vars/all/vault.yml` ; le site les tient de
@# `underlay.vault.yml`, qui vit a cote de sa carte et hors depot. Sans le `-e @`, le
@# role echoue sur son assertion — et l'echec ne dit pas qu'il manque un FICHIER,
@# seulement que les valeurs sont vides.
@set -e; \
if [[ ! -f "playbooks/groupes/$(GROUPE).yml" ]]; then \
printf 'Refus: aucun playbook pour le groupe %s.\n' "$(GROUPE)"; exit 2; \
fi; \
connus="$$(python3 scripts/site_inventaire.py --list | python3 -c \
'import json,sys; print(" ".join(k for k in json.load(sys.stdin) if k != "_meta"))')"; \
if [[ " $$connus " != *" $(GROUPE) "* ]]; then \
printf 'Refus: le groupe %s n existe pas au site.\n' "$(GROUPE)"; \
printf 'Groupes du site : %s\n' "$$connus"; \
exit 2; \
fi; \
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
ansible-playbook -i scripts/site_inventaire.py "playbooks/groupes/$(GROUPE).yml" \
"$${supp[@]}" $(ARGS)
.PHONY: site-creer
site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true
@set -e; \
if [[ "$(CONFIRMER)" != "true" ]]; then \
printf '%s\n' 'Refus: creation des VM de l hebergeur (clone Proxmox).'; \
printf '%s\n' 'Relancer avec CONFIRMER=true.'; \
exit 2; \
fi; \
machines="$$(python3 scripts/site_machines.py lister)"; \
if [[ -z "$$machines" ]]; then printf '%s\n' 'Refus: aucune machine declaree dans l underlay.'; exit 2; fi; \
for m in $$machines; do \
printf '\n=== Machine du site: %s ===\n' "$$m"; \
t0=$$(date +%s); \
params="$$(python3 scripts/site_machines.py parametres-proxmox --machine "$$m")"; \
eval "$$params"; \
$(MAKE) cloner-vm \
HOTE="$$m" \
VMID="$$SETOPS_VMID" \
ADRESSE_IP="$$SETOPS_IP" \
CIDR="$$SETOPS_CIDR" \
PASSERELLE="$$SETOPS_PASSERELLE" \
VLAN="$${SETOPS_VLAN:-}" \
PONT_PROXMOX="$$SETOPS_PONT" \
STOCKAGE_PROXMOX="$${SETOPS_STOCKAGE:-$(STOCKAGE_PROXMOX)}" \
TAILLE_DISQUE="$$SETOPS_DISQUE" \
COEURS="$$SETOPS_COEURS" \
MEMOIRE="$$SETOPS_MEMOIRE" \
NOEUD_PROXMOX="$$SETOPS_NOEUD" \
VMID_MODELE="$$SETOPS_VMID_MODELE" \
CLONE_COMPLET="$$SETOPS_CLONE_COMPLET" \
FORMAT_DISQUE="$(FORMAT_DISQUE)"; \
printf '=== %s : %s s ===\n' "$$m" "$$(( $$(date +%s) - t0 ))"; \
done; \
printf '\nLes machines du site sont creees.\n'
.PHONY: _attendre-flotte
_attendre-flotte: ansible-runtime
@set -e; \

View file

@ -68,7 +68,7 @@ le côté humain **est** la preuve de l'affirmation « exploitable sans IA » (c
| 1 | Le plancher, sans DNS | Sur un nœud : « commande » | 👁 observe | — |
| 2 | Interroge l'autoritatif | Demande à PowerDNS directement : « commande » | 👁 observe | — |
| 3 | Vois les couches | Compare getent hosts (plancher) et dig (DNS) : deux chemins, même IP. | 👁 observe | — |
| 4 | Casse & répare | Sur un nœud sans client_unbound, vide /etc/hosts de ses entrées chezlepro (garde une sauvegarde !) et coupe l'accès au DNS : la résolution interne échoue. Restaure /etc/hosts : ça remarche sans DNS. Tu viens de *sentir* pourquoi le planc… | 🔨 casse-répare | — |
| 4 | Casse & répare | Sur un nœud sans client_resolveur, vide /etc/hosts de ses entrées chezlepro (garde une sauvegarde !) et coupe l'accès au DNS : la résolution interne échoue. Restaure /etc/hosts : ça remarche sans DNS. Tu viens de *sentir* pourquoi le pla… | 🔨 casse-répare | — |
*Source : [DNS & résolution de noms](DNS-et-résolution) · § À toi de jouer.*

View file

@ -0,0 +1,78 @@
# Preuve de conformite — Set-OPS — 2026-08-24
> 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 (42 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 36 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 34 rôles, 87 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 | playbook: playbooks/proxmox/cloner_vm_debian.yml |
| 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, 35 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 : 25 cle(s), aucun manque. |
| 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 : 7 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 68 regles, 15 routes, admin=10.0.0.0/24,10.17.0.0/24,10.29.19.41/32,192.168.254.2/32,192.168.255.2/32. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 51 groupe(s), 80 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 | 28 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 17, 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 | 49 scripts expliques et atteignables, 102 cibles make documentees, 60 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (125 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 (37 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (22 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 : 35 role(s) serveur/client tous nommes, 36 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 : 45 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 |
## 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-24._

View file

@ -0,0 +1,81 @@
# Preuve de conformite — Set-OPS — 2026-08-25
> 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** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/production/hosts.yml`
- **Verdict** : ✅ CONFORME (44 OK · 0 echec · 1 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 36 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 34 rôles, 92 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_oauth2_proxy |
| 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 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
| 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 : 18 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 14 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), 50 groupe(s), 79 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 5 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 | 28 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 17, 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 | 49 scripts expliques et atteignables, 102 cibles make documentees, 60 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 14 exigence(s) de role, toutes satisfaites (39 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 (16 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (23 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 0 application(s) exigeant une base l'ont toutes (0 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 2 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 : 35 role(s) serveur/client tous nommes, 36 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 : 45 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, 56 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). |
## 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-25._

View file

@ -75,7 +75,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis
- ✅ **soldé** — README de rôles : **tous les rôles en ont un** (les 12 manquants écrits le
2026-07-29 : `serveur_debian`, `hosts_statiques`, `resoudre_base`, `resoudre_annuaire`,
`serveur_dovecot`, `serveur_postfix`, `serveur_rspamd`, `client_backup`, `serveur_backup`,
`client_unbound`, `serveur_oauth2_proxy`, `serveur_icingaweb2`).
`client_resolveur`, `serveur_oauth2_proxy`, `serveur_icingaweb2`).
- ✅ **soldé** — `expose` **est** consommé au déploiement : `plan/applications.yml` →
filtre `expositions_des_applications` → vhosts nginx dérivés
(`roles/serveur_nginx/tasks/main.yml`, template `expositions.conf.j2`, drapeau

View file

@ -37,7 +37,7 @@ Ce que la reconstruction couvre, par capacité :
|---|---|---|
| Socle et durcissement | `serveur_debian`, `serveur_durci` | clone du gabarit doré → machine conforme |
| Confiance | `serveur_step_ca`, `client_pki` | mTLS avec SAN dérivés du plan, renouvellement |
| Noms | `serveur_powerdns`, `client_unbound` | autoritaire interne + résolveur local |
| Noms | `serveur_powerdns`, `client_resolveur` | autoritaire interne + résolveur local |
| Identité | `serveur_openldap`, `serveur_keycloak` | LDAPS, SSO OIDC, **fédération LDAP automatisée** (`tasks/federation-ldap.yml`), exposé par le plan (`auth.<domaine>`) |
| Passerelle SSO | `serveur_oauth2_proxy` | SSO devant une app sans OIDC natif (éprouvé sur Icinga Web 2) |
| Données | `serveur_postgresql`, `serveur_redis` | bases et comptes dérivés du registre, TLS `verify-full` |
@ -46,15 +46,18 @@ Ce que la reconstruction couvre, par capacité :
| Observabilité | `serveur_prometheus`, `serveur_loki`, `serveur_grafana` | métriques, journaux, tableaux sous SSO |
| Supervision | `serveur_icinga`, `serveur_icingaweb2` | Icinga 2 + IcingaDB + Web 2 + BPM, au SSO |
| Forge | `serveur_forgejo` | Git + PostgreSQL + SSO OIDC |
| Collaboration | `serveur_nextcloud`, `serveur_collabora` | déployés par la reconstruction, base et client OIDC dérivés du plan — **usage** (dépôt de fichier, édition partagée) non consigné comme preuve |
| Collaboration | `serveur_nextcloud`, `serveur_collabora` (**natif**, plus de conteneur) | déployés par la reconstruction, base et client OIDC dérivés du plan — **usage** (dépôt de fichier, édition partagée) non consigné comme preuve |
| Plateforme webapp | `serveur_web_frontal`, `serveur_web_dorsal` | sites statiques et webapps natives (venv + systemd + nginx), **zéro conteneur** |
| Sauvegardes | `serveur_backup`, `client_backup` | restic hors-nœud, **restauration éprouvée** (2026-08-12 : la donnée revient) |
| Exploitation | `serveur_ops` | le poste depuis lequel l'ecosysteme se reconstruit : Ansible epingle, genome clone depuis **sa propre forge**, cle SSH propre — **sans** la voute ni son mot de passe |
| Source d'artefacts | `serveur_artefacts`, `client_artefacts` | cache apt de l'ecosysteme (apt-cacher-ng) : les paquets viennent de chez soi, pas de six serveurs etrangers — **mode hors ligne** pour prouver ce que le cache detient vraiment |
| Runner de site | `serveur_ops_site` | le pouvoir de **materialiser** : creer et detruire des VM sur la fabric. Detient la voute du SITE, chiffree, et n'entre JAMAIS chez un tenant — reserve a l'ecosysteme de l'hebergeur |
| 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 |
| 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`
(→ Postfix), `client_dns` (→ plancher `/etc/hosts` + `client_unbound`), `client_ldap`
(→ Postfix), `client_dns` (→ plancher `/etc/hosts` + `client_resolveur`), `client_ldap`
(login LDAP au niveau OS, hors design).
> **Ce que « éprouvé » ne dit pas.** La reconstruction prouve que le moteur mène une
@ -153,7 +156,7 @@ boîtes (Dovecot). La coupure suit l'exposition, pas le logiciel.
| Confiance PKI / ACME | `client_pki` | **tout hôte** (universelle) |
| Métriques Prometheus | `client_metrique` | **tout hôte** (universelle) |
| Journaux vers Loki | `client_journal` | **tout hôte** (universelle) |
| Résolution locale (Unbound) | `client_unbound` | **tout hôte** (universelle) |
| Résolution locale (Unbound) | `client_resolveur` | **tout hôte** (universelle) |
| Relais SMTP | `client_smtp` | déclaré par hôte, dans le plan |
| Sauvegarde restic | `client_backup` | déclaré par hôte — **obligatoire pour tout détenteur d'état** (P36) |
@ -270,7 +273,7 @@ L'ordre ci-dessous privilégie les dépendances structurantes avant les applicat
- Tout service exposé en HTTP(S) doit prévoir son intégration avec `serveur_nginx`.
- Tout service avec authentification humaine doit prévoir son intégration avec `serveur_keycloak`, sauf justification contraire.
- Tout service générant des alertes ou notifications doit prévoir `client_smtp`.
- Toute VM de service rejoint `client_pki`, `client_metrique`, `client_journal` et `client_unbound` — **par dérivation, sans rien écrire** (intégrations universelles, P26). La supervision, elle, ne pose rien sur l'hôte.
- Toute VM de service rejoint `client_pki`, `client_metrique`, `client_journal` et `client_resolveur` — **par dérivation, sans rien écrire** (intégrations universelles, P26). La supervision, elle, ne pose rien sur l'hôte.
- Tout hôte qui **détient de l'état** doit porter `client_backup` (P36 le refuse sinon).
- Tout service utilisant un certificat interne doit dépendre de `client_pki`.
- Tout rôle serveur doit documenter ses ports, secrets, sauvegardes, dépendances et groupes clients associés.

View file

@ -39,6 +39,9 @@ couches:
- serveur_postgresql
- serveur_openldap
- serveur_powerdns
# Le resolveur du tenant vient APRES son autoritatif : il le prend en stub-zone,
# et sa validation exige que la zone souveraine reponde deja.
- serveur_resolveur
- serveur_redis
- serveur_prometheus
- serveur_loki
@ -50,6 +53,12 @@ couches:
# La source d'artefacts vient AVANT ceux qui installent des paquets — c'est tout
# son objet. Placee plus tard, elle serait remplie apres avoir servi.
- serveur_artefacts
# LA RACINE DE LA CHAINE DE CACHES, et elle appartient au SITE, pas au tenant :
# VM du tenant -> cache du tenant -> cache du SITE -> Debian
# Classee ici pour que son ordre soit dit, mais elle ne se deploie pas dans le meme
# mouvement : elle vit dans l'underlay de l'hebergeur et se joint par
# `scripts/site_inventaire.py`. Un tenant ne la deploie jamais — il la CONSOMME.
- serveur_cache_site
- nom: apps
raison: "Les applications métier, qui consomment les services (base, SSO, courriel, edge)."
@ -67,6 +76,9 @@ couches:
# Le poste d'exploitation vient APRES la forge : il clone le genome depuis
# elle. Le placer plus tot le laisserait sans source.
- serveur_ops
# Le runner de SITE vient APRES le runner de tenant : il suppose les depots
# clones. Il n'ajoute qu'un pouvoir -- celui de materialiser sur la fabric.
- serveur_ops_site
- nom: agents
raison: "Les intégrations clientes qui expédient vers les services centraux (métriques, journaux, courriel, sauvegardes, résolution locale). Déployées en dernier, quand leurs cibles sont debout."
@ -75,5 +87,5 @@ couches:
- client_journal
- client_smtp
- client_backup
- client_unbound
- client_resolveur
- client_artefacts

View file

@ -118,6 +118,23 @@ groupes:
raison: "Un cache apt ne depend d'aucun service de l'ecosysteme : il ne fait que relayer et retenir."
surveillance: "Verifier l'ecoute sur 3142, le taux de service depuis le journal, et l'espace du cache."
serveur_ops_site:
requiert_groupes_actifs:
- serveur_ops
# LE RUNNER DE SITE EST ADDITIF : il suppose le poste d'exploitation en place, dont il
# reutilise la racine, l'utilisateur et les depots clones. Seul, il n'aurait ni carte
# de la fabric ni moteur pour agir.
raison: "Le runner de site n'ajoute qu'un pouvoir a un poste d'exploitation existant : sans lui, rien a quoi l'attacher."
surveillance: "Verifier que la voute du site est presente ET CHIFFREE, et que la carte de la fabric est lisible."
serveur_resolveur:
requiert_groupes_actifs:
- serveur_powerdns
# Le resolveur prend la zone souveraine en STUB-ZONE : sans autoritatif, il ne saurait
# resoudre aucun nom de l'ecosysteme, et sa propre validation echouerait.
raison: "Le resolveur du tenant delegue la zone souveraine a l'autoritatif ; sans lui, il ne sait rien de l'ecosysteme."
surveillance: "Verifier qu'il repond pour la zone interne ET pour un nom de l'Internet."
serveur_nextcloud:
requiert_groupes_actifs:
- serveur_postgresql

View file

@ -8,17 +8,17 @@ Le service DNS interne est la premiere capacite de plateforme.
> `serveur_debian`) genere `/etc/hosts` sur **chaque** VM depuis l'inventaire : tout
> l'ecosysteme se resout par nom **meme serveur DNS eteint** (et au bootstrap, avant
> que PowerDNS ne soit la). PowerDNS devient une **commodite** (zone, externe,
> dynamique), plus un point de defaillance. Le resolveur local `client_unbound` est
> dynamique), plus un point de defaillance. Le resolveur local `client_resolveur` est
> **optionnel** (opt-in, avec bascule validee) : sans lui, le plancher `/etc/hosts` suffit.
## Groupes
```text
serveur_powerdns -> service DNS central PowerDNS Authoritative
client_unbound -> resolveur local optionnel (opt-in, bascule validee)
client_resolveur -> resolveur local optionnel (opt-in, bascule validee)
```
`client_unbound` (résolveur local optionnel) peut viser PowerDNS en stub-zone + récursion.
`client_resolveur` (résolveur local optionnel) peut viser PowerDNS en stub-zone + récursion.
## Zone initiale
@ -69,15 +69,15 @@ Quand `serveur_postgresql` sera stable, il sera possible de migrer vers un backe
## Résolveur local (optionnel)
Le role `client_unbound` (opt-in) installe un résolveur récursif local qui, en stub-zone,
Le role `client_resolveur` (opt-in) installe un résolveur récursif local qui, en stub-zone,
délègue les noms internes à PowerDNS et récurse le reste.
La bascule du resolver local est **protegee** (le role valide qu'Unbound répond AVANT de
basculer `/etc/resolv.conf`) :
```yaml
client_unbound_apply: true
client_unbound_confirm: true
client_resolveur_apply: true
client_resolveur_confirm: true
```
Sans ces deux variables, le role prépare Unbound mais ne modifie pas `/etc/resolv.conf`.

View file

@ -0,0 +1,101 @@
# Filiation, mutualisation, émancipation
> **Pour qui :** l'**architecte** de la lignée et l'**hébergeur** qui abrite des
> écosystèmes tiers. Décrit les trois âges d'un écosystème et ce qu'ils impliquent au plan.
## Le constat
Un écosystème ne naît pas autoportant. Il lui faut une forge pour lire son génome, une
source d'artefacts pour ses paquets, un dépôt pour ses sauvegardes — et rien de tout cela
n'existe la première seconde. Il emprunte donc à son hôte.
Jusqu'ici, le moteur a rencontré ce besoin **trois fois sans le nommer** :
```
client_artefacts_actif dérivé : « une source existe-t-elle chez moi ? »
serveur_ops_forge_externe « je lis mon génome ailleurs »
client_backup_cible patient 0 sauvegarde chez eregion — mutualisé, en production
```
Trois astuces, une seule notion. Ce document la déclare.
## Les trois âges
```
FILIATION l'enfant emprunte à son parent ce qu'il ne porte pas encore
MUTUALISATION un état DURABLE et CHOISI — on emprunte parce qu'on le veut
ÉMANCIPATION l'écosystème porte tout lui-même
```
**L'émancipation n'est pas une obligation.** Un petit organisme peut rester mutualisé
toute sa vie ; ce qui compte est qu'il *puisse* s'émanciper, et que la sortie ne soit ni
un piège ni une reconstruction. C'est la différence entre un locataire et un captif.
## Ce que ça veut dire pour les modèles
Un modèle sans forge n'est pas un modèle amputé : c'est un écosystème **au premier âge**,
dont le runner lit le génome chez son hôte. L'ajout d'une forge n'est pas une correction,
c'est une **émancipation**.
```
premier âge serveur_ops_forge_externe: true + l'adresse de la forge de l'hôte
émancipé serveur_ops_forge_externe: false + sa propre serveur_forgejo au plan
```
## Ce que ça veut dire pour l'hébergeur
L'hébergeur ne vend plus seulement des machines : il vend **l'abri pendant la jeunesse**.
Chaque service mutualisé est une ligne de facture, et chaque émancipation est une décision
du client — jamais une rupture technique.
Pour une clientèle d'OBNL et de coopératives, c'est le bon geste : on entre à bas coût, on
grandit vers l'autonomie, et **on n'est jamais captif**, puisque le génome est déjà chez
soi dès le premier jour.
## Ce qui est mutualisable, et ce qui ne l'est pas
| service | mutualisable | remarque |
|---|---|---|
| forge (génome) | oui | `serveur_ops_forge_externe` |
| source d'artefacts | oui | `client_artefacts` se désactive seul si aucune n'existe |
| sauvegarde | oui | `client_backup_cible` pointe déjà hors de l'écosystème |
| observabilité | oui | l'hébergeur surveille ses locataires |
| relais courriel | oui | |
| DNS | partiellement | un sous-domaine délégué, pas la zone entière |
| **PKI** | à trancher | une AC intermédiaire signée par l'hôte est possible, mais l'AC **définit l'identité** de l'écosystème |
| **identité** (LDAP/SSO) | le plus intime | mutualiser l'annuaire, c'est confier ses gens |
| **la voûte** | **jamais** | les secrets ne se mutualisent pas, à aucun âge |
## Ce que l'émancipation doit prouver
Basculer une déclaration ne suffit pas. Une émancipation est un **acte outillé** en trois
temps :
1. **déclarer** — lever le drapeau au plan, déployer le service chez soi ;
2. **migrer l'état** — le génome, les sauvegardes, les certificats existants ;
3. **prouver que le lien est coupé** — et c'est le temps qu'on oublie.
Sans cette troisième preuve, on croirait s'être émancipé en restant dépendant sans le
savoir. C'est exactement le défaut que ce dépôt traque partout ailleurs : un vert sur un
périmètre vide. Une émancipation non prouvée est une émancipation non faite.
## Forme attendue d'une déclaration
Tout service mutualisable doit se déclarer de la même façon, pour qu'un exploitant lise
l'état de son écosystème d'un coup d'œil plutôt qu'en fouillant chaque rôle :
```yaml
<service>_externe: true # je l'emprunte
<service>_amont: "https://…" # à qui
```
`false` — ou l'absence du couple — signifie *chez moi*. Le rôle **refuse** de démarrer si
le drapeau est levé sans que l'amont soit nommé : emprunter sans dire à qui, c'est ne rien
déclarer du tout.
## État au 2026-08-24
Seule la forge porte cette déclaration complète (`serveur_ops`). Les autres services
mutualisés le sont par des mécanismes antérieurs, cohérents mais non uniformes. Les
aligner sur la forme ci-dessus reste à faire — et l'instrument de preuve de l'étape 3
n'existe pas encore.

View file

@ -420,7 +420,7 @@ Reste à trancher sur une interface **physique** : `ip ?`, `access-group ?`, `sp
et `<PORT-TRUNK>` ; rien dans le modèle ne peut les deviner.
- ~~**La sortie générale** n'est pas déclarée~~ — **réglé le 2026-08-02.** Elle est
déclarée dans le registre, donc dérivée comme le reste : `serveur_debian` (le socle, porté
par tous les hôtes) déclare 443, 80 et 123/udp — dépôts apt et horloge ; `client_unbound`
par tous les hôtes) déclare 443, 80 et 123/udp — dépôts apt et horloge ; `client_resolveur`
déclare 53 en UDP et TCP, la récursion depuis la racine que le choix souverain implique.
Le `block out` de la section 5 est donc un vrai default-deny **assumé**, et le devis
l'énonce désormais au lieu de le poser en silence.

View file

@ -18,7 +18,7 @@ integration:
sauf_role: serveur_step_ca # facultatif — voir « exemptions »
```
**Facultative** — `client_backup`, `client_smtp`, `client_unbound`. Là il y a un vrai choix,
**Facultative** — `client_backup`, `client_smtp`, `client_resolveur`. Là il y a un vrai choix,
et il se déclare par serveur, dans `plan/serveurs.yml : integrations`.
**Pourquoi cette inversion.** Le plan portait 57 lignes d'intégration écrites à la main. 28

View file

@ -40,7 +40,7 @@ nftables baseline · fail2ban SSH · auditd · AppArmor · sysctl · unattended-
journald (rétention) · core_dumps · systemd_ssh_auto.
### F. Endpoints des services centraux (les rôles `client_*` en dérivent)
- DNS interne : plancher `/etc/hosts` (`hosts_statiques`) + PowerDNS + `client_unbound` (opt-in).
- DNS interne : plancher `/etc/hosts` (`hosts_statiques`) + PowerDNS + `client_resolveur` (opt-in).
- AC/PKI (`client_pki_ca_url` → infra-pki, provisioner).
- IdM/LDAP : annuaire résolu par `resoudre_annuaire` (hôte + base DN dérivés du domaine).
- Relais courriel (`client_smtp_relais` → edge-mta, MTA Postfix, port 25).
@ -90,7 +90,7 @@ domaines publics, `edge`, autorité DNS, FQDN exposés.
| Secrets Vault | **Constante** 🔒 | — (gérés à part, jamais en clair) |
| `fuseau_horaire` | Défaut | par hôte (rare) |
| `proxmox_clone_noeud` / `stockage` / `pont` | Défaut | par hôte (`serveurs.yml`) |
| DNS internes (plancher `/etc/hosts` + PowerDNS + `client_unbound`) | Défaut | par hôte / groupe |
| DNS internes (plancher `/etc/hosts` + PowerDNS + `client_resolveur`) | Défaut | par hôte / groupe |
| Politiques durcissement (SSH, nftables, fail2ban, journald…) | Défaut | par hôte / groupe |
| Relais SMTP, TLS internes | Défaut | par hôte / groupe |
| `ciuser`, compte `ansible` | Défaut | rarement surchargé |

View file

@ -94,7 +94,7 @@ prouvés.
- **Forge logicielle** : `serveur_forgejo`.
- **Observabilité** : `serveur_prometheus`, `serveur_grafana`, `serveur_loki`, `serveur_icinga`,
avec les clients `client_metrique`, `client_journal`, `client_supervision`.
- **Intégrations transverses** : `client_pki`, `client_backup`, `client_smtp`, `client_metrique`, `client_journal`, `client_unbound`.
- **Intégrations transverses** : `client_pki`, `client_backup`, `client_smtp`, `client_metrique`, `client_journal`, `client_resolveur`.
- **Méta-rôles d'agrégation** : `identity`, `applications`, `database`, `web`, `monitoring`,
`backup`, `storage`.

View file

@ -12,10 +12,10 @@
| `client_metrique` | ingress | 9100 | tcp | serveur_prometheus | tls | Scrape des métriques par Prometheus (node_exporter en HTTPS). |
| `client_pki` | egress | 8443 | tcp | serveur_step_ca | tls-requis | Émission/renouvellement des certificats par ACME et récupération de la racine auprès de l'AC interne. |
| `client_smtp` | egress | 25 | tcp | serveur_postfix | starttls | Relais des notifications locales vers le MTA central (Postfix), STARTTLS. |
| `client_unbound` | ingress | 53 | udp | localhost | clair | Résolveur local sur boucle locale (les processus du nœud interrogent 127.0.0.1). |
| `client_unbound` | egress | 53 | tcp | serveur_powerdns | clair | Transfert des requêtes de la zone souveraine vers le DNS autoritatif interne (PowerDNS). |
| `client_unbound` | egress | 53 | udp | externe | clair | Récursion DNS depuis la racine (UDP d'abord), validée par DNSSEC — la confidentialité du transport n'est pas l'enjeu, l'authenticité l'est. |
| `client_unbound` | egress | 53 | tcp | externe | clair | Récursion DNS en TCP : repli obligatoire quand la réponse dépasse la taille UDP (fréquent avec DNSSEC). |
| `client_resolveur` | ingress | 53 | udp | localhost | clair | Résolveur local sur boucle locale (les processus du nœud interrogent 127.0.0.1). |
| `client_resolveur` | egress | 53 | tcp | serveur_powerdns | clair | Transfert des requêtes de la zone souveraine vers le DNS autoritatif interne (PowerDNS). |
| `client_resolveur` | egress | 53 | udp | externe | clair | Récursion DNS depuis la racine (UDP d'abord), validée par DNSSEC — la confidentialité du transport n'est pas l'enjeu, l'authenticité l'est. |
| `client_resolveur` | egress | 53 | tcp | externe | clair | Récursion DNS en TCP : repli obligatoire quand la réponse dépasse la taille UDP (fréquent avec DNSSEC). |
| `serveur_backup` | ingress | 22 | tcp | client_backup | ssh | Dépôt restic servi par SSH (utilisateur restreint restic + clé) ; chaque client_backup pousse ses instantanés. |
| `serveur_backup` | egress | 5665 | tcp | serveur_icinga | tls-requis | Rapport passif des sauvegardes vers l'API Icinga : le depot est le seul a voir ce qui est reellement arrive. |
| `serveur_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). |

View file

@ -1,19 +1,38 @@
---
- name: Appliquer le groupe client_artefacts
hosts: client_artefacts
# client_artefacts — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
#
# Cette integration depend de `serveur_artefacts`, et sa metadonnee le declare
# (`roles/client_artefacts/meta/integration.yml`). L'hote qui PORTE ce serveur est donc
# configure AVANT ceux qui s'y adressent — sinon les clients s'enrolent aupres d'un
# service qui n'est pas encore la, ou qui redemarre au meme instant.
#
# Ce fichier est le miroir de la declaration ; la preuve P44 refuse tout ecart.
#
# `any_errors_fatal` sur le premier play : si le serveur n'a pas pu etre configure,
# y raccrocher des clients ne produit que des echecs qui accusent le reseau.
- name: Intégration client_artefacts — le serveur d'abord
hosts: client_artefacts:&serveur_artefacts
become: true
module_defaults:
any_errors_fatal: true
module_defaults: &defauts_artefacts
ansible.builtin.apt:
lock_timeout: 300
gather_facts: true
pre_tasks:
pre_tasks: &pre_artefacts
- 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:
- client_artefacts
- name: Intégration client_artefacts — puis les hôtes qui s'y adressent
hosts: client_artefacts:!serveur_artefacts
become: true
module_defaults: *defauts_artefacts
gather_facts: true
pre_tasks: *pre_artefacts
roles:
- client_artefacts

View file

@ -1,22 +1,38 @@
---
- name: Appliquer le groupe client_journal
hosts: client_journal
# client_journal — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
#
# Cette integration depend de `serveur_loki`, et sa metadonnee le declare
# (`roles/client_journal/meta/integration.yml`). L'hote qui PORTE ce serveur est donc
# configure AVANT ceux qui s'y adressent — sinon les clients s'enrolent aupres d'un
# service qui n'est pas encore la, ou qui redemarre au meme instant.
#
# Ce fichier est le miroir de la declaration ; la preuve P44 refuse tout ecart.
#
# `any_errors_fatal` sur le premier play : si le serveur n'a pas pu etre configure,
# y raccrocher des clients ne produit que des echecs qui accusent le reseau.
- name: Intégration client_journal — le serveur d'abord
hosts: client_journal:&serveur_loki
become: true
# Le verrou dpkg est tenu par les maj automatiques de Debian, par vagues, plusieurs
# minutes apres le premier demarrage. Pose ICI plutot que dans chaque role : toute
# tache apt du play en herite, y compris celles des roles inclus.
module_defaults:
any_errors_fatal: true
module_defaults: &defauts_journal
ansible.builtin.apt:
lock_timeout: 300
gather_facts: true
pre_tasks:
pre_tasks: &pre_journal
- 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:
- client_journal
- name: Intégration client_journal — puis les hôtes qui s'y adressent
hosts: client_journal:!serveur_loki
become: true
module_defaults: *defauts_journal
gather_facts: true
pre_tasks: *pre_journal
roles:
- client_journal

View file

@ -1,22 +1,38 @@
---
- name: Appliquer le groupe client_pki
hosts: client_pki
# client_pki — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
#
# Cette integration depend de `serveur_step_ca`, et sa metadonnee le declare
# (`roles/client_pki/meta/integration.yml`). L'hote qui PORTE ce serveur est donc
# configure AVANT ceux qui s'y adressent — sinon les clients s'enrolent aupres d'un
# service qui n'est pas encore la, ou qui redemarre au meme instant.
#
# Ce fichier est le miroir de la declaration ; la preuve P44 refuse tout ecart.
#
# `any_errors_fatal` sur le premier play : si le serveur n'a pas pu etre configure,
# y raccrocher des clients ne produit que des echecs qui accusent le reseau.
- name: Intégration client_pki — le serveur d'abord
hosts: client_pki:&serveur_step_ca
become: true
# Le verrou dpkg est tenu par les maj automatiques de Debian, par vagues, plusieurs
# minutes apres le premier demarrage. Pose ICI plutot que dans chaque role : toute
# tache apt du play en herite, y compris celles des roles inclus.
module_defaults:
any_errors_fatal: true
module_defaults: &defauts_pki
ansible.builtin.apt:
lock_timeout: 300
gather_facts: true
pre_tasks:
pre_tasks: &pre_pki
- 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:
- client_pki
- name: Intégration client_pki — puis les hôtes qui s'y adressent
hosts: client_pki:!serveur_step_ca
become: true
module_defaults: *defauts_pki
gather_facts: true
pre_tasks: *pre_pki
roles:
- client_pki

View file

@ -0,0 +1,38 @@
---
# client_resolveur — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
#
# Cette integration depend de `serveur_resolveur`, et sa metadonnee le declare
# (`roles/client_resolveur/meta/integration.yml`). L'hote qui PORTE ce serveur est donc
# configure AVANT ceux qui s'y adressent — sinon les clients s'enrolent aupres d'un
# service qui n'est pas encore la, ou qui redemarre au meme instant.
#
# Ce fichier est le miroir de la declaration ; la preuve P44 refuse tout ecart.
#
# `any_errors_fatal` sur le premier play : si le serveur n'a pas pu etre configure,
# y raccrocher des clients ne produit que des echecs qui accusent le reseau.
- name: Intégration client_resolveur — le serveur d'abord
hosts: client_resolveur:&serveur_resolveur
become: true
any_errors_fatal: true
module_defaults: &defauts_resolveur
ansible.builtin.apt:
lock_timeout: 300
gather_facts: true
pre_tasks: &pre_resolveur
- 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:
- client_resolveur
- name: Intégration client_resolveur — puis les hôtes qui s'y adressent
hosts: client_resolveur:!serveur_resolveur
become: true
module_defaults: *defauts_resolveur
gather_facts: true
pre_tasks: *pre_resolveur
roles:
- client_resolveur

View file

@ -0,0 +1,15 @@
---
- name: Appliquer le groupe serveur_cache_site
hosts: serveur_cache_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_cache_site

View file

@ -24,7 +24,7 @@
# effet faute de `resolvconf` (qu'on ne peut pas installer sans résolution — la boucle
# se referme). Mesuré le 2026-08-06 sur deux VM neuves.
#
# PASSAGE DE RELAIS : `client_unbound` reprend ce fichier plus tard pour le basculer
# PASSAGE DE RELAIS : `client_resolveur` reprend ce fichier plus tard pour le basculer
# vers `127.0.0.1`. La garde ci-dessous le respecte — sans elle, chaque déploiement
# défairait la bascule, et deux rôles se disputeraient le même fichier sans fin.
- name: Lire le résolveur en place
@ -42,18 +42,57 @@
mode: "0644"
content: |
# Géré par Set-OPS — résolveur d'amorçage (intrant `dns_amorcage`).
# Remplacé par `client_unbound` lorsqu'il bascule vers le résolveur local.
# Remplacé par `client_resolveur` lorsqu'il bascule vers le résolveur local.
{% for serveur in dns_amorcage.split(',') if serveur | trim %}
nameserver {{ serveur | trim }}
{% endfor %}
# L'AMORÇAGE NE DOIT PAS DÉFAIRE CE QUI EST DÉJÀ EN PLACE (2026-08-25).
#
# La garde ne protégeait que l'hôte du résolveur lui-même (`127.0.0.1`). Partout
# ailleurs, le socle réécrivait `/etc/resolv.conf` avec `dns_amorcage` — donc
# **défaisait la bascule** de `client_resolveur` à chaque passage.
#
# Ça ne s'était jamais vu chez un tenant : `dns_amorcage` y vaut l'adresse du
# résolveur du tenant, si bien que réécrire remettait la même valeur. La
# coïncidence masquait le défaut.
#
# Sur le site, les deux ont divergé quelques heures — l'amorçage pointait encore sur
# la frontière alors que `client_resolveur` avait basculé les machines sur
# `site-dns-01`. Chaque déploiement du socle les ramenait en arrière, en silence, et
# ça ne s'est vu qu'en retirant la règle de pare-feu vers la frontière : les cinq
# machines ont alors perdu la résolution d'un coup.
#
# On ne pose donc l'amorçage que si rien de sensé n'est déjà là : ni la loopback, ni
# un résolveur déclaré de cet écosystème.
vars:
serveur_debian_resolv_texte: >-
{{ serveur_debian_resolv_actuel.content | default('') | b64decode | string }}
serveur_debian_resolveurs_declares: >-
{{ (groups['serveur_resolveur'] | default([]))
| map('extract', hostvars, 'ansible_host') | select | list }}
when:
- dns_amorcage is defined
- dns_amorcage | string | length > 0
- "'127.0.0.1' not in (serveur_debian_resolv_actuel.content | default('') | b64decode | string)"
- "'127.0.0.1' not in serveur_debian_resolv_texte"
- serveur_debian_resolveurs_declares
| select('in', serveur_debian_resolv_texte) | list | length == 0
roles:
- common_packages
# LE PLANCHER AVANT LE PREMIER `apt` (2026-08-25).
#
# `hosts_statiques` venait APRÈS `common_packages`, qui commence par un `apt update`.
# Or apt vise le cache PAR SON NOM — `site-cache-01.<domaine>` — ce qui est voulu :
# une configuration agnostique de l'adressage.
#
# Le jour où les adresses changent, la boucle se referme : apt échoue faute de
# résoudre, donc le socle n'atteint jamais `hosts_statiques`, donc le plancher reste
# périmé, donc apt échoue. Mesuré le 2026-08-25 après le découpage du site en zones :
# cinq machines bloquées, `/etc/hosts` vide de toute entrée du site.
#
# Le résolveur d'amorçage est déjà une `pre_task` pour exactement cette raison. Le
# plancher est de la même nature : il ne s'installe pas, **il rend installable**.
- hosts_statiques
- common_packages
- qemu_guest_agent
- cloud_init
- sudo_ansible

View file

@ -0,0 +1,15 @@
---
- name: Appliquer le groupe serveur_ops_site
hosts: serveur_ops_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_ops_site

View file

@ -1,10 +1,7 @@
---
- name: Appliquer le groupe client_unbound
hosts: client_unbound
- name: Appliquer le groupe serveur_resolveur
hosts: serveur_resolveur
become: true
# Le verrou dpkg est tenu par les maj automatiques de Debian, par vagues, plusieurs
# minutes apres le premier demarrage. Pose ICI plutot que dans chaque role : toute
# tache apt du play en herite, y compris celles des roles inclus.
module_defaults:
ansible.builtin.apt:
lock_timeout: 300
@ -19,4 +16,4 @@
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- client_unbound
- serveur_resolveur

View file

@ -214,11 +214,36 @@
| selectattr('vmid', 'equalto', proxmox_clone_vmid | int)
| list | length > 0 }}
# OU VIT LE GABARIT — et ce n'est pas forcement ou va la VM (2026-08-25).
#
# L'URL de clonage visait `nodes/{{ proxmox_clone_noeud }}`, c'est-a-dire le noeud de
# DESTINATION. Or l'API veut le noeud qui DETIENT le modele ; la destination se dit
# par `target`. Cloner vers gandalf un gabarit qui vit sur asgard rendait donc :
#
# 500 Configuration file 'nodes/gandalf/qemu-server/99998.conf' does not exist
#
# Ca n'etait jamais apparu parce que toutes les VM naissaient sur le noeud du gabarit
# et migraient ensuite. La premiere machine placee ailleurs l'a revele.
- name: Localiser le noeud qui detient le gabarit
ansible.builtin.set_fact:
proxmox_noeud_gabarit: >-
{{ ((proxmox_inventaire_vm.json.data | default([]))
| selectattr('vmid', 'defined')
| selectattr('vmid', 'equalto', proxmox_clone_vmid_modele | int)
| map(attribute='node') | list | first) | default('', true) }}
- name: "Un noeud de la grappe detient bien le gabarit {{ proxmox_clone_vmid_modele }}"
ansible.builtin.assert:
that: proxmox_noeud_gabarit | length > 0
fail_msg: >-
Aucun noeud de la grappe ne detient le gabarit
{{ proxmox_clone_vmid_modele }}. Sans lui, rien a cloner.
- name: Cloner la VM depuis le modele (API directe, cible par VMID)
vars:
proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}"
ansible.builtin.uri:
url: "{{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_clone_noeud }}/qemu/{{ proxmox_clone_vmid_modele | int }}/clone"
url: "{{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_noeud_gabarit }}/qemu/{{ proxmox_clone_vmid_modele | int }}/clone"
method: POST
headers:
Authorization: >-
@ -236,6 +261,9 @@
# A la CREATION seulement : l'API ne sait pas changer le pool d'une VM
# existante par cet appel (il faut passer par le membre du pool).
pool: "{{ proxmox_clone_pool | default(omit, true) }}"
# LA DESTINATION SE DIT ICI, pas dans l'URL. Omise quand elle coincide avec le
# noeud du gabarit : l'API refuse un `target` inutile sur stockage non partage.
target: "{{ proxmox_clone_noeud if proxmox_clone_noeud != proxmox_noeud_gabarit else omit }}"
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
status_code: 200
register: proxmox_clone_lance
@ -246,6 +274,23 @@
no_log: true
when: not proxmox_clone_deja_la
# L'ECHEC DU POST ETAIT AVALE. `failed_when: false` protege du 500 « VMID deja pris »,
# mais il avalait AUSSI un refus reel — et la tache d'attente qui suit se bloquait
# alors sur un clonage qui n'avait jamais commence. Un blocage sans message, la panne
# la plus chere a diagnostiquer. On garde le masque pour l'en-tete, et on releve
# l'echec ici, message de l'API compris.
- name: "Le clonage a bien ete accepte par l'API"
ansible.builtin.assert:
that: (proxmox_clone_lance.status | default(0)) == 200
fail_msg: >-
L'API a refuse le clonage
({{ proxmox_clone_lance.status | default('aucune reponse') }}) :
{{ proxmox_clone_lance.json.message
| default(proxmox_clone_lance.msg | default('sans message')) }}
— gabarit {{ proxmox_clone_vmid_modele }} sur « {{ proxmox_noeud_gabarit }} »,
destination « {{ proxmox_clone_noeud }} ».
when: not proxmox_clone_deja_la
# ATTENDRE QUE LE CLONE SOIT REELLEMENT FINI, pas seulement demande.
#
# `POST .../clone` rend un UPID et la main IMMEDIATEMENT : Proxmox copie le disque en
@ -262,8 +307,20 @@
vars:
proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}"
ansible.builtin.uri:
# LE NOEUD VIENT DE L'UPID LUI-MEME, et de nulle part ailleurs (2026-08-25).
#
# Cette attente interrogeait `nodes/{{ proxmox_clone_noeud }}` — la DESTINATION.
# Or un clonage inter-noeuds s'execute sur le noeud du GABARIT : l'UPID n'existait
# pas la ou on le cherchait, l'API repondait en erreur, `failed_when: false`
# l'avalait, et `until` n'etait jamais satisfait. Resultat : soixante tentatives a
# dix secondes pour un clonage deja termine en 87 s — dix minutes d'attente
# immobile, puis la suite qui reprend sans un mot.
#
# Un UPID s'ecrit `UPID:<noeud>:<pid>:...`. Le noeud y est deja ; le redemander a
# une variable, c'est se donner une chance de se tromper.
url: >-
{{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_clone_noeud
{{ proxmox_api_racine }}/api2/json/nodes/{{
proxmox_clone_lance.json.data.split(':')[1]
}}/tasks/{{ proxmox_clone_lance.json.data | urlencode }}/status
method: GET
headers:
@ -288,10 +345,18 @@
Le clonage de {{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) ne s'est pas
termine correctement : etat « {{ proxmox_clone_tache.json.data.status | default('?')
}} », sortie « {{ proxmox_clone_tache.json.data.exitstatus | default('?') }} ».
Regarder la tache sur {{ proxmox_clone_noeud }}.
Regarder la tache sur
« {{ proxmox_clone_lance.json.data.split(':')[1] | default('?') }} ».
when:
- not proxmox_clone_deja_la
- proxmox_clone_tache.json.data.exitstatus | default('OK') != 'OK'
# `exitstatus | default('OK')` faisait passer pour un SUCCES une attente qui
# n'avait jamais rien observe : quand l'API repondait en erreur — le cas du
# clonage inter-noeuds interroge sur le mauvais noeud — il n'y avait aucun
# `json.data`, donc aucun `exitstatus`, donc « OK » par defaut. On exige
# desormais d'avoir VU la tache s'arreter, et bien s'arreter.
- >-
(proxmox_clone_tache.json.data.status | default('')) != 'stopped'
or (proxmox_clone_tache.json.data.exitstatus | default('')) != 'OK'
- name: Dire pourquoi le clonage a echoue, s'il a echoue
ansible.builtin.fail:
@ -373,6 +438,21 @@
- proxmox_clone_coeurs is defined and proxmox_clone_coeurs | string | length > 0
- proxmox_clone_memoire is defined and proxmox_clone_memoire | string | length > 0
# Un desarmement doit se VOIR. Sans ce message, l'ecart entre ce que l'ecosysteme
# declare et ce que la VM recoit resterait muet — et c'est precisement le genre de
# silence qui a coute une soiree de diagnostic le 2026-08-25.
- name: Dire que le pare-feu Proxmox reste desarme (pont classique, pas un VNet SDN)
ansible.builtin.debug:
msg: >-
`proxmox_clone_parefeu_interface` vaut vrai, mais {{ proxmox_clone_nom }} nait
sur `{{ proxmox_clone_pont }}` — un pont Linux, pas un VNet SDN. Le pare-feu
Proxmox y jetterait le retour des flux en epingle (`ctstate INVALID`) : il reste
desarme. La frontiere continue de policer ce segment.
when:
- proxmox_clone_parefeu_interface | default(false) | bool
- proxmox_clone_pont is defined
- proxmox_clone_pont is match('^vmbr[0-9]+$')
- name: Ajuster le reseau de la VM clonee
community.general.proxmox_nic:
api_host: "{{ proxmox_api_host_effectif }}"
@ -386,7 +466,36 @@
model: virtio
bridge: "{{ proxmox_clone_pont }}"
tag: "{{ proxmox_clone_vlan | int if proxmox_clone_vlan is defined and proxmox_clone_vlan | string | length > 0 else omit }}"
firewall: "{{ proxmox_clone_parefeu_interface | default(false) | bool }}"
# LE PARE-FEU PROXMOX NE S'ARME QUE SUR UN VNET SDN (2026-08-25).
#
# `firewall=1` fait passer tout le trafic ponte par conntrack. Sur un VNET SDN
# c'est sans consequence : en EVPN le routage inter-VNet se fait dans le VRF, SUR
# LE NOEUD. Le flux ne quitte jamais l'hyperviseur, conntrack en voit un chemin
# coherent, et le devis est-ouest (`devis_proxmox_fw.py`) peut le policer.
#
# Sur un PONT CLASSIQUE route par une frontiere externe, le meme reglage devient
# un piege. Deux VM du meme noeud dans deux VLAN differents ne se parlent qu'en
# EPINGLE : la trame sort par le lien physique, la frontiere la route, et elle
# revient sur LE MEME PONT. La meme table conntrack voit alors les deux moities de
# la connexion, classe le retour `INVALID`, et `PVEFW-FORWARD` le jette.
#
# MESURE DU 2026-08-25, sur le site decoupe en quatre zones : `site-ops-01` et
# `site-pki-01` (asgard), `site-dns-01` et `site-cache-01` (gandalf) ne se
# joignaient plus — 0/8 — alors que TOUTES les paires entre noeuds differents
# etaient a 8/8. Douze tentatives faisaient monter le compteur `ctstate INVALID`
# de +112 sur asgard et +116 sur gandalf ; `firewall=0` pose, il ne bouge plus.
#
# Le defaut se deguise en panne reseau : la poignee TCP aboutit (SYN/SYN-ACK
# creent l'etat), et ce sont les paquets de DONNEES qui disparaissent. On accuse
# le MTU, la frontiere, la zone. Rien de tout cela n'y est pour quelque chose.
#
# L'INTENTION DECLAREE NE SUFFIT DONC PAS : un tenant declare
# `proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme
# valeur PAR DEFAUT, chaque hote la remplacant par son VNet derive. L'hote qui
# retombe sur `vmbr1` naitrait arme sur un pont classique. On croise donc
# l'intention avec le pont REELLEMENT utilise. Un pont Linux se nomme `vmbrN` ;
# un VNet SDN porte le nom derive de sa zone (`t29serv`).
firewall: "{{ (proxmox_clone_parefeu_interface | default(false) | bool) and (proxmox_clone_pont is not match('^vmbr[0-9]+$')) }}"
# `mtu: 1` est la valeur Proxmox pour « HERITE DU PONT » (virtio uniquement).
#
# Sans elle, l'invite nait a 1500 quel que soit le pont. En SDN EVPN la zone est a

View file

@ -74,5 +74,5 @@
import_playbook: groupes/client_smtp.yml
- name: Couche agents — groupe client_backup
import_playbook: groupes/client_backup.yml
- name: Couche agents — groupe client_unbound
import_playbook: groupes/client_unbound.yml
- name: Couche agents — groupe client_resolveur
import_playbook: groupes/client_resolveur.yml

View file

@ -1,6 +1,18 @@
---
# Collections Ansible requises par Set-OPS.
# Installer : ansible-galaxy collection install -r requirements.yml
#
# CE FICHIER EST LA SEULE SOURCE POUR UN RUNNER (2026-08-24). Le poste d'exploitation
# n'installe QUE ce qui est declare ici — il n'herite pas du gros lot qui traine sur le
# poste du mainteneur. Une collection utilisee par un role et absente d'ici marche chez
# le mainteneur et echoue partout ailleurs : c'est le genre d'ecart qu'on ne voit qu'en
# portant le moteur sur une autre machine.
collections:
- name: community.postgresql
- name: community.general
# `serveur_backup` : ansible.posix.authorized_key. Utilisee depuis longtemps, jamais
# declaree — donc absente du runner, qui n'aurait pas pu deployer les sauvegardes.
- name: ansible.posix
# `community.docker` a ete RETIREE le 2026-08-24 : `serveur_collabora` etait la seule
# exception conteneurisee de la flotte, et il tourne desormais en natif (paquet coolwsd
# du depot amont Collabora). Plus aucun role n'a besoin de Docker.

View file

@ -2,6 +2,14 @@
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33).
integration:
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
#
# Braquer apt sur un cache absent casse toute installation ulterieure — y compris celle
# des roles qui suivent dans le meme deploiement.
#
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
serveur: serveur_artefacts
universelle: true
raison: >-
Tout hote installe des paquets, et doit les prendre chez lui quand l'ecosysteme a

View file

@ -2,6 +2,13 @@
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33).
integration:
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
#
# Un collecteur qui expedie vers un puits inexistant accumule en local, sans le dire.
#
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
serveur: serveur_loki
universelle: true
raison: >-
Tout hote expedie ses journaux. Un incident se reconstitue depuis les traces

View file

@ -3,6 +3,15 @@
# serveur du plan : une ligne recopiee 14 fois n'existe que pour etre oubliee une
# quinzieme. Voir docs/integrations-vm.md et docs/decisions-architecture.md (D-33).
integration:
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
#
# AUCUNE. `client_metrique` pose un exportateur que Prometheus vient LIRE : il n'attend
# personne, c'est le serveur qui vient a lui. La distinction compte — toutes les
# integrations ne dependent pas d'un service debout.
#
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
serveur: ~
universelle: true
raison: >-
Tout hote est mesure. Une machine hors supervision tombe sans que personne

View file

@ -4,8 +4,23 @@ client_pki_paquets:
client_pki_steppath: "/etc/step"
# AC interne (serveur_step_ca / infra-pki-01).
client_pki_ca_url: "https://infra-pki-01.{{ domaine_interne }}:8443"
# L'AC INTERNE SE DÉRIVE DU GROUPE QUI LA PORTE, elle ne se nomme pas (2026-08-25).
#
# Cette URL écrivait `infra-pki-01` EN DUR. Chez un tenant ça marchait toujours : la
# nomenclature nomme sa PKI ainsi, donc le littéral et la réalité coïncidaient. Le SITE
# est le premier à l'appeler autrement — `site-pki-01` — et les cinq machines ont essayé
# de s'enrôler auprès d'un hôte qui n'existe nulle part :
#
# lookup infra-pki-01.genese.internal ... no such host
#
# Le message accusait le DNS, alors que c'était le nom qui était faux. Un littéral qui a
# raison par coïncidence est un bogue qui attend son premier cas particulier.
#
# `groups['serveur_step_ca']` dit qui porte l'autorité, dans un tenant comme sur un site.
# Pas de repli : sans autorité déclarée, s'adresser à un nom inventé produirait exactement
# la panne qu'on vient de corriger. Le rôle refuse (voir tasks/main.yml).
client_pki_ca_hote: "{{ (groups['serveur_step_ca'] | default([])) | first | default('', true) }}"
client_pki_ca_url: "https://{{ client_pki_ca_hote }}.{{ domaine_interne }}:8443"
client_pki_provisioner: "admin@{{ domaine_interne }}"
# Identite de l'hote.
@ -35,6 +50,27 @@ client_pki_depot_cle_fichier: "/etc/apt/keyrings/smallstep.asc"
client_pki_depot_uri: "https://packages.smallstep.com/stable/debian"
client_pki_depot_suite: "debs"
# QUI PEUT LIRE LA CLE PRIVEE (2026-08-25).
#
# Elle est en `0600 root:root`, et c'est le bon defaut. Les consommateurs TLS habituels —
# nginx, Postfix, Dovecot — demarrent en root, lisent la cle, puis deprivilegient : ils
# n'ont jamais eu besoin d'autre chose.
#
# Forgejo tourne en `git` DES LE DEPART. Sur la forge du site, qui sert le genome sans
# edge devant elle, la cle etait donc illisible par le seul processus qui en a besoin.
#
# Un GROUPE lecteur et `0640` reglent ca sans ouvrir la cle a tout le monde. On declare le
# groupe explicitement, service par service : elargir par defaut serait exactement le
# genre de commodite qui finit par rendre une cle privee lisible par `nogroup`.
client_pki_cle_groupe: "root"
client_pki_cle_mode: "0600"
# LE CERTIFICAT, LUI, EST PUBLIC — et l'etait deja avant d'etre sur ce disque : il est
# presente a chaque poignee de main, a quiconque se connecte. Le garder en `0600` ne
# protegeait rien et empechait tout service non-root de le lire. Le certificat racine est
# d'ailleurs deja en `0644` juste a cote, pour la meme raison.
client_pki_cert_mode: "0644"
# Services a recharger apres un renouvellement de cert : les VRAIS consommateurs
# (nginx sur l'edge, postfix/dovecot sur le mail, slapd sur l'annuaire). Sans ca,
# le cert est renouvele sur disque mais le service sert l'ancien jusqu'a un reload.

View file

@ -2,6 +2,16 @@
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33).
integration:
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
#
# L'AC doit exister ET repondre avant qu'un hote lui demande un certificat. Sur l'hote
# qui la porte, le role recharge `step-ca` en reemettant son propre certificat :
# pendant cette fenetre, elle ne repond plus. Mesure du 2026-08-25 : quatre clients en
# echec simultane, message `TLS handshake timeout` — qui accuse le reseau, pas l'ordre.
#
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
serveur: serveur_step_ca
universelle: true
raison: >-
Tout hote fait confiance a l'AC interne et porte un certificat : c'est le

View file

@ -1,4 +1,22 @@
---
# SANS AUTORITÉ DÉCLARÉE, ON NE DEVINE PAS SON NOM.
#
# L'URL de l'AC se dérivait d'un littéral (`infra-pki-01`), vrai chez tout tenant par
# coïncidence de nomenclature. Le premier écosystème à nommer sa PKI autrement a vu ses
# cinq machines s'enrôler auprès d'un hôte inexistant, et le message accusait le DNS.
#
# Elle se dérive maintenant de `groups['serveur_step_ca']`. Si ce groupe est vide, il n'y
# a pas d'autorité du tout : le dire ici vaut mieux que de laisser `step ca bootstrap`
# échouer sur un nom tronqué, trois tâches plus loin, en parlant de résolution.
- name: Une autorité de certification est-elle déclarée ?
ansible.builtin.assert:
that:
- client_pki_ca_hote | length > 0
fail_msg: >-
Aucun hôte ne porte `serveur_step_ca` dans cet inventaire : il n'y a pas d'autorité
interne à qui demander un certificat. Déclarer une PKI, ou exempter cet écosystème
de `client_pki` — mais avec une raison, jamais en silence.
# L'empreinte du root CA est la SOURCE DE VÉRITÉ de l'autorité elle-même : on la
# dérive à chaud (robuste au from-zero — une AC régénérée a une empreinte neuve).
# `client_pki_ca_fingerprint_override` permet d'épingler explicitement si besoin.
@ -179,6 +197,32 @@
changed_when: true
notify: Recharger les consommateurs du cert
# POSÉ À CHAQUE PASSAGE, PAS SEULEMENT À L'ÉMISSION.
#
# `step ca certificate` réécrit la clé avec ses propres droits, et le renouvellement
# automatique aussi. Ne régler les droits qu'au moment où le certificat change les
# perdrait au premier renouvellement — une panne qui surviendrait des semaines plus tard,
# sans rapport visible avec cette tâche.
- name: Donner accès à la clé privée au service qui doit la lire
ansible.builtin.file:
path: "{{ client_pki_cle }}"
owner: root
group: "{{ client_pki_cle_groupe }}"
mode: "{{ client_pki_cle_mode }}"
when: client_pki_cle_groupe != 'root' or client_pki_cle_mode != '0600'
notify: Recharger les consommateurs du cert
# Le certificat est public. `step` l'ecrit en 0600 comme la cle ; on le rend lisible, sans
# quoi un service non-root echoue sur le CERT apres avoir obtenu la CLE — et le message
# parle de permission sur un fichier que rien ne justifie de proteger.
- name: Rendre le certificat d'hôte lisible (il est public par nature)
ansible.builtin.file:
path: "{{ client_pki_cert }}"
owner: root
group: root
mode: "{{ client_pki_cert_mode }}"
notify: Recharger les consommateurs du cert
- name: Deployer l'unite systemd de renouvellement
ansible.builtin.template:
src: cert-renewer@.service.j2

View file

@ -1,4 +1,4 @@
# client_unbound
# client_resolveur
**Résolveur local** par nœud (Unbound) : cache + récursion, avec une **stub-zone** vers
l'autoritatif interne (PowerDNS). Rend la résolution *dynamique* sans casser Internet.
@ -21,8 +21,8 @@ Changer le résolveur d'un nœud à distance, c'est risquer de le rendre muet. L
donc **deux** interrupteurs, et vérifie avant d'agir :
```yaml
client_unbound_apply: true
client_unbound_confirm: true
client_resolveur_apply: true
client_resolveur_confirm: true
```
Sans les deux, le rôle installe et démarre Unbound mais **ne touche pas** à `/etc/resolv.conf`
@ -33,18 +33,18 @@ qu'ensuite. Même doctrine que la règle 4 d'`AGENTS.md` : action risquée = con
## Variables
| Variable | Défaut | Rôle |
| --- | --- | --- |
| `client_unbound_ecoute` | `127.0.0.1` | Écoute (boucle locale) |
| `client_unbound_zone_interne` | `{{ domaine_interne }}` | Zone en stub |
| `client_unbound_dns_autoritatif` | dérivé du groupe `serveur_powerdns` | Cible de la stub-zone |
| `client_unbound_transitaires` | `[]` | Vide = récursion propre (racine + DNSSEC) |
| `client_unbound_apply` / `_confirm` | `false` / `false` | Bascule de `/etc/resolv.conf` |
| `client_resolveur_ecoute` | `127.0.0.1` | Écoute (boucle locale) |
| `client_resolveur_zone_interne` | `{{ domaine_interne }}` | Zone en stub |
| `client_resolveur_dns_autoritatif` | dérivé du groupe `serveur_powerdns` | Cible de la stub-zone |
| `client_resolveur_transitaires` | `[]` | Vide = récursion propre (racine + DNSSEC) |
| `client_resolveur_apply` / `_confirm` | `false` / `false` | Bascule de `/etc/resolv.conf` |
## Flux (`meta/flux.yml`)
`53/udp` entrant en **localhost** · `53/tcp` sortant vers `serveur_powerdns`.
## Notes / limites
- Transitaires vides = Unbound **récurse lui-même** (racine + DNSSEC). Si la récursion
sortante est bloquée par le réseau, renseigner `client_unbound_transitaires`.
sortante est bloquée par le réseau, renseigner `client_resolveur_transitaires`.
- Le rôle exige l'IP de l'autoritatif interne (assertion) : `serveur_powerdns` doit être
dans l'inventaire avec un `ansible_host`.
- DoT (DNS chiffré) n'est pas encore branché — c'est un des flux restants du zéro-confiance.

View file

@ -0,0 +1,34 @@
---
# INTÉGRATION CLIENTE : désigner le résolveur de l'écosystème.
#
# CE RÔLE N'INSTALLE PLUS RIEN (2026-08-24). Il posait un Unbound sur CHAQUE VM — N démons
# identiques, ~21 Mo chacun, pour quelques centaines de requêtes. Le résolveur est
# désormais un service du tenant (`serveur_resolveur`), et ce rôle ne fait plus qu'une
# chose : écrire `/etc/resolv.conf`.
#
# L'ancien nom mentait dès lors qu'il n'installait plus Unbound — d'où `client_resolveur`.
# DÉRIVÉ de l'inventaire : l'hôte qui porte le résolveur. Aucun résolveur au plan rend une
# valeur vide, et le rôle ne touche à rien (voir `client_resolveur_actif`).
client_resolveur_hote: >-
{{ (groups['serveur_resolveur'] | default([]) | first | default('')) }}
client_resolveur_adresse: >-
{{ hostvars[client_resolveur_hote].ansible_host | default('')
if client_resolveur_hote else '' }}
# L'INTÉGRATION SUIT L'EXISTENCE DU SERVICE — elle ne se déclare pas. Un écosystème sans
# résolveur garde la résolution d'amorçage posée par cloud-init : c'est un choix valide,
# pas une panne.
client_resolveur_actif: "{{ (groups['serveur_resolveur'] | default([])) | length > 0 }}"
client_resolveur_zone_interne: "{{ domaine_interne }}"
client_resolveur_resolv_conf: "/etc/resolv.conf"
# --- BASCULE PROTÉGÉE ---------------------------------------------------------
#
# Réécrire /etc/resolv.conf peut couper toute la flotte si la cible est mauvaise. Le rôle
# exige donc une confirmation ET vérifie que le résolveur répond DÉJÀ — pour l'interne et
# pour l'Internet — avant de basculer quoi que ce soit.
client_resolveur_apply: false
client_resolveur_confirm: false
client_resolveur_attente_reconnexion: 120

View file

@ -0,0 +1,18 @@
---
# Flux réseau du client de résolution. Voir docs/flux-conception.md.
#
# Il n'écoute plus rien : depuis 2026-08-24, le résolveur est un service du tenant et
# non un démon local. L'hôte ne fait qu'interroger.
flux:
- sens: egress
port: 53
protocole: udp
pair: serveur_resolveur
chiffrement: clair
raison: "Résoudre auprès du résolveur de l'écosystème, et de personne d'autre."
- sens: egress
port: 53
protocole: tcp
pair: serveur_resolveur
chiffrement: clair
raison: "Réponses longues et bascule TCP, obligatoires en DNS."

View file

@ -0,0 +1,21 @@
---
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33).
integration:
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
#
# Basculer un hote sur un resolveur qui n'est pas encore pret le rend MUET, et avec lui
# tout ce qui resout un nom. C'est la dependance la plus dangereuse du lot : le runner
# qui devrait reparer tombe avec les autres.
#
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
serveur: serveur_resolveur
universelle: true
raison: >-
Tout hote resout des noms, et doit le faire aupres du resolveur de son ecosysteme
plutot que d'un tiers. Une machine restee sur la resolution d'amorcage envoie
chacune de ses questions dehors, sans que rien ne le signale.
# AUCUNE exemption, pas meme l'hote qui PORTE le resolveur : il se sert lui-meme, et
# c'est le cas le plus simple. L'exempter reviendrait a dire que le resolveur ne se
# fait pas confiance.

View file

@ -0,0 +1,75 @@
---
- name: Exiger un résolveur joignable quand l'intégration est active
ansible.builtin.assert:
that:
- client_resolveur_adresse | length > 0
fail_msg: >-
client_resolveur est actif mais aucun hôte ne porte `serveur_resolveur`, ou son
adresse est introuvable. Déclarer le service au plan, ou forcer
client_resolveur_actif=false.
when: client_resolveur_actif | bool
- name: Refuser la bascule du résolveur sans confirmation explicite
ansible.builtin.assert:
that:
- client_resolveur_confirm | bool
fail_msg: "Bascule refusée : définir client_resolveur_confirm=true (avec client_resolveur_apply=true)."
when: client_resolveur_apply | bool
# ON VÉRIFIE AVANT DE BASCULER, PAS APRÈS. Un resolv.conf qui pointe vers un service muet
# coupe l'hôte de tout — et le diagnostic, lui, exige de résoudre des noms.
- name: Valider que le résolveur répond AVANT de basculer (interne + Internet)
ansible.builtin.command: "dig @{{ client_resolveur_adresse }} {{ item.nom }} {{ item.type }} +short"
register: client_resolveur_validation
changed_when: false
failed_when: client_resolveur_validation.stdout | trim == ''
loop:
- { nom: "{{ client_resolveur_zone_interne }}", type: "SOA" }
- { nom: "deb.debian.org", type: "A" }
loop_control:
label: "{{ item.nom }} {{ item.type }}"
when:
- client_resolveur_actif | bool
- client_resolveur_apply | bool
- client_resolveur_confirm | bool
- not ansible_check_mode
- name: Désigner le résolveur de l'écosystème
ansible.builtin.copy:
dest: "{{ client_resolveur_resolv_conf }}"
content: |
# Généré par Set-OPS (rôle client_resolveur). Résolveur de l'écosystème.
search {{ client_resolveur_zone_interne }}
nameserver {{ client_resolveur_adresse }}
owner: root
group: root
mode: "0644"
when:
- client_resolveur_actif | bool
- client_resolveur_apply | bool
- client_resolveur_confirm | bool
- not ansible_check_mode
# RETIRER L'ANCIEN DÉMON LOCAL — APRÈS la bascule, jamais avant.
#
# Sans cette tâche, les N Unbound qu'on voulait supprimer resteraient installés et actifs :
# le rôle aurait changé le `resolv.conf` sans rien économiser, et la raison même du
# changement serait perdue. Une intégration qui ne sait pas se retirer est un piège
# différé.
#
# L'ORDRE COMPTE. Retirer le paquet avant d'avoir écrit le nouveau `resolv.conf` couperait
# l'hôte de toute résolution au milieu du play — et le diagnostic, lui, a besoin de
# résoudre des noms.
#
# L'HÔTE QUI PORTE LE RÉSOLVEUR EST ÉPARGNÉ, évidemment : c'est le même paquet.
- name: Retirer le résolveur local devenu inutile
ansible.builtin.apt:
name: unbound
state: absent
purge: false
when:
- client_resolveur_actif | bool
- client_resolveur_apply | bool
- client_resolveur_confirm | bool
- inventory_hostname not in (groups['serveur_resolveur'] | default([]))
- not ansible_check_mode

View file

@ -1,28 +0,0 @@
---
# Résolveur local Unbound par nœud : cache + récursion (ou forward), avec une
# stub-zone vers l'autoritatif interne (PowerDNS). Rend la résolution dynamique
# (au lieu du plancher /etc/hosts statique) sans casser Internet. La bascule de
# /etc/resolv.conf est PROTÉGÉE (apply + confirm) — bascule du résolveur validée avant application.
client_unbound_paquets:
- unbound
- bind9-dnsutils # dig, pour valider avant de basculer le resolver
client_unbound_service: "unbound"
client_unbound_ecoute: "127.0.0.1"
# Zone interne autoritative (PowerDNS) — résolue via une stub-zone.
client_unbound_zone_interne: "{{ domaine_interne }}"
# IP du serveur autoritatif interne. Dérivée du groupe serveur_powerdns.
client_unbound_dns_autoritatif: "{{ hostvars[groups['serveur_powerdns'][0]].ansible_host | default('') if groups.get('serveur_powerdns') else '' }}"
# Transitaires pour '.' (Internet). Vide => Unbound recurse lui-même (racine + DNSSEC).
# À renseigner si la récursion sortante est bloquée (forward vers l'upstream réseau).
client_unbound_transitaires: []
# Bascule de /etc/resolv.conf vers 127.0.0.1 : les DEUX requis (garde-fou anti-coupure).
client_unbound_apply: false
client_unbound_confirm: false
client_unbound_resolv_conf: "/etc/resolv.conf"
# Delai maximal pour que l'hote reponde apres le redemarrage d'Unbound. Depasser reste
# un ECHEC : on attend une machine qui revient, pas une panne qu'on tait.
client_unbound_attente_reconnexion: 180

View file

@ -1,18 +0,0 @@
---
# `ignore_unreachable` : le redemarrage a deja fait perdre la connexion en cours de
# deploiement — « Connection timed out during banner exchange », le 2026-08-09 sur
# web-dorsal-01. La CAUSE n'est pas etablie : `usedns no` ecarte la resolution inverse
# de sshd, et le journal du deploiement rate a ete ecrase avant d'etre lu. Ce que le
# depot sait deja de ce symptome (Makefile, `_attendre-hote`) : a travers la frontiere,
# le TCP s'etablit par proxy SYN et l'echec se lit ainsi meme quand l'hote n'est
# simplement pas la.
#
# On ne masque donc pas une panne : une perte ICI n'abandonne plus les couches
# suivantes, et `wait_for_connection` exige que l'hote revienne pour de bon. S'il ne
# revient pas, la tache suivante echoue — franchement.
- name: Redémarrer unbound
ansible.builtin.systemd:
name: "{{ client_unbound_service }}"
state: restarted
when: not ansible_check_mode
ignore_unreachable: true

View file

@ -1,32 +0,0 @@
---
# Flux réseau du résolveur local Unbound (opt-in). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 53
protocole: udp
pair: localhost
chiffrement: clair
raison: "Résolveur local sur boucle locale (les processus du nœud interrogent 127.0.0.1)."
- sens: egress
port: 53
protocole: tcp
pair: serveur_powerdns
chiffrement: clair
raison: "Transfert des requêtes de la zone souveraine vers le DNS autoritatif interne (PowerDNS)."
# Récursion sortante : `client_unbound_transitaires` est vide par défaut, donc Unbound
# interroge lui-même la racine puis les autoritatifs — c'est le choix souverain, sans
# dépendre du résolveur d'un fournisseur. Renseigner des transitaires rendrait ces deux
# flux inutiles, mais les laisser déclarés ne coûte rien et évite une panne muette.
- sens: egress
port: 53
protocole: udp
pair: externe
chiffrement: clair
raison: "Récursion DNS depuis la racine (UDP d'abord), validée par DNSSEC — la confidentialité du transport n'est pas l'enjeu, l'authenticité l'est."
- sens: egress
port: 53
protocole: tcp
pair: externe
chiffrement: clair
raison: "Récursion DNS en TCP : repli obligatoire quand la réponse dépasse la taille UDP (fréquent avec DNSSEC)."

View file

@ -1,16 +0,0 @@
---
# Politique d'integration. Declaree ICI, une fois, plutot que recopiee sur chaque
# serveur du plan. Voir docs/integrations-vm.md et docs/decisions-architecture.md (D-33).
integration:
universelle: true
raison: >-
Tout hote doit resoudre des noms — ne serait-ce que pour `apt`. Le DNS
autoritatif du tenant (PowerDNS) ne repond QUE pour la zone souveraine et
refuse le reste : sans resolveur recursif, une machine ne peut meme pas
s'installer. `client_unbound` delegue la zone interne a l'autoritatif et
recurse depuis la racine, sans dependre du resolveur d'un fournisseur.
# Le serveur autoritatif est exempte : PowerDNS occupe deja le port 53 sur cet
# hote. Y ajouter Unbound produirait un conflit de liaison, pas un resolveur.
# Il resout par le resolveur d'amorcage (`proxmox_clone_dns`), qui lui suffit :
# ses seuls besoins externes sont apt et NTP.
sauf_role: serveur_powerdns

View file

@ -1,83 +0,0 @@
---
- name: Exiger l'IP de l'autoritatif interne (PowerDNS)
ansible.builtin.assert:
that:
- client_unbound_dns_autoritatif | length > 0
fail_msg: "client_unbound_dns_autoritatif requis (aucun hôte serveur_powerdns actif ?)."
- name: Installer Unbound
ansible.builtin.apt:
name: "{{ client_unbound_paquets }}"
state: present
update_cache: true
cache_valid_time: 3600
- name: Déployer la configuration Unbound (stub-zone interne + récursion)
ansible.builtin.template:
src: setops.conf.j2
dest: /etc/unbound/unbound.conf.d/setops.conf
owner: root
group: root
mode: "0644"
notify: Redémarrer unbound
- name: Valider la configuration Unbound
ansible.builtin.command: unbound-checkconf
changed_when: false
when: not ansible_check_mode
- name: Activer et démarrer Unbound
ansible.builtin.systemd:
name: "{{ client_unbound_service }}"
enabled: true
state: started
when: not ansible_check_mode
- name: Appliquer les redémarrages Unbound avant toute validation/bascule
ansible.builtin.meta: flush_handlers
# Attendre une CONDITION — que l'hote reponde — et non une duree. Si la connexion a
# saute pendant le redemarrage, on la retablit ici ; sinon la tache passe aussitot.
- name: Exiger que l hote reponde avant de poursuivre
ansible.builtin.wait_for_connection:
timeout: "{{ client_unbound_attente_reconnexion }}"
sleep: 5
when: not ansible_check_mode
# --- Bascule du resolver : PROTÉGÉE + validée (ne casse jamais un nœud) ---
- name: Refuser la bascule du resolver sans confirmation explicite
ansible.builtin.assert:
that:
- client_unbound_confirm | bool
fail_msg: "Bascule refusée : définir client_unbound_confirm=true (avec client_unbound_apply=true)."
when: client_unbound_apply | bool
- name: Valider qu'Unbound résout AVANT de basculer (interne + Internet)
ansible.builtin.command: "dig @127.0.0.1 {{ item.nom }} {{ item.type }} +short"
register: client_unbound_validation
changed_when: false
failed_when: client_unbound_validation.stdout | trim == ''
loop:
- { nom: "{{ client_unbound_zone_interne }}", type: "SOA" }
- { nom: "deb.debian.org", type: "A" }
loop_control:
label: "{{ item.nom }} {{ item.type }}"
when:
- client_unbound_apply | bool
- client_unbound_confirm | bool
- not ansible_check_mode
- name: Basculer /etc/resolv.conf vers Unbound local (127.0.0.1)
ansible.builtin.copy:
dest: "{{ client_unbound_resolv_conf }}"
content: |
# Généré par Set-OPS (rôle client_unbound). Résolveur local Unbound.
search {{ client_unbound_zone_interne }}
nameserver 127.0.0.1
owner: root
group: root
mode: "0644"
when:
- client_unbound_apply | bool
- client_unbound_confirm | bool
- not ansible_check_mode

View file

@ -1,25 +0,0 @@
# Géré par Set-OPS (rôle client_unbound). Ne pas éditer à la main.
# Résolveur local : cache + récursion (ou forward), stub-zone vers l'autoritatif interne.
server:
interface: {{ client_unbound_ecoute }}
access-control: 127.0.0.0/8 allow
hide-identity: yes
hide-version: yes
prefetch: yes
do-ip6: no
# La zone interne n'est pas signée DNSSEC (PowerDNS autoritatif, dnssec off).
domain-insecure: "{{ client_unbound_zone_interne }}"
# Zone interne : déléguée à l'autoritatif (PowerDNS).
stub-zone:
name: "{{ client_unbound_zone_interne }}"
stub-addr: {{ client_unbound_dns_autoritatif }}
{% if client_unbound_transitaires | length > 0 %}
# Internet : forward vers l'upstream réseau (au lieu de la récursion directe).
forward-zone:
name: "."
{% for t in client_unbound_transitaires %}
forward-addr: {{ t }}
{% endfor %}
{% endif %}

View file

@ -50,3 +50,28 @@ common_packages_list:
# Attente du verrou dpkg (secondes). Genereux : au premier demarrage, les mises a
# jour automatiques de Debian le tiennent par vagues pendant plusieurs minutes.
common_packages_lock_timeout: 300
# --- QUI DÉCIDE DE CE QUI S'INSTALLE (2026-08-25) -----------------------------
#
# Debian arme `unattended-upgrades` et les minuteurs `apt-daily` par défaut. Set-OPS
# applique DÉJÀ les mises à jour, explicitement, dans ce rôle. **Deux mécanismes font le
# même travail sur la même flotte, et se disputent le même verrou dpkg.**
#
# Sur une machine tranquille ça ne se voit jamais. Après six redémarrages et des heures
# sans réseau — ce qu'a vécu le site le 2026-08-25 — `unattended-upgrades` rattrape tout
# son retard et tient le verrou 48 minutes. `lock_timeout` vaut 300 s : les déploiements
# échouent, trois fois de suite, sur une course que personne n'a décidée.
#
# Allonger l'attente serait un pansement : on patienterait plus longtemps devant un
# concurrent qu'on n'a pas voulu. On désarme donc, et Set-OPS reste seul maître de ce qui
# s'installe — un écosystème souverain ne laisse pas un démon décider seul quand redémarrer
# ses services.
#
# CONTREPARTIE ASSUMÉE, ET ELLE EST RÉELLE : les correctifs de sécurité ne s'appliquent
# plus qu'au passage de Set-OPS. C'est tenable si les déploiements sont réguliers, et ça
# devient une dette sinon. Mettre à `false` pour rendre la main à Debian.
common_packages_desarmer_maj_auto: true
common_packages_unites_maj_auto:
- apt-daily.timer
- apt-daily-upgrade.timer
- unattended-upgrades.service

View file

@ -6,6 +6,21 @@
#
# Attendre ici plutot que seulement avant le playbook : le verrou peut etre repris
# ENTRE deux taches, ce qu'une verification ponctuelle en amont ne peut pas empecher.
# DÉSARMER AVANT TOUTE OPÉRATION APT, ET NON APRÈS.
#
# Une tâche apt placée avant celle-ci attendrait le verrou que celle-ci doit justement
# libérer — l'ordre n'est pas cosmétique. `state: stopped` en plus de `masked` : masquer
# empêche un démarrage futur, pas celui qui est déjà en cours.
- name: Désarmer les mises à jour automatiques de Debian (Set-OPS les applique lui-même)
ansible.builtin.systemd_service:
name: "{{ item }}"
state: stopped
enabled: false
masked: true
loop: "{{ common_packages_unites_maj_auto }}"
when: common_packages_desarmer_maj_auto | bool
failed_when: false # une unite absente n'est pas une faute : elle est desarmee
- name: Mettre à jour le cache APT
ansible.builtin.apt:
update_cache: true

View file

@ -18,7 +18,7 @@ C'est ce qui rend l'**ordre de reconstruction** possible : appliqué dans la cou
déploiement d'une instance neuve.
Les trois couches de résolution : ce plancher → PowerDNS autoritatif → Unbound local
(`client_unbound`, opt-in). Cf. `docs/dns-interne.md`.
(`client_resolveur`, opt-in). Cf. `docs/dns-interne.md`.
## Variables
| Variable | Défaut | Rôle |

View file

@ -1,4 +1,32 @@
---
# CLOUD-INIT REECRIT /etc/hosts A CHAQUE DEMARRAGE (2026-08-24).
#
# Le gabarit dore laisse `manage_etc_hosts: true`. A chaque boot, cloud-init regenere
# /etc/hosts depuis son propre modele — et efface le plancher de resolution que ce role
# vient de poser. La machine perd alors la seule facon qu'elle a de nommer ses voisines
# sans DNS, et personne ne le voit : rien n'echoue tant qu'on ne demande pas un nom.
#
# CONSTATE SUR infra-dns-01 : redemarre a 18h53 lors d'un deplacement de disque, il a
# perdu ses six entrees de flotte. Le defaut est apparu deux jours plus tard, sous la
# forme d'un `apt update` qui n'arrivait plus a resoudre le cache d'artefacts — un
# message qui ne parlait pas du tout du vrai probleme.
#
# On le desactive par un fragment plutot qu'en editant cloud.cfg : le gabarit reste
# intact, et `apt purge cloud-init` ou une remise a zero rend la machine a son etat
# d'origine.
- name: Empêcher cloud-init de réécrire le plancher à chaque démarrage
ansible.builtin.copy:
dest: /etc/cloud/cloud.cfg.d/99-setops-hosts.cfg
content: |
# GÉNÉRÉ par Set-OPS (rôle hosts_statiques). NE PAS éditer à la main.
# /etc/hosts est le PLANCHER DE RÉSOLUTION de l'écosystème, dérivé de l'inventaire.
# Laisser cloud-init le régénérer au démarrage l'effacerait en silence.
manage_etc_hosts: false
owner: root
group: root
mode: "0644"
when: hosts_statiques_actif | bool
# Alias d'exposition (best-effort) : dérivés du plan si celui-ci est disponible.
- name: Vérifier la présence des registres du plan (sur le nœud de contrôle)
ansible.builtin.stat:
@ -16,18 +44,29 @@
- hosts_statiques_publier_expositions | bool
- setops_plan_dir is defined
# CHAQUE REGISTRE EST CHARGÉ POUR LUI-MÊME (2026-08-25).
#
# La condition exigeait que les DEUX fichiers existent (`length == 2`). Un tout-ou-rien
# sans raison : `domaines.yml` déclare les domaines PUBLICS, et un écosystème peut
# légitimement n'en avoir aucun — c'est le cas du SITE, dont tous les services sont
# internes.
#
# Conséquence : `applications.yml` n'était pas chargé non plus, la dérivation rendait une
# liste vide, et le plancher s'écrivait SANS AUCUN ALIAS — sans erreur. Le certificat de
# la forge portait `forge.genese.internal` et ce nom ne résolvait nulle part.
#
# On charge donc ce qui existe, fichier par fichier. Un registre absent n'est pas une
# faute ; il est simplement absent.
- name: Charger les registres applications et domaines (si présents)
ansible.builtin.include_vars:
file: "{{ item }}"
loop:
- "{{ setops_plan_dir }}/applications.yml"
- "{{ setops_plan_dir }}/domaines.yml"
file: "{{ item.item }}"
loop: "{{ hosts_statiques_plan.results | default([]) }}"
loop_control:
label: "{{ item | basename }}"
label: "{{ item.item | basename }}"
when:
- hosts_statiques_actif | bool
- hosts_statiques_publier_expositions | bool
- (hosts_statiques_plan.results | default([]) | selectattr('stat.exists', 'defined') | selectattr('stat.exists') | list | length) == 2
- item.stat.exists | default(false)
- name: Dériver les alias d'exposition (FQDN exposés → edge qui les sert)
ansible.builtin.set_fact:

View file

@ -45,3 +45,21 @@ serveur_artefacts_hors_ligne: false
# Journalisation : apt-cacher-ng tient un journal des requêtes servies. C'est lui qui
# permet de répondre à « ce paquet, l'avons-nous servi ou est-il venu du dehors ? ».
serveur_artefacts_journal: "/var/log/apt-cacher-ng"
# --- LE CHAINAGE DES CACHES (2026-08-24) --------------------------------------
#
# Chaque écosystème garde SON cache, et celui-ci prend le cache de l'hébergeur comme
# amont. Debian n'est alors téléchargé qu'UNE FOIS pour tout le site :
#
# VM du tenant -> cache du tenant -> cache du SITE -> Debian
#
# POURQUOI PAS UN CACHE UNIQUE OÙ TOUTES LES VM TAPENT. Il aurait fallu ouvrir un flux de
# chaque machine de chaque tenant vers un hôte d'un autre tenant — N×M chemins à travers
# le default-deny. Avec le chaînage il n'y en a qu'UN par écosystème, entre deux caches.
#
# Et l'étanchéité y gagne : le cache du site ne voit que des requêtes AGRÉGÉES, jamais
# quelle machine installe quoi.
#
# Vide = ce cache va directement à l'amont Debian. C'est le cas du cache du SITE lui-même,
# et de tout écosystème qui s'est émancipé (docs/filiation-emancipation.md).
serveur_artefacts_amont: ""

View file

@ -16,3 +16,15 @@ flux:
pair: externe
chiffrement: clair
raison: "Remplir le cache depuis les dépôts Debian amont (deb.debian.org, security)."
# LE CHAINAGE, cote consommateur. Un seul port, et seulement vers un CACHE — jamais
# d'une VM vers le cache d'un autre tenant.
#
# L'INGRESS CORRESPONDANT N'EST PAS ICI : il appartient a `serveur_cache_site`, le
# cache designe du site. Sans cette separation, chaque cache aurait accepte chaque
# autre — un maillage complet, visible au devis du 2026-08-24.
- sens: egress
port: 3142
protocole: tcp
pair: voisins_site
chiffrement: clair
raison: "Prendre le cache du site comme amont, plutot que d'aller chez Debian."

View file

@ -17,3 +17,9 @@ Offlinemode: 1
{% else %}
Offlinemode: 0
{% endif %}
{% if serveur_artefacts_amont %}
# CHAÎNAGE : ce cache passe par celui de l'hébergeur plutôt que d'aller chez Debian.
# Debian n'est ainsi téléchargé qu'une fois pour tout le site.
Proxy: {{ serveur_artefacts_amont }}
{% endif %}

View file

@ -0,0 +1,56 @@
# serveur_cache_site
**Le cache d'artefacts du SITE** : celui que les écosystèmes voisins prennent comme amont,
pour que Debian ne soit téléchargé qu'une fois pour toute la fabric.
## Le chaînage
```
VM du tenant → cache du tenant → cache du SITE → Debian
```
Chaque écosystème **garde le sien** — il reste maître de ses paquets et s'en émancipe d'un
drapeau — mais il ne va plus chercher chez Debian.
**Un cache unique où toutes les VM taperaient** aurait demandé un flux de chaque machine
de chaque tenant vers un hôte d'un autre tenant : N×M chemins à travers le default-deny.
Avec le chaînage il n'y en a **qu'un par écosystème**, entre deux caches.
Et l'étanchéité y gagne : le cache du site ne voit que des requêtes **agrégées**, jamais
quelle machine installe quoi.
## Pourquoi deux rôles plutôt qu'un drapeau
Les flux se déclarent dans `meta/flux.yml`, qui est **statique**. Un rôle unique aurait dû
déclarer l'ingress inter-tenant pour **tous** les caches — et le devis l'a montré :
```
+ TENANT_PATI29 -> CHEZ17_SERVEUR_ARTEFACTS un maillage complet,
+ TENANT_TECH23 -> CHEZ17_SERVEUR_ARTEFACTS chaque cache acceptant chaque autre
```
Avec la séparation, l'ingress n'existe qu'ici, et un seul cache est joignable.
## Qui peut le déclarer
**L'écosystème de l'hébergeur**, celui qui exploite la fabric. Un tenant ordinaire qui le
déclarerait s'offrirait à servir ses voisins sans y être invité.
C'est le même patron que `serveur_ops_site` : le **runner de site** prépare le terrain —
VNets, routes, flux — pour que les plans des tenants tiennent. Aucun écosystème n'ouvre de
porte chez un autre ; c'est le site qui autorise.
## Ce qu'il vérifie
**Qu'il ne se chaîne pas lui-même.** Il est le bout de la chaîne : le laisser pointer sur
un amont ferait tourner les paquets en rond entre deux caches, ou les ferait sortir du site
sans que personne ne l'ait décidé.
**Que le cache écoute vraiment.** Le rôle n'installe rien : sa seule prise sur le réel est
de vérifier que le service qu'il désigne existe. Sans ça, il attribuerait une
responsabilité à un port que personne n'écoute.
## Ce que ce rôle ne fait pas
Il n'installe ni ne configure apt-cacher-ng — c'est `serveur_artefacts`. Il **attribue une
responsabilité** et la rend lisible dans le plan.

View file

@ -0,0 +1,28 @@
---
# LE CACHE D'ARTEFACTS DU SITE — nomme `cache_site` et non `artefacts_site`.
#
# POURQUOI CE NOM COURT (2026-08-24). OPNsense refuse un nom d'alias de 32 caracteres ou
# plus, et le schema `SETOPS_<tenant>_<ROLE>` ne laisse que 17 caracteres au role :
#
# SETOPS_PATI29_SERVEUR_ARTEFACTS_SITE 36 refuse a l'application
# SETOPS_PATI29_SRV_ARTEFACTS_SITE 32 refuse encore, d'un caractere
# SETOPS_PATI29_SRV_CACHE_SITE 28 passe
#
# La contrainte n'etait ecrite nulle part et s'est manifestee a l'APPLICATION, pas au
# devis. Elle l'est desormais, et le devis REFUSE plutot que de promettre un objet que la
# cible rejettera. — celui que les autres écosystèmes prennent comme amont.
#
# Même patron que `serveur_ops_site` : le rôle n'installe rien de plus, il **attribue une
# responsabilité** et la rend lisible dans le plan. `serveur_artefacts` fait le travail ;
# celui-ci déclare que ce cache-là sert au-delà de son propre écosystème.
#
# POURQUOI DEUX RÔLES PLUTÔT QU'UN DRAPEAU. Les flux se déclarent dans `meta/flux.yml`,
# qui est STATIQUE : un rôle unique aurait dû déclarer l'ingress inter-tenant pour TOUS
# les caches. Le devis obtenu le montrait — un maillage complet où chaque cache acceptait
# chaque autre, alors qu'un seul doit être joignable.
#
# QUI PEUT LE DÉCLARER : l'écosystème de l'hébergeur, celui qui exploite la fabric. Un
# tenant ordinaire qui le déclarerait s'offrirait à servir ses voisins sans y être invité.
#
# Le cache du site va DIRECTEMENT chez Debian : il est le bout de la chaîne.
serveur_cache_site_amont_attendu: ""

View file

@ -0,0 +1,12 @@
---
# 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 cache de paquets ne sert que des machines. Ce qui le protege n'est pas une
authentification mais la LISTE de ses pairs : seuls les tenants de ce site le
joignent, et la frontiere le fait respecter. Ce qu'il sert est public par nature.

View file

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

View file

@ -0,0 +1,46 @@
---
# Flux réseau du cache d'artefacts DU SITE. Voir docs/flux-conception.md.
#
# UN SEUL FLUX, et c'est tout son sens : accepter les caches des écosystèmes voisins.
# `serveur_artefacts` porte le reste (servir sa propre flotte, remplir depuis Debian).
#
# `voisins_site` désigne les autres tenants que CE SITE héberge. Ni `flotte` (mon
# écosystème), ni `externe` — qui signifie « depuis l'Internet » et aurait publié le cache
# au monde entier.
#
# LA RÈGLE APPARTIENT AU SITE : c'est son runner qui prépare le terrain, pas un tenant qui
# ouvrirait une porte chez un autre.
flux:
- sens: ingress
port: 3142
protocole: tcp
pair: voisins_site
chiffrement: clair
raison: >-
Servir les caches des écosystèmes voisins. Debian n'est ainsi téléchargé qu'une
fois pour tout le site, et le cache ne voit que des requêtes AGRÉGÉES — jamais
quelle machine installe quoi.
# LA RACINE DOIT POUVOIR REMPLIR. Ce cache n'a pas d'amont interne — c'est LUI l'amont
# de tous les autres. Sans flux sortant, la chaine entiere se termine sur un cache vide :
#
# VM du tenant -> cache du tenant -> cache du SITE -> (rien)
#
# Il manquait, et ca ne se serait vu qu'au premier `apt update` d'un ecosysteme neuf —
# c'est-a-dire au pire moment.
- sens: egress
port: 80
protocole: tcp
pair: externe
chiffrement: clair
raison: >-
Remplir le cache depuis les dépôts Debian amont. En clair parce que les dépôts apt
sont signés : l'intégrité vient de la signature, pas du transport.
- sens: egress
port: 443
protocole: tcp
pair: externe
chiffrement: tls-requis
raison: >-
Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache
les relaie pour que la flotte n'ait pas à sortir elle-même.

View file

@ -0,0 +1,19 @@
---
# CE RÔLE EN EXIGE UN AUTRE, ET C'EST À ANSIBLE DE LE SAVOIR.
#
# `serveur_cache_site` n'installe rien : il MARQUE un cache comme racine de la chaîne et
# vérifie qu'il n'a pas d'amont. Pour cela il lit les variables de `serveur_artefacts`,
# qui pose apt-cacher-ng.
#
# Or les valeurs par défaut d'un rôle ne sont en portée que DANS LE PLAY QUI L'INCLUT.
# Déclarer les deux rôles sur la machine ne suffisait donc pas : chaque groupe est appliqué
# par son propre playbook, et le marqueur ne voyait jamais `serveur_artefacts_port`.
#
# 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.
#
# Chez patient 0 les deux vivaient sur le même hôte et étaient appliqués ensemble : la
# dépendance existait déjà, elle n'était simplement écrite nulle part.
dependencies:
- role: serveur_artefacts

View file

@ -0,0 +1,42 @@
---
# CE RÔLE MARQUE UN CACHE ; IL N'EN INSTALLE PAS.
#
# Il présuppose `serveur_artefacts` sur le même hôte — c'est lui qui pose apt-cacher-ng —
# et lit ses variables. Chez patient 0 les deux vivaient sur `forge-01`, donc la
# dépendance ne se voyait pas. La première machine à ne porter que le marqueur l'a
# révélée, et le message était : « variable non définie : `serveur_artefacts_port` ».
#
# Ce message ne dit pas qu'il manque un RÔLE. Il envoie chercher une faute de frappe dans
# un fichier de variables, alors que la cause est une déclaration incomplète, un cran
# au-dessus. On le dit donc nous-mêmes, et en premier.
- name: Le rôle qui installe le cache est-il bien là ?
ansible.builtin.assert:
that:
- serveur_artefacts_port is defined
fail_msg: >-
`serveur_cache_site` marque un cache existant, il n'en installe aucun.
Cet hôte doit AUSSI porter `serveur_artefacts`, qui pose apt-cacher-ng et définit
`serveur_artefacts_port`. Ajouter les deux rôles à sa déclaration, dans cet ordre.
# UN CACHE DE SITE QUI SE CHAÎNERAIT SUR UN AUTRE SERAIT UNE BOUCLE.
#
# Il est le bout de la chaîne : les caches des tenants viennent à lui, et lui va chez
# Debian. Le laisser pointer sur un amont ferait tourner les paquets en rond entre deux
# caches, ou pire, hors du site sans que personne ne l'ait décidé.
- name: Exiger que le cache du site aille directement à l'amont
ansible.builtin.assert:
that:
- (serveur_artefacts_amont | default('')) == serveur_cache_site_amont_attendu
fail_msg: >-
Ce cache est déclaré cache du SITE : il doit aller directement chez Debian.
`serveur_artefacts_amont` doit rester vide — sinon les caches se chaînent en boucle.
# ÉCRIRE, PUIS RELIRE (D-68). Le rôle n'installe rien : sa seule prise sur le réel est de
# vérifier que le service qu'il désigne existe vraiment. Sans ça, il attribuerait une
# responsabilité à un port que personne n'écoute.
- name: Le cache écoute-t-il vraiment ?
ansible.builtin.wait_for:
host: "127.0.0.1"
port: "{{ serveur_artefacts_port }}"
timeout: 30
when: not ansible_check_mode

View file

@ -1,27 +1,69 @@
# serveur_collabora
Serveur d'édition de documents **Collabora Online (CODE)**, protocole WOPI, consommé
par Nextcloud (`richdocuments`). **Codifié depuis le spike du 2026-07-05** (Docker, prouvé).
**Collabora Online, en natif** — paquet `coolwsd`, systemd, derrière l'edge nginx. Aucun
conteneur.
## Ce que le rôle fait
- Installe Docker + lance le conteneur `collabora/code` (`restart: always`) ;
- publie sur `127.0.0.1:9980` (Nextcloud co-localisé) **ET** l'IP LAN `:9980` (l'edge) — gotcha spike : bind localhost seul ⇒ **502** depuis l'edge ;
- `aliasgroup1` = le domaine Nextcloud (`cloud.<domaine>`) — Collabora n'accepte le WOPI que de cet hôte ;
- `ssl.enable=false` + `ssl.termination=true` (TLS terminé à l'edge) ;
- attend la santé (`/hosting/capabilities` = 200).
## Pourquoi ce rôle a été réécrit
## Dépendances / exposition (à fournir)
- **Vhost edge WebSocket-aware** : `bureau.<domaine>` → ce nœud:9980 avec `map $http_upgrade`
+ en-têtes `Upgrade`/`Connection` + `proxy_read_timeout` long. **Le vhost générique ne suffit pas**
(l'édition live échoue sans WebSocket) ;
- FQDN `bureau.<domaine>` dans les SAN du cert edge ;
- port 9980 à firewaller (voir `meta/flux.yml` : ingress depuis l'edge seulement).
Il lançait `collabora/code` dans Docker. C'était la **seule exception conteneurisée** de
la flotte, et elle contredisait le principe fondateur : logiciel libre en natif, systemd +
paquets + nginx. Elle traînait en plus une dépendance entière — `community.docker` — qui
n'était même pas déclarée dans `requirements.yml`, donc absente de tout runner.
## Docker vs natif
Le spike a prouvé **Docker** → on codifie Docker. Le **CODE natif** (paquets Collabora,
`loolwsd`) serait plus « Set-OPS » (natif, pas de moteur Docker) mais exigerait **son propre
spike**. Décision à trancher.
Depuis la réécriture, **plus aucun rôle du dépôt n'a besoin de Docker**.
## Robustesse
- **Épingler `serveur_collabora_tag`** en prod (pas de `:latest` surprise) ;
- surveillance Icinga sur `/hosting/capabilities`.
## Ce qui a été éprouvé avant d'écrire
Doctrine du dépôt : *éprouver l'outil avant le rôle*. Sur une Debian 13.6 réelle, sans
rien installer :
```
apt-get install --simulate coolwsd -> résout jusqu'à « Conf coolwsd (26.04.3.1-1) »
libgcc1 (exigé par le paquet) -> fourni par libgcc-s1 sur trixie
le dépôt CODE-deb -> PLAT : Packages et Release à la racine, pas de dists/
```
Le paquet livre aussi `/etc/nginx/snippets/coolwsd.conf` et un profil AppArmor — deux
choses que cette flotte sait exploiter, contrairement à une image opaque.
## La configuration : par surcharge, pas par réécriture
Le `coolwsd.xml` livré fait **439 lignes richement commentées**. Le remplacer par un
gabarit maison obligerait à suivre son évolution à chaque version amont, et ferait perdre
les commentaires qui expliquent chaque réglage.
`coolwsd` accepte des surcharges `--o:<clé>=<valeur>`. Le rôle pose donc un **fragment
systemd** : la configuration de Set-OPS tient en un fichier lisible, celle du paquet reste
intacte, et `apt purge` rend la machine à son état d'origine.
## La console d'administration est FERMÉE
Elle expose un formulaire identifiant/mot de passe hors de tout SSO, avec un secret à
créer, faire tourner et surveiller — pour une fonction dont personne n'a besoin au
quotidien. Même raisonnement que `serveur_forgejo_connexion_locale`.
L'ouvrir (`serveur_collabora_admin_actif: true`) exige alors `vault_collabora_admin` en
voûte, et de l'ajouter au gabarit de l'instance.
## Variables principales
| Variable | Défaut | Rôle |
|---|---|---|
| `serveur_collabora_port` | `9980` | écoute, derrière l'edge |
| `serveur_collabora_server_name` | `bureau.<domaine>` | nom externe — vide, coolwsd le déduit mal derrière un mandataire |
| `serveur_collabora_domaine_nextcloud` | `cloud.<domaine>` | l'hôte WOPI autorisé |
| `serveur_collabora_ssl_termination` | `true` | TLS terminé à l'edge, comme le reste de la flotte |
| `serveur_collabora_admin_actif` | `false` | console d'administration |
## Ce que ce rôle vérifie
Il interroge `/hosting/capabilities` après démarrage. Un service « active » qui ne répond
pas là serait découvert par Nextcloud **devant un usager**, à la première ouverture de
document.
## Ce qui n'est pas encore prouvé
L'**installabilité** est établie par simulation sur Debian 13.6. Le **fonctionnement** ne
l'est pas : aucun écosystème de la flotte ne fait tourner Collabora aujourd'hui. La
première mise en service devra vérifier l'édition d'un document de bout en bout, depuis
Nextcloud.

View file

@ -1,22 +1,87 @@
---
# =========================================================================
# serveur_collabora — serveur d'édition de documents (Collabora Online / CODE),
# protocole WOPI, consommé par Nextcloud (richdocuments). Codifié depuis le spike
# du 2026-07-05 (Docker, prouvé). Le CODE natif (paquets Collabora) reste une
# option future — nécessiterait son propre spike.
# =========================================================================
serveur_collabora_conteneur: "collabora"
serveur_collabora_image: "collabora/code"
serveur_collabora_tag: "latest" # À ÉPINGLER en prod (robustesse / pas d'upgrade surprise)
# COLLABORA ONLINE, EN NATIF — plus de conteneur.
#
# Ce rôle lançait `collabora/code` dans Docker, ce qui contredisait le principe fondateur
# du dépôt : logiciel libre **en natif**, systemd + paquets + nginx, zéro conteneur. C'était
# la seule exception de la flotte, et elle traînait une dépendance entière
# (`community.docker`) qui n'était même pas déclarée dans `requirements.yml`.
#
# CE QUI A ÉTÉ ÉPROUVÉ AVANT D'ÉCRIRE CE RÔLE (2026-08-24), sur une Debian 13.6 réelle,
# sans rien installer :
#
# apt-get install --simulate coolwsd -> résout jusqu'à « Conf coolwsd (26.04.3.1-1) »
# libgcc1 (exigé) -> fourni par libgcc-s1 sur trixie
# le dépôt CODE-deb est PLAT -> Packages et Release à la racine, pas de dists/
#
# Le paquet livre aussi `/etc/nginx/snippets/coolwsd.conf` et un profil AppArmor — deux
# choses que cette flotte sait déjà exploiter, contrairement à une image opaque.
serveur_collabora_paquets:
- coolwsd
serveur_collabora_service: "coolwsd"
serveur_collabora_port: 9980
# L'edge (autre hôte) doit joindre le conteneur → publier sur localhost ET l'IP LAN
# (gotcha spike : bind 127.0.0.1 seul => 502 depuis l'edge). Firewaller via le registre des flux.
serveur_collabora_lan_ip: "{{ ansible_default_ipv4.address | default(ansible_host) }}"
# Domaine Nextcloud autorisé (aliasgroup) — Collabora n'accepte le WOPI que de cet hôte.
# --- LE DÉPÔT AMONT ----------------------------------------------------------
#
# Dépôt PLAT : `Suites: ./` et pas de composant. Une source classique (`deb … stable
# main`) ne le trouverait pas.
serveur_collabora_depot_uri: "https://www.collaboraoffice.com/repos/CollaboraOnline/CODE-deb"
serveur_collabora_depot_cle_url: "https://www.collaboraoffice.com/downloads/gpg/collaboraonline-release-keyring.gpg"
serveur_collabora_depot_cle_fichier: "/etc/apt/keyrings/collaboraonline.gpg"
# --- CONFIGURATION : PAR SURCHARGE, PAS PAR RÉÉCRITURE -----------------------
#
# Le `coolwsd.xml` livré fait 439 lignes richement commentées. Le remplacer par un gabarit
# maison, ce serait s'engager à suivre son évolution à chaque version amont — et perdre
# les commentaires qui expliquent chaque réglage.
#
# `coolwsd` accepte des surcharges `--o:<clé>=<valeur>` sur sa ligne de commande. On pose
# donc un fragment systemd : la configuration de Set-OPS tient en un seul fichier lisible,
# le fichier du paquet reste intact, et `apt purge` rend la machine à son état d'origine.
serveur_collabora_fragment: "/etc/systemd/system/coolwsd.service.d/setops.conf"
# TLS TERMINÉ À L'EDGE, comme tout le reste de la flotte : `serveur_nginx` présente le
# certificat step-ca, Collabora écoute en clair derrière lui.
serveur_collabora_ssl_enable: false
serveur_collabora_ssl_termination: true
# Nom externe, tel que le navigateur l'atteint. Vide = déduit de la requête, ce qui échoue
# derrière un mandataire inverse — d'où la valeur explicite.
serveur_collabora_server_name: "bureau.{{ domaine_interne }}"
# Hôtes WOPI autorisés — qui a le droit de faire éditer un document.
#
# Les défauts du paquet couvrent déjà les plages privées (10/8, 172.16/12, 192.168/16,
# loopback), donc l'adressage dérivé de Set-OPS. On ajoute le FQDN de Nextcloud pour que
# l'autorisation soit LISIBLE dans le plan plutôt que déduite d'un masque.
serveur_collabora_domaine_nextcloud: "cloud.{{ domaine_interne }}"
serveur_collabora_aliasgroup: "https://{{ serveur_collabora_domaine_nextcloud | regex_escape }}:443"
# TLS terminé à l'edge : Collabora sert en HTTP derrière le proxy.
serveur_collabora_extra_params: "--o:ssl.enable=false --o:ssl.termination=true"
# --- CONSOLE D'ADMINISTRATION : FERMÉE PAR DÉFAUT ----------------------------
#
# Elle sert à surveiller les sessions et les documents ouverts. Personne n'en a besoin au
# quotidien, et elle expose un formulaire identifiant/mot de passe de plus — hors de tout
# SSO, avec un secret à créer, à faire tourner et à surveiller.
#
# Même raisonnement que `serveur_forgejo_connexion_locale` : une porte ouverte en
# permanence contourne la politique centrale et ne se révoque nulle part. On la ferme, et
# l'on garde l'accès par SSH à l'hôte — le mécanisme de secours retenu par ce dépôt.
#
# L'ouvrir exige alors un secret en voûte, et de l'ajouter au gabarit de l'instance.
# LE MOT DE PASSE N'EST PAS TIRE D'UNE CLE DE VOUTE PAR DEFAUT, ET C'EST VOULU.
#
# `scripts/voute.py` exige de la voute TOUTE reference `vault_*` trouvee dans un role
# actif — c'est ce qui garantit qu'aucun secret n'est oublie. Mais une porte FERMEE ne
# doit pas reclamer de clef : nommer ici `vault_collabora_admin` obligerait chaque
# ecosysteme installant Collabora a inventer, chiffrer et faire tourner un secret pour
# une console que personne n'ouvre.
#
# Qui veut la console la declare chez lui :
#
# # group_vars/serveur_collabora.yml de l'instance
# serveur_collabora_admin_actif: true
# serveur_collabora_admin_mot_de_passe: "{{ vault_collabora_admin }}"
#
# et ajoute `vault_collabora_admin` a son gabarit de voute — ou P18 le lui rappellera.
serveur_collabora_admin_actif: false
serveur_collabora_admin_utilisateur: "collabora-admin"
serveur_collabora_admin_mot_de_passe: ""

View file

@ -0,0 +1,7 @@
---
- name: Redemarrer Collabora
when: not ansible_check_mode
ansible.builtin.systemd:
name: "{{ serveur_collabora_service }}"
state: restarted
daemon_reload: true

View file

@ -1,39 +1,109 @@
---
- name: Installer Docker (moteur du conteneur Collabora)
# LA CONDITION EST ECRITE D'UN SEUL TENANT, ET C'EST DELIBERE.
#
# `scripts/verifier_intrants.py` lit les `assert` de la forme `var | length > 0` comme un
# CONTRAT : l'instance doit fournir cette valeur. Et il ignore volontairement les `when:`
# — un intrant exige sous condition reste un intrant exige.
#
# Ecrite en deux morceaux (`that: var | length > 0` + `when: actif`), cette garde
# promettait donc un secret pour une console FERMEE, et P32 le refusait a juste titre.
# Ecrite en une implication, elle dit ce qu'elle veut vraiment dire : SI la console est
# ouverte, ALORS un mot de passe. Ne pas la « simplifier » en la recoupant en deux.
- name: Exiger un secret si — et seulement si — la console d'administration est ouverte
ansible.builtin.assert:
that:
- not (serveur_collabora_admin_actif | bool) or (serveur_collabora_admin_mot_de_passe | length > 0)
fail_msg: >-
La console d'administration est ouverte mais son mot de passe est vide.
Le renseigner depuis la voûte de l'instance, ou refermer la console
(`serveur_collabora_admin_actif: false`).
- name: Assurer le repertoire des trousseaux apt
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
owner: root
group: root
mode: "0755"
# Récupérée UNE FOIS, comme la clé Smallstep : une plateforme souveraine ne recontacte pas
# un serveur étranger à chaque déploiement pour une clé déjà installée. Une rotation amont
# ne passe pas inaperçue pour autant — `apt` refuse alors le dépôt, bruyamment.
- name: Cette ressource est-elle deja recuperee ? (cle Collabora)
ansible.builtin.stat:
path: "{{ serveur_collabora_depot_cle_fichier }}"
register: serveur_collabora_cle_presente
- name: Telecharger la cle de signature Collabora
when: not serveur_collabora_cle_presente.stat.exists
ansible.builtin.get_url:
url: "{{ serveur_collabora_depot_cle_url }}"
dest: "{{ serveur_collabora_depot_cle_fichier }}"
owner: root
group: root
mode: "0644"
timeout: 30
register: serveur_collabora_cle
retries: 5
delay: 6
until: serveur_collabora_cle is succeeded
# DEPOT PLAT : `Suites: ./`, aucun composant. Une source classique ne le trouverait pas.
- name: Ajouter le depot apt Collabora
ansible.builtin.template:
src: collabora.sources.j2
dest: /etc/apt/sources.list.d/collabora.sources
owner: root
group: root
mode: "0644"
- name: Installer Collabora Online
when: not ansible_check_mode # dry-run : le depot n'est pas reellement ajoute
ansible.builtin.apt:
name: docker.io
name: "{{ serveur_collabora_paquets }}"
state: present
update_cache: true
cache_valid_time: 3600
- name: Activer et demarrer Docker
- name: Assurer le repertoire du fragment systemd
ansible.builtin.file:
path: "{{ serveur_collabora_fragment | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
# LA CONFIGURATION DE SET-OPS TIENT EN UN FICHIER, et le coolwsd.xml du paquet reste
# intact. Voir defaults/main.yml pour le raisonnement.
- name: Deployer la surcharge Set-OPS
ansible.builtin.template:
src: setops.conf.j2
dest: "{{ serveur_collabora_fragment }}"
owner: root
group: root
mode: "0640"
notify: Redemarrer Collabora
- name: Appliquer les redemarrages avant de verifier
ansible.builtin.meta: flush_handlers
- name: Activer et demarrer Collabora
when: not ansible_check_mode
ansible.builtin.systemd:
name: docker
name: "{{ serveur_collabora_service }}"
enabled: true
state: started
when: not ansible_check_mode
daemon_reload: true
- name: Lancer le conteneur Collabora CODE (WOPI, publie sur localhost + IP LAN)
community.docker.docker_container:
name: "{{ serveur_collabora_conteneur }}"
image: "{{ serveur_collabora_image }}:{{ serveur_collabora_tag }}"
state: started
pull: true
restart_policy: always
ports:
- "127.0.0.1:{{ serveur_collabora_port }}:9980"
- "{{ serveur_collabora_lan_ip }}:{{ serveur_collabora_port }}:9980"
env:
aliasgroup1: "{{ serveur_collabora_aliasgroup }}"
extra_params: "{{ serveur_collabora_extra_params }}"
# ECRIRE, PUIS RELIRE (D-68). Un service « active » qui ne repond pas a `capabilities`
# est un vert sur un perimetre vide : Nextcloud le decouvrirait a la premiere ouverture
# de document, c'est-a-dire devant un usager.
- name: Collabora repond-il vraiment ?
when: not ansible_check_mode
- name: Attendre que Collabora reponde (capabilities)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ serveur_collabora_port }}/hosting/capabilities"
status_code: 200
register: serveur_collabora_sante
until: serveur_collabora_sante.status == 200
status_code: [200]
timeout: 20
register: serveur_collabora_capacites
retries: 12
delay: 5
when: not ansible_check_mode
until: serveur_collabora_capacites is succeeded

View file

@ -0,0 +1,8 @@
# GÉNÉRÉ par Set-OPS (rôle serveur_collabora). NE PAS éditer à la main.
#
# Dépôt PLAT : `Suites: ./` et aucun composant. Constaté le 2026-08-24 — `Packages` et
# `Release` vivent à la racine, il n'y a pas de `dists/`.
Types: deb
URIs: {{ serveur_collabora_depot_uri }}
Suites: ./
Signed-By: {{ serveur_collabora_depot_cle_fichier }}

View file

@ -0,0 +1,17 @@
# GÉNÉRÉ par Set-OPS (rôle serveur_collabora). NE PAS éditer à la main.
#
# Surcharge de l'unité du paquet. Le `coolwsd.xml` livré (439 lignes commentées) reste
# intact : le remplacer par un gabarit maison obligerait à suivre son évolution à chaque
# version amont, et ferait perdre les commentaires qui expliquent chaque réglage.
[Service]
ExecStart=
ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate \
--o:child_root_path=/opt/cool/child-roots \
--o:file_server_root_path=/usr/share/coolwsd \
--o:ssl.enable={{ serveur_collabora_ssl_enable | bool | lower }} \
--o:ssl.termination={{ serveur_collabora_ssl_termination | bool | lower }} \
--o:server_name={{ serveur_collabora_server_name }} \
--o:storage.wopi.alias_group[0].host=https://{{ serveur_collabora_domaine_nextcloud }}:443 \
--o:admin_console.enable={{ serveur_collabora_admin_actif | bool | lower }}{% if serveur_collabora_admin_actif | bool %} \
--o:admin_console.username={{ serveur_collabora_admin_utilisateur }} \
--o:admin_console.password={{ serveur_collabora_admin_mot_de_passe }}{% endif %}

View file

@ -25,6 +25,24 @@ serveur_forgejo_branding: false
serveur_forgejo_http_addr: "0.0.0.0"
serveur_forgejo_http_port: 3000
# --- SERVIR TLS SOI-MEME, QUAND IL N'Y A PAS D'EDGE DEVANT (2026-08-25) --------
#
# Le patron habituel est un nginx d'edge qui termine le TLS et parle en clair a la forge
# sur la boucle locale. Le gabarit posait donc `ROOT_URL = https://…` sans jamais
# configurer de certificat : c'etait vrai PARCE QUE quelqu'un d'autre s'en chargeait.
#
# La forge du SITE n'a pas d'edge devant elle — le site n'en a pas — et elle porte LE
# GENOME. Le code qui fabrique tous les ecosystemes voyageait donc en clair sur le
# reseau, alors que l'AC du site tourne a trois adresses de la.
#
# `false` par defaut : chez un tenant, rien ne change, l'edge garde son role. C'est
# l'ecosysteme sans edge qui doit le demander.
serveur_forgejo_tls: false
# Le certificat de la MACHINE, pose par `client_pki` — pas un cert propre a la forge.
# Une machine, une identite : c'est ce que l'AC a signe, avec ses SAN.
serveur_forgejo_tls_cert: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.crt"
serveur_forgejo_tls_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key"
# Base de donnees : resolue depuis le registre par le groupe consommateur.
serveur_forgejo_groupe: "serveur_forgejo"
serveur_forgejo_db_host: "" # dérivé (FQDN) par resoudre_base au déploiement

View file

@ -1,12 +1,42 @@
---
# Flux réseau de Forgejo (forge Git). Voir docs/flux-conception.md.
flux:
# LE PORT DE LA FORGE N'EST PAS LE MÊME PARTOUT (2026-08-25).
#
# `3000` est le port qu'elle écoute DERRIÈRE un edge, en clair : le nginx termine le TLS
# et lui parle sur la boucle locale. C'était vrai de toutes les forges tant qu'il y avait
# toujours un edge devant.
#
# La forge du SITE n'en a pas — le site n'a pas d'edge — et sert son propre TLS sur le
# port du schéma, `443`. Écrire `3000` en dur ici serait donc devenu faux pour elle,
# sans que rien ne le signale : ce flux ne traverse pas la frontière, donc aucun devis
# ne s'en plaint. Une déclaration fausse qui ne gêne personne est une déclaration qui
# dérive jusqu'au jour où quelqu'un s'y fie.
#
# `derive` dit la vérité : le port vient de `serveur_forgejo_http_port`, et
# `verifier_ports` sait qu'il n'y a pas d'écoute fixe à comparer.
# DEUX ENTREES, PARCE QUE CE SONT DEUX SITUATIONS — pas une ligne qui dirait « ça
# dépend ». Le vocabulaire des flux n'a pas de mot pour l'indécision, et c'est tant
# mieux : un flux se lit pour savoir ce qui circule et comment. Une valeur ambiguë
# rendrait l'audit impossible sans aller lire le déploiement.
- sens: ingress
port: 3000
protocole: tcp
pair: edge
chiffrement: clair
raison: "Interface web + Git HTTP servis via l'edge (TLS terminé à l'edge)."
raison: >-
Interface web + Git HTTP derrière un edge : le nginx termine le TLS et parle en
clair à la forge. C'est le cas de tout tenant.
- sens: ingress
port: derive
protocole: tcp
pair: flotte
chiffrement: tls
raison: >-
Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du
schéma (443), avec le certificat de la machine. `derive` parce que le port vient de
`serveur_forgejo_http_port` : écrire 3000 en dur ici serait faux pour elle, et rien
ne le signalerait puisque ce flux ne traverse pas la frontière.
- sens: egress
port: 5432
protocole: tcp

View file

@ -2,11 +2,23 @@
# L'URL PUBLIQUE de l'IdP se derive de l'exposition declaree au plan, elle ne se
# fabrique pas : `keycloak.<domaine>` n'est publie nulle part (voir resoudre_idp).
#
# ON NE RESOUT PAS UN FOURNISSEUR QU'ON N'UTILISERA PAS (2026-08-25).
#
# Cette resolution partait INCONDITIONNELLEMENT, y compris quand OIDC est desactive.
# `resoudre_idp` lit les registres du plan (`applications.yml`, `domaines.yml`) et exige
# que l'IdP y declare une exposition. Sur une forge SANS Keycloak — celle du site, qui
# porte le genome et n'a pas d'annuaire — il n'y a ni Keycloak ni plan : le role echouait
# sur `'setops_plan_dir' is undefined`, un message qui ne dit rien de la vraie cause.
#
# `serveur_forgejo_oidc_actif` derive deja de la presence du groupe `serveur_keycloak`.
# Il suffisait de s'en servir ici aussi : la resolution suit l'usage.
- name: Resoudre le fournisseur d'identite (role partage)
ansible.builtin.include_role:
name: resoudre_idp
vars:
resoudre_idp_realm: "{{ serveur_forgejo_oidc_realm }}"
when: serveur_forgejo_oidc_actif | bool
- name: Refuser de fermer la connexion locale sur un Forgejo anterieur a la v10
# `ENABLE_INTERNAL_SIGNIN` a ete ajoute en v10 (suivi amont, issue 7476). Sur une
# version anterieure il est ignore EN SILENCE : le role croirait avoir ferme la porte
@ -311,6 +323,34 @@
GITEA_WORK_DIR: "{{ serveur_forgejo_data }}"
no_log: true
# ON VERIFIE L'ETAT, ON NE FAIT PAS CONFIANCE AU DRAPEAU (2026-08-25).
#
# La creation passe deja `--must-change-password=false` — corrige le 2026-08-23, sur la
# premiere forge de patient 0. **Forgejo 16 l'a ignore** : le compte de la forge du SITE
# est ne avec le drapeau POSE malgre l'option.
#
# La panne qui en resulte n'accuse rien de juste : toute requete d'API rend **403**, pas
# 401. Le jeton est valide, l'identite reconnue, la requete rejetee quand meme — on
# cherche un scope, un droit, une politique, et la cause est un drapeau sur le compte.
#
# D'ou cette tache : elle ne demande pas a la creation de bien se comporter, elle CONSTATE
# l'etat voulu et le retablit. Idempotente, et sans effet quand la creation a tenu parole.
- name: Le compte d'administration ne doit exiger aucun changement de mot de passe
ansible.builtin.command:
cmd: >-
{{ serveur_forgejo_binaire }} admin user must-change-password --unset
{{ serveur_forgejo_admin_user }}
--config {{ serveur_forgejo_config }}
become: true
become_user: "{{ serveur_forgejo_utilisateur }}"
environment:
FORGEJO_WORK_DIR: "{{ serveur_forgejo_data }}"
register: forgejo_mcp
changed_when: "'Updated 1 users' in (forgejo_mcp.stdout | default(''))"
failed_when:
- forgejo_mcp.rc != 0
- "'not found' not in (forgejo_mcp.stderr | default(''))"
- name: Configurer la source OAuth2 SSO (Keycloak, idempotent)
ansible.builtin.shell:
executable: /bin/bash

View file

@ -6,9 +6,19 @@ WORK_PATH = {{ serveur_forgejo_data }}
[server]
DOMAIN = {{ serveur_forgejo_hostname }}
ROOT_URL = https://{{ serveur_forgejo_hostname }}/
{# ROOT_URL EST GRAVEE DANS LES URL DE CLONAGE, les webhooks et les courriels. Un port
qui n'est pas celui du schema y reste pour toujours : chaque ecosysteme descendant
porterait `:3000` dans son `origin`. On ne l'ecrit donc que s'il est vraiment atypique. #}
ROOT_URL = https://{{ serveur_forgejo_hostname }}{{ (':' ~ serveur_forgejo_http_port | string) if (serveur_forgejo_tls and serveur_forgejo_http_port | int != 443) else '' }}/
HTTP_ADDR = {{ serveur_forgejo_http_addr }}
HTTP_PORT = {{ serveur_forgejo_http_port }}
{% if serveur_forgejo_tls %}
; TLS SERVI PAR LA FORGE ELLE-MEME : il n'y a pas d'edge devant elle. Le certificat est
; celui de la MACHINE, pose et renouvele par `client_pki` depuis l'AC interne.
PROTOCOL = https
CERT_FILE = {{ serveur_forgejo_tls_cert }}
KEY_FILE = {{ serveur_forgejo_tls_cle }}
{% endif %}
SSH_DOMAIN = {{ serveur_forgejo_hostname }}
[database]

View file

@ -10,6 +10,28 @@ User={{ serveur_forgejo_utilisateur }}
Group={{ serveur_forgejo_utilisateur }}
WorkingDirectory={{ serveur_forgejo_data }}
ExecStart={{ serveur_forgejo_binaire }} web --config {{ serveur_forgejo_config }}
; RELOAD GRACIEUX PAR SIGHUP (2026-08-25).
;
; Sans `ExecReload`, systemd repond « Job type reload is not applicable for unit
; forgejo.service » — et le handler qui recharge les consommateurs apres un
; renouvellement de certificat ECHOUE. Le cert serait renouvele sur disque et servi
; perime en memoire : la cicatrice est deja dans ce depot, on ne la refait pas.
;
; Forgejo traite SIGHUP comme un redemarrage gracieux : il reprend ses ecoutes, donc
; relit le certificat, sans couper les connexions en cours.
{% if serveur_forgejo_http_port | int < 1024 %}
; SE LIER A UN PORT PRIVILEGIE SANS ETRE ROOT.
;
; Forgejo tourne en `{{ serveur_forgejo_utilisateur }}` des le demarrage — il ne peut donc
; pas ouvrir un port < 1024 sans y etre autorise. `CAP_NET_BIND_SERVICE` accorde
; exactement ce droit, et rien d'autre : pas de root, pas de setuid.
;
; Sans elle, le service echoue au `listen` avec « permission denied » sur le PORT, un
; message qui ressemble a s'y meprendre a celui d'un fichier illisible.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
{% endif %}
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
Environment=USER={{ serveur_forgejo_utilisateur }}

View file

@ -44,16 +44,62 @@ serveur_ops_ansible: "ansible-core>=2.18,<2.19"
serveur_ops_forge_hote: >-
{{ (groups['serveur_forgejo'] | default([]) | first | default('')) }}
serveur_ops_forge_url: >-
https://forge.{{ domaine_interne }}
{{ serveur_ops_forge_amont if (serveur_ops_forge_externe | bool)
else 'https://forge.' ~ domaine_interne }}
serveur_ops_forge_organisation: "genome"
# --- FILIATION, MUTUALISATION, ÉMANCIPATION ----------------------------------
#
# Un écosystème naît FILS : il n'héberge pas encore tout ce dont il a besoin, et emprunte
# à son hôte. Il peut rester ainsi — la MUTUALISATION est un état durable et choisi, pas
# une infirmité — ou devenir AUTOPORTANT le jour où il le décide.
#
# La forge est le premier service concerné. `false` : le génome est lu sur la forge de
# CET écosystème (état émancipé). `true` : il est lu chez l'hôte (état natal, ou
# mutualisation assumée) — et l'écosystème n'a alors pas besoin d'héberger de forge.
#
# CE N'EST PAS UN RABAIS. Un modèle sans forge n'est pas un modèle amputé : c'est un
# écosystème au premier âge, dont le runner lit le génome chez son parent. L'émancipation
# consiste à lever ce drapeau, déployer sa propre forge, et PROUVER que le lien est coupé.
#
# Cette variable était citée par `docs/dependances-groupes.yml` et par le README sans
# exister nulle part (constaté le 2026-08-24) : une exemption qui ne pouvait jamais
# s'appliquer, donc un écosystème sans forge que le registre refusait quand même.
serveur_ops_forge_externe: false
serveur_ops_forge_amont: ""
# --- LA CONFIANCE ENVERS UNE FORGE D'UN AUTRE TENANT -------------------------
#
# Lire le genome chez un voisin suppose de faire confiance a SON autorite — or chaque
# ecosysteme a la sienne, et `client_pki` n'etablit la confiance que dans celle du tenant.
#
# ON NE POSE PAS CETTE RACINE DANS LE MAGASIN SYSTEME. L'y mettre ferait confiance a cette
# AC pour TOUT ce que la machine contacte, alors qu'on ne lui demande qu'une chose : servir
# un depot git. `git config http.<url>.sslCAInfo` limite la confiance a CETTE url — une
# porte, pas un trousseau.
#
# Chemin d'un certificat racine PUBLIC, sur le controleur. Vide = rien a poser (forge du
# meme ecosysteme, ou AC publiquement reconnue).
serveur_ops_forge_amont_ac: ""
serveur_ops_forge_amont_ac_depot: "{{ serveur_ops_racine }}/.ac-amont.crt"
# Les dépôts du génome, et où ils atterrissent. Les noms sont ceux que la forge porte
# (minuscules) ; les destinations reprennent la disposition du poste du mainteneur, où
# le moteur et les instances sont des dossiers frères — c'est cette fraternité que
# `scripts/instances.py` découvre.
#
# AUCUN DÉFAUT NE NOMME UN ÉCOSYSTÈME PARTICULIER (2026-08-24).
#
# La première version listait ici `ops-patient0`. Tout écosystème qui aurait déployé un
# poste sans déclarer ses propres dépôts aurait donc cloné le génome de **patient 0** —
# silencieusement, et en croyant piloter le sien. C'est la faute que ce dépôt combat sous
# tous ses déguisements : un défaut plausible qui rend un résultat faux sans rien dire.
#
# Le moteur est le seul défaut légitime : il est le même pour tous. Le dépôt d'instance,
# lui, est propre à l'écosystème et DOIT être déclaré — la tâche d'exigence refuse de
# poursuivre sans lui.
serveur_ops_depots:
- { depot: "set-ops-public", dest: "Set-OPS-public", role: "moteur" }
- { depot: "ops-patient0", dest: "OPS-Patient0", role: "instance" }
# --- LES DEUX SYMLINKS (D-80) ------------------------------------------------
#
@ -68,7 +114,10 @@ serveur_ops_depots:
# CE QUE LE POSTE N'AURA PAS POUR AUTANT : `underlay.vault.yml`, qui porte les secrets du
# monde physique. Il est hors dépôt (gitignore), donc absent du génome. Le poste lit la
# CARTE de la fabric, pas ses clés.
serveur_ops_instance: "OPS-Patient0"
# Le dossier de l'instance pilotée, cible du lien `instance`. Sans valeur, le rôle
# refuse : un poste qui ne sait pas quel tenant il pilote n'a rien à piloter.
serveur_ops_instance: ""
# Vide = le poste ne pose pas de lien `underlay.yml` (il configure, il n'engendre pas).
serveur_ops_underlay: ""
@ -90,6 +139,17 @@ serveur_ops_cle_generer: true
# devant son propre tiers.
#
# Effet de bord recherché : le poste devient déployable **hors ligne**.
# LE CACHE SUIT LE CONTENU DE `requirements.yml`, PAS SON EXISTENCE (2026-08-24).
#
# La premiere version gardait le telechargement par `creates: <cache>/requirements.yml` :
# une collection AJOUTEE au fichier n'aurait jamais ete recuperee, puisque le temoin
# existait deja. Le runner serait reste en retard sur le moteur, en silence -- exactement
# le defaut qui a fait qu'`ansible.posix` manquait sans que rien ne le dise.
#
# L'empreinte du fichier entre dans le chemin : changer une ligne de `requirements.yml`
# cree un cache neuf, donc un telechargement. Les anciens caches restent (ils ne genent
# pas) et documentent ce que les versions precedentes exigeaient.
serveur_ops_cache_collections: >-
{{ (setops_cache_artefacts | default(lookup('env', 'HOME') + '/.cache/setops')) }}/collections
{{ (setops_cache_artefacts | default(lookup('env', 'HOME') + '/.cache/setops')) }}/collections-{{
lookup('file', serveur_ops_requirements_controleur) | hash('sha1') | truncate(12, true, '') }}
serveur_ops_requirements_controleur: "{{ playbook_dir }}/../../requirements.yml"

View file

@ -25,3 +25,15 @@ flux:
pair: externe
chiffrement: tls-requis
raison: "API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant."
# LIRE LE GENOME CHEZ UN VOISIN. La forge du genome se mutualise par SITE : le moteur,
# les modeles et la fabric sont le meme contenu pour tous les tenants. Seul le plan
# `OPS-xxx` differe.
#
# La forge DE TRAVAIL d'un ecosysteme -- celle qui sert ses gens -- reste un service du
# tenant, et n'a rien a voir avec ce flux.
- sens: egress
port: 443
protocole: tcp
pair: voisins_site
chiffrement: tls-requis
raison: "Cloner le genome depuis la forge du site, quand cet ecosysteme n'heberge pas la sienne."

View file

@ -1,12 +1,23 @@
---
# UN POSTE QUI NE SAIT PAS QUEL ÉCOSYSTÈME IL PILOTE N'A RIEN À PILOTER.
#
# On exige explicitement un dépôt d'instance, plutôt que de retomber sur un défaut : le
# défaut nommait patient 0, et un écosystème distrait aurait cloné le génome d'un autre
# sans qu'aucune erreur ne le signale.
- name: Exiger les intrants du poste d'exploitation
ansible.builtin.assert:
that:
- domaine_interne | default('') | length > 0
- serveur_ops_depots | length > 0
- (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | list | length) == 1
- (serveur_ops_depots | selectattr('role', 'eq', 'instance') | list | length) >= 1
- serveur_ops_instance | default('') | length > 0
- not (serveur_ops_forge_externe | bool) or (serveur_ops_forge_amont | length > 0)
fail_msg: >-
serveur_ops exige `domaine_interne` et au moins un dépôt dans
`serveur_ops_depots` (le moteur au minimum).
serveur_ops exige `domaine_interne`, exactement un dépôt `role: moteur`, au moins
un dépôt `role: instance`, et `serveur_ops_instance` (le dossier que le lien
`instance` doit désigner). Et si `serveur_ops_forge_externe` est levé — écosystème
au premier âge, qui lit son génome chez son hôte — `serveur_ops_forge_amont` doit
nommer cette forge. Déclarer le tout dans group_vars/serveur_ops.yml.
- name: Installer l'outillage du poste
ansible.builtin.apt:
@ -57,14 +68,42 @@
delay: 6
until: serveur_ops_pip is succeeded
# LA CONFIANCE, AVANT LE CLONE — et limitee a une seule url.
#
# Voir defaults/main.yml : on ne pose pas cette racine dans le magasin systeme. `git
# config http.<url>.sslCAInfo` ne l'applique qu'aux echanges avec CETTE forge.
- name: Deposer la racine de la forge amont
ansible.builtin.copy:
src: "{{ serveur_ops_forge_amont_ac }}"
dest: "{{ serveur_ops_forge_amont_ac_depot }}"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
when:
- serveur_ops_forge_amont_ac | length > 0
- not ansible_check_mode
- name: Limiter la confiance a la forge amont, et a elle seule
community.general.git_config:
name: "http.{{ serveur_ops_forge_url }}/.sslCAInfo"
scope: global
value: "{{ serveur_ops_forge_amont_ac_depot }}"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when:
- serveur_ops_forge_amont_ac | length > 0
- not ansible_check_mode
# --- LE GÉNOME, DEPUIS SA PROPRE FORGE ---------------------------------------
#
# Cloné en anonyme : les dépôts du génome sont lisibles sur la forge de l'écosystème, et
# le poste n'a donc AUCUN justificatif à détenir pour se reconstruire. C'est délibéré —
# un secret de moins sur la machine qui en concentre déjà beaucoup.
#
# La confiance TLS vient de `client_pki` (racine step-ca dans le magasin système). Sans
# lui, `git clone` refuse le certificat de la forge — et c'est bien qu'il refuse.
# La confiance TLS vient de `client_pki` quand la forge est celle de CET écosystème
# (racine step-ca dans le magasin système). Quand elle est celle d'un voisin, elle vient
# de la tâche ci-dessus — limitée à cette seule url. Sans l'une ou l'autre, `git clone`
# refuse le certificat, et c'est bien qu'il refuse.
- name: Cloner le génome depuis la forge de l'écosystème
ansible.builtin.git:
repo: "{{ serveur_ops_forge_url }}/{{ serveur_ops_forge_organisation }}/{{ item.depot }}.git"
@ -128,6 +167,7 @@
when: not ansible_check_mode
- name: Deposer les collections sur le poste
register: serveur_ops_depot_collections
ansible.builtin.copy:
src: "{{ serveur_ops_cache_collections }}/"
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/"
@ -148,10 +188,21 @@
-r requirements.yml
-p {{ serveur_ops_racine }}/.ansible/collections
chdir: "{{ serveur_ops_racine }}/.collections-hors-ligne"
creates: "{{ serveur_ops_racine }}/.ansible/collections/ansible_collections/community/general"
# LE GARDE-FOU SUIVAIT LA MAUVAISE CHOSE (2026-08-24). Il etait `creates: …/community/general` :
# une collection AJOUTEE a requirements.yml n'aurait jamais ete installee, puisque le
# repertoire temoin existait deja. Le runner serait reste en retard sur le moteur, en
# silence. On suit desormais le DEPOT des archives : si les collections deposees
# changent, on reinstalle.
changed_when: true
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode
# DEUX `when` SUR UNE MEME TACHE, C'EST UN SEUL — LE DERNIER (2026-08-24). En ajoutant
# la condition sur le depot, j'ai laisse celle du check-mode juste en dessous : YAML
# garde la derniere cle et jette l'autre, en silence. `ansible-lint` l'a vu ; personne
# d'autre ne l'aurait vu. Les conditions vont donc dans UNE liste.
when:
- serveur_ops_depot_collections is changed
- not ansible_check_mode
# LES DEUX SYMLINKS (D-80). `instance` dit QUEL tenant on pilote. Le poste en pose un par
# défaut ; l'exploitant le bascule comme sur le poste du mainteneur.

View file

@ -0,0 +1,62 @@
# serveur_ops_site
**Le runner de SITE** : celui qui matérialise. Il crée des VM sur la fabric, et rien d'autre.
## Pourquoi ce rôle existe séparément
Le travail d'un runner se divise en trois, et la ligne de partage est celle des voûtes :
| | voûte requise | portée | rôle |
|---|---|---|---|
| calculer — plan → inventaire | aucune | tenant | `serveur_ops` |
| configurer — rôles sur ses machines | **tenant** | tenant | `serveur_ops` |
| matérialiser — créer/détruire des VM | **SITE** | fabric | **ce rôle** |
**Un runner par tenant qui matérialiserait** mettrait la voûte du SITE en N exemplaires —
le secret le plus dangereux du système, recopié autant de fois qu'il y a de locataires.
**Un runner unique qui ferait tout** devrait entrer en SSH chez tous les tenants, donc
traverser le default-deny inter-tenant. Et il rendrait l'**émancipation impossible** : un
écosystème dont le runner appartient à l'hébergeur ne peut pas se rebâtir sans lui.
Deux pouvoirs, deux rôles, **aucun omnipotent**. Ce rôle crée des VM vides et n'entre
jamais chez un tenant ; `serveur_ops` habille des machines et ne touche jamais la fabric.
## Qui a le droit de le déclarer
**L'écosystème de l'hébergeur**, celui qui exploite la fabric. Un tenant ordinaire qui le
déclarerait s'arrogerait un pouvoir sur ses voisins.
## Ce qu'il dépose, et comment
La voûte du SITE, **chiffrée**, en `0600`. Le mot de passe n'est jamais stocké : le
Makefile ajoute `--ask-vault-pass` quand aucun fichier de mot de passe n'est défini, et
l'exploitant le tape au moment d'agir.
`decrypt: false` est **obligatoire** sur la copie. Sans lui, Ansible déchiffre la source
quand il détient le mot de passe — constaté le 2026-08-24 : 776 octets en clair au lieu de
3465 chiffrés, sur une machine où ils n'avaient rien à faire. Le rôle **relit l'en-tête**
après avoir écrit et refuse si le fichier n'est pas chiffré.
## Colocalisation
Rien n'oblige à lui donner sa propre machine : chez l'hébergeur, les deux pouvoirs
résident légitimement au même endroit, et une machine de plus dans un écosystème
volontairement maigre se paie. Le déclarer séparément rend le pouvoir **visible**, ce qui
est l'essentiel.
Chez un tenant qui n'est pas l'hébergeur, la question ne se pose pas : le rôle n'a pas à
y être.
## Variables
| Variable | Rôle |
|---|---|
| `serveur_ops_site_depot` | dossier du dépôt SITE chez le runner (cloné par `serveur_ops`) |
| `serveur_ops_site_voute_source` | chemin de `underlay.vault.yml` sur le contrôleur |
| `serveur_ops_site_voute_deposer` | `false` pour un runner qui lit la carte sans détenir les clés |
## Ce que ce rôle ne fait pas
Il n'installe rien, n'ouvre aucun port, ne sauvegarde rien. Il ne fait qu'**attribuer un
pouvoir** — et le rendre lisible dans le plan.

View file

@ -0,0 +1,43 @@
---
# LE RUNNER DE SITE — celui qui MATÉRIALISE.
#
# Le travail d'un runner se divise en trois, et les deux premières portées appartiennent
# au TENANT tandis que la troisième appartient à la FABRIC :
#
# calculer plan -> inventaire aucune voûte portée OPS (serveur_ops)
# configurer rôles sur ses machines voûte du TENANT portée OPS (serveur_ops)
# matérialiser créer/détruire des VM voûte du SITE portée SITE (ce rôle)
#
# POURQUOI LA SÉPARATION. Si chaque écosystème portait le pouvoir de matérialiser, la
# voûte du SITE se retrouverait en N exemplaires — le secret le plus dangereux du système,
# recopié autant de fois qu'il y a de locataires. C'est précisément ce que la séparation
# des voûtes (2026-08-22) cherchait à éviter.
#
# Et l'inverse serait pire : un runner UNIQUE qui ferait tout devrait entrer en SSH chez
# tous les tenants, donc traverser le default-deny inter-tenant — et rendrait toute
# émancipation impossible, puisqu'un écosystème ne pourrait plus se rebâtir sans son hôte.
#
# Deux pouvoirs, deux rôles, aucun omnipotent : ce rôle crée des VM vides et n'entre
# jamais chez un tenant ; `serveur_ops` habille des machines et ne touche jamais la fabric.
#
# QUI PEUT LE DÉCLARER : l'écosystème de l'HÉBERGEUR, celui qui exploite la fabric. Un
# tenant ordinaire qui le déclarerait s'arrogerait un pouvoir sur ses voisins.
serveur_ops_site_utilisateur: "setops"
serveur_ops_site_racine: "/opt/setops"
# Le dépôt du SITE, tel qu'il atterrit chez le runner. Il doit avoir été cloné par
# `serveur_ops` (entrée `role: hebergeur` de `serveur_ops_depots`).
serveur_ops_site_depot: ""
# --- LA VOÛTE DU SITE --------------------------------------------------------
#
# Déposée CHIFFRÉE, jamais en clair. Le mot de passe n'est pas stocké : le Makefile
# ajoute `--ask-vault-pass` quand aucun fichier de mot de passe n'est défini, et
# l'exploitant le tape au moment d'agir.
#
# `decrypt: no` EST OBLIGATOIRE sur la copie. Sans lui, Ansible DÉCHIFFRE la source quand
# il détient le mot de passe — mesuré le 2026-08-24, 776 octets en clair au lieu de 3465
# chiffrés, sur une machine où ils n'avaient rien à faire.
serveur_ops_site_voute_source: ""
serveur_ops_site_voute_deposer: true

View file

@ -0,0 +1,12 @@
---
# 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, puis `sudo -iu setops`"
raison: >-
Ce role n'expose aucune interface : il DETIENT un pouvoir au lieu d'en offrir un.
Ce qui le protege n'est pas une authentification mais le fait que la voute deposee
reste CHIFFREE et que son mot de passe soit tape a l'execution, jamais stocke.

View file

@ -0,0 +1,7 @@
---
# Empreinte ressources — ce rôle n'ajoute qu'un fichier et des appels d'API. Il se
# colocalise avec `serveur_ops`, dont il partage la racine et l'utilisateur.
setops_empreinte:
coeurs: 0
memoire_mo: 0
disque_go: 0

View file

@ -0,0 +1,40 @@
---
# Flux réseau du runner de SITE. Voir docs/flux-conception.md.
#
# Les flux du POUVOIR DE MATERIALISER : API de l'hyperviseur, shell des noeuds, API de
# la frontiere. Chacun est un pouvoir distinct, donc declare a part. Ce rôle n'entre JAMAIS
# chez un tenant — c'est `serveur_ops` qui configure les machines, une fois qu'elles
# existent.
flux:
- sens: egress
port: 8006
protocole: tcp
pair: fabric
chiffrement: tls-requis
raison: >-
API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux
par lequel un écosystème peut en matérialiser un autre.
# SSH VERS LES HYPERVISEURS. L'API Proxmox ne fait pas tout : deplacer un disque,
# lire une configuration reseau, poser un pont — cela passe par le shell du nœud.
# Le declarer separement de l'API rend visible que ce sont DEUX pouvoirs distincts.
- sens: egress
port: 22
protocole: tcp
pair: fabric
chiffrement: ssh
raison: >-
Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts,
deplacement de disques. Un pouvoir distinct de l'API, donc declare a part.
# L'API DE LA FRONTIERE. Le runner du site pose les regles et les alias qui laissent
# passer les flux d'un tenant : sans ce flux, il prepare un terrain injoignable.
- sens: egress
port: 443
protocole: tcp
pair: fabric
chiffrement: tls-requis
raison: >-
API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux
du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain
injoignable.

View file

@ -0,0 +1,64 @@
---
- name: Exiger les intrants du runner de site
ansible.builtin.assert:
that:
- serveur_ops_site_depot | length > 0
- (not (serveur_ops_site_voute_deposer | bool)) or (serveur_ops_site_voute_source | length > 0)
fail_msg: >-
serveur_ops_site exige `serveur_ops_site_depot` (le dossier du dépôt SITE chez le
runner, cloné par serveur_ops) et, si le dépôt de la voûte est demandé,
`serveur_ops_site_voute_source` (le chemin de `underlay.vault.yml` sur le contrôleur).
- name: Le dépôt du SITE est-il bien là ?
ansible.builtin.stat:
path: "{{ serveur_ops_site_racine }}/{{ serveur_ops_site_depot }}/underlay.yml"
register: serveur_ops_site_carte
- name: Refuser si la carte de la fabric manque
ansible.builtin.assert:
that:
- serveur_ops_site_carte.stat.exists
fail_msg: >-
{{ serveur_ops_site_racine }}/{{ serveur_ops_site_depot }}/underlay.yml est absent.
Déclarer ce dépôt dans `serveur_ops_depots` (role: hebergeur) et rejouer serveur_ops :
un runner de site sans carte ne sait pas sur quoi il matérialise.
when: not ansible_check_mode
# LES CLÉS DU MONDE PHYSIQUE, DE DROIT ET NON PAR EMPRUNT.
#
# `decrypt: no` : voir defaults/main.yml. Le fichier reste chiffré ; seul le mot de passe,
# tapé à l'exécution, l'ouvre.
- name: Déposer la voûte du SITE (chiffrée)
ansible.builtin.copy:
src: "{{ serveur_ops_site_voute_source }}"
dest: "{{ serveur_ops_site_racine }}/{{ serveur_ops_site_depot }}/underlay.vault.yml"
owner: "{{ serveur_ops_site_utilisateur }}"
group: "{{ serveur_ops_site_utilisateur }}"
mode: "0600"
decrypt: false
when:
- serveur_ops_site_voute_deposer | bool
- not ansible_check_mode
# ÉCRIRE, PUIS RELIRE (D-68). Une voûte déchiffrée par accident est une fuite silencieuse :
# le fichier existe, le rôle se dit satisfait, et les clés du cluster dorment en clair.
- name: Relire l'en-tête de la voûte déposée
ansible.builtin.command:
cmd: "head -c 21 {{ serveur_ops_site_racine }}/{{ serveur_ops_site_depot }}/underlay.vault.yml"
register: serveur_ops_site_entete
changed_when: false
when:
- serveur_ops_site_voute_deposer | bool
- not ansible_check_mode
- name: Exiger que la voûte déposée soit CHIFFRÉE
ansible.builtin.assert:
that:
- "'$ANSIBLE_VAULT' in serveur_ops_site_entete.stdout"
fail_msg: >-
La voûte déposée n'est PAS chiffrée — elle a été déchiffrée en transit.
Vérifier `decrypt: false` sur la copie. Détruire le fichier au shred et
considérer les justificatifs du cluster comme exposés.
when:
- serveur_ops_site_voute_deposer | bool
- not ansible_check_mode

View file

@ -19,10 +19,20 @@ serveur_powerdns_soa_expire: 1209600
serveur_powerdns_soa_minimum: 3600
# L'adresse du tenant SEULEMENT, pas `0.0.0.0` : lier toutes les interfaces occupe aussi
# `127.0.0.1:53`, ou un resolveur recursif local voudrait s'installer. Ce n'est pas un
# probleme theorique — c'est la raison pour laquelle `client_unbound` exempte cet hote.
# probleme theorique — c'est la raison pour laquelle `client_resolveur` exempte cet hote.
# PowerDNS reste autoritatif pur : il repond pour la zone souveraine et REFUSE le reste.
serveur_powerdns_listen_addresses:
- "{{ ansible_host | default('0.0.0.0') }}"
# LE RESOLVEUR DU TENANT PARTAGE CET HOTE (2026-08-24). Quand c'est le cas, l'autoritatif
# lui laisse le port 53 de l'adresse LAN et se replie sur la loopback, sur un autre port :
# le resolveur devient la SEULE porte, et personne n'interroge l'autoritatif directement.
#
# Derive de l'inventaire, pas declare a la main : l'exploitant ne doit pas avoir a se
# souvenir de deplacer un port parce qu'il a ajoute un role.
serveur_powerdns_colocalise_resolveur: >-
{{ inventory_hostname in (groups['serveur_resolveur'] | default([])) }}
serveur_powerdns_listen_port: "{{ 5300 if (serveur_powerdns_colocalise_resolveur | bool) else 53 }}"
serveur_powerdns_listen_addresses: >-
{{ ['127.0.0.1'] if (serveur_powerdns_colocalise_resolveur | bool)
else [ansible_host | default('0.0.0.0')] }}
serveur_powerdns_allow_axfr_ips: []
# Enregistrements additionnels geres explicitement.

View file

@ -1,21 +1,31 @@
---
# Flux réseau du DNS autoritatif interne (PowerDNS). Voir docs/flux-conception.md.
# LE PORT DE L'AUTORITATIF EST DÉRIVÉ DEPUIS LE 2026-08-24, et c'est pour ça qu'il est
# déclaré `derive` plutôt que `53` :
#
# seul sur son hôte -> 53 sur l'adresse du tenant
# partageant l'hôte avec le résolveur -> 5300 sur la loopback, derrière lui
#
# Le résolveur devient alors la seule porte, et personne n'interroge l'autoritatif
# directement. `verifier_ports` traite un port non numérique comme « pas une écoute fixe »
# — ce qui est exactement le cas : les deux services lient le port 53 mais sur des
# ADRESSES différentes, ce qu'une comparaison de ports seuls ne peut pas distinguer.
flux:
- sens: ingress
port: 53
port: derive
protocole: udp
pair: flotte
chiffrement: clair
raison: "Résolution DNS interne (zone souveraine). DoT/DoH = feuille de route (chiffrement DNS)."
raison: "Zone souveraine. 53 seul sur son hôte, 5300 sur la loopback derrière le résolveur."
- sens: ingress
port: 53
port: derive
protocole: tcp
pair: flotte
chiffrement: clair
raison: "Résolution DNS interne en TCP (réponses volumineuses, AXFR restreint par allow_axfr_ips)."
raison: "Idem en TCP (réponses volumineuses, AXFR restreint par allow_axfr_ips)."
# L'autoritatif ne RÉCURSE pour personne — mais il doit résoudre POUR LUI-MÊME : `apt`,
# NTP, les certificats. Il est le seul hôte exempté de `client_unbound` (PowerDNS occupe
# NTP, les certificats. Il est le seul hôte exempté de `client_resolveur` (PowerDNS occupe
# déjà son port 53), donc rien d'autre ne lui ouvre ce flux. Sans cette déclaration, la
# frontière jette ses requêtes et la machine ne peut même pas s'installer — mesuré le
# 2026-08-06, sur la VM qui devait justement servir de DNS au tenant.

View file

@ -2,6 +2,9 @@
Ne PAS le redeclarer ici : sinon PowerDNS refuse « multiple backends 'bind' ». #}
bind-config={{ serveur_powerdns_bind_config }}
local-address={{ serveur_powerdns_listen_addresses | join(',') }}
# Le port suit l'adresse : 53 quand l'autoritatif est seul sur son hote, 5300 quand le
# resolveur du tenant partage la machine et prend le port 53 de l'adresse LAN.
local-port={{ serveur_powerdns_listen_port }}
{% if serveur_powerdns_allow_axfr_ips | length > 0 %}
allow-axfr-ips={{ serveur_powerdns_allow_axfr_ips | join(',') }}
disable-axfr=no

View file

@ -0,0 +1,66 @@
# serveur_resolveur
**Le résolveur de l'écosystème** : un seul Unbound pour tout le tenant, récursif depuis
les serveurs racine, qui délègue la zone souveraine à PowerDNS.
## Pourquoi un seul, et pourquoi ici
Avant le 2026-08-24, `client_unbound` posait un Unbound sur **chaque VM**. Ça marchait et
c'était parfaitement étanche — mais c'était N démons identiques pour un service unique.
Mesure prise avant de décider : **~21 Mo de RSS par VM**, et **28 à 189 requêtes** servies
depuis le démarrage. Le gain de cache mutualisé est donc négligeable ; ce qu'on récupère,
c'est un démon au lieu de quatorze, et **un endroit à regarder** au lieu de quatorze.
## Pourquoi pas sur la frontière
Un résolveur partagé par tout le SITE — l'OPNsense — aurait été le geste le plus économe.
Il devrait alors connaître la zone interne de **chaque tenant** : sans vues par réseau
soigneusement réglées, le tenant A résoudrait les noms du tenant B. C'est l'étanchéité
qu'on protège partout ailleurs.
Et un tenant dont le résolveur vit chez l'hébergeur **ne peut plus s'émanciper avec**
(voir `docs/filiation-emancipation.md`).
La récursion est générique ; **la zone interne ne l'est pas**. C'est ce qui fait du
résolveur un service de tenant, et non de fabric.
## La cohabitation avec l'autoritatif
Les deux vivent sur `infra-dns-01` et voudraient le port 53. Le partage est **dérivé, pas
déclaré** — l'exploitant n'a pas à se souvenir de déplacer un port parce qu'il a ajouté un
rôle :
```
resolveur (Unbound) 10.29.19.11:53 + 127.0.0.1:53 la seule porte
autoritatif (PowerDNS) 127.0.0.1:5300 derrière lui
```
Personne n'interroge l'autoritatif directement. Le résolveur le prend en `stub-zone`.
## Aucun transitaire
`forward-addr` reste vide : Unbound récurse depuis la racine. Poser un transitaire
reviendrait à confier chaque question de l'écosystème à un tiers — ce que la bascule du
2026-08-23 avait justement supprimé.
## Ce qu'il vérifie avant de se déclarer bon
Il interroge sa propre adresse pour la **zone interne** *et* pour un nom de l'**Internet**.
Un résolveur « active » qui ne répond pas est le pire des cas : toute la flotte pointe vers
lui, et découvre la panne en essayant de résoudre — c'est-à-dire au moment où le
diagnostic lui-même devient impossible.
## Variables principales
| Variable | Défaut | Rôle |
|---|---|---|
| `serveur_resolveur_ecoutes` | loopback + IP du tenant | où il répond |
| `serveur_resolveur_reseaux_autorises` | le supernet du tenant | qui a le droit de demander |
| `serveur_resolveur_autoritatif` | `127.0.0.1:5300` | la zone souveraine |
| `serveur_resolveur_transitaires` | `[]` | vide = récursion depuis la racine |
## Ce que ce rôle ne fait pas
Il ne bascule aucun `/etc/resolv.conf` — c'est le travail de `client_resolveur`, qui
valide que ce service répond **avant** de désigner qui que ce soit.

View file

@ -0,0 +1,44 @@
---
# LE RÉSOLVEUR DE L'ÉCOSYSTÈME — un seul, pour tout le tenant.
#
# Avant : un Unbound sur CHAQUE VM, écoutant sur 127.0.0.1. Ça marchait, et c'était
# parfaitement étanche — mais c'était N démons identiques pour un service unique.
#
# MESURE DU 2026-08-24, avant de décider : ~21 Mo de RSS par VM, et entre 28 et 189
# requêtes servies depuis le démarrage. Le gain de cache mutualisé est donc négligeable ;
# ce qu'on récupère, c'est un démon au lieu de quatorze, et un endroit à regarder au lieu
# de quatorze.
#
# POURQUOI PAS SUR LA FRONTIÈRE (OPNsense), qui aurait été le geste le plus économe : un
# résolveur partagé par tout le SITE devrait connaître la zone interne de CHAQUE tenant.
# Sans vues par réseau soigneusement réglées, le tenant A résoudrait les noms du tenant B
# — l'étanchéité qu'on protège partout ailleurs. Et un tenant dont le résolveur vit chez
# l'hébergeur ne peut plus s'émanciper avec (docs/filiation-emancipation.md).
#
# La récursion est générique ; la ZONE INTERNE ne l'est pas. C'est ce qui fait du
# résolveur un service de TENANT, et non de fabric.
serveur_resolveur_paquets:
- unbound
serveur_resolveur_service: "unbound"
# Il écoute pour toute la flotte du tenant — plus seulement pour lui-même.
serveur_resolveur_ecoutes:
- "127.0.0.1"
- "{{ ansible_host }}"
# QUI A LE DROIT DE L'INTERROGER : le supernet du tenant, dérivé du seed comme tout le
# reste. Un résolveur ouvert au monde est un relais d'amplification.
serveur_resolveur_reseaux_autorises:
- "{{ setops_supernet | default('127.0.0.0/8') }}"
# La zone souveraine et son autoritatif. PowerDNS se replie sur la loopback quand il
# partage cet hôte (voir serveur_powerdns/defaults), pour lui laisser le port 53.
serveur_resolveur_zone_interne: "{{ domaine_interne }}"
serveur_resolveur_autoritatif: "127.0.0.1"
serveur_resolveur_autoritatif_port: 5300
# AUCUN TRANSITAIRE : Unbound récurse depuis les serveurs racine. Poser un transitaire ici
# reviendrait à confier chaque question de l'écosystème à un tiers — ce que la bascule du
# 2026-08-23 avait justement supprimé.
serveur_resolveur_transitaires: []

View file

@ -0,0 +1,6 @@
---
- name: Redémarrer le résolveur
when: not ansible_check_mode
ansible.builtin.systemd:
name: "{{ serveur_resolveur_service }}"
state: restarted

View file

@ -0,0 +1,13 @@
---
# Position de ce role dans la directive d'authentification (D-38..D-41).
# Voir docs/authentification.md. Gardee par la preuve P29.
authentification:
portee: sans-auth-humaine
mecanisme: aucun
formulaire_local: sans-objet
secours: "Acces SSH a l'hote"
raison: >-
Un resolveur DNS ne sert que des machines et n'expose aucune interface humaine.
Ce qui le protege n'est pas une authentification mais sa LISTE D'ACCES : il ne
repond qu'au supernet du tenant. Un resolveur ouvert au monde serait un relais
d'amplification, pas un service mal authentifie.

View file

@ -0,0 +1,8 @@
---
# Empreinte ressources — mesure du 2026-08-24 : ~21 Mo de RSS pour un Unbound servant
# une VM. Servir la flotte entiere n'y change presque rien : le cout est le cache, et le
# trafic mesure etait de quelques centaines de requetes.
setops_empreinte:
coeurs: 0
memoire_mo: 64
disque_go: 0

View file

@ -0,0 +1,23 @@
---
# Flux réseau du résolveur du tenant. Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 53
protocole: udp
pair: flotte
chiffrement: clair
raison: "Toute la flotte du tenant résout ici — et nulle part ailleurs."
- sens: ingress
port: 53
protocole: tcp
pair: flotte
chiffrement: clair
raison: "Réponses longues et bascule TCP, obligatoires en DNS."
- sens: egress
port: 53
protocole: udp
pair: externe
chiffrement: clair
raison: >-
Récursion depuis les serveurs racine. Aucun transitaire : l'écosystème ne confie
ses questions à personne.

View file

@ -0,0 +1,55 @@
---
- name: Exiger la zone souveraine et un réseau autorisé
ansible.builtin.assert:
that:
- serveur_resolveur_zone_interne | length > 0
- serveur_resolveur_reseaux_autorises | length > 0
fail_msg: >-
serveur_resolveur exige `domaine_interne` et au moins un réseau dans
`serveur_resolveur_reseaux_autorises`. Un résolveur sans liste d'accès serait
soit muet, soit un relais ouvert.
- name: Installer le résolveur
ansible.builtin.apt:
name: "{{ serveur_resolveur_paquets }}"
state: present
update_cache: true
when: not ansible_check_mode
- name: Déployer la configuration Set-OPS
ansible.builtin.template:
src: setops.conf.j2
dest: /etc/unbound/unbound.conf.d/setops.conf
owner: root
group: root
mode: "0644"
notify: Redémarrer le résolveur
- name: Valider la configuration avant de la servir
ansible.builtin.command: unbound-checkconf
changed_when: false
when: not ansible_check_mode
- name: Activer et démarrer le résolveur
ansible.builtin.systemd:
name: "{{ serveur_resolveur_service }}"
enabled: true
state: started
when: not ansible_check_mode
- name: Appliquer les redémarrages avant de vérifier
ansible.builtin.meta: flush_handlers
# ÉCRIRE, PUIS RELIRE (D-68). Un résolveur « active » qui ne répond pas est le pire des
# cas : toute la flotte pointe vers lui, et découvre la panne en essayant de résoudre.
- name: Répond-il vraiment, pour l'interne ET pour l'Internet ?
ansible.builtin.command: "dig @{{ ansible_host }} {{ item.nom }} {{ item.type }} +short"
register: serveur_resolveur_controle
changed_when: false
failed_when: serveur_resolveur_controle.stdout | trim == ''
loop:
- { nom: "{{ serveur_resolveur_zone_interne }}", type: "SOA" }
- { nom: "deb.debian.org", type: "A" }
loop_control:
label: "{{ item.nom }} {{ item.type }}"
when: not ansible_check_mode

View file

@ -0,0 +1,37 @@
# GÉNÉRÉ par Set-OPS (rôle serveur_resolveur). NE PAS éditer à la main.
server:
{% for a in serveur_resolveur_ecoutes %}
interface: {{ a }}
{% endfor %}
{% for r in serveur_resolveur_reseaux_autorises %}
access-control: {{ r }} allow
{% endfor %}
access-control: 127.0.0.0/8 allow
hide-identity: yes
hide-version: yes
prefetch: yes
do-ip6: no
# La zone souveraine n'est pas signée : on ne la soumet pas à la validation DNSSEC.
domain-insecure: "{{ serveur_resolveur_zone_interne }}"
# UNBOUND REFUSE D'INTERROGER UNE LOOPBACK PAR DÉFAUT (2026-08-24).
#
# `do-not-query-localhost` vaut `yes` d'origine — une protection contre les boucles.
# Or l'autoritatif de l'écosystème vit désormais SUR 127.0.0.1:5300, derrière ce
# résolveur. Sans cette ligne, toute la zone souveraine rend SERVFAIL.
#
# Et la panne était MASQUÉE : le plancher /etc/hosts répondait pour les noms de la
# flotte, si bien qu'un `getent hosts forge.genese.internal` semblait prouver que la
# résolution marchait. Seul un `dig` explicite l'a montrée.
do-not-query-localhost: no
stub-zone:
name: "{{ serveur_resolveur_zone_interne }}"
stub-addr: {{ serveur_resolveur_autoritatif }}@{{ serveur_resolveur_autoritatif_port }}
{% if serveur_resolveur_transitaires %}
forward-zone:
name: "."
{% for f in serveur_resolveur_transitaires %}
forward-addr: {{ f }}
{% endfor %}
{% endif %}

View file

@ -2,3 +2,40 @@
ssh_baseline_password_authentication: "no"
ssh_baseline_permit_root_login: "no"
ssh_baseline_pubkey_authentication: "yes"
# --- QUI A LE DROIT D'ENTRER --------------------------------------------------
#
# La cle du compte d'administration est posee par CLOUD-INIT, a la creation de la VM.
# Il n'existait donc aucun moyen declaratif d'en autoriser une SECONDE sur des machines
# deja nees — constate le 2026-08-24 : le poste d'exploitation fabriquait sa propre paire
# depuis son premier deploiement, et n'a jamais pu joindre un seul hote
# (`Permission denied (publickey)`).
#
# CHAQUE ENTREE PORTE SON `etat`, ET C'EST DELIBERE. Une liste purement additive ne sait
# pas RETIRER : une cle autorisee une fois le resterait pour toujours, et « revoquer »
# voudrait dire se connecter a la main sur chaque machine. Ici, passer une entree a
# `absent` la retire au prochain passage, sur toute la flotte.
#
# ON N'UTILISE PAS `exclusive: true` : il effacerait la cle posee par cloud-init si le
# plan ne la reprenait pas, et fermerait la flotte a tout le monde d'un seul deploiement.
# Le prix de ce choix est qu'une cle posee HORS du plan ne sera pas detectee ici.
#
# ssh_baseline_cles_admin:
# - cle: "ssh-ed25519 AAAA… setops@ops-01.exemple.internal"
# pourquoi: "poste d'exploitation — configure la flotte"
# etat: present # `absent` pour revoquer
ssh_baseline_admin_user: "{{ sudo_ansible_admin_user | default('ansible') }}"
ssh_baseline_cles_admin: []
# 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 : les deux coexistent sur toute
# machine anterieure au changement, et sshd retient la PREMIERE valeur qu'il rencontre —
# donc l'ancienne, l'ordre lexical la placant devant.
#
# Cette liste est le registre de ces mues. On n'y ajoute 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.
ssh_baseline_fichiers_perimes:
- 10-chezlepro.conf

View file

@ -12,6 +12,25 @@
group: root
mode: "0755"
# UN RENOMMAGE DE ROLE LAISSE SON ANCIEN FICHIER EN VIGUEUR (2026-08-25).
#
# Ce role s'appelait `chezlepro` et deposait `10-chezlepro.conf`. Renomme en `setops`, il
# depose `10-setops.conf` — et l'ancien EST RESTE. Mesure sur forge-01 : les deux paires
# coexistent, et elles ne disent pas la meme chose (`MaxSessions 2` contre `10`).
#
# sshd retient LA PREMIERE valeur rencontree pour chaque mot-cle, et lit les drop-ins dans
# l'ordre lexical : c'est donc l'ANCIEN fichier qui gagne. Une configuration qu'on croit
# avoir remplacee reste aux commandes, en silence, et sa trace se lira un jour comme une
# lenteur inexplicable pendant un deploiement parallele.
#
# Retirer un fichier qu'on ne depose plus fait partie du travail de le deposer.
- name: Retirer les configurations SSH d'une nomenclature abandonnee
ansible.builtin.file:
path: "/etc/ssh/sshd_config.d/{{ item }}"
state: absent
loop: "{{ ssh_baseline_fichiers_perimes }}"
notify: Validate and reload ssh
- name: Déployer la configuration SSH Set-OPS
ansible.builtin.template:
src: 10-setops.conf.j2
@ -26,3 +45,15 @@
name: ssh
enabled: true
state: started
# Voir defaults/main.yml pour le raisonnement (etat par entree, pas d'`exclusive`).
- name: Autoriser — ou révoquer — les clés d'administration déclarées au plan
ansible.posix.authorized_key:
user: "{{ ssh_baseline_admin_user }}"
key: "{{ item.cle }}"
state: "{{ item.etat | default('present') }}"
comment: "{{ item.pourquoi | default(omit) }}"
loop: "{{ ssh_baseline_cles_admin }}"
loop_control:
label: "{{ item.pourquoi | default(item.cle[:40]) }} → {{ item.etat | default('present') }}"
when: not ansible_check_mode

View file

@ -28,3 +28,13 @@ ssh_hardening_login_grace_time: "20s"
# dans `ansible.cfg`) : ces valeurs sont la marge, pas la contrainte.
ssh_hardening_max_startups: "10:30:60"
ssh_hardening_max_sessions: 10
# MEME MUE, MEME DETTE : ce role a depose `20-chezlepro-hardening.conf` avant de porter le
# nom du moteur. L'ancien fichier survit au renommage et, l'ordre lexical le placant
# devant, c'est LUI que sshd retient pour chaque mot-cle en double. Mesure sur forge-01 :
# `MaxSessions 2` (ancien) l'emportait sur `MaxSessions 10` (courant).
#
# On n'y liste que des noms qu'on a reellement deposes : supprimer un fichier qu'on n'a
# jamais ecrit serait effacer la configuration d'un autre.
ssh_hardening_fichiers_perimes:
- 20-chezlepro-hardening.conf

View file

@ -1,4 +1,14 @@
---
# VOIR defaults/main.yml : un renommage de role laisse son ancien fichier EN VIGUEUR, et
# l'ordre lexical le place devant le nouveau. Retirer ce qu'on ne depose plus fait partie
# du travail de deposer.
- name: Retirer le hardening SSH d'une nomenclature abandonnee
ansible.builtin.file:
path: "/etc/ssh/sshd_config.d/{{ item }}"
state: absent
loop: "{{ ssh_hardening_fichiers_perimes }}"
notify: Validate and reload ssh
- name: Déployer le hardening SSH additionnel
ansible.builtin.template:
src: 20-setops-hardening.conf.j2

View file

@ -64,6 +64,10 @@ NOEUD_SORTIE = "<NOEUD-DE-SORTIE-EVPN>"
IF_TRANSIT = "<IF-TRANSIT>"
IF_WAN = "<IF-WAN>"
IF_GESTION = "<IF-GESTION>"
# L'interface ou arrive le trafic des MACHINES DU SITE (vlan 30 / vmbr3). Une regle
# posee sur la mauvaise interface ne correspond jamais : le trafic du site ne penetre pas
# le pare-feu par le lien de transit des tenants, mais par sa propre patte.
IF_SITE = "<IF-SITE>"
def depot_hebergeur() -> Path | None:
@ -222,12 +226,24 @@ ROLE_SOCLE = "serveur_debian"
def flux_frontiere(flux_par_role: dict[str, list[dict]]) -> list[tuple[str, dict]]:
"""[(role, flux)] pour tout flux dont le pair inclut `externe`, trie."""
"""[(role, flux)] pour tout flux que la FRONTIERE doit porter, trie.
Deux familles, et pas une :
`externe` — le nord-sud : l'Internet d'un cote, un tenant de l'autre.
`voisins_site` — l'est-ouest INTER-TENANT. Deux tenants de la meme fabric vivent
sur des VLAN distincts, routes par la frontiere : leur trafic la
traverse, donc elle doit le porter.
OUBLIER LA SECONDE, C'ETAIT DECLARER UN FLUX QUE PERSONNE N'APPLIQUE (2026-08-24). Le
mot `voisins_site` etait accepte par la validation, le devis rendait zero regle, et
rien ne le signalait — le chainage des caches n'aurait simplement jamais fonctionne.
"""
retenus = [
(role, fl)
for role, flux in sorted(flux_par_role.items())
for fl in flux
if "externe" in _pairs(fl)
if "externe" in _pairs(fl) or "voisins_site" in _pairs(fl)
]
retenus.sort(key=lambda rf: (rf[1].get("sens", ""), rf[0], str(rf[1].get("port"))))
return retenus
@ -249,9 +265,40 @@ def cibles_par_role(role: str, inventaire: Path) -> list[str]:
return sorted({ips[h] for h in _hotes_du_groupe(data, role) if h in ips})
# OPNsense refuse un nom d'alias de 32 caracteres ou plus. La contrainte n'etait nulle
# part, et elle s'est manifestee a l'APPLICATION — pas au devis :
#
# SETOPS_PATI29_SERVEUR_ARTEFACTS_SITE (36)
# « The name must start with a letter [...] and be less than 32 characters »
#
# L'outil a bien refuse d'appliquer quoi que ce soit, mais le devis, lui, annoncait un
# objet impossible. Un devis qui promet ce que la cible refusera n'est pas un devis.
LONGUEUR_MAX_ALIAS = 31
# Meme abreviation que le pare-feu est-ouest (`devis_proxmox_fw` : serveur_ -> srv-), pour
# qu'un seul schema de lecture serve aux deux couches.
ABREGES = (("SERVEUR_", "SRV_"), ("CLIENT_", "CLI_"))
def nom_alias(role: str, etiquette: str) -> str:
"""Alias d'hotes, PREFIXE par le tenant : deux tenants ont chacun leur nginx."""
return f"SETOPS_{etiquette}_{role.upper()}"
"""Alias d'hotes, PREFIXE par le tenant : deux tenants ont chacun leur nginx.
Abrege SEULEMENT si le nom complet depasse — un nom court reste lisible tel quel, et
la flotte existante n'est pas renommee pour la commodite d'un seul role.
"""
nom = f"SETOPS_{etiquette}_{role.upper()}"
if len(nom) <= LONGUEUR_MAX_ALIAS:
return nom
court = role.upper()
for long, bref in ABREGES:
court = court.replace(long, bref)
nom = f"SETOPS_{etiquette}_{court}"
if len(nom) > LONGUEUR_MAX_ALIAS:
# Mieux vaut un devis qui refuse qu'un devis qui promet l'impossible.
raise SystemExit(
f"Nom d'alias trop long pour OPNsense ({len(nom)} > {LONGUEUR_MAX_ALIAS}) : {nom}.\n"
f"Raccourcir le nom du role `{role}`.")
return nom
def _instance_active() -> tuple[str, dict] | tuple[None, None]:
@ -263,6 +310,51 @@ def _instance_active() -> tuple[str, dict] | tuple[None, None]:
return None, None
def _plan_site() -> Path | None:
"""Le `plan/` du SITE, a cote de son `underlay.yml`."""
c = underlay_mod.chemin()
return (c.resolve().parent / "plan") if c else None
def _lire_plan_site(nom: str) -> dict:
d = _plan_site()
f = (d / nom) if d else None
if not f or not f.is_file():
return {}
return yaml.safe_load(f.read_text(encoding="utf-8")) or {}
def _machines_du_plan_site() -> list[dict]:
srv = (_lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}
return [{**s, "nom": nom} for nom, s in srv.items()
if str(s.get("etat", "actif")) == "actif"]
def _ports_du_plan_site() -> dict[str, str]:
"""Port declare au plan pour chaque role du site — ce que `port: derive` vaut ici.
UN PORT NON NUMERIQUE N'EST PAS UNE ECOUTE (voir `resoudre_flux`). `derive` dit que le
port depend du deploiement : la forge ecoute 3000 derriere un edge, 443 quand elle
sert son propre TLS. Le rendre tel quel produirait une regle que le boitier refuse —
on l'a deja paye sur le pare-feu est-ouest.
"""
apps = (_lire_plan_site("applications.yml") or {}).get("applications") or {}
out: dict[str, str] = {}
for app in apps.values():
if app.get("groupe") and app.get("port"):
out[str(app["groupe"])] = str(app["port"])
return out
def _services_du_plan_site() -> dict[str, list[str]]:
apps = (_lire_plan_site("applications.yml") or {}).get("applications") or {}
out: dict[str, list[str]] = {}
for app in apps.values():
if app.get("hote") and app.get("groupe"):
out.setdefault(str(app["hote"]), []).append(str(app["groupe"]))
return out
def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"""Le devis, en structure — rendue ensuite en texte ou en JSON."""
flux_par_role = charger_flux()
@ -271,6 +363,15 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
if_wan = _ou_marqueur(_intr, "opnsense_if_wan", IF_WAN)
if_transit = _ou_marqueur(_intr, "opnsense_if_transit", IF_TRANSIT)
if_gestion = _ou_marqueur(_intr, "opnsense_if_gestion", IF_GESTION)
if_site = _ou_marqueur(_intr, "opnsense_if_site", IF_SITE)
# UNE PATTE PAR ZONE DU SITE (2026-08-25). Le site n'est plus un `/24` plat : chaque
# nature d'autorite a son VLAN, et le trafic penetre le pare-feu par SA patte. Une
# regle posee sur la mauvaise interface ne correspond jamais.
if_zones = dict(_intr.get("opnsense_if_zones") or {})
def _if_de(reseau: str | None) -> str:
"""L'interface ou arrive le trafic de cette zone ; l'ancienne patte a defaut."""
return if_zones.get(str(reseau or ""), if_site)
nom_actif, nomenclature_active = _instance_active()
# L'alias d'un tenant vaut ses SOUS-RESEAUX ATTRIBUES, pas son supernet.
@ -421,6 +522,23 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion))
if admin_par_if[etiquette]["wan"]:
portees.append((f"SETOPS_ADMIN_{etiquette}_WAN", if_wan))
elif entrant and "voisins_site" in _pairs(fl):
# UN FLUX ENTRE TENANTS DU MEME SITE.
#
# Ce n'est ni `flotte` (mon ecosysteme) ni `externe` (l'Internet) : le
# paquet vient d'un VOISIN pose sur la meme fabric. Il arrive par le meme
# lien de transit que tout le reste — les tenants ne se distinguent pas
# par une interface mais par leur ALIAS SOURCE, deja construit plus haut
# (`SETOPS_TENANT_<etiquette>`).
#
# LA REGLE APPARTIENT AU SITE, PAS A L'UN DES DEUX TENANTS. C'est le
# runner de site qui prepare le terrain : il configure les VNets, les
# routes et les flux pour que les plans des tenants tiennent. Aucun
# ecosysteme n'ouvre de porte chez un autre.
portees = [
(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit)
for nom2, p2, n2 in tenants if nom2 != nom
]
elif entrant:
# Un service publie est joint DEPUIS INTERNET par le WAN, et le plus
# souvent aussi depuis le VLAN d'administration — le poste de
@ -448,6 +566,16 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
portees = [("any", if_wan)]
if admin_par_if[etiquette]["gestion"] and fl.get("poste", True):
portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion))
elif "voisins_site" in _pairs(fl):
# SORTANT VERS UN VOISIN : la destination est NOMMEE, pas « tout sauf
# l'interne ». Rendre `!SETOPS_INTERNES` ici aurait ouvert le port vers
# l'INTERNET — mesure du 2026-08-24, visible au devis avant application.
#
# Une regle par voisin : une regle porte une destination.
portees = [
(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit)
for nom2, p2, n2 in tenants if nom2 != nom
]
else:
portees = [("any", if_transit)]
for source, interface in portees:
@ -460,7 +588,11 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# lui-meme pour qu'il apparaisse dans l'identite de la regle — sans
# quoi la version niee et la version ouverte auraient la meme cle et
# la seconde ne remplacerait jamais la premiere.
"destination": destination if entrant else "!SETOPS_INTERNES",
# Sortant vers un voisin : la destination est le voisin lui-meme,
# portee par `source` puisque `portees` la transporte. Sortant vers
# l'Internet : « tout sauf l'interne ».
"destination": destination if entrant
else (source if "voisins_site" in _pairs(fl) else "!SETOPS_INTERNES"),
"ports": _ports(fl),
"chiffrement": fl.get("chiffrement"),
"role": role,
@ -469,6 +601,177 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
})
intrants = intrants_frontiere()
# --- LES MACHINES DU SITE -------------------------------------------------
#
# Elles ne sont dans AUCUN plan de tenant : la boucle ci-dessus ne pouvait donc pas
# les voir, et le devis rendait zero regle pour elles sans rien signaler. Un devis
# muet sur une machine qui existe n'est pas un devis.
#
# Leur trafic arrive par la patte du site (`if_site`), pas par le lien de transit.
# Une regle posee sur la mauvaise interface ne correspond jamais — c'est la panne la
# plus silencieuse de cette couche.
_u = underlay_mod.charger()
# LES MACHINES DU SITE VIENNENT DE SON PLAN, plus de l'underlay (2026-08-25).
#
# En deplacant le plan hors de `underlay.yml`, j'ai vide `machines()` sans reprendre
# ici : le devis a cesse de voir le site et a propose de RETIRER ses 36 objets — tous
# ses alias et toutes ses regles. Aucune preuve ne couvre le devis de la frontiere du
# site, donc `make prouver` restait vert. C'est le plan avant application qui l'a
# attrape, et rien d'autre ne l'aurait fait.
_machines_site = _machines_du_plan_site()
_services_site = _services_du_plan_site()
_ports_site = _ports_du_plan_site()
if _machines_site:
_res_site = {r.get("nom"): r for r in underlay_mod.reseaux(_u)}
_sous_site = sorted({str(_res_site[m["reseau"]]["sous_reseau"])
for m in _machines_site if m.get("reseau") in _res_site})
alias["SETOPS_SITE"] = {
"type": "network",
"contenu": _sous_site,
"description": "Reseau des machines de l'hebergeur (runner de SITE, cache racine)",
}
# LA FABRIC — ce que le runner du site doit joindre pour MATERIALISER : l'API des
# hyperviseurs, leur shell, l'API de la frontiere. C'est du RFC 1918 : le rendre
# par `!SETOPS_INTERNES` (la forme de « vers Internet ») l'aurait exclu tout en
# ayant l'air d'ouvrir le flux. D'ou le mot `fabric`, et cet alias-ci.
_fabric = sorted({str(h["ip"]) for h in underlay_mod.hotes(_u)
if h.get("role") in ("hyperviseur", "frontiere") and h.get("ip")})
alias["SETOPS_FABRIC"] = {
"type": "host",
"contenu": _fabric,
"description": "Materiel de l'hebergeur : hyperviseurs et frontiere — "
"ce que le runner du SITE pilote pour materialiser",
}
# LE PLAN D'ADMINISTRATION, source du SSH vers le site. Sans lui, la regle
# n'aurait pas de source : l'omettre ferme l'acces, l'ouvrir a `any` exposerait
# le SSH du runner — la machine la plus puissante du site — a l'Internet.
_admin_site = [str(r["sous_reseau"]) for r in underlay_mod.reseaux(_u)
if r.get("nom") == "management" and r.get("sous_reseau")]
if _admin_site:
alias["SETOPS_ADMIN_SITE"] = {
"type": "network",
"contenu": _admin_site,
"description": "Plan d'administration — seule source du SSH vers le site",
}
# Le socle vaut aussi pour le site : ses machines sont des Debian de la flotte,
# et c'est lui qui porte leur SSH. `site_inventaire.py` les y range.
# LE SITE RESOUT CHEZ SA PASSERELLE, ET IL FAUT LE DIRE (2026-08-25).
#
# Un tenant a son propre resolveur DANS son reseau : son DNS ne traverse jamais la
# frontiere, et aucun flux ne le declare. Le site n'en a pas — trois machines ne
# justifient pas un service de plus — il interroge donc la frontiere elle-meme.
#
# Sans cette regle, le default-deny bloque la requete, et la panne ne ressemble pas
# a un pare-feu : `/etc/resolv.conf` est correct, Unbound ecoute bien sur toutes les
# interfaces, le port repond au test TCP — et `apt update` echoue quand meme. On a
# cherche du cote du resolveur pendant que c'etait le filtre.
#
# La destination est l'adresse DECLAREE dans `site.dns_amorcage`, pas `any` : ouvrir
# le 53 vers le monde depuis le site serait une sortie DNS non policee.
_resolveur = str((_u.get("site") or {}).get("dns_amorcage") or "").strip()
if _resolveur:
alias["SETOPS_RESOLVEUR_SITE"] = {
"type": "host",
"contenu": [_resolveur],
"description": "Resolveur des machines du site — leur passerelle, "
"declaree dans `site.dns_amorcage`",
}
for _proto in ("udp", "tcp"):
regles.append({
"sens": "out",
"interface": if_site,
"protocole": _proto,
"source": "SETOPS_SITE",
"destination": "SETOPS_RESOLVEUR_SITE",
"ports": ["53"],
"chiffrement": "clair",
"role": "site",
"tenant": "SITE",
"raison": "Resolution DNS des machines du site aupres de leur "
"passerelle : le site n'a pas de resolveur a lui.",
})
_roles_site = sorted({g for gs in _services_site.values() for g in gs}
| {"serveur_debian"})
for _role in _roles_site:
_ips = sorted({str(m["ip"]) for m in _machines_site
if _role == "serveur_debian"
or _role in _services_site.get(m["nom"], [])})
_a = nom_alias(_role, "SITE")
alias[_a] = {
"type": "host",
"contenu": _ips,
"description": f"Machines du site portant {_role}",
}
for _fl in flux_par_role.get(_role, []):
_entrant = _fl.get("sens") == "ingress"
_pairs_fl = _pairs(_fl)
if _entrant and "voisins_site" in _pairs_fl:
# Un tenant qui s'adresse au site : le paquet penetre le pare-feu par
# le lien de TRANSIT, pas par la patte du site. Une regle par voisin,
# parce qu'une regle porte une source.
_portees = [(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit)
for _n2, p2, n2 in tenants]
elif _entrant and "flotte" in _pairs_fl:
# CE QUI ETAIT GRATUIT DEVIENT POLICE (2026-08-25).
#
# `flotte` veut dire « les machines de mon ecosysteme ». Dans un `/24`
# plat, ce trafic ne traversait rien : le cache, la forge, le
# resolveur et l'AC se joignaient en L2, sans qu'aucune regle
# n'existe — ni ne manque.
#
# Une zone par autorite change cela : une machine qui interroge le
# resolveur ou tire un paquet du cache passe desormais par la
# frontiere. Ce qui n'avait pas besoin d'etre dit doit l'etre.
#
# Une regle PAR ZONE SOURCE : le paquet penetre le pare-feu par la
# patte de la zone d'ou il part, et une regle posee ailleurs ne
# correspondrait jamais. La destination reste nommee — le role vise,
# jamais la zone entiere.
_portees = [("SETOPS_SITE", z) for z in
sorted({_if_de(m.get("reseau")) for m in _machines_site})]
elif _entrant and "22" in _ports(_fl) and _admin_site:
# Le SSH de gestion arrive par la patte d'ADMINISTRATION de la
# frontiere, pas par celle du site : c'est la que le paquet penetre
# le pare-feu. Une regle posee ailleurs ne correspondrait jamais.
_portees = [("SETOPS_ADMIN_SITE", if_gestion)]
elif _entrant:
continue # rien d'autre n'entre chez le site depuis l'exterieur
else:
# Une portee par ZONE ou ce role est present : deux machines d'un meme
# role dans deux zones differentes ne partagent pas leur patte.
_zones = sorted({_if_de(m.get("reseau")) for m in _machines_site
if _role == "serveur_debian"
or _role in _services_site.get(m["nom"], [])})
_portees = [(_a, z) for z in _zones] or [(_a, if_site)]
# `derive` se resout par le port declare au plan ; sans lui, on n'emet
# rien plutot qu'une regle que le boitier refusera.
_p = _ports(_fl)
if any(not str(x).isdigit() for x in _p):
_reel = _ports_site.get(_role)
if not _reel:
# SUR STDERR, JAMAIS STDOUT : ce script rend du JSON, et une
# note au milieu le rend illisible a `appliquer_opnsense`.
print(f"note : {_role} declare un port `{'/'.join(_p)}` que le "
f"plan du site ne resout pas — aucune regle emise.",
file=sys.stderr)
continue
_p = [_reel]
for _src, _itf in _portees:
regles.append({
"sens": "in" if _entrant else "out",
"interface": _itf,
"protocole": _fl.get("protocole", "tcp"),
"source": _src if _entrant else _a,
"destination": _a if _entrant else (
"SETOPS_FABRIC" if "fabric" in _pairs_fl else "!SETOPS_INTERNES"),
"ports": _p,
"chiffrement": _fl.get("chiffrement"),
"role": _role,
"tenant": "SITE",
"raison": _fl.get("raison", ""),
})
transit = transit_underlay()
saut = prochain_saut(transit)
# UNE ROUTE PAR SOUS-RESEAU REELLEMENT ATTRIBUE, et non une par supernet.
@ -515,6 +818,19 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
}
for nom, pfx, n in tenants
]
# LE SITE AUSSI SORT. Son reseau est directement attache a la frontiere (OPT2), donc
# le mode automatique d'OPNsense le couvrirait — mais nous ne sommes plus en mode
# automatique : declarer les tenants a la main l'a bascule. Compter sur un mode qu'on
# a soi-meme quitte, c'est le genre de supposition qui se paie au premier `apt update`
# du cache racine, quand plus rien ne se telecharge et que tout a l'air correct.
if _machines_site and "SETOPS_SITE" in alias:
nat.append({
"interface": if_wan,
"source": "SETOPS_SITE",
"destination": "any",
"cible": "wanip",
"tenant": "SITE",
})
# Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois :
# elle sert au prochain saut des routes ET au commentaire qui l'explique.

View file

@ -90,9 +90,30 @@ def _verifier_unicite(noms: list[str], tenant: str) -> None:
vus[nom] = role
# Les ports DERIVES ne sont pas des ecoutes fixes : ils dependent de ce que l'hote porte.
# Le pare-feu de l'hyperviseur, lui, ne connait que des nombres.
#
# CONSTATE LE 2026-08-24, A L'APPLICATION : `dport: derive` est parti tel quel vers l'API,
# qui a repondu « invalid port 'derive' » -- six regles refusees. `verifier_ports` savait
# deja sauter un port non numerique ; ce generateur-ci ne le savait pas.
#
# CE QUE CA VEUT DIRE POUR POWERDNS, le seul role concerne : depuis que le resolveur du
# tenant est la seule porte, l'autoritatif n'ecoute que sur 127.0.0.1:5300. Aucune regle
# est-ouest n'a d'objet pour lui — sauter est donc la BONNE reponse, pas un contournement.
#
# Les ports sautes sont RECENSES et affiches avec le devis : un flux qu'on n'applique pas
# doit se voir, sinon c'est un trou silencieux.
PORTS_DERIVES: set[str] = set()
def _ports(fl: dict) -> list[str]:
p = fl["port"]
return [str(x) for x in (p if isinstance(p, list) else [p])]
tous = [str(x) for x in (p if isinstance(p, list) else [p])]
fixes = [x for x in tous if x.isdigit()]
for x in tous:
if not x.isdigit():
PORTS_DERIVES.add(x)
return fixes
def construire(tenants: list[tuple[str, str, dict]]) -> dict:
@ -370,6 +391,15 @@ def main(argv: list[str]) -> int:
f"{total_g} groupe(s), {total_r} regle(s).")
return 0
print(json.dumps(devis, indent=2, ensure_ascii=False) if args.json else rendre(devis))
# UN FLUX QU'ON N'APPLIQUE PAS DOIT SE VOIR. Sauter un port derive est la bonne
# reponse, mais le taire en ferait un trou silencieux.
if PORTS_DERIVES and not args.json:
print(f"\n note : {len(PORTS_DERIVES)} port(s) DERIVE(s) non rendus ici — "
f"{', '.join(sorted(PORTS_DERIVES))}.")
print(" Un port derive depend de ce que l'hote porte ; le pare-feu de "
"l'hyperviseur ne connait que des nombres.")
print(" Cas connu : PowerDNS derriere le resolveur du tenant n'ecoute que sur "
"127.0.0.1 — aucune regle est-ouest n'a d'objet pour lui.")
return 0

View file

@ -87,8 +87,20 @@ def _bloc_gestion(underlay: dict | None, nom_switch: str | None) -> list[str]:
if not r or not r.get("sous_reseau"):
return []
net = ipaddress.ip_network(r["sous_reseau"], strict=False)
out = [f"interface Vlan{r['vlan']}", f" description gestion {nom_switch}",
f" ip address {h['ip']} {net.netmask}", " no shutdown"]
# LE SEGMENT DE GESTION PEUT NE PAS ETRE ETIQUETE (2026-08-25).
#
# Un switch declare son adresse sur un reseau ; si ce reseau est un SEGMENT PHYSIQUE
# (`segment_physique: true`, aucune encapsulation), il n'existe aucune `interface
# VlanN` a configurer — l'adresse appartient a l'interface de gestion native du
# boitier, dont le nom depend du modele. On le DIT plutot que d'inventer une syntaxe :
# un devis qui promet une commande fausse est pire qu'un devis qui se tait.
if r.get("vlan") is None:
out = [f"! gestion {nom_switch} : le segment '{r['nom']}' n'est pas etiquete —",
f"! {h['ip']} {net.netmask} va sur l'interface de gestion native du",
"! boitier (nom dependant du modele), pas sur une interface VLAN."]
else:
out = [f"interface Vlan{r['vlan']}", f" description gestion {nom_switch}",
f" ip address {h['ip']} {net.netmask}", " no shutdown"]
gw = r.get("passerelle")
if gw:
porteur = next((x for x in underlay_mod.hotes(underlay)
@ -98,6 +110,25 @@ def _bloc_gestion(underlay: dict | None, nom_switch: str | None) -> list[str]:
return out
# UN SEGMENT SANS ETIQUETTE N'APPARTIENT A AUCUNE FABRIC DE COMMUTATEUR (2026-08-25).
#
# Un reseau peut se declarer `segment_physique: true` : le plan d'administration de
# Chezlepro arrive sur un PORT dedie de la frontiere, sans encapsulation, et ne traverse
# aucun switch — mesure du 2026-08-24, trois sondes muettes avec temoin positif. Il n'a
# donc pas de cle `vlan`.
#
# Ce devis emettait `r['vlan']` en une vingtaine d'endroits. Le premier segment non
# etiquete a leve un `KeyError`, l'API du panneau a rendu une 500, et la vue Reseau de
# l'interface est restee VIDE — une panne qui ne ressemble a rien, deux couches plus haut
# que sa cause. Colmater les vingt appels aurait laisse le vingt-et-unieme.
#
# On filtre donc A LA SOURCE, et une seule fois : ce fichier ne configure que des
# commutateurs, et un commutateur n'a rien a dire d'un segment qui ne l'atteint pas.
def reseaux_de_fabric(underlay: dict | None, fabric: str) -> list[dict]:
return [r for r in underlay_mod.reseaux_de_fabric(underlay, fabric)
if r.get("vlan") is not None]
def section_underlay(underlay: dict | None) -> list[str]:
"""VLANs + SVIs (si passerelle) de la fabric du routeur. Vide si aucun underlay.
@ -106,7 +137,7 @@ def section_underlay(underlay: dict | None) -> list[str]:
leurs VLAN sur ces switches serait faux, et les mettre dans leurs trunks aussi.
"""
fabric = underlay_mod.fabric_du_routeur(underlay)
reseaux = underlay_mod.reseaux_de_fabric(underlay, fabric)
reseaux = reseaux_de_fabric(underlay, fabric)
if not reseaux:
return []
out = [f"! ----- 0. Underlay — fabric '{fabric}' (cluster-global, hors tenant) -----"]
@ -152,7 +183,7 @@ def section_underlay(underlay: dict | None) -> list[str]:
for autre in underlay_mod.fabriques(underlay):
if autre == fabric:
continue
r_autres = underlay_mod.reseaux_de_fabric(underlay, autre)
r_autres = reseaux_de_fabric(underlay, autre)
out.append(f"! HORS PERIMETRE — fabric '{autre}' : "
+ ", ".join(f"{r['nom']} (VLAN {r['vlan']})" for r in r_autres))
out.append("! Portee par des switches distincts, sans cable commun avec celle-ci :")
@ -283,7 +314,7 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str,
if not r_nom:
return []
fabric = underlay_mod.fabric_du_routeur(underlay)
mgmt = next((r for r in underlay_mod.reseaux_de_fabric(underlay, fabric)
mgmt = next((r for r in reseaux_de_fabric(underlay, fabric)
if r.get("passerelle")), None)
# SOURCE UNIQUE : `switches_acces()` decide qui est un switch d'acces — reseau de
# management ET role `switch`. Une copie locale de ce filtre avait laisse les
@ -306,7 +337,7 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str,
# Le lien de frontiere N'EST PLUS reserve au routeur : depuis que les noeuds de
# sortie EVPN y ont une patte, un hyperviseur branche sur un switch d'acces doit
# pouvoir l'atteindre. Le sauter ici ferait tomber son trafic sortant en silence.
for r in underlay_mod.reseaux_de_fabric(underlay, fabric):
for r in reseaux_de_fabric(underlay, fabric):
out += [f"vlan {r['vlan']}", f" name {r['nom']}"]
if underlay_mod.routage_tenants(underlay) == "sdn":
out.append("! Aucun VLAN de tenant : ils vivent dans le SDN, pas sur le fil.")
@ -357,7 +388,7 @@ def reseaux_par_via(underlay: dict | None, role: str) -> dict[str, list[dict]]:
"""
par_via: dict[str, dict[str, dict]] = {}
fab = underlay_mod.fabric_du_routeur(underlay)
connus = {r.get("nom"): r for r in underlay_mod.reseaux_de_fabric(underlay, fab)
connus = {r.get("nom"): r for r in reseaux_de_fabric(underlay, fab)
if r.get("vlan") is not None}
for h in underlay_mod.hotes(underlay):
if h.get("role") != role or not h.get("reseau"):
@ -382,7 +413,7 @@ def vlans_du_role(underlay: dict | None, role: str,
noms = {h.get("reseau") for h in underlay_mod.hotes(underlay)
if h.get("role") == role and h.get("reseau")}
fab = underlay_mod.fabric_du_routeur(underlay)
reseaux = [r for r in underlay_mod.reseaux_de_fabric(underlay, fab)
reseaux = [r for r in reseaux_de_fabric(underlay, fab)
if r.get("nom") in noms and r.get("vlan") is not None]
if not reseaux and repli:
reseaux = [repli]
@ -486,7 +517,7 @@ def switch_route(underlay: dict | None) -> bool:
cas d'erreur — disparait d'elle-meme.
"""
r_nom = underlay_mod.routeur(underlay)
for r in underlay_mod.reseaux_de_fabric(underlay, underlay_mod.fabric_du_routeur(underlay)):
for r in reseaux_de_fabric(underlay, underlay_mod.fabric_du_routeur(underlay)):
gw = r.get("passerelle")
if not gw:
continue
@ -736,7 +767,7 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
# perdraient leur propre adresse de gestion. Un port terminal porte ce que son hote
# declare ; un lien de fabric porte ce qui doit traverser.
vlans_fabric = [str(r["vlan"])
for r in underlay_mod.reseaux_de_fabric(
for r in reseaux_de_fabric(
underlay, underlay_mod.fabric_du_routeur(underlay))
if r.get("vlan") is not None]
# En SDN, les VLAN tenants n'existent pas sur le fil : le trunk ne porte que
@ -753,7 +784,7 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
# Aucun hyperviseur declare : on ne peut rien deriver de leurs rattachements.
# Emettre TOUT l'underlay est le repli sur, et il faut le dire — un devis muet
# ferait croire qu'il n'y a pas de port a configurer, ce qui est faux.
par_via_hyp = {"": [r for r in underlay_mod.reseaux_de_fabric(
par_via_hyp = {"": [r for r in reseaux_de_fabric(
underlay, underlay_mod.fabric_du_routeur(underlay)) if r.get("vlan") is not None]}
out += ["! Aucun hote `role: hyperviseur` declare dans underlay.yml : ce port porte",
"! TOUT l'underlay, faute de mieux. Les declarer rendrait ce bloc precis —",

View file

@ -172,6 +172,47 @@ def generer() -> dict:
sdn = underlay_mod.routage_tenants(underlay_mod.charger()) == "sdn"
index_tenant = nomenclature.get("index")
# UNE MACHINE SANS FONCTION DECLAREE N'A PAS D'ADRESSE — ET LE DISAIT PAS (2026-08-24).
#
# `deriver_nomenclature(...) or {}` avale l'echec : une fonction absente de
# `nomenclature.fonctions` rend un dictionnaire vide, et la machine entre dans
# l'inventaire avec `ansible_host: None` et `vlan: None`. La generation se declare
# REUSSIE. La panne n'apparait qu'au deploiement, sous une forme incomprehensible —
# Ansible tentant de joindre une adresse qui n'existe pas.
#
# Constate en ajoutant `ops-01` aux modeles : `presence-web` range son socle en zone 1
# (« Fondations ») et n'a pas de categorie 4. La machine a ete posee, la fonction non,
# et rien ne l'a signale.
#
# On refuse en BLOC plutot qu'a la premiere fautive : l'exploitant corrige son plan
# une fois, pas une machine a la fois.
# CE GENERATEUR EST CELUI DES TENANTS, ET DE PERSONNE D'AUTRE (2026-08-24).
#
# J'y avais ajoute une branche « adressage declare » pour y faire entrer les machines
# d'un SITE. C'etait le debut d'une confusion : un site et un tenant ne sont pas deux
# variantes d'une meme chose. Un site est le TERRAIN, un tenant ce qui y pousse.
#
# Le symptome etait visible tout de suite — une branche `si l'adresse est declaree`,
# un `nomenclature.yml` de site reduit a une coquille vide, et un site exclu des devis
# par ABSENCE d'index plutot que par nature. Une exclusion fondee sur un manque est
# une exclusion qu'un ajout innocent casserait.
#
# Les machines de l'hebergeur se declarent desormais dans `underlay.yml`, a cote des
# switches et des hyperviseurs — du mobilier de fabric. Ce qui se partage entre les
# deux mondes, ce sont les ROLES, pas la forme du plan.
sans_fonction = [
nom for nom, srv in serveurs.items()
if not (deriver_nomenclature(str(srv.get("fonction", "")),
fonction_seq(nom)[1], nomenclature) or {}).get("adresse_ip")
]
if sans_fonction:
connues = ", ".join(sorted((nomenclature.get("fonctions") or {}))) or "(aucune)"
raise SystemExit(
"Machines sans adresse derivable — leur `fonction` n'est pas declaree dans "
"`nomenclature.fonctions` :\n"
+ "".join(f" - {n} : fonction « {serveurs[n].get('fonction', '')} »\n" for n in sans_fonction)
+ f"Fonctions connues de ce plan : {connues}")
children: dict = {
"modeles_vm": {"hosts": {}},
"hotes_actifs": {"hosts": {}},

View file

@ -166,7 +166,7 @@ CHAMPS_PROXMOX: tuple[tuple, ...] = (
("SETOPS_NOEUD", "proxmox_noeud", False),
("SETOPS_COEURS", "proxmox_coeurs", False),
("SETOPS_MEMOIRE", "proxmox_memoire", False),
# Resolveur d'AMORCAGE, pose par cloud-init. Il ne sert qu'une fois : `client_unbound`
# Resolveur d'AMORCAGE, pose par cloud-init. Il ne sert qu'une fois : `client_resolveur`
# bascule ensuite `/etc/resolv.conf` vers 127.0.0.1. Mais sans lui, la VM nait sans
# resolution de noms et `apt` ne peut meme pas installer Unbound — l'amorcage
# n'aboutit jamais. Le DNS autoritatif du tenant ne convient PAS : il refuse tout ce

View file

@ -181,7 +181,7 @@ def integrations_universelles(racine: Path | None = None) -> dict[str, dict]:
Une integration universelle n'est PAS recopiee sur chaque serveur du plan : le
role la declare une fois dans roles/<role>/meta/integration.yml, et tout hote
la recoit. Le plan ne porte plus que les integrations reellement facultatives
(client_backup, client_smtp, client_unbound) et les exemptions.
(client_backup, client_smtp, client_resolveur) et les exemptions.
Pourquoi : recopiee 14 fois, la meme ligne finit par manquer une quinzieme —
et un hote non supervise, non journalise ou sans certificat ne proteste pas.
@ -480,7 +480,18 @@ def expositions_des_applications(applications: dict, domaines: dict, edge: str |
for fqdn in (app.get("expose") or []):
dom = domaine_parent(fqdn, doms)
conf = doms.get(dom, {}) if dom else {}
edge_dom = conf.get("edge")
# SANS EDGE DECLARE, LE SERVICE SE SERT LUI-MEME (2026-08-25).
#
# `edge` se resout par le DOMAINE : chez un tenant, `domaines.yml` dit quel
# nginx front telle zone, et tout FQDN de cette zone y aboutit. Le SITE n'a
# pas d'edge — chaque service ecoute lui-meme, avec son propre certificat.
#
# Sans ce repli, la derivation rendait `edge: None` : le gabarit de
# `hosts_statiques` cherchait alors `groups[None]`, ne trouvait rien, et
# ecrivait un plancher SANS alias. Le certificat de la forge portait
# `forge.genese.internal`, et ce nom ne resolvait nulle part — du TLS correct
# sur un nom que personne ne pouvait appeler.
edge_dom = conf.get("edge") or app.get("groupe")
if edge is not None and edge_dom != edge:
continue
resultat.append({

View file

@ -928,6 +928,222 @@ def preuve_parente_inscrite() -> tuple[bool, str]:
return r.returncode == 0, (sortie[-1] if sortie else "")
def preuve_devis_frontiere_du_site() -> tuple[bool, str]:
"""Le devis de la frontiere VOIT les machines du site — lues independamment.
POURQUOI CETTE PREUVE EXISTE (2026-08-25). En deplacant le plan du site hors de
`underlay.yml`, `underlay.machines()` s'est vide sans que `devis_opnsense` soit
rebranche. Le devis a cesse de voir le site et a propose de RETIRER 36 objets — tous
ses alias et toutes ses regles. La frontiere aurait laisse tomber la forge du genome,
le cache racine et le runner du site, d'un seul `CONFIRMER=true`.
Rien ne l'a signale : le harnais est reste vert, `ansible-lint` aussi. Seule la
lecture MANUELLE du plan avant application l'a attrape.
ELLE LIT LE PLAN ELLE-MEME, ET C'EST TOUT SON INTERET. Un premier jet passait par
`devis_opnsense._machines_du_plan_site()` — la fonction meme dont la panne etait a
detecter. Eprouve sur la regression reelle, il SE TAISAIT : les deux voyaient le vide,
et la preuve concluait « rien a prouver ». Une preuve qui partage la source de ce
qu'elle verifie ne verifie rien.
Elle se tait quand il n'y a pas de plan de site — un depot sans underlay monte n'a
rien a prouver ici — mais jamais quand le plan existe et que le devis l'ignore.
"""
import ipaddress
sys.path.insert(0, str(RACINE / "scripts"))
import underlay as underlay_mod
import devis_opnsense as D
import devis_reseau
# --- LE TEMOIN : le plan, lu directement, sans passer par le devis ---------
chemin = underlay_mod.chemin()
plan = (chemin.resolve().parent / "plan") if chemin else None
f_srv = (plan / "serveurs.yml") if plan else None
if not f_srv or not f_srv.is_file():
return True, "Aucun plan de site (pas de `plan/serveurs.yml`) : rien a exiger."
serveurs = (yaml.safe_load(f_srv.read_text(encoding="utf-8")) or {}).get("serveurs") or {}
attendus = {n: s for n, s in serveurs.items()
if str(s.get("etat", "actif")) == "actif"}
if not attendus:
return True, "Plan de site sans machine active : rien a exiger."
f_app = plan / "applications.yml"
apps = ((yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {}
) if f_app.is_file() else {}
roles_par_hote: dict[str, list[str]] = {}
for app in apps.values():
if app.get("hote") and app.get("groupe"):
roles_par_hote.setdefault(str(app["hote"]), []).append(str(app["groupe"]))
# --- CE QUE LE DEVIS PRODUIT, au meme perimetre que `make frontiere-plan` --
devis = D.construire(devis_reseau.decouvrir_du_site())
alias = devis.get("alias") or {}
regles = devis.get("regles") or []
manques: list[str] = []
reseaux = [str(x) for x in ((alias.get("SETOPS_SITE") or {}).get("contenu") or [])]
if not reseaux:
manques.append(f"`SETOPS_SITE` est absent ou vide, alors que le plan declare "
f"{len(attendus)} machine(s) : le devis ne voit pas le site")
else:
for nom, srv in attendus.items():
if not any(ipaddress.ip_address(str(srv["ip"])) in
ipaddress.ip_network(r, strict=False) for r in reseaux):
manques.append(f"{nom} ({srv['ip']}) n'est dans aucun sous-reseau de "
f"`SETOPS_SITE`")
for hote, groupes in roles_par_hote.items():
if hote not in attendus:
continue
for g in groupes:
attendu = D.nom_alias(g, "SITE")
if attendu not in alias:
manques.append(f"{hote} porte `{g}` au plan, mais l'alias `{attendu}` "
f"n'est pas au devis")
n_site = sum(1 for r in regles if str(r.get("tenant")) == "SITE")
if not n_site:
manques.append("aucune regle du devis ne porte sur le site : la frontiere ne "
"laisserait rien passer vers ses machines")
if manques:
return False, ("Le devis de la frontiere ne voit pas le site :\n - "
+ "\n - ".join(manques))
return True, (f"Devis de la frontiere : {len(attendus)} machine(s) du plan retrouvees, "
f"{n_site} regle(s) du site.")
def preuve_ordre_des_integrations() -> tuple[bool, str]:
"""Une integration applique SON SERVEUR avant ceux qui s'y adressent.
POURQUOI (2026-08-25). `client_pki` s'executait sur tous les hotes en parallele. Sur
celui qui porte l'autorite, le role reemet le certificat de `step-ca` et RECHARGE le
service ; les quatre autres l'interrogeaient dans cette fenetre et echouaient ensemble
sur `net/http: TLS handshake timeout` — un message qui accuse le reseau alors que la
cause est une course de dix secondes.
Ce n'est pas propre a la PKI. Toute integration a la meme forme : un `client_*`
s'adresse a un `serveur_*`, et l'ordre compte. Le cas le plus dangereux n'est pas le
certificat mais le RESOLVEUR — un hote bascule sur un resolveur pas encore pret
devient muet, et le runner qui devrait le reparer tombe avec lui.
La dependance se declare dans `roles/<client>/meta/integration.yml` (`serveur:`), et
le playbook de groupe en est le miroir. Cette preuve refuse l'ecart entre les deux :
sans elle, la declaration deviendrait un commentaire, et l'ordre une coincidence.
`serveur: ~` est une reponse valable — `client_metrique` pose un exportateur que
Prometheus vient LIRE, il n'attend personne. Toutes les integrations ne dependent pas
d'un service debout, et le dire explicitement vaut mieux que l'omettre.
"""
manques: list[str] = []
vus = 0
for meta in sorted((RACINE / "roles").glob("client_*/meta/integration.yml")):
role = meta.parent.parent.name
d = (yaml.safe_load(meta.read_text(encoding="utf-8")) or {}).get("integration") or {}
if "serveur" not in d:
manques.append(f"{role} : `meta/integration.yml` ne declare pas `serveur:` — "
f"on ne peut donc pas savoir si son ordre est correct "
f"(mettre `~` si l'integration n'attend aucun service)")
continue
srv = d.get("serveur")
pb = RACINE / "playbooks" / "groupes" / f"{role}.yml"
if not pb.is_file():
continue
texte = pb.read_text(encoding="utf-8")
if not srv:
continue # rien a ordonner, et c'est declare
vus += 1
if f"hosts: {role}:&{srv}" not in texte:
manques.append(f"{role} : declare dependre de `{srv}`, mais son playbook "
f"n'applique pas d'abord `{role}:&{srv}`")
if f"hosts: {role}:!{srv}" not in texte:
manques.append(f"{role} : son playbook n'exclut pas `{srv}` du second play — "
f"le serveur serait reconfigure deux fois, et l'ordre perdu")
if "any_errors_fatal: true" not in texte:
manques.append(f"{role} : sans `any_errors_fatal` sur le premier play, un "
f"serveur en echec laisse ses clients s'y raccrocher pour rien")
if manques:
return False, ("L'ordre des integrations n'est pas garanti :\n - "
+ "\n - ".join(manques))
return True, (f"{vus} integration(s) appliquent leur serveur avant leurs clients.")
def preuve_parefeu_proxmox_sdn_seulement() -> tuple[bool, str]:
"""Le pare-feu Proxmox ne s'arme que sur un VNet SDN, jamais sur un pont classique.
POURQUOI (2026-08-25). `firewall=1` fait passer tout le trafic ponte par conntrack.
Sur un VNet SDN c'est sans consequence : en EVPN le routage inter-VNet se fait dans le
VRF, SUR LE NOEUD, et le flux ne quitte jamais l'hyperviseur.
Sur un pont classique route par une frontiere externe, le meme reglage devient un
piege. Deux VM du meme noeud dans deux VLAN differents ne se parlent qu'en EPINGLE :
la trame sort par le lien physique, la frontiere la route, elle revient sur LE MEME
PONT. La meme table conntrack voit les deux moities de la connexion, classe le retour
`INVALID`, et `PVEFW-FORWARD` le jette.
Mesure : sur le site decoupe en quatre zones, les paires intra-noeud etaient a 0/8 et
toutes les paires inter-noeuds a 8/8 ; douze tentatives faisaient monter le compteur
`ctstate INVALID` de +112 sur asgard et +116 sur gandalf. `firewall=0` pose, il ne
bouge plus. Le defaut se deguise en panne reseau : la poignee TCP aboutit, et ce sont
les paquets de DONNEES qui disparaissent — on accuse le MTU, la frontiere, la zone.
L'INTENTION DECLAREE NE SUFFIT PAS : un tenant declare
`proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme valeur
PAR DEFAUT, chaque hote la remplacant par son VNet derive. L'hote qui retombe sur
`vmbr1` naitrait arme sur un pont classique.
Cette preuve n'inspecte pas le texte du playbook : elle EVALUE l'expression qu'il
porte, sur quatre cas. Le cas « intention vraie sur un VNet » doit rendre VRAI —
sinon une expression constamment fausse passerait la preuve sans rien garantir.
"""
import jinja2
pb = RACINE / "playbooks" / "proxmox" / "cloner_vm_debian.yml"
if not pb.is_file():
return False, f"{pb.relative_to(RACINE)} est introuvable."
lignes = [l for l in pb.read_text(encoding="utf-8").splitlines()
if re.match(r"\s*firewall:\s*", l)]
if len(lignes) != 1:
return False, (f"{pb.relative_to(RACINE)} porte {len(lignes)} ligne(s) "
f"`firewall:` — la preuve en attend exactement une, sinon elle ne "
f"sait pas laquelle gouverne la carte.")
expr = lignes[0].split("firewall:", 1)[1].strip().strip('"').strip("'")
# Jinja2 seul ne connait ni le test `match` ni le filtre `bool` — ils viennent
# d'Ansible. On les redonne a l'identique, sinon la preuve echouerait sur l'outil
# plutot que sur ce qu'elle mesure.
env = jinja2.Environment(undefined=jinja2.StrictUndefined)
env.tests["match"] = lambda v, motif: re.match(motif, str(v)) is not None
env.filters["bool"] = lambda v: (v if isinstance(v, bool)
else str(v).strip().lower() in ("true", "yes", "on", "1"))
cas = [ # (intention, pont, attendu, pourquoi)
(True, "t29serv", True, "un VNet SDN : le routage inter-VNet reste sur le noeud"),
(True, "vmbr3", False, "un pont classique : le retour en epingle serait jete"),
(True, "vmbr1", False, "le pont par defaut d'un tenant, si l'hote y retombe"),
(False, "t29serv", False, "l'ecosysteme ne demande pas de filtrage est-ouest"),
]
manques: list[str] = []
for intention, pont, attendu, pourquoi in cas:
try:
rendu = env.from_string(expr).render(
proxmox_clone_parefeu_interface=intention, proxmox_clone_pont=pont)
except jinja2.UndefinedError as e:
manques.append(f"intention={intention} pont={pont} : l'expression reclame "
f"une variable que la preuve ne fournit pas ({e})")
continue
obtenu = str(rendu).strip().lower() in ("true", "yes", "1")
if obtenu is not attendu:
manques.append(f"intention={intention} pont={pont} : rend {obtenu}, "
f"attendu {attendu} — {pourquoi}")
if manques:
return False, ("Le pare-feu Proxmox n'est pas correctement croise avec le pont :"
"\n - " + "\n - ".join(manques))
return True, ("Le pare-feu Proxmox ne s'arme que sur un VNet SDN "
f"({len(cas)} cas evalues, dont un qui doit rendre VRAI).")
def preuve_glossaire_enseigne() -> tuple[bool, str]:
"""Tout mot que le depot emploie devant l'exploitant est explique au glossaire.
@ -1266,6 +1482,12 @@ PREUVES: list[dict] = [
"func": preuve_resolution_unique},
{"id": "P42", "titre": "L'edge porte les noms qu'il publie", "refs": [],
"func": preuve_edge_porte_ses_noms},
{"id": "P44", "titre": "Integrations : le serveur avant ses clients", "refs": [],
"func": preuve_ordre_des_integrations},
{"id": "P45", "titre": "Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs",
"refs": [], "func": preuve_parefeu_proxmox_sdn_seulement},
{"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": [],
"cmds": [[sys.executable, "scripts/verifier_ports.py"]]},
]

Some files were not shown because too many files have changed in this diff Show more