une console pour le site, et un repli qui ouvrait vers l Internet

Icinga Web 2 gagne un troisieme mode, locale : nginx authentifie en HTTP Basic
et pose REMOTE_USER, l application le croit. Un SITE n a ni annuaire ni Keycloak
— ce sont des services d ecosysteme. Ni backend LDAP ni backend de groupes : un
backend qui vise une ressource inexistante fait echouer chaque ouverture de
session. L habilitation nomme alors une personne, entorse a D-66 ecrite plutot
que contournee.

Deux pieges en chemin : resoudre_annuaire etait appele sans condition et tombait
sur NoneType has no len (default sans son second argument ne remplace pas None),
et le flux du role ne nommait que l edge — le site n en a pas, donc personne ne
pouvait entrer. serveur_grafana portait deja la reponse.

LE DEFAUT DU DEVIS : une sortie vers un role ABSENT de l ecosysteme retombait
sur !SETOPS_INTERNES, la forme de vers l Internet. La frontiere aurait autorise
la console a parler LDAPS a n importe quelle machine du monde, pour joindre un
annuaire qui n existe pas. C est une source vide ouvre le port, cote
destination. Trois regles du meme defaut etaient DEJA posees pour postfix.

La frontiere n est pas ecrite : elle porte la production, et le devis attend un
mot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-14 19:11:22 -04:00
parent c308c9c5b0
commit 5ecce4282a
15 changed files with 335 additions and 13 deletions

View file

@ -1,5 +1,90 @@
# CHANGELOG — Set-OPS
## 2026-09-14 (19) — Une console pour le site, et un repli qui ouvrait vers l'Internet
Le site calculait 88 verdicts que personne ne pouvait lire. Il a desormais sa console —
et la construire a revele un defaut du devis de frontiere qui, lui, etait deja applique.
### Un troisieme mode d'authentification : `locale`
Icinga Web 2 n'a pas d'OIDC natif. Ses deux modes existants supposent l'un un ANNUAIRE
(`ldap`), l'autre une PASSERELLE SSO (`external`). **Un SITE n'a ni l'un ni l'autre** :
ce sont des services d'ECOSYSTEME, et l'hebergeur n'en est pas un.
`locale` : nginx authentifie en HTTP Basic et pose `REMOTE_USER` ; l'application croit ce
que le serveur web lui dit — c'est exactement le contrat du backend `external`, avec un
vestibule plus simple. Ni backend d'annuaire, ni backend de groupes, ni ressource LDAP :
un backend `ldap` qui vise une ressource inexistante ne rend pas une liste vide, il fait
ECHOUER chaque ouverture de session.
C'est le meme choix que `serveur_grafana_connexion_locale` et le compte local de Forgejo
au site. **L'habilitation nomme alors une personne**, ce qui contredit D-66 (« le groupe,
jamais des personnes ») — la regle suppose un annuaire, et il n'y en a pas. C'est ecrit
dans `roles.ini.j2` plutot que contourne en silence.
`auth_basic` est pose au niveau du `server`, pas du seul bloc PHP : place la, il aurait
laisse passer tout ce que `try_files` sert directement. Une authentification qu'on
contourne par un chemin voisin n'en est pas une.
### Deux pieges rencontres en chemin
**`resoudre_annuaire` etait appele sans condition**, et tombait sur un plan sans annuaire :
object of type 'NoneType' has no len()
Un message qui ne nomme ni l'annuaire, ni le role qui le demandait, ni la raison. Deux
corrections : le consommateur ne resout plus d'annuaire quand son mode n'en a pas, et
`resoudre_annuaire` emploie `default('', true)` — le second argument dit « remplace aussi
ce qui est FAUX », c'est-a-dire `None`. Sans lui, `None` traverse et heurte `| length`.
**`serveur_icingaweb2` ne declarait son entree que pour l'`edge`.** Le site n'en a pas :
nginx ecoutait, php-fpm repondait, et le pare-feu ne laissait entrer personne.
`serveur_grafana` portait deja la reponse — *joignable depuis le plan d'administration
partout, sans quoi un deploiement sans edge n'a plus de console*. Les deux consoles de
l'observabilite avaient la meme contrainte et une seule des deux l'avait ecrite.
### LE DEFAUT DU DEVIS : un role absent traduit en « tout l'Internet »
`make frontiere-plan` proposait cinq regles. Quatre etaient attendues. La cinquieme :
+ regle opt9 tcp 636 SETOPS_SITE_SERVEUR_ICINGAWEB2 -> !SETOPS_INTERNES
`serveur_icingaweb2` declare une sortie LDAPS vers `serveur_openldap`. Le site n'en a pas.
Le devis cherchait la destination parmi les roles PRESENTS, n'en trouvait aucun, et
retombait sur `!SETOPS_INTERNES` — la forme de « vers l'Internet ». La frontiere aurait
autorise la console a parler LDAPS a **n'importe quelle machine du monde**, pour joindre
un annuaire qui n'existe pas.
C'est « une source vide ouvre le port », cote DESTINATION. Le repli est juste quand le
pair est lointain — un depot Debian, un serveur NTP. Il est faux des que le pair NOMME un
role : le flux ne parle alors pas de l'Internet, il parle d'une machine, et elle n'est pas
la. Le devis distingue desormais les deux, et le dit :
note : serveur_icingaweb2 declare une sortie vers serveur_openldap, absent de ce
site — aucune regle emise (le repli aurait ouvert le port vers l'Internet).
**ET TROIS REGLES DU MEME DEFAUT ETAIENT DEJA POSEES.** Le devis corrige les signale
perimees :
- opt9 TCP 24 SETOPS_SITE_SERVEUR_POSTFIX -> !SETOPS_INTERNES (vers serveur_dovecot)
- opt9 TCP 12345 SETOPS_SITE_SERVEUR_POSTFIX -> !SETOPS_INTERNES (vers serveur_dovecot)
- opt9 TCP 636 SETOPS_SITE_SERVEUR_POSTFIX -> !SETOPS_INTERNES (vers serveur_openldap)
Le relais de courriel du site n'a ni Dovecot ni annuaire a joindre. Ces trois regles ne
pouvaient porter aucun trafic utile — elles ne faisaient qu'elargir la frontiere.
Les retirer est un RETRECISSEMENT.
### Etat
Console deployee, verifiee sur la machine : nginx et php-fpm actifs, `401` sans
identifiants, `verify-full` vers PostgreSQL, **zero** reference LDAP dans les trois `.ini`.
`vault_icingaweb2_admin` pose dans la voute du site avec les memes gardes que la veille,
et ajoute aux quatre gabarits d'ecosysteme.
**LA FRONTIERE N'EST PAS ECRITE.** Elle porte la production, et `frontiere-appliquer`
exige `CONFIRMER=true` — ce garde-fou existe pour un humain. Le devis est lu, les quatre
ajouts et les trois retraits sont compris ; l'ecriture attend un mot.
## 2026-09-14 (18) — La supervision du SITE etait morte depuis 11:10, et rien ne le disait
L'exploitant : *« je ne vois pas de serveur icinga pour le site ? »*. Il y en a un. Il

View file

@ -30,21 +30,21 @@
| 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 : 29 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 30 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 29 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 : 6 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 : 13 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, 4 tenant(s), 49 groupe(s), 88 regle(s). |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 4 tenant(s), 49 groupe(s), 90 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 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 : 5 pool(s) Proxmox, 42 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 | 67 scripts expliques et atteignables, 123 cibles make documentees, 68 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 35 exigence(s) de role, toutes satisfaites (142 cle(s) declaree(s) par l'instance). |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 36 exigence(s) de role, toutes satisfaites (142 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 35 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 46 document(s) declarent leur lecteur (40 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). |
@ -55,14 +55,14 @@
| 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 : 63 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 5 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 154 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 92 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 (128 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 241 regles `pass`), tous non consignes et tous motives. |
| 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 240 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 — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
@ -79,7 +79,7 @@
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 39 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ |
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 3 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (5 exposition(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 10 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 11 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |

View file

@ -54,7 +54,7 @@
| `serveur_icinga` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base relationnelle du moteur Icinga (verify-full). |
| `serveur_icinga` | egress | echo-request | icmp | serveur_debian | n-a | La supervision verifie que ses hotes repondent (hostalive) : sans ce flux, elle les tient tous pour morts et supprime leurs notifications. |
| `serveur_icinga` | egress | echo-request | icmp | fabric | n-a | La supervision verifie que la frontiere sert encore chaque zone. Aucun agent ne peut vivre sur un pare-feu : le controle ACTIF est le seul chemin, et il n'existait pas. |
| `serveur_icingaweb2` | ingress | 8080 | tcp | edge | clair | Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via oauth2-proxy). |
| `serveur_icingaweb2` | ingress | 8080 | tcp | edge, admin | clair | Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via oauth2-proxy), et joignable depuis le plan d'administration là où il n'y a pas d'edge. |
| `serveur_icingaweb2` | egress | 636 | tcp | serveur_openldap | tls-requis | Authentification des utilisateurs sur l'annuaire (LDAPS). |
| `serveur_icingaweb2` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Lecture d'IcingaDB (base relationnelle, verify-full). |
| `serveur_keycloak` | ingress | 8080 | tcp | edge | clair | Console et endpoints OIDC servis au navigateur et aux applications via l'edge (TLS terminé à l'edge). |

View file

@ -42,6 +42,13 @@ vault_forgejo_internal_token: ""
vault_grafana_admin: ""
vault_redis: ""
# LA CONSOLE DE SUPERVISION, QUAND ELLE S'AUTHENTIFIE SEULE.
#
# `serveur_icingaweb2_auth: locale` — le mode d'un SITE, qui n'a ni annuaire ni Keycloak.
# nginx demande ce mot de passe à l'entrée, Icinga Web 2 croit le nom que nginx lui passe.
# Inutile en mode `ldap` ou `external` : le rôle ne l'exige que dans le mode qui s'en sert.
vault_icingaweb2_admin: ""
# LE COMPTE DE METRIQUES DE POSTGRESQL — lecture seule, role `pg_monitor`.
#
# Il ne lit que les vues de statistiques : pas une ligne de donnee applicative. Faire

View file

@ -22,10 +22,20 @@
file: "{{ setops_plan_dir }}/applications.yml"
failed_when: false
# `default('', true)` ET PAS `default('')` — LA DIFFERENCE EST TOUT LE DEFAUT (2026-09-14).
#
# `.get('hote')` rend `None` quand l'application existe sans hote, et `default('')` ne
# remplace que l'INDEFINI : un `None` le traverse intact, puis `| length` le heurte :
#
# object of type 'NoneType' has no len()
#
# Un message qui ne nomme ni l'annuaire, ni le role qui le demandait, ni la raison. Le
# second argument de `default` dit « remplace aussi ce qui est faux » — c'est-a-dire
# `None`, la chaine vide et zero.
- name: Dériver l'hôte de l'annuaire depuis le plan
ansible.builtin.set_fact:
resoudre_annuaire_hote: "{{ (applications | default({})).get(resoudre_annuaire_app, {}).get('hote') }}"
when: ((applications | default({})).get(resoudre_annuaire_app, {}).get('hote') | default('')) | length > 0
when: ((applications | default({})).get(resoudre_annuaire_app, {}).get('hote') | default('', true)) | length > 0
# set_fact (pas les defaults) pour que ces valeurs persistent dans le rôle consommateur
# après include_role (les defaults du rôle inclus ne fuient pas hors de son exécution).

View file

@ -63,6 +63,23 @@ serveur_icingaweb2_groupes_dn: "ou=groups,{{ resoudre_annuaire_base_dn | default
# Backend d'authentification : "ldap" (direct) ou "external" (utilisateur fourni par une
# passerelle SSO devant, ex. oauth2-proxy → Keycloak). En "external", nginx doit poser REMOTE_USER.
# TROIS MODES D'AUTHENTIFICATION, ET LE TROISIEME EST NE POUR LE SITE (2026-09-14).
#
# `ldap` — connexion directe a l'annuaire. Icinga Web 2 n'a pas d'OIDC natif.
# `external` — une passerelle SSO devant (oauth2-proxy -> Keycloak) pose `REMOTE_USER`,
# et un backend LDAP sert a resoudre les GROUPES.
# `locale` — ni annuaire ni passerelle : nginx authentifie lui-meme, et
# l'habilitation nomme une personne.
#
# POURQUOI `locale` EXISTE. Un SITE n'a ni LDAP ni Keycloak — ce sont des services
# d'ecosysteme, et l'hebergeur n'en est pas un. Sans ce mode, la console de supervision
# du site etait impossible : le plan declarait `serveur_icinga` sans `serveur_icingaweb2`,
# et 88 verdicts se calculaient sans que personne puisse les LIRE.
#
# C'EST LE MEME CHOIX QUE POUR GRAFANA ET FORGEJO au site — `connexion_locale`, un compte
# d'administration, un mot de passe en voute. Nommer une personne contredit D-66 (« le
# GROUPE, jamais des personnes ») ; la regle suppose un annuaire, et il n'y en a pas ici.
# On l'ecrit plutot que de la contourner en silence.
serveur_icingaweb2_auth: "ldap"
# Variable nginx d'où vient REMOTE_USER. En SSO : l'en-tête d'oauth2-proxy.
serveur_icingaweb2_remote_user: "$remote_user"
@ -85,3 +102,21 @@ serveur_icingaweb2_bpm_processes: {}
# pile PHP est vivante. Ce qu'ils excluent, c'est le 502 et le 504 — php-fpm mort ou
# bloque — et l'absence de reponse, qui est le nginx local tombe.
serveur_icingaweb2_sonde_codes: "HTTP/1.1 200,HTTP/1.1 302,HTTP/1.1 401,HTTP/1.1 403"
# --- MODE `locale` : ce que nginx demande a l'entree ---------------------------------
#
# LE MOT DE PASSE VIENT DE LA VOUTE, comme partout. Vide, le role REFUSE de deployer en
# mode `locale` — degrader, jamais deviner, et surtout jamais un mot de passe par defaut
# sur une console qui montre l'etat de toute une fabric.
serveur_icingaweb2_admin_utilisateur: "icinga-admin"
serveur_icingaweb2_admin_motdepasse: "{{ vault_icingaweb2_admin | default('') }}"
serveur_icingaweb2_htpasswd: "/etc/nginx/.icingaweb2.htpasswd"
serveur_icingaweb2_realm: "Supervision"
# TLS VERS POSTGRESQL, DERIVE DE CE QUE SERT LE SERVEUR. Voir `resoudre_base` et P78.
#
# P78 nommait ce role comme « sans reglage TLS » — c'etait vrai et sans consequence tant
# qu'il ne tournait nulle part face a un `hostssl`. Au site, il en trouve un.
serveur_icingaweb2_db_sslmode: >-
{{ 'verify-full' if (resoudre_base_db_tls_force | default(false)) else '' }}
serveur_icingaweb2_db_sslrootcert: "/etc/step/certs/root_ca.crt"

View file

@ -2,12 +2,26 @@
# Flux réseau d'Icinga Web 2 (frontal de supervision). Voir docs/flux-conception.md.
# Icinga 2 (API 5665) et IcingaDB (Redis 6380) sont co-localisés : consommés en localhost.
flux:
# `admin` EN PLUS DE `edge` — le meme raisonnement que `serveur_grafana`, qui le portait
# deja et dont la phrase vaut mot pour mot ici : *joignable depuis le plan
# d'administration partout, sans quoi un deploiement sans edge n'a plus de console*.
#
# CE QUE SON ABSENCE COUTAIT (2026-09-14) : le SITE n'a pas d'`infra-edge`. La console
# de supervision y a ete deployee, nginx ecoutait, php-fpm repondait — et le pare-feu
# ne laissait entrer PERSONNE. 88 verdicts lisibles par aucune machine au monde.
#
# Les deux consoles de l'observabilite avaient la meme contrainte et une seule des deux
# l'avait ecrite. En mode `locale`, nginx authentifie lui-meme : ce qui entre par le
# plan d'administration frappe un vestibule, pas l'application.
- sens: ingress
port: 8080
protocole: tcp
pair: edge
pair: [edge, admin]
chiffrement: clair
raison: "Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via oauth2-proxy)."
raison: >-
Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via
oauth2-proxy), et joignable depuis le plan d'administration là où il n'y a pas
d'edge.
- sens: egress
port: 5432
protocole: tcp

View file

@ -5,6 +5,15 @@
vars:
resoudre_base_groupe: "{{ serveur_icingaweb2_icinga_groupe }}"
# ON NE RESOUT PAS UN ANNUAIRE QUAND ON N'EN A PAS (2026-09-14).
#
# `resoudre_annuaire` etait appele SANS CONDITION. Au site — qui n'a ni LDAP ni Keycloak —
# il tombait sur un plan sans application d'annuaire :
#
# object of type 'NoneType' has no len()
#
# Un message qui ne nomme ni l'annuaire, ni le role qui le demandait, ni la raison. Le
# mode `locale` existe precisement pour se passer d'annuaire : il n'a rien a resoudre.
- name: Résoudre l'annuaire (rôle partagé)
ansible.builtin.include_role:
name: resoudre_annuaire
@ -12,6 +21,7 @@
# SON compte, pas celui de l'administrateur de l'annuaire (2026-09-13).
resoudre_annuaire_bind_cn: "icingaweb2"
resoudre_annuaire_secret: "vault_ldap_bind_icingaweb2"
when: serveur_icingaweb2_auth != 'locale'
- name: Adopter la connexion annuaire pour Icinga Web 2
ansible.builtin.set_fact:
@ -22,6 +32,7 @@
serveur_icingaweb2_ldap_bind_dn: "{{ resoudre_annuaire_bind_dn }}"
serveur_icingaweb2_ldap_bind_password: "{{ resoudre_annuaire_bind_password }}"
no_log: true
when: serveur_icingaweb2_auth != 'locale'
# LE CACHE DU CONTROLEUR D'ABORD, LE DEPOT DISTANT POUR LE RESTE (2026-09-03).
#
@ -111,6 +122,75 @@
label: "{{ item.key }}"
when: serveur_icingaweb2_bpm_processes | length > 0
# --- MODE `locale` : LE VESTIBULE, AVANT LA PORTE (2026-09-14) ------------------------
#
# Un SITE n'a ni annuaire ni passerelle SSO — ce sont des services d'ecosysteme. La
# console de supervision etait donc impossible chez lui, et 88 verdicts se calculaient
# sans que personne puisse les lire.
#
# `htpasswd` A LA MAIN PLUTOT QUE LE MODULE `community.general.htpasswd` : ce module
# reecrit le fichier a chaque passage quand le hachage porte un sel aleatoire, et le role
# se declarerait `changed` pour toujours. On hache une fois, on compare, on n'ecrit que
# si ca differe.
- name: Exiger un mot de passe pour la console locale
ansible.builtin.assert:
that:
- serveur_icingaweb2_admin_motdepasse | length > 0
fail_msg: >-
`serveur_icingaweb2_auth: locale` mais `vault_icingaweb2_admin` est vide.
Une console qui montre l'etat de toute une fabric ne s'ouvre pas avec un mot de
passe devine : ajouter la cle a la voute de cet ecosysteme.
when: serveur_icingaweb2_auth == 'locale'
- name: Installer de quoi hacher un mot de passe pour nginx
ansible.builtin.apt:
name: apache2-utils
state: present
when: serveur_icingaweb2_auth == 'locale'
# CE QUI EST DEJA POSE EST-IL LE BON MOT DE PASSE ? `htpasswd -v` le VERIFIE sans rien
# reecrire — c'est lui qui rend la tache idempotente, pas une comparaison de fichiers
# (le sel change a chaque hachage, deux fichiers justes different toujours).
- name: Le mot de passe deja pose est-il le bon ?
ansible.builtin.command:
argv:
- htpasswd
- -vb
- "{{ serveur_icingaweb2_htpasswd }}"
- "{{ serveur_icingaweb2_admin_utilisateur }}"
- "{{ serveur_icingaweb2_admin_motdepasse }}"
register: serveur_icingaweb2_htpasswd_verif
changed_when: false
failed_when: false
no_log: true
when: serveur_icingaweb2_auth == 'locale'
- name: Poser le mot de passe de la console locale
ansible.builtin.command:
argv:
- htpasswd
- -cbB
- "{{ serveur_icingaweb2_htpasswd }}"
- "{{ serveur_icingaweb2_admin_utilisateur }}"
- "{{ serveur_icingaweb2_admin_motdepasse }}"
no_log: true
# ELLE NE S'EXECUTE QUE SI LA VERIFICATION A ECHOUE — donc quand elle tourne, elle
# change vraiment quelque chose. `changed_when: true` n'est pas une complaisance ici :
# c'est l'idempotence qui vit dans le `when`, pas dans un test de sortie.
changed_when: true
when:
- serveur_icingaweb2_auth == 'locale'
- (serveur_icingaweb2_htpasswd_verif.rc | default(1)) != 0
notify: Recharger nginx
- name: Refermer le fichier de mots de passe sur nginx
ansible.builtin.file:
path: "{{ serveur_icingaweb2_htpasswd }}"
owner: root
group: www-data
mode: "0640"
when: serveur_icingaweb2_auth == 'locale'
- name: Déployer le vhost nginx local d'Icinga Web 2
ansible.builtin.template:
src: nginx.conf.j2

View file

@ -1,5 +1,16 @@
; Géré par Set-OPS (rôle serveur_icingaweb2). Ne pas éditer à la main.
{% if serveur_icingaweb2_auth == 'external' %}
{% if serveur_icingaweb2_auth == 'locale' %}
; MODE `locale` : nginx a DEJA authentifie, et pose `REMOTE_USER`. Icinga Web 2 fait
; confiance a ce que le serveur web lui dit — c'est exactement le contrat du backend
; `external`, celui qu'emploie aussi le SSO. La difference n'est pas ici, elle est devant :
; une authentification HTTP Basic au lieu d'une passerelle OIDC.
;
; AUCUN BACKEND D'ANNUAIRE, parce qu'il n'y en a pas. Un site n'a ni LDAP ni Keycloak :
; ce sont des services d'ecosysteme. L'habilitation se fait donc par NOM dans `roles.ini`,
; et c'est ecrit la-bas pourquoi.
[icingaweb2]
backend = "external"
{% elif serveur_icingaweb2_auth == 'external' %}
; SSO : l'utilisateur est fourni par la passerelle (oauth2-proxy → Keycloak) via REMOTE_USER.
[icingaweb2]
backend = "external"

View file

@ -2,6 +2,11 @@
; Habilitation par GROUPE d'annuaire (D-66) : ajouter quelqu'un a
; `{{ serveur_icingaweb2_groupe_admin }}` dans LDAP lui ouvre la supervision, sans
; deploiement et sans que personne ne soit nomme ici.
{% if serveur_icingaweb2_auth == 'locale' %}
; MODE `locale` : AUCUN BACKEND DE GROUPES. Un backend `ldap` qui vise une ressource
; inexistante ne rend pas une liste vide — il fait ECHOUER chaque ouverture de session,
; et l'erreur parle d'annuaire a qui n'en a jamais declare.
{% else %}
[annuaire]
backend = "ldap"
resource = "icingaweb_ldap"
@ -10,3 +15,4 @@ group_class = "{{ serveur_icingaweb2_groupe_class }}"
group_name_attribute = "cn"
group_member_attribute = "{{ serveur_icingaweb2_groupe_membre_attr }}"
base_dn = "{{ serveur_icingaweb2_groupes_dn }}"
{% endif %}

View file

@ -9,6 +9,22 @@ server {
root {{ serveur_icingaweb2_docroot }};
index index.php;
{% if serveur_icingaweb2_auth == 'locale' %}
# MODE `locale` : C'EST NGINX QUI AUTHENTIFIE.
#
# Icinga Web 2 n'a pas d'OIDC natif et un SITE n'a ni annuaire ni passerelle SSO.
# HTTP Basic devant `external` est le contrat que l'application attend deja — le
# serveur web dit QUI, l'application le croit. C'est exactement ce que fait
# oauth2-proxy chez un locataire, avec un vestibule plus simple.
#
# AU NIVEAU DU `server`, PAS DU `location` : place dans le seul bloc PHP, la
# protection sauterait tout ce que `try_files` sert directement — feuilles de style,
# icones, et surtout les fichiers que `location ~ /\.ht` croit couvrir. Une
# authentification qu'on peut contourner par un chemin voisin n'en est pas une.
auth_basic "{{ serveur_icingaweb2_realm }}";
auth_basic_user_file {{ serveur_icingaweb2_htpasswd }};
{% endif %}
location / {
try_files $uri $uri/ /index.php$is_args$args;

View file

@ -8,6 +8,14 @@ dbname = "{{ resoudre_base_entree.base }}"
username = "{{ resoudre_base_entree.proprietaire }}"
password = "{{ resoudre_base_db_password }}"
charset = "UTF8"
{% if serveur_icingaweb2_db_sslmode | default('') %}
; LE SERVEUR IMPOSE `hostssl` : sans ces deux lignes, PostgreSQL refuse la connexion et
; l'erreur parle de `pg_hba`, pas de chiffrement. Derive de ce que sert le serveur — voir
; `resoudre_base` et P78.
ssl_mode = "{{ serveur_icingaweb2_db_sslmode }}"
ssl_ca = "{{ serveur_icingaweb2_db_sslrootcert }}"
{% endif %}
{% if serveur_icingaweb2_auth != 'locale' %}
[icingaweb_ldap]
type = "ldap"
@ -18,3 +26,4 @@ root_dn = "{{ serveur_icingaweb2_ldap_base }}"
bind_dn = "{{ serveur_icingaweb2_ldap_bind_dn }}"
bind_pw = "{{ serveur_icingaweb2_ldap_bind_password }}"
timeout = "5"
{% endif %}

View file

@ -1,9 +1,22 @@
; Géré par Set-OPS (rôle serveur_icingaweb2). Ne pas éditer à la main.
[Administrators]
{% if serveur_icingaweb2_auth == 'locale' %}
; ICI ON NOMME QUELQU'UN, ET C'EST UNE ENTORSE ASSUMEE A D-66.
;
; « Le groupe, jamais des personnes » suppose un annuaire ou le groupe existe. Un SITE
; n'en a pas : ni LDAP, ni Keycloak — ce sont des services d'ecosysteme, et l'hebergeur
; n'en est pas un. Nommer un groupe absent ne protegerait personne, il empecherait
; seulement d'entrer.
;
; Le jour ou un site porte un annuaire, ce role bascule en `ldap` ou `external` et cette
; branche cesse de s'appliquer — sans rien reecrire.
users = "{{ serveur_icingaweb2_admin_utilisateur }}"
{% else %}
; Le GROUPE, jamais des personnes (D-66) : revoquer quelqu'un ne demande alors
; aucun deploiement. `serveur_icingaweb2_admins` reste un repli, vide par defaut.
groups = "{{ serveur_icingaweb2_groupe_admin }}"
{% if serveur_icingaweb2_admins | length > 0 %}
users = "{{ serveur_icingaweb2_admins }}"
{% endif %}
{% endif %}
permissions = "*"

View file

@ -33,6 +33,12 @@ from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[1]
# LES ROLES QUE LE DEPOT CONNAIT — pour distinguer un PAIR QUI NOMME UN ROLE d'un mot-cle
# de portee (`externe`, `flotte`, `admin`, `edge`, `voisins_site`, `frontiere`, `fabric`,
# `localhost`). La distinction decide de ce qu'on fait d'une destination introuvable :
# ignorer le flux, ou l'ouvrir vers l'Internet. Les deux n'ont rien a voir.
_ROLES_CONNUS = {d.name for d in (RACINE / "roles").iterdir() if d.is_dir()}
sys.path.insert(0, str(RACINE / "scripts"))
from inventory_rules import sous_reseau_de, supernet_de # noqa: E402
@ -971,8 +977,31 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
_dests = ["SETOPS_FABRIC"]
else:
_internes = sorted(set(_pairs_fl) & set(_roles_site))
_dests = ([nom_alias(_x, "SITE") for _x in _internes]
if _internes else ["!SETOPS_INTERNES"])
if _internes:
_dests = [nom_alias(_x, "SITE") for _x in _internes]
elif set(_pairs_fl) & _ROLES_CONNUS:
# UN PAIR QUI NOMME UN ROLE ABSENT N'EST PAS « L'INTERNET ».
#
# Mesure du 2026-09-14, au devis et avant toute ecriture :
# `serveur_icingaweb2` declare une sortie LDAPS vers
# `serveur_openldap`. Un SITE n'a pas d'annuaire — ce sont des
# services d'ecosysteme. `_internes` etait donc vide, et le repli
# rendait `!SETOPS_INTERNES` : la frontiere aurait autorise la
# console a parler LDAPS a N'IMPORTE QUELLE machine du monde,
# pour joindre un annuaire qui n'existe pas.
#
# C'est « une source vide ouvre le port », cote DESTINATION. Le
# repli est juste quand le pair est un mot-cle lointain
# (`externe`, un depot Debian, un serveur NTP) ; il est faux des
# que le pair NOMME un role, parce qu'alors le flux ne parle pas
# de l'Internet — il parle d'une machine, et elle n'est pas la.
print(f"note : {_role} declare une sortie vers "
f"{'/'.join(sorted(set(_pairs_fl) & _ROLES_CONNUS))}, "
f"absent de ce site — aucune regle emise (le repli aurait "
f"ouvert le port vers l'Internet).", file=sys.stderr)
continue
else:
_dests = ["!SETOPS_INTERNES"]
for _src, _itf in _portees:
for _dst in _dests:
regles.append({

View file

@ -636,6 +636,13 @@ def inventaire() -> dict:
"serveur_postgresql_tls_actif": True,
"serveur_grafana_oidc_actif": False,
"serveur_grafana_connexion_locale": True,
# LA CONSOLE DE SUPERVISION S'AUTHENTIFIE SEULE, AU SITE (2026-09-14).
#
# Meme raison que pour Grafana juste au-dessus : un SITE n'a ni annuaire ni
# Keycloak — ce sont des services d'ECOSYSTEME, et l'hebergeur n'en est pas
# un. Les deux autres modes d'Icinga Web 2 (`ldap`, `external`) supposent
# l'un ou l'autre ; ici, nginx authentifie et l'application le croit.
"serveur_icingaweb2_auth": "locale",
# Le depot du site, pour les machines du site qui y deposent. Vide s'il n'y
# en a pas — `client_backup` refuse alors, plutot que de viser un nom qui
# ne repond pas : mieux vaut un deploiement qui s'arrete qu'une sauvegarde