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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-12 14:25:28 -04:00
parent 78c3505577
commit f3bb9caee3
26 changed files with 1415 additions and 31 deletions

View file

@ -1,5 +1,102 @@
# CHANGELOG — Set-OPS # 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 ## 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 **95 preuves.** `make verifier` passe ; `test_dovecot_sonde_et_imap.py` garde les deux corrections

View file

@ -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_relais_du_site.py
python3 scripts/tests/test_sasl_local.py python3 scripts/tests/test_sasl_local.py
python3 scripts/tests/test_dovecot_sonde_et_imap.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_sonde_tcp.py
python3 scripts/tests/test_restauration.py python3 scripts/tests/test_restauration.py
python3 scripts/tests/test_repertoires_partages.py python3 scripts/tests/test_repertoires_partages.py

View file

@ -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` | 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 | 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_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 | 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 | 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. | | `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 | 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_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 | 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 | 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 | 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. | | `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_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` | 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 | 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_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_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. | | `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 ## Synthèse chiffrement
- **clair** : 52 flux - **clair** : 49 flux
- **n-a** : 8 flux - **n-a** : 8 flux
- **ssh** : 9 flux - **ssh** : 9 flux
- **starttls** : 6 flux - **starttls** : 6 flux
- **tls** : 8 flux - **tls** : 10 flux
- **tls-cible** : 2 flux - **tls-cible** : 2 flux
- **tls-requis** : 36 flux - **tls-requis** : 37 flux

View file

@ -8,7 +8,12 @@ client_journal_config: "/etc/alloy/config.alloy"
# Point de collecte Loki (obs-01, serveur_loki, VLAN 14). # 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. # 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). # 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" 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 --- # --- TLS (zero-confiance) : Alloy pousse vers Loki en https + verifie root_ca ---
client_journal_loki_tls: false client_journal_loki_tls: false
client_journal_loki_ca: "/etc/step/certs/root_ca.crt" 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 # 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 # 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. # du site — jamais fabrique ici.
client_journal_syslog_emetteur: "frontiere" 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). # LE JOURNAL D'AUDIT, EXPEDIE COMME LE RESTE (2026-09-11).
# #
# DERIVE DE L'APPARTENANCE AU GROUPE, pas declare par instance : `auditd` vient avec # DERIVE DE L'APPARTENANCE AU GROUPE, pas declare par instance : `auditd` vient avec

View file

@ -184,6 +184,104 @@
append: true append: true
notify: Redemarrer alloy 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 - name: Deployer la configuration Alloy
ansible.builtin.template: ansible.builtin.template:
src: config.alloy.j2 src: config.alloy.j2

View file

@ -141,6 +141,16 @@ loki.source.syslog "frontiere" {
protocol = "{{ client_journal_syslog_protocole }}" protocol = "{{ client_journal_syslog_protocole }}"
label_structured_data = true label_structured_data = true
labels = { job = "syslog", host = "{{ client_journal_syslog_emetteur }}" } 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] forward_to = [loki.relabel.syslog.receiver]
} }

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -45,7 +45,10 @@ flux:
port: 1514 port: 1514
protocole: tcp protocole: tcp
pair: fabric 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: >- raison: >-
Journaux de la frontiere. Elle n'accueille aucun agent et n'emet que du syslog ; sans Journaux de la frontiere, en TLS mutuel. Elle n'accueille aucun agent et n'emet que du
ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part. syslog ; sans ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part.

View file

@ -72,6 +72,12 @@ serveur_postgresql_exportateur_service: prometheus-postgres-exporter
serveur_postgresql_exportateur_port: 9187 serveur_postgresql_exportateur_port: 9187
serveur_postgresql_exportateur_utilisateur: setops_metriques serveur_postgresql_exportateur_utilisateur: setops_metriques
serveur_postgresql_exportateur_mot_de_passe: "{{ vault_pg_exportateur | default('') }}" 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 --------------------------------- # --- RESTAURATION (2026-09-30) — voir tasks/main.yml ---------------------------------
# #

View file

@ -20,20 +20,12 @@ flux:
port: 9187 port: 9187
protocole: tcp protocole: tcp
pair: [serveur_prometheus] pair: [serveur_prometheus]
# `clair`, ET C'EST UNE DETTE QU'ON ECRIT PLUTOT QUE DE LA TAIRE (2026-09-14). # 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
# `client_metrique` sert deja ses metriques en TLS, certificat synchronise par # renouvellement), et Prometheus verifie contre l'autorite des que
# `client_pki`. Cet exportateur-ci ne le fait pas encore : il faudrait lui poser un # `serveur_postgresql_tls_actif` est vrai — partout, au site comme chez les locataires.
# `--web.config.file` et l'abonner au meme renouvellement. chiffrement: tls
#
# 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
raison: >- raison: >-
Metriques PostgreSQL lues par l'observatoire. Series, pas verdicts : ce qui derive Metriques PostgreSQL lues par l'observatoire, en HTTPS verifie. Series, pas verdicts :
lentement — cache qui decroche, connexions qui montent, bases qui grossissent — n'a ce qui derive lentement — cache qui decroche, connexions qui montent, bases qui
que le graphe pour se faire voir. En clair pour l'instant, contrairement a grossissent — n'a que le graphe pour se faire voir.
`client_metrique` : dette inscrite, a fermer par `--web.config.file` + `client_pki`.

View file

@ -29,6 +29,11 @@ exportateur:
service: prometheus-postgres-exporter service: prometheus-postgres-exporter
port: 9187 port: 9187
job: postgresql 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. # LES PANNEAUX : ce qui merite d'etre REGARDE DANS LE TEMPS.
# #

View file

@ -330,6 +330,100 @@
state: present state: present
when: serveur_postgresql_exportateur_actif | bool 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 # 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 # 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. # 0600, il ne l'est que par root et le service.
@ -340,7 +434,9 @@
DATA_SOURCE_NAME="postgresql://{{ serveur_postgresql_exportateur_utilisateur DATA_SOURCE_NAME="postgresql://{{ serveur_postgresql_exportateur_utilisateur
}}:{{ serveur_postgresql_exportateur_mot_de_passe }}:{{ serveur_postgresql_exportateur_mot_de_passe
}}@127.0.0.1:5432/postgres?sslmode=disable" }}@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 dest: /etc/default/prometheus-postgres-exporter
owner: root owner: root
group: root group: root

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -30,7 +30,8 @@ flux:
port: 9100 port: 9100
protocole: tcp protocole: tcp
pair: frontiere pair: frontiere
chiffrement: clair # HTTPS VERIFIE (2026-10-12) : certificat emis par l'autorite de la frontiere.
chiffrement: tls
raison: >- raison: >-
Scrape de l'exportateur de la frontiere (os-node_exporter), sur sa seule patte de 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 supervision : l'equipement qui voit passer TOUT le trafic n'avait ni temperature ni

View file

@ -43,9 +43,21 @@ scrape_configs:
| map('extract', hostvars) | selectattr('ansible_host', 'defined') | map('extract', hostvars) | selectattr('ansible_host', 'defined')
| map(attribute='ansible_host') | map(attribute='ansible_host')
| map('regex_replace', '$', ':' ~ (expo.port | default(0))) | list %} | 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 %} {% if expo and cibles %}
- job_name: {{ expo.job }} - job_name: {{ expo.job }}
{% if tls.oui %}
scheme: https
tls_config:
ca_file: {{ serveur_prometheus_metriques_ca }}
{% endif %}
static_configs: static_configs:
- targets: {{ cibles | to_json }} - targets: {{ cibles | to_json }}
{% endif %} {% endif %}
@ -55,6 +67,13 @@ scrape_configs:
{% for job in serveur_prometheus_cibles_supplementaires %} {% for job in serveur_prometheus_cibles_supplementaires %}
- job_name: {{ job.job }} - 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: static_configs:
{% if job.hotes | default({}) %} {% if job.hotes | default({}) %}
{# UNE CIBLE NOMMEE PAR BLOC : l'etiquette `hote` s'attache a un `static_config`, pas a {# UNE CIBLE NOMMEE PAR BLOC : l'etiquette `hote` s'attache a un `static_config`, pas a

View file

@ -61,7 +61,17 @@ class Frontiere:
def __init__(self, url: str, cle: str, secret: str, verifier: bool): def __init__(self, url: str, cle: str, secret: str, verifier: bool):
self.base = url.rstrip("/") self.base = url.rstrip("/")
self.auth = "Basic " + base64.b64encode(f"{cle}:{secret}".encode()).decode() 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 @staticmethod
def _formulaire(corps: dict) -> bytes: def _formulaire(corps: dict) -> bytes:

592
scripts/frontiere_pki.py Normal file
View file

@ -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/<nom>`
# (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 = <ancien>...' 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())

View file

@ -627,6 +627,33 @@ def inventaire() -> dict:
if h.get("role") == "frontiere" if h.get("role") == "frontiere"
and str(h.get("etat") or "actif") == "actif"), "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). # LE NOM PUBLIC D'UN SERVICE VIENT DU PLAN, PAS DU ROLE — AUSSI AU SITE (2026-09-14).
# #
# `instancier` derive `<groupe>_hostname` de l'exposition declaree depuis le # `instancier` derive `<groupe>_hostname` de l'exposition declaree depuis le
@ -823,8 +850,24 @@ def inventaire() -> dict:
# `tls_force` pose `hostssl` : toute connexion NON chiffree est refusee. # `tls_force` pose `hostssl` : toute connexion NON chiffree est refusee.
# On ne l'active qu'apres avoir confirme que le certificat servi vient bien # 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. # 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_force": True,
"serveur_postgresql_tls_actif": 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_oidc_actif": False,
"serveur_grafana_connexion_locale": True, "serveur_grafana_connexion_locale": True,
# LA CONSOLE DE SUPERVISION GERE SES COMPTES ELLE-MEME, AU SITE. # LA CONSOLE DE SUPERVISION GERE SES COMPTES ELLE-MEME, AU SITE.
@ -892,6 +935,13 @@ def inventaire() -> dict:
# configuration a garder en phase. # configuration a garder en phase.
**({"client_journal_syslog_ecoute": f"{srv.get('ip')}:1514", **({"client_journal_syslog_ecoute": f"{srv.get('ip')}:1514",
"client_journal_syslog_emetteur": _nom_frontiere, "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 : # La sonde interroge la MEME etiquette que celle posee sur l'ecouteur :
# une seule derivation, deux consommateurs. # une seule derivation, deux consommateurs.
"serveur_loki_sonde_frontiere_etiquette": _nom_frontiere} "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 # materiel legende « 192.168.11.43:9100 » — et c'est a l'exploitant de se
# souvenir quelle machine chauffe. # souvenir quelle machine chauffe.
[{"job": "fabric", "cibles": _fabric_metriques, "hotes": _fabric_nommes}] [{"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), + ([{"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 {}), if (_fabric_metriques and "serveur_prometheus" in _groupes_de(nom)) else {}),
**communes, **communes,
# En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le # 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 "") _ip_loki = str((serveurs.get(_a_app.get("hote")) or {}).get("ip") or "")
break 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 = {} _hyperviseurs = {}
for _h in U.hotes(u): for _h in U.hotes(u):
if str(_h.get("role")) != "hyperviseur": 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 # 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 # plancher `/etc/hosts` du socle n'est pas pose sur un hyperviseur, Proxmox s'en
# sert pour l'identite de noeud du cluster. # 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": **({"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 # 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 : # site ; un hyperviseur ne l'atteint pas — mesure depuis asgard :
# `10.37.33.21:3142` injoignable, par la frontiere comme tout le reste. Avec # `10.37.33.21:3142` injoignable, par la frontiere comme tout le reste. Avec

View file

@ -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())

View file

@ -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())

View file

@ -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/<groupe>.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())