From f3bb9caee3953e3faa3142e0cee01f7284a4246e Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 12 Oct 2026 14:25:28 -0400 Subject: [PATCH] chiffrement : metriques et journaux du site en TLS, et la frontiere certifiee par sa propre autorite Metriques et journaux (CHANGELOG 142). Le site pose les quatre interrupteurs TLS que chaque locataire pose dans ses group_vars : node_exporter et Loki servaient en HTTP sur les neuf VM, le certificat etait la, inutilise. test_parite_tls_site.py compare desormais le site aux locataires. Les hyperviseurs poussent leurs journaux en HTTPS verifie, avec la racine du site comme simple fichier. L'exportateur PostgreSQL sert en HTTPS partout, sur l'interrupteur de PostgreSQL lui-meme, que Prometheus lit pour choisir son schema. La frontiere (CHANGELOG 143). Une autorite subordonnee signee par la racine du site, bornee (pathlen 0, noms sous le domaine et adresses du site, contrainte critique), cle nee sur la boite (scripts/frontiere_pki.py). Syslog vers Loki en TLS mutuel (seule la frontiere peut ecrire), exportateur en HTTPS, interface web sur le certificat de services ; les clients d'API verifient la frontiere contre la racine du site. La lecture du magasin passe par config.xml : l'API de recherche renvoie les cles privees. Deploye et mesure au site, chez les deux locataires et sur la frontiere ; 95 preuves. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 97 +++ Makefile | 3 + docs/registre-flux.md | 12 +- roles/client_journal/defaults/main.yml | 23 +- roles/client_journal/tasks/main.yml | 98 +++ .../client_journal/templates/config.alloy.j2 | 10 + .../templates/setops-alloy-cert-sync.path.j2 | 10 + .../setops-alloy-cert-sync.service.j2 | 7 + .../templates/setops-alloy-cert-sync.sh.j2 | 18 + roles/serveur_loki/meta/flux.yml | 9 +- roles/serveur_postgresql/defaults/main.yml | 6 + roles/serveur_postgresql/meta/flux.yml | 24 +- roles/serveur_postgresql/meta/metriques.yml | 5 + roles/serveur_postgresql/tasks/main.yml | 98 ++- .../setops-pg-exportateur-cert-sync.path.j2 | 10 + ...setops-pg-exportateur-cert-sync.service.j2 | 7 + .../setops-pg-exportateur-cert-sync.sh.j2 | 20 + .../templates/web-config-exportateur.yml.j2 | 5 + roles/serveur_prometheus/meta/flux.yml | 3 +- .../templates/prometheus.yml.j2 | 19 + scripts/appliquer_opnsense.py | 12 +- scripts/frontiere_pki.py | 592 ++++++++++++++++++ scripts/site_inventaire.py | 76 ++- scripts/tests/test_exportateur_pg_tls.py | 83 +++ scripts/tests/test_frontiere_pki.py | 92 +++ scripts/tests/test_parite_tls_site.py | 107 ++++ 26 files changed, 1415 insertions(+), 31 deletions(-) create mode 100644 roles/client_journal/templates/setops-alloy-cert-sync.path.j2 create mode 100644 roles/client_journal/templates/setops-alloy-cert-sync.service.j2 create mode 100644 roles/client_journal/templates/setops-alloy-cert-sync.sh.j2 create mode 100644 roles/serveur_postgresql/templates/setops-pg-exportateur-cert-sync.path.j2 create mode 100644 roles/serveur_postgresql/templates/setops-pg-exportateur-cert-sync.service.j2 create mode 100644 roles/serveur_postgresql/templates/setops-pg-exportateur-cert-sync.sh.j2 create mode 100644 roles/serveur_postgresql/templates/web-config-exportateur.yml.j2 create mode 100644 scripts/frontiere_pki.py create mode 100644 scripts/tests/test_exportateur_pg_tls.py create mode 100644 scripts/tests/test_frontiere_pki.py create mode 100644 scripts/tests/test_parite_tls_site.py diff --git a/CHANGELOG.md b/CHANGELOG.md index be4fd30..6a61564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,102 @@ # CHANGELOG — Set-OPS +## 2026-10-12 (143) — La frontière est certifiée par la PKI du site, et porte sa propre autorité + +**95 preuves.** `make verifier` passe ; `test_frontiere_pki.py` garde le chantier (témoin : il +échoue quand la lecture repasse par `/api/trust/*/search` ou quand l'autorité perd `pathlen 0`). +Demande de Daniel : la frontière certifiée par la PKI du site, avec sa propre autorité +subordonnée, pour les services d'OPNsense et les VPN d'administration, noms sous `genese.internal`. + +**F1 — L'autorité de la frontière** (`scripts/frontiere_pki.py`, `plan` / `appliquer` / `regenerer`). +- La racine du site (`pathlen:1`) signe l'autorité de `bifrost-1` ; l'intermédiaire de step-ca + (`pathlen:0`) ne le pouvait pas. Bornée : `pathlen 0`, signature seulement, contrainte de noms + **critique** — `genese.internal` et le supernet du site `10.37.0.0/16`. Éprouvée sur une racine + de test jetable : OpenSSL refuse un nom court et une adresse de locataire. +- **La clé naît sur la frontière** (openssl, dossier de root) ; la racine signe sur `site-pki-01` + (mot de passe de la voûte par l'entrée standard) ; l'import passe par l'API **locale** de la boîte. +- Le magasin d'OPNsense porte la racine (certificat seul, recopiée dans le magasin du système), + l'autorité, un certificat de **services** (serveur et client) et un certificat d'**exportateur** + à part — l'exportateur tourne en `nobody`, qui ne doit pas lire la clé des services. Noms : le + FQDN et les huit pattes du site, rien d'autre (la contrainte rendrait le reste invalide). +- **Les clés ont transité par le poste, une fois** : `/api/trust/{ca,cert}/search` renvoie les objets + entiers, clé privée comprise. La lecture se fait désormais sur la frontière (`config.xml`, champs + publics seulement), et l'autorité et ses certificats ont été **régénérés**. L'ancienne autorité + n'est pas révoquée (la racine n'a pas de liste de révocation) : valide jusqu'en 2031, sa clé n'a + circulé que sur le réseau d'administration, en TLS non vérifié. À traiter : une CRL de la racine. + +**F2 — Le syslog de la frontière en TLS mutuel (1514).** Alloy sert le certificat de `site-mon-01` +et exige un certificat client émis par l'autorité de la frontière (publiée à côté de la racine, +`ac-frontiere-bifrost-1.crt`) ; la frontière vérifie Alloy contre la racine. La bascule n'a lieu +qu'après une vraie poignée de main depuis la boîte. Mesuré : journaux reçus (272 lignes/min) ; +sans certificat client, « certificate required » ; avec un certificat du site mais pas de la +frontière, « unknown ca ». Coupure : ~40 s. + +**F4 — L'exportateur de la frontière en HTTPS.** Le greffon n'a pas d'option TLS : `--web.config.file` +s'ajoute par `/usr/local/etc/rc.conf.d/node_exporter`, que FreeBSD lit après le fichier qu'OPNsense +réécrit. Mesuré : Prometheus collecte en HTTPS vérifié (`up`) ; le HTTP clair reçoit 400 ; après +une régénération forcée du gabarit d'OPNsense et un redémarrage, le TLS tient. + +**F3 — L'interface web** (`frontiere_pki.py interface`, classe R3, confirmée par Daniel). Elle sert +le certificat de services et sa chaîne (certificat, autorité de la frontière) ; la commande vérifie +la poignée depuis le poste et remet l'ancien certificat d'elle-même si elle échoue (retour arrière +par SSH, qui ne dépend pas de lighttpd). Mesuré : HTTPS sur `10.37.0.1` vérifié contre la racine ; +l'API répond. + +**Les clients d'API vérifient la frontière** (`opnsense_api_verifier_certs: true`) : `Frontiere` +vérifie contre `ac-racine-site.crt`, que le magasin du poste ne connaît pas. Témoin : contre une +autre racine, la connexion est refusée. `make frontiere-plan` passe en vérifié ; il propose 3 +descriptions de règles à corriger (les raisons des flux 1514, 9187 et de l'exportateur ont changé). + +Registre : 1514 `tls-requis`, exportateur de la frontière `tls`. Reste en clair : node_exporter des +hyperviseurs. + +## 2026-10-12 (142) — Les métriques et les journaux du site passent en TLS, et l'exportateur PostgreSQL partout + +**95 preuves.** `make verifier` passe ; deux tests nouveaux (témoins : ils échouent contre le code +précédent — 23 écarts pour la parité, 5 points pour l'exportateur). Simulé puis **déployé au site +et chez les deux locataires**. + +La mesure des écoutes l'a montré : au site, node_exporter (9100) et Loki (3100) servaient en HTTP +sur les neuf VM, alors que le registre les dit `tls` ; et l'exportateur PostgreSQL (9187) était en +clair partout, dette inscrite au registre le 2026-09-14. + +- **Le site pose les quatre interrupteurs des locataires** (`client_metrique_tls_actif`, + `serveur_prometheus_metriques_tls`, `serveur_loki_tls_actif`, `client_journal_loki_tls`). Les neuf + VM portent `client_pki` : le certificat était là, inutilisé. Même écart que PostgreSQL et que le + certificat de l'edge le 2026-09-12 — la troisième fois. +- **`test_parite_tls_site.py`** compare désormais le site aux locataires : chaque clé `*tls*` vraie + dans leurs `group_vars` doit l'être dans les hostvars du site. Ce qui diffère encore est nommé + (`RESTE`), ou obtenu autrement sous une condition vérifiée (`AUTREMENT`) — jamais toléré en silence. +- **Les hyperviseurs poussent leurs journaux en HTTPS vérifié**, par l'adresse IP de Loki (qui est + dans son certificat). Sans `client_pki`, ils reçoivent la racine du site comme un simple fichier + (`client_journal_loki_ca_contenu`, lu dans `ac-racine-site.crt`). +- **L'URL de Loki suit l'interrupteur TLS** dans `client_journal`, comme Grafana depuis le 2026-09-28. +- **L'exportateur PostgreSQL sert en HTTPS** (`--web.config.file`, certificat de la machine resynchronisé + à chaque renouvellement), sur l'interrupteur de PostgreSQL lui-même. Sa déclaration le nomme + (`tls_si`) et Prometheus le lit dans les hostvars de chaque cible : toutes en TLS, il collecte en + HTTPS vérifié ; une seule sans, il reste en HTTP plutôt que d'en perdre une. Registre : 9187 `tls`. + +Reste en clair, nommé : node_exporter des **hyperviseurs** (il leur faudrait un certificat à eux) et +l'exportateur de la **frontière** — chantier suivant. + +La simulation a aussi montré que les hyperviseurs avaient pris du retard sur `client_journal` : la +coupure du rapport d'usage d'Alloy (2026-10-10) ne les avait jamais atteints, et gandalf et vishnu +étaient en Alloy 1.19.2 quand l'épinglage dit 1.20.1. Rattrapés par ce déploiement (1.20.1 partout). + +Mesuré après déploiement : +- **Prometheus du site** : les neuf VM (`node`) et l'exportateur PostgreSQL lus en HTTPS, tous `up` ; + la fabric et la frontière en HTTP, `up`, comme prévu. **Chez chaque locataire** : 13 `node` et + l'exportateur PostgreSQL en HTTPS, tous `up`. +- **Loki du site** : TLS 1.3, certificat vérifié ; une requête en HTTP clair reçoit 400. Les douze + machines (VM et hyperviseurs) et le syslog de la frontière y arrivent dans les deux minutes. +- **La sonde de chiffrement** (9100, 3100, 9187) : 11 écoutes sur 11 en TLS au site, 15 sur 15 chez + chaque locataire ; aucune en clair. +- Grafana : sources saines (Loki, Prometheus). Sonde « ingestion » verte. + +Le prix de la bascule : environ 100 s de journaux perdus (12:12:28 → 12:14:08), entre le passage de +Loki en TLS et celui de chaque Alloy — Loki rendait 400 aux envois en clair, et Alloy les abandonne +après ses reprises. Aucune erreur depuis. + ## 2026-10-12 (141) — La sonde « boites » cherche un vrai compte, et l'IMAP n'écoute plus en clair **95 preuves.** `make verifier` passe ; `test_dovecot_sonde_et_imap.py` garde les deux corrections diff --git a/Makefile b/Makefile index 322251b..bc8afe5 100644 --- a/Makefile +++ b/Makefile @@ -340,6 +340,9 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_relais_du_site.py python3 scripts/tests/test_sasl_local.py python3 scripts/tests/test_dovecot_sonde_et_imap.py + python3 scripts/tests/test_parite_tls_site.py + python3 scripts/tests/test_exportateur_pg_tls.py + python3 scripts/tests/test_frontiere_pki.py python3 scripts/tests/test_sonde_tcp.py python3 scripts/tests/test_restauration.py python3 scripts/tests/test_repertoires_partages.py diff --git a/docs/registre-flux.md b/docs/registre-flux.md index 721c69d..28f74b6 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -66,7 +66,7 @@ | `serveur_keycloak` | ingress | 8080 | tcp | edge | clair | Console et endpoints OIDC servis au navigateur et aux applications via l'edge (TLS terminé à l'edge). | | `serveur_keycloak` | egress | 636 | tcp | serveur_openldap | tls-requis | Fédération de l'annuaire OpenLDAP (LDAPS). | | `serveur_keycloak` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Persistance Keycloak dans PostgreSQL (verify-full). | -| `serveur_loki` | ingress | 1514 | tcp | fabric | clair | Journaux de la frontiere. Elle n'accueille aucun agent et n'emet que du syslog ; sans ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part. | +| `serveur_loki` | ingress | 1514 | tcp | fabric | tls-requis | Journaux de la frontiere, en TLS mutuel. Elle n'accueille aucun agent et n'emet que du syslog ; sans ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part. | | `serveur_loki` | ingress | 3100 | tcp | client_journal | tls | Réception des journaux poussés par Alloy (client_journal) en HTTPS (http_tls_config, cert step-ca). | | `serveur_loki` | ingress | 3100 | tcp | localhost | clair | Requêtes de Grafana co-localisé (datasource Loki en localhost). | | `serveur_loki` | ingress | 3100 | tcp | fabric | tls | Journaux des hyperviseurs. La machine qui PORTE les VM ecrivait ses journaux nulle part ailleurs que sur son propre disque — invisibles le jour ou c'est elle qui casse. | @@ -100,7 +100,7 @@ | `serveur_postfix` | egress | 636 | tcp | serveur_openldap | tls-requis | Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire, et mots de passe de soumission vérifiés par le Dovecot local (LDAPS). | | `serveur_postfix` | egress | 11332 | tcp | localhost | clair | Filtre milter rspamd co-localisé (antispam + signature DKIM). | | `serveur_postgresql` | ingress | 5432 | tcp | serveur_keycloak, serveur_forgejo, serveur_icinga, serveur_nextcloud | tls-requis | Connexions applicatives à PostgreSQL (verify-full ; pg_hba hostssl). | -| `serveur_postgresql` | ingress | 9187 | tcp | serveur_prometheus | clair | Metriques PostgreSQL lues par l'observatoire. Series, pas verdicts : ce qui derive lentement — cache qui decroche, connexions qui montent, bases qui grossissent — n'a que le graphe pour se faire voir. En clair pour l'instant, contrairement a `client_metrique` : dette inscrite, a fermer par `--web.config.file` + `client_pki`. | +| `serveur_postgresql` | ingress | 9187 | tcp | serveur_prometheus | tls | Metriques PostgreSQL lues par l'observatoire, en HTTPS verifie. Series, pas verdicts : ce qui derive lentement — cache qui decroche, connexions qui montent, bases qui grossissent — n'a que le graphe pour se faire voir. | | `serveur_powerdns` | ingress | 5300 | tcp | dns_public_site | clair | AXFR signe TSIG par le serveur DNS public du site, vers l'instance publique uniquement. | | `serveur_powerdns` | ingress | 5300 | udp | dns_public_site | clair | Interrogation du SOA par le secondaire du site avant chaque transfert. | | `serveur_powerdns` | ingress | derive | udp | flotte | clair | Zone souveraine. 53 seul sur son hôte, 5300 sur la loopback derrière le résolveur. | @@ -109,7 +109,7 @@ | `serveur_powerdns` | egress | 53 | tcp | externe | clair | Repli TCP de la résolution sortante du serveur autoritatif (réponses dépassant la taille UDP). | | `serveur_prometheus` | ingress | 9090 | tcp | localhost | clair | Console Prometheus consommée en local par Grafana co-localisé (pas d'exposition inter-nœud). | | `serveur_prometheus` | egress | 9100 | tcp | client_metrique | tls | Scrape des node_exporter (HTTPS via cert step-ca) sur chaque nœud instrumenté. | -| `serveur_prometheus` | egress | 9100 | tcp | frontiere | clair | Scrape de l'exportateur de la frontiere (os-node_exporter), sur sa seule patte de supervision : l'equipement qui voit passer TOUT le trafic n'avait ni temperature ni charge dans la supervision. | +| `serveur_prometheus` | egress | 9100 | tcp | frontiere | tls | Scrape de l'exportateur de la frontiere (os-node_exporter), sur sa seule patte de supervision : l'equipement qui voit passer TOUT le trafic n'avait ni temperature ni charge dans la supervision. | | `serveur_prometheus` | egress | 9100 | tcp | fabric | clair | Scrape des hyperviseurs : la machine qui PORTE les VM etait invisible de la supervision qui les surveille. Elle ne peut pas pousser (route par defaut gelee, D-57), donc on la tire. | | `serveur_redis` | ingress | 6379 | tcp | localhost | clair | Cache/verrous consommés uniquement par l'application co-localisée (ex. Nextcloud). Aucune exposition inter-nœud. | | `serveur_resolveur` | ingress | 53 | udp | flotte | clair | Toute la flotte du tenant résout ici — et nulle part ailleurs. | @@ -130,10 +130,10 @@ ## Synthèse chiffrement -- **clair** : 52 flux +- **clair** : 49 flux - **n-a** : 8 flux - **ssh** : 9 flux - **starttls** : 6 flux -- **tls** : 8 flux +- **tls** : 10 flux - **tls-cible** : 2 flux -- **tls-requis** : 36 flux +- **tls-requis** : 37 flux diff --git a/roles/client_journal/defaults/main.yml b/roles/client_journal/defaults/main.yml index 424e63b..f1171c2 100644 --- a/roles/client_journal/defaults/main.yml +++ b/roles/client_journal/defaults/main.yml @@ -8,7 +8,12 @@ client_journal_config: "/etc/alloy/config.alloy" # Point de collecte Loki (obs-01, serveur_loki, VLAN 14). # Hôte Loki dérivé du groupe serveur_loki (FQDN via le plancher) ; override par instance au besoin. -client_journal_loki_url: "http://{{ (groups.get('serveur_loki') or ['obs-01']) | first }}.{{ domaine_interne }}:3100/loki/api/v1/push" +# LE SCHEMA SUIT L'INTERRUPTEUR TLS (2026-10-12) — meme defaut que Grafana le 2026-09-28 : +# une URL `http://` ecrite a cote d'un `client_journal_loki_tls: true` parlerait en clair a +# un Loki qui n'ecoute qu'en HTTPS. +client_journal_loki_url: >- + {{ 'https' if client_journal_loki_tls | bool else 'http' }}://{{ + (groups.get('serveur_loki') or ['obs-01']) | first }}.{{ domaine_interne }}:3100/loki/api/v1/push # Depot apt officiel Grafana (partage avec serveur_loki / serveur_grafana). client_journal_depot_cle_url: "{{ client_journal_depot_schema }}://apt.grafana.com/gpg-full.key" @@ -18,6 +23,11 @@ client_journal_depot_source: "deb [signed-by=/etc/apt/keyrings/grafana.asc] {{ c # --- TLS (zero-confiance) : Alloy pousse vers Loki en https + verifie root_ca --- client_journal_loki_tls: false client_journal_loki_ca: "/etc/step/certs/root_ca.crt" +# UNE MACHINE SANS PKI PEUT QUAND MEME VERIFIER (2026-10-12). Un hyperviseur n'a pas +# `client_pki` : aucune racine sous /etc/step. Pour verifier Loki, il lui suffit du +# certificat PUBLIC de l'autorite — pas d'un certificat a lui. Non vide, ce contenu est +# pose a `client_journal_loki_ca` (au site, `ac-racine-site.crt` lu par l'inventaire). +client_journal_loki_ca_contenu: "" # Port de l'interface HTTP locale d'Alloy (diagnostic). DECLARE ici parce que son defaut # amont — 12345 — entrait en collision avec `serveur_dovecot_sasl_port`, qui valait 12345 @@ -82,6 +92,17 @@ client_journal_syslog_protocole: "tcp" # du site — jamais fabrique ici. client_journal_syslog_emetteur: "frontiere" +# LE RECEPTEUR SYSLOG EN TLS, AUTHENTIFIANT SES CLIENTS (2026-10-12). Le syslog de la +# frontiere passait en clair. Vrai : Alloy sert le certificat de la machine (client_pki, +# recopie la ou `alloy` le lit, et a chaque renouvellement) et EXIGE un certificat client +# signe par une autorite de `client_journal_syslog_clients_ca_contenu` — au site, celle de +# la frontiere seule : personne d'autre ne peut y ecrire. +client_journal_syslog_tls: false +client_journal_syslog_tls_dir: "/etc/alloy/tls" +client_journal_syslog_tls_source_cert: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.crt" +client_journal_syslog_tls_source_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key" +client_journal_syslog_clients_ca_contenu: "" + # LE JOURNAL D'AUDIT, EXPEDIE COMME LE RESTE (2026-09-11). # # DERIVE DE L'APPARTENANCE AU GROUPE, pas declare par instance : `auditd` vient avec diff --git a/roles/client_journal/tasks/main.yml b/roles/client_journal/tasks/main.yml index 7361800..2be1443 100644 --- a/roles/client_journal/tasks/main.yml +++ b/roles/client_journal/tasks/main.yml @@ -184,6 +184,104 @@ append: true notify: Redemarrer alloy +# --- RECEPTEUR SYSLOG EN TLS (2026-10-12) -------------------------------------------- +# Meme pont que node_exporter, Loki, Dovecot : le certificat de la machine, recopie la ou le +# service le lit, recopie a chaque renouvellement. Avant la configuration : Alloy redemarre +# avec des fichiers deja en place. +- name: Syslog TLS — repertoire des certificats (alloy) + ansible.builtin.file: + path: "{{ client_journal_syslog_tls_dir }}" + state: directory + owner: "{{ client_journal_utilisateur }}" + group: "{{ client_journal_utilisateur }}" + mode: "0700" + when: + - client_journal_syslog_ecoute | length > 0 + - client_journal_syslog_tls | bool + +- name: Syslog TLS — script de synchronisation du certificat + ansible.builtin.template: + src: setops-alloy-cert-sync.sh.j2 + dest: /usr/local/sbin/setops-alloy-cert-sync + owner: root + group: root + mode: "0755" + when: + - client_journal_syslog_ecoute | length > 0 + - client_journal_syslog_tls | bool + +- name: Syslog TLS — unites de synchronisation (service + path) + ansible.builtin.template: + src: "{{ item.s }}" + dest: "{{ item.d }}" + owner: root + group: root + mode: "0644" + loop: + - { s: "setops-alloy-cert-sync.service.j2", d: "/etc/systemd/system/setops-alloy-cert-sync.service" } + - { s: "setops-alloy-cert-sync.path.j2", d: "/etc/systemd/system/setops-alloy-cert-sync.path" } + loop_control: + label: "{{ item.d | basename }}" + when: + - client_journal_syslog_ecoute | length > 0 + - client_journal_syslog_tls | bool + +- name: Syslog TLS — activer la surveillance du certificat (path) + ansible.builtin.systemd: + name: setops-alloy-cert-sync.path + enabled: true + state: started + daemon_reload: true + when: + - client_journal_syslog_ecoute | length > 0 + - client_journal_syslog_tls | bool + - not ansible_check_mode + +- name: Syslog TLS — synchroniser le certificat maintenant + ansible.builtin.command: /usr/local/sbin/setops-alloy-cert-sync + changed_when: false + when: + - client_journal_syslog_ecoute | length > 0 + - client_journal_syslog_tls | bool + - not ansible_check_mode + +# SEULS CES EMETTEURS PEUVENT ECRIRE. Sans certificat d'autorite, le recepteur n'accepterait +# aucun client : on refuse de deployer plutot que d'eteindre la journalisation de la +# frontiere en silence. Un CERTIFICAT, pas seulement une valeur non vide — et cette forme-la +# echappe a P32, qui ne lit pas les `when:` et aurait exige la variable de chaque locataire. +- name: Syslog TLS — exiger les autorites des emetteurs admis + ansible.builtin.assert: + that: + - client_journal_syslog_clients_ca_contenu is search('BEGIN CERTIFICATE') + fail_msg: >- + client_journal_syslog_tls est vrai, mais aucune autorite d'emetteur n'est fournie + (client_journal_syslog_clients_ca_contenu) : le recepteur refuserait tout le monde. + when: + - client_journal_syslog_ecoute | length > 0 + - client_journal_syslog_tls | bool + +- name: Syslog TLS — poser les autorites des emetteurs admis + ansible.builtin.copy: + content: "{{ client_journal_syslog_clients_ca_contenu }}" + dest: "{{ client_journal_syslog_tls_dir }}/clients.crt" + owner: "{{ client_journal_utilisateur }}" + group: "{{ client_journal_utilisateur }}" + mode: "0644" + notify: Redemarrer alloy + when: + - client_journal_syslog_ecoute | length > 0 + - client_journal_syslog_tls | bool + +- name: Poser l'autorite qui signe Loki (machine sans PKI) + ansible.builtin.copy: + content: "{{ client_journal_loki_ca_contenu }}" + dest: "{{ client_journal_loki_ca }}" + owner: root + group: root + mode: "0644" + notify: Redemarrer alloy + when: client_journal_loki_ca_contenu | length > 0 + - name: Deployer la configuration Alloy ansible.builtin.template: src: config.alloy.j2 diff --git a/roles/client_journal/templates/config.alloy.j2 b/roles/client_journal/templates/config.alloy.j2 index 26fbd1f..de9c05c 100644 --- a/roles/client_journal/templates/config.alloy.j2 +++ b/roles/client_journal/templates/config.alloy.j2 @@ -141,6 +141,16 @@ loki.source.syslog "frontiere" { protocol = "{{ client_journal_syslog_protocole }}" label_structured_data = true labels = { job = "syslog", host = "{{ client_journal_syslog_emetteur }}" } +{% if client_journal_syslog_tls | bool %} + // TLS, ET UN CERTIFICAT CLIENT EXIGE : un `ca_file` suffit a l'exiger dans ce + // recepteur (`client_auth_type` n'y est pas accepte — `alloy validate`, 2026-10-12). + tls_config { + cert_file = "{{ client_journal_syslog_tls_dir }}/alloy.crt" + key_file = "{{ client_journal_syslog_tls_dir }}/alloy.key" + ca_file = "{{ client_journal_syslog_tls_dir }}/clients.crt" + min_version = "TLS12" + } +{% endif %} } forward_to = [loki.relabel.syslog.receiver] } diff --git a/roles/client_journal/templates/setops-alloy-cert-sync.path.j2 b/roles/client_journal/templates/setops-alloy-cert-sync.path.j2 new file mode 100644 index 0000000..33dca48 --- /dev/null +++ b/roles/client_journal/templates/setops-alloy-cert-sync.path.j2 @@ -0,0 +1,10 @@ +# Gere par Set-OPS (role client_journal). Ne pas editer a la main. +[Unit] +Description=Set-OPS — surveille le certificat step_ca du recepteur syslog d'Alloy + +[Path] +PathChanged={{ client_journal_syslog_tls_source_cert }} +Unit=setops-alloy-cert-sync.service + +[Install] +WantedBy=multi-user.target diff --git a/roles/client_journal/templates/setops-alloy-cert-sync.service.j2 b/roles/client_journal/templates/setops-alloy-cert-sync.service.j2 new file mode 100644 index 0000000..10266f3 --- /dev/null +++ b/roles/client_journal/templates/setops-alloy-cert-sync.service.j2 @@ -0,0 +1,7 @@ +# Gere par Set-OPS (role client_journal). Ne pas editer a la main. +[Unit] +Description=Set-OPS — synchronise le certificat step_ca vers le recepteur syslog d'Alloy + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/setops-alloy-cert-sync diff --git a/roles/client_journal/templates/setops-alloy-cert-sync.sh.j2 b/roles/client_journal/templates/setops-alloy-cert-sync.sh.j2 new file mode 100644 index 0000000..1f8d2ba --- /dev/null +++ b/roles/client_journal/templates/setops-alloy-cert-sync.sh.j2 @@ -0,0 +1,18 @@ +#!/bin/sh +# Gere par Set-OPS (role client_journal). Ne pas editer a la main. +# Recopie le certificat step_ca (client_pki, root:root) la ou `alloy` le lit, pour le +# recepteur syslog en TLS. Idempotent ; appele au deploiement ET au renouvellement (.path). +set -eu + +SRC_CERT="{{ client_journal_syslog_tls_source_cert }}" +SRC_KEY="{{ client_journal_syslog_tls_source_cle }}" +DST="{{ client_journal_syslog_tls_dir }}" + +[ -f "$SRC_CERT" ] && [ -f "$SRC_KEY" ] || exit 0 + +install -o {{ client_journal_utilisateur }} -g {{ client_journal_utilisateur }} -m 0644 "$SRC_CERT" "$DST/alloy.crt" +install -o {{ client_journal_utilisateur }} -g {{ client_journal_utilisateur }} -m 0600 "$SRC_KEY" "$DST/alloy.key" + +# Le recepteur lit son certificat au demarrage : un nouveau certificat sur disque resterait +# sans effet jusqu'au prochain redemarrage (renouvellement-cert-recharger-consommateur). +systemctl try-restart {{ client_journal_service }} 2>/dev/null || true diff --git a/roles/serveur_loki/meta/flux.yml b/roles/serveur_loki/meta/flux.yml index 864778e..2c6f721 100644 --- a/roles/serveur_loki/meta/flux.yml +++ b/roles/serveur_loki/meta/flux.yml @@ -45,7 +45,10 @@ flux: port: 1514 protocole: tcp pair: fabric - chiffrement: clair + # EN TLS, CLIENT AUTHENTIFIE (2026-10-12) : Alloy sert le certificat de la machine et + # exige un certificat emis par l'autorite de la frontiere ; la frontiere verifie Alloy + # contre la racine du site (scripts/frontiere_pki.py). + chiffrement: tls-requis raison: >- - Journaux de la frontiere. Elle n'accueille aucun agent et n'emet que du syslog ; sans - ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part. + Journaux de la frontiere, en TLS mutuel. Elle n'accueille aucun agent et n'emet que du + syslog ; sans ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part. diff --git a/roles/serveur_postgresql/defaults/main.yml b/roles/serveur_postgresql/defaults/main.yml index 74885c2..2b1fc34 100644 --- a/roles/serveur_postgresql/defaults/main.yml +++ b/roles/serveur_postgresql/defaults/main.yml @@ -72,6 +72,12 @@ serveur_postgresql_exportateur_service: prometheus-postgres-exporter serveur_postgresql_exportateur_port: 9187 serveur_postgresql_exportateur_utilisateur: setops_metriques serveur_postgresql_exportateur_mot_de_passe: "{{ vault_pg_exportateur | default('') }}" +# L'EXPORTATEUR SERT EN TLS QUAND LA BASE LE FAIT (2026-10-12). Meme interrupteur que +# PostgreSQL, pas un second : la machine a alors son certificat (client_pki), et Prometheus +# lit ce meme interrupteur (`meta/metriques.yml`, `tls_si`) pour choisir son schema. Deux +# interrupteurs pourraient diverger — l'un servirait en TLS, l'autre lirait en clair. +serveur_postgresql_exportateur_tls_dir: "/etc/prometheus-postgres-exporter/tls" +serveur_postgresql_exportateur_web_config: "/etc/prometheus-postgres-exporter/web-config.yml" # --- RESTAURATION (2026-09-30) — voir tasks/main.yml --------------------------------- # diff --git a/roles/serveur_postgresql/meta/flux.yml b/roles/serveur_postgresql/meta/flux.yml index e763870..6b99979 100644 --- a/roles/serveur_postgresql/meta/flux.yml +++ b/roles/serveur_postgresql/meta/flux.yml @@ -20,20 +20,12 @@ flux: port: 9187 protocole: tcp pair: [serveur_prometheus] - # `clair`, ET C'EST UNE DETTE QU'ON ECRIT PLUTOT QUE DE LA TAIRE (2026-09-14). - # - # `client_metrique` sert deja ses metriques en TLS, certificat synchronise par - # `client_pki`. Cet exportateur-ci ne le fait pas encore : il faudrait lui poser un - # `--web.config.file` et l'abonner au meme renouvellement. - # - # CE QUI TRANSITE N'EST PAS ANODIN : nombre de connexions, tailles des bases, ratios - # de cache. Pas de donnee applicative, mais l'etat interne d'un serveur de donnees. - # Le flux ne sort pas de la zone, et la frontiere ne le laisse passer que depuis - # l'observatoire — ce n'est pas une excuse, c'est ce qui rend la dette tenable en - # attendant. - chiffrement: clair + # DETTE FERMEE (2026-10-12). Inscrite `clair` le 2026-09-14 : l'exportateur sert + # desormais en HTTPS (`--web.config.file`, certificat de la machine resynchronise a chaque + # renouvellement), et Prometheus verifie contre l'autorite des que + # `serveur_postgresql_tls_actif` est vrai — partout, au site comme chez les locataires. + chiffrement: tls raison: >- - Metriques PostgreSQL lues par l'observatoire. Series, pas verdicts : ce qui derive - lentement — cache qui decroche, connexions qui montent, bases qui grossissent — n'a - que le graphe pour se faire voir. En clair pour l'instant, contrairement a - `client_metrique` : dette inscrite, a fermer par `--web.config.file` + `client_pki`. + Metriques PostgreSQL lues par l'observatoire, en HTTPS verifie. Series, pas verdicts : + ce qui derive lentement — cache qui decroche, connexions qui montent, bases qui + grossissent — n'a que le graphe pour se faire voir. diff --git a/roles/serveur_postgresql/meta/metriques.yml b/roles/serveur_postgresql/meta/metriques.yml index fe428bd..249c4b6 100644 --- a/roles/serveur_postgresql/meta/metriques.yml +++ b/roles/serveur_postgresql/meta/metriques.yml @@ -29,6 +29,11 @@ exportateur: service: prometheus-postgres-exporter port: 9187 job: postgresql + # LE SCHEMA SUIT UN INTERRUPTEUR DE LA CIBLE (2026-10-12). Prometheus lit cette variable + # dans les hostvars de chaque hote du groupe : toutes vraies, il collecte en HTTPS et + # verifie contre l'autorite ; sinon en HTTP. Le role, lui, sert en TLS sur le meme + # interrupteur — une seule valeur decide des deux bouts. + tls_si: serveur_postgresql_tls_actif # LES PANNEAUX : ce qui merite d'etre REGARDE DANS LE TEMPS. # diff --git a/roles/serveur_postgresql/tasks/main.yml b/roles/serveur_postgresql/tasks/main.yml index 8305db9..056005f 100644 --- a/roles/serveur_postgresql/tasks/main.yml +++ b/roles/serveur_postgresql/tasks/main.yml @@ -330,6 +330,100 @@ state: present when: serveur_postgresql_exportateur_actif | bool +# --- TLS DE L'EXPORTATEUR (2026-10-12) ----------------------------------------------- +# Le 9187 etait le seul port de metriques en clair, chez les locataires comme au site : +# une dette inscrite au registre le 2026-09-14. Meme pont que `client_metrique` : le +# certificat de la machine, copie la ou `prometheus` le lit, recopie a chaque renouvellement. +- name: Exportateur TLS — repertoire des certificats (prometheus) + ansible.builtin.file: + path: "{{ serveur_postgresql_exportateur_tls_dir }}" + state: directory + owner: prometheus + group: prometheus + mode: "0700" + when: + - serveur_postgresql_exportateur_actif | bool + - serveur_postgresql_tls_actif | bool + +- name: Exportateur TLS — script de synchronisation du certificat + ansible.builtin.template: + src: setops-pg-exportateur-cert-sync.sh.j2 + dest: /usr/local/sbin/setops-pg-exportateur-cert-sync + owner: root + group: root + mode: "0755" + when: + - serveur_postgresql_exportateur_actif | bool + - serveur_postgresql_tls_actif | bool + +- name: Exportateur TLS — unites de synchronisation (service + path) + ansible.builtin.template: + src: "{{ item.s }}" + dest: "{{ item.d }}" + owner: root + group: root + mode: "0644" + loop: + - { s: "setops-pg-exportateur-cert-sync.service.j2", d: "/etc/systemd/system/setops-pg-exportateur-cert-sync.service" } + - { s: "setops-pg-exportateur-cert-sync.path.j2", d: "/etc/systemd/system/setops-pg-exportateur-cert-sync.path" } + loop_control: + label: "{{ item.d | basename }}" + when: + - serveur_postgresql_exportateur_actif | bool + - serveur_postgresql_tls_actif | bool + +- name: Exportateur TLS — activer la surveillance du certificat (path) + ansible.builtin.systemd: + name: setops-pg-exportateur-cert-sync.path + enabled: true + state: started + daemon_reload: true + when: + - serveur_postgresql_exportateur_actif | bool + - serveur_postgresql_tls_actif | bool + - not ansible_check_mode + +- name: Exportateur TLS — synchroniser le certificat maintenant + ansible.builtin.command: /usr/local/sbin/setops-pg-exportateur-cert-sync + changed_when: false + when: + - serveur_postgresql_exportateur_actif | bool + - serveur_postgresql_tls_actif | bool + - not ansible_check_mode + +- name: Exportateur TLS — garde-fou, ne pas activer sans certificat en place + ansible.builtin.stat: + path: "{{ serveur_postgresql_exportateur_tls_dir }}/exportateur.key" + register: serveur_postgresql_exportateur_cle + when: + - serveur_postgresql_exportateur_actif | bool + - serveur_postgresql_tls_actif | bool + - not ansible_check_mode + +- name: Exportateur TLS — exiger le certificat synchronise + ansible.builtin.assert: + that: + - serveur_postgresql_exportateur_cle.stat.exists + fail_msg: >- + Certificat de l'exportateur absent ({{ serveur_postgresql_exportateur_tls_dir }}) : + la machine porte-t-elle client_pki ? TLS non active, plutot qu'un exportateur muet. + when: + - serveur_postgresql_exportateur_actif | bool + - serveur_postgresql_tls_actif | bool + - not ansible_check_mode + +- name: Exportateur TLS — deployer la web-config + ansible.builtin.template: + src: web-config-exportateur.yml.j2 + dest: "{{ serveur_postgresql_exportateur_web_config }}" + owner: root + group: prometheus + mode: "0640" + notify: Redemarrer l exportateur de metriques + when: + - serveur_postgresql_exportateur_actif | bool + - serveur_postgresql_tls_actif | bool + # LA CHAINE DE CONNEXION NE PASSE PAS PAR LA LIGNE DE COMMANDE. Un `DATA_SOURCE_NAME` en # argument serait lisible dans `ps` par tout le monde sur la machine ; dans un fichier a # 0600, il ne l'est que par root et le service. @@ -340,7 +434,9 @@ DATA_SOURCE_NAME="postgresql://{{ serveur_postgresql_exportateur_utilisateur }}:{{ serveur_postgresql_exportateur_mot_de_passe }}@127.0.0.1:5432/postgres?sslmode=disable" - ARGS="--web.listen-address=:{{ serveur_postgresql_exportateur_port }}" + ARGS="--web.listen-address=:{{ serveur_postgresql_exportateur_port }}{{ + (' --web.config.file=' ~ serveur_postgresql_exportateur_web_config) + if serveur_postgresql_tls_actif | bool else '' }}" dest: /etc/default/prometheus-postgres-exporter owner: root group: root diff --git a/roles/serveur_postgresql/templates/setops-pg-exportateur-cert-sync.path.j2 b/roles/serveur_postgresql/templates/setops-pg-exportateur-cert-sync.path.j2 new file mode 100644 index 0000000..addf7a9 --- /dev/null +++ b/roles/serveur_postgresql/templates/setops-pg-exportateur-cert-sync.path.j2 @@ -0,0 +1,10 @@ +# Gere par Set-OPS (role serveur_postgresql). Ne pas editer a la main. +[Unit] +Description=Set-OPS — surveille le certificat step_ca de l'exportateur PostgreSQL + +[Path] +PathChanged={{ serveur_postgresql_tls_source_cert }} +Unit=setops-pg-exportateur-cert-sync.service + +[Install] +WantedBy=multi-user.target diff --git a/roles/serveur_postgresql/templates/setops-pg-exportateur-cert-sync.service.j2 b/roles/serveur_postgresql/templates/setops-pg-exportateur-cert-sync.service.j2 new file mode 100644 index 0000000..6e2b59a --- /dev/null +++ b/roles/serveur_postgresql/templates/setops-pg-exportateur-cert-sync.service.j2 @@ -0,0 +1,7 @@ +# Gere par Set-OPS (role serveur_postgresql). Ne pas editer a la main. +[Unit] +Description=Set-OPS — synchronise le certificat step_ca vers l'exportateur PostgreSQL + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/setops-pg-exportateur-cert-sync diff --git a/roles/serveur_postgresql/templates/setops-pg-exportateur-cert-sync.sh.j2 b/roles/serveur_postgresql/templates/setops-pg-exportateur-cert-sync.sh.j2 new file mode 100644 index 0000000..0fa53b0 --- /dev/null +++ b/roles/serveur_postgresql/templates/setops-pg-exportateur-cert-sync.sh.j2 @@ -0,0 +1,20 @@ +#!/bin/sh +# Gere par Set-OPS (role serveur_postgresql). Ne pas editer a la main. +# Synchronise le cert step_ca (client_pki, root:root) vers un emplacement lisible par +# 'prometheus' (l'utilisateur de l'exportateur). Cle en 0600. Idempotent ; appele au +# deploiement ET au renouvellement (unite .path). +set -eu + +SRC_CERT="{{ serveur_postgresql_tls_source_cert }}" +SRC_KEY="{{ serveur_postgresql_tls_source_cle }}" +DST="{{ serveur_postgresql_exportateur_tls_dir }}" + +[ -f "$SRC_CERT" ] && [ -f "$SRC_KEY" ] || exit 0 + +install -o prometheus -g prometheus -m 0644 "$SRC_CERT" "$DST/exportateur.crt" +install -o prometheus -g prometheus -m 0600 "$SRC_KEY" "$DST/exportateur.key" + +# REDEMARRAGE, pas rechargement : meme famille que node_exporter (client_metrique), dont +# l'ExecReload Debian envoie un SIGHUP que le binaire ne sait pas honorer. `try-restart` : +# ne demarre pas un exportateur qu'on aurait volontairement arrete. +systemctl try-restart {{ serveur_postgresql_exportateur_service }} 2>/dev/null || true diff --git a/roles/serveur_postgresql/templates/web-config-exportateur.yml.j2 b/roles/serveur_postgresql/templates/web-config-exportateur.yml.j2 new file mode 100644 index 0000000..4304ad1 --- /dev/null +++ b/roles/serveur_postgresql/templates/web-config-exportateur.yml.j2 @@ -0,0 +1,5 @@ +# Gere par Set-OPS (role serveur_postgresql). TLS de l'exportateur (exporter-toolkit). +# Cert/cle step_ca synchronises par setops-pg-exportateur-cert-sync (proprietaire prometheus). +tls_server_config: + cert_file: {{ serveur_postgresql_exportateur_tls_dir }}/exportateur.crt + key_file: {{ serveur_postgresql_exportateur_tls_dir }}/exportateur.key diff --git a/roles/serveur_prometheus/meta/flux.yml b/roles/serveur_prometheus/meta/flux.yml index 158bd39..05a3ff2 100644 --- a/roles/serveur_prometheus/meta/flux.yml +++ b/roles/serveur_prometheus/meta/flux.yml @@ -30,7 +30,8 @@ flux: port: 9100 protocole: tcp pair: frontiere - chiffrement: clair + # HTTPS VERIFIE (2026-10-12) : certificat emis par l'autorite de la frontiere. + chiffrement: tls raison: >- Scrape de l'exportateur de la frontiere (os-node_exporter), sur sa seule patte de supervision : l'equipement qui voit passer TOUT le trafic n'avait ni temperature ni diff --git a/roles/serveur_prometheus/templates/prometheus.yml.j2 b/roles/serveur_prometheus/templates/prometheus.yml.j2 index b5c0064..995dd0e 100644 --- a/roles/serveur_prometheus/templates/prometheus.yml.j2 +++ b/roles/serveur_prometheus/templates/prometheus.yml.j2 @@ -43,9 +43,21 @@ scrape_configs: | map('extract', hostvars) | selectattr('ansible_host', 'defined') | map(attribute='ansible_host') | map('regex_replace', '$', ':' ~ (expo.port | default(0))) | list %} +{# TLS SI LA DECLARATION LE DIT ET QUE CHAQUE CIBLE L'A (2026-10-12) : `tls_si` nomme + l'interrupteur que le role exportateur lit lui aussi. Une seule cible sans lui, et le + job reste en HTTP — mieux qu'un job HTTPS qui en perdrait une sans rien dire. #} +{% set tls = namespace(oui=(expo.tls_si | default('') | length > 0) if expo else false) %} +{% for h in (groups.get(role, []) | intersect(groups.get('hotes_actifs', [])) | sort) %} +{% if tls.oui and not (hostvars[h][expo.tls_si] | default(false) | bool) %}{% set tls.oui = false %}{% endif %} +{% endfor %} {% if expo and cibles %} - job_name: {{ expo.job }} +{% if tls.oui %} + scheme: https + tls_config: + ca_file: {{ serveur_prometheus_metriques_ca }} +{% endif %} static_configs: - targets: {{ cibles | to_json }} {% endif %} @@ -55,6 +67,13 @@ scrape_configs: {% for job in serveur_prometheus_cibles_supplementaires %} - job_name: {{ job.job }} +{# UNE CIBLE HORS FLOTTE PEUT SERVIR EN TLS (2026-10-12) : la frontiere, certifiee par son + autorite subordonnee, verifiee contre la meme racine que le reste. #} +{% if job.tls | default(false) %} + scheme: https + tls_config: + ca_file: {{ serveur_prometheus_metriques_ca }} +{% endif %} static_configs: {% if job.hotes | default({}) %} {# UNE CIBLE NOMMEE PAR BLOC : l'etiquette `hote` s'attache a un `static_config`, pas a diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index 25f6153..5dd11fb 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -61,7 +61,17 @@ class Frontiere: def __init__(self, url: str, cle: str, secret: str, verifier: bool): self.base = url.rstrip("/") self.auth = "Basic " + base64.b64encode(f"{cle}:{secret}".encode()).decode() - self.ctx = None if verifier else ssl._create_unverified_context() + # VERIFIER CONTRE LA RACINE DU SITE (2026-10-12). Depuis que la frontiere sert un + # certificat de son autorite (scripts/frontiere_pki.py), il se verifie — mais pas + # contre le magasin du poste, qui ne connait pas la racine du site. Sans verification, + # chaque appel envoyait la cle d'API a quiconque repondait sur l'adresse, et c'est par + # ce canal que les cles du magasin ont transite le meme jour. + if verifier: + hebergeur = devis_mod.depot_hebergeur() + racine = hebergeur / "ac-racine-site.crt" if hebergeur else None + self.ctx = ssl.create_default_context(cafile=str(racine) if racine and racine.is_file() else None) + else: + self.ctx = ssl._create_unverified_context() @staticmethod def _formulaire(corps: dict) -> bytes: diff --git a/scripts/frontiere_pki.py b/scripts/frontiere_pki.py new file mode 100644 index 0000000..9b02d48 --- /dev/null +++ b/scripts/frontiere_pki.py @@ -0,0 +1,592 @@ +#!/usr/bin/env python3 +"""La frontiere certifiee par la PKI du site, avec sa propre autorite subordonnee. + + python3 scripts/frontiere_pki.py plan # lit, ne change rien + CONFIRMER=true python3 scripts/frontiere_pki.py appliquer # pose ce qui manque + CONFIRMER=true python3 scripts/frontiere_pki.py regenerer # refait l'autorite a neuf + CONFIRMER=true python3 scripts/frontiere_pki.py interface # R3 : l'interface web passe au certificat + +POURQUOI (2026-10-12). Directive : tout transit chiffre, a l'interne comme a l'externe. La +frontiere (OPNsense) etait le seul element du site sans certificat de l'autorite : son +interface servait un certificat auto-signe, son exportateur de metriques et son syslog +parlaient en clair. Daniel a demande qu'elle soit certifiee par la PKI du site ET qu'elle +porte sa propre autorite subordonnee — pour ses services et, plus tard, les certificats des +VPN d'administration. + +CE QUE LE SCRIPT POSE, dans le magasin d'OPNsense (Systeme > Confiance) : + 1. la RACINE du site, certificat seul (pour que la chaine se lie et se verifie) ; + 2. l'AUTORITE de la frontiere, signee par la racine, bornee : aucune autorite sous elle + (pathlen 0), signature de certificats seulement, noms sous le domaine du site et + adresses du site — rien d'autre (contrainte de noms critique) ; + 3. le certificat de SERVICES (interface, syslog : serveur et client) ; + 4. le certificat de l'EXPORTATEUR, a part : il tourne en `nobody`, qui ne doit pas pouvoir + lire la cle des services. + +OU NAIT CHAQUE CLE. Celle de l'autorite nait SUR la frontiere (openssl), dans un dossier de +root, et y est importee par l'API LOCALE de la boite : elle ne transite ni par le poste ni +par le controleur. La racine signe sur `site-pki-01` ; sa cle n'en sort pas, son mot de passe +vient de la voute de l'underlay et passe par l'entree standard. Les cles des certificats sont +generees par OPNsense lui-meme. + +ELIGIBILITE DES NOMS. La contrainte rend invalide tout nom hors du domaine : le certificat ne +porte donc ni le nom court, ni le transit, ni la grappe — seulement le FQDN et les pattes du +site (`setops_frontiere`, derive par l'inventaire du site). +""" +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +import time +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE / "scripts")) + +import devis_opnsense as devis_mod # noqa: E402 +import site_inventaire as si # noqa: E402 +from appliquer_opnsense import Frontiere, _voute # noqa: E402 + +JOUR = 86400 +SOUS_AC_VALIDITE_H = 5 * 365 * 24 # 5 ans, dans la validite de la racine +CERT_VALIDITE_J = 397 # plafond des navigateurs +SOUS_AC_MARGE_J = 365 # en deca : a refaire +CERT_MARGE_J = 30 +HOTE_PKI = "site-pki-01" +REBOND = "ansible@{admin}" + + +def _ssh(cible: list[str], commande: str, entree: str | None = None) -> str: + r = subprocess.run(["ssh", "-o", "BatchMode=yes", *cible, commande], input=entree, + capture_output=True, text=True, timeout=120) + if r.returncode != 0: + raise SystemExit(f"echec ssh {cible[-1]} : {(r.stderr or r.stdout).strip()[:400]}") + return r.stdout + + +class Contexte: + def __init__(self) -> None: + inv = si.inventaire() + hv = inv["_meta"]["hostvars"] + pki = hv.get(HOTE_PKI) or {} + self.f = pki.get("setops_frontiere") or {} + if not self.f.get("admin") or not self.f.get("adresses"): + raise SystemExit(f"`setops_frontiere` absent de {HOTE_PKI} : l'inventaire du site ne la derive pas.") + self.domaine = str(pki.get("domaine_interne") or "") + self.ip_pki = str(pki.get("ansible_host") or "") + self.nom = str(self.f["nom"]) + self.fqdn = f"{self.nom}.{self.domaine}" + base = devis_mod.depot_hebergeur() + self.racine_pem = (base / "ac-racine-site.crt").read_text(encoding="utf-8") + intr = devis_mod.intrants_frontiere() + self.v = _voute(base) + self.api = Frontiere(str(intr["opnsense_api_url"]), self.v["vault_opnsense_api_key"], + self.v["vault_opnsense_api_secret"], + bool(intr.get("opnsense_api_verifier_certs"))) + self.frontiere = [f"ansible@{self.f['admin']}"] + self.pki = ["-J", REBOND.format(admin=self.f["admin"]), f"ansible@{self.ip_pki}"] + self.desc_racine = "Set-OPS racine du site" + self.desc_sous_ac = f"Set-OPS autorite de {self.nom}" + self.desc_services = f"Set-OPS {self.nom} services" + self.desc_expo = f"Set-OPS {self.nom} exportateur" + + def lister(self, quoi: str) -> list[dict]: + """Les autorites (`ca`) ou certificats (`cert`) du magasin — SANS LEURS CLES. + + PAS PAR L'API (mesure du 2026-10-12). `/api/trust/{ca,cert}/search` renvoie chaque + objet ENTIER, cle privee comprise (`prv`) : lister le magasin depuis le poste y + rapatriait la cle de l'autorite. La lecture se fait donc SUR la frontiere, dans + `config.xml`, et seuls les champs publics en sortent. + """ + lecteur = ( + "import base64,json,sys,xml.etree.ElementTree as E\n" + "c=E.parse('/conf/config.xml').getroot()\n" + "out=[]\n" + f"for n in c.iter({quoi!r}):\n" + " crt=base64.b64decode(n.findtext('crt') or '').decode()\n" + " out.append({k:(n.findtext(k) or '') for k in ('refid','descr','caref')}|{'crt':crt,'uuid':n.get('uuid') or ''})\n" + "json.dump(out,sys.stdout)\n") + import json + rows = json.loads(_ssh(self.frontiere, "sudo -n /usr/local/bin/python3 -", entree=lecteur)) + for r in rows: + r["valid_to"] = _fin_de_validite(r["crt"]) + return rows + + +def _fin_de_validite(pem: str) -> str: + """Fin de validite (epoch) d'un certificat PEM, lue par openssl sur le poste.""" + if "BEGIN CERTIFICATE" not in pem: + return "0" + r = subprocess.run(["openssl", "x509", "-noout", "-enddate", "-dateopt", "iso_8601"], + input=pem, capture_output=True, text=True) + fin = r.stdout.strip().split("=", 1)[-1] + try: + return str(int(time.mktime(time.strptime(fin, "%Y-%m-%d %H:%M:%SZ")) - time.timezone)) + except ValueError: + return "0" + + +def _valide_jusqua(row: dict) -> float: + try: + return float(row.get("valid_to") or 0) + except ValueError: + return 0.0 + + +def _meilleur(rows: list[dict], descr: str) -> dict | None: + rows = [r for r in rows if r.get("descr") == descr] + return max(rows, key=_valide_jusqua) if rows else None + + +def etat(c: Contexte) -> dict: + cas, certs = c.lister("ca"), c.lister("cert") + maintenant = time.time() + racine = _meilleur(cas, c.desc_racine) + sous_ac = _meilleur(cas, c.desc_sous_ac) + e = { + "racine": racine, + "sous_ac": sous_ac, + "services": _meilleur(certs, c.desc_services), + "exportateur": _meilleur(certs, c.desc_expo), + } + e["a_faire"] = [] + if not racine: + e["a_faire"].append("racine") + if not sous_ac or _valide_jusqua(sous_ac) < maintenant + SOUS_AC_MARGE_J * JOUR: + e["a_faire"].append("sous_ac") + for cle in ("services", "exportateur"): + r = e[cle] + if (not r or _valide_jusqua(r) < maintenant + CERT_MARGE_J * JOUR + or (sous_ac and r.get("caref") != sous_ac.get("refid"))): + e["a_faire"].append(cle) + return e + + +def afficher(c: Contexte, e: dict) -> None: + print(f"Frontiere {c.nom} ({c.f['admin']}) — domaine {c.domaine}, adresses {', '.join(c.f['adresses'])}") + for cle, desc in (("racine", c.desc_racine), ("sous_ac", c.desc_sous_ac), + ("services", c.desc_services), ("exportateur", c.desc_expo)): + r = e[cle] + if r: + fin = time.strftime("%Y-%m-%d", time.localtime(_valide_jusqua(r))) + print(f" {'A REFAIRE' if cle in e['a_faire'] else 'ok':9} {desc} (refid {r.get('refid')}, jusqu'au {fin})") + else: + print(f" {'A POSER':9} {desc}") + + +GABARIT_SOUS_AC = """{{ + "subject": {{{{ toJson .Subject }}}}, + "keyUsage": ["certSign", "crlSign"], + "basicConstraints": {{"isCA": true, "maxPathLen": 0}}, + "nameConstraints": {{ + "critical": true, + "permittedDNSDomains": [{domaine}], + "permittedIPRanges": [{supernet}] + }} +}} +""" + + +def poser_racine(c: Contexte) -> str: + r = c.api("/api/trust/ca/add", {"ca": {"action": "existing", "descr": c.desc_racine, + "crt_payload": c.racine_pem}}) + if r.get("result") != "saved": + raise SystemExit(f"import de la racine refuse : {r}") + print(f" racine importee (certificat seul) : {r.get('uuid')}") + return str(r.get("uuid")) + + +def poser_sous_ac(c: Contexte) -> None: + import json + # 1. LA CLE NAIT SUR LA FRONTIERE. Dossier de root, 0700 ; la demande seule en sort. + sujet = f"/O=Set-OPS Internal CA/CN=Set-OPS {c.nom} CA" + dossier = _ssh(c.frontiere, "sudo -n sh -c 'umask 077; mktemp -d /root/setops-pki.XXXXXX'").strip() + csr = _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote( + f"umask 077; cd {dossier} && openssl ecparam -name prime256v1 -genkey -noout -out sous-ac.key" + f" && openssl req -new -key sous-ac.key -subj {shlex.quote(sujet)} -out sous-ac.csr && cat sous-ac.csr")) + try: + # 2. LA RACINE SIGNE SUR site-pki-01. Le mot de passe passe par l'entree standard. + gabarit = GABARIT_SOUS_AC.format(domaine=json.dumps(c.domaine), supernet=json.dumps(c.f["supernet"])) + script = ( + "set -e; umask 077; D=$(mktemp -d); trap 'rm -rf \"$D\"' EXIT; " + f"printf %s {shlex.quote(csr)} > \"$D/s.csr\"; printf %s {shlex.quote(gabarit)} > \"$D/s.tpl\"; " + "cat > \"$D/mdp\"; " + f"step certificate sign --template \"$D/s.tpl\" --not-after {SOUS_AC_VALIDITE_H}h " + "--password-file \"$D/mdp\" \"$D/s.csr\" /etc/step-ca/certs/root_ca.crt /etc/step-ca/secrets/root_ca_key") + crt = _ssh(c.pki, "sudo -n bash -c " + shlex.quote(script), entree=c.v["vault_step_ca_password"]) + if "BEGIN CERTIFICATE" not in crt: + raise SystemExit("la racine n'a rien rendu de signe") + # 3. VERIFIER AVANT D'IMPORTER : chaine vers la racine du site, et cle correspondante. + verif = _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote( + f"cd {dossier} && cat > sous-ac.crt && printf %s {shlex.quote(c.racine_pem)} > racine.crt" + " && openssl verify -CAfile racine.crt sous-ac.crt" + " && [ \"$(openssl x509 -in sous-ac.crt -noout -pubkey)\" = \"$(openssl pkey -in sous-ac.key -pubout)\" ]" + " && echo CLE-OK"), entree=crt) + if "sous-ac.crt: OK" not in verif or "CLE-OK" not in verif: + raise SystemExit(f"le certificat signe ne se verifie pas : {verif.strip()[:200]}") + print(" autorite signee par la racine, chaine et cle verifiees sur la frontiere") + # 4. IMPORT PAR L'API LOCALE de la boite : la cle est lue sur place par curl. + conf = f'user = "{c.v["vault_opnsense_api_key"]}:{c.v["vault_opnsense_api_secret"]}"\n' + rep = _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote( + f"cd {dossier} && curl -sk -K - https://127.0.0.1/api/trust/ca/add" + " --data-urlencode 'ca[action]=existing'" + f" --data-urlencode {shlex.quote('ca[descr]=' + c.desc_sous_ac)}" + " --data-urlencode 'ca[crt_payload]@sous-ac.crt'" + " --data-urlencode 'ca[prv_payload]@sous-ac.key'"), entree=conf) + if '"saved"' not in rep: + raise SystemExit(f"import de l'autorite refuse : {rep.strip()[:300]}") + print(" autorite importee dans le magasin d'OPNsense (cle generee et restee sur la boite)") + finally: + _ssh(c.frontiere, f"sudo -n rm -P -rf {shlex.quote(dossier)}") + + +def poser_certificat(c: Contexte, descr: str, sous_ac: dict, type_cert: str) -> None: + corps = {"cert": { + "action": "internal", "descr": descr, "caref": sous_ac["refid"], + "key_type": "prime256v1", "digest": "sha256", "lifetime": str(CERT_VALIDITE_J), + "cert_type": type_cert, "private_key_location": "firewall", + "commonname": c.fqdn, "organization": "Set-OPS", "country": "CA", + "altnames_dns": c.fqdn, "altnames_ip": "\n".join(c.f["adresses"]), + }} + r = c.api("/api/trust/cert/add", corps) + if r.get("result") != "saved": + raise SystemExit(f"emission de « {descr} » refusee : {r}") + print(f" emis : {descr} ({type_cert})") + + +def racine_dans_le_systeme(c: Contexte) -> bool: + """La racine est-elle dans le magasin du SYSTEME (FreeBSD), pas seulement dans celui d'OPNsense ? + + syslog-ng verifie son destinataire contre `/etc/ssl/certs`. OPNsense y recopie ses + autorites racines au demarrage ou au rechargement global — pas a l'import. Mesure du + 2026-10-12 : racine importee, absente de `/etc/ssl/certs`. + """ + # LE SYSTEME LUI-MEME JUGE, pas une recherche de texte : le nom de l'autorite est dans le + # base64, invisible a `grep` — premiere ecriture de cette fonction, fausse pour ca. + sortie = _ssh(c.frontiere, "openssl verify -CApath /etc/ssl/certs /dev/stdin 2>&1; true", + entree=c.racine_pem) + return sortie.strip().endswith(": OK") + + +def recopier_racine(c: Contexte) -> None: + _ssh(c.frontiere, "sudo -n /usr/local/sbin/configctl system trust configure") + if not racine_dans_le_systeme(c): + raise SystemExit("la racine n'entre pas dans le magasin du systeme apres `trust configure`.") + print(" racine recopiee dans le magasin du systeme (configctl system trust configure)") + + +def fichier_autorite(c: Contexte) -> Path: + """Le certificat PUBLIC de l'autorite de la frontiere, a cote de `ac-racine-site.crt`. + + L'inventaire du site le lit pour dire au recepteur syslog QUI peut lui ecrire : les + certificats emis par cette autorite, et eux seuls. + """ + return devis_mod.depot_hebergeur() / f"ac-frontiere-{c.nom}.crt" + + +def publier_autorite(c: Contexte, sous_ac: dict) -> bool: + f = fichier_autorite(c) + pem = sous_ac["crt"].strip() + "\n" + if f.is_file() and f.read_text(encoding="utf-8") == pem: + return False + f.write_text(pem, encoding="utf-8") + print(f" publie : {f} (a committer dans le depot du site)") + return True + + +# --- USAGE : LE SYSLOG VERS LE LOKI DU SITE (F2) ------------------------------------- + +def _loki_syslog(c: Contexte) -> tuple[str, str]: + inv = si.inventaire() + for h, v in inv["_meta"]["hostvars"].items(): + ecoute = str(v.get("client_journal_syslog_ecoute") or "") + if ecoute and v.get("client_journal_syslog_tls"): + ip, port = ecoute.rsplit(":", 1) + return ip, port + return "", "" + + +def destinations_syslog(c: Contexte) -> list[dict]: + ip, port = _loki_syslog(c) + r = c.api("/api/syslog/settings/searchDestinations", {"rowCount": 100, "current": 1}) + return [d for d in (r.get("rows") or []) if d.get("hostname") == ip and str(d.get("port")) == port] + + +def poignee_tls_vers_loki(c: Contexte, services: dict) -> str: + """Une VRAIE poignee de main, depuis la frontiere, avec SON certificat de services. + + LA GARDE AVANT DE BASCULER (2026-10-12). Une destination syslog morte a deja eteint TOUTE + la journalisation de la frontiere pendant dix jours. On ne passe en `tls4` que si le + recepteur accepte ce certificat et que la frontiere verifie le sien contre la racine + du site — la meme verification que syslog-ng fera. Rend la conclusion d'openssl. + """ + ip, port = _loki_syslog(c) + script = ( + "set -e; umask 077; D=$(mktemp -d /root/setops-essai.XXXXXX); trap 'rm -P -rf \"$D\"' EXIT; " + "/usr/local/bin/python3 - \"$D\" <<'PY'\n" + "import base64,sys,xml.etree.ElementTree as E\n" + "c=E.parse('/conf/config.xml').getroot()\n" + "for n in c.iter('cert'):\n" + f" if n.findtext('refid')=={services['refid']!r}:\n" + " open(sys.argv[1]+'/c.pem','wb').write(base64.b64decode(n.findtext('crt')))\n" + " open(sys.argv[1]+'/k.pem','wb').write(base64.b64decode(n.findtext('prv')))\n" + "PY\n" + f"echo | openssl s_client -connect {ip}:{port} -cert \"$D/c.pem\" -key \"$D/k.pem\" " + f"-CApath /etc/ssl/certs -verify_ip {ip} -verify_return_error -brief 2>&1 | head -20") + return _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote(script)) + + +def basculer_syslog(c: Contexte, services: dict) -> None: + dest = destinations_syslog(c) + if not dest: + print(" syslog : aucune destination vers le Loki du site — rien a basculer") + return + a_faire = [d for d in dest if d.get("transport") != "tls4" or d.get("certificate") != services["refid"]] + if not a_faire: + return + essai = poignee_tls_vers_loki(c, services) + if "Verification: OK" not in essai or "Protocol version: TLS" not in essai: + raise SystemExit("syslog : le recepteur n'accepte pas encore la poignee TLS — destination laissee " + f"telle quelle (deployer d'abord client_journal au site).\n{essai.strip()[:300]}") + for d in a_faire: + r = c.api(f"/api/syslog/settings/setDestination/{d['uuid']}", + {"destination": {"transport": "tls4", "certificate": services["refid"]}}) + if r.get("result") != "saved": + raise SystemExit(f"syslog : destination refusee : {r}") + r = c.api("/api/syslog/service/reconfigure", {}) + if r.get("status") not in ("ok", "OK"): + raise SystemExit(f"syslog : rechargement refuse : {r}") + print(f" syslog : {len(a_faire)} destination(s) en tls4, certificat de services (poignee verifiee)") + + +# --- USAGE : L'EXPORTATEUR DE METRIQUES EN TLS (F4) ----------------------------------- + +EXPO_DIR = "/usr/local/etc/node_exporter" +# OPNSENSE REECRIT `/etc/rc.conf.d/node_exporter` a chaque sauvegarde du greffon, et son +# greffon n'offre aucune option TLS. FreeBSD lit ENSUITE `/usr/local/etc/rc.conf.d/` +# (rc.subr : `for _d in /etc ${local_startup}`), qu'OPNsense ne touche pas : on y ajoute +# l'option aux arguments du greffon, au lieu de les remplacer. Mesure du 2026-10-12. +EXPO_RC = "/usr/local/etc/rc.conf.d/node_exporter" +EXPO_RC_CONTENU = ( + "# Gere par Set-OPS (scripts/frontiere_pki.py). Lu APRES /etc/rc.conf.d/node_exporter,\n" + "# qu'OPNsense reecrit : ajoute le TLS aux arguments du greffon sans les remplacer.\n" + f'node_exporter_args="${{node_exporter_args}} --web.config.file={EXPO_DIR}/web-config.yml"\n') +EXPO_WEB = ( + "# Gere par Set-OPS (scripts/frontiere_pki.py). TLS de l'exportateur (exporter-toolkit).\n" + "tls_server_config:\n" + f" cert_file: {EXPO_DIR}/tls/exportateur.crt\n" + f" key_file: {EXPO_DIR}/tls/exportateur.key\n") + + +def _ecoute_exportateur(c: Contexte) -> str: + sortie = _ssh(c.frontiere, "grep -h '^node_exporter_listen_address' /etc/rc.conf.d/node_exporter; true") + return sortie.split("=", 1)[-1].strip().strip('"') if "=" in sortie else "" + + +def poignee_exportateur(c: Contexte) -> str: + """Le point de vue de Prometheus : HTTPS sur l'adresse d'ecoute, verifie par la racine.""" + ecoute = _ecoute_exportateur(c) + if not ecoute: + return "aucune adresse d'ecoute" + ip = ecoute.rsplit(":", 1)[0] + return _ssh(c.frontiere, f"echo | openssl s_client -connect {shlex.quote(ecoute)} -CApath /etc/ssl/certs " + f"-verify_ip {shlex.quote(ip)} -verify_return_error -brief 2>&1 | head -20; true") + + +def exportateur_en_tls(c: Contexte) -> bool: + return "Verification: OK" in poignee_exportateur(c) + + +def poser_exportateur(c: Contexte, expo: dict, sous_ac: dict) -> None: + """Ecrit certificat (+ autorite de la frontiere) et cle de l'exportateur, depuis config.xml, + SUR la boite. La cle de l'exportateur — et elle seule — est lisible par `nobody`.""" + script = ( + "set -e; umask 022; " + f"mkdir -p {EXPO_DIR}/tls /usr/local/etc/rc.conf.d; " + "/usr/local/bin/python3 - <<'PY'\n" + "import base64,os,xml.etree.ElementTree as E\n" + "c=E.parse('/conf/config.xml').getroot()\n" + "def trouve(tag,refid):\n" + " return next(n for n in c.iter(tag) if n.findtext('refid')==refid)\n" + f"f=trouve('cert',{expo['refid']!r}); a=trouve('ca',{sous_ac['refid']!r})\n" + "crt=base64.b64decode(f.findtext('crt'))+base64.b64decode(a.findtext('crt'))\n" + f"d={EXPO_DIR + '/tls'!r}\n" + "open(d+'/exportateur.crt','wb').write(crt); os.chmod(d+'/exportateur.crt',0o644)\n" + "k=d+'/exportateur.key'; fd=os.open(k,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o640)\n" + "os.write(fd,base64.b64decode(f.findtext('prv'))); os.close(fd); os.chown(k,0,65534); os.chmod(k,0o640)\n" + "PY\n" + f"printf %s {shlex.quote(EXPO_WEB)} > {EXPO_DIR}/web-config.yml; " + f"printf %s {shlex.quote(EXPO_RC_CONTENU)} > {EXPO_RC}; " + "/usr/local/etc/rc.d/node_exporter restart >/dev/null 2>&1; sleep 2") + _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote(script)) + essai = poignee_exportateur(c) + if "Verification: OK" not in essai: + raise SystemExit(f"exportateur : la poignee HTTPS ne se verifie pas apres la pose.\n{essai.strip()[:300]}") + print(f" exportateur : HTTPS sur {_ecoute_exportateur(c)}, certificat verifie par la racine du site") + + +# --- USAGE : L'INTERFACE WEB (F3) — CLASSE R3 ------------------------------------------ +# +# Aucune API : la page d'administration ecrit `system/webgui/ssl-certref` puis redemarre +# lighttpd, qui porte AUSSI l'API. Un certificat que lighttpd refuserait couperait l'un et +# l'autre. Le chemin de secours est SSH (sshd ne depend pas de lighttpd) : +# sudo php -r '...ssl-certref = ...' puis configctl webgui restart +# `write_config` garde la version precedente dans /conf/backup. Geste separe d'`appliquer`, +# avec sa propre confirmation (AGENTS.md, R3). + +def certref_interface(c: Contexte) -> str: + return _ssh(c.frontiere, "sudo -n /usr/local/bin/python3 -c \"import xml.etree.ElementTree as E;" + "print(E.parse('/conf/config.xml').getroot().findtext('system/webgui/ssl-certref') or '')\"").strip() + + +def poser_certref_interface(c: Contexte, refid: str) -> None: + php = ("require_once('config.inc'); require_once('util.inc'); global $config; " + f"$config['system']['webgui']['ssl-certref'] = '{refid}'; " + "write_config('Set-OPS : certificat de l interface web');") + _ssh(c.frontiere, "sudo -n /usr/local/bin/php -r " + shlex.quote(php) + + " && sudo -n /usr/local/sbin/configctl webgui restart >/dev/null; sleep 4") + + +def interface_verifiee(c: Contexte) -> str: + """Depuis le POSTE : la poignee HTTPS de l'interface, verifiee contre la racine du site.""" + racine = devis_mod.depot_hebergeur() / "ac-racine-site.crt" + r = subprocess.run(["openssl", "s_client", "-connect", f"{c.f['admin']}:443", "-CAfile", str(racine), + "-verify_ip", c.f["admin"], "-verify_return_error", "-brief"], + input="", capture_output=True, text=True, timeout=20) + return r.stdout + r.stderr + + +def basculer_interface(c: Contexte) -> None: + e = etat(c) + if not e["services"]: + raise SystemExit("aucun certificat de services : lancer d'abord `appliquer`.") + avant = certref_interface(c) + if avant == e["services"]["refid"]: + print(" interface : deja servie par le certificat de services") + return + print(f" interface : ssl-certref {avant} -> {e['services']['refid']} (retour arriere : {avant})") + poser_certref_interface(c, e["services"]["refid"]) + essai = interface_verifiee(c) + if "Verification: OK" not in essai: + print(" interface : la verification ECHOUE — retour arriere immediat") + poser_certref_interface(c, avant) + raise SystemExit(f"interface remise sur {avant}.\n{essai.strip()[:300]}") + print(f" interface : HTTPS sur {c.f['admin']}:443 verifie contre la racine du site") + + +def references(c: Contexte, refid: str) -> int: + """Combien de fois `refid` apparait dans config.xml HORS de sa propre definition.""" + n = _ssh(c.frontiere, f"sudo -n grep -c {shlex.quote(refid)} /conf/config.xml; true").strip() + return max(int(n or 0) - 1, 0) + + +def regenerer(c: Contexte) -> None: + """Retire l'autorite de la frontiere et ses certificats, pour les refaire a neuf. + + QUAND : une cle a pu etre exposee. Premier usage, le 2026-10-12 : la liste par l'API + avait rapatrie les cles sur le poste avant que `lister` ne lise sur la frontiere. + REFUSE si l'un d'eux est employe ailleurs (interface, syslog...) : le retirer couperait + ce service — c'est alors un geste a part, avec son retour arriere. + """ + e = etat(c) + certs = [e[k] for k in ("services", "exportateur") if e[k]] + employes = [r["descr"] for r in certs if references(c, r["refid"]) > 0] + if employes: + raise SystemExit(f"refus : employe(s) ailleurs dans config.xml — {', '.join(employes)}") + + def retirer(quoi: str, r: dict) -> None: + rep = c.api(f"/api/trust/{quoi}/del/{r['uuid']}", {}) + if rep.get("result") != "deleted": + raise SystemExit(f"retrait de « {r['descr']} » refuse : {rep}") + print(f" retire : {r['descr']}") + + for r in certs: + retirer("cert", r) + # L'AUTORITE ENSUITE, et seulement si plus rien ne la nomme : ses propres certificats + # viennent de partir, toute autre reference serait un usage qu'on ne connait pas. + if e["sous_ac"]: + if references(c, e["sous_ac"]["refid"]) > 0: + raise SystemExit("refus : l'autorite est encore nommee dans config.xml apres le retrait de ses certificats") + retirer("ca", e["sous_ac"]) + + +def main() -> int: + action = sys.argv[1] if len(sys.argv) > 1 else "plan" + if action not in ("plan", "appliquer", "regenerer", "interface"): + raise SystemExit(__doc__) + c = Contexte() + if action == "interface": + print(f"Interface de {c.nom} : ssl-certref = {certref_interface(c)}") + if os.environ.get("CONFIRMER") != "true": + print("Geste R3 (l'interface ET l'API passent par lighttpd) : CONFIRMER=true pour basculer.") + return 0 + basculer_interface(c) + return 0 + if action == "regenerer": + if os.environ.get("CONFIRMER") != "true": + raise SystemExit("regenerer retire l'autorite de la frontiere : CONFIRMER=true.") + regenerer(c) + action = "appliquer" + e = etat(c) + if e["racine"] and not racine_dans_le_systeme(c): + e["a_faire"].append("racine_systeme") + if e["sous_ac"] and (not fichier_autorite(c).is_file() + or fichier_autorite(c).read_text(encoding="utf-8").strip() != e["sous_ac"]["crt"].strip()): + e["a_faire"].append("publier") + afficher(c, e) + if "racine_systeme" in e["a_faire"]: + print(" A FAIRE racine recopiee dans le magasin du systeme (syslog-ng en depend)") + if "publier" in e["a_faire"]: + print(f" A FAIRE publier l'autorite dans {fichier_autorite(c).name} (le recepteur syslog la lit)") + if e["exportateur"]: + ok = exportateur_en_tls(c) + print(f" {'ok' if ok else 'A FAIRE':9} exportateur en HTTPS ({_ecoute_exportateur(c)}), verifie par la racine") + if not ok: + e["a_faire"].append("exportateur_tls") + if e["services"]: + for d in destinations_syslog(c): + ok = d.get("transport") == "tls4" and d.get("certificate") == e["services"]["refid"] + print(f" {'ok' if ok else 'A FAIRE':9} syslog vers {d.get('hostname')}:{d.get('port')} " + f"en {d.get('transport')} — attendu tls4 + certificat de services") + if not ok: + e["a_faire"].append("syslog") + if not e["a_faire"]: + print("Rien a faire.") + if action == "plan" or not e["a_faire"]: + return 0 + # L'ETAT EST RELU PLUS BAS (apres chaque pose) : ce qui ne se relit pas — la bascule du + # syslog — se retient ici. Premiere ecriture : relu, oublie, « Fait. » sans rien faire. + syslog_a_faire = "syslog" in e["a_faire"] + expo_a_faire = "exportateur_tls" in e["a_faire"] or "exportateur" in e["a_faire"] + if os.environ.get("CONFIRMER") != "true": + print("\nCONFIRMER=true pour poser ce qui manque.") + return 0 + print() + if "racine" in e["a_faire"]: + poser_racine(c) + if "sous_ac" in e["a_faire"]: + poser_sous_ac(c) + if not racine_dans_le_systeme(c): + recopier_racine(c) + e = etat(c) + if not e["sous_ac"] or e["sous_ac"].get("caref") != (e["racine"] or {}).get("refid"): + raise SystemExit("l'autorite n'est pas rattachee a la racine dans le magasin — arret.") + if "services" in e["a_faire"]: + poser_certificat(c, c.desc_services, e["sous_ac"], "combined_server_client") + if "exportateur" in e["a_faire"]: + poser_certificat(c, c.desc_expo, e["sous_ac"], "server_cert") + publier_autorite(c, etat(c)["sous_ac"]) + if syslog_a_faire: + basculer_syslog(c, etat(c)["services"]) + if expo_a_faire: + e2 = etat(c) + poser_exportateur(c, e2["exportateur"], e2["sous_ac"]) + print() + afficher(c, etat(c)) + print("Fait.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 9b3178b..85bdfb3 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -627,6 +627,33 @@ def inventaire() -> dict: if h.get("role") == "frontiere" and str(h.get("etat") or "actif") == "actif"), "frontiere") + # CE QUE LA PKI DU SITE CERTIFIE DE LA FRONTIERE (2026-10-12). Son autorite subordonnee + # est bornee aux noms sous le domaine du site et aux adresses du site : on ne retient donc + # que ses pattes dans le supernet du site — ni le transit (10.0.4.x), ni la grappe + # (192.168.11.x), qu'une contrainte de noms rendrait invalides. `management` est la patte + # d'administration, celle par ou le controleur la joint. + try: + _autorite_frontiere = ((U.chemin().resolve().parent / f"ac-frontiere-{_nom_frontiere}.crt") + .read_text(encoding="utf-8") if U.chemin() else "") + except OSError: + _autorite_frontiere = "" + import ipaddress as _ipa + from inventory_rules import supernet_de as _supernet_de + _supernet_site = _supernet_de(int(u.get("index"))) if u and u.get("index") is not None else "" + _pattes_frontiere = [h for h in U.hotes(u) + if h.get("role") == "frontiere" and str(h.get("nom")) == _nom_frontiere + and str(h.get("etat") or "actif") == "actif" and h.get("ip")] + _frontiere_pki = { + "nom": _nom_frontiere, + "admin": next((str(h["ip"]) for h in _pattes_frontiere + if str(h.get("reseau")) == "management"), ""), + "supernet": _supernet_site, + "adresses": sorted({str(h["ip"]) for h in _pattes_frontiere + if _supernet_site and _ipa.ip_address(str(h["ip"])) + in _ipa.ip_network(_supernet_site)}, + key=_ipa.ip_address), + } if _pattes_frontiere and _supernet_site else {} + # LE NOM PUBLIC D'UN SERVICE VIENT DU PLAN, PAS DU ROLE — AUSSI AU SITE (2026-09-14). # # `instancier` derive `_hostname` de l'exposition declaree depuis le @@ -823,8 +850,24 @@ def inventaire() -> dict: # `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. + # L'AUTORITE DU SITE CERTIFIE LA FRONTIERE (2026-10-12) — voir + # `playbooks/maintenance/frontiere_pki.yml`. Sur la machine qui PORTE la racine. + **({"setops_frontiere": _frontiere_pki} + if (_frontiere_pki and "serveur_step_ca" in _groupes_de(nom)) else {}), "serveur_postgresql_tls_force": True, "serveur_postgresql_tls_actif": True, + # LES METRIQUES ET LES JOURNAUX DU SITE CIRCULAIENT EN CLAIR (mesure du 2026-10-12). + # + # Meme ecart que PostgreSQL ci-dessus, et que le certificat de l'edge plus bas : + # chaque locataire pose ces quatre interrupteurs dans ses group_vars, et + # l'inventaire du site, qui n'en a pas, ne les posait nulle part. node_exporter + # (9100) et Loki (3100) servaient en HTTP sur les neuf VM, alors que le registre + # les dit `tls`. Toutes portent `client_pki` : le certificat etait la, inutilise. + # `test_parite_tls_site.py` compare desormais le site aux locataires. + "client_metrique_tls_actif": True, + "serveur_prometheus_metriques_tls": True, + "serveur_loki_tls_actif": True, + "client_journal_loki_tls": True, "serveur_grafana_oidc_actif": False, "serveur_grafana_connexion_locale": True, # LA CONSOLE DE SUPERVISION GERE SES COMPTES ELLE-MEME, AU SITE. @@ -892,6 +935,13 @@ def inventaire() -> dict: # configuration a garder en phase. **({"client_journal_syslog_ecoute": f"{srv.get('ip')}:1514", "client_journal_syslog_emetteur": _nom_frontiere, + # EN TLS, ET LA FRONTIERE SEULE PEUT Y ECRIRE (2026-10-12) : le recepteur exige + # un certificat client emis par l'autorite de la frontiere, que + # `frontiere_pki.py` publie a cote de la racine. Sans ce fichier, le recepteur + # reste en clair plutot que de refuser tout le monde. + **({"client_journal_syslog_tls": True, + "client_journal_syslog_clients_ca_contenu": _autorite_frontiere} + if _autorite_frontiere else {}), # La sonde interroge la MEME etiquette que celle posee sur l'ecouteur : # une seule derivation, deux consommateurs. "serveur_loki_sonde_frontiere_etiquette": _nom_frontiere} @@ -921,8 +971,11 @@ def inventaire() -> dict: # materiel legende « 192.168.11.43:9100 » — et c'est a l'exploitant de se # souvenir quelle machine chauffe. [{"job": "fabric", "cibles": _fabric_metriques, "hotes": _fabric_nommes}] + # EN HTTPS DES QUE LA FRONTIERE A SON AUTORITE (2026-10-12) : + # `frontiere_pki.py` pose le TLS de son exportateur et publie l'autorite. + ([{"job": "frontiere", "cibles": sorted(_frontiere_metriques), - "hotes": _frontiere_metriques}] if _frontiere_metriques else [])} + "hotes": _frontiere_metriques, "tls": bool(_autorite_frontiere)}] + if _frontiere_metriques else [])} if (_fabric_metriques and "serveur_prometheus" in _groupes_de(nom)) else {}), **communes, # En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le @@ -977,6 +1030,15 @@ def inventaire() -> dict: _ip_loki = str((serveurs.get(_a_app.get("hote")) or {}).get("ip") or "") break + # LA RACINE DU SITE, CERTIFICAT PUBLIC : le fichier que le site publie deja a ses + # locataires (`contexte.py`). Absente, les hyperviseurs gardent leur URL d'avant + # plutot que de pousser en HTTPS sans pouvoir verifier. + try: + _racine_site = ((U.chemin().resolve().parent / "ac-racine-site.crt").read_text(encoding="utf-8") + if U.chemin() else "") + except OSError: + _racine_site = "" + _hyperviseurs = {} for _h in U.hotes(u): if str(_h.get("role")) != "hyperviseur": @@ -1012,8 +1074,18 @@ def inventaire() -> dict: # LES JOURNAUX, EUX, PARTENT D'ICI — et par une ADRESSE, pas par un nom : le # plancher `/etc/hosts` du socle n'est pas pose sur un hyperviseur, Proxmox s'en # sert pour l'identite de noeud du cluster. + # + # EN HTTPS, VERIFIE (2026-10-12) : le certificat de Loki porte son adresse IP, et + # l'hyperviseur recoit la racine du site comme un simple fichier — pas de + # `client_pki`, pas de certificat a lui, rien a renouveler. **({"client_journal_loki_url": - f"http://{_ip_loki}:3100/loki/api/v1/push"} if _ip_loki else {}), + f"https://{_ip_loki}:3100/loki/api/v1/push", + "client_journal_loki_tls": True, + "client_journal_loki_ca": "/etc/alloy/ac-racine-site.crt", + "client_journal_loki_ca_contenu": _racine_site} + if (_ip_loki and _racine_site) else + {"client_journal_loki_url": f"http://{_ip_loki}:3100/loki/api/v1/push"} + if _ip_loki else {}), # NI LE CACHE DU SITE NI SES AMORCAGES (2026-10-03). `communes` le donne a toute machine du # site ; un hyperviseur ne l'atteint pas — mesure depuis asgard : # `10.37.33.21:3142` injoignable, par la frontiere comme tout le reste. Avec diff --git a/scripts/tests/test_exportateur_pg_tls.py b/scripts/tests/test_exportateur_pg_tls.py new file mode 100644 index 0000000..1a4d725 --- /dev/null +++ b/scripts/tests/test_exportateur_pg_tls.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""L'exportateur PostgreSQL sert en TLS, et Prometheus le lit en HTTPS verifie. + +POURQUOI (2026-10-12). Le 9187 etait le seul port de metriques en clair, chez les +locataires comme au site — dette inscrite au registre le 2026-09-14. Un seul interrupteur +decide des deux bouts : `serveur_postgresql_tls_actif`, que la declaration de l'exportateur +nomme (`tls_si`) et que Prometheus lit dans les hostvars de chaque cible. +""" +from __future__ import annotations + +import base64 +import sys +from pathlib import Path + +import jinja2 +import yaml + +RACINE = Path(__file__).resolve().parents[2] +PG = RACINE / "roles/serveur_postgresql" + + +def rendre_prometheus(tls_par_hote: dict[str, bool]) -> dict: + meta = PG / "meta/metriques.yml" + # LES FILTRES D'ANSIBLE LUI-MEME (intersect, extract, regex_replace, b64decode...) : + # un rendu avec des imitations prouverait les imitations. + from ansible.plugins.filter.core import FilterModule as Coeur + from ansible.plugins.filter.mathstuff import FilterModule as Maths + env = jinja2.Environment(extensions=["jinja2.ext.do"]) + env.filters.update(Coeur().filters()) + env.filters.update(Maths().filters()) + hotes = sorted(tls_par_hote) + hostvars = {h: {"ansible_host": f"10.0.0.{i + 1}", "serveur_postgresql_tls_actif": v} + for i, (h, v) in enumerate(sorted(tls_par_hote.items()))} + rendu = env.from_string((RACINE / "roles/serveur_prometheus/templates/prometheus.yml.j2") + .read_text(encoding="utf-8")).render( + serveur_prometheus_intervalle="15s", serveur_prometheus_metriques_tls=True, + serveur_prometheus_metriques_ca="/etc/step/certs/root_ca.crt", + serveur_prometheus_groupe_metriques="client_metrique", serveur_prometheus_port_node=9100, + serveur_prometheus_cibles_supplementaires=[], + groups={"client_metrique": hotes, "hotes_actifs": hotes, "serveur_postgresql": hotes}, + hostvars=hostvars, + serveur_prometheus_declarations={"results": [{ + "item": {"path": str(meta)}, + "content": base64.b64encode(meta.read_bytes()).decode()}]}, + ) + return {j["job_name"]: j for j in yaml.safe_load(rendu)["scrape_configs"]} + + +def main() -> int: + echecs: list[str] = [] + + expo = (yaml.safe_load((PG / "meta/metriques.yml").read_text(encoding="utf-8")) or {}).get("exportateur") or {} + if expo.get("tls_si") != "serveur_postgresql_tls_actif": + echecs.append(f"meta/metriques.yml : tls_si = {expo.get('tls_si')!r}, attendu serveur_postgresql_tls_actif") + + flux = yaml.safe_load((PG / "meta/flux.yml").read_text(encoding="utf-8"))["flux"] + f9187 = next((f for f in flux if str(f.get("port")) == "9187"), {}) + if f9187.get("chiffrement") != "tls": + echecs.append(f"le registre dit 9187 `{f9187.get('chiffrement')}`, attendu `tls`") + + taches = (PG / "tasks/main.yml").read_text(encoding="utf-8") + if "--web.config.file=" not in taches or "serveur_postgresql_exportateur_web_config" not in taches: + echecs.append("l'exportateur ne recoit pas `--web.config.file`") + if "setops-pg-exportateur-cert-sync" not in taches: + echecs.append("l'exportateur n'est pas abonne au renouvellement du certificat") + + jobs = rendre_prometheus({"data-sql-01": True, "data-sql-02": True}) + pg = jobs.get("postgresql", {}) + if pg.get("scheme") != "https" or (pg.get("tls_config") or {}).get("ca_file") != "/etc/step/certs/root_ca.crt": + echecs.append(f"toutes cibles TLS : job postgresql = {pg}, attendu https + ca_file") + jobs = rendre_prometheus({"data-sql-01": True, "data-sql-02": False}) + if jobs.get("postgresql", {}).get("scheme") == "https": + echecs.append("une cible sans TLS : le job ne doit pas passer en https (il la perdrait)") + + for e in echecs: + print(f"ECHEC : {e}") + if not echecs: + print("OK : l'exportateur PostgreSQL sert en TLS ; Prometheus le lit en HTTPS verifie sur le meme interrupteur.") + return 1 if echecs else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_frontiere_pki.py b/scripts/tests/test_frontiere_pki.py new file mode 100644 index 0000000..1a6c1e4 --- /dev/null +++ b/scripts/tests/test_frontiere_pki.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""L'autorite de la frontiere est bornee, et ses cles ne quittent pas la boite. + +POURQUOI (2026-10-12). La frontiere porte desormais une autorite subordonnee signee par la +racine du site (scripts/frontiere_pki.py). Ce test garde ce qui la rend sure : + 1. le gabarit de signature : aucune autorite sous elle, signature seulement, contrainte + de noms CRITIQUE sur le domaine et le supernet du site ; + 2. la lecture du magasin ne passe pas par `/api/trust/*/search`, qui renvoie les cles + privees (mesure du 2026-10-12 : la liste rapatriait la cle de l'autorite sur le poste) ; + 3. les noms du certificat sont ELIGIBLES : FQDN et adresses du supernet seulement ; + 4. l'interface web (R3) reste un geste a part, derriere sa propre confirmation ; + 5. le registre dit `tls` pour le syslog (1514) et l'exportateur de la frontiere. +""" +from __future__ import annotations + +import ipaddress +import json +import re +import sys +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(RACINE / "scripts")) + +import frontiere_pki as fp # noqa: E402 + + +def main() -> int: + echecs: list[str] = [] + + # 1. Le gabarit, rendu comme le script le rend. + rendu = fp.GABARIT_SOUS_AC.format(domaine=json.dumps("exemple.internal"), supernet=json.dumps("10.99.0.0/16")) + gabarit = json.loads(rendu.replace("{{ toJson .Subject }}", "{}")) + if gabarit.get("basicConstraints") != {"isCA": True, "maxPathLen": 0}: + echecs.append(f"basicConstraints = {gabarit.get('basicConstraints')}, attendu isCA + maxPathLen 0") + if sorted(gabarit.get("keyUsage") or []) != ["certSign", "crlSign"]: + echecs.append(f"keyUsage = {gabarit.get('keyUsage')}, attendu certSign + crlSign seulement") + nc = gabarit.get("nameConstraints") or {} + if not nc.get("critical") or nc.get("permittedDNSDomains") != ["exemple.internal"] \ + or nc.get("permittedIPRanges") != ["10.99.0.0/16"]: + echecs.append(f"nameConstraints = {nc}, attendu critique, domaine et supernet du site") + + # 2. Aucune lecture du magasin par la recherche de l'API (elle rend les cles). + source = (RACINE / "scripts/frontiere_pki.py").read_text(encoding="utf-8") + code = "\n".join(l for l in source.splitlines() if not l.lstrip().startswith("#")) + if re.search(r"/api/trust/[^\"']*/search", code.replace("`/api/trust/{ca,cert}/search`", "")): + echecs.append("frontiere_pki.py lit le magasin par /api/trust/*/search, qui renvoie les cles privees") + if "/conf/config.xml" not in code or "'crt'" not in code: + echecs.append("la lecture du magasin ne se fait plus sur la frontiere (config.xml)") + + # 3. Les noms du certificat, tels que l'inventaire du site les derive. + try: + import site_inventaire as si + hv = si.inventaire()["_meta"]["hostvars"] + f = (hv.get(fp.HOTE_PKI) or {}).get("setops_frontiere") or {} + except Exception: # noqa: BLE001 — aucun site monte + f = {} + if f: + reseau = ipaddress.ip_network(f["supernet"]) + hors = [a for a in f["adresses"] if ipaddress.ip_address(a) not in reseau] + if hors: + echecs.append(f"adresses hors du supernet {reseau} dans le certificat : {hors}") + if not f.get("admin") or f["admin"] not in f["adresses"]: + echecs.append("l'adresse d'administration de la frontiere n'est pas dans ses adresses du site") + + # 4. L'interface web : un geste a part, confirme a part. + if "def basculer_interface" not in code or 'action == "interface"' not in code: + echecs.append("la bascule de l'interface web n'est plus une commande separee") + if re.search(r"if action == .appliquer.[\s\S]*basculer_interface\(", code.split("def main")[1].split('if action == "interface"')[0]): + echecs.append("`appliquer` bascule l'interface web (R3) sans sa propre confirmation") + + # 5. Le registre. + def chiffrement(role: str, port: int, pair: str) -> str: + flux = yaml.safe_load((RACINE / f"roles/{role}/meta/flux.yml").read_text(encoding="utf-8"))["flux"] + return next((x.get("chiffrement") for x in flux + if x.get("port") == port and pair in (x.get("pair") if isinstance(x.get("pair"), list) else [x.get("pair")])), "") + if chiffrement("serveur_loki", 1514, "fabric") != "tls-requis": + echecs.append("le registre ne dit pas le syslog de la frontiere (1514) en tls-requis") + if chiffrement("serveur_prometheus", 9100, "frontiere") != "tls": + echecs.append("le registre ne dit pas l'exportateur de la frontiere en tls") + + for e in echecs: + print(f"ECHEC : {e}") + if not echecs: + print("OK : autorite de la frontiere bornee, magasin lu sans ses cles, noms eligibles, interface a part.") + return 1 if echecs else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_parite_tls_site.py b/scripts/tests/test_parite_tls_site.py new file mode 100644 index 0000000..cf64991 --- /dev/null +++ b/scripts/tests/test_parite_tls_site.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Ce qu'un locataire chiffre par ses group_vars, le site le chiffre aussi. + +POURQUOI (2026-10-12). Trois fois le meme ecart : un interrupteur TLS que chaque locataire +pose dans ses `group_vars`, et que l'inventaire du site — dynamique, sans group_vars — ne +posait nulle part. PostgreSQL servait le certificat auto-signe de Debian (2026-09-12), +l'edge aussi (meme jour), et node_exporter et Loki servaient en HTTP (2026-10-12) alors +que le registre des flux les dit `tls`. Chaque fois, le certificat etait la, inutilise. + +Le test lit les `group_vars/.yml` des locataires montes a cote du depot, retient +chaque cle qui contient `tls` et vaut vrai, et exige la meme valeur dans les hostvars des +machines du site qui portent ce groupe. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(RACINE / "scripts")) + +import site_inventaire as si # noqa: E402 + +# CE QUI RESTE A FAIRE, NOMME — pas une tolerance muette. Chaque entree dit pourquoi le +# site differe encore, et disparait le jour ou l'ecart se ferme. +RESTE = { + ("client_metrique", "client_metrique_tls_actif", "hyperviseurs"): + "node_exporter des hyperviseurs : il leur faudrait un certificat a eux " + "(pas de client_pki sur la fabric) — collecte encore en HTTP, chantier suivant.", +} + +# CE QUE LE SITE OBTIENT AUTREMENT, avec la CONDITION qui le prouve dans ses hostvars. +# Pas une dispense : si la condition tombe, l'ecart redevient un echec. +AUTREMENT = { + ("serveur_icinga", "serveur_icinga_db_tls"): ( + lambda v: v.get("serveur_postgresql_tls_force") is True, + "derive par le role de `serveur_postgresql_tls_force` (resoudre_base), vrai au site"), + ("serveur_postfix", "serveur_postfix_lmtp_tls_verify"): ( + lambda v: v.get("serveur_postfix_mode") == "relais", + "sans objet : le relais du site ne remet rien par LMTP (`serveur_postfix_mode: relais`)"), +} + + +def interrupteurs_des_locataires() -> dict[tuple[str, str], list[str]]: + """{(groupe, cle): [locataires]} pour chaque cle *tls* vraie d'un group_vars.""" + vus: dict[tuple[str, str], list[str]] = {} + for gv in sorted(RACINE.parent.glob("OPS-*/inventories/principal/group_vars/*.yml")): + locataire = gv.parents[3].name + if locataire.endswith("-lab"): + continue + try: + donnees = yaml.safe_load(gv.read_text(encoding="utf-8")) or {} + except yaml.YAMLError: + continue + if not isinstance(donnees, dict): + continue + for cle, val in donnees.items(): + if "tls" in str(cle) and val is True: + vus.setdefault((gv.stem, str(cle)), []).append(locataire) + return vus + + +def main() -> int: + attendus = interrupteurs_des_locataires() + if not attendus: + print("OK : aucun locataire monte a cote du depot — rien a comparer.") + return 0 + try: + inv = si.inventaire() + except SystemExit as e: # plan du site refuse ou absent + print(f"OK : aucun site monte ({e}) — rien a comparer.") + return 0 + except Exception as e: # noqa: BLE001 + print(f"OK : aucun site monte ({type(e).__name__}) — rien a comparer.") + return 0 + + hv = inv["_meta"]["hostvars"] + hyperviseurs = set((inv.get("hyperviseurs") or {}).get("hosts") or []) + echecs, restes = [], [] + for (groupe, cle), qui in sorted(attendus.items()): + for hote in (inv.get(groupe) or {}).get("hosts") or []: + if hv.get(hote, {}).get(cle) is True: + continue + autre = AUTREMENT.get((groupe, cle)) + if autre and autre[0](hv.get(hote, {})): + continue + classe = "hyperviseurs" if hote in hyperviseurs else "vm" + raison = RESTE.get((groupe, cle, classe)) + if raison: + restes.append(f"{hote} : {cle} — {raison}") + else: + echecs.append(f"{hote} ({groupe}) : `{cle}` vaut {hv.get(hote, {}).get(cle)!r} au site, " + f"true chez {', '.join(qui)}") + + for r in sorted(set(restes)): + print(f"RESTE : {r}") + for e in echecs: + print(f"ECHEC : {e}") + if not echecs: + print(f"OK : les {len(attendus)} interrupteurs TLS des locataires valent aussi au site.") + return 1 if echecs else 0 + + +if __name__ == "__main__": + raise SystemExit(main())