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
|
||||
|
||||
## 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
|
||||
|
|
|
|||
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'
|
||||
|
||||
.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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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