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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-08 07:33:49 -04:00
parent 5fde136e9f
commit 0cc04177fb
5 changed files with 248 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

103
scripts/devis_expositions.py Executable file
View file

@ -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 aucune machine n'existe. Et « lire
des données après connexion » ne vaut rien en TLS, 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:]))