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:
parent
5fde136e9f
commit
0cc04177fb
5 changed files with 248 additions and 0 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -1,5 +1,30 @@
|
||||||
# CHANGELOG — Set-OPS
|
# 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
|
## 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
|
Deuxième application du patron devis/applicateur aux services, sur le défaut le plus
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -418,6 +418,10 @@ ca-empreinte: ansible-runtime _instance-requise ## Empreinte de la racine, lue S
|
||||||
| tail -1 | tr -d ' \r'
|
| tail -1 | tr -d ' \r'
|
||||||
|
|
||||||
.PHONY: frontiere-plan frontiere-appliquer
|
.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)
|
certificats-plan: ansible-runtime ## Ecart entre les certificats sur disque et ceux reellement servis (aucune ecriture)
|
||||||
@rm -f $(SETOPS_INSTANCE)/devis-certificats.json.*
|
@rm -f $(SETOPS_INSTANCE)/devis-certificats.json.*
|
||||||
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null
|
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
```
|
```
|
||||||
make identite-plan # realm, fédération, mappeurs, politique, comptes
|
make identite-plan # realm, fédération, mappeurs, politique, comptes
|
||||||
make certificats-plan # certificats sur disque contre certificats réellement servis
|
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
|
## 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`.
|
échouait toutes les quatorze minutes sur `'step ca renew' requires the '--ca-url' flag`.
|
||||||
Rien ne le signalait.
|
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
|
## 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
|
`include_vars` au niveau du *play* **prime sur les `group_vars`**. Charger les défauts d'un
|
||||||
|
|
|
||||||
89
playbooks/maintenance/devis-expositions.yml
Normal file
89
playbooks/maintenance/devis-expositions.yml
Normal 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
103
scripts/devis_expositions.py
Executable 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 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:]))
|
||||||
Loading…
Reference in a new issue