From bef06551127e4c68dec86aca3f4fc5bc9c65798b Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sat, 12 Sep 2026 19:08:31 -0400 Subject: [PATCH] le site reconstruit depuis zero : 7/7, 0 echec, et seize corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SITE-Chezlepro n avait jamais ete rase. La limite qu on repetait partout — l infrastructure d accueil n a jamais ete reconstruite depuis zero — se lisait comme de la prudence. C etait seize defauts que rien d autre n aurait pu reveler. Un locataire naît dans un monde deja peuple : le site lui fournit paquets, noms, genome, heure et depot. Un site n a personne au-dessus, sauf sa frontiere. Onze des seize murs viennent de la. DEUX CAPACITES QUI N EXISTAIENT PAS. make site-raser — rien ne detruisait les machines du site, donc la limite etait un trou d outillage. make forge-amorcer — la forge naît vide et le runner y clone ; l amorcage part du poste, seul endroit qui detienne alors le genome. TROIS GARDES QUI VERIFIAIENT LA FORME. La plus couteuse des familles : elles donnent l apparence d une verification. Le resolveur comparait des adresses au lieu de mesurer si la resolution aboutit, et protegeait ainsi l etat casse. L administration etait reconnue a son port. Un flux a deux paires n obtenait qu une branche. UN ECART DE SECURITE. Le PostgreSQL du site servait le certificat auto-signe de Debian, sans reseaux autorises ni hostssl — invisible tant qu aucun client n exigeait la verification. Le defaut n a pas casse la construction : la construction a revele le defaut. UN ACCES ACCIDENTEL. Celui de l exploitant tenait au chevauchement d adressage que le renumerotage a supprime. Separer les index n a pas cause le probleme, il a retire le hasard qui le masquait. La sequence du premier jour est ecrite : runbooks §9, avec les seize murs et ce que chacun enseigne. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q --- CHANGELOG.md | 85 +++++++++++ Makefile | 16 ++ docs/audit/preuve-2026-09-12.md | 12 +- docs/registre-flux.md | 6 +- docs/runbooks-exploitation.md | 90 ++++++++++++ playbooks/groupes/serveur_debian.yml | 27 +++- roles/client_pki/tasks/main.yml | 42 +++++- roles/serveur_debian/meta/flux.yml | 36 +++++ roles/serveur_forgejo/meta/flux.yml | 11 +- roles/serveur_icinga/tasks/main.yml | 19 +++ scripts/devis_opnsense.py | 24 ++- scripts/forge_amorcer.py | 212 +++++++++++++++++++++++++++ scripts/site_inventaire.py | 30 ++++ 13 files changed, 597 insertions(+), 13 deletions(-) create mode 100644 scripts/forge_amorcer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbfb72..40013aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,90 @@ # CHANGELOG — Set-OPS +## 2026-09-12 (2) — Le site est reconstruit depuis zero, et il a fallu seize corrections + +`SITE-Chezlepro` n'avait jamais ete rase. Il avait ete monte par ajouts successifs, sur des +semaines, avec un service deja debout a chaque etape. La limite qu'on repetait partout — +« l'infrastructure d'accueil n'a jamais ete reconstruite depuis zero » — se lisait comme de +la prudence. + + 7 machines 0 echec 0 injoignable 46 couches + 16:31 -> 19:00 creation 12 min 08 s, puis HUIT passages de deploiement + +### Ce qui rend un site different d'un locataire + +Un locataire naît dans un monde deja peuple : le site lui fournit les paquets, les noms, le +genome, l'heure et le depot de sauvegarde. **Un site n'a personne au-dessus de lui**, sauf +sa frontiere. Tout ce qu'un locataire recoit, un site doit se le donner — et pendant qu'il +se le donne, il ne l'a pas. + +Onze des quinze murs viennent de la. + +### Deux capacites qui n'existaient pas + +**`make site-raser`.** `raser.py` ne visait que l'instance active — un locataire. Rien ne +detruisait les machines du site. Ce n'est donc pas que personne n'avait essaye de le +reconstruire : **l'outil n'en offrait pas le moyen**. La limite etait un trou d'outillage, +pas une fatalite. Les quatre verrous de `raser.py` sont repris tels quels. + +**`make forge-amorcer`.** Le runner clone le genome depuis SA PROPRE forge, et +`serveur_forgejo` ne cree ni organisation ni depot. A froid la forge naît vide. Le geste +part du POSTE, et c'est structurel : a cet instant il est le seul endroit qui detienne le +genome. Un amorcage vient toujours de l'exterieur de ce qu'il amorce. + +### Trois gardes qui verifiaient la forme au lieu du resultat + +C'est la famille la plus couteuse : elles donnent l'apparence d'une verification. + +**Le resolveur.** « Le `resolv.conf` nomme-t-il deja le resolveur declare ? » — juste en +regime etabli. A froid, les sept machines naissent avec `nameserver `, qui est +l'une des sept et n'a aucun resolveur. La garde concluait « elle pointe deja au bon +endroit » et **protegeait l'etat casse**. Elle mesure desormais si la resolution ABOUTIT. + +**L'administration reconnue a son port.** `elif "22" in _ports(fl)` — juste tant que le seul +flux administratif etait SSH. L'exploitant declare `admin` sur le 443 de sa forge, la machine +accepte, la frontiere refuse, et les deux couches se croient d'accord. + +**Un flux a deux paires n'obtient qu'une branche.** La forge declare `pair: [flotte, admin]`; +la chaine de `elif` rangeait le flux dans le premier cas et la part administrative +disparaissait en silence. La portee administrative s'AJOUTE desormais. + +### Un ecart de securite, que seule la construction pouvait montrer + +Le PostgreSQL du SITE servait le certificat AUTO-SIGNE du paquet Debian, pas celui de son +AC — `serveur_postgresql_tls_actif` vaut `false` par defaut et le site ne le declarait +nulle part, quand le locataire le met a `true`. Ni `reseaux_autorises`, ni `tls_force`. + +Personne ne l'avait vu parce qu'aucun client n'exigeait la verification. Le premier a +l'exiger — l'import du schema Icinga DB — a echoue sur `certificate verify failed`. +**Le defaut n'a pas casse la construction : la construction a revele le defaut.** + +### L'acces de l'exploitant etait accidentel + +L'ancien plan d'administration `10.17.0.0/24` vivait DANS `10.17.0.0/16`, que les +expositions du site acceptent deja pour les locataires. Sortir le site de ce supernet a +revele qu'**aucune declaration n'avait jamais accorde cet acces**. + +C'est l'explication complete d'une mesure du matin meme — « le site a une console deployee +et sans chemin d'acces ». Ce n'etait pas Grafana : c'etait tout le site. La decision de +separer les index n'a pas cause le probleme, elle a **retire le hasard qui le masquait**. + +### Deux passages, au minimum + +`client_pki` pose les droits d'une cle pour un consommateur qu'une couche ULTERIEURE cree. +Au premier passage le groupe `git` n'existe pas, la cle reste `root:root` — donc FERMEE, +jamais plus ouverte — et Forgejo ne demarre qu'au second. + +C'etait d'abord un blocage CIRCULAIRE : l'echec arretait le play avant la couche qui aurait +cree le groupe. Rejouer n'y changeait rien. La tache constate desormais l'absence et +reporte, au lieu d'echouer. + +### La sequence est ecrite + +`docs/runbooks-exploitation.md` §9 — « Le premier jour d'un site », avec les seize murs et +ce que chacun enseigne. Le seizieme s'est montre en ECRIVANT cette entree : `make +wiki-publier` refuse, parce que Forgejo ne cree `.wiki.git` qu'a la premiere page. +Sans cette section, le prochain site les rencontrerait tous. + ## 2026-09-12 (1) — Un site prend son propre index, et la garde les compte enfin Decision de l'exploitant : **sites et locataires se partagent la classe A**, chacun avec diff --git a/Makefile b/Makefile index bc13e79..680e325 100644 --- a/Makefile +++ b/Makefile @@ -1109,6 +1109,22 @@ genome-pousser: ## Le runner du site pousse le genome sur la forge du site — D playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS) .PHONY: site-creer +forge-amorcer: ## Amorce la forge d'un SITE neuf avec le genome — CONFIRMER=true + @# LE MAILLON QUI N'EXISTAIT PAS (2026-09-12, premiere reconstruction du site depuis + @# zero). Le runner clone le genome depuis SA PROPRE forge, et `serveur_forgejo` ne + @# cree ni organisation ni depot : a froid la forge naît vide et le clone echoue. + @# + @# Le deploiement n'avait jamais rencontre ce cas — la forge existait depuis le premier + @# jour, remplie a la main, a une date que personne n'a notee. + @# + @# DEPUIS LE POSTE, et c'est structurel : a cet instant il est le SEUL endroit qui + @# detienne le genome. Un amorcage vient toujours de l'exterieur de ce qu'il amorce. + @# + @# export SETOPS_FORGE_MDP=… puis make forge-amorcer CONFIRMER=true + python3 scripts/forge_amorcer.py $(if $(filter true,$(CONFIRMER)),--confirmer) \ + $(if $(FORGE),--forge "$(FORGE)") + +.PHONY: forge-amorcer site-raser: ansible-runtime ## DESTRUCTIF : detruit les VM du SITE — CONFIRMER=true ET SITE= @# CETTE CIBLE MANQUAIT, ET SON ABSENCE AVAIT UN NOM (2026-09-12). `raser` ne vise que @# l'instance ACTIVE — un locataire. Rien ne detruisait les machines du site : ce n'est diff --git a/docs/audit/preuve-2026-09-12.md b/docs/audit/preuve-2026-09-12.md index c7c24d9..0950c1b 100644 --- a/docs/audit/preuve-2026-09-12.md +++ b/docs/audit/preuve-2026-09-12.md @@ -21,7 +21,7 @@ | 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), 1 nom(s) surveille(s) sans reference orpheline. | | P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 41 groupes classes, aucun cycle, aucune arete en arriere. | -| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 107 flux, schéma + matrice OK. | +| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 109 flux, schéma + matrice OK. | | P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). | | P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site | | P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. | @@ -43,7 +43,7 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, 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 | 61 scripts expliques et atteignables, 118 cibles make documentees, 68 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 62 scripts expliques et atteignables, 119 cibles make documentees, 68 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (138 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 34 revendication(s) de port, aucune collision entre roles co-localises (36 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 44 document(s) declarent leur lecteur (38 genere(s) exempte(s)). | @@ -53,16 +53,16 @@ | P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 41 role(s) serveur/client tous nommes, 41 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 : 57 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | +| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 58 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 : 7 machine(s) du plan retrouvees, 151 regle(s) du site. | +| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 153 regle(s) du site. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. | | P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). | | P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos | | P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). | | P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 89 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | -| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (125 lignes). | -| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 219 regles `pass`), tous non consignes et tous motives. | +| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (127 lignes). | +| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 239 regles `pass`), tous non consignes et tous motives. | | P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 | | P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. | | P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b | diff --git a/docs/registre-flux.md b/docs/registre-flux.md index adc62ef..7dd2991 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -29,6 +29,8 @@ | `serveur_debian` | ingress | 22 | tcp | flotte, externe | ssh | Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense). | | `serveur_debian` | ingress | echo-request | icmp | serveur_icinga | n-a | La supervision verifie que ce noeud repond (hostalive). Sans lui, elle le tient pour mort et supprime ses notifications. | | `serveur_debian` | ingress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » entrant : sans lui, un distant ne peut pas nous demander de réduire nos paquets — les transferts se figent. | +| `serveur_debian` | egress | 53 | udp | frontiere | n-a | Résolution de noms à l'amorçage, avant que le résolveur de l'écosystème n'existe. Sans elle, les machines d'un site neuf ne peuvent pas résoudre leurs dépôts de paquets — et rien ne peut donc s'installer, y compris le résolveur lui-même. | +| `serveur_debian` | egress | 53 | tcp | frontiere | n-a | Réponses longues et bascule TCP, obligatoires en DNS. Déclarer l'UDP sans le TCP donne une résolution qui marche jusqu'à la première réponse tronquée. | | `serveur_debian` | egress | 80 | tcp | externe | clair | Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport). | | `serveur_debian` | egress | 123 | udp | frontiere | n-a | Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de l'écosystème. Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. | | `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre. | @@ -39,7 +41,7 @@ | `serveur_dovecot` | egress | 636 | tcp | serveur_openldap | tls-requis | userdb/passdb : Dovecot résout et authentifie les comptes sur l'annuaire (LDAPS). | | `serveur_forge_site` | ingress | 443 | tcp | voisins_site | tls-requis | Servir le génome aux écosystèmes de ce site : c'est de cette forge qu'ils clonent leur moteur, leurs modèles et la carte de la fabric (D-81). Sans ce flux, un écosystème neuf ne peut pas se reproduire. | | `serveur_forgejo` | ingress | 3000 | tcp | edge | clair | 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. | -| `serveur_forgejo` | ingress | derive | tcp | flotte | tls | 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. | +| `serveur_forgejo` | ingress | derive | tcp | flotte, admin | tls | 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. | | `serveur_forgejo` | egress | 25 | tcp | serveur_postfix | starttls | Notifications courriel (relais via le MTA Postfix). | | `serveur_forgejo` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC et jetons auprès de Keycloak (via son FQDN publié à l'edge). | | `serveur_forgejo` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Forgejo (verify-full). | @@ -117,7 +119,7 @@ ## Synthèse chiffrement - **clair** : 39 flux -- **n-a** : 6 flux +- **n-a** : 8 flux - **ssh** : 8 flux - **starttls** : 6 flux - **tls** : 11 flux diff --git a/docs/runbooks-exploitation.md b/docs/runbooks-exploitation.md index fc646df..76b5119 100644 --- a/docs/runbooks-exploitation.md +++ b/docs/runbooks-exploitation.md @@ -326,3 +326,93 @@ laissé le premier renommage, jusqu'au passage de `client_pki`. > Un `INJOIGNABLE` ne condamne pas le service : il dit que **ce poste** n'a pas pu ouvrir > la connexion. Les zones du SITE ne sont pas routées depuis le plan d'administration du > locataire — le mur est la frontière, pas le vhost. + +## 9. Le premier jour d'un site — la séquence, et les seize murs + +> **Écrit le 2026-09-12**, au sortir de la première reconstruction d'un site depuis zéro. +> Avant elle, `SITE-Chezlepro` n'avait jamais été rasé : il avait été monté par ajouts +> successifs, sur des semaines, avec un service déjà debout à chaque étape. +> +> La limite qu'on répétait — « l'infrastructure d'accueil n'a jamais été reconstruite +> depuis zéro » — se lisait comme de la prudence. C'était **seize défauts** que rien +> d'autre n'aurait pu révéler. + +### Ce qui rend un site différent d'un locataire + +Un locataire naît dans un monde déjà peuplé : le site lui fournit les paquets, les noms, +le génome, l'heure et le dépôt de sauvegarde. **Un site n'a personne au-dessus de lui**, +sauf sa frontière. Tout ce qu'un locataire reçoit, un site doit se le donner — et pendant +qu'il se le donne, il ne l'a pas. + +C'est de là que viennent onze des quinze murs. + +### La séquence, dans l'ordre + +```bash +# 0. AVANT TOUT — l'état sort du bâtiment +make depot-hors-site VERS= + +# 1. Les VM, depuis l'underlay +make site-creer CONFIRMER=true # ~12 min pour 7 machines + +# 2. Le résolveur d'amorçage, DÉCLARÉ dans le plan du site +# plan/10-intrants.yml : dns_amorcage: +# Sans lui, les machines pointent sur le DNS du site — qui est l'une d'elles. + +# 3. Le déploiement, DEUX FOIS au minimum +ansible-playbook -i scripts/site_inventaire.py playbooks/site.yml \ + -e "@$(dirname "$(readlink -f underlay.yml)")/underlay.vault.yml" + +# 4. Entre les deux passages : amorcer la forge +export SETOPS_FORGE_MDP=… # jamais en argument de ligne de commande +make forge-amorcer CONFIRMER=true + +# 5. Rejouer jusqu'à 0 échec +``` + +**La voûte se passe en `-e @`** — `make site-appliquer` la dérive du symlink `underlay.yml`, +mais il n'existe aucune cible qui déploie le site EN ENTIER. Un `ansible-playbook` direct +l'oublie, et l'échec parle d'une assertion, jamais d'un fichier manquant. + +**Deux passages, au minimum, et ce n'est pas un contournement.** `client_pki` pose les +droits d'une clé pour un consommateur qu'une couche ultérieure crée : au premier passage le +groupe `git` n'existe pas, la clé reste fermée, et Forgejo ne démarre qu'au second. + +### Les quinze murs, et ce que chacun enseigne + +| # | Le mur | Ce qu'il enseigne | +|---|---|---| +| 1 | Aucun moyen de raser le site | `make site-raser` — la limite était un trou d'outillage, pas une fatalité | +| 2 | La forge naît vide, le runner y clone | `make forge-amorcer` — un amorçage vient de l'extérieur de ce qu'il amorce | +| 3 | `dns_amorcage` pointe sur le DNS du site | le mécanisme existait, la **surcharge** n'avait jamais été posée | +| 4 | La garde du résolveur teste l'adresse écrite | **une adresse écrite ne prouve pas qu'elle répond** | +| 5 | Aucune cible « déployer tout le site » | la voûte n'était jointe nulle part à la séquence complète | +| 6 | `client_pki` bloque sur un groupe absent | blocage **circulaire** : l'échec empêchait d'atteindre ce qui créait le groupe | +| 7 | PostgreSQL du site sans TLS de l'AC | un écart de **sécurité**, révélé par le premier client exigeant `verify-full` | +| 8 | `pg_hba` n'autorisait personne | le site ne déclarait aucun réseau client | +| 9 | `/etc/setops` absent sur le dépôt | un rôle supposait qu'une couche ultérieure était déjà passée | +| 10 | Forgejo attend 300 s une clé illisible | une attente devrait abandonner quand la cause est déjà au journal | +| 11 | La clé SSH de l'exploitant inconnue de la forge | une forge neuve ne connaît personne | +| 12 | L'accès de l'exploitant était **accidentel** | il tenait au chevauchement d'adressage que le renumérotage a supprimé | +| 13 | Le devis reconnaît l'administration à son port | `"22" in ports` plutôt que `"admin" in pairs` | +| 14 | Un flux à deux paires n'obtient qu'une branche | la chaîne de `elif` rangeait `[flotte, admin]` dans un seul cas | +| 15 | Dépôts créés privés, runner anonyme | `could not read Username` — un message qui pointe ailleurs que sa cause | +| 16 | Le wiki n'existe pas sur une forge neuve | Forgejo ne crée `.wiki.git` qu'à la **première page**, posée à la main dans l'interface | + +### Le motif + +Onze des seize sont **du code juste en régime établi**, faux le premier jour : un résolveur +qui se pointe sur lui-même, une clé dont le consommateur n'existe pas encore, un répertoire +créé par une couche ultérieure, une forge vide qu'on croit remplie. + +Trois sont des **gardes qui vérifiaient la forme au lieu du résultat**. C'est la famille la +plus coûteuse : elles donnent l'apparence d'une vérification. + +Un seul touchait la sécurité — et il était invisible tant qu'aucun client n'exigeait la +vérification. **Le défaut n'a pas cassé la construction : la construction a révélé le +défaut.** + +> **Pour le prochain site.** Poser `dns_amorcage` dès le départ, prévoir deux passages, +> amorcer la forge entre les deux, déclarer `nftables_admin_ssh` — sans quoi l'exploitant ne +> peut pas atteindre ce qu'il vient de construire — et créer la première page du wiki dans +> l'interface avant `make wiki-publier`. diff --git a/playbooks/groupes/serveur_debian.yml b/playbooks/groupes/serveur_debian.yml index 347adf5..45a1307 100644 --- a/playbooks/groupes/serveur_debian.yml +++ b/playbooks/groupes/serveur_debian.yml @@ -104,6 +104,29 @@ failed_when: false changed_when: false + # UNE ADRESSE ECRITE NE PROUVE PAS QU'ELLE REPOND (2026-09-12). + # + # La garde ci-dessous se contentait de comparer des ADRESSES : « le resolv.conf + # nomme-t-il deja le resolveur declare ? ». En regime etabli c'est juste — on ne + # clobbere pas un resolveur qui marche. A froid c'est exactement a l'envers. + # + # Mesure a la premiere reconstruction du site depuis zero : les sept machines + # naissent avec `nameserver 10.37.34.11`, l'adresse de `site-dns-01` — qui est + # l'une des sept et n'a encore aucun resolveur. La garde concluait « elle pointe + # deja au bon endroit », n'ecrivait rien, et `apt update` echouait sur les sept. + # La garde protegeait l'etat casse. + # + # ON MESURE DONC LE RESULTAT, pas la forme : le resolveur en place resout-il ? + # C'est la meme lecon que `client_resolveur` enonce a cote — « un resolv.conf qui + # pointe vers un service muet » — appliquee un cran plus tot. + - name: Le résolveur en place répond-il ? + ansible.builtin.command: + argv: ["getent", "ahostsv4", "deb.debian.org"] + register: serveur_debian_resolution + failed_when: false + changed_when: false + timeout: 15 + - name: Poser le résolveur d'amorçage (avant tout apt) ansible.builtin.copy: dest: /etc/resolv.conf @@ -144,8 +167,8 @@ - dns_amorcage is defined - dns_amorcage | string | length > 0 - "'127.0.0.1' not in serveur_debian_resolv_texte" - - serveur_debian_resolveurs_declares - | select('in', serveur_debian_resolv_texte) | list | length == 0 + # Le resolveur en place ne resout pas : peu importe l'adresse qu'il porte. + - serveur_debian_resolution.rc | default(1) != 0 roles: # LE PLANCHER AVANT LE PREMIER `apt` (2026-08-25). diff --git a/roles/client_pki/tasks/main.yml b/roles/client_pki/tasks/main.yml index 8f7946f..a70b7eb 100644 --- a/roles/client_pki/tasks/main.yml +++ b/roles/client_pki/tasks/main.yml @@ -313,15 +313,55 @@ # 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. +# LE CONSOMMATEUR N'EXISTE PAS ENCORE AU PREMIER JOUR (2026-09-12). +# +# `client_pki` (couche `pki_client`) precede `serveur_forgejo` (couche `apps`), et c'est +# le paquet de Forgejo qui cree le groupe `git`. Sur une machine deja construite le +# groupe est la, herite d'un passage precedent ; a froid il n'existe pas : +# +# chgrp failed: failed to look up group git +# +# ET C'ETAIT UN BLOCAGE CIRCULAIRE, pas un ordre qui converge : l'echec arretait le play +# pour cet hote AVANT la couche qui aurait cree le groupe. Rejouer n'y changeait rien — +# mesure deux fois, le 2026-09-12. +# +# ON NE FABRIQUE PAS LE GROUPE ICI : le paquet du service le creera avec le GID qu'il +# veut, et un groupe pose d'avance l'obligerait a composer avec le notre. On constate +# son absence, on laisse la cle en `root:root` — donc FERMEE, jamais plus ouverte — et +# le passage suivant, une fois le service installe, donne l'acces. +- name: Le groupe qui doit lire la clé existe-t-il ? + ansible.builtin.getent: + database: group + key: "{{ client_pki_cle_groupe }}" + fail_key: false + register: client_pki_groupe_lu + when: client_pki_cle_groupe != 'root' + changed_when: false + - 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' + when: + - client_pki_cle_groupe != 'root' or client_pki_cle_mode != '0600' + - client_pki_cle_groupe == 'root' + or (client_pki_groupe_lu.ansible_facts.getent_group | default({})) + .get(client_pki_cle_groupe) is not none notify: Recharger les consommateurs du cert +- name: Dire que l'accès à la clé attend son consommateur + ansible.builtin.debug: + msg: >- + Le groupe « {{ client_pki_cle_groupe }} » n'existe pas encore : la clé reste en + root:root, donc FERMEE. Le service qui doit la lire n'est pas installé — le + passage suivant, après sa couche, lui donnera l'accès. + when: + - client_pki_cle_groupe != 'root' + - (client_pki_groupe_lu.ansible_facts.getent_group | default({})) + .get(client_pki_cle_groupe) is none + # 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. diff --git a/roles/serveur_debian/meta/flux.yml b/roles/serveur_debian/meta/flux.yml index bb390a8..b5e3091 100644 --- a/roles/serveur_debian/meta/flux.yml +++ b/roles/serveur_debian/meta/flux.yml @@ -55,6 +55,42 @@ flux: partage: true raison: "La supervision verifie que ce noeud repond (hostalive). Sans lui, elle le tient pour mort et supprime ses notifications." + # LA FRONTIÈRE EST AUSSI L'AUTORITÉ DE NOMS D'AMORÇAGE (2026-09-12). + # + # TROUVÉ À LA PREMIÈRE RECONSTRUCTION DU SITE DEPUIS ZÉRO. Les sept machines du site + # naissent avec `nameserver 10.37.34.11` — `site-dns-01`, qui est elle-même une des + # sept et n'a pas encore de résolveur. `site-dns-01` pointe donc sur elle-même, et + # `apt update` échoue sur les sept : + # + # Erreur temporaire de résolution de « deb.debian.org » + # + # Le locataire n'a jamais ce problème : son résolveur est le SITE, qui existe déjà. + # Le site, lui, n'a personne au-dessus — sauf la frontière, dont l'Unbound tourne et + # écoute sur `*:53`. Il ne manquait que le passage. + # + # CE QUE CETTE RÈGLE NE DIT PAS. « Toute la flotte résout ici, et nulle part ailleurs » + # (`serveur_resolveur/meta/flux.yml`) reste la règle en régime établi : le résolveur + # de l'écosystème est le seul que les machines INTERROGENT, parce que c'est lui que + # leur `/etc/resolv.conf` nomme. Cette règle n'ouvre qu'une possibilité, celle qui + # rend l'amorçage possible — et sans elle, un écosystème neuf ne peut pas naître. + - sens: egress + port: 53 + protocole: udp + pair: frontiere + chiffrement: n-a + raison: >- + Résolution de noms à l'amorçage, avant que le résolveur de l'écosystème n'existe. + Sans elle, les machines d'un site neuf ne peuvent pas résoudre leurs dépôts de + paquets — et rien ne peut donc s'installer, y compris le résolveur lui-même. + - sens: egress + port: 53 + protocole: tcp + pair: frontiere + chiffrement: n-a + raison: >- + Réponses longues et bascule TCP, obligatoires en DNS. Déclarer l'UDP sans le TCP + donne une résolution qui marche jusqu'à la première réponse tronquée. + # L'HEURE VIENT DE LA FRONTIÈRE, PAS D'INTERNET (2026-09-11). # # C'était `pair: externe` : les quatorze machines interrogeaient `2.debian.pool.ntp.org`, diff --git a/roles/serveur_forgejo/meta/flux.yml b/roles/serveur_forgejo/meta/flux.yml index 5716ed2..d2ca39f 100644 --- a/roles/serveur_forgejo/meta/flux.yml +++ b/roles/serveur_forgejo/meta/flux.yml @@ -30,7 +30,16 @@ flux: - sens: ingress port: derive protocole: tcp - pair: flotte + # `admin` EN PLUS DE `flotte` (2026-09-12). Sans edge devant elle, la forge du SITE + # n'etait joignable que par les machines de l'ecosysteme — jamais par l'exploitant. + # Ca passait inapercu tant que son plan d'administration vivait dans le supernet d'un + # locataire, que les expositions acceptent deja. Une fois le site sorti de ce + # supernet, l'exploitant ne pouvait plus ni amorcer sa forge ni y pousser le genome. + # + # `admin` se resout depuis `nftables_admin_ssh` : un ecosysteme qui ne declare aucun + # administrateur n'emet aucune regle, et c'est le comportement voulu — une source + # vide ouvrirait le port a tous. + pair: [flotte, admin] chiffrement: tls raison: >- Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du diff --git a/roles/serveur_icinga/tasks/main.yml b/roles/serveur_icinga/tasks/main.yml index 470f4ab..9fd9453 100644 --- a/roles/serveur_icinga/tasks/main.yml +++ b/roles/serveur_icinga/tasks/main.yml @@ -378,6 +378,25 @@ src: "{{ serveur_icinga_ca }}" register: serveur_icinga_ca_contenu +# LE REPERTOIRE N'EXISTE PAS AU PREMIER JOUR (2026-09-12). `/etc/setops` est cree par +# `client_sante`, qui vit dans une couche POSTERIEURE. Sur une machine deja construite il +# est la ; a froid, non — et `copy` ne cree pas ses parents : +# +# Destination directory /etc/setops does not exist +# +# On le pose donc ici, avec les memes droits que `client_sante` lui donnerait. Deux roles +# qui creent le meme repertoire ne se genent pas ; un role qui suppose qu'un autre est +# deja passe, si. +- name: Assurer le repertoire d'accueil sur le depot de sauvegarde + when: groups['serveur_backup'] | default([]) | length > 0 + ansible.builtin.file: + path: "{{ serveur_icinga_ca_destination_depot | dirname }}" + state: directory + owner: root + group: root + mode: "0755" + delegate_to: "{{ groups['serveur_backup'] | first }}" + - name: Deposer l'AC sur le depot de sauvegarde when: groups['serveur_backup'] | default([]) | length > 0 ansible.builtin.copy: diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 43dbeba..4fd1297 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -796,6 +796,17 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # jamais la zone entiere. _portees = [("SETOPS_SITE", z) for z in sorted({_if_de(m.get("reseau")) for m in _machines_site})] + # UN FLUX A DEUX PAIRES N'OBTIENT QU'UNE BRANCHE (2026-09-12). + # + # La chaine de `elif` range chaque flux dans UN cas. La forge du site + # declare `pair: [flotte, admin]` : `flotte` capte la premiere, et la + # part administrative disparait sans que rien ne le dise. Mesure a la + # premiere reconstruction — la machine acceptait l'exploitant, la + # frontiere le refusait, et les deux couches se croyaient d'accord. + # + # On AJOUTE la portee administrative au lieu d'en faire un cas exclusif. + if "admin" in _pairs_fl and _admin_site: + _portees += [("SETOPS_ADMIN_SITE", if_gestion)] elif _entrant and "fabric" in _pairs_fl: # LA FABRIC PARLE AUSSI AU SITE (2026-09-10). # @@ -828,7 +839,18 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: _portees = [("SETOPS_FABRIC", _if_de(r)) for r in _res_fabric] if not _portees: continue - elif _entrant and "22" in _ports(_fl) and _admin_site: + elif _entrant and ("admin" in _pairs_fl or "22" in _ports(_fl)) and _admin_site: + # `admin` N'EST PAS QUE LE SSH (2026-09-12). Cette branche testait le + # PORT — « si c'est du 22, ça vient de l'administration ». Juste tant + # que le seul flux administratif etait SSH. + # + # Mesure a la premiere reconstruction du site : l'exploitant declare + # `admin` sur le 443 de sa forge pour pouvoir l'amorcer, la machine + # accepte, et la frontiere continue de refuser — parce que le devis ne + # reconnaissait l'administration qu'a son port. Deux couches, une seule + # au courant : c'est la panne la plus silencieuse de cette pile. + # + # On teste desormais la PAIRE DECLAREE, et le port reste ce qu'il est. # 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. diff --git a/scripts/forge_amorcer.py b/scripts/forge_amorcer.py new file mode 100644 index 0000000..89b3ab0 --- /dev/null +++ b/scripts/forge_amorcer.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Amorce la forge d'un SITE neuf avec le genome. Le maillon qui n'existait pas. + +POURQUOI CE SCRIPT EXISTE (2026-09-12, premiere reconstruction du site depuis zero). + +Le runner du site clone le genome depuis SA PROPRE forge — `serveur_ops_forge_hote` +derive de `groups['serveur_forgejo']`. L'ordre des couches est juste : `serveur_forgejo` +precede `serveur_ops`. Mais `roles/serveur_forgejo` ne cree NI organisation NI depot : a +froid, la forge naît vide, et le `git clone` du runner echoue apres trois essais. + +LE DEPLOIEMENT N'AVAIT JAMAIS RENCONTRE CE CAS parce que la forge existait depuis le +premier jour, remplie a la main, a une date que personne n'a notee. C'est exactement ce +qu'une limite « jamais reconstruit depuis zero » cache : pas une fragilite, un MAILLON +QUI N'AVAIT JAMAIS ETE FABRIQUE PAR LE CODE. + +POURQUOI DEPUIS LE POSTE, ET PAS DEPUIS UN ROLE. A cet instant precis, le poste est le +SEUL endroit qui detienne le genome : la forge est vide et le runner n'existe pas encore. +Un role qui tenterait de se servir lui-meme tournerait en rond. C'est un geste d'amorcage, +et un amorcage vient toujours de l'exterieur de ce qu'il amorce. + +LA LISTE VIENT DE LA MEME DECLARATION QUE LE RUNNER LIT — `serveur_ops_depots` dans le +plan du site. Ecrire une seconde liste ici la ferait diverger de celle qui compte, et ce +depot passe son temps a supprimer ce genre de paire. + +Usage : + python3 scripts/forge_amorcer.py # montre ce qui serait pousse + python3 scripts/forge_amorcer.py --confirmer +""" +from __future__ import annotations + +import argparse +import base64 +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +FRERES = RACINE.parent +sys.path.insert(0, str(RACINE / "scripts")) + +import underlay as underlay_mod # noqa: E402 + + +def _depots_declares() -> list[dict]: + """`serveur_ops_depots` du plan du SITE — la source que le runner lit.""" + import yaml + plan = underlay_mod.lire_plan_site("serveurs.yml") or {} + for srv in (plan.get("serveurs") or {}).values(): + d = ((srv.get("variables") or {}).get("serveur_ops_depots")) + if d: + return list(d) + return [] + + +def _api(base: str, chemin: str, methode: str = "GET", corps: dict | None = None, + auth: str = "") -> tuple[int, object]: + req = urllib.request.Request(f"{base}/api/v1{chemin}", method=methode) + req.add_header("Authorization", f"Basic {auth}") + req.add_header("Content-Type", "application/json") + donnees = json.dumps(corps).encode() if corps is not None else None + import ssl + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE # la racine step-ca n'est pas encore au magasin du poste + try: + with urllib.request.urlopen(req, donnees, timeout=25, context=ctx) as r: + return r.status, json.loads(r.read() or b"null") + except urllib.error.HTTPError as e: + return e.code, (e.read() or b"").decode()[:200] + except OSError as e: + return 0, str(e) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--forge", help="URL de la forge (defaut : derivee du plan du site)") + ap.add_argument("--organisation", default="genome") + ap.add_argument("--utilisateur", default="forgejo-admin") + ap.add_argument("--cle-publique", + default=str(Path.home() / ".ssh" / "id_ed25519_ansible_chezlepro.pub"), + help="cle publique a enregistrer dans la forge neuve (pour le push SSH)") + ap.add_argument("--confirmer", action="store_true") + a = ap.parse_args() + + depots = _depots_declares() + if not depots: + print("Aucun `serveur_ops_depots` au plan du site : rien a amorcer.") + return 2 + + # L'hote de la forge vient du plan, comme pour le runner. + import yaml + apps = (underlay_mod.lire_plan_site("applications.yml") or {}).get("applications") or {} + hote = next((str(x.get("hote")) for x in apps.values() + if str(x.get("groupe")) == "serveur_forgejo"), "") + fqdn = next((str(f) for x in apps.values() + if str(x.get("groupe")) == "serveur_forgejo" + for f in (x.get("expose") or [])), "") + base = a.forge or (f"https://{fqdn}" if fqdn else "") + if not base: + print("La forge du site ne se derive pas du plan : passer --forge.") + return 2 + + print(f"Forge : {base} (machine {hote or '?'})") + print(f"Organisation : {a.organisation}\n") + + manquants, prets = [], [] + for d in depots: + local = FRERES / str(d.get("dest")) + if not (local / ".git").is_dir(): + manquants.append(f"{d['dest']} — absent du poste ({local})") + else: + prets.append((str(d["depot"]), local, str(d.get("branche") or "main"))) + + for nom, local, branche in prets: + print(f" {nom:22} <- {local.name:20} branche {branche}") + for m in manquants: + print(f" MANQUANT {m}") + + if manquants: + print(f"\nREFUS : {len(manquants)} depot(s) declare(s) n'existent pas sur ce poste.\n" + f"Amorcer une forge avec une liste incomplete la rendrait credible et fausse :\n" + f"le runner clonerait ce qui est la et echouerait sur le reste, plus tard.") + return 2 + + if not a.confirmer: + print(f"\n{len(prets)} depot(s) seraient crees puis pousses.\n" + f"Relancer avec : make forge-amorcer CONFIRMER=true") + return 0 + + mdp = os.environ.get("SETOPS_FORGE_MDP", "") + if not mdp: + print("\nREFUS : mot de passe de l'administrateur de la forge absent.\n" + " export SETOPS_FORGE_MDP=\"$(ansible-vault view … | …)\" puis relancer.\n" + "Il n'est pas lu du depot : un secret ne traverse pas un argument de ligne\n" + "de commande, ou il finirait dans l'historique du shell.") + return 2 + auth = base64.b64encode(f"{a.utilisateur}:{mdp}".encode()).decode() + + # LA FORGE NEUVE NE CONNAIT PERSONNE (2026-09-12). Les remotes `origin` poussent en + # `ssh://git@…` ; une Forgejo fraiche rejette la cle de l'exploitant — `Permission + # denied (publickey)` — parce qu'aucune cle n'y est enregistree. Amorcer sans cela + # revient a creer des depots qu'on ne peut pas remplir. + cle = Path(a.cle_publique) + if cle.is_file(): + st, rep = _api(base, "/user/keys", "POST", + {"title": f"amorcage-{cle.stem}", "key": cle.read_text().strip()}, auth) + if st in (201, 200): + print(f" cle publique {cle.name} : enregistree") + elif st == 422: + print(f" cle publique {cle.name} : deja la") + else: + print(f" cle publique {cle.name} : ECHEC {st} {rep}") + return 2 + else: + print(f" cle publique absente ({cle}) — le push SSH echouera") + return 2 + + st, _ = _api(base, f"/orgs/{a.organisation}", auth=auth) + if st == 404: + st, rep = _api(base, "/orgs", "POST", {"username": a.organisation}, auth) + print(f" organisation {a.organisation} : " + + ("creee" if st in (201, 200) else f"ECHEC {st} {rep}")) + elif st == 200: + print(f" organisation {a.organisation} : deja la") + else: + print(f" organisation {a.organisation} : ECHEC {st}") + return 2 + + echecs = 0 + for nom, local, branche in prets: + st, _ = _api(base, f"/repos/{a.organisation}/{nom}", auth=auth) + if st == 404: + st, rep = _api(base, f"/orgs/{a.organisation}/repos", "POST", + {"name": nom, "private": False}, auth) + # PUBLIC DANS LE PERIMETRE DE LA FORGE, ET C'EST VOULU (2026-09-12). + # + # Cree en `private` au premier essai — par reflexe. Le runner clone + # ANONYMEMENT : la tache `git` de `serveur_ops` ne porte aucun identifiant, + # et l'echec ne dit pas « depot prive », il dit : + # + # could not read Username for 'https://forge.genese.internal' + # + # La forge n'est joignable que depuis les zones du site, les supernets + # locataires et le plan d'administration : « public » y signifie « lisible + # par ceux qui ont deja franchi la frontiere ». Les secrets, eux, sont dans + # des voutes chiffrees — c'est la qu'est la protection, pas dans un drapeau + # de visibilite qui casserait la reproduction. + if st not in (201, 200): + print(f" {nom:22} ECHEC creation {st} {rep}") + echecs += 1 + continue + # `--force` JAMAIS : si le depot porte deja quelque chose, on veut le savoir, + # pas l'ecraser. Un amorcage n'a de sens que sur une forge vide. + r = subprocess.run(["git", "-C", str(local), "push", "origin", branche], + capture_output=True, text=True, timeout=180) + if r.returncode == 0: + print(f" {nom:22} pousse ({branche})") + else: + print(f" {nom:22} ECHEC push : {(r.stderr or '').strip().splitlines()[-1][:90]}") + echecs += 1 + + print(f"\n{len(prets) - echecs}/{len(prets)} depot(s) amorces.") + return 1 if echecs else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index bb6ab53..f7ed237 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -479,6 +479,36 @@ def inventaire() -> dict: # Les deux lignes vont ensemble : eteindre l'une sans allumer l'autre # laisse un Grafana sans aucune porte. Le role refuse desormais cette # combinaison, plutot que de la deployer. + # LE POSTGRESQL DU SITE ETAIT HORS DE LA DOCTRINE (2026-09-12). + # + # `serveur_postgresql_tls_actif` vaut `false` par defaut, et le tenant le + # met a `true` dans ses group_vars. Le SITE ne le mettait nulle part : son + # PostgreSQL servait le certificat AUTO-SIGNE du paquet Debian + # (`ssl-cert-snakeoil.pem`), pas celui de son AC. + # + # PERSONNE NE L'AVAIT VU parce qu'aucun client du site n'exigeait la + # verification. Le premier a l'exiger — l'import du schema Icinga DB, qui + # se connecte par le FQDN — a echoue sur `certificate verify failed`, et + # c'est ainsi que l'ecart s'est montre. Le defaut n'a pas casse la + # construction : la construction a revele le defaut. + # + # Ce n'est pas un blocage d'amorcage, c'est un trou de zero-confiance + # est-ouest : le site chiffrait sans que personne ne verifie a qui. + # ET LES CLIENTS DOIVENT ETRE NOMMES. Le tenant declare + # `serveur_postgresql_reseaux_autorises: [supernet]` ; le site ne declarait + # rien, donc `pg_hba` n'autorisait que le local : + # + # aucune entree dans pg_hba.conf pour l hote 10.37.36.11, + # utilisateur icingadb, chiffrement SSL + # + # Les zones du site, et elles seules — pas un `0.0.0.0/0` qui rendrait le + # verrou `hostssl` decoratif. + "serveur_postgresql_reseaux_autorises": _zones_du_site, + # `tls_force` pose `hostssl` : toute connexion NON chiffree est refusee. + # On ne l'active qu'apres avoir confirme que le certificat servi vient bien + # de l'AC — c'est fait, mesure a 17:31 ce jour. + "serveur_postgresql_tls_force": True, + "serveur_postgresql_tls_actif": True, "serveur_grafana_oidc_actif": False, "serveur_grafana_connexion_locale": True, # Le depot du site, pour les machines du site qui y deposent. Vide s'il n'y