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