From 5ecce4282a59ed331150c1c08f7adefb590a9fe1 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 14 Sep 2026 19:11:22 -0400 Subject: [PATCH] une console pour le site, et un repli qui ouvrait vers l Internet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 85 +++++++++++++++++++ docs/audit/preuve-2026-09-14.md | 12 +-- docs/registre-flux.md | 2 +- exemples/vault.exemple.yml | 7 ++ roles/resoudre_annuaire/tasks/main.yml | 12 ++- roles/serveur_icingaweb2/defaults/main.yml | 35 ++++++++ roles/serveur_icingaweb2/meta/flux.yml | 18 +++- roles/serveur_icingaweb2/tasks/main.yml | 80 +++++++++++++++++ .../templates/authentication.ini.j2 | 13 ++- .../templates/groups.ini.j2 | 6 ++ .../templates/nginx.conf.j2 | 16 ++++ .../templates/resources.ini.j2 | 9 ++ .../serveur_icingaweb2/templates/roles.ini.j2 | 13 +++ scripts/devis_opnsense.py | 33 ++++++- scripts/site_inventaire.py | 7 ++ 15 files changed, 335 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7845672..7d488a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/audit/preuve-2026-09-14.md b/docs/audit/preuve-2026-09-14.md index c2c637a..e6348d9 100644 --- a/docs/audit/preuve-2026-09-14.md +++ b/docs/audit/preuve-2026-09-14.md @@ -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. | diff --git a/docs/registre-flux.md b/docs/registre-flux.md index e082cb2..aa7fd61 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -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). | diff --git a/exemples/vault.exemple.yml b/exemples/vault.exemple.yml index 7fc22f7..811e5c0 100644 --- a/exemples/vault.exemple.yml +++ b/exemples/vault.exemple.yml @@ -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 diff --git a/roles/resoudre_annuaire/tasks/main.yml b/roles/resoudre_annuaire/tasks/main.yml index 0939411..79bc3db 100644 --- a/roles/resoudre_annuaire/tasks/main.yml +++ b/roles/resoudre_annuaire/tasks/main.yml @@ -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). diff --git a/roles/serveur_icingaweb2/defaults/main.yml b/roles/serveur_icingaweb2/defaults/main.yml index a5ebadb..fa95b72 100644 --- a/roles/serveur_icingaweb2/defaults/main.yml +++ b/roles/serveur_icingaweb2/defaults/main.yml @@ -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" diff --git a/roles/serveur_icingaweb2/meta/flux.yml b/roles/serveur_icingaweb2/meta/flux.yml index 60692f6..ee38916 100644 --- a/roles/serveur_icingaweb2/meta/flux.yml +++ b/roles/serveur_icingaweb2/meta/flux.yml @@ -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 diff --git a/roles/serveur_icingaweb2/tasks/main.yml b/roles/serveur_icingaweb2/tasks/main.yml index 8ff1c87..0458e37 100644 --- a/roles/serveur_icingaweb2/tasks/main.yml +++ b/roles/serveur_icingaweb2/tasks/main.yml @@ -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 diff --git a/roles/serveur_icingaweb2/templates/authentication.ini.j2 b/roles/serveur_icingaweb2/templates/authentication.ini.j2 index 82f472a..9bb9e7a 100644 --- a/roles/serveur_icingaweb2/templates/authentication.ini.j2 +++ b/roles/serveur_icingaweb2/templates/authentication.ini.j2 @@ -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" diff --git a/roles/serveur_icingaweb2/templates/groups.ini.j2 b/roles/serveur_icingaweb2/templates/groups.ini.j2 index 2ade2b9..21b32ce 100644 --- a/roles/serveur_icingaweb2/templates/groups.ini.j2 +++ b/roles/serveur_icingaweb2/templates/groups.ini.j2 @@ -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 %} diff --git a/roles/serveur_icingaweb2/templates/nginx.conf.j2 b/roles/serveur_icingaweb2/templates/nginx.conf.j2 index 020a0de..b7f7c40 100644 --- a/roles/serveur_icingaweb2/templates/nginx.conf.j2 +++ b/roles/serveur_icingaweb2/templates/nginx.conf.j2 @@ -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; diff --git a/roles/serveur_icingaweb2/templates/resources.ini.j2 b/roles/serveur_icingaweb2/templates/resources.ini.j2 index b840d0e..8648177 100644 --- a/roles/serveur_icingaweb2/templates/resources.ini.j2 +++ b/roles/serveur_icingaweb2/templates/resources.ini.j2 @@ -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 %} diff --git a/roles/serveur_icingaweb2/templates/roles.ini.j2 b/roles/serveur_icingaweb2/templates/roles.ini.j2 index 1d02365..a9b2e71 100644 --- a/roles/serveur_icingaweb2/templates/roles.ini.j2 +++ b/roles/serveur_icingaweb2/templates/roles.ini.j2 @@ -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 = "*" diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 4fd1297..72a804e 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -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({ diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 7871b35..6a7dc5c 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -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