From 0cc04177fb15c583c66351f6ba22b427c4b5d586 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sat, 8 Aug 2026 07:33:49 -0400 Subject: [PATCH] devis des expositions : une vraie requete, depuis deux points de vue Troisieme devis de service. Chaque expose: du plan repond-il, et si non, ou ca casse. - requete HTTPS complete avec la racine de l'AC, jamais un connect() : a travers l'OPNsense (anti-spoofing) toute connexion TCP reussit, et en TLS le silence apres connect() ne distingue pas un service sain d'un trou. - deux points de vue : depuis l'edge (edge + dorsal) et depuis le poste (DNS + frontiere + edge + dorsal). Leur difference diagnostique. - un code n'est pas un verdict : mon premier comparateur laissait passer un 502 des deux cotes. Trouve par le test negatif, pas par la relecture. Etat : les 6 expositions repondent des deux cotes. Test negatif (frontiere qui bloque + dorsal tombe) : 2 ecarts nommes distinctement, code 1. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 25 +++++ Makefile | 4 + docs/devis-services.md | 27 +++++ playbooks/maintenance/devis-expositions.yml | 89 +++++++++++++++++ scripts/devis_expositions.py | 103 ++++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 playbooks/maintenance/devis-expositions.yml create mode 100755 scripts/devis_expositions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ca1da0..8ec4753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # CHANGELOG — Set-OPS +## 2026-08-08 — Devis des expositions : « est-ce que mes services répondent ? » + +Troisième devis de service. Il pose la seule question qui compte pour un utilisateur, et +quand la réponse est non, il dit **où** ça casse. + +**Une vraie requête, jamais un `connect()`.** À travers l'OPNsense (anti-spoofing), toute +connexion TCP réussit — y compris vers une adresse où aucune machine n'existe. Et « lire des +données après connexion » ne vaut rien en TLS, où c'est le client qui parle en premier : le +port 443 d'un edge sain se comporte exactement comme un port mort. Le devis fait donc une +requête HTTPS complète, avec la racine de l'AC, et lit le code de retour. + +**Deux points de vue** — depuis l'edge (edge + dorsal) et depuis le poste (DNS + frontière + +edge + dorsal). C'est leur *différence* qui diagnostique : l'un répond et pas l'autre, ce +n'est pas le service, c'est le chemin. + +**Un code n'est pas un verdict.** Mon premier comparateur ne testait que la *présence* d'un +code : un 502 des deux côtés passait pour conforme alors que le dorsal est mort derrière. +Trouvé par le test négatif, pas par la relecture — troisième fois aujourd'hui que c'est +l'épreuve, et non le raisonnement, qui tranche. Un 302 ou un 401 reste en revanche un +service vivant : il redirige vers l'IdP ou exige une authentification. + +**État** : les six expositions déclarées au plan répondent, des deux points de vue. Test +négatif — une frontière qui bloque et un dorsal tombé — les deux écarts sont nommés +distinctement, code de sortie 1. + ## 2026-08-08 — Le devis des certificats trouve l'autorité expirée depuis huit heures Deuxième application du patron devis/applicateur aux services, sur le défaut le plus diff --git a/Makefile b/Makefile index 8b5fb0f..e2dc2cc 100644 --- a/Makefile +++ b/Makefile @@ -418,6 +418,10 @@ ca-empreinte: ansible-runtime _instance-requise ## Empreinte de la racine, lue S | tail -1 | tr -d ' \r' .PHONY: frontiere-plan frontiere-appliquer +expositions-plan: ansible-runtime ## Chaque exposition du plan repond-elle ? (edge et poste, aucune ecriture) + @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-expositions.yml >/dev/null + @python3 scripts/devis_expositions.py + certificats-plan: ansible-runtime ## Ecart entre les certificats sur disque et ceux reellement servis (aucune ecriture) @rm -f $(SETOPS_INSTANCE)/devis-certificats.json.* @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null diff --git a/docs/devis-services.md b/docs/devis-services.md index 7e412c7..2dfd5f3 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -6,6 +6,7 @@ ``` make identite-plan # realm, fédération, mappeurs, politique, comptes make certificats-plan # certificats sur disque contre certificats réellement servis +make expositions-plan # chaque `expose:` du plan répond-il, depuis l'edge et depuis le poste ``` ## Le trou qu'il comble @@ -62,6 +63,32 @@ elle-même, le certificat était **expiré depuis plus de huit heures** et le re échouait toutes les quatorze minutes sur `'step ca renew' requires the '--ca-url' flag`. Rien ne le signalait. +## Le devis des expositions + +Il pose la question que se pose vraiment un utilisateur : **est-ce que mes services +répondent ?** Et quand la réponse est non, il dit *où* ça casse. + +**Une vraie requête, jamais un `connect()`.** À travers l'OPNsense (anti-spoofing), toute +connexion TCP réussit — y compris vers une adresse où aucune machine n'existe. Et « lire +des données après connexion » ne vaut rien en TLS, où c'est le *client* qui parle en +premier : le port 443 d'un edge sain se comporte exactement comme un port mort. Seul le +code de retour d'une requête HTTPS complète tranche. + +**Deux points de vue, et c'est leur différence qui diagnostique :** + +| Point de vue | Ce qu'il traverse | +|---|---| +| depuis l'edge | edge + dorsal | +| depuis le poste | DNS + frontière + edge + dorsal — le chemin d'un vrai utilisateur | + +L'un répond et pas l'autre : ce n'est pas le service, c'est le chemin. + +**Un code n'est pas un verdict.** Un 502 ou un 504 *est* une réponse, et pourtant le dorsal +est mort derrière. Le premier jet ne testait que la présence d'un code et laissait donc +passer un dorsal tombé — trouvé par le test négatif, pas par la relecture. Un 302 ou un 401, +en revanche, est un service parfaitement vivant qui redirige vers l'IdP ou exige une +authentification. + ## Un piège de construction, à connaître avant d'écrire le prochain `include_vars` au niveau du *play* **prime sur les `group_vars`**. Charger les défauts d'un diff --git a/playbooks/maintenance/devis-expositions.yml b/playbooks/maintenance/devis-expositions.yml new file mode 100644 index 0000000..63d861a --- /dev/null +++ b/playbooks/maintenance/devis-expositions.yml @@ -0,0 +1,89 @@ +--- +# Devis des expositions web — RELEVE seul. Ne modifie rien (D-23). +# +# Question posee : chaque `expose:` du plan repond-il VRAIMENT ? Et si non, ou ca casse. +# +# UNE VRAIE REQUETE, jamais un `connect()`. A travers l'OPNsense (anti-spoofing), toute +# connexion TCP reussit — y compris vers une adresse ou aucune machine n'existe. Et +# « lire des donnees apres connexion » ne vaut rien en TLS, ou c'est le CLIENT qui parle +# en premier : le port 443 d'un edge sain se comporte exactement comme un port mort. +# Seul le code de retour d'une requete HTTPS complete tranche. +# +# DEUX POINTS DE VUE, et c'est la difference entre les deux qui diagnostique : +# - depuis l'edge, avec `--resolve` vers 127.0.0.1 : edge + dorsal, sans la frontiere +# - depuis le poste : DNS + frontiere + edge + dorsal, le chemin d'un vrai utilisateur +# L'un repond et pas l'autre => ce n'est pas le service, c'est le chemin. +# +# make expositions-plan + +- name: Devis des expositions — depuis l'edge + # Un seul edge suffit : le devis mesure les expositions, pas les hotes. + hosts: serveur_nginx[0] + become: true + gather_facts: false + + tasks: + - name: Charger le registre des applications + ansible.builtin.include_vars: + file: "{{ setops_plan_dir }}/applications.yml" + + - name: Recenser les expositions déclarées au plan + ansible.builtin.set_fact: + devis_expositions: >- + {{ applications | dict2items + | selectattr('value.expose', 'defined') + | map(attribute='value.expose') | flatten | unique | sort }} + + - name: Interroger chaque exposition depuis l'edge (edge + dorsal, sans la frontière) + ansible.builtin.uri: + url: "https://{{ item }}/" + ca_path: "/etc/step/certs/root_ca.crt" + status_code: [200, 301, 302, 303, 307, 401, 403] + follow_redirects: none + timeout: 10 + register: devis_expo_edge + failed_when: false + changed_when: false + loop: "{{ devis_expositions }}" + loop_control: + label: "{{ item }}" + # `--resolve` de curl n'existe pas dans `uri` : l'edge se resout lui-meme par le + # DNS interne, ce qui est le comportement reel des services du tenant. + + - name: Récupérer la racine de l'AC sur le contrôleur + ansible.builtin.fetch: + src: /etc/step/certs/root_ca.crt + dest: "{{ playbook_dir }}/../../instance/.racine-ac.crt" + flat: true + + - name: Interroger chaque exposition depuis le poste (le chemin d'un vrai utilisateur) + ansible.builtin.uri: + url: "https://{{ item }}/" + ca_path: "{{ playbook_dir }}/../../instance/.racine-ac.crt" + status_code: [200, 301, 302, 303, 307, 401, 403] + follow_redirects: none + timeout: 10 + register: devis_expo_poste + failed_when: false + changed_when: false + delegate_to: localhost + become: false + loop: "{{ devis_expositions }}" + loop_control: + label: "{{ item }}" + + - name: Déposer le relevé sur le contrôleur + ansible.builtin.copy: + dest: "{{ playbook_dir }}/../../instance/devis-expositions.json" + mode: "0600" + content: >- + {{ { + 'edge': inventory_hostname, + 'expositions': devis_expositions, + 'depuis_edge': devis_expo_edge.results | map('combine', {}) | list + | zip(devis_expositions) | list, + 'poste': devis_expo_poste.results | map('combine', {}) | list + | zip(devis_expositions) | list, + } | to_nice_json }} + delegate_to: localhost + become: false diff --git a/scripts/devis_expositions.py b/scripts/devis_expositions.py new file mode 100755 index 0000000..2a20f98 --- /dev/null +++ b/scripts/devis_expositions.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Devis des expositions web : chaque `expose:` du plan répond-il vraiment ? + +Lit le relevé déposé par `playbooks/maintenance/devis-expositions.yml`. Aucun accès réseau +ici. Sort en code 1 s'il y a un écart. + +**Une vraie requête, jamais un `connect()`.** À travers l'OPNsense (anti-spoofing), toute +connexion TCP réussit — y compris vers une adresse où aucune machine n'existe. Et « lire +des données après connexion » ne vaut rien en TLS, où c'est le *client* qui parle en +premier : le port 443 d'un edge sain se comporte exactement comme un port mort. Seul le +code de retour d'une requête HTTPS complète tranche. + +**Deux points de vue, et c'est leur différence qui diagnostique :** + + depuis l'edge — edge + dorsal, sans la frontière + depuis le poste — DNS + frontière + edge + dorsal, le chemin d'un vrai utilisateur + +L'un répond et pas l'autre : ce n'est pas le service, c'est le chemin. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +RELEVE = RACINE / "instance" / "devis-expositions.json" + +# Un service VIVANT peut legitimement repondre autre chose que 200 : une redirection vers +# l'IdP, ou un 401/403 quand l'authentification est exigee. Ce qui compte est qu'il ait +# repondu quelque chose d'intelligible. +VIVANT = {200, 301, 302, 303, 307, 401, 403} + + +def _lire(paires: list) -> dict[str, dict]: + """Le relevé associe chaque résultat à son FQDN (zip côté Ansible).""" + out = {} + for resultat, fqdn in paires: + out[fqdn] = resultat + return out + + +def _verdict(r: dict) -> tuple[int, bool, str]: + """Rend (code affiché, vivant ?, message). + + Un code n'est pas un verdict : un 502 ou un 504 est une reponse, et pourtant le + service est mort derriere. Le premier jet ne testait que la PRESENCE d'un code et + laissait donc passer un dorsal tombe — trouve par le test negatif, pas par la + lecture. Le 2026-08-08. + """ + code = r.get("status", 0) + if not isinstance(code, int) or code <= 0: + return 0, False, (r.get("msg") or "aucune réponse")[:90] + return code, code in VIVANT, (r.get("msg") or "")[:90] + + +def analyser(releve: dict) -> list[str]: + edge, poste, ecarts = _lire(releve["depuis_edge"]), _lire(releve["poste"]), [] + largeur = max((len(f) for f in releve["expositions"]), default=10) + + print(f" {'exposition':<{largeur}} edge poste") + for fqdn in releve["expositions"]: + ce, ve, me = _verdict(edge.get(fqdn, {})) + cp, vp, mp = _verdict(poste.get(fqdn, {})) + print(f" {fqdn:<{largeur}} {ce or '---':<6} {cp or '---'}") + + if not ve and not vp: + ecarts.append(f"{fqdn} : en echec des DEUX cotes ({ce or 'muet'}) — le service " + f"lui-meme ne repond pas. {me}") + elif ve and not vp: + ecarts.append(f"{fqdn} : repond depuis l'edge ({ce}) mais PAS depuis le poste " + f"({cp or 'muet'}) — ce n'est pas le service, c'est le chemin " + f"(DNS ou frontiere). {mp}") + elif vp and not ve: + ecarts.append(f"{fqdn} : repond depuis le poste ({cp}) mais pas depuis l'edge " + f"({ce or 'muet'}) — la resolution interne diverge de la publique. {me}") + return ecarts + + +def main(argv: list[str] | None = None) -> int: + chemin = Path(argv[0]) if argv else RELEVE + if not chemin.exists(): + print("Relevé absent. Lancer d'abord : make expositions-plan", file=sys.stderr) + return 2 + + releve = json.loads(chemin.read_text(encoding="utf-8")) + print(f"Devis des expositions — {len(releve['expositions'])} déclarée(s) au plan, " + f"edge {releve['edge']}\n") + ecarts = analyser(releve) + + if not ecarts: + print("\nCONFORME : toute exposition déclarée répond, des deux points de vue.") + return 0 + print() + for e in ecarts: + print(f" [écart] {e}") + print(f"\nÉCART : {len(ecarts)} exposition(s) en difficulté.") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))